arthur/frontend/test/arthur/domain/node_test.cljs

186 lines
9.5 KiB
Text
Raw Normal View History

Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
(ns arthur.domain.node-test
"The transform and the time map. Both are places where a wrong answer looks
like a plausible different answer, which is why they are asserted numerically
rather than looked at."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.channel :as ch]
[arthur.domain.node :as node]))
(defn- pt [m x y]
(let [out (js/Float64Array. 2)]
(node/apply-pt! out 0 m x y)
[(aget out 0) (aget out 1)]))
(defn- close? [a b] (< (js/Math.abs (- a b)) 1e-12))
(defn- close-pt? [[ax ay] [bx by]] (and (close? ax bx) (close? ay by)))
(defn- local [& {:keys [pos rot scale skew anchor]
:or {pos [0 0] rot 0 scale [1 1] skew [0 0] anchor [0 0]}}]
(node/local! (node/mat) pos rot scale skew anchor))
;; ---- the transform, component by component ----
(deftest the-identity-transform-moves-nothing
(is (= [5 7] (pt (local) 5 7))))
(deftest translation-rotation-and-scale-each-do-their-own-job
(is (= [15 27] (pt (local :pos [10 20]) 5 7)))
(is (close-pt? [0 10] (pt (local :rot (/ js/Math.PI 2)) 10 0)))
(is (= [20 21] (pt (local :scale [2 3]) 10 7))))
(deftest rotation-and-scale-happen-about-the-anchor
;; :anchor is Flash's registration point and Blender's origin. Getting it wrong
;; is why hand-placed parts SWING rather than turn, and a swing looks like a
;; parenting bug rather than like a wrong pivot.
(let [m (local :rot (/ js/Math.PI 2) :anchor [10 0])]
(is (close-pt? [10 0] (pt m 10 0)) "the anchor itself is a fixed point")
(is (close-pt? [10 10] (pt m 20 0)) "and the rest turns about it"))
(let [m (local :scale [2 2] :anchor [10 10])]
(is (close-pt? [10 10] (pt m 10 10)))
(is (close-pt? [30 30] (pt m 20 20)))))
(deftest skew-is-shear-factors-so-the-identity-is-zero
;; Stored as factors rather than angles: kx is x gained per unit y, so a
;; decomposition round-trips without a tangent, and 0 means "none" rather than
;; needing atan of something.
(is (= [5 7] (pt (local :skew [0 0]) 5 7)))
(is (= [12 7] (pt (local :skew [1 0]) 5 7)) "kx adds y into x")
(is (= [5 12] (pt (local :skew [0 1]) 5 7)) "ky adds x into y"))
(deftest the-composition-order-is-the-one-the-model-specifies
;; local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
;;
;; Asserted against the product of the five matrices built separately, so the
;; closed form in node/local! is checked rather than trusted. Every other order
;; produces a transform that is right at the origin and wrong everywhere else,
;; which is exactly the kind of wrong that survives inspection.
(let [pos [3 -4] rot 0.7 scale [1.5 0.5] skew [0.25 -0.1] anchor [11 -6]
T (fn [x y] (js/Float64Array. #js [1 0 0 1 x y]))
R (fn [t] (js/Float64Array. #js [(js/Math.cos t) (js/Math.sin t)
(- (js/Math.sin t)) (js/Math.cos t) 0 0]))
K (fn [[kx ky]] (js/Float64Array. #js [1 ky kx 1 0 0]))
S (fn [[sx sy]] (js/Float64Array. #js [sx 0 0 sy 0 0]))
step (fn [acc m] (node/mul! (node/mat) acc m))
want (reduce step (T (nth pos 0) (nth pos 1))
[(T (nth anchor 0) (nth anchor 1))
(R rot) (K skew) (S scale)
(T (- (nth anchor 0)) (- (nth anchor 1)))])
got (local :pos pos :rot rot :scale scale :skew skew :anchor anchor)]
(is (every? (fn [i] (close? (aget want i) (aget got i))) (range 6))
(str (vec (array-seq want)) " vs " (vec (array-seq got))))))
(deftest mul-may-write-into-either-operand
;; Evaluation composes world := parent · local with dest aliasing local, so
;; that a node's world transform needs no scratch. If mul! wrote before reading,
;; the bug would be a node in the right place whose CHILDREN are wrong.
(let [a (js/Float64Array. #js [2 0 0 3 5 7])
b (js/Float64Array. #js [1 0.5 -0.5 1 -2 4])
want (node/mul! (node/mat) a b)
into-b (node/mul! b a b)]
(is (= (vec (array-seq want)) (vec (array-seq into-b))))))
(deftest world-composes-through-the-parent
(let [parent (local :pos [100 50] :scale [2 2])
child (local :pos [10 0])
w (node/world! (node/mat) parent nil child (node/mat))]
(is (close-pt? [120 50] (pt w 0 0)))))
(deftest pinv-keeps-a-child-from-jumping-when-it-acquires-a-parent
;; Blender's parent_inverse. Nothing produces one yet; what is asserted is that
;; the field is in the composition, because its absence is the kind of thing
;; that makes a parenting feature feel broken and the fix is a migration.
(let [parent (local :pos [100 50])
child (local :pos [10 0])
before (pt child 0 0)
;; the inverse of the parent at the moment of parenting
pinv (js/Float64Array. #js [1 0 0 1 -100 -50])
after (pt (node/world! (node/mat) parent pinv child (node/mat)) 0 0)]
(is (close-pt? before after))))
(deftest mean-scale-is-exact-for-a-similarity
;; A disc under a non-uniform transform is an ellipse and the rasteriser has no
;; ellipse, so a disc's radius takes sqrt|det|. For the similarity the anchor
;; fit produces — the only transform that reaches a disc today — that is exact.
(is (close? 1.0 (node/mean-scale (local))))
(is (close? 3.0 (node/mean-scale (local :scale [3 3]))))
(is (close? 3.0 (node/mean-scale (local :scale [3 3] :rot 1.234)))
"and rotation does not change it"))
;; ---- time maps ----
(deftest exposure-floors-and-never-rounds
;; Rounding would let an output frame read a pose from the FUTURE, which is a
;; lead — a separate control, applied after this one, for a separate reason.
(is (= [0 1 2 3 4 5] (mapv #(node/expose % 1) (range 6))))
(is (= [0 0 2 2 4 4] (mapv #(node/expose % 2) (range 6))))
(is (= [0 0 0 3 3 3] (mapv #(node/expose % 3) (range 6))))
(is (= 4 (node/expose 5 2)) "frame 5 at exposure 2 reads frame 4, not 6"))
(deftest exposure-comes-before-offset-and-the-order-is-visible
;; THE INVARIANT: flooring onto a grid and shifting against the clock do not
;; commute. Shift first and the floor discards it on most frames, so the lead
;; slider appears to do nothing at any exposure above 1 — which is
;; indistinguishable from the slider being unwired.
(let [n {:id :m :time {:mode :map :expose 2 :offset 1}}
got (mapv #(node/local-frame n %) (range 8))
wrong-way (mapv #(node/expose (+ % 1) 2) (range 8))]
(is (= [1 1 3 3 5 5 7 7] got))
(is (= [0 2 2 4 4 6 6 8] wrong-way))
(is (not= got wrong-way) "and the two orders really do differ")))
(deftest inherit-is-the-default-and-changes-nothing
(is (= (vec (range 8)) (mapv #(node/local-frame {:id :x} %) (range 8))))
(is (= (vec (range 8)) (mapv #(node/local-frame {:id :x :time {:mode :inherit}} %) (range 8)))))
(deftest a-retimed-instance-is-refused-rather-than-ignored
;; :rate is a symbol instance's timing and symbols are out of scope. Silently
;; dropping it would be a retimed blink playing at the wrong speed, which looks
;; like a bad blink and not like a missing feature.
(is (thrown-with-msg? ExceptionInfo #":rate"
(node/local-frame {:id :x :time {:mode :map :rate 0.5}} 0)))
(is (= 4 (node/local-frame {:id :x :time {:mode :map :expose 2 :rate 1.0}} 5))
"rate 1.0 is the identity and is allowed, because it appears in the spec's example"))
;; ---- the shape ----
(deftest transform-channels-default-to-the-identity
(let [chs (node/channels {:id :x :kind :group})]
(is (= [0.0 0.0] (ch/value-at (get chs [:xform :pos]) 0)))
(is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0)))
(is (= true (ch/value-at (get chs [:vis]) 0))))
(testing "and a node's own channels win"
(let [chs (node/channels {:id :x :kind :group
:channels {[:xform :pos] (ch/framed [5 5])}})]
(is (= [5 5] (ch/value-at (get chs [:xform :pos]) 0)))
(is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0))))))
(deftest skew-and-anchor-are-in-the-shape-although-nothing-drives-them
;; A decomposition is not extensible after the fact: adding a component later
;; means migrating every stored transform. So both are present from the start,
;; on every kind.
(doseq [k node/implemented-kinds]
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k))
(is (contains? (get node/valid-paths k) [:xform :anchor]) (str k))))
(deftest valid-paths-follow-from-the-kind
(is (contains? (:poly node/valid-paths) [:geom :pts]))
(is (not (contains? (:poly node/valid-paths) [:geom :radius])))
(is (contains? (:disc node/valid-paths) [:geom :radius]))
(is (contains? (:rect node/valid-paths) [:geom :size]))
(is (not (contains? (:group node/valid-paths) [:geom :pts]))
"a group is a pure transform node"))
(deftest problems-names-the-ways-a-node-is-malformed
(is (empty? (node/problems {:id :x :kind :group :z "a1"})))
(is (seq (node/problems {:kind :group :z "a1"})) "no :id")
(is (seq (node/problems {:id :x :kind :blob :z "a1"})) "not a kind")
(is (seq (node/problems {:id :x :kind :instance :z "a1"})) "a kind that is not built")
Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
(is (seq (node/problems {:id :x :kind :group})) "no :z")
(is (seq (node/problems {:id :x :kind :group :z "a1" :span [3]})) "a malformed span")
(is (seq (node/problems {:id :x :kind :group :z "a1"
:channels {[:geom :pts] (ch/framed [0 0 1 0 1 1])}}))
"a channel that is not valid on this kind")
(is (seq (node/problems {:id :x :kind :poly :z "a1"
:channels {[:geom :pts] {:animated? true}}}))
"and a channel that is malformed in itself"))