152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
// Math helpers for the kaleidoscope feature. Kept as pure data so
|
|
// the canvas2d offline renderer (lib/render.ts), the Konva live
|
|
// stage (components/CanvasStage.vue), AND the exported runtime
|
|
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
|
|
// JS) all derive identical geometry from the same definitions.
|
|
|
|
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
|
|
* "no kaleido, just render once unchanged". */
|
|
export function kaleidoCountFor(entity: { kaleido?: boolean; kaleidoCount?: number }): number {
|
|
if (!entity.kaleido) return 1
|
|
const n = Math.floor(entity.kaleidoCount ?? 0)
|
|
if (n < 2) return 1
|
|
return n
|
|
}
|
|
|
|
/** Per-copy radial angle in degrees. Index 0 → 0° (the source). */
|
|
export function kaleidoAngleDegrees(index: number, count: number): number {
|
|
if (count <= 1) return 0
|
|
return (index * 360) / count
|
|
}
|
|
|
|
/** ClipInstance kaleido pivot in world space. Defaults to the
|
|
* instance's own position when no explicit pivot is set. */
|
|
export function clipKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
|
|
const ix = inst.x ?? 0
|
|
const iy = inst.y ?? 0
|
|
return {
|
|
x: inst.kaleidoPivotX ?? ix,
|
|
y: inst.kaleidoPivotY ?? iy,
|
|
}
|
|
}
|
|
|
|
/** Per-arm local clip time (seconds). Returns `null` when the arm is
|
|
* outside the clip's playable range — i.e. the arm hasn't started
|
|
* yet (its delayed start is still in the future) or it has already
|
|
* finished. Callers must skip arms where this returns null.
|
|
*
|
|
* Semantics: arm `i` is delayed by `i * kaleidoOffset` seconds
|
|
* relative to the source. So with offset = 0.5s and four arms, at
|
|
* scene time `t` arm 0 shows the source frame, arm 1 shows the
|
|
* frame from 0.5s earlier in the clip's playback (equivalent to
|
|
* "arm 1 starts 0.5s after arm 0"), arm 2 from 1s earlier, etc.
|
|
* Before its delayed start the arm is hidden; after the clip's end
|
|
* it stays gone.
|
|
*/
|
|
export function clipArmLocalTime(
|
|
inst: ClipInstance,
|
|
baseLocal: number,
|
|
index: number,
|
|
trimmedDuration: number,
|
|
): number | null {
|
|
if (trimmedDuration <= 0) return null
|
|
const offset = inst.kaleidoOffset ?? 0
|
|
const trimStart = inst.trimStart ?? 0
|
|
const offsetIntoBlock = baseLocal - trimStart
|
|
// Arm 0 (and the no-shift case) just uses the source's own clip-
|
|
// local time; arms i>0 with a non-zero offset are delayed.
|
|
const shifted = (index === 0 || offset === 0)
|
|
? offsetIntoBlock
|
|
: offsetIntoBlock - index * offset
|
|
const EPS = 1e-9
|
|
if (shifted < -EPS || shifted > trimmedDuration + EPS) return null
|
|
return trimStart + Math.max(0, Math.min(trimmedDuration, shifted))
|
|
}
|
|
|
|
/**
|
|
* For rotateCopies=false: compute the rotated *position* of the
|
|
* instance's anchor around the kaleido pivot. The caller then
|
|
* translates by this rotated position (instead of the original
|
|
* (ix, iy)) so the contents land in a circle around the pivot
|
|
* without rotating themselves.
|
|
*/
|
|
export function clipArmTranslate(
|
|
inst: ClipInstance,
|
|
index: number,
|
|
count: number,
|
|
): { x: number; y: number } {
|
|
const ix = inst.x ?? 0
|
|
const iy = inst.y ?? 0
|
|
if (index === 0 || count <= 1) return { x: ix, y: iy }
|
|
const rotateCopies = inst.kaleidoRotateCopies !== false
|
|
if (rotateCopies) return { x: ix, y: iy }
|
|
const { x: px, y: py } = clipKaleidoPivot(inst)
|
|
const a = (kaleidoAngleDegrees(index, count) * Math.PI) / 180
|
|
const dx = ix - px
|
|
const dy = iy - py
|
|
return {
|
|
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
|
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
|
}
|
|
}
|
|
|
|
/** Bool helper: should the per-arm transform also rotate the
|
|
* contents (true) or only translate them around the pivot (false)?
|
|
* Default true mirrors the natural kaleidoscope look. Layers don't
|
|
* expose a toggle — they always rotate copies. */
|
|
export function shouldRotateClipCopies(inst: ClipInstance): boolean {
|
|
return inst.kaleidoRotateCopies !== false
|
|
}
|
|
|
|
/** Convenience wrapper for the layer side, where the math is
|
|
* simpler (contents always rotate with the copy). */
|
|
export function layerKaleidoCount(layer: Layer): number {
|
|
return kaleidoCountFor(layer)
|
|
}
|
|
|
|
/** 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,
|
|
props: AnimatableProps,
|
|
): { x: number; y: number } {
|
|
return {
|
|
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. */
|
|
export function layerArmTransform(
|
|
layer: Layer,
|
|
props: AnimatableProps,
|
|
index: number,
|
|
count: number,
|
|
): { x: number; y: number; rotation: number } {
|
|
if (index === 0 || count <= 1) {
|
|
return { x: props.x, y: props.y, rotation: props.rotation }
|
|
}
|
|
const { x: px, y: py } = layerKaleidoPivot(layer, props)
|
|
const angle = kaleidoAngleDegrees(index, count)
|
|
const a = (angle * Math.PI) / 180
|
|
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: props.rotation + angle,
|
|
}
|
|
}
|