Regenerate the base, keep the hand work, and say when you cannot
The loop the layer design exists for, tested for the first time: correct a generated channel by hand, turn the generator's knob, and get the new base with the correction still on it. `replace-feature` already carried `:over` across — somebody anticipated this — so the feature path needed a test and not a fix. The head path needed a fix, and there was a second fault of my own making. `regenerate-head` leaves the head's authored channels alone once somebody has placed it by hand, and decided that by `(= (:channels old) (:measured old))`. Sound, until a correction exists: an `:over` layer makes those unequal, so the FIRST correction anyone made would have stopped the head following re-measurement for good — the exact opposite of what a layer is for. It compares the channels without their layers now. The test fails against the old guard, which is how I know the bug was real and not a story about one. The other fault was mine, from the commit before this one. An `:offset` whose shape does not match its base threw, which is right for authored data — the validator catches it — but WRONG for the case the model actually names: turn the mouth's `:verts` knob and the re-freeze gives it a different number of points, so a correction that was correct when it was made stops fitting through nobody's error, and a throw in the read path takes the stage down. So a base that has outgrown a correction is a CONFLICT, and a conflict is the third thing beside applied and discarded. The regeneration records `:conflict` on the layer; the layer stays exactly where it is; `over-at` skips it, so the picture is the base meanwhile; and `clip/conflicts` lists them for a view to offer. A later regeneration that restores the shape clears the mark, so resolving one can be as simple as putting the knob back. Deliberately NOT `problems`. A document with a conflict loads, evaluates and saves — it contains a decision nobody has made yet, and refusing to open it would be the persistence layer taking a side in an editing question. The distinction in the validator is one line: a shape mismatch nobody has recorded is an authoring bug, and one a regeneration recorded is a conflict. `channel/conflict-with` is the single rule for "can this layer apply to this base", used by the validator, by `conflicts`, and by the regeneration that marks them. Only `:offset` can conflict, since `:replace` states a whole value and has nothing to agree with; a shape that cannot be read yet — an empty key map — is not a disagreement. `value-shape` answers it without sampling anything. 414 tests, 5,696 assertions, and `:verts` in the test is a real topology change rather than a synthetic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
94c0a21de1
commit
72b57e3786
7 changed files with 297 additions and 11 deletions
|
|
@ -364,3 +364,45 @@
|
|||
with (assoc base :generated {:by :roto/lips-outer :params {:verts 8}})]
|
||||
(is (= (mapv #(ch/value-at base % store) (range 3))
|
||||
(mapv #(ch/value-at with % store) (range 3))))))
|
||||
|
||||
(deftest a-correction-the-base-outgrew-is-a-conflict-and-not-a-problem
|
||||
;; What a topology change does: a re-freeze gives the mouth a different number
|
||||
;; of points, and a correction that was right when it was made can no longer
|
||||
;; be added component by component. That is not a broken document — it is a
|
||||
;; decision waiting for a person.
|
||||
(let [pts (fn [n] {:animated? true :interp :hold :keys {0 (vec (repeat n 1))}})
|
||||
nudge (ch/layer :nudge [0 4] :offset (ch/framed [1 1 1 1]))
|
||||
fits (assoc (pts 4) :over [nudge])
|
||||
outgrown (assoc (pts 6) :over [nudge])]
|
||||
(is (nil? (ch/conflict-with (pts 4) nudge)))
|
||||
(is (empty? (ch/conflicts fits)))
|
||||
(is (empty? (ch/problems fits)))
|
||||
(is (re-find #"different shape" (ch/conflict-with (pts 6) nudge)))
|
||||
;; Unrecorded, it is an authoring bug and says so.
|
||||
(is (seq (ch/problems outgrown)))
|
||||
(is (= [:nudge] (mapv :id (ch/conflicts outgrown))))
|
||||
;; Recorded, the document is sound and the correction is simply not applied.
|
||||
(let [marked (assoc (pts 6) :over [(assoc nudge :conflict "outgrown")])]
|
||||
(is (empty? (ch/problems marked)) "a recorded conflict is not a reason not to load")
|
||||
(is (= [:nudge] (mapv :id (ch/conflicts marked))))
|
||||
(is (= [1 1 1 1 1 1] (vec (ch/value-at marked 0 nil)))
|
||||
"the base alone — neither misapplied nor silently dropped")
|
||||
(is (= (ch/value-at marked 0 nil) (first (via-cursor marked [0])))
|
||||
"and the cursor skips it too"))))
|
||||
|
||||
(deftest replace-never-conflicts-and-an-opaque-value-cannot-be-offset
|
||||
(let [flag (ch/keyed {0 true} :hold)]
|
||||
(is (nil? (ch/conflict-with flag (ch/layer :r [0 2] :replace (ch/framed false))))
|
||||
"replace states a whole value, so it has nothing to agree with")
|
||||
(is (re-find #"not a number" (ch/conflict-with flag (ch/layer :o [0 2] :offset (ch/framed 1)))))
|
||||
(is (nil? (ch/conflict-with (ch/keyed {} :hold) (ch/layer :o [0 2] :offset (ch/framed 1))))
|
||||
"an empty key map is not a disagreement")))
|
||||
|
||||
(deftest the-shape-of-a-channels-values-is-readable-without-sampling-it
|
||||
(is (= :scalar (ch/value-shape (ch/framed 3))))
|
||||
(is (= 2 (ch/value-shape (ch/framed [1 2]))))
|
||||
(is (= :scalar (ch/value-shape {:animated? true :dense {:stride 1}})))
|
||||
(is (= 40 (ch/value-shape {:animated? true :dense {:stride 40}})))
|
||||
(is (= 2 (ch/value-shape (ch/keyed {0 [1 2], 4 [3 4]} :linear))))
|
||||
(is (= :opaque (ch/value-shape (ch/keyed {0 :a} :hold))))
|
||||
(is (nil? (ch/value-shape (ch/keyed {} :hold))) "nothing to read it off"))
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[clojure.walk :as walk]
|
||||
[arthur.demo.stage :as stage]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.domain.project :as project]
|
||||
|
|
@ -256,3 +257,98 @@
|
|||
(doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)]
|
||||
(is (contains? nodes (:linked-to n))
|
||||
(str "the voice " (:id n) " still links to a node that is there")))))
|
||||
|
||||
;; ---- corrections survive the thing they are corrections to ----
|
||||
|
||||
(defn- corrected
|
||||
"Put one offset correction on a node's channel, as a hand edit would."
|
||||
[entry node path values]
|
||||
(update-in entry [:clip :symbols :face-1 :nodes node :channels path :over]
|
||||
(fnil conj []) (ch/layer :by-hand [2 6] :offset values)))
|
||||
|
||||
(deftest regenerating-replaces-the-base-and-keeps-the-hand-correction
|
||||
;; The loop the whole layer design exists for: generate motion, correct it by
|
||||
;; hand, turn the generator's knob, keep the correction.
|
||||
(let [width (count (:keys (channel @initial :iris-r [:xform :pos])))
|
||||
before (corrected @initial :iris-r [:xform :pos] (ch/framed [3 -3]))
|
||||
after (regenerate/change before
|
||||
{:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2})
|
||||
base (fn [entry] (dissoc (channel entry :iris-r [:xform :pos]) :over))]
|
||||
(is (not= (base before) (base after)) "the base was regenerated")
|
||||
(is (= (base (regenerate/change @initial
|
||||
{:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2}))
|
||||
(base after))
|
||||
"and regenerated to exactly what it would have been without the correction")
|
||||
(is (= [(ch/layer :by-hand [2 6] :offset (ch/framed [3 -3]))]
|
||||
(:over (channel after :iris-r [:xform :pos])))
|
||||
"while the correction came across untouched, and unconflicted")
|
||||
(is (empty? (ch/conflicts (channel after :iris-r [:xform :pos]))))
|
||||
(is (empty? (clip/problems (:clip after))))
|
||||
(is (= width (count (:keys (channel after :iris-r [:xform :pos]))))
|
||||
"sanity: this channel is keyed, so the correction rides a keyed base")))
|
||||
|
||||
(deftest a-correction-does-not-stop-the-head-following-its-measurement
|
||||
;; `regenerate-head` leaves the authored channels alone once somebody has
|
||||
;; PLACED the head by hand — but a correction is not a placement. Comparing
|
||||
;; the bases is what keeps the first correction from freezing the part it was
|
||||
;; made to adjust.
|
||||
(let [plain (regenerate/change @initial
|
||||
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
|
||||
path [:clip :symbols :face-1 :nodes :head]
|
||||
prop (first (keys (get-in @initial (conj path :measured))))
|
||||
shape (ch/value-shape (get-in @initial (conj path :channels prop)))
|
||||
nudge (ch/layer :by-hand [2 6] :offset
|
||||
(ch/framed (if (= :scalar shape) 1 (vec (repeat shape 0.5)))))
|
||||
before (update-in @initial (conj path :channels prop :over) (fnil conj []) nudge)
|
||||
after (regenerate/change before
|
||||
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
|
||||
base (fn [entry] (dissoc (get-in entry (conj path :channels prop)) :over))]
|
||||
(is (= (base plain) (base after))
|
||||
"the head's base followed the re-measurement, correction and all")
|
||||
(is (= [nudge] (:over (get-in after (conj path :channels prop))))
|
||||
"and the correction is the one that was made, unmarked")
|
||||
(is (empty? (ch/conflicts (get-in after (conj path :channels prop)))))
|
||||
(is (empty? (clip/problems (:clip after))))))
|
||||
|
||||
(deftest a-regeneration-that-outgrows-a-correction-records-the-conflict
|
||||
;; `:verts` is the mouth's vertex count, so turning it IS the topology change
|
||||
;; the lane model names. A geometry correction is a row of components, and a
|
||||
;; base with a different number of them cannot take it. Marked, not dropped:
|
||||
;; the hand work stays in the document for a person to move, and the picture
|
||||
;; meanwhile is the base.
|
||||
(let [path [:geom :pts]
|
||||
fitted (fn [entry]
|
||||
(ch/layer :by-hand [2 6] :offset
|
||||
(ch/framed (vec (repeat (ch/value-shape (channel entry :mouth path))
|
||||
0.5)))))
|
||||
with (fn [layer] (update-in @initial
|
||||
[:clip :symbols :face-1 :nodes :mouth :channels path :over]
|
||||
(fnil conj []) layer))
|
||||
before (with (fitted @initial))
|
||||
after (regenerate/change before
|
||||
{:scope :feature :id :face-1/mouth :knob :verts :value 10})
|
||||
layer (first (:over (channel after :mouth path)))]
|
||||
(is (not= (ch/value-shape (channel @initial :mouth path))
|
||||
(ch/value-shape (channel after :mouth path)))
|
||||
"the mouth really does have a different number of points now")
|
||||
(is (= :by-hand (:id layer)) "the correction is still in the document")
|
||||
(is (re-find #"different shape" (:conflict layer)))
|
||||
(is (= [:by-hand] (mapv :id (ch/conflicts (channel after :mouth path)))))
|
||||
(is (empty? (clip/problems (:clip after)))
|
||||
"a recorded conflict does not make the document unloadable")
|
||||
(is (= (dissoc (channel (regenerate/change @initial
|
||||
{:scope :feature :id :face-1/mouth :knob :verts :value 10})
|
||||
:mouth path)
|
||||
:over)
|
||||
(dissoc (channel after :mouth path) :over))
|
||||
"and the base is what it would have been with no correction at all")
|
||||
;; And the document says so once, for a view to offer.
|
||||
(is (= [{:id :by-hand :symbol :face-1 :node :mouth :channel [:geom :pts]}]
|
||||
(mapv #(dissoc % :why) (clip/conflicts (:clip after)))))
|
||||
;; A mouth edit that does not change the vertex count leaves it applying.
|
||||
(let [fine (regenerate/change before
|
||||
{:scope :feature :id :face-1/mouth :knob :aperture-cut :value 0.2})]
|
||||
(is (nil? (:conflict (first (:over (channel fine :mouth path))))))
|
||||
(is (empty? (ch/conflicts (channel fine :mouth path))))
|
||||
(is (empty? (clip/conflicts (:clip fine))))
|
||||
(is (empty? (clip/problems (:clip fine)))))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue