0.4.0: clipart drawer, animation paths, bezier keyframes

- Clipart library: new left-edge drawer (5px idle, expands on hover,
  400px when open). User picks a local folder via the File System Access
  API in settings; directory handle is persisted in IndexedDB so the
  library survives reloads. Dragging a thumbnail onto the stage creates
  an image asset + layer at the drop point.
- Animation paths on the stage: selecting a keyframe draws the tween
  path from the previous keyframe to the selected one as a solid
  polyline with per-frame draggable dots. All other segments on the
  same position-track render as dashed ghost lines so the full curve
  progression is visible.
- Path dots grow on hover (2.5 → 6 px), show a grab cursor, and have a
  wider hit area so they're easier to grab.
- Bezier-curved position interpolation: keyframes gain optional
  ctrlX/ctrlY fields. When set, x/y tween along a quadratic bezier
  through the control point instead of a straight line. Dragging a
  path dot solves for the control so the curve passes through the
  drag point at its frame progress. Mirrored across all four render
  pipelines.
- Tests: new suites for polygon actions, freeform drawing lifecycle,
  polygon + stroke rendering (15 new tests). Fixed the mask-render
  tests that relied on a default-transparent canvas background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 10:35:43 +02:00
1 parent 276d621d20
commit 869b67458a
16 files changed
+1379 -15

No files matched your search

+319
View File
@@ -11,6 +11,7 @@ import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectF
import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import type { PolygonAsset } from '@/types/scene'
import { drawSceneFrame } from '@/lib/render'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import {
@@ -1622,6 +1623,7 @@ export function registerAllTests(): void {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
@@ -1801,4 +1803,321 @@ export function registerAllTests(): void {
expect(kf.props.x).toBe(50)
})
})
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
const asset = s.addAsset({
kind: 'polygon',
name: 'Poly',
points: [
{ x: 0, y: -40 },
{ x: 40, y: 20 },
{ x: -40, y: 20 },
],
fill: '#abc',
}) as PolygonAsset
const layer = s.addLayer(asset.id)
return { s, asset, layer }
}
test('addLayer seeds initial keyframe with points for polygons', () => {
const { layer, asset } = makePolygonLayer()
const kf0 = layer.keyframes[0]
expect(kf0).toBeTruthy()
expect(Array.isArray(kf0.props.points)).toBe(true)
expect(kf0.props.points!.length).toBe(asset.points.length)
})
test('movePolygonVertex writes a points-array kf at playhead', () => {
const { s, layer, asset } = makePolygonLayer()
s.setPlayhead(0)
s.movePolygonVertex(layer.id, 0, 10, -50)
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
expect(kf.props.points![0].x).toBe(10)
expect(kf.props.points![0].y).toBe(-50)
// Other points remain unchanged
expect(kf.props.points![1].x).toBe(asset.points[1].x)
expect(kf.props.points![1].y).toBe(asset.points[1].y)
})
test('setPolygonPointCount grows by midpoint insertion and keeps KFs in sync', () => {
const { s, layer, asset } = makePolygonLayer()
const originalLen = asset.points.length
s.setPolygonPointCount(asset.id, originalLen + 2)
expect(asset.points.length).toBe(originalLen + 2)
for (const kf of layer.keyframes) {
if (!kf.props.points) continue
expect(kf.props.points.length).toBe(originalLen + 2)
}
})
test('setPolygonPointCount shrinks but never below 3', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonPointCount(asset.id, 1)
expect(asset.points.length).toBe(3)
})
test('insertPolygonPointOnEdge inserts at expected index across all keyframes', () => {
const { s, layer, asset } = makePolygonLayer()
s.insertPolygonPointOnEdge(asset.id, 1, 0.5)
expect(asset.points.length).toBe(4)
const kf = layer.keyframes[0]
expect(kf.props.points!.length).toBe(4)
// midpoint between original [0] and [1]
const expectedX = (0 + 40) / 2
const expectedY = (-40 + 20) / 2
expect(asset.points[1].x).toBe(expectedX)
expect(asset.points[1].y).toBe(expectedY)
expect(kf.props.points![1].x).toBe(expectedX)
expect(kf.props.points![1].y).toBe(expectedY)
})
test('reversePolygonPoints reverses asset + keyframe points', () => {
const { s, layer, asset } = makePolygonLayer()
const firstBefore = { ...asset.points[0] }
s.reversePolygonPoints(asset.id)
expect(asset.points[asset.points.length - 1].x).toBe(firstBefore.x)
expect(asset.points[asset.points.length - 1].y).toBe(firstBefore.y)
const kf = layer.keyframes[0]
expect(kf.props.points![kf.props.points!.length - 1].x).toBe(firstBefore.x)
})
test('setPolygonClosed toggles the closed flag', () => {
const { s, asset } = makePolygonLayer()
s.setPolygonClosed(asset.id, false)
expect(asset.closed).toBe(false)
s.setPolygonClosed(asset.id, true)
expect(asset.closed).toBe(true)
})
})
suite('freeform drawing — lifecycle', () => {
test('startFreeformDrawing creates asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBeTruthy()
expect(s.drawingLayerId).toBeTruthy()
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId)!
expect(asset.kind).toBe('polygon')
expect((asset as PolygonAsset).closed).toBe(false)
})
test('addDrawingPoint appends to asset and to KF points', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(10, 20)
s.addDrawingPoint(30, 40)
const asset = s.scene.assets.find((a) => a.id === s.drawingAssetId) as PolygonAsset
expect(asset.points.length).toBe(2)
expect(asset.points[1].x).toBe(30)
const layer = s.activeLayers.find((l) => l.id === s.drawingLayerId)!
expect(layer.keyframes[0].props.points!.length).toBe(2)
})
test('finishDrawing with <2 points drops the in-progress asset', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.addDrawingPoint(5, 5) // only 1 point
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('finishDrawing with >=2 points keeps the layer and clears drawing state', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(50, 50)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
expect(s.drawingMode).toBeNull()
expect(s.drawingAssetId).toBeNull()
expect(s.drawingLayerId).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(true)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(true)
})
test('cancelDrawing removes the in-progress asset and layer', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(10, 10)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.cancelDrawing()
expect(s.drawingMode).toBeNull()
expect(s.scene.assets.some((a) => a.id === assetId)).toBe(false)
expect(s.activeLayers.some((l) => l.id === layerId)).toBe(false)
})
test('startFreeformDrawing resumes on selected open polygon instead of creating new', () => {
const s = makeFreshStore()
s.startFreeformDrawing()
s.addDrawingPoint(0, 0)
s.addDrawingPoint(20, 20)
const assetId = s.drawingAssetId!
const layerId = s.drawingLayerId!
s.finishDrawing()
// Layer stays selected; invoke start again
s.selectLayer(layerId)
s.startFreeformDrawing()
expect(s.drawingMode).toBe('freeform')
expect(s.drawingAssetId).toBe(assetId)
expect(s.drawingLayerId).toBe(layerId)
})
})
suite('polygon + stroke — rendering', () => {
test('drawSceneFrame renders a filled closed polygon in fill color', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Tri',
points: [
{ x: 0, y: -30 },
{ x: 30, y: 20 },
{ x: -30, y: 20 },
],
fill: '#ff0000',
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const inside = ctx.getImageData(50, 55, 1, 1).data
expect(inside[0] > 200).toBe(true) // red channel
expect(inside[3] > 200).toBe(true) // alpha
})
test('open polygon with stroke renders only the stroke path', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Line',
points: [
{ x: -40, y: 0 },
{ x: 40, y: 0 },
],
fill: 'transparent',
closed: false,
stroke: '#00ff00',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onLine = ctx.getImageData(50, 50, 1, 1).data
const offLine = ctx.getImageData(50, 80, 1, 1).data
expect(onLine[1] > 200).toBe(true) // green on line
expect(onLine[3] > 200).toBe(true)
expect(offLine[3]).toBe(0) // transparent elsewhere
})
test('rect with stroke renders the outline', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
stroke: '#0000ff',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// On the top edge of the rect
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Interior
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(onEdge[2] > 200).toBe(true) // blue on edge
expect(inside[3]).toBe(0) // transparent interior
})
test('strokeWidth without explicit stroke falls back to black', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: 'transparent',
strokeWidth: 4,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
const onEdge = ctx.getImageData(50, 30, 1, 1).data
// Black stroke: RGB all low, alpha high
expect(onEdge[0] < 40).toBe(true)
expect(onEdge[1] < 40).toBe(true)
expect(onEdge[2] < 40).toBe(true)
expect(onEdge[3] > 200).toBe(true)
})
test('transparent fill on closed polygon skips fill', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const a = s.addAsset({
kind: 'polygon',
name: 'Hollow',
points: [
{ x: -30, y: -30 },
{ x: 30, y: -30 },
{ x: 30, y: 30 },
{ x: -30, y: 30 },
],
fill: 'transparent',
closed: true,
stroke: '#ff00ff',
strokeWidth: 3,
})
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 0, { x: 50, y: 50 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Center should be fully transparent (no fill)
const center = ctx.getImageData(50, 50, 1, 1).data
expect(center[3]).toBe(0)
})
})
}