Files
OneCAD/crates/engine/src/profile_curves_tests.rs
T
bherbruckandClaude Opus 5.5 296683229d Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push)

* WIP2

* WIP3

* WIP4 wxs fix

* rustfmt

* Rename the remaining identifiers in merged crash-report code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:17:01 -04:00

207 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Profiles picked by the curves around them stay the same region when a parameter moves the
//! sketch, where a pick by point can land in another region.
use serde_json::{Value, json};
use crate::Session;
use solidcraft_doc::{FeatureKind, ProfileSel};
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 volumes(s: &Session) -> Vec<f64> {
s.model.state().bodies.iter().map(|b| b.body.tessellate(1e-2).unwrap().measure().volume).collect()
}
fn last_error(s: &Session) -> Option<String> {
let id = s.doc.features.last().unwrap().id;
s.model.result(id).and_then(|r| r.error.clone())
}
/// A 60 × 20 tray split in two by a divider `split` mm from its left side: the left bay is
/// bounded by the left side, the divider and the pieces of the bottom and top the divider cuts.
fn tray() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "split", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tray"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [60, 0], [60, 20], [0, 20]], "closed": true, "ids": ["bottom", "right", "top", "left"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["bottom", "right", "top", "left"]}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [30, 20]], "ids": ["div"]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "div.start", "b": "bottom"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "div.end", "b": "top"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "div"}));
run(&mut s, "sketch.dimension", json!({"entities": ["left", "div"], "value": "split"}));
run(&mut s, "sketch.finish", json!({}));
s
}
fn extrude_kind(s: &Session) -> ProfileSel {
match &s.doc.features.last().unwrap().kind {
FeatureKind::Extrude { profiles, .. } => profiles.clone(),
k => panic!("{k:?}"),
}
}
/// The user's case: the left bay picked by its curves keeps extruding the left bay when
/// `split` moves the divider past the old pick point; a pick by that point (as a design saved
/// before picks were stored by curves holds it) silently extrudes the right bay instead.
#[test]
fn a_profile_picked_by_curves_follows_a_parameter_change() {
let mut s = tray();
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"curves": ["left", "bottom", "div", "top"]}], "distance": 10}));
let v = volumes(&s);
assert_eq!(v.len(), 1);
assert!((v[0] - 30.0 * 20.0 * 10.0).abs() < 1.0, "{v:?}");
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
assert_eq!(last_error(&s), None);
let v = volumes(&s);
assert!((v[0] - 20.0 * 20.0 * 10.0).abs() < 1.0, "still the left bay, now 20 wide: {v:?}");
// The same bay picked by a point (25, 10), stored as a point.
let mut s = tray();
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"point": [25, 10]}], "distance": 10}));
let id = s.doc.features.last().unwrap().id;
if let Some(f) = s.doc_mut().feature_mut(id)
&& let FeatureKind::Extrude { profiles, .. } = &mut f.kind
{
*profiles = ProfileSel::Points { points: vec![solidcraft_geom::Vec2::new(25.0, 10.0)] };
}
s.refresh();
assert!((volumes(&s)[0] - 6000.0).abs() < 1.0);
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
let v = volumes(&s);
assert!((v[0] - 40.0 * 20.0 * 10.0).abs() < 1.0, "the point now sits in the right bay: {v:?}");
}
/// A pick by point is stored by the curves around the picked profile, so it follows too.
#[test]
fn a_point_pick_is_stored_by_curves() {
let mut s = tray();
run(&mut s, "solid.extrude", json!({"sketch": "Tray", "profiles": [{"point": [25, 10]}], "distance": 10}));
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
let mut ids = loops[0].clone();
ids.sort();
assert_eq!(ids, ["bottom", "div", "left", "top"]);
run(&mut s, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
assert_eq!(last_error(&s), None);
assert!((volumes(&s)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&s));
}
/// New explicit API lists are sets even when reordered or repeated. Their saved
/// form must not accidentally become a legacy graph-span multiset.
#[test]
fn explicit_curve_pick_normalizes_repeats_before_save_and_parameter_edit() {
let mut s = tray();
run(&mut s, "solid.extrude", json!({"sketch":"Tray","profiles":[{"curves":["top","bottom","div","left","bottom","top"]}],"distance":10}));
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
assert_eq!(loops, vec![vec!["top", "bottom", "div", "left"]]);
let mut reopened = Session::new(solidcraft_doc::format::read(&s.doc.to_json()).unwrap());
run(&mut reopened, "parameters.change", json!({"name":"split","expression":"20 mm"}));
assert_eq!(last_error(&reopened), None);
assert!((volumes(&reopened)[0] - 4000.0).abs() < 1.0);
}
#[test]
fn repeated_explicit_curve_ids_do_not_count_split_graph_spans() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"S"}));
run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0],[10,10],[0,10]],"closed":true,"ids":["a","b","c","d"]}));
run(&mut s, "sketch.line", json!({"points":[[5,-5],[5,0]],"ids":["stub"]}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(s.model.state().sketches[0].profiles[0].outer_curves.iter().filter(|id| *id == "a").count(), 2);
run(&mut s, "solid.extrude", json!({"sketch":"S","profiles":[{"curves":["d","a","a","a","c","b"]}],"distance":2}));
let ProfileSel::Curves { loops } = extrude_kind(&s) else { panic!("{:?}", extrude_kind(&s)) };
assert_eq!(loops, vec![vec!["d", "a", "c", "b"]]);
let reopened = Session::new(solidcraft_doc::format::read(&s.doc.to_json()).unwrap());
assert_eq!(last_error(&reopened), None);
assert!((volumes(&reopened)[0] - 200.0).abs() < 1e-3);
}
/// Curves cut into several pieces of one loop still name it: a line ending on the square's
/// bottom from outside cuts the bottom in two, both pieces on the square's loop.
#[test]
fn a_curve_cut_into_pieces_still_names_its_profile() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true, "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 0]], "ids": ["stub"]}));
run(&mut s, "sketch.finish", json!({}));
let ps = &s.model.state().sketches[0].profiles;
assert_eq!(ps.len(), 1);
assert_eq!(ps[0].outer_curves.iter().filter(|c| *c == "a").count(), 2, "the bottom is in two pieces: {:?}", ps[0].outer_curves);
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["a", "b", "c", "d"]}], "distance": 2}));
assert_eq!(last_error(&s), None);
assert!((volumes(&s)[0] - 200.0).abs() < 1e-3, "{:?}", volumes(&s));
}
/// Both halves of a circle cut by a line are bounded by the same two curves: an error, not one
/// of them. A list no profile matches is an error too.
#[test]
fn ambiguous_and_missing_curve_picks_are_errors() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "ring"}));
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]], "ids": ["cut"]}));
run(&mut s, "sketch.circle.center", json!({"center": [40, 0], "radius": 3, "id": "dot"}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["ring", "cut"]}], "distance": 2})).unwrap_err().to_string();
assert!(e.contains("2 profiles"), "{e}");
assert!(s.model.state().bodies.is_empty());
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["ring"]}], "distance": 2})).unwrap_err().to_string();
assert!(e.contains("no profile"), "{e}");
let e = s.execute("solid.extrude", &json!({"sketch": "S", "profiles": [{"curves": ["dot", "cut"]}], "distance": 2})).unwrap_err().to_string();
assert!(e.contains("no profile"), "{e}");
// A closed curve alone.
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["dot"]}], "distance": 2}));
assert!((volumes(&s)[0] - std::f64::consts::PI * 9.0 * 2.0).abs() < 0.5, "{:?}", volumes(&s));
}
/// An island inside the picked outline is its hole, as with a pick by point; the island is a
/// profile of its own, picked by its curve.
#[test]
fn a_curve_pick_keeps_islands_as_holes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true, "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5, "id": "hole"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["a", "b", "c", "d"]}], "distance": 1}));
let disc = std::f64::consts::PI * 25.0;
assert!((volumes(&s)[0] - (400.0 - disc)).abs() < 0.5, "{:?}", volumes(&s));
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [{"curves": ["hole"]}], "distance": 1, "operation": "new"}));
assert!((volumes(&s)[1] - disc).abs() < 0.5, "{:?}", volumes(&s));
}
/// Curve picks are saved in the design file and read back; timeline.edit takes the command's
/// `profiles` form.
#[test]
fn curve_picks_save_load_and_edit() {
let mut s = tray();
run(
&mut s,
"solid.extrude",
json!({"sketch": "Tray", "profiles": [{"curves": ["left", "bottom", "div", "top"]}], "distance": 10, "name": "Bay"}),
);
let text = serde_json::to_string(&*s.doc).unwrap();
let doc = solidcraft_doc::format::read(&text).unwrap();
let mut t = Session::new(doc);
assert_eq!(extrude_kind(&t), extrude_kind(&s));
run(&mut t, "parameters.change", json!({"name": "split", "expression": "20 mm"}));
assert!((volumes(&t)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&t));
// Switch to the right bay through timeline.edit.
run(&mut t, "timeline.edit", json!({"feature": "Bay", "set": {"profiles": [{"curves": ["div", "bottom", "right", "top"]}]}}));
assert!((volumes(&t)[0] - 8000.0).abs() < 1.0, "{:?}", volumes(&t));
run(&mut t, "timeline.edit", json!({"feature": "Bay", "set": {"profiles": [{"point": [5, 5]}]}}));
assert!(matches!(extrude_kind(&t), ProfileSel::Curves { .. }), "{:?}", extrude_kind(&t));
assert!((volumes(&t)[0] - 4000.0).abs() < 1.0, "{:?}", volumes(&t));
// An old design's point pick still loads and evaluates.
let old = text.replace(r#"{"type":"curves","loops":[["left","bottom","div","top"]]}"#, r#"{"type":"points","points":[[25.0,10.0]]}"#);
assert_ne!(old, text);
let t = Session::new(solidcraft_doc::format::read(&old).unwrap());
assert!((volumes(&t)[0] - 6000.0).abs() < 1.0, "{:?}", volumes(&t));
}