From 70cf131c47ffc809ec6acb9a6e6a0df86cc92e0f Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 5 Jun 2026 14:31:31 +0200
Subject: [PATCH] Make kaleido pivot keyframable
---
src/components/CanvasStage.vue | 59 ++++++++------
src/components/FontPicker.vue | 66 ++++++++++++----
src/components/PropertiesPanel.vue | 20 ++++-
src/devtools/tests.ts | 123 ++++++++++++++++++++++++++---
src/lib/exportPlayer.ts | 39 ++++++---
src/lib/interpolate.ts | 16 ++++
src/lib/kaleido.ts | 39 ++++-----
src/lib/render.ts | 4 +-
src/types/scene.ts | 6 ++
9 files changed, 285 insertions(+), 87 deletions(-)
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index a5ebc60..06522c9 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -561,36 +561,35 @@ function kaleidoIndicesForLayer(layer: Layer): number[] {
}
/** Augments a layer's Konva config with the kaleido per-copy
- * position + rotation. Pivot defaults to the layer's own (x, y),
- * but a per-layer override moves the orbit centre elsewhere on the
- * canvas — same arithmetic as render.ts/exportPlayer use, so all
- * three render paths land identical pixels. Non-source copies are
+ * position + rotation. Pivot lookup matches render.ts: live drag
+ * override → keyframed `props.kaleidoPivotX/Y` → static layer
+ * override → layer's own (x, y). Non-source copies are
* listening:false to keep events on the source. */
function withK>(
cfg: T,
layer: Layer,
+ props: AnimatableProps,
ki: number,
n: number,
): T {
if (n <= 1 || ki === 0) return cfg
- const sx = typeof cfg.x === 'number' ? (cfg.x as number) : 0
- const sy = typeof cfg.y === 'number' ? (cfg.y as number) : 0
- const srot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0
- // Live-pivot drag: when the user drags the on-stage handle for
- // this layer, route through liveKaleidoPivot so the orbit follows
- // the pointer in real time.
const live = liveKaleidoPivot.value
- const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx)
- const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy)
+ const liveOverride = live && live.instanceId === layer.id
+ const pivotX = liveOverride
+ ? live.x
+ : props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x
+ const pivotY = liveOverride
+ ? live.y
+ : props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y
const angle = kaleidoAngleDegrees(ki, n)
const a = (angle * Math.PI) / 180
- const dx = sx - pivotX
- const dy = sy - pivotY
+ const dx = props.x - pivotX
+ const dy = props.y - pivotY
return {
...cfg,
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
- rotation: srot + angle,
+ rotation: props.rotation + angle,
listening: false,
draggable: false,
} as T
@@ -1683,8 +1682,14 @@ const kaleidoPivotHandle = computed(() => {
if (asset?.kind === 'button') return null
const sampled = sampleLayer(layer, store.playhead)
const live = liveKaleidoPivot.value
- const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x)
- const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y)
+ // Pivot lookup matches render.ts: live drag → keyframed prop →
+ // legacy static layer field → sampled layer position.
+ const x = live && live.instanceId === lyId
+ ? live.x
+ : sampled.kaleidoPivotX ?? layer.kaleidoPivotX ?? sampled.x
+ const y = live && live.instanceId === lyId
+ ? live.y
+ : sampled.kaleidoPivotY ?? layer.kaleidoPivotY ?? sampled.y
return { kind: 'layer', layerId: layer.id, x, y }
}
return null
@@ -1752,9 +1757,11 @@ function onKaleidoPivotPointerUp() {
kaleidoPivotY: liveKaleidoPivot.value.y,
})
} else {
- store.setLayerKaleidoPivot(pivotDragEntity.layerId, {
- x: liveKaleidoPivot.value.x,
- y: liveKaleidoPivot.value.y,
+ // Layer pivot is keyframable. Upsert at the current playhead
+ // so dropping multiple kfs animates the orbit centre.
+ store.upsertKeyframe(pivotDragEntity.layerId, store.playhead, {
+ kaleidoPivotX: liveKaleidoPivot.value.x,
+ kaleidoPivotY: liveKaleidoPivot.value.y,
})
}
}
@@ -4496,7 +4503,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
Your fonts
-
+
@@ -327,6 +331,38 @@ function isActive(id: string): boolean {
color: var(--accent);
}
+.font-popover .custom-font-row {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ color: var(--text);
+}
+
+.font-popover .custom-font-row:hover {
+ background: var(--panel-hover, var(--panel-alt));
+}
+
+.font-popover .custom-font-row.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.font-popover .custom-font-select {
+ flex: 1 1 auto;
+ min-width: 0;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ padding: 6px 0 6px 8px;
+}
+
+.font-popover .custom-font-select:hover {
+ background: transparent;
+}
+
.font-popover .check {
flex-shrink: 0;
}
@@ -352,10 +388,12 @@ function isActive(id: string): boolean {
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
+ margin-right: 4px;
opacity: 0;
}
-.font-popover .font-option:hover .delete-custom {
+.font-popover .custom-font-row:hover .delete-custom,
+.font-popover .delete-custom:focus-visible {
opacity: 1;
}
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
index d359d38..1ffd60a 100644
--- a/src/components/PropertiesPanel.vue
+++ b/src/components/PropertiesPanel.vue
@@ -225,17 +225,25 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido)
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
+/** Display value for the layer's kaleido pivot inputs. Reads the
+ * sampled keyframe value first (so a moving pivot animates the
+ * number too), then the layer's static legacy override, then the
+ * layer's own (x, y). Rounded to int for compact display. */
const kaleidoPivotX = computed(() => {
const l = selected.value
if (!l) return 0
+ const s = sampled.value
+ if (s?.kaleidoPivotX !== undefined) return Math.round(s.kaleidoPivotX)
if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX)
- return Math.round(sampled.value?.x ?? 0)
+ return Math.round(s?.x ?? 0)
})
const kaleidoPivotY = computed(() => {
const l = selected.value
if (!l) return 0
+ const s = sampled.value
+ if (s?.kaleidoPivotY !== undefined) return Math.round(s.kaleidoPivotY)
if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY)
- return Math.round(sampled.value?.y ?? 0)
+ return Math.round(s?.y ?? 0)
})
function setKaleidoOn(on: boolean) {
@@ -243,9 +251,15 @@ function setKaleidoOn(on: boolean) {
store.setLayerKaleido(selected.value.id, on)
}
+/** Pivot writes go through the keyframe pipeline — same flow as
+ * Transform X/Y. Animators can drop multiple kfs and the orbit
+ * centre interpolates between them. */
function setKaleidoPivot(patch: { x?: number; y?: number }) {
if (!selected.value) return
- store.setLayerKaleidoPivot(selected.value.id, patch)
+ const props: Partial = {}
+ if (patch.x !== undefined) props.kaleidoPivotX = patch.x
+ if (patch.y !== undefined) props.kaleidoPivotY = patch.y
+ store.upsertKeyframe(selected.value.id, store.playhead, props)
}
const wiggleOn = computed(() => !!selected.value?.wiggle)
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 9a48ab0..3e6f6b3 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -15554,7 +15554,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work.
- expect(src).toContain("'33'")
+ expect(src).toContain("'34'")
})
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15932,7 +15932,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing.
- expect(src).toContain("'33'")
+ expect(src).toContain("'34'")
})
})
@@ -16207,24 +16207,39 @@ export function registerAllTests(): void {
suite('layer kaleido pivot — separate orbit centre', () => {
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
const { layerArmTransform } = await import('@/lib/kaleido')
+ const baseProps = (x: number, y: number, rot = 0) => ({
+ x, y, rotation: rot, scaleX: 1, scaleY: 1, opacity: 1,
+ }) as unknown as import('@/types/scene').AnimatableProps
// Layer at (100, 100), no custom pivot → arm 1 of 4 rotates
// 90° around (100, 100): same result as no-pivot baseline.
const layer = { id: 'L', kaleido: true, kaleidoCount: 4 } as unknown as import('@/types/scene').Layer
- const a = layerArmTransform(layer, 100, 100, 0, 1, 4)
+ const a = layerArmTransform(layer, baseProps(100, 100), 1, 4)
// dx=dy=0 → arm 1 stays at pivot.
expect(a.x).toBeCloseTo(100, 5)
expect(a.y).toBeCloseTo(100, 5)
expect(a.rotation).toBeCloseTo(90, 5)
- // Custom pivot at (50, 50) shifts the orbit centre. Layer at
- // (100, 100), arm 1 of 4 (90°) around (50, 50):
- // dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100)
+ // Static legacy override at (50, 50): same rotation, layer at
+ // (100, 100). Arm 1 (90°): dx=50, dy=50 → after R(90°): (-50, 50)
+ // → world (0, 100).
const layerWithPivot = {
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
} as unknown as import('@/types/scene').Layer
- const b = layerArmTransform(layerWithPivot, 100, 100, 0, 1, 4)
+ const b = layerArmTransform(layerWithPivot, baseProps(100, 100), 1, 4)
expect(b.x).toBeCloseTo(0, 5)
expect(b.y).toBeCloseTo(100, 5)
expect(b.rotation).toBeCloseTo(90, 5)
+ // Keyframed pivot via props takes precedence over the legacy
+ // layer-level override. Pivot via props (200, 200), layer
+ // override (50, 50) — props wins. dx=−100, dy=−100 → R(90°): (100, −100)
+ // → world (300, 100).
+ const c = layerArmTransform(
+ layerWithPivot,
+ { ...baseProps(100, 100), kaleidoPivotX: 200, kaleidoPivotY: 200 } as import('@/types/scene').AnimatableProps,
+ 1,
+ 4,
+ )
+ expect(c.x).toBeCloseTo(300, 5)
+ expect(c.y).toBeCloseTo(100, 5)
})
test('store.setLayerKaleidoPivot: patch + null clear', () => {
@@ -16322,14 +16337,20 @@ export function registerAllTests(): void {
expect(pivotInputs.length).toBe(2)
expect(Number(pivotInputs[0].value)).toBe(50)
expect(Number(pivotInputs[1].value)).toBe(50)
- // Type a new pivot X via @input.
+ // Type a new pivot X via @input — pivot writes go through the
+ // keyframe pipeline now (same path as Transform X/Y).
pivotInputs[0].value = '120'
pivotInputs[0].dispatchEvent(new Event('input'))
await nextTick()
const stored = s.scene.layers.find((l) => l.id === layer.id)!
- expect(stored.kaleidoPivotX).toBe(120)
- // Y stays at the sampled default until the user touches it.
- expect(stored.kaleidoPivotY === undefined).toBe(true)
+ // Find the upserted keyframe at the playhead.
+ const kf = stored.keyframes.find((k) => Math.abs(k.time - s.playhead) < 1e-3)
+ expect(!!kf).toBe(true)
+ expect(kf!.props.kaleidoPivotX).toBe(120)
+ // Y stays unset on the kf since the user only touched X.
+ expect(kf!.props.kaleidoPivotY === undefined).toBe(true)
+ // The legacy top-level field is untouched (still undefined).
+ expect(stored.kaleidoPivotX === undefined).toBe(true)
} finally {
handle.unmount()
}
@@ -16361,11 +16382,89 @@ export function registerAllTests(): void {
}
})
+ test('layer kaleido pivot interpolates between keyframes', async () => {
+ // Keyframe pivot at (0, 0) at t=0 and (200, 200) at t=1.
+ // At t=0.5 the sampled pivot should land at (100, 100). Verify
+ // by checking arm 1 (90° of 4) of a layer at world (50, 50):
+ // dx=50-100=-50, dy=50-100=-50 → R(90°): dx'=50, dy'=-50
+ // → world (150, 50).
+ const { sampleLayer } = await import('@/lib/interpolate')
+ const { layerArmTransform } = await import('@/lib/kaleido')
+ const layer = {
+ id: 'L',
+ name: 'L',
+ assetId: 'a',
+ visible: true,
+ base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
+ keyframes: [
+ {
+ id: 'k1', time: 0, easing: 'linear',
+ props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 0, kaleidoPivotY: 0 },
+ },
+ {
+ id: 'k2', time: 1, easing: 'linear',
+ props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 200, kaleidoPivotY: 200 },
+ },
+ ],
+ kaleido: true,
+ kaleidoCount: 4,
+ } as unknown as import('@/types/scene').Layer
+ const props = sampleLayer(layer, 0.5)
+ expect(props.kaleidoPivotX).toBeCloseTo(100, 5)
+ expect(props.kaleidoPivotY).toBeCloseTo(100, 5)
+ const tf = layerArmTransform(layer, props, 1, 4)
+ expect(tf.x).toBeCloseTo(150, 5)
+ expect(tf.y).toBeCloseTo(50, 5)
+ })
+
+ test('layer kaleido pivot: one-sided override lerps from layer position, not snap', async () => {
+ // Regression: when only ONE of two adjacent keyframes sets
+ // kaleidoPivotX/Y, the older lerp logic snapped the entire
+ // interval to the defined value (because optional props
+ // default to "carry through"). Pivot should instead lerp from
+ // the layer's own (x, y) on the unset side to the explicit
+ // value on the other — so adding a single late kf produces a
+ // smooth orbit-centre transition, not an instant jump.
+ const { sampleLayer } = await import('@/lib/interpolate')
+ const layer = {
+ id: 'L',
+ name: 'L',
+ assetId: 'a',
+ visible: true,
+ base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
+ keyframes: [
+ {
+ id: 'k1', time: 0, easing: 'linear',
+ // No kaleidoPivotX/Y — layer's own (50, 50) is the implicit start.
+ props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
+ },
+ {
+ id: 'k2', time: 1, easing: 'linear',
+ props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 250, kaleidoPivotY: 250 },
+ },
+ ],
+ kaleido: true,
+ kaleidoCount: 4,
+ } as unknown as import('@/types/scene').Layer
+ // At t=0 → pivot starts at the layer's own position (50, 50).
+ const start = sampleLayer(layer, 0)
+ expect(start.kaleidoPivotX).toBeCloseTo(50, 5)
+ expect(start.kaleidoPivotY).toBeCloseTo(50, 5)
+ // At t=0.5 → halfway between (50, 50) and (250, 250) = (150, 150).
+ const mid = sampleLayer(layer, 0.5)
+ expect(mid.kaleidoPivotX).toBeCloseTo(150, 5)
+ expect(mid.kaleidoPivotY).toBeCloseTo(150, 5)
+ // At t=1 → fully at the explicit kf2 value.
+ const end = sampleLayer(layer, 1)
+ expect(end.kaleidoPivotX).toBeCloseTo(250, 5)
+ expect(end.kaleidoPivotY).toBeCloseTo(250, 5)
+ })
+
test('exported runtime engine carries layerArmTf helper', () => {
const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(')
// Bumped on this work.
- expect(src).toContain("'33'")
+ expect(src).toContain("'34'")
})
})
}
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index 08777ec..15d1244 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial).scenes)
}
-const RUNTIME_VERSION = '33'
+const RUNTIME_VERSION = '34'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
@@ -107,6 +107,17 @@ const ENGINE_TEMPLATE = `(function(){
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.shadowColor;
+ // Kaleido pivot: mirror src/lib/interpolate.ts. A one-sided
+ // override lerps from the layer's own (x, y) on the unset side
+ // to the explicit value on the other.
+ var apx=a.kaleidoPivotX, bpx=b.kaleidoPivotX;
+ if(apx!=null || bpx!=null){
+ out.kaleidoPivotX = lerp(apx!=null?apx:a.x, bpx!=null?bpx:b.x, t);
+ }
+ var apy=a.kaleidoPivotY, bpy=b.kaleidoPivotY;
+ if(apy!=null || bpy!=null){
+ out.kaleidoPivotY = lerp(apy!=null?apy:a.y, bpy!=null?bpy:b.y, t);
+ }
return out;
}
// Interpolate a single keyframe track against 'base' at 'time'.
@@ -736,20 +747,24 @@ const ENGINE_TEMPLATE = `(function(){
return n<2?1:n;
}
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
- // Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the
- // pivot to the layer's sampled (x, y); a per-layer override can
- // shift it elsewhere on the canvas.
- function layerArmTf(layer, sx, sy, srot, i, n){
- if(i===0||n<=1) return {x:sx,y:sy,rotation:srot};
- var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX);
- var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY);
+ // Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup:
+ // sampled props (keyframed) → layer's static legacy override →
+ // layer's own sampled (x, y).
+ function layerArmTf(layer, props, i, n){
+ if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation};
+ var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX);
+ var ppy=(props.kaleidoPivotY==null?null:props.kaleidoPivotY);
+ var lpx=(layer.kaleidoPivotX==null?null:layer.kaleidoPivotX);
+ var lpy=(layer.kaleidoPivotY==null?null:layer.kaleidoPivotY);
+ var px=(ppx!=null?ppx:(lpx!=null?lpx:props.x));
+ var py=(ppy!=null?ppy:(lpy!=null?lpy:props.y));
var ang=kalAngle(i,n);
var a=ang*Math.PI/180;
- var dx=sx-px, dy=sy-py;
+ var dx=props.x-px, dy=props.y-py;
return {
x: px+dx*Math.cos(a)-dy*Math.sin(a),
y: py+dx*Math.sin(a)+dy*Math.cos(a),
- rotation: srot+ang
+ rotation: props.rotation+ang
};
}
function clipKaleidoPivot(inst){
@@ -833,7 +848,7 @@ const ENGINE_TEMPLATE = `(function(){
var p=sample(layer,time);
var kn=kalCount(layer);
for(var ki=0;ki {
const pb = b.points![i]
diff --git a/src/lib/kaleido.ts b/src/lib/kaleido.ts
index 842001e..8331abb 100644
--- a/src/lib/kaleido.ts
+++ b/src/lib/kaleido.ts
@@ -4,7 +4,7 @@
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
// JS) all derive identical geometry from the same definitions.
-import type { Layer, ClipInstance } from '@/types/scene'
+import type { AnimatableProps, Layer, ClipInstance } from '@/types/scene'
/** Number of radial copies to render. Returns 1 when kaleido is off
* or the count is below the floor — the caller should treat 1 as
@@ -107,42 +107,45 @@ export function layerKaleidoCount(layer: Layer): number {
return kaleidoCountFor(layer)
}
-/** Effective pivot for a layer. Defaults to the layer's sampled
- * position; an explicit `kaleidoPivotX/Y` overrides per-axis. */
+/** Effective pivot for a layer. Lookup chain:
+ * 1. `props.kaleidoPivotX/Y` — keyframed value (lets the pivot
+ * animate)
+ * 2. `Layer.kaleidoPivotX/Y` — legacy static override (kept for
+ * back-compat with projects saved before the feature went
+ * keyframable)
+ * 3. layer's sampled (x, y) — default
+ */
export function layerKaleidoPivot(
layer: Layer,
- sampledX: number,
- sampledY: number,
+ props: AnimatableProps,
): { x: number; y: number } {
return {
- x: layer.kaleidoPivotX ?? sampledX,
- y: layer.kaleidoPivotY ?? sampledY,
+ x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x,
+ y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y,
}
}
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
- * layer. The source (index 0) is unchanged. Higher indices
- * rotate around the pivot — both their position AND their
- * orientation, since layer kaleido always rotates copies. */
+ * layer. The source (index 0) is unchanged. Higher indices rotate
+ * around the pivot — both their position AND their orientation,
+ * since layer kaleido always rotates copies. */
export function layerArmTransform(
layer: Layer,
- sampledX: number,
- sampledY: number,
- sampledRotation: number,
+ props: AnimatableProps,
index: number,
count: number,
): { x: number; y: number; rotation: number } {
if (index === 0 || count <= 1) {
- return { x: sampledX, y: sampledY, rotation: sampledRotation }
+ return { x: props.x, y: props.y, rotation: props.rotation }
}
- const { x: px, y: py } = layerKaleidoPivot(layer, sampledX, sampledY)
+ const { x: px, y: py } = layerKaleidoPivot(layer, props)
const angle = kaleidoAngleDegrees(index, count)
const a = (angle * Math.PI) / 180
- const dx = sampledX - px
- const dy = sampledY - py
+ const dx = props.x - px
+ const dy = props.y - py
return {
x: px + dx * Math.cos(a) - dy * Math.sin(a),
y: py + dx * Math.sin(a) + dy * Math.cos(a),
- rotation: sampledRotation + angle,
+ rotation: props.rotation + angle,
}
}
diff --git a/src/lib/render.ts b/src/lib/render.ts
index 5b13840..0d04e9b 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -164,7 +164,7 @@ function drawLayers(
// their own internal layers and click routing must stay 1:1).
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) {
- const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount)
+ const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.globalAlpha *= p.opacity
ctx.translate(tf.x, tf.y)
@@ -361,7 +361,7 @@ function drawMaskShape(
// N rotated shapes.
const kCount = layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) {
- const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount)
+ const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.translate(tf.x, tf.y)
ctx.rotate((tf.rotation * Math.PI) / 180)
diff --git a/src/types/scene.ts b/src/types/scene.ts
index 053482f..9fa3d9c 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -29,6 +29,12 @@ export interface AnimatableProps {
points?: Point[]
stroke?: string
strokeWidth?: number
+ /** Kaleidoscope pivot — keyframable like x/y. When unset on every
+ * reachable kf, falls back to the layer's static
+ * `Layer.kaleidoPivotX/Y` (legacy field), then to (x, y). Lets the
+ * pivot itself dance through the scene. */
+ kaleidoPivotX?: number
+ kaleidoPivotY?: number
}
export interface Keyframe {
|