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() { +
+ +
+
+ {{ t('properties.wiggle.toggle') }} + +
+ + + +

{{ t('properties.wiggle.hint') }}

+
+
+