Files
RUWIO/src/lib/kaleido.ts
T
2026-06-05 14:31:31 +02:00

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,
}
}