Large sketches: the solver works on each component's own Jacobian (block-diagonal DOF analysis, no dense whole-sketch matrices), component grouping by map, and profile finding uses its grid for end checks and keyed sorting. A 10 000-entity sketch solves in 65 ms instead of 1.3 s and commands on it take ~0.2 s instead of 5 s; numbers in docs/perf.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 04:56:07 +00:00
1 parent 69353faf75
commit f9b7604ed1
3 files changed
+173 -41

No files matched your search

+17 -4
View File
@@ -343,7 +343,17 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol));
let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol));
// Crossings at (or next to) curve ends are already cuts.
if on_x.is_none() || on_y.is_none() || ends.iter().any(|e| e.dist(p) < tol * 100.0) {
let near_end = || {
let (cx, cy) = grid.cell(p);
(-1..=1).any(|dx| {
(-1..=1).any(|dy| {
end_cells
.get(&(cx + dx, cy + dy))
.is_some_and(|v| v.iter().any(|k| ends.get(*k).is_some_and(|e| e.dist(p) < tol * 100.0)))
})
})
};
if on_x.is_none() || on_y.is_none() || near_end() {
continue;
}
if let (Some(tx), Some(ty)) = (on_x, on_y) {
@@ -611,7 +621,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
v.push(k);
}
}
let mut out: Vec<Profile> = loops
let out: Vec<Profile> = loops
.iter()
.enumerate()
.map(|(i, (l, ids, _))| {
@@ -624,8 +634,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid }
})
.collect();
let first_curve = |p: &Profile| p.outer_curves.iter().filter_map(|id| sk.curve_index(id)).min().unwrap_or(usize::MAX);
out.sort_by(|a, b| first_curve(a).cmp(&first_curve(b)).then(b.area.total_cmp(&a.area)));
let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
let first_curve = |p: &Profile| p.outer_curves.iter().filter_map(|id| index.get(id.as_str()).copied()).min().unwrap_or(usize::MAX);
let mut keyed: Vec<(usize, Profile)> = out.into_iter().map(|p| (first_curve(&p), p)).collect();
keyed.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.area.total_cmp(&a.1.area)));
let out: Vec<Profile> = keyed.into_iter().map(|x| x.1).collect();
out
}
+120 -37
View File
@@ -444,6 +444,14 @@ enum Block<'a> {
TangentAt(usize, &'a ConstraintKind, usize),
}
fn copy_block<'a>(b: &Block<'a>) -> Block<'a> {
match b {
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
Block::User(i, k) => Block::User(*i, k),
Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p),
}
}
fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
match b {
Block::Arc { c, a, b } => {
@@ -634,6 +642,53 @@ fn system(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], x: &mut [f6
(r, Some(j), owner)
}
/// Residuals and the Jacobian restricted to `vars` (columns in that order; `col` maps a global
/// variable to its column, usize::MAX elsewhere). Blocks only touch their component's variables,
/// so a component's Jacobian is small even in a huge sketch.
fn local_system(
sk: &Sketch,
lay: &Layout,
blocks: &[(Block, Vec<usize>)],
x: &mut [f64],
col: &[usize],
ncols: usize,
) -> (Vec<f64>, Mat, Vec<usize>) {
let (r, _, owner) = system(sk, lay, blocks, x, false);
let mut j = Mat::zeros(r.len(), ncols);
let mut row = 0;
let mut plus = Vec::new();
let mut minus = Vec::new();
for (b, vars) in blocks {
plus.clear();
block_residuals(&State { sk, lay, x }, b, &mut plus);
let rows = plus.len();
for &v in vars {
let Some(c) = col.get(v).copied().filter(|c| *c < ncols) else { continue };
let Some(x0) = x.get(v).copied() else { continue };
let h = 1e-7 * x0.abs().max(1.0);
if let Some(xv) = x.get_mut(v) {
*xv = x0 + h;
}
plus.clear();
block_residuals(&State { sk, lay, x }, b, &mut plus);
if let Some(xv) = x.get_mut(v) {
*xv = x0 - h;
}
minus.clear();
block_residuals(&State { sk, lay, x }, b, &mut minus);
if let Some(xv) = x.get_mut(v) {
*xv = x0;
}
for k in 0..rows {
let d = (plus.get(k).copied().unwrap_or(0.0) - minus.get(k).copied().unwrap_or(0.0)) / (2.0 * h);
j.set(row + k, c, d);
}
}
row += rows;
}
(r, j, owner)
}
fn max_abs(v: &[f64]) -> f64 {
v.iter().fold(0.0_f64, |m, x| if x.is_finite() { m.max(x.abs()) } else { f64::INFINITY })
}
@@ -642,7 +697,7 @@ fn norm2(v: &[f64]) -> f64 {
}
/// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns iterations.
fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize], x: &mut Vec<f64>) -> usize {
fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize], x: &mut Vec<f64>, col: &[usize]) -> usize {
let n = vars.len();
let mut lambda = 1e-3;
let (mut r, _, _) = system(sk, lay, blocks, x, false);
@@ -651,13 +706,13 @@ fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize],
if max_abs(&r) < TOL {
return it;
}
let (_, Some(j), _) = system(sk, lay, blocks, x, true) else { return it };
// Normal equations restricted to this component's variables.
let (_, j, _) = local_system(sk, lay, blocks, x, col, n);
// Normal equations of this component.
let mut a = Mat::zeros(n, n);
let mut g = vec![0.0; n];
for row in 0..j.rows {
let ri = r.get(row).copied().unwrap_or(0.0);
let nz: Vec<(usize, f64)> = vars.iter().enumerate().map(|(li, &v)| (li, j.get(row, v))).filter(|(_, d)| *d != 0.0).collect();
let nz: Vec<(usize, f64)> = (0..n).map(|li| (li, j.get(row, li))).filter(|(_, d)| *d != 0.0).collect();
for &(li, di) in &nz {
if let Some(gi) = g.get_mut(li) {
*gi += di * ri;
@@ -766,11 +821,19 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
}
}
let mut comps: Vec<(usize, Vec<usize>, Vec<usize>)> = Vec::new(); // (root, vars, block idx)
let mut comp_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for v in 0..lay.n {
let r = find(&mut parent, v);
match comps.iter_mut().find(|c| c.0 == r) {
Some(c) => c.1.push(v),
None => comps.push((r, vec![v], Vec::new())),
match comp_of.get(&r) {
Some(&k) => {
if let Some(c) = comps.get_mut(k) {
c.1.push(v);
}
}
None => {
comp_of.insert(r, comps.len());
comps.push((r, vec![v], Vec::new()));
}
}
}
let mut constant_blocks = Vec::new();
@@ -778,13 +841,22 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
match vars.first() {
Some(&v) => {
let r = find(&mut parent, v);
if let Some(c) = comps.iter_mut().find(|c| c.0 == r) {
if let Some(c) = comp_of.get(&r).and_then(|k| comps.get_mut(*k)) {
c.2.push(bi);
}
}
None => constant_blocks.push(bi),
}
}
// Column of each variable within its component.
let mut col = vec![usize::MAX; lay.n];
for (_, vars, _) in &comps {
for (k, v) in vars.iter().enumerate() {
if let Some(c) = col.get_mut(*v) {
*c = k;
}
}
}
let mut iterations = 0;
// Solve each component; `blocks` is consumed by index so rebuild per component.
@@ -793,42 +865,53 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
if bis.is_empty() {
continue;
}
let sub: Vec<(Block, Vec<usize>)> = bis
.iter()
.filter_map(|&bi| {
all_blocks.get(bi).map(|(b, v)| {
let b2 = match b {
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
Block::User(i, k) => Block::User(*i, k),
Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p),
};
(b2, v.clone())
})
})
.collect();
iterations += lm(sk, &lay, &sub, vars, &mut x);
let sub: Vec<(Block, Vec<usize>)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect();
iterations += lm(sk, &lay, &sub, vars, &mut x, &col);
}
// Final residuals, Jacobian and analysis over everything.
let (r, j, owner) = system(sk, &lay, &all_blocks, &mut x, true);
let max_residual = max_abs(&r);
// Final residuals and analysis, component by component (the Jacobian is block diagonal:
// its rank is the sum of the components' ranks).
let mut max_residual = 0.0_f64;
let mut failing: Vec<String> = Vec::new();
for (row, v) in r.iter().enumerate() {
if !(v.abs() < ACCEPT)
&& let Some((Block::User(ci, _) | Block::TangentAt(ci, _, _), _)) = owner.get(row).and_then(|bi| all_blocks.get(*bi))
&& let Some(c) = sk.constraints.get(*ci)
&& !failing.contains(&c.id)
{
failing.push(c.id.clone());
let mut det = vec![false; lay.n];
let mut rank = 0;
let mut check = |r: &[f64], owner: &[usize], blocks: &[(Block, Vec<usize>)], failing: &mut Vec<String>| {
max_residual = max_residual.max(max_abs(r));
for (row, v) in r.iter().enumerate() {
if !(v.abs() < ACCEPT)
&& let Some((Block::User(ci, _) | Block::TangentAt(ci, _, _), _)) = owner.get(row).and_then(|bi| blocks.get(*bi))
&& let Some(c) = sk.constraints.get(*ci)
&& !failing.contains(&c.id)
{
failing.push(c.id.clone());
}
}
};
for (_, vars, bis) in &comps {
let sub: Vec<(Block, Vec<usize>)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect();
if sub.is_empty() {
continue;
}
let (r, j, owner) = local_system(sk, &lay, &sub, &mut x, &col, vars.len());
check(&r, &owner, &sub, &mut failing);
if j.rows > 0 {
let (d, rk) = determined_vars(&j, 1e-7);
rank += rk;
for (k, v) in vars.iter().enumerate() {
if let (Some(slot), Some(flag)) = (det.get_mut(*v), d.get(k)) {
*slot = *flag;
}
}
}
}
let _ = constant_blocks;
let consts: Vec<(Block, Vec<usize>)> =
constant_blocks.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect();
if !consts.is_empty() {
let (r, _, owner) = system(sk, &lay, &consts, &mut x, false);
check(&r, &owner, &consts, &mut failing);
}
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
let use_x = if status == SolveStatus::Solved { &x } else { &x0 };
let (det, rank) = match &j {
Some(j) if j.rows > 0 => determined_vars(j, 1e-7),
_ => (vec![false; lay.n], 0),
};
// Write back.
for (i, p) in sk.points.iter_mut().enumerate() {
if let Some(v) = lay.pvar.get(i).copied().flatten() {
+36
View File
@@ -0,0 +1,36 @@
# Performance
## Large sketches
Measured with `cargo test -p solvecraft-engine --release sketch_perf -- --ignored --nocapture`
(`crates/engine/src/cmd/sketch_perf_tests.rs`). The sketch holds `n` rectangles (four lines,
horizontal/vertical constraints and two length dimensions each) and `n` circles. All times are
wall-clock milliseconds on the development machine while other builds were running (load
average around 60), so treat them as upper bounds.
| n | curves | points | constraints | solve | profiles | draw lists | snap query | add a line (command) | drag a point (command) |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 250 | 1 250 | 1 251 | 1 500 | 15 | 30 | 12 | 0.1 | 68 | 48 |
| 500 | 2 500 | 2 501 | 3 000 | 35 | 47 | 10 | 0.1 | 123 | 100 |
| 1000 | 5 000 | 5 001 | 6 000 | 65 | 134 | 1 | 0.3 | 232 | 100 |
"Add a line" and "drag a point" are whole commands: they copy the sketch, solve it, store it and
re-evaluate the timeline (a second solve, plus profile finding). "Draw lists" is
`view::sketch_lines`, the polylines the viewport draws.
Before the 2026-10-08 changes the n = 1000 sketch took 1 280 ms to solve, 590 ms to find
profiles and 5.1 s per command:
- The solver built one dense Jacobian over every variable of the sketch, for each connected
component's iterations and again for the degree-of-freedom analysis (5 000 × 10 000 entries,
then reduced row echelon form). It now builds each component's Jacobian over that component's
own variables. The sketch's Jacobian is block diagonal, so its rank is the sum of the
components' ranks, and components were already solved separately.
- Grouping variables into components searched a list per variable. It now uses a map.
- Profile finding looked at every curve end for each crossing it found, and sorted profiles with
a linear id lookup in the comparison. It now uses the grid it already builds for crossings,
and sorts on precomputed keys.
Projection (linked reference geometry) is cached per model state, so edits to a sketch with
projected silhouettes or sections on a ~20 000-triangle model cost about 3 ms each instead of
about 80 ms.