Make kaleido pivot keyframable
This commit is contained in:
1 parent
c646f66e8f
commit
70cf131c47
9 files changed
+285
-87
No files matched your search
@@ -561,36 +561,35 @@ function kaleidoIndicesForLayer(layer: Layer): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Augments a layer's Konva config with the kaleido per-copy
|
/** Augments a layer's Konva config with the kaleido per-copy
|
||||||
* position + rotation. Pivot defaults to the layer's own (x, y),
|
* position + rotation. Pivot lookup matches render.ts: live drag
|
||||||
* but a per-layer override moves the orbit centre elsewhere on the
|
* override → keyframed `props.kaleidoPivotX/Y` → static layer
|
||||||
* canvas — same arithmetic as render.ts/exportPlayer use, so all
|
* override → layer's own (x, y). Non-source copies are
|
||||||
* three render paths land identical pixels. Non-source copies are
|
|
||||||
* listening:false to keep events on the source. */
|
* listening:false to keep events on the source. */
|
||||||
function withK<T extends Record<string, unknown>>(
|
function withK<T extends Record<string, unknown>>(
|
||||||
cfg: T,
|
cfg: T,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
|
props: AnimatableProps,
|
||||||
ki: number,
|
ki: number,
|
||||||
n: number,
|
n: number,
|
||||||
): T {
|
): T {
|
||||||
if (n <= 1 || ki === 0) return cfg
|
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 live = liveKaleidoPivot.value
|
||||||
const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx)
|
const liveOverride = live && live.instanceId === layer.id
|
||||||
const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy)
|
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 angle = kaleidoAngleDegrees(ki, n)
|
||||||
const a = (angle * Math.PI) / 180
|
const a = (angle * Math.PI) / 180
|
||||||
const dx = sx - pivotX
|
const dx = props.x - pivotX
|
||||||
const dy = sy - pivotY
|
const dy = props.y - pivotY
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
|
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
|
||||||
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
|
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
|
||||||
rotation: srot + angle,
|
rotation: props.rotation + angle,
|
||||||
listening: false,
|
listening: false,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
} as T
|
} as T
|
||||||
@@ -1683,8 +1682,14 @@ const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
|
|||||||
if (asset?.kind === 'button') return null
|
if (asset?.kind === 'button') return null
|
||||||
const sampled = sampleLayer(layer, store.playhead)
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
const live = liveKaleidoPivot.value
|
const live = liveKaleidoPivot.value
|
||||||
const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x)
|
// Pivot lookup matches render.ts: live drag → keyframed prop →
|
||||||
const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y)
|
// 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 { kind: 'layer', layerId: layer.id, x, y }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -1752,9 +1757,11 @@ function onKaleidoPivotPointerUp() {
|
|||||||
kaleidoPivotY: liveKaleidoPivot.value.y,
|
kaleidoPivotY: liveKaleidoPivot.value.y,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
store.setLayerKaleidoPivot(pivotDragEntity.layerId, {
|
// Layer pivot is keyframable. Upsert at the current playhead
|
||||||
x: liveKaleidoPivot.value.x,
|
// so dropping multiple kfs animates the orbit centre.
|
||||||
y: liveKaleidoPivot.value.y,
|
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-group
|
||||||
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4581,7 +4588,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
</v-group>
|
</v-group>
|
||||||
<v-rect
|
<v-rect
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4589,7 +4596,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-circle
|
<v-circle
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4597,7 +4604,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-text
|
<v-text
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4605,7 +4612,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-line
|
<v-line
|
||||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4613,7 +4620,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
|
|||||||
@@ -164,22 +164,26 @@ function isActive(id: string): boolean {
|
|||||||
|
|
||||||
<div v-if="customFonts.length > 0" class="font-popover-section">
|
<div v-if="customFonts.length > 0" class="font-popover-section">
|
||||||
<div class="section-title">Your fonts</div>
|
<div class="section-title">Your fonts</div>
|
||||||
<button
|
<div
|
||||||
v-for="f in customFonts"
|
v-for="f in customFonts"
|
||||||
:key="f.id"
|
:key="f.id"
|
||||||
type="button"
|
class="custom-font-row"
|
||||||
class="font-option"
|
|
||||||
:class="{ active: isActive(f.id) }"
|
:class="{ active: isActive(f.id) }"
|
||||||
role="option"
|
|
||||||
:aria-selected="isActive(f.id)"
|
|
||||||
@click="pick(f.id)"
|
|
||||||
>
|
>
|
||||||
<PhCheck
|
<button
|
||||||
:size="12"
|
type="button"
|
||||||
class="check"
|
class="font-option custom-font-select"
|
||||||
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
|
role="option"
|
||||||
/>
|
:aria-selected="isActive(f.id)"
|
||||||
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
|
@click="pick(f.id)"
|
||||||
|
>
|
||||||
|
<PhCheck
|
||||||
|
:size="12"
|
||||||
|
class="check"
|
||||||
|
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
|
||||||
|
/>
|
||||||
|
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="delete-custom"
|
class="delete-custom"
|
||||||
@@ -188,7 +192,7 @@ function isActive(id: string): boolean {
|
|||||||
>
|
>
|
||||||
<PhTrash :size="12" />
|
<PhTrash :size="12" />
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="font-popover-divider"></div>
|
<div class="font-popover-divider"></div>
|
||||||
@@ -327,6 +331,38 @@ function isActive(id: string): boolean {
|
|||||||
color: var(--accent);
|
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 {
|
.font-popover .check {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -352,10 +388,12 @@ function isActive(id: string): boolean {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-right: 4px;
|
||||||
opacity: 0;
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,17 +225,25 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
|||||||
|
|
||||||
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
|
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 kaleidoPivotX = computed(() => {
|
||||||
const l = selected.value
|
const l = selected.value
|
||||||
if (!l) return 0
|
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)
|
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 kaleidoPivotY = computed(() => {
|
||||||
const l = selected.value
|
const l = selected.value
|
||||||
if (!l) return 0
|
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)
|
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) {
|
function setKaleidoOn(on: boolean) {
|
||||||
@@ -243,9 +251,15 @@ function setKaleidoOn(on: boolean) {
|
|||||||
store.setLayerKaleido(selected.value.id, on)
|
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 }) {
|
function setKaleidoPivot(patch: { x?: number; y?: number }) {
|
||||||
if (!selected.value) return
|
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)
|
const wiggleOn = computed(() => !!selected.value?.wiggle)
|
||||||
|
|||||||
+111
-12
@@ -15554,7 +15554,7 @@ export function registerAllTests(): void {
|
|||||||
expect(src).toContain('clipKaleidoPivot')
|
expect(src).toContain('clipKaleidoPivot')
|
||||||
expect(src).toContain('clipArmLocalT')
|
expect(src).toContain('clipArmLocalT')
|
||||||
// Runtime version got bumped during the kaleido + wiggle work.
|
// 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 () => {
|
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
|
||||||
@@ -15932,7 +15932,7 @@ export function registerAllTests(): void {
|
|||||||
expect(src).toContain('function wiggleSeeds(')
|
expect(src).toContain('function wiggleSeeds(')
|
||||||
// Runtime version got bumped through the wiggle work and the
|
// Runtime version got bumped through the wiggle work and the
|
||||||
// later layer-kaleido-pivot landing.
|
// 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', () => {
|
suite('layer kaleido pivot — separate orbit centre', () => {
|
||||||
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
|
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
|
||||||
const { layerArmTransform } = await import('@/lib/kaleido')
|
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
|
// Layer at (100, 100), no custom pivot → arm 1 of 4 rotates
|
||||||
// 90° around (100, 100): same result as no-pivot baseline.
|
// 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 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.
|
// dx=dy=0 → arm 1 stays at pivot.
|
||||||
expect(a.x).toBeCloseTo(100, 5)
|
expect(a.x).toBeCloseTo(100, 5)
|
||||||
expect(a.y).toBeCloseTo(100, 5)
|
expect(a.y).toBeCloseTo(100, 5)
|
||||||
expect(a.rotation).toBeCloseTo(90, 5)
|
expect(a.rotation).toBeCloseTo(90, 5)
|
||||||
// Custom pivot at (50, 50) shifts the orbit centre. Layer at
|
// Static legacy override at (50, 50): same rotation, layer at
|
||||||
// (100, 100), arm 1 of 4 (90°) around (50, 50):
|
// (100, 100). Arm 1 (90°): dx=50, dy=50 → after R(90°): (-50, 50)
|
||||||
// dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100)
|
// → world (0, 100).
|
||||||
const layerWithPivot = {
|
const layerWithPivot = {
|
||||||
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
|
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
|
||||||
} as unknown as import('@/types/scene').Layer
|
} 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.x).toBeCloseTo(0, 5)
|
||||||
expect(b.y).toBeCloseTo(100, 5)
|
expect(b.y).toBeCloseTo(100, 5)
|
||||||
expect(b.rotation).toBeCloseTo(90, 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', () => {
|
test('store.setLayerKaleidoPivot: patch + null clear', () => {
|
||||||
@@ -16322,14 +16337,20 @@ export function registerAllTests(): void {
|
|||||||
expect(pivotInputs.length).toBe(2)
|
expect(pivotInputs.length).toBe(2)
|
||||||
expect(Number(pivotInputs[0].value)).toBe(50)
|
expect(Number(pivotInputs[0].value)).toBe(50)
|
||||||
expect(Number(pivotInputs[1].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].value = '120'
|
||||||
pivotInputs[0].dispatchEvent(new Event('input'))
|
pivotInputs[0].dispatchEvent(new Event('input'))
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const stored = s.scene.layers.find((l) => l.id === layer.id)!
|
const stored = s.scene.layers.find((l) => l.id === layer.id)!
|
||||||
expect(stored.kaleidoPivotX).toBe(120)
|
// Find the upserted keyframe at the playhead.
|
||||||
// Y stays at the sampled default until the user touches it.
|
const kf = stored.keyframes.find((k) => Math.abs(k.time - s.playhead) < 1e-3)
|
||||||
expect(stored.kaleidoPivotY === undefined).toBe(true)
|
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 {
|
} finally {
|
||||||
handle.unmount()
|
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', () => {
|
test('exported runtime engine carries layerArmTf helper', () => {
|
||||||
const src = generateRuntimeScript()
|
const src = generateRuntimeScript()
|
||||||
expect(src).toContain('function layerArmTf(')
|
expect(src).toContain('function layerArmTf(')
|
||||||
// Bumped on this work.
|
// Bumped on this work.
|
||||||
expect(src).toContain("'33'")
|
expect(src).toContain("'34'")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+27
-12
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
|
|||||||
return Array.isArray((p as Partial<Project>).scenes)
|
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
|
/** Materialize the engine source. The font map is rebuilt on every
|
||||||
* call so user-uploaded custom fonts (which live in localStorage
|
* 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);
|
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
|
||||||
else if(b.shadowColor) out.shadowColor=b.shadowColor;
|
else if(b.shadowColor) out.shadowColor=b.shadowColor;
|
||||||
else if(a.shadowColor) out.shadowColor=a.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;
|
return out;
|
||||||
}
|
}
|
||||||
// Interpolate a single keyframe track against 'base' at 'time'.
|
// Interpolate a single keyframe track against 'base' at 'time'.
|
||||||
@@ -736,20 +747,24 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
return n<2?1:n;
|
return n<2?1:n;
|
||||||
}
|
}
|
||||||
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
|
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
|
||||||
// Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the
|
// Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup:
|
||||||
// pivot to the layer's sampled (x, y); a per-layer override can
|
// sampled props (keyframed) → layer's static legacy override →
|
||||||
// shift it elsewhere on the canvas.
|
// layer's own sampled (x, y).
|
||||||
function layerArmTf(layer, sx, sy, srot, i, n){
|
function layerArmTf(layer, props, i, n){
|
||||||
if(i===0||n<=1) return {x:sx,y:sy,rotation:srot};
|
if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation};
|
||||||
var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX);
|
var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX);
|
||||||
var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY);
|
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 ang=kalAngle(i,n);
|
||||||
var a=ang*Math.PI/180;
|
var a=ang*Math.PI/180;
|
||||||
var dx=sx-px, dy=sy-py;
|
var dx=props.x-px, dy=props.y-py;
|
||||||
return {
|
return {
|
||||||
x: px+dx*Math.cos(a)-dy*Math.sin(a),
|
x: px+dx*Math.cos(a)-dy*Math.sin(a),
|
||||||
y: py+dx*Math.sin(a)+dy*Math.cos(a),
|
y: py+dx*Math.sin(a)+dy*Math.cos(a),
|
||||||
rotation: srot+ang
|
rotation: props.rotation+ang
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function clipKaleidoPivot(inst){
|
function clipKaleidoPivot(inst){
|
||||||
@@ -833,7 +848,7 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
var kn=kalCount(layer);
|
var kn=kalCount(layer);
|
||||||
for(var ki=0;ki<kn;ki++){
|
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.save();
|
||||||
ctxm.translate(tfm.x,tfm.y);
|
ctxm.translate(tfm.x,tfm.y);
|
||||||
ctxm.rotate(tfm.rotation*Math.PI/180);
|
ctxm.rotate(tfm.rotation*Math.PI/180);
|
||||||
@@ -932,7 +947,7 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
// areas would break click routing.
|
// areas would break click routing.
|
||||||
var kn=(asset.kind==='button')?1:kalCount(layer);
|
var kn=(asset.kind==='button')?1:kalCount(layer);
|
||||||
for(var ki=0;ki<kn;ki++){
|
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.save();
|
||||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||||
ctx.translate(tfL.x,tfL.y);
|
ctx.translate(tfL.x,tfL.y);
|
||||||
|
|||||||
@@ -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)
|
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 (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
|
||||||
else if (a.strokeWidth !== undefined) out.strokeWidth = a.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) {
|
if (a.points && b.points && a.points.length === b.points.length) {
|
||||||
out.points = a.points.map((pa, i) => {
|
out.points = a.points.map((pa, i) => {
|
||||||
const pb = b.points![i]
|
const pb = b.points![i]
|
||||||
|
|||||||
+21
-18
@@ -4,7 +4,7 @@
|
|||||||
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
|
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
|
||||||
// JS) all derive identical geometry from the same definitions.
|
// 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
|
/** 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
|
* 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)
|
return kaleidoCountFor(layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effective pivot for a layer. Defaults to the layer's sampled
|
/** Effective pivot for a layer. Lookup chain:
|
||||||
* position; an explicit `kaleidoPivotX/Y` overrides per-axis. */
|
* 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(
|
export function layerKaleidoPivot(
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
sampledX: number,
|
props: AnimatableProps,
|
||||||
sampledY: number,
|
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: layer.kaleidoPivotX ?? sampledX,
|
x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x,
|
||||||
y: layer.kaleidoPivotY ?? sampledY,
|
y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
|
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
|
||||||
* layer. The source (index 0) is unchanged. Higher indices
|
* layer. The source (index 0) is unchanged. Higher indices rotate
|
||||||
* rotate around the pivot — both their position AND their
|
* around the pivot — both their position AND their orientation,
|
||||||
* orientation, since layer kaleido always rotates copies. */
|
* since layer kaleido always rotates copies. */
|
||||||
export function layerArmTransform(
|
export function layerArmTransform(
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
sampledX: number,
|
props: AnimatableProps,
|
||||||
sampledY: number,
|
|
||||||
sampledRotation: number,
|
|
||||||
index: number,
|
index: number,
|
||||||
count: number,
|
count: number,
|
||||||
): { x: number; y: number; rotation: number } {
|
): { x: number; y: number; rotation: number } {
|
||||||
if (index === 0 || count <= 1) {
|
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 angle = kaleidoAngleDegrees(index, count)
|
||||||
const a = (angle * Math.PI) / 180
|
const a = (angle * Math.PI) / 180
|
||||||
const dx = sampledX - px
|
const dx = props.x - px
|
||||||
const dy = sampledY - py
|
const dy = props.y - py
|
||||||
return {
|
return {
|
||||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||||
rotation: sampledRotation + angle,
|
rotation: props.rotation + angle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -164,7 +164,7 @@ function drawLayers(
|
|||||||
// their own internal layers and click routing must stay 1:1).
|
// their own internal layers and click routing must stay 1:1).
|
||||||
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
|
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
|
||||||
for (let kf = 0; kf < kCount; kf++) {
|
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.save()
|
||||||
ctx.globalAlpha *= p.opacity
|
ctx.globalAlpha *= p.opacity
|
||||||
ctx.translate(tf.x, tf.y)
|
ctx.translate(tf.x, tf.y)
|
||||||
@@ -361,7 +361,7 @@ function drawMaskShape(
|
|||||||
// N rotated shapes.
|
// N rotated shapes.
|
||||||
const kCount = layerKaleidoCount(layer)
|
const kCount = layerKaleidoCount(layer)
|
||||||
for (let kf = 0; kf < kCount; kf++) {
|
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.save()
|
||||||
ctx.translate(tf.x, tf.y)
|
ctx.translate(tf.x, tf.y)
|
||||||
ctx.rotate((tf.rotation * Math.PI) / 180)
|
ctx.rotate((tf.rotation * Math.PI) / 180)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export interface AnimatableProps {
|
|||||||
points?: Point[]
|
points?: Point[]
|
||||||
stroke?: string
|
stroke?: string
|
||||||
strokeWidth?: number
|
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 {
|
export interface Keyframe {
|
||||||
|
|||||||
Reference in new issue
Block a user