🐛 Keep a plain swap's inherited orientation through sync

A plain swap also retains orientation inherited from its old component.
Record that retained orientation as geometry when it differs from the
new component's expected placement. Parent-derived orientation stays
untouched and size-only overrides still clear.

The regression fails before the fix. The swap suite passes 41 tests /
162 assertions; the seeded MVP property also passes 100 scenarios.

Benchmark vs Phase 1e: main 10/100/1000 0.54/0.57/0.66; levels 0.60;
chain 0.62; library 0.67; many shapes 0.83; switch 0.77. No flags.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-23 19:38:52 +02:00
parent 407e14682f
commit fbb56e2916
3 changed files with 18 additions and 6 deletions

View File

@ -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 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.
- Swap without keep-touched: the new subtree keeps `swap-keep-attrs` values; the new head keeps the old shape's id. A retained rotation or flip that differs from the new component's expected placement becomes a `:geometry-group` override so later sync preserves it, including orientation inherited from the old component. The swap authors that choice. 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.

View File

@ -405,9 +405,9 @@
(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."
(defn- keep-orientation-override
"A plain swap keeps its old orientation. When that differs from the new
component's expected placement, the swap authors a geometry override."
[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))
@ -425,7 +425,7 @@
(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?)
changed?
(pcb/update-shapes [(:id shape)]
#(update % :touched (fnil conj #{}) :geometry-group)
{:ignore-touched true}))))
@ -454,7 +454,7 @@
[changes parents-of-swapped]
(if keep-touched?
(generate-keep-touched changes new-shape shape orig-shapes page libraries ldata)
[(keep-authored-orientation changes shape new-shape page ldata component-id) []])
[(keep-orientation-override 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.

View File

@ -734,3 +734,15 @@
(t/is (mth/close? 0 (:rotation child) 0.001))
(t/is (= #{:swap-group} (:touched head)))
(t/is (nil? (:touched child)))))
(t/deftest plain-swap-keeps-the-orientation-inherited-from-its-old-component
(let [initial (-> (tm/make-situation (setup-sized))
(r/step (n/rotate :star-main 45)) r/settle)
swapped (-> initial (r/step (n/swap-shape :card-star :heart)) r/settle)
head (tm/resolve-shape swapped :card-star)
file (tm/file swapped)
full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))]
(t/is (nil? (:touched (tm/resolve-shape initial :card-star))))
(t/is (ctk/touched-group? head :geometry-group))
(t/is (mth/close? 45 (:rotation head) 0.001))
(t/is (= (orientation file :card-star) (orientation full :card-star)))))