Make kaleido pivot keyframable

This commit is contained in:
th committed 2026-06-05 14:31:31 +02:00
1 parent c646f66e8f
commit 70cf131c47
9 files changed
+276 -78

No files matched your search

+33 -26
View File
@@ -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<T extends Record<string, unknown>>(
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<KaleidoPivotEntity | null>(() => {
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) {
>
<v-group
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4581,7 +4588,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
</v-group>
<v-rect
v-else-if="entry.item.asset.kind === 'rect'"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4589,7 +4596,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4597,7 +4604,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4605,7 +4612,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4613,7 +4620,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
+43 -5
View File
@@ -164,12 +164,15 @@ function isActive(id: string): boolean {
<div v-if="customFonts.length > 0" class="font-popover-section">
<div class="section-title">Your fonts</div>
<button
<div
v-for="f in customFonts"
:key="f.id"
type="button"
class="font-option"
class="custom-font-row"
:class="{ active: isActive(f.id) }"
>
<button
type="button"
class="font-option custom-font-select"
role="option"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
@@ -180,6 +183,7 @@ function isActive(id: string): boolean {
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
<button
type="button"
class="delete-custom"
@@ -188,7 +192,7 @@ function isActive(id: string): boolean {
>
<PhTrash :size="12" />
</button>
</button>
</div>
</div>
<div class="font-popover-divider"></div>
@@ -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;
}
+17 -3
View File
@@ -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<import('@/types/scene').AnimatableProps> = {}
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)
+111 -12
View File
@@ -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'")
})
})
}
+27 -12
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).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<kn;ki++){
var tfm=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn);
var tfm=layerArmTf(layer, p, ki, kn);
ctxm.save();
ctxm.translate(tfm.x,tfm.y);
ctxm.rotate(tfm.rotation*Math.PI/180);
@@ -932,7 +947,7 @@ const ENGINE_TEMPLATE = `(function(){
// areas would break click routing.
var kn=(asset.kind==='button')?1:kalCount(layer);
for(var ki=0;ki<kn;ki++){
var tfL=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn);
var tfL=layerArmTf(layer, p, ki, kn);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(tfL.x,tfL.y);
+16
View File
@@ -69,6 +69,22 @@ export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): An
if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t)
else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth
// Kaleido pivot defaults to the layer's own (x, y) when unset on a
// keyframe — so a one-sided override (only b has kaleidoPivotX, a
// doesn't) lerps from a.x → b.kaleidoPivotX instead of snapping to
// b's value at t=0+. Same on the reverse direction.
{
const ax = a.kaleidoPivotX
const bx = b.kaleidoPivotX
if (ax !== undefined || bx !== undefined) {
out.kaleidoPivotX = lerp(ax ?? a.x, bx ?? b.x, t)
}
const ay = a.kaleidoPivotY
const by = b.kaleidoPivotY
if (ay !== undefined || by !== undefined) {
out.kaleidoPivotY = lerp(ay ?? a.y, by ?? b.y, t)
}
}
if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => {
const pb = b.points![i]
+21 -18
View File
@@ -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,
}
}
+2 -2
View File
@@ -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)
+6
View File
@@ -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 {