diff --git a/package.json b/package.json index bc8d83b..17870f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.2.7", + "version": "1.2.8", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 586e730..9b72184 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -21,6 +21,13 @@ import { importImageFile, loadImage } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' import { useDocumentDrawer } from '@/composables/useDocumentDrawer' import { fontCssFamily, fontsReady } from '@/lib/fonts' +import { + clipArmLocalTime, + clipArmTranslate, + kaleidoAngleDegrees, + kaleidoCountFor, + shouldRotateClipCopies, +} from '@/lib/kaleido' import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render' import type { Action, @@ -541,6 +548,33 @@ function buttonChildConfig>(cfg: T): T { return { ...cfg, draggable: false, listening: false } } +/** Returns [0..N-1] when kaleido is on, else [0]. The Konva templates + * iterate this and call `withK(cfg, ki, n)` to add the per-copy + * rotation. For ki>0 the copy is non-listening so only the source + * receives clicks/drag/transform events. */ +function kaleidoIndicesForLayer(layer: Layer): number[] { + const n = kaleidoCountFor(layer) + if (n <= 1) return [0] + return Array.from({ length: n }, (_, i) => i) +} + +/** Augments a layer's Konva config with the kaleido per-copy rotation. + * Konva rotates around the node's `(x, y)` world position (offset is + * the local-space anchor), so adding `kAngle` to `rotation` gives the + * desired rotation around the layer's pivot for free. Non-source + * copies are listening:false to keep events on the source. */ +function withK>(cfg: T, ki: number, n: number): T { + if (n <= 1 || ki === 0) return cfg + const kAngle = kaleidoAngleDegrees(ki, n) + const baseRot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0 + return { + ...cfg, + rotation: baseRot + kAngle, + listening: false, + draggable: false, + } as T +} + /** SVG-asset child wrapper. Same listening/drag config as button * children, but also pins `strokeScaleEnabled: false`. svgStackForAsset * pre-multiplies each child's strokeWidth by the outer placement @@ -907,6 +941,10 @@ interface RenderedClipInstance { * no measurable layers (template falls back to a placeholder). */ bounds: Rect | null items: RenderedClipLayer[] + /** Kaleido arm index (0 = source). Static (non-kaleido) instances + * always emit a single bundle with armIndex 0, armCount 1. */ + armIndex: number + armCount: number } type StackItem = @@ -1052,39 +1090,50 @@ const stackItems = computed(() => { if (!clip) continue const trimStart = inst.trimStart ?? 0 const trimEnd = inst.trimEnd ?? 0 - const visible = Math.max(0, clip.duration - trimStart - trimEnd) + const visibleDur = Math.max(0, clip.duration - trimStart - trimEnd) const offsetIntoBlock = store.playhead - inst.startTime - if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue - const localTime = trimStart + offsetIntoBlock - // Bundle the clip's layers under a single clip-instance entry - // so the template can wrap them in a draggable Konva group that - // carries the instance's x / y / scale transform. - const items: RenderedClipLayer[] = [] - for (let k = clip.layers.length - 1; k >= 0; k--) { - const cl = clip.layers[k] - if (!cl.visible) continue - if (!isLayerActiveAt(cl, localTime)) continue - const ca = assetMap.value.get(cl.assetId) - if (!ca) continue - items.push({ - instanceId: inst.id, - layer: cl, - asset: ca, - props: sampleLayer(cl, localTime), + // For kaleido instances delayed arms can outlive the source's + // window; widen the early-out so they survive into the loop. + const armCount = kaleidoCountFor(inst) + const offsetSec = inst.kaleidoOffset ?? 0 + const maxArmDelay = armCount > 1 && offsetSec !== 0 + ? Math.max(0, (armCount - 1) * offsetSec) + : 0 + if (offsetIntoBlock < 0 || offsetIntoBlock > visibleDur + maxArmDelay) continue + const baseLocal = trimStart + offsetIntoBlock + const bounds = computeClipBounds(clip, assetMap.value) + // Bundle each kaleido arm separately. Each arm samples its own + // localT so a delayed arm shows the right clip frame for *its* + // playback offset (not the source's). Static (non-kaleido) + // instances run this loop once with armIndex 0. + for (let armIndex = 0; armIndex < armCount; armIndex++) { + const localT = clipArmLocalTime(inst, baseLocal, armIndex, visibleDur) + if (localT === null) continue + const items: RenderedClipLayer[] = [] + for (let k = clip.layers.length - 1; k >= 0; k--) { + const cl = clip.layers[k] + if (!cl.visible) continue + if (!isLayerActiveAt(cl, localT)) continue + const ca = assetMap.value.get(cl.assetId) + if (!ca) continue + items.push({ + instanceId: inst.id, + layer: cl, + asset: ca, + props: sampleLayer(cl, localT), + }) + } + out.push({ + kind: 'clipInstance', + bundle: { + instance: inst, + bounds, + items, + armIndex, + armCount, + }, }) } - // Always push the bundle when the instance is in time-range, - // even if no items are renderable at this playhead: the frame - // rect still needs to render so the clip can be selected (and - // the transformer framed) on empty frames. - out.push({ - kind: 'clipInstance', - bundle: { - instance: inst, - bounds: computeClipBounds(clip, assetMap.value), - items, - }, - }) } } return out @@ -1516,6 +1565,47 @@ function clipInstanceGroupConfig(inst: ClipInstance) { } } +/** Per-arm Konva Group config for a kaleido'd clip instance. ki=0 is + * the source (events go here), ki>0 are decorative non-listening + * copies. Spatial layout mirrors lib/kaleido.ts: rotateCopies=true + * rotates the entire local space around the pivot (contents follow); + * rotateCopies=false places the instance translated around the pivot + * without rotating its contents. */ +function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) { + const base = clipInstanceGroupConfig(inst) + const n = kaleidoCountFor(inst) + if (n <= 1 || ki === 0) return base + const trans = clipArmTranslate(inst, ki, n) + if (shouldRotateClipCopies(inst)) { + // Rotate the source's local space around the pivot. Konva doesn't + // expose a "rotate around an arbitrary point" knob in a single + // node, but we can replicate it by translating to the pivot, + // rotating, and translating back — folded into x / y / rotation. + const px = inst.kaleidoPivotX ?? (inst.x ?? 0) + const py = inst.kaleidoPivotY ?? (inst.y ?? 0) + const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180 + const dx = (inst.x ?? 0) - px + const dy = (inst.y ?? 0) - py + const rotatedX = px + dx * Math.cos(a) - dy * Math.sin(a) + const rotatedY = py + dx * Math.sin(a) + dy * Math.cos(a) + return { + ...base, + x: rotatedX, + y: rotatedY, + rotation: (inst.rotation ?? 0) + kaleidoAngleDegrees(ki, n), + listening: false, + draggable: false, + } + } + return { + ...base, + x: trans.x, + y: trans.y, + listening: false, + draggable: false, + } +} + /** Invisible frame rect sized to the clip's union bbox. Sits as the * first child of the ci-group so: * - the group's client rect is dominated by this stable rectangle, @@ -4158,9 +4248,13 @@ function onResizeHandlePointerUp(e: PointerEvent) { " > +

{{ t('properties.lockedLayer') }}

diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 2fc6445..a3ba43a 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -1257,18 +1257,20 @@ export function registerAllTests(): void { }) test('runtime wraps each clip instance in translate + rotate + scale', () => { - // Pin that the exported runtime's per-clip draw loop honours - // ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor - // and the exported HTML stay visually identical for any - // clip-instance transform. + // Pin that the exported runtime's per-clip draw honours + // ClipInstance.x/y/scaleX/scaleY/rotation. Refactored into a + // `drawClipInstance(inst, clip, time)` helper for the kaleido + // feature, so we now look at that function's body rather than + // the inline `inst2.*` variables. const r = generateRuntimeScript() - expect(r).toContain('inst2.x') - expect(r).toContain('inst2.y') - expect(r).toContain('inst2.scaleX') - expect(r).toContain('inst2.scaleY') - expect(r).toContain('inst2.rotation') - expect(r).toContain('ctx.rotate(irot2*Math.PI/180)') - expect(r).toContain('ctx.scale(isx2,isy2)') + expect(r).toContain('function drawClipInstance(') + expect(r).toContain('inst.x') + expect(r).toContain('inst.y') + expect(r).toContain('inst.scaleX') + expect(r).toContain('inst.scaleY') + expect(r).toContain('inst.rotation') + expect(r).toContain('ctx.rotate(irot*Math.PI/180)') + expect(r).toContain('ctx.scale(isx,isy)') }) test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => { @@ -15323,4 +15325,244 @@ export function registerAllTests(): void { } }) }) + + suite('kaleidoscope — store + render + engine', () => { + test('Layer kaleido toggle / count roundtrip + clamp', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 20, height: 20, fill: '#f00' }) + const layer = s.addLayer(a.id)! + // Toggle on → defaults applied. + s.setLayerKaleido(layer.id, true) + expect(layer.kaleido).toBe(true) + expect(layer.kaleidoCount).toBe(6) + // Setter clamps to [2, 64] integer. + s.setLayerKaleidoCount(layer.id, 1.7) + expect(layer.kaleidoCount).toBe(2) + s.setLayerKaleidoCount(layer.id, 999) + expect(layer.kaleidoCount).toBe(64) + s.setLayerKaleidoCount(layer.id, 8) + expect(layer.kaleidoCount).toBe(8) + // Toggle off keeps the count so re-toggling restores it. + s.setLayerKaleido(layer.id, false) + expect(layer.kaleido === undefined).toBe(true) + expect(layer.kaleidoCount).toBe(8) + }) + + test('drawSceneFrame replicates a layer at four cardinal angles when kaleidoCount=4', () => { + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + // 20×20 red rect with its pivot 60px to the left of its centre, + // so the source copy lands at world rect (150,90)-(170,110) and + // 4 kaleido copies fan out at 0°/90°/180°/270° around (100,100). + const a = s.addAsset({ + kind: 'rect', name: 'K', width: 20, height: 20, fill: '#ff0000', + }) + const layer = s.addLayer(a.id)! + layer.offsetX = -50 + layer.offsetY = 10 + s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 }) + s.setLayerKaleido(layer.id, true) + s.setLayerKaleidoCount(layer.id, 4) + + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}) + + // Sample one pixel inside each expected rotated copy. The rect + // body sits ~60px off the pivot in its source orientation; the + // four cardinal positions land on (160,100), (100,160), + // (40,100), (100,40). + const p = (x: number, y: number) => ctx.getImageData(x, y, 1, 1).data + const samples = [p(160, 100), p(100, 160), p(40, 100), p(100, 40)] + for (const px of samples) { + // Red dominant, alpha non-zero. + expect(px[0]).toBeGreaterThan(200) + expect(px[3]).toBeGreaterThan(200) + } + }) + + test('drawSceneFrame leaves a button-asset layer single-rendered even with kaleido on', () => { + // Buttons must keep their hit-area count at 1. The render path + // explicitly bypasses kaleido for asset.kind === 'button'. + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + const btn = s.addButtonAsset('B', 40, 40) + const layer = s.addLayer(btn.id)! + layer.offsetX = -50 + layer.offsetY = 10 + s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 }) + // Force the kaleido flags on; renderer must still ignore them. + layer.kaleido = true + layer.kaleidoCount = 4 + + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}) + // No assertion on pixels (button assets paint via their inner + // layer stack — empty here). The smoke test is that rendering + // completes without throwing and the engine path doesn't loop + // on the button. Reaching this line means we're good. + expect(true).toBe(true) + }) + + test('ClipInstance kaleido fields roundtrip + clear semantics', () => { + const s = makeFreshStore() + const clip = s.addClip('Spinner') + const track = s.addClipTrack() + const inst = s.addClipInstance(clip.id, 0, track.id)! + s.setClipInstanceKaleido(inst.id, { + kaleido: true, + kaleidoCount: 6, + kaleidoOffset: 0.5, + kaleidoPivotX: 100, + kaleidoPivotY: 200, + kaleidoRotateCopies: false, + }) + const stored = s.scene.clipInstances!.find((c) => c.id === inst.id)! + expect(stored.kaleido).toBe(true) + expect(stored.kaleidoCount).toBe(6) + expect(stored.kaleidoOffset).toBe(0.5) + expect(stored.kaleidoPivotX).toBe(100) + expect(stored.kaleidoPivotY).toBe(200) + expect(stored.kaleidoRotateCopies).toBe(false) + // Setting offset back to 0 deletes the field so the object stays + // clean across saves; same for rotateCopies=true. + s.setClipInstanceKaleido(inst.id, { kaleidoOffset: 0, kaleidoRotateCopies: true }) + expect(stored.kaleidoOffset === undefined).toBe(true) + expect(stored.kaleidoRotateCopies === undefined).toBe(true) + }) + + test('clipArmLocalTime delays each arm by i*offset and hides arms outside the clip window', async () => { + const { clipArmLocalTime } = await import('@/lib/kaleido') + // 4-arm clip, 2s trimmed window, dt = 0.5s. Arm i is delayed + // by i*0.5 s relative to the source — at scene time = source's + // offsetIntoBlock t, arm i sees clip-time (t - i*0.5). + const inst = { + kaleidoOffset: 0.5, + trimStart: 0, + } as import('@/types/scene').ClipInstance + // baseLocal = 0.25 (= source playing at t=0.25). + // i=0 → 0.25 (visible) + // i=1 → 0.25 - 0.5 = -0.25 → null (hasn't started yet) + // i=2 → 0.25 - 1.0 = -0.75 → null + // i=3 → 0.25 - 1.5 = -1.25 → null + expect(clipArmLocalTime(inst, 0.25, 0, 2)).toBeCloseTo(0.25, 5) + expect(clipArmLocalTime(inst, 0.25, 1, 2)).toBe(null) + expect(clipArmLocalTime(inst, 0.25, 2, 2)).toBe(null) + expect(clipArmLocalTime(inst, 0.25, 3, 2)).toBe(null) + // baseLocal = 1.5 — late enough for several arms to be in range. + expect(clipArmLocalTime(inst, 1.5, 0, 2)).toBeCloseTo(1.5, 5) + expect(clipArmLocalTime(inst, 1.5, 1, 2)).toBeCloseTo(1.0, 5) + expect(clipArmLocalTime(inst, 1.5, 2, 2)).toBeCloseTo(0.5, 5) + expect(clipArmLocalTime(inst, 1.5, 3, 2)).toBeCloseTo(0, 5) + // baseLocal = 2.4 — past the clip's end. Arm 0 (and any arm + // with `shifted` outside [0, trimmedDur]) returns null; delayed + // arms whose shifted falls in range are still valid. + expect(clipArmLocalTime(inst, 2.4, 0, 2)).toBe(null) + expect(clipArmLocalTime(inst, 2.4, 1, 2)).toBeCloseTo(1.9, 5) + expect(clipArmLocalTime(inst, 2.4, 4, 2)).toBeCloseTo(0.4, 5) + // trimStart shifts the returned value but not the math. + const inst2 = { kaleidoOffset: 0.5, trimStart: 0.4 } as import('@/types/scene').ClipInstance + expect(clipArmLocalTime(inst2, 0.4 + 1.5, 2, 2)).toBeCloseTo(0.4 + 0.5, 5) + }) + + test('drawSceneFrame: delayed kaleido arms appear only after their stagger', () => { + // Build a clip that draws a single red square. Stagger 4 arms by + // 0.5s each, place the kaleido pivot off-canvas so arms 0-3 land + // at four distinct pixel positions, then sample those positions + // at scene-times that should expose 1, then 2, then 3 arms. + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + s.scene.duration = 5 + const sq = s.addAsset({ + kind: 'rect', name: 'Sq', width: 20, height: 20, fill: '#ff0000', + }) + const clip = s.addClip('SquareClip') + clip.duration = 1 + clip.layers = [ + { + id: 'cl-l1', + name: 'sq', + assetId: sq.id, + visible: true, + base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [], + offsetX: -50, + offsetY: 10, + }, + ] + const track = s.addClipTrack() + const inst = s.addClipInstance(clip.id, 0, track.id)! + s.setClipInstanceTransform(inst.id, { x: 100, y: 100 }) + s.setClipInstanceKaleido(inst.id, { + kaleido: true, + kaleidoCount: 4, + kaleidoOffset: 0.5, + }) + const draw = (t: number) => { + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, t, {}) + return ctx + } + const isRed = (ctx: CanvasRenderingContext2D, x: number, y: number) => { + const px = ctx.getImageData(x, y, 1, 1).data + return px[0] > 200 && px[3] > 200 + } + // At t=0: only arm 0 visible (160, 100). + let ctx = draw(0) + expect(isRed(ctx, 160, 100)).toBe(true) + expect(isRed(ctx, 100, 160)).toBe(false) + expect(isRed(ctx, 40, 100)).toBe(false) + expect(isRed(ctx, 100, 40)).toBe(false) + // At t=0.5: arms 0 and 1 visible. + ctx = draw(0.5) + expect(isRed(ctx, 160, 100)).toBe(true) + expect(isRed(ctx, 100, 160)).toBe(true) + expect(isRed(ctx, 40, 100)).toBe(false) + // At t=1.5: source (arm 0) is past its 1s clip so gone, but arms + // 1 (in clip-time 1.0 — at end), 2 (0.5), 3 (0) still visible. + ctx = draw(1.5) + expect(isRed(ctx, 160, 100)).toBe(false) + expect(isRed(ctx, 100, 160)).toBe(true) + expect(isRed(ctx, 40, 100)).toBe(true) + expect(isRed(ctx, 100, 40)).toBe(true) + }) + + test('exported runtime engine carries kaleido helpers + drawClipInstance', () => { + const src = generateRuntimeScript() + expect(src).toContain('function kalCount(') + expect(src).toContain('function kalAngle(') + expect(src).toContain('function drawClipInstance(') + expect(src).toContain('clipKaleidoPivot') + expect(src).toContain('clipArmLocalT') + // Runtime version got bumped through this feature's iterations. + expect(src).toContain("'31'") + }) + + test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => { + // Regression guard: the kaleido wrapping must not change the + // visibility gate for layers without kaleido — same fixtures as + // the layerActiveAt parity test above. + const src = generateRuntimeScript() + const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/) + expect(!!match).toBe(true) + const layerActiveAt = new Function('layer', 'time', match![1]) as (layer: unknown, time: number) => boolean + const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 } + expect(layerActiveAt(a, 0)).toBe(false) + expect(layerActiveAt(a, 2)).toBe(true) + }) + }) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 1d85d33..a3589f7 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -201,12 +201,26 @@ export const en = { polygon: 'Polygon', appearance: 'Appearance', button: 'On Click', + kaleido: 'Kaleidoscope', }, text: { text: 'Text', fontSize: 'Font Size', font: 'Font', }, + kaleido: { + toggle: 'Kaleido', + repeat: 'Repeat', + offset: 'Start offset (frames)', + rotateCopies: 'Rotate copies outward', + pivotX: 'Pivot X', + pivotY: 'Pivot Y', + hint: 'Renders the layer N times around its pivot.', + hintClip: 'Renders the clip N times around the pivot. Each copy starts at a different point in the clip when "Start offset" is set.', + }, + clipInstance: { + banner: 'Clip instance — only kaleidoscope is editable here. Use the canvas to move/resize.', + }, polygonPoints: 'Points', polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.', polygonClosed: 'Closed path (fill)', diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 07f0df6..0774e9a 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project { return Array.isArray((p as Partial).scenes) } -const RUNTIME_VERSION = '28' +const RUNTIME_VERSION = '31' /** Materialize the engine source. The font map is rebuilt on every * call so user-uploaded custom fonts (which live in localStorage @@ -689,6 +689,85 @@ const ENGINE_TEMPLATE = `(function(){ } if(closed) tctx.closePath(); } + // Kaleidoscope: number of radial copies for an entity (Layer or + // ClipInstance). 1 = no kaleido. Mirrors lib/kaleido.ts. + function kalCount(e){ + if(!e || !e.kaleido) return 1; + var n=Math.floor(e.kaleidoCount||0); + return n<2?1:n; + } + function kalAngle(i,n){ return n<=1?0:(i*360)/n; } + function clipKaleidoPivot(inst){ + var ix=inst.x||0, iy=inst.y||0; + return { + x:(inst.kaleidoPivotX==null?ix:inst.kaleidoPivotX), + y:(inst.kaleidoPivotY==null?iy:inst.kaleidoPivotY) + }; + } + function clipArmLocalT(inst, baseLocal, i, trimmedDur){ + if(trimmedDur<=0) return null; + var off=inst.kaleidoOffset||0; + var trimS=inst.trimStart||0; + var offsetIntoBlock=baseLocal-trimS; + // Arm 0 (and the no-shift case) → source's own time. Arms i>0 + // with a non-zero offset start later. + var shifted=(i===0||off===0)?offsetIntoBlock:(offsetIntoBlock-i*off); + var EPS=1e-9; + if(shifted < -EPS || shifted > trimmedDur+EPS) return null; + if(shifted<0) shifted=0; + if(shifted>trimmedDur) shifted=trimmedDur; + return trimS+shifted; + } + function clipArmTrans(inst, i, n){ + var ix=inst.x||0, iy=inst.y||0; + if(i===0||n<=1) return {x:ix,y:iy}; + var rot=inst.kaleidoRotateCopies!==false; + if(rot) return {x:ix,y:iy}; + var pv=clipKaleidoPivot(inst); + var a=kalAngle(i,n)*Math.PI/180; + var dx=ix-pv.x, dy=iy-pv.y; + return { + x:pv.x+dx*Math.cos(a)-dy*Math.sin(a), + y:pv.y+dx*Math.sin(a)+dy*Math.cos(a) + }; + } + function drawClipInstance(inst, clip, time){ + var trimS=inst.trimStart||0; + var trimE=inst.trimEnd||0; + var visibleClip=Math.max(0,clip.duration-trimS-trimE); + var offsetIntoBlock=time-inst.startTime; + var isx=(inst.scaleX==null?1:inst.scaleX); + var isy=(inst.scaleY==null?1:inst.scaleY); + var irot=inst.rotation||0; + var n=kalCount(inst); + var rotateCopies=inst.kaleidoRotateCopies!==false; + var pv=clipKaleidoPivot(inst); + var baseLocal=trimS+offsetIntoBlock; + // Delayed arms can outlive the source's own window — keep + // rendering until the LAST arm has finished playing. + var offSec=inst.kaleidoOffset||0; + var maxArmDelay=(n>1 && offSec!==0)?Math.max(0,(n-1)*offSec):0; + if(offsetIntoBlock<0||offsetIntoBlock>visibleClip+maxArmDelay) return; + for(var i=0;i1 && i>0 && rotateCopies){ + ctx.translate(pv.x,pv.y); + ctx.rotate(ka*Math.PI/180); + ctx.translate(-pv.x,-pv.y); + } + ctx.translate(tr.x,tr.y); + if(irot!==0) ctx.rotate(irot*Math.PI/180); + ctx.scale(isx,isy); + for(var li=clip.layers.length-1;li>=0;li--){ + drawLayer(clip.layers[li], lt); + } + ctx.restore(); + } + } function drawMaskShape(ctxm,layer,time,buttonMode){ var asset=assetMap[layer.assetId]; if(!asset) return; @@ -697,38 +776,42 @@ const ENGINE_TEMPLATE = `(function(){ if(!buttonLayerActiveAt(layer,time)) return; } else if(!layerActiveAt(layer,time)) return; var p=sample(layer,time); - ctxm.save(); - ctxm.translate(p.x,p.y); - ctxm.rotate(p.rotation*Math.PI/180); - ctxm.scale(p.scaleX,p.scaleY); - ctxm.fillStyle='#000000'; - if(asset.kind==='rect'){ - var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2; - var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; - ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height); - } else if(asset.kind==='circle'){ - var mcOff=layer.offsetX!=null?layer.offsetX:0; - var mcOffY=layer.offsetY!=null?layer.offsetY:0; - ctxm.beginPath(); - ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2); - ctxm.fill(); - } else if(asset.kind==='text'){ - var mtOff=layer.offsetX!=null?layer.offsetX:0; - var mtOffY=layer.offsetY!=null?layer.offsetY:0; - ctxm.font=asset.fontSize+'px '+fontFam(asset); - ctxm.textBaseline='top'; - ctxm.fillText(asset.text,-mtOff,-mtOffY); - } else { - var mpOffX=layer.offsetX!=null?layer.offsetX:0; - var mpOffY=layer.offsetY!=null?layer.offsetY:0; - var mpPts=p.points||asset.points||[]; - var mpClosed=asset.closed!==false; - if(mpClosed && mpPts.length>=3){ - tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true); + var kn=kalCount(layer); + for(var ki=0;ki=3){ + tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true); + ctxm.fill(); + } } + ctxm.restore(); } - ctxm.restore(); } // Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts. // A layer is "alive" from its earliest NORMAL kf onward, ending @@ -790,10 +873,15 @@ const ENGINE_TEMPLATE = `(function(){ if(!buttonLayerActiveAt(layer,time)) return; } else if(!layerActiveAt(layer,time)) return; var p=sample(layer,time); + // Kaleido off for button-asset layers — multiplying their hit + // areas would break click routing. + var kn=(asset.kind==='button')?1:kalCount(layer); + for(var ki=0;kivisibleClip)continue; - var localT=tS+offset; - var ix2=inst2.x||0, iy2=inst2.y||0; - var isx2=(inst2.scaleX==null?1:inst2.scaleX); - var isy2=(inst2.scaleY==null?1:inst2.scaleY); - var irot2=inst2.rotation||0; - var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0; - if(needsXf){ - ctx.save(); - ctx.translate(ix2,iy2); - if(irot2!==0) ctx.rotate(irot2*Math.PI/180); - ctx.scale(isx2,isy2); - } - for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){ - drawLayer(clip2.layers[cl2],localT); - } - if(needsXf) ctx.restore(); + drawClipInstance(inst2, clip2, time); } continue; } @@ -1061,27 +1130,7 @@ const ENGINE_TEMPLATE = `(function(){ var inst4=insts3[ti3]; var clip4=clipById2[inst4.clipId]; if(!clip4) continue; - var tS4=inst4.trimStart||0; - var tE4=inst4.trimEnd||0; - var vis4=Math.max(0,clip4.duration-tS4-tE4); - var off4=time-inst4.startTime; - if(off4<0||off4>vis4) continue; - var lt4=tS4+off4; - var ix4=inst4.x||0, iy4=inst4.y||0; - var isx4=(inst4.scaleX==null?1:inst4.scaleX); - var isy4=(inst4.scaleY==null?1:inst4.scaleY); - var irot4=inst4.rotation||0; - var needsXf4=ix4!==0||iy4!==0||isx4!==1||isy4!==1||irot4!==0; - if(needsXf4){ - ctx.save(); - ctx.translate(ix4,iy4); - if(irot4!==0) ctx.rotate(irot4*Math.PI/180); - ctx.scale(isx4,isy4); - } - for(var cl4=clip4.layers.length-1;cl4>=0;cl4--){ - drawLayer(clip4.layers[cl4],lt4); - } - if(needsXf4) ctx.restore(); + drawClipInstance(inst4, clip4, time); } }; if(!gMasks.length){ diff --git a/src/lib/kaleido.ts b/src/lib/kaleido.ts new file mode 100644 index 0000000..380f6af --- /dev/null +++ b/src/lib/kaleido.ts @@ -0,0 +1,109 @@ +// Math helpers for the kaleidoscope feature. Kept as pure data so +// the canvas2d offline renderer (lib/render.ts), the Konva live +// stage (components/CanvasStage.vue), AND the exported runtime +// (lib/exportPlayer.ts engine source — by mirroring the formulas in +// JS) all derive identical geometry from the same definitions. + +import type { Layer, ClipInstance } from '@/types/scene' + +/** Number of radial copies to render. Returns 1 when kaleido is off + * or the count is below the floor — the caller should treat 1 as + * "no kaleido, just render once unchanged". */ +export function kaleidoCountFor(entity: { kaleido?: boolean; kaleidoCount?: number }): number { + if (!entity.kaleido) return 1 + const n = Math.floor(entity.kaleidoCount ?? 0) + if (n < 2) return 1 + return n +} + +/** Per-copy radial angle in degrees. Index 0 → 0° (the source). */ +export function kaleidoAngleDegrees(index: number, count: number): number { + if (count <= 1) return 0 + return (index * 360) / count +} + +/** ClipInstance kaleido pivot in world space. Defaults to the + * instance's own position when no explicit pivot is set. */ +export function clipKaleidoPivot(inst: ClipInstance): { x: number; y: number } { + const ix = inst.x ?? 0 + const iy = inst.y ?? 0 + return { + x: inst.kaleidoPivotX ?? ix, + y: inst.kaleidoPivotY ?? iy, + } +} + +/** Per-arm local clip time (seconds). Returns `null` when the arm is + * outside the clip's playable range — i.e. the arm hasn't started + * yet (its delayed start is still in the future) or it has already + * finished. Callers must skip arms where this returns null. + * + * Semantics: arm `i` is delayed by `i * kaleidoOffset` seconds + * relative to the source. So with offset = 0.5s and four arms, at + * scene time `t` arm 0 shows the source frame, arm 1 shows the + * frame from 0.5s earlier in the clip's playback (equivalent to + * "arm 1 starts 0.5s after arm 0"), arm 2 from 1s earlier, etc. + * Before its delayed start the arm is hidden; after the clip's end + * it stays gone. + */ +export function clipArmLocalTime( + inst: ClipInstance, + baseLocal: number, + index: number, + trimmedDuration: number, +): number | null { + if (trimmedDuration <= 0) return null + const offset = inst.kaleidoOffset ?? 0 + const trimStart = inst.trimStart ?? 0 + const offsetIntoBlock = baseLocal - trimStart + // Arm 0 (and the no-shift case) just uses the source's own clip- + // local time; arms i>0 with a non-zero offset are delayed. + const shifted = (index === 0 || offset === 0) + ? offsetIntoBlock + : offsetIntoBlock - index * offset + const EPS = 1e-9 + if (shifted < -EPS || shifted > trimmedDuration + EPS) return null + return trimStart + Math.max(0, Math.min(trimmedDuration, shifted)) +} + +/** + * For rotateCopies=false: compute the rotated *position* of the + * instance's anchor around the kaleido pivot. The caller then + * translates by this rotated position (instead of the original + * (ix, iy)) so the contents land in a circle around the pivot + * without rotating themselves. + */ +export function clipArmTranslate( + inst: ClipInstance, + index: number, + count: number, +): { x: number; y: number } { + const ix = inst.x ?? 0 + const iy = inst.y ?? 0 + if (index === 0 || count <= 1) return { x: ix, y: iy } + const rotateCopies = inst.kaleidoRotateCopies !== false + if (rotateCopies) return { x: ix, y: iy } + const { x: px, y: py } = clipKaleidoPivot(inst) + const a = (kaleidoAngleDegrees(index, count) * Math.PI) / 180 + const dx = ix - px + const dy = iy - py + return { + x: px + dx * Math.cos(a) - dy * Math.sin(a), + y: py + dx * Math.sin(a) + dy * Math.cos(a), + } +} + +/** Bool helper: should the per-arm transform also rotate the + * contents (true) or only translate them around the pivot (false)? + * Default true mirrors the natural kaleidoscope look. Layers don't + * expose a toggle — they always rotate copies. */ +export function shouldRotateClipCopies(inst: ClipInstance): boolean { + return inst.kaleidoRotateCopies !== false +} + +/** Convenience wrapper for the layer side, where the math is + * simpler (pivot is always the layer's own position, contents + * always rotate with the copy). */ +export function layerKaleidoCount(layer: Layer): number { + return kaleidoCountFor(layer) +} diff --git a/src/lib/render.ts b/src/lib/render.ts index db58b2a..b64ab0f 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -2,6 +2,15 @@ import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, Sv import { BUTTON_STATE_TIMES } from '@/types/scene' import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate' import { fontCssFamily } from './fonts' +import { + clipArmLocalTime, + clipArmTranslate, + clipKaleidoPivot, + kaleidoAngleDegrees, + kaleidoCountFor, + layerKaleidoCount, + shouldRotateClipCopies, +} from './kaleido' export async function loadSceneImages(scene: Scene): Promise> { const result: Record = {} @@ -150,11 +159,16 @@ function drawLayers( if (!asset) continue const p = sampleLayer(layer, time) - ctx.save() - ctx.globalAlpha *= p.opacity - ctx.translate(p.x, p.y) - ctx.rotate((p.rotation * Math.PI) / 180) - ctx.scale(p.scaleX, p.scaleY) + // Kaleido is disabled on button-asset layers (button assets render + // their own internal layers and click routing must stay 1:1). + const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer) + for (let kf = 0; kf < kCount; kf++) { + const kAngle = kaleidoAngleDegrees(kf, kCount) + ctx.save() + ctx.globalAlpha *= p.opacity + ctx.translate(p.x, p.y) + ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180) + ctx.scale(p.scaleX, p.scaleY) const shadow = computeShadow(p) applyShadow(ctx, shadow) @@ -261,8 +275,63 @@ function drawLayers( drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true) } - clearShadow(ctx) - ctx.restore() + clearShadow(ctx) + ctx.restore() + } + } +} + +/** Renders one clip instance — including kaleidoscope replication + * when configured. Shared by the top-level scene loop and the + * in-group child-drawing path so both honour the same kaleido math. */ +function renderClipInstance( + target: CanvasRenderingContext2D, + inst: import('@/types/scene').ClipInstance, + clip: import('@/types/scene').Clip, + time: number, + assetMap: Map, + images: Record, +) { + const trimStart = inst.trimStart ?? 0 + const trimEnd = inst.trimEnd ?? 0 + const visible = Math.max(0, clip.duration - trimStart - trimEnd) + const offsetIntoBlock = time - inst.startTime + const isx = inst.scaleX ?? 1 + const isy = inst.scaleY ?? 1 + const irot = inst.rotation ?? 0 + + const kCount = kaleidoCountFor(inst) + const rotateCopies = shouldRotateClipCopies(inst) + const pivot = clipKaleidoPivot(inst) + const baseLocal = trimStart + offsetIntoBlock + + // With kaleido + offset, delayed arms can still be valid after the + // source's window has ended (they started later, so they finish + // later). The early-out for the source's window only applies when + // there's no delay-staggered arm to render. + const offsetSec = inst.kaleidoOffset ?? 0 + const maxArmDelay = kCount > 1 && offsetSec !== 0 ? Math.max(0, (kCount - 1) * offsetSec) : 0 + if (offsetIntoBlock < 0 || offsetIntoBlock > visible + maxArmDelay) return + + for (let kf = 0; kf < kCount; kf++) { + const kAngle = kaleidoAngleDegrees(kf, kCount) + const localT = clipArmLocalTime(inst, baseLocal, kf, visible) + if (localT === null) continue + const trans = clipArmTranslate(inst, kf, kCount) + + target.save() + if (kCount > 1 && kf > 0 && rotateCopies) { + // Rotate the entire local space around the pivot — contents + // follow the rotation, giving the natural kaleido look. + target.translate(pivot.x, pivot.y) + target.rotate((kAngle * Math.PI) / 180) + target.translate(-pivot.x, -pivot.y) + } + target.translate(trans.x, trans.y) + if (irot !== 0) target.rotate((irot * Math.PI) / 180) + target.scale(isx, isy) + drawMaskedGroup(target, clip.layers, assetMap, images, localT) + target.restore() } } @@ -286,38 +355,45 @@ function drawMaskShape( if (!isButtonLayerActiveAt(layer, time)) return } else if (!isLayerActiveAt(layer, time)) return const p = sampleLayer(layer, time) - ctx.save() - ctx.translate(p.x, p.y) - ctx.rotate((p.rotation * Math.PI) / 180) - ctx.scale(p.scaleX, p.scaleY) - ctx.fillStyle = '#000000' - if (asset.kind === 'rect') { - const offX = layer.offsetX ?? asset.width / 2 - const offY = layer.offsetY ?? asset.height / 2 - ctx.fillRect(-offX, -offY, asset.width, asset.height) - } else if (asset.kind === 'circle') { - const offX = layer.offsetX ?? 0 - const offY = layer.offsetY ?? 0 - ctx.beginPath() - ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2) - ctx.fill() - } else if (asset.kind === 'text') { - const offX = layer.offsetX ?? 0 - const offY = layer.offsetY ?? 0 - ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}` - ctx.textBaseline = 'top' - ctx.fillText(asset.text, -offX, -offY) - } else { - const offX = layer.offsetX ?? 0 - const offY = layer.offsetY ?? 0 - const pts = p.points ?? asset.points - const closed = asset.closed !== false - if (closed && pts.length >= 3) { - tracePolygonPath(ctx, pts, asset.points, offX, offY, true) + // Mask layers honour kaleido — every radial copy contributes to the + // composite mask buffer, so the cumulative mask is the union of the + // N rotated shapes. + const kCount = layerKaleidoCount(layer) + for (let kf = 0; kf < kCount; kf++) { + const kAngle = kaleidoAngleDegrees(kf, kCount) + ctx.save() + ctx.translate(p.x, p.y) + ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180) + ctx.scale(p.scaleX, p.scaleY) + ctx.fillStyle = '#000000' + if (asset.kind === 'rect') { + const offX = layer.offsetX ?? asset.width / 2 + const offY = layer.offsetY ?? asset.height / 2 + ctx.fillRect(-offX, -offY, asset.width, asset.height) + } else if (asset.kind === 'circle') { + const offX = layer.offsetX ?? 0 + const offY = layer.offsetY ?? 0 + ctx.beginPath() + ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2) ctx.fill() + } else if (asset.kind === 'text') { + const offX = layer.offsetX ?? 0 + const offY = layer.offsetY ?? 0 + ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}` + ctx.textBaseline = 'top' + ctx.fillText(asset.text, -offX, -offY) + } else { + const offX = layer.offsetX ?? 0 + const offY = layer.offsetY ?? 0 + const pts = p.points ?? asset.points + const closed = asset.closed !== false + if (closed && pts.length >= 3) { + tracePolygonPath(ctx, pts, asset.points, offX, offY, true) + ctx.fill() + } } + ctx.restore() } - ctx.restore() } /** Render the contents of a scene-level TrackGroup. Unlike @@ -379,32 +455,7 @@ function drawTrackGroup( for (const inst of instances) { const clip = clipById.get(inst.clipId) if (!clip) continue - const trimStart = inst.trimStart ?? 0 - const trimEnd = inst.trimEnd ?? 0 - const visible = Math.max(0, clip.duration - trimStart - trimEnd) - const offsetIntoBlock = time - inst.startTime - if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue - const ix = inst.x ?? 0 - const iy = inst.y ?? 0 - const isx = inst.scaleX ?? 1 - const isy = inst.scaleY ?? 1 - const irot = inst.rotation ?? 0 - const needsTransform = - ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0 - if (needsTransform) { - target.save() - target.translate(ix, iy) - if (irot !== 0) target.rotate((irot * Math.PI) / 180) - target.scale(isx, isy) - } - // Clip-internal masks: same scoping as the main timeline, - // confined to the clip. drawMaskedGroup partitions the clip's - // layers into masks vs. content and composites the result via - // destination-in / destination-out, so a mask placed above - // content layers in the clip masks the content but doesn't - // bleed out to anything else on the canvas. - drawMaskedGroup(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock) - if (needsTransform) target.restore() + renderClipInstance(target, inst, clip, time, assetMap, images) } } @@ -616,28 +667,7 @@ export function drawSceneFrame( for (const inst of instances) { const clip = clipById.get(inst.clipId) if (!clip) continue - const trimStart = inst.trimStart ?? 0 - const trimEnd = inst.trimEnd ?? 0 - const visible = Math.max(0, clip.duration - trimStart - trimEnd) - const offsetIntoBlock = time - inst.startTime - if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue - const ix = inst.x ?? 0 - const iy = inst.y ?? 0 - const isx = inst.scaleX ?? 1 - const isy = inst.scaleY ?? 1 - const irot = inst.rotation ?? 0 - const needsTransform = - ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0 - if (needsTransform) { - drawCtx.save() - drawCtx.translate(ix, iy) - if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180) - drawCtx.scale(isx, isy) - } - // Same as the in-group clip path above: drawMaskedGroup so - // clip-local masks apply to layers within this clip only. - drawMaskedGroup(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock) - if (needsTransform) drawCtx.restore() + renderClipInstance(drawCtx, inst, clip, time, assetMap, images) } continue } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 0601e7d..0557ed7 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -2163,6 +2163,23 @@ export const useSceneStore = defineStore('scene', { const n = name.trim() if (n) layer.name = n }, + setLayerKaleido(id: string, on: boolean) { + const layer = this.activeLayers.find(l => l.id === id) + if (!layer) return + if (on) { + layer.kaleido = true + if (!layer.kaleidoCount || layer.kaleidoCount < 2) layer.kaleidoCount = 6 + } else { + // Keep kaleidoCount so re-toggling restores the user's choice. + delete layer.kaleido + } + }, + setLayerKaleidoCount(id: string, count: number) { + const layer = this.activeLayers.find(l => l.id === id) + if (!layer) return + const n = Math.max(2, Math.min(64, Math.floor(count))) + layer.kaleidoCount = n + }, setClipTrackName(id: string, name: string) { const track = this.scene.clipTracks?.find((t) => t.id === id) if (track) track.name = name.trim() || undefined @@ -3245,6 +3262,46 @@ export const useSceneStore = defineStore('scene', { else inst.rotation = r } }, + setClipInstanceKaleido( + id: string, + patch: Partial<{ + kaleido: boolean + kaleidoCount: number + kaleidoOffset: number + kaleidoRotateCopies: boolean + kaleidoPivotX: number + kaleidoPivotY: number + }>, + ) { + const inst = this.scene.clipInstances?.find((ci) => ci.id === id) + if (!inst) return + if (patch.kaleido !== undefined) { + if (patch.kaleido) { + inst.kaleido = true + if (!inst.kaleidoCount || inst.kaleidoCount < 2) inst.kaleidoCount = 6 + } else { + delete inst.kaleido + } + } + if (patch.kaleidoCount !== undefined) { + const n = Math.max(2, Math.min(64, Math.floor(patch.kaleidoCount))) + inst.kaleidoCount = n + } + if (patch.kaleidoOffset !== undefined) { + if (patch.kaleidoOffset === 0) delete inst.kaleidoOffset + else inst.kaleidoOffset = patch.kaleidoOffset + } + if (patch.kaleidoRotateCopies !== undefined) { + if (patch.kaleidoRotateCopies) delete inst.kaleidoRotateCopies + else inst.kaleidoRotateCopies = false + } + if (patch.kaleidoPivotX !== undefined) { + inst.kaleidoPivotX = patch.kaleidoPivotX + } + if (patch.kaleidoPivotY !== undefined) { + inst.kaleidoPivotY = patch.kaleidoPivotY + } + }, /** Create an empty group. When `anchorTrackId` is given the group * lands directly above that track in trackOrder — resolving to * the parent group's position if the anchor sits inside one. Falls diff --git a/src/types/scene.ts b/src/types/scene.ts index 5c07198..0088f12 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -230,6 +230,13 @@ export interface Layer { onClick?: Action[] /** Only meaningful for button layers: initial disabled flag at runtime. */ buttonDisabled?: boolean + /** Render N radial copies around the layer's pivot. Spatial only — + * layers don't get a per-copy time offset (that lives on + * ClipInstance, where it's the headline use case). */ + kaleido?: boolean + /** ≥ 2, integer. Total visible copies = kaleidoCount. Disabled + * when `kaleido` is false. */ + kaleidoCount?: number } export interface Clip { @@ -287,6 +294,26 @@ export interface ClipInstance { scaleY?: number /** Degrees clockwise; applied around the group's origin. */ rotation?: number + /** Render the clip N times around `kaleidoPivotX/Y`. Each copy can + * be time-shifted via `kaleidoOffset` so the radial arms show + * different points of the clip's playback simultaneously. */ + kaleido?: boolean + /** ≥ 2, integer. */ + kaleidoCount?: number + /** Seconds. Local clip time for arm i is + * `(visibleOffsetIntoBlock - i * kaleidoOffset)` wrapped into the + * trimmed sub-window. Default 0 → all arms show the same frame. */ + kaleidoOffset?: number + /** When true (default), each copy rotates so its content follows + * the radial direction. When false, copies are translated around + * the pivot but keep the source's orientation — a ring of identical + * un-rotated duplicates. */ + kaleidoRotateCopies?: boolean + /** World-space pivot for the radial arrangement. Defaults to the + * instance's own (x, y). Setting it elsewhere lets the user place + * the kaleido centre off the clip. */ + kaleidoPivotX?: number + kaleidoPivotY?: number } /** diff --git a/src/version.ts b/src/version.ts index f7e7c6a..02d3db8 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '1.2.7' +export const APP_VERSION = '1.2.8' export const APP_NAME = 'RUWIO'