Files
cadcraft/crates/engine/tests/it/region.rs
aiongg 9cf115e808 CI: one test binary per crate, nextest, line tables only (#1989)
* Build: line tables only in dev, no debug info for dependencies

* Tests: one integration-test binary per crate

Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.

* xtask ci: nextest, tests without examples, one wasm check call
2026-10-23 13:34:26 -04:00

183 lines
8.7 KiB
Rust
Raw Permalink 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.
//! Regions (#510): REGION turns closed objects into region objects; AREA and MASSPROP measure
//! them; UNION, SUBTRACT and INTERSECT combine regions whose boundaries don't meet; they move,
//! explode and snap like other objects.
use cadcraft_doc::{EntityKind, Handle, Region, Space};
use cadcraft_engine::Session;
use cadcraft_geom::{PI, Vec2};
use serde_json::{Value, json};
fn h(v: &Value) -> Handle {
Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap()
}
fn region(s: &Session, hd: Handle) -> Region {
match &s.doc().unwrap().entity(hd).unwrap().kind {
EntityKind::Region(r) => r.clone(),
k => panic!("not a region: {k:?}"),
}
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6 * b.abs().max(1.0)
}
/// A region from a rectangle, and one from a circle.
fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = h(&s.execute("rectang", &json!({"p1": a, "p2": b})).unwrap());
s.execute("region", &json!({"handles": [r.hex()]})).unwrap();
r
}
fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle {
let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap());
s.execute("region", &json!({"handles": [hd.hex()]})).unwrap();
hd
}
#[test]
fn region_from_closed_objects_measures() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
let c = circle_region(&mut s, [20.0, 0.0], 2.0);
s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap();
let line = s.doc().unwrap().model.iter().last().unwrap().handle;
let r = s.execute("region", &json!({"handles": [line.hex()]})).unwrap();
assert!(r["message"].as_str().unwrap().starts_with('0'), "an open line makes no region");
assert_eq!(region(&s, a).loops.len(), 1);
assert_eq!(s.doc().unwrap().entity(a).unwrap().kind.type_name(), "Region");
let area = s.execute("area", &json!({"handle": a.hex()})).unwrap();
assert!(near(area["area"].as_f64().unwrap(), 40.0) && near(area["perimeter"].as_f64().unwrap(), 28.0), "{area}");
let area = s.execute("area", &json!({"handle": c.hex()})).unwrap();
assert!(near(area["area"].as_f64().unwrap(), 4.0 * PI), "{area}");
let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap();
let r0 = &m["regions"][0];
assert!(near(r0["area"].as_f64().unwrap(), 40.0));
assert!(near(r0["centroid"][0].as_f64().unwrap(), 5.0) && near(r0["centroid"][1].as_f64().unwrap(), 2.0), "{r0}");
// ∫y² dA over 0..10 × 0..4 = 10 · 4³/3; ∫x² dA = 4 · 10³/3; ∫xy dA = 100/2 · 16/2.
assert!(near(r0["momentsOfInertia"][0].as_f64().unwrap(), 640.0 / 3.0), "{r0}");
assert!(near(r0["momentsOfInertia"][1].as_f64().unwrap(), 4000.0 / 3.0), "{r0}");
assert!(near(r0["productOfInertia"].as_f64().unwrap(), 400.0), "{r0}");
assert!(m["message"].as_str().unwrap().contains("Centroid"));
// The circle's moments about its centre come out as π r⁴ / 4 (principal, both).
let m = s.execute("massprop", &json!({"handles": [c.hex()]})).unwrap();
let pm = &m["regions"][0]["principalMoments"];
assert!((pm[0].as_f64().unwrap() - 4.0 * PI).abs() < 3e-3 && (pm[1].as_f64().unwrap() - 4.0 * PI).abs() < 3e-3, "{pm}");
assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err());
}
#[test]
fn booleans_on_separate_and_nested_regions() {
// Subtract a circle inside a rectangle: a hole.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let c = circle_region(&mut s, [5.0, 5.0], 2.0);
s.execute("subtract", &json!({"from": [a.hex()], "remove": [c.hex()]})).unwrap();
let r = region(&s, a);
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), vec![true, false]);
assert!(near(r.area(), 100.0 - 4.0 * PI));
assert!(s.doc().unwrap().entity(c).is_none());
assert!(!r.contains(Vec2::new(5.0, 5.0)) && r.contains(Vec2::new(1.0, 1.0)));
// Union with a region inside the hole: an island in the hole.
let d = circle_region(&mut s, [5.0, 5.0], 1.0);
s.execute("union", &json!({"handles": [a.hex(), d.hex()]})).unwrap();
let r = region(&s, a);
assert_eq!(r.loops.len(), 3);
assert!(near(r.area(), 100.0 - 4.0 * PI + PI));
// Union with a separate region: one region of two pieces.
let e = rect_region(&mut s, [20.0, 0.0], [22.0, 2.0]);
s.execute("union", &json!({"handles": [a.hex(), e.hex()]})).unwrap();
assert!(near(region(&s, a).area(), 100.0 - 3.0 * PI + 4.0));
// Union with a region inside the filled part: absorbed.
let f = rect_region(&mut s, [0.5, 0.5], [1.5, 1.5]);
s.execute("union", &json!({"handles": [a.hex(), f.hex()]})).unwrap();
assert_eq!(region(&s, a).loops.len(), 4);
// Intersect: the inner one; separate regions leave nothing.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let b = rect_region(&mut s, [2.0, 2.0], [3.0, 3.0]);
s.execute("intersect", &json!({"handles": [a.hex(), b.hex()]})).unwrap();
assert!(near(region(&s, a).area(), 1.0));
let c = rect_region(&mut s, [20.0, 0.0], [21.0, 1.0]);
let r = s.execute("intersect", &json!({"handles": [a.hex(), c.hex()]})).unwrap();
assert!(r["handle"].is_null());
assert_eq!(s.doc().unwrap().model.len(), 0);
// Overlapping boundaries: not available yet, nothing changes.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let b = rect_region(&mut s, [5.0, 5.0], [15.0, 15.0]);
let err = s.execute("union", &json!({"handles": [a.hex(), b.hex()]})).unwrap_err().to_string();
assert!(err.contains("not available yet"), "{err}");
assert_eq!(s.doc().unwrap().model.len(), 2);
assert!(s.execute("subtract", &json!({"from": [a.hex()], "remove": [a.hex()]})).is_err());
}
#[test]
fn subtract_on_the_command_line() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let _c = circle_region(&mut s, [5.0, 5.0], 2.0);
s.set_selection(Vec::new());
for t in ["subtract", "0,5", "", "3,5", ""] {
s.cmdline(t).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(region(&s, a).loops.len(), 2);
}
#[test]
fn regions_move_grip_explode_and_snap() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
let c = circle_region(&mut s, [5.0, 2.0], 1.0);
s.execute("subtract", &json!({"from": [a.hex()], "remove": [c.hex()]})).unwrap();
s.execute("move", &json!({"handles": [a.hex()], "delta": [1, 1]})).unwrap();
s.execute("rotate", &json!({"handles": [a.hex()], "base": [1, 1], "angle": 90})).unwrap();
s.execute("mirror", &json!({"handles": [a.hex()], "p1": [1, 0], "p2": [1, 1], "erase": true})).unwrap();
let a = s.doc().unwrap().model.iter().last().unwrap().handle;
let r = region(&s, a);
assert!(near(r.area(), 40.0 - PI));
let b = r.bounds();
assert!(b.min.near(Vec2::new(1.0, 1.0), 1e-9) && b.max.near(Vec2::new(5.0, 11.0), 1e-9), "{b:?}");
// One grip in the middle; dragging it moves the region.
let k = s.doc().unwrap().entity(a).unwrap().kind.clone();
assert_eq!(k.grips(), vec![Vec2::new(3.0, 6.0)]);
let EntityKind::Region(m) = cadcraft_engine::grips::stretch_grip(&k, 0, Vec2::new(4.0, 6.0)).unwrap() else { panic!() };
assert!(m.bounds().min.near(Vec2::new(2.0, 1.0), 1e-9));
// A corner snaps; a pick on the edge picks it.
let d = s.doc().unwrap();
let hit = cadcraft_engine::snap::osnap(d, &Space::Model, Vec2::new(5.05, 11.05), 0.3, cadcraft_engine::snap::mode::END, None, false).unwrap();
assert!(hit.point.near(Vec2::new(5.0, 11.0), 1e-9));
assert_eq!(cadcraft_engine::select::pick(d, &Space::Model, Vec2::new(3.0, 1.0), 0.1), Some(a));
// EXPLODE: the boundary's lines and arcs.
s.execute("explode", &json!({"handles": [a.hex()]})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 4);
assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Arc(_))).count(), 2);
}
#[test]
fn hostile_region_params_never_panic() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [1e300, 1e300]);
let b = circle_region(&mut s, [1.0, 1.0], 1e-9);
for (id, p) in [
("union", json!({"handles": [a.hex(), b.hex(), "zz"]})),
("subtract", json!({"from": [a.hex()], "remove": "x"})),
("subtract", json!({"from": [], "remove": [b.hex()]})),
("intersect", json!({"handles": []})),
("massprop", json!({"handles": [a.hex(), b.hex()]})),
("region", json!({"handles": ["ffffff"]})),
] {
let _ = s.execute(id, &p);
}
for t in ["union", "", "subtract", "", "massprop", ""] {
let _ = s.cmdline(t);
}
}