Add polygon painting with per-gap drawing keys

This commit is contained in:
Olive Vaughn 2026-09-28 22:37:23 -04:00
parent 73ab153b02
commit a45e89f4e4
15 changed files with 435 additions and 11 deletions

View file

@ -92,6 +92,19 @@ Then open **<http://localhost:8778/>**. Django serves the page from
`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files
between the two.
### Paint sketch
Click **new polygon**, place at least three vertices on the stage, then click
**finish shape**. Select a shape to drag its vertices. Scrub to another frame and
click **new drawing key** to copy the visible outline there; the previous drawing
holds until that key. The numbered drawing-key buttons jump to editable keys.
The transition control between two drawing keys can switch that gap between a
hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
best when the same vertex
keeps the same meaning in every drawing. Paint shapes use the timeline clock
directly, so the roto exposure grid does not delay a drawing key or step its
tween. Use the project **save** button to persist the drawings.
`/index.html` still works, and that is deliberate: it is the URL the browser suite
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
resolution and the suite learned to ask for the file.

View file

@ -6,6 +6,7 @@
(:require [arthur.db :as db]
[arthur.events.footage :as footage]
[arthur.events.playback]
[arthur.events.paint]
[arthur.events.project]
[arthur.subs.playback]
[arthur.subs.render]

View file

@ -55,6 +55,7 @@
(def default
{;; --- the document ---
:clip/current :take
:paint/revision 0
:palette :arthur/default ; a NAME; the ramp itself is project data
;; --- the clip ---

View file

@ -169,9 +169,15 @@
;; ---------------------------------------------------------------------------
;; the specification
(defn segment-interp
"How the key at `left` leads to the next key. A channel default remains useful
for uniform tracks; :segments overrides only the gaps an artist chose."
[ch left]
(get (:segments ch) left (:interp ch)))
(defn- interpolate [ch f left right]
(let [a (get (:keys ch) left)]
(if (and (= :linear (:interp ch)) right (>= f left) (> right left))
(if (and (= :linear (segment-interp ch left)) right (>= f left) (> right left))
(let [b (get (:keys ch) right)
t (/ (- f left) (- right left))]
(if (vector? a)
@ -302,7 +308,9 @@
(every? (fn [v] (and (vector? v)
(= (count v) (count first-value))
(every? number? v)))
values)))]
values)))
linear? (or (= :linear (:interp ch))
(some #{:linear} (vals (:segments ch))))]
(cond-> []
(not (map? ch))
(conj "not a map")
@ -326,7 +334,14 @@
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
(conj (str ":interp " (:interp ch) " is not implemented"))
(and (map? ch) (= :linear (:interp ch))
(and (map? ch) (contains? ch :segments)
(or (not (map? (:segments ch)))
(not (:keys ch))
(not (every? (set (keys (:keys ch))) (keys (:segments ch))))
(not (every? #{:hold :linear} (vals (:segments ch))))))
(conj ":segments must map existing key frames to :hold or :linear")
(and (map? ch) linear?
(or (:dense ch) (not linear-values?)))
(conj ":linear interpolation needs numeric keys of one shape")

View file

@ -0,0 +1,57 @@
(ns arthur.domain.paint
"Small authored polygon operations. Paint nodes read timeline frames directly;
the roto root's exposure and picture sampling must not quantise a hand edit."
(:require [arthur.domain.channel :as channel]))
(def geometry [:geom :pts])
(defn shapes [clip]
(->> (get-in clip [:timelines :main :nodes])
(filter (fn [[_ node]] (:paint? node)))
(sort-by (comp :z val))
vec))
(defn active-frame [ch frame]
(let [frames (sort (keys (:keys ch)))]
(or (last (take-while #(<= % frame) frames)) (first frames))))
(defn new-shape [clip id frame points color]
(let [end (get-in clip [:timelines :main :frames])
z (str "z" (js/Date.now) "-" (name id))]
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
(even? (count points)))
(assoc-in clip [:timelines :main :nodes id]
{:id id :name (str "shape " (inc (count (shapes clip))))
:kind :poly :paint? true :parent nil :z z
:span [frame end]
:channels {geometry (channel/keyed {frame points})
[:style :color] (channel/framed color)}})
clip)))
(defn add-key [clip id frame]
(let [path [:timelines :main :nodes id]
node (get-in clip path)
ch (get-in node [:channels geometry])
[start end] (:span node)]
(if (and (:paint? node) (<= start frame) (< frame end) ch)
(assoc-in clip (into path [:channels geometry :keys frame])
(vec (channel/value-at ch frame)))
clip)))
(defn set-vertex [clip id key-frame vertex [x y]]
(let [path [:timelines :main :nodes id :channels geometry :keys key-frame]
points (get-in clip path)
i (* 2 vertex)]
(if (and points (< (inc i) (count points)))
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
clip)))
(defn set-segment-interp [clip id key-frame interp]
(let [node (get-in clip [:timelines :main :nodes id])
keys (get-in node [:channels geometry :keys])]
(if (and (:paint? node) (contains? keys key-frame)
(some #(< key-frame %) (clojure.core/keys keys))
(#{:hold :linear} interp))
(assoc-in clip [:timelines :main :nodes id :channels geometry
:segments key-frame] interp)
clip)))

View file

@ -0,0 +1,33 @@
(ns arthur.events.paint
(:require [arthur.domain.paint :as paint]
[arthur.footage.store :as store]
[re-frame.core :as rf]))
(defn- edit [db f]
(let [id (store/edit-clip! (:clip/current db) f)]
(if id
(-> db
(assoc :clip/current id)
(update :paint/revision (fnil inc 0))
(update :project merge {:status "paint edited · unsaved"}))
db)))
(rf/reg-event-db
::new-shape
(fn [db [_ id points color]]
(edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color))))
(rf/reg-event-db
::add-key
(fn [db [_ id]]
(edit db #(paint/add-key % id (get-in db [:playback :frame])))))
(rf/reg-event-db
::set-vertex
(fn [db [_ id key-frame vertex point]]
(edit db #(paint/set-vertex % id key-frame vertex point))))
(rf/reg-event-db
::set-segment-interp
(fn [db [_ id key-frame interp]]
(edit db #(paint/set-segment-interp % id key-frame interp))))

View file

@ -31,3 +31,13 @@
(if (= id (:id @loaded))
@loaded
(db/clip-entry id)))
(defn edit-clip!
"Edit the loaded document. Built-in clips are copied into the runtime store
on first edit, so their delayed source values remain reusable."
[id f]
(if (= id (:id @loaded))
(do (swap! loaded update :clip f) id)
(let [entry (entry id)]
(when entry
(install! (update entry :clip f) "paint")))))

View file

@ -15,11 +15,13 @@
[re-frame.core :as rf]))
(rf/reg-sub ::clip-id (fn [db _] (:clip/current db)))
(rf/reg-sub ::paint-revision (fn [db _] (:paint/revision db)))
(rf/reg-sub
::clip
:<- [::clip-id]
(fn [id _] (:clip (footage/entry id))))
:<- [::paint-revision]
(fn [[id _] _] (:clip (footage/entry id))))
(rf/reg-sub
::timeline

View file

@ -0,0 +1,149 @@
(ns arthur.ui.paint
"Polygon authoring overlay. The raster canvas remains the playback sink; SVG
supplies only editor handles and an unfinished outline."
(:require [arthur.domain.channel :as channel]
[arthur.domain.paint :as paint]
[arthur.domain.palette :as palette]
[arthur.events.paint :as events]
[arthur.events.playback :as pb]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[re-frame.core :as rf]
[reagent.core :as r]))
(defonce ^:private selected (r/atom nil))
(defonce ^:private drawing? (r/atom false))
(defonce ^:private draft (r/atom []))
(defonce ^:private tone (r/atom :skin-base))
(defonce ^:private dragging (atom nil))
(defn- point [event w h]
(let [box (.getBoundingClientRect (.-currentTarget event))]
[(-> (/ (* (- (.-clientX event) (.-left box)) w) (.-width box))
js/Math.round (max 0) (min (dec w)))
(-> (/ (* (- (.-clientY event) (.-top box)) h) (.-height box))
js/Math.round (max 0) (min (dec h)))]))
(defn- pairs [pts]
(mapv vec (partition 2 pts)))
(defn- points-text [pts]
(apply str (interpose " " (map (fn [[x y]] (str x "," y)) (pairs pts)))))
(defn- begin! []
(reset! selected nil)
(reset! draft [])
(reset! drawing? true))
(defn- finish! []
(when (>= (count @draft) 6)
(let [id (keyword (str "paint-" (random-uuid)))]
(rf/dispatch [::events/new-shape id @draft @tone])
(reset! selected id)
(reset! draft [])
(reset! drawing? false))))
(defn toolbar []
(let [clip @(rf/subscribe [::render/clip])
frame @(rf/subscribe [::playback/frame])
shapes (paint/shapes clip)
ids (set (map first shapes))
id (when (contains? ids @selected) @selected)
shape (get-in clip [:timelines :main :nodes id])
geom (get-in shape [:channels paint/geometry])
active (when geom (paint/active-frame geom frame))
keys (when geom (sort (keys (:keys geom))))
next-key (first (filter #(> % active) keys))
tween? (= :linear (channel/segment-interp geom active))]
[:div.paint-tools
[:div.row
[:strong "paint"]
[:button {:class (when @drawing? "on") :on-click begin!} "new polygon"]
(when @drawing?
[:button {:disabled (< (count @draft) 6) :on-click finish!} "finish shape"])
(when @drawing?
[:button {:on-click #(do (reset! drawing? false) (reset! draft []))} "cancel"])
[:label "colour "
[:select {:value (name @tone)
:on-change #(reset! tone (keyword (.. % -target -value)))}
(for [{:keys [name hex]} (rest palette/entries)]
^{:key name} [:option {:value (clojure.core/name name)}
(str (clojure.core/name name) " " hex)])]]]
[:div.row
[:label "shape "
[:select {:value (if id (name id) "")
:on-change #(reset! selected (when-not (= "" (.. % -target -value))
(keyword (.. % -target -value))))}
[:option {:value ""} "select"]
(for [[shape-id node] shapes]
^{:key shape-id} [:option {:value (name shape-id)} (:name node)])]]
(when id
[:button {:disabled (or (< frame (first (:span shape)))
(contains? (:keys geom) frame))
:on-click #(rf/dispatch [::events/add-key id])}
"new drawing key"])
(when (and id next-key)
[:label (str "key " active " → " next-key " ")
[:select {:value (name (or (channel/segment-interp geom active) :hold))
:on-change #(rf/dispatch [::events/set-segment-interp id active
(keyword (.. % -target -value))])}
[:option {:value "hold"} "hold"]
[:option {:value "linear"} "tween shape"]]])]
(when (seq keys)
[:div.row
[:span "drawing keys "]
(for [f keys]
^{:key f}
[:button {:class (when (= frame f) "on")
:on-click #(rf/dispatch [::pb/seek f])}
(str f)])])
[:div.hint
(cond
@drawing? (str "Click vertices on the stage, then Finish shape. "
(quot (count @draft) 2) " points")
(and id tween? (not (contains? (:keys geom) frame)))
"Tweening between drawings. Add a drawing key here, or jump to a key to edit its vertices."
id (str "Drag vertices to edit drawing key " active
". New drawing key copies the visible shape at this frame. The transition control changes only the selected gap.")
:else "Create a polygon, or select one to edit its drawing keys.")]]))
(defn overlay [w h zoom]
(let [clip @(rf/subscribe [::render/clip])
frame @(rf/subscribe [::playback/frame])
shape (get-in clip [:timelines :main :nodes @selected])
geom (get-in shape [:channels paint/geometry])
active (when geom (paint/active-frame geom frame))
visible? (and shape (let [[start end] (:span shape)] (<= start frame) (< frame end)))
pts (when visible? (channel/value-at geom frame))
editable? (or (not= :linear (channel/segment-interp geom active))
(contains? (:keys geom) frame))]
[:svg.paint-overlay
{:width (* zoom w) :height (* zoom h)
:view-box (str "0 0 " w " " h)
:on-pointer-down (fn [event]
(when @drawing?
(let [[x y] (point event w h)]
(swap! draft into [x y]))))
:on-pointer-move (fn [event]
(when-let [[id key-frame vertex] @dragging]
(rf/dispatch [::events/set-vertex id key-frame vertex
(point event w h)])))
:on-pointer-up (fn [_] (reset! dragging nil))
:on-pointer-cancel (fn [_] (reset! dragging nil))}
(when (seq @draft)
[:polyline {:points (points-text @draft) :fill "none"
:stroke "#d0ba86" :stroke-width 1}])
(when (and visible? (not @drawing?) pts)
[:g
[:polygon {:points (points-text pts) :fill "none"
:stroke "#e6ca8b" :stroke-width 1}]
(for [[i [x y]] (map-indexed vector (pairs (when editable? pts)))]
^{:key i}
[:circle {:cx x :cy y :r 2.6 :fill "#fff1be"
:stroke "#161820" :stroke-width 0.7
:on-pointer-down (fn [event]
(.stopPropagation event)
(.preventDefault event)
(.setPointerCapture (.-currentTarget event)
(.-pointerId event))
(reset! dragging [@selected active i]))}])])]))

View file

@ -16,6 +16,7 @@
[arthur.subs.playback :as sub]
[arthur.subs.render :as render]
[arthur.ui.player :as player]
[arthur.ui.paint :as paint]
[re-frame.core :as rf]
[reagent.core :as r]))
@ -299,15 +300,18 @@
;; store, so this being a re-render costs nothing per frame.
(let [w @(rf/subscribe [::sub/width])
h @(rf/subscribe [::sub/height])]
[:canvas.stage
{:ref #(player/set-canvas! %)
:width w :height h
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]))
[:div.stage-wrap
[:canvas.stage
{:ref #(player/set-canvas! %)
:width w :height h
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]
[paint/overlay w h zoom]]))
(defn view []
[:main
[:h1 "arthur"]
[stage]
[paint/toolbar]
[audio]
[transport]
[exporter]

View file

@ -30,6 +30,16 @@
(is (= (mapv #(ch/value-at c %) frames)
(mapv #(ch/sample! cursor %) frames)))))
(deftest one-channel-can-cut-then-tween
(let [c (assoc (ch/keyed {0 [0 0], 4 [4 0], 8 [8 0]})
:segments {4 :linear})
cursor (ch/cursor c)]
(is (= [0 0] (ch/value-at c 2)))
(is (= [4 0] (ch/value-at c 4)))
(is (= [6 0] (ch/value-at c 6)))
(is (= (mapv #(ch/value-at c %) [0 2 4 6 8 3 7])
(mapv #(ch/sample! cursor %) [0 2 4 6 8 3 7])))))
(deftest a-frame-before-the-first-key-reads-the-first-key
;; The JS activeKey clamps low, and that is kept: a channel's first key is the
;; pose the part starts in. Having NO value is a different question — it is a

View file

@ -0,0 +1,32 @@
(ns arthur.domain.paint-test
(:require [cljs.test :refer [deftest is]]
[arthur.demo :as demo]
[arthur.domain.channel :as channel]
[arthur.domain.leaf :as leaf]
[arthur.domain.paint :as paint]
[arthur.domain.timeline :as timeline]))
(defn- geometry [clip]
(get-in clip [:timelines :main :nodes :paint-test :channels paint/geometry]))
(deftest drawing-keys-hold-and-tween-on-the-timeline-clock
(let [a [10 10 30 10 20 30]
c0 (paint/new-shape demo/clip :paint-test 3 a :brow)
c1 (paint/add-key c0 :paint-test 9)
c2 (paint/set-vertex c1 :paint-test 9 0 [22 10])
c3 (paint/add-key c2 :paint-test 15)
c4 (paint/set-vertex c3 :paint-test 15 0 [34 10])
held (geometry c4)
mixed-clip (paint/set-segment-interp c4 :paint-test 9 :linear)
mixed (geometry mixed-clip)]
(is (= [3 229] (get-in c2 [:timelines :main :nodes :paint-test :span])))
(is (= a (channel/value-at held 8)))
(is (= 10 (first (channel/value-at held 8))))
(is (= 22 (first (channel/value-at held 9))))
(is (= 10 (first (channel/value-at mixed 6))) "the first gap cuts")
(is (= 28 (first (channel/value-at mixed 12))) "the second gap tweens")
(is (empty? (channel/problems mixed)))
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
(is (some #(= :paint-test (:node %))
(timeline/eval-frame (get-in c2 [:timelines :main]) 3)))
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))

View file

@ -394,6 +394,95 @@ async function main() {
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].selectedClip));
// Paint through the visible controls. This exercises the SVG pointer path,
// the authored node, the raster preview and the document round trip.
await page.eval(SEEK(0));
await sleep(150);
const beforePaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
return [...d];
})()`);
const painted = await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'new polygon');
button.click();
const svg = document.querySelector('.paint-overlay');
const box = svg.getBoundingClientRect();
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: box.left + x * box.width / 320,
clientY: box.top + y * box.height / 200,
}));
}
return true;
})()`);
await sleep(100);
const finished = await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'finish shape');
if (!button || button.disabled) return false;
button.click();
return true;
})()`);
await sleep(250);
const afterPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
return [...d];
})()`);
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
'a polygon drawn with the paint controls appears on the canvas');
for (const f of [8, 16]) {
await page.eval(SEEK(f));
await sleep(100);
check(await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'new drawing key');
if (!button || button.disabled) return false;
button.click();
return true;
})()`), `a drawing key can be added at frame ${f}`);
await sleep(100);
}
await page.eval(SEEK(8));
await sleep(100);
const tweenSelected = await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
const select = label?.querySelector('select');
if (!select) return false;
select.value = 'linear';
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
await sleep(100);
check(tweenSelected && await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the second drawing gap can be set to tween');
check(await page.eval(CLICK('save')), 'the painted document can be saved');
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
'the painted shape is saved', await page.eval(STATUS));
check(await page.eval(CLICK('open')), 'the painted document can be reopened');
check((await statusMatching(/opened /)) !== null,
'the painted shape is reopened', await page.eval(STATUS));
await sleep(150);
const reopenedPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
})()`);
check(afterPaint.join(',') === reopenedPaint.join(','),
'the painted polygon survives the project round trip');
await page.eval(SEEK(8));
await sleep(100);
check(await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the per-gap tween setting survives the project round trip');
// The local 8625 study is optional in a fresh test database. When present,
// tune its shared symbol while its stage is playing.
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')