Files
RUWIO/src/lib/interpolate.ts
T
thandClaude Opus 4.7 ddea7d348f 0.5.0: RUWIO rebrand, vertex bezier, animation paths, welcome focus
- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG),
  welcome dialog header and about dialog switch from the PhSparkle
  glyph to the real logo. Redundant name text next to the logo is
  removed across the board.
- About dialog: logo spans the full dialog width, the bottom Close
  button is gone (close via header X stays).
- Vertex bezier for polygons: each vertex can be Corner / Curve / Split
  curve via right-click. Curve mirrors tangent handles symmetrically,
  split curve keeps them independent. Tangents are fully animatable
  per keyframe — lerp mirrored across all four render pipelines.
- Stage path overlay: selecting a keyframe draws the tween arc between
  the previous and selected keyframe with draggable per-frame dots that
  curve the path via quadratic bezier control points. Other segments
  on the same track render as dashed ghost lines so the full curve is
  visible. Path dots grow + show grab cursor on hover.
- Vertex box-select: click-drag on empty stage while a polygon is
  selected boxes vertices. Shift-click toggles individual vertices
  into/out of the selection. Multi-vertex drag moves all selected at
  once; right-click on a selected vertex applies Corner/Curve/Split
  to all. Tangent handles only render when exactly one vertex is
  selected.
- Welcome dialog focus: opens with the most recent project focused.
  Enter opens it; Tab cycles only through the dialog's real buttons
  (delete icons pulled out of tab order, scrollable body removed from
  tab order). Export-current removed from welcome (not useful on
  startup).
- Modal focus trap: all modals now keep Tab inside the dialog.
- Fill / stroke transparency toggle restores the last real color when
  flipped back on, with a layer-base / asset fallback chain so there's
  always a visible color to restore to. A watcher keeps the restore
  cache in sync with the sampled value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:12:38 +02:00

148 lines
5.5 KiB
TypeScript

import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => 1 - (1 - t) ** 2,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
}
function merge(base: AnimatableProps, partial: Partial<AnimatableProps>): AnimatableProps {
return { ...base, ...partial }
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t
}
function parseHex(hex: string): [number, number, number] | null {
const h = hex.trim().replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
if (full.length < 6) return null
const n = parseInt(full.slice(0, 6), 16)
if (Number.isNaN(n)) return null
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function formatHex(r: number, g: number, b: number): string {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
const h = (v: number) => clamp(v).toString(16).padStart(2, '0')
return `#${h(r)}${h(g)}${h(b)}`
}
export function lerpColor(a: string, b: string, t: number): string {
const pa = parseHex(a)
const pb = parseHex(b)
if (!pa || !pb) return t < 0.5 ? a : b
return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t))
}
function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps {
const out: AnimatableProps = {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
scaleX: lerp(a.scaleX, b.scaleX, t),
scaleY: lerp(a.scaleY, b.scaleY, t),
rotation: lerp(a.rotation, b.rotation, t),
opacity: lerp(a.opacity, b.opacity, t),
}
if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
else if (b.fill) out.fill = b.fill
else if (a.fill) out.fill = a.fill
if (a.blur !== undefined && b.blur !== undefined) out.blur = lerp(a.blur, b.blur, t)
else if (b.blur !== undefined) out.blur = b.blur
else if (a.blur !== undefined) out.blur = a.blur
for (const key of ['shadowBlur', 'shadowOpacity', 'shadowAngle', 'shadowDistance'] as const) {
const av = a[key]
const bv = b[key]
if (av !== undefined && bv !== undefined) out[key] = lerp(av, bv, t)
else if (bv !== undefined) out[key] = bv
else if (av !== undefined) out[key] = av
}
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
if (a.stroke && b.stroke) out.stroke = lerpColor(a.stroke, b.stroke, t)
else if (b.stroke) out.stroke = b.stroke
else if (a.stroke) out.stroke = a.stroke
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
if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => {
const pb = b.points![i]
const merged: typeof pa = { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) }
for (const key of ['inX', 'inY', 'outX', 'outY'] as const) {
const av = pa[key]
const bv = pb[key]
if (av !== undefined && bv !== undefined) merged[key] = lerp(av, bv, t)
else if (bv !== undefined) merged[key] = bv
else if (av !== undefined) merged[key] = av
}
return merged
})
} else if (b.points) {
out.points = b.points.map((p) => ({ ...p }))
} else if (a.points) {
out.points = a.points.map((p) => ({ ...p }))
}
return out
}
export function sortedKeyframes(layer: Layer): Keyframe[] {
return [...layer.keyframes].sort((a, b) => a.time - b.time)
}
function sampleKeyframeSet(
kfs: Keyframe[] | undefined,
base: AnimatableProps,
time: number,
): AnimatableProps {
if (!kfs || !kfs.length) return { ...base }
const sorted = [...kfs].sort((a, b) => a.time - b.time)
if (time <= sorted[0].time) return merge(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return merge(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return merge(base, b.props)
return merge(base, a.props)
}
const raw = (time - a.time) / (b.time - a.time)
const t = easings[b.easing](raw)
const pa = merge(base, a.props)
const pb = merge(base, b.props)
const out = lerpProps(pa, pb, t)
// Bezier-curved position: if b defines a quadratic control, override x/y
if (b.ctrlX !== undefined && b.ctrlY !== undefined) {
const oneMinus = 1 - t
out.x = oneMinus * oneMinus * pa.x + 2 * oneMinus * t * b.ctrlX + t * t * pb.x
out.y = oneMinus * oneMinus * pa.y + 2 * oneMinus * t * b.ctrlY + t * t * pb.y
}
return out
}
}
return { ...base }
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
const posSource = layer.positionKeyframes
const rotSource = layer.rotationKeyframes
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
return {
...main,
x: pos.x,
y: pos.y,
rotation: rot.rotation,
}
}