mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M8: Grid auto-placement on an occupancy bitmap (#1593)
* M8: grid auto-placement on an occupancy bitmap (#1083) Grid placement tested every cell of a child's span at every position it tried. The taken cells are now a bitmap: spans are checked a 64-bit word at a time, the search jumps past the blocking cell, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one with the same column and spans found room. Placements are unchanged (checked against the old search on random grids); a crafted 1000 x 1000 grid takes 0.3 ms instead of 192 ms. * M8: grid occupancy stores rows only once a cell in them is taken An empty or sparse grid no longer allocates the whole bitmap up front (CodeRabbit on #1593). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
bc11197c21
commit
393abf8075
+2
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,134 @@
|
||||
//! Grid auto-placement on an occupancy bitmap (#1083) places every child exactly where the plain
|
||||
//! cell-by-cell search did, on randomised grids with anchored, half-anchored and spanning
|
||||
//! children, while no longer testing every cell of a span at every position.
|
||||
|
||||
use figcraft_doc::{AutoLayout, Document, NodeId, NodeKind};
|
||||
|
||||
use crate::{GridCell, MAX_GRID_CELLS, MAX_TRACKS, grid_place};
|
||||
|
||||
/// The cell-by-cell placement `grid_place` had before #1083, kept as the reference.
|
||||
fn reference(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> Vec<GridCell> {
|
||||
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
|
||||
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
|
||||
let mut occ: Vec<Vec<bool>> = Vec::new();
|
||||
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
|
||||
};
|
||||
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
while occ.len() < r + rs {
|
||||
occ.push(vec![false; cols]);
|
||||
}
|
||||
for row in occ.iter_mut().skip(r).take(rs) {
|
||||
for cell in row.iter_mut().skip(c).take(cs) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
let spans = |id: NodeId| {
|
||||
let cl = doc.node(id).unwrap().child_layout.clone();
|
||||
let cs = (cl.column_span() as usize).min(cols);
|
||||
let rs = (cl.row_span() as usize).min(max_rows);
|
||||
(cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs)
|
||||
};
|
||||
let mut placed: Vec<Option<GridCell>> = vec![None; kids.len()];
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if let (Some(c), Some(r), cs, rs) = spans(*id) {
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
}
|
||||
let mut cursor = (0usize, 0usize);
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if placed[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let (col, row, cs, rs) = spans(*id);
|
||||
let last_row = max_rows - rs;
|
||||
let (c, r) = match (col, row) {
|
||||
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
|
||||
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
|
||||
(None, None) => {
|
||||
let mut found = None;
|
||||
let (mut r, mut c) = cursor;
|
||||
while r <= last_row {
|
||||
if c + cs <= cols && free(&occ, c, r, cs, rs) {
|
||||
found = Some((c, r));
|
||||
break;
|
||||
}
|
||||
c += 1;
|
||||
if c + cs > cols {
|
||||
c = 0;
|
||||
r += 1;
|
||||
}
|
||||
}
|
||||
let (c, r) = found.unwrap_or((0, last_row));
|
||||
cursor = (r, c + cs);
|
||||
(c, r)
|
||||
}
|
||||
};
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
placed.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0 % n.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid of `cols` columns with `n` children, each anchored, half-anchored or free, with spans
|
||||
/// up to `span` (sometimes past the grid, which clamps them).
|
||||
fn grid(rng: &mut Rng, cols: u32, n: usize, span: u64) -> (Document, AutoLayout, Vec<NodeId>) {
|
||||
let mut d = Document::new("grid");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let kids: Vec<NodeId> = (0..n)
|
||||
.map(|_| {
|
||||
let id = d.create(frame, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let anchor = rng.below(4);
|
||||
let (column, row) = (rng.below(u64::from(cols) + 3) as u32, rng.below(40) as u32);
|
||||
let cl = &mut d.get_mut(id).unwrap().child_layout;
|
||||
cl.grid_column = matches!(anchor, 1 | 3).then_some(column);
|
||||
cl.grid_row = matches!(anchor, 2 | 3).then_some(row);
|
||||
cl.grid_column_span = rng.below(span + 1) as u32;
|
||||
cl.grid_row_span = rng.below(span + 1) as u32;
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
let al = AutoLayout { grid_columns: cols, ..Default::default() };
|
||||
(d, al, kids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_match_the_cell_by_cell_search_on_random_grids() {
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
for case in 0..400 {
|
||||
let cols = [1, 2, 3, 7, 63, 64, 65, 130][case % 8];
|
||||
let (n, span) = (rng.below(60) as usize + 1, [1, 3, 9, 70][case % 4]);
|
||||
let (d, al, kids) = grid(&mut rng, cols, n, span);
|
||||
let (cells, _) = grid_place(&d, &al, &kids);
|
||||
assert_eq!(cells, reference(&d, &al, &kids), "case {case}: {cols} columns, {n} children, spans up to {span}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_grid_puts_the_rest_on_its_last_rows_as_before() {
|
||||
// 1000 columns leave 1000 rows; a child too tall for what is left goes to the last row.
|
||||
let mut rng = Rng(7);
|
||||
let (mut d, al, kids) = grid(&mut rng, 1000, 30, 1);
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
let cl = &mut d.get_mut(*id).unwrap().child_layout;
|
||||
(cl.grid_column, cl.grid_row, cl.grid_column_span, cl.grid_row_span) = (Some(0), None, 1000, if i % 2 == 0 { 400 } else { 1 });
|
||||
}
|
||||
let (cells, _) = grid_place(&d, &al, &kids);
|
||||
assert_eq!(cells, reference(&d, &al, &kids));
|
||||
assert!(cells.iter().any(|c| c.row == 600 && c.row_span == 400), "a tall child past the free rows sits at the last row it fits");
|
||||
}
|
||||
+21
-29
@@ -689,20 +689,7 @@ impl GridGeometry {
|
||||
pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<GridCell>, usize) {
|
||||
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
|
||||
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
|
||||
let mut occ: Vec<Vec<bool>> = Vec::new();
|
||||
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
|
||||
};
|
||||
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
while occ.len() < r + rs {
|
||||
occ.push(vec![false; cols]);
|
||||
}
|
||||
for row in occ.iter_mut().skip(r).take(rs) {
|
||||
for cell in row.iter_mut().skip(c).take(cs) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut occ = occupancy::Occupancy::new(cols);
|
||||
let spans = |id: NodeId| {
|
||||
let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default();
|
||||
let cs = (cl.column_span() as usize).min(cols);
|
||||
@@ -713,7 +700,7 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
// 1. Fully anchored children.
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if let (Some(c), Some(r), cs, rs) = spans(*id) {
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
occ.mark(c, r, cs, rs);
|
||||
if let Some(slot) = placed.get_mut(i) {
|
||||
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
@@ -721,6 +708,10 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
}
|
||||
// 2. The rest, in order (a cursor keeps auto-placed children in reading order).
|
||||
let mut cursor = (0usize, 0usize); // (row, column)
|
||||
// Cells only ever get taken, so the first free row for a column span never moves up: each
|
||||
// (column, spans) looks on from where it last found one.
|
||||
let mut from_row: std::collections::HashMap<(usize, usize, usize), usize> = std::collections::HashMap::new();
|
||||
let mut scratch = Vec::new();
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if placed.get(i).is_some_and(Option::is_some) {
|
||||
continue;
|
||||
@@ -728,28 +719,26 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
let (col, row, cs, rs) = spans(*id);
|
||||
let last_row = max_rows - rs;
|
||||
let (c, r) = match (col, row) {
|
||||
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
|
||||
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
|
||||
(Some(c), _) => {
|
||||
let start = from_row.get(&(c, cs, rs)).copied().unwrap_or(0);
|
||||
let found = occ.first_row(c, cs, rs, start, last_row);
|
||||
from_row.insert((c, cs, rs), found.unwrap_or(last_row + 1));
|
||||
(c, found.unwrap_or(last_row))
|
||||
}
|
||||
(None, Some(r)) => (occ.first_column(r, rs, 0, cs, &mut scratch).unwrap_or(0), r),
|
||||
(None, None) => {
|
||||
let mut found = None;
|
||||
let (mut r, mut c) = cursor;
|
||||
while r <= last_row {
|
||||
if c + cs <= cols && free(&occ, c, r, cs, rs) {
|
||||
found = Some((c, r));
|
||||
break;
|
||||
}
|
||||
c += 1;
|
||||
if c + cs > cols {
|
||||
c = 0;
|
||||
r += 1;
|
||||
}
|
||||
let mut found = None;
|
||||
while r <= last_row && found.is_none() {
|
||||
found = occ.first_column(r, rs, c, cs, &mut scratch).map(|c| (c, r));
|
||||
(r, c) = (r + 1, 0);
|
||||
}
|
||||
let (c, r) = found.unwrap_or((0, last_row));
|
||||
cursor = (r, c + cs);
|
||||
(c, r)
|
||||
}
|
||||
};
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
occ.mark(c, r, cs, rs);
|
||||
if let Some(slot) = placed.get_mut(i) {
|
||||
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
@@ -1171,6 +1160,9 @@ mod content_align_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod grid_place_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_tests;
|
||||
mod occupancy;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Which cells of a layout grid are taken, one bit per cell (#1083). Grid placement asks whether a
|
||||
//! span of cells is free a 64-bit word at a time and jumps past the cell that blocks it, instead of
|
||||
//! testing every cell of the span at every position.
|
||||
|
||||
/// The taken cells of a grid of `cols` columns, row by row.
|
||||
pub(crate) struct Occupancy {
|
||||
cols: usize,
|
||||
words: usize,
|
||||
bits: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The bits of columns `c..end` that fall in word `w`.
|
||||
fn mask(w: usize, c: usize, end: usize) -> u64 {
|
||||
let lo = c.max(w * 64) - w * 64;
|
||||
let hi = end.min((w + 1) * 64) - w * 64;
|
||||
match hi.saturating_sub(lo) {
|
||||
0 => 0,
|
||||
64 => u64::MAX,
|
||||
n => ((1u64 << n) - 1) << lo,
|
||||
}
|
||||
}
|
||||
|
||||
/// The last taken column of `c..c + cs` in `row`, if any.
|
||||
fn last_taken(row: &[u64], c: usize, cs: usize) -> Option<usize> {
|
||||
let end = c + cs;
|
||||
if cs == 0 {
|
||||
return None;
|
||||
}
|
||||
(c / 64..=(end - 1) / 64).rev().find_map(|w| {
|
||||
let v = row.get(w).copied().unwrap_or(0) & mask(w, c, end);
|
||||
(v != 0).then(|| w * 64 + 63 - v.leading_zeros() as usize)
|
||||
})
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
/// An empty grid of `cols` columns. Rows are stored only once something takes a cell in them
|
||||
/// (or a row below them), so an empty or sparse grid allocates little.
|
||||
pub(crate) fn new(cols: usize) -> Self {
|
||||
Self { cols, words: cols.div_ceil(64), bits: Vec::new() }
|
||||
}
|
||||
|
||||
fn row(&self, r: usize) -> &[u64] {
|
||||
self.bits.get(r * self.words..(r + 1) * self.words).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Take columns `c..c + cs` of rows `r..r + rs`.
|
||||
pub(crate) fn mark(&mut self, c: usize, r: usize, cs: usize, rs: usize) {
|
||||
let end = c + cs;
|
||||
if cs == 0 || rs == 0 {
|
||||
return;
|
||||
}
|
||||
let need = (r + rs) * self.words;
|
||||
if self.bits.len() < need {
|
||||
self.bits.resize(need, 0);
|
||||
}
|
||||
for ri in r..r + rs {
|
||||
for w in c / 64..=(end - 1) / 64 {
|
||||
if let Some(word) = self.bits.get_mut(ri * self.words + w) {
|
||||
*word |= mask(w, c, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first column from `from` where columns `c..c + cs` are free in rows `r..r + rs` and fit
|
||||
/// the grid (`scratch`: a buffer for the rows' union).
|
||||
pub(crate) fn first_column(&self, r: usize, rs: usize, from: usize, cs: usize, scratch: &mut Vec<u64>) -> Option<usize> {
|
||||
scratch.clear();
|
||||
scratch.resize(self.words, 0);
|
||||
for ri in r..r + rs {
|
||||
for (u, w) in scratch.iter_mut().zip(self.row(ri)) {
|
||||
*u |= w;
|
||||
}
|
||||
}
|
||||
let mut c = from;
|
||||
while c + cs <= self.cols {
|
||||
match last_taken(scratch, c, cs) {
|
||||
None => return Some(c),
|
||||
// No span starting at or before the taken column can include it free.
|
||||
Some(t) => c = t + 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The first row from `from` (at most `last`) where columns `c..c + cs` are free in `rs` rows
|
||||
/// in a row.
|
||||
pub(crate) fn first_row(&self, c: usize, cs: usize, rs: usize, from: usize, last: usize) -> Option<usize> {
|
||||
let mut run = 0;
|
||||
for r in from..last + rs {
|
||||
if last_taken(self.row(r), c, cs).is_some() {
|
||||
run = 0;
|
||||
continue;
|
||||
}
|
||||
run += 1;
|
||||
if run == rs {
|
||||
return Some(r + 1 - rs);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Auto layout and constraints parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor (grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Auto layout is the most used layout feature in Figma and the one where small differences (a
|
||||
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
|
||||
@@ -95,6 +95,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. |
|
||||
| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user