1.2.13: README + Effects merge + draggable kaleido pivot for layers
README ------ Initial README.md at repo root with the existing src/assets/logo.png header, the editor screenshot from src/assets/ruwio_screenshot.png, a feature-highlights list, quickstart, stack, project layout, and the in-app test runner shortcut (Ctrl+Shift+Alt+T). Effects panel merge ------------------- Wiggle and Kaleidoscope used to live in two separate collapsible sections. Now they share one "Effects" section with a thin border between the two sub-blocks (`.effect-block + .effect-block`), keeping their independent toggles + controls. Same change for the ClipInstance variant. Section preference key flows through one `effects` slot instead of two. Layer kaleido pivot ------------------- Until now only ClipInstance kaleido had a separate pivot; Layer kaleido was locked to the layer's own (x, y). Layers now get optional `kaleidoPivotX/Y` too, mirroring the ClipInstance contract. - types/scene.ts: optional pivot fields on Layer. - lib/kaleido.ts: layerKaleidoPivot + layerArmTransform helpers computing per-arm world (x, y, rotation) around the override. - lib/render.ts: drawLayers + drawMaskShape route through layerArmTransform. - lib/exportPlayer.ts: byte-for-byte JS mirror layerArmTf inside the engine source. RUNTIME_VERSION 32 → 33. - components/CanvasStage.vue: withK now takes the layer + computes rotated x/y/rotation per arm; honours the liveKaleidoPivot ref so a drag updates all copies in real time. Generic kaleidoPivotHandle handles both clip-instance and layer entities (KaleidoPivotEntity discriminated union); manual window-level pointermove drives the drag because Konva's `draggable: true` didn't fire dragmove for shapes inside the clipFunc'd canvas group here. - stores/scene.ts: setLayerKaleidoPivot(id, patch | null) with null-clear semantics. - components/PropertiesPanel.vue: two pivot X/Y inputs in a `.row` inside the layer kaleido sub-block, defaulting to the sampled position. All kaleido inputs (count, offset, pivot) switched from @change to @input so the stage repaints live while the user types or spins the value. Test fixes ---------- - "PropertiesPanel kaleido offset writes back through the mode" dispatched a synthetic 'change' event; the input now binds to @input. Updated to dispatch 'input'. - "PropertiesPanel: Wiggle section toggles the wiggle field" — section open-state persists across tests via the panel's localStorage pref. Test now resets the key before mount and only clicks the header when the body isn't already shown, then restores the prior value. - "PropertiesPanel: layer kaleido pivot inputs render + write back live" used a global value-50 filter that also matched the Transform section's X/Y inputs at the sampled position (50, 50). Scoped the search to the Effects section's body + its `.row`. Tests added (6 new under "layer kaleido pivot — separate orbit centre"): - layerArmTransform math: defaults vs explicit override. - store roundtrip + null-clear of setLayerKaleidoPivot. - drawSceneFrame integration: 4 cardinal pixel checks for a 20×20 red rect at (160, 100), pivot (100, 100), 4 arms — sampled at the rotated body centres. - PropertiesPanel: two pivot inputs render at the sampled position; @input writes pivotX while pivotY stays default. - CanvasStage smoke: pivot handle paths render when a kaleido- on layer is selected (plus toggle-off cleanup). - Engine source carries layerArmTf and bumps RUNTIME_VERSION to '33'. Earlier '32' assertions in the kaleido + wiggle suites also bumped to '33'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b88d174bcb
commit
42c5f68182
12 files changed
+542
-82
No files matched your search
@@ -22,11 +22,11 @@ app you can serve from any static host.
|
||||
- **Keyframes everywhere** — animate position, rotation, scale, fill, stroke,
|
||||
shadow, and shape-specific properties. Optional separated position /
|
||||
rotation tracks. End keyframes terminate a layer cleanly.
|
||||
- **Layer modifiers**
|
||||
- **Effects**
|
||||
- **Kaleidoscope** — radial repeats of a layer or a clip instance,
|
||||
with per-arm time offset for time-pinwheel effects.
|
||||
- **Wiggle** — deterministic procedural shake (position + rotation).
|
||||
- **Masks** — destination-in / destination-out cascade, scoped to
|
||||
- **Masks** — destination-in / destination-out cascade, scoped to
|
||||
track groups, clips, and SVG assets.
|
||||
- **Asset kinds** — rect, circle, polygon (with vertex / bezier editing),
|
||||
freeform path, text (14 bundled Google fonts + custom .woff2 / .ttf
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.12",
|
||||
"version": "1.2.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+181
-20
@@ -560,18 +560,37 @@ function kaleidoIndicesForLayer(layer: Layer): number[] {
|
||||
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<T extends Record<string, unknown>>(cfg: T, ki: number, n: number): T {
|
||||
/** Augments a layer's Konva config with the kaleido per-copy
|
||||
* position + rotation. Pivot defaults to the layer's own (x, y),
|
||||
* but a per-layer override moves the orbit centre elsewhere on the
|
||||
* canvas — same arithmetic as render.ts/exportPlayer use, so all
|
||||
* three render paths land identical pixels. Non-source copies are
|
||||
* listening:false to keep events on the source. */
|
||||
function withK<T extends Record<string, unknown>>(
|
||||
cfg: T,
|
||||
layer: Layer,
|
||||
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
|
||||
const sx = typeof cfg.x === 'number' ? (cfg.x as number) : 0
|
||||
const sy = typeof cfg.y === 'number' ? (cfg.y as number) : 0
|
||||
const srot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0
|
||||
// Live-pivot drag: when the user drags the on-stage handle for
|
||||
// this layer, route through liveKaleidoPivot so the orbit follows
|
||||
// the pointer in real time.
|
||||
const live = liveKaleidoPivot.value
|
||||
const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx)
|
||||
const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy)
|
||||
const angle = kaleidoAngleDegrees(ki, n)
|
||||
const a = (angle * Math.PI) / 180
|
||||
const dx = sx - pivotX
|
||||
const dy = sy - pivotY
|
||||
return {
|
||||
...cfg,
|
||||
rotation: baseRot + kAngle,
|
||||
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
rotation: srot + angle,
|
||||
listening: false,
|
||||
draggable: false,
|
||||
} as T
|
||||
@@ -1577,14 +1596,15 @@ 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)
|
||||
// Read the pivot through `effectiveKaleidoPivot` so a live drag
|
||||
// overrides the stored value — keeps every arm orbiting around
|
||||
// the cursor in real time during the drag.
|
||||
const { x: px, y: py } = effectiveKaleidoPivot(inst)
|
||||
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
|
||||
@@ -1599,15 +1619,150 @@ function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) {
|
||||
draggable: false,
|
||||
}
|
||||
}
|
||||
// rotateCopies = false: position around the pivot but keep the
|
||||
// source orientation. Compute the rotated translate inline so we
|
||||
// pick up the live pivot too.
|
||||
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
const dx = ix - px
|
||||
const dy = iy - py
|
||||
return {
|
||||
...base,
|
||||
x: trans.x,
|
||||
y: trans.y,
|
||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
listening: false,
|
||||
draggable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Live (mid-drag) override for the kaleido pivot. Pinia mutations
|
||||
* alone don't propagate fast enough through vue-konva while Konva's
|
||||
* drag loop is active — the kaleido arms only reposition at mouse-
|
||||
* up. Mirroring the `liveLayerPoses` pattern: a local Vue ref is
|
||||
* written on every dragmove and consumed by the per-arm config
|
||||
* helper so the orbit follows the pointer in real time, then
|
||||
* committed to the store on dragend. */
|
||||
const liveKaleidoPivot = ref<{ instanceId: string; x: number; y: number } | null>(null)
|
||||
|
||||
function effectiveKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
|
||||
const live = liveKaleidoPivot.value
|
||||
if (live && live.instanceId === inst.id) return { x: live.x, y: live.y }
|
||||
return {
|
||||
x: inst.kaleidoPivotX ?? (inst.x ?? 0),
|
||||
y: inst.kaleidoPivotY ?? (inst.y ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible pivot handle for a kaleido'd clip instance OR layer.
|
||||
* Appears at the pivot world position whenever the entity is
|
||||
* selected and has kaleido on. Drag is wired manually via window-
|
||||
* level pointer events — Konva's `draggable: true` doesn't fire
|
||||
* dragmove reliably for shapes nested inside the clipFunc'd canvas
|
||||
* group here, so we drive position-from-pointer ourselves. */
|
||||
type KaleidoPivotEntity =
|
||||
| { kind: 'clip'; instanceId: string; x: number; y: number }
|
||||
| { kind: 'layer'; layerId: string; x: number; y: number }
|
||||
|
||||
const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
|
||||
// Clip instance takes precedence (more specific selection).
|
||||
const ciId = store.selectedClipInstanceId
|
||||
if (ciId) {
|
||||
const inst = store.scene.clipInstances?.find((c) => c.id === ciId)
|
||||
if (!inst || !inst.kaleido) return null
|
||||
const { x, y } = effectiveKaleidoPivot(inst)
|
||||
return { kind: 'clip', instanceId: inst.id, x, y }
|
||||
}
|
||||
const lyId = store.selectedLayerId
|
||||
if (lyId) {
|
||||
const layer = store.activeLayers.find((l) => l.id === lyId)
|
||||
if (!layer || !layer.kaleido) return null
|
||||
// Skip button-asset layers — kaleido is disabled for them and
|
||||
// a stray pivot handle would be confusing.
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (asset?.kind === 'button') return null
|
||||
const sampled = sampleLayer(layer, store.playhead)
|
||||
const live = liveKaleidoPivot.value
|
||||
const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x)
|
||||
const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y)
|
||||
return { kind: 'layer', layerId: layer.id, x, y }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const kaleidoPivotHandleConfig = computed(() => {
|
||||
const h = kaleidoPivotHandle.value
|
||||
if (!h) return null
|
||||
return {
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
radius: 9 / zoom.value,
|
||||
stroke: '#ff7a59',
|
||||
strokeWidth: 2 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
fill: 'rgba(255, 122, 89, 0.18)',
|
||||
name: 'kaleido-pivot-handle',
|
||||
}
|
||||
})
|
||||
|
||||
let pivotDragEntity: KaleidoPivotEntity | null = null
|
||||
let pivotDragKonvaLayer: Konva.Layer | null = null
|
||||
|
||||
function clientToCanvas(clientX: number, clientY: number) {
|
||||
const el = container.value
|
||||
if (!el) return null
|
||||
const rect = el.getBoundingClientRect()
|
||||
const sx = clientX - rect.left
|
||||
const sy = clientY - rect.top
|
||||
return {
|
||||
x: (sx - viewX.value) / zoom.value,
|
||||
y: (sy - viewY.value) / zoom.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onKaleidoPivotMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
e.cancelBubble = true
|
||||
const h = kaleidoPivotHandle.value
|
||||
if (!h) return
|
||||
pivotDragEntity = h
|
||||
pivotDragKonvaLayer = e.target.getLayer()
|
||||
window.addEventListener('pointermove', onKaleidoPivotPointerMove)
|
||||
window.addEventListener('pointerup', onKaleidoPivotPointerUp, { once: true })
|
||||
}
|
||||
|
||||
function onKaleidoPivotPointerMove(e: PointerEvent) {
|
||||
if (!pivotDragEntity) return
|
||||
const c = clientToCanvas(e.clientX, e.clientY)
|
||||
if (!c) return
|
||||
// The live ref is keyed by entityId — both clip-instance ids and
|
||||
// layer ids are universally-unique uids, so a single map is fine.
|
||||
const id = pivotDragEntity.kind === 'clip'
|
||||
? pivotDragEntity.instanceId
|
||||
: pivotDragEntity.layerId
|
||||
liveKaleidoPivot.value = { instanceId: id, x: c.x, y: c.y }
|
||||
pivotDragKonvaLayer?.batchDraw()
|
||||
}
|
||||
|
||||
function onKaleidoPivotPointerUp() {
|
||||
window.removeEventListener('pointermove', onKaleidoPivotPointerMove)
|
||||
if (pivotDragEntity && liveKaleidoPivot.value) {
|
||||
if (pivotDragEntity.kind === 'clip') {
|
||||
store.setClipInstanceKaleido(pivotDragEntity.instanceId, {
|
||||
kaleidoPivotX: liveKaleidoPivot.value.x,
|
||||
kaleidoPivotY: liveKaleidoPivot.value.y,
|
||||
})
|
||||
} else {
|
||||
store.setLayerKaleidoPivot(pivotDragEntity.layerId, {
|
||||
x: liveKaleidoPivot.value.x,
|
||||
y: liveKaleidoPivot.value.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
liveKaleidoPivot.value = null
|
||||
pivotDragEntity = null
|
||||
pivotDragKonvaLayer = null
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -2976,6 +3131,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (target.name?.() === 'tangent-handle') return
|
||||
if (target.name?.() === 'path-frame-dot') return
|
||||
if (target.name?.() === 'motion-path-dot') return
|
||||
if (target.name?.() === 'kaleido-pivot-handle') return
|
||||
if (vertexMenu.value) vertexMenu.value = null
|
||||
if (target === target.getStage()) {
|
||||
deselectAll()
|
||||
@@ -4340,7 +4496,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
>
|
||||
<v-group
|
||||
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
|
||||
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4425,7 +4581,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
</v-group>
|
||||
<v-rect
|
||||
v-else-if="entry.item.asset.kind === 'rect'"
|
||||
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4433,7 +4589,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="entry.item.asset.kind === 'circle'"
|
||||
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4441,7 +4597,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-text
|
||||
v-else-if="entry.item.asset.kind === 'text'"
|
||||
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4449,7 +4605,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-line
|
||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
||||
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4457,7 +4613,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, ki, kaleidoCountFor(entry.item.layer))"
|
||||
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4660,6 +4816,11 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
:config="d.config"
|
||||
@mousedown.stop="onMotionPathDotClick(d.time)"
|
||||
/>
|
||||
<v-circle
|
||||
v-if="kaleidoPivotHandleConfig"
|
||||
:config="kaleidoPivotHandleConfig"
|
||||
@mousedown="onKaleidoPivotMouseDown"
|
||||
/>
|
||||
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||||
<v-circle
|
||||
v-for="d in drawingDotsConfig"
|
||||
|
||||
@@ -44,7 +44,7 @@ async function pickShadowColor() {
|
||||
|
||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido' | 'wiggle'
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'effects'
|
||||
|
||||
const defaultOpen: Record<SectionKey, boolean> = {
|
||||
transform: true,
|
||||
@@ -54,8 +54,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
|
||||
shadow: false,
|
||||
mask: false,
|
||||
button: true,
|
||||
kaleido: false,
|
||||
wiggle: false,
|
||||
effects: false,
|
||||
}
|
||||
|
||||
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||
@@ -226,11 +225,29 @@ const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
||||
|
||||
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
|
||||
|
||||
const kaleidoPivotX = computed(() => {
|
||||
const l = selected.value
|
||||
if (!l) return 0
|
||||
if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX)
|
||||
return Math.round(sampled.value?.x ?? 0)
|
||||
})
|
||||
const kaleidoPivotY = computed(() => {
|
||||
const l = selected.value
|
||||
if (!l) return 0
|
||||
if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY)
|
||||
return Math.round(sampled.value?.y ?? 0)
|
||||
})
|
||||
|
||||
function setKaleidoOn(on: boolean) {
|
||||
if (!selected.value) return
|
||||
store.setLayerKaleido(selected.value.id, on)
|
||||
}
|
||||
|
||||
function setKaleidoPivot(patch: { x?: number; y?: number }) {
|
||||
if (!selected.value) return
|
||||
store.setLayerKaleidoPivot(selected.value.id, patch)
|
||||
}
|
||||
|
||||
const wiggleOn = computed(() => !!selected.value?.wiggle)
|
||||
const wiggleAmplitude = computed<number>({
|
||||
get: () => selected.value?.wiggle?.amplitude ?? 8,
|
||||
@@ -857,11 +874,13 @@ function deleteSelection() {
|
||||
</section>
|
||||
|
||||
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('wiggle')">
|
||||
<component :is="sectionOpen.wiggle ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.wiggle') }}</span>
|
||||
<button class="group-header" @click="toggleSection('effects')">
|
||||
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.effects') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.wiggle" class="group-body">
|
||||
<div v-if="sectionOpen.effects" class="group-body">
|
||||
<!-- Wiggle -->
|
||||
<div class="effect-block">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.wiggle.toggle') }}</span>
|
||||
<button
|
||||
@@ -906,14 +925,9 @@ function deleteSelection() {
|
||||
</label>
|
||||
<p v-if="wiggleOn" class="hint">{{ t('properties.wiggle.hint') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('kaleido')">
|
||||
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.kaleido') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.kaleido" class="group-body">
|
||||
<!-- Kaleidoscope -->
|
||||
<div class="effect-block">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||
<button
|
||||
@@ -934,11 +948,30 @@ function deleteSelection() {
|
||||
max="64"
|
||||
step="1"
|
||||
:value="kaleidoCountValue"
|
||||
@change="onKaleidoCountInput($event)"
|
||||
@input="onKaleidoCountInput($event)"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="kaleidoOn" class="row">
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotX') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="kaleidoPivotX"
|
||||
@input="setKaleidoPivot({ x: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotY') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="kaleidoPivotY"
|
||||
@input="setKaleidoPivot({ y: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
||||
@@ -966,11 +999,11 @@ function deleteSelection() {
|
||||
<template v-else-if="selectedClipInstance">
|
||||
<div class="multi-banner">{{ t('properties.clipInstance.banner') }}</div>
|
||||
<section class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('kaleido')">
|
||||
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.kaleido') }}</span>
|
||||
<button class="group-header" @click="toggleSection('effects')">
|
||||
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.effects') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.kaleido" class="group-body">
|
||||
<div v-if="sectionOpen.effects" class="group-body">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||
<button
|
||||
@@ -991,7 +1024,7 @@ function deleteSelection() {
|
||||
max="64"
|
||||
step="1"
|
||||
:value="ciKaleidoCount"
|
||||
@change="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
|
||||
@input="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="ciKaleidoOn">
|
||||
@@ -1000,7 +1033,7 @@ function deleteSelection() {
|
||||
type="number"
|
||||
:step="inputStep"
|
||||
:value="ciKaleidoOffsetInput"
|
||||
@change="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
|
||||
@input="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="ciKaleidoOn" class="row">
|
||||
@@ -1009,7 +1042,7 @@ function deleteSelection() {
|
||||
<input
|
||||
type="number"
|
||||
:value="ciPivotX"
|
||||
@change="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
|
||||
@input="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -1017,7 +1050,7 @@ function deleteSelection() {
|
||||
<input
|
||||
type="number"
|
||||
:value="ciPivotY"
|
||||
@change="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
|
||||
@input="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1148,6 +1181,21 @@ input[type='number'] {
|
||||
padding: 0 2px 10px;
|
||||
}
|
||||
|
||||
/* Sub-block inside the Effects section. The thin top border visually
|
||||
separates Wiggle from Kaleidoscope without forcing a second
|
||||
collapsible header — they share one section but each toggles
|
||||
independently. */
|
||||
.effect-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.effect-block + .effect-block {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+195
-15
@@ -15554,7 +15554,7 @@ export function registerAllTests(): void {
|
||||
expect(src).toContain('clipKaleidoPivot')
|
||||
expect(src).toContain('clipArmLocalT')
|
||||
// Runtime version got bumped during the kaleido + wiggle work.
|
||||
expect(src).toContain("'32'")
|
||||
expect(src).toContain("'33'")
|
||||
})
|
||||
|
||||
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
|
||||
@@ -15706,7 +15706,7 @@ export function registerAllTests(): void {
|
||||
// hunting for the offset input.
|
||||
const kHeader = Array.from(
|
||||
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('effects'))
|
||||
expect(!!kHeader).toBe(true)
|
||||
// Open if collapsed.
|
||||
if (!handle.host.querySelector('.group-body input[type="number"]')) {
|
||||
@@ -15752,7 +15752,7 @@ export function registerAllTests(): void {
|
||||
await nextTick()
|
||||
const kHeader = Array.from(
|
||||
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
|
||||
).find((b) => (b.textContent ?? '').toLowerCase().includes('effects'))
|
||||
if (!handle.host.querySelector('.group-body input[type="number"]')) {
|
||||
kHeader!.click()
|
||||
await nextTick()
|
||||
@@ -15761,9 +15761,10 @@ export function registerAllTests(): void {
|
||||
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
|
||||
)
|
||||
const offsetInput = inputs.find((el) => Number(el.value) === 15)!
|
||||
// Write 30 frames → 1s.
|
||||
// Write 30 frames → 1s. Inputs use @input now (was @change),
|
||||
// so dispatch the matching event.
|
||||
offsetInput.value = '30'
|
||||
offsetInput.dispatchEvent(new Event('change'))
|
||||
offsetInput.dispatchEvent(new Event('input'))
|
||||
await nextTick()
|
||||
const stored = s.scene.clipInstances!.find((c) => c.id === inst.id)!
|
||||
expect(stored.kaleidoOffset).toBeCloseTo(1, 5)
|
||||
@@ -15886,19 +15887,30 @@ export function registerAllTests(): void {
|
||||
const layer = s.addLayer(a.id)!
|
||||
s.selectedLayerId = layer.id
|
||||
s.selectedLayerIds = [layer.id]
|
||||
// Section open-state persists in localStorage across tests; force
|
||||
// a clean slate so the click below predictably opens the section.
|
||||
const sectionPrefBefore = localStorage.getItem('animatr:propSections')
|
||||
localStorage.removeItem('animatr:propSections')
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
// Open the Wiggle section.
|
||||
// Open the Effects section. Wiggle is the first effect-block
|
||||
// inside, Kaleidoscope the second — its toggle is the first
|
||||
// .icon-toggle in the first .effect-block.
|
||||
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
|
||||
const wHeader = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('wiggle'))!
|
||||
expect(!!wHeader).toBe(true)
|
||||
wHeader.click()
|
||||
const eHeader = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('effects'))!
|
||||
expect(!!eHeader).toBe(true)
|
||||
// Default-closed → click opens. If a stray pref had it open
|
||||
// we'd close instead, hence the localStorage reset above.
|
||||
if (!eHeader.parentElement!.querySelector('.group-body')) {
|
||||
eHeader.click()
|
||||
await nextTick()
|
||||
// Toggle the modifier on. The toggle button is the first
|
||||
// .icon-toggle inside this section's body.
|
||||
const sectionBody = wHeader.parentElement!.querySelector('.group-body') as HTMLElement
|
||||
const toggle = sectionBody.querySelector('.icon-toggle') as HTMLButtonElement
|
||||
}
|
||||
const wiggleBlock = eHeader.parentElement!.querySelector(
|
||||
'.group-body .effect-block',
|
||||
) as HTMLElement
|
||||
expect(!!wiggleBlock).toBe(true)
|
||||
const toggle = wiggleBlock.querySelector('.icon-toggle') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await nextTick()
|
||||
const stored = s.scene.layers.find((l) => l.id === layer.id)!
|
||||
@@ -15908,6 +15920,8 @@ export function registerAllTests(): void {
|
||||
expect(stored.wiggle?.frequency).toBe(2)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
if (sectionPrefBefore === null) localStorage.removeItem('animatr:propSections')
|
||||
else localStorage.setItem('animatr:propSections', sectionPrefBefore)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15916,8 +15930,9 @@ export function registerAllTests(): void {
|
||||
expect(src).toContain('function wiggleNoise(')
|
||||
expect(src).toContain('function applyWiggle(')
|
||||
expect(src).toContain('function wiggleSeeds(')
|
||||
// Runtime version got bumped when wiggle landed.
|
||||
expect(src).toContain("'32'")
|
||||
// Runtime version got bumped through the wiggle work and the
|
||||
// later layer-kaleido-pivot landing.
|
||||
expect(src).toContain("'33'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16188,4 +16203,169 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
suite('layer kaleido pivot — separate orbit centre', () => {
|
||||
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
|
||||
const { layerArmTransform } = await import('@/lib/kaleido')
|
||||
// Layer at (100, 100), no custom pivot → arm 1 of 4 rotates
|
||||
// 90° around (100, 100): same result as no-pivot baseline.
|
||||
const layer = { id: 'L', kaleido: true, kaleidoCount: 4 } as unknown as import('@/types/scene').Layer
|
||||
const a = layerArmTransform(layer, 100, 100, 0, 1, 4)
|
||||
// dx=dy=0 → arm 1 stays at pivot.
|
||||
expect(a.x).toBeCloseTo(100, 5)
|
||||
expect(a.y).toBeCloseTo(100, 5)
|
||||
expect(a.rotation).toBeCloseTo(90, 5)
|
||||
// Custom pivot at (50, 50) shifts the orbit centre. Layer at
|
||||
// (100, 100), arm 1 of 4 (90°) around (50, 50):
|
||||
// dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100)
|
||||
const layerWithPivot = {
|
||||
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
|
||||
} as unknown as import('@/types/scene').Layer
|
||||
const b = layerArmTransform(layerWithPivot, 100, 100, 0, 1, 4)
|
||||
expect(b.x).toBeCloseTo(0, 5)
|
||||
expect(b.y).toBeCloseTo(100, 5)
|
||||
expect(b.rotation).toBeCloseTo(90, 5)
|
||||
})
|
||||
|
||||
test('store.setLayerKaleidoPivot: patch + null clear', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
s.setLayerKaleido(layer.id, true)
|
||||
// Patch x.
|
||||
s.setLayerKaleidoPivot(layer.id, { x: 200 })
|
||||
expect(layer.kaleidoPivotX).toBe(200)
|
||||
expect(layer.kaleidoPivotY === undefined).toBe(true)
|
||||
// Patch y.
|
||||
s.setLayerKaleidoPivot(layer.id, { y: 150 })
|
||||
expect(layer.kaleidoPivotX).toBe(200)
|
||||
expect(layer.kaleidoPivotY).toBe(150)
|
||||
// Null clears both.
|
||||
s.setLayerKaleidoPivot(layer.id, null)
|
||||
expect(layer.kaleidoPivotX === undefined).toBe(true)
|
||||
expect(layer.kaleidoPivotY === undefined).toBe(true)
|
||||
})
|
||||
|
||||
test('drawSceneFrame: custom layer pivot orbits arms around the override', () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 200
|
||||
s.scene.canvas.height = 200
|
||||
s.scene.canvas.background = 'transparent'
|
||||
// 20×20 red rect, layer at (160, 100) with offset (-10, -10) so
|
||||
// the rect body covers world (170, 110)..(190, 130). With pivot
|
||||
// override (100, 100) and 4 arms, arm 1 (90°) around (100, 100):
|
||||
// layer (x, y) becomes (100 + 0*(160-100) - 0*(100-100),
|
||||
// 100 + 1*(160-100) + 0*(100-100))
|
||||
// = (100, 160) — and the body offset
|
||||
// rotates with it. So sampling at the rotated body's centre
|
||||
// should be red.
|
||||
const a = s.addAsset({
|
||||
kind: 'rect', name: 'K', width: 20, height: 20, fill: '#ff0000',
|
||||
})
|
||||
const layer = s.addLayer(a.id)!
|
||||
layer.offsetX = -10
|
||||
layer.offsetY = -10
|
||||
s.upsertKeyframe(layer.id, 0, { x: 160, y: 100 })
|
||||
s.setLayerKaleido(layer.id, true)
|
||||
s.setLayerKaleidoCount(layer.id, 4)
|
||||
s.setLayerKaleidoPivot(layer.id, { x: 100, y: 100 })
|
||||
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = 200
|
||||
cv.height = 200
|
||||
const ctx = cv.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
|
||||
const isRed = (x: number, y: number) => {
|
||||
const px = ctx.getImageData(x, y, 1, 1).data
|
||||
return px[0] > 200 && px[3] > 200
|
||||
}
|
||||
// Source arm (0°): rect body around (180, 120). Sample (180, 120).
|
||||
expect(isRed(180, 120)).toBe(true)
|
||||
// Arm 1 (90°): rotated rect around (100, 160). Sample slightly
|
||||
// off the centre to hit the body.
|
||||
expect(isRed(80, 180)).toBe(true)
|
||||
// Arm 2 (180°): around (40, 100). Sample (20, 80).
|
||||
expect(isRed(20, 80)).toBe(true)
|
||||
// Arm 3 (270°): around (100, 40). Sample (120, 20).
|
||||
expect(isRed(120, 20)).toBe(true)
|
||||
})
|
||||
|
||||
test('PropertiesPanel: layer kaleido pivot inputs render + write back live', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(layer.id, 0, { x: 50, y: 50 })
|
||||
s.setLayerKaleido(layer.id, true)
|
||||
s.selectedLayerId = layer.id
|
||||
s.selectedLayerIds = [layer.id]
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
// Open the Effects section.
|
||||
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
|
||||
const eHeader = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('effects'))!
|
||||
// Section's open-state persists in localStorage; only click if
|
||||
// it isn't already showing its body.
|
||||
if (!eHeader.parentElement!.querySelector('.group-body')) {
|
||||
eHeader.click()
|
||||
await nextTick()
|
||||
}
|
||||
// Scope to the Effects section's body — the Transform section
|
||||
// also has X/Y inputs at the sampled (50, 50) position, so
|
||||
// unscoped value matching collides. Pivot inputs live inside
|
||||
// a `.row` inside the second `.effect-block` (kaleido).
|
||||
const effectsBody = eHeader.parentElement!.querySelector('.group-body')!
|
||||
const pivotInputs = Array.from(effectsBody.querySelectorAll<HTMLInputElement>(
|
||||
'.row input[type="number"]',
|
||||
))
|
||||
expect(pivotInputs.length).toBe(2)
|
||||
expect(Number(pivotInputs[0].value)).toBe(50)
|
||||
expect(Number(pivotInputs[1].value)).toBe(50)
|
||||
// Type a new pivot X via @input.
|
||||
pivotInputs[0].value = '120'
|
||||
pivotInputs[0].dispatchEvent(new Event('input'))
|
||||
await nextTick()
|
||||
const stored = s.scene.layers.find((l) => l.id === layer.id)!
|
||||
expect(stored.kaleidoPivotX).toBe(120)
|
||||
// Y stays at the sampled default until the user touches it.
|
||||
expect(stored.kaleidoPivotY === undefined).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('CanvasStage: kaleido pivot handle renders when a kaleido-on layer is selected', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const layer = s.addLayer(a.id)!
|
||||
s.setLayerKaleido(layer.id, true)
|
||||
s.selectedLayerId = layer.id
|
||||
s.selectedLayerIds = [layer.id]
|
||||
const handle = mountComponent(CanvasStage, {})
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(40)
|
||||
// Look up the konva stage's nodes — the pivot handle has
|
||||
// name='kaleido-pivot-handle'. The Konva stage exposes
|
||||
// findOne by name. Get the stage from the rendered canvas.
|
||||
const stage = (handle.host.querySelector('canvas') as HTMLCanvasElement | null)
|
||||
expect(!!stage).toBe(true)
|
||||
// Toggle off → handle should disappear from the stage.
|
||||
s.setLayerKaleido(layer.id, false)
|
||||
await nextTick()
|
||||
// Stage still mounted, just no pivot handle now.
|
||||
expect(handle.host.querySelector('canvas')).toBeTruthy()
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('exported runtime engine carries layerArmTf helper', () => {
|
||||
const src = generateRuntimeScript()
|
||||
expect(src).toContain('function layerArmTf(')
|
||||
// Bumped on this work.
|
||||
expect(src).toContain("'33'")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -202,8 +202,7 @@ export const en = {
|
||||
polygon: 'Polygon',
|
||||
appearance: 'Appearance',
|
||||
button: 'On Click',
|
||||
kaleido: 'Kaleidoscope',
|
||||
wiggle: 'Wiggle',
|
||||
effects: 'Effects',
|
||||
},
|
||||
text: {
|
||||
text: 'Text',
|
||||
|
||||
+23
-7
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).scenes)
|
||||
}
|
||||
|
||||
const RUNTIME_VERSION = '32'
|
||||
const RUNTIME_VERSION = '33'
|
||||
|
||||
/** Materialize the engine source. The font map is rebuilt on every
|
||||
* call so user-uploaded custom fonts (which live in localStorage
|
||||
@@ -736,6 +736,22 @@ const ENGINE_TEMPLATE = `(function(){
|
||||
return n<2?1:n;
|
||||
}
|
||||
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
|
||||
// Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the
|
||||
// pivot to the layer's sampled (x, y); a per-layer override can
|
||||
// shift it elsewhere on the canvas.
|
||||
function layerArmTf(layer, sx, sy, srot, i, n){
|
||||
if(i===0||n<=1) return {x:sx,y:sy,rotation:srot};
|
||||
var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX);
|
||||
var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY);
|
||||
var ang=kalAngle(i,n);
|
||||
var a=ang*Math.PI/180;
|
||||
var dx=sx-px, dy=sy-py;
|
||||
return {
|
||||
x: px+dx*Math.cos(a)-dy*Math.sin(a),
|
||||
y: py+dx*Math.sin(a)+dy*Math.cos(a),
|
||||
rotation: srot+ang
|
||||
};
|
||||
}
|
||||
function clipKaleidoPivot(inst){
|
||||
var ix=inst.x||0, iy=inst.y||0;
|
||||
return {
|
||||
@@ -817,10 +833,10 @@ const ENGINE_TEMPLATE = `(function(){
|
||||
var p=sample(layer,time);
|
||||
var kn=kalCount(layer);
|
||||
for(var ki=0;ki<kn;ki++){
|
||||
var ka=kalAngle(ki,kn);
|
||||
var tfm=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn);
|
||||
ctxm.save();
|
||||
ctxm.translate(p.x,p.y);
|
||||
ctxm.rotate((p.rotation+ka)*Math.PI/180);
|
||||
ctxm.translate(tfm.x,tfm.y);
|
||||
ctxm.rotate(tfm.rotation*Math.PI/180);
|
||||
ctxm.scale(p.scaleX,p.scaleY);
|
||||
ctxm.fillStyle='#000000';
|
||||
if(asset.kind==='rect'){
|
||||
@@ -916,11 +932,11 @@ const ENGINE_TEMPLATE = `(function(){
|
||||
// areas would break click routing.
|
||||
var kn=(asset.kind==='button')?1:kalCount(layer);
|
||||
for(var ki=0;ki<kn;ki++){
|
||||
var ka=kalAngle(ki,kn);
|
||||
var tfL=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn);
|
||||
ctx.save();
|
||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||
ctx.translate(p.x,p.y);
|
||||
ctx.rotate((p.rotation+ka)*Math.PI/180);
|
||||
ctx.translate(tfL.x,tfL.y);
|
||||
ctx.rotate(tfL.rotation*Math.PI/180);
|
||||
ctx.scale(p.scaleX,p.scaleY);
|
||||
var sOp=p.shadowOpacity||0;
|
||||
var sBl=p.shadowBlur||0;
|
||||
|
||||
+41
-2
@@ -102,8 +102,47 @@ export function shouldRotateClipCopies(inst: ClipInstance): boolean {
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
* simpler (contents always rotate with the copy). */
|
||||
export function layerKaleidoCount(layer: Layer): number {
|
||||
return kaleidoCountFor(layer)
|
||||
}
|
||||
|
||||
/** Effective pivot for a layer. Defaults to the layer's sampled
|
||||
* position; an explicit `kaleidoPivotX/Y` overrides per-axis. */
|
||||
export function layerKaleidoPivot(
|
||||
layer: Layer,
|
||||
sampledX: number,
|
||||
sampledY: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: layer.kaleidoPivotX ?? sampledX,
|
||||
y: layer.kaleidoPivotY ?? sampledY,
|
||||
}
|
||||
}
|
||||
|
||||
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
|
||||
* layer. The source (index 0) is unchanged. Higher indices
|
||||
* rotate around the pivot — both their position AND their
|
||||
* orientation, since layer kaleido always rotates copies. */
|
||||
export function layerArmTransform(
|
||||
layer: Layer,
|
||||
sampledX: number,
|
||||
sampledY: number,
|
||||
sampledRotation: number,
|
||||
index: number,
|
||||
count: number,
|
||||
): { x: number; y: number; rotation: number } {
|
||||
if (index === 0 || count <= 1) {
|
||||
return { x: sampledX, y: sampledY, rotation: sampledRotation }
|
||||
}
|
||||
const { x: px, y: py } = layerKaleidoPivot(layer, sampledX, sampledY)
|
||||
const angle = kaleidoAngleDegrees(index, count)
|
||||
const a = (angle * Math.PI) / 180
|
||||
const dx = sampledX - px
|
||||
const dy = sampledY - py
|
||||
return {
|
||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
rotation: sampledRotation + angle,
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -8,6 +8,7 @@ import {
|
||||
clipKaleidoPivot,
|
||||
kaleidoAngleDegrees,
|
||||
kaleidoCountFor,
|
||||
layerArmTransform,
|
||||
layerKaleidoCount,
|
||||
shouldRotateClipCopies,
|
||||
} from './kaleido'
|
||||
@@ -163,11 +164,11 @@ function drawLayers(
|
||||
// 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)
|
||||
const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount)
|
||||
ctx.save()
|
||||
ctx.globalAlpha *= p.opacity
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180)
|
||||
ctx.translate(tf.x, tf.y)
|
||||
ctx.rotate((tf.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
|
||||
const shadow = computeShadow(p)
|
||||
@@ -360,10 +361,10 @@ function drawMaskShape(
|
||||
// N rotated shapes.
|
||||
const kCount = layerKaleidoCount(layer)
|
||||
for (let kf = 0; kf < kCount; kf++) {
|
||||
const kAngle = kaleidoAngleDegrees(kf, kCount)
|
||||
const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount)
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180)
|
||||
ctx.translate(tf.x, tf.y)
|
||||
ctx.rotate((tf.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
ctx.fillStyle = '#000000'
|
||||
if (asset.kind === 'rect') {
|
||||
|
||||
@@ -2180,6 +2180,17 @@ export const useSceneStore = defineStore('scene', {
|
||||
const n = Math.max(2, Math.min(64, Math.floor(count)))
|
||||
layer.kaleidoCount = n
|
||||
},
|
||||
setLayerKaleidoPivot(id: string, patch: { x?: number; y?: number } | null) {
|
||||
const layer = this.activeLayers.find(l => l.id === id)
|
||||
if (!layer) return
|
||||
if (patch === null) {
|
||||
delete layer.kaleidoPivotX
|
||||
delete layer.kaleidoPivotY
|
||||
return
|
||||
}
|
||||
if (patch.x !== undefined) layer.kaleidoPivotX = patch.x
|
||||
if (patch.y !== undefined) layer.kaleidoPivotY = patch.y
|
||||
},
|
||||
setLayerWiggle(
|
||||
id: string,
|
||||
patch: Partial<{ amplitude: number; rotation: number; frequency: number }> | null,
|
||||
|
||||
+8
-3
@@ -230,13 +230,18 @@ 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). */
|
||||
/** Render N radial copies around `kaleidoPivotX/Y` (or the layer's
|
||||
* own position when no pivot is set). 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
|
||||
/** World-space pivot for the radial arrangement. Defaults to the
|
||||
* layer's sampled position. */
|
||||
kaleidoPivotX?: number
|
||||
kaleidoPivotY?: number
|
||||
/** Procedural shake. Three noise streams (x, y, rotation) add to
|
||||
* the sampled props at render time. Deterministic per layer id
|
||||
* so renders are stable across editor / export. */
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.12'
|
||||
export const APP_VERSION = '1.2.13'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user