mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 02:46:51 -06:00
* 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>
267 lines
12 KiB
Rust
267 lines
12 KiB
Rust
//! 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}");
|
||
}
|