Skip to content

Commit e8f18b0

Browse files
authored
Cache calculation of bounding boxes with transforms (#3287)
* Cache calculation of bounding boxes with transforms * Reuse bounding boxes across for the same rotation * Cleanup code and add documentation * Add tests
1 parent 8d3a8c2 commit e8f18b0

File tree

3 files changed

+307
-9
lines changed

3 files changed

+307
-9
lines changed

editor/src/messages/portfolio/document/utility_types/document_metadata.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,7 @@ impl DocumentMetadata {
154154
pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
155155
self.click_targets(layer)?
156156
.iter()
157-
.filter_map(|click_target| match click_target.target_type() {
158-
ClickTargetType::Subpath(subpath) => subpath.bounding_box_with_transform(transform),
159-
ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform),
160-
})
157+
.filter_map(|click_target| click_target.bounding_box_with_transform(transform))
161158
.reduce(Quad::combine_bounds)
162159
}
163160

node-graph/gcore/src/transform.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ pub trait Transform {
2121
let rotation = -rotation_matrix.mul_vec2(DVec2::X).angle_to(DVec2::X);
2222
if rotation == -0. { 0. } else { rotation }
2323
}
24+
25+
/// Detects if the transform contains skew by checking if the transformation matrix
26+
/// deviates from a pure rotation + uniform scale + translation.
27+
///
28+
/// Returns true if the matrix columns are not orthogonal or have different lengths,
29+
/// indicating the presence of skew or non-uniform scaling.
30+
fn has_skew(&self) -> bool {
31+
let mat2 = self.transform().matrix2;
32+
let col0 = mat2.x_axis;
33+
let col1 = mat2.y_axis;
34+
35+
const EPSILON: f64 = 1e-10;
36+
37+
// Check if columns are orthogonal (dot product should be ~0) and equal length
38+
// Non-orthogonal columns or different lengths indicate skew/non-uniform scaling
39+
col0.dot(col1).abs() > EPSILON || (col0.length() - col1.length()).abs() > EPSILON
40+
}
2441
}
2542

2643
pub trait TransformMut: Transform {

node-graph/gcore/src/vector/click_target.rs

Lines changed: 289 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
use std::sync::{Arc, RwLock};
2+
13
use super::algorithms::{bezpath_algorithms::bezpath_is_inside_bezpath, intersection::filtered_segment_intersections};
24
use super::misc::dvec2_to_point;
35
use crate::math::math_ext::QuadExt;
46
use crate::math::quad::Quad;
57
use crate::subpath::Subpath;
8+
use crate::transform::Transform;
69
use crate::vector::PointId;
710
use crate::vector::misc::point_to_dvec2;
811
use glam::{DAffine2, DMat2, DVec2};
912
use kurbo::{Affine, BezPath, ParamCurve, PathSeg, Shape};
1013

14+
type BoundingBox = Option<[DVec2; 2]>;
15+
1116
#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
1217
pub struct FreePoint {
1318
pub id: PointId,
@@ -30,12 +35,99 @@ pub enum ClickTargetType {
3035
FreePoint(FreePoint),
3136
}
3237

38+
/// Fixed-size ring buffer cache for rotated bounding boxes.
39+
///
40+
/// Stores up to 8 rotation angles and their corresponding bounding boxes to avoid
41+
/// recomputing expensive bezier curve bounds for repeated rotations. Uses 7-bit
42+
/// fingerprint hashing with MSB as presence flag for fast lookup.
43+
#[derive(Clone, Debug, Default)]
44+
struct BoundingBoxCache {
45+
/// Packed 7-bit fingerprints with MSB presence flags for cache lookup
46+
fingerprints: u64,
47+
/// (rotation_angle, cached_bounds) pairs
48+
elements: [(f64, BoundingBox); Self::CACHE_SIZE],
49+
/// Next position to write in ring buffer
50+
write_ptr: usize,
51+
}
52+
53+
impl BoundingBoxCache {
54+
/// Cache size - must be ≤ 8 since fingerprints is u64 (8 bytes, 1 byte per element)
55+
const CACHE_SIZE: usize = 8;
56+
const FINGERPRINT_BITS: u32 = 7;
57+
const PRESENCE_FLAG: u8 = 1 << Self::FINGERPRINT_BITS;
58+
59+
/// Generates a 7-bit fingerprint from rotation with MSB as presence flag
60+
fn rotation_fingerprint(rotation: f64) -> u8 {
61+
(rotation.to_bits() % (1 << Self::FINGERPRINT_BITS)) as u8 | Self::PRESENCE_FLAG
62+
}
63+
/// Attempts to find cached bounding box for the given rotation.
64+
/// Returns Some(bounds) if found, None if not cached.
65+
fn try_read(&self, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> Option<BoundingBox> {
66+
// Build bitmask of positions with matching fingerprints for vectorized comparison
67+
let mut mask: u8 = 0;
68+
for (i, fp) in (0..Self::CACHE_SIZE).zip(self.fingerprints.to_le_bytes()) {
69+
// Check MSB for presence and lower 7 bits for fingerprint match
70+
if fp == fingerprint {
71+
mask |= 1 << i;
72+
}
73+
}
74+
// Check each position with matching fingerprint for exact rotation match
75+
while mask != 0 {
76+
let pos = mask.trailing_zeros() as usize;
77+
78+
if rotation == self.elements[pos].0 {
79+
// Found cached rotation - apply scale and translation to cached bounds
80+
let transform = DAffine2::from_scale_angle_translation(scale, 0., translation);
81+
let new_bounds = self.elements[pos].1.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]);
82+
83+
return Some(new_bounds);
84+
}
85+
mask &= !(1 << pos);
86+
}
87+
None
88+
}
89+
/// Computes and caches bounding box for the given rotation, then applies scale/translation.
90+
/// Returns the final transformed bounds.
91+
fn add_to_cache(&mut self, subpath: &Subpath<PointId>, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
92+
// Compute bounds for pure rotation (expensive operation we want to cache)
93+
let bounds = subpath.bounding_box_with_transform(DAffine2::from_angle(rotation));
94+
95+
if bounds.is_none() {
96+
return bounds;
97+
}
98+
99+
// Store in ring buffer at current write position
100+
let write_ptr = self.write_ptr;
101+
self.elements[write_ptr] = (rotation, bounds);
102+
103+
// Update fingerprint byte for this position
104+
let mut bytes = self.fingerprints.to_le_bytes();
105+
bytes[write_ptr] = fingerprint;
106+
self.fingerprints = u64::from_le_bytes(bytes);
107+
108+
// Advance write pointer (ring buffer behavior)
109+
self.write_ptr = (write_ptr + 1) % Self::CACHE_SIZE;
110+
111+
// Apply scale and translation to cached rotated bounds
112+
let transform = DAffine2::from_scale_angle_translation(scale, 0., translation);
113+
bounds.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)])
114+
}
115+
}
116+
33117
/// Represents a clickable target for the layer
34-
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
118+
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
35119
pub struct ClickTarget {
36120
target_type: ClickTargetType,
37121
stroke_width: f64,
38-
bounding_box: Option<[DVec2; 2]>,
122+
bounding_box: BoundingBox,
123+
#[serde(skip)]
124+
bounding_box_cache: Arc<RwLock<BoundingBoxCache>>,
125+
}
126+
127+
impl PartialEq for ClickTarget {
128+
fn eq(&self, other: &Self) -> bool {
129+
self.target_type == other.target_type && self.stroke_width == other.stroke_width && self.bounding_box == other.bounding_box
130+
}
39131
}
40132

41133
impl ClickTarget {
@@ -45,6 +137,7 @@ impl ClickTarget {
45137
target_type: ClickTargetType::Subpath(subpath),
46138
stroke_width,
47139
bounding_box,
140+
bounding_box_cache: Default::default(),
48141
}
49142
}
50143

@@ -60,23 +153,52 @@ impl ClickTarget {
60153
target_type: ClickTargetType::FreePoint(point),
61154
stroke_width,
62155
bounding_box,
156+
bounding_box_cache: Default::default(),
63157
}
64158
}
65159

66160
pub fn target_type(&self) -> &ClickTargetType {
67161
&self.target_type
68162
}
69163

70-
pub fn bounding_box(&self) -> Option<[DVec2; 2]> {
164+
pub fn bounding_box(&self) -> BoundingBox {
71165
self.bounding_box
72166
}
73167

74168
pub fn bounding_box_center(&self) -> Option<DVec2> {
75169
self.bounding_box.map(|bbox| bbox[0] + (bbox[1] - bbox[0]) / 2.)
76170
}
77171

78-
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
79-
self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)])
172+
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> BoundingBox {
173+
match self.target_type {
174+
ClickTargetType::Subpath(ref subpath) => {
175+
// Bypass cache for skewed transforms since rotation decomposition isn't valid
176+
if transform.has_skew() {
177+
return subpath.bounding_box_with_transform(transform);
178+
}
179+
180+
// Decompose transform into rotation, scale, translation for caching strategy
181+
let rotation = transform.decompose_rotation();
182+
let scale = transform.decompose_scale();
183+
let translation = transform.translation;
184+
185+
// Generate fingerprint for cache lookup
186+
let fingerprint = BoundingBoxCache::rotation_fingerprint(rotation);
187+
188+
// Try to read from cache first
189+
let read_lock = self.bounding_box_cache.read().unwrap();
190+
if let Some(value) = read_lock.try_read(rotation, scale, translation, fingerprint) {
191+
return value;
192+
}
193+
std::mem::drop(read_lock);
194+
195+
// Cache miss - compute and store new entry
196+
let mut write_lock = self.bounding_box_cache.write().unwrap();
197+
write_lock.add_to_cache(subpath, rotation, scale, translation, fingerprint)
198+
}
199+
// TODO: use point for calculation of bbox
200+
ClickTargetType::FreePoint(_) => self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]),
201+
}
80202
}
81203

82204
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
@@ -170,3 +292,165 @@ impl ClickTarget {
170292
}
171293
}
172294
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::*;
299+
use crate::subpath::Subpath;
300+
use glam::DVec2;
301+
use std::f64::consts::PI;
302+
303+
#[test]
304+
fn test_bounding_box_cache_fingerprint_generation() {
305+
// Test that fingerprints have MSB set and use only 7 bits for data
306+
let rotation1 = 0.0;
307+
let rotation2 = PI / 3.0;
308+
let rotation3 = PI / 2.0;
309+
310+
let fp1 = BoundingBoxCache::rotation_fingerprint(rotation1);
311+
let fp2 = BoundingBoxCache::rotation_fingerprint(rotation2);
312+
let fp3 = BoundingBoxCache::rotation_fingerprint(rotation3);
313+
314+
// All fingerprints should have MSB set (presence flag)
315+
assert_eq!(fp1 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
316+
assert_eq!(fp2 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
317+
assert_eq!(fp3 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
318+
319+
// Lower 7 bits should contain the actual fingerprint data
320+
let data1 = fp1 & !BoundingBoxCache::PRESENCE_FLAG;
321+
let data2 = fp2 & !BoundingBoxCache::PRESENCE_FLAG;
322+
let data3 = fp3 & !BoundingBoxCache::PRESENCE_FLAG;
323+
324+
// Data portions should be different (unless collision)
325+
assert!(data1 != data2 && data2 != data3 && data3 != data1);
326+
}
327+
328+
#[test]
329+
fn test_bounding_box_cache_basic_operations() {
330+
let mut cache = BoundingBoxCache::default();
331+
332+
// Create a simple rectangle subpath for testing
333+
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
334+
335+
let rotation = PI / 4.0;
336+
let scale = DVec2::new(2.0, 2.0);
337+
let translation = DVec2::new(10.0, 20.0);
338+
let fingerprint = BoundingBoxCache::rotation_fingerprint(rotation);
339+
340+
// Cache should be empty initially
341+
assert!(cache.try_read(rotation, scale, translation, fingerprint).is_none());
342+
343+
// Add to cache
344+
let result = cache.add_to_cache(&subpath, rotation, scale, translation, fingerprint);
345+
assert!(result.is_some());
346+
347+
// Should now be able to read from cache
348+
let cached = cache.try_read(rotation, scale, translation, fingerprint);
349+
assert!(cached.is_some());
350+
assert_eq!(cached.unwrap(), result);
351+
}
352+
353+
#[test]
354+
fn test_bounding_box_cache_ring_buffer_behavior() {
355+
let mut cache = BoundingBoxCache::default();
356+
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(10.0, 10.0));
357+
let scale = DVec2::ONE;
358+
let translation = DVec2::ZERO;
359+
360+
// Fill cache beyond capacity to test ring buffer behavior
361+
let rotations: Vec<f64> = (0..10).map(|i| i as f64 * PI / 8.0).collect();
362+
363+
for rotation in &rotations {
364+
let fingerprint = BoundingBoxCache::rotation_fingerprint(*rotation);
365+
cache.add_to_cache(&subpath, *rotation, scale, translation, fingerprint);
366+
}
367+
368+
// First two entries should be overwritten (cache size is 8)
369+
let first_fp = BoundingBoxCache::rotation_fingerprint(rotations[0]);
370+
let second_fp = BoundingBoxCache::rotation_fingerprint(rotations[1]);
371+
let last_fp = BoundingBoxCache::rotation_fingerprint(rotations[9]);
372+
373+
assert!(cache.try_read(rotations[0], scale, translation, first_fp).is_none());
374+
assert!(cache.try_read(rotations[1], scale, translation, second_fp).is_none());
375+
assert!(cache.try_read(rotations[9], scale, translation, last_fp).is_some());
376+
}
377+
378+
#[test]
379+
fn test_click_target_bounding_box_caching() {
380+
// Create a click target with a simple rectangle
381+
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
382+
let click_target = ClickTarget::new_with_subpath(subpath, 1.0);
383+
384+
let rotation = PI / 6.0;
385+
let scale = DVec2::new(1.5, 1.5);
386+
let translation = DVec2::new(20.0, 30.0);
387+
let transform = DAffine2::from_scale_angle_translation(scale, rotation, translation);
388+
389+
// Helper function to count present values in cache
390+
let count_present_values = || {
391+
let cache = click_target.bounding_box_cache.read().unwrap();
392+
cache.fingerprints.to_le_bytes().iter().filter(|&&fp| fp & BoundingBoxCache::PRESENCE_FLAG != 0).count()
393+
};
394+
395+
// Initially cache should be empty
396+
assert_eq!(count_present_values(), 0);
397+
398+
// First call should compute and cache
399+
let result1 = click_target.bounding_box_with_transform(transform);
400+
assert!(result1.is_some());
401+
assert_eq!(count_present_values(), 1);
402+
403+
// Second call with same transform should use cache, not add new entry
404+
let result2 = click_target.bounding_box_with_transform(transform);
405+
assert_eq!(result1, result2);
406+
assert_eq!(count_present_values(), 1); // Should still be 1, not 2
407+
408+
// Different scale/translation but same rotation should use cached rotation
409+
let transform2 = DAffine2::from_scale_angle_translation(DVec2::new(2.0, 2.0), rotation, DVec2::new(50.0, 60.0));
410+
let result3 = click_target.bounding_box_with_transform(transform2);
411+
assert!(result3.is_some());
412+
assert_ne!(result1, result3); // Different due to different scale/translation
413+
assert_eq!(count_present_values(), 1); // Should still be 1, reused same rotation
414+
}
415+
416+
#[test]
417+
fn test_click_target_skew_bypass_cache() {
418+
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
419+
let click_target = ClickTarget::new_with_subpath(subpath.clone(), 1.0);
420+
421+
// Create a transform with skew (non-uniform scaling in different directions)
422+
let skew_transform = DAffine2::from_cols_array(&[2.0, 0.5, 0.0, 1.0, 10.0, 20.0]);
423+
assert!(skew_transform.has_skew());
424+
425+
// Should bypass cache and compute directly
426+
let result = click_target.bounding_box_with_transform(skew_transform);
427+
let expected = subpath.bounding_box_with_transform(skew_transform);
428+
assert_eq!(result, expected);
429+
}
430+
431+
#[test]
432+
fn test_cache_fingerprint_collision_handling() {
433+
let mut cache = BoundingBoxCache::default();
434+
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(10.0, 10.0));
435+
let scale = DVec2::ONE;
436+
let translation = DVec2::ZERO;
437+
438+
// Find two rotations that produce the same fingerprint (collision)
439+
let rotation1 = 0.0;
440+
let rotation2 = 0.25;
441+
let fp1 = BoundingBoxCache::rotation_fingerprint(rotation1);
442+
let fp2 = BoundingBoxCache::rotation_fingerprint(rotation2);
443+
444+
// If we found a collision, test that exact rotation matching still works
445+
if fp1 == fp2 && rotation1 != rotation2 {
446+
// Add first rotation
447+
cache.add_to_cache(&subpath, rotation1, scale, translation, fp1);
448+
449+
// Should find the exact rotation
450+
assert!(cache.try_read(rotation1, scale, translation, fp1).is_some());
451+
452+
// Should not find the colliding rotation (different exact value)
453+
assert!(cache.try_read(rotation2, scale, translation, fp2).is_none());
454+
}
455+
}
456+
}

0 commit comments

Comments
 (0)