✨ Turn a swapped copy head with the child it replaces

A swapped copy head followed the position, constraints and layout item
attributes of the main child it replaces, but not its rotation or
flips. When the main rotated or flipped that child, the copies that
kept it turned and the swapped ones did not. Sync, and a variant switch
that carries a swap, now give the head and its subtree the flips and
then the rotation of that child, around the head's own center, unless
the user changed the head's geometry. The swap itself uses the same
step.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  34.1 / 0.97
  edit-through-a-chain-of-dependents   68.9 /  75.5 / 1.10
  library-update-into-copies           83.1 /  81.3 / 0.98
  main-edit-into-10-copies              1.8 /   1.6 / 0.88
  main-edit-into-100-copies            15.4 /  15.6 / 1.01
  main-edit-into-1000-copies          153.1 / 153.2 / 1.00
  variant-switch-into-copies           32.1 /  31.4 / 0.98

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-13 11:59:42 +02:00
parent 2793c0a6d4
commit a7c5cd304a
3 changed files with 77 additions and 26 deletions

View File

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

View File

@ -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)
@ -2420,9 +2421,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 #{})
@ -2444,7 +2446,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)]
@ -2457,9 +2461,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
@ -2768,6 +2774,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]
@ -2784,25 +2812,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

View File

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