🐛 Match component orientation with its relative transform

Apply the relative orientation matrix directly. Large rotations also
change flip flags, so applying flips before rotation can add 180 degrees.
The text regression fails before this fix at 135 and 225 degrees.

Validation: geometry suite, 4 tests / 50 assertions; existing golden
cases and worlds, 1098 assertions. No existing golden digest changed.

Benchmark lower-quantile ratios against Phase 1e:
main 10/100/1000 copies 0.51/0.59/0.58; three levels 0.53;
chain 0.61; library 0.53; many shapes 0.54; variant switch 0.70.
No regression flag.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-23 09:40:49 +02:00
parent 4f7e9f7e19
commit 705dbd296e
2 changed files with 43 additions and 43 deletions

View File

@ -1401,26 +1401,23 @@
(dissoc :modifiers))))))))
(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."
"Turn the subtree of `head` around `center` to match `target`'s matrix.
Flip flags can change during a rotation, so they cannot be applied as
separate transforms before that rotation."
[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))))))))
(when-not (and (= (:transform head) (:transform target))
(mth/close? (or (:rotation head) 0) (or (:rotation target) 0) 0.01))
(let [relative (gmt/multiply (or (:transform target) (gmt/matrix))
(or (:transform-inverse head) (gmt/matrix)))
transform (gmt/transform-in center relative)
turned (gsh/apply-transform head transform)
angle (- (or (:rotation target) 0) (or (:rotation turned) 0))]
(when (or (not (gmt/unit? relative)) (not (mth/close? (mod angle 360) 0 0.01)))
(fn [shape]
(-> shape
(assoc-in [:modifiers :rotation] angle)
(gsh/apply-transform transform)
(dissoc :modifiers)))))))
(defn- maybe-sync
[c enabled? done? f]

View File

@ -11,6 +11,7 @@
through their constraints (decision (f) of the component contract)."
(:require
[app.common.files.changes-builder :as pcb]
[app.common.geom.matrix :as gmt]
[app.common.geom.modifiers :as gm]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
@ -147,27 +148,29 @@
(t/deftest rotating-a-copy-with-text-in-a-detached-child-is-already-synced
(let [setup (fn []
(tm/make-situation
(-> (thf/sample-file :file1)
(thtx/add-text-component-with-copy [[["Hello world"]]])
(ths/update-shape :main-child :grow-type :auto-height)
(ths/update-shape :copy-child :grow-type :auto-height)
(ths/update-shape :copy-child :touched nil)
(tho/add-simple-component :outer :outer-root :outer-child
:root-params {:width 100 :height 100})
(thc/instantiate-component :outer :outer-copy))))
situation (r/run-variant
{:setup setup
:operation (tm/in-sequence [(n/nest-copy :copy-root :outer-root)
(n/detach :copy-root)
(n/rotate :outer-copy 45)])})
file (tm/file situation)
text (first (filter #(= (thi/id :copy-child) (:shape-ref %))
(vals (:objects (thf/current-page file)))))
full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))
synced (ths/get-shape-by-id full (:id text))]
(t/is (some? text))
(t/is (gsh/close-attrs? :points (:points text) (:points synced) 0.01))
(t/is (mth/close? (:x text) (:x synced) 0.01))
(t/is (mth/close? (:y text) (:y synced) 0.01))))
(doseq [angle [45 135 225]]
(let [setup (fn []
(tm/make-situation
(-> (thf/sample-file :file1)
(thtx/add-text-component-with-copy [[["Hello world"]]])
(ths/update-shape :main-child :grow-type :auto-height)
(ths/update-shape :copy-child :grow-type :auto-height)
(ths/update-shape :copy-child :touched nil)
(tho/add-simple-component :outer :outer-root :outer-child
:root-params {:width 100 :height 100})
(thc/instantiate-component :outer :outer-copy))))
situation (r/run-variant
{:setup setup
:operation (tm/in-sequence [(n/nest-copy :copy-root :outer-root)
(n/detach :copy-root)
(n/rotate :outer-copy angle)])})
file (tm/file situation)
text (first (filter #(= (thi/id :copy-child) (:shape-ref %))
(vals (:objects (thf/current-page file)))))
full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))
synced (ths/get-shape-by-id full (:id text))]
(t/is (some? text))
(t/is (gmt/close? (:transform text) (:transform synced)))
(t/is (gsh/close-attrs? :points (:points text) (:points synced) 0.01))
(t/is (mth/close? (:x text) (:x synced) 0.01))
(t/is (mth/close? (:y text) (:y synced) 0.01)))))