Files
cadcraft/crates/engine/tests/it/shapes.rs
T
Nicholas GowerandBrandon Thomas 9f2060d674 analysis: report found/analysed/skipped counts, caps and implausibility (#1890)
* analysis: report found/analyzed/skipped, caps and implausible values (fixes #1440)

* docs: document the functions touched by #1440

Add doc comments to the self-check helpers, the COUNT/SHAPES/MASSPROP entry points and helpers, and the tests and fixtures the analysis self-check change touches, so CodeRabbit's docstring coverage check passes.

* analysis: tolerate rounding past a bound, and skip area checks without extents (#1440)

The self-check's implausible() compared value > bound exactly, so a border
rectangle whose shoelace area equals the extents' width * height could be
flagged by one ulp of rounding. Allow a 1e-9 relative slack past the bound
(the roundings of two ways of measuring the same thing), while a clearly
larger value is still flagged.

MASSPROP and SHAPES fell back to an extents area of 0 when nothing visible
gave the drawing extents, so every region or shape with area got a false
"area is larger than the drawing extents" flag. Pass f64::INFINITY instead:
implausible() already skips non-finite bounds, because no bound applies.

Tests: one ulp past the bound is not flagged while 1e-6 past is; an
extents-filling rectangle is not flagged; invisible-only masses/shapes with
no visible extents are not flagged.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:56:51 -04:00

267 lines
12 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.
//! SHAPES: perimeter, area, centroid, winding, self-intersection and duplicate vertices of the
//! closed shapes in a drawing (#1436).
use cadcraft_engine::Session;
use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3};
use cadcraft_engine::doc::{Common, EntityKind, Face3d, LwPolyline, Space};
use serde_json::{Value, json};
const EPS: f64 = 1e-9;
/// Add a polyline through `(x, y, bulge)` vertices to model space.
fn pline(s: &mut Session, pts: &[(f64, f64, f64)], closed: bool) {
let vertices = pts.iter().map(|&(x, y, b)| PolyVertex::with_bulge(Vec2::new(x, y), b)).collect();
let k = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false });
s.doc_mut().unwrap().add(&Space::Model, Common::default(), k).unwrap();
}
/// Add a unit 3D face at height `z`; a face is not a closed 2D shape, so SHAPES must skip it.
fn face(s: &mut Session, z: f64) {
let corners = [[0.0, 0.0, z], [1.0, 0.0, z], [1.0, 1.0, z], [0.0, 1.0, z]].map(|c| Vec3::new(c[0], c[1], c[2]));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Face3d(Face3d { corners, hidden_edges: 0 })).unwrap();
}
/// The last model-space entity, made invisible so it lies outside the drawing extents.
fn hide_last(s: &mut Session) {
let h = s.doc().unwrap().model.iter().last().unwrap().handle;
s.doc_mut().unwrap().modify_entity(h, |e| e.common.visible = false).unwrap();
}
/// Add a closed 10 × 10 square polyline at the origin.
fn square(s: &mut Session) {
pline(s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
}
fn shapes(s: &mut Session, p: Value) -> Value {
s.execute("shapes", &p).unwrap()
}
fn num(v: &Value) -> f64 {
v.as_f64().unwrap()
}
fn near(v: &Value, want: f64) {
assert!((num(v) - want).abs() < EPS, "{v} != {want}");
}
#[test]
fn square_both_windings() {
let mut s = Session::new();
square(&mut s);
pline(&mut s, &[(0.0, 10.0, 0.0), (10.0, 10.0, 0.0), (10.0, 0.0, 0.0), (0.0, 0.0, 0.0)], true);
let r = shapes(&mut s, json!({}));
assert_eq!(r["schema"], 1);
assert_eq!(r["count"], 2);
for (sh, winding) in r["shapes"].as_array().unwrap().iter().zip(["ccw", "cw"]) {
near(&sh["area"], 100.0);
near(&sh["perimeter"], 40.0);
near(&sh["centroid"][0], 5.0);
near(&sh["centroid"][1], 5.0);
assert_eq!(sh["winding"], winding);
assert_eq!(sh["selfIntersection"], Value::Null);
assert_eq!(sh["duplicateVertices"], json!([]));
}
}
#[test]
fn bulged_side_is_exact() {
let mut s = Session::new();
// The side x = 10 bulges outward into a semicircle of radius 5 about (10, 5).
pline(&mut s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 1.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
let r = shapes(&mut s, json!({}));
let sh = &r["shapes"][0];
let pi = std::f64::consts::PI;
let area = 100.0 + 12.5 * pi;
near(&sh["area"], area);
near(&sh["perimeter"], 30.0 + 5.0 * pi);
near(&sh["centroid"][0], (500.0 + 12.5 * pi * (10.0 + 20.0 / (3.0 * pi))) / area);
near(&sh["centroid"][1], 5.0);
assert_eq!(sh["winding"], "ccw");
}
#[test]
fn bow_tie_reports_its_crossing() {
let mut s = Session::new();
pline(&mut s, &[(0.0, 0.0, 0.0), (10.0, 10.0, 0.0), (10.0, 0.0, 0.0), (0.0, 10.0, 0.0)], true);
let r = shapes(&mut s, json!({}));
let x = &r["shapes"][0]["selfIntersection"];
near(&x[0], 5.0);
near(&x[1], 5.0);
}
/// Two coincident consecutive vertices are reported with their index and distance, while the
/// shape is still measured (the duplicate does not change its area).
#[test]
fn repeated_vertex_is_listed() {
let mut s = Session::new();
pline(&mut s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
let r = shapes(&mut s, json!({}));
let sh = &r["shapes"][0];
assert_eq!(sh["duplicateVertices"], json!([{ "index": 1, "distance": 0.0 }]));
near(&sh["area"], 100.0);
}
/// An open polyline whose last vertex repeats its first is treated as closed: one shape, its area
/// measured, and the coincident closing vertex not reported as a duplicate.
#[test]
fn open_polyline_with_matching_ends_is_closed() {
let mut s = Session::new();
pline(&mut s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0), (0.0, 0.0, 0.0)], false);
let r = shapes(&mut s, json!({}));
assert_eq!(r["count"], 1);
near(&r["shapes"][0]["area"], 100.0);
assert_eq!(r["shapes"][0]["duplicateVertices"], json!([]));
}
/// A circle is measured exactly, and objects that are not closed shapes (a line, a text, an open
/// polyline) are counted under `skipped` with their reasons instead of polluting the result.
#[test]
fn circle_and_skipped_entities() {
let mut s = Session::new();
s.execute("circle", &json!({"center": [3, -2], "radius": 2})).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("text", &json!({ "at": [0, 0], "text": "NOTE", "height": 0.25 })).unwrap();
pline(&mut s, &[(0.0, 0.0, 0.0), (5.0, 0.0, 0.0), (5.0, 5.0, 0.0)], false);
let r = shapes(&mut s, json!({}));
assert_eq!(r["count"], 1);
let c = &r["shapes"][0];
assert_eq!(c["type"], "Circle");
near(&c["area"], 4.0 * std::f64::consts::PI);
near(&c["perimeter"], 4.0 * std::f64::consts::PI);
assert_eq!(c["centroid"], json!([3.0, -2.0]));
assert_eq!(c["winding"], "ccw");
let sk = r["skipped"].as_object().unwrap();
assert_eq!(sk.len(), 3, "{sk:?}");
assert!(sk.values().all(|v| v["count"] == 1));
assert!(sk.values().any(|v| v["reason"] == "open"));
assert_eq!(sk.values().filter(|v| v["reason"] == "not a closed shape").count(), 2);
}
/// Paging reports only the requested window of shapes, and a malformed option is an error rather
/// than silently falling back to a default.
#[test]
fn paging_and_bad_options() {
let mut s = Session::new();
for _ in 0..3 {
square(&mut s);
}
let r = shapes(&mut s, json!({ "limit": 1 }));
assert_eq!((r["count"].clone(), r["returned"].clone(), r["truncated"].clone()), (json!(3), json!(1), json!(true)));
let r = shapes(&mut s, json!({ "offset": 1 }));
assert_eq!((r["returned"].clone(), r["truncated"].clone()), (json!(2), json!(false)));
assert!(s.execute("shapes", &json!({ "tolerance": -1 })).is_err());
assert!(s.execute("shapes", &json!({ "limit": "x" })).is_err());
assert!(s.execute("shapes", &json!({ "window": [1, 2] })).is_err());
}
/// #1440: an agent must be told what an analysis covered: found, analyzed and skipped, the caps
/// it hit, and values that cannot be right. The existing keys stay as they were.
#[test]
fn self_check_counts_found_analyzed_and_skipped() {
let mut s = Session::new();
square(&mut s);
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("text", &json!({"at": [0, 0], "text": "NOTE", "height": 0.25})).unwrap();
face(&mut s, 0.0);
let r = shapes(&mut s, json!({}));
// Add-only: the old keys and their meanings are unchanged.
assert_eq!((r["schema"].clone(), r["count"].clone(), r["returned"].clone()), (json!(1), json!(1), json!(1)));
for key in ["offset", "truncated", "shapes", "skipped"] {
assert!(!r[key].is_null(), "{key}: {r}");
}
let sc = &r["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!(sc["found"], 4);
assert_eq!(sc["analyzed"], 1);
assert_eq!(sc["skipped"]["3D Face"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Text"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"], r["skipped"], "the self-check repeats the skip map it is built from");
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
let analyzed = sc["analyzed"].as_u64().unwrap();
let skipped: u64 = sc["skipped"].as_object().unwrap().values().map(|v| v["count"].as_u64().unwrap()).sum();
assert_eq!(sc["found"].as_u64().unwrap(), analyzed + skipped);
}
/// #1440: a cap that stopped the analysis is reported with its value — the page `limit` when it
/// truncates, and `crossingSegments` when a shape has more edges than the self-intersection check
/// covers (which then reports no self-intersection) — while an unreached cap stays out of the
/// report.
#[test]
fn self_check_reports_the_caps_it_hits() {
let mut s = Session::new();
for _ in 0..3 {
square(&mut s);
}
let r = shapes(&mut s, json!({ "limit": 1 }));
assert_eq!(r["truncated"], true);
assert_eq!(r["selfCheck"]["caps"], json!({ "limit": 1 }), "the page cap and its value: {r}");
assert_eq!((r["selfCheck"]["found"].clone(), r["selfCheck"]["analyzed"].clone()), (json!(3), json!(3)));
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({}), "a cap that was not reached is not reported");
// A closed polyline with more edges than the self-intersection cap can check.
let zig: Vec<(f64, f64, f64)> = (0..=4_000).map(|i| (f64::from(i), 0.0, 0.0)).collect();
pline(&mut s, &zig, true);
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({ "crossingSegments": 4_000 }), "{r}");
assert_eq!(r["shapes"].as_array().unwrap().last().unwrap()["selfIntersection"], Value::Null);
}
/// #1440: geometry that cannot be right is flagged with the bound it broke — a perimeter past 10×
/// the shape's bounding-box diagonal, and an area larger than the drawing extents (invisible
/// objects lie outside the extents, which is how the test makes an out-of-extents shape).
#[test]
fn self_check_flags_implausible_geometry() {
let mut s = Session::new();
square(&mut s); // visible extents: 10 x 10, area 100
// A sawtooth in the same 10 x 10 box: perimeter about 410, past 10 x the 14.14 diagonal.
let teeth: Vec<(f64, f64, f64)> = (0..=40).map(|i| (f64::from(i) * 0.25, if i % 2 == 0 { 0.0 } else { 10.0 }, 0.0)).collect();
pline(&mut s, &teeth, true);
// Invisible (so outside the drawing extents): 100 x 100, area 10,000.
pline(&mut s, &[(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let perimeter = flags.iter().find(|f| f["field"] == "perimeter").unwrap_or_else(|| panic!("no perimeter flag: {r}"));
assert!(perimeter["reason"].as_str().unwrap().contains("10"), "{perimeter}");
assert_eq!(perimeter["type"], "Polyline");
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["reason"].as_str().unwrap().contains("drawing extents"), "{area}");
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap());
}
/// #1440 review: a border rectangle that fills the drawing extents has an area equal to them in
/// exact arithmetic, so it is not implausible; a shape clearly larger than the extents still is.
#[test]
fn self_check_does_not_flag_an_extents_filling_rectangle() {
let mut s = Session::new();
square(&mut s); // the extents are its own 10 x 10 box, area 100.
let r = shapes(&mut s, json!({}));
assert_eq!(r["selfCheck"]["implausible"], json!([]), "an extents-filling rectangle is not a bug: {r}");
// The same rectangle a millionth larger, invisible so it lies outside the extents: clearly
// past the tolerance, so it is still flagged.
pline(&mut s, &[(0.0, 0.0, 0.0), (10.000001, 0.0, 0.0), (10.000001, 10.000001, 0.0), (0.0, 10.000001, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap() * (1.0 + 1e-9), "{area}");
}
/// #1440 review: with no visible extents (the only shape is invisible) there is no drawing bound
/// to compare an area against, so it is not flagged as larger than the extents.
#[test]
fn self_check_has_no_area_bound_without_visible_extents() {
let mut s = Session::new();
square(&mut s);
hide_last(&mut s);
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
let r = shapes(&mut s, json!({}));
assert_eq!(r["count"], 1);
assert_eq!(r["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {r}");
}