diff --git a/.serena/memories/common/component-sync-contract.md b/.serena/memories/common/component-sync-contract.md index 6c2adab139..da69b6b1db 100644 --- a/.serena/memories/common/component-sync-contract.md +++ b/.serena/memories/common/component-sync-contract.md @@ -22,7 +22,7 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w - Edit on a copy shape (authored): sets the attr's group (text content: `:content-group` + diff subgroups), clears `:remote-synced`. Descendants moved/resized only because of it stay untouched. - Rename: an explicit rename touches `:name-group` on root or child. Automatic renames (text name following its content) are derived. Untouched names sync from the near main; a touched root name on update-main renames the component. - Edit on a main: no touched change on the main; triggers sync. -- Sync main→copy: every untouched group gets the near main's value (geometry: expected geometry); touched groups keep their values; never sets or clears `:touched`. Main child added → added clean to copies. Main child deleted → copy child removed, overrides or not. Main reorder → copies follow. A swapped sub-head pairs with the main child it replaces (its `:shape-ref`) and takes only that child's placement (position, constraints, `layout-item-*`); everything else syncs from its own component's main. A main child deleted → its swapped copies go with it. A swap made in the main reaches copies without `:swap-group` and spares those with it. +- Sync main→copy: every untouched group gets the near main's value (geometry: expected geometry); touched groups keep their values; never sets or clears `:touched`. Main child added → added clean to copies. Main child deleted → copy child removed, overrides or not. Main reorder → copies follow. A swapped sub-head pairs with the main child it replaces (its `:shape-ref`) and takes only that child's placement (position, rotation, flips, constraints, `layout-item-*`); everything else syncs from its own component's main. A main child deleted → its swapped copies go with it. A swap made in the main reaches copies without `:swap-group` and spares those with it. - Sync cascade is eager: one sync runs to a fixpoint in memory (copies of A, including the A copy in B's main → copies of B → …) and commits once in the triggering op's undo group. The file is never left partly synced. Syncing only the components reported as changed equals a full-file sync. - Reset: the selected head's subtree becomes equal to its near main (expected geometry); all its touched groups cleared, `:swap-group` included: a swapped head turns back into the near main's component in place, keeping its id. Nothing outside that subtree changes. - Update main (from a top copy head): the copy's differing values go to the main; the copy's touched cleared; other copies then sync and keep their own overrides. A swapped child replaces the main child it stands for, and neither keeps `:swap-group`. diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index b433382d8f..0bc6d1e80e 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -115,6 +115,7 @@ (declare update-flex-child-main-attrs) (declare update-flex-child-copy-attrs) (declare update-swapped-placement) +(declare orient-like) (declare reposition-shape) (declare make-change) @@ -2426,9 +2427,10 @@ (defn generate-take-placement "Give the swapped copy head `shape-inst` the placement of `placed`, the shape whose position it takes, expressed in the same coordinates: the - attributes that place it in its parent (position, constraints and layout - item attributes) that the user did not change. Its content comes from its - own component. With `layout-placed?`, the position is left to the layout. + attributes that place it in its parent (position, rotation, flips, + constraints and layout item attributes) that the user did not change. Its + content comes from its own component. With `layout-placed?`, the position + is left to the layout. `objects` holds the subtree of `shape-inst`, which moves with it." [changes shape-inst placed objects layout-placed?] (let [touched (get shape-inst :touched #{}) @@ -2450,7 +2452,9 @@ delta (gpt/subtract (position placed) (position shape-inst)) move? (and (untouched? :x) (not layout-placed?) - (not (gpt/almost-zero? delta)))] + (not (gpt/almost-zero? delta))) + orient (when (untouched? :rotation) + (orient-like shape-inst placed (gsh/shape->center shape-inst)))] (cond-> changes (seq placement) (pcb/update-shapes [(:id shape-inst)] @@ -2463,9 +2467,11 @@ placement)) {:ignore-touched true}) - move? + (or move? (some? orient)) (pcb/update-shapes (cfh/get-children-ids-with-self objects (:id shape-inst)) - #(gsh/move % delta) + #(cond-> % + (some? orient) (orient) + move? (gsh/move delta)) {:ignore-touched true})))) (defn- update-swapped-placement @@ -2774,6 +2780,28 @@ (pcb/with-objects (:objects container)) (generate-detach-instance container libraries id)))) +(defn- orient-like + "A function that turns each shape of the subtree of `head` around `center`, + so that `head` takes the flips and then the rotation of `target`; nil when + they already agree." + [head target center] + (let [flip-x? (not= (true? (:flip-x head)) (true? (:flip-x target))) + flip-y? (not= (true? (:flip-y head)) (true? (:flip-y target))) + flip (when (or flip-x? flip-y?) + (ctm/resize-modifiers (gpt/point (if flip-x? -1 1) (if flip-y? -1 1)) center)) + head' (cond-> head (some? flip) (gsh/transform-shape flip)) + angle (- (d/nilv (:rotation target) 0) (d/nilv (:rotation head') 0)) + rotate? (not (mth/close? (mod angle 360) 0 0.01)) + angle (if (> angle 180) (- angle 360) angle)] + (when (or (some? flip) rotate?) + (fn [shape] + (cond-> shape + (some? flip) + (gsh/transform-shape flip) + + rotate? + (as-> shape (gsh/transform-shape shape (ctm/rotation-modifiers shape center angle)))))))) + (defn- added-subtree "The shapes the changes add under the shape `id`, itself included." [changes id] @@ -2790,25 +2818,12 @@ 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}))))) + (if-let [orient (orient-like new-shape shape (gsh/shape->center new-shape))] + (pcb/update-shapes changes + (map :id (added-subtree changes (:id new-shape))) + orient + {:ignore-touched true}) + changes)) (defn- main-ref-ids "Map the shapes of a main subtree to the shapes of a copy of it: for each diff --git a/common/test/common_tests/logic/swap_group_test.cljc b/common/test/common_tests/logic/swap_group_test.cljc index dfa1c14576..6b8a9db554 100644 --- a/common/test/common_tests/logic/swap_group_test.cljc +++ b/common/test/common_tests/logic/swap_group_test.cljc @@ -583,3 +583,39 @@ 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))))) + +(t/deftest swapped-head-follows-the-rotation-of-the-replaced-child + (let [file (-> (setup-sized) + (tho/swap-component-in-shape :copy-star :heart)) + file' (-> file + (transform-subtree :card-star #(ctm/rotation-modifiers %1 %2 30)) + (ths/update-shape :card-main :opacity 0.9) + (propagate :card) + (thf/validate-file!)) + head (ths/get-shape file' :copy-star)] + (t/is (== 30 (:rotation (orientation file' :card-star)))) + (t/is (== 30 (:rotation (orientation file' :copy-star)))) + (t/is (== 30 (mth/precision (:rotation (ths/get-shape-by-id file' (first (:shapes head)))) 2))) + (t/is (ctk/swapped? head)) + (t/is (not (ctk/touched-group? head :geometry-group))))) + +(t/deftest swapped-head-follows-the-flip-of-the-replaced-child + (let [file' (-> (setup-sized) + (tho/swap-component-in-shape :copy-star :heart) + (transform-subtree :card-star (fn [_ center] (ctm/resize-modifiers (gpt/point -1 1) center))) + (ths/update-shape :card-main :opacity 0.9) + (propagate :card) + (thf/validate-file!))] + (t/is (:flip-x (orientation file' :card-star))) + (t/is (:flip-x (orientation file' :copy-star))))) + +(t/deftest rotated-swapped-head-keeps-its-rotation + ;; the user rotated the swapped head: the main's rotation does not reach it + (let [file' (-> (setup-sized) + (tho/swap-component-in-shape :copy-star :heart) + (transform-subtree :copy-star #(ctm/rotation-modifiers %1 %2 10)) + (transform-subtree :card-star #(ctm/rotation-modifiers %1 %2 30)) + (ths/update-shape :card-main :opacity 0.9) + (propagate :card) + (thf/validate-file!))] + (t/is (== 10 (:rotation (orientation file' :copy-star))))))