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
This commit is contained in:
parent
8d3a8c2c11
commit
e8f18b0ac0
|
|
@ -154,10 +154,7 @@ impl DocumentMetadata {
|
||||||
pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
|
pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
|
||||||
self.click_targets(layer)?
|
self.click_targets(layer)?
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|click_target| match click_target.target_type() {
|
.filter_map(|click_target| click_target.bounding_box_with_transform(transform))
|
||||||
ClickTargetType::Subpath(subpath) => subpath.bounding_box_with_transform(transform),
|
|
||||||
ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform),
|
|
||||||
})
|
|
||||||
.reduce(Quad::combine_bounds)
|
.reduce(Quad::combine_bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ pub trait Transform {
|
||||||
let rotation = -rotation_matrix.mul_vec2(DVec2::X).angle_to(DVec2::X);
|
let rotation = -rotation_matrix.mul_vec2(DVec2::X).angle_to(DVec2::X);
|
||||||
if rotation == -0. { 0. } else { rotation }
|
if rotation == -0. { 0. } else { rotation }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detects if the transform contains skew by checking if the transformation matrix
|
||||||
|
/// deviates from a pure rotation + uniform scale + translation.
|
||||||
|
///
|
||||||
|
/// Returns true if the matrix columns are not orthogonal or have different lengths,
|
||||||
|
/// indicating the presence of skew or non-uniform scaling.
|
||||||
|
fn has_skew(&self) -> bool {
|
||||||
|
let mat2 = self.transform().matrix2;
|
||||||
|
let col0 = mat2.x_axis;
|
||||||
|
let col1 = mat2.y_axis;
|
||||||
|
|
||||||
|
const EPSILON: f64 = 1e-10;
|
||||||
|
|
||||||
|
// Check if columns are orthogonal (dot product should be ~0) and equal length
|
||||||
|
// Non-orthogonal columns or different lengths indicate skew/non-uniform scaling
|
||||||
|
col0.dot(col1).abs() > EPSILON || (col0.length() - col1.length()).abs() > EPSILON
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait TransformMut: Transform {
|
pub trait TransformMut: Transform {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use super::algorithms::{bezpath_algorithms::bezpath_is_inside_bezpath, intersection::filtered_segment_intersections};
|
use super::algorithms::{bezpath_algorithms::bezpath_is_inside_bezpath, intersection::filtered_segment_intersections};
|
||||||
use super::misc::dvec2_to_point;
|
use super::misc::dvec2_to_point;
|
||||||
use crate::math::math_ext::QuadExt;
|
use crate::math::math_ext::QuadExt;
|
||||||
use crate::math::quad::Quad;
|
use crate::math::quad::Quad;
|
||||||
use crate::subpath::Subpath;
|
use crate::subpath::Subpath;
|
||||||
|
use crate::transform::Transform;
|
||||||
use crate::vector::PointId;
|
use crate::vector::PointId;
|
||||||
use crate::vector::misc::point_to_dvec2;
|
use crate::vector::misc::point_to_dvec2;
|
||||||
use glam::{DAffine2, DMat2, DVec2};
|
use glam::{DAffine2, DMat2, DVec2};
|
||||||
use kurbo::{Affine, BezPath, ParamCurve, PathSeg, Shape};
|
use kurbo::{Affine, BezPath, ParamCurve, PathSeg, Shape};
|
||||||
|
|
||||||
|
type BoundingBox = Option<[DVec2; 2]>;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct FreePoint {
|
pub struct FreePoint {
|
||||||
pub id: PointId,
|
pub id: PointId,
|
||||||
|
|
@ -30,12 +35,99 @@ pub enum ClickTargetType {
|
||||||
FreePoint(FreePoint),
|
FreePoint(FreePoint),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixed-size ring buffer cache for rotated bounding boxes.
|
||||||
|
///
|
||||||
|
/// Stores up to 8 rotation angles and their corresponding bounding boxes to avoid
|
||||||
|
/// recomputing expensive bezier curve bounds for repeated rotations. Uses 7-bit
|
||||||
|
/// fingerprint hashing with MSB as presence flag for fast lookup.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct BoundingBoxCache {
|
||||||
|
/// Packed 7-bit fingerprints with MSB presence flags for cache lookup
|
||||||
|
fingerprints: u64,
|
||||||
|
/// (rotation_angle, cached_bounds) pairs
|
||||||
|
elements: [(f64, BoundingBox); Self::CACHE_SIZE],
|
||||||
|
/// Next position to write in ring buffer
|
||||||
|
write_ptr: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundingBoxCache {
|
||||||
|
/// Cache size - must be ≤ 8 since fingerprints is u64 (8 bytes, 1 byte per element)
|
||||||
|
const CACHE_SIZE: usize = 8;
|
||||||
|
const FINGERPRINT_BITS: u32 = 7;
|
||||||
|
const PRESENCE_FLAG: u8 = 1 << Self::FINGERPRINT_BITS;
|
||||||
|
|
||||||
|
/// Generates a 7-bit fingerprint from rotation with MSB as presence flag
|
||||||
|
fn rotation_fingerprint(rotation: f64) -> u8 {
|
||||||
|
(rotation.to_bits() % (1 << Self::FINGERPRINT_BITS)) as u8 | Self::PRESENCE_FLAG
|
||||||
|
}
|
||||||
|
/// Attempts to find cached bounding box for the given rotation.
|
||||||
|
/// Returns Some(bounds) if found, None if not cached.
|
||||||
|
fn try_read(&self, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> Option<BoundingBox> {
|
||||||
|
// Build bitmask of positions with matching fingerprints for vectorized comparison
|
||||||
|
let mut mask: u8 = 0;
|
||||||
|
for (i, fp) in (0..Self::CACHE_SIZE).zip(self.fingerprints.to_le_bytes()) {
|
||||||
|
// Check MSB for presence and lower 7 bits for fingerprint match
|
||||||
|
if fp == fingerprint {
|
||||||
|
mask |= 1 << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check each position with matching fingerprint for exact rotation match
|
||||||
|
while mask != 0 {
|
||||||
|
let pos = mask.trailing_zeros() as usize;
|
||||||
|
|
||||||
|
if rotation == self.elements[pos].0 {
|
||||||
|
// Found cached rotation - apply scale and translation to cached bounds
|
||||||
|
let transform = DAffine2::from_scale_angle_translation(scale, 0., translation);
|
||||||
|
let new_bounds = self.elements[pos].1.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]);
|
||||||
|
|
||||||
|
return Some(new_bounds);
|
||||||
|
}
|
||||||
|
mask &= !(1 << pos);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
/// Computes and caches bounding box for the given rotation, then applies scale/translation.
|
||||||
|
/// Returns the final transformed bounds.
|
||||||
|
fn add_to_cache(&mut self, subpath: &Subpath<PointId>, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
|
||||||
|
// Compute bounds for pure rotation (expensive operation we want to cache)
|
||||||
|
let bounds = subpath.bounding_box_with_transform(DAffine2::from_angle(rotation));
|
||||||
|
|
||||||
|
if bounds.is_none() {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in ring buffer at current write position
|
||||||
|
let write_ptr = self.write_ptr;
|
||||||
|
self.elements[write_ptr] = (rotation, bounds);
|
||||||
|
|
||||||
|
// Update fingerprint byte for this position
|
||||||
|
let mut bytes = self.fingerprints.to_le_bytes();
|
||||||
|
bytes[write_ptr] = fingerprint;
|
||||||
|
self.fingerprints = u64::from_le_bytes(bytes);
|
||||||
|
|
||||||
|
// Advance write pointer (ring buffer behavior)
|
||||||
|
self.write_ptr = (write_ptr + 1) % Self::CACHE_SIZE;
|
||||||
|
|
||||||
|
// Apply scale and translation to cached rotated bounds
|
||||||
|
let transform = DAffine2::from_scale_angle_translation(scale, 0., translation);
|
||||||
|
bounds.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents a clickable target for the layer
|
/// Represents a clickable target for the layer
|
||||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ClickTarget {
|
pub struct ClickTarget {
|
||||||
target_type: ClickTargetType,
|
target_type: ClickTargetType,
|
||||||
stroke_width: f64,
|
stroke_width: f64,
|
||||||
bounding_box: Option<[DVec2; 2]>,
|
bounding_box: BoundingBox,
|
||||||
|
#[serde(skip)]
|
||||||
|
bounding_box_cache: Arc<RwLock<BoundingBoxCache>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for ClickTarget {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.target_type == other.target_type && self.stroke_width == other.stroke_width && self.bounding_box == other.bounding_box
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClickTarget {
|
impl ClickTarget {
|
||||||
|
|
@ -45,6 +137,7 @@ impl ClickTarget {
|
||||||
target_type: ClickTargetType::Subpath(subpath),
|
target_type: ClickTargetType::Subpath(subpath),
|
||||||
stroke_width,
|
stroke_width,
|
||||||
bounding_box,
|
bounding_box,
|
||||||
|
bounding_box_cache: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +153,7 @@ impl ClickTarget {
|
||||||
target_type: ClickTargetType::FreePoint(point),
|
target_type: ClickTargetType::FreePoint(point),
|
||||||
stroke_width,
|
stroke_width,
|
||||||
bounding_box,
|
bounding_box,
|
||||||
|
bounding_box_cache: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +161,7 @@ impl ClickTarget {
|
||||||
&self.target_type
|
&self.target_type
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bounding_box(&self) -> Option<[DVec2; 2]> {
|
pub fn bounding_box(&self) -> BoundingBox {
|
||||||
self.bounding_box
|
self.bounding_box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,8 +169,36 @@ impl ClickTarget {
|
||||||
self.bounding_box.map(|bbox| bbox[0] + (bbox[1] - bbox[0]) / 2.)
|
self.bounding_box.map(|bbox| bbox[0] + (bbox[1] - bbox[0]) / 2.)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
|
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> BoundingBox {
|
||||||
self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)])
|
match self.target_type {
|
||||||
|
ClickTargetType::Subpath(ref subpath) => {
|
||||||
|
// Bypass cache for skewed transforms since rotation decomposition isn't valid
|
||||||
|
if transform.has_skew() {
|
||||||
|
return subpath.bounding_box_with_transform(transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompose transform into rotation, scale, translation for caching strategy
|
||||||
|
let rotation = transform.decompose_rotation();
|
||||||
|
let scale = transform.decompose_scale();
|
||||||
|
let translation = transform.translation;
|
||||||
|
|
||||||
|
// Generate fingerprint for cache lookup
|
||||||
|
let fingerprint = BoundingBoxCache::rotation_fingerprint(rotation);
|
||||||
|
|
||||||
|
// Try to read from cache first
|
||||||
|
let read_lock = self.bounding_box_cache.read().unwrap();
|
||||||
|
if let Some(value) = read_lock.try_read(rotation, scale, translation, fingerprint) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
std::mem::drop(read_lock);
|
||||||
|
|
||||||
|
// Cache miss - compute and store new entry
|
||||||
|
let mut write_lock = self.bounding_box_cache.write().unwrap();
|
||||||
|
write_lock.add_to_cache(subpath, rotation, scale, translation, fingerprint)
|
||||||
|
}
|
||||||
|
// TODO: use point for calculation of bbox
|
||||||
|
ClickTargetType::FreePoint(_) => self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
|
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
|
||||||
|
|
@ -170,3 +292,165 @@ impl ClickTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::subpath::Subpath;
|
||||||
|
use glam::DVec2;
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bounding_box_cache_fingerprint_generation() {
|
||||||
|
// Test that fingerprints have MSB set and use only 7 bits for data
|
||||||
|
let rotation1 = 0.0;
|
||||||
|
let rotation2 = PI / 3.0;
|
||||||
|
let rotation3 = PI / 2.0;
|
||||||
|
|
||||||
|
let fp1 = BoundingBoxCache::rotation_fingerprint(rotation1);
|
||||||
|
let fp2 = BoundingBoxCache::rotation_fingerprint(rotation2);
|
||||||
|
let fp3 = BoundingBoxCache::rotation_fingerprint(rotation3);
|
||||||
|
|
||||||
|
// All fingerprints should have MSB set (presence flag)
|
||||||
|
assert_eq!(fp1 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
|
||||||
|
assert_eq!(fp2 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
|
||||||
|
assert_eq!(fp3 & BoundingBoxCache::PRESENCE_FLAG, BoundingBoxCache::PRESENCE_FLAG);
|
||||||
|
|
||||||
|
// Lower 7 bits should contain the actual fingerprint data
|
||||||
|
let data1 = fp1 & !BoundingBoxCache::PRESENCE_FLAG;
|
||||||
|
let data2 = fp2 & !BoundingBoxCache::PRESENCE_FLAG;
|
||||||
|
let data3 = fp3 & !BoundingBoxCache::PRESENCE_FLAG;
|
||||||
|
|
||||||
|
// Data portions should be different (unless collision)
|
||||||
|
assert!(data1 != data2 && data2 != data3 && data3 != data1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bounding_box_cache_basic_operations() {
|
||||||
|
let mut cache = BoundingBoxCache::default();
|
||||||
|
|
||||||
|
// Create a simple rectangle subpath for testing
|
||||||
|
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
|
||||||
|
|
||||||
|
let rotation = PI / 4.0;
|
||||||
|
let scale = DVec2::new(2.0, 2.0);
|
||||||
|
let translation = DVec2::new(10.0, 20.0);
|
||||||
|
let fingerprint = BoundingBoxCache::rotation_fingerprint(rotation);
|
||||||
|
|
||||||
|
// Cache should be empty initially
|
||||||
|
assert!(cache.try_read(rotation, scale, translation, fingerprint).is_none());
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
let result = cache.add_to_cache(&subpath, rotation, scale, translation, fingerprint);
|
||||||
|
assert!(result.is_some());
|
||||||
|
|
||||||
|
// Should now be able to read from cache
|
||||||
|
let cached = cache.try_read(rotation, scale, translation, fingerprint);
|
||||||
|
assert!(cached.is_some());
|
||||||
|
assert_eq!(cached.unwrap(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bounding_box_cache_ring_buffer_behavior() {
|
||||||
|
let mut cache = BoundingBoxCache::default();
|
||||||
|
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(10.0, 10.0));
|
||||||
|
let scale = DVec2::ONE;
|
||||||
|
let translation = DVec2::ZERO;
|
||||||
|
|
||||||
|
// Fill cache beyond capacity to test ring buffer behavior
|
||||||
|
let rotations: Vec<f64> = (0..10).map(|i| i as f64 * PI / 8.0).collect();
|
||||||
|
|
||||||
|
for rotation in &rotations {
|
||||||
|
let fingerprint = BoundingBoxCache::rotation_fingerprint(*rotation);
|
||||||
|
cache.add_to_cache(&subpath, *rotation, scale, translation, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First two entries should be overwritten (cache size is 8)
|
||||||
|
let first_fp = BoundingBoxCache::rotation_fingerprint(rotations[0]);
|
||||||
|
let second_fp = BoundingBoxCache::rotation_fingerprint(rotations[1]);
|
||||||
|
let last_fp = BoundingBoxCache::rotation_fingerprint(rotations[9]);
|
||||||
|
|
||||||
|
assert!(cache.try_read(rotations[0], scale, translation, first_fp).is_none());
|
||||||
|
assert!(cache.try_read(rotations[1], scale, translation, second_fp).is_none());
|
||||||
|
assert!(cache.try_read(rotations[9], scale, translation, last_fp).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_target_bounding_box_caching() {
|
||||||
|
// Create a click target with a simple rectangle
|
||||||
|
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
|
||||||
|
let click_target = ClickTarget::new_with_subpath(subpath, 1.0);
|
||||||
|
|
||||||
|
let rotation = PI / 6.0;
|
||||||
|
let scale = DVec2::new(1.5, 1.5);
|
||||||
|
let translation = DVec2::new(20.0, 30.0);
|
||||||
|
let transform = DAffine2::from_scale_angle_translation(scale, rotation, translation);
|
||||||
|
|
||||||
|
// Helper function to count present values in cache
|
||||||
|
let count_present_values = || {
|
||||||
|
let cache = click_target.bounding_box_cache.read().unwrap();
|
||||||
|
cache.fingerprints.to_le_bytes().iter().filter(|&&fp| fp & BoundingBoxCache::PRESENCE_FLAG != 0).count()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initially cache should be empty
|
||||||
|
assert_eq!(count_present_values(), 0);
|
||||||
|
|
||||||
|
// First call should compute and cache
|
||||||
|
let result1 = click_target.bounding_box_with_transform(transform);
|
||||||
|
assert!(result1.is_some());
|
||||||
|
assert_eq!(count_present_values(), 1);
|
||||||
|
|
||||||
|
// Second call with same transform should use cache, not add new entry
|
||||||
|
let result2 = click_target.bounding_box_with_transform(transform);
|
||||||
|
assert_eq!(result1, result2);
|
||||||
|
assert_eq!(count_present_values(), 1); // Should still be 1, not 2
|
||||||
|
|
||||||
|
// Different scale/translation but same rotation should use cached rotation
|
||||||
|
let transform2 = DAffine2::from_scale_angle_translation(DVec2::new(2.0, 2.0), rotation, DVec2::new(50.0, 60.0));
|
||||||
|
let result3 = click_target.bounding_box_with_transform(transform2);
|
||||||
|
assert!(result3.is_some());
|
||||||
|
assert_ne!(result1, result3); // Different due to different scale/translation
|
||||||
|
assert_eq!(count_present_values(), 1); // Should still be 1, reused same rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_target_skew_bypass_cache() {
|
||||||
|
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(100.0, 50.0));
|
||||||
|
let click_target = ClickTarget::new_with_subpath(subpath.clone(), 1.0);
|
||||||
|
|
||||||
|
// Create a transform with skew (non-uniform scaling in different directions)
|
||||||
|
let skew_transform = DAffine2::from_cols_array(&[2.0, 0.5, 0.0, 1.0, 10.0, 20.0]);
|
||||||
|
assert!(skew_transform.has_skew());
|
||||||
|
|
||||||
|
// Should bypass cache and compute directly
|
||||||
|
let result = click_target.bounding_box_with_transform(skew_transform);
|
||||||
|
let expected = subpath.bounding_box_with_transform(skew_transform);
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fingerprint_collision_handling() {
|
||||||
|
let mut cache = BoundingBoxCache::default();
|
||||||
|
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::new(10.0, 10.0));
|
||||||
|
let scale = DVec2::ONE;
|
||||||
|
let translation = DVec2::ZERO;
|
||||||
|
|
||||||
|
// Find two rotations that produce the same fingerprint (collision)
|
||||||
|
let rotation1 = 0.0;
|
||||||
|
let rotation2 = 0.25;
|
||||||
|
let fp1 = BoundingBoxCache::rotation_fingerprint(rotation1);
|
||||||
|
let fp2 = BoundingBoxCache::rotation_fingerprint(rotation2);
|
||||||
|
|
||||||
|
// If we found a collision, test that exact rotation matching still works
|
||||||
|
if fp1 == fp2 && rotation1 != rotation2 {
|
||||||
|
// Add first rotation
|
||||||
|
cache.add_to_cache(&subpath, rotation1, scale, translation, fp1);
|
||||||
|
|
||||||
|
// Should find the exact rotation
|
||||||
|
assert!(cache.try_read(rotation1, scale, translation, fp1).is_some());
|
||||||
|
|
||||||
|
// Should not find the colliding rotation (different exact value)
|
||||||
|
assert!(cache.try_read(rotation2, scale, translation, fp2).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue