🐛 Compose child flip flags through parent placement

Keep the source and parent-frame flip composition when placing component
content through a rotated parent. A fresh large matrix turn can otherwise
set both child flip flags even though the user only rotated (F170).
Preserve nil/false representation when the parent does not flip an axis.

The portable regression fails two flag assertions before the fix. Eight
geometry tests pass 61 assertions, the original library shrink passes, and
the pure golden master matches all 1149 assertions without recording.
Common and frontend lint and format pass.

Benchmark ratios against Phase 1e: main 10/100/1000 copies 0.58/0.74/0.57,
three levels 0.49, chain 0.49, library 0.52, many shapes 0.47, variant
switch 0.61. No regression flags.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-24 09:53:30 +02:00
parent 3e36275f96
commit 1e9d19b214
3 changed files with 34 additions and 5 deletions

View File

@ -20,6 +20,14 @@ After a geometric mutation, equivalent fields such as `:y`, `(:y :selrect)`, and
- `gsh/transform-shape`: applies a full transform.
- `cts/setup-shape`: initializes geometry for new shapes; variant test helpers such as `thv/add-variant-with-child` use it.
## Flip flags through parent placement
When composing a parent's orientation onto content, compose flip flags from
the source shape and the origin/target parent frames. Matrix decomposition
can set both flags during a large rotation, even when several smaller
rotations authored neither flip (F170). Keep the original nil/false value
when the parent frames do not change that axis's flip.
## Edits that break the invariant
- `(assoc shape :x ...)` or `(assoc shape :y ...)`: updates only one field and leaves `:selrect` / `:points` stale.
@ -45,4 +53,4 @@ Using `(ths/update-shape file label :y 101)` leaves `:selrect.y` stale. Downstre
When a copy shape changes geometry through the proper pipeline (`set-shape-attr` via `process-operation :set`), `:touched` gains `:geometry-group` unless ignored. Tests can either drive the production update with `cls/generate-update-shapes`, or inject `(assoc shape :touched #{:geometry-group})` when only touched state matters.
If a test needs both a new position and touched state, move the shape first with geometry-preserving helpers, then inject or assert touched state.
If a test needs both a new position and touched state, move the shape first with geometry-preserving helpers, then inject or assert touched state.

View File

@ -276,10 +276,16 @@
main-container
(let [transform (gmt/transform-in (gsh/shape->center shape-main) relative)
angle (- (or (:rotation target) 0) (or (:rotation origin) 0))
turn #(-> %
(assoc-in [:modifiers :rotation] angle)
(gsh/apply-transform transform)
(dissoc :modifiers))]
flip (fn [shape key]
(cond-> (get shape key)
(not= (boolean (get origin key)) (boolean (get target key))) not))
turn (fn [shape]
(-> shape
(assoc-in [:modifiers :rotation] angle)
(gsh/apply-transform transform)
(dissoc :modifiers)
(assoc :flip-x (flip shape :flip-x)
:flip-y (flip shape :flip-y))))]
(update main-container :objects
(fn [objects]
(reduce #(update %1 %2 turn) objects

View File

@ -213,3 +213,18 @@
after (get-in (thf/current-page synced) [:objects (:id child)])]
(t/is (= (select-keys child [:flip-x :flip-y]) (select-keys after [:flip-x :flip-y])))
(t/is (gsh/close-attrs? :points (:points child) (:points after) 0.001))))
(t/deftest update-main-keeps-child-flips-when-syncing-through-a-rotated-parent
(let [updated (-> (setup/nested-component-with-copy)
(r/step (n/rotate :copy-root 45)) r/settle
(r/step (n/rotate :copy-root 90)) r/settle
(r/step (n/update-main :copy-root)) r/settle)
file (tm/file updated)
main (tm/resolve-shape updated :main2-nested-head)
child (tm/shape-by-id updated (first (:shapes main)))
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])]
(t/is (= (flips shape) (flips after)))
(t/is (gsh/close-attrs? :points (:points shape) (:points after) 0.001))))))