🐛 Carry child geometry through parent rotations received by sync

When sync turns a parent, turn its subtree as derived placement before
walking the children. Geometry overrides keep their size and relative
angle, including swapped children. Read current builder objects throughout
the walk so later syncs do not transform stale records again.

Normalize matrix axis directions for this turn: constraints already handle
resize, and minimum-size matrix scale must not resize children twice.

Portable regression fails for ordinary and swapped geometry overrides.
Expanded controls cover parent turns of 45/135 degrees and authored child
angles of 0/10 degrees, points, flags, retained size, repeat sync and undo.
Broad focused/golden checks: 204 tests, 2314 assertions. Latest controls
and shrunk program: 2 tests, 89 assertions. Goldens remain unchanged.
Initialize the container when direct sync receives an empty builder.
Register the geometry namespace in the JavaScript runner and read record
fields with get in the older flip control. Geometry: 9 tests, 149
assertions on both runtimes. Full common JS: 1768 tests, 29215 assertions.
Full frontend: 1532 tests, 9039 assertions. Lint and formatting pass.
Live WASM verifies both roles, retained placement and later style sync.

Component benchmark ratios: mains 0.51/0.56/0.64; levels 0.62;
chain 0.55; library 0.53; many shapes 0.45; variant switch 0.60.
All worlds pass the unchanged baseline gate.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-24 15:55:39 +02:00
parent 2ea74b7ad1
commit cfb800620c
3 changed files with 94 additions and 4 deletions

View File

@ -206,7 +206,7 @@
:components (generate-sync-shape-direct changes
(get libraries current-file-id)
libraries
container
(assoc container :objects objects')
(:id shape')
false)
:colors (clla/generate-sync-colors changes library-id shape' libraries)
@ -297,7 +297,12 @@
[changes file libraries container shape-id reset?]
(shape-log :debug shape-id container
:msg "Sync shape direct" :shape-inst (str shape-id) :reset? reset?)
(let [shape-inst (ctn/get-shape container shape-id)
(let [changes (if (pcb/get-objects changes)
changes
(-> changes
(pcb/with-container container)
(pcb/with-objects (:objects container))))
shape-inst (ctn/get-shape container shape-id)
library (dm/get-in libraries [(:component-file shape-inst) :data])
component (ctkl/get-component library (:component-id shape-inst) true)]
(if (and (ctk/in-component-copy? shape-inst)
@ -399,6 +404,31 @@
(grc/rect->center (:selrect (get objects (:id shape-main)))))]
(reduce #(update %1 %2 gsh/move delta) objects ids)))
(defn- sync-descendant-orientation
"A parent turned by sync carries its whole subtree, including geometry
overrides. Resize remains the job of constraints, not matrix scale."
[changes container before]
(let [after (get (pcb/get-objects changes) (:id before))
changed? (and after
(or (not (mth/close? (or (:rotation before) 0) (or (:rotation after) 0) 1e-10))
(not= (boolean (:flip-x before)) (boolean (:flip-x after)))
(not= (boolean (:flip-y before)) (boolean (:flip-y after)))))
ids (when changed? (cfh/get-children-ids (:objects container) (:id before)))]
(if (seq ids)
(let [orientation (fn [shape]
(let [{:keys [a b c d]} (or (:transform shape) (gmt/matrix))
horizontal (gpt/unit (gpt/point a b))
vertical (gpt/unit (gpt/point c d))
matrix (gmt/matrix (:x horizontal) (:y horizontal)
(:x vertical) (:y vertical) 0 0)]
(assoc shape :transform matrix :transform-inverse (gmt/inverse matrix))))
turn (orient-like (orientation before) (orientation after) (gsh/shape->center before))
delta (gpt/subtract (gsh/shape->center after) (gsh/shape->center before))]
(pcb/update-shapes changes ids
#(cond-> % (some? turn) turn :always (gsh/move delta))
{:ignore-touched true}))
changes)))
(defn- sync-copy-recursive
"Sync the copy shape `shape-inst` from `shape-main`, the main shape it
pairs with, then pair their children (`compare-children`) and walk each
@ -427,10 +457,15 @@
(update :objects placed-like shape-main placed))
ctx (assoc ctx :main-container main-container)
shape-main (get-in main-container [:objects (:id shape-main)])
changes (-> changes
(sync-copy-shape ctx shape-inst shape-main)
(sync-descendant-orientation container shape-inst))
container (assoc container :objects (pcb/get-objects changes))
ctx (assoc ctx :container container)
shape-inst (ctn/get-shape container (:id shape-inst))
children-inst (vec (ctn/get-direct-children container shape-inst))
children-main (vec (ctn/get-direct-children main-container shape-main))]
(cond-> (-> changes
(sync-copy-shape ctx shape-inst shape-main)
(compare-children
children-inst
children-main

View File

@ -13,6 +13,7 @@
[app.common.files.changes-builder :as pcb]
[app.common.geom.matrix :as gmt]
[app.common.geom.modifiers :as gm]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.logic.placement :as clp]
@ -35,6 +36,58 @@
(t/use-fixtures :each thi/test-fixture)
(t/deftest authored-child-geometry-follows-a-parent-rotation-received-through-sync
(doseq [swapped? [false true]
angle [45 135]
own-angle [0 10]]
(let [file (-> (thf/sample-file :file)
(tho/add-simple-component :a :main-a :rect-a
:root-params {:width 100 :height 100})
(tho/add-simple-component :other :main-other :rect-other
:root-params {:width 100 :height 100})
(tho/add-frame :main-b {:width 200 :height 200})
(thc/instantiate-component :a :b-a :parent-label :main-b)
(thc/make-component :b :main-b)
(tho/add-frame :main-c {:width 300 :height 300})
(thc/instantiate-component :b :c-b :parent-label :main-c)
(thc/make-component :c :main-c)
(thc/instantiate-component :c :copy-c :children-labels [:copy-b :copy-a]))
initial (tm/make-situation file)
initial (cond-> initial swapped? (r/step (n/swap-shape :copy-a :other)))
edited (-> initial r/settle (r/step (n/change-height :copy-a 80)) r/settle
(r/step (n/rotate :copy-a own-angle)) r/settle)
pending (r/step edited (n/rotate :c-b angle))
pending-file (tm/file pending)
bare-changes (cll/generate-sync-shape-direct
(pcb/empty-changes) pending-file {(:id pending-file) pending-file}
(ctn/make-container (thf/current-page pending-file) :page)
(thi/id :copy-c) false)
bare-file (thf/apply-changes pending-file bare-changes)
turned (r/settle pending)
parent (tm/resolve-shape turned :copy-b)
child (tm/resolve-shape turned :copy-a)
old-parent (tm/resolve-shape edited :copy-b)
old-child (tm/resolve-shape edited :copy-a)
expected (-> old-child
(gsh/transform-shape (ctm/rotation-modifiers old-child (gsh/shape->center old-parent) angle))
(gsh/move (gpt/subtract (gsh/shape->center parent) (gsh/shape->center old-parent))))
file (tm/file turned)
full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))]
(t/is (mth/close? angle (:rotation parent) 0.001))
(t/is (mth/close? (+ angle own-angle) (:rotation child) 0.001))
(t/is (= (boolean (:flip-x parent)) (boolean (:flip-x child))))
(t/is (= (boolean (:flip-y parent)) (boolean (:flip-y child))))
(t/is (mth/close? 80 (:height child) 0.001))
(t/is (gsh/close-attrs? :points (:points expected) (:points child) 0.001))
(t/is (gsh/close-attrs? :points (:points expected)
(:points (ths/get-shape-by-id bare-file (:id child))) 0.001))
(t/is (mth/close? (+ angle own-angle) (:rotation (ths/get-shape-by-id bare-file (:id child))) 0.001))
(t/is (= (if swapped? #{:geometry-group :swap-group} #{:geometry-group}) (:touched child)))
(t/is (gsh/close-attrs? :points (:points child)
(:points (ths/get-shape-by-id full (:id child))) 0.001))
(t/is (= (thn/normalize-file (tm/file edited))
(thn/normalize-file (tm/file (r/step turned (n/undo)))))))))
(t/deftest a-nested-copy-keeps-its-detached-parents-orientation-on-sync
(let [detached (-> (setup/nested-component-with-copy)
(r/step (n/rotate :copy-root 90)) r/settle
@ -225,6 +278,6 @@
synced (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))]
(doseq [shape [main child]]
(let [after (ths/get-shape-by-id synced (:id shape))
flips #(mapv (comp boolean %) [:flip-x :flip-y])]
flips (fn [shape] (mapv #(boolean (get shape %)) [:flip-x :flip-y]))]
(t/is (= (flips shape) (flips after)))
(t/is (gsh/close-attrs? :points (:points shape) (:points after) 0.001))))))

View File

@ -63,6 +63,7 @@
[common-tests.logic.chained-propagation-test]
[common-tests.logic.comp-creation-test]
[common-tests.logic.comp-detach-with-nested-test]
[common-tests.logic.comp-expected-geometry-test]
[common-tests.logic.comp-flex-interactions-test]
[common-tests.logic.comp-main-edit-breaks-copy-slots-test]
[common-tests.logic.comp-move-swapped-copies-test]
@ -194,6 +195,7 @@
'common-tests.logic.chained-propagation-test
'common-tests.logic.comp-creation-test
'common-tests.logic.comp-detach-with-nested-test
'common-tests.logic.comp-expected-geometry-test
'common-tests.logic.comp-flex-interactions-test
'common-tests.logic.comp-main-edit-breaks-copy-slots-test
'common-tests.logic.comp-move-swapped-copies-test