Draw with tools: a pen, a brush that makes polygons, and knockouts
One tool at a time, picked from a strip down the left of the stage or by its letter, as in Photoshop, Illustrator and Flash: V selects and transforms, P is the pen, B the brush, E the eraser. The options bar above the stage holds the tool's settings, and the palette is a grid under the tools with the colour a new shape gets above it, as Deluxe Paint's was. The pen's draft is filled into the picture as it is drawn, so the edge on screen is the pixels the shape will be. Click the first point, Enter, Esc or leaving the pen closes it; the pen stays the tool. Editing a shape's points is the pen ON that shape — Figma's vector edit mode — so the separate points mode is gone: click an edge to add a point, ⌥-click one to delete it, on every key at once so tweens keep meaning something. The brush paints a mask, and what it will be is shown while painting: the stroke is traced round its pixel edges, holes and all, and simplified to a point every so many pixels of outline by the same function the saved shapes come from. A hole is bridged into the one ring along a whole-pixel row, which the fill never samples. Blender's Adjust Last Operation re-traces the last stroke from what it was made of. A shape coloured CLEAR is a knockout: its symbol is drawn into a layer of its own and the knockout clears it, every colour or one. The eraser makes one in the symbol it starts on, of the colour it starts on, or of every colour with ⌥. A click goes through a knockout to what shows. Previews are drawn in the stacking context of what they will land in. The polygon's points no longer stay behind when the shape is moved: they were read off the saved document while the box handles read the one mid-drag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
551d572347
commit
1f0b4d9918
21 changed files with 1479 additions and 277 deletions
|
|
@ -7,6 +7,8 @@
|
|||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.paint :as paint]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.raster :as raster]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
|
@ -480,3 +482,43 @@
|
|||
(is (= [128 128 128]
|
||||
(nth (pal/effective-ramp context (clip/active-palette resolve))
|
||||
(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)))))
|
||||
|
|
|
|||
62
frontend/test/arthur/domain/outline_test.cljs
Normal file
62
frontend/test/arthur/domain/outline_test.cljs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
(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 simplify-keeps-exactly-the-count-asked-for
|
||||
(let [ring (first (outline/rings (outline/stamp! (outline/mask 60 60) [30 30] [30 30] 40)))]
|
||||
(is (= 24 (count (outline/simplify ring 12))))
|
||||
(is (= 6 (count (outline/simplify ring 1))) "never fewer than three points")
|
||||
(is (= ring (outline/simplify ring 10000)))))
|
||||
|
||||
(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 points-go-by-length
|
||||
(let [dab (first (outline/pieces (outline/stamp! (outline/mask 80 40) [10 20] [10 20] 8)))
|
||||
long (first (outline/pieces (outline/stamp! (outline/mask 80 40) [10 20] [70 20] 8)))]
|
||||
(is (< (count (outline/polygon dab 6)) (count (outline/polygon long 6))))
|
||||
(is (= 6 (count (outline/polygon dab 1000))) "never fewer than three")))
|
||||
|
||||
(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)))))))
|
||||
|
|
@ -33,3 +33,14 @@
|
|||
(is (some #(= :paint-test (:node %))
|
||||
(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))))))
|
||||
|
||||
(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")))
|
||||
|
|
|
|||
|
|
@ -219,3 +219,34 @@
|
|||
(let [dest (js/Uint8ClampedArray. (* 23 17 4))]
|
||||
(is (= (naive ras pal/rgb 1)
|
||||
(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)))))
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@
|
|||
:playback {:frame 6}}
|
||||
after (ui/beginning-polygon db)
|
||||
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 (nil? (get-in saved [:symbols :main :display])))
|
||||
(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 (= [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 (= :polygon (get-in after [:ui :tool])))))
|
||||
(is (= [] (get-in after [:ui :draft])))))
|
||||
|
||||
(deftest sequence-commands-use-isolated-history-transactions
|
||||
(let [doc (fixture/document)
|
||||
|
|
@ -455,7 +455,7 @@
|
|||
(is (= :drawing-a sid) "the shape went into the drawing the selected clip places")
|
||||
(is (= [:a shape-id] path))
|
||||
(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
|
||||
;; The clip `beginning-polygon` materializes lives in the symbol the LANE
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue