From a90c6d1d8cea6a0cb68e9a1ec7346b08f3e4fdf0 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 23 Oct 2026 19:11:24 +0200 Subject: [PATCH] :bug: Keep authored rotation through a plain component swap Retain geometry touched when a plain swap keeps authored orientation, so a later sync preserves it. Size-only and content overrides still clear. Red regression reproduced; swap suite passes 39 tests / 154 assertions. Benchmark vs Phase 1e: main 10/100/1000 0.51/0.64/0.65; levels 0.50; chain 0.57; library 0.53; many shapes 0.47; switch 0.59. No flags. AI-assisted-by: GPT-6 --- .../common/component-sync-contract.md | 2 +- common/src/app/common/logic/variants.cljc | 28 ++++++++++++++++++- .../common_tests/logic/swap_group_test.cljc | 23 +++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.serena/memories/common/component-sync-contract.md b/.serena/memories/common/component-sync-contract.md index ad554a1a75..6413ecc871 100644 --- a/.serena/memories/common/component-sync-contract.md +++ b/.serena/memories/common/component-sync-contract.md @@ -29,7 +29,7 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w - Detach: the subtree loses component links, `:touched`, `:remote-synced`. First-level nested heads stay copies, move one nesting level up and inherit the near main's touched groups, so values their old near main overrode become overrides. A swapped head left with no enclosing copy becomes a plain copy of its own component (`:shape-ref` = its main root, no `:swap-group`). - Instantiate / nest a copy: the new subtree has no touched groups. - Duplicate a copy: the new subtree keeps the source's values and touched; if its nesting level changes, touched is inherited as in detach. Paste and move out of a copy follow the same rule. -- Swap without keep-touched: the new subtree is clean and keeps `swap-keep-attrs` values; the new head keeps the old shape's id. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy. +- Swap without keep-touched: the new subtree keeps `swap-keep-attrs` values; the new head keeps the old shape's id. A retained authored rotation or flip stays a `:geometry-group` override so later sync preserves it; size-only overrides clear, and parent-derived orientation stays untouched. Other content overrides clear. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy. - Variant switch with keep-touched: an attr is carried from the old copy only when its group is touched and the old and new mains hold the same value for it. Resulting touched = groups of carried attrs (+ `:swap-group` when the head is inside a copy). A value equal to the new main is not carried and not touched. Texts: the same per text group (letters carried only when both mains have the same letters; a style group when both mains agree on it once placed on each other's letters). - Delete component: copies keep their data. The deleted main cannot be edited, so it starts no sync, but changes from components nested in it still pass through: sync updates its stored shapes (`:objects` of the deleted component), its copies, and the components that nest it. Restore brings back an up-to-date main. - Undo/redo: restores the exact prior state, `:touched` and `:remote-synced` included, of the op and the sync it caused together (same undo group, one step). Undo never needs a re-sync. diff --git a/common/src/app/common/logic/variants.cljc b/common/src/app/common/logic/variants.cljc index fa8ae37a1f..2066f17070 100644 --- a/common/src/app/common/logic/variants.cljc +++ b/common/src/app/common/logic/variants.cljc @@ -12,6 +12,7 @@ [app.common.logic.shapes :as cls] [app.common.logic.swap :as clsw] [app.common.logic.variant-properties :as clvp] + [app.common.math :as mth] [app.common.path-names :as cpn] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] @@ -404,6 +405,31 @@ (when-let [parent (get objects parent-id)] (or (d/index-of (:shapes parent) shape-id) 0))) +(defn- keep-authored-orientation + "A plain swap drops content overrides, but keeps an authored rotation or + flip as geometry so a later sync does not remove its retained placement." + [changes shape baseline page ldata component-id] + (let [parent (get-in page [:objects (:parent-id shape)]) + main (ctf/get-component-root ldata (ctkl/get-component ldata component-id)) + main-parent (get-in (ctf/get-component-container ldata (ctkl/get-component ldata component-id)) + [:objects (:parent-id main)]) + parent-delta (if (:shape-ref parent) 0 + (- (or (:rotation parent) 0) (or (:rotation main-parent) 0))) + rotation (mod (+ (or (:rotation baseline) 0) parent-delta) 360) + flip (fn [key] + (if (:shape-ref parent) + (boolean (get baseline key)) + (odd? (count (filter identity [(get baseline key) (get parent key) + (get main-parent key)]))))) + changed? (or (not (mth/close? (mod (or (:rotation shape) 0) 360) rotation 0.01)) + (not= (boolean (:flip-x shape)) (flip :flip-x)) + (not= (boolean (:flip-y shape)) (flip :flip-y)))] + (cond-> changes + (and (ctk/touched-group? shape :geometry-group) changed?) + (pcb/update-shapes [(:id shape)] + #(update % :touched (fnil conj #{}) :geometry-group) + {:ignore-touched true})))) + (defn generate-component-swap-in-place "Swap the copy head `shape` of `page` for a copy of the component `component-id` of library data `ldata`. The new head takes the old one's @@ -428,7 +454,7 @@ [changes parents-of-swapped] (if keep-touched? (generate-keep-touched changes new-shape shape orig-shapes page libraries ldata) - [changes []]) + [(keep-authored-orientation changes shape new-shape page ldata component-id) []]) ;; Replacement keeps the head id and its cell. Cleanup during the ;; temporary deletion must not change the parent's placement data. diff --git a/common/test/common_tests/logic/swap_group_test.cljc b/common/test/common_tests/logic/swap_group_test.cljc index d6df6d007d..2f1a5942b4 100644 --- a/common/test/common_tests/logic/swap_group_test.cljc +++ b/common/test/common_tests/logic/swap_group_test.cljc @@ -698,3 +698,26 @@ (propagate :card) (thf/validate-file!))] (t/is (== 10 (:rotation (orientation file' :copy-star)))))) + +(t/deftest plain-swap-keeps-authored-rotation-as-a-geometry-override + (doseq [target [:plain-star :card-star :copy-star]] + (let [initial (tm/make-situation (setup-sized)) + rotated (-> initial (r/step (n/rotate target 45)) r/settle + (r/step (n/change-property target :opacity 0.4)) r/settle) + swapped (-> rotated (r/step (n/swap-shape target :heart)) r/settle) + head (tm/resolve-shape swapped target) + file (tm/file swapped) + full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))] + (t/is (ctk/touched-group? head :geometry-group)) + (t/is (== 45 (:rotation head))) + (t/is (not (ctk/touched-group? head :layer-effects-group))) + (t/is (= 1 (:opacity head 1))) + (t/is (= (orientation file target) (orientation full target)))))) + +(t/deftest plain-swap-drops-a-size-only-override + (let [situation (-> (tm/make-situation (setup-sized)) + (r/step (n/change-height :plain-star 80)) r/settle + (r/step (n/swap-shape :plain-star :heart)) r/settle) + head (tm/resolve-shape situation :plain-star)] + (t/is (== 40 (:height head))) + (t/is (not (ctk/touched-group? head :geometry-group)))))