From 4455402eabbcffc22a2c341d460fbd33f1088f14 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 01:11:36 +0000 Subject: [PATCH] Sketch profiles: grid-accelerated splitting and node merging, bounding-box nesting (large sketches several times faster); randomized sketch-editing test (thousands of mixed create/modify/constraint/projection/undo steps: no panics, designs round-trip) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch_constraints.rs | 4 + crates/engine/src/cmd/sketch_fuzz_tests.rs | 100 ++++++++++++ crates/sketch/src/profiles.rs | 159 +++++++++++++++++--- 3 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_fuzz_tests.rs diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 25ab797b..bb0c3b0c 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -514,3 +514,7 @@ fn snap(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "sketch_constraints_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "sketch_fuzz_tests.rs"] +mod fuzz; diff --git a/crates/engine/src/cmd/sketch_fuzz_tests.rs b/crates/engine/src/cmd/sketch_fuzz_tests.rs new file mode 100644 index 00000000..facbf6bc --- /dev/null +++ b/crates/engine/src/cmd/sketch_fuzz_tests.rs @@ -0,0 +1,100 @@ +//! Random sequences of sketch commands: nothing panics, every sketch stays evaluable, and the +//! design survives a save/load round trip. + +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +/// Small deterministic generator (xorshift). +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + fn f(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() % 1_000_000) as f64 / 1_000_000.0 * (hi - lo) + } + fn pick<'a, T>(&mut self, v: &'a [T]) -> Option<&'a T> { + if v.is_empty() { None } else { v.get((self.next() as usize) % v.len()) } + } + fn pt(&mut self) -> Value { + json!([(self.f(-50.0, 50.0) * 10.0).round() / 10.0, (self.f(-50.0, 50.0) * 10.0).round() / 10.0]) + } +} + +fn entities(s: &Session) -> (Vec, Vec, Vec) { + let Some(id) = s.active_sketch else { return Default::default() }; + let st = s.model.state(); + let Some(ss) = st.sketch(id) else { return Default::default() }; + let sk = &ss.sketch; + let curves = sk.curves.iter().map(|c| c.id.clone()).collect(); + let points = sk.points.iter().map(|p| p.id.clone()).collect(); + let cons = sk.constraints.iter().map(|c| c.id.clone()).collect(); + (curves, points, cons) +} + +#[test] +fn random_sketch_editing_never_breaks() { + for seed in 1..=4u64 { + let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed); + let mut s = Session::default(); + let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10})); + let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}})); + for step in 0..150 { + let (curves, points, cons) = entities(&s); + let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default(); + let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default(); + let (cmd, params) = match r.next() % 34 { + 0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})), + 1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})), + 2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})), + 3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})), + 4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})), + 5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})), + 6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})), + 7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})), + 8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})), + 9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})), + 10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})), + 11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})), + 12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})), + 13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})), + 14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})), + 15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})), + 16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})), + 17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})), + 18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})), + 19 => ("SketchDimension", json!({"entities": [c(&mut r)]})), + 20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})), + 21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})), + 22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})), + 23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})), + 24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})), + 25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})), + 26 => ("SketchAutoConstraintAndDimCmd", json!({})), + 27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})), + 28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})), + 29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})), + 30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})), + 31 => ("UndoCommand", json!({})), + 32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})), + _ => ("RedoCommand", json!({})), + }; + if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, ¶ms) { + panic!("seed {seed} step {step}: {id} {params}: {msg}"); + } + if s.active_sketch.is_none() { + let _ = s.execute("SketchCreate", &json!({"plane": "XY"})); + } + } + // The design round-trips. + let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(back, *s.doc, "seed {seed}"); + } +} diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 46bda3f0..e1b7525c 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -68,6 +68,45 @@ fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option { } } +/// A uniform grid over a set of boxes (about one box per cell), for neighbour queries. +struct Grid { + lo: Vec2, + size: f64, +} + +impl Grid { + fn new(boxes: &[(Vec2, Vec2)]) -> Grid { + let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)); + for (a, b) in boxes { + lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y)); + hi = Vec2::new(hi.x.max(b.x), hi.y.max(b.y)); + } + if !(lo.is_finite() && hi.is_finite()) { + return Grid { lo: Vec2::ZERO, size: 1.0 }; + } + let ext = (hi.x - lo.x).max(hi.y - lo.y).max(1e-9); + let per_side = (boxes.len() as f64).sqrt().ceil().clamp(1.0, 512.0); + Grid { lo, size: ext / per_side } + } + fn cell(&self, p: Vec2) -> (i64, i64) { + (((p.x - self.lo.x) / self.size).floor() as i64, ((p.y - self.lo.y) / self.size).floor() as i64) + } + /// Cells a box covers; None for boxes spanning too many (they are checked against all). + fn cells(&self, b: &(Vec2, Vec2)) -> Option> { + let (c0, c1) = (self.cell(b.0), self.cell(b.1)); + if (c1.0 - c0.0 + 1).saturating_mul(c1.1 - c0.1 + 1) > 2048 { + return None; + } + let mut out = Vec::new(); + for x in c0.0..=c1.0 { + for y in c0.1..=c1.1 { + out.push((x, y)); + } + } + Some(out) + } +} + fn find(p: &mut [usize], mut i: usize) -> usize { while let Some(&q) = p.get(i) { if q == i { @@ -150,17 +189,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec { Shape::Round { .. } => {} } } - let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; - for (i, (_, sh)) in shapes.iter().enumerate() { - for p in &ends { - if let Some(t) = sh.param_on(*p, tol) - && let Some(c) = cuts.get_mut(i) - { - c.push(t); - } - } - } - // Bounding boxes: only shapes whose boxes overlap can cross. + // Bounding boxes, and a uniform grid over them: only shapes sharing a cell can meet. let boxes: Vec<(Vec2, Vec2)> = shapes .iter() .map(|(_, sh)| match *sh { @@ -172,9 +201,69 @@ pub fn find_profiles(sk: &Sketch) -> Vec { (Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y, _ => false, }; - if shapes.len() <= 20_000 { + let grid = Grid::new(&boxes); + let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; + // End points of other curves lying on each shape. + let mut end_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + for (k, p) in ends.iter().enumerate() { + end_cells.entry(grid.cell(*p)).or_default().push(k); + } + let all_ends: Vec = (0..ends.len()).collect(); + for (i, (_, sh)) in shapes.iter().enumerate() { + let Some(b) = boxes.get(i) else { continue }; + let cand: Vec = match grid.cells(b) { + Some(cs) => cs.iter().flat_map(|c| end_cells.get(c).map(Vec::as_slice).unwrap_or(&[])).copied().collect(), + None => all_ends.clone(), + }; + for k in cand { + if let Some(p) = ends.get(k) + && let Some(t) = sh.param_on(*p, tol) + && let Some(cu) = cuts.get_mut(i) + { + cu.push(t); + } + } + } + let mut shape_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + let mut global: Vec = Vec::new(); + for (i, b) in boxes.iter().enumerate() { + match grid.cells(b) { + Some(cs) => { + for c in cs { + shape_cells.entry(c).or_default().push(i); + } + } + None => global.push(i), + } + } + let mut partners: Vec> = vec![Vec::new(); shapes.len()]; + for v in shape_cells.values() { + for (k, &i) in v.iter().enumerate() { + for &j in v.iter().skip(k + 1) { + let (lo, hi) = if i < j { (i, j) } else { (j, i) }; + if let Some(p) = partners.get_mut(lo) { + p.push(hi); + } + } + } + } + for &g in &global { + for j in 0..shapes.len() { + if j != g { + let (lo, hi) = if g < j { (g, j) } else { (j, g) }; + if let Some(p) = partners.get_mut(lo) { + p.push(hi); + } + } + } + } + for p in &mut partners { + p.sort_unstable(); + p.dedup(); + } + if shapes.len() <= 50_000 { for i in 0..shapes.len() { - for j in (i + 1)..shapes.len() { + for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) { if !overlap(i, j) { continue; } @@ -211,12 +300,24 @@ pub fn find_profiles(sk: &Sketch) -> Vec { } // Pieces → edges between merged nodes. let mut nodes: Vec = Vec::new(); + // Grid buckets of `tol`-sized cells: merging looks only at neighbouring cells. + let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); let mut node = |p: Vec2| -> usize { - if let Some(i) = nodes.iter().position(|q| q.dist(p) <= tol) { - return i; + let (cx, cy) = cell(p); + for dx in -1..=1 { + for dy in -1..=1 { + if let Some(v) = grid.get(&(cx + dx, cy + dy)) + && let Some(i) = v.iter().copied().find(|i| nodes.get(*i).is_some_and(|q| q.dist(p) <= tol)) + { + return i; + } + } } nodes.push(p); - nodes.len() - 1 + let i = nodes.len() - 1; + grid.entry((cx, cy)).or_default().push(i); + i }; let mut loops: Vec<(Loop2, Vec, usize)> = Vec::new(); // (ccw loop, curve ids, component) let mut edges: Vec = Vec::new(); @@ -402,8 +503,18 @@ pub fn find_profiles(sk: &Sketch) -> Vec { } } - // Nesting: parent = smallest containing loop from another component. + // Nesting: parent = smallest containing loop from another component. Bounding boxes rule + // out most pairs before the point-in-loop test. let areas: Vec = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect(); + let boxes: Vec<(Vec2, Vec2)> = loops + .iter() + .map(|(l, _, _)| { + l.polyline(1e-3).iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }) + }) + .collect(); + let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y); let mut parent_of: Vec> = vec![None; loops.len()]; for (i, (li, _, ci)) in loops.iter().enumerate() { let probe = li.segs.first().map(Seg2::mid).unwrap_or_default(); @@ -411,10 +522,10 @@ pub fn find_profiles(sk: &Sketch) -> Vec { let mut best: Option<(f64, usize)> = None; for (j, (lj, _, cj)) in loops.iter().enumerate() { let aj = areas.get(j).copied().unwrap_or(0.0); - if i == j || ci == cj || aj <= ai { + if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) { continue; } - if lj.contains(probe) && best.is_none_or(|(ba, _)| aj < ba) { + if lj.contains(probe) { best = Some((aj, j)); } } @@ -422,11 +533,19 @@ pub fn find_profiles(sk: &Sketch) -> Vec { *p = best.map(|(_, j)| j); } } + let mut kids_of: Vec> = vec![Vec::new(); loops.len()]; + for (k, p) in parent_of.iter().enumerate() { + if let Some(p) = p + && let Some(v) = kids_of.get_mut(*p) + { + v.push(k); + } + } let mut out: Vec = loops .iter() .enumerate() .map(|(i, (l, ids, _))| { - let kids: Vec = (0..loops.len()).filter(|k| parent_of.get(*k).copied().flatten() == Some(i)).collect(); + let kids: Vec = kids_of.get(i).cloned().unwrap_or_default(); let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect(); let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect(); let region = Region2 { outer: l.ccw(), holes };