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:
thandClaude Opus 4.7 committed 2026-04-26 23:26:56 +02:00
1 parent b88d174bcb
commit 42c5f68182
12 files changed
+609 -149

No files matched your search

+181 -20
View File
@@ -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"