QDIM: one snap index per call, cap the input (fixes #356) (#357)

With DIMASSOC on, every dimension QDIM added looked up the objects under
its definition points by scanning the whole space, and each dimension
adds an entity, so QDIM over many objects took quadratic time (20,000
lines: 28 s). QDIM now builds the SnapIndex DIMCONTINUE/DIMBASELINE use
(#281) once per call; dimensions are no snap targets, so it stays valid
while QDIM adds them. A call naming more than 100,000 objects, or whose
objects give more than 100,000 distinct points, is refused before
anything is created.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 23:31:50 +02:00 committed by GitHub
parent 776d99a1e0
commit 2f7a1e60d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 81 additions and 2 deletions

View File

@ -378,6 +378,9 @@ fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
/// QDIM: continuous dimensions across the endpoints of the selected objects.
fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} objects per call")));
}
let at = point_req("qdim", p, "at")?;
let vertical = bool_or(p, "vertical", false);
let d = s.doc()?;
@ -400,12 +403,23 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Err(bad("qdim", "select objects with at least two distinct points"));
}
if pts.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} distinct points per call")));
}
let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 };
// One snap index for all the dimensions, as DIMCONTINUE does: looking up each dimension's
// objects by scanning the drawing made QDIM quadratic (the added dimensions are no snap targets).
let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None };
let mut out = Vec::new();
for w in pts.windows(2) {
if let [a, b] = w {
let k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, "");
out.push(add_dim(s, k)?.hex());
let mut k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, "");
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]);
}
let h = s.add_entity(k)?;
s.last_dim = Some(h);
out.push(h.hex());
}
}
Ok(json!({ "handles": out }))

View File

@ -0,0 +1,65 @@
//! QDIM over many objects: one snap index per call (no rescan of the drawing per dimension), the
//! same associations as DIMREASSOCIATE, and a cap on the input size.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{DimAssoc, EntityKind, Handle};
use serde_json::{Value, json};
fn assoc_of(s: &Session, handles: &[Value]) -> Vec<Vec<DimAssoc>> {
let d = s.doc().unwrap();
handles
.iter()
.map(|h| match &d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().kind {
EntityKind::Dimension(dm) => dm.assoc.clone(),
k => panic!("not a dimension: {k:?}"),
})
.collect()
}
fn handles(r: &Value) -> Vec<Value> {
r["handles"].as_array().unwrap().clone()
}
#[test]
fn qdim_attaches_like_dimreassociate_and_large_input_is_capped() {
// Lines end to end with a point on every joint (ties with the line ends) and a circle around
// every midpoint (its centre is a QDIM point and outranks the line midpoint).
let mut s = Session::new();
for i in 0..40 {
let x = f64::from(i);
s.execute("line", &json!({"points": [[x, 0], [x + 1.0, 0]]})).unwrap();
s.execute("point", &json!({"at": [x, 0]})).unwrap();
s.execute("circle", &json!({"center": [x + 0.5, 0], "radius": 0.1})).unwrap();
}
let objs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
let r = s.execute("qdim", &json!({"handles": objs, "at": [0, 3]})).unwrap();
let hs = handles(&r);
assert_eq!(hs.len(), 80, "one dimension between each pair of neighbouring points");
let quick = assoc_of(&s, &hs);
assert!(quick.iter().all(|a| a.len() == 2), "every point is on an object");
// DIMREASSOCIATE scans the drawing per point; the indexed QDIM must agree with it.
s.execute("dimreassociate", &json!({"handles": hs})).unwrap();
assert_eq!(assoc_of(&s, &hs), quick);
// Many objects finish (this was quadratic in the number of dimensions).
let mut s = Session::new();
let pts: Vec<[f64; 2]> = (0..=20_000).map(|i| [f64::from(i), 0.0]).collect();
let objs = handles(&s.execute("line", &json!({"points": pts})).unwrap());
let r = s.execute("qdim", &json!({"handles": objs, "at": [0, 3]})).unwrap();
let hs = handles(&r);
assert_eq!(hs.len(), 20_000);
assert!(assoc_of(&s, &hs[..10]).iter().all(|a| a.len() == 2));
// More than the cap (objects, or distinct points) is refused with nothing created.
let n = s.doc().unwrap().model.iter().count();
let too_many: Vec<Value> = (0..100_001).map(|_| objs[0].clone()).collect();
let e = s.execute("qdim", &json!({"handles": too_many, "at": [0, 3]})).unwrap_err();
assert!(e.to_string().contains("100000"), "{e}");
let vertices: Vec<[f64; 2]> = (0..100_002).map(|i| [f64::from(i), f64::from(i % 2)]).collect();
s.execute("pline", &json!({"vertices": vertices})).unwrap();
let pl = s.doc().unwrap().model.last().unwrap().handle.hex();
let n = n + 1;
let e = s.execute("qdim", &json!({"handles": [pl], "at": [0, 3]})).unwrap_err();
assert!(e.to_string().contains("100000"), "{e}");
assert_eq!(s.doc().unwrap().model.iter().count(), n);
}