mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 22:49:30 +00:00
🐛 Fix problems with pixel precission in WASM renderer (#11560)
This commit is contained in:
parent
d6e82f44a6
commit
bf2fcba3be
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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 []
|
||||||
|
|||||||
@ -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)]))
|
||||||
|
|||||||
104
frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs
Normal file
104
frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs
Normal 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)))))))
|
||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user