From 9e25cf155ae9f97b08e400f917775a8f97dab781 Mon Sep 17 00:00:00 2001
From: th
Date: Sun, 26 Apr 2026 20:24:23 +0200
Subject: [PATCH] =?UTF-8?q?1.2.10:=20wiggle=20=E2=80=94=20deterministic=20?=
=?UTF-8?q?procedural=20shake=20modifier=20per=20layer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.
Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
layer, derived from the id so the same layer always wobbles the
same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
cheap, no noise-lib dependency. Phase constants are irrational
ratios picked so the streams don't collapse into a low-period
pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.
Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
sample with applyWiggle. Every renderer goes through sampleLayer
(canvas2d, Konva, button-state, clip-internal), so the modifier
lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
post-process branch in the engine's sample(). RUNTIME_VERSION
31 → 32.
Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
individual fields with magnitude clamping (≥ 0); null deletes
the whole wiggle field.
UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
(PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
for position amplitude (0-64 px), rotation amplitude (0-45°),
and frequency (0-10 Hz). Hidden for button-asset layers
(consistent with Kaleido's hit-area constraint).
Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
time points.
- layerSeeds stability + the three seeds are pairwise distinct;
different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
and bumps RUNTIME_VERSION to '32'.
Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
claude.txt | 108 ++++++++++++++++++++
package.json | 2 +-
src/components/PropertiesPanel.vue | 90 ++++++++++++++++-
src/devtools/tests.ts | 152 ++++++++++++++++++++++++++++-
src/i18n/messages.ts | 8 ++
src/lib/exportPlayer.ts | 67 ++++++++++---
src/lib/interpolate.ts | 34 ++++---
src/lib/wiggle.ts | 83 ++++++++++++++++
src/stores/scene.ts | 21 ++++
src/types/scene.ts | 11 +++
src/version.ts | 2 +-
11 files changed, 546 insertions(+), 32 deletions(-)
create mode 100644 claude.txt
create mode 100644 src/lib/wiggle.ts
diff --git a/claude.txt b/claude.txt
new file mode 100644
index 0000000..8f27ed7
--- /dev/null
+++ b/claude.txt
@@ -0,0 +1,108 @@
+FUTURE FUN: THREE FEATURES TO LAND
+==================================
+
+End-of-day backlog of ideas Claude pitched and the user liked. Each
+sized for ~one patch bump. Wiggle goes first; the others can wait.
+
+
+1. WIGGLE — procedural shake modifier (per layer)
+-------------------------------------------------
+
+Add a "Wiggle" property to Layer. When on, every sampled frame
+adds deterministic noise to position and/or rotation, giving any
+layer that classic animator wobble. Pairs thematically with Kaleido:
+both are per-layer procedural modifiers stacked on top of the base
+keyframe sample.
+
+Data model (src/types/scene.ts):
+ wiggle?: {
+ amplitude?: number // pixels for x/y
+ rotation?: number // degrees for rotation
+ frequency?: number // Hz (cycles per second)
+ }
+ // Defaults when `wiggle` is set but a sub-field is missing:
+ // amplitude=8px, rotation=2°, frequency=2Hz. Toggle off ⇒ delete.
+
+Math (src/lib/wiggle.ts) — shared truth:
+ function layerSeed(id: string): [number, number, number]
+ // 3 stable 32-bit ints derived from the layer id, used as
+ // independent seeds for x / y / rotation noise streams.
+ function wiggleNoise(seed: number, t: number): number
+ // Deterministic [-1, 1] output. Sum of three sin waves with
+ // different irrational phase offsets — cheap, smooth, no
+ // dependency on a noise lib.
+ return (
+ sin(seed * 12.9898 + t * 78.233) +
+ sin(seed * 4.31 + t * 11.71 ) +
+ sin(seed * 7.71 + t * 23.5 )
+ ) / 3
+ function applyWiggle(props, layer, time): props'
+ // Adds amp * noise(seedX, t * freq) to props.x, etc.
+
+Wire-in:
+- src/lib/interpolate.ts sampleLayer: post-process the result of
+ the keyframe sample with applyWiggle. ALL renderers go through
+ sampleLayer (canvas2d, Konva, button-state, clip-internal), so a
+ single hook here lights up everywhere except the export engine.
+- src/lib/exportPlayer.ts: mirror the math in JS inside the engine
+ source so exports look identical. RUNTIME_VERSION bump.
+
+UI (src/components/PropertiesPanel.vue + i18n):
+- New "Wiggle" collapsible section, between Mask and Kaleido feels
+ natural. Toggle (PhWaveSine icon), then three RangeSliders:
+ amplitude (0–64 px), rotation (0–45°), frequency (0–10 Hz).
+ Hidden for button-asset layers (consistent with Kaleido).
+
+Store (src/stores/scene.ts):
+- setLayerWiggle(id, patch) with same delete-on-default semantics
+ as Kaleido (rotation=0 ⇒ delete the field, etc.).
+
+Tests:
+- wiggleNoise determinism: noise(seed, t) === noise(seed, t).
+- wiggleNoise smoothness: |n(t) - n(t+1/60)| stays bounded.
+- sampleLayer with wiggle: x/y/rotation drift around base, return
+ to base when wiggle is off.
+- Engine source carries wiggleNoise; same sample at the same time
+ matches between editor and engine.
+- PropertiesPanel renders the new section + writes wiggle fields
+ back to the store.
+
+
+2. MOTION-PATH OVERLAY (selected layer's position keyframes)
+------------------------------------------------------------
+
+Visualize a layer's keyframed path on the canvas as a faint dashed
+line connecting consecutive position keyframes, with each keyframe
+rendered as a draggable dot. The position-keyframe drag-handle
+infrastructure (PathFrame, activeSegments in CanvasStage.vue ~2562)
+is already 70% there — currently only used when a position-kf is
+explicitly being dragged. Promote that overlay to "always on while
+the layer is selected".
+
+Scope:
+- Toggle in the View menu (or a slash-key shortcut) called "Motion
+ paths" — global flag persisted in localStorage like
+ `useShowLocked`.
+- For the selected layer (and only the selected layer), draw the
+ overlay even when nothing is being dragged. Reuse the existing
+ segment / curve-sampling helpers.
+- Color follows layer.color so two layers' paths don't collide
+ visually.
+
+Bigger lift than Wiggle but mostly leveraging existing code.
+
+
+3. EYEDROPPER (color sampling from anywhere on the page)
+--------------------------------------------------------
+
+Tiny feature using the browser's EyeDropper API. Adds a small
+pipette icon next to every color swatch in the PropertiesPanel
+(fill, stroke, shadow color). Click → fires `new EyeDropper().open()`
+→ user picks any pixel on screen → result sets the swatch.
+
+Caveats:
+- Chromium-only API. Hide the button when `window.EyeDropper` is
+ undefined (Safari + Firefox) — a clean progressive enhancement.
+- Permission prompt is browser-handled, no extra plumbing.
+
+Smallest of the three. Good "polish day" snack.
diff --git a/package.json b/package.json
index fbc20b7..69710be 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.2.9",
+ "version": "1.2.10",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
index ea84769..b846618 100644
--- a/src/components/PropertiesPanel.vue
+++ b/src/components/PropertiesPanel.vue
@@ -8,6 +8,7 @@ import {
PhEyeSlash,
PhFlower,
PhMaskHappy,
+ PhWaveSine,
PhMinus,
PhPolygon,
PhPlus,
@@ -27,7 +28,7 @@ const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel
const SECTION_STORAGE_KEY = 'animatr:propSections'
-type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido'
+type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido' | 'wiggle'
const defaultOpen: Record = {
transform: true,
@@ -38,6 +39,7 @@ const defaultOpen: Record = {
mask: false,
button: true,
kaleido: false,
+ wiggle: false,
}
function readStoredOpen(): Record {
@@ -213,6 +215,40 @@ function setKaleidoOn(on: boolean) {
store.setLayerKaleido(selected.value.id, on)
}
+const wiggleOn = computed(() => !!selected.value?.wiggle)
+const wiggleAmplitude = computed({
+ get: () => selected.value?.wiggle?.amplitude ?? 8,
+ set: (v: number) => {
+ if (!selected.value) return
+ store.setLayerWiggle(selected.value.id, { amplitude: v })
+ },
+})
+const wiggleRotation = computed({
+ get: () => selected.value?.wiggle?.rotation ?? 2,
+ set: (v: number) => {
+ if (!selected.value) return
+ store.setLayerWiggle(selected.value.id, { rotation: v })
+ },
+})
+const wiggleFrequency = computed({
+ get: () => selected.value?.wiggle?.frequency ?? 2,
+ set: (v: number) => {
+ if (!selected.value) return
+ store.setLayerWiggle(selected.value.id, { frequency: v })
+ },
+})
+
+function setWiggleOn(on: boolean) {
+ if (!selected.value) return
+ if (on) {
+ // Seed with the pleasant defaults animators tend to start with;
+ // they can dial it down from there.
+ store.setLayerWiggle(selected.value.id, { amplitude: 8, rotation: 2, frequency: 2 })
+ } else {
+ store.setLayerWiggle(selected.value.id, null)
+ }
+}
+
function onKaleidoCountInput(e: Event) {
if (!selected.value) return
const v = Number((e.target as HTMLInputElement).value)
@@ -775,6 +811,58 @@ function deleteSelection() {
+
+
+