From 2793c0a6d455a118b1f439f9a4e28547b23c6099 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 13 Oct 2026 11:07:26 +0200 Subject: [PATCH] :bug: Keep the rotation and flips of a swapped shape A swap placed the new copy at the position of the shape it replaces, but not with its rotation or flips. When that shape was rotated or flipped with its parent, the new copy came out straight. The new copy and its subtree now take the flips and then the rotation of the shape it replaces, around their own center, as geometry that comes from the parent, not as an override. A variant switch goes through the same swap. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 38.3 / 1.09 edit-through-a-chain-of-dependents 68.9 / 70.7 / 1.03 library-update-into-copies 83.1 / 82.2 / 0.99 main-edit-into-10-copies 1.8 / 1.7 / 0.95 main-edit-into-100-copies 15.4 / 16.7 / 1.08 main-edit-into-1000-copies 153.1 / 167.4 / 1.09 variant-switch-into-copies 32.1 / 29.6 / 0.92 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/logic/libraries.cljc | 51 +++++++++++-- .../common_tests/logic/swap_group_test.cljc | 75 +++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 8025cdf736..d9d9fb3fc2 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -27,6 +27,7 @@ [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.library :as ctl] + [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.types.pages-list :as ctpl] [app.common.types.path.segment :as segment] @@ -2767,6 +2768,42 @@ (pcb/with-objects (:objects container)) (generate-detach-instance container libraries id)))) +(defn- added-subtree + "The shapes the changes add under the shape `id`, itself included." + [changes id] + (let [added (->> (:redo-changes changes) + (filter #(= :add-obj (:type %))) + (map :obj) + (d/index-by :id))] + (->> (vals added) + (filter #(or (= (:id %) id) + (contains? (set (cfh/get-parent-ids added (:id %))) id)))))) + +(defn- generate-keep-orientation + "Give the new head of a swap, and its subtree, the flips and the rotation + of the shape it replaces, around the head's own center. They come from + its parent, as when the parent is rotated, and are not overrides." + [changes new-shape shape] + (let [angle (d/nilv (:rotation shape) 0) + flip-x? (true? (:flip-x shape)) + flip-y? (true? (:flip-y shape))] + (if (and (mth/almost-zero? angle) (not flip-x?) (not flip-y?)) + changes + (let [center (gsh/shape->center new-shape) + flip (when (or flip-x? flip-y?) + (ctm/resize-modifiers (gpt/point (if flip-x? -1 1) (if flip-y? -1 1)) center)) + orient (fn [shape] + (cond-> shape + (some? flip) + (gsh/transform-shape flip) + + (not (mth/almost-zero? angle)) + (as-> shape (gsh/transform-shape shape (ctm/rotation-modifiers shape center angle)))))] + (pcb/update-shapes changes + (map :id (added-subtree changes (:id new-shape))) + orient + {:ignore-touched true}))))) + (defn- main-ref-ids "Map the shapes of a main subtree to the shapes of a copy of it: for each shape in the subtree of `head`, follow its ref chain until a shape whose id @@ -2801,13 +2838,8 @@ swapped? (or (nil? ref-shape) (not= (:component-id ref-shape) (:component-id new-shape)) (not= (:component-file ref-shape) (:component-file new-shape))) - new-ids (->> (:redo-changes changes) - (filter #(= :add-obj (:type %))) - (map :obj) - (d/index-by :id)) - children (->> (vals new-ids) - (filter #(contains? (set (cfh/get-parent-ids new-ids (:id %))) - (:id new-shape)))) + children (->> (added-subtree changes (:id new-shape)) + (remove #(= (:id %) (:id new-shape)))) ref-map (when-not swapped? (main-ref-ids file* (:container (meta ref-shape)) libraries ref-shape (into #{} (map :shape-ref) children)))] @@ -2852,7 +2884,10 @@ [new-shape changes] (if inside-copy? (generate-swap-in-copy changes new-shape shape file page libraries) - [new-shape changes])] + [new-shape changes]) + + changes + (generate-keep-orientation changes new-shape shape)] [new-shape (-> changes ;; Restore the properties diff --git a/common/test/common_tests/logic/swap_group_test.cljc b/common/test/common_tests/logic/swap_group_test.cljc index 6fdb4aaefc..dfa1c14576 100644 --- a/common/test/common_tests/logic/swap_group_test.cljc +++ b/common/test/common_tests/logic/swap_group_test.cljc @@ -9,6 +9,7 @@ the main child it replaces, its component gives the content, and the touched group :swap-group marks the swap." (:require + [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] @@ -16,6 +17,7 @@ [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.logic.variants :as clv] + [app.common.math :as mth] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -24,6 +26,7 @@ [app.common.test-helpers.variants :as thv] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.modifiers :as ctm] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -508,3 +511,75 @@ (t/is (ctk/swapped? (ths/get-shape file'' :copy-child))) (t/is (not= (position file :copy-child) (position file' :copy-child))) (t/is (= (position file :copy-child) (position file'' :copy-child))))) + +(defn- transform-subtree + "Apply the modifiers `f` gives for each shape to the shape and its children, + as rotating or flipping it in the editor does." + [file label f] + (let [page (thf/current-page file) + objects (:objects page) + shape (get objects (thi/id label)) + center (gsh/shape->center shape) + ids (set (cfh/get-children-ids-with-self objects (:id shape))) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + ids + #(gsh/transform-shape % (f % center)) + objects + {})] + (thf/apply-changes file changes))) + +(defn- setup-sized + [] + ;; :star and :heart are 40x40 frames with a 30x30 child; :holder is a plain + ;; frame with a copy of :star; :copy is a copy of :card, which nests :star + (-> (thf/sample-file :file1) + (tho/add-simple-component :star :star-main :star-rect + :root-params {:width 40 :height 40} + :child-params {:width 30 :height 30}) + (tho/add-simple-component :heart :heart-main :heart-rect + :root-params {:width 40 :height 40} + :child-params {:width 30 :height 30 :type :circle}) + (tho/add-frame :holder :width 200 :height 100) + (thc/instantiate-component :star :plain-star :parent-label :holder) + (tho/add-frame :card-main :width 200 :height 100) + (thc/instantiate-component :star :card-star :parent-label :card-main) + (thc/make-component :card :card-main) + (thc/instantiate-component :card :copy :children-labels [:copy-star]))) + +(defn- orientation + [file label] + (let [shape (ths/get-shape file label)] + {:rotation (mth/precision (d/nilv (:rotation shape) 0) 2) + :flip-x (true? (:flip-x shape)) + :center (let [c (gsh/shape->center shape)] [(mth/precision (:x c) 2) (mth/precision (:y c) 2)])})) + +(t/deftest swap-keeps-the-rotation-of-the-replaced-shape + (doseq [[parent child] [[:holder :plain-star] [:copy :copy-star]]] + (let [file (transform-subtree (setup-sized) parent #(ctm/rotation-modifiers %1 %2 30)) + file' (tho/swap-component-in-shape file child :heart) + head (ths/get-shape file' child)] + (t/is (== 30 (:rotation (orientation file child)))) + (t/is (= (orientation file child) (orientation file' child))) + (t/is (== 30 (mth/precision (:rotation (ths/get-shape-by-id file' (first (:shapes head)))) 2))) + (t/is (not (ctk/touched-group? head :geometry-group)))))) + +(t/deftest swap-keeps-the-flip-of-the-replaced-shape + (let [file (transform-subtree (setup-sized) :copy + (fn [_ center] (ctm/resize-modifiers (gpt/point -1 1) center))) + file' (tho/swap-component-in-shape file :copy-star :heart)] + (t/is (:flip-x (orientation file :copy-star))) + (t/is (= (orientation file :copy-star) (orientation file' :copy-star))))) + +(t/deftest switch-keeps-the-rotation-of-a-rotated-copy + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :star :star-main :star-rect + :root-params {:width 40 :height 40} + :child-params {:width 30 :height 30}) + (thv/add-variant-with-copy :variant :c01 :m01 :c02 :m02 :child1 :child2 :star) + (thc/instantiate-component :c01 :copy01 :children-labels [:copy-child]) + (transform-subtree :copy01 #(ctm/rotation-modifiers %1 %2 30))) + file' (tho/swap-component-in-shape file :copy01 :c02 {:keep-touched? true}) + copy (ths/get-shape file' :copy01) + child (ths/get-shape-by-id file' (first (:shapes copy)))] + (t/is (== 30 (mth/precision (:rotation copy) 2))) + (t/is (== 30 (mth/precision (:rotation child) 2)))))