From 8073453d860623cc27f8eb692a2933b216c1ffb2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 10:36:56 +0200 Subject: [PATCH] BOUNDARY: cap point list and aggregate output per call (#723) (#795) * BOUNDARY: cap point list and aggregate output per call (#723) Co-Authored-By: Claude Sonnet 5.5 * BOUNDARY: a call over the output budget restores the drawing The session does not roll back a command that returns an error, so the polylines created before the cap was hit stayed in the drawing. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/cmd/hatch.rs | 16 ++++++++++++++++ crates/engine/src/tests.rs | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 1952a4e..18537cb 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -468,11 +468,27 @@ fn boundary_at(s: &mut Session, q: Vec2) -> Result> { Ok(out) } +/// Most internal points one BOUNDARY call accepts (checked before the list is copied). +const MAX_BOUNDARY_POINTS: usize = 256; +/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces +/// against, so this bounds the aggregate tracing work of the batch. +const MAX_BOUNDARY_OUTPUT: usize = 1024; + fn run_boundary(s: &mut Session, p: &Value) -> Result { + let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?; + if raw.len() > MAX_BOUNDARY_POINTS { + return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len()))); + } let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?; + let before = s.state()?.doc.clone(); let mut out = Vec::new(); for q in pts { out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex())); + if out.len() > MAX_BOUNDARY_OUTPUT { + // The refused call leaves no partial result behind. + s.state_mut()?.doc = before; + return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work"))); + } } Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) })) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1b59178..97ce8be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -861,6 +861,21 @@ fn gradient_pick_point_keeps_island_of_separate_lines() { } } +#[test] +fn boundary_point_batch_is_capped() { + let mut s = Session::new(); + let sq = [[0, 0], [20, 0], [20, 20], [0, 20]]; + for i in 0..4 { + s.execute("line", &json!({"points": [sq[i], sq[(i + 1) % 4]]})).unwrap(); + } + let many: Vec<_> = (0..100_000).map(|_| json!([2, 2])).collect(); + let t = std::time::Instant::now(); + assert!(s.execute("boundary", &json!({"points": many})).is_err()); + assert!(t.elapsed().as_secs() < 5); + let ok: Vec<_> = (0..8).map(|_| json!([2, 2])).collect(); + assert!(s.execute("boundary", &json!({"points": ok})).is_ok()); +} + #[test] fn boundary_pick_point_keeps_island_of_separate_lines() { for lines in [true, false] {