diff --git a/common/src/app/common/geom/image_crop.cljc b/common/src/app/common/geom/image_crop.cljc new file mode 100644 index 0000000000..fdd9f89527 --- /dev/null +++ b/common/src/app/common/geom/image_crop.cljc @@ -0,0 +1,38 @@ +;; 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 app.common.geom.image-crop + "Geometry of the image crop (resize with the mod key on a shape with an + image fill). The shape is a window over a picture that keeps its size + and position; the window never flips." + (:require + [app.common.math :as mth])) + +(defn crop-axis + "Resize one axis of the crop window. `size` is the current window size, + `origin` the fixed point relative to the window start and `scale` the + resize factor, negative when the dragged edge crosses the fixed point. + Returns `[offset size]`: the new window start relative to the old one, + and the new size, always positive." + [size origin scale] + (let [start (* origin (- 1 scale)) + end (+ origin (* (- size origin) scale))] + [(mth/min start end) (mth/abs (- end start))])) + +(defn update-image-transform + "Return the image fill transform that keeps the picture in place when + the window of `width` x `height` moves by `dx`, `dy` and gets a size of + `width'` x `height'`. The transform values are relative to the window + size, and a nil transform means the picture fills the window." + [transform width height dx dy width' height'] + (let [x (get transform :x 0.0) + y (get transform :y 0.0) + w (get transform :width 1.0) + h (get transform :height 1.0)] + {:x (/ (- (* x width) dx) width') + :y (/ (- (* y height) dy) height') + :width (/ (* w width) width') + :height (/ (* h height) height')})) diff --git a/common/test/common_tests/geom_image_crop_test.cljc b/common/test/common_tests/geom_image_crop_test.cljc new file mode 100644 index 0000000000..cdd30e444f --- /dev/null +++ b/common/test/common_tests/geom_image_crop_test.cljc @@ -0,0 +1,67 @@ +;; 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-crop-test + (:require + [app.common.geom.image-crop :as gic] + [app.common.math :as mth] + [clojure.test :as t])) + +(defn- close-vals? + [a b] + (every? true? (map mth/close? a b))) + +(defn- picture-box + "Picture position and size relative to the original window start, for a + window at `offset` with `size` and the given image transform." + [{:keys [x y width height]} [dx dy] [w h]] + [(+ dx (* x w)) (+ dy (* y h)) (* width w) (* height h)]) + +(t/deftest crop-axis-test + (t/testing "shrinks the window towards the fixed edge" + ;; Top handler: the bottom edge (300) stays fixed. + (t/is (close-vals? [60 240] (gic/crop-axis 300 300 0.8)))) + + (t/testing "grows the window away from the fixed edge" + (t/is (close-vals? [-60 360] (gic/crop-axis 300 300 1.2)))) + + (t/testing "crossing the fixed edge moves the window to the other side" + ;; Top handler dragged 100px below the bottom edge. + (t/is (close-vals? [300 100] (gic/crop-axis 300 300 (/ -1 3)))) + ;; Bottom handler dragged 150px above the top edge. + (t/is (close-vals? [-150 150] (gic/crop-axis 300 0 -0.5)))) + + (t/testing "resize from the center keeps the center when crossing" + (t/is (close-vals? [75 150] (gic/crop-axis 300 150 -0.5))))) + +(t/deftest update-image-transform-test + (let [width 400 + height 300 + tf {:x 0 :y -0.2 :width 1 :height 1.4}] + + (t/testing "the picture keeps its position and size" + (doseq [[dx dy w' h'] [[0 60 400 240] + [0 300 400 100] + [0 -150 400 150] + [-50 75 500 150]]] + (let [tf' (gic/update-image-transform tf width height dx dy w' h')] + (t/is (close-vals? (picture-box tf [0 0] [width height]) + (picture-box tf' [dx dy] [w' h'])) + (str "window " [dx dy w' h']))))) + + (t/testing "a missing transform means the picture fills the window" + (t/is (close-vals? [0 -0.25 1 1.25] + ((juxt :x :y :width :height) + (gic/update-image-transform nil 400 300 0 60 400 240))))) + + (t/testing "moving the window back restores the original transform" + (let [[dy h'] (gic/crop-axis height height (/ -1 3)) + crossed (gic/update-image-transform tf width height 0 dy width h') + [dy2 h2] (gic/crop-axis h' 0 (/ height h' -1)) + back (gic/update-image-transform crossed width h' 0 dy2 width h2)] + (t/is (close-vals? [(- dy) height] [dy2 h2]) "window back at its original place") + (t/is (close-vals? ((juxt :x :y :width :height) tf) + ((juxt :x :y :width :height) back))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index e4cec9b442..88e10df432 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -35,6 +35,7 @@ [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-image-bounds-resize-test] + [common-tests.geom-image-crop-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] @@ -138,6 +139,7 @@ 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test 'common-tests.geom-image-bounds-resize-test + 'common-tests.geom-image-crop-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/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 15990f2de3..640a9fc11f 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] + [app.common.geom.image-crop :as gic] [app.common.geom.matrix :as gmt] [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] @@ -212,10 +213,29 @@ ;; Resize origin point given the selected handler selrect (dm/get-prop shape :selrect) handler-origin (get-handler-resize-origin selrect handler) + selrect-start (gpt/point (dm/get-prop selrect :x) (dm/get-prop selrect :y)) + + ;; Image crop (mod key): the shape is a window over a + ;; picture that keeps its size and position. Dragging an + ;; edge past the fixed one moves the window to the other + ;; side instead of flipping it. + crop-origin (if ^boolean center? + (gpt/point (/ width 2) (/ height 2)) + (gpt/subtract handler-origin selrect-start)) + [crop-dx crop-w] (gic/crop-axis width (dm/get-prop crop-origin :x) (dm/get-prop scalev :x)) + [crop-dy crop-h] (gic/crop-axis height (dm/get-prop crop-origin :y) (dm/get-prop scalev :y)) + + scalev (if ^boolean bounds-resize? + (gpt/point (/ crop-w width) (/ crop-h height)) + scalev) ;; If we want resize from center, displace the shape ;; so it is still centered after resize. - displacement (when ^boolean center? + displacement (cond + ^boolean bounds-resize? + (gpt/transform (gpt/point crop-dx crop-dy) shape-transform) + + ^boolean center? (-> shape-center (gpt/subtract handler-origin) (gpt/multiply scalev) @@ -224,7 +244,10 @@ (gpt/multiply (gpt/point -1 -1)) (gpt/transform shape-transform))) - resize-origin (gmt/transform-point-center handler-origin shape-center shape-transform) + resize-origin (gmt/transform-point-center + (if ^boolean bounds-resize? selrect-start handler-origin) + shape-center + shape-transform) resize-origin (if (some? displacement) (gpt/add resize-origin displacement) resize-origin) @@ -242,57 +265,23 @@ change-height? (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)]) + ;; Keep the picture in place inside the new crop window + update-image + (fn [img-fill] + (update img-fill :transform gic/update-image-transform + width height crop-dx crop-dy crop-w crop-h)) new-fills - (when (and bounds-resize? (seq (:fills shape))) + (when (and ^boolean 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)) + (cond-> fill + (some? (:fill-image fill)) + (update :fill-image update-image))) (: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'})))] + (when (and ^boolean bounds-resize? (some? (:fill-image shape))) + (update-image (:fill-image shape)))] (cond-> (ctm/empty) (some? displacement)