Compare commits
No commits in common. "e7f5f828451bd232d5ab0b2da25e374959098769" and "a6b6c116c6124fc6bd0a58d2828dec82ac3ac257" have entirely different histories.
e7f5f82845
...
a6b6c116c6
32 changed files with 495 additions and 2024 deletions
|
|
@ -1,42 +0,0 @@
|
|||
"""Schema 7: an anchor is a peg.
|
||||
|
||||
`[:xform :anchor]` is gone from the transform. `T(a)·M·T(-a)` is a transform
|
||||
conjugated by a translation — "do M in a frame shifted by a" — and a parent
|
||||
already is a shifted frame, so an anchor was a peg written inline: one that could
|
||||
not be selected, keyed, shared between nodes, or placed above a measured channel.
|
||||
A pivot nobody chose is now derived from what the node draws, per drag, and stored
|
||||
nowhere; a pivot to keep is a peg, an ordinary `:group` parent.
|
||||
|
||||
Nothing is converted, as in schema 6. Every project is marked 7, and one that
|
||||
still carries an anchor is refused when it is opened, by name and with what to do
|
||||
about it — `node/problems` in the frontend.
|
||||
|
||||
Not converted rather than not worth converting. Dropping an anchor is in fact
|
||||
pixel-exact wherever rotation and scale are the identity, since the anchor
|
||||
cancels out of the composition there — and that is everywhere a freeze, a drop or
|
||||
a new drawing wrote one. It is NOT exact on anything a hand has since turned or
|
||||
scaled, where the composed translation is `a + p - M·a`, and it cannot be made
|
||||
exact at all where `pos` is dense, because tier 2 is content-addressed and not
|
||||
rewritable here. A conversion would therefore be silent and right for most nodes
|
||||
and silent and wrong for exactly the ones somebody had hand-placed, which is the
|
||||
worse failure: a refusal names the document and says what to do.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
apps.get_model("clips", "Project").objects.update(schema_version=7)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0015_tracing_images")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=7),
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -250,7 +250,7 @@ class Project(models.Model):
|
|||
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
|
||||
)
|
||||
name = models.CharField(max_length=200, default="untitled")
|
||||
schema_version = models.PositiveIntegerField(default=7)
|
||||
schema_version = models.PositiveIntegerField(default=6)
|
||||
seq = models.PositiveBigIntegerField(default=0)
|
||||
palette = models.CharField(max_length=64, default="arthur/default")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ class DocumentTests(TestCase):
|
|||
self.assertEqual(5, len(response.json()["written"]))
|
||||
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(7, loaded["schema_version"])
|
||||
self.assertEqual(6, loaded["schema_version"])
|
||||
self.assertEqual(1, len(loaded["clips"]))
|
||||
clip = loaded["clips"][0]
|
||||
self.assertEqual("c1", clip["cid"])
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ Every animatable property is a channel, and channels are addressed **by path**:
|
|||
[:xform :rot] {:animated? false :value 0.0}
|
||||
[:xform :scale] {:animated? false :value [1.0 1.0]}
|
||||
[:xform :skew] {:animated? false :value [0.0 0.0]}
|
||||
[:xform :anchor]{:animated? false :value [0.0 0.0]}
|
||||
[:geom :pts] {:animated? true :interp :hold :dense {...} :generated {...}}
|
||||
[:style :color] {:animated? false :value :skin-dark}
|
||||
[:vis] {:animated? true :interp :hold :keys {0 true, 37 false}}}
|
||||
|
|
@ -323,7 +324,7 @@ to change to allow it.
|
|||
## Transform: decomposed, never a matrix
|
||||
|
||||
```clojure
|
||||
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky]}
|
||||
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky] :anchor [ax ay]}
|
||||
```
|
||||
|
||||
Stored decomposed for two reasons. Each component has to be independently
|
||||
|
|
@ -333,108 +334,18 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
|
|||
Composition, per node:
|
||||
|
||||
```
|
||||
local = T(pos) · R(rot) · K(skew) · S(scale)
|
||||
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
world = world(parent) · pinv · local
|
||||
```
|
||||
|
||||
`:anchor` is Flash's registration point and Blender's origin: rotation and scale
|
||||
happen about it, and getting it wrong is why hand-placed parts swing rather than
|
||||
turn.
|
||||
|
||||
`:pinv` is Blender's `parent_inverse`, captured at the moment of parenting so the
|
||||
child does not jump when it acquires a parent. Small, and its absence is the kind
|
||||
of thing that makes a parenting feature feel broken.
|
||||
|
||||
### There is no `:anchor`, because an anchor is a peg
|
||||
|
||||
Rotation and scale happen about the node's **own origin**. There is no
|
||||
registration point in the decomposition, and that is a deletion rather than a
|
||||
gap, because
|
||||
|
||||
```
|
||||
T(pos) · T(a) · R·K·S · T(-a) ≡ peg at pos+a carrying R·K·S, child at -a
|
||||
```
|
||||
|
||||
to the last bit of the mantissa — `node-test` asserts it. `T(a)·M·T(-a)` is `M`
|
||||
conjugated by a translation, which is "do `M` in a frame shifted by `a`", and a
|
||||
**parent already is a shifted frame**. So an anchor was a peg that could not be
|
||||
selected, could not be keyed, could not be shared between nodes, and could not be
|
||||
put above a measured channel. Same expressive content, strictly less reach.
|
||||
|
||||
What it did, two mechanisms now do, split along who owns the pivot:
|
||||
|
||||
**A pivot nobody chose is derived per drag and never stored.** `gesture/pivot` is
|
||||
the middle of what the node draws — `pick/bounds-of`, the same call the stage
|
||||
draws the selection box from, on the same frame — or the node's own origin when it
|
||||
draws nothing. `gesture/about` then solves for the position that holds that point
|
||||
still:
|
||||
|
||||
```
|
||||
q = M⁻¹(c − p) the material point under c
|
||||
p' = c − M'·q = c − M'·M⁻¹(c − p)
|
||||
```
|
||||
|
||||
so a turn about a point that is **not** the node's origin writes `pos` as well as
|
||||
`rot`. Nothing is cached, so nothing can go stale: the stored anchor was the
|
||||
centre of what the node drew, captured once at creation, while the box beside it
|
||||
was recomputed every render — so on anything edited since it was made, the cross
|
||||
and the box visibly disagreed and the pivot was wrong. A symbol with more than one
|
||||
node diverged on the first edit.
|
||||
|
||||
**And a drawing's origin is the middle of what it draws**, from the moment it is
|
||||
drawn — `paint/centred`, which splits a stroke into a ring about its own middle
|
||||
and the `pos` that puts it back. This is what keeps the paragraph above from being
|
||||
the whole story, because the `pos` that `about` solves for is an **arc** in the
|
||||
angle and `pos` interpolates along the **chord**:
|
||||
|
||||
| | pivot = origin | pivot ≠ origin |
|
||||
| --- | --- | --- |
|
||||
| one drag | right | right |
|
||||
| between two keys | right | **wrong**, by the sagitta of the arc |
|
||||
|
||||
A 360° turn is where that is unmissable and was first seen: 0° and 360° are the
|
||||
only two frames where a wrong pivot cannot be seen at all, so the keys looked
|
||||
right and every frame between them was wrong — a shape keyed bottom-left to
|
||||
top-centre with one full turn on the way left the stage completely in the middle
|
||||
of the spin, orbiting its origin at a radius of 126 px on a 320×200 stage, because
|
||||
a stroke used to be stored exactly as drawn and its origin was therefore the
|
||||
**symbol's** origin, the top-left corner of the stage.
|
||||
|
||||
With the origin on the content there is nothing to solve: `gesture/at-origin?`
|
||||
holds, `turn` writes `rot` alone, `scale` writes `scale` alone, and a keyed turn is
|
||||
right on every frame. `about` is then needed only where the pivot genuinely is not
|
||||
any node's origin — a multi-selection about its shared box, a measured part, or a
|
||||
drawing whose points have been edited away from their own middle — and in each of
|
||||
those a pivot that has to be **keyed** is a peg, below. Hand-authored scenes were
|
||||
always written this way: `demo/scene.edn`'s card is
|
||||
`[-44 -30 44 -30 44 30 -44 30]` with its place in `pos`.
|
||||
|
||||
**A pivot somebody chose is a peg** — an ordinary `:group` parent, `nest/peg`,
|
||||
with `:pinv` captured so nothing moves when it appears. Toon Boom's peg, Fusion's
|
||||
separate Transform node, Harmony's peg-over-the-drawing. It is the answer to the
|
||||
three things a derived pivot cannot do:
|
||||
|
||||
| want | why a derived pivot cannot | what the peg does |
|
||||
| --- | --- | --- |
|
||||
| a pivot that persists — an arm turning about its shoulder | a gesture's pivot is the middle of the drawing and lives for one drag, and the drawing's own origin cannot be moved there without moving its points out from under everything that reads them | the peg's `pos`, static, nowhere near the middle |
|
||||
| a pivot that travels — a foot roll | an anchor could only be keyed against `pos`, interpolated in the same breath, the two obliged to agree frame for frame | the peg's `pos` is an ordinary channel, so key it |
|
||||
| a hand transform over a **measured** one | impossible: `local`'s translation is `pos − M·a`, and under a measured `M` writing `a` moves the thing it was meant to leave alone | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
|
||||
|
||||
That last row is why the rotoscoped parts were worst. `flow/freeze` used to run a
|
||||
`pivoted` pass writing a default anchor onto everything it had made, and it
|
||||
**skipped every `node/measured?` node** — correctly, for the reason in the table.
|
||||
So the traced mouth, lids and brows got no pivot at all and turned about the
|
||||
origin of head-local space, which is the top-left corner of the *footage*: on a
|
||||
320×200 stage the mouth pivoted about (−234, −395), off the stage by more than a
|
||||
stage. The pass is gone; there is no node a derived pivot can be missing from.
|
||||
|
||||
A peg is also how `demo/stage` places its seven faces, and that is the case that
|
||||
makes the pair necessary rather than tidy: `:scale` is **keyed** — the faces pulse
|
||||
— and the source's middle has to stay on its authored centre throughout. A static
|
||||
`pos` cannot do it alone, since `T(pos)·S(k(f))` moves that point whenever `k`
|
||||
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, with nothing keyed
|
||||
that was not keyed before.
|
||||
|
||||
`gesture/refusal` still turns a hand edit on a measured channel away, because the
|
||||
next regenerate would discard it — but it can now name a way through, and the way
|
||||
is a peg.
|
||||
|
||||
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
|
||||
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
|
||||
`[:xform :pos]` with no conversion. The analysis output and the animation model
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ Full specification in `docs/animation-model.md`. The subset to build:
|
|||
[:xform :rot] {:animated? false :value 0.0}
|
||||
[:xform :scale] {:animated? false :value [1.0 1.0]}
|
||||
[:xform :skew] {:animated? false :value [0.0 0.0]}
|
||||
[:xform :anchor] {:animated? false :value [0.0 0.0]}
|
||||
[:geom :pts] {:animated? true :interp :hold
|
||||
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
|
||||
:generated {:by :roto/lips-outer :analysis "sha256:…"
|
||||
|
|
@ -169,24 +170,15 @@ uses to offer a parameter panel instead of raw keys. It lives on the *channel*,
|
|||
not the node, because a node wants a rotoscoped `[:geom :pts]` and a
|
||||
hand-animated `[:xform :pos]` at the same time.
|
||||
|
||||
`:skew`, `:span` and `:over` stay in the shape even though nothing drives them
|
||||
yet: each is a component of a decomposition or of a composition order, and adding
|
||||
one later migrates every stored transform.
|
||||
|
||||
`:anchor` was in this list and has since been **deleted**, which is the one place
|
||||
the reasoning above came out wrong. It is not a component of the decomposition:
|
||||
`T(a)·M·T(-a)` is `M` conjugated by a translation, and a parent already is a
|
||||
translated frame, so an anchor is a peg written inline — one that cannot be
|
||||
selected, keyed, shared, or put above a measured channel. Rotation and scale
|
||||
happen about the node's own origin; a pivot nobody chose is derived per drag by
|
||||
`domain/gesture` and a pivot somebody chose is a peg. See
|
||||
docs/animation-model.md, "There is no `:anchor`, because an anchor is a peg".
|
||||
`:skew`, `:span`, `:anchor` and `:over` stay in the shape even though nothing
|
||||
drives them yet: each is a component of a decomposition or of a composition
|
||||
order, and adding one later migrates every stored transform.
|
||||
|
||||
Transform composition, per node:
|
||||
|
||||
```
|
||||
local = T(pos) · R(rot) · K(skew) · S(scale)
|
||||
world = world(parent) · pinv · local
|
||||
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
world = world(parent) · local
|
||||
```
|
||||
|
||||
## What the prototype knows that you would otherwise rediscover
|
||||
|
|
|
|||
|
|
@ -6,12 +6,8 @@
|
|||
|
||||
(def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn")))
|
||||
|
||||
(defn- position-track
|
||||
"Where the peg sits over time: its `center` plus a slow two-axis drift. The
|
||||
peg's own position, so the stage point the face is pinned to is what moves —
|
||||
not an offset that has to be kept in step with a changing scale."
|
||||
[center drift phase frames]
|
||||
(let [base center
|
||||
(defn- position-track [center anchor drift phase frames]
|
||||
(let [base (mapv - center anchor)
|
||||
[dx dy] drift
|
||||
wave (fn [f period] (js/Math.sin (* 2 js/Math.PI (/ (+ f phase) period))))
|
||||
x0 (wave 0 96)
|
||||
|
|
@ -26,10 +22,6 @@
|
|||
(defn compose
|
||||
"The authored layout plus a source clip -> the composed stage document.
|
||||
|
||||
A PEG'S IDENTITY IS AUTHORED TOO, beside its instance's, for the reason the
|
||||
instance's is: `compose` is a pure function of the layout, so a generated one
|
||||
would make the same stage a different document on every call.
|
||||
|
||||
AN INSTANCE IS KEYED BY ITS :uuid, not by the authored id. The authored id
|
||||
(`:left`, `:voice-right`) is a handle for reading the EDN and for the
|
||||
`:linked-to` written there; it does not appear in the document this returns.
|
||||
|
|
@ -43,9 +35,7 @@ A PEG'S IDENTITY IS AUTHORED TOO, beside its instance's, for the reason the
|
|||
question from which drawing that is."
|
||||
[source]
|
||||
(let [{:keys [name width height frames symbol instances audio scale]} layout
|
||||
;; Which point of the SOURCE is pinned to the stage `:center`. Its
|
||||
;; middle, unless the layout says otherwise for an off-centre drawing.
|
||||
default-origin (or (:origin layout)
|
||||
default-anchor (or (:anchor layout)
|
||||
[(/ (:width source) 2) (/ (:height source) 2)])
|
||||
original (get-in source [:symbols :main])
|
||||
;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the
|
||||
|
|
@ -57,54 +47,20 @@ A PEG'S IDENTITY IS AUTHORED TOO, beside its instance's, for the reason the
|
|||
(throw (ex-info "the stage layout names an instance that is not there"
|
||||
{:in what :id id
|
||||
:known (vec (sort-by str (keys by-id)))}))))
|
||||
;; EVERY PLACEMENT IS A PEG AND AN INSTANCE, and this layout is what
|
||||
;; makes the pair necessary rather than tidy. `:scale` is KEYED — the
|
||||
;; faces pulse — and it has to happen about the point the face is pinned
|
||||
;; to. A stored `[:xform :anchor]` used to buy that: a static position
|
||||
;; and a moving scale, turning about a fixed point. No static position
|
||||
;; can do it alone, because `T(pos)·S(k(f))` moves the source's middle
|
||||
;; whenever `k` changes, so holding it still would mean keying `pos` in
|
||||
;; lockstep with `scale` — two channels that have to agree frame for
|
||||
;; frame, which is the thing channels exist to avoid.
|
||||
;;
|
||||
;; A peg does it with neither:
|
||||
;;
|
||||
;; peg pos = center (+ drift), scale = k(f)
|
||||
;; └ face pos = -origin
|
||||
;;
|
||||
;; world = T(center)·S(k(f))·T(-origin)
|
||||
;;
|
||||
;; which takes `origin` to `center` for EVERY k, with nothing keyed that
|
||||
;; was not keyed before. That is the same matrix the anchor produced —
|
||||
;; `node-test` asserts the identity — and it is reachable, keyable and
|
||||
;; selectable, which the anchor was not.
|
||||
;;
|
||||
;; THE PEG IS THE PLACEMENT: it carries WHERE (pos, scale) and WHEN
|
||||
;; (`:at`, `:span`), and the face under it carries only which drawing and
|
||||
;; the offset to its origin. The time map has to be the peg's, because
|
||||
;; `:scale` is read in the placement's own frames — that is what staggers
|
||||
;; the entrances' growth — and `:span` goes with it so that
|
||||
;; `node/placed-span` still answers where the placement sits on the stage.
|
||||
;; The face then reads its peg's frames as its own and shows whenever the
|
||||
;; peg does.
|
||||
nodes (into
|
||||
{:root {:id :root :name "stage" :kind :group :z "a1"}}
|
||||
(mapcat (fn [{:keys [uuid peg name z span at center origin drift phase]}]
|
||||
(let [origin (or origin default-origin)]
|
||||
[[peg {:id peg :name name :kind :group
|
||||
:parent :root :z z :span span
|
||||
:time {:mode :map :at at :rate 1}
|
||||
:channels {[:xform :pos]
|
||||
(if drift
|
||||
(position-track center drift phase frames)
|
||||
(ch/framed center))
|
||||
[:xform :scale] scale}}]
|
||||
[uuid {:id uuid :name (str name " face") :kind :instance
|
||||
:parent peg :z "a1"
|
||||
:source {:symbol symbol}
|
||||
:channels {[:xform :pos]
|
||||
(ch/framed (mapv - origin))}}]]))
|
||||
instances))
|
||||
(map (fn [{:keys [uuid name z span at center anchor drift phase]}]
|
||||
(let [anchor (or anchor default-anchor)]
|
||||
[uuid {:id uuid :name name :kind :instance
|
||||
:parent :root :z z :span span
|
||||
:time {:mode :map :at at :rate 1}
|
||||
:source {:symbol symbol}
|
||||
:channels {[:xform :pos] (if drift
|
||||
(position-track center anchor drift phase frames)
|
||||
(ch/framed (mapv - center anchor)))
|
||||
[:xform :anchor] {:animated? false :value anchor}
|
||||
[:xform :scale] scale}}]))
|
||||
instances))
|
||||
nodes (into nodes
|
||||
(map (fn [{:keys [uuid linked-to z source span at gain pan]}]
|
||||
[uuid {:id uuid :kind :audio :parent :root :z z
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@
|
|||
:symbol :sym/face-8625
|
||||
:name "8625 stage study"
|
||||
:width 320 :height 200 :frames 280
|
||||
;; Each :center below places the source clip's center on the stage; :origin
|
||||
;; overrides which point of the source that is, for an off-centre drawing.
|
||||
;; :peg is the identity of the transform node the placement hangs off — it
|
||||
;; carries :center and :scale, the face below it carries -:origin, and that pair
|
||||
;; is what makes a KEYED scale happen about the pinned point. See demo/stage.
|
||||
;; Each :center below places the source clip's center on the stage. A symbol
|
||||
;; can author :anchor to override that default for an off-center drawing.
|
||||
;; Each placement reads this pulse in its own local time, so the staggered
|
||||
;; entrances start their growth at different moments on the master timeline.
|
||||
:scale {:animated? true :interp :linear
|
||||
|
|
@ -53,37 +50,30 @@
|
|||
;; uuid. Nothing downstream of `compose` sees the authored id.
|
||||
:instances
|
||||
[{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f01"
|
||||
:name "8625 left" :z "a1"
|
||||
:at 0 :span [0 280]
|
||||
:center [40 40] :drift [3 2] :phase 0}
|
||||
{:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f02"
|
||||
:name "8625 right" :z "a2"
|
||||
:at 48 :span [0 232]
|
||||
:center [120 40] :drift [-3 2] :phase 17}
|
||||
{:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f03"
|
||||
:name "8625 top third" :z "a5"
|
||||
:at 24 :span [0 256]
|
||||
:center [200 40] :drift [2 -3] :phase 31}
|
||||
{:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f04"
|
||||
:name "8625 top fourth" :z "a6"
|
||||
:at 72 :span [0 208]
|
||||
:center [280 40] :drift [-2 -2] :phase 49}
|
||||
{:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f05"
|
||||
:name "8625 bottom left" :z "a7"
|
||||
:at 96 :span [0 184]
|
||||
:center [70 135] :drift [3 -2] :phase 63}
|
||||
{:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f06"
|
||||
:name "8625 bottom middle" :z "a8"
|
||||
:at 120 :span [0 160]
|
||||
:center [160 135] :drift [-2 3] :phase 81}
|
||||
{:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f07"
|
||||
:name "8625 bottom right" :z "a9"
|
||||
:at 144 :span [0 136]
|
||||
:center [250 135] :drift [2 2] :phase 107}]}
|
||||
|
|
|
|||
|
|
@ -589,13 +589,10 @@
|
|||
that draws nothing gets the STAGE's middle, which is where a drawing made into
|
||||
it will be, because drawings are made on the stage.
|
||||
|
||||
WHERE A DROP LANDS, and nothing else any more. It used to be copied into each
|
||||
new instance's anchor as a pivot default, which made it a cache of a derived
|
||||
value that nothing invalidated — so a symbol edited afterwards kept its
|
||||
instances pivoting about where its drawing had been. Pivots are derived per
|
||||
drag now and there is nothing to keep in step; see `domain/gesture`. What is
|
||||
left is positional: `place-symbol` puts this point under the pointer, and
|
||||
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed."
|
||||
What this feeds is a DEFAULT: `place-symbol` copies it into a new instance's
|
||||
anchor and nothing ever updates it, as Flash's transformation point and After
|
||||
Effects' anchor point are set once and left. A symbol that grows later keeps
|
||||
its instances' pivots where they were, so nothing on screen moves."
|
||||
[clip store sid]
|
||||
(let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
|
||||
bounds (fn [[x0 y0 x1 y1 :as b] x y]
|
||||
|
|
@ -618,12 +615,12 @@
|
|||
(defn place-symbol
|
||||
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
|
||||
|
||||
THE MIDDLE GOES UNDER THE POINTER. `center` says where the symbol's drawing
|
||||
sits in its own coordinates, and `pos` is set so that point lands on `point`, a
|
||||
stage pixel; without one — a drop on the timeline — the drawing stays where it
|
||||
was drawn. No pivot is stored: an instance turns about the middle of what it
|
||||
draws at the moment it is dragged, so editing the symbol afterwards cannot
|
||||
leave a pivot behind. See `domain/gesture`.
|
||||
THE ANCHOR IS THE MIDDLE. Every instance pivots about the centre of what it
|
||||
draws — see `center` — so rotating or scaling one turns it in place rather than
|
||||
swinging it about a corner. At the identity transform the anchor moves nothing,
|
||||
so where the drawing lands is `pos` alone: with `point`, a stage pixel, the
|
||||
middle goes there; without one — a drop on the timeline — the drawing stays
|
||||
where it was drawn.
|
||||
|
||||
THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node
|
||||
map — it is what `:linked-to`, an export target and a saved leaf all name — so
|
||||
|
|
@ -656,8 +653,9 @@
|
|||
:time {:mode :map :at frame :rate 1}
|
||||
:source {:symbol sid}
|
||||
:playback {:in 0 :speed 1 :end :stop}
|
||||
:channels {[:xform :pos] {:animated? false
|
||||
:value (if point (mapv - point middle) [0 0])}}})))))
|
||||
:channels {[:xform :pos] {:animated? false
|
||||
:value (if point (mapv - point middle) [0 0])}
|
||||
[:xform :anchor] {:animated? false :value middle}}})))))
|
||||
|
||||
(defn place-sound
|
||||
"Place a sound at host frame `frame`. Its span uses `source`'s fps when
|
||||
|
|
|
|||
|
|
@ -49,40 +49,26 @@
|
|||
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.
|
||||
|
||||
TWO SPACES COME BACK OUT, and they are not the same one. The piece the shape
|
||||
KEEPS is written into that shape's own geometry, so it comes back through
|
||||
`world⁻¹`, the node's own coordinates. Every other piece becomes a NEW node
|
||||
beside it, whose points are read against its own fresh transform, so those come
|
||||
back through `parent⁻¹` — the space a node's `pos` lives in, which is what
|
||||
`paint/new-shape` takes and centres.
|
||||
|
||||
Both were `world⁻¹` before, and that was wrong for the new pieces by exactly
|
||||
the cut shape's own transform. It could not be seen while every drawing had an
|
||||
identity transform, which was true of all of them for as long as a stroke was
|
||||
stored exactly as drawn: the two spaces coincided, so erasing a shape nobody had
|
||||
moved worked, and erasing one somebody had moved scattered the offcuts."
|
||||
cut away entirely is deleted."
|
||||
[clip store open f paths cutters ids]
|
||||
(first
|
||||
(reduce
|
||||
(fn [[clip ids] path]
|
||||
(let [{:keys [sid id frame world parent]} (nest/placement clip store open path f)
|
||||
(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))
|
||||
up (when parent (node/invert parent))
|
||||
left (when (and inv up geom)
|
||||
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] left
|
||||
(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 (through inv keep)))]
|
||||
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame (through up pts) colour))
|
||||
(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)))
|
||||
|
|
|
|||
|
|
@ -7,33 +7,6 @@
|
|||
is in the placement's matrices, so a shape five symbols down moves under the
|
||||
pointer like one on top.
|
||||
|
||||
A GESTURE PIVOTS ABOUT THE MIDDLE OF WHAT THE NODE DRAWS, and its own origin
|
||||
when it draws nothing. One rule, for a drawing, a peg and a measured face part
|
||||
alike — `pivot` below.
|
||||
|
||||
WHAT THAT COSTS DEPENDS ON WHERE THE ORIGIN IS, and that is the whole of why
|
||||
`paint/centred` exists. `node/local!` turns and scales about the node's own
|
||||
origin and nothing else, so a pivot anywhere else has to be paid for by writing
|
||||
`pos` as well — `about` solves for it — and that solution is an ARC in the
|
||||
angle while `pos` interpolates along the CHORD. It is therefore exact on the
|
||||
frame it is written and nowhere between two keys, which is fine for a drag and
|
||||
is not a thing to key: a keyed 360° turn came back to the right place having
|
||||
gone right off the stage in the middle, because 0° and 360° are the only frames
|
||||
where the error vanishes.
|
||||
|
||||
So a drawing's origin is put on the middle of what it draws the moment it is
|
||||
drawn, and then the pivot IS the origin, `about` has nothing to do, and a turn
|
||||
writes `rot` alone — right on every frame, keyed or not. `turn` and `scale`
|
||||
check for that and write the one channel.
|
||||
|
||||
Two kinds of node are left where the pivot is not the origin and cannot be made
|
||||
to be: a MEASURED part, whose points and position are dense tier-2 geometry in
|
||||
the footage's space, and a drawing whose points have been edited far enough to
|
||||
take their middle off its origin. Both still drag correctly about their middle,
|
||||
both are inexact if that drag is keyed, and for both the answer to a pivot that
|
||||
has to persist or be keyed is a peg — which is a node, so its pivot is its
|
||||
origin, so this all collapses again one level up.
|
||||
|
||||
The normal keying rule is `node/set-channel`, the inspector's: a channel with
|
||||
keys gets one on the node's own frame, and one without has its one value
|
||||
changed. Auto-key deliberately replaces that rule with `set-keyed-channel`,
|
||||
|
|
@ -54,7 +27,7 @@
|
|||
[n f store]
|
||||
(let [at #(ch/value-at (get (node/channels n) [:xform %]) f store)
|
||||
xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])]
|
||||
{:pos (xy :pos) :rot (at :rot) :scale (xy :scale) :skew (xy :skew)}))
|
||||
{:pos (xy :pos) :rot (at :rot) :scale (xy :scale) :anchor (xy :anchor)}))
|
||||
|
||||
(defn- measured-channel? [n path]
|
||||
(let [c (get-in n [:channels path])]
|
||||
|
|
@ -65,167 +38,48 @@
|
|||
accept authored correction layers; measured scale cannot yet be decomposed
|
||||
safely. The one-argument form asks whether any stage gesture is possible."
|
||||
([n] (when (node/measured? n)
|
||||
"its transform is measured — use a correction, or put a peg over it"))
|
||||
"its transform is measured — use a correction or place the instance it is in"))
|
||||
([n kind]
|
||||
(when (and (= :scale kind) (measured-channel? n [:xform :scale]))
|
||||
"its scale is measured — put a peg over it and scale that")))
|
||||
"its scale is measured — place the instance it is in")))
|
||||
|
||||
(defn- through [m [x y]]
|
||||
(let [out (js/Float64Array. 2)]
|
||||
(node/apply-pt! out 0 m x y)
|
||||
[(aget out 0) (aget out 1)]))
|
||||
|
||||
(defn linear
|
||||
"The node's own linear part, `R(rot)·K(skew)·S(scale)`, as a 2x3 whose
|
||||
translation is zero — so putting a point through it applies the rotation, skew
|
||||
and scale and nothing else. `node/local!` with a zero `pos` rather than a
|
||||
second closed form, so there is one place the decomposition is written out."
|
||||
[{:keys [rot scale skew]}]
|
||||
(node/local! (node/mat) [0 0] rot scale skew))
|
||||
|
||||
(defn local-of
|
||||
"The node's whole local transform, `T(pos)·R·K·S` — what takes a point in the
|
||||
node's own coordinates to its parent's."
|
||||
[{:keys [pos rot scale skew]}]
|
||||
(node/local! (node/mat) pos rot scale skew))
|
||||
|
||||
(defn about
|
||||
"The `pos` that keeps parent-space point `c` still while the node's linear part
|
||||
changes from `v`'s to `v'`'s. Nil when `v`'s is singular — a node scaled to
|
||||
nothing has no point under `c` to hold.
|
||||
|
||||
THE ONE EQUATION a pivot needs, and it replaces the stored anchor outright.
|
||||
Local is `T(p)·M`. The material point sitting under `c` is `q = M⁻¹(c − p)`,
|
||||
and holding it there under the new `M'` is
|
||||
|
||||
p' = c − M'·q = c − M'·M⁻¹·(c − p)
|
||||
|
||||
Checkable at both ends it has to be right at: with `c` the node's own origin,
|
||||
`c = p`, so `q = 0` and `p' = p` — turning about yourself never moves you. And
|
||||
for a pure turn, `M' = R(da)·M`, so `M'·M⁻¹ = R(da)` and `p' = c + R(da)(p − c)`,
|
||||
which is the familiar rotation of `p` about `c`.
|
||||
|
||||
WHAT IT COSTS is in the namespace docstring, and it is the reason `turn` and
|
||||
`scale` would rather not call this at all: `p'` is an arc in the angle and `pos`
|
||||
interpolates along the chord, so a pivot that is not the node's own origin is
|
||||
held exactly on the frame it is written and nowhere between two keys. Needed
|
||||
for a multi-selection about its shared box, for a measured part, and for a
|
||||
drawing edited away from its middle; not needed, and not called, when the pivot
|
||||
is the origin."
|
||||
[v v' c]
|
||||
(let [m (linear v)]
|
||||
(when-let [inv (node/invert m)]
|
||||
(let [q (through inv (mapv - c (:pos v)))]
|
||||
(mapv - c (through (linear v') q))))))
|
||||
|
||||
(defn pivot
|
||||
"The parent-space point a drag on this node turns and scales about: the middle
|
||||
of `bounds` — what it draws, in its own coordinates — through its own
|
||||
transform, or its own origin when it draws nothing.
|
||||
|
||||
ONE RULE FOR SHAPES, PEGS AND MEASURED PARTS ALIKE, and `pick/bounds-of` on the
|
||||
same frame is where the bounds come from, so the cross and the selection box
|
||||
cannot drift apart: they are one computation.
|
||||
|
||||
FOR A DRAWING THIS IS ITS ORIGIN, and that is not a coincidence to keep up
|
||||
either — `paint/centred` puts a shape's origin on the middle of what it draws
|
||||
when it is drawn. Which is what makes the ordinary case free: `at-origin?`
|
||||
holds, so a turn writes `rot` alone and a keyed turn is right between its keys.
|
||||
A `:group` draws nothing, so `bounds` is nil and this is its `pos`, which is
|
||||
the same statement — a peg's origin is where it was put."
|
||||
[v bounds]
|
||||
(let [[x0 y0 x1 y1] bounds]
|
||||
(through (local-of v)
|
||||
(if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0]))))
|
||||
|
||||
(defn at-origin?
|
||||
"Is parent-space point `c` the node's own origin?
|
||||
|
||||
WITHIN A MILLIONTH OF A PIXEL, because this asks a question about intent and
|
||||
gets an answer in floating point: `paint/centred` subtracts the middle of a
|
||||
ring from its own points, so re-deriving that middle from the result lands on
|
||||
zero to within the rounding of the subtraction, a part in 1e14 of the
|
||||
coordinates. A pivot that close to the origin IS the origin — there is no
|
||||
gesture in which a millionth of a pixel is a pivot somewhere else."
|
||||
[v c]
|
||||
(let [[dx dy] (mapv - c (:pos v))]
|
||||
(< (js/Math.hypot dx dy) 1e-6)))
|
||||
|
||||
(defn move
|
||||
"The node's position with the drag carried from stage point `p0` to `p1`."
|
||||
"The node's position with its pivot carried from stage point `p0` to `p1`."
|
||||
[{:keys [parent]} {:keys [pos]} p0 p1]
|
||||
(when-let [inv (node/invert parent)]
|
||||
{[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))}))
|
||||
|
||||
(defn angle
|
||||
"The angle of stage point `p` about parent-space pivot `c`, in the space the
|
||||
node's rotation is in."
|
||||
[{:keys [parent]} c p]
|
||||
"The angle of stage point `p` about the node's pivot, in the space its
|
||||
rotation is in."
|
||||
[{:keys [parent]} {:keys [pos anchor]} p]
|
||||
(when-let [inv (node/invert parent)]
|
||||
(let [[x y] (mapv - (through inv p) c)]
|
||||
(let [[x y] (mapv - (through inv p) (mapv + pos anchor))]
|
||||
(js/Math.atan2 y x))))
|
||||
|
||||
(defn turn
|
||||
"The node turned by `da` radians about parent-space point `c`: the rotation,
|
||||
and — only if `c` is not the node's own origin — the position that holds `c`
|
||||
still.
|
||||
|
||||
ONE CHANNEL WHENEVER IT CAN BE, which for a drawing is always, because
|
||||
`paint/centred` put its origin on the middle of what it draws. `about` would
|
||||
return the position unchanged here, to within the rounding of its own matrix
|
||||
inverse; not calling it is the difference between a turn that writes `rot` and
|
||||
one that writes `rot` and a `pos` key that is a hair off the one already there.
|
||||
The second is the one that sends a keyed spin off the stage, since `pos` tweens
|
||||
along the chord of an arc it has no way to know about.
|
||||
|
||||
`c` is still honoured where it is genuinely not the origin — a measured part, a
|
||||
drawing edited away from its middle — and is then exact on this frame alone."
|
||||
[v c da]
|
||||
(let [v' (update v :rot + da)]
|
||||
(cond-> {[:xform :rot] (:rot v')}
|
||||
(and c (not (at-origin? v c)))
|
||||
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))
|
||||
"The node's rotation, turned by `da` radians."
|
||||
[{:keys [rot]} da]
|
||||
{[:xform :rot] (+ rot da)})
|
||||
|
||||
(defn scale
|
||||
"The node's scale with the point under stage `p0` taken to `p1`, about
|
||||
parent-space pivot `c`, along the node's own axes — or by the same factor on
|
||||
both when `uniform?` — and, only if `c` is not the node's own origin, the
|
||||
position that holds `c` still.
|
||||
|
||||
The factors are measured in the node's OWN coordinates, which is what makes a
|
||||
corner drag track the pointer on a node that has been turned. On a drawing the
|
||||
pivot is the origin, so `q` is the origin too and the pointer offsets are
|
||||
already measured from it — and, as in `turn`, the position is left alone rather
|
||||
than rewritten to a hair off itself."
|
||||
[{:keys [world]} v c p0 p1 uniform?]
|
||||
(when-let [winv (node/invert world)]
|
||||
(when-let [minv (node/invert (linear v))]
|
||||
(let [q (through minv (mapv - c (:pos v)))
|
||||
a (mapv - (through winv p0) q)
|
||||
b (mapv - (through winv p1) q)
|
||||
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
|
||||
r (when uniform?
|
||||
(let [aa (reduce + (map * a a))]
|
||||
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa))))
|
||||
s (:scale v)
|
||||
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))
|
||||
v' (assoc v :scale s')]
|
||||
(cond-> {[:xform :scale] s'}
|
||||
(and c (not (at-origin? v c)))
|
||||
(into (when-let [p (about v v' c)] {[:xform :pos] p})))))))
|
||||
|
||||
(defn scale-by
|
||||
"The node scaled by factor `k` on both axes about parent-space point `c`, and
|
||||
the position that holds `c` still.
|
||||
|
||||
What a MULTI-SELECTION scales by: one factor for everything about the shared
|
||||
box, so a group of shapes keeps its arrangement instead of each member solving
|
||||
for its own factors. `scale` is the single-node form, where the factors come
|
||||
out of the pointer in the node's own axes."
|
||||
[v c k]
|
||||
(let [v' (update v :scale #(mapv (partial * k) %))]
|
||||
(cond-> {[:xform :scale] (:scale v')}
|
||||
c (into (when-let [p (about v v' c)] {[:xform :pos] p})))))
|
||||
"The node's scale with the point under stage `p0` taken to `p1`, about its
|
||||
pivot, along its own axes — or by the same factor on both when `uniform?`."
|
||||
[{:keys [world]} {:keys [anchor] s :scale} p0 p1 uniform?]
|
||||
(when-let [inv (node/invert world)]
|
||||
(let [a (mapv - (through inv p0) anchor)
|
||||
b (mapv - (through inv p1) anchor)
|
||||
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
|
||||
r (if uniform?
|
||||
(let [aa (reduce + (map * a a))]
|
||||
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa)))
|
||||
nil)]
|
||||
{[:xform :scale] (if r (mapv #(* r %) s) (mapv * s (map k a b)))})))
|
||||
|
||||
(def ^:private manual-layer ::manual-transform)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@
|
|||
move keeps a node's world maps and re-expresses them under the new parent: the
|
||||
matrix becomes a `:pinv`, Blender's parent-inverse, and the time a new `:at`
|
||||
and `:rate`. Its channels, keys and span are untouched."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
|
|
@ -604,9 +601,8 @@
|
|||
|
||||
The new symbol starts where the earliest of them starts and ends where the
|
||||
last one ends, so its instance's bar on the timeline covers exactly theirs.
|
||||
Its instance sits at the identity, so nothing moves. It stores no pivot: an
|
||||
instance turns about the middle of what it draws at the moment it is dragged,
|
||||
so this cannot leave one behind when the group's contents are edited later."
|
||||
Its instance sits at the identity, so nothing moves, and pivots about the
|
||||
middle of what it now holds."
|
||||
[clip store open froms sid uuid f]
|
||||
(let [host-path (pop (first froms))
|
||||
{host :sid frame :frame} (inside clip store open host-path f)
|
||||
|
|
@ -637,151 +633,7 @@
|
|||
(node/mat) back)]
|
||||
(if (:refused r) (reduced r) r)))
|
||||
{:clip made} froms)]
|
||||
moved))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; pegs
|
||||
|
||||
(defn peg
|
||||
"Put a PEG over the node at row path `path`: a free `:group` between it and
|
||||
whatever it hangs off now, sitting on the pivot the node has at frame `f`.
|
||||
`{:clip :sid :id}` or `{:refused why}`.
|
||||
|
||||
A PEG IS WHAT AN ANCHOR WAS, REACHABLE. `T(a)·M·T(-a)` — a transform conjugated
|
||||
by a translation — is \"do M in a frame shifted by `a`\", and a parent already IS
|
||||
a shifted frame; `node-test` asserts the two produce the same matrix. So the
|
||||
stored anchor was a peg that could not be selected, keyed, shared, or placed
|
||||
over a measured channel, and this is the same capability with none of those
|
||||
restrictions. It is the answer to all three things a derived pivot cannot do:
|
||||
|
||||
A PIVOT THAT PERSISTS. A gesture's pivot is the middle of what the node draws
|
||||
and lives for one drag. An arm that turns about its SHOULDER wants a pivot
|
||||
nowhere near that middle, and it wants it on every frame, not re-derived per
|
||||
drag — and it cannot be the drawing's own origin, since moving that moves the
|
||||
drawing's points out from under everything that reads them.
|
||||
|
||||
A PIVOT THAT IS KEYED. The peg's `pos` is an ordinary channel, so a pivot
|
||||
that travels — a foot roll — is a keyed position. An anchor could only have
|
||||
been keyed against `pos`, which is interpolated in the same breath, and the
|
||||
two would have had to agree frame for frame.
|
||||
|
||||
A HAND TRANSFORM OVER A MEASURED ONE. This is the one that was impossible.
|
||||
`gesture/refusal` turns a drag on a measured node away because the next
|
||||
regenerate would discard it, and an anchor written onto one would move the
|
||||
thing it was meant to leave alone — `node/local!`'s translation is
|
||||
`pos − M·a`, and under a measured `M` that is not the identity. A peg's
|
||||
channels are its own, so the hand transform composes OUTSIDE the measured one
|
||||
and the measurement stays regenerable. Resolve publishes a track and parents a
|
||||
transform to it; Harmony puts a peg over the drawing. Same shape.
|
||||
|
||||
NOTHING MOVES, and that is `:pinv`'s whole job — Blender's parent-inverse, the
|
||||
same field `transplant` writes for the same reason. The peg takes the node's
|
||||
place in the hierarchy, inheriting its `:parent` and its `:pinv`; the node hangs
|
||||
off the peg with `T(-c)` as its own, so
|
||||
|
||||
... · pinv · T(c) · T(-c) · local = ... · pinv · local
|
||||
|
||||
to the bit. The node's channels are untouched, which is what lets this work on a
|
||||
measured node at all.
|
||||
|
||||
THE PEG TAKES THE NODE'S `:z`, so draw order is unchanged: a parent's z path is
|
||||
a prefix of its child's, so the node now sorts at `[… z z]` where it sorted at
|
||||
`[… z]`, and against any sibling the comparison is decided at the same place it
|
||||
was before. It takes no `:time` and no `:span`: an identity time map leaves the
|
||||
node's own frames exactly as they were, and a peg with no span is simply always
|
||||
there, so what is on screen when does not change either."
|
||||
[clip store open path f uuid]
|
||||
(let [{:keys [sid id frame]} (placement clip store open path f)
|
||||
n (when sid (get-in clip [:symbols sid :nodes id]))]
|
||||
(cond
|
||||
(nil? n) {:refused "it is not on screen at this frame"}
|
||||
(= :audio (:kind n)) {:refused "a sound has no transform to pivot"}
|
||||
(contains? (:nodes (clip/symbol clip sid)) uuid) {:refused "that id is taken"}
|
||||
:else
|
||||
(let [c (gesture/pivot (gesture/values n frame store)
|
||||
((pick/bounds-of clip store sid n) frame))]
|
||||
{:sid sid
|
||||
:id uuid
|
||||
:clip (clip/update-symbol
|
||||
clip sid update :nodes
|
||||
(fn [nodes]
|
||||
(-> nodes
|
||||
(assoc uuid (cond-> {:id uuid :kind :group
|
||||
:name (str (clip/node-label clip id n) " peg")
|
||||
:parent (:parent n)
|
||||
:z (:z n)
|
||||
:channels {[:xform :pos] (ch/framed c)}}
|
||||
(:pinv n) (assoc :pinv (:pinv n))))
|
||||
(update id assoc
|
||||
:parent uuid
|
||||
:pinv (vec (array-seq
|
||||
(node/local! (node/mat) (mapv - c) 0 [1 1] [0 0])))))))}))))
|
||||
|
||||
(defn- animated?
|
||||
[n path]
|
||||
(let [c (get-in n [:channels path])]
|
||||
(boolean (or (:keys c) (:dense c) (:generated c)))))
|
||||
|
||||
(defn repivot
|
||||
"Move the peg at row path `path` so its origin sits on parent-space point `c`,
|
||||
WITHOUT MOVING ANYTHING UNDER IT. `{:clip :sid :id}` or `{:refused why}`.
|
||||
|
||||
THE GESTURE A PEG WAS MISSING, and the one the stage's ordinary drag is not.
|
||||
Dragging a peg writes its `pos`, and a peg is a PARENT, so that translates
|
||||
everything below it — which is the right behaviour for the drag and the wrong
|
||||
one for \"put the pivot here\". They are different operations and only look like
|
||||
one because both move the same cross. After Effects splits them the same way:
|
||||
dragging a layer moves it, the pan-behind tool moves its anchor and compensates.
|
||||
|
||||
What makes it hold still is that only the COMPOSITION of the peg's own
|
||||
transform and each child's parent-inverse reaches the child:
|
||||
|
||||
world(child) = … · local(peg) · pinv(child) · local(child)
|
||||
|
||||
so moving the peg from `local` to `local'` and solving
|
||||
|
||||
pinv(child)' = local'⁻¹ · local · pinv(child)
|
||||
|
||||
leaves that product exactly as it was. The child's own channels are never
|
||||
touched, which is what lets this work over a MEASURED child — the case the
|
||||
whole peg exists for.
|
||||
|
||||
REFUSED ON AN ANIMATED PEG, rather than quietly wrong. `pinv` is one stored
|
||||
matrix and the compensation above depends on `local(peg)`, so if the peg's
|
||||
position is keyed there is a different correction to make on every frame and no
|
||||
single `pinv` is it. A peg being animated is the normal case once it is doing
|
||||
its job, so the honest move is to say so and let another peg above it carry the
|
||||
new pivot."
|
||||
[clip store open path f c]
|
||||
(let [{:keys [sid id]} (placement clip store open path f)
|
||||
n (when sid (get-in clip [:symbols sid :nodes id]))
|
||||
nodes (when sid (:nodes (clip/symbol clip sid)))
|
||||
kids (when n (keep (fn [[k m]] (when (= id (:parent m)) k)) nodes))]
|
||||
(cond
|
||||
(nil? n) {:refused "it is not on screen at this frame"}
|
||||
(empty? kids) {:refused "nothing hangs off it, so it has no pivot to move"}
|
||||
(animated? n [:xform :pos])
|
||||
{:refused (str "its position is animated, so one stored parent-inverse "
|
||||
"cannot hold its children still on every frame — put a peg "
|
||||
"over it and move that one's pivot instead")}
|
||||
:else
|
||||
(let [v (gesture/values n f store)
|
||||
old (gesture/local-of v)
|
||||
new (node/local! (node/mat) c (:rot v) (:scale v) (:skew v))]
|
||||
(if-let [back (node/invert new)]
|
||||
{:sid sid :id id
|
||||
:clip (clip/update-symbol
|
||||
clip sid update :nodes
|
||||
(fn [ns]
|
||||
(reduce
|
||||
(fn [ns k]
|
||||
(let [fix (node/mul! (node/mat) back old)
|
||||
;; `local'⁻¹ · local · pinv`, with an absent pinv the
|
||||
;; identity it stands for.
|
||||
pv (if-let [p (node/pinv (get ns k))]
|
||||
(node/mul! (node/mat) fix p)
|
||||
fix)]
|
||||
(assoc-in ns [k :pinv] (vec (array-seq pv)))))
|
||||
(update ns id node/set-channel [:xform :pos] f (vec c))
|
||||
kids)))}
|
||||
{:refused "it is scaled to nothing, so it has no pivot to move"})))))
|
||||
(cond-> moved
|
||||
(:clip moved) (update :clip assoc-in
|
||||
[:symbols host :nodes uuid :channels [:xform :anchor] :value]
|
||||
(clip/center (:clip moved) store sid)))))))
|
||||
|
|
|
|||
|
|
@ -28,22 +28,11 @@
|
|||
(def xform-paths
|
||||
"In composition order, which is also the order they have to be sampled in.
|
||||
|
||||
:skew is in here although nothing drives it yet. A decomposition is not
|
||||
extensible after the fact: adding a component later means migrating every
|
||||
stored transform, so it is in the shape and in the composition order from the
|
||||
start.
|
||||
|
||||
THERE IS NO :anchor, and that is a deletion rather than an omission. An anchor
|
||||
is `T(a)·M·T(-a)` — the conjugation of a transform by a translation, which is
|
||||
to say \"do M in a frame shifted by a\" — and a PARENT ALREADY IS A SHIFTED
|
||||
FRAME. So an anchor is a peg that cannot be addressed, cannot be keyed, cannot
|
||||
be shared between nodes, and cannot be put above a measured channel, which is
|
||||
exactly where a pivot is most needed. Same expressive content, strictly less
|
||||
reach. What it used to do, two mechanisms now do properly: `domain/gesture`
|
||||
turns and scales about a point computed at the moment of the drag and stores
|
||||
nothing, and a peg — an ordinary `:group` parent — carries a pivot that has to
|
||||
persist, be keyed, or sit over a measured transform. See docs/animation-model.md."
|
||||
[[:xform :pos] [:xform :rot] [:xform :scale] [:xform :skew]])
|
||||
:skew and :anchor are in here although nothing drives either yet. A
|
||||
decomposition is not extensible after the fact: adding a component later means
|
||||
migrating every stored transform, so both are in the shape and in the
|
||||
composition order from the start."
|
||||
[[:xform :pos] [:xform :rot] [:xform :scale] [:xform :skew] [:xform :anchor]])
|
||||
|
||||
(def valid-paths
|
||||
"The set of valid channel paths follows from the node's :kind, and that is a
|
||||
|
|
@ -78,6 +67,7 @@
|
|||
[:xform :rot] (ch/framed 0.0)
|
||||
[:xform :scale] (ch/framed [1.0 1.0])
|
||||
[:xform :skew] (ch/framed [0.0 0.0])
|
||||
[:xform :anchor] (ch/framed [0.0 0.0])
|
||||
[:vis] (ch/framed true)})
|
||||
|
||||
(def audio-defaults
|
||||
|
|
@ -97,14 +87,13 @@
|
|||
(defn measured?
|
||||
"Is this node's transform regenerated from the footage rather than authored?
|
||||
|
||||
What it guards is that a hand edit to a measured transform is thrown away by
|
||||
the next regenerate, which is what `gesture/refusal` refuses. The way to
|
||||
transform one of these by hand is a PEG above it: the hand transform is then on
|
||||
a node of its own and the measured channels underneath are left to be
|
||||
regenerated. Nothing has to be written onto the measured node at all, which is
|
||||
what the old pivot default could never manage — an anchor under a measured
|
||||
scale does not cancel out of `local!`, so writing one moved the very thing it
|
||||
was meant to leave alone."
|
||||
ONE PREDICATE, TWO CALLERS, and they are the same question asked twice: a hand
|
||||
edit to a measured transform is thrown away by the next regenerate, which is
|
||||
what `gesture/refusal` refuses — and a DEFAULT written under one is worse than
|
||||
useless, which is what `flow/freeze`'s pivot pass declines to do. A node a hand
|
||||
cannot transform has no use for a pivot, and at a measured scale an anchor does
|
||||
not cancel out of `local!` the way it does at the identity, so writing one
|
||||
would move the very thing it was meant to leave alone."
|
||||
[n]
|
||||
(boolean (some #(let [c (get-in n [:channels [:xform %]])]
|
||||
(or (:dense c) (:generated c)))
|
||||
|
|
@ -363,10 +352,10 @@
|
|||
dest))
|
||||
|
||||
(defn local!
|
||||
"dest := T(pos) · R(rot) · K(skew) · S(scale)
|
||||
"dest := T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
|
||||
Written out closed-form rather than as four matrix products, because this runs
|
||||
per node per frame and the four products would each allocate. The derivation,
|
||||
Written out closed-form rather than as five matrix products, because this runs
|
||||
per node per frame and the five products would each allocate. The derivation,
|
||||
so the constants are checkable rather than trusted:
|
||||
|
||||
R·K·S = | c -s | · | 1 kx | · | sx 0 |
|
||||
|
|
@ -378,28 +367,32 @@
|
|||
R·K·S = | sx(c - s·ky) sy(c·kx - s) |
|
||||
| sx(s + c·ky) sy(s·kx + c) |
|
||||
|
||||
and the translation is pos itself, because rotation and scale happen about the
|
||||
node's OWN ORIGIN and nothing else. Turning about any other point is
|
||||
`gesture/about`, which solves for the `pos` that holds the chosen point still
|
||||
and writes it alongside the rotation — so a pivot is a fact about a drag rather
|
||||
than a field on a node, and there is no stored pivot to go stale. A pivot that
|
||||
has to outlive a drag is a peg: see `xform-paths`.
|
||||
and the translation is anchor + pos - M·anchor, which is what makes rotation
|
||||
and scale happen ABOUT the anchor. :anchor is Flash's registration point and
|
||||
Blender's origin, and getting it wrong is why hand-placed parts swing rather
|
||||
than turn.
|
||||
|
||||
:skew is stored as shear FACTORS, not angles — kx is x gained per unit y — so
|
||||
that the identity is 0 and a decomposition round-trips without a tangent."
|
||||
[^js dest pos rot scale skew]
|
||||
[^js dest pos rot scale skew anchor]
|
||||
(let [c (js/Math.cos rot)
|
||||
s (js/Math.sin rot)
|
||||
sx (ch/component scale 0)
|
||||
sy (ch/component scale 1)
|
||||
kx (ch/component skew 0)
|
||||
ky (ch/component skew 1)]
|
||||
(aset dest 0 (* sx (- c (* s ky))))
|
||||
(aset dest 1 (* sx (+ s (* c ky))))
|
||||
(aset dest 2 (* sy (- (* c kx) s)))
|
||||
(aset dest 3 (* sy (+ (* s kx) c)))
|
||||
(aset dest 4 (ch/component pos 0))
|
||||
(aset dest 5 (ch/component pos 1))
|
||||
ky (ch/component skew 1)
|
||||
ax (ch/component anchor 0)
|
||||
ay (ch/component anchor 1)
|
||||
a (* sx (- c (* s ky)))
|
||||
b (* sx (+ s (* c ky)))
|
||||
cc (* sy (- (* c kx) s))
|
||||
d (* sy (+ (* s kx) c))]
|
||||
(aset dest 0 a)
|
||||
(aset dest 1 b)
|
||||
(aset dest 2 cc)
|
||||
(aset dest 3 d)
|
||||
(aset dest 4 (+ ax (ch/component pos 0) (- (+ (* a ax) (* cc ay)))))
|
||||
(aset dest 5 (+ ay (ch/component pos 1) (- (+ (* b ax) (* d ay)))))
|
||||
dest))
|
||||
|
||||
(defn pinv
|
||||
|
|
@ -486,10 +479,6 @@
|
|||
(not (and (finite-number? (get-in n [:time :rate]))
|
||||
(pos? (get-in n [:time :rate])))))
|
||||
(conj ":time :rate must be positive")
|
||||
(contains? (:channels n) [:xform :anchor])
|
||||
(conj (str ":anchor is gone — a pivot nobody chose is derived from what "
|
||||
"the node draws, and a pivot to keep is a peg over it; "
|
||||
"edit > add peg, then turn and key that"))
|
||||
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")
|
||||
(and (:span n) (not (and (vector? (:span n)) (= 2 (count (:span n)))
|
||||
(every? finite-number? (:span n))
|
||||
|
|
@ -502,15 +491,9 @@
|
|||
(or (empty? hs) (apply < hs))))))
|
||||
(conj ":time :holds must be a vector of increasing frames"))
|
||||
|
||||
;; `[:xform :anchor]` is excluded because it has a NAMED refusal above.
|
||||
;; It is the one invalid path a stored document is likely to carry — every
|
||||
;; schema-6 drawing and placement had one — so "not valid on a :poly node"
|
||||
;; would be the first thing a person saw, and it says nothing about what to
|
||||
;; do. The precedent is `symbol/problems`' refusal of `:trace`.
|
||||
(into (when valid
|
||||
(for [[path _] (:channels n)
|
||||
:when (and (not (contains? valid path))
|
||||
(not= path [:xform :anchor]))]
|
||||
:when (not (contains? valid path))]
|
||||
(str "channel " (pr-str path) " is not valid on a " k " node"))))
|
||||
|
||||
(into (for [[path c] (:channels n)
|
||||
|
|
|
|||
|
|
@ -1,58 +1,11 @@
|
|||
(ns arthur.domain.paint
|
||||
"Small authored polygon operations, each on a named symbol. Paint nodes read
|
||||
their symbol's frames directly; a roto instance's exposure and picture sampling
|
||||
must not quantise a hand edit.
|
||||
|
||||
A SHAPE'S ORIGIN IS THE MIDDLE OF WHAT IT DRAWS. Points arrive here in the
|
||||
space the node's `[:xform :pos]` lives in — that is what `nest/drawn-inside`
|
||||
hands over — and `centred` splits them into a ring about the origin and the
|
||||
`pos` that puts it back where it was drawn. See `centred`: it is the invariant
|
||||
the whole pivot story rests on, and this namespace is where it is established."
|
||||
must not quantise a hand edit."
|
||||
(:require [arthur.domain.channel :as channel]))
|
||||
|
||||
(def geometry [:geom :pts])
|
||||
|
||||
(defn middle
|
||||
"The middle of the box round flat points `pts`, in their own space."
|
||||
[pts]
|
||||
(let [xs (take-nth 2 pts)
|
||||
ys (take-nth 2 (rest pts))]
|
||||
[(/ (+ (apply min xs) (apply max xs)) 2)
|
||||
(/ (+ (apply min ys) (apply max ys)) 2)]))
|
||||
|
||||
(defn centred
|
||||
"Flat points `pts`, given in the space a node's `pos` lives in, as
|
||||
`[ring pos]`: the same drawing about the origin, and the position that puts it
|
||||
back exactly where it was.
|
||||
|
||||
THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, and that is the one
|
||||
invariant the pivot rests on. `node/local!` turns and scales about the node's
|
||||
OWN ORIGIN and nothing else — deliberately, since `[:xform :anchor]` was
|
||||
deleted — so a node whose origin is nowhere near its content turns about
|
||||
nowhere near its content. A stroke stored exactly as it was drawn has its
|
||||
origin at the SYMBOL's origin, which on the stage is the top-left corner, so
|
||||
every drawing anyone made turned about the corner of the stage: 126 px away on
|
||||
a 320x200 stage, which is an orbit wider than the stage.
|
||||
|
||||
It could not be seen while a drag was the only way to turn something, because
|
||||
`gesture/about` solved for the `pos` that holds the chosen pivot still and
|
||||
wrote it alongside the rotation — exactly right on the frame of the drag. But
|
||||
that solution is `p' = c + R(θ)(p − c)`, an ARC, and `pos` is interpolated
|
||||
along the chord, so a keyed turn held its pivot on its keys and nowhere
|
||||
between: a 360° spin returned to the right place having gone right off the
|
||||
stage in the middle of the turn, since 0° and 360° are the only frames where
|
||||
the error vanishes. With the origin on the content there is nothing to solve
|
||||
and nothing to interpolate — a turn writes `rot` alone, and `pos` goes back to
|
||||
being the motion path it reads as.
|
||||
|
||||
Hand-authored scenes have always been written this way — `demo/scene.edn`'s
|
||||
card is `[-44 -30 44 -30 44 30 -44 30]` with its place in `pos` — so this is
|
||||
the paint tool joining the convention rather than a new one."
|
||||
[pts]
|
||||
(let [[cx cy] (middle pts)]
|
||||
[(into [] (map-indexed (fn [i v] (- v (if (even? i) cx cy)))) pts)
|
||||
[cx cy]]))
|
||||
|
||||
(defn shapes [clip sid]
|
||||
(->> (get-in clip [:symbols sid :nodes])
|
||||
(filter (fn [[_ node]] (:paint? node)))
|
||||
|
|
@ -63,28 +16,22 @@
|
|||
(let [frames (sort (keys (:keys ch)))]
|
||||
(or (last (take-while #(<= % frame) frames)) (first frames))))
|
||||
|
||||
(defn new-shape
|
||||
"`clip` with a shape drawn at `points` — in the space the new node's `pos` will
|
||||
live in, which is the symbol's own coordinates — on frame `frame` of `sid`.
|
||||
|
||||
THE RING IS CENTRED AND THE MIDDLE GOES IN `pos`, which is the whole of
|
||||
`centred`: the shape's origin is what it draws, so it turns and scales about
|
||||
itself with nothing stored and nothing solved. This is the one place every
|
||||
drawing is born — the pen, the brush, and each piece the eraser leaves — so it
|
||||
is the one place the invariant has to be established."
|
||||
[clip sid id frame points color]
|
||||
(defn new-shape [clip sid id frame points color]
|
||||
(let [end (get-in clip [:symbols sid :frames])
|
||||
z (str "z" (js/Date.now) "-" (name id))]
|
||||
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
|
||||
(even? (count points)))
|
||||
(let [[ring pos] (centred points)]
|
||||
(assoc-in clip [:symbols sid :nodes id]
|
||||
{:id id :name (str "shape " (inc (count (shapes clip sid))))
|
||||
:kind :poly :paint? true :parent nil :z z
|
||||
:span [frame end]
|
||||
:channels {geometry (channel/keyed {frame ring} :hold)
|
||||
[:xform :pos] (channel/framed pos)
|
||||
[:style :color] (channel/framed color)}}))
|
||||
(assoc-in clip [:symbols sid :nodes id]
|
||||
{:id id :name (str "shape " (inc (count (shapes clip sid))))
|
||||
:kind :poly :paint? true :parent nil :z z
|
||||
:span [frame end]
|
||||
:channels {geometry (channel/keyed {frame points} :hold)
|
||||
[:style :color] (channel/framed color)
|
||||
;; Turned and scaled about its middle, as a placed
|
||||
;; symbol is: set once here and never followed.
|
||||
[:xform :anchor]
|
||||
(channel/framed (mapv (fn [vs] (/ (+ (apply min vs) (apply max vs)) 2))
|
||||
[(take-nth 2 points) (take-nth 2 (rest points))]))}})
|
||||
clip)))
|
||||
|
||||
(defn add-key [clip sid id frame]
|
||||
|
|
@ -144,35 +91,10 @@
|
|||
pts))))
|
||||
|
||||
(defn set-points
|
||||
"Key `key-frame` of the shape is `points`, whatever it held, IN THE NODE'S OWN
|
||||
COORDINATES: a cut writing back the piece it kept.
|
||||
|
||||
The node's origin is left where it is, which is why this is the form a cut
|
||||
uses. Re-centring would have to move `pos` to compensate, and `pos` can be
|
||||
keyed and the drawing can be keyed, so there is no one middle to move it to —
|
||||
the only exact moment for that is while the transform is static, which is what
|
||||
`place-points` is for."
|
||||
"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)))
|
||||
|
||||
(defn place-points
|
||||
"Key `key-frame` of the shape is `points`, GIVEN IN THE SPACE ITS `pos` LIVES
|
||||
IN: centred as `new-shape` centres them, with `pos` moved to put them back.
|
||||
|
||||
What a re-fit writes. Adjusting a brush stroke's fit re-traces the same stroke
|
||||
from the stage pixels it was painted in, so its points arrive in the same space
|
||||
they did when the shape was made, and writing them as the node's own would move
|
||||
the drawing by its own position. Only ever used on a shape a stroke has just
|
||||
made, which is why moving `pos` is exact here: nothing is keyed yet."
|
||||
[clip sid id key-frame points]
|
||||
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
|
||||
(if (get-in clip path)
|
||||
(let [[ring pos] (centred points)]
|
||||
(-> clip
|
||||
(assoc-in path ring)
|
||||
(assoc-in [:symbols sid :nodes id :channels [:xform :pos]]
|
||||
(channel/framed pos))))
|
||||
clip)))
|
||||
|
|
|
|||
|
|
@ -63,13 +63,8 @@
|
|||
(and (<= 0 u) (< u w) (<= 0 v) (< v h))))
|
||||
false))
|
||||
|
||||
;; A HOLE AND A LIGHT HAVE BOUNDS LIKE ANYTHING ELSE. A knockout and a remap
|
||||
;; were both left out of this, which is the rule `hit-op` plays by — a click
|
||||
;; goes through them to what shows under — carried over to a gesture it is wrong
|
||||
;; for: a marquee asks what is INSIDE it, not what a point lands on, and leaving
|
||||
;; them out meant the only way to select one anywhere was its timeline row.
|
||||
(defn- op-bounds [{:keys [kind pts n cx cy r size] :as op}]
|
||||
(case kind
|
||||
(defn- op-bounds [{:keys [kind pts n cx cy r size knock lut] :as op}]
|
||||
(case (when-not (or knock lut) kind)
|
||||
:trace (let [cs (trace-corners op)]
|
||||
[(apply min (map first cs)) (apply min (map second cs))
|
||||
(apply max (map first cs)) (apply max (map second cs))])
|
||||
|
|
@ -103,40 +98,24 @@
|
|||
"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. A remap is the same kind of thing the other way up: it is
|
||||
light on what is under it, so a click goes through it to what it lights.
|
||||
|
||||
EXCEPT WHAT IS ALREADY SELECTED, which `selected` is the row path of. Both of
|
||||
those rules are about reaching PAST a shape, and neither has anything to say
|
||||
about the shape you have in your hands: a selected remap or knockout answered
|
||||
no point on the stage at all, so there was nothing to drag it by — the stage's
|
||||
move gesture is this hit-test on the ops, and `handles` draws a box round
|
||||
bounds that nothing could then grab. The first press on one started a marquee
|
||||
instead, which selected nothing and so threw the selection away, and that is
|
||||
the whole of why a palette swapper could be selected from a timeline row and
|
||||
then neither moved nor resized. The ordinary depth rule — a click inside what
|
||||
is selected keeps it, so a deep selection can be dragged — is `choose`'s, and
|
||||
this is the same rule reaching one step further down."
|
||||
([ops point] (hit-op ops point nil))
|
||||
([ops [x y] selected]
|
||||
(:op (reduce (fn [holes op]
|
||||
(cond
|
||||
(not (on? op x y)) holes
|
||||
(and selected (prefix? selected (path-of op))) (reduced {:op op})
|
||||
(:lut op) 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))))))
|
||||
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.
|
||||
`selected` is what is selected now, which a light or a hole does not hide."
|
||||
([ops point] (hit ops point nil))
|
||||
([ops point selected] (some-> (hit-op ops point selected) path-of)))
|
||||
"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
|
||||
"The row path a click on `hit` selects, with `selected` the one selected now."
|
||||
|
|
@ -155,6 +134,14 @@
|
|||
(subvec hit 0 (inc (count selected)))
|
||||
selected))
|
||||
|
||||
(defn- union
|
||||
"The smaller box around both, either of which may be nil."
|
||||
[a b]
|
||||
(cond (nil? a) b
|
||||
(nil? b) a
|
||||
:else (let [[ax0 ay0 ax1 ay1] a [bx0 by0 bx1 by1] b]
|
||||
[(min ax0 bx0) (min ay0 by0) (max ax1 bx1) (max ay1 by1)])))
|
||||
|
||||
(defn bounds-of
|
||||
"A closure from a frame to `[x0 y0 x1 y1]` around what node `n` draws on it, in
|
||||
its own coordinates — nil on a frame it draws nothing on.
|
||||
|
|
@ -162,13 +149,8 @@
|
|||
A CLOSURE, as `clip/resolver` is, and for the same reason it is: inside an
|
||||
instance is its whole symbol resolved at that frame, and a resolver costs the
|
||||
symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one
|
||||
is a resolver per frame.
|
||||
|
||||
WHAT A SELECTION BOX IS DRAWN FROM, and now also what a gesture pivots about —
|
||||
the two were the same quantity all along, computed in two places at two times.
|
||||
The box came from here on every render and the pivot from a value stored at
|
||||
creation, so they drifted apart the moment anything was edited, which is what
|
||||
made a pivot on anything beyond one unchanging shape wrong. See `domain/gesture`."
|
||||
is a resolver per frame, which is what made `pivot` want a separate path for
|
||||
instances rather than the one walk it is."
|
||||
[document store sid n]
|
||||
(let [grow (fn [[x0 y0 x1 y1 :as b] x y]
|
||||
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
|
||||
|
|
@ -219,3 +201,22 @@
|
|||
(let [s (at f [:geom :size])]
|
||||
(when-not (ch/nothing? s) (let [h (/ s 2)] [(- h) (- h) h h]))))
|
||||
(constantly nil))))
|
||||
|
||||
(defn pivot
|
||||
"The middle of everything node `n` draws over its own frames `fs`, in its own
|
||||
coordinates — where it should turn and scale about. Nil for a node that draws
|
||||
nothing on any of them.
|
||||
|
||||
`clip/center`'s rule, for a NODE rather than a symbol, and the same rule
|
||||
`clip/place-symbol` and `paint/new-shape` already set theirs by. A node left
|
||||
without one pivots about its own coordinate ORIGIN, and an origin is not a
|
||||
middle: traced geometry is in the footage's normalised space, whose origin is
|
||||
the top-left corner of the IMAGE, so the pivot lands hundreds of stage pixels
|
||||
off the stage and a corner drag slides the shape about instead of resizing it.
|
||||
|
||||
ALL its frames, not the first, for the reason `clip/center` says: a mouth that
|
||||
opens and travels still has its middle where the mouth is."
|
||||
[document store sid n fs]
|
||||
(let [bounds (bounds-of document store sid n)]
|
||||
(when-let [[x0 y0 x1 y1] (reduce #(union %1 (bounds %2)) nil fs)]
|
||||
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])))
|
||||
|
|
|
|||
|
|
@ -29,20 +29,7 @@
|
|||
(:require [arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.wire :as wire]))
|
||||
|
||||
(def schema-version
|
||||
"The stored document format. A client refuses a project stored under a
|
||||
different one — see `events/project` — so this is bumped by any change to what
|
||||
a leaf may contain.
|
||||
|
||||
7 deleted `[:xform :anchor]`. Nothing is converted, as in 6: every project is
|
||||
marked 7, and one still carrying an anchor is refused when it is opened, by
|
||||
name and with what to do about it — `node/problems`. Dropping one would in fact
|
||||
be exact wherever rotation and scale are the identity, which is everywhere a
|
||||
freeze or a drop wrote one, since the anchor cancels out of `node/local!`
|
||||
there; it is NOT exact on anything since turned or scaled by hand, and that is
|
||||
the case a silent conversion would quietly move. So it is refused rather than
|
||||
guessed at."
|
||||
7)
|
||||
(def schema-version 6)
|
||||
|
||||
(defn block-keys
|
||||
"Every tier-2 key a leaf map names, in a stable order."
|
||||
|
|
|
|||
|
|
@ -359,16 +359,17 @@
|
|||
(quot (if (vector? pts) (count pts) (.-length pts)) 2))
|
||||
|
||||
(defn- xform-at
|
||||
"The four transform components at the node's local frame, or nil when any of
|
||||
"The five transform components at the node's local frame, or nil when any of
|
||||
them has no value on it."
|
||||
[rd]
|
||||
(let [pos (rd [:xform :pos])
|
||||
rot (rd [:xform :rot])
|
||||
scl (rd [:xform :scale])
|
||||
skw (rd [:xform :skew])]
|
||||
skw (rd [:xform :skew])
|
||||
anc (rd [:xform :anchor])]
|
||||
(when-not (or (ch/nothing? pos) (ch/nothing? rot) (ch/nothing? scl)
|
||||
(ch/nothing? skw))
|
||||
[pos rot scl skw])))
|
||||
(ch/nothing? skw) (ch/nothing? anc))
|
||||
[pos rot scl skw anc])))
|
||||
|
||||
(defn- visible?
|
||||
"Is the node switched on this frame?
|
||||
|
|
@ -422,10 +423,10 @@
|
|||
plf (node/local-frame n ppf)
|
||||
rd (fn [path] (read id path (get chs path) lf plf))]
|
||||
(when (visible? id (rd [:vis]))
|
||||
(when-let [[pos rot scl skw] (xform-at rd)]
|
||||
(when-let [[pos rot scl skw anc] (xform-at rd)]
|
||||
;; dest aliases `local` here, which mul! allows: it reads both
|
||||
;; operands fully before writing either.
|
||||
(let [m (node/local! (mat-for id) pos rot scl skw)]
|
||||
(let [m (node/local! (mat-for id) pos rot scl skw anc)]
|
||||
{:m (node/world! m (:m parent) (pinv-for id) m scratch)
|
||||
:f lf
|
||||
:pre plf
|
||||
|
|
|
|||
|
|
@ -797,7 +797,7 @@
|
|||
(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/place-points c sid id frame pts))
|
||||
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)))))))
|
||||
|
||||
|
|
@ -1048,34 +1048,17 @@
|
|||
land on — middled on that stage rather than wherever the picture's own middle
|
||||
happens to fall. A 1920px still is otherwise six stages tall.
|
||||
|
||||
A DEFAULT, set once: nothing keeps it fitted afterwards.
|
||||
|
||||
THIS RESCALES, SO IT HAS TO RE-PLACE, and that is work the stored anchor used
|
||||
to do silently. `clip/place-symbol` has just put the picture's middle where it
|
||||
belongs — under the pointer, or wherever it was — at the IDENTITY scale, and
|
||||
then this changes the scale. With an anchor on the middle, `T(a)·S(k)·T(-a)`
|
||||
left that point alone for any `k` and there was nothing to fix. Without one,
|
||||
`T(pos)·S(k)` moves it by `(1-k)·middle`, so the middle has to be solved for
|
||||
again — the same equation `gesture/about` solves per drag, with `middle` as the
|
||||
point to hold.
|
||||
|
||||
A trace's own coordinates are its pixels, `[0 0 width height]`, so its middle
|
||||
is half its size and `pos = where − k·middle` for both branches: `where` is
|
||||
where `place-symbol` left the middle when a pointer chose it, and the stage's
|
||||
own middle when nothing did — a 1920px still is otherwise six stages tall and
|
||||
dropped off-centre as well."
|
||||
A DEFAULT, set once, as the anchor is: nothing keeps it fitted afterwards."
|
||||
[document sid uuid {:keys [width height]} point?]
|
||||
(let [[w h] (clip/stage document sid)
|
||||
k (min (/ w width) (/ h height))
|
||||
mid [(/ width 2) (/ height 2)]]
|
||||
k (min (/ w width) (/ h height))]
|
||||
(update-in document [:symbols sid :nodes uuid :channels]
|
||||
(fn [chs]
|
||||
(let [where (if point?
|
||||
(mapv + (:value (get chs [:xform :pos])) mid)
|
||||
[(/ w 2) (/ h 2)])]
|
||||
(assoc chs
|
||||
[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed (mapv - where (mapv * [k k] mid)))))))))
|
||||
(cond-> (assoc chs [:xform :scale] (ch/framed [k k]))
|
||||
(not point?)
|
||||
(assoc [:xform :pos]
|
||||
(ch/framed (mapv - [(/ w 2) (/ h 2)]
|
||||
(:value (get chs [:xform :anchor]))))))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::drop-symbol
|
||||
|
|
@ -1363,45 +1346,6 @@
|
|||
uuid (conj host uuid)])
|
||||
(update-in [:ui :expanded] conj (conj host uuid)))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::peg
|
||||
;; A peg over the selected node, selected afterwards — because the point of
|
||||
;; making one is to transform it, and the thing you just made is the thing you
|
||||
;; meant to grab. The peg takes the node's place in the hierarchy, so the row
|
||||
;; path it is selected by is the one the node had.
|
||||
(fn [db [_ path]]
|
||||
(let [{clip :clip st :store} (store/entry (:clip/current db))
|
||||
open (get-in db [:ui :open])
|
||||
f (editing-frame db clip)
|
||||
uuid (random-uuid)
|
||||
r (nest/peg clip st open path f uuid)]
|
||||
(if-let [why (:refused r)]
|
||||
(refused db why)
|
||||
(-> db
|
||||
(edit/edit (constantly (:clip r)))
|
||||
(assoc-in [:ui :selection] [:node (:sid r) uuid (conj (pop (vec path)) uuid)]))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::repivot
|
||||
;; Put the pivot of the peg at `path` on stage point `point`, moving nothing.
|
||||
;; One edit at the end of the drag rather than per pointermove: a repivot moves
|
||||
;; nothing on screen by construction, so there is no intermediate state worth
|
||||
;; drawing — `ui/stage` follows the pointer with the cross alone.
|
||||
(fn [db [_ path point]]
|
||||
(let [{clip :clip st :store} (store/entry (:clip/current db))
|
||||
open (get-in db [:ui :open])
|
||||
f (editing-frame db clip)
|
||||
pl (nest/placement clip st open (vec path) f)
|
||||
c (when pl (let [out (js/Float64Array. 2)]
|
||||
(when-let [inv (node/invert (:parent pl))]
|
||||
(node/apply-pt! out 0 inv (first point) (second point))
|
||||
[(aget out 0) (aget out 1)])))
|
||||
r (when c (nest/repivot clip st open (vec path) f c))]
|
||||
(cond
|
||||
(nil? r) db
|
||||
(:refused r) (refused db (:refused r))
|
||||
:else (edit/edit db (constantly (:clip r)))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::edit-keyframes
|
||||
(fn [db [_ items delta]]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.geom :as geom]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.ring :as ring]
|
||||
[arthur.flow.address :as address]))
|
||||
|
|
@ -400,10 +401,9 @@
|
|||
cy (/ (+ y0 y1) 2)
|
||||
k (min (/ (* 0.8 w) (max 1e-9 (- x1 x0)))
|
||||
(/ (* 0.8 h) (max 1e-9 (- y1 y0))))]
|
||||
;; `pos` alone, because `T(a)·S(k)·T(-a)` translates by `a − k·a`: folding
|
||||
;; that into the position is the same pixels with no stored pivot.
|
||||
{[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed [(- (/ w 2) (* k cx)) (- (/ h 2) (* k cy))])}))
|
||||
{[:xform :anchor] (ch/framed [cx cy])
|
||||
[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed [(- (/ w 2) cx) (- (/ h 2) cy)])}))
|
||||
|
||||
(defn face-placement
|
||||
"The face's transform on the stage, as FRAMED channels.
|
||||
|
|
@ -429,19 +429,17 @@
|
|||
bounding box — and it is a fraction of the STAGE, so a 1440x1920 portrait clip
|
||||
composited onto a 320x200 stage is not a problem to solve.
|
||||
|
||||
POSITION puts the reference centroid a QUARTER of the way down the stage,
|
||||
because the rigid landmarks are eyes and nose — the upper middle of a face — so
|
||||
a quarter down leaves the jaw and the mouth on the stage. Whatever hangs off is
|
||||
clipped, which is not a feature to add: every fill in `domain/raster` clamps
|
||||
already.
|
||||
ANCHOR is the reference centroid, and this is where `:anchor` earns its place.
|
||||
MediaPipe's normalised space has its origin at the image's TOP-LEFT CORNER, so
|
||||
head-local geometry is not centred on anything; the registration point of the
|
||||
face is the head's own centre, and rotation and scale have to happen about that
|
||||
rather than about a corner of the footage. Getting that wrong is why hand-placed
|
||||
parts swing rather than turn.
|
||||
|
||||
THE CENTROID IS IN THE POSITION, NOT IN A PIVOT. MediaPipe's normalised space
|
||||
has its origin at the image's TOP-LEFT CORNER, so head-local geometry is not
|
||||
centred on anything and the placement has to carry the offset to the head's own
|
||||
centre. This used to be an `:anchor`, which made the offset do double duty as a
|
||||
pivot; `pos = stage − k·centroid` is the same translation with nothing stored,
|
||||
and the face then turns about the middle of what it actually draws rather than
|
||||
about a centroid measured once. See `domain/gesture`.
|
||||
POSITION puts the anchor a QUARTER of the way down the stage, because the rigid
|
||||
landmarks are eyes and nose — the upper middle of a face — so a quarter down
|
||||
leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which
|
||||
is not a feature to add: every fill in `domain/raster` clamps already.
|
||||
|
||||
ONE MAPPING, COMPUTED OVER EVERY SUBJECT AND WRITTEN INTO EACH FACE. It is
|
||||
measured across all of them together — which is what keeps two faces filmed
|
||||
|
|
@ -464,9 +462,10 @@
|
|||
c (geom/centroid ref)
|
||||
span (- (reduce max (map :x ref)) (reduce min (map :x ref)))
|
||||
k (/ (* 0.4 w) span)]
|
||||
{[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed [(- (/ w 2) (* k (:x c)))
|
||||
(- (* 0.25 h) (* k (:y c)))])}))))
|
||||
{[:xform :anchor] (ch/framed [(:x c) (:y c)])
|
||||
[:xform :scale] (ch/framed [k k])
|
||||
[:xform :pos] (ch/framed [(- (/ w 2) (:x c))
|
||||
(- (* 0.25 h) (:y c))])}))))
|
||||
|
||||
(defn- mouth-part [subject absent? obs
|
||||
{:keys [analysis verts anchor-avg contour-avg aperture-cut] :as params}
|
||||
|
|
@ -772,6 +771,46 @@
|
|||
{})
|
||||
:store (into (merge (:store head) (:store plate)) (mapcat :store) parts)}))
|
||||
|
||||
(defn pivoted
|
||||
"Every node a freeze makes that a hand can transform, pivoting about the
|
||||
middle of what it draws.
|
||||
|
||||
THE SAME RULE AS EVERYWHERE ELSE, and this is the one place that used to skip
|
||||
it: `clip/place-symbol` writes an instance's anchor, `paint/new-shape` a
|
||||
drawing's, `nest/group` a new symbol's, and `face-placement` the source
|
||||
placement's — and the traced parts underneath it got none, so each of them
|
||||
turned and scaled about ITS OWN ORIGIN, which for head-local geometry is the
|
||||
top-left corner of the footage. `freeze_test` already said why that is wrong
|
||||
for the face; it is no less wrong for the mouth.
|
||||
|
||||
WHAT IT SKIPS IS `node/measured?`, the predicate `gesture/refusal` refuses a
|
||||
hand edit by — so a node gets a pivot exactly when a hand can use one, which
|
||||
is the invariant worth having rather than a list of exceptions. It is also
|
||||
what keeps this off `:head`: the head carries the measured similarity, its
|
||||
scale is nowhere near 1, and an anchor under a scale does NOT cancel out of
|
||||
`node/local!` the way it does at the identity, so writing one there would move
|
||||
the whole face. Skipping it because it draws nothing would be true today and
|
||||
true by accident.
|
||||
|
||||
A DEFAULT, written once, never followed: an anchor already on a node is left
|
||||
alone, and nothing updates one when the geometry moves later. On everything it
|
||||
does write to, rotation and scale are the identity, where the anchor cancels
|
||||
out — so this changes where a part pivots and not one pixel of what it draws."
|
||||
[clip store]
|
||||
(reduce
|
||||
(fn [c [sid id]]
|
||||
(let [n (get-in c [:symbols sid :nodes id])
|
||||
at [:symbols sid :nodes id :channels [:xform :anchor]]]
|
||||
(if (or (get-in c at) (node/measured? n))
|
||||
c
|
||||
(if-let [p (pick/pivot c store sid n (range (get-in c [:symbols sid :frames])))]
|
||||
(assoc-in c at (ch/framed p))
|
||||
c))))
|
||||
clip
|
||||
(for [sid (sort-by str (keys (:symbols clip)))
|
||||
id (sort-by str (keys (get-in clip [:symbols sid :nodes])))]
|
||||
[sid id])))
|
||||
|
||||
(defn clip
|
||||
"Subject-id -> conditioned measurements becomes one symbol per face, and a
|
||||
symbol called :main that places them.
|
||||
|
|
@ -833,7 +872,8 @@
|
|||
{:subject subject :feature id :frames nf :actual (count track)}))))
|
||||
(let [store (merged :store)]
|
||||
{:store store
|
||||
:clip (reduce (fn [c [subject inputs]]
|
||||
(head-mode {:subject subject :trace (get inputs :trace (:trace params))}
|
||||
{:clip c}))
|
||||
built ordered)})))
|
||||
:clip (-> (reduce (fn [c [subject inputs]]
|
||||
(head-mode {:subject subject :trace (get inputs :trace (:trace params))}
|
||||
{:clip c}))
|
||||
built ordered)
|
||||
(pivoted store))})))
|
||||
|
|
|
|||
|
|
@ -220,12 +220,9 @@
|
|||
"The topmost row path drawn at stage point `point` on the frame last painted,
|
||||
or nil. Read off the ops the canvas was drawn from, so a click picks exactly
|
||||
what is seen: their point buffers stay good until the next paint, and a
|
||||
pointer event is handled between two.
|
||||
|
||||
`selected` is the row path selected now, which a light or a hole it belongs to
|
||||
does not hide from this: see `pick/hit-op`."
|
||||
([point] (at point nil))
|
||||
([point selected] (pick/hit (:ops @state) point selected)))
|
||||
pointer event is handled between two."
|
||||
[point]
|
||||
(pick/hit (:ops @state) point))
|
||||
|
||||
(defn hit-op
|
||||
"The topmost op that shows at `point`, as `at` picks it: what the eraser
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
|
||||
(defn- ghost
|
||||
"Where a drag out of the pool would land: the outline of its first frame,
|
||||
dashed, and a cross on the middle it will land on, which goes under the
|
||||
dashed, and a cross on the middle it will pivot about, which goes under the
|
||||
pointer. Drawn from the drag's own outline rather than by resolving anything,
|
||||
so hovering costs one re-render and no evaluation. The cross is always drawn: a
|
||||
face symbol is a fraction of a pixel until what places it scales it up, and
|
||||
|
|
@ -122,13 +122,6 @@
|
|||
(defonce ^:private gesture (atom nil))
|
||||
(defonce ^:private marquee (r/atom nil))
|
||||
|
||||
;; An ⌥-drag of a peg's cross: where the pivot is being put, in stage pixels.
|
||||
;; A REAGENT atom and not the plain `gesture` one, because the only thing that
|
||||
;; moves during it is the cross — a repivot holds everything else still by
|
||||
;; construction — so this drives a re-render and the document is left alone until
|
||||
;; the pointer goes up.
|
||||
(defonce ^:private repivot (r/atom nil))
|
||||
|
||||
(defn- loaded
|
||||
"The document and its tier-2 store, read WHEN THE POINTER GOES DOWN.
|
||||
|
||||
|
|
@ -156,23 +149,14 @@
|
|||
(rf/dispatch [::ui/select nil]))))
|
||||
|
||||
(defn- begin!
|
||||
"Start dragging `kind` of the node at `path` from stage point `p`.
|
||||
|
||||
THE PIVOT IS DERIVED HERE, once, and held for the drag. Once per pointerdown is
|
||||
what makes deriving it affordable where a stored one was tempting — and holding
|
||||
it for the drag is what keeps a turn steady: re-deriving per pointermove would
|
||||
chase the box the turn is itself moving.
|
||||
|
||||
On a drawing it comes out as the node's own origin, because that is where
|
||||
`paint/centred` put it, and `gesture/turn` then writes `rot` alone."
|
||||
"Start dragging `kind` of the node at `path` from stage point `p`."
|
||||
[{:keys [open f] :as ctx} kind path p]
|
||||
(let [{document :clip st :store} (loaded ctx)]
|
||||
(when-let [{:keys [sid id frame] :as pl} (nest/placement document st open path f)]
|
||||
(let [n (get-in document [:symbols sid :nodes id])
|
||||
v0 (gesture/values n frame st)
|
||||
c (gesture/pivot v0 ((pick/bounds-of document st sid n) frame))]
|
||||
v0 (gesture/values n frame st)]
|
||||
(reset! gesture {:kind kind :pl pl :path path :open open :v0 v0 :p0 p :n n
|
||||
:c c :a (gesture/angle pl c p) :turned 0})))))
|
||||
:a (gesture/angle pl v0 p) :turned 0})))))
|
||||
|
||||
(defn- stage-bounds [{:keys [world bounds]}]
|
||||
(when (and world bounds)
|
||||
|
|
@ -200,26 +184,23 @@
|
|||
(and (seq members) box)
|
||||
(reset! gesture {:kind kind :members members :p0 p :box box}))))
|
||||
|
||||
(defn- multi-values
|
||||
"What one member of a multi-selection makes of the drag. `[cx cy]` is the
|
||||
middle of the shared box, in stage pixels — every member scales about THAT, so
|
||||
the selection keeps its arrangement.
|
||||
|
||||
The whole of what this used to open-code — take the member's anchor to stage,
|
||||
scale it about the box, and write the position difference — is `scale-by`,
|
||||
because that was this same pivot equation with the stored anchor as the pivot."
|
||||
[kind {:keys [pl v0]} [cx cy] p0 p]
|
||||
(defn- multi-values [kind {:keys [pl v0]} [cx cy] p0 p]
|
||||
(case kind
|
||||
:move (gesture/move pl v0 p0 p)
|
||||
:scale
|
||||
(let [d0 (max 1e-6 (js/Math.hypot (- (first p0) cx) (- (second p0) cy)))
|
||||
k (/ (js/Math.hypot (- (first p) cx) (- (second p) cy)) d0)]
|
||||
(when-let [inv (node/invert (:parent pl))]
|
||||
(gesture/scale-by v0 (through inv [cx cy]) k)))
|
||||
k (/ (js/Math.hypot (- (first p) cx) (- (second p) cy)) d0)
|
||||
[px py] (through (:world pl) (:anchor v0))
|
||||
target [(+ cx (* k (- px cx))) (+ cy (* k (- py cy)))]
|
||||
inv (node/invert (:parent pl))]
|
||||
(when inv
|
||||
(let [[a b] (through inv [px py]) [c d] (through inv target)]
|
||||
{[:xform :pos] (mapv + (:pos v0) [(- c a) (- d b)])
|
||||
[:xform :scale] (mapv #(* k %) (:scale v0))})))
|
||||
nil))
|
||||
|
||||
(defn- drag! [p ^js event]
|
||||
(let [{:keys [kind pl path open v0 p0 n a c turned values]} @gesture
|
||||
(let [{:keys [kind pl path open v0 p0 n a turned values]} @gesture
|
||||
shift? (.-shiftKey event)
|
||||
moved? (or values (< 1 (js/Math.hypot (- (first p) (first p0)) (- (second p) (second p0)))))]
|
||||
(when moved?
|
||||
|
|
@ -236,16 +217,16 @@
|
|||
(do (reset! gesture nil) (rf/dispatch [::ui/refuse why]))
|
||||
(let [vs (case kind
|
||||
:move (gesture/move pl v0 p0 p)
|
||||
:scale (gesture/scale pl v0 c p0 p shift?)
|
||||
:turn (let [b (gesture/angle pl c p)
|
||||
:scale (gesture/scale pl v0 p0 p shift?)
|
||||
:turn (let [b (gesture/angle pl v0 p)
|
||||
d (- b a)
|
||||
t (+ turned (- d (* 2 js/Math.PI (js/Math.round (/ d (* 2 js/Math.PI))))))
|
||||
q (/ js/Math.PI 12)]
|
||||
(swap! gesture assoc :a b :turned t)
|
||||
;; ⇧ turns in 15° steps, as everywhere.
|
||||
(gesture/turn v0 c (if shift?
|
||||
(- (* q (js/Math.round (/ (+ (:rot v0) t) q))) (:rot v0))
|
||||
t))))]
|
||||
(gesture/turn v0 (if shift?
|
||||
(- (* q (js/Math.round (/ (+ (:rot v0) t) q))) (:rot v0))
|
||||
t))))]
|
||||
(when vs
|
||||
(swap! gesture assoc :values vs)
|
||||
(rf/dispatch [::ui/gesture
|
||||
|
|
@ -266,40 +247,12 @@
|
|||
"The selected node's box, drawn through its own transform so it turns with
|
||||
it: a square on each corner to scale by, a knob above to turn by, and a cross
|
||||
on the pivot. Dragging inside it moves it — that is the stage's own
|
||||
pointerdown, which keeps a selection it lands inside.
|
||||
|
||||
THE CROSS IS THE MIDDLE OF THE BOX, and that is an identity rather than a
|
||||
coincidence to keep up: both come from the same `bounds` on the same frame, so
|
||||
the cross cannot drift off the box. On a node that draws nothing — a peg — there
|
||||
is no box and the cross marks its own origin, which is what it turns about.
|
||||
|
||||
ON A DRAWING THOSE ARE THE SAME POINT, because `paint/centred` puts a shape's
|
||||
origin on the middle of what it draws. So the cross marks the node's origin as
|
||||
well as its box's middle, and a turn about it is `rot` alone rather than a
|
||||
rotation plus a position solved to place it.
|
||||
|
||||
THE CROSS IS NOT A HANDLE FOR THE PIVOT here. A peg's cross IS draggable
|
||||
(`::ui/repivot`), which is the general answer: a pivot that has to persist, be
|
||||
keyed, or sit over a measured transform is a peg, which is a node, whose pivot
|
||||
is its own origin.
|
||||
|
||||
A PEG GETS A ROSETTE INSTEAD OF A BOX, and it has to get something: it draws
|
||||
nothing, so it has no bounds to hang handles on — and the stage's other way in,
|
||||
dragging what is under the pointer, is `pick/choose` on the DRAWN ops, which
|
||||
never returns a node that draws nothing. Without this a peg could be made and
|
||||
then only be typed at in the inspector, which is not a pivot anybody would use.
|
||||
So the handles fall back to a fixed-size cluster about its own origin: the cross
|
||||
moves it, a knob turns it, four corners scale it. FIXED SIZE, in stage pixels,
|
||||
because there is no drawing to be proportional to — the same reason the ghost's
|
||||
cross is always drawn at a fixed size."
|
||||
pointerdown, which keeps a selection it lands inside."
|
||||
[ctx]
|
||||
(let [{:keys [world bounds]} @(rf/subscribe [::sub/selected-placement])
|
||||
(let [{:keys [world bounds node frame]} @(rf/subscribe [::sub/selected-placement])
|
||||
[_ _ _ path] @(rf/subscribe [::sub/selection])
|
||||
[px py] (when world
|
||||
(let [[x0 y0 x1 y1] bounds]
|
||||
(through world (if bounds
|
||||
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
|
||||
[0 0]))))
|
||||
[ax ay] (when world (:anchor (gesture/values node frame (:store (loaded ctx)))))
|
||||
[px py] (when world (through world [ax ay]))
|
||||
grab (fn [kind]
|
||||
(fn [^js event]
|
||||
(.stopPropagation event)
|
||||
|
|
@ -309,7 +262,7 @@
|
|||
(begin! ctx kind path (xy svg event (:w ctx) (:h ctx))))))]
|
||||
(when world
|
||||
[:g.handles
|
||||
(if-let [[x0 y0 x1 y1] bounds]
|
||||
(when-let [[x0 y0 x1 y1] bounds]
|
||||
(let [corners (partition 2 (through world [x0 y0 x1 y0 x1 y1 x0 y1]))
|
||||
[cx cy tx ty] (through world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2) (/ (+ x0 x1) 2) y0])
|
||||
len (max 1e-6 (js/Math.hypot (- tx cx) (- ty cy)))
|
||||
|
|
@ -321,53 +274,10 @@
|
|||
(doall
|
||||
(for [[i [x y]] (map-indexed vector corners)]
|
||||
^{:key i}
|
||||
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
|
||||
:on-pointer-down (grab :scale)}]))])
|
||||
;; A peg. Same three gestures, hung on nothing.
|
||||
(let [r 7]
|
||||
[:<>
|
||||
;; BEFORE the corners, and smaller than the ring they sit on, or it
|
||||
;; swallows the inner half of every one of them: a corner spans 5.2
|
||||
;; to 8.8 from the middle, so the disc has to stop short of 5.2.
|
||||
[:circle.peg-grab
|
||||
{:cx px :cy py :r 5
|
||||
;; ⌃/⌘ moves the PIVOT instead of the peg, which is the pan-behind
|
||||
;; split: a plain drag writes `pos` and carries the children with
|
||||
;; it, and that is a translate, not a new pivot.
|
||||
;;
|
||||
;; NOT ⌥, which this was and which does not survive the trip. Most
|
||||
;; Linux window managers grab Alt-drag to move the window, so the
|
||||
;; page never sees the pointer at all and the gesture is simply
|
||||
;; missing — on the machine it is missing from, with no error. ⌥ is
|
||||
;; still accepted for anyone whose desktop leaves it alone, and the
|
||||
;; command key is the one the rest of this stage already reaches for.
|
||||
;;
|
||||
;; ⇧ is deliberately NOT it: it means CONSTRAIN everywhere else
|
||||
;; here — uniform scale, 15° turn steps — and it is what a snap to
|
||||
;; the child's corners would want when this drag grows one.
|
||||
:on-pointer-down
|
||||
(fn [^js event]
|
||||
(.stopPropagation event)
|
||||
(.preventDefault event)
|
||||
(let [svg (.-ownerSVGElement (.-currentTarget event))
|
||||
p (xy svg event (:w ctx) (:h ctx))]
|
||||
(.setPointerCapture svg (.-pointerId event))
|
||||
(if (or (.-ctrlKey event) (.-metaKey event) (.-altKey event))
|
||||
(reset! repivot {:path path :p p})
|
||||
(begin! ctx :move path p))))}]
|
||||
[:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}]
|
||||
[:circle.knob {:cx px :cy (- py r 8) :r 2.2 :on-pointer-down (grab :turn)}]
|
||||
(doall
|
||||
(for [[i [x y]] (map-indexed vector [[(- px r) (- py r)] [(+ px r) (- py r)]
|
||||
[(+ px r) (+ py r)] [(- px r) (+ py r)]])]
|
||||
^{:key i}
|
||||
[:rect.corner {:x (- x 1.8) :y (- y 1.8) :width 3.6 :height 3.6
|
||||
:on-pointer-down (grab :scale)}]))]))
|
||||
;; Where the pivot is going, while it is being put there.
|
||||
(let [[cx cy] (or (:p @repivot) [px py])]
|
||||
[:path.pivot {:class (when @repivot "moving")
|
||||
:d (str "M " (- cx 4) " " cy " H " (+ cx 4)
|
||||
" M " cx " " (- cy 4) " V " (+ cy 4))}])])))
|
||||
[:path.pivot {:d (str "M " (- px 3) " " py " H " (+ px 3)
|
||||
" M " px " " (- py 3) " V " (+ py 3))}]])))
|
||||
|
||||
(defn- group-handles [ctx placements]
|
||||
(let [boxes (keep stage-bounds placements)]
|
||||
|
|
@ -642,10 +552,7 @@
|
|||
(do (.setPointerCapture svg (.-pointerId event))
|
||||
(stroke-begin! ctx tool p size tone event))
|
||||
|
||||
;; WITH THE SELECTION, so a shape the hit-test treats as light
|
||||
;; or as a hole — a palette swapper, a knockout — can be
|
||||
;; dragged by the one you are holding. See `pick/hit-op`.
|
||||
(let [path (pick/choose selected (player/at p selected) (.-altKey event))]
|
||||
(let [path (pick/choose selected (player/at p) (.-altKey event))]
|
||||
(when (and path (not (.-shiftKey event)))
|
||||
(reset! selection-anchor {:clip-id clip-id :open (:open ctx) :path path}))
|
||||
(cond
|
||||
|
|
@ -681,7 +588,7 @@
|
|||
:on-double-click (fn [^js event]
|
||||
(when (= :select tool)
|
||||
(let [p (xy (.-currentTarget event) event w h)
|
||||
hit (player/at p selected)
|
||||
hit (player/at p)
|
||||
path (pick/deeper selected hit)]
|
||||
(cond
|
||||
(not= path selected) (select! ctx path)
|
||||
|
|
@ -706,16 +613,11 @@
|
|||
@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])))
|
||||
@repivot (swap! repivot assoc :p p)
|
||||
@marquee (swap! marquee assoc :p p)
|
||||
@gesture (drag! p event))))
|
||||
:on-pointer-up (fn [_]
|
||||
(reset! dragging nil)
|
||||
(stroke-end!)
|
||||
;; One edit, at the end: see `::ui/repivot`.
|
||||
(when-let [{:keys [path p]} @repivot]
|
||||
(reset! repivot nil)
|
||||
(rf/dispatch [::ui/repivot path p]))
|
||||
(if-let [{:keys [p0 p more?]} @marquee]
|
||||
(let [depth (or (some-> selected count) 1)
|
||||
paths (player/in-rect [(first p0) (second p0)
|
||||
|
|
|
|||
|
|
@ -58,11 +58,6 @@
|
|||
selections @(rf/subscribe [::sub/selections])
|
||||
clipboard @(rf/subscribe [::sub/clipboard])
|
||||
selected? (boolean (seq selections))
|
||||
;; A peg goes over ONE node: it takes that node's place in the
|
||||
;; hierarchy, and there is no one place a pair of them would go.
|
||||
one-node (when (= 1 (count selections))
|
||||
(let [[kind _ _ path] (first selections)]
|
||||
(when (and (= :node kind) (seq path)) path)))
|
||||
title (or project-name "untitled")
|
||||
commit! (fn []
|
||||
(rf/dispatch [::project/rename @draft])
|
||||
|
|
@ -90,14 +85,7 @@
|
|||
{:label "duplicate unique"
|
||||
:sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D"
|
||||
:disabled? (not selected?)
|
||||
:on-click #(rf/dispatch [::ui/duplicate-unique])}
|
||||
;; The only way to keep a pivot, and the only way to transform a
|
||||
;; measured part by hand — which is what `gesture/refusal` tells
|
||||
;; you to do, so it has to be reachable from somewhere.
|
||||
{:label "add peg"
|
||||
:sub "A transform node over the selection · ⌃/⌘-drag its cross to place the pivot"
|
||||
:disabled? (nil? one-node)
|
||||
:on-click #(rf/dispatch [::ui/peg one-node])}]}]
|
||||
:on-click #(rf/dispatch [::ui/duplicate-unique])}]}]
|
||||
[snapshots/view]
|
||||
(if @renaming?
|
||||
[:input.project-name {:auto-focus true :value @draft
|
||||
|
|
|
|||
|
|
@ -12,19 +12,6 @@
|
|||
(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))))))
|
||||
|
||||
(defn- placed
|
||||
"Node `id`'s drawing on frame 0 back in the space it was drawn in: its own
|
||||
points plus its `pos`.
|
||||
|
||||
A shape's points are about its OWN ORIGIN and its `pos` says where that origin
|
||||
is, so the ring to rasterise in stage pixels is the sum — `paint/centred`. The
|
||||
cut is still checked in the pixels it was made in; it is read back out through
|
||||
the transform the shape now has, rather than by assuming it has none."
|
||||
[nodes id]
|
||||
(let [[cx cy] (channel/value-at (get-in nodes [id :channels [:xform :pos]]) 0 nil)]
|
||||
(into [] (map-indexed (fn [i v] (+ v (if (even? i) cx cy))))
|
||||
(channel/value-at (get-in nodes [id :channels paint/geometry]) 0 nil))))
|
||||
|
||||
(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))
|
||||
|
|
@ -59,13 +46,10 @@
|
|||
(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 #(placed 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))
|
||||
"the offcut is a new shape of its own, and is where it was cut from — its
|
||||
points come back through the space its `pos` lives in, not through the
|
||||
transform of the shape it was cut out of")
|
||||
(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]))
|
||||
|
|
@ -76,5 +60,4 @@
|
|||
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 (= (first (paint/centred square)) (ks 0) (ks 10))
|
||||
"the keys either side keep the shape's own points, untouched")))
|
||||
(is (= square (ks 0) (ks 10)))))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
(clip/place-symbol nil :mid :box 2 v nil)
|
||||
(turn :main u [40 20] (/ js/Math.PI 2) [2 2])
|
||||
(turn :mid v [5 -3] 0.3 [1.5 0.5])
|
||||
(paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow))))
|
||||
(paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow)
|
||||
(assoc-in [:symbols :box :nodes :shape :channels [:xform :anchor]] (ch/framed [5 3])))))
|
||||
|
||||
(defn- drawn [c path]
|
||||
(partition 2 (take 6 (array-seq (:pts (first (filter #(= path (:node %))
|
||||
|
|
@ -37,25 +38,15 @@
|
|||
|
||||
(defn- near? [a b] (every? #(< (js/Math.abs %) 1e-9) (map - (flatten a) (flatten b))))
|
||||
|
||||
(defn- pivot-of
|
||||
"The parent-space pivot the stage would derive for this node at this frame —
|
||||
`begin!`'s one line, so a test drags about the same point the stage does."
|
||||
[c st sid id frame v0]
|
||||
(gesture/pivot v0 ((pick/bounds-of c st sid (get-in c [:symbols sid :nodes id])) frame)))
|
||||
|
||||
(defn- at [m p] (let [out (js/Float64Array. 2)]
|
||||
(vec (array-seq (node/apply-pt! out 0 m (first p) (second p))))))
|
||||
|
||||
(defn- dragged [c path f vs-fn]
|
||||
(let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16)
|
||||
v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame nil)]
|
||||
(gesture/apply-values c sid id frame
|
||||
(vs-fn pl v0 (pivot-of c nil sid id frame v0)))))
|
||||
(gesture/apply-values c sid id frame (vs-fn pl v0))))
|
||||
|
||||
(deftest a-shape-two-symbols-down-moves-under-the-pointer
|
||||
(doseq [path [[u] [u v] [u v :shape]]]
|
||||
(let [c (two-down)
|
||||
moved (dragged c path 16 (fn [pl v0 _] (gesture/move pl v0 [30 30] [37 26])))]
|
||||
moved (dragged c path 16 #(gesture/move %1 %2 [30 30] [37 26]))]
|
||||
(is (near? (map (fn [[x y]] [(+ x 7) (- y 4)]) (drawn c [u v :shape]))
|
||||
(drawn moved [u v :shape]))
|
||||
(str "moving " path " by (7, -4) on the stage moves the shape by (7, -4)")))))
|
||||
|
|
@ -74,56 +65,15 @@
|
|||
(get-in out [:symbols :main :nodes :shape :channels [:xform :pos] :interp])))))
|
||||
|
||||
(deftest turning-keeps-the-pivot-where-it-is
|
||||
;; The shape was drawn at [0 0 10 0 5 10], so the middle of what it draws is
|
||||
;; (5, 5) of the space it was drawn in — and `paint/centred` made that the
|
||||
;; node's OWN ORIGIN, which is (0, 0) in its own coordinates. So the pivot is
|
||||
;; the origin, and the turn is one channel: there is no `pos` to solve for,
|
||||
;; because `node/local!` already turns about exactly this point.
|
||||
(let [c (two-down)
|
||||
path [u v :shape]
|
||||
{:keys [world]} (nest/placement c nil :main path 16)
|
||||
pivot #(let [out (js/Float64Array. 2)]
|
||||
(vec (array-seq (node/apply-pt! out 0 (:world (nest/placement % nil :main path 16)) 0 0))))
|
||||
turned (dragged c path 16 #(gesture/turn %2 %3 0.7))]
|
||||
(vec (array-seq (node/apply-pt! out 0 (:world (nest/placement % nil :main path 16)) 5 3))))
|
||||
turned (dragged c path 16 #(gesture/turn %2 0.7))]
|
||||
(is (some? world))
|
||||
(is (near? (pivot c) (pivot turned)) "the middle of the drawing stays put")
|
||||
(is (not (near? (drawn c path) (drawn turned path))) "and the rest goes round it")
|
||||
(is (= #{[:xform :rot]} (set (keys (gesture/turn (gesture/values
|
||||
(get-in c [:symbols :box :nodes :shape]) 4 nil)
|
||||
(pivot-of c nil :box :shape 4
|
||||
(gesture/values
|
||||
(get-in c [:symbols :box :nodes :shape]) 4 nil))
|
||||
0.7))))
|
||||
"a turn about the node's own origin writes rot ALONE — a pos written
|
||||
beside it would be solved for this frame and interpolated along the
|
||||
chord of an arc between keys, which is a keyed spin leaving the stage")))
|
||||
|
||||
(deftest a-keyed-turn-holds-its-pivot-between-its-keys
|
||||
;; THE BUG, as it was reported: a shape keyed from the bottom left to the top
|
||||
;; centre with a 360° turn on the way flew right off the stage in the middle of
|
||||
;; the spin and came back. 0° and 360° are the two frames where a wrong pivot
|
||||
;; cannot be seen, so the keys looked right and every frame between them did
|
||||
;; not. Here the whole tween is checked, which is the only way this is caught.
|
||||
(let [drawn-at [100 80 140 80 140 110 100 110]
|
||||
c (-> (clip/blank)
|
||||
(paint/new-shape :main :shape 0 drawn-at :brow)
|
||||
;; Keyed by hand, as the inspector keys: bottom left to top
|
||||
;; centre, one full turn on the way.
|
||||
(assoc-in [:symbols :main :nodes :shape :channels [:xform :pos]]
|
||||
(ch/keyed {0 [60 150] 30 [160 40]} :linear))
|
||||
(assoc-in [:symbols :main :nodes :shape :channels [:xform :rot]]
|
||||
(ch/keyed {0 0 30 (* 2 js/Math.PI)} :linear)))
|
||||
middle (fn [f]
|
||||
(let [{:keys [world]} (nest/placement c nil :main [:shape] f)
|
||||
[x0 y0 x1 y1] ((pick/bounds-of c nil :main
|
||||
(get-in c [:symbols :main :nodes :shape])) f)]
|
||||
(at world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])))]
|
||||
(doseq [f (range 0 31)]
|
||||
(let [[x y] (middle f)
|
||||
t (/ f 30)]
|
||||
(is (near? [x y] [(+ 60 (* t 100)) (- 150 (* t 110))])
|
||||
(str "frame " f ": the middle of the drawing is at " (pr-str [x y])
|
||||
", not on the straight line from (60, 150) to (160, 40)"))))))
|
||||
(is (near? (pivot c) (pivot turned)) "the anchor stays put")
|
||||
(is (not (near? (drawn c path) (drawn turned path))) "and the rest goes round it")))
|
||||
|
||||
(deftest scaling-takes-the-grabbed-point-to-the-pointer
|
||||
(let [c (two-down)
|
||||
|
|
@ -131,45 +81,39 @@
|
|||
{:keys [world]} (nest/placement c nil :main path 16)
|
||||
out (js/Float64Array. 2)
|
||||
at #(vec (array-seq (node/apply-pt! out 0 %1 %2 %3)))
|
||||
;; (5, -5) of the shape's own coordinates is the point drawn at (10, 0):
|
||||
;; the ring is centred, so what was drawn is offset by its own middle.
|
||||
p0 (at world 5 -5)
|
||||
p0 (at world 10 0)
|
||||
p1 [(+ (first p0) 3) (- (second p0) 5)]
|
||||
grown (dragged c path 16 #(gesture/scale %1 %2 %3 p0 p1 false))
|
||||
grown (dragged c path 16 #(gesture/scale %1 %2 p0 p1 false))
|
||||
w2 (:world (nest/placement grown nil :main path 16))]
|
||||
(is (near? p1 (at w2 5 -5))
|
||||
(is (near? p1 (at w2 10 0))
|
||||
"the corner grabbed is under the pointer, through a turned, unevenly scaled parent")
|
||||
(is (near? (at world 0 0) (at w2 0 0)) "about the middle of the drawing, which is its origin")))
|
||||
(is (near? (at world 5 3) (at w2 5 3)) "about the pivot")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; which way a corner drag goes
|
||||
;;
|
||||
;; `scale` takes the point under the pointer to the pointer, about the node's
|
||||
;; PIVOT, and that is the whole of it — so which way a corner drag goes is
|
||||
;; decided entirely by where the pivot is. In the middle of what the node draws,
|
||||
;; which is where `gesture/pivot` derives it, pulling a corner away from the
|
||||
;; middle makes the node bigger. At the node's coordinate ORIGIN — which is what
|
||||
;; a node whose stored anchor had never been written got, and for head-local
|
||||
;; geometry is the top-left corner of the FOOTAGE — every corner drag is a drag
|
||||
;; away from a point off in the corner: the shape shrinks and slides while the
|
||||
;; corner dutifully follows the pointer, which is what the bug looked like from
|
||||
;; the outside.
|
||||
;;
|
||||
;; None of it is stored any more, so none of it can be absent or stale. These
|
||||
;; tests used to need a freeze pass to have written the pivots they check.
|
||||
;; decided entirely by where the pivot is. With it in the middle of what the node
|
||||
;; draws, where `clip/place-symbol`, `paint/new-shape` and now `flow/freeze` all
|
||||
;; put it, pulling a corner away from the middle makes the node bigger. With it
|
||||
;; at the node's coordinate ORIGIN, which is what a node with no anchor gets,
|
||||
;; every corner drag is a drag away from some point off in the corner of the
|
||||
;; footage: the shape shrinks and slides while the corner dutifully follows the
|
||||
;; pointer, which is what the bug looked like from the outside.
|
||||
|
||||
(defn- at [m p] (let [out (js/Float64Array. 2)]
|
||||
(vec (array-seq (node/apply-pt! out 0 m (first p) (second p))))))
|
||||
|
||||
(defn- handles
|
||||
"Where the stage would draw this node's box and pivot: its own bounds through
|
||||
its `:world`, and the MIDDLE OF THOSE BOUNDS, which is what `ui/stage`'s
|
||||
`handles` does. One `bounds-of` feeding both is the point — the cross cannot
|
||||
drift off the box, because it is the box's own middle. On a drawing it is also
|
||||
the node's own origin, because that is where `paint/centred` put it."
|
||||
"Where the stage would draw this node's box and pivot: its own bounds and
|
||||
anchor through its `:world`, which is what `ui/stage`'s `handles` does."
|
||||
[c st open path f]
|
||||
(let [{:keys [sid id world frame]} (nest/placement c st open path f)
|
||||
n (get-in c [:symbols sid :nodes id])
|
||||
[x0 y0 x1 y1] ((pick/bounds-of c st sid n) frame)]
|
||||
{:corners (mapv #(at world %) [[x0 y0] [x1 y0] [x1 y1] [x0 y1]])
|
||||
:pivot (at world [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])}))
|
||||
:pivot (at world (:anchor (gesture/values n frame st)))}))
|
||||
|
||||
(defn- span
|
||||
"How big the box is, as the length of its diagonals — which does not care that
|
||||
|
|
@ -185,10 +129,9 @@
|
|||
[c st open path f i d]
|
||||
(let [{:keys [sid id frame] :as pl} (nest/placement c st open path f)
|
||||
v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame st)
|
||||
c* (pivot-of c st sid id frame v0)
|
||||
p0 (nth (:corners (handles c st open path f)) i)
|
||||
p1 (mapv + p0 d)]
|
||||
{:clip (gesture/apply-values c sid id frame (gesture/scale pl v0 c* p0 p1 false) false st)
|
||||
{:clip (gesture/apply-values c sid id frame (gesture/scale pl v0 p0 p1 false))
|
||||
:p0 p0 :p1 p1}))
|
||||
|
||||
(defn- away
|
||||
|
|
@ -198,13 +141,22 @@
|
|||
len (max 1e-9 (js/Math.hypot dx dy))]
|
||||
[(* k (/ dx len)) (* k (/ dy len))]))
|
||||
|
||||
(defn- middled
|
||||
"`two-down` with the shape pivoting about the middle of what it draws — which
|
||||
is what `paint/new-shape` writes and what `two-down` deliberately moves off, so
|
||||
that the rest of this file is not accidentally testing the easy case."
|
||||
[]
|
||||
(let [c (two-down)]
|
||||
(assoc-in c [:symbols :box :nodes :shape :channels [:xform :anchor]]
|
||||
(ch/framed (pick/pivot c nil :box (get-in c [:symbols :box :nodes :shape]) [4])))))
|
||||
|
||||
(deftest dragging-a-corner-away-from-the-middle-makes-it-bigger
|
||||
(doseq [[what c path f]
|
||||
[["a shape on its own"
|
||||
(paint/new-shape (clip/blank) :main :shape 4 [100 80 140 80 140 110 100 110] :brow)
|
||||
[:shape] 4]
|
||||
["a shape two symbols down, through a turned and unevenly scaled parent"
|
||||
(two-down) [u v :shape] 16]]
|
||||
(middled) [u v :shape] 16]]
|
||||
i (range 4)]
|
||||
(let [before (handles c nil :main path f)
|
||||
out (corner-drag c nil :main path f i (away before i 6))
|
||||
|
|
@ -238,12 +190,9 @@
|
|||
(deftest a-face-part-scales-about-its-own-middle
|
||||
;; The whole measured rig, from `demo/take`: source-space geometry under a
|
||||
;; dense head similarity, under the authored source placement, inside an
|
||||
;; instance. The mouth's pivot used to sit at (-234, -395) on a 320x200 stage —
|
||||
;; the top-left corner of the FOOTAGE, carried onto the stage — because its
|
||||
;; stored anchor was never written: `freeze` refused to write one onto anything
|
||||
;; measured, and rightly, since an anchor under a measured scale does not cancel
|
||||
;; out. Deriving it removes the refusal along with the field. There is nothing
|
||||
;; to write, so there is no node a pivot can be missing from.
|
||||
;; instance. Before `freeze/pivoted` the mouth's pivot sat at (-234, -395) on a
|
||||
;; 320x200 stage — the top-left corner of the FOOTAGE, carried onto the stage —
|
||||
;; and every corner drag was a drag away from it.
|
||||
(let [{c :clip st :store} @take/frozen
|
||||
f 10]
|
||||
(doseq [path [[:face-1] [:face-1 :mouth] [:face-1 :eye-r]]]
|
||||
|
|
@ -276,7 +225,7 @@
|
|||
(let [v (gesture/values n 10 st)]
|
||||
(is (map? v) (str sid "/" id " could not be read at all"))
|
||||
(is (every? #(or (number? %) (ch/nothing? %))
|
||||
(concat (:pos v) (:scale v) (:skew v) [(:rot v)]))
|
||||
(concat (:pos v) (:scale v) (:anchor v) [(:rot v)]))
|
||||
(str sid "/" id " did not read as numbers: " (pr-str v))))
|
||||
(when (node/measured? n)
|
||||
(is (thrown? js/Error (gesture/values n 10 nil))
|
||||
|
|
@ -294,8 +243,8 @@
|
|||
;; jumps on the first pointermove. Two drags have to compose.
|
||||
(let [c (two-down)
|
||||
path [u v :shape]
|
||||
once (dragged c path 16 (fn [pl v0 _] (gesture/move pl v0 [30 30] [37 26])))
|
||||
twice (dragged once path 16 (fn [pl v0 _] (gesture/move pl v0 [30 30] [33 31])))
|
||||
once (dragged c path 16 #(gesture/move %1 %2 [30 30] [37 26]))
|
||||
twice (dragged once path 16 #(gesture/move %1 %2 [30 30] [33 31]))
|
||||
;; The same second drag, begun from the clip as it was BEFORE the first.
|
||||
stale (let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16)
|
||||
v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame nil)]
|
||||
|
|
@ -311,9 +260,9 @@
|
|||
(deftest a-drag-keys-a-keyed-channel-and-sets-a-framed-one
|
||||
(let [c (update-in (two-down) [:symbols :mid :nodes v :channels [:xform :pos]]
|
||||
(constantly (ch/keyed {0 [5 -3] 20 [9 -3]} :linear)))
|
||||
moved (dragged c [u v] 16 (fn [pl v0 _] (gesture/move pl v0 [0 0] [4 0])))
|
||||
moved (dragged c [u v] 16 #(gesture/move %1 %2 [0 0] [4 0]))
|
||||
pos (get-in moved [:symbols :mid :nodes v :channels [:xform :pos]])
|
||||
rot (dragged c [u v] 16 #(gesture/turn %2 %3 0.1))]
|
||||
rot (dragged c [u v] 16 #(gesture/turn %2 0.1))]
|
||||
(is (= #{0 4 20} (set (keys (:keys pos))))
|
||||
"a key on the instance's own frame — 16 of main is 6 of mid, 4 of its own — and the others kept")
|
||||
(is (= {:animated? false :value 0.4}
|
||||
|
|
@ -379,7 +328,5 @@
|
|||
(deftest an-instances-box-is-what-its-symbol-draws
|
||||
(let [c (two-down)
|
||||
{:keys [frame]} (nest/placement c nil :main [u v] 16)]
|
||||
(is (= [0 0 10 10] ((pick/bounds-of c nil :mid (get-in c [:symbols :mid :nodes v])) frame))
|
||||
"an instance's box is what its symbol draws, in the symbol's space")
|
||||
(is (= [-5 -5 5 5] ((pick/bounds-of c nil :box (get-in c [:symbols :box :nodes :shape])) 4))
|
||||
"and the shape's own box is about its own origin, which is its middle")))
|
||||
(is (= [0 0 10 10] ((pick/bounds-of c nil :mid (get-in c [:symbols :mid :nodes v])) frame)))
|
||||
(is (= [0 0 10 10] ((pick/bounds-of c nil :box (get-in c [:symbols :box :nodes :shape])) 4)))))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
[arthur.demo.stage :as stage]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
|
|
@ -151,14 +150,6 @@
|
|||
[document id]
|
||||
(get-in document [:symbols :main :nodes (uuid-of id)]))
|
||||
|
||||
(defn- peg
|
||||
"The PEG of the placement the layout calls `id`: the transform node the face
|
||||
hangs off, which carries where and when it sits on the stage."
|
||||
[document id]
|
||||
(get-in document [:symbols :main :nodes
|
||||
(->> (:instances stage/layout)
|
||||
(some (fn [p] (when (= id (:id p)) (:peg p)))))]))
|
||||
|
||||
(deftest stage-fixture-keeps-source-as-one-symbol
|
||||
(let [document (stage/compose source)]
|
||||
(is (empty? (clip/problems document)))
|
||||
|
|
@ -180,41 +171,25 @@
|
|||
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
|
||||
(is (= 7 (count (filter #(= :instance (:kind %))
|
||||
(vals (get-in document [:symbols :main :nodes]))))))
|
||||
(is (= [0 232] (:span (peg document :right))) "its own frames, from its own 0")
|
||||
(is (= [48 280] (node/placed-span (peg document :right))) "and where that sits on the stage")
|
||||
(is (= (uuid-of :right) (:id (placement document :right))))
|
||||
(is (= (:id (peg document :right)) (:parent (placement document :right)))
|
||||
"the face hangs off its peg")
|
||||
(testing "a keyed scale happens about the pinned point, and that is the peg"
|
||||
;; THE CASE THAT MAKES A PEG NECESSARY. `:scale` is keyed — the faces pulse
|
||||
;; — and the source's middle has to stay on the authored `:center` through
|
||||
;; all of it. A stored anchor used to buy that with a static position; with
|
||||
;; `T(pos)·S(k(f))` alone it would take a `pos` keyed in lockstep with
|
||||
;; `scale`, two channels obliged to agree frame for frame. The peg needs
|
||||
;; neither: it holds `center` and the keyed scale, the face holds `-origin`,
|
||||
;; and `T(center)·S(k)·T(-origin)` takes origin to center for EVERY k.
|
||||
(let [pg (peg document :left)
|
||||
face (placement document :left)
|
||||
scale (get-in pg [:channels [:xform :scale]])
|
||||
pos (get-in pg [:channels [:xform :pos]])
|
||||
off (get-in face [:channels [:xform :pos] :value])]
|
||||
(is (= [-160 -100] off) "the face is offset to its own origin, and that is all")
|
||||
(is (nil? (get-in face [:channels [:xform :scale]])) "the scale is the peg's")
|
||||
(is (= [40 40] (ch/value-at pos 0 nil)) "the peg sits on the authored center")
|
||||
(is (not= (ch/value-at pos 0 nil) (ch/value-at pos 40 nil)) "and drifts during playback")
|
||||
(is (= [0.4 0.4] (ch/value-at scale 0 nil)))
|
||||
(is (= [0.56 0.56] (ch/value-at scale 12 nil)))
|
||||
(is (= [0.52 0.52] (ch/value-at scale 48 nil)))
|
||||
(doseq [f [0 12 48]]
|
||||
(let [k (ch/value-at scale f nil)
|
||||
;; peg · face, composed as the evaluator does
|
||||
m (node/mul! (node/mat)
|
||||
(node/local! (node/mat) (ch/value-at pos 0 nil) 0 k [0 0])
|
||||
(node/local! (node/mat) off 0 [1 1] [0 0]))
|
||||
out (js/Float64Array. 2)]
|
||||
(node/apply-pt! out 0 m 160 100)
|
||||
(is (= [40 40] [(aget out 0) (aget out 1)])
|
||||
(str "f" f ": the face center stays put while it scales, at k=" (pr-str k)))))))
|
||||
(is (= [0 232] (:span (placement document :right))) "its own frames, from its own 0")
|
||||
(is (= [48 280] (node/placed-span (placement document :right))) "and where that sits on the stage")
|
||||
(let [left (placement document :left)
|
||||
scale (get-in left [:channels [:xform :scale]])
|
||||
anchor (get-in left [:channels [:xform :anchor] :value])
|
||||
pos (get-in left [:channels [:xform :pos]])
|
||||
start-pos (ch/value-at pos 0 nil)]
|
||||
(is (= [160 100] anchor) "the source center becomes a stored pivot")
|
||||
(is (= [-120 -60] start-pos))
|
||||
(is (not= start-pos (ch/value-at pos 40 nil)) "the face drifts during playback")
|
||||
(is (= [0.4 0.4] (ch/value-at scale 0 nil)))
|
||||
(is (= [0.56 0.56] (ch/value-at scale 12 nil)))
|
||||
(is (= [0.52 0.52] (ch/value-at scale 48 nil)))
|
||||
(doseq [f [0 12 48]]
|
||||
(let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f nil) [0 0] anchor)
|
||||
out (js/Float64Array. 2)]
|
||||
(node/apply-pt! out 0 m 160 100)
|
||||
(is (= [40 40] [(aget out 0) (aget out 1)])
|
||||
"the face center stays put while it scales"))))
|
||||
(testing "the editorial link resolves to the placement's uuid"
|
||||
;; The EDN names `:right`; the document must carry the identity, or the link
|
||||
;; dangles the moment anything is renamed. `clip/problems` above checks it
|
||||
|
|
@ -358,34 +333,18 @@
|
|||
[:symbols :main :nodes u :channels]))]
|
||||
(is (= [25 35] (clip/center c nil :box)) "the middle of the square")
|
||||
(is (= [160 100] (clip/center c nil :empty)) "nothing drawn: the stage's middle")
|
||||
(testing "a placement stores a position and no pivot at all"
|
||||
(is (= [[:xform :pos]] (keys (placed c :box nil))) "one channel, and it is where it sits")
|
||||
(testing "the anchor is the middle, and it moves nothing at the identity"
|
||||
(is (= [25 35] (get-in (placed c :box nil) [[:xform :anchor] :value])))
|
||||
(is (= [0 0] (get-in (placed c :box nil) [[:xform :pos] :value]))
|
||||
"dropped on the timeline: where it was drawn"))
|
||||
(testing "dropped on a stage pixel, its middle goes there"
|
||||
(is (= [75 65] (get-in (placed c :box [100 100]) [[:xform :pos] :value]))))
|
||||
(testing "growing the symbol later moves where its instances pivot, and moves nothing on screen"
|
||||
;; THE BUG, INVERTED. This used to assert the opposite — that the instance
|
||||
;; kept pivoting about where the symbol's drawing had been — because the
|
||||
;; middle was copied into a stored anchor at drop time and nothing ever
|
||||
;; invalidated it. A pivot derived per drag follows the drawing instead, and
|
||||
;; it cannot move anything on screen by doing so: there is no stored value
|
||||
;; for the composition to read, so adding `sq2` changes the pivot and not
|
||||
;; one pixel of the picture.
|
||||
(let [c (clip/place-symbol c nil :main :box 0 u nil)
|
||||
grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2"))
|
||||
;; what `gesture/pivot` derives for the instance, before and after
|
||||
pivot-of (fn [doc]
|
||||
(let [n (get-in doc [:symbols :main :nodes u])]
|
||||
(gesture/pivot (gesture/values n 0 nil)
|
||||
((pick/bounds-of doc nil :main n) 0))))]
|
||||
(is (= [25 35] (clip/center c nil :box)) "the symbol's middle")
|
||||
(is (= [55 35] (clip/center grown nil :box)) "and it moved when the symbol grew")
|
||||
(is (= [25 35] (pivot-of c)))
|
||||
(is (= [55 35] (pivot-of grown)) "the instance's pivot followed the drawing")
|
||||
(is (= (get-in c [:symbols :main :nodes u :channels])
|
||||
(get-in grown [:symbols :main :nodes u :channels]))
|
||||
"and not one channel of the instance changed, so nothing on screen moved")))))
|
||||
(testing "and growing the symbol later does not move an instance's pivot"
|
||||
(let [c (clip/place-symbol c nil :main :box 0 u nil)
|
||||
grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2"))]
|
||||
(is (= [55 35] (clip/center grown nil :box)) "the symbol's middle moved")
|
||||
(is (= [25 35] (get-in grown [:symbols :main :nodes u :channels [:xform :anchor] :value]))
|
||||
"the instance's did not")))))
|
||||
|
||||
(deftest palette-context-is-inherited-keyed-and-overridable
|
||||
(let [palette (fn [id name a b]
|
||||
|
|
@ -555,14 +514,7 @@
|
|||
(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])))
|
||||
;; Reaching past a hole is one thing; the hole you are holding is another.
|
||||
;; Without this there is no point on the stage a selected knockout answers,
|
||||
;; so the box `handles` draws round it has nothing to drag it by.
|
||||
(is (= [:glasses :glass] (pick/hit ops [15 10] [:glasses :glass]))
|
||||
"unless the hole is what is selected, which is how it is dragged")
|
||||
(is (= [:under] (pick/hit ops [15 10] [:under]))
|
||||
"something else being selected does not make the hole solid")))
|
||||
(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]}
|
||||
|
|
@ -602,10 +554,4 @@
|
|||
(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")
|
||||
(is (= [:beam] (pick/hit ops [7 10] [:beam]))
|
||||
"unless the light is what is selected, which is how it is dragged")
|
||||
(is (= [:wall] (pick/hit ops [7 10] [:door]))
|
||||
"something else being selected does not make the light solid")
|
||||
(is (= [[:wall] [:beam]] (pick/in-rect ops [4 4 6 6] 1))
|
||||
"and a marquee reaches it, where a click goes through it")))
|
||||
(is (= [:wall] (pick/hit ops [7 10])) "a click goes through the light")))
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.demo.take :as take]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.paint :as paint]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]))
|
||||
[arthur.domain.palette :as pal]))
|
||||
|
||||
(defn- nested
|
||||
"Three symbols: :outer places :inner, and :loose is placed by nothing."
|
||||
|
|
@ -85,11 +82,8 @@
|
|||
((clip/resolver % :main nil pal/index-of nil) 16))))))
|
||||
{:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16)
|
||||
out (js/Float64Array. 2)
|
||||
;; THE SHAPE'S OWN COORDINATES, which is the centred ring `new-shape`
|
||||
;; stored and not the points handed to it: a drawing's origin is the
|
||||
;; middle of what it draws, and `matrix` is the map out of that space.
|
||||
seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y))))
|
||||
(partition 2 (first (paint/centred [0 0 10 0 5 10]))))
|
||||
(partition 2 [0 0 10 0 5 10]))
|
||||
[x y] (array-seq (node/apply-pt! out 0 (node/invert matrix) 7 3))
|
||||
moved (paint/set-vertex c :box :shape frame 0 [x y])
|
||||
keyed (paint/add-key c :box :shape (:frame (nest/inside c nil :main [u v :shape] 20)))]
|
||||
|
|
@ -389,149 +383,3 @@
|
|||
(is (nil? (get-in slid [:symbols :lane :nodes :a]))
|
||||
"a fully covered neighbor is removed")
|
||||
(is (empty? (clip/problems slid)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; pegs
|
||||
;;
|
||||
;; What `[:xform :anchor]` used to be, as a node. These assert the three things
|
||||
;; it is for, and the one invariant that has to hold whatever it is for: NOTHING
|
||||
;; MOVES when a peg appears.
|
||||
|
||||
(def ^:private pg #uuid "00000000-0000-4000-8000-0000000000f1")
|
||||
|
||||
(defn- shaped
|
||||
"A shape at a known place, turned and scaled, so that `:pinv` has real work to
|
||||
do rather than cancelling against an identity."
|
||||
[]
|
||||
(-> (clip/blank)
|
||||
(paint/new-shape :main :shape 0 [0 0 10 0 5 10] :brow)
|
||||
(update-in [:symbols :main :nodes :shape :channels] merge
|
||||
{[:xform :pos] (ch/framed [40 25])
|
||||
[:xform :rot] (ch/framed 0.4)
|
||||
[:xform :scale] (ch/framed [1.5 0.75])})))
|
||||
|
||||
(defn- drawn-at
|
||||
"Every drawn point on frame `f`, flat. `st` because a measured take's geometry
|
||||
is dense and a dense channel read without its tier-2 store throws."
|
||||
([c f] (drawn-at c nil f))
|
||||
([c st f]
|
||||
(->> ((clip/resolver c :main st pal/index-of nil) f)
|
||||
(filter #(= :poly (:kind %)))
|
||||
(mapcat #(array-seq (:pts %)))
|
||||
vec)))
|
||||
|
||||
(defn- near? [a b] (and (= (count a) (count b))
|
||||
(every? #(< (js/Math.abs %) 1e-9) (map - a b))))
|
||||
|
||||
(deftest a-peg-appears-over-a-node-and-moves-nothing
|
||||
(let [c (shaped)
|
||||
r (nest/peg c nil :main [:shape] 0 pg)
|
||||
out (:clip r)]
|
||||
(is (nil? (:refused r)) (:refused r))
|
||||
(is (= :group (get-in out [:symbols :main :nodes pg :kind])) "a peg is an ordinary group")
|
||||
(is (= pg (get-in out [:symbols :main :nodes :shape :parent])) "and the node hangs off it")
|
||||
(is (= (get-in c [:symbols :main :nodes :shape :channels])
|
||||
(get-in out [:symbols :main :nodes :shape :channels]))
|
||||
"the node's channels are untouched — which is what lets this work on a measured one")
|
||||
(is (near? (drawn-at c 0) (drawn-at out 0))
|
||||
"and nothing moved: that is `:pinv`'s whole job")
|
||||
(is (empty? (clip/problems out)))))
|
||||
|
||||
(deftest a-peg-sits-on-the-pivot-so-it-turns-about-the-same-point
|
||||
;; The peg lands where the cross was, so grabbing it turns about exactly the
|
||||
;; point a drag on the node would have. That is what makes it a REPLACEMENT for
|
||||
;; the pivot rather than a second, differently-placed one.
|
||||
(let [c (shaped)
|
||||
n (get-in c [:symbols :main :nodes :shape])
|
||||
was (gesture/pivot (gesture/values n 0 nil)
|
||||
((pick/bounds-of c nil :main n) 0))
|
||||
out (:clip (nest/peg c nil :main [:shape] 0 pg))
|
||||
peg (get-in out [:symbols :main :nodes pg])]
|
||||
(is (near? was (:value (get-in peg [:channels [:xform :pos]])))
|
||||
"the peg's position IS the pivot")
|
||||
;; And a peg draws nothing, so its own derived pivot is its own origin —
|
||||
;; which is that same point. Toon Boom's rule, falling out of one sentence.
|
||||
(is (near? was (gesture/pivot (gesture/values peg 0 nil)
|
||||
((pick/bounds-of out nil :main peg) 0)))
|
||||
"so the peg turns about where it was put")))
|
||||
|
||||
(deftest turning-a-peg-turns-its-child-about-that-point
|
||||
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
pl (nest/placement c nil :main [pg] 0)
|
||||
v0 (gesture/values (get-in c [:symbols :main :nodes pg]) 0 nil)
|
||||
piv (gesture/pivot v0 nil)
|
||||
out (gesture/apply-values c :main pg 0 (gesture/turn v0 piv 0.6))
|
||||
;; every drawn point's distance from the pivot, before and after
|
||||
radii (fn [doc]
|
||||
(let [[px py] (:value (get-in c [:symbols :main :nodes pg
|
||||
:channels [:xform :pos]]))]
|
||||
(map (fn [[x y]] (js/Math.hypot (- x px) (- y py)))
|
||||
(partition 2 (drawn-at doc 0)))))]
|
||||
(is (some? pl))
|
||||
(is (not (near? (drawn-at c 0) (drawn-at out 0))) "the child moved")
|
||||
(is (near? (vec (radii c)) (vec (radii out)))
|
||||
"and every point kept its distance from the peg — so it TURNED about it")))
|
||||
|
||||
(deftest a-peg-goes-over-a-measured-node-which-is-the-one-thing-an-anchor-could-not-do
|
||||
;; `gesture/refusal` turns a drag on a measured node away and tells you to put
|
||||
;; a peg over it, so this is that advice being true. An anchor could never have
|
||||
;; been written here: `node/local!`'s translation is `pos − M·a`, and under the
|
||||
;; head's measured similarity `M` is nowhere near the identity, so writing one
|
||||
;; would have moved the whole face. A peg's channels are its own.
|
||||
(let [{c :clip st :store} @take/frozen
|
||||
head (get-in c [:symbols :face-1 :nodes :head])]
|
||||
(is (node/measured? head))
|
||||
(is (string? (gesture/refusal head)) "a drag on it is still refused")
|
||||
(let [r (nest/peg c st :main [:face-1 :head] 0 pg)
|
||||
out (:clip r)]
|
||||
(is (nil? (:refused r)) (:refused r))
|
||||
(is (= pg (get-in out [:symbols :face-1 :nodes :head :parent])))
|
||||
(is (= (:channels head) (get-in out [:symbols :face-1 :nodes :head :channels]))
|
||||
"the measured channels are untouched, so the next regenerate still owns them")
|
||||
(is (nil? (gesture/refusal (get-in out [:symbols :face-1 :nodes pg])))
|
||||
"and the peg over it CAN be dragged, which is the whole point")
|
||||
(is (empty? (clip/problems out)))
|
||||
(doseq [f [0 7 20]]
|
||||
(is (near? (drawn-at c st f) (drawn-at out st f))
|
||||
(str "frame " f " moved when the peg appeared"))))))
|
||||
|
||||
(deftest moving-a-pegs-pivot-moves-nothing-under-it
|
||||
;; THE OPERATION A PLAIN DRAG IS NOT. Dragging a peg writes its `pos`, and a
|
||||
;; peg is a parent, so that carries its children with it — a translate. Putting
|
||||
;; the pivot somewhere else has to leave the picture alone, and does, because
|
||||
;; only `local(peg) · pinv(child)` reaches the child and `repivot` preserves
|
||||
;; that product.
|
||||
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
was (drawn-at c 0)
|
||||
r (nest/repivot c nil :main [pg] 0 [10 90])
|
||||
out (:clip r)]
|
||||
(is (nil? (:refused r)) (:refused r))
|
||||
(is (= [10 90] (:value (get-in out [:symbols :main :nodes pg :channels [:xform :pos]])))
|
||||
"the peg's origin is where it was put")
|
||||
(is (near? was (drawn-at out 0)) "and not one point of the child moved")
|
||||
(is (empty? (clip/problems out)))
|
||||
;; And it is a REAL pivot afterwards: a turn about it keeps it fixed.
|
||||
(let [v0 (gesture/values (get-in out [:symbols :main :nodes pg]) 0 nil)
|
||||
piv (gesture/pivot v0 nil)
|
||||
spun (gesture/apply-values out :main pg 0 (gesture/turn v0 piv 0.5))
|
||||
radii (fn [doc] (map (fn [[x y]] (js/Math.hypot (- x 10) (- y 90)))
|
||||
(partition 2 (drawn-at doc 0))))]
|
||||
(is (near? [10 90] piv) "the derived pivot followed it")
|
||||
(is (not (near? (drawn-at out 0) (drawn-at spun 0))) "the child turned")
|
||||
(is (near? (vec (radii out)) (vec (radii spun)))
|
||||
"about the pivot's new home, keeping every distance from it"))))
|
||||
|
||||
(deftest a-pegs-pivot-cannot-be-moved-once-its-position-is-animated
|
||||
;; Refused rather than quietly wrong: the compensation depends on the peg's own
|
||||
;; transform, so a keyed position needs a different `pinv` per frame and one
|
||||
;; stored matrix is not it.
|
||||
(let [c (:clip (nest/peg (shaped) nil :main [:shape] 0 pg))
|
||||
keyed (assoc-in c [:symbols :main :nodes pg :channels [:xform :pos]]
|
||||
(ch/keyed {0 [10 10] 5 [40 10]} :linear))
|
||||
r (nest/repivot keyed nil :main [pg] 0 [10 90])]
|
||||
(is (string? (:refused r)))
|
||||
(is (re-find #"animated" (:refused r)) (:refused r))
|
||||
(is (re-find #"peg over it" (:refused r)) (:refused r)))
|
||||
(testing "and a node with nothing under it has no pivot to move"
|
||||
(let [r (nest/repivot (shaped) nil :main [:shape] 0 [10 90])]
|
||||
(is (re-find #"nothing hangs off it" (:refused r)) (:refused r)))))
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
(defn- close? [a b] (< (js/Math.abs (- a b)) 1e-12))
|
||||
(defn- close-pt? [[ax ay] [bx by]] (and (close? ax bx) (close? ay by)))
|
||||
|
||||
(defn- local [& {:keys [pos rot scale skew]
|
||||
:or {pos [0 0] rot 0 scale [1 1] skew [0 0]}}]
|
||||
(node/local! (node/mat) pos rot scale skew))
|
||||
(defn- local [& {:keys [pos rot scale skew anchor]
|
||||
:or {pos [0 0] rot 0 scale [1 1] skew [0 0] anchor [0 0]}}]
|
||||
(node/local! (node/mat) pos rot scale skew anchor))
|
||||
|
||||
;; ---- the transform, component by component ----
|
||||
|
||||
|
|
@ -28,19 +28,16 @@
|
|||
(is (close-pt? [0 10] (pt (local :rot (/ js/Math.PI 2)) 10 0)))
|
||||
(is (= [20 21] (pt (local :scale [2 3]) 10 7))))
|
||||
|
||||
(deftest rotation-and-scale-happen-about-the-nodes-own-origin
|
||||
;; THERE IS NO :anchor, and this is the half of that which lives here: a local
|
||||
;; transform turns and scales about [0 0] of the node's own space and nothing
|
||||
;; else. Turning about any other point is `gesture/about`, which solves for the
|
||||
;; `pos` that holds that point still — see `gesture-test`. The two together are
|
||||
;; what the anchor used to be, with no stored pivot to fall out of step with the
|
||||
;; drawing.
|
||||
(let [m (local :rot (/ js/Math.PI 2))]
|
||||
(is (close-pt? [0 0] (pt m 0 0)) "the origin is the fixed point")
|
||||
(is (close-pt? [0 10] (pt m 10 0)) "and the rest turns about it"))
|
||||
(let [m (local :scale [2 2])]
|
||||
(is (close-pt? [0 0] (pt m 0 0)))
|
||||
(is (close-pt? [20 20] (pt m 10 10)))))
|
||||
(deftest rotation-and-scale-happen-about-the-anchor
|
||||
;; :anchor is Flash's registration point and Blender's origin. Getting it wrong
|
||||
;; is why hand-placed parts SWING rather than turn, and a swing looks like a
|
||||
;; parenting bug rather than like a wrong pivot.
|
||||
(let [m (local :rot (/ js/Math.PI 2) :anchor [10 0])]
|
||||
(is (close-pt? [10 0] (pt m 10 0)) "the anchor itself is a fixed point")
|
||||
(is (close-pt? [10 10] (pt m 20 0)) "and the rest turns about it"))
|
||||
(let [m (local :scale [2 2] :anchor [10 10])]
|
||||
(is (close-pt? [10 10] (pt m 10 10)))
|
||||
(is (close-pt? [30 30] (pt m 20 20)))))
|
||||
|
||||
(deftest skew-is-shear-factors-so-the-identity-is-zero
|
||||
;; Stored as factors rather than angles: kx is x gained per unit y, so a
|
||||
|
|
@ -51,13 +48,13 @@
|
|||
(is (= [5 12] (pt (local :skew [0 1]) 5 7)) "ky adds x into y"))
|
||||
|
||||
(deftest the-composition-order-is-the-one-the-model-specifies
|
||||
;; local = T(pos) · R(rot) · K(skew) · S(scale)
|
||||
;; local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
;;
|
||||
;; Asserted against the product of the four matrices built separately, so the
|
||||
;; Asserted against the product of the five matrices built separately, so the
|
||||
;; closed form in node/local! is checked rather than trusted. Every other order
|
||||
;; produces a transform that is right at the origin and wrong everywhere else,
|
||||
;; which is exactly the kind of wrong that survives inspection.
|
||||
(let [pos [3 -4] rot 0.7 scale [1.5 0.5] skew [0.25 -0.1]
|
||||
(let [pos [3 -4] rot 0.7 scale [1.5 0.5] skew [0.25 -0.1] anchor [11 -6]
|
||||
T (fn [x y] (js/Float64Array. #js [1 0 0 1 x y]))
|
||||
R (fn [t] (js/Float64Array. #js [(js/Math.cos t) (js/Math.sin t)
|
||||
(- (js/Math.sin t)) (js/Math.cos t) 0 0]))
|
||||
|
|
@ -65,31 +62,13 @@
|
|||
S (fn [[sx sy]] (js/Float64Array. #js [sx 0 0 sy 0 0]))
|
||||
step (fn [acc m] (node/mul! (node/mat) acc m))
|
||||
want (reduce step (T (nth pos 0) (nth pos 1))
|
||||
[(R rot) (K skew) (S scale)])
|
||||
got (local :pos pos :rot rot :scale scale :skew skew)]
|
||||
[(T (nth anchor 0) (nth anchor 1))
|
||||
(R rot) (K skew) (S scale)
|
||||
(T (- (nth anchor 0)) (- (nth anchor 1)))])
|
||||
got (local :pos pos :rot rot :scale scale :skew skew :anchor anchor)]
|
||||
(is (every? (fn [i] (close? (aget want i) (aget got i))) (range 6))
|
||||
(str (vec (array-seq want)) " vs " (vec (array-seq got))))))
|
||||
|
||||
(deftest an-anchor-is-a-peg-written-inline
|
||||
;; The identity that makes the deletion safe rather than a trade: a node with
|
||||
;; anchor `a` is exactly a peg at `pos + a` carrying the rotation and scale,
|
||||
;; parenting a child offset by `-a`. Same matrix, to the last bit of the
|
||||
;; mantissa — so everything the anchor could express, a parent already could,
|
||||
;; and the parent can also be keyed, shared, and put over a measured channel.
|
||||
(let [pos [7 -3] rot 0.9 scale [1.4 0.6] a [11 -6]
|
||||
;; what T(pos)·T(a)·R·K·S·T(-a) used to produce, built from the parts
|
||||
anchored (reduce (fn [acc m] (node/mul! (node/mat) acc m))
|
||||
(js/Float64Array. #js [1 0 0 1 (nth pos 0) (nth pos 1)])
|
||||
[(js/Float64Array. #js [1 0 0 1 (nth a 0) (nth a 1)])
|
||||
(local :rot rot :scale scale)
|
||||
(js/Float64Array. #js [1 0 0 1 (- (nth a 0)) (- (nth a 1))])])
|
||||
;; the same thing as a peg and a child
|
||||
peg (local :pos (mapv + pos a) :rot rot :scale scale)
|
||||
child (local :pos (mapv - a))
|
||||
world (node/world! (node/mat) peg nil child (node/mat))]
|
||||
(is (every? (fn [i] (close? (aget anchored i) (aget world i))) (range 6))
|
||||
(str (vec (array-seq anchored)) " vs " (vec (array-seq world))))))
|
||||
|
||||
(deftest mul-may-write-into-either-operand
|
||||
;; Evaluation composes world := parent · local with dest aliasing local, so
|
||||
;; that a node's world transform needs no scratch. If mul! wrote before reading,
|
||||
|
|
@ -189,31 +168,13 @@
|
|||
(is (= [5 5] (ch/value-at (get chs [:xform :pos]) 0 nil)))
|
||||
(is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0 nil))))))
|
||||
|
||||
(deftest skew-is-in-the-shape-although-nothing-drives-it
|
||||
(deftest skew-and-anchor-are-in-the-shape-although-nothing-drives-them
|
||||
;; A decomposition is not extensible after the fact: adding a component later
|
||||
;; means migrating every stored transform. So it is present from the start, on
|
||||
;; every kind that is in the picture.
|
||||
;; means migrating every stored transform. So both are present from the start,
|
||||
;; on every kind that is in the picture.
|
||||
(doseq [k (disj node/implemented-kinds :audio)]
|
||||
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k))))
|
||||
|
||||
(deftest there-is-no-anchor-anywhere-in-the-shape
|
||||
;; The deletion, asserted rather than assumed. A document carrying one is not
|
||||
;; migrated, it is invalid — `problems` rejects the channel on every kind — so
|
||||
;; there is no shape in which a stale stored pivot can come back.
|
||||
(doseq [k node/implemented-kinds]
|
||||
(is (not (contains? (get node/valid-paths k) [:xform :anchor])) (str k)))
|
||||
(is (not (contains? (set node/xform-paths) [:xform :anchor])))
|
||||
(is (not (contains? node/defaults [:xform :anchor])))
|
||||
(let [ps (node/problems {:id :x :kind :poly :z "a1"
|
||||
:channels {[:xform :anchor] (ch/framed [1 2])}})]
|
||||
(is (seq ps) "a node carrying one does not validate")
|
||||
;; NAMED, with what to do about it, the way `symbol/problems` refuses
|
||||
;; `:trace` — every schema-6 drawing and placement carried an anchor, so this
|
||||
;; is the first thing a person opening an old project sees, and "not valid on
|
||||
;; a :poly node" would tell them nothing.
|
||||
(is (some #(re-find #"peg" %) ps) (str "no named refusal: " (pr-str ps)))
|
||||
(is (not-any? #(re-find #"is not valid on a" %) ps)
|
||||
(str "the generic path complaint should not also fire: " (pr-str ps)))))
|
||||
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k))
|
||||
(is (contains? (get node/valid-paths k) [:xform :anchor]) (str k))))
|
||||
|
||||
(deftest valid-paths-follow-from-the-kind
|
||||
(is (contains? (:poly node/valid-paths) [:geom :pts]))
|
||||
|
|
|
|||
|
|
@ -12,30 +12,22 @@
|
|||
(get-in clip [:symbols :main :nodes :paint-test :channels paint/geometry]))
|
||||
|
||||
(deftest drawing-keys-hold-and-tween-on-the-symbol-clock
|
||||
;; `a` is what was drawn, `ring` is what the node holds: the same drawing about
|
||||
;; its own middle, with the middle in `pos`. Every point below is in the node's
|
||||
;; own coordinates, which is the space `set-vertex` writes in.
|
||||
(let [a [10 10 30 10 20 30]
|
||||
ring (first (paint/centred a))
|
||||
(let [a [10 10 30 10 20 30]
|
||||
c0 (paint/new-shape demo/clip :main :paint-test 3 a :brow)
|
||||
c1 (paint/add-key c0 :main :paint-test 9)
|
||||
c2 (paint/set-vertex c1 :main :paint-test 9 0 [2 -10])
|
||||
c2 (paint/set-vertex c1 :main :paint-test 9 0 [22 10])
|
||||
c3 (paint/add-key c2 :main :paint-test 15)
|
||||
c4 (paint/set-vertex c3 :main :paint-test 15 0 [14 -10])
|
||||
c4 (paint/set-vertex c3 :main :paint-test 15 0 [34 10])
|
||||
held (geometry c4)
|
||||
mixed-clip (update-in c4 [:symbols :main :nodes :paint-test]
|
||||
node/set-segment-interp paint/geometry 9 :linear)
|
||||
mixed (geometry mixed-clip)]
|
||||
(is (= [3 229] (get-in c2 [:symbols :main :nodes :paint-test :span])))
|
||||
(is (= [-10 -10 10 -10 0 10] ring) "centred on the middle of its own box")
|
||||
(is (= [20 20] (channel/value-at (get-in c0 [:symbols :main :nodes :paint-test
|
||||
:channels [:xform :pos]]) 3 nil))
|
||||
"and placed back where it was drawn")
|
||||
(is (= ring (channel/value-at held 8 nil)))
|
||||
(is (= -10 (first (channel/value-at held 8 nil))))
|
||||
(is (= 2 (first (channel/value-at held 9 nil))))
|
||||
(is (= -10 (first (channel/value-at mixed 6 nil))) "the first gap cuts")
|
||||
(is (= 8 (first (channel/value-at mixed 12 nil))) "the second gap tweens")
|
||||
(is (= a (channel/value-at held 8 nil)))
|
||||
(is (= 10 (first (channel/value-at held 8 nil))))
|
||||
(is (= 22 (first (channel/value-at held 9 nil))))
|
||||
(is (= 10 (first (channel/value-at mixed 6 nil))) "the first gap cuts")
|
||||
(is (= 28 (first (channel/value-at mixed 12 nil))) "the second gap tweens")
|
||||
(is (empty? (channel/problems mixed)))
|
||||
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
|
||||
(is (some #(= :paint-test (:node %))
|
||||
|
|
@ -43,13 +35,12 @@
|
|||
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))
|
||||
|
||||
(deftest a-point-is-added-and-taken-away-on-every-key
|
||||
;; `[0 0 10 0 10 10]` drawn is `[-5 -5 5 -5 5 5]` held, about its own middle.
|
||||
(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 [15 -5]))
|
||||
(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 [-5 -5 0 -5 5 -5 5 5] 6 [-5 -5 5 -5 15 -5 5 5]} (ks c1))
|
||||
(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")))
|
||||
|
|
|
|||
|
|
@ -176,20 +176,6 @@
|
|||
t (nest/own-time c (:store @frozen) :face-1 [:plate] 30)]
|
||||
(is (= 30 (* (:rate t) (- 30 (:at t)))) "frame 30 is 30, though it shows 12")))
|
||||
|
||||
(defn- middle-on-stage
|
||||
"Where the picture's own middle lands, for a tracing placement `n`.
|
||||
|
||||
`pos + scale · middle`, because a trace's own coordinates are its pixels and
|
||||
the scaled middle moves with the scale. This used to be `pos + anchor`: with
|
||||
the anchor ON the middle, `T(a)·S(k)·T(-a)` left that point alone whatever `k`
|
||||
was, so the sum read correctly without the scale appearing in it at all. That
|
||||
is the work `events/ui`'s `fitted` now does explicitly."
|
||||
[doc n]
|
||||
(let [{:keys [width height]} (clip/symbol doc (node/source n))]
|
||||
(mapv + (get-in n [:channels [:xform :pos] :value])
|
||||
(mapv * (get-in n [:channels [:xform :scale] :value])
|
||||
[(/ width 2) (/ height 2)]))))
|
||||
|
||||
(deftest a-dropped-still-lasts-the-rest-of-the-symbol-fits-it-and-is-reused
|
||||
(let [id (store/install! {:clip (clip/blank) :store {}} "drop-tracing")
|
||||
sym {:name "sheet" :type :trace :media {:image "abc"}
|
||||
|
|
@ -211,7 +197,8 @@
|
|||
"as long as what is left of the symbol it landed in")
|
||||
(is (= [30 (clip/frames doc :main)] (node/placed-span n)))
|
||||
(is (= [k k] (get-in n [:channels [:xform :scale] :value])) "as tall as the stage")
|
||||
(is (= [(/ w 2) (/ h 2)] (middle-on-stage doc n))
|
||||
(is (= [(/ w 2) (/ h 2)] (mapv + (get-in n [:channels [:xform :pos] :value])
|
||||
(get-in n [:channels [:xform :anchor] :value])))
|
||||
"dropped on the timeline, it is middled on the stage")
|
||||
(let [[doc2 n2] (drop!)]
|
||||
(is (= sid (node/source n2)) "the same picture is the same symbol")
|
||||
|
|
@ -234,8 +221,9 @@
|
|||
[w h] (clip/stage doc host)
|
||||
k (min (/ w 3000) (/ h image-h))]
|
||||
(is (= [k k] (get-in n [:channels [:xform :scale] :value])))
|
||||
(is (= (or point [(/ w 2) (/ h 2)]) (middle-on-stage doc n))
|
||||
"the picture's middle lands where the drop asked for it, at the fitted scale")
|
||||
(is (= (or point [(/ w 2) (/ h 2)])
|
||||
(mapv + (get-in n [:channels [:xform :pos] :value])
|
||||
(get-in n [:channels [:xform :anchor] :value]))))
|
||||
(is (= plate (get-in doc [:symbols :face-1 :nodes :plate]))
|
||||
"the face's registered tracing placement is unchanged")
|
||||
(is (= (clip/symbol c sid) (clip/symbol doc sid))
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@
|
|||
(let [c (freeze/head-mode {} @frozen)
|
||||
res (clip/resolver c :main @store pal/index-of nil)
|
||||
k (first (:value (chan :place [:xform :scale])))
|
||||
anc (:value (chan :place [:xform :anchor]))
|
||||
pos (:value (chan :place [:xform :pos]))
|
||||
tfs (:transforms @take/measured)]
|
||||
(doseq [f (range 0 take/frames 13)]
|
||||
|
|
@ -222,11 +223,10 @@
|
|||
;; the fit removed the head's motion, so putting it back is the fit's
|
||||
;; inverse. Node :head carries exactly this.
|
||||
im (geom/apply-sim (freeze/invert (nth tfs ef)) g)
|
||||
;; And where :place puts it: scaled about its own origin, then
|
||||
;; translated, which is p ↦ k·p + pos. The recentring that used to be
|
||||
;; an `:anchor` is folded into `pos`, so this is the whole of it.
|
||||
wx (+ (* k (:x im)) (nth pos 0))
|
||||
wy (+ (* k (:y im)) (nth pos 1))]
|
||||
;; And where :place puts it: scaled about the anchor, then translated,
|
||||
;; which is p ↦ k(p - anchor) + anchor + pos.
|
||||
wx (+ (* k (- (:x im) (nth anc 0))) (nth anc 0) (nth pos 0))
|
||||
wy (+ (* k (- (:y im) (nth anc 1))) (nth anc 1) (nth pos 1))]
|
||||
(is (some? op) (str "frame " f " emitted no mouth op"))
|
||||
;; Tolerance is the Float32 transform block's, scaled to stage pixels, and
|
||||
;; it is three orders below a pixel.
|
||||
|
|
@ -310,81 +310,100 @@
|
|||
;; The difference from `makeXform` in one assertion: the placement is a FRAMED
|
||||
;; transform on a node, which a hand can revise, and it claims no generator that
|
||||
;; would offer to overwrite it.
|
||||
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
|
||||
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]]
|
||||
(let [c (get (node/channels (node :place)) path)]
|
||||
(is (= :framed (ch/describe c)) (str path " is not framed"))
|
||||
(is (nil? (:generated c)) (str path " claims provenance")))))
|
||||
|
||||
(deftest a-freeze-stores-no-pivot-at-all
|
||||
;; THE PASS THAT USED TO BE HERE IS GONE, and so is the biconditional it
|
||||
;; needed. `freeze/pivoted` walked every node it had made and wrote the middle
|
||||
;; of what that node drew into `[:xform :anchor]` — skipping anything
|
||||
;; `node/measured?`, because an anchor under a measured scale does not cancel
|
||||
;; out of `node/local!` and writing one would have moved the whole face. So the
|
||||
;; traced parts a hand most wants to adjust were exactly the ones that could
|
||||
;; never be given a pivot.
|
||||
(deftest a-part-has-a-pivot-exactly-when-a-hand-can-use-one
|
||||
;; The face's anchor was always the head's centre — the test below — and the
|
||||
;; parts underneath it had none at all, so each one turned and scaled about ITS
|
||||
;; OWN origin, which is the top-left corner of the FOOTAGE. On a 320x200 stage
|
||||
;; the mouth's pivot sat at (-234, -395): off the stage by more than a stage,
|
||||
;; so a corner drag slid the mouth about instead of resizing it.
|
||||
;;
|
||||
;; A derived pivot has no such exception, because there is nothing to write.
|
||||
;; BY BICONDITIONAL, over every node the freeze makes, rather than against a
|
||||
;; list of the ones that happen to have geometry today. The rule `pivoted` goes
|
||||
;; by is `node/measured?` — the one `gesture/refusal` refuses a hand edit by —
|
||||
;; so the two have to agree exactly: a pivot is written where a hand could use
|
||||
;; it and nowhere else. A brow has a dense `[:xform :pos]` and so gets none,
|
||||
;; which is not an exception to the rule, it is the rule.
|
||||
(let [c @clip*]
|
||||
(doseq [sid [:main :face-1]
|
||||
[id n] (get-in c [:symbols sid :nodes])]
|
||||
(is (nil? (get-in n [:channels [:xform :anchor]]))
|
||||
(str sid "/" id " stores a pivot")))))
|
||||
id (keys (get-in c [:symbols sid :nodes]))
|
||||
;; `:place` authors its own in `face-placement`, upstream of this.
|
||||
:when (not= [:face-1 :place] [sid id])]
|
||||
(let [n (get-in c [:symbols sid :nodes id])
|
||||
frames (range (get-in c [:symbols sid :frames]))
|
||||
anchor (:value (get-in n [:channels [:xform :anchor]]))
|
||||
usable (and (not (node/measured? n))
|
||||
(some? (pick/pivot c @store sid n frames)))]
|
||||
(is (= usable (some? anchor))
|
||||
(str sid "/" id " has a pivot: " (some? anchor)
|
||||
", but a hand can use one: " usable))
|
||||
(is (= (nil? (gesture/refusal n)) (not (node/measured? n)))
|
||||
(str sid "/" id ": `refusal` and `measured?` disagree"))
|
||||
(when anchor
|
||||
(let [bounds (pick/bounds-of c @store sid n)
|
||||
[x0 y0 x1 y1] (reduce #(let [k (bounds %2)]
|
||||
(cond (nil? %1) k (nil? k) %1
|
||||
:else (mapv (fn [op i] (op (nth %1 i) (nth k i)))
|
||||
[min min max max] (range 4))))
|
||||
nil frames)]
|
||||
(is (and (<= x0 (nth anchor 0) x1) (<= y0 (nth anchor 1) y1))
|
||||
(str sid "/" id "'s pivot " (pr-str anchor) " is outside what it draws, "
|
||||
(pr-str [x0 y0 x1 y1])))))))))
|
||||
|
||||
(deftest every-part-pivots-inside-what-it-draws-measured-or-not
|
||||
;; The mouth's pivot used to sit at (-234, -395) on a 320x200 stage — the
|
||||
;; top-left corner of the FOOTAGE, carried onto the stage — so a corner drag
|
||||
;; slid the mouth about instead of resizing it. This is that, fixed, and
|
||||
;; asserted over EVERY node the freeze makes rather than the ones that happened
|
||||
;; to get an anchor: where a node draws something, the pivot derived for it is
|
||||
;; inside what it draws, `node/measured?` or not.
|
||||
(let [c @clip*]
|
||||
(doseq [sid [:main :face-1]
|
||||
[id n] (get-in c [:symbols sid :nodes])]
|
||||
(let [bounds ((pick/bounds-of c @store sid n) 0)
|
||||
v (gesture/values n 0 @store)
|
||||
[px py] (gesture/pivot v bounds)]
|
||||
(is (every? #(js/Number.isFinite %) [px py])
|
||||
(str sid "/" id "'s pivot is not a point: " (pr-str [px py])))
|
||||
(if bounds
|
||||
;; Inside its own bounds, through its own transform — so inside what it
|
||||
;; draws wherever that transform puts it.
|
||||
(let [[x0 y0 x1 y1] bounds
|
||||
[cx cy] (gesture/pivot v bounds)
|
||||
[ox oy] (gesture/pivot (assoc v :pos [0 0] :rot 0 :scale [1 1]) bounds)]
|
||||
(is (and (<= x0 ox x1) (<= y0 oy y1))
|
||||
(str sid "/" id "'s pivot " (pr-str [ox oy])
|
||||
" is outside what it draws, " (pr-str bounds)))
|
||||
(is (every? #(js/Number.isFinite %) [cx cy])))
|
||||
;; A node that draws nothing pivots about its own origin, which in its
|
||||
;; parent's coordinates is exactly its position. That is the peg rule,
|
||||
;; and `:head` is where it earns its keep: it carries the measured
|
||||
;; similarity, so it could never have held an anchor.
|
||||
(is (= (:pos v) [px py])
|
||||
(str sid "/" id " draws nothing, so its pivot is its own origin")))))))
|
||||
|
||||
(deftest the-head-can-still-not-be-dragged-but-a-peg-over-it-can
|
||||
;; `measured?` is still the predicate a hand edit is refused by — that has not
|
||||
;; changed and should not: an edit to a measured channel is thrown away by the
|
||||
;; next regenerate. What changed is the advice, and that it is now true: the
|
||||
;; head draws nothing, so it pivots about its own origin, and a peg above it
|
||||
;; carries a hand transform on channels of its own.
|
||||
(deftest the-head-keeps-no-pivot-of-its-own
|
||||
;; The case that makes `measured?` the right predicate rather than "draws
|
||||
;; nothing": `:head` carries the measured similarity, so its scale is nowhere
|
||||
;; near 1 and an anchor on it would NOT cancel out of `node/local!` — it would
|
||||
;; move the whole face. It is skipped for that reason, and would still be
|
||||
;; skipped if it were ever given geometry.
|
||||
(is (node/measured? (node :head)))
|
||||
(is (string? (gesture/refusal (node :head))))
|
||||
(is (re-find #"peg" (gesture/refusal (node :head)))))
|
||||
(is (nil? (get-in (node :head) [:channels [:xform :anchor]]))))
|
||||
|
||||
(deftest giving-every-part-its-pivot-moves-nothing-on-screen
|
||||
;; The claim `pivoted`'s docstring makes, asserted in pixels rather than
|
||||
;; trusted: rotation and scale are the identity on a node a freeze has just
|
||||
;; made, and at the identity the anchor cancels out of `node/local!`. So the
|
||||
;; pass decides where a part PIVOTS and nothing else — if it ever renders
|
||||
;; differently, it has been applied to a node whose transform is not the
|
||||
;; identity, which is the one way it could go wrong.
|
||||
(let [c @clip*
|
||||
;; Every node the freeze makes EXCEPT `:place`, whose anchor
|
||||
;; `face-placement` authors — which is the set `pivoted` writes.
|
||||
every (for [sid [:main :face-1]
|
||||
id (keys (get-in c [:symbols sid :nodes]))
|
||||
:when (and (not= [:face-1 :place] [sid id])
|
||||
(seq (get-in c [:symbols sid :nodes id :channels])))]
|
||||
[sid id])
|
||||
anchors #(into {} (for [[sid id] every]
|
||||
[[sid id] (get-in % [:symbols sid :nodes id
|
||||
:channels [:xform :anchor]])]))
|
||||
bare (reduce (fn [c [sid id]]
|
||||
(update-in c [:symbols sid :nodes id :channels]
|
||||
dissoc [:xform :anchor]))
|
||||
c every)
|
||||
again (freeze/pivoted bare @store)]
|
||||
(is (every? nil? (vals (anchors bare))) "stripped")
|
||||
(is (= (anchors c) (anchors again)) "the pass puts back exactly what the freeze wrote")
|
||||
(doseq [f (range 0 take/frames 17)]
|
||||
(is (= (render bare f) (render again f))
|
||||
(str "frame " f " draws differently once every part has a pivot")))))
|
||||
|
||||
(deftest the-place-puts-the-head-s-centre-where-it-says-it-does
|
||||
;; k·centroid + pos is where the head's centre lands in the parent, and that is
|
||||
;; the recentring MediaPipe's space makes necessary: its origin is the image's
|
||||
;; top-left corner, so head-local geometry is not centred on anything. It used
|
||||
;; to be an `:anchor`, which made the offset double as a pivot; folded into
|
||||
;; `pos` it is the same translation — `a + p − k·a` with `p = stage − k·a` — and
|
||||
;; the face then pivots about the middle of what it actually draws.
|
||||
(let [k (first (:value (chan :place [:xform :scale])))
|
||||
;; anchor + pos is where the anchor lands in the parent, which is what makes
|
||||
;; `:anchor` the registration point: scale and rotation happen about the head's
|
||||
;; centre rather than about the corner of the footage, where MediaPipe's
|
||||
;; normalised space has its origin.
|
||||
(let [anc (:value (chan :place [:xform :anchor]))
|
||||
pos (:value (chan :place [:xform :pos]))
|
||||
c (geom/centroid (:ref @take/measured))]
|
||||
(is (< (abs (- (+ (* k (:x c)) (nth pos 0)) (/ W 2))) 1e-9))
|
||||
(is (< (abs (- (+ (* k (:y c)) (nth pos 1)) (* 0.25 H))) 1e-9))))
|
||||
(is (< (abs (- (nth anc 0) (:x c))) 1e-12))
|
||||
(is (< (abs (- (nth anc 1) (:y c))) 1e-12))
|
||||
(is (< (abs (- (+ (nth anc 0) (nth pos 0)) (/ W 2))) 1e-9))
|
||||
(is (< (abs (- (+ (nth anc 1) (nth pos 1)) (* 0.25 H))) 1e-9))))
|
||||
|
||||
(deftest the-stage-is-the-clip-s-and-not-the-footage-s
|
||||
;; Project dimensions are independent of the footage, which is precisely what
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
// Browser smoke test for pegs: a peg is made over a shape, nothing moves, and
|
||||
// the stage gives it handles that actually drag.
|
||||
//
|
||||
// WHAT A UNIT TEST CANNOT SEE. A peg is a `:group`, so it draws nothing, and
|
||||
// both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the
|
||||
// ops, and `handles` hangs its box, corners and knob off `pick/bounds-of`. So a
|
||||
// peg was selectable only from a timeline row and had nothing to grab — the
|
||||
// pivot that `gesture/refusal` tells you to make was unusable on the stage, and
|
||||
// every assertion about the maths still passed. Hence this: the handles are
|
||||
// asserted as DOM, with real pointer events through them.
|
||||
//
|
||||
// Uses an in-memory fixture and performs no server-side writes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
||||
const profile = mkdtempSync(join(tmpdir(), 'arthur-peg-'));
|
||||
const port = 9341;
|
||||
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
||||
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
||||
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
||||
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
||||
], { stdio: 'ignore' });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws, id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params) => new Promise(res => {
|
||||
const n = ++id; pending.set(n, res);
|
||||
ws.send(JSON.stringify({ id: n, method, params }));
|
||||
});
|
||||
const evalJS = async expr => {
|
||||
const r = await send('Runtime.evaluate',
|
||||
{ expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
|
||||
return r.result.value;
|
||||
};
|
||||
let failures = 0;
|
||||
const ok = (cond, what, extra = '') => {
|
||||
console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
try {
|
||||
let target;
|
||||
for (let i = 0; i < 100 && !target; i++) {
|
||||
await sleep(100);
|
||||
try {
|
||||
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
||||
.find(t => t.type === 'page' && t.url.startsWith(url));
|
||||
} catch {}
|
||||
}
|
||||
assert(target, 'editor page');
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise(r => ws.addEventListener('open', r));
|
||||
ws.addEventListener('message', e => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
||||
});
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
for (let i = 0; i < 160; i++) {
|
||||
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
// Local route, a turned+scaled shape in :main, selected.
|
||||
const setup = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map;
|
||||
c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test')));
|
||||
const blank = arthur.domain.clip.blank();
|
||||
let doc = arthur.domain.paint.new_shape(blank, k('main'), k('shape'), 0,
|
||||
v(100, 80, 140, 80, 140, 110, 100, 110), k('brow'));
|
||||
doc = c.update_in(doc, v(k('symbols'), k('main'), k('nodes'), k('shape'), k('channels')),
|
||||
ch => c.merge(ch, m(v(k('xform'), k('rot')), arthur.domain.channel.framed(0.4),
|
||||
v(k('xform'), k('scale')), arthur.domain.channel.framed(v(1.5, 0.75)))));
|
||||
const entry = m(k('clip'), doc, k('store'), m());
|
||||
const cid = arthur.footage.store.install_BANG_(entry, 'peg-probe');
|
||||
c.swap_BANG_(re_frame.db.app_db, db =>
|
||||
c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')),
|
||||
k('clip/current'), cid, k('paint/revision'), 0));
|
||||
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
|
||||
v(k('node'), k('main'), k('shape'), v(k('shape')))));
|
||||
return {cid: String(cid)};
|
||||
`);
|
||||
console.log('setup:', JSON.stringify(setup));
|
||||
await sleep(600);
|
||||
|
||||
const before = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const res = arthur.domain.clip.resolver(c.get(e,k('clip')), k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
const ops = res.call(null, 0);
|
||||
return Array.from(c.get(c.first(ops), k('pts')));
|
||||
`);
|
||||
ok(Array.isArray(before) && before.length === 8, 'the shape draws before the peg', JSON.stringify(before));
|
||||
|
||||
// Add the peg, exactly as the menu item does.
|
||||
const made = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
re_frame.core.dispatch_sync(v(k('arthur.events.ui/peg'), v(k('shape'))));
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const doc = c.get(e, k('clip'));
|
||||
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
|
||||
const shape = c.get(nodes, k('shape'));
|
||||
const pegId = c.get(shape, k('parent'));
|
||||
const peg = c.get(nodes, pegId);
|
||||
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
const sel = c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))));
|
||||
return {
|
||||
kind: String(c.get(peg, k('kind'))),
|
||||
pegPos: c.clj__GT_js(c.get_in(peg, v(k('channels'), v(k('xform'),k('pos')), k('value')))),
|
||||
hasPinv: !!c.get(shape, k('pinv')),
|
||||
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
||||
selection: sel ? sel.map(String) : null,
|
||||
problems: c.clj__GT_js(arthur.domain.clip.problems(doc)),
|
||||
};
|
||||
`);
|
||||
ok(made.kind === ':group', 'the peg is a group', made.kind);
|
||||
ok(made.hasPinv, 'the child got a :pinv');
|
||||
ok((made.problems ?? []).length === 0, 'the document still validates', JSON.stringify(made.problems));
|
||||
const moved = Math.max(...before.map((b, i) => Math.abs(b - made.pts[i])));
|
||||
ok(moved < 1e-9, 'nothing moved when the peg appeared', `max drift ${moved}`);
|
||||
ok(String(made.selection?.[2]) !== ':shape', 'the peg is selected afterwards', JSON.stringify(made.selection));
|
||||
console.log('peg pos:', JSON.stringify(made.pegPos));
|
||||
|
||||
await sleep(700);
|
||||
// THE BIT THAT UNIT TESTS CANNOT SEE: does the stage render grabbable handles?
|
||||
const dom = await evalJS(`
|
||||
const q = s => document.querySelector(s);
|
||||
const g = q('.paint-overlay .handles');
|
||||
const grab = q('.paint-overlay .handles .peg-grab');
|
||||
const r = grab && grab.getBoundingClientRect();
|
||||
return {
|
||||
handles: !!g,
|
||||
pegGrab: !!grab,
|
||||
pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
|
||||
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
|
||||
corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
|
||||
boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
|
||||
rect: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
|
||||
};
|
||||
`);
|
||||
console.log('dom:', JSON.stringify(dom));
|
||||
ok(dom.handles, 'the stage draws handles for the selected peg');
|
||||
ok(dom.pegGrab, 'there is a .peg-grab to drag it by');
|
||||
ok(dom.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents));
|
||||
ok(dom.knobs === 1, 'a turn knob', String(dom.knobs));
|
||||
ok(dom.corners === 4, 'four scale corners', String(dom.corners));
|
||||
ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes));
|
||||
|
||||
// Drag it, through real pointer events on that element.
|
||||
if (dom.rect) {
|
||||
const { x, y } = dom.rect;
|
||||
for (const [type, dx] of [['mousePressed', 0], ['mouseMoved', 18], ['mouseMoved', 36], ['mouseReleased', 36]]) {
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type, x: x + dx, y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
|
||||
});
|
||||
await sleep(80);
|
||||
}
|
||||
await sleep(400);
|
||||
const after = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const doc = c.get(e, k('clip'));
|
||||
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
|
||||
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
|
||||
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
|
||||
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts')))};
|
||||
`);
|
||||
const dx = after.pts[0] - made.pts[0];
|
||||
const dy = after.pts[1] - made.pts[1];
|
||||
console.log('after drag: peg pos', JSON.stringify(after.pos), 'child moved by', dx.toFixed(3), dy.toFixed(3));
|
||||
ok(dx > 2, 'dragging the peg cross moved its child', `dx=${dx.toFixed(3)}`);
|
||||
ok(Math.abs(dy) < 1.5, 'and only horizontally, as dragged', `dy=${dy.toFixed(3)}`);
|
||||
}
|
||||
// ⌥-drag the cross: the PIVOT moves and the child must not.
|
||||
const dom2 = await evalJS(`
|
||||
const q = document.querySelector('.paint-overlay .handles .peg-grab');
|
||||
const r = q && q.getBoundingClientRect();
|
||||
const corner = document.querySelector('.paint-overlay .handles .corner');
|
||||
const cr = corner && corner.getBoundingClientRect();
|
||||
return {
|
||||
grab: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
|
||||
// The corner's own middle must hit the CORNER, not the move disc over it.
|
||||
cornerHit: cr ? (document.elementFromPoint(cr.x + cr.width/2, cr.y + cr.height/2) || {}).className
|
||||
?.baseVal ?? null : null,
|
||||
};
|
||||
`);
|
||||
console.log('dom2:', JSON.stringify(dom2));
|
||||
ok(dom2.cornerHit === 'corner', 'a scale corner is on top of the move disc, not under it',
|
||||
String(dom2.cornerHit));
|
||||
|
||||
if (dom2.grab) {
|
||||
// Every modifier that has to place the pivot. CDP modifiers: 1=alt, 2=ctrl,
|
||||
// 4=meta. ⌥ alone was the whole gesture once, and on a desktop whose window
|
||||
// manager grabs Alt-drag the page never sees it — so each is asserted
|
||||
// through real pointer events rather than by reading the handler.
|
||||
const { x, y } = dom2.grab;
|
||||
let after2;
|
||||
for (const [name, modifiers, sign] of [['alt', 1, -1], ['ctrl', 2, 1], ['meta', 4, -1]]) {
|
||||
const pre = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const doc = c.get(e, k('clip'));
|
||||
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
|
||||
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
|
||||
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
||||
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
|
||||
`);
|
||||
// Where the cross is NOW, since the previous modifier already moved it.
|
||||
const g = await evalJS(`
|
||||
const q = document.querySelector('.paint-overlay .handles .peg-grab');
|
||||
const r = q && q.getBoundingClientRect();
|
||||
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
|
||||
`);
|
||||
const dx = 30 * sign, dy = 20 * sign;
|
||||
for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx/2, dy/2],
|
||||
['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) {
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type, x: g.x + fx, y: g.y + fy, button: 'left', buttons: 1, clickCount: 1,
|
||||
pointerType: 'mouse', modifiers,
|
||||
});
|
||||
await sleep(90);
|
||||
}
|
||||
await sleep(450);
|
||||
const post = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const doc = c.get(e, k('clip'));
|
||||
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
|
||||
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
|
||||
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
|
||||
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
|
||||
`);
|
||||
const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i])));
|
||||
const moved = Math.hypot(post.pos[0] - pre.pos[0], post.pos[1] - pre.pos[1]);
|
||||
ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`,
|
||||
`drift ${drift}`);
|
||||
ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`);
|
||||
after2 = post;
|
||||
}
|
||||
// And a plain drag still translates, which is the other half of the split.
|
||||
{
|
||||
const g = await evalJS(`
|
||||
const q = document.querySelector('.paint-overlay .handles .peg-grab');
|
||||
const r = q && q.getBoundingClientRect();
|
||||
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
|
||||
`);
|
||||
const pre = after2;
|
||||
for (const [type, fx] of [['mousePressed', 0], ['mouseMoved', 12], ['mouseReleased', 12]]) {
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type, x: g.x + fx, y: g.y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
|
||||
});
|
||||
await sleep(90);
|
||||
}
|
||||
await sleep(450);
|
||||
const post = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const res = arthur.domain.clip.resolver(c.get(e, k('clip')), k('main'), c.get(e,k('store')),
|
||||
arthur.domain.palette.index_of, null);
|
||||
return Array.from(c.get(c.first(res.call(null, 0)), k('pts')));
|
||||
`);
|
||||
ok(Math.abs(post[0] - pre.pts[0]) > 2, 'and an UNMODIFIED drag still translates the child',
|
||||
`dx=${(post[0] - pre.pts[0]).toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed');
|
||||
} finally {
|
||||
if (ws) ws.close();
|
||||
chrome.kill();
|
||||
try {
|
||||
rmSync(profile, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOTEMPTY') throw error;
|
||||
}
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
// Browser smoke test for a palette swapper on the stage: a remap shape is
|
||||
// selected as a timeline row selects it, and the handles over it actually drag.
|
||||
//
|
||||
// WHAT A UNIT TEST CANNOT SEE, as `peg.mjs` says of a peg, and for a closely
|
||||
// related reason. A remap draws no colour of its own — it lights what is under
|
||||
// it — so `pick/hit-op` let a click go THROUGH it, and the stage's move gesture
|
||||
// is that hit-test. `handles` meanwhile hangs its box and corners off
|
||||
// `pick/bounds-of`, which reads the node's points and knows nothing about
|
||||
// colour. So a remap shape got a full set of handles over a shape that answered
|
||||
// no point on the stage at all: the first press on it started a marquee, the
|
||||
// marquee selected nothing, and the selection it had been given by a timeline
|
||||
// row was thrown away — which looks exactly like handles that do not work.
|
||||
// Every assertion about the maths still passed, hence this.
|
||||
//
|
||||
// A plain shape goes through the same two drags as the control, so a harness
|
||||
// that has stopped delivering pointer events cannot read as a pass.
|
||||
//
|
||||
// Uses an in-memory fixture and performs no server-side writes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
||||
const profile = mkdtempSync(join(tmpdir(), 'arthur-remap-'));
|
||||
const port = 9344;
|
||||
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
||||
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
||||
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
||||
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
||||
], { stdio: 'ignore' });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws, id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params) => new Promise(res => {
|
||||
const n = ++id; pending.set(n, res);
|
||||
ws.send(JSON.stringify({ id: n, method, params }));
|
||||
});
|
||||
const evalJS = async expr => {
|
||||
const r = await send('Runtime.evaluate',
|
||||
{ expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
|
||||
return r.result.value;
|
||||
};
|
||||
let failures = 0;
|
||||
const ok = (cond, what, extra = '') => {
|
||||
console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
// A press, two moves and a release, as a hand makes them: the stage only calls
|
||||
// a gesture a gesture once the pointer has left the press by a pixel.
|
||||
const drag = async (x, y, dx, dy) => {
|
||||
for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx / 2, dy / 2],
|
||||
['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) {
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type, x: x + fx, y: y + fy, button: 'left', buttons: 1, clickCount: 1,
|
||||
pointerType: 'mouse',
|
||||
});
|
||||
await sleep(90);
|
||||
}
|
||||
await sleep(400);
|
||||
};
|
||||
// The handles as DOM, in client pixels: what a hand would aim at.
|
||||
const rectOf = async sel => evalJS(`
|
||||
const q = document.querySelector('${sel}');
|
||||
if (!q) return null;
|
||||
const r = q.getBoundingClientRect();
|
||||
return {cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height};
|
||||
`);
|
||||
const state = async which => evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const n = c.get_in(c.get(e, k('clip')), v(k('symbols'), k('main'), k('nodes'), k('${which}')));
|
||||
const ch = p => c.clj__GT_js(c.get_in(n, v(k('channels'), p, k('value'))));
|
||||
return {pos: ch(v(k('xform'), k('pos'))), scale: ch(v(k('xform'), k('scale'))),
|
||||
selection: c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))))};
|
||||
`);
|
||||
|
||||
try {
|
||||
let target;
|
||||
for (let i = 0; i < 100 && !target; i++) {
|
||||
await sleep(100);
|
||||
try {
|
||||
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
||||
.find(t => t.type === 'page' && t.url.startsWith(url));
|
||||
} catch {}
|
||||
}
|
||||
assert(target, 'editor page');
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise(r => ws.addEventListener('open', r));
|
||||
ws.addEventListener('message', e => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
||||
});
|
||||
await send('Runtime.enable');
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
// Local route; a plain shape, and a remap shape beside it on its own ground.
|
||||
const setup = await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map;
|
||||
c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test')));
|
||||
let doc = arthur.domain.clip.blank();
|
||||
doc = arthur.domain.paint.new_shape(doc, k('main'), k('plain'), 0,
|
||||
v(40, 40, 90, 40, 90, 90, 40, 90), 1);
|
||||
doc = arthur.domain.paint.new_shape(doc, k('main'), k('light'), 0,
|
||||
v(140, 40, 200, 40, 200, 100, 140, 100), v(k('remap'), m(1, 2)));
|
||||
const entry = m(k('clip'), doc, k('store'), m());
|
||||
const cid = arthur.footage.store.install_BANG_(entry, 'remap-probe');
|
||||
c.swap_BANG_(re_frame.db.app_db, db =>
|
||||
c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')),
|
||||
k('clip/current'), cid, k('paint/revision'), 0));
|
||||
return arthur.domain.symbol.remap_QMARK_(
|
||||
c.get_in(doc, v(k('symbols'),k('main'),k('nodes'),k('light'),
|
||||
k('channels'),v(k('style'),k('color')),k('value'))));
|
||||
`);
|
||||
ok(setup === true, 'the fixture holds a remap shape');
|
||||
await sleep(800);
|
||||
|
||||
// The rule the fix must not break: with nothing selected, a click still goes
|
||||
// through the light to what it lights.
|
||||
const through = await evalJS(`
|
||||
const c = cljs.core, v = c.vector;
|
||||
const hit = arthur.ui.player.at(v(170, 70));
|
||||
return hit ? JSON.stringify(c.clj__GT_js(hit)) : null;
|
||||
`);
|
||||
ok(through === null, 'with nothing selected a click goes through the light',
|
||||
String(through));
|
||||
|
||||
for (const which of ['light', 'plain']) {
|
||||
console.log(`\n${which}:`);
|
||||
// Selected the way a timeline row selects it: a path, and no stage click.
|
||||
await evalJS(`
|
||||
const c = cljs.core, k = c.keyword, v = c.vector;
|
||||
re_frame.core.dispatch_sync(v(k('arthur.events.ui/set-tool'), k('select')));
|
||||
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
|
||||
v(k('node'), k('main'), k('${which}'), v(k('${which}')))));
|
||||
return true;
|
||||
`);
|
||||
await sleep(500);
|
||||
|
||||
const box = await rectOf('.paint-overlay .handles .box');
|
||||
ok(!!box, 'the stage puts a box round it');
|
||||
if (!box) continue;
|
||||
|
||||
const before = await state(which);
|
||||
await drag(box.cx, box.cy, 25, 0);
|
||||
const moved = await state(which);
|
||||
ok(Array.isArray(moved.pos) && Math.abs(moved.pos[0] - 0) > 2,
|
||||
'dragging its middle moves it', JSON.stringify(moved.pos));
|
||||
ok(JSON.stringify(moved.selection) === JSON.stringify(before.selection),
|
||||
'and the drag keeps the selection rather than marqueeing it away',
|
||||
JSON.stringify(moved.selection));
|
||||
|
||||
const corner = await rectOf('.paint-overlay .handles .corner');
|
||||
ok(!!corner, 'and corners to scale by');
|
||||
if (!corner) continue;
|
||||
await drag(corner.cx, corner.cy, 18, 18);
|
||||
const scaled = await state(which);
|
||||
ok(Array.isArray(scaled.scale) && Math.abs(scaled.scale[0] - 1) > 0.05,
|
||||
'dragging a corner scales it', JSON.stringify(scaled.scale));
|
||||
}
|
||||
|
||||
console.log(failures ? `\n${failures} FAILED`
|
||||
: '\nPASS: a palette swapper can be grabbed, moved and scaled on the stage');
|
||||
} finally {
|
||||
if (ws) ws.close();
|
||||
chrome.kill();
|
||||
}
|
||||
|
|
@ -1425,10 +1425,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
|||
.paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; }
|
||||
.paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; }
|
||||
.paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; }
|
||||
/* A peg draws nothing, so the cross is the only thing to grab it by. */
|
||||
.paint-overlay .handles .peg-grab { fill: transparent; stroke: none; cursor: move; }
|
||||
/* While an alt-drag is placing it. */
|
||||
.paint-overlay .handles .pivot.moving { stroke: #7ad7a0; stroke-width: 0.9; }
|
||||
.paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }
|
||||
|
||||
/* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue