♻️ Write the touched groups with the :set-touched operation

Two operations set the touched groups of a shape: a :set of :touched,
which assigns the value as given, and :set-touched, which keeps them
only on a copy and drops an empty set. update-shapes and the undo of
add-component wrote the first while their own undo and sync wrote the
second. The contract prefers :set-touched; both builders now write it,
and an update with only its touched groups changed still records their
undo. A :set of :touched is still applied, for changes already stored.

Case AE leaves no empty touched set on the shape a detach turned plain;
both golden masters record the state again for it.

comp-touched-test: an update that sets the touched groups of a copy
shape writes one :set-touched operation, and its undo restores them.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  16.6 / 0.47
  edit-through-a-chain-of-dependents   68.9 /  32.8 / 0.48
  library-update-into-copies           83.1 /  42.8 / 0.51
  main-edit-among-many-shapes           4.8 /   2.3 / 0.48
  main-edit-into-10-copies              1.8 /   1.0 / 0.54
  main-edit-into-100-copies            15.4 /   8.9 / 0.58
  main-edit-into-1000-copies          153.1 /  82.0 / 0.54
  variant-switch-into-copies           32.1 /  17.7 / 0.55

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-15 15:11:35 +02:00
parent 52e1ddfd1f
commit 5da4793d2a
4 changed files with 34 additions and 9 deletions

View File

@ -608,9 +608,16 @@
new-val (get new attr)
changed? (not= old-val new-val)
;; the touched groups are written whole, with the
;; operation of their own
touched? (= attr :touched)
rops
(cond-> rops
changed?
(and changed? touched?)
(conj {:type :set-touched :touched new-val})
(and changed? (not touched?))
(conj {:type :set :attr attr :val new-val
:ignore-geometry ignore-geometry?
:ignore-touched (if (set? ignore-touched)
@ -619,7 +626,7 @@
uops
(cond-> uops
changed?
(and changed? (not touched?))
(conj {:type :set :attr attr :val old-val
:ignore-touched true}))]
@ -642,10 +649,10 @@
;; a touch also clears :remote-synced
uops (cond-> uops
(seq uops)
(seq rops)
(conj {:type :set-touched :touched (:touched old-obj)})
(and (seq uops) (:remote-synced old-obj))
(and (seq rops) (:remote-synced old-obj))
(conj {:type :set-remote-synced :remote-synced true}))
change (cond-> {:type :mod-obj :id id}
@ -898,9 +905,8 @@
{:type :set
:attr :shape-ref
:val (:shape-ref shape)}
{:type :set
:attr :touched
:val (:touched shape)}]})]
{:type :set-touched
:touched (:touched shape)}]})]
(-> changes
(update :redo-changes
(fn [redo-changes]

View File

@ -309,6 +309,25 @@
(t/is (true? (:remote-synced (ths/get-shape file'' :copy-child))))
(t/is (nil? (:touched (ths/get-shape file'' :copy-child))))))
(t/deftest test-an-update-writes-touched-groups-whole
;; an update that sets the touched groups of a copy shape writes them with
;; the operation of their own, and its undo gives back the ones it had
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :copy-child)}
#(assoc % :touched #{:shadow-group})
(:objects page)
{})
ops (mapcat :operations (:redo-changes changes))
file' (thf/apply-changes file changes)
file'' (thf/apply-undo-changes file' changes)]
(t/is (= [{:type :set-touched :touched #{:shadow-group}}] ops))
(t/is (= #{:shadow-group} (:touched (ths/get-shape file' :copy-child))))
(t/is (nil? (:touched (ths/get-shape file'' :copy-child))))))
(t/deftest test-touched-set-by-an-update-then-its-attrs-touch
;; an update sets the touched groups of a copy shape and changes an
;; attribute: the attribute touches its group on top of them, whatever the

View File

@ -11,7 +11,7 @@
:ad
[{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-1}\n swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["9835a328e680007d" "8cc31cfc15b28449" "40c27ac7ca18d972"]}]
:ae
[{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n detach {:target #fn, :detached sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["a3cb16fb335bc929" "c59b7ba151b736fb" "ccd586f1f2b263e4" "07eb2229667eeb92"]}
[{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n detach {:target #fn, :detached sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["a3cb16fb335bc929" "e10b1283627fbeb6" "9be2dfab826240b0" "1fc0e554196dfd2b"]}
{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n duplicate {:target #fn, :new-head 5}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["a3cb16fb335bc929" "d291c9ca9a187b1b" "99bb6214c05d575b" "0694d4feab3cb633"]}]
:af
[{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n move-to-cell {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["a3bca1c5fd1b9b29" "8a7fa3fde8815d35" "1c786eab89f462da"]}

View File

@ -11,7 +11,7 @@
:case-ad-update-main-pushes-a-swapped-child
[{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:component \"main\", :copy sync-main-copyhead-1}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}", :states ["70154c2f90c4d94f" "8276a39e105869aa" "60ae4e382dab157a"]}]
:case-ae-detached-or-duplicated-swapped-copy-follows-its-component
[{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["d74e87bca3fa2eb9" "04d085bb07bb3857" "b886602f9de07d6e" "b57c324aaa781800"]}
[{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["d74e87bca3fa2eb9" "ffc66442a5849acd" "ad43d75dcc79bc8a" "6fa02ad66793cf43"]}
{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn, :new-head 5}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["d74e87bca3fa2eb9" "e2162174653b4d7b" "0bd8763796243e1f" "f612d9fde86170a6"]}]
:case-af-grid-cell-move-reaches-a-swapped-copy-child
[{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["5a6af6c0b8f86ac3" "ec2294c072f52ae5" "8cd63b096547fb1b"]}