🐛 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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alonso.torres 2026-10-13 11:07:26 +02:00
parent f8f92d7763
commit 6b7101bfd1
2 changed files with 118 additions and 8 deletions

View File

@ -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

View File

@ -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)))))