- 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>
148 lines
5.5 KiB
TypeScript
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,
|
|
}
|
|
}
|