The kernel moved from monstertruck to truck: truck's booleans handle blind holes, pockets and bosses that monstertruck's did not (see the ADR). Fillets and chamfers on straight convex edges between planar faces are our own exact local B-rep operation. The document holds parameters with unit-aware expressions and a timeline that re-evaluates incrementally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.4 KiB
Rust
184 lines
7.4 KiB
Rust
use solvecraft_geom::{Vec2, Vec3};
|
|
use solvecraft_kernel::measure;
|
|
use solvecraft_sketch::{ConstraintKind, Sketch};
|
|
|
|
use crate::*;
|
|
|
|
fn rel(a: f64, b: f64) -> f64 {
|
|
(a - b).abs() / b.abs().max(1e-12)
|
|
}
|
|
|
|
/// 40 x 30 rectangle with its width and height driven by `width` / d2.
|
|
fn plate_doc() -> (Document, u64) {
|
|
let mut doc = Document::new("plate");
|
|
doc.set_param("width", "40 mm", Some("mm"), None).unwrap();
|
|
let mut sk = Sketch::new();
|
|
let pts = [Vec2::new(0.0, 0.0), Vec2::new(38.0, 0.5), Vec2::new(39.0, 31.0), Vec2::new(0.5, 29.0)];
|
|
let ids: Vec<usize> = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
|
let l: Vec<usize> = (0..4).map(|i| sk.add_line_pts(ids[i], ids[(i + 1) % 4], None).unwrap()).collect();
|
|
use ConstraintKind::*;
|
|
sk.add_constraint(Coincident { p: ids[0], q: 0 }, None).unwrap();
|
|
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
|
|
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
|
|
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
|
|
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
|
|
let d1 = doc.new_model_param("width", "mm");
|
|
sk.add_constraint(Length { l: l[0], value: 40.0 }, Some(d1)).unwrap();
|
|
let d2 = doc.new_model_param("30 mm", "mm");
|
|
sk.add_constraint(Length { l: l[1], value: 30.0 }, Some(d2)).unwrap();
|
|
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
|
doc.add_feature(
|
|
FeatureKind::Extrude {
|
|
sketch: s,
|
|
profiles: ProfileSel::All,
|
|
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
|
operation: Operation::NewBody,
|
|
targets: vec![],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
(doc, s)
|
|
}
|
|
|
|
#[test]
|
|
fn parametric_plate_reevaluates() {
|
|
let (mut doc, _) = plate_doc();
|
|
let mut m = Model::new();
|
|
m.evaluate(&doc);
|
|
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
|
let st = m.state();
|
|
assert_eq!(st.bodies.len(), 1);
|
|
assert_eq!(st.bodies[0].name, "Body1");
|
|
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
|
assert!(st.sketches[0].report.fully_constrained());
|
|
|
|
// No change: nothing recomputed.
|
|
m.evaluate(&doc);
|
|
assert_eq!(m.last_recomputed, 0);
|
|
|
|
doc.set_param("width", "50 mm", None, None).unwrap();
|
|
m.evaluate(&doc);
|
|
assert_eq!(m.last_recomputed, 2);
|
|
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
|
|
|
// Changing only the extrude distance re-evaluates only the extrude.
|
|
if let Some(FeatureKind::Extrude { extent, .. }) = doc.features.get_mut(1).map(|f| &mut f.kind) {
|
|
extent.distance = "width / 5".into();
|
|
}
|
|
m.evaluate(&doc);
|
|
assert_eq!(m.last_recomputed, 1);
|
|
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn box_fillet_cut_timeline() {
|
|
let (mut doc, _) = plate_doc();
|
|
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap();
|
|
let mut sk = Sketch::new();
|
|
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
|
|
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
|
doc.add_feature(
|
|
FeatureKind::Extrude {
|
|
sketch: s2,
|
|
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
|
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
|
operation: Operation::Cut,
|
|
targets: vec![],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut m = Model::new();
|
|
m.evaluate(&doc);
|
|
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
|
let st = m.state();
|
|
let v = measure(&st.bodies[0].body).unwrap().volume;
|
|
let expect = 24000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
|
|
assert!(rel(v, expect) < 1e-3, "{v} vs {expect}");
|
|
// Roll back to before the fillet.
|
|
doc.marker = Some(2);
|
|
m.evaluate(&doc);
|
|
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
|
assert!(m.results[3].skipped);
|
|
}
|
|
|
|
#[test]
|
|
fn errors_are_reported_not_fatal() {
|
|
let (mut doc, s) = plate_doc();
|
|
doc.add_feature(
|
|
FeatureKind::Revolve {
|
|
sketch: s,
|
|
profiles: ProfileSel::All,
|
|
axis: AxisRef::SketchAxis { axis: "y".into() },
|
|
angle: "90 deg".into(),
|
|
operation: Operation::NewBody,
|
|
targets: vec![],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap();
|
|
doc.add_feature(
|
|
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let mut m = Model::new();
|
|
m.evaluate(&doc);
|
|
assert!(m.results[2].error.is_none(), "{:?}", m.results[2]);
|
|
assert!(m.results[3].error.is_some());
|
|
assert!(m.results[4].error.as_deref().unwrap_or("").contains("nope"));
|
|
assert_eq!(m.state().bodies.len(), 2);
|
|
let quarter = measure(&m.state().bodies[1].body).unwrap().volume;
|
|
// Revolving the 40x30 rectangle (x in [0,40]) 90° about sketch y: π/4 · 40² · 30.
|
|
assert!(rel(quarter, std::f64::consts::FRAC_PI_4 * 1600.0 * 30.0) < 1e-3, "{quarter}");
|
|
}
|
|
|
|
#[test]
|
|
fn params_and_serialization() {
|
|
let (mut doc, _) = plate_doc();
|
|
assert!(doc.set_param("bad name", "1", None, None).is_err());
|
|
assert!(doc.set_param("mm", "1", None, None).is_err());
|
|
assert!(doc.set_param("loop", "loop + 1", None, None).is_err());
|
|
assert!(doc.remove_param("width").is_err(), "used by d1");
|
|
doc.set_param("spare", "3", None, None).unwrap();
|
|
doc.remove_param("spare").unwrap();
|
|
let json = doc.to_json();
|
|
let back = Document::from_json(&json).unwrap();
|
|
assert_eq!(back, doc);
|
|
assert!(Document::from_json("{").is_err());
|
|
let gone = doc.delete_feature(doc.features[0].id).unwrap();
|
|
assert_eq!(gone.len(), 2, "the extrude goes with its sketch");
|
|
assert!(doc.param("d1").is_none(), "unused model parameters are pruned");
|
|
}
|
|
|
|
#[test]
|
|
fn primitives_and_combine() {
|
|
let mut doc = Document::new("p");
|
|
doc.add_feature(
|
|
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
|
|
None,
|
|
)
|
|
.unwrap();
|
|
doc.add_feature(
|
|
FeatureKind::Cylinder {
|
|
base: Vec3::new(5.0, 5.0, 0.0),
|
|
axis: Vec3::Z,
|
|
radius: "2".into(),
|
|
height: "20".into(),
|
|
operation: Operation::NewBody,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
doc.add_feature(FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Cut, keep_tools: false }, None)
|
|
.unwrap();
|
|
let mut m = Model::new();
|
|
m.evaluate(&doc);
|
|
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
|
let st = m.state();
|
|
assert_eq!(st.bodies.len(), 1);
|
|
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 1000.0 - std::f64::consts::PI * 40.0) < 1e-3);
|
|
}
|