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:
1 parent
276d621d20
commit
869b67458a
16 files changed
+1379
-15
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user