From 1b8bbc73726b834f6a0eae21d156629c1d6b2fce Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Wed, 30 Sep 2026 00:12:21 -0400 Subject: [PATCH] Edit shapes nested in other symbols, from any symbol above them `nest/inside` walked a row path only through instances. Every node has the same two maps to its parent, so the walk now steps into any node: inside an instance is the symbol it places, inside a shape is where its points and keys are. The selected shape at any depth is `inside` over its full path, which gives the stage editor its handles (through the matrix, drags back through the inverse) and the inspector the shape's own frame to key at, and the time map back for jumping to a key. `inside` resolves only the node's lineage: where a node is depends on its parents and nothing else, and resolving the whole symbol cost more than a stage frame (7.9ms against 3ms on the swarm; now 0.09ms). Checked equal to the whole-symbol resolve on every node and frame of the swarm, two instances deep. Co-Authored-By: Claude Opus 5.5 --- frontend/src/arthur/domain/nest.cljs | 45 ++++++++++++------- frontend/src/arthur/events/paint.cljs | 6 ++- frontend/src/arthur/subs/ui.cljs | 19 +++++++- frontend/src/arthur/ui/params.cljs | 21 +++++---- frontend/src/arthur/ui/stage.cljs | 52 +++++++++++++--------- frontend/test/arthur/domain/nest_test.cljs | 36 +++++++++++++++ 6 files changed, 130 insertions(+), 49 deletions(-) diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index a43b8ba..e350b00 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -23,7 +23,7 @@ [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol])) -(defn- invert +(defn invert "The inverse of a 2x3 affine, or nil when it has none — an instance scaled to nothing has no inside to draw into." [^js m] @@ -34,34 +34,46 @@ (/ (- (* c f) (* d e)) det) (/ (- (* b e) (* a f)) det)])))) (defn- resolved - "Symbol `sid` of `clip`, resolved at `frame`: the resolver, which then answers - `symbol/world-of` and `symbol/frame-of` for that frame." - [clip store sid frame] - (let [r (symbol/resolver (clip/symbol clip sid) store pal/index-of nil - {:source-fps (:fps clip)})] + "Node `id` of symbol `sid`, resolved at `frame`: the resolver, which then + answers `symbol/world-of` and `symbol/frame-of` for it on that frame. + + Only its lineage is resolved, because where a node is depends on its parents + and nothing else in the symbol — and the whole symbol costs more than a frame + of the stage, which the editor asks for on every frame." + [clip store sid frame id] + (let [sym (clip/symbol clip sid) + sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id)) + r (symbol/resolver sym store pal/index-of nil {:source-fps (:fps clip)})] (r frame) r)) (defn inside - "Walk row path `path` down from symbol `sid`, whose frame `f` is showing. - Returns `{:sid :frame :matrix :time}`: the symbol the last instance places, - the frame it is showing there, the matrix from its coordinates to `sid`'s, and - the time map from `sid`'s frames to its own — or nil when an instance on the - way is not on screen at that frame, where there is no inside to be in. + "Walk row path `path` down from symbol `sid`, whose frame `f` is showing, into + the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that + node places (nil for one that places none), the frame of its own it is + showing, the matrix from its coordinates to `sid`'s, and the time map from + `sid`'s frames to its own — or nil when a node on the way is not on screen at + that frame, where there is no inside to be in. + + THE SAME STEP FOR EVERY NODE. Inside an instance is the symbol it places; + inside a shape is where its points and keys are. Either way it is the node's + own coordinates and frames, so a shape any depth down is edited through the + maps it is drawn with. The frame and the matrix come from RESOLVING each level, so they are the ones the stage draws with, floors included. The time map is the affine part, floors - aside, and is nil through a looping instance, whose frames come round again - and do not map one to one." + aside, and is nil through a looping node, whose frames come round again and do + not map one to one." [clip store sid path f] (reduce (fn [{:keys [sid frame matrix time]} id] - (let [r (resolved clip store sid frame) + (let [r (resolved clip store sid frame id) nodes (:nodes (clip/symbol clip sid)) chain (map #(get nodes %) (rseq (symbol/lineage nodes id))) m (symbol/world-of r id) local (symbol/frame-of r id) inner (get-in nodes [id :of])] - (if (and m (number? local) inner (< -1 local (clip/frames clip inner))) + (if (and m (number? local) + (or (nil? inner) (< -1 local (clip/frames clip inner)))) {:sid inner :frame (js/Math.floor local) :matrix (node/mul! (node/mat) matrix m) :time (when (and time (not-any? #(get-in % [:time :loop?]) chain)) @@ -151,7 +163,7 @@ ;; Its parents in this symbol, outermost first. chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) parent (when-let [p (:parent n)] - (some-> (symbol/world-of (resolved clip store host frame) p) + (some-> (symbol/world-of (resolved clip store host frame p) p) js/Float64Array.from))] (cond (= host target) {:refused "it is already there"} @@ -209,6 +221,7 @@ (nil? (get-in clip [:symbols (:sid here) :nodes (peek from)])) {:refused "nothing to move"} (or (nil? here) (nil? there)) {:refused "both have to be on screen at this frame"} + (nil? (:sid there)) {:refused "only a symbol can take it"} (not (and a b)) {:refused "a looping instance is in the way"} (nil? inv) {:refused "the target is scaled to nothing"} :else (transplant clip store (:sid here) (:frame here) (peek from) (:sid there) diff --git a/frontend/src/arthur/events/paint.cljs b/frontend/src/arthur/events/paint.cljs index eaac934..7bf614b 100644 --- a/frontend/src/arthur/events/paint.cljs +++ b/frontend/src/arthur/events/paint.cljs @@ -14,8 +14,10 @@ (rf/reg-event-db ::add-key - (fn [db [_ sid id]] - (edit/edit db #(paint/add-key % sid id (get-in db [:playback :frame]))))) + ;; `frame` is the shape's own, which is the transport's only for a shape in the + ;; open symbol with no time map of its own. + (fn [db [_ sid id frame]] + (edit/edit db #(paint/add-key % sid id frame)))) (rf/reg-event-db ::set-vertex diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index ab9258c..18eb5cf 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -4,7 +4,8 @@ Cheap by construction, like `subs/playback`: each reads a path and returns a value, so clicking a swatch notifies the swatches and nothing else." - (:require [arthur.footage.store :as store] + (:require [arthur.domain.nest :as nest] + [arthur.footage.store :as store] [arthur.subs.playback :as playback] [arthur.subs.render :as render] [re-frame.core :as rf])) @@ -32,6 +33,22 @@ (when-let [n (get-in clip [:symbols sid :nodes id])] [sid id n]))))) +(rf/reg-sub + ::selected-local + :<- [::selected-node] + :<- [::selection] + :<- [::render/clip-id] + :<- [::render/open] + :<- [::playback/frame] + (fn [[[_ id n] [_ _ _ path] clip-id open f] _] + ;; `nest/inside` the selected node, from the open symbol: its own frame, the + ;; matrix from its coordinates to the stage's, and the time map from the open + ;; symbol's frames to its own. A selection made on the stage has no path and + ;; names a node in the open symbol. + (when n + (let [{clip :clip st :store} (store/entry clip-id)] + (nest/inside clip st open (or path [id]) f))))) + (rf/reg-sub ::project-footage :<- [::render/clip-id] diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 8d9dba1..f419b41 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -85,8 +85,12 @@ "The polygon controls: jump to a drawing key, add one here, and choose what the gap after the current one does. Lifted out of the old stage toolbar unchanged — a drawing key is a parameter of the shape, and this is where the shape's - parameters are." - [sid id n frame] + parameters are. + + Keys are in the shape's OWN frames — the symbol's it lives in, through every + instance above it — and `time` maps the open symbol's frames to them. `frame` + is nil when the shape is not on screen, where there is no here to key." + [sid id n {:keys [frame time]}] (let [geom (get-in n [:channels paint/geometry]) active (when geom (paint/active-frame geom frame)) ks (when geom (sort (keys (:keys geom)))) @@ -94,9 +98,9 @@ [start end] (:span n)] [:<> [:div.row {:style {:margin "5px 0"}} - [:button {:disabled (or (< frame start) (>= frame end) + [:button {:disabled (or (nil? frame) (< frame start) (>= frame end) (contains? (:keys geom) frame)) - :on-click #(rf/dispatch [::paint-events/add-key sid id])} + :on-click #(rf/dispatch [::paint-events/add-key sid id frame])} "drawing key here"]] (when (seq ks) [:div.row @@ -105,7 +109,9 @@ (for [f ks] ^{:key f} [:button {:class (when (= frame f) "on") - :on-click #(rf/dispatch [::pb/seek f])} + :disabled (nil? time) + :on-click #(rf/dispatch [::pb/seek (js/Math.round + (+ (:at time) (/ f (:rate time))))])} (str f)]))]) (when next-k [:div.row {:style {:margin-top "5px"}} @@ -117,8 +123,7 @@ [:option {:value "linear"} "tween"]]]])])) (defn- node-section [[sid id n]] - (let [frame @(rf/subscribe [::playback/frame]) - [start end] (:span n)] + (let [[start end] (:span n)] [section (str (name (:kind n)) " · in " (name sid)) [facts "name" (or (:name n) (brief id)) @@ -130,7 +135,7 @@ ;; in this symbol. See `node/placed-span`. "span" (when start (str start " … " end)) "at" (when (= :map (get-in n [:time :mode])) (str (get-in n [:time :at] 0)))] - (when (:paint? n) [drawing-keys sid id n frame]) + (when (:paint? n) [drawing-keys sid id n @(rf/subscribe [::sub/selected-local])]) [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] [:dl.facts (doall diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 54c3c94..a7c1661 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -10,6 +10,8 @@ THE CANVAS IS THE RASTER'S OWN SIZE, scaled by CSS. See `ui/canvas` for why that is load-bearing rather than convenient." (:require [arthur.domain.channel :as channel] + [arthur.domain.nest :as nest] + [arthur.domain.node :as node] [arthur.domain.paint :as paint] [arthur.events.paint :as paint-events] [arthur.events.ui :as ui] @@ -48,23 +50,29 @@ (defonce ^:private dragging (atom nil)) (defn- editing - "The selected node when it is a polygon in the open symbol, as - `[sid id node geom active-key editable?]`. Nothing else is vertex-editable yet: - a shape inside an instance is drawn through that instance's transform, and - handles in the open symbol's space would be in the wrong place." - [frame] - (let [[sid id n] @(rf/subscribe [::sub/selected-node])] - (when (and (= @(rf/subscribe [::render/open]) sid) (:paint? n)) + "The selected node when it is a polygon on screen, however deep it is nested, + as `[sid id geom active-key editable? frame matrix]`: its own frame, and the + matrix from its coordinates to the stage's. See `::sub/selected-local`." + [] + (let [[sid id n] @(rf/subscribe [::sub/selected-node]) + {:keys [frame matrix]} @(rf/subscribe [::sub/selected-local])] + (when (and (:paint? n) matrix) (let [geom (get-in n [:channels paint/geometry]) - active (when geom (paint/active-frame geom frame)) - [start end] (:span n)] - [sid id n geom active + active (when geom (paint/active-frame geom frame))] + [sid id geom active ;; A frame between two drawing keys with a tween running has no vertices ;; of its own to move: what is on screen there is interpolated, and ;; dragging it would silently edit the key behind it instead. - (and (<= start frame) (< frame end) - (or (not= :linear (channel/segment-interp geom active)) - (contains? (:keys geom) frame)))])))) + (or (not= :linear (channel/segment-interp geom active)) + (contains? (:keys geom) frame)) + frame matrix])))) + +(defn- through + "Flat points `pts` through matrix `m`." + [m pts] + (let [out (js/Float64Array. 2)] + (into [] (mapcat (fn [[x y]] (node/apply-pt! out 0 m x y) [(aget out 0) (aget out 1)])) + (partition 2 pts)))) (defn- ghost "Where a drag out of the pool would land: the outline of its first frame, @@ -94,13 +102,11 @@ " M " cx " " (- cy 5) " V " (+ cy 5))}]])))) (defn- overlay [w h] - (let [clip @(rf/subscribe [::render/clip]) - frame @(rf/subscribe [::playback/frame]) - tool @(rf/subscribe [::sub/tool]) + (let [tool @(rf/subscribe [::sub/tool]) draft @(rf/subscribe [::sub/draft]) drawing? (= :polygon tool) - [sid id _ geom active editable?] (editing frame) - pts (when geom (channel/value-at geom frame))] + [sid id geom active editable? frame matrix] (editing) + pts (when geom (through matrix (channel/value-at geom frame)))] [:svg {:class (str "paint-overlay" (when drawing? " drawing")) :width (* zoom w) :height (* zoom h) :view-box (str "0 0 " w " " h) @@ -109,10 +115,12 @@ (let [[x y] (stage-point event w h)] (rf/dispatch [::ui/add-draft-point x y])))) :on-pointer-move (fn [event] - (when-let [[sid node key-frame vertex] @dragging] + ;; Back through the inverse of what the handle was + ;; drawn through, into the shape's own coordinates. + (when-let [[sid node key-frame vertex inv] @dragging] (rf/dispatch [::paint-events/set-vertex sid node key-frame vertex - (stage-point event w h)]))) + (through inv (stage-point event w h))]))) :on-pointer-up (fn [_] (reset! dragging nil)) :on-pointer-cancel (fn [_] (reset! dragging nil))} [ghost] @@ -123,7 +131,7 @@ [:g [:polygon {:points (points-text pts) :fill "none" :stroke "#e6ca8b" :stroke-width 1}] - (when editable? + (when-let [inv (when editable? (nest/invert matrix))] (doall (for [[i [x y]] (map-indexed vector (pairs pts))] ^{:key i} @@ -135,7 +143,7 @@ (.preventDefault event) (.setPointerCapture (.-currentTarget event) (.-pointerId event)) - (reset! dragging [sid id active i]))}])))])])) + (reset! dragging [sid id active i inv]))}])))])])) (defn view [] ;; Reactive on the clip's dimensions, so selecting a clip of another size diff --git a/frontend/test/arthur/domain/nest_test.cljs b/frontend/test/arthur/domain/nest_test.cljs index a40f15d..a648772 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -47,6 +47,42 @@ (map - drawn (take 6 (array-seq (:pts op))))) "resolved back out through the instance, it is exactly what was drawn"))) +(deftest a-shape-two-instances-down-is-edited-where-it-is-seen + (let [u #uuid "00000000-0000-4000-8000-0000000000d1" + v #uuid "00000000-0000-4000-8000-0000000000d2" + turn (fn [c host id pos rot k] + (update-in c [:symbols host :nodes id :channels] merge + {[:xform :pos] (ch/framed pos) + [:xform :rot] (ch/framed rot) + [:xform :scale] (ch/framed [k k])})) + c (-> (clip/blank) + (assoc-in [:symbols :mid] {:id :mid :frames 40 :nodes {}}) + (assoc-in [:symbols :box] {:id :box :frames 30 :nodes {}}) + (clip/place-symbol nil :main :mid 10 u nil) + (clip/place-symbol nil :mid :box 2 v nil) + (turn :main u [40 20] (/ js/Math.PI 2) 2) + (turn :mid v [5 -3] 0.3 1.5) + (paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow)) + draw #(take 6 (array-seq (:pts (first (filter (fn [op] (= [u [v :shape]] (:node op))) + ((clip/resolver % nil pal/index-of :main) 16)))))) + {:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16) + out (js/Float64Array. 2) + seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y)))) + (partition 2 [0 0 10 0 5 10])) + [x y] (array-seq (node/apply-pt! out 0 (nest/invert matrix) 7 3)) + moved (paint/set-vertex c :box :shape frame 0 [x y]) + keyed (paint/add-key c :box :shape (:frame (nest/inside c nil :main [u v :shape] 20)))] + (is (= 4 frame) "16 of main is 6 of mid, which is 4 of box and of the shape in it") + (is (= {:at 12 :rate 1} time)) + (is (= #{4 8} (set (keys (get-in keyed [:symbols :box :nodes :shape :channels paint/geometry :keys])))) + "a key added at 20 of main lands at 8, the shape's own time") + (is (nil? (nest/inside c nil :main [u v :shape] 13)) + "and where the shape is not on screen there is nothing to edit") + (is (every? #(< (js/Math.abs %) 1e-9) (map - (draw c) seen)) + "the handles sit on what the stage draws") + (is (every? #(< (js/Math.abs %) 1e-9) (map - [7 3] (take 2 (draw moved)))) + "and a vertex dragged to (7, 3) is drawn at (7, 3)"))) + (deftest a-placed-symbols-sound-is-heard-where-it-is-placed (let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1" :span [10 40] :time {:mode :map :at -10 :rate 1}