G11: Shape Builder's edge-source grid holds a bounded number of references

Follow-up to #4021. The grid filed each piece of a segment under its cells
without checking, so a degenerate grid (collinear horizontal lines: cells a
hundred points wide and a hair tall) stored one segment ~400 times per cell,
and coincident vertical segments (one column, a row per segment) filed every
segment under every row: a quadratic number of references, about a GiB for
16,384 segments within the Shape Builder's limit.

Now a segment is filed once per cell (its pieces come in order, so the cell's
last entry is enough to check), and the grid holds a budget of references
(64 per item plus four rows' or columns' worth, `reference_budget`); the
items past it go to the scanned list, so the index stays O(n·sqrt(n)) in
memory and every lookup still finds every segment through its point.

Regression: `rect_index_holds_a_bounded_number_of_references` (fails without
the change: 4,100 references per cell for ten collinear lines).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh
This commit is contained in:
Angelo Bernardini
2026-10-26 09:09:44 +07:00
committed by bflatastic
co-authored by Claude Fable 5.1
parent 3567e5be7d
commit 6f8ac34f38
3 changed files with 67 additions and 12 deletions
+62 -9
View File
@@ -117,6 +117,15 @@ struct RectIndex<T> {
/// a hatch cuts a shape into) is filed under the cells along it, while a rectangle covering most
/// of the grid costs more to file and to read than to scan for every point.
const CELLS_PER_FOOTPRINT: usize = 64;
/// The cell references a grid of `cells` cells holds for `n` items before the remaining items go
/// to the scanned list: a few per item, plus a few rows or columns' worth (a long segment is filed
/// under a cell of each row or column it crosses, about `sqrt(cells)` of them in a square grid).
/// Coincident segments make a grid of one column and a row per segment, where each would be filed
/// under every row: a quadratic number of references, which this keeps to `n·sqrt(n)`.
fn reference_budget(n: usize, cells: usize) -> usize {
let across = (cells as f64).sqrt().ceil().min(1e9) as usize;
n.saturating_mul(64 + 4 * across).saturating_add(1024)
}
/// The cells (columns or rows) an interval `lo..=hi` touches on one axis of a grid: clamped to the
/// grid, so an interval past its edge gets the nearest cell (whose items `contains` then rejects),
@@ -154,6 +163,7 @@ impl<T> RectIndex<T> {
}
let cell = ((all.width() / cols as f64).max(1e-9), (all.height() / rows as f64).max(1e-9));
let cap = CELLS_PER_FOOTPRINT.max(2 * cols.max(rows));
let mut budget = reference_budget(n, cols * rows);
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); cols * rows];
let mut rest = Vec::new();
for (i, (r, t)) in items.iter().enumerate() {
@@ -163,22 +173,36 @@ impl<T> RectIndex<T> {
continue;
};
let mut scan = !r.is_finite();
let mut spans = Vec::new();
for f in if scan { Vec::new() } else { footprint(r, t, cell.0.min(cell.1)) } {
match (cell_span(f.x0, f.x1, all.x0, cell.0, cols), cell_span(f.y0, f.y1, all.y0, cell.1, rows)) {
(Some(xs), Some(ys)) if (xs.end() - xs.start() + 1) * (ys.end() - ys.start() + 1) <= cap => {
for y in ys {
for x in xs.clone() {
if let Some(c) = cells.get_mut(y * cols + x) {
c.push(index);
}
}
}
}
(Some(xs), Some(ys)) if (xs.end() - xs.start() + 1) * (ys.end() - ys.start() + 1) <= cap => spans.push((xs, ys)),
_ => scan = true,
}
}
// The item's cells, counted before they are filed: past the grid's budget the item,
// and the ones after it, are scanned instead (the index stays bounded in memory).
let needed: usize = spans.iter().map(|(xs, ys)| (xs.end() - xs.start() + 1) * (ys.end() - ys.start() + 1)).sum();
if !scan && needed > budget {
scan = true;
}
if scan {
rest.push(i);
continue;
}
budget -= needed;
for (xs, ys) in spans {
for y in ys {
for x in xs.clone() {
// Pieces of one segment that fall in the same cell (a degenerate grid's
// tiny cells) file it once: the item's pieces come in order.
if let Some(c) = cells.get_mut(y * cols + x)
&& c.last() != Some(&index)
{
c.push(index);
}
}
}
}
}
Self { items, origin: all.origin(), cell, cols, cells, rest }
@@ -2119,6 +2143,35 @@ mod tests {
assert_eq!(index.at(Point::new(-5.0, 0.0), 0.1).count(), 0);
}
/// The grid's memory stays linear in the item count: pieces of one segment falling in the same
/// cell file it once, and coincident segments (each along every cell of a one-column grid) go
/// to the scanned list once the grid holds its budget of references, and are still all found.
#[test]
fn rect_index_holds_a_bounded_number_of_references() {
// Ten collinear horizontal lines: a grid of 10 cells a hundred points wide and a hair tall,
// so each line's 4096 pieces fall about 400 per cell; filed once per cell.
let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0)));
let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect();
let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell));
assert!(index.cells.iter().all(|c| c.len() <= 10), "{:?}", index.cells.iter().map(Vec::len).collect::<Vec<_>>());
assert_eq!(index.at(Point::new(500.0, 0.0), 0.1).count(), 10);
// 4,096 coincident vertical segments: one column, a row per segment, every segment along
// every row would be 16 million references; the budget keeps it to a few hundred per item.
let n = 4096u32;
let seg = PathSeg::Line(kurbo::Line::new((50.0, 0.0), (50.0, 200.0)));
let items: Vec<_> = (0..n).map(|k| (seg.bounding_box(), (seg, k))).collect();
let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell));
let references: usize = index.cells.iter().map(Vec::len).sum();
assert!(references <= reference_budget(n as usize, index.cells.len()), "{references} references");
assert!(references < 2_000_000, "{references} references");
assert!(!index.rest.is_empty() && index.rest.len() < n as usize, "{} scanned", index.rest.len());
let mut got: Vec<u32> = index.at(Point::new(50.0, 100.0), 0.1).map(|(_, k)| *k).collect();
got.sort_unstable();
assert_eq!(got.len(), n as usize);
assert_eq!(got, (0..n).collect::<Vec<_>>());
assert_eq!(index.at(Point::new(60.0, 100.0), 0.1).count(), 0);
}
fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath {
SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), closed)
}
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long