diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index b78add32b7..58bb9d26db 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -703,9 +703,9 @@ #_:clj-kondo/ignore (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] - :or {ignore-constraints false ignore-snap-pixel false} + :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil} :as params}] (let [modif-tree (without-nil-ids modif-tree)] (ptk/reify ::set-wasm-modifiers @@ -756,7 +756,7 @@ root-modifiers :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)))] (when wasm-ready? (wasm.api/set-modifiers modifiers)) @@ -831,10 +831,8 @@ ;; primaries and descendants would snap back to their ;; pre-drag positions on drop. ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. + ;; Only without `snap-pixel?`: the delta that lands + ;; the shape on the pixel grid is known to WASM alone. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -864,7 +862,7 @@ geometry-entries)) :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis))) ignore-tree (calculate-ignore-tree-wasm transforms objects) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index aa43f69c1d..5db0783294 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -592,12 +592,14 @@ (rx/merge (->> angle-stream (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)) (->> angle-stream (rx/take-until stopper) (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))) @@ -638,7 +640,9 @@ modif-tree (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) (:current-page-id state)) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cdb7e04ef9..64d19c56d8 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2122,13 +2122,34 @@ (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 "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) or `entries` is empty, returns `[]` so callers never receive `nil` (which would trip `set-modifiers`' vector assert)." - [entries pixel-precision] + [entries snap-pixel? snap-ignore-axis] (if-not (and (initialized?) (not ^boolean (empty? entries))) [] (let [heapf32 (mem/get-heap-f32) @@ -2146,7 +2167,8 @@ offset 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)) length (aget heapu32 offset) max-offset (+ offset 1 (* length MODIFIER-U32-SIZE)) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index a235d83915..82d4f8ab99 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -58,7 +58,7 @@ This effectively tells the caller \"apply exactly the transform that was requested\", which is what the real WASM engine does for simple moves / resizes without constraints." - [entries _pixel-precision] + [entries _snap-pixel? _snap-ignore-axis] (track! :propagate-modifiers) (when (d/not-empty? entries) (into [] diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs index 7e39bf91ee..e3e50ccecc 100644 --- a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -46,7 +46,7 @@ the real implementations." [] (set! wasm.api/propagate-modifiers - (fn [entries _pixel-precision] + (fn [entries _snap-pixel? _snap-ignore-axis] (swap! captured-geometry-entries into entries) (into [] (map (fn [[id data]] [id (:transform data)])) diff --git a/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs new file mode 100644 index 0000000000..37982a1f96 --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs @@ -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))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 2f111f71f5..d621c714ec 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -45,6 +45,7 @@ [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] [frontend-tests.logic.wasm-modifiers-nil-id-test] + [frontend-tests.logic.wasm-pixel-snap-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -154,6 +155,7 @@ 'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.update-position-test 'frontend-tests.logic.wasm-modifiers-nil-id-test + 'frontend-tests.logic.wasm-pixel-snap-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index da6289b289..1898ed7276 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -12,8 +12,8 @@ use common::GetBounds; use crate::error::Result; use crate::shapes; use crate::shapes::{ - ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry, - TransformEntrySource, Type, + ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape, + TransformEntry, TransformEntrySource, Type, }; use crate::state::{ShapesPoolRef, State}; use crate::uuid::Uuid; @@ -139,36 +139,84 @@ fn calculate_bool_bounds( Some(result) } -fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { - let tr = bounds.transform_matrix().unwrap_or_default(); - let tr_inv = tr.invert().unwrap_or_default(); +/// Which parts of the geometry a pixel-grid correction rounds: only the ones +/// the transform changes, so a move keeps its dimensions and a resize keeps +/// its anchored corner. +#[derive(PartialEq, Debug, Clone, Copy)] +struct SnapGeometry { + x: bool, + y: bool, + width: bool, + height: bool, +} - let x = bounds.min_x().round(); - let y = bounds.min_y().round(); +impl SnapGeometry { + /// 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(); - let height = bounds.height(); + fn resized(&self) -> bool { + self.width || self.height + } - let target_width = bounds.width().round(); - let target_height = bounds.height().round(); + fn any(&self) -> bool { + self.x || self.y || self.resized() + } +} - let scale_width = if width > 0.1 { - f32::max(0.01, target_width / width) +/// Rounds a transform so the parts of the shape the gesture changed land on +/// 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 { - 1.0 + bounds.min_x() }; - let scale_height = if height > 0.1 { - f32::max(0.01, target_height / height) + let y = if snap.y { + bounds.min_y().round() } else { - 1.0 + bounds.min_y() }; - 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); + if snap.resized() { + let tr = bounds.transform_matrix().unwrap_or_default(); + let tr_inv = tr.invert().unwrap_or_default(); + + let width = bounds.width(); + 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(); @@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { #[allow(clippy::too_many_arguments)] fn propagate_transform( entry: TransformEntry, - pixel_precision: bool, + pixel_precision: PixelPrecision, state: &State, entries: &mut VecDeque, bounds: &mut HashMap, @@ -286,8 +334,11 @@ fn propagate_transform( } } - if pixel_precision { - set_pixel_precision(&mut transform, &mut shape_bounds_after); + if pixel_precision.enabled() { + 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 { @@ -417,10 +468,15 @@ fn reflow_shape( 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( state: &State, modifiers: &[TransformEntry], - pixel_precision: bool, + pixel_precision: PixelPrecision, ) -> Result> { let mut entries: VecDeque<_> = modifiers .iter() @@ -567,6 +623,249 @@ mod tests { 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] fn test_group_bounds() { let parent_id = Uuid::new_v4(); diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs index b0ff2a52d0..98224a0e97 100644 --- a/render-wasm/src/shapes/transform.rs +++ b/render-wasm/src/shapes/transform.rs @@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet}; use crate::uuid::Uuid; 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 for PixelPrecision { + fn from(value: u8) -> Self { + match value { + 1 => PixelPrecision::Both, + 2 => PixelPrecision::OnlyX, + 3 => PixelPrecision::OnlyY, + _ => PixelPrecision::Disabled, + } + } +} + #[derive(PartialEq, Debug, Clone)] pub enum Modifier { - Transform(TransformEntry, bool), + Transform(TransformEntry, PixelPrecision), Reflow(Uuid, bool), } impl Modifier { 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 { - Modifier::Transform(TransformEntry::parent(id, transform), false) + Modifier::Transform( + TransformEntry::parent(id, transform), + PixelPrecision::Disabled, + ) } pub fn reflow(id: Uuid, force_reflow: bool) -> Self { Modifier::Reflow(id, force_reflow) diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index d09bb109b2..f39825ebaf 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -403,6 +403,20 @@ impl ShapesPoolImpl { /// 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 /// 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 { self.clean_shape_cache(); diff --git a/render-wasm/src/wasm/transforms.rs b/render-wasm/src/wasm/transforms.rs index 87989c545b..e8c92da247 100644 --- a/render-wasm/src/wasm/transforms.rs +++ b/render-wasm/src/wasm/transforms.rs @@ -76,7 +76,7 @@ impl From for TransformEntry { #[no_mangle] #[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 entries: Vec = bytes @@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> .collect::>>()?; 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)) }) }