M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)

Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
This commit is contained in:
BFlat
2026-10-11 10:04:25 +07:00
committed by GitHub
parent 1444754f66
commit 08d91869cb
47 changed files with 2328 additions and 326 deletions
+121
View File
@@ -0,0 +1,121 @@
//! Boolean group results for drawing, cached so editing one operand stays cheap.
//!
//! A boolean group's result is its operands folded bottom to top, each combined with the result
//! of those below it by its own operation (`Node::bool_op`, else the group's). Every prefix of the
//! fold is cached under a rolling hash of the operands it covers, so when an operand changes (a
//! drag, a point edit) the fold restarts from the longest unchanged prefix: the operands below the
//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at
//! all. The caches drop their least recently used half when full, so a long drag doesn't evict
//! every other group's result.
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_doc::BoolOp;
use figcraft_geom::BezPath;
use crate::draw::hash_path;
/// A bounded map of computed paths; the least recently used half goes when it is full.
pub(crate) struct PathCache {
map: HashMap<u64, (Arc<BezPath>, u64)>,
tick: u64,
cap: usize,
}
impl PathCache {
pub(crate) fn new(cap: usize) -> Self {
Self { map: HashMap::new(), tick: 0, cap: cap.max(2) }
}
pub(crate) fn get(&mut self, key: u64) -> Option<Arc<BezPath>> {
self.tick += 1;
let tick = self.tick;
self.map.get_mut(&key).map(|(p, used)| {
*used = tick;
p.clone()
})
}
pub(crate) fn insert(&mut self, key: u64, path: Arc<BezPath>) {
if self.map.len() >= self.cap {
let mut ticks: Vec<u64> = self.map.values().map(|(_, t)| *t).collect();
// Keep the newer half: drop every entry used at or before the median tick.
let mid = (ticks.len() / 2).saturating_sub(1);
let (_, cut, _) = ticks.select_nth_unstable(mid);
let cut = *cut;
self.map.retain(|_, (_, t)| *t > cut);
}
self.tick += 1;
self.map.insert(key, (path, self.tick));
}
}
/// The shared cache of a cache-returning `OnceLock`, recovering from a poisoned lock.
pub(crate) fn locked(c: &Mutex<PathCache>) -> std::sync::MutexGuard<'_, PathCache> {
c.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn cache() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(4096)))
}
thread_local! {
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many boolean steps (one operand combined with the result below it) this thread has
/// computed: what a cache miss costs. For tests and benchmarks.
pub fn boolean_steps() -> u64 {
STEPS.with(std::cell::Cell::get)
}
/// The result of folding `operands` (bottom to top, each with its operation; the bottom one's is
/// unused), reusing the longest cached prefix. Invalid geometry gives an empty path.
pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> BezPath {
if operands.len() < 2 {
return figcraft_pathops::boolean_ops(operands.iter().map(|(p, op)| (p, *op))).unwrap_or_default();
}
// keys[i]: the fold of operands 0..=i.
let mut h = DefaultHasher::new();
let keys: Vec<u64> = operands
.iter()
.enumerate()
.map(|(i, (p, op))| {
if i > 0 {
(*op as u8).hash(&mut h);
}
hash_path(p, &mut h);
9u8.hash(&mut h);
h.finish()
})
.collect();
let hit = {
let mut c = locked(cache());
keys.iter().enumerate().skip(1).rev().find_map(|(i, k)| c.get(*k).map(|p| (i, p)))
};
let (start, mut acc) = match hit {
Some((i, p)) if i + 1 == operands.len() => return (*p).clone(),
Some((i, p)) => (i, (*p).clone()),
None => match operands.first() {
Some((p, _)) => (0, p.clone()),
None => return BezPath::new(),
},
};
let mut fresh = Vec::new();
for ((p, op), key) in operands.iter().zip(&keys).skip(start + 1) {
STEPS.with(|s| s.set(s.get() + 1));
acc = match figcraft_pathops::boolean_step(&acc, p, *op) {
Ok(r) => r,
Err(_) => return BezPath::new(),
};
fresh.push((*key, Arc::new(acc.clone())));
}
let mut c = locked(cache());
for (k, p) in fresh {
c.insert(k, p);
}
acc
}
+135
View File
@@ -0,0 +1,135 @@
//! Live boolean editing in the renderer: per-operand operations, how much of a group is
//! recomputed when one operand changes, the bounded path caches, and one-sided widths on screen.
use super::*;
use crate::boolean::{PathCache, boolean_steps, fold_cached};
use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
fn render(d: &Document, p: NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
}
/// A boolean group doing `op` over squares 20 wide at x = 10, 25, 40, … (overlapping neighbours),
/// on a page of its own.
fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId>) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let b = d.create(p, NodeKind::BooleanOp { op }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let kids = (0..n).map(|i| d.create(b, NodeKind::Rectangle, "R", 10.0 + 15.0 * i as f64, 10.0, 20.0, 20.0).unwrap()).collect();
d.fit_to_children(b).unwrap();
(d, p, b, kids)
}
fn outline(d: &Document, id: NodeId) -> BezPath {
node_outline(d, d.node(id).unwrap()).unwrap().0
}
#[test]
fn operands_combine_by_their_own_operations() {
// Union of three squares in a row, the middle one subtracted instead.
let (mut d, p, b, kids) = row_of_squares(BoolOp::Union, 3);
let union = outline(&d, b).area().abs();
assert!((union - 20.0 * 50.0).abs() < 0.5, "{union}");
d.get_mut(kids[1]).unwrap().bool_op = Some(BoolOp::Subtract);
let mixed = outline(&d, b);
// (A − B) ∪ C: A keeps x 10..25, C is whole (x 40..60).
assert!((mixed.area().abs() - 20.0 * (15.0 + 20.0)).abs() < 0.5, "{}", mixed.area());
let pix = render(&d, p);
assert!(dark(&pix, 15, 20) && !dark(&pix, 32, 20) && dark(&pix, 50, 20));
// The bottom operand's own operation changes nothing.
d.get_mut(kids[0]).unwrap().bool_op = Some(BoolOp::Intersect);
assert!((outline(&d, b).area().abs() - mixed.area().abs()).abs() < 1e-9);
}
#[test]
fn editing_one_operand_recombines_only_from_it_up() {
// Distinct sizes so no other test's cached results can be reused.
let (mut d, _, b, kids) = row_of_squares(BoolOp::Union, 8);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().height = 20.0 + i as f64 * 0.37;
}
let steps = |d: &Document| {
let before = boolean_steps();
let _ = outline(d, b);
boolean_steps() - before
};
assert_eq!(steps(&d), 7, "the first time: every operand above the bottom one");
assert_eq!(steps(&d), 0, "unchanged: from the cache");
// Moving the sixth operand (a drag frame) recombines it and the two above it.
let mut moved = d.clone();
moved.get_mut(kids[5]).unwrap().transform = figcraft_geom::Affine::translate((3.0, 1.5)) * d.node(kids[5]).unwrap().transform;
assert_eq!(steps(&moved), 3);
// The top one: one step. Going back: cached.
let mut top = moved.clone();
top.get_mut(kids[7]).unwrap().height = 33.3;
assert_eq!(steps(&top), 1);
assert_eq!(steps(&moved), 0);
assert_eq!(steps(&d), 0);
// Hiding an operand drops it from the fold (those above it are recombined).
let mut hidden = d.clone();
hidden.get_mut(kids[6]).unwrap().visible = false;
assert_eq!(steps(&hidden), 1);
}
#[test]
fn other_groups_are_not_recomputed() {
let (mut d, _, b, kids) = row_of_squares(BoolOp::Exclude, 4);
let (d2, _, b2, _) = row_of_squares(BoolOp::Intersect, 5);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().width = 21.0 + i as f64 * 0.29;
}
let _ = outline(&d, b);
let _ = outline(&d2, b2);
let before = boolean_steps();
d.get_mut(kids[3]).unwrap().width = 30.1;
let _ = outline(&d, b);
let _ = outline(&d2, b2);
assert_eq!(boolean_steps() - before, 1, "only the edited group's top step");
}
#[test]
fn caches_drop_the_least_recently_used_half() {
let mut c = PathCache::new(4);
let p = std::sync::Arc::new(BezPath::new());
for k in 0..4 {
c.insert(k, p.clone());
}
// Using 0 and 1 keeps them when the cache fills up.
assert!(c.get(0).is_some() && c.get(1).is_some());
c.insert(9, p.clone());
assert!(c.get(0).is_some() && c.get(1).is_some() && c.get(9).is_some());
assert!(c.get(2).is_none() && c.get(3).is_none());
// Junk and degenerate folds.
assert!(fold_cached(&[]).elements().is_empty());
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((1.0, 1.0));
let sq = figcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1);
assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).elements().is_empty());
}
#[test]
fn one_sided_widths_paint_one_side() {
// A line from (10, 100) to (190, 100), 20 wide, all of it on the right (+y, looking along it).
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let l = d.create(p, NodeKind::Line, "L", 10.0, 100.0, 180.0, 0.0).unwrap();
let n = d.get_mut(l).unwrap();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 20.0;
n.stroke.width_profile = Some(WidthProfile { points: vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)] });
let pix = render(&d, p);
assert!(dark(&pix, 100, 110) && dark(&pix, 100, 118), "the right side, 20 deep");
assert!(!dark(&pix, 100, 95), "nothing on the left");
// The paint bounds reach the widest side.
let b = d.world_render_bounds(l).unwrap();
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
}
+26 -40
View File
@@ -10,6 +10,7 @@ use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::backdrop::{self, Under};
use crate::boolean::{PathCache, locked};
use crate::paint::{prepare, ts_blend};
use crate::{blur, grain, lod, shadow};
@@ -68,9 +69,9 @@ fn fill_with(
}
/// Stroke outline cache: (geometry hash) → outline path.
fn stroke_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
fn stroke_cache() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(4096)))
}
pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
@@ -143,21 +144,32 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
}
props.dashes.len().hash(&mut h);
for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() {
(p.t.to_bits(), p.scale.to_bits()).hash(&mut h);
(p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h);
}
let key = h.finish();
if let Some(p) = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
if let Some(p) = locked(stroke_cache()).get(key) {
return p;
}
let out = Arc::new(figcraft_pathops::stroke_outline(path, props, closed));
let mut c = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 4096 {
c.clear();
}
c.insert(key, out.clone());
locked(stroke_cache()).insert(key, out.clone());
out
}
/// A node's painted stroke area in local coordinates with the rule to fill it by (`None` without a
/// visible stroke or geometry). Text strokes follow its glyphs and aren't covered.
pub fn stroke_fill(doc: &Document, n: &Node) -> Option<(Arc<BezPath>, FillRule)> {
if !n.has_visible_stroke() {
return None;
}
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
let geom = match &n.kind {
NodeKind::BooleanOp { .. } => node_outline(doc, n).map(|(p, _)| p),
NodeKind::Text(_) | NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_doc::geometry::stroke_geometry(n),
}?;
Some((stroke_area(n, &geom, closed), stroke_rule(n, closed)))
}
/// Even-odd ring paths need the even-odd rule; everything else non-zero.
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
@@ -172,7 +184,7 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
match &n.kind {
NodeKind::BooleanOp { op } => {
let paths: Vec<BezPath> = n
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
.children
.iter()
.filter_map(|c| doc.node(*c))
@@ -180,11 +192,10 @@ pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
.filter_map(|c| {
let (mut p, _) = subtree_outline(doc, c, 0)?;
p.apply_affine(c.transform);
Some(p)
Some((p, c.bool_op.unwrap_or(*op)))
})
.collect();
let p = boolean_cached(&paths, *op);
Some((p, FillRule::EvenOdd))
Some((crate::boolean::fold_cached(&operands), FillRule::EvenOdd))
}
NodeKind::Text(_) => {
let (l, dy) = figcraft_text::layout_node(n)?;
@@ -230,31 +241,6 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
}
}
fn boolean_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn boolean_cached(paths: &[BezPath], op: figcraft_doc::BoolOp) -> BezPath {
let mut h = std::collections::hash_map::DefaultHasher::new();
(op as u8).hash(&mut h);
for p in paths {
hash_path(p, &mut h);
9u8.hash(&mut h);
}
let key = h.finish();
if let Some(p) = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return (**p).clone();
}
let out = figcraft_pathops::boolean(paths, op).unwrap_or_default();
let mut c = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 2048 {
c.clear();
}
c.insert(key, Arc::new(out.clone()));
out
}
fn device_rect(xf: Affine, r: Rect) -> Rect {
figcraft_geom::transform_rect_bbox(xf, r)
}
+4 -1
View File
@@ -7,6 +7,7 @@
pub mod backdrop;
pub mod blur;
pub mod boolean;
pub mod draw;
pub mod fx;
pub mod glass;
@@ -17,7 +18,7 @@ pub mod paint;
pub mod retained;
pub mod shadow;
pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};
@@ -376,6 +377,8 @@ mod backdrop_tests;
#[cfg(test)]
mod band_tests;
#[cfg(test)]
mod boolean_cache_tests;
#[cfg(test)]
mod effect_order_tests;
#[cfg(test)]
mod grain_tests;