diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index 58cd5d970f..96e71a5fcb 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -72,6 +72,13 @@ [:map {:title "PlainColorAttrs"} [:color schema:hex-color]]) +(def schema:image-transform + [:map {:title "ImageTransform" :closed true} + [:x {:optional true} ::sm/safe-number] + [:y {:optional true} ::sm/safe-number] + [:width {:optional true} ::sm/safe-number] + [:height {:optional true} ::sm/safe-number]]) + (def schema:image [:map {:title "ImageColor" :closed true} [:width [::sm/int {:min 0 :gen/gen sg/int}]] @@ -79,7 +86,8 @@ [:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text] [:id ::sm/uuid] [:name {:optional true} ::sm/text] - [:keep-aspect-ratio {:optional true} :boolean]]) + [:keep-aspect-ratio {:optional true} :boolean] + [:transform {:optional true} schema:image-transform]]) (def image-attrs "A set of attrs that corresponds to image data type" diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 32f806568f..2f8ca65a6f 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -119,12 +119,15 @@ (defn write-image-fill [offset buffer opacity image] - (let [image-id (get image :id) - image-width (get image :width) - image-height (get image :height) - alpha (mth/floor (* opacity 0xff)) - keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00) - flags (bit-or keep-aspect-ratio 0x00)] + (let [image-id (get image :id) + image-width (get image :width) + image-height (get image :height) + alpha (mth/floor (* opacity 0xff)) + keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00) + transform (get image :transform) + has-transform? (some? transform) + transform-flag (if has-transform? 0x02 0x00) + flags (bit-or keep-aspect-ratio transform-flag)] (buf/write-byte buffer (+ offset 0) 0x03) (buf/write-uuid buffer (+ offset 4) image-id) (buf/write-byte buffer (+ offset 20) alpha) @@ -132,6 +135,17 @@ (buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use) (buf/write-int buffer (+ offset 24) image-width) (buf/write-int buffer (+ offset 28) image-height) + (if has-transform? + (do + (buf/write-float buffer (+ offset 32) (double (get transform :x 0.0))) + (buf/write-float buffer (+ offset 36) (double (get transform :y 0.0))) + (buf/write-float buffer (+ offset 40) (double (get transform :width 1.0))) + (buf/write-float buffer (+ offset 44) (double (get transform :height 1.0)))) + (do + (buf/write-float buffer (+ offset 32) 0.0) + (buf/write-float buffer (+ offset 36) 0.0) + (buf/write-float buffer (+ offset 40) 1.0) + (buf/write-float buffer (+ offset 44) 1.0))) (+ offset FILL-U8-SIZE))) (defn- write-metadata @@ -208,28 +222,36 @@ :type type}}) 3 ;; image fill - (let [id (buf/read-uuid dbuffer (+ doffset 4)) - alpha (buf/read-unsigned-byte dbuffer (+ doffset 20)) - opacity (mth/precision (/ alpha 0xff) 2) - flags (buf/read-unsigned-byte dbuffer (+ doffset 21)) - ratio (boolean (bit-and flags 0x01)) - width (buf/read-int dbuffer (+ doffset 24)) - height (buf/read-int dbuffer (+ doffset 28)) - mtype (buf/read-short mbuffer (+ moffset 2)) - mtype (case mtype - 0x01 "image/jpeg" - 0x02 "image/png" - 0x03 "image/gif" - 0x04 "image/webp" - 0x05 "image/svg+xml")] + (let [id (buf/read-uuid dbuffer (+ doffset 4)) + alpha (buf/read-unsigned-byte dbuffer (+ doffset 20)) + opacity (mth/precision (/ alpha 0xff) 2) + flags (buf/read-unsigned-byte dbuffer (+ doffset 21)) + ratio (not (zero? (bit-and flags 0x01))) + has-tf (not (zero? (bit-and flags 0x02))) + width (buf/read-int dbuffer (+ doffset 24)) + height (buf/read-int dbuffer (+ doffset 28)) + transform (when has-tf + {:x (buf/read-float dbuffer (+ doffset 32)) + :y (buf/read-float dbuffer (+ doffset 36)) + :width (buf/read-float dbuffer (+ doffset 40)) + :height (buf/read-float dbuffer (+ doffset 44))}) + mtype (buf/read-short mbuffer (+ moffset 2)) + mtype (case mtype + 0x01 "image/jpeg" + 0x02 "image/png" + 0x03 "image/gif" + 0x04 "image/webp" + 0x05 "image/svg+xml")] {:fill-opacity opacity - :fill-image {:id id - :width width - :height height - :mtype mtype - :keep-aspect-ratio ratio - ;; FIXME: we are not encodign the name, looks useless - :name "sample"}}))] + :fill-image (cond-> {:id id + :width width + :height height + :mtype mtype + :keep-aspect-ratio ratio + ;; FIXME: we are not encodign the name, looks useless + :name "sample"} + (some? transform) + (assoc :transform transform))}))] (if refs? (let [ref-file (buf/read-uuid mbuffer (+ moffset 4)) diff --git a/common/test/common_tests/geom_image_bounds_resize_test.cljc b/common/test/common_tests/geom_image_bounds_resize_test.cljc new file mode 100644 index 0000000000..a67fd67e13 --- /dev/null +++ b/common/test/common_tests/geom_image_bounds_resize_test.cljc @@ -0,0 +1,275 @@ +;; 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 common-tests.geom-image-bounds-resize-test + (:require + #?(:clj [clojure.test :refer [deftest is testing]] + :cljs [cljs.test :refer-macros [deftest is testing]]) + [app.common.math :as mth] + [app.common.schema :as sm] + [app.common.types.color :as clr] + [app.common.types.fills :as fills] + [app.common.types.fills.impl :as fills.impl] + [app.common.uuid :as uuid])) + +(deftest test-image-transform-schema + (testing "validates image with transform" + (let [img {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true + :transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}] + (is (sm/validate clr/schema:image img)))) + + (testing "validates image without transform" + (let [img {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true}] + (is (sm/validate clr/schema:image img)))) + + (testing "validates fill with image transform" + (let [fill {:fill-opacity 0.8 + :fill-image {:id (uuid/custom 1) + :width 400 + :height 300 + :mtype "image/png" + :keep-aspect-ratio true + :transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}] + (is (sm/validate fills/schema:fill fill))))) + +(deftest test-image-fill-buffer-roundtrip + (testing "roundtrip image fill without transform" + (let [fill-vec [{:fill-opacity 0.9 + :fill-image {:id (uuid/custom 1) + :width 800 + :height 600 + :mtype "image/jpeg" + :keep-aspect-ratio true + :name "sample"}}] + coerced (fills/from-plain fill-vec) + plain (into [] coerced)] + (is (= 1 (count plain))) + (is (= 0.9 (:fill-opacity (first plain)))) + (is (= 800 (-> plain first :fill-image :width))) + (is (= 600 (-> plain first :fill-image :height))) + (is (true? (-> plain first :fill-image :keep-aspect-ratio))) + (is (nil? (-> plain first :fill-image :transform))))) + + (testing "roundtrip image fill with transform" + (let [fill-vec [{:fill-opacity 0.75 + :fill-image {:id (uuid/custom 2) + :width 1920 + :height 1080 + :mtype "image/webp" + :keep-aspect-ratio false + :name "sample" + :transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}] + coerced (fills/from-plain fill-vec) + plain (into [] coerced) + tf (-> plain first :fill-image :transform)] + (is (= 1 (count plain))) + (is (= 0.75 (:fill-opacity (first plain)))) + (is (= 1920 (-> plain first :fill-image :width))) + (is (= 1080 (-> plain first :fill-image :height))) + (is (false? (-> plain first :fill-image :keep-aspect-ratio))) + (is (some? tf)) + (is (mth/close? 0.25 (double (:x tf)))) + (is (mth/close? -0.15 (double (:y tf)))) + (is (mth/close? 1.5 (double (:width tf)))) + (is (mth/close? 2.0 (double (:height tf))))))) + +(defn compute-bounds-resize-transform + "Mathematical model for independent image bounds resizing" + [{:keys [width height handler center? sx sy transform]}] + (let [w-new (* width sx) + h-new (* height sy) + [dx dy] (if ^boolean center? + [(/ (* width (- 1.0 sx)) 2.0) + (/ (* height (- 1.0 sy)) 2.0)] + [(case handler + (:left :bottom-left :top-left) (* width (- 1.0 sx)) + 0.0) + (case handler + (:top :top-left :top-right) (* height (- 1.0 sy)) + 0.0)]) + nx0 (get transform :x 0.0) + ny0 (get transform :y 0.0) + nw0 (get transform :width 1.0) + nh0 (get transform :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + {:transform {:x nx' :y ny' :width nw' :height nh'} + :rendered-pixel-rect {:x (* nx' w-new) + :y (* ny' h-new) + :width (* nw' w-new) + :height (* nh' h-new)}})) + +(deftest test-handle-anchoring-mathematics + (testing "Right handle crop (shrinking width to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content remains 200x100 starting at (0, 0) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Left handle crop (shrinking width to 50% from left)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))) + + (testing "Top handle crop (shrinking height to 50% from top)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 1.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + ;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Top-Left handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Center resize (Alt modifier)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})] + (is (mth/close? -0.5 (-> res :transform :x))) + (is (mth/close? -0.5 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -25.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Bottom handle crop (shrinking height to 50% from bottom)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 1.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Top-Right handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? -1.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Bottom-Left handle crop (shrinking both dimensions to 50%)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})] + (is (mth/close? -1.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 2.0 (-> res :transform :width))) + (is (mth/close? 2.0 (-> res :transform :height))) + (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))) + (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height))))) + + (testing "Expanding bounds beyond original size (empty space exposure)" + (let [res (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})] + (is (mth/close? 0.0 (-> res :transform :x))) + (is (mth/close? 0.0 (-> res :transform :y))) + (is (mth/close? 0.5 (-> res :transform :width))) + (is (mth/close? 1.0 (-> res :transform :height))) + ;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space + (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x))) + (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))) + +(deftest test-sequential-resize-operations + (testing "Sequential crops: crop right then crop left" + ;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1} + ;; Step 1: Crop right handle from 200 to 150 (sx = 0.75) + (let [step1 (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0}) + tf1 (:transform step1)] + (is (mth/close? 0.0 (:x tf1))) + (is (mth/close? (/ 1.0 0.75) (:width tf1))) + ;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3) + (let [step2 (compute-bounds-resize-transform + {:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1}) + tf2 (:transform step2)] + ;; The final 100x100 container has bitmap with width 200px + (is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width))) + ;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px + (is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x)))))) + + (testing "Bounds resize followed by standard proportional scaling" + ;; Step 1: Bounds resize crops width from 200 to 100 + (let [step1 (compute-bounds-resize-transform + {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0}) + tf1 (:transform step1)] + (is (mth/close? 2.0 (:width tf1))) + (is (mth/close? 1.0 (:height tf1))) + + ;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x) + ;; During standard scale, normalized transform tf1 is kept constant! + (let [scaled-w (* 100.0 2.0) + scaled-h (* 100.0 2.0) + rendered-w (* (:width tf1) scaled-w) + rendered-h (* (:height tf1) scaled-h)] + ;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame! + (is (mth/close? 400.0 rendered-w)) + (is (mth/close? 200.0 rendered-h)))))) + +(deftest test-proportion-lock-invariance + (testing "Shape proportion-lock attribute remains unchanged" + (let [shape {:id (uuid/custom 10) + :type :rect + :width 200 + :height 100 + :proportion-lock true + :fills [{:fill-image {:id (uuid/custom 1) + :width 800 + :height 600 + :keep-aspect-ratio true}}]} + ;; Simulate bounds resize interaction + has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape))) + mod-pressed? true + bounds-resize? (and has-img? mod-pressed?) + lock-during-drag (if bounds-resize? false (:proportion-lock shape))] + ;; During drag, lock is bypassed (unless Shift is pressed) + (is (false? lock-during-drag)) + ;; Shape's persistent setting is completely preserved + (is (true? (:proportion-lock shape)))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index ab075151ee..d1ae2bbce5 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -29,6 +29,7 @@ [common-tests.geom-flex-layout-test] [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] + [common-tests.geom-image-bounds-resize-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] @@ -108,6 +109,7 @@ 'common-tests.geom-flex-layout-test 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test + 'common-tests.geom-image-bounds-resize-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 58bb9d26db..47d97b4249 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -99,7 +99,9 @@ :layout-item-margin-type :layout-grid-cells :layout-grid-columns - :layout-grid-rows}) + :layout-grid-rows + :fills + :fill-image}) ;; -- temporary modifiers ------------------------------------------- diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 5db0783294..15990f2de3 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -149,10 +149,15 @@ ;; -- Resize -------------------------------------------------------- +(defn- shape-has-image-fill? + [shape] + (boolean (or (some :fill-image (:fills shape)) + (:fill-image shape)))) + (defn start-resize "Enter mouse resize mode, until mouse button is released." [handler ids shape] - (letfn [(resize [shape initial layout objects [point lock? center? point-snap]] + (letfn [(resize [shape initial layout objects [point lock? center? bounds-resize? point-snap]] (let [selrect (dm/get-prop shape :selrect) width (dm/get-prop selrect :width) height (dm/get-prop selrect :height) @@ -235,7 +240,59 @@ (not (mth/close? (dm/get-prop scalev :x) 1)) change-height? - (not (mth/close? (dm/get-prop scalev :y) 1))] + (not (mth/close? (dm/get-prop scalev :y) 1)) + + ;; Calculate independent image bounds resize transform + sx (dm/get-prop scalev :x) + sy (dm/get-prop scalev :y) + w-new (* width sx) + h-new (* height sy) + + bounds-resize? (and ^boolean bounds-resize? + (pos? w-new) + (pos? h-new)) + + [dx dy] (if ^boolean center? + [(/ (* width (- 1.0 sx)) 2.0) + (/ (* height (- 1.0 sy)) 2.0)] + [(case handler + (:left :bottom-left :top-left) (* width (- 1.0 sx)) + 0.0) + (case handler + (:top :top-left :top-right) (* height (- 1.0 sy)) + 0.0)]) + + new-fills + (when (and bounds-resize? (seq (:fills shape))) + (mapv (fn [fill] + (if-let [img-fill (:fill-image fill)] + (let [tf (get img-fill :transform) + nx0 (get tf :x 0.0) + ny0 (get tf :y 0.0) + nw0 (get tf :width 1.0) + nh0 (get tf :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + (assoc-in fill [:fill-image :transform] + {:x nx' :y ny' :width nw' :height nh'})) + fill)) + (:fills shape))) + + new-fill-image + (when (and bounds-resize? (some? (:fill-image shape))) + (let [img-fill (:fill-image shape) + tf (get img-fill :transform) + nx0 (get tf :x 0.0) + ny0 (get tf :y 0.0) + nw0 (get tf :width 1.0) + nh0 (get tf :height 1.0) + nx' (/ (- (* nx0 width) dx) w-new) + ny' (/ (- (* ny0 height) dy) h-new) + nw' (/ nw0 sx) + nh' (/ nh0 sy)] + (assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))] (cond-> (ctm/empty) (some? displacement) @@ -258,18 +315,30 @@ (and new-grow-type (not= new-grow-type (dm/get-prop shape :grow-type))) (ctm/change-property :grow-type new-grow-type) + (and bounds-resize? (some? new-fills)) + (ctm/change-property :fills new-fills) + + (and bounds-resize? (some? new-fill-image)) + (ctm/change-property :fill-image new-fill-image) + ^boolean scale-text (ctm/scale-content (dm/get-prop scalev :x))))) ;; Unifies the instantaneous proportion lock modifier ;; activated by Shift key and the shapes own proportion ;; lock flag that can be activated on element options. - (normalize-proportion-lock [[point shift? alt?]] - (let [proportion-lock? (:proportion-lock shape)] + (normalize-proportion-lock [[point shift? alt? mod?]] + (let [has-img? (shape-has-image-fill? shape) + bounds-resize? (and has-img? (boolean mod?)) + proportion-lock? (:proportion-lock shape) + lock? (if bounds-resize? + (boolean shift?) + (or ^boolean proportion-lock? + ^boolean shift?))] [point - (or ^boolean proportion-lock? - ^boolean shift?) - alt?]))] + lock? + alt? + bounds-resize?]))] (reify ptk/UpdateEvent (update [_ state] @@ -297,10 +366,10 @@ resize-events-stream (->> ms/mouse-position (rx/filter some?) - (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt) + (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt ms/mouse-position-mod) (rx/map normalize-proportion-lock) (rx/switch-map - (fn [[point _ _ :as current]] + (fn [[point _ _ _ :as current]] (->> (snap/closest-snap-point page-id shapes objects layout zoom focus point) (rx/map #(conj current %))))) (rx/map #(resize shape initial-position layout objects %)) diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs index 3b7c29da0b..41238e7608 100644 --- a/frontend/src/app/main/ui/shapes/fills.cljs +++ b/frontend/src/app/main/ui/shapes/fills.cljs @@ -119,31 +119,43 @@ (if (:fill-image value) (let [uri (cf/resolve-file-media (:fill-image value)) keep-ar? (-> value :fill-image :keep-aspect-ratio) + tf (-> value :fill-image :transform) + img-x (if (some? tf) (* (get tf :x 0) width) 0) + img-y (if (some? tf) (* (get tf :y 0) height) 0) + img-w (if (some? tf) (* (get tf :width 1) width) width) + img-h (if (some? tf) (* (get tf :height 1) height) height) image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index) :href (get embed uri uri) :preserveAspectRatio (if keep-ar? "xMidYMid slice" "none") - :width width - :height height + :x img-x + :y img-y + :width img-w + :height img-h :key (dm/str fill-index) :opacity (:fill-opacity value)}] [:> :image image-props]) [:> :rect props]))) (when ^boolean has-image? - [:g - ;; We add this shape to add a padding so the patter won't repeat - ;; Issue: https://tree.taiga.io/project/penpot/issue/5583 - [:rect {:x 0 - :y 0 - :width (* width no-repeat-padding) - :height (* height no-repeat-padding) - :fill "none"}] - [:image {:href uri - :preserveAspectRatio "none" - :x 0 - :y 0 - :width width - :height height}]])]])]))) + (let [tf (-> image :transform) + img-x (if (some? tf) (* (get tf :x 0) width) 0) + img-y (if (some? tf) (* (get tf :y 0) height) 0) + img-w (if (some? tf) (* (get tf :width 1) width) width) + img-h (if (some? tf) (* (get tf :height 1) height) height)] + [:g + ;; We add this shape to add a padding so the patter won't repeat + ;; Issue: https://tree.taiga.io/project/penpot/issue/5583 + [:rect {:x 0 + :y 0 + :width (* width no-repeat-padding) + :height (* height no-repeat-padding) + :fill "none"}] + [:image {:href uri + :preserveAspectRatio "none" + :x img-x + :y img-y + :width img-w + :height img-h}]]))]])]))) (mf/defc fills {::mf/wrap-props false} diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index bebd20f7c6..1b45d1c759 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -3,6 +3,7 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::error::Result; use crate::get_resources; +use crate::math::Rect as MathRect; use crate::render::get_source_rect; use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; @@ -91,11 +92,20 @@ fn draw_image_fill( let size = image.dimensions(); let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; - - let src_rect = get_source_rect(size, container, image_fill); - let dest_rect = container; let sampling = get_resources().sampling_options; + let dest_rect = match image_fill.transform() { + Some(tf) => MathRect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + }; + let src_rect = get_source_rect(size, &dest_rect, image_fill); + let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape); + // `save_layer` is only required when a shape-level image filter (blur) must // run over the clipped image. Otherwise a plain save/clip (or no clip for // axis-aligned rects) avoids an offscreen buffer per fill — the hot path @@ -121,7 +131,7 @@ fn draw_image_fill( let mut draw_paint = paint.clone(); draw_paint.set_anti_alias(antialias); - if is_axis_aligned_image_rect(shape) { + if !needs_clip { canvas.draw_image_rect_with_sampling_options( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), @@ -163,10 +173,6 @@ fn draw_svg_image_fill( let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; let size = skia::ISize::new(size.width as i32, size.height as i32); - let src_rect = get_source_rect(size, container, image_fill); - if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { - return true; - } let mut image_paint = skia::Paint::default(); image_paint.set_anti_alias(antialias); @@ -183,16 +189,31 @@ fn draw_svg_image_fill( let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint); canvas.save_layer(&fill_layer); - // Map the cropped source rect onto the container: cover semantics when - // keep-aspect-ratio is set, stretch otherwise (same math as the raster - // path, expressed as a canvas transform). - let scale_x = container.width() / src_rect.width(); - let scale_y = container.height() / src_rect.height(); + let dest_rect = match image_fill.transform() { + Some(tf) => MathRect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + }; + + let src_rect = get_source_rect(size, &dest_rect, image_fill); + if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 { + canvas.restore(); + canvas.restore(); + return true; + } + + let scale_x = dest_rect.width() / src_rect.width(); + let scale_y = dest_rect.height() / src_rect.height(); canvas.translate(( - container.left - src_rect.left * scale_x, - container.top - src_rect.top * scale_y, + dest_rect.left - src_rect.left * scale_x, + dest_rect.top - src_rect.top * scale_y, )); canvas.scale((scale_x, scale_y)); + dom.render(canvas); canvas.restore(); diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 15d5dc09f7..1faeb54185 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -118,6 +118,14 @@ impl Gradient { } } +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct ImageFillTransform { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + #[derive(Debug, Clone, PartialEq)] pub struct ImageFill { id: Uuid, @@ -125,6 +133,7 @@ pub struct ImageFill { width: i32, height: i32, keep_aspect_ratio: bool, + transform: Option, } impl ImageFill { @@ -135,6 +144,25 @@ impl ImageFill { width, height, keep_aspect_ratio, + transform: None, + } + } + + pub fn new_with_transform( + id: Uuid, + opacity: u8, + width: i32, + height: i32, + keep_aspect_ratio: bool, + transform: Option, + ) -> Self { + Self { + id, + opacity, + width, + height, + keep_aspect_ratio, + transform, } } @@ -157,6 +185,10 @@ impl ImageFill { pub fn height(&self) -> i32 { self.height } + + pub fn transform(&self) -> Option<&ImageFillTransform> { + self.transform.as_ref() + } } #[derive(Debug, Clone, PartialEq, Copy)] diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c4084ea983..5d2a6e29e2 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -187,4 +187,30 @@ mod tests { assert_eq!(bytes[0], 0x03); assert_eq!(shapes::Fill::from(RawFillData::from(bytes)), fill); } + + #[test] + fn test_image_fill_with_transform_round_trip() { + let transform = shapes::ImageFillTransform { + x: 0.1, + y: -0.2, + width: 1.5, + height: 2.0, + }; + let image_fill = shapes::ImageFill::new_with_transform( + crate::uuid::Uuid::nil(), + 0xcc, + 400, + 300, + false, + Some(transform), + ); + let fill = shapes::Fill::Image(image_fill); + let raw_fill = + RawFillData::try_from(&fill).expect("image fill with transform must be serializable"); + let bytes = <[u8; RAW_FILL_DATA_SIZE]>::from(raw_fill); + + assert_eq!(bytes[0], 0x03); + let deserialized = shapes::Fill::from(RawFillData::from(bytes)); + assert_eq!(deserialized, fill); + } } diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index cbcb25b392..e6b2e634c5 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -30,6 +30,7 @@ fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { } const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0; +const FLAG_HAS_TRANSFORM: u8 = 1 << 1; const IMAGE_IDS_SIZE: usize = 32; const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag @@ -43,20 +44,30 @@ pub struct RawImageFillData { d: u32, opacity: u8, flags: u8, - // 16-bit padding here, reserved for future use + _pad: u16, width: i32, height: i32, + transform_x: f32, + transform_y: f32, + transform_w: f32, + transform_h: f32, } impl From<&ImageFill> for RawImageFillData { fn from(image_fill: &ImageFill) -> Self { let id = image_fill.id(); let (a, b, c, d) = crate::utils::uuid_to_u32_quartet(&id); - let flags = if image_fill.keep_aspect_ratio() { + let mut flags = if image_fill.keep_aspect_ratio() { FLAG_KEEP_ASPECT_RATIO } else { 0 }; + let (tx, ty, tw, th) = if let Some(tf) = image_fill.transform() { + flags |= FLAG_HAS_TRANSFORM; + (tf.x, tf.y, tf.width, tf.height) + } else { + (0.0, 0.0, 1.0, 1.0) + }; Self { a, @@ -65,8 +76,13 @@ impl From<&ImageFill> for RawImageFillData { d, opacity: image_fill.opacity(), flags, + _pad: 0, width: image_fill.width(), height: image_fill.height(), + transform_x: tx, + transform_y: ty, + transform_w: tw, + transform_h: th, } } } @@ -75,13 +91,24 @@ impl From for ImageFill { fn from(value: RawImageFillData) -> Self { let id = uuid_from_u32_quartet(value.a, value.b, value.c, value.d); let keep_aspect_ratio = value.flags & FLAG_KEEP_ASPECT_RATIO != 0; + let transform = if value.flags & FLAG_HAS_TRANSFORM != 0 { + Some(crate::shapes::ImageFillTransform { + x: value.transform_x, + y: value.transform_y, + width: value.transform_w, + height: value.transform_h, + }) + } else { + None + }; - Self::new( + Self::new_with_transform( id, value.opacity, value.width, value.height, keep_aspect_ratio, + transform, ) } }