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