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
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
clearStoredClipartFolder,
|
||||
disposeClipartItems,
|
||||
isFileSystemAccessSupported,
|
||||
listClipart,
|
||||
loadStoredClipartFolder,
|
||||
pickClipartFolder,
|
||||
type ClipartItem,
|
||||
} from '@/lib/clipartLibrary'
|
||||
|
||||
interface State {
|
||||
handle: FileSystemDirectoryHandle | null
|
||||
folderName: string | null
|
||||
items: ClipartItem[]
|
||||
open: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
supported: boolean
|
||||
initialized: boolean
|
||||
}
|
||||
|
||||
const DRAWER_OPEN_KEY = 'animatr:clipartDrawerOpen'
|
||||
|
||||
function readDrawerOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DRAWER_OPEN_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeDrawerOpen(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(DRAWER_OPEN_KEY, open ? '1' : '0')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export const useClipartStore = defineStore('clipart', {
|
||||
state: (): State => ({
|
||||
handle: null,
|
||||
folderName: null,
|
||||
items: [],
|
||||
open: readDrawerOpen(),
|
||||
loading: false,
|
||||
error: null,
|
||||
supported: isFileSystemAccessSupported(),
|
||||
initialized: false,
|
||||
}),
|
||||
actions: {
|
||||
async initialize() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
if (!this.supported) return
|
||||
const handle = await loadStoredClipartFolder()
|
||||
if (!handle) return
|
||||
this.handle = handle
|
||||
this.folderName = handle.name
|
||||
await this.refresh()
|
||||
},
|
||||
async pickFolder() {
|
||||
this.error = null
|
||||
try {
|
||||
const handle = await pickClipartFolder()
|
||||
if (!handle) return
|
||||
this.setHandle(handle)
|
||||
await this.refresh()
|
||||
} catch (err) {
|
||||
this.error = (err as Error).message ?? 'Failed to pick folder'
|
||||
}
|
||||
},
|
||||
setHandle(handle: FileSystemDirectoryHandle) {
|
||||
this.disposeItems()
|
||||
this.handle = handle
|
||||
this.folderName = handle.name
|
||||
this.items = []
|
||||
},
|
||||
async clearFolder() {
|
||||
this.disposeItems()
|
||||
this.handle = null
|
||||
this.folderName = null
|
||||
this.items = []
|
||||
this.error = null
|
||||
await clearStoredClipartFolder()
|
||||
},
|
||||
async refresh() {
|
||||
if (!this.handle) return
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const next = await listClipart(this.handle)
|
||||
this.disposeItems()
|
||||
this.items = next
|
||||
} catch (err) {
|
||||
this.error = (err as Error).message ?? 'Failed to read folder'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
disposeItems() {
|
||||
disposeClipartItems(this.items)
|
||||
},
|
||||
setOpen(v: boolean) {
|
||||
this.open = v
|
||||
writeDrawerOpen(v)
|
||||
},
|
||||
toggle() {
|
||||
this.setOpen(!this.open)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -365,6 +365,57 @@ export const useSceneStore = defineStore('scene', {
|
||||
const idx = found.arr.indexOf(found.kf)
|
||||
if (idx >= 0) found.arr.splice(idx, 1)
|
||||
},
|
||||
setKeyframeControl(layerId: string, keyframeId: string, ctrlX: number, ctrlY: number) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
const found = findKf(layer, keyframeId)
|
||||
if (!found) return
|
||||
found.kf.ctrlX = ctrlX
|
||||
found.kf.ctrlY = ctrlY
|
||||
},
|
||||
clearKeyframeControl(layerId: string, keyframeId: string) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
const found = findKf(layer, keyframeId)
|
||||
if (!found) return
|
||||
delete found.kf.ctrlX
|
||||
delete found.kf.ctrlY
|
||||
},
|
||||
// Given a target world point that the curve should pass through at
|
||||
// segment progress `u` (0..1), solve for the quadratic bezier control.
|
||||
setKeyframeControlFromPoint(
|
||||
layerId: string,
|
||||
keyframeId: string,
|
||||
u: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
const found = findKf(layer, keyframeId)
|
||||
if (!found) return
|
||||
if (u <= 0 || u >= 1) return
|
||||
// Find the previous keyframe in the same arr
|
||||
const arr = [...found.arr].sort((x, y) => x.time - y.time)
|
||||
const idx = arr.findIndex((k) => k.id === keyframeId)
|
||||
if (idx <= 0) return
|
||||
const prev = arr[idx - 1]
|
||||
const samp = sampleLayer(layer, prev.time)
|
||||
const endSamp = sampleLayer(layer, found.kf.time)
|
||||
const p0x = samp.x
|
||||
const p0y = samp.y
|
||||
const p1x = endSamp.x
|
||||
const p1y = endSamp.y
|
||||
const oneMinus = 1 - u
|
||||
// targetX = oneMinus²*p0x + 2·oneMinus·u·C + u²*p1x
|
||||
// => C = (target - oneMinus²·p0 - u²·p1) / (2·oneMinus·u)
|
||||
const denom = 2 * oneMinus * u
|
||||
if (Math.abs(denom) < 1e-6) return
|
||||
const cx = (targetX - oneMinus * oneMinus * p0x - u * u * p1x) / denom
|
||||
const cy = (targetY - oneMinus * oneMinus * p0y - u * u * p1y) / denom
|
||||
found.kf.ctrlX = cx
|
||||
found.kf.ctrlY = cy
|
||||
},
|
||||
setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
|
||||
Reference in new issue
Block a user