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 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-30 00:12:21 -04:00
parent 6a53adb5e0
commit 1b8bbc7372
6 changed files with 130 additions and 49 deletions

View file

@ -23,7 +23,7 @@
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol])) [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 "The inverse of a 2x3 affine, or nil when it has none — an instance scaled to
nothing has no inside to draw into." nothing has no inside to draw into."
[^js m] [^js m]
@ -34,34 +34,46 @@
(/ (- (* c f) (* d e)) det) (/ (- (* b e) (* a f)) det)])))) (/ (- (* c f) (* d e)) det) (/ (- (* b e) (* a f)) det)]))))
(defn- resolved (defn- resolved
"Symbol `sid` of `clip`, resolved at `frame`: the resolver, which then answers "Node `id` of symbol `sid`, resolved at `frame`: the resolver, which then
`symbol/world-of` and `symbol/frame-of` for that frame." answers `symbol/world-of` and `symbol/frame-of` for it on that frame.
[clip store sid frame]
(let [r (symbol/resolver (clip/symbol clip sid) store pal/index-of nil Only its lineage is resolved, because where a node is depends on its parents
{:source-fps (:fps clip)})] 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 frame)
r)) r))
(defn inside (defn inside
"Walk row path `path` down from symbol `sid`, whose frame `f` is showing. "Walk row path `path` down from symbol `sid`, whose frame `f` is showing, into
Returns `{:sid :frame :matrix :time}`: the symbol the last instance places, the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that
the frame it is showing there, the matrix from its coordinates to `sid`'s, and node places (nil for one that places none), the frame of its own it is
the time map from `sid`'s frames to its own — or nil when an instance on the showing, the matrix from its coordinates to `sid`'s, and the time map from
way is not on screen at that frame, where there is no inside to be in. `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 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 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 aside, and is nil through a looping node, whose frames come round again and do
and do not map one to one." not map one to one."
[clip store sid path f] [clip store sid path f]
(reduce (fn [{:keys [sid frame matrix time]} id] (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)) nodes (:nodes (clip/symbol clip sid))
chain (map #(get nodes %) (rseq (symbol/lineage nodes id))) chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))
m (symbol/world-of r id) m (symbol/world-of r id)
local (symbol/frame-of r id) local (symbol/frame-of r id)
inner (get-in nodes [id :of])] 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) {:sid inner :frame (js/Math.floor local)
:matrix (node/mul! (node/mat) matrix m) :matrix (node/mul! (node/mat) matrix m)
:time (when (and time (not-any? #(get-in % [:time :loop?]) chain)) :time (when (and time (not-any? #(get-in % [:time :loop?]) chain))
@ -151,7 +163,7 @@
;; Its parents in this symbol, outermost first. ;; Its parents in this symbol, outermost first.
chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
parent (when-let [p (:parent n)] 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))] js/Float64Array.from))]
(cond (cond
(= host target) {:refused "it is already there"} (= host target) {:refused "it is already there"}
@ -209,6 +221,7 @@
(nil? (get-in clip [:symbols (:sid here) :nodes (peek from)])) (nil? (get-in clip [:symbols (:sid here) :nodes (peek from)]))
{:refused "nothing to move"} {:refused "nothing to move"}
(or (nil? here) (nil? there)) {:refused "both have to be on screen at this frame"} (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"} (not (and a b)) {:refused "a looping instance is in the way"}
(nil? inv) {:refused "the target is scaled to nothing"} (nil? inv) {:refused "the target is scaled to nothing"}
:else (transplant clip store (:sid here) (:frame here) (peek from) (:sid there) :else (transplant clip store (:sid here) (:frame here) (peek from) (:sid there)

View file

@ -14,8 +14,10 @@
(rf/reg-event-db (rf/reg-event-db
::add-key ::add-key
(fn [db [_ sid id]] ;; `frame` is the shape's own, which is the transport's only for a shape in the
(edit/edit db #(paint/add-key % sid id (get-in db [:playback :frame]))))) ;; 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 (rf/reg-event-db
::set-vertex ::set-vertex

View file

@ -4,7 +4,8 @@
Cheap by construction, like `subs/playback`: each reads a path and returns a Cheap by construction, like `subs/playback`: each reads a path and returns a
value, so clicking a swatch notifies the swatches and nothing else." 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.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[re-frame.core :as rf])) [re-frame.core :as rf]))
@ -32,6 +33,22 @@
(when-let [n (get-in clip [:symbols sid :nodes id])] (when-let [n (get-in clip [:symbols sid :nodes id])]
[sid id n]))))) [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 (rf/reg-sub
::project-footage ::project-footage
:<- [::render/clip-id] :<- [::render/clip-id]

View file

@ -85,8 +85,12 @@
"The polygon controls: jump to a drawing key, add one here, and choose what the "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 — 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 a drawing key is a parameter of the shape, and this is where the shape's
parameters are." parameters are.
[sid id n frame]
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]) (let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame)) active (when geom (paint/active-frame geom frame))
ks (when geom (sort (keys (:keys geom)))) ks (when geom (sort (keys (:keys geom))))
@ -94,9 +98,9 @@
[start end] (:span n)] [start end] (:span n)]
[:<> [:<>
[:div.row {:style {:margin "5px 0"}} [: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)) (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"]] "drawing key here"]]
(when (seq ks) (when (seq ks)
[:div.row [:div.row
@ -105,7 +109,9 @@
(for [f ks] (for [f ks]
^{:key f} ^{:key f}
[:button {:class (when (= frame f) "on") [: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)]))]) (str f)]))])
(when next-k (when next-k
[:div.row {:style {:margin-top "5px"}} [:div.row {:style {:margin-top "5px"}}
@ -117,8 +123,7 @@
[:option {:value "linear"} "tween"]]]])])) [:option {:value "linear"} "tween"]]]])]))
(defn- node-section [[sid id n]] (defn- node-section [[sid id n]]
(let [frame @(rf/subscribe [::playback/frame]) (let [[start end] (:span n)]
[start end] (:span n)]
[section (str (name (:kind n)) " · in " (name sid)) [section (str (name (:kind n)) " · in " (name sid))
[facts [facts
"name" (or (:name n) (brief id)) "name" (or (:name n) (brief id))
@ -130,7 +135,7 @@
;; in this symbol. See `node/placed-span`. ;; in this symbol. See `node/placed-span`.
"span" (when start (str start " … " end)) "span" (when start (str start " … " end))
"at" (when (= :map (get-in n [:time :mode])) (str (get-in n [:time :at] 0)))] "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"]] [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
[:dl.facts [:dl.facts
(doall (doall

View file

@ -10,6 +10,8 @@
THE CANVAS IS THE RASTER'S OWN SIZE, scaled by CSS. See `ui/canvas` for why THE CANVAS IS THE RASTER'S OWN SIZE, scaled by CSS. See `ui/canvas` for why
that is load-bearing rather than convenient." that is load-bearing rather than convenient."
(:require [arthur.domain.channel :as channel] (:require [arthur.domain.channel :as channel]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.events.paint :as paint-events] [arthur.events.paint :as paint-events]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
@ -48,23 +50,29 @@
(defonce ^:private dragging (atom nil)) (defonce ^:private dragging (atom nil))
(defn- editing (defn- editing
"The selected node when it is a polygon in the open symbol, as "The selected node when it is a polygon on screen, however deep it is nested,
`[sid id node geom active-key editable?]`. Nothing else is vertex-editable yet: as `[sid id geom active-key editable? frame matrix]`: its own frame, and the
a shape inside an instance is drawn through that instance's transform, and matrix from its coordinates to the stage's. See `::sub/selected-local`."
handles in the open symbol's space would be in the wrong place." []
[frame] (let [[sid id n] @(rf/subscribe [::sub/selected-node])
(let [[sid id n] @(rf/subscribe [::sub/selected-node])] {:keys [frame matrix]} @(rf/subscribe [::sub/selected-local])]
(when (and (= @(rf/subscribe [::render/open]) sid) (:paint? n)) (when (and (:paint? n) matrix)
(let [geom (get-in n [:channels paint/geometry]) (let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame)) active (when geom (paint/active-frame geom frame))]
[start end] (:span n)] [sid id geom active
[sid id n geom active
;; A frame between two drawing keys with a tween running has no vertices ;; 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 ;; of its own to move: what is on screen there is interpolated, and
;; dragging it would silently edit the key behind it instead. ;; dragging it would silently edit the key behind it instead.
(and (<= start frame) (< frame end)
(or (not= :linear (channel/segment-interp geom active)) (or (not= :linear (channel/segment-interp geom active))
(contains? (:keys geom) frame)))])))) (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 (defn- ghost
"Where a drag out of the pool would land: the outline of its first frame, "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))}]])))) " M " cx " " (- cy 5) " V " (+ cy 5))}]]))))
(defn- overlay [w h] (defn- overlay [w h]
(let [clip @(rf/subscribe [::render/clip]) (let [tool @(rf/subscribe [::sub/tool])
frame @(rf/subscribe [::playback/frame])
tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft]) draft @(rf/subscribe [::sub/draft])
drawing? (= :polygon tool) drawing? (= :polygon tool)
[sid id _ geom active editable?] (editing frame) [sid id geom active editable? frame matrix] (editing)
pts (when geom (channel/value-at geom frame))] pts (when geom (through matrix (channel/value-at geom frame)))]
[:svg {:class (str "paint-overlay" (when drawing? " drawing")) [:svg {:class (str "paint-overlay" (when drawing? " drawing"))
:width (* zoom w) :height (* zoom h) :width (* zoom w) :height (* zoom h)
:view-box (str "0 0 " w " " h) :view-box (str "0 0 " w " " h)
@ -109,10 +115,12 @@
(let [[x y] (stage-point event w h)] (let [[x y] (stage-point event w h)]
(rf/dispatch [::ui/add-draft-point x y])))) (rf/dispatch [::ui/add-draft-point x y]))))
:on-pointer-move (fn [event] :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 (rf/dispatch [::paint-events/set-vertex
sid node key-frame 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-up (fn [_] (reset! dragging nil))
:on-pointer-cancel (fn [_] (reset! dragging nil))} :on-pointer-cancel (fn [_] (reset! dragging nil))}
[ghost] [ghost]
@ -123,7 +131,7 @@
[:g [:g
[:polygon {:points (points-text pts) :fill "none" [:polygon {:points (points-text pts) :fill "none"
:stroke "#e6ca8b" :stroke-width 1}] :stroke "#e6ca8b" :stroke-width 1}]
(when editable? (when-let [inv (when editable? (nest/invert matrix))]
(doall (doall
(for [[i [x y]] (map-indexed vector (pairs pts))] (for [[i [x y]] (map-indexed vector (pairs pts))]
^{:key i} ^{:key i}
@ -135,7 +143,7 @@
(.preventDefault event) (.preventDefault event)
(.setPointerCapture (.-currentTarget event) (.setPointerCapture (.-currentTarget event)
(.-pointerId event)) (.-pointerId event))
(reset! dragging [sid id active i]))}])))])])) (reset! dragging [sid id active i inv]))}])))])]))
(defn view [] (defn view []
;; Reactive on the clip's dimensions, so selecting a clip of another size ;; Reactive on the clip's dimensions, so selecting a clip of another size

View file

@ -47,6 +47,42 @@
(map - drawn (take 6 (array-seq (:pts op))))) (map - drawn (take 6 (array-seq (:pts op)))))
"resolved back out through the instance, it is exactly what was drawn"))) "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 (deftest a-placed-symbols-sound-is-heard-where-it-is-placed
(let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1" (let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1"
:span [10 40] :time {:mode :map :at -10 :rate 1} :span [10 40] :time {:mode :map :at -10 :rate 1}