From a4ce750be287c2ff973171fce50f3be9da06ecfa Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 1 Oct 2026 01:25:37 -0400 Subject: [PATCH] The face owns its placement, not the take that holds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-to-stage mapping moves off :main's :face group and onto each face's own :place, above its head. `face-placement` computes exactly what it computed before, over every subject together, so two faces filmed side by side keep their filmed relation — it is written into each face instead of onto a group above them all. Same transform, same subtree, one level lower, and the composite is identical to the pixel: a digest over every op :main emits across the whole take is unchanged either way. THE OWNER IS THE POINT. A face carrying its own mapping is the right size wherever it is put — dropped into another symbol, or opened in its own tab to be drawn over — and the take that holds it needs to know nothing. On a group above the instances the scale belonged to the take, so a face taken out of it had no size at all and drew at a fraction of a pixel. The pool's thumbnails drop the workaround that knew about this: a symbol is rendered rooted at itself again, because a face now carries the placement that makes that honest, so the pool needs to know nothing about where a symbol happens to be used. `domain/node` and `arthur.export` leave its requires with it. The tests here were reading the placement off :main. The photo registration test changes shape rather than location: its premise was that face-1's head is its own root, so a photo sitting where it was filmed was image pixels over image height and nothing else. The head still cancels — that is what the test is about — but it now cancels against the face's own placement, which is why the photo comes with the face into its own tab instead of sitting at a fraction of a pixel beside it. Co-Authored-By: Claude Opus 5 --- docs/animation-model.md | 4 +- docs/port-plan.md | 5 +- docs/timing-model.md | 2 +- frontend/src/arthur/flow/freeze.cljs | 47 +++++++++++----- frontend/src/arthur/ui/pool.cljs | 41 +++----------- frontend/test/arthur/domain/trace_test.cljs | 28 +++++++--- frontend/test/arthur/flow/freeze_test.cljs | 54 ++++++++++--------- .../test/arthur/flow/multi_face_test.cljs | 2 +- .../test/arthur/flow/regenerate_test.cljs | 8 +-- 9 files changed, 106 insertions(+), 85 deletions(-) diff --git a/docs/animation-model.md b/docs/animation-model.md index 647f798..9b16489 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -434,7 +434,7 @@ selected head placement, so it aligns with the vectors drawn over it. :mouth :mouth-in :teeth :lid-r :lid-l :brow-r :brow-l … ``` -Changing anchor keys edits `:head` and never touches `:face`, so it cannot move +Changing anchor keys edits `:head` and never touches `:place`, so it cannot move something that was placed by hand. A group node is free, and keeping the authored and the measured transform apart is the whole reason the transform is decomposed in the first place. @@ -679,7 +679,7 @@ Proof that it covers what exists, not just what is wanted: | square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` | | brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** | | head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames | -| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on `:face`; the stage clips | +| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on the face's own `:place`; the stage clips | | `stabilize` transforms | dense `[:xform :*]` on `:head`, read through its optional `:anchors` map | | registered underlay | not data — a UI layer riding `(world-of resolver :head)` | | painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed | diff --git a/docs/port-plan.md b/docs/port-plan.md index f9a67e3..6556278 100644 --- a/docs/port-plan.md +++ b/docs/port-plan.md @@ -333,11 +333,12 @@ per-frame header. numbers: it centres on the face oval's bbox and zooms until the face is 80% of the raster height, so every vertex carries a cropping decision made once from one frame's landmarks. Dropping it is a deletion. Placement becomes `[:xform :*]` on -an authored `:face` node, the stage clips whatever hangs off, and project +an authored `:place` node inside the face, the stage clips whatever hangs off, +and project dimensions stop being tied to the footage. See "What space geometry is in" in `docs/animation-model.md`. -The anchor transform freezes onto `:head`, one level under `:face`, and the +The anchor transform freezes onto `:head`, one level under `:place`, and the normalise on/off/per-plate toggle is which of the three channel shapes that node carries. Always measure and always store factored, whatever the toggle says: smoothing and velocity-minimum key selection both require the split to exist in diff --git a/docs/timing-model.md b/docs/timing-model.md index b8fe700..029132d 100644 --- a/docs/timing-model.md +++ b/docs/timing-model.md @@ -39,7 +39,7 @@ measurements without losing the anchor choices. A source image used for tracing should be registered with that image's measured stabilizing transform, then the selected head transform, then the authored -`:face` placement. This makes the photo and head-local vectors share the same +`:place` placement the face carries. This makes the photo and head-local vectors share the same orientation and position. Tracing-photo selection is a separate editor address; it does not choose the head anchor. diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index ab989a4..9933e2d 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -25,7 +25,8 @@ oval's bbox and zooms until the face is 80% of the raster height, so every stored vertex carries a cropping decision made once, at analysis time, from one frame's landmarks. Here the geometry stays in the node's own local space and the - framing is `[:xform :*]` on an authored `:face` node, which the stage clips. + framing is `[:xform :*]` on each face's own authored `:place` node, which the + stage clips. Project dimensions are therefore independent of the footage — see `face-placement`, and \"What space geometry is in\" in docs/animation-model.md. @@ -396,7 +397,8 @@ The synthetic take uses the reference rigid configuration for its default. Real footage can request `:fit-motion?`: its default fits the observed mouth, eyes and brows in the stage across the shot. - Both are ordinary editable transforms on :face, never baked into the geometry. + Both are ordinary editable transforms on the face's own `:place`, never baked + into the geometry. The face oval is not measured, because its only consumers in the prototype were the old baked framing transform and the placeholder plate outline. @@ -419,8 +421,18 @@ leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which is not a feature to add: every fill in `domain/raster` clamps already. - All subjects share one source-to-stage mapping on the :face group. Each - instance can then be placed independently with ordinary transform channels." + ONE MAPPING, COMPUTED OVER EVERY SUBJECT AND WRITTEN INTO EACH FACE. It is + measured across all of them together — which is what keeps two faces filmed + side by side in their filmed relation — and then stored on each face's own + `:place` rather than on a group above them all. Same transform, same subtree, + one level lower: the composite is identical to the pixel. + + THE OWNER IS THE POINT. A face carrying its own source-to-stage mapping is a + face that is the right size wherever it is put — dropped into another symbol, + or opened in its own tab to be drawn over — and the symbol that places it needs + to know nothing. On a group above the instances the scale belonged to the take, + so a face taken out of that take had no size at all and drew at a fraction of a + pixel." [{:keys [stage fit-motion?]} subjects] (let [[w h] stage inputs (vals subjects)] @@ -646,6 +658,17 @@ ;; --------------------------------------------------------------------------- ;; the clip +(defn- place-in + "Put the source-to-stage mapping on symbol `sym`'s own `:place`, above its head. + + `:place` is the one authored node a freeze leaves on a face, and `face-placement` + says why it is a default rather than a measurement." + [sym channels] + (-> sym + (assoc-in [:nodes :place] {:id :place :name "source placement" :kind :group + :z "a1" :channels channels}) + (assoc-in [:nodes :head :parent] :place))) + (defn- subject-part "A subject's drawing, metadata and blocks. Node names are symbol-local." [params subject {:keys [outer eyes brows teeth] :as inputs}] @@ -727,10 +750,11 @@ [{:keys [name fps stage expose] :as params} subjects] (when-not (and (map? subjects) (seq subjects) (every? keyword? (keys subjects)) - (not-any? #{:main :root :face} (keys subjects))) - (throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {}))) + (not-any? #{:main :root :place} (keys subjects))) + (throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :place" {}))) (let [ordered (sort-by (comp str key) subjects) parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered) + placement (face-placement params subjects) lengths (distinct (map #(get-in % [1 :symbol :frames]) parts)) _ (when-not (and (= 1 (count lengths)) (pos? (first lengths))) (throw (ex-info "subjects need the same positive frame count" @@ -745,17 +769,16 @@ (into {:main {:id :main :frames nf :nodes (into {:root {:id :root :name "clip" :kind :group :z "a1" - :time {:mode :map :expose expose}} - :face {:id :face :name "source placement" :kind :group - :parent :root :z "a1" - :channels (face-placement params subjects)}} + :time {:mode :map :expose expose}}} (map-indexed (fn [i [id _]] - [id {:id id :kind :instance :parent :face + [id {:id id :kind :instance :parent :root :z (str "a" i) :source {:symbol id}}])) ordered)}} - (map (fn [[id part]] [id (:symbol part)])) parts)}] + (map (fn [[id part]] + [id (place-in (:symbol part) placement)])) + parts)}] (doseq [[subject inputs] ordered [id track] (:presence inputs)] (when-not (and (= nf (count track)) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index 852bed2..d746fee 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -45,9 +45,7 @@ footage stay separate records on the server, so dropping the same file twice does not decode it twice." (:require [arthur.domain.clip :as clip] - [arthur.domain.node :as node] [arthur.domain.raster :as raster] - [arthur.export :as export] [arthur.events.footage :as footage] [arthur.events.playback :as pb] [arthur.events.project :as project] @@ -74,35 +72,16 @@ ;; cache's. (atom {:clip nil :urls {}})) -(defn- placed-in - "Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing - places. Lowest ids first, so a symbol placed several times gets one picture and - the same one every time." - [document sid] - (first (for [h (sort-by str (keys (:symbols document))) - n (sort-by str (keys (get-in document [:symbols h :nodes]))) - :when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)] - [h n]))) - (defn- draw-symbol "`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser the stage uses — so the picture in the pool is the picture, not a sketch of it. - THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety. - Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped - face is stored head-local in units of one image height, so its numbers are - around 0..1 and the source-to-stage scale that turns them into stage pixels — - several hundred — lives on the `:face` group of the symbol that PLACES it. See - `freeze/face-placement` and \"What space geometry is in\" in - docs/animation-model.md. Rendered rooted at itself, a face is therefore correct - and under a pixel across, which is a true picture of nothing anybody wants to - look at. - - So a placed symbol is drawn where it is placed, with everything else in that - host isolated away — `export/isolate`, whose docstring draws the same - distinction for the same reason. A symbol nothing places has no placement to - borrow and is rendered rooted at itself, which for the main timeline is exactly - right because the stage is its own. + ROOTED AT THE SYMBOL, which is only honest because a symbol carries its own + placement. A face's geometry is head-local in units of one image height, and + the source-to-stage mapping that turns those into stage pixels lives on the + face's own `:place` — see `freeze/face-placement`. So a face drawn by itself is + the size it is on the stage, and this needs to know nothing about where it + happens to be used. nil rather than a throw when the frame will not resolve — a cycle, a missing block, an instance naming a symbol that has gone. A pool is a list of names, @@ -116,12 +95,8 @@ [document sid store palette ramp] (try (when (clip/symbol document sid) - (let [[host id] (placed-in document sid) - document (cond-> document - host (update-in [:symbols host] export/isolate id)) - root (or host sid) - resolve (clip/resolver document root store palette {}) - [w h] (clip/stage document root)] + (let [resolve (clip/resolver document sid store palette {}) + [w h] (clip/stage document sid)] (-> (raster/make w h) (raster/clear! (get palette :bg 0)) (raster/draw-ops! (resolve 0)) diff --git a/frontend/test/arthur/domain/trace_test.cljs b/frontend/test/arthur/domain/trace_test.cljs index 00cf994..e0ccbc9 100644 --- a/frontend/test/arthur/domain/trace_test.cljs +++ b/frontend/test/arthur/domain/trace_test.cljs @@ -4,6 +4,7 @@ [arthur.domain.clip :as clip] [arthur.domain.leaf :as leaf] [arthur.domain.nest :as nest] + [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol] [arthur.domain.trace :as trace] @@ -58,15 +59,30 @@ (vec (array-seq (trace/photo-matrix (symbol/world-of r :head) h @store (trace/photo-frame (trace/of h) f) 1000))))) +(defn- filmed + "Where a photo sitting exactly where it was filmed must land: the face's OWN + placement, over image height, and nothing else. + + `:place` carries the source-to-stage mapping now, above `:head` — so a face + opened in its own tab draws at the size it is on the stage, and its photo has + to come with it. That is the whole reason the mapping belongs to the face: the + head still cancels out, which is what these assertions are about, but it + cancels against a placement rather than against nothing." + [c f] + (let [r (symbol/resolver (clip/symbol c :face-1) @store pal/index-of nil)] + (r f) + (vec (array-seq (node/mul! (node/mat) (symbol/world-of r :place) + (js/Float64Array. #js [0.001 0 0 0.001 0 0])))))) + (deftest the-photo-registers-to-the-face-it-was-filmed-with - ;; Face-1 is its own symbol and its head is its root, so a photo sitting where - ;; it was filmed is image pixels over image height and nothing else. - (let [filmed [0.001 0 0 0.001 0 0]] - (is (near? filmed (photo-at (traced {:frames [] :origin :continuous}) 30)) + (let [a (traced {:frames [] :origin :continuous}) + b (traced {:frames [12] :origin :keys}) + d (traced {:frames [12] :origin :continuous})] + (is (near? (filmed a 30) (photo-at a 30)) "a head showing the photo's own frame cancels out") - (is (near? filmed (photo-at (traced {:frames [12] :origin :keys}) 30)) + (is (near? (filmed b 30) (photo-at b 30)) "held at the trace frame, face and photo both stand still") - (is (not (near? filmed (photo-at (traced {:frames [12] :origin :continuous}) 30))) + (is (not (near? (filmed d 30) (photo-at d 30))) "a continuous head carries the held photo along with it"))) (defn- wrapped diff --git a/frontend/test/arthur/flow/freeze_test.cljs b/frontend/test/arthur/flow/freeze_test.cljs index 1fe8139..2c62d8c 100644 --- a/frontend/test/arthur/flow/freeze_test.cljs +++ b/frontend/test/arthur/flow/freeze_test.cljs @@ -86,13 +86,19 @@ (is (empty? (clip/problems c)) (str spec ": " (pr-str (clip/problems c))))))) (deftest the-tree-is-the-one-the-model-specifies - (is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face))) + ;; :main HOLDS INSTANCES AND NOTHING ELSE. The source-to-stage placement used + ;; to be a `:face` group here, above them all; it is each face's own `:place` + ;; now, so the take is a container that knows nothing and a face is the right + ;; size wherever it is put. Same transform, same subtree, one level lower. + (is (= [:face-1 :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face-1))) + (is (nil? (get-in @clip* [:symbols :main :nodes :face]))) (is (= #{:face-1} (node/sources (get-in @clip* [:symbols :main :nodes :face-1])))) - (is (= [:head] (symbol/lineage (:nodes @sym*) :head))) - (is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth))) - (is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in))) + (is (= [:place] (symbol/lineage (:nodes @sym*) :place))) + (is (= [:head :place] (symbol/lineage (:nodes @sym*) :head))) + (is (= [:mouth :head :place] (symbol/lineage (:nodes @sym*) :mouth))) + (is (= [:mouth-in :mouth :head :place] (symbol/lineage (:nodes @sym*) :mouth-in))) (is (= {:mode :map :expose 2} (:time (node :root)))) - (is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in]))) + (is (every? #(nil? (:time (node %))) [:place :head :mouth :mouth-in]))) (deftest geometry-is-flat-and-dense-and-the-interior-shares-the-outline-s-block (doseq [id [:mouth :mouth-in]] @@ -121,11 +127,11 @@ (abs (- a b))))] (is (<= gap (/ q 2)) (str "worst quantisation error " gap " against a quantum of " q)) - ;; And in stage pixels, which is the unit anyone can judge. `:face`'s scale is + ;; And in stage pixels, which is the unit anyone can judge. `:place`'s scale is ;; stage px per image height, so the whole quantum is q·k — a twentieth of a ;; pixel at this placement, two orders below anything the rasteriser can ;; express, which is the argument for Int16 geometry stated as a measurement. - (let [k (first (:value (chan :face [:xform :scale])))] + (let [k (first (:value (chan :place [:xform :scale])))] (is (< (* q k) 0.1) (str "the quantum is " (* q k) " stage pixels at " k " px per image height")) (is (< (* gap k) (* q k)))))) @@ -190,15 +196,15 @@ (deftest head-local-geometry-composed-through-head-and-face-lands-on-the-stage ;; The end-to-end claim of the split, asserted against the OPS the resolver ;; actually emits rather than against an intermediate: stored head-local, put - ;; back through `:head`, placed by `:face`, the mouth is where the composition + ;; back through `:head`, placed by `:place`, the mouth is where the composition ;; of the two says it is. A test that recomputed the chain would only be ;; checking arithmetic against itself; this checks `node/local!`, `node/world!` ;; and `emit` as well. (let [c (freeze/head-mode {} @frozen) res (clip/resolver c :main @store pal/index-of nil) - k (first (:value (chan :face [:xform :scale]))) - anc (:value (chan :face [:xform :anchor])) - pos (:value (chan :face [:xform :pos])) + k (first (:value (chan :place [:xform :scale]))) + anc (:value (chan :place [:xform :anchor])) + pos (:value (chan :place [:xform :pos])) tfs (:transforms @take/measured)] (doseq [f (range 0 take/frames 13)] (let [ops (res f) @@ -217,7 +223,7 @@ ;; the fit removed the head's motion, so putting it back is the fit's ;; inverse. Node :head carries exactly this. im (geom/apply-sim (freeze/invert (nth tfs ef)) g) - ;; And where :face puts it: scaled about the anchor, then translated, + ;; And where :place puts it: scaled about the anchor, then translated, ;; which is p ↦ k(p - anchor) + anchor + pos. wx (+ (* k (- (:x im) (nth anc 0))) (nth anc 0) (nth pos 0)) wy (+ (* k (- (:y im) (nth anc 1))) (nth anc 1) (nth pos 1))] @@ -272,8 +278,8 @@ b (freeze/head-mode {:trace {:origin :start}} @frozen) c (freeze/head-mode {:trace {:origin :keys :frames [0 40]}} @frozen)] (doseq [x [b c]] - (is (= (get (nodes a) :face) (get (nodes x) :face)) - ":face moved") + (is (= (get (nodes a) :place) (get (nodes x) :place)) + ":place moved") (is (= (dissoc (nodes a) :head) (dissoc (nodes x) :head)) "a node other than :head changed") ;; The measurement does not go away when the head is locked: always measure, @@ -304,7 +310,7 @@ ;; transform on a node, which a hand can revise, and it claims no generator that ;; would offer to overwrite it. (doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]] - (let [c (get (node/channels (node :face)) path)] + (let [c (get (node/channels (node :place)) path)] (is (= :framed (ch/describe c)) (str path " is not framed")) (is (nil? (:generated c)) (str path " claims provenance"))))) @@ -324,8 +330,8 @@ (let [c @clip*] (doseq [sid [:main :face-1] id (keys (get-in c [:symbols sid :nodes])) - ;; `:face` authors its own in `face-placement`, upstream of this. - :when (not= [:main :face] [sid id])] + ;; `:place` authors its own in `face-placement`, upstream of this. + :when (not= [:face-1 :place] [sid id])] (let [n (get-in c [:symbols sid :nodes id]) frames (range (get-in c [:symbols sid :frames])) anchor (:value (get-in n [:channels [:xform :anchor]])) @@ -364,11 +370,11 @@ ;; differently, it has been applied to a node whose transform is not the ;; identity, which is the one way it could go wrong. (let [c @clip* - ;; Every node the freeze makes EXCEPT `:face`, whose anchor + ;; Every node the freeze makes EXCEPT `:place`, whose anchor ;; `face-placement` authors — which is the set `pivoted` writes. every (for [sid [:main :face-1] id (keys (get-in c [:symbols sid :nodes])) - :when (and (not= [:main :face] [sid id]) + :when (and (not= [:face-1 :place] [sid id]) (seq (get-in c [:symbols sid :nodes id :channels])))] [sid id]) anchors #(into {} (for [[sid id] every] @@ -385,13 +391,13 @@ (is (= (render bare f) (render again f)) (str "frame " f " draws differently once every part has a pivot"))))) -(deftest the-face-puts-the-head-s-centre-where-it-says-it-does +(deftest the-place-puts-the-head-s-centre-where-it-says-it-does ;; anchor + pos is where the anchor lands in the parent, which is what makes ;; `:anchor` the registration point: scale and rotation happen about the head's ;; centre rather than about the corner of the footage, where MediaPipe's ;; normalised space has its origin. - (let [anc (:value (chan :face [:xform :anchor])) - pos (:value (chan :face [:xform :pos])) + (let [anc (:value (chan :place [:xform :anchor])) + pos (:value (chan :place [:xform :pos])) c (geom/centroid (:ref @take/measured))] (is (< (abs (- (nth anc 0) (:x c))) 1e-12)) (is (< (abs (- (nth anc 1) (:y c))) 1e-12)) @@ -418,8 +424,8 @@ (vec (array-seq (:data (get (:store big) (key-of (:clip big))))))) "the geometry is the same numbers at either stage size") (is (not= (:value (get-in (nodes (:clip big)) - [:face :channels [:xform :scale]])) - (:value (chan :face [:xform :scale])))))) + [:place :channels [:xform :scale]])) + (:value (chan :place [:xform :scale])))))) ;; --------------------------------------------------------------------------- ;; the aperture, as [:vis] diff --git a/frontend/test/arthur/flow/multi_face_test.cljs b/frontend/test/arthur/flow/multi_face_test.cljs index 7099adb..98af740 100644 --- a/frontend/test/arthur/flow/multi_face_test.cljs +++ b/frontend/test/arthur/flow/multi_face_test.cljs @@ -177,7 +177,7 @@ flat (assoc-in clip [:symbols :main :nodes] (merge (dissoc (:nodes (clip/symbol clip :main)) :face-1) (assoc-in (get-in clip [:symbols :face-1 :nodes]) - [:head :parent] :face))) + [:head :parent] :place))) nested (clip/resolver clip :main store pal/index-of nil) reference (clip/resolver flat :main store pal/index-of nil)] (doseq [f [0 1 7 20 39]] diff --git a/frontend/test/arthur/flow/regenerate_test.cljs b/frontend/test/arthur/flow/regenerate_test.cljs index e0b7a0b..232e7a6 100644 --- a/frontend/test/arthur/flow/regenerate_test.cljs +++ b/frontend/test/arthur/flow/regenerate_test.cljs @@ -46,8 +46,8 @@ (channel after :iris-l [:xform :pos]))) (is (= (channel before :mouth [:geom :pts]) (channel after :mouth [:geom :pts]))) - (is (= (get-in before [:clip :symbols :main :nodes :face]) - (get-in after [:clip :symbols :main :nodes :face]))) + (is (= (get-in before [:clip :symbols :face-1 :nodes :place]) + (get-in after [:clip :symbols :face-1 :nodes :place]))) (is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos]) (channel after :iris-r [:xform :pos]))))) @@ -85,8 +85,8 @@ (get-in after [:clip :symbols :face-1 :nodes :head :measured]))) (is (= (get-in full [:clip :symbols :face-1 :nodes :head :measured]) (get-in after [:clip :symbols :face-1 :nodes :head :measured]))) - (is (= (get-in before [:clip :symbols :main :nodes :face]) - (get-in after [:clip :symbols :main :nodes :face]))))) + (is (= (get-in before [:clip :symbols :face-1 :nodes :place]) + (get-in after [:clip :symbols :face-1 :nodes :place]))))) (deftest contour-edit-does-not-rebuild-head-or-teeth (let [before @initial