🐛 Fix problems with pixel precission in WASM renderer (#11560)

This commit is contained in:
Alonso Torres 2026-09-09 15:11:58 +02:00 committed by GitHub
parent d6e82f44a6
commit bf2fcba3be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 532 additions and 47 deletions

View File

@ -703,9 +703,9 @@
#_:clj-kondo/ignore #_:clj-kondo/ignore
(defn set-wasm-modifiers (defn set-wasm-modifiers
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis
subtree-ids-by-id selection-rect-cache] subtree-ids-by-id selection-rect-cache]
:or {ignore-constraints false ignore-snap-pixel false} :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil}
:as params}] :as params}]
(let [modif-tree (without-nil-ids modif-tree)] (let [modif-tree (without-nil-ids modif-tree)]
(ptk/reify ::set-wasm-modifiers (ptk/reify ::set-wasm-modifiers
@ -756,7 +756,7 @@
root-modifiers root-modifiers
:else :else
(let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)]
(if (seq propagated) propagated root-modifiers)))] (if (seq propagated) propagated root-modifiers)))]
(when wasm-ready? (when wasm-ready?
(wasm.api/set-modifiers modifiers)) (wasm.api/set-modifiers modifiers))
@ -831,10 +831,8 @@
;; primaries and descendants would snap back to their ;; primaries and descendants would snap back to their
;; pre-drag positions on drop. ;; pre-drag positions on drop.
;; ;;
;; Skipped when `snap-pixel?` is on: WASM applies ;; Only without `snap-pixel?`: the delta that lands
;; per-shape pixel correction (different scale/translate ;; the shape on the pixel grid is known to WASM alone.
;; per descendant) which we can't replicate cheaply on
;; the CLJS side.
(reduce (reduce
(fn [acc [id data]] (fn [acc [id data]]
(let [t (:transform data) (let [t (:transform data)
@ -864,7 +862,7 @@
geometry-entries)) geometry-entries))
:else :else
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)))
ignore-tree ignore-tree
(calculate-ignore-tree-wasm transforms objects) (calculate-ignore-tree-wasm transforms objects)

View File

@ -592,12 +592,14 @@
(rx/merge (rx/merge
(->> angle-stream (->> angle-stream
(rx/sample mconst/rotation-sample-time) (rx/sample mconst/rotation-sample-time)
(rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center))) (rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)
:ignore-snap-pixel true))
(rx/take-until stopper)) (rx/take-until stopper))
(->> angle-stream (->> angle-stream
(rx/take-until stopper) (rx/take-until stopper)
(rx/last) (rx/last)
(rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center))))) (rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)
:ignore-snap-pixel true))))
(rx/of (finish-transform))) (rx/of (finish-transform)))
@ -638,7 +640,9 @@
modif-tree modif-tree
(dwm/build-modif-tree ids objects get-modifier)] (dwm/build-modif-tree ids objects get-modifier)]
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options)))) (rx/of (dwm/apply-wasm-modifiers modif-tree
:ignore-touched (:ignore-touched options)
:ignore-snap-pixel true)))
(let [page-id (or (:page-id options) (let [page-id (or (:page-id options)
(:current-page-id state)) (:current-page-id state))

View File

@ -2122,13 +2122,34 @@
(h/call wasm/internal-module "_set_structure_modifiers")))) (h/call wasm/internal-module "_set_structure_modifiers"))))
;; Axes the pixel grid rounds, as `propagate_modifiers` expects them.
(def ^:private pixel-precision
{:disabled 0
:both 1
:only-x 2
:only-y 3})
(defn- pixel-precision-mode
"Encodes the pixel grid snapping for the renderer. `snap-ignore-axis`
names the axis to leave alone (`:x`, `:y` or nil)."
[snap-pixel? snap-ignore-axis]
(pixel-precision
(cond
(not snap-pixel?) :disabled
(= :x snap-ignore-axis) :only-y
(= :y snap-ignore-axis) :only-x
:else :both)))
(defn propagate-modifiers (defn propagate-modifiers
"Propagates geometry modifiers through the WASM shape tree. "Propagates geometry modifiers through the WASM shape tree.
Rounds the resulting geometry to the pixel grid when `snap-pixel?` is set,
skipping the axis named by `snap-ignore-axis` (`:x`, `:y` or nil).
Always returns a vector. When the context is not ready (lost / mid-reload) Always returns a vector. When the context is not ready (lost / mid-reload)
or `entries` is empty, returns `[]` so callers never receive `nil` (which or `entries` is empty, returns `[]` so callers never receive `nil` (which
would trip `set-modifiers`' vector assert)." would trip `set-modifiers`' vector assert)."
[entries pixel-precision] [entries snap-pixel? snap-ignore-axis]
(if-not (and (initialized?) (not ^boolean (empty? entries))) (if-not (and (initialized?) (not ^boolean (empty? entries)))
[] []
(let [heapf32 (mem/get-heap-f32) (let [heapf32 (mem/get-heap-f32)
@ -2146,7 +2167,8 @@
offset offset
entries) entries)
(let [offset (-> (h/call wasm/internal-module "_propagate_modifiers" pixel-precision) (let [precision (pixel-precision-mode snap-pixel? snap-ignore-axis)
offset (-> (h/call wasm/internal-module "_propagate_modifiers" precision)
(mem/->offset-32)) (mem/->offset-32))
length (aget heapu32 offset) length (aget heapu32 offset)
max-offset (+ offset 1 (* length MODIFIER-U32-SIZE)) max-offset (+ offset 1 (* length MODIFIER-U32-SIZE))

View File

@ -58,7 +58,7 @@
This effectively tells the caller \"apply exactly the transform that This effectively tells the caller \"apply exactly the transform that
was requested\", which is what the real WASM engine does for simple was requested\", which is what the real WASM engine does for simple
moves / resizes without constraints." moves / resizes without constraints."
[entries _pixel-precision] [entries _snap-pixel? _snap-ignore-axis]
(track! :propagate-modifiers) (track! :propagate-modifiers)
(when (d/not-empty? entries) (when (d/not-empty? entries)
(into [] (into []

View File

@ -46,7 +46,7 @@
the real implementations." the real implementations."
[] []
(set! wasm.api/propagate-modifiers (set! wasm.api/propagate-modifiers
(fn [entries _pixel-precision] (fn [entries _snap-pixel? _snap-ignore-axis]
(swap! captured-geometry-entries into entries) (swap! captured-geometry-entries into entries)
(into [] (into []
(map (fn [[id data]] [id (:transform data)])) (map (fn [[id data]] [id (:transform data)]))

View File

@ -0,0 +1,104 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.wasm-pixel-snap-test
"Covers which pixel-grid snapping options reach the WASM renderer.
The rounding happens in Rust, so these tests assert on the arguments
crossing the bridge: which axis an axis-locked drag leaves alone, and
that rotation does not snap."
(:require
[app.common.geom.point :as gpt]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.render-wasm.api :as wasm.api]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]))
(def ^:private captured-snap-options
"One entry per `wasm.api/propagate-modifiers` call during a test."
(atom []))
(defn- install-capturing-spy!
"Records the snap options of every propagation. Must run after
`thw/setup-wasm-mocks!` so teardown restores the real implementation."
[]
(set! wasm.api/propagate-modifiers
(fn [entries snap-pixel? snap-ignore-axis]
(swap! captured-snap-options conj
{:snap-pixel? snap-pixel? :snap-ignore-axis snap-ignore-axis})
(into []
(map (fn [[id data]] [id (:transform data)]))
entries))))
(defn- enable-pixel-grid
[]
(fn [state]
(update state :workspace-layout conj :snap-pixel-grid)))
(t/use-fixtures :each
{:before (fn []
(cthi/reset-idmap!)
(reset! captured-snap-options [])
(thw/setup-wasm-mocks!)
(install-capturing-spy!))
:after (fn []
(thw/teardown-wasm-mocks!))})
(t/deftest axis-locked-move-tells-the-renderer-which-axis-to-ignore
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
modif-tree (dwm/create-modif-tree [(:id rect)]
(ctm/move-modifiers (gpt/point 5.2 0)))
events [(enable-pixel-grid)
(dwm/apply-wasm-modifiers modif-tree :snap-ignore-axis :y)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? true :snap-ignore-axis :y}]
@captured-snap-options)))))))
(t/deftest move-without-axis-lock-snaps-both-axes
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
modif-tree (dwm/create-modif-tree [(:id rect)]
(ctm/move-modifiers (gpt/point 5.2 3.7)))
events [(enable-pixel-grid)
(dwm/apply-wasm-modifiers modif-tree)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? true :snap-ignore-axis nil}]
@captured-snap-options)))))))
(t/deftest rotation-does-not-snap-to-the-pixel-grid
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
store (ths/setup-store file)
rect (cths/get-shape file :rect1)
events [(enable-pixel-grid)
(dwt/increase-rotation #{(:id rect)} 15)]]
(ths/run-store
store done events
(fn [_new-state]
(t/is (= [{:snap-pixel? false :snap-ignore-axis nil}]
@captured-snap-options)))))))

View File

@ -45,6 +45,7 @@
[frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.sidebar-transform-coalescing-test]
[frontend-tests.logic.update-position-test] [frontend-tests.logic.update-position-test]
[frontend-tests.logic.wasm-modifiers-nil-id-test] [frontend-tests.logic.wasm-modifiers-nil-id-test]
[frontend-tests.logic.wasm-pixel-snap-test]
[frontend-tests.main-errors-test] [frontend-tests.main-errors-test]
[frontend-tests.plugins.comments-test] [frontend-tests.plugins.comments-test]
[frontend-tests.plugins.context-shapes-test] [frontend-tests.plugins.context-shapes-test]
@ -154,6 +155,7 @@
'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.sidebar-transform-coalescing-test
'frontend-tests.logic.update-position-test 'frontend-tests.logic.update-position-test
'frontend-tests.logic.wasm-modifiers-nil-id-test 'frontend-tests.logic.wasm-modifiers-nil-id-test
'frontend-tests.logic.wasm-pixel-snap-test
'frontend-tests.plugins.comments-test 'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.file-test 'frontend-tests.plugins.file-test

View File

@ -12,8 +12,8 @@ use common::GetBounds;
use crate::error::Result; use crate::error::Result;
use crate::shapes; use crate::shapes;
use crate::shapes::{ use crate::shapes::{
ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry, ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape,
TransformEntrySource, Type, TransformEntry, TransformEntrySource, Type,
}; };
use crate::state::{ShapesPoolRef, State}; use crate::state::{ShapesPoolRef, State};
use crate::uuid::Uuid; use crate::uuid::Uuid;
@ -139,36 +139,84 @@ fn calculate_bool_bounds(
Some(result) Some(result)
} }
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { /// Which parts of the geometry a pixel-grid correction rounds: only the ones
let tr = bounds.transform_matrix().unwrap_or_default(); /// the transform changes, so a move keeps its dimensions and a resize keeps
let tr_inv = tr.invert().unwrap_or_default(); /// its anchored corner.
#[derive(PartialEq, Debug, Clone, Copy)]
struct SnapGeometry {
x: bool,
y: bool,
width: bool,
height: bool,
}
let x = bounds.min_x().round(); impl SnapGeometry {
let y = bounds.min_y().round(); /// Flags the properties that differ between the two bounds. The axis mask
/// in `precision` applies to the position only.
fn new(before: &Bounds, after: &Bounds, precision: PixelPrecision) -> Self {
SnapGeometry {
x: precision.rounds_x() && !is_close_to(before.min_x(), after.min_x()),
y: precision.rounds_y() && !is_close_to(before.min_y(), after.min_y()),
width: !is_close_to(before.width(), after.width()),
height: !is_close_to(before.height(), after.height()),
}
}
let width = bounds.width(); fn resized(&self) -> bool {
let height = bounds.height(); self.width || self.height
}
let target_width = bounds.width().round(); fn any(&self) -> bool {
let target_height = bounds.height().round(); self.x || self.y || self.resized()
}
}
let scale_width = if width > 0.1 { /// Rounds a transform so the parts of the shape the gesture changed land on
f32::max(0.01, target_width / width) /// the pixel grid, leaving everything else exactly where it is.
fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds, snap: SnapGeometry) {
// Target corner, taken before the size correction: that correction scales
// about the bounds center, and the translation below undoes the corner
// displacement it causes. An unsnapped axis targets its own value.
let x = if snap.x {
bounds.min_x().round()
} else { } else {
1.0 bounds.min_x()
}; };
let scale_height = if height > 0.1 { let y = if snap.y {
f32::max(0.01, target_height / height) bounds.min_y().round()
} else { } else {
1.0 bounds.min_y()
}; };
if f32::is_finite(scale_width) && f32::is_finite(scale_height) { if snap.resized() {
let mut round_transform = Matrix::scale((scale_width, scale_height)); let tr = bounds.transform_matrix().unwrap_or_default();
round_transform.post_concat(&tr); let tr_inv = tr.invert().unwrap_or_default();
round_transform.pre_concat(&tr_inv);
transform.post_concat(&round_transform); let width = bounds.width();
bounds.transform_mut(&round_transform); let height = bounds.height();
// A rounded dimension is never smaller than one pixel.
let target_width = f32::max(1.0, width.round());
let target_height = f32::max(1.0, height.round());
let scale_width = if snap.width && width > 0.1 {
f32::max(0.01, target_width / width)
} else {
1.0
};
let scale_height = if snap.height && height > 0.1 {
f32::max(0.01, target_height / height)
} else {
1.0
};
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
let mut round_transform = Matrix::scale((scale_width, scale_height));
round_transform.post_concat(&tr);
round_transform.pre_concat(&tr_inv);
transform.post_concat(&round_transform);
bounds.transform_mut(&round_transform);
}
} }
let dx = x - bounds.min_x(); let dx = x - bounds.min_x();
@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn propagate_transform( fn propagate_transform(
entry: TransformEntry, entry: TransformEntry,
pixel_precision: bool, pixel_precision: PixelPrecision,
state: &State, state: &State,
entries: &mut VecDeque<Modifier>, entries: &mut VecDeque<Modifier>,
bounds: &mut HashMap<Uuid, Bounds>, bounds: &mut HashMap<Uuid, Bounds>,
@ -286,8 +334,11 @@ fn propagate_transform(
} }
} }
if pixel_precision { if pixel_precision.enabled() {
set_pixel_precision(&mut transform, &mut shape_bounds_after); let snap = SnapGeometry::new(&shape_bounds_before, &shape_bounds_after, pixel_precision);
if snap.any() {
set_pixel_precision(&mut transform, &mut shape_bounds_after, snap);
}
} }
if entry.propagate { if entry.propagate {
@ -417,10 +468,15 @@ fn reflow_shape(
Ok(()) Ok(())
} }
/// Propagates a set of transforms through the shape tree, returning one
/// transform per affected shape.
///
/// The transforms are relative to the committed geometry, so callers clear
/// any transform modifier of their own before propagating.
pub fn propagate_modifiers( pub fn propagate_modifiers(
state: &State, state: &State,
modifiers: &[TransformEntry], modifiers: &[TransformEntry],
pixel_precision: bool, pixel_precision: PixelPrecision,
) -> Result<Vec<TransformEntry>> { ) -> Result<Vec<TransformEntry>> {
let mut entries: VecDeque<_> = modifiers let mut entries: VecDeque<_> = modifiers
.iter() .iter()
@ -567,6 +623,249 @@ mod tests {
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
} }
#[test]
fn test_pixel_precision_move_keeps_size() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 3.7)));
let mut transform = Matrix::translate((5.2, 3.7));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.width(), 100.5));
assert!(is_close_to(bounds_after.height(), 50.3));
assert!(is_close_to(bounds_after.min_x(), 16.0));
assert!(is_close_to(bounds_after.min_y(), 24.0));
assert!(math::is_move_only_matrix(&transform));
}
#[test]
fn test_pixel_precision_resize_rounds_size() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::scale((1.1, 1.1)));
let mut transform = Matrix::scale((1.1, 1.1));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(
bounds_after.width(),
bounds_after.width().round()
));
assert!(is_close_to(
bounds_after.height(),
bounds_after.height().round()
));
assert!(is_close_to(
bounds_after.min_x(),
bounds_after.min_x().round()
));
assert!(is_close_to(
bounds_after.min_y(),
bounds_after.min_y().round()
));
}
#[test]
fn test_propagate_pixel_precision_move_only_rounds_position() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
let entry = TransformEntry::from_input(shape_id, Matrix::translate((5.2, 3.7)));
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let shape = state.shapes.get(&shape_id).unwrap();
let bounds = shape.bounds().transform(&transform);
assert!(is_close_to(bounds.width(), 100.5));
assert!(is_close_to(bounds.height(), 50.3));
assert!(is_close_to(bounds.min_x(), 16.0));
assert!(is_close_to(bounds.min_y(), 24.0));
}
#[test]
fn test_propagate_pixel_precision_resize_keeps_anchored_corner() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.4, 70.6);
}
// Drag the bottom-right corner in small steps: the top-left corner
// stays put on every step.
for step in 1..40 {
let delta = step as f32 * 0.05;
let mut resize = Matrix::scale(((100.0 + delta) / 100.0, (50.0 + delta) / 50.0));
resize.post_translate(Point::new(10.4, 20.6));
resize.pre_translate(Point::new(-10.4, -20.6));
let entry = TransformEntry::from_input(shape_id, resize);
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let shape = state.shapes.get(&shape_id).unwrap();
let bounds = shape.bounds().transform(&transform);
assert!(
is_close_to(bounds.min_x(), 10.4) && is_close_to(bounds.min_y(), 20.6),
"corner moved to ({}, {}) at delta {}",
bounds.min_x(),
bounds.min_y(),
delta
);
assert!(is_close_to(bounds.width(), bounds.width().round()));
assert!(is_close_to(bounds.height(), bounds.height().round()));
}
}
#[test]
fn test_pixel_precision_only_x_leaves_y_untouched() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 0.0)));
let mut transform = Matrix::translate((5.2, 0.0));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyX);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.min_x(), 16.0));
assert!(is_close_to(bounds_after.min_y(), 20.6));
}
#[test]
fn test_pixel_precision_only_y_leaves_x_untouched() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
let mut bounds_after = bounds.transform(&Matrix::translate((0.0, 3.7)));
let mut transform = Matrix::translate((0.0, 3.7));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyY);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.min_x(), 10.4));
assert!(is_close_to(bounds_after.min_y(), 24.0));
}
#[test]
fn test_pixel_precision_resize_never_rounds_below_one_pixel() {
let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.0, 20.0, 0.4, 0.3));
let mut bounds_after = bounds.transform(&Matrix::scale((1.5, 1.5)));
let mut transform = Matrix::scale((1.5, 1.5));
let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
set_pixel_precision(&mut transform, &mut bounds_after, snap);
assert!(is_close_to(bounds_after.width(), 1.0));
assert!(is_close_to(bounds_after.height(), 1.0));
}
#[test]
fn test_propagate_pixel_precision_snaps_every_frame_of_a_gesture() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
// One frame of a drag, as the entry point runs it: clear the
// modifiers, propagate the delta accumulated since the gesture
// started, then push the result back as the active modifier, which is
// what the renderer draws.
let frame = |state: &mut State, delta: f32| {
state.shapes.clear_transform_modifiers();
let entry = TransformEntry::from_input(shape_id, Matrix::translate((delta, delta)));
let result = propagate_modifiers(state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let bounds = state
.shapes
.get_raw(&shape_id)
.unwrap()
.bounds()
.transform(&transform);
state.set_modifiers(HashMap::from([(shape_id, transform)]));
bounds
};
// Every frame lands on the pixel grid and keeps the size.
for step in 1..40 {
let bounds = frame(&mut state, step as f32 * 0.35);
assert!(
is_close_to(bounds.min_x(), bounds.min_x().round())
&& is_close_to(bounds.min_y(), bounds.min_y().round()),
"shape landed off the pixel grid at ({}, {}) on frame {}",
bounds.min_x(),
bounds.min_y(),
step
);
assert!(is_close_to(bounds.width(), 100.5));
assert!(is_close_to(bounds.height(), 50.3));
}
}
#[test]
fn test_propagate_pixel_precision_resize_only_rounds_the_changed_dimension() {
let shape_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let shape = state.shapes.add_shape(shape_id);
shape.set_selrect(10.4, 20.6, 110.9, 70.9);
}
// Drag the right edge: the width lands on the grid, the height and
// the top-left corner stay put.
let mut resize = Matrix::scale((103.3 / 100.5, 1.0));
resize.post_translate(Point::new(10.4, 20.6));
resize.pre_translate(Point::new(-10.4, -20.6));
let entry = TransformEntry::from_input(shape_id, resize);
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
let transform = result
.iter()
.find(|entry| entry.id == shape_id)
.map(|entry| entry.transform)
.unwrap();
let bounds = state
.shapes
.get_raw(&shape_id)
.unwrap()
.bounds()
.transform(&transform);
assert!(is_close_to(bounds.width(), 103.0));
assert!(is_close_to(bounds.height(), 50.3));
assert!(is_close_to(bounds.min_x(), 10.4));
assert!(is_close_to(bounds.min_y(), 20.6));
}
#[test] #[test]
fn test_group_bounds() { fn test_group_bounds() {
let parent_id = Uuid::new_v4(); let parent_id = Uuid::new_v4();

View File

@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet};
use crate::uuid::Uuid; use crate::uuid::Uuid;
use skia::Matrix; use skia::Matrix;
/// Axes the pixel grid rounds. An axis-locked drag rounds only the axis it
/// moves along.
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum PixelPrecision {
Disabled,
Both,
OnlyX,
OnlyY,
}
impl PixelPrecision {
pub fn enabled(&self) -> bool {
*self != PixelPrecision::Disabled
}
pub fn rounds_x(&self) -> bool {
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyX)
}
pub fn rounds_y(&self) -> bool {
matches!(self, PixelPrecision::Both | PixelPrecision::OnlyY)
}
}
impl From<u8> for PixelPrecision {
fn from(value: u8) -> Self {
match value {
1 => PixelPrecision::Both,
2 => PixelPrecision::OnlyX,
3 => PixelPrecision::OnlyY,
_ => PixelPrecision::Disabled,
}
}
}
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone)]
pub enum Modifier { pub enum Modifier {
Transform(TransformEntry, bool), Transform(TransformEntry, PixelPrecision),
Reflow(Uuid, bool), Reflow(Uuid, bool),
} }
impl Modifier { impl Modifier {
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self { pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::from_propagate(id, transform), false) Modifier::Transform(
TransformEntry::from_propagate(id, transform),
PixelPrecision::Disabled,
)
} }
pub fn parent(id: Uuid, transform: Matrix) -> Self { pub fn parent(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::parent(id, transform), false) Modifier::Transform(
TransformEntry::parent(id, transform),
PixelPrecision::Disabled,
)
} }
pub fn reflow(id: Uuid, force_reflow: bool) -> Self { pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
Modifier::Reflow(id, force_reflow) Modifier::Reflow(id, force_reflow)

View File

@ -403,6 +403,20 @@ impl ShapesPoolImpl {
/// gone, but if we don't touch their tiles they keep pointing at the /// gone, but if we don't touch their tiles they keep pointing at the
/// previous modified position and the tile texture cache may serve stale /// previous modified position and the tile texture cache may serve stale
/// pixels. /// pixels.
/// Drops the transform modifiers, keeping structure and scale-content
/// entries, so the pool serves committed geometry again. Called before
/// propagating a new set of transforms, which are relative to that
/// geometry.
pub fn clear_transform_modifiers(&mut self) {
if self.modifiers.is_empty() {
return;
}
self.clean_shape_cache();
self.modifiers = HashMap::default();
self.modifier_uuids.clear();
}
pub fn clean_all(&mut self) -> Vec<Uuid> { pub fn clean_all(&mut self) -> Vec<Uuid> {
self.clean_shape_cache(); self.clean_shape_cache();

View File

@ -76,7 +76,7 @@ impl From<RawTransformEntry> for TransformEntry {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> { pub extern "C" fn propagate_modifiers(pixel_precision: u8) -> Result<*mut u8> {
let bytes = mem::bytes(); let bytes = mem::bytes();
let entries: Vec<TransformEntry> = bytes let entries: Vec<TransformEntry> = bytes
@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8>
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
with_state!(state, { with_state!(state, {
let result = shapes::propagate_modifiers(state, &entries, pixel_precision)?; state.shapes.clear_transform_modifiers();
let result = shapes::propagate_modifiers(state, &entries, pixel_precision.into())?;
Ok(mem::write_vec(result)) Ok(mem::write_vec(result))
}) })
} }