Merge master drawing tools with tracing layers

This commit is contained in:
Olive Vaughn 2026-10-04 00:05:16 -04:00
commit f4dd047642
27 changed files with 2025 additions and 306 deletions

View file

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@mediapipe/tasks-vision": "1.0.1", "@mediapipe/tasks-vision": "1.0.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -1015,6 +1016,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/polygon-clipping": {
"version": "0.15.7",
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
"integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^3.0.2",
"splaytree": "^3.1.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -1216,6 +1227,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1416,6 +1433,15 @@
"source-map": "^0.5.6" "source-map": "^0.5.6"
} }
}, },
"node_modules/splaytree": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz",
"integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==",
"license": "MIT",
"engines": {
"node": ">=18.20 || >=20"
}
},
"node_modules/stream-browserify": { "node_modules/stream-browserify": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",

View file

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@mediapipe/tasks-vision": "1.0.1", "@mediapipe/tasks-vision": "1.0.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View file

@ -17,6 +17,7 @@
[arthur.ui.index :as index] [arthur.ui.index :as index]
[arthur.ui.player :as player] [arthur.ui.player :as player]
[arthur.ui.shell :as shell] [arthur.ui.shell :as shell]
[arthur.ui.tools :as tools]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.dom.client :as rdc])) [reagent.dom.client :as rdc]))
@ -46,6 +47,7 @@
;; the blank one, and the blank one is what a bad address leaves on screen. ;; the blank one, and the blank one is what a bad address leaves on screen.
(collab/start!) (collab/start!)
(history/install-keys!) (history/install-keys!)
(tools/install-keys!)
(reset! root (rdc/create-root (js/document.getElementById "app"))) (reset! root (rdc/create-root (js/document.getElementById "app")))
(mount) (mount)
(player/start!)) (player/start!))

View file

@ -172,7 +172,8 @@
:selection nil :selection nil
:selections [] :selections []
:tone 1 :tone 1
:tool nil :tool :select
:brush 6
:auto-key? false :auto-key? false
:draft [] :draft []
:knobs {} :knobs {}

View file

@ -254,6 +254,53 @@
;; and generated symbols carry their own rate explicitly. ;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn- op-path [op] (let [n (:node op)] (if (vector? n) n [n])))
(defn- own-span
"Where in `ops` the symbol at row path `path` is drawn: the end of its layer
when it has one, and the first and last of its own ops."
[ops path]
(let [own? #(and (< (count path) (count (op-path %)))
(= path (subvec (op-path %) 0 (count path))))
begin (first (keep-indexed #(when (and (= :begin (:kind %2)) (= path (op-path %2))) %1) ops))
end (when begin
(reduce (fn [depth i]
(case (:kind (ops i))
:begin (inc depth)
:end (if (= 1 depth) (reduced i) (dec depth))
depth))
0 (range begin (count ops))))
mine (keep-indexed #(when (own? %2) %1) ops)]
{:end (when (integer? end) end) :from (first mine) :to (last mine)}))
(defn- insert-at [ops i & more] (into (into (subvec ops 0 i) more) (subvec ops i)))
(defn atop
"`ops` with `op` drawn on top of what the symbol at row path `path` draws —
in ITS stacking context, where a shape added to it would land, so what is
above that symbol stays above. At the end when it drew nothing."
[ops path op]
(let [ops (vec ops)
{:keys [end to]} (own-span ops path)]
(cond end (insert-at ops end op)
to (insert-at ops (inc to) op)
:else (conj ops op))))
(defn in-layer
"`ops` with `op` drawn last INSIDE the symbol at row path `path` — in its
layer, so a knockout clears only what that symbol drew, as one saved there
would. A symbol that has no layer yet is given one around its ops. `ops`
unchanged when that symbol drew nothing."
[ops path op]
(let [ops (vec ops)
{:keys [end from to]} (own-span ops path)]
(cond
end (insert-at ops end op)
from (-> ops
(insert-at (inc to) op {:kind :end :node path})
(insert-at from {:kind :begin :node path}))
:else ops)))
(defn- transform-op (defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space. Its "Put a symbol's already resolved mark into its instance's parent space. Its
name becomes its path of instances down to it, the path its timeline row has." name becomes its path of instances down to it, the path its timeline row has."
@ -392,6 +439,9 @@
;; there used to be. Their resolvers still hold the frame ;; there used to be. Their resolvers still hold the frame
;; before whenever they were not on. ;; before whenever they were not on.
entered (volatile! {}) entered (volatile! {})
;; A symbol that knocks out is drawn into a layer of its own,
;; so what it clears is only ever its own.
layered? (symbol/knocks? nodes)
step (fn [f pre inherited forced] step (fn [f pre inherited forced]
(when context? (when context?
(vreset! active (or forced (vreset! active (or forced
@ -405,7 +455,7 @@
(vreset! entered {}) (vreset! entered {})
(let [by-id (into {} (map (juxt :node identity)) (let [by-id (into {} (map (juxt :node identity))
(own (js/Math.floor f) (js/Math.floor pre)))] (own (js/Math.floor f) (js/Math.floor pre)))]
(into [] (cond-> (into (if layered? [{:kind :begin :node []}] [])
(mapcat (mapcat
(fn [id] (fn [id]
(let [n (get nodes id)] (let [n (get nodes id)]
@ -447,7 +497,8 @@
(when (and context? (:palette n)) (when (and context? (:palette n))
(selection-at n frame nil))))))) (selection-at n frame nil)))))))
(when-let [op (get by-id id)] [op])))) (when-let [op (get by-id id)] [op]))))
ids))))] ids))
layered? (conj {:kind :end :node []}))))]
(reify (reify
IFn IFn
(-invoke [_ f] (step f (dec f) nil nil)) (-invoke [_ f] (step f (dec f) nil nil))

View file

@ -0,0 +1,74 @@
(ns arthur.domain.cut
"The eraser's cut: a shape's ring with a stroke taken out of it.
Illustrator's and Flash's eraser, not a raster one: what is left is the
SHAPE, with the cut edge made of its own points — points the pen moves, keys
and tweens like any others. A cut through the middle leaves two pieces, and
a cut inside it leaves a hole, which is bridged into the one ring as a brush
stroke's is (see `outline`).
In stage pixels, on both sides: the preview cuts the rings it is about to
draw, and the saved cut cuts the same rings and takes the answer back into
the shape's own coordinates — one function, so the preview is the result."
(:require ["polygon-clipping" :as clipping]
[arthur.domain.channel :as channel]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.outline :as outline]
[arthur.domain.paint :as paint]))
(defn- area [ring]
(let [ps (vec (partition 2 ring)) n (count ps)]
(js/Math.abs (/ (reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
(- (* ax by) (* bx ay))))
(range n)))
2))))
(defn cut
"Ring `ring` with `cutters` — each `[outer & holes]` — taken out of it: one
ring per piece left, biggest first, an empty vector when nothing is left, or
nil when the cut does not touch it."
[ring cutters]
(let [shape #js [(outline/->js ring)]
knife (into-array (map #(into-array (map outline/->js %)) cutters))]
(when (seq (array-seq (clipping/intersection shape knife)))
(->> (array-seq (clipping/difference shape knife))
(map (fn [^js poly] (mapv outline/->ring (array-seq poly))))
(sort-by (comp - area first))
(mapv outline/join)))))
(defn- through [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 erase
"`clip` with `cutters`, in the stage pixels of symbol `open` at frame `f`,
cut out of each shape at row path in `paths`, on the frame each is showing.
The shape keeps the biggest piece, on a key at that frame — made there if
the frame had none, so the keys either side keep their points. Every other
piece is a new shape of the same colour, its id the next of `ids`. A shape
cut away entirely is deleted."
[clip store open f paths cutters ids]
(first
(reduce
(fn [[clip ids] path]
(let [{:keys [sid id frame world]} (nest/placement clip store open path f)
n (get-in clip [:symbols sid :nodes id])
geom (get-in n [:channels paint/geometry])
inv (when world (node/invert world))
left (when (and inv geom)
(cut (through world (channel/value-at geom frame store)) cutters))]
(cond
(nil? left) [clip ids]
(empty? left) [(nest/delete-node clip sid id) ids]
:else
(let [[keep & more] (map #(through inv %) left)
colour (channel/value-at (get-in n [:channels [:style :color]]) frame store)
kept (-> (if (contains? (:keys geom) frame) clip (paint/add-key clip sid id frame))
(paint/set-points sid id frame keep))]
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame pts colour))
kept (map vector ids more))
(drop (count more) ids)]))))
[clip ids] paths)))

View file

@ -0,0 +1,293 @@
(ns arthur.domain.outline
"A brush stroke, as pixels and then as polygons.
Flash's brush: what you paint becomes filled shapes the moment you let go, and
the stroke is not kept. Here the stroke is first a MASK — a byte per stage
pixel, stamped with the brush's disc as the pointer moves, so what is on screen
while painting is exactly the pixels — and on letting go each piece of it is
traced round its pixel edges and simplified to as many points as are asked for,
as the mouth's ring is.
The trace walks pixel CORNERS, so before simplifying, a traced ring filled by
`raster/fill-poly-buf!` is the mask again exactly.
A HOLE STAYS A HOLE, and a shape is still one ring. A loop painted round a gap
is a piece with a hole in it; the hole is traced as well, and joined to the
outside by a HORIZONTAL bridge along a whole-pixel row — out and back along
the same line. The fill samples at pixel centres (y + 0.5) and an edge with no
height crosses no scanline, so the bridge draws nothing, the even-odd fill
leaves the hole empty, and nothing downstream has to know a ring can have
one. It is how earcut and the TrueType rasterisers take holes too.
`pieces` keeps the trace — outside and holes, at full resolution — so the
fit can change after the stroke, and `polygon` is the one place it is fitted
and bridged: the preview and the saved shape are the same function's output,
so what is on screen while painting is what is kept.
FITTED TO A TOLERANCE, not cut to a count: every point of the traced edge is
within so many pixels of the polygon (Douglas-Peucker), so points go where
the shape bends and none are spent on a straight run."
(:require ["polygon-clipping" :as clipping]
[arthur.domain.raster :as raster]))
(defn mask [w h] {:w w :h h :buf (js/Uint8Array. (* w h))})
(defn stamp!
"The brush, `size` pixels across, from `[ax ay]` to `[bx by]`: a disc every
half radius along the way, so a fast stroke is still one stroke."
[m [ax ay] [bx by] size]
(let [r (max 0.5 (/ size 2))
steps (max 1 (js/Math.ceil (/ (js/Math.hypot (- bx ax) (- by ay)) (max 0.5 (/ r 2)))))]
(dotimes [i (inc steps)]
(let [t (/ i steps)]
(raster/fill-disc! m (+ ax (* t (- bx ax))) (+ ay (* t (- by ay))) r 1))))
m)
(defn- flood!
"Label the 4-connected region of pixels whose ink is `ink` from pixel `start`
with `id` in `lab`, inside the box `[x0 y0 x1 y1]`. Its size, and whether it
reaches the box's edge — a gap that does is outside, not a hole.
A typed stack and no allocation per pixel: this runs on every pointer move."
[^js lab ^js buf w [x0 y0 x1 y1] ink start id ^js stack]
(aset lab start id)
(aset stack 0 start)
(let [top (volatile! 1) size (volatile! 0) edge? (volatile! false)
visit! (fn [q] (when (and (zero? (aget lab q)) (== ink (aget buf q)))
(aset lab q id)
(aset stack @top q)
(vswap! top inc)))]
(while (pos? @top)
(let [p (aget stack (vswap! top dec))
x (mod p w) y (quot p w)]
(vswap! size inc)
(when (or (== x x0) (== x x1) (== y y0) (== y y1)) (vreset! edge? true))
(when (< x0 x) (visit! (dec p)))
(when (< x x1) (visit! (inc p)))
(when (< y0 y) (visit! (- p w)))
(when (< y y1) (visit! (+ p w)))))
{:size @size :edge? @edge?}))
(def ^:private dirs [[1 0] [0 1] [-1 0] [0 -1]])
(defn- ring
"The edge of the region `in?` that starts at pixel `start`, the first of it in
raster order, as flat corner points: walked with the region on the right and a
point only where the walk turns. Round a piece, that is its outside; round a
hole, the inside edge of the piece around it."
[w in? start]
(let [x0 (mod start w) y0 (quot start w)]
;; Heading east along the start pixel's top edge, the region is below: on the
;; right. At each corner the two pixels ahead decide: the one ahead on the
;; right empty turns right, the one ahead on the left full turns left, and
;; otherwise straight on. Turning right first keeps two regions that touch
;; only at a corner apart, as the 4-connected fill did.
;; The start corner is always a turn — the walk arrives at it heading
;; north up the start pixel's left edge — and is passed only once, because
;; the three pixels round it other than the start are all outside.
(loop [x x0 y y0 d 0 out [x0 y0]]
(let [[l r] (case d
0 [[x (dec y)] [x y]]
1 [[x y] [(dec x) y]]
2 [[(dec x) y] [(dec x) (dec y)]]
3 [[(dec x) (dec y)] [x (dec y)]])
nd (cond (not (apply in? r)) (mod (inc d) 4)
(apply in? l) (mod (+ d 3) 4)
:else d)
out (if (or (= nd d) (and (= x x0) (= y y0))) out (conj out x y))
[dx dy] (dirs nd)
nx (+ x dx) ny (+ y dy)]
(if (and (= nx x0) (= ny y0))
out
(recur nx ny nd out))))))
(defn- bounds
"`[x0 y0 x1 y1]` round what the mask holds, a pixel wider each way so a gap
open to the outside reaches the edge of it — or nil for an empty mask."
[{:keys [w h ^js buf]}]
(let [b #js [w h -1 -1]]
(dotimes [o (* w h)]
(when (== 1 (aget buf o))
(let [x (mod o w) y (quot o w)]
(aset b 0 (min (aget b 0) x)) (aset b 1 (min (aget b 1) y))
(aset b 2 (max (aget b 2) x)) (aset b 3 (max (aget b 3) y)))))
(when (<= 0 (aget b 2))
[(max 0 (dec (aget b 0))) (max 0 (dec (aget b 1)))
(min (dec w) (inc (aget b 2))) (min (dec h) (inc (aget b 3)))])))
(defn pieces
"Each piece of the mask bigger than `smallest` pixels, biggest first, as
`{:outer ring :holes [ring …]}` at full resolution: a hole is a gap inside a
piece that does not reach the outside, of more than `smallest` pixels."
([m] (pieces m 2))
([{:keys [w h ^js buf] :as m} smallest]
(when-let [[x0 y0 x1 y1 :as box] (bounds m)]
(let [lab (js/Int32Array. (* w h))
stack (js/Int32Array. (* w h))
;; Pieces get positive labels and gaps negative ones, in raster
;; order, so each is labelled from its own first pixel.
found (let [acc (array)]
(doseq [y (range y0 (inc y1))]
(dotimes [i (inc (- x1 x0))]
(let [o (+ x0 i (* y w))]
(when (zero? (aget lab o))
(let [ink (aget buf o)
id (cond-> (inc (.-length acc)) (zero? ink) -)]
(.push acc (assoc (flood! lab buf w box ink o id stack)
:id id :start o)))))))
(vec acc))
in (fn [id] (fn [x y] (and (< -1 x w) (< -1 y h) (== id (aget lab (+ x (* y w)))))))
;; A hole's first pixel has a pixel of its piece directly above it:
;; anything else above would be the hole itself, and earlier.
holes (group-by #(aget lab (- (:start %) w))
(filter #(and (neg? (:id %)) (not (:edge? %)) (< smallest (:size %))) found))]
(->> found
(filter #(and (pos? (:id %)) (< smallest (:size %))))
(sort-by (comp - :size))
(mapv (fn [{:keys [id start]}]
{:outer (ring w (in id) start)
:holes (mapv #(ring w (in (:id %)) (:start %)) (get holes id))})))))))
(defn rings
"The outside of every piece of the mask, as flat corner points, biggest
first."
([m] (rings m 2))
([m smallest] (mapv :outer (pieces m smallest))))
(defn- area
"The area inside ring `pts`, by the shoelace."
[pts]
(let [ps (vec (partition 2 pts)) n (count ps)]
(js/Math.abs (/ (reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
(- (* ax by) (* bx ay))))
(range n)))
2))))
(defn- bridge
"Ring `outer` with `hole` joined into it: from the hole's leftmost point,
along its row to the nearest edge of `outer` on the left, round the hole, and
back. Both are on a whole-pixel row, so the bridge is never filled. See the
namespace docstring."
[outer hole]
(let [hs (vec (partition 2 hole))
k (apply min-key (comp first hs) (range (count hs)))
[hx hy] (hs k)
os (vec (partition 2 outer))
n (count os)
;; The edge the row meets nearest on the left. A level edge is skipped:
;; running along one draws nothing either.
[i qx] (->> (range n)
(keep (fn [i]
(let [[ax ay] (os i) [bx by] (os (mod (inc i) n))]
(when (and (not= ay by) (<= (min ay by) hy (max ay by)))
(let [x (+ ax (* (/ (- hy ay) (- by ay)) (- bx ax)))]
(when (< x hx) [i x]))))))
(reduce (fn [best c] (if (or (nil? best) (< (second best) (second c))) c best))
nil))]
(if i
(vec (concat (apply concat (subvec os 0 (inc i)))
[qx hy]
(apply concat (subvec hs k)) (apply concat (subvec hs 0 k))
[hx hy qx hy]
(apply concat (subvec os (inc i)))))
outer)))
(defn- dp
"Douglas-Peucker over the open run of points `i`..`j` of `xs`/`ys`: the
indices kept so that no point between is further than `tol` from the line."
[^js xs ^js ys i j tol]
(let [ax (aget xs i) ay (aget ys i) bx (aget xs j) by (aget ys j)
dx (- bx ax) dy (- by ay) l (js/Math.hypot dx dy)
[k d] (reduce (fn [[_ best :as acc] k]
(let [px (- (aget xs k) ax) py (- (aget ys k) ay)
e (if (zero? l) (js/Math.hypot px py) (/ (js/Math.abs (- (* dx py) (* dy px))) l))]
(if (< best e) [k e] acc)))
[nil 0] (range (inc i) j))]
(if (and k (< tol d))
(into (dp xs ys i k tol) (rest (dp xs ys k j tol)))
[i j])))
(defn fit
"Ring `pts` with as few points as keep every point of it within `tol` pixels
of the result: Douglas-Peucker, closed by splitting at the point furthest from
the first. A pixel staircase within a pixel of a diagonal becomes the
diagonal, and a square corner stays — the points go where the shape bends."
[pts tol]
(let [c (quot (count pts) 2)]
(if (<= c 3)
(vec pts)
(let [xs (js/Float64Array. (take-nth 2 pts)) ys (js/Float64Array. (take-nth 2 (rest pts)))
far (apply max-key #(js/Math.hypot (- (aget xs %) (aget xs 0)) (- (aget ys %) (aget ys 0)))
(range c))
xs2 (js/Float64Array. (inc c)) ys2 (js/Float64Array. (inc c))
_ (dotimes [k c] (aset xs2 k (aget xs k)) (aset ys2 k (aget ys k)))
_ (do (aset xs2 c (aget xs 0)) (aset ys2 c (aget ys 0)))
keep (into (dp xs2 ys2 0 far tol) (rest (butlast (dp xs2 ys2 far c tol))))]
(if (< (count keep) 3)
(vec pts)
(into [] (mapcat (fn [k] [(aget xs k) (aget ys k)])) keep))))))
(defn- crosses?
"Does any edge of ring `a` cross any edge of ring `b`?"
[a b]
(let [edges (fn [r] (let [ps (vec (partition 2 r)) n (count ps)]
(map (fn [i] [(ps i) (ps (mod (inc i) n))]) (range n))))
side (fn [[ax ay] [bx by] [px py]] (- (* (- bx ax) (- py ay)) (* (- by ay) (- px ax))))]
(some (fn [[p q]]
(some (fn [[r s]]
(and (neg? (* (side p q r) (side p q s)))
(neg? (* (side r s p) (side r s q)))))
(edges b)))
(edges a))))
(defn- perimeter [pts]
(let [ps (vec (partition 2 pts)) n (count ps)]
(reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
(js/Math.hypot (- bx ax) (- by ay))))
(range n)))))
(defn ->js
"Flat ring `ring` as `polygon-clipping` takes one."
[ring]
(into-array (map into-array (partition 2 ring))))
(defn ->ring
"A ring back from `polygon-clipping`, flat, without the point it repeats to
close."
[^js r]
(into [] (mapcat identity) (butlast (map vec (array-seq r)))))
(defn rings-of
"Piece `p` of `pieces` fitted to within `tol` pixels, outside first, holes
after.
EVERY HOLE IS KEPT, and kept simple: fitted like the outside, then clipped to
the inside of it and clear of the holes before it. Fitting each ring on its
own can push a hole's edge across the outside's, and a crossing is what makes
the even-odd fill cut through the body; clipping takes exactly that part off
and leaves the few points the fit chose. Fitting closer until nothing crossed
was tried, and spent hundreds of points on what is plainly a straight line."
[{:keys [outer holes]} tol]
(let [outer (fit outer tol)]
(reduce (fn [rs hole]
(let [h (fit hole tol)]
(if (not-any? #(crosses? h %) rs)
(conj rs h)
(let [inside (clipping/intersection #js [(->js h)] #js [(->js outer)])
clear (if (next rs)
(clipping/difference inside (into-array (map #(array (->js %)) (rest rs))))
inside)]
(into rs (comp (map #(->ring (aget % 0))) (filter #(< 2 (area %))))
(array-seq clear))))))
[outer]
(sort-by (comp - area) holes))))
(defn join
"Rings `[outer & holes]` as one ring, each hole bridged in, leftmost first."
[[outer & holes]]
(reduce bridge outer (sort-by #(apply min (take-nth 2 %)) holes)))
(defn polygon
"Piece `p` of `pieces` as the one ring a shape is made of. See `rings-of`."
[p tol]
(join (rings-of p tol)))

View file

@ -53,3 +53,48 @@
(if (and points (< (inc i) (count points))) (if (and points (< (inc i) (count points)))
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y))) (assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
clip))) clip)))
(defn- every-key
"`f` over the points of every key of the shape's geometry."
[clip sid id f]
(let [path [:symbols sid :nodes id :channels geometry :keys]]
(if (and (:paint? (get-in clip [:symbols sid :nodes id])) (map? (get-in clip path)))
(update-in clip path update-vals f)
clip)))
(defn insert-vertex
"A new point after point `i`, a fraction `t` of the way along the edge to the
next, on EVERY key: a point's index is what it is across keys, so a tween
between two keys only means anything while they have the same points. The
shape does not change on any key."
[clip sid id i t]
(every-key clip sid id
(fn [pts]
(let [n (quot (count pts) 2)
j (mod (inc i) n)
at #(+ (nth pts (+ (* 2 i) %))
(* t (- (nth pts (+ (* 2 j) %)) (nth pts (+ (* 2 i) %)))))]
(if (< i n)
(-> (subvec pts 0 (* 2 (inc i)))
(conj (at 0) (at 1))
(into (subvec pts (* 2 (inc i)))))
pts)))))
(defn delete-vertex
"Point `i` gone from every key, as `insert-vertex` adds one. A triangle keeps
its three."
[clip sid id i]
(every-key clip sid id
(fn [pts]
(if (and (< 6 (count pts)) (< (* 2 i) (count pts)))
(into (subvec pts 0 (* 2 i)) (subvec pts (* 2 (inc i))))
pts))))
(defn set-points
"Key `key-frame` of the shape is `points`, whatever it held: a stroke being
re-simplified to another count."
[clip sid id key-frame points]
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
(if (get-in clip path)
(assoc-in clip path points)
clip)))

View file

@ -63,19 +63,8 @@
(and (<= 0 u) (< u w) (<= 0 v) (< v h)))) (and (<= 0 u) (< u w) (<= 0 v) (< v h))))
false)) false))
(defn hit (defn- op-bounds [{:keys [kind pts n cx cy r size knock lut] :as op}]
"The row path of the topmost op in `ops`, in draw order, at stage point `[x y]`, (case (when-not (or knock lut) kind)
or nil.
WHAT IS DRAWN WINS OVER A REFERENCE. A tracing photo usually covers the whole
stage and is painted over the picture, so topmost-first would make every click
land on it; a trace is picked only where nothing drawn is under the pointer."
[ops [x y]]
(let [{traces true drawn false} (group-by #(= :trace (:kind %)) (rseq (vec ops)))]
(some #(when (on? % x y) (path-of %)) (concat drawn traces))))
(defn- op-bounds [{:keys [kind pts n cx cy r size] :as op}]
(case kind
:trace (let [cs (trace-corners op)] :trace (let [cs (trace-corners op)]
[(apply min (map first cs)) (apply min (map second cs)) [(apply min (map first cs)) (apply min (map second cs))
(apply max (map first cs)) (apply max (map second cs))]) (apply max (map first cs)) (apply max (map second cs))])
@ -105,6 +94,29 @@
(defn- prefix? [a b] (defn- prefix? [a b]
(and (<= (count a) (count b)) (= a (subvec b 0 (count a))))) (and (<= (count a) (count b)) (= a (subvec b 0 (count a)))))
(defn hit-op
"The topmost op in `ops`, in draw order, that SHOWS at stage point `[x y]`, or
nil. A knockout is never hit: it is a hole, and a click in it is a click on
whatever shows through — so it hides the ops beneath it in its own symbol, of
the colour it clears."
[ops [x y]]
(:op (reduce (fn [holes op]
(cond
;; A remap is light on what is under it, not a thing.
(or (:lut op) (not (on? op x y))) holes
(:knock op) (conj holes [(pop (path-of op)) (:knock op)])
(some (fn [[in k]] (and (prefix? in (path-of op))
(or (neg? k) (== k (:color op)))))
holes) holes
:else (reduced {:op op})))
[] (let [{traces true drawn false} (group-by #(= :trace (:kind %)) (rseq (vec ops)))]
(concat drawn traces)))))
(defn hit
"The row path of the topmost op that shows at stage point `[x y]`, or nil."
[ops point]
(some-> (hit-op ops point) path-of))
(defn choose (defn choose
"The row path a click on `hit` selects, with `selected` the one selected now." "The row path a click on `hit` selects, with `selected` the one selected now."
[selected hit deep?] [selected hit deep?]

View file

@ -24,6 +24,53 @@
(.fill buf index) (.fill buf index)
r) r)
;; ---------------------------------------------------------------------------
;; layers and knockouts
;;
;; A symbol holding a KNOCKOUT shape is drawn into a layer of its own first —
;; Flash's Erase blend inside a symbol set to Layer. A layer is a raster with a
;; `:cov` byte per pixel saying whether anything is there; a knockout clears
;; coverage, of every colour or of one, and the layer lands on the one under it
;; only where it is covered. The main raster has no `:cov` and is always
;; covered.
;;
;; THE INK is how a shape marks the pixels it covers, and there are three, as
;; Deluxe Paint and Animator Pro had inks:
;;
;; nil an ordinary fill: write the shape's index
;; a number a knockout: clear coverage — of every colour at -1, or of
;; that index only
;; a Uint8Array a REMAP, index -> index: what is already there is drawn
;; in another slot. A flashlight is a circle of this.
(defn- plot! [^js buf cov o index ink]
(cond
(nil? ink) (do (aset buf o index)
(when cov (aset cov o 1)))
(number? ink) (when (and cov (or (neg? ink) (== (aget buf o) ink)))
(aset cov o 0))
:else (aset buf o (aget ink (aget buf o)))))
(defn- shows?
"Does pixel `o` hold `over`? A stencil only matches what is really there, so
an uncovered pixel of a layer holds nothing."
[^js buf cov o over]
(or (nil? over)
(and (or (nil? cov) (== 1 (aget cov o))) (== (aget buf o) over))))
(defn layer [w h]
{:w w :h h :buf (js/Uint8Array. (* w h)) :cov (js/Uint8Array. (* w h))})
(defn composite!
"Land layer `l` on raster `r` wherever `l` is covered."
[{:keys [buf cov] :as r} l]
(let [^js lb (:buf l) ^js lc (:cov l)]
(dotimes [o (.-length lb)]
(when (== 1 (aget lc o))
(aset buf o (aget lb o))
(when cov (aset cov o 1)))))
r)
(defn fill-poly-buf! (defn fill-poly-buf!
"Even-odd scanline fill of a polygon held FLAT in `pts` as [x0 y0 x1 y1 …], "Even-odd scanline fill of a polygon held FLAT in `pts` as [x0 y0 x1 y1 …],
using the first `n` points. Samples at pixel centres (y + 0.5), so a polygon using the first `n` points. Samples at pixel centres (y + 0.5), so a polygon
@ -35,8 +82,9 @@
allocation is the only thing that will make this stutter. allocation is the only thing that will make this stutter.
`pts` may be a CLJS vector or any typed array; scanline crossings are collected `pts` may be a CLJS vector or any typed array; scanline crossings are collected
into a plain JS array and sorted in place." into a plain JS array and sorted in place. `ink` is as `plot!`'s."
[{:keys [w h buf] :as r} pts n index] ([r pts n index] (fill-poly-buf! r pts n index nil))
([{:keys [w h buf cov] :as r} pts n index ink]
(when (>= n 3) (when (>= n 3)
(let [px (fn [i] (if (vector? pts) (-nth pts (* 2 i)) (aget pts (* 2 i)))) (let [px (fn [i] (if (vector? pts) (-nth pts (* 2 i)) (aget pts (* 2 i))))
py (fn [i] (if (vector? pts) (-nth pts (inc (* 2 i))) (aget pts (inc (* 2 i))))) py (fn [i] (if (vector? pts) (-nth pts (inc (* 2 i))) (aget pts (inc (* 2 i)))))
@ -78,8 +126,8 @@
x-to (min (dec w) (js/Math.floor (- xb 0.5))) x-to (min (dec w) (js/Math.floor (- xb 0.5)))
row (* y w)] row (* y w)]
(dotimes [dx (inc (- x-to x-from))] (dotimes [dx (inc (- x-to x-from))]
(aset buf (+ row x-from dx) index))))))))) (plot! buf cov (+ row x-from dx) index ink)))))))))
r) r))
(defn fill-poly! (defn fill-poly!
"`fill-poly-buf!` over a seq of {:x :y} points. "`fill-poly-buf!` over a seq of {:x :y} points.
@ -99,8 +147,9 @@
any radius, including mid-blink when the opening is a two-pixel sliver, so any radius, including mid-blink when the opening is a two-pixel sliver, so
the lid crops the iris for free instead of the gaze range needing a the lid crops the iris for free instead of the gaze range needing a
clamp that would flatten the performance at the extremes." clamp that would flatten the performance at the extremes."
([r cx cy rad index] (fill-disc! r cx cy rad index nil)) ([r cx cy rad index] (fill-disc! r cx cy rad index nil nil))
([{:keys [w h buf] :as r} cx cy rad index over] ([r cx cy rad index over] (fill-disc! r cx cy rad index over nil))
([{:keys [w h buf cov] :as r} cx cy rad index over ink]
(let [rr (* rad rad) (let [rr (* rad rad)
y0 (max 0 (js/Math.floor (- cy rad))) y0 (max 0 (js/Math.floor (- cy rad)))
y1 (min (dec h) (js/Math.ceil (+ cy rad))) y1 (min (dec h) (js/Math.ceil (+ cy rad)))
@ -114,8 +163,8 @@
dy (- (+ y 0.5) cy)] dy (- (+ y 0.5) cy)]
(when (<= (+ (* dx dx) (* dy dy)) rr) (when (<= (+ (* dx dx) (* dy dy)) rr)
(let [o (+ (* y w) x)] (let [o (+ (* y w) x)]
(when (or (nil? over) (= (aget buf o) over)) (when (shows? buf cov o over)
(aset buf o index))))))) (plot! buf cov o index ink)))))))
r))) r)))
(defn fill-rect! (defn fill-rect!
@ -132,8 +181,9 @@
on every frame. Round the extents instead and a fractional centre gives you on every frame. Round the extents instead and a fractional centre gives you
three pixels on one frame and four on the next, which reads as the pupil three pixels on one frame and four on the next, which reads as the pupil
breathing." breathing."
([r cx cy size index] (fill-rect! r cx cy size index nil)) ([r cx cy size index] (fill-rect! r cx cy size index nil nil))
([{:keys [w h buf] :as r} cx cy size index over] ([r cx cy size index over] (fill-rect! r cx cy size index over nil))
([{:keys [w h buf cov] :as r} cx cy size index over ink]
(let [size (js/Math.round size)] (let [size (js/Math.round size)]
(when (>= size 1) (when (>= size 1)
(let [x0 (js/Math.round (- cx (/ size 2))) (let [x0 (js/Math.round (- cx (/ size 2)))
@ -143,8 +193,8 @@
(dotimes [iy (- yb ya)] (dotimes [iy (- yb ya)]
(dotimes [ix (- xb xa)] (dotimes [ix (- xb xa)]
(let [o (+ (* (+ ya iy) w) xa ix)] (let [o (+ (* (+ ya iy) w) xa ix)]
(when (or (nil? over) (= (aget buf o) over)) (when (shows? buf cov o over)
(aset buf o index)))))))) (plot! buf cov o index ink))))))))
r)) r))
(def ^:private little-endian? (def ^:private little-endian?
@ -223,6 +273,27 @@
(aset d (+ o 3) 255)))))) (aset d (+ o 3) 255))))))
{:width W :height H :data d}))) {:width W :height H :data d})))
(defn- fill-mask!
"Every pixel set in `mask`, a byte per stage pixel: a brush stroke as it is
being painted, before it is a polygon."
[{:keys [buf cov] :as r} ^js mask index ink]
(dotimes [o (.-length mask)]
(when (== 1 (aget mask o))
(plot! buf cov o index ink)))
r)
;; One layer per depth of nesting, reused from frame to frame, as the resolver
;; reuses its point buffers.
(defonce ^:private layers (atom {}))
(defn- layer-at [depth w h]
(let [l (get @layers depth)]
(if (and l (= w (:w l)) (= h (:h l)))
l
(let [l (layer w h)]
(swap! layers assoc depth l)
l))))
(defn draw-ops! (defn draw-ops!
"Paint a list of draw ops, in the order given, into the raster. Stage 7. "Paint a list of draw ops, in the order given, into the raster. Stage 7.
@ -230,12 +301,26 @@
space points and a PALETTE INDEX, and the rasteriser knows nothing about nodes, space points and a PALETTE INDEX, and the rasteriser knows nothing about nodes,
channels, time maps or provenance. Everything above here can be rearranged channels, time maps or provenance. Everything above here can be rearranged
without touching a scanline, and a painted cel and a rotoscoped mouth arrive without touching a scanline, and a painted cel and a rotoscoped mouth arrive
here indistinguishable from each other, which is the point." here indistinguishable from each other, which is the point.
[r ops]
(doseq [{:keys [kind pts n color stencil cx cy size] :as op} ops] `:begin` and `:end` bracket the ops of a symbol drawn into a layer of its own;
`:knock` on an op makes it a knockout and `:lut` a remap. See `plot!`."
[{:keys [w h] :as r} ops]
(reduce
(fn [stack {:keys [kind pts n color stencil cx cy size] :as op}]
(let [top (peek stack)
ink (or (:knock op) (:lut op))]
(case kind (case kind
:poly (fill-poly-buf! r pts n color) :begin (let [l (layer-at (count stack) w h)]
:disc (fill-disc! r cx cy (:r op) color stencil) (.fill (:cov l) 0)
:rect (fill-rect! r cx cy size color stencil) (conj stack l))
(throw (ex-info "draw op kind is not rasterisable" {:op (dissoc op :pts)})))) :end (if (< 1 (count stack))
(let [s (pop stack)] (composite! (peek s) top) s)
stack)
:poly (do (fill-poly-buf! top pts n color ink) stack)
:disc (do (fill-disc! top cx cy (:r op) color stencil ink) stack)
:rect (do (fill-rect! top cx cy size color stencil ink) stack)
:mask (do (fill-mask! top (:mask op) color ink) stack)
(throw (ex-info "draw op kind is not rasterisable" {:op (dissoc op :pts)})))))
[r] ops)
r) r)

View file

@ -280,6 +280,41 @@
(nil? k) 255 (nil? k) 255
:else (get palette k 255))) :else (get palette k 255)))
(defn knockout?
"Is colour `c` a KNOCKOUT rather than a tone? `:clear` clears every colour
beneath it in its symbol, `[:clear tone]` clears that tone only. See
`raster/plot!`."
[c]
(or (= :clear c) (and (vector? c) (= :clear (first c)))))
(defn remap?
"Is colour `c` a REMAP: `[:remap {from to …}]`, slots of the palette in
scope? It draws nothing of its own; what is already under it is drawn in
other slots — a flashlight is a circle of this. See `raster/plot!`."
[c]
(and (vector? c) (= :remap (first c))))
(defn- knock-index [palette c]
(if (= :clear c) -1 (colour-index palette (second c))))
(defn lut
"Remap `[:remap m]` as a raster index -> index table in `palette`. Slots of
any other palette are left as they are."
[palette [_ m]]
(let [t (js/Uint8Array. 256)]
(dotimes [i 256] (aset t i i))
(doseq [[a b] m] (aset t (colour-index palette a) (colour-index palette b)))
t))
(defn knocks?
"Does any node in `nodes` knock out, on any frame? Such a symbol is drawn into
a layer of its own."
[nodes]
(some (fn [[_ n]]
(let [c (get-in n [:channels [:style :color]])]
(some knockout? (cons (:value c) (vals (:keys c))))))
nodes))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the walk ;; the walk
@ -401,7 +436,14 @@
"Emit geometry in the symbol's space. Rect sizes stay fractional until "Emit geometry in the symbol's space. Rect sizes stay fractional until
rasterization, so enclosing symbol transforms can still scale them." rasterization, so enclosing symbol transforms can still scale them."
[{:keys [palette buf-for]} n {:keys [m rd]} base] [{:keys [palette buf-for]} n {:keys [m rd]} base]
(let [colour #(colour-index palette (rd [:style :color]))] ;; Read only by the kinds that have a colour: a group or an instance has no
;; `[:style :color]` to read.
(let [paint (fn [op]
(let [c (rd [:style :color])]
(cond
(knockout? c) (assoc op :knock (knock-index palette c) :color 0)
(remap? c) (assoc op :lut (lut palette c) :color 0)
:else (assoc op :color (colour-index palette c)))))]
(case (:kind n) (case (:kind n)
:group nil :group nil
:instance nil :instance nil
@ -415,23 +457,21 @@
(node/apply-pt! out k m (node/apply-pt! out k m
(ch/component pts (* 2 k)) (ch/component pts (* 2 k))
(ch/component pts (inc (* 2 k))))) (ch/component pts (inc (* 2 k)))))
(assoc base :kind :poly :pts out :n np :color (colour))))) (paint (assoc base :kind :poly :pts out :n np)))))
:disc :disc
(let [rad (rd [:geom :radius])] (let [rad (rd [:geom :radius])]
(when-not (ch/nothing? rad) (when-not (ch/nothing? rad)
(assoc base :kind :disc (paint (assoc base :kind :disc
:cx (aget m 4) :cy (aget m 5) :cx (aget m 4) :cy (aget m 5)
:r (* rad (node/mean-scale m)) :r (* rad (node/mean-scale m))))))
:color (colour))))
:rect :rect
(let [size (rd [:geom :size])] (let [size (rd [:geom :size])]
(when-not (ch/nothing? size) (when-not (ch/nothing? size)
(assoc base :kind :rect (paint (assoc base :kind :rect
:cx (aget m 4) :cy (aget m 5) :cx (aget m 4) :cy (aget m 5)
:size (* size (node/mean-scale m)) :size (* size (node/mean-scale m))))))
:color (colour))))
(throw (ex-info "node kind is not implemented" (throw (ex-info "node kind is not implemented"
{:node (:id n) :kind (:kind n)}))))) {:node (:id n) :kind (:kind n)})))))

View file

@ -20,9 +20,11 @@
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clipboard :as clipboard] [arthur.domain.clipboard :as clipboard]
[arthur.domain.correction :as correction] [arthur.domain.correction :as correction]
[arthur.domain.cut :as cut]
[arthur.domain.creation :as creation] [arthur.domain.creation :as creation]
[arthur.domain.gesture :as gesture] [arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.outline :as outline]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.span :as span] [arthur.domain.span :as span]
@ -70,7 +72,7 @@
(-> db (-> db
(assoc-in [:ui :selection] selection) (assoc-in [:ui :selection] selection)
(assoc-in [:ui :selections] (if selection [selection] [])) (assoc-in [:ui :selections] (if selection [selection] []))
(update :ui dissoc :points :retry))) (update :ui dissoc :retry)))
(rf/reg-event-db ::select (fn [db [_ selection]] (selected db selection))) (rf/reg-event-db ::select (fn [db [_ selection]] (selected db selection)))
@ -425,7 +427,7 @@
(-> db (-> db
(assoc-in [:ui :selection] primary) (assoc-in [:ui :selection] primary)
(assoc-in [:ui :selections] addresses) (assoc-in [:ui :selections] addresses)
(update :ui dissoc :points :retry)) (update :ui dissoc :retry))
(-> db (-> db
(assoc-in [:ui :selection] nil) (assoc-in [:ui :selection] nil)
(assoc-in [:ui :selections] []))))) (assoc-in [:ui :selections] [])))))
@ -498,24 +500,71 @@
(rf/reg-event-db ::duplicate-unique (fn [db _] (duplicate-selected db true))) (rf/reg-event-db ::duplicate-unique (fn [db _] (duplicate-selected db true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; drawing a polygon ;; drawing
;; ;;
;; Three events and a vector of numbers. The draft is in app-db rather than in a ;; THE TOOL IS WHAT A DRAG ON THE STAGE DOES; the selection is what it does it
;; ratom because the stage draws it, the palette colours it and the params pane ;; to. One tool at a time, each with a key, as in Photoshop, Illustrator and
;; reports its vertex count — and because a half-drawn shape surviving a hot ;; Flash: V selects and transforms, P is the pen, B the brush, E the eraser.
;; reload is worth more than the handful of dispatches it costs. Clicks are rare; ;; Editing a shape's points is the pen ON that shape — Figma's vector edit
;; this is not the drag path. ;; mode — so there is no separate points mode to be in.
;;
;; The pen's draft is in app-db rather than in a ratom because the stage draws
;; it, the canvas fills it and the options bar counts it — and because a
;; half-drawn shape surviving a hot reload is worth more than the handful of
;; dispatches it costs. Clicks are rare; this is not the drag path. `:hover` is
;; where the pointer is while a draft is open, so the canvas can fill the shape
;; the next click would make.
(def tools #{:select :pen :brush :eraser})
(declare finishing-polygon)
(rf/reg-event-db
::set-tool
;; Leaving the pen commits what it was drawing, as Esc does: an unfinished
;; polygon is not a thing the document can hold.
(fn [db [_ tool]]
(if (contains? tools tool)
(-> (if (= tool (get-in db [:ui :tool])) db (finishing-polygon db))
(update :ui merge {:tool tool :draft []})
(update :ui dissoc :hover))
db)))
(rf/reg-event-db (rf/reg-event-db
::cancel-polygon ::cancel-polygon
(fn [db _] (update db :ui merge {:tool nil :draft []}))) (fn [db _] (update (assoc-in db [:ui :draft] []) :ui dissoc :hover)))
(rf/reg-event-db (rf/reg-event-db
::add-draft-point ::hover
(fn [db [_ x y]] (fn [db [_ p]] (if p (assoc-in db [:ui :hover] p) (update db :ui dissoc :hover))))
(if (= :polygon (get-in db [:ui :tool]))
(update-in db [:ui :draft] into [x y]) (def tool-keys #{"v" "p" "b" "e" "[" "]" "enter" "escape"})
db)))
(rf/reg-event-fx
::tool-key
;; A key from `ui/tools`, decided here against the state it changes, and
;; passed on as the event the button for it would send.
(fn [{:keys [db]} [_ k]]
(let [{:keys [tool draft brush]} (:ui db)
size (or brush 6)
by (if (< size 10) 1 2)
ev (case k
"v" [::set-tool :select]
"p" [::set-tool :pen]
"b" [::set-tool :brush]
"e" [::set-tool :eraser]
"[" [::brush-size (- size by)]
"]" [::brush-size (+ size by)]
"enter" (cond (seq draft) [::finish-polygon]
(#{:select nil} tool) [::set-tool :pen])
"escape" (when (= :pen tool)
(if (seq draft) [::finish-polygon] [::set-tool :select]))
nil)]
(if ev {:fx [[:dispatch ev]]} {}))))
(rf/reg-event-db
::brush-size
(fn [db [_ size]] (assoc-in db [:ui :brush] (-> size js/Math.round (max 1) (min 64)))))
(defn new-lane-drawing (defn new-lane-drawing
"Create the one-frame drawing cel implied by drawing with a lane row active. "Create the one-frame drawing cel implied by drawing with a lane row active.
@ -583,56 +632,153 @@
(peek (:path landing)) (:path landing)])) (peek (:path landing)) (:path landing)]))
:else db)] :else db)]
(if (:refused landing) (if (:refused landing) db' (assoc-in db' [:ui :draft] []))))
db'
(update db' :ui merge {:tool :polygon :draft []}))))
(rf/reg-event-db (rf/reg-event-db
::begin-polygon ::add-draft-point
(fn [db _] (beginning-polygon db))) ;; The first point is where the drawing begins: a lane gets its new cel then,
;; so it is the current one while the rest are placed.
(fn [db [_ x y]]
(if (= :pen (get-in db [:ui :tool]))
(update-in (if (seq (get-in db [:ui :draft])) db (beginning-polygon db))
[:ui :draft] (fnil into []) [x y])
db)))
(rf/reg-event-db (defn- landing-into
::finish-polygon "Where shapes drawn on the stage go, and their points re-expressed there:
;; The drawing already exists from `::begin-polygon`; finishing commits only `{:clip :sid :frame :down :rings}` with each of `rings` in that symbol's
;; the shape, so cancelling a draft does not undo the drawing that was started. coordinates, or `{:refused why}`. `down` is the row path to draw inside, or
(fn [db _] nil for the creation target, which a lane makes a new cel for."
(let [draft (get-in db [:ui :draft]) [db down rings]
open (get-in db [:ui :open]) (let [open (get-in db [:ui :open])
{clip :clip st :store} (store/entry (:clip/current db)) {clip :clip st :store} (store/entry (:clip/current db))
landing (polygon-landing clip st db) landing (if down {:clip clip :path down} (polygon-landing clip st db))
source (:clip landing) source (:clip landing)
down (:path landing) inside #(nest/drawn-inside source st open (:path landing) (editing-frame db source) %)
{:keys [sid frame pts]} at (when-not (:refused landing) (inside []))]
(when-not (:refused landing)
(nest/drawn-inside source st open down (editing-frame db source) draft))]
(cond (cond
(< (count draft) 6) db (:refused landing) landing
(:refused landing) (update db :project merge {:status (:refused landing)}) (nil? (:sid at)) {:refused (if (:lane? landing)
(nil? sid)
(update db :project merge
{:status (if (:lane? landing)
"this sequence is not on screen at this frame" "this sequence is not on screen at this frame"
"what you are drawing into is not on screen at this frame")}) "what you are drawing into is not on screen at this frame")}
:else {:clip source :sid (:sid at) :frame (:frame at) :down (:path landing)
:rings (mapv (comp :pts inside) rings)})))
(defn- finishing-polygon
"`db` with the pen's draft committed as a shape, when it has three points."
[db]
(let [draft (get-in db [:ui :draft])
{:keys [refused clip sid frame down rings]} (when (<= 6 (count draft))
(landing-into db nil [draft]))]
(cond
(< (count draft) 6) (update (assoc-in db [:ui :draft] []) :ui dissoc :hover)
refused (update db :project merge {:status refused})
;; `random-uuid` is the one impurity in this namespace, and it is here ;; `random-uuid` is the one impurity in this namespace, and it is here
;; rather than in `domain/paint` for the reason `clip/place-symbol` spells ;; rather than in `domain/paint` for the reason `clip/place-symbol` spells
;; out: a node's id is its identity in the saved document, so the pure ;; out: a node's id is its identity in the saved document, so the pure
;; layer must be handed one rather than invent one. If replaying the event ;; layer must be handed one rather than invent one. If replaying the event
;; log ever has to reproduce a document exactly, this becomes a cofx. ;; log ever has to reproduce a document exactly, this becomes a cofx.
;;
;; NOTHING IS OPENED HERE. Expansion is the twist triangle's business; the
;; shape is selected and on the stage, which is where you were looking.
:else :else
(let [id (keyword (str "paint-" (random-uuid)))] (let [id (keyword (str "paint-" (random-uuid)))]
;; NOTHING IS OPENED HERE. Finishing a shape used to expand every row
;; between the open symbol and the new shape, which inside a lane meant
;; tearing the lane's one row open into its portal, that clip's
;; channels and every shape already in the drawing — a dozen rows, in
;; answer to a gesture that said nothing about the outline. Expansion
;; is the twist triangle's business and nobody else's; the shape is
;; selected and on the stage with handles on it, which is where you
;; were looking.
(-> db (-> db
(edit/transaction (edit/transaction
(fn [_] (paint/new-shape source sid id frame pts (get-in db [:ui :tone])))) (fn [_] (paint/new-shape clip sid id frame (first rings) (get-in db [:ui :tone]))))
(update :ui merge {:tool nil :draft []}) (assoc-in [:ui :draft] [])
(selected [:node sid id (conj down id)]))))))) (update :ui dissoc :hover :last)
(selected [:node sid id (conj down id)]))))))
;; Closing on the first point, Enter, Esc and leaving the pen all commit, as in
;; Figma; the pen stays the tool, as it does in Photoshop.
(rf/reg-event-db ::finish-polygon (fn [db _] (finishing-polygon db)))
;; ---------------------------------------------------------------------------
;; brush and eraser
;;
;; A stroke arrives as the pieces `domain/outline` traced off its mask, in stage
;; pixels, holes and all. Each becomes a shape fitted to within `:fit` pixels
;; of what was painted — points where it bends, none on a straight run. What
;; it was made from is kept in `[:ui :last]`, Blender's Adjust Last Operation,
;; so the fit can be changed afterwards from the trace rather than from the
;; points already thrown away, until something else is done.
(def default-fit 1)
(defn- stroke-points [pieces fit]
(mapv #(outline/polygon % fit) pieces))
(rf/reg-event-db
::fit
(fn [db [_ fit]] (assoc-in db [:ui :fit] (-> fit (max 0.5) (min 8)))))
(defn- saved [db] (:clip (store/entry (:clip/current db))))
(defn- fresh-ids [] (repeatedly #(keyword (str "paint-" (random-uuid)))))
(defn- erasing
"`db` with `pieces` cut out of the shapes at `paths`. See `cut/erase`."
[db before pieces fit paths ids]
(let [{st :store} (store/entry (:clip/current db))
cutters (mapv #(outline/rings-of % fit) pieces)]
(edit/transaction db (fn [_] (cut/erase before st (get-in db [:ui :open])
(editing-frame db before) paths cutters ids)))))
(rf/reg-event-db
::stroke
;; The brush draws in the tone, into the creation target, as the pen does.
;; With `paths` it is the eraser's, and cuts the shapes they lead to.
(fn [db [_ {:keys [pieces paths]}]]
(let [fit (get-in db [:ui :fit] default-fit)
before (saved db)]
(if paths
(let [ids (vec (take 64 (fresh-ids)))
db (erasing db before pieces fit paths ids)]
(assoc-in db [:ui :last] {:kind :eraser :pieces pieces :paths paths :ids ids
:fit fit :before before :after (saved db)}))
(let [{:keys [refused clip sid frame] :as at} (when (seq pieces)
(landing-into db nil (stroke-points pieces fit)))
ids (vec (take (count pieces) (fresh-ids)))]
(cond
(empty? pieces) db
refused (update db :project merge {:status refused})
:else
(let [db (-> db
(edit/transaction
(fn [_] (reduce (fn [c [id pts]]
(paint/new-shape c sid id frame pts (get-in db [:ui :tone])))
clip (map vector ids (:rings at)))))
(selected [:node sid (peek ids) (conj (:down at) (peek ids))]))]
(assoc-in db [:ui :last] {:kind :brush :pieces pieces :fit fit
:down (:down at) :sid sid :frame frame :ids ids
:after (saved db)}))))))))
(rf/reg-event-db
::adjust-last
;; The last stroke's fit, and the next one's: one setting, in the options
;; bar and in the panel, as Blender's redo panel writes back to the tool.
(fn [db [_ fit]]
(let [{:keys [kind pieces paths ids down sid frame before after]} (get-in db [:ui :last])
;; Only while the document is still what the stroke left it as.
db (assoc-in db [:ui :fit] fit)]
(if-not (and pieces (identical? after (saved db)))
db
(let [db (if (= :eraser kind)
(erasing db before pieces fit paths ids)
(let [at (landing-into db down (stroke-points pieces fit))]
(edit/transaction
db (fn [c] (reduce (fn [c [id pts]] (paint/set-points c sid id frame pts))
c (map vector ids (:rings at)))))))]
(update-in db [:ui :last] assoc :fit fit :after (saved db)))))))
(rf/reg-event-db
::insert-vertex
(fn [db [_ sid id i t]] (edit/transaction db #(paint/insert-vertex % sid id i t))))
(rf/reg-event-db
::delete-vertex
(fn [db [_ sid id i]] (edit/transaction db #(paint/delete-vertex % sid id i))))
(rf/reg-event-db (rf/reg-event-db
::set-knob ::set-knob
@ -1031,14 +1177,6 @@
(:refused r) (refused db (:refused r)) (:refused r) (refused db (:refused r))
:else (edit/edit db (constantly (:clip r))))))) :else (edit/edit db (constantly (:clip r)))))))
(rf/reg-event-db
::points
;; Editing the selected shape's points rather than transforming it — a
;; double-click on the shape, as Figma's, which is one level further in. Any
;; new selection leaves it.
(fn [db [_ on?]]
(if on? (assoc-in db [:ui :points] true) (update db :ui dissoc :points))))
(rf/reg-event-db (rf/reg-event-db
::refuse ::refuse
(fn [db [_ why]] (refused db why))) (fn [db [_ why]] (refused db why)))
@ -1121,7 +1259,10 @@
(rf/reg-event-db (rf/reg-event-db
::delete-selected ::delete-selected
;; Backspace while drawing takes the last point back, as in Illustrator.
(fn [db _] (fn [db _]
(if (seq (get-in db [:ui :draft]))
(update-in db [:ui :draft] #(subvec % 0 (- (count %) 2)))
(let [primary (get-in db [:ui :selection]) (let [primary (get-in db [:ui :selection])
many (vec (get-in db [:ui :selections])) many (vec (get-in db [:ui :selections]))
selections (if (some #{primary} many) many (if primary [primary] [])) selections (if (some #{primary} many) many (if primary [primary] []))
@ -1131,7 +1272,7 @@
(edit/transaction #(reduce (fn [c [sid id]] (nest/delete-node c sid id)) % nodes)) (edit/transaction #(reduce (fn [c [sid id]] (nest/delete-node c sid id)) % nodes))
(assoc-in [:ui :selection] nil) (assoc-in [:ui :selection] nil)
(assoc-in [:ui :selections] [])) (assoc-in [:ui :selections] []))
db)))) db)))))
(rf/reg-event-db (rf/reg-event-db
::restack ::restack

View file

@ -204,4 +204,3 @@
(pre-frame-of [_ path] (symbol/pre-frame-of resolve path)) (pre-frame-of [_ path] (symbol/pre-frame-of resolve path))
clip/IActivePalette clip/IActivePalette
(active-palette [_] (clip/active-palette resolve))))))) (active-palette [_] (clip/active-palette resolve)))))))

View file

@ -29,15 +29,29 @@
(rf/reg-sub ::clipboard (fn [db _] (get-in db [:ui :clipboard]))) (rf/reg-sub ::clipboard (fn [db _] (get-in db [:ui :clipboard])))
(rf/reg-sub ::retry (fn [db _] (get-in db [:ui :retry]))) (rf/reg-sub ::retry (fn [db _] (get-in db [:ui :retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone]))) (rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool]))) (rf/reg-sub ::tool (fn [db _] (or (get-in db [:ui :tool]) :select)))
(rf/reg-sub ::auto-key? (fn [db _] (boolean (get-in db [:ui :auto-key?])))) (rf/reg-sub ::auto-key? (fn [db _] (boolean (get-in db [:ui :auto-key?]))))
(rf/reg-sub ::draft (fn [db _] (get-in db [:ui :draft]))) (rf/reg-sub ::draft (fn [db _] (get-in db [:ui :draft])))
(rf/reg-sub ::hover (fn [db _] (get-in db [:ui :hover])))
(rf/reg-sub ::brush (fn [db _] (get-in db [:ui :brush] 6)))
(rf/reg-sub ::fit (fn [db _] (get-in db [:ui :fit] 1)))
(rf/reg-sub ::last-op (fn [db _] (get-in db [:ui :last])))
(rf/reg-sub ::convert (fn [db _] (get-in db [:ui :convert]))) (rf/reg-sub ::convert (fn [db _] (get-in db [:ui :convert])))
(rf/reg-sub ::drop (fn [db _] (get-in db [:ui :drop]))) (rf/reg-sub ::drop (fn [db _] (get-in db [:ui :drop])))
(rf/reg-sub ::tabs (fn [db _] (get-in db [:ui :tabs]))) (rf/reg-sub ::tabs (fn [db _] (get-in db [:ui :tabs])))
(rf/reg-sub ::expanded (fn [db _] (get-in db [:ui :expanded]))) (rf/reg-sub ::expanded (fn [db _] (get-in db [:ui :expanded])))
(rf/reg-sub ::knobs (fn [db _] (get-in db [:ui :knobs]))) (rf/reg-sub ::knobs (fn [db _] (get-in db [:ui :knobs])))
(rf/reg-sub ::points (fn [db _] (get-in db [:ui :points])))
(rf/reg-sub
::last
:<- [::last-op]
:<- [::render/clip-id]
:<- [::render/paint-revision]
;; The last stroke while the document is still what it left: any other edit,
;; or an undo, takes the panel away, as Blender's goes with the next operation.
(fn [[op id _] _]
(when (and op (identical? (:after op) (:clip (store/entry id)))) op)))
(rf/reg-sub (rf/reg-sub
::selected-node ::selected-node
@ -56,17 +70,18 @@
::selected-local ::selected-local
:<- [::selected-node] :<- [::selected-node]
:<- [::selection] :<- [::selection]
:<- [::render/clip]
:<- [::render/clip-id] :<- [::render/clip-id]
:<- [::render/open] :<- [::render/open]
:<- [::render/open-frame] :<- [::render/open-frame]
(fn [[[_ id n] [_ _ _ path] clip-id open f] _] (fn [[[_ id n] [_ _ _ path] clip clip-id open f] _]
;; `nest/inside` the selected node, from the open symbol: its own frame, the ;; `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 ;; 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 ;; symbol's frames to its own. A selection made on the stage has no path and
;; names a node in the open symbol. ;; names a node in the open symbol. From the clip as it is mid-drag, as
;; `::selected-placement` is, so the points go with the shape they belong to.
(when n (when n
(let [{clip :clip st :store} (store/entry clip-id)] (nest/inside clip (:store (store/entry clip-id)) open (or path [id]) f))))
(nest/inside clip st open (or path [id]) f)))))
(rf/reg-sub (rf/reg-sub
::own-time ::own-time

View file

@ -1,101 +1,194 @@
(ns arthur.ui.palette (ns arthur.ui.palette
"The bar above the stage: the tone a new shape gets, and the tool that makes "The palette: a grid of the open palette's slots under the tools, and the
one. palette asset it is, in the options bar.
A GRID BESIDE THE TOOLS, as Deluxe Paint and Animator Pro put theirs — the
indexed tools this one descends from — rather than a strip of dots across the
top: a palette is a fixed table of slots, and a grid shows it as one. Over the
grid sits the colour a new shape gets, big, as Photoshop's foreground chip.
Shapes store only the selected local slot number. Palette identity is supplied Shapes store only the selected local slot number. Palette identity is supplied
by the symbol tree, and the colour input edits the project palette asset. by the symbol tree, and the colour input edits the project palette asset.
Picking a slot also recolours whatever is selected; picking CLEAR makes it a
knockout — select a lens, pick clear, and it is a hole.
The footage switch is NOT here. It is a viewing aid rather than something you The footage switch is NOT here. It is a viewing aid rather than something you
set before you draw, so it is a section of the inspector — one that is there set before you draw, so it is a section of the inspector. See `ui/params`."
for whatever the open symbol has faces of, so it still does not come and go (:require [arthur.domain.channel :as channel]
with the selection. See `ui/params`."
(:require [arthur.domain.palette :as pal]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.events.ui :as ui] [arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]
[arthur.events.project :as project] [arthur.events.project :as project]
[arthur.events.ui :as ui]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[arthur.ui.layout :as layout] [re-frame.core :as rf]
[re-frame.core :as rf])) [reagent.core :as r]))
(rf/reg-sub ::chosen (fn [db _] (get-in db [:ui :palette]))) (rf/reg-sub ::chosen (fn [db _] (get-in db [:ui :palette])))
(defn- swatch [pid i {:keys [name hex]} tone placements] (rf/reg-event-db ::select (fn [db [_ id]] (assoc-in db [:ui :palette] id)))
(let [picker-id (str "palette-picker-" pid "-" i)]
[:div {:key i :class "palette-slot"} (defn- shown
[:button {:class (str "swatch" (when (= i tone) " on")) "The palette asset the grid shows: the one chosen, or the project's default."
:style {:background hex} []
:title (str i (when name (str " · " (clojure.core/name name))) (let [clip @(rf/subscribe [::render/clip])
" · " hex " · double-click to edit") chosen @(rf/subscribe [::chosen])
:on-click #(do (rf/dispatch [::ui/set-tone i]) pid (if (contains? (:palettes clip) chosen) chosen (pal/default-palette-id clip))]
[clip pid (get (pal/palettes clip) pid)]))
(defn- pick!
"Make `tone` the colour new shapes get, and recolour what is selected."
[tone placements]
(rf/dispatch [::ui/set-tone tone])
(let [edits (into [] (keep (fn [{:keys [sid id node frame]}] (let [edits (into [] (keep (fn [{:keys [sid id node frame]}]
(when (contains? (get node/valid-paths (:kind node)) (when (contains? (get node/valid-paths (:kind node)) [:style :color])
[:style :color]) {:sid sid :id id :path [:style :color] :frame frame :value tone})))
{:sid sid :id id :path [:style :color]
:frame frame :value i})))
placements)] placements)]
(when (seq edits) (when (seq edits)
(rf/dispatch [::project/set-channels edits])))) (rf/dispatch [::project/set-channels edits]))))
;; ---------------------------------------------------------------------------
;; remap: a shape that lights what is under it
;;
;; A shape coloured `[:remap {from to …}]` draws nothing of its own: whatever is
;; already under it is drawn in other slots of the same palette, as Deluxe
;; Paint's and Animator Pro's shade inks did. A flashlight is a circle of it.
;; Pick MAP from the strip and draw one; its map is set in the inspector, or by
;; dragging one swatch of the strip onto another while it is selected.
(defn- remap
"The selected node when it is a remap shape, with its map at the frame shown:
`{:sid :id :frame :keys :map}`, or nil."
[]
(let [[sid id n] @(rf/subscribe [::sub/selected-node])
frame (:frame @(rf/subscribe [::sub/selected-local]))
ch (get-in n [:channels [:style :color]])
c (when ch (channel/value-at ch (or frame 0) nil))]
(when (symbol/remap? c)
{:sid sid :id id :frame frame :keys (:keys ch) :map (second c)})))
(defn- map-slot!
"Light `from` as `to` under the selected remap shape; as itself, not at all."
[{:keys [sid id frame map]} from to]
(rf/dispatch [::project/set-channel sid id [:style :color] frame
[:remap (if (= from to) (dissoc map from) (assoc map from to))]]))
(defn remap-section
"The inspector's map for a selected remap shape: each slot as what is under
the shape will be drawn in, the original in its corner where it is changed.
A slot opens a choice of what to draw it as."
[]
(r/with-let [open (r/atom nil)]
(let [[_ _ palette] (shown)
{:keys [sid id frame keys] m :map :as rm} (remap)
slots (:slots palette)
hex #(:hex (get slots %))]
(when rm
[:section.section
[:h2 "remap"]
[:div.remap-head
[:button.key {:class (cond (contains? keys frame) "on" keys "keyed")
:disabled (nil? frame)
:title (if keys (str (count keys) " held keys") "key the map here")
:on-click #(rf/dispatch [::project/toggle-key sid id [:style :color] frame])}
"◆"]
[:span.dim (if (seq m)
(str "what is under it: " (count m) " slot" (when (< 1 (count m)) "s") " changed")
"changes nothing yet — pick a slot")]
[:span {:style {:flex 1}}]
(when (seq m)
[:button {:on-click #(rf/dispatch [::project/set-channel sid id [:style :color] frame [:remap {}]])}
"reset"])]
[:div.remap
(doall
(for [i (range (count slots))]
^{:key i}
[:button {:class (str "cell" (when (contains? m i) " mapped") (when (= i @open) " on"))
:style {:background (hex (get m i i))}
:title (str "slot " i (when (contains? m i) (str " → " (m i))))
:on-click #(swap! open (fn [o] (when-not (= o i) i)))}
(when (contains? m i) [:i {:style {:background (hex i)}}])]))]
(when-let [from @open]
[:div.remap-pick
[:span.dim (str "under it, draw " from " as")]
(doall
(for [i (range (count slots))]
^{:key i}
[:button {:class (str "cell" (when (= i (get m from from)) " on"))
:style {:background (hex i)}
:title (if (= i from) (str i " — as it is") (str "slot " i))
:on-click #(do (map-slot! rm from i) (reset! open nil))}]))])]))))
(defn- swatch [pid i {:keys [name hex]} tone placements rm hex-of]
(let [picker-id (str "palette-picker-" pid "-" i)
to (get (:map rm) i)]
[:div.slot {:key i}
[:button {:class (str "cell" (when (= i tone) " on"))
:style {:background hex}
:title (str "slot " i (when name (str " · " (clojure.core/name name)))
" · " hex
(when to (str " · lit as " to " under the selected remap"))
" — double-click to edit"
(when rm ", drag onto another to remap it under the selected remap"))
:draggable (some? rm)
:on-drag-start #(.setData (.-dataTransfer %) "application/x-arthur-slot" (str i))
:on-drag-over (fn [^js e] (when rm (.preventDefault e)))
:on-drop (fn [^js e]
(.preventDefault e)
(let [from (js/parseInt (.getData (.-dataTransfer e) "application/x-arthur-slot"))]
(when (and rm (not (js/isNaN from))) (map-slot! rm from i))))
:on-click #(pick! i placements)
:on-double-click (fn [e] :on-double-click (fn [e]
(.preventDefault e) (.preventDefault e)
(some-> (js/document.getElementById picker-id) (.click)))}] (some-> (js/document.getElementById picker-id) (.click)))}
(when to [:i.badge {:style {:background (hex-of to)}}])]
[:input {:id picker-id :class "palette-picker" :type "color" :value hex [:input {:id picker-id :class "palette-picker" :type "color" :value hex
:aria-label (str "edit palette slot " i) :aria-label (str "edit palette slot " i)
:on-change #(rf/dispatch [::project/palette-color pid i (.. % -target -value)])}]])) :on-change #(rf/dispatch [::project/palette-color pid i (.. % -target -value)])}]]))
(defn bar [] (defn grid
(let [tone @(rf/subscribe [::sub/tone]) "The colour chip and the slots, for the tool strip."
clip @(rf/subscribe [::render/clip]) []
chosen @(rf/subscribe [::chosen]) (let [[_ pid palette] (shown)
pid (if (contains? (:palettes clip) chosen) chosen (pal/default-palette-id clip)) tone @(rf/subscribe [::sub/tone])
palette (get (pal/palettes clip) pid)
placements @(rf/subscribe [::sub/selected-placements]) placements @(rf/subscribe [::sub/selected-placements])
selections @(rf/subscribe [::sub/selections]) hex (when (number? tone) (:hex (get (:slots palette) tone)))]
tool @(rf/subscribe [::sub/tool]) [:div.palette
draft @(rf/subscribe [::sub/draft])] [:div {:class (str "chip" (cond (= :clear tone) " clear" (symbol/remap? tone) " map"))
[:div.palette-bar :style (when hex {:background hex})
:title (cond (= :clear tone) "clear: what is drawn knocks out what is under it in its symbol"
(symbol/remap? tone) "remap: what is drawn lights what is under it"
:else (str "slot " tone " · " hex))}
[:span (cond (= :clear tone) "×" (symbol/remap? tone) "⇄" :else tone)]]
[:div.cells
(let [rm (remap) hex-of #(:hex (get (:slots palette) %))]
(doall (map-indexed #(swatch pid %1 %2 tone placements rm hex-of) (:slots palette))))
[:div.slot
[:button {:class (str "cell clear" (when (= :clear tone) " on"))
:title "clear — draws a knockout: a hole in its symbol"
:on-click #(pick! :clear placements)}]]
[:div.slot
[:button {:class (str "cell map" (when (symbol/remap? tone) " on"))
:title "remap — draws light: what is under it is drawn in other slots of the palette. Set which in the inspector."
:on-click #(pick! [:remap {}] placements)}
"⇄"]]]]))
(defn assets
"Which palette asset the grid shows, and a new or duplicated one, for the
options bar."
[]
(let [[clip pid palette] (shown)]
[:div.palette-assets
[:select {:value (str pid) [:select {:value (str pid)
:title "palette asset" :title "palette asset"
:on-change (fn [e] :on-change (fn [e]
(let [v (.. e -target -value) (let [v (.. e -target -value)
id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] id (first (filter #(= v (str %)) (keys (pal/palettes clip))))]
(rf/dispatch [::ui/set-tone 0]) (rf/dispatch [::ui/set-tone 0])
(rf/dispatch [:arthur.ui.palette/select id])))} (rf/dispatch [::select id])))}
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))] (for [[id p] (sort-by (comp str :name val) (pal/palettes clip))]
^{:key (str id)} [:option {:value (str id)} (:name p)])] ^{:key (str id)} [:option {:value (str id)} (:name p)])]
[:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"] [:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"]
[:button {:title (str "duplicate " (:name palette) " — a copy you can retone") [:button {:title (str "duplicate " (:name palette) " — a copy you can retone")
:on-click #(rf/dispatch [::project/duplicate-palette pid])} "⧉"] :on-click #(rf/dispatch [::project/duplicate-palette pid])} "⧉"]]))
[:div.swatches (doall (map-indexed #(swatch pid %1 %2 tone placements) (:slots palette)))]
[:span.dim (str tone)]
(when (> (count selections) 1)
[:span.dim (str (count selections) " selected")])
[:span {:style {:flex 1}}]
;; Every tracing layer at once: the reference on or off, and how strongly
;; it draws. A property of looking at the stage, like the zoom beside it.
(let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])]
[:<>
[:button {:class (when on? "on")
:title "show tracing layers over the picture · never exported"
:on-click #(rf/dispatch [::ui/tracing-on (not on?)])}
"tracing"]
[:input.trace-opacity
{:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?)
:title "how strongly tracing layers draw"
:style {:flex "0 0 64px"}
:on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]])
(if (= :polygon tool)
[:<>
[:span.dim (str (quot (count draft) 2) " points")]
[:button {:disabled (< (count draft) 6)
:on-click #(rf/dispatch [::ui/finish-polygon])} "finish"]
[:button {:on-click #(rf/dispatch [::ui/cancel-polygon])} "cancel"]]
[:button {:title "pen tool — click points on the stage"
:on-click #(rf/dispatch [::ui/begin-polygon])} "pen"])
;; The stage's zoom, at the right end of the bar above the stage: it is a
;; property of the view and not of the document, so it sits in the view's
;; own chrome rather than in the inspector.
[layout/zoomer :stage "the stage"]]))
(rf/reg-event-db :arthur.ui.palette/select
(fn [db [_ id]] (assoc-in db [:ui :palette] id)))

View file

@ -23,6 +23,7 @@
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[arthur.ui.palette :as palette]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
@ -209,9 +210,10 @@
(defn- color-control [sid id ch frame auto-key? palette] (defn- color-control [sid id ch frame auto-key? palette]
(let [keyed? (some? (:keys ch)) (let [keyed? (some? (:keys ch))
value (channel/value-at ch (or frame 0) nil) value (channel/value-at ch (or frame 0) nil)
value (if (integer? value) ;; A knockout or a remap is no slot, and none is marked.
value value (cond
(or (first (keep-indexed #(when (= value (:name %2)) %1) (integer? value) value
(keyword? value) (or (first (keep-indexed #(when (= value (:name %2)) %1)
(:slots palette))) 0)) (:slots palette))) 0))
off? (and keyed? (nil? frame))] off? (and keyed? (nil? frame))]
[:dd.channel {:class (when auto-key? "live")} [:dd.channel {:class (when auto-key? "live")}
@ -783,6 +785,7 @@
[palette-placement-section node] [palette-placement-section node]
[clip-section]) [clip-section])
(when (and node (not palette-placement?)) [node-section node]) (when (and node (not palette-placement?)) [node-section node])
(when (and node (not palette-placement?)) [palette/remap-section])
(when (and placed (not palette-placement?)) [symbol-section placed "source symbol"]) (when (and placed (not palette-placement?)) [symbol-section placed "source symbol"])
(when (and node (not palette-placement?)) ^{:key (str (first node) "/" (second node))} (when (and node (not palette-placement?)) ^{:key (str (first node) "/" (second node))}
[correction-section node]) [correction-section node])

View file

@ -20,12 +20,16 @@
no global interceptors." no global interceptors."
(:require [arthur.clock :as clock] (:require [arthur.clock :as clock]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.cut :as cut]
[arthur.domain.outline :as outline]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.pick :as pick] [arthur.domain.pick :as pick]
[arthur.domain.raster :as raster] [arthur.domain.raster :as raster]
[arthur.domain.symbol :as symbol]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.subs.playback :as sub] [arthur.subs.playback :as sub]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as ui-sub]
[arthur.ui.canvas :as canvas] [arthur.ui.canvas :as canvas]
[arthur.ui.tracing :as tracing] [arthur.ui.tracing :as tracing]
[re-frame.core :as rf] [re-frame.core :as rf]
@ -103,9 +107,19 @@
(some-> @tracker ratom/dispose!) (some-> @tracker ratom/dispose!)
(reset! tracker (reset! tracker
(ratom/run! (ratom/run!
(let [{was :resolver was-t :tracing} @snapshot (let [{was :resolver was-t :tracing :as before} @snapshot
now @(rf/subscribe [::render/shown]) now @(rf/subscribe [::render/shown])
t @(rf/subscribe [::render/tracing])] t @(rf/subscribe [::render/tracing])
;; The pen's draft is drawn INTO the picture, filled, so what
;; is on screen while placing points is the pixels a finished
;; shape will be.
pen {:draft @(rf/subscribe [::ui-sub/draft])
:hover @(rf/subscribe [::ui-sub/hover])
:tone @(rf/subscribe [::ui-sub/tone])
:fit @(rf/subscribe [::ui-sub/fit])
;; Where a new shape would land, so what is being drawn
;; is drawn in that symbol's stacking context.
:target (:path @(rf/subscribe [::ui-sub/creation-target]))}]
(reset! snapshot (reset! snapshot
{:resolver now {:resolver now
:palette @(rf/subscribe [::render/palette]) :palette @(rf/subscribe [::render/palette])
@ -116,17 +130,36 @@
:height @(rf/subscribe [::sub/height]) :height @(rf/subscribe [::sub/height])
:tracing t :tracing t
:frame @(rf/subscribe [::sub/frame]) :frame @(rf/subscribe [::sub/frame])
:playing? @(rf/subscribe [::sub/playing?])}) :playing? @(rf/subscribe [::sub/playing?])
:pen pen})
;; A new resolver means a new scene or a new palette, and neither ;; A new resolver means a new scene or a new palette, and neither
;; moves the playhead — so nothing else would ask for a redraw. ;; moves the playhead — so nothing else would ask for a redraw.
;; ;;
;; Switching tracing on or off wants the same redraw for the same ;; Switching tracing on or off wants the same redraw for the same
;; reason, and it needs asking for SEPARATELY: it is a viewing aid ;; reason, and it needs asking for SEPARATELY: it is a viewing aid
;; in the editor's own state, so it changes what the canvases should ;; in the editor's own state, so it changes what the canvases should
;; show without touching the frame number OR the resolver. ;; show without touching the frame number OR the resolver. The
(when-not (and (identical? was now) (= was-t t)) ;; document and the store are left out of the comparison because
;; they are what a new resolver already means.
;; Comparing the whole map is as cheap as picking fields out of it:
;; the document and the store it carries are the same OBJECTS unless
;; the resolver changed too, and that is tested first.
(when-not (and (identical? was now) (= was-t t) (= pen (:pen before)))
(repaint!)))))) (repaint!))))))
;; The brush or eraser stroke being painted: its mask, mutated in place as the
;; pointer moves, which is why it is an atom the stage pokes rather than state
;; anything renders from. `{:mask :version :slot :knock :paths}`, `:mask` being an
;; `outline/mask`.
(defonce ^:private stroke (atom nil))
(defonce ^:private trace-cache (atom nil))
(defn stroke!
"Show stroke `s` in the picture until it is replaced or nil, and redraw."
[s]
(reset! stroke s)
(repaint!))
(defn set-canvas! [el] (defn set-canvas! [el]
(swap! state assoc :canvas el) (swap! state assoc :canvas el)
;; The :ref fires AFTER the loop has started, so the first tick or two run ;; The :ref fires AFTER the loop has started, so the first tick or two run
@ -149,9 +182,83 @@
[point] [point]
(pick/hit (:ops @state) point)) (pick/hit (:ops @state) point))
(defn hit-op
"The topmost op that shows at `point`, as `at` picks it: what the eraser
starts on."
[point]
(pick/hit-op (:ops @state) point))
(defn in-rect [rect depth] (defn in-rect [rect depth]
(pick/in-rect (:ops @state) rect depth)) (pick/in-rect (:ops @state) rect depth))
(defn- index-of-slot [palette active slot]
(if (and (:palettes palette) (:offsets palette))
(pal/render-index palette active slot)
slot))
(defn- traced
"Stroke `s` as the rings letting go would make of it, `[outer & holes]` per
piece: traced and simplified by the same function the saved shapes are, so
the preview IS them. Once per version of the mask and fit, not per paint."
[{:keys [mask version]} fit]
(let [key [mask version fit]]
(if (= key (:key @trace-cache))
(:rings @trace-cache)
(:rings (reset! trace-cache
{:key key
:rings (mapv #(outline/rings-of % fit) (outline/pieces mask))})))))
(defn- op-path [op] (let [n (:node op)] (if (vector? n) n [n])))
(defn- poly [ring op]
(assoc op :kind :poly :pts (into-array ring) :n (quot (count ring) 2)))
(defn- cut-ops
"`ops` with each op of a shape at one of `paths` cut by `cutters`, as the
eraser will cut it."
[ops paths cutters]
(into []
(mapcat (fn [{:keys [pts n] :as op}]
(or (when (and (= :poly (:kind op)) (contains? paths (op-path op)))
(some->> (cut/cut (vec (take (* 2 n) (array-seq pts))) cutters)
(map #(poly % (dissoc op :pts :n)))))
[op])))
ops))
(defn- inked
"Op `op` drawn in tone `tone`: a slot's colour, or — for a remap — light on
what is under it, through the same table the saved shape gets."
[op tone palette active]
(if (symbol/remap? tone)
(assoc op :lut (symbol/lut #(index-of-slot palette active %) tone) :color 0)
(assoc op :color (index-of-slot palette active tone))))
(defn- with-previews
"What is being drawn and is not in the document yet, added to `ops` in the
stacking context of the symbol it will land in: the pen's draft as far as the
pointer, a brush stroke as the polygons it will be, and an eraser's cut as
the shapes it will leave."
[ops palette active]
(let [{:keys [draft hover tone fit target]} (:pen @snapshot)
target (or target [])
pts (cond-> (vec draft) (and (seq draft) hover) (into hover))
{:keys [slot knock paths] :as s} @stroke
rings (when (:mask s) (traced s fit))]
(cond-> ops
(and (<= 6 (count pts)) (or (number? tone) (symbol/remap? tone)))
(clip/atop target (inked (poly pts {}) tone palette active))
paths
(cut-ops paths rings)
(and rings (not paths) (nil? knock))
(as-> ops (reduce #(clip/atop %1 target (inked (poly (outline/join %2) {}) slot palette active))
ops rings))
(and rings (not paths) knock)
(as-> ops (reduce #(clip/in-layer %1 target (poly (outline/join %2) {:color 0 :knock knock}))
ops rings)))))
(defn paint! (defn paint!
"Resolve `f` and put it on the canvas. `ops` are consumed here and only here — "Resolve `f` and put it on the canvas. `ops` are consumed here and only here —
the resolver reuses its point buffers between frames, so they have to be the resolver reuses its point buffers between frames, so they have to be
@ -178,7 +285,7 @@
(swap! state assoc :ops (into (vec picture) traces)) (swap! state assoc :ops (into (vec picture) traces))
(-> ras (-> ras
(raster/clear! bg) (raster/clear! bg)
(raster/draw-ops! picture)) (raster/draw-ops! (with-previews picture palette active)))
(js/performance.mark "arthur/blit:start") (js/performance.mark "arthur/blit:start")
(canvas/blit! canvas ras (pal/effective-ramp palette active)) (canvas/blit! canvas ras (pal/effective-ramp palette active))
(tracing/paint! traces (assoc tracing :width width :playing? playing?) repaint!)) (tracing/paint! traces (assoc tracing :width width :playing? playing?) repaint!))

View file

@ -14,12 +14,12 @@
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.ui.layout :as layout] [arthur.ui.layout :as layout]
[arthur.ui.location :as location] [arthur.ui.location :as location]
[arthur.ui.palette :as palette]
[arthur.ui.params :as params] [arthur.ui.params :as params]
[arthur.ui.pool :as pool] [arthur.ui.pool :as pool]
[arthur.ui.stage :as stage] [arthur.ui.stage :as stage]
[arthur.ui.tabs :as tabs] [arthur.ui.tabs :as tabs]
[arthur.ui.timeline :as timeline] [arthur.ui.timeline :as timeline]
[arthur.ui.tools :as tools]
[arthur.ui.topbar :as topbar] [arthur.ui.topbar :as topbar]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
@ -74,8 +74,10 @@
(when-not (gone? :pool) [layout/grip :pool pool :col 1]) (when-not (gone? :pool) [layout/grip :pool pool :col 1])
[:section.view [:section.view
[tabs/view] [tabs/view]
[palette/bar] [tools/options]
[stage/view]] [:div.workspace
[tools/toolbox]
[stage/view]]]
(when-not (gone? :params) [layout/grip :params params :col -1]) (when-not (gone? :params) [layout/grip :params params :col -1])
(when-not (gone? :params) [params/view]) (when-not (gone? :params) [params/view])
;; Above the timeline rather than inside it: the bar says where an edit would ;; Above the timeline rather than inside it: the bar says where an edit would

View file

@ -14,8 +14,10 @@
[arthur.domain.gesture :as gesture] [arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.outline :as outline]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.domain.pick :as pick] [arthur.domain.pick :as pick]
[arthur.domain.symbol :as symbol]
[arthur.events.paint :as paint-events] [arthur.events.paint :as paint-events]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.footage.store :as store] [arthur.footage.store :as store]
@ -26,6 +28,7 @@
[arthur.ui.layout :as layout] [arthur.ui.layout :as layout]
[arthur.ui.player :as player] [arthur.ui.player :as player]
[arthur.ui.tracing :as tracing] [arthur.ui.tracing :as tracing]
[arthur.ui.tools :as tools]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
@ -344,10 +347,168 @@
:width (+ 2 (* 2.1 (count label))) :height 5}] :width (+ 2 (* 2.1 (count label))) :height 5}]
[:text.creation-target-tag {:x 1 :y 3.8} label]])])))) [:text.creation-target-tag {:x 1 :y 3.8} label]])]))))
;; ---------------------------------------------------------------------------
;; the drawing tools
;; Where the pointer is over the stage, in stage pixels: the brush's footprint
;; and the pen's edge marker follow it. A ratom, read only by `cursor` and
;; `points`, so a move re-renders those and not the overlay.
(defonce ^:private pointer (r/atom nil))
;; The brush or eraser stroke under the pointer, as `dragging` is for a vertex.
(defonce ^:private painting (atom nil))
(def ^:private close-by
"Stage pixels from the first point at which a click closes the polygon."
3)
(defn- snapped
"`p` from the draft's last point, at the nearest 45° when ⇧ is held."
[draft [x y :as p] ^js event]
(if (and (.-shiftKey event) (<= 2 (count draft)))
(let [ax (nth draft (- (count draft) 2)) ay (peek draft)
a (* (/ js/Math.PI 4) (js/Math.round (/ (js/Math.atan2 (- y ay) (- x ax)) (/ js/Math.PI 4))))
d (js/Math.hypot (- x ax) (- y ay))]
[(js/Math.round (+ ax (* d (js/Math.cos a)))) (js/Math.round (+ ay (* d (js/Math.sin a))))])
p))
(defn- closes? [draft [x y]]
(and (<= 6 (count draft))
(<= (js/Math.hypot (- x (first draft)) (- y (second draft))) close-by)))
(defn- on-edge
"`[i t]`: the edge of ring `pts` that stage point `p` is on, from point `i`
a fraction `t` of the way to the next — or nil."
[pts [x y]]
(let [ps (pairs pts) n (count ps)
;; Not a bridge: a point there would open the hole. See `outline-path`.
there (set (map (fn [i] [(ps i) (ps (mod (inc i) n))]) (range n)))]
(some (fn [i]
(let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))
dx (- bx ax) dy (- by ay)
l2 (+ (* dx dx) (* dy dy))
t (if (zero? l2) 0 (/ (+ (* (- x ax) dx) (* (- y ay) dy)) l2))]
(when (and (< 0.05 t 0.95) (not (contains? there [[bx by] [ax ay]]))
(<= (js/Math.hypot (- x (+ ax (* t dx))) (- y (+ ay (* t dy)))) 1.5))
[i t])))
(range n))))
(defn- stroke-begin!
"Start painting with the brush, or erasing, at stage point `p`.
The eraser cuts the shapes in the symbol holding what the stroke STARTS on,
of the colour it starts on — Photoshop's background eraser, Flash's erase
fills — or, with ⌥, every one of them. Starting on nothing with ⌥ cuts in
the open symbol."
[{:keys [open f] :as ctx} tool p size tone ^js event]
(let [eraser? (= :eraser tool)
all? (.-altKey event)
op (when eraser? (player/hit-op p))
path (when op (let [n (:node op)] (if (vector? n) n [n])))
within (if path (pop path) [])
{document :clip st :store} (loaded ctx)
colour #(when-let [{:keys [sid id frame]} (nest/placement document st open % f)]
(when (get-in document [:symbols sid :nodes id :paint?])
(channel/value-at (get-in document [:symbols sid :nodes id :channels [:style :color]])
frame st)))
start (when path (colour path))
paths (when (and eraser? (or all? path))
(when-let [{:keys [sid]} (nest/inside document st open within f)]
(into #{}
(keep (fn [[id n]]
(let [q (conj within id) c (when (= :poly (:kind n)) (colour q))]
(when (and (some? c) (not (symbol/knockout? c)) (or all? (= c start)))
q))))
(get-in document [:symbols sid :nodes]))))
m (outline/stamp! (outline/mask (:w ctx) (:h ctx)) p p size)
s (cond
;; The brush's target is the creation target, which the player
;; reads for itself.
(not eraser?) {:mask m :slot tone :knock (when (= :clear tone) -1)}
(seq paths) {:mask m :paths paths})]
(if s
(do (reset! painting (assoc s :last p :size size :version 0))
(player/stroke! (assoc s :version 0)))
(rf/dispatch [::ui/refuse "nothing to erase there — ⌥ erases every colour"]))))
(defn- stroke-move! [p]
(when-let [{:keys [mask last size]} @painting]
(outline/stamp! mask last p size)
;; The version says the mask changed under the same object, so the player
;; traces it again — once a frame at most, however fast the pointer moves.
(player/stroke! (swap! painting #(-> % (assoc :last p) (update :version inc))))))
(defn- stroke-end! []
(when-let [{:keys [mask paths]} @painting]
(reset! painting nil)
;; Synchronously, so the shapes are in the document before the preview of
;; them goes: otherwise the stroke blinks out for a frame in between.
(rf/dispatch-sync [::ui/stroke {:pieces (outline/pieces mask) :paths paths}])
(player/stroke! nil)))
(defn- cursor
"The brush's footprint under the pointer, the size it paints."
[tool]
(let [size @(rf/subscribe [::sub/brush])]
(when-let [[x y] @pointer]
[:circle {:class (str "footprint " (name tool)) :cx x :cy y :r (/ size 2)}])))
(defn- outline-path
"Ring `pts` as an SVG path, without its bridges: an edge the ring runs along
both ways is where a hole is joined to the outside (see `domain/outline`),
which the fill never draws and the outline should not either."
[pts]
(let [ps (pairs pts) n (count ps)
edges (map (fn [i] [(ps i) (ps (mod (inc i) n))]) (range n))
there (set edges)]
(apply str (for [[a b] edges :when (not (contains? there [b a]))]
(str "M" (first a) "," (second a) "L" (first b) "," (second b))))))
(defn- points
"The pen on the selected shape: its points to drag, ⌥-click to delete, and a
marker where a click on an edge would add one."
[ctx draft]
(let [[sid id geom active editable? frame matrix] (editing)
pts (when geom (through matrix (channel/value-at geom frame (:store (loaded ctx)))))
edge (when (and editable? (empty? draft) (not (channel/nothing? pts)) @pointer)
(on-edge pts @pointer))]
(when (and pts (not (channel/nothing? pts)))
[:g.points
[:path.outline {:d (outline-path pts)}]
(when-let [[i t] edge]
(let [[ax ay] (nth (pairs pts) i) [bx by] (nth (pairs pts) (mod (inc i) (quot (count pts) 2)))]
[:circle.insert {:cx (+ ax (* t (- bx ax))) :cy (+ ay (* t (- by ay))) :r 1.6}]))
(when-let [inv (when editable? (node/invert matrix))]
(doall
(for [[i [x y]] (map-indexed vector (pairs pts))]
^{:key i}
[:circle.vertex {:cx x :cy y :r 1.8
:on-pointer-down
(fn [^js event]
(.stopPropagation event)
(.preventDefault event)
(if (.-altKey event)
(rf/dispatch [::ui/delete-vertex sid id i])
(do (.setPointerCapture (.-currentTarget event) (.-pointerId event))
(reset! dragging [sid id active i inv]))))}])))])))
(defn- draft-lines
"The pen's draft as hairlines — the fill is in the picture — with the segment
to the pointer, and a ring on the first point when a click there closes it."
[draft hover]
(when (seq draft)
(let [[fx fy] draft]
[:g.draft
[:polyline {:points (points-text (cond-> draft hover (into hover)))}]
[:circle {:class (str "first" (when (and hover (closes? draft hover)) " closing"))
:cx fx :cy fy :r 1.8}]])))
(defn- overlay [w h zoom] (defn- overlay [w h zoom]
(let [tool @(rf/subscribe [::sub/tool]) (let [tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft]) draft @(rf/subscribe [::sub/draft])
drawing? (= :polygon tool) hover @(rf/subscribe [::sub/hover])
tone @(rf/subscribe [::sub/tone])
size @(rf/subscribe [::sub/brush])
[_ _ _ selected] @(rf/subscribe [::sub/selection]) [_ _ _ selected] @(rf/subscribe [::sub/selection])
selections @(rf/subscribe [::sub/selections]) selections @(rf/subscribe [::sub/selections])
placements @(rf/subscribe [::sub/selected-placements]) placements @(rf/subscribe [::sub/selected-placements])
@ -359,24 +520,43 @@
;; edit's coordinate is the document's. ;; edit's coordinate is the document's.
:open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::render/open-frame]) :open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::render/open-frame])
:w w :h h} :w w :h h}
points? @(rf/subscribe [::sub/points]) pen? (= :pen tool)
[sid id geom active editable? frame matrix] (when points? (editing)) ;; The shape the pen is on, read while rendering: a click handler is
pts (when geom (through matrix (channel/value-at geom frame ;; not a reactive context to subscribe from.
(:store (store/entry clip-id))))) [esid eid egeom _ editable? eframe matrix] (editing)
selected-pts (when (and egeom matrix)
(let [pts (channel/value-at egeom eframe (:store (loaded ctx)))]
(when-not (channel/nothing? pts)
(through matrix pts))))
paints? (#{:brush :eraser} tool)
mark @marquee] mark @marquee]
[:svg {:class (str "paint-overlay" (when drawing? " drawing")) [:svg {:class (str "paint-overlay tool-" (name tool)
(when (and pen? hover (closes? draft hover)) " closing"))
: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)
:tab-index -1 :tab-index -1
:on-pointer-down (fn [^js event] :on-pointer-down
(if drawing? (fn [^js event]
(let [[x y] (stage-point event w h)]
(rf/dispatch [::ui/add-draft-point x y]))
(let [svg (.-currentTarget event) (let [svg (.-currentTarget event)
p (xy svg event w h) p (xy svg event w h)]
path (pick/choose selected (player/at p)
(or (.-metaKey event) (.-ctrlKey event)))]
(.focus svg) (.focus svg)
(case tool
:pen
(let [q (snapped draft (stage-point event w h) event)
pts (when (and egeom editable? (empty? draft))
(through matrix (channel/value-at egeom eframe (:store (loaded ctx)))))
edge (when (and pts (not (channel/nothing? pts))) (on-edge pts p))]
(cond
(closes? draft q) (rf/dispatch [::ui/finish-polygon])
edge (rf/dispatch [::ui/insert-vertex esid eid (first edge) (second edge)])
:else (rf/dispatch [::ui/add-draft-point (first q) (second q)])))
(:brush :eraser)
(do (.setPointerCapture svg (.-pointerId event))
(stroke-begin! ctx tool p size tone event))
(let [path (pick/choose selected (player/at p)
(or (.-metaKey event) (.-ctrlKey event)))]
(cond (cond
(and (.-shiftKey event) path) (and (.-shiftKey event) path)
(when-let [address (address-for ctx path)] (when-let [address (address-for ctx path)]
@ -392,35 +572,40 @@
(.setPointerCapture svg (.-pointerId event)) (.setPointerCapture svg (.-pointerId event))
(if (and (> (count placements) 1) (contains? selected-paths path)) (if (and (> (count placements) 1) (contains? selected-paths path))
(begin-many! ctx :move placements p) (begin-many! ctx :move placements p)
(begin! ctx :move path p)))))) (begin! ctx :move path p)))))))
:on-double-click (fn [^js event] :on-double-click (fn [^js event]
(when-not drawing? (when (= :select tool)
(let [p (xy (.-currentTarget event) event w h) (let [p (xy (.-currentTarget event) event w h)
hit (player/at p) hit (player/at p)
path (pick/deeper selected hit)] path (pick/deeper selected hit)]
(cond (cond
(not= path selected) (select! ctx path) (not= path selected) (select! ctx path)
(= hit selected) (rf/dispatch [::ui/points true]))))) ;; Into the shape: the pen, on its points.
(= hit selected) (rf/dispatch [::ui/set-tool :pen])))))
:on-key-down (fn [^js event] :on-key-down (fn [^js event]
;; Out a level, as Figma's Esc: the instance that ;; Out a level, as Figma's Esc: the instance that
;; holds what is selected, then nothing. ;; holds what is selected, then nothing.
(when (and (= "Escape" (.-key event)) selected (not drawing?)) (when (and (= "Escape" (.-key event)) selected (= :select tool))
(if points? (select! ctx (pop selected))))
(rf/dispatch [::ui/points false]) :on-pointer-leave (fn [_] (reset! pointer nil))
(select! ctx (pop selected))))) :on-pointer-move
:on-pointer-move (fn [event] (fn [^js event]
;; Back through the inverse of what the handle was
;; drawn through, into the shape's own coordinates.
(if-let [[sid node key-frame vertex inv] @dragging]
(rf/dispatch [::paint-events/set-vertex
sid node key-frame vertex
(through inv (stage-point event w h))])
(let [p (xy (.-currentTarget event) event w h)] (let [p (xy (.-currentTarget event) event w h)]
(if @marquee (reset! pointer p)
(swap! marquee assoc :p p) (cond
(when @gesture (drag! p event)))))) ;; Back through the inverse of what the handle was drawn
;; through, into the shape's own coordinates.
@dragging (let [[sid node key-frame vertex inv] @dragging]
(rf/dispatch [::paint-events/set-vertex sid node key-frame vertex
(through inv (stage-point event w h))]))
@painting (stroke-move! p)
(and pen? (seq draft)) (let [q (snapped draft (stage-point event w h) event)]
(when (not= q hover) (rf/dispatch [::ui/hover q])))
@marquee (swap! marquee assoc :p p)
@gesture (drag! p event))))
:on-pointer-up (fn [_] :on-pointer-up (fn [_]
(reset! dragging nil) (reset! dragging nil)
(stroke-end!)
(if-let [{:keys [p0 p more?]} @marquee] (if-let [{:keys [p0 p more?]} @marquee]
(let [depth (or (some-> selected count) 1) (let [depth (or (some-> selected count) 1)
paths (player/in-rect [(first p0) (second p0) paths (player/in-rect [(first p0) (second p0)
@ -431,11 +616,13 @@
(rf/dispatch [::ui/select-many (into old addresses)])) (rf/dispatch [::ui/select-many (into old addresses)]))
(let-go! true))) (let-go! true)))
:on-pointer-cancel (fn [_] :on-pointer-cancel (fn [_]
(reset! dragging nil) (reset! marquee nil) (let-go! false))} (reset! dragging nil) (reset! marquee nil)
(reset! painting nil) (player/stroke! nil)
(let-go! false))}
[ghost] [ghost]
(when (seq draft) (when (seq selected-pts)
[:polyline {:points (points-text draft) :fill "none" [:polygon.selected-paint-outline
:stroke "#d0ba86" :stroke-width 1}]) {:points (points-text selected-pts) :pointer-events "none"}])
(when mark (when mark
(let [[[x0 y0] [x1 y1]] [(:p0 mark) (:p mark)]] (let [[[x0 y0] [x1 y1]] [(:p0 mark) (:p mark)]]
[:rect.marquee {:x (min x0 x1) :y (min y0 y1) [:rect.marquee {:x (min x0 x1) :y (min y0 y1)
@ -444,26 +631,12 @@
;; DRAWN WHILE DRAWING, unlike the handles. "Where will this polygon land" ;; DRAWN WHILE DRAWING, unlike the handles. "Where will this polygon land"
;; is the question the outline exists to answer, and the moment it is being ;; is the question the outline exists to answer, and the moment it is being
;; asked is mid-draft. ;; asked is mid-draft.
(when-not points? [creation-box]) [creation-box]
(when-not (or drawing? points?) (when (= :select tool)
(if (> (count placements) 1) [group-handles ctx placements] [handles ctx])) (if (> (count placements) 1) [group-handles ctx placements] [handles ctx]))
(when (and id pts (not drawing?) (not (channel/nothing? pts))) (when pen? [points ctx draft])
[:g (when pen? [draft-lines draft hover])
[:polygon {:points (points-text pts) :fill "none" (when paints? [cursor tool])]))
:stroke "#e6ca8b" :stroke-width 1}]
(when-let [inv (when editable? (node/invert matrix))]
(doall
(for [[i [x y]] (map-indexed vector (pairs pts))]
^{:key i}
[:circle.vertex {: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 [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
@ -505,4 +678,5 @@
:width (* zoom w) :height (* zoom h)}] :width (* zoom w) :height (* zoom h)}]
[overlay w h zoom]] [overlay w h zoom]]
(when destination-name (when destination-name
[:div.stage-target "creating in " [:strong destination-name]])])) [:div.stage-target "creating in " [:strong destination-name]])
[tools/adjust-last]]))

View file

@ -0,0 +1,132 @@
(ns arthur.ui.tools
"The tools: a strip down the left of the stage, an options bar across the top
of it, and their keys.
PHOTOSHOP'S ARRANGEMENT, which Illustrator and Flash share: one tool at a
time, picked from a vertical strip or by its letter, and a bar above the
canvas holding that tool's options — the brush's size, the pen's point count.
The palette sits in the strip under the tools. See `events/ui` for what each
tool does to the selection."
(:require [arthur.events.ui :as ui]
[arthur.subs.ui :as sub]
[arthur.subs.render :as render]
[arthur.ui.layout :as layout]
[arthur.ui.palette :as palette]
[re-frame.core :as rf]))
(def ^:private kit
[{:tool :select :key "v" :name "Select"
:tip "click to select, drag to move · double-click a shape to edit its points"
:icon [:path {:d "M4 2 L4 14 L7 11 L9.5 16 L11.5 15 L9 10 L13 10 Z"}]}
{:tool :pen :key "p" :name "Pen"
:tip "click to place points · click the first to close · on a selected shape, click an edge to add a point and ⌥-click a point to delete it"
:icon [:<> [:path {:d "M9 1 L14 8 L11.5 13 L6.5 13 L4 8 Z"}]
[:path.cut {:d "M9 2 V8"}]
[:rect {:x 6.5 :y 14 :width 5 :height 2}]]}
{:tool :brush :key "b" :name "Brush"
:tip "paint, and each piece becomes a polygon when you let go"
:icon [:<> [:path {:d "M15 1.5 L16.5 3 L9 10.5 L7.5 9 Z"}]
[:path {:d "M6.5 10 C4 10 3 11.5 3 13.5 C3 15 1.5 16 1.5 16 C5 16 8 15 8 11.5 Z"}]]}
{:tool :eraser :key "e" :name "Eraser"
:tip "cuts the shapes of the colour you start on, in its symbol · ⌥ cuts every colour"
:icon [:<> [:path {:d "M10 2.5 L16 8.5 L10 14.5 L5.5 14.5 L2 11 Z"}]
[:path.cut {:d "M6 6.5 L12 12.5"}]]}])
(defn toolbox []
(let [tool @(rf/subscribe [::sub/tool])]
[:aside.toolbox
[:div.tools
(doall
(for [{t :tool :keys [key name tip icon]} kit]
^{:key t}
[:button {:class (str "tool" (when (= t tool) " on"))
:title (str name " (" (.toUpperCase key) ") — " tip)
:on-click #(rf/dispatch [::ui/set-tool t])}
[:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))]
[palette/grid]]))
(defn- size-control []
(let [size @(rf/subscribe [::sub/brush])]
[:label.option {:title "[ and ]"} "size"
[:input {:type "range" :min 1 :max 64 :value size
:on-change #(rf/dispatch [::ui/brush-size (js/parseInt (.. % -target -value))])}]
[:span.value (str size "px")]]))
(defn- fit-control
"How closely a stroke's polygon follows what was painted: every point of the
painted edge within so many pixels of it. Closer is more points, and only
where the shape bends."
[fit event]
[:label.option {:title "how far the polygon may stray from what you painted — closer is more points, only where it bends"}
"fit within"
[:input {:type "range" :min 0.5 :max 8 :step 0.5 :value fit
:on-change #(rf/dispatch [event (js/parseFloat (.. % -target -value))])}]
[:span.value (str fit "px")]])
(defn options []
(let [tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft])
selections @(rf/subscribe [::sub/selections])
{:keys [name tip]} (some #(when (= tool (:tool %)) %) kit)]
[:div.options-bar
[:strong.tool-name name]
(case tool
:pen (if (seq draft)
[:<>
[:span.dim (str (quot (count draft) 2) " points")]
[:button {:disabled (< (count draft) 6) :title "Enter"
:on-click #(rf/dispatch [::ui/finish-polygon])} "close"]
[:button {:on-click #(rf/dispatch [::ui/cancel-polygon])} "discard"]]
[:span.dim.hint tip])
(:brush :eraser) [:<> [size-control]
[fit-control @(rf/subscribe [::sub/fit]) ::ui/fit]
[:span.dim.hint tip]]
(if (< 1 (count selections))
[:span.dim (str (count selections) " selected")]
[:span.dim.hint tip]))
[:span.spacer]
[palette/assets]
(let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])]
[:<>
[:button {:class (when on? "on")
:title "show tracing layers over the picture · never exported"
:on-click #(rf/dispatch [::ui/tracing-on (not on?)])} "tracing"]
[:input.trace-opacity
{:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?)
:title "how strongly tracing layers draw" :style {:flex "0 0 64px"}
:on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]])
;; The stage's zoom: a property of the view and not of the document, so it
;; sits in the view's own chrome rather than in the inspector.
[layout/zoomer :stage "the stage"]]))
(defn adjust-last
"Blender's Adjust Last Operation, for a brush or eraser stroke: its fit,
changeable until something else is done."
[]
(when-let [{:keys [kind ids fit]} @(rf/subscribe [::sub/last])]
[:div.adjust-last
[:strong (if (= :eraser kind) "Erase" "Brush stroke")]
(when (and (= :brush kind) (< 1 (count ids))) [:span.dim (str (count ids) " pieces")])
[fit-control fit ::ui/adjust-last]]))
(defn- typing?
"Is a key going into a field? A slider or a checkbox has focus without
taking letters, and a tool's key still picks the tool there, as in Photoshop."
[^js target]
(or (#{"TEXTAREA" "SELECT"} (.-tagName target))
(and (= "INPUT" (.-tagName target))
(not (#{"range" "checkbox" "radio" "button" "color"} (.-type target))))
(.-isContentEditable target)))
(defn install-keys!
"V, P, B and E pick a tool. Enter closes the pen's polygon, or takes the pen
to the selected shape's points; Esc closes it too, and then leaves the pen.
[ and ] size the brush. Delete and undo are `events/history`'s."
[]
(.addEventListener
js/window "keydown"
(fn [^js e]
(when-not (or (typing? (.-target e)) (.-metaKey e) (.-ctrlKey e) (.-altKey e))
(when (contains? ui/tool-keys (.toLowerCase (.-key e)))
(.preventDefault e)
(rf/dispatch [::ui/tool-key (.toLowerCase (.-key e))]))))))

View file

@ -0,0 +1,63 @@
(ns arthur.domain.cut-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.channel :as channel]
[arthur.domain.clip :as clip]
[arthur.domain.cut :as cut]
[arthur.domain.paint :as paint]
[arthur.domain.raster :as r]))
(def square [10 10 50 10 50 50 10 50])
(defn- ink [ring]
(let [b (:buf (r/fill-poly-buf! (r/make 60 60) ring (quot (count ring) 2) 1))]
(fn [x y] (aget b (+ x (* y 60))))))
(deftest a-cut-off-the-side-keeps-the-rest-with-the-cut-edge-as-its-points
(let [[left & more] (cut/cut square [[[40 0 60 0 60 60 40 60]]])]
(is (empty? more))
(is (= 1 ((ink left) 20 30)))
(is (= 0 ((ink left) 45 30)))
(is (some #{40} (take-nth 2 left)) "the cut edge is the shape's own points")))
(deftest a-cut-through-the-middle-leaves-two-pieces-biggest-first
(let [pieces (cut/cut square [[[25 0 30 0 30 60 25 60]]])]
(is (= 2 (count pieces)))
(is (= 1 ((ink (first pieces)) 40 30)) "the right side is the bigger")
(is (= 1 ((ink (second pieces)) 15 30)))))
(deftest a-cut-inside-leaves-a-hole-in-one-ring
(let [[ring & more] (cut/cut square [[[25 25 35 25 35 35 25 35]]])]
(is (empty? more))
(is (= 0 ((ink ring) 30 30)))
(is (= 1 ((ink ring) 15 30)))))
(deftest a-cut-that-misses-is-nil-and-one-that-takes-everything-is-empty
(is (nil? (cut/cut square [[[0 0 5 0 5 5 0 5]]])))
(is (= [] (cut/cut square [[[0 0 60 0 60 60 0 60]]]))))
(deftest a-ring-with-a-bridged-hole-cuts-like-the-shape-it-draws
(let [holed (first (cut/cut square [[[25 25 35 25 35 35 25 35]]]))
[ring] (cut/cut holed [[[0 0 20 0 20 60 0 60]]])]
(is (= 0 ((ink ring) 30 30)) "the hole is still a hole")
(is (= 0 ((ink ring) 15 30)))
(is (= 1 ((ink ring) 45 30)))))
(deftest erasing-keeps-the-big-piece-on-the-shape-and-makes-the-rest-new
(let [clip (-> (clip/blank) (paint/new-shape :main :s 0 square 3))
out (cut/erase clip nil :main 0 [[:s]] [[[25 0 30 0 30 60 25 60]]] [:t :u])
nodes (get-in out [:symbols :main :nodes])
pts #(channel/value-at (get-in nodes [% :channels paint/geometry]) 0 nil)]
(is (= #{:s :t} (set (keys nodes))))
(is (= 1 ((ink (pts :s)) 40 30)))
(is (= 1 ((ink (pts :t)) 15 30)))
(is (= 3 (channel/value-at (get-in nodes [:t :channels [:style :color]]) 0 nil)))
(is (= {} (get-in (cut/erase clip nil :main 0 [[:s]] [[[0 0 60 0 60 60 0 60]]] [])
[:symbols :main :nodes]))
"cut away entirely, it is gone")))
(deftest erasing-between-keys-keys-the-frame-and-leaves-the-others
(let [clip (-> (clip/blank) (paint/new-shape :main :s 0 square 3) (paint/add-key :main :s 10))
out (cut/erase clip nil :main 5 [[:s]] [[[40 0 60 0 60 60 40 60]]] [])
ks (get-in out [:symbols :main :nodes :s :channels paint/geometry :keys])]
(is (= #{0 5 10} (set (keys ks))))
(is (= square (ks 0) (ks 10)))))

View file

@ -7,6 +7,8 @@
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.domain.pick :as pick]
[arthur.domain.raster :as raster]
[arthur.domain.pose :as pose] [arthur.domain.pose :as pose]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol])) [arthur.domain.symbol :as symbol]))
@ -480,3 +482,64 @@
(is (= [128 128 128] (is (= [128 128 128]
(nth (pal/effective-ramp context (clip/active-palette resolve)) (nth (pal/effective-ramp context (clip/active-palette resolve))
(pal/render-index context day 1)))))) (pal/render-index context day 1))))))
(deftest a-knockout-makes-a-hole-in-its-own-symbol-only
(let [sq (fn [x0 y0 x1 y1] [x0 y0 x1 y0 x1 y1 x0 y1])
document
(-> (clip/blank)
(assoc-in [:symbols :lens] {:id :lens :frames 4 :nodes {}})
(paint/new-shape :main :under 0 (sq 0 0 40 20) 4)
(paint/new-shape :lens :frame 0 (sq 0 0 40 20) 8)
(paint/new-shape :lens :glass 0 (sq 10 5 20 15) :clear)
(assoc-in [:symbols :main :nodes :glasses]
{:id :glasses :kind :instance :source {:symbol :lens} :z "zz" :span [0 4]}))
ops (vec ((clip/resolver document :main nil (pal/compile document) nil) 0))
ras (raster/draw-ops! (raster/clear! (raster/make 320 200) 0) ops)
at (fn [x y] (aget (:buf ras) (+ x (* y 320))))
colour (fn [path] (:color (first (filter #(= path (:node %)) ops))))]
(is (= [:begin :poly :poly :end]
(mapv :kind (filter #(and (vector? (:node %)) (= :glasses (first (:node %)))) ops))))
(is (= (colour :under) (at 15 10)) "the glass shows what is under the glasses")
(is (= (colour [:glasses :frame]) (at 5 10)) "and the frame is still there round it")
(is (not= (at 15 10) (at 5 10)))
(is (= [:under] (pick/hit ops [15 10])) "a click in the glass is a click on what shows")
(is (= [:glasses :frame] (pick/hit ops [5 10])))))
(deftest an-op-goes-into-the-layer-of-the-symbol-it-is-for
(let [a {:kind :poly :node :a} b {:kind :poly :node [:g :b]} c {:kind :poly :node [:g :c]}
k {:kind :mask :knock -1}]
(is (= [a {:kind :begin :node [:g]} b c k {:kind :end :node [:g]}]
(clip/in-layer [a b c] [:g] k)) "a layer is made round what it drew")
(is (= [a {:kind :begin :node [:g]} b k {:kind :end :node [:g]} c]
(clip/in-layer [a {:kind :begin :node [:g]} b {:kind :end :node [:g]} c] [:g] k))
"an existing one is used")
(is (= [{:kind :begin :node []} a b k {:kind :end :node []}]
(clip/in-layer [a b] [] k)) "the open symbol is every op")
(is (= [a] (clip/in-layer [a] [:nothing] k)))))
(deftest a-preview-goes-on-top-of-its-own-symbol-only
(let [a {:kind :poly :node :a} b {:kind :poly :node [:g :b]} c {:kind :poly :node :c}
p {:kind :poly :preview true}]
(is (= [a b p c] (clip/atop [a b c] [:g] p)) "under what is above the symbol")
(is (= [a b c p] (clip/atop [a b c] [] p)) "the open symbol is on top of everything")
(is (= [a b c p] (clip/atop [a b c] [:empty] p)))))
(deftest a-remap-shape-lights-what-is-under-it-and-nothing-else
;; A flashlight: a circle that draws nothing of its own, and draws what is
;; already under it in other slots of the palette.
(let [sq (fn [x0 y0 x1 y1] [x0 y0 x1 y0 x1 y1 x0 y1])
document (-> (clip/blank)
(paint/new-shape :main :wall 0 (sq 0 0 40 20) 2)
(paint/new-shape :main :door 0 (sq 10 0 20 20) 3)
(paint/new-shape :main :beam 0 (sq 5 5 15 15) [:remap {2 5}])
(assoc-in [:symbols :main :nodes :wall :z] "a")
(assoc-in [:symbols :main :nodes :door :z] "b")
(assoc-in [:symbols :main :nodes :beam :z] "c"))
context (pal/compile document)
ops (vec ((clip/resolver document :main nil context nil) 0))
ras (raster/draw-ops! (raster/clear! (raster/make 320 200) 0) ops)
at (fn [x y] (aget (:buf ras) (+ x (* y 320))))
slot #(pal/render-index context (pal/default-palette-id document) %)]
(is (= (slot 5) (at 7 10)) "the wall under the beam is lit")
(is (= (slot 3) (at 12 10)) "the door under it is a slot the map leaves alone")
(is (= (slot 2) (at 30 10)) "and the wall outside it is as it was")
(is (= [:wall] (pick/hit ops [7 10])) "a click goes through the light")))

View file

@ -0,0 +1,98 @@
(ns arthur.domain.outline-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.outline :as outline]
[arthur.domain.raster :as r]))
(defn- covered [{:keys [buf]}] (count (filter #(= 1 %) (array-seq buf))))
(deftest a-traced-ring-fills-back-to-exactly-the-mask
(let [m (outline/stamp! (outline/mask 40 30) [8 8] [30 20] 6)
[ring & more] (outline/rings m)
back (r/fill-poly-buf! (r/make 40 30) ring (quot (count ring) 2) 1)]
(is (empty? more) "one stroke is one piece")
(is (= (vec (array-seq (:buf m))) (vec (array-seq (:buf back)))))))
(deftest a-single-pixel-is-a-square
(let [m (outline/mask 5 5)]
(aset (:buf m) 12 1)
(is (= [[2 2 3 2 3 3 2 3]] (outline/rings m 0)))))
(deftest pieces-are-traced-apart-biggest-first
(let [m (-> (outline/mask 40 20)
(outline/stamp! [5 10] [5 10] 4)
(outline/stamp! [20 10] [34 10] 6))]
(is (= 2 (count (outline/rings m))))
(is (< 8 (covered m)))
(is (< 15 (apply min (take-nth 2 (first (outline/rings m))))) "the long one first")))
(deftest a-fit-keeps-the-corners-and-straightens-the-stairs
(let [block (let [m (outline/mask 60 60)]
(doseq [y (range 10 40) x (range 10 50)] (aset (:buf m) (+ x (* y 60)) 1))
(first (outline/rings m)))
stairs (let [m (outline/mask 60 60)]
(doseq [y (range 5 55) x (range 5 (inc y))] (aset (:buf m) (+ x (* y 60)) 1))
(first (outline/rings m)))]
(is (= 8 (count (outline/fit block 1))) "a rectangle is its four corners")
(is (= 6 (count (outline/fit stairs 1))) "a pixel staircase is one diagonal: a triangle")
(is (< 6 (count (outline/fit stairs 0.25))) "unless asked to fit closer than the steps")))
(deftest a-loop-keeps-its-hole-in-one-ring
;; A ring of brush round a gap: one piece, one hole, and the one polygon it
;; makes fills back to the ring and leaves the gap empty.
(let [m (reduce (fn [m a]
(let [p #(vector (+ 30 (* 15 (js/Math.cos %))) (+ 30 (* 15 (js/Math.sin %))))]
(outline/stamp! m (p a) (p (+ a 0.3)) 5)))
(outline/mask 60 60) (range 0 6.6 0.3))
[piece & more] (outline/pieces m)
fill (fn [ring] (:buf (r/fill-poly-buf! (r/make 60 60) ring (quot (count ring) 2) 1)))
middle (+ 30 (* 30 60))]
(is (empty? more))
(is (= 1 (count (:holes piece))))
(is (= (vec (array-seq (:buf m))) (vec (array-seq (fill (outline/polygon piece 0.01)))))
"unsimplified, the bridged ring is the stroke exactly")
(is (zero? (aget (fill (outline/polygon piece 0.01)) middle)) "the middle is a hole")
(is (zero? (aget (fill (outline/polygon piece 8)) middle)) "and simplified, still a hole")))
(deftest overlapping-strokes-leave-no-slivers-and-nothing-crosses
;; A scribble back and forth over itself: the gaps between passes are not
;; holes anyone meant, and the one ring it makes fills back to the scribble
;; without a cut through it.
(let [m (reduce (fn [m i] (outline/stamp! m [10 (+ 10 (* 3 i))] [70 (+ 12 (* 3 i))] 5))
(outline/mask 80 60) (range 10))
[piece] (outline/pieces m)
ring (outline/polygon piece 1)
back (:buf (r/fill-poly-buf! (r/make 80 60) ring (quot (count ring) 2) 1))
miss (count (filter true? (map not= (array-seq (:buf m)) (array-seq back))))]
(is (< miss 80) (str miss " pixels differ — within a pixel of the edge, not a cut"))
(is (every? #(< (count %) 40) (outline/rings-of piece 1)) "and every ring is simple")))
(deftest a-gap-open-to-the-outside-is-not-a-hole
(let [m (-> (outline/mask 40 40)
(outline/stamp! [5 5] [30 5] 4)
(outline/stamp! [30 5] [30 30] 4)
(outline/stamp! [30 30] [5 30] 4))]
(is (= [] (:holes (first (outline/pieces m)))))))
(deftest a-small-hole-is-a-few-points-not-its-staircase
(let [m (outline/mask 60 60)]
(doseq [y (range 10 50) x (range 10 50)
:when (not (and (< 25 x 34) (< 25 y 34)))]
(aset (:buf m) (+ x (* y 60)) 1))
(let [[outer hole] (outline/rings-of (first (outline/pieces m)) 1)]
(is (= 8 (count outer)))
(is (= 8 (count hole)) "an 8×8 hole is its four corners"))))
(deftest a-hole-whose-fit-would-cross-the-outside-is-clipped-to-it
;; A wedge-shaped gap reaching to within a pixel of the outside: fitted on its
;; own, its edge can cross the outside's. It stays a hole, inside the body.
(let [m (outline/mask 60 60)]
(doseq [y (range 10 50) x (range 10 50)
:when (not (and (< 12 x 47) (< 12 y (- 47 (quot (- x 12) 3)))))]
(aset (:buf m) (+ x (* y 60)) 1))
(let [rs (outline/rings-of (first (outline/pieces m)) 3)
ring (outline/join rs)
back (:buf (r/fill-poly-buf! (r/make 60 60) ring (quot (count ring) 2) 1))]
(is (< 1 (count rs)) "the hole is kept")
(is (zero? (aget back (+ 25 (* 20 60)))) "and is a hole")
(is (every? #(< (count %) 30) rs) "and simple")
(is (zero? (aget back (+ 5 (* 5 60)))) "and nothing outside the body is filled"))))

View file

@ -33,3 +33,14 @@
(is (some #(= :paint-test (:node %)) (is (some #(= :paint-test (:node %))
(symbol/eval-frame (get-in c2 [:symbols :main]) 3 nil pal/index-of nil))) (symbol/eval-frame (get-in c2 [:symbols :main]) 3 nil pal/index-of nil)))
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip)))))) (is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))
(deftest a-point-is-added-and-taken-away-on-every-key
(let [c0 (-> (paint/new-shape demo/clip :main :p 0 [0 0 10 0 10 10] :brow)
(paint/add-key :main :p 6)
(paint/set-vertex :main :p 6 1 [20 0]))
c1 (paint/insert-vertex c0 :main :p 0 0.5)
ks #(get-in % [:symbols :main :nodes :p :channels paint/geometry :keys])]
(is (= {0 [0 0 5 0 10 0 10 10] 6 [0 0 10 0 20 0 10 10]} (ks c1))
"half way along the same edge of each key")
(is (= (ks c0) (ks (paint/delete-vertex c1 :main :p 1))))
(is (= (ks c0) (ks (paint/delete-vertex c0 :main :p 0))) "a triangle keeps its three")))

View file

@ -219,3 +219,34 @@
(let [dest (js/Uint8ClampedArray. (* 23 17 4))] (let [dest (js/Uint8ClampedArray. (* 23 17 4))]
(is (= (naive ras pal/rgb 1) (is (= (naive ras pal/rgb 1)
(vec (array-seq (:data (r/->rgba ras pal/rgb 1 dest)))))))))) (vec (array-seq (:data (r/->rgba ras pal/rgb 1 dest))))))))))
(defn- sq [x0 y0 x1 y1] [x0 y0 x1 y0 x1 y1 x0 y1])
(deftest a-knockout-clears-its-own-layer-and-nothing-under-it
(let [ras (-> (r/make 20 10) (r/clear! 0)
(r/draw-ops! [{:kind :poly :pts (sq 0 0 20 10) :n 4 :color 1}
{:kind :begin}
{:kind :poly :pts (sq 0 0 10 10) :n 4 :color 2}
{:kind :poly :pts (sq 10 0 20 10) :n 4 :color 3}
{:kind :poly :pts (sq 5 0 15 10) :n 4 :color 0 :knock -1}
{:kind :end}]))]
(is (= 50 (count-index ras 2)) "0..5 of the left half is left")
(is (= 50 (count-index ras 3)) "15..20 of the right half is left")
(is (= 100 (count-index ras 1)) "the hole shows what was under the layer")))
(deftest a-knockout-of-one-colour-leaves-the-others
(let [ras (-> (r/make 20 10) (r/clear! 0)
(r/draw-ops! [{:kind :begin}
{:kind :poly :pts (sq 0 0 10 10) :n 4 :color 2}
{:kind :poly :pts (sq 10 0 20 10) :n 4 :color 3}
{:kind :poly :pts (sq 0 0 20 10) :n 4 :color 0 :knock 3}
{:kind :end}]))]
(is (= 100 (count-index ras 2)))
(is (zero? (count-index ras 3)))))
(deftest a-mask-op-paints-the-pixels-it-holds
(let [m (js/Uint8Array. 200)]
(aset m 3 1) (aset m 150 1)
(is (= 2 (count-index (-> (r/make 20 10) (r/clear! 0)
(r/draw-ops! [{:kind :mask :mask m :color 4}]))
4)))))

View file

@ -335,7 +335,7 @@
:playback {:frame 6}} :playback {:frame 6}}
after (ui/beginning-polygon db) after (ui/beginning-polygon db)
saved (:clip (store/entry (:clip/current after)))] saved (:clip (store/entry (:clip/current after)))]
(is (= :polygon (get-in after [:ui :tool]))) (is (= [] (get-in after [:ui :draft])) "a draft is begun")
(is (= {} (get-in saved [:symbols :main :nodes]))) (is (= {} (get-in saved [:symbols :main :nodes])))
(is (nil? (get-in saved [:symbols :main :display]))) (is (nil? (get-in saved [:symbols :main :display])))
(is (nil? (get-in (store/entry (:clip/current after)) [:history :done]))))) (is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))))
@ -353,7 +353,7 @@
(is (not= :a cel-id) "the occupied cel is not reused") (is (not= :a cel-id) "the occupied cel is not reused")
(is (= [1 2] (node/placed-span (get-in saved [:symbols sid :nodes cel-id])))) (is (= [1 2] (node/placed-span (get-in saved [:symbols sid :nodes cel-id]))))
(is (= 1 (clip/frames saved (node/source (get-in saved [:symbols sid :nodes cel-id]))))) (is (= 1 (clip/frames saved (node/source (get-in saved [:symbols sid :nodes cel-id])))))
(is (= :polygon (get-in after [:ui :tool]))))) (is (= [] (get-in after [:ui :draft])))))
(deftest sequence-commands-use-isolated-history-transactions (deftest sequence-commands-use-isolated-history-transactions
(let [doc (fixture/document) (let [doc (fixture/document)
@ -455,7 +455,7 @@
(is (= :drawing-a sid) "the shape went into the drawing the selected clip places") (is (= :drawing-a sid) "the shape went into the drawing the selected clip places")
(is (= [:a shape-id] path)) (is (= [:a shape-id] path))
(is (empty? (get-in after [:ui :expanded]))) (is (empty? (get-in after [:ui :expanded])))
(is (nil? (get-in after [:ui :tool])))))) (is (empty? (get-in after [:ui :draft])) "the pen stays the tool, with nothing drafted"))))
(deftest a-clip-made-for-a-drawing-is-selected-in-the-symbol-it-lives-in (deftest a-clip-made-for-a-drawing-is-selected-in-the-symbol-it-lives-in
;; The clip `beginning-polygon` materializes lives in the symbol the LANE ;; The clip `beginning-polygon` materializes lives in the symbol the LANE

View file

@ -778,7 +778,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.tab:hover .close, .tab.on .close { visibility: visible; } .tab:hover .close, .tab.on .close { visibility: visible; }
.tab .close:hover { background: var(--hair); color: var(--fg); } .tab .close:hover { background: var(--hair); color: var(--fg); }
.palette-bar { .options-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
@ -876,8 +876,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.paint-overlay { position: absolute; inset: 0; touch-action: none; } .paint-overlay { position: absolute; inset: 0; touch-action: none; }
/* The footage traced over: a reference above the picture, never part of it. */ /* The footage traced over: a reference above the picture, never part of it. */
.tracing { position: absolute; inset: 0; pointer-events: none; } .tracing { position: absolute; inset: 0; pointer-events: none; }
.paint-overlay.drawing { cursor: crosshair; }
.paint-overlay circle { cursor: grab; }
/* Where a drag out of the pool would land. Dashed and unfilled, so it reads as /* Where a drag out of the pool would land. Dashed and unfilled, so it reads as
"not there yet" over whatever is already drawn. */ "not there yet" over whatever is already drawn. */
@ -1422,6 +1420,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
rotates with the node and the tag does not. Sizes are in user units because rotates with the node and the tag does not. Sizes are in user units because
this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */ this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */
.paint-overlay .creation-target { pointer-events: none; } .paint-overlay .creation-target { pointer-events: none; }
.paint-overlay .selected-paint-outline { fill: none; stroke: #fbbf24; stroke-width: 0.8; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .creation-target .creation-target-box { fill: none; stroke: var(--target-stage); stroke-width: 0.7; } .paint-overlay .creation-target .creation-target-box { fill: none; stroke: var(--target-stage); stroke-width: 0.7; }
.paint-overlay .creation-target .creation-target-tag-bg { fill: var(--target-stage); } .paint-overlay .creation-target .creation-target-tag-bg { fill: var(--target-stage); }
.paint-overlay .creation-target .creation-target-tag { .paint-overlay .creation-target .creation-target-tag {
@ -1532,8 +1531,8 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.stage-area { padding: 5px; } .stage-area { padding: 5px; }
/* Touch targets, in the strips that are all buttons. */ /* Touch targets, in the strips that are all buttons. */
.pane-head, .palette-bar { gap: 7px; } .pane-head, .options-bar { gap: 7px; }
.palette-bar button, .pane-head button, .top button { min-height: 24px; } .options-bar button, .pane-head button, .top button { min-height: 24px; }
/* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does /* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does
not fit is to shrink every item and then push the last ones off the end — not fit is to shrink every item and then push the last ones off the end —
@ -1542,17 +1541,175 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer` So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer`
and `.status` rules above still win on specificity, which is what keeps the and `.status` rules above still win on specificity, which is what keeps the
right-hand groups right-hand while there IS room. */ right-hand groups right-hand while there IS room. */
.top > *, .pane-head > *, .palette-bar > * { flex-shrink: 0; } .top > *, .pane-head > *, .options-bar > * { flex-shrink: 0; }
.top button, .pane-head button { white-space: nowrap; } .top button, .pane-head button { white-space: nowrap; }
.pane-head, .palette-bar { overflow-x: auto; } .pane-head, .options-bar { overflow-x: auto; }
/* The hints go first: the tool's name and its controls are what is needed. */
/* The one compressible thing in the palette bar is the swatches, so they .options-bar .hint { display: none; }
scroll inside their own share instead of taking it from the commands. The }
tone's NAME goes: the ringed swatch already says which one is active, and
it is the widest thing in the bar that nothing is lost by dropping. */ /* --------------------------------------------------------------------------
.palette-bar .swatches { flex: 1 1 130px; min-width: 56px; overflow-x: auto; } the tools: a strip down the left of the stage, an options bar above it.
/* A swatch is a 15px circle or it is not a swatch: the strip scrolls, the
dots do not get thinner. */ Photoshop's arrangement, which Illustrator and Flash share. The palette is a
.palette-bar .swatches > * { flex-shrink: 0; } grid in the strip under the tools, as Deluxe Paint's and Animator Pro's were:
.palette-bar > .dim { display: none; } a fixed table of slots, shown as one. */
.options-bar {
gap: 8px;
min-height: 29px;
padding: 3px 8px;
}
.options-bar .tool-name { font-weight: 600; min-width: 46px; }
.options-bar .hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 0 1 auto; }
.options-bar .spacer { flex: 1; }
.option { display: flex; align-items: center; gap: 6px; color: var(--dim); white-space: nowrap; }
.option input[type="range"] { width: 110px; }
.option .value { color: var(--fg); min-width: 30px; font-variant-numeric: tabular-nums; }
.palette-assets { display: flex; align-items: center; gap: 3px; }
.workspace { flex: 1; min-height: 0; display: flex; }
.workspace > .stage-area { flex: 1; min-width: 0; position: relative; }
.toolbox {
flex: 0 0 52px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 7px 0;
background: var(--chrome);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.tools { display: grid; gap: 2px; }
.tool {
width: 34px; height: 30px;
display: grid; place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: none;
color: var(--fg);
}
.tool:hover { background: var(--sunk); border-color: var(--hair); }
.tool.on { background: var(--sel-bg); border-color: var(--sel); color: var(--sel); }
.tool svg { fill: currentColor; stroke: none; }
.tool svg .cut { fill: none; stroke: var(--chrome); stroke-width: 1.2; }
.tool.on svg .cut { stroke: var(--sel-bg); }
.palette { display: grid; gap: 7px; justify-items: center; padding-top: 9px; border-top: 1px solid var(--hair); }
/* The colour a new shape gets: Photoshop's foreground chip. */
.palette .chip {
width: 36px; height: 36px;
display: grid; place-items: end start;
border: 1px solid var(--fg);
border-radius: 3px;
box-shadow: inset 0 0 0 2px var(--pane);
}
.palette .chip span {
margin: 0 0 2px 3px; padding: 0 3px;
border-radius: 2px;
background: rgba(255, 255, 255, .85);
color: var(--fg); font-size: 9px; line-height: 12px;
}
.palette .cells { display: grid; grid-template-columns: repeat(2, 17px); gap: 2px; }
.palette .slot { display: contents; }
.palette .cell {
width: 17px; height: 17px;
padding: 0;
border: 1px solid rgba(0, 0, 0, .35);
border-radius: 2px;
cursor: pointer;
}
.palette .cell:hover { border-color: var(--fg); transform: scale(1.12); }
.palette .cell.on { border-color: var(--fg); box-shadow: 0 0 0 1px var(--pane), 0 0 0 2px var(--sel); position: relative; z-index: 1; }
/* Clear is no colour: a knockout. Checkered, as transparency is everywhere. */
.palette .clear,
.palette .chip.clear {
background-color: #fff;
background-image:
linear-gradient(45deg, #c9c9c5 25%, transparent 25%, transparent 75%, #c9c9c5 75%),
linear-gradient(45deg, #c9c9c5 25%, transparent 25%, transparent 75%, #c9c9c5 75%);
background-size: 8px 8px;
background-position: 0 0, 4px 4px;
}
.palette .cell.clear { grid-column: span 2; width: 36px; }
/* --------------------------------------------------------------------------
on the stage: the tools' marks, in stage pixels (the SVG's viewBox), and their
cursors. Hairlines, so the pixels under them can be judged. */
.paint-overlay.tool-pen {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cpath d='M2 2 L9 5 L14 11 L11 14 L5 9 Z' fill='%23111' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/%3E%3Cpath d='M2 2 L7 7' stroke='%23fff' stroke-width='1'/%3E%3C/svg%3E") 2 2, crosshair;
}
.paint-overlay.tool-pen.closing {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cpath d='M2 2 L9 5 L14 11 L11 14 L5 9 Z' fill='%23111' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/%3E%3Ccircle cx='17' cy='17' r='3' fill='none' stroke='%23111' stroke-width='1.6'/%3E%3Ccircle cx='17' cy='17' r='3' fill='none' stroke='%23fff' stroke-width='.6'/%3E%3C/svg%3E") 2 2, crosshair;
}
.paint-overlay.tool-brush, .paint-overlay.tool-eraser { cursor: none; }
.paint-overlay .footprint { fill: none; stroke: #fff1be; stroke-width: 0.5; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .footprint.eraser { stroke-dasharray: 3 2; }
.paint-overlay .draft polyline { fill: none; stroke: #fff1be; stroke-width: 1; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .draft .first { fill: #161820; stroke: #fff1be; stroke-width: 0.5; pointer-events: none; }
.paint-overlay .draft .first.closing { fill: #fff1be; r: 2.6; }
.paint-overlay .points .outline { fill: none; stroke: #fff1be; stroke-width: 1; stroke-opacity: .55; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .points .vertex { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: move; }
.paint-overlay .points .vertex:hover { fill: #fff; r: 2.4; }
.paint-overlay .points .insert { fill: #161820; stroke: #fff1be; stroke-width: 0.5; pointer-events: none; }
/* Blender's Adjust Last Operation: the last stroke's settings, in the corner of
the view it was made in, until something else is done. */
.adjust-last {
position: absolute;
left: 10px; bottom: 10px;
display: flex; align-items: center; gap: 10px;
padding: 5px 9px;
background: var(--pane);
border: 1px solid var(--line);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .25);
}
/* Remap: the map swatch, and the inspector's grid for a remap shape. A slot is
shown as what is under the shape will be drawn in; the original is the chip
in its corner. */
.palette .cell.map,
.palette .chip.map {
background: linear-gradient(135deg, #3a3f52 0 50%, #e8d9a8 50% 100%);
color: #fff; font-size: 11px; line-height: 1; text-shadow: 0 1px 1px rgba(0, 0, 0, .6);
}
.palette .cell.map { grid-column: span 2; width: 36px; }
.palette .cell { position: relative; }
.palette .cell .badge {
position: absolute; right: -2px; bottom: -2px;
width: 7px; height: 7px;
border: 1px solid var(--pane); border-radius: 50%;
}
.remap-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.remap, .remap-pick { display: grid; grid-template-columns: repeat(8, 22px); gap: 3px; }
.remap-pick { margin-top: 7px; padding-top: 7px; border-top: 1px solid var(--hair); }
.remap-pick > .dim { grid-column: 1 / -1; }
.remap .cell, .remap-pick .cell {
position: relative;
width: 22px; height: 22px;
padding: 0;
border: 1px solid rgba(0, 0, 0, .35);
border-radius: 3px;
cursor: pointer;
}
.remap .cell:hover, .remap-pick .cell:hover { border-color: var(--fg); }
.remap .cell.on, .remap-pick .cell.on { box-shadow: 0 0 0 1px var(--pane), 0 0 0 2px var(--sel); }
.remap .cell.mapped i {
position: absolute; left: 2px; top: 2px;
width: 8px; height: 8px;
border: 1px solid rgba(255, 255, 255, .8);
border-radius: 2px;
} }