From 3bc329813c4314841c66701342bb62339151166c Mon Sep 17 00:00:00 2001
From: th
Date: Sat, 25 Apr 2026 22:45:01 +0200
Subject: [PATCH] 1.2.1: strokes scale with the layer (incl. inside SVG-asset
placements)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- strokeCfg now defaults to strokeScaleEnabled:true so stroked rect /
circle / text outlines grow proportionally when the layer is scaled
(Konva default; previously hard-pinned to false).
- polygonConfig and clipPolygonConfig had inline stroke blocks that
also forced strokeScaleEnabled:false. Flipped both to true so
freeform / polygon outlines scale alongside the shape.
- SVG-asset placements use a dedicated svgChildConfig wrapper +
pre-multiplied strokeWidth in svgStackForAsset: each child's stroke
is bumped by the placement's geometric-mean scale and rendered with
strokeScaleEnabled:false (Konva resets the ctx to identity before
stroking) so the on-screen width lands at the right pixel count
regardless of how Konva propagates parent transforms to nested
stroke rendering. Konva-mounted tests cover outer-scale 1×, 3× and
non-uniform 4×/1×.
UI chrome (transformer borders, vertex / tangent handles, hit-shape
outlines, animation-path ghosts, ghost-locked layer indicators) keeps
strokeScaleEnabled:false intentionally — those should stay at constant
pixel width regardless of zoom.
---
package.json | 2 +-
src/components/CanvasStage.vue | 74 ++++++++++++++---
src/devtools/tests.ts | 148 +++++++++++++++++++++++++++++++++
src/version.ts | 2 +-
4 files changed, 213 insertions(+), 13 deletions(-)
diff --git a/package.json b/package.json
index 8bb1700..5db2d8c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.2.0",
+ "version": "1.2.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index c8c9220..c10158e 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -540,6 +540,18 @@ function buttonChildConfig>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
+/** 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
+ * scale; combined with strokeScaleEnabled:false (Konva resets the
+ * ctx transform to identity before stroking), the on-screen stroke
+ * width comes out at the right number of pixels regardless of
+ * whether Konva propagates the parent group's scale to nested
+ * stroke rendering. */
+function svgChildConfig>(cfg: T): T {
+ return { ...cfg, draggable: false, listening: false, strokeScaleEnabled: false }
+}
+
/** Invisible Konva rect that covers the SVG asset's canvas rect.
* Children are non-listening (buttonChildConfig) so without a hit
* surface the layer would fall through to the stage and stay
@@ -577,9 +589,18 @@ function buttonStackForTime(
/** Static render stack for an SVG asset placed in the main scene.
* All child keyframes live at t=0 by construction, so there's no
- * per-state lookup — just sample each visible layer once. */
+ * per-state lookup — just sample each visible layer once.
+ *
+ * `outerScale` is the placement layer's scale factor; child stroke
+ * widths get pre-multiplied by it so the rendered outline grows
+ * proportionally with the outer scale. Konva's strokeScaleEnabled
+ * only honours the *node's own* scale, not the cumulative parent
+ * transform — without this pre-multiply, strokes inside a scaled
+ * SVG asset would render at constant pixel width even though every
+ * shape inside grows. */
function svgStackForAsset(
asset: SvgAsset,
+ outerScale = 1,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const layers = asset.layers.slice().reverse()
@@ -588,7 +609,18 @@ function svgStackForAsset(
if (l.editorHidden) continue
const a = assetMap.value.get(l.assetId)
if (!a) continue
- out.push({ layer: l, asset: a, props: sampleLayer(l, 0) })
+ const props = sampleLayer(l, 0)
+ // Resolve effective stroke width from props ?? asset, then scale.
+ // strokeCfg looks at props.strokeWidth first; setting it here
+ // overrides the asset fallback for this render only.
+ const baseSw =
+ props.strokeWidth ??
+ ('strokeWidth' in a ? (a.strokeWidth as number | undefined) : undefined) ??
+ 0
+ if (outerScale !== 1 && baseSw > 0) {
+ props.strokeWidth = baseSw * outerScale
+ }
+ out.push({ layer: l, asset: a, props })
}
return out
}
@@ -1198,7 +1230,12 @@ function strokeCfg(
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
const stroke = (props.stroke ?? asset.stroke) || '#000000'
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
- return { stroke, strokeWidth, strokeScaleEnabled: false }
+ // Strokes scale with the layer (Konva default). Set explicitly:
+ // Konva's bounding-box / hit / cache paths can default to the
+ // shape's previous attribute value when the prop is reactively
+ // omitted, so `strokeScaleEnabled: true` lands on the node either
+ // way after the first render.
+ return { stroke, strokeWidth, strokeScaleEnabled: true }
}
function rectConfig(layer: Layer, asset: Extract, props: AnimatableProps) {
@@ -1385,7 +1422,12 @@ function polygonConfig(
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
- cfg.strokeScaleEnabled = false
+ // Default Konva behaviour: stroke scales with the node's own
+ // scale. Previously hard-pinned to false, which left freeform /
+ // polygon outlines at constant pixel width when the user scaled
+ // the layer. svgChildConfig overrides this back to false for
+ // SVG-asset placements (which pre-multiply strokeWidth instead).
+ cfg.strokeScaleEnabled = true
} else {
cfg.stroke = undefined
cfg.strokeWidth = 0
@@ -1607,7 +1649,11 @@ function clipPolygonConfig(item: RenderedClipLayer) {
if (stroke && strokeWidth > 0) {
cfg.stroke = stroke
cfg.strokeWidth = strokeWidth
- cfg.strokeScaleEnabled = false
+ // Match the regular polygonConfig: strokes scale with the
+ // shape's transform so a scaled clip-instance polygon's outline
+ // grows proportionally instead of staying at constant pixel
+ // width.
+ cfg.strokeScaleEnabled = true
}
return cfg
}
@@ -4077,28 +4123,34 @@ function onResizeHandlePointerUp(e: PointerEvent) {
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
/>
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 0243db6..8a401ae 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -13956,6 +13956,154 @@ export function registerAllTests(): void {
expect(restoredAsset.canvas.height).toBe(80)
expect(restoredAsset.layers.length).toBe(1)
})
+
+ /** Konva-mounted helpers for the stroke-scaling tests below. */
+ async function mountCanvasWithKonva() {
+ const VueKonva = (await import('vue-konva')).default
+ const host = document.createElement('div')
+ host.setAttribute('data-test-host', '')
+ host.style.width = '600px'
+ host.style.height = '400px'
+ document.body.appendChild(host)
+ const app = createApp({ render() { return h(CanvasStage, {}) } })
+ app.use(VueKonva)
+ app.directive('tooltip', vTooltip)
+ app.mount(host)
+ return { host, app, unmount() { app.unmount(); host.remove() } }
+ }
+ async function getKonvaStage() {
+ const Konva = (await import('konva')).default
+ return Konva.stages[Konva.stages.length - 1]
+ }
+
+ test('strokes inside an SVG asset placement scale with the outer scale', async () => {
+ // Regression: Konva's strokeScaleEnabled flag honours only the
+ // node's own scale, so an inner v-rect with scaleX=1 inside a
+ // v-group with scaleX=3 used to render a constant-pixel stroke.
+ // svgStackForAsset now pre-multiplies child strokeWidth by the
+ // outer scale, and svgChildConfig forces strokeScaleEnabled:false
+ // so the multiplied width lands as the right number of on-screen
+ // pixels regardless of how Konva propagates parent transforms.
+ const s = makeFreshStore()
+ s.setDuration(1)
+ s.scene.canvas = { width: 200, height: 200, background: '#ffffff' }
+ const svg = s.addSvgAsset('Logo', 100, 100)
+ s.enterSvgAssetEdit(svg.id)
+ const rect = s.addAsset({
+ kind: 'rect', name: 'R', width: 60, height: 60,
+ fill: '#22cc88', stroke: '#000000', strokeWidth: 4,
+ })
+ s.addLayer(rect.id)
+ s.exitSvgAssetEdit()
+ const placement = s.addLayer(svg.id)!
+ // Bake a 3× scale on the placement.
+ s.upsertKeyframe(placement.id, 0, { scaleX: 3, scaleY: 3 })
+ const handle = await mountCanvasWithKonva()
+ try {
+ await nextTick()
+ await tick(40)
+ const stage = await getKonvaStage() as unknown as {
+ findOne: (sel: string) => unknown
+ }
+ // Outer v-group carries the placement layer's id.
+ const group = stage.findOne(`#${placement.id}`) as unknown as {
+ find: (sel: string) => Array<{
+ stroke: () => string | undefined
+ strokeWidth: () => number
+ strokeScaleEnabled: () => boolean
+ }>
+ } | null
+ expect(group).toBeTruthy()
+ // The svg-hit-catcher rect has no stroke colour set
+ // (Konva defaults `strokeWidth` to 2 even with no stroke).
+ // Distinguish the actual child rect by `stroke()` being a
+ // real colour.
+ const rects = group!.find('Rect')
+ const stroked = rects.find((r) => !!r.stroke())
+ expect(stroked).toBeTruthy()
+ // Pre-multiplied: base 4 × outer scale 3 = 12.
+ expect(stroked!.strokeWidth()).toBeCloseTo(12, 5)
+ // strokeScaleEnabled stays off so Konva resets the ctx
+ // before stroking — the multiplied width then maps 1:1 to
+ // on-screen pixels.
+ expect(stroked!.strokeScaleEnabled()).toBe(false)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('outer scale = 1: stroke width passes through unchanged', async () => {
+ // Sanity: with no scaling on the placement we don't multiply
+ // anything (the 1× factor in svgStackForAsset short-circuits).
+ const s = makeFreshStore()
+ s.setDuration(1)
+ s.scene.canvas = { width: 200, height: 200, background: '#ffffff' }
+ const svg = s.addSvgAsset('Logo', 100, 100)
+ s.enterSvgAssetEdit(svg.id)
+ const rect = s.addAsset({
+ kind: 'rect', name: 'R', width: 60, height: 60,
+ fill: '#22cc88', stroke: '#000000', strokeWidth: 5,
+ })
+ s.addLayer(rect.id)
+ s.exitSvgAssetEdit()
+ const placement = s.addLayer(svg.id)!
+ const handle = await mountCanvasWithKonva()
+ try {
+ await nextTick()
+ await tick(40)
+ const stage = await getKonvaStage() as unknown as {
+ findOne: (sel: string) => unknown
+ }
+ const group = stage.findOne(`#${placement.id}`) as unknown as {
+ find: (sel: string) => Array<{
+ stroke: () => string | undefined
+ strokeWidth: () => number
+ }>
+ }
+ const stroked = group.find('Rect').find((r) => !!r.stroke())
+ expect(stroked).toBeTruthy()
+ expect(stroked!.strokeWidth()).toBeCloseTo(5, 5)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('non-uniform outer scale: strokeWidth uses the geometric mean', async () => {
+ // For scaleX != scaleY the cleanest single-thickness number is
+ // sqrt(|sx| * |sy|) — the geometric mean. With sx=4, sy=1 the
+ // factor is 2, so a base 3px stroke → 6px on screen.
+ const s = makeFreshStore()
+ s.setDuration(1)
+ const svg = s.addSvgAsset('Logo', 100, 100)
+ s.enterSvgAssetEdit(svg.id)
+ const rect = s.addAsset({
+ kind: 'rect', name: 'R', width: 60, height: 60,
+ fill: '#22cc88', stroke: '#000000', strokeWidth: 3,
+ })
+ s.addLayer(rect.id)
+ s.exitSvgAssetEdit()
+ const placement = s.addLayer(svg.id)!
+ s.upsertKeyframe(placement.id, 0, { scaleX: 4, scaleY: 1 })
+ const handle = await mountCanvasWithKonva()
+ try {
+ await nextTick()
+ await tick(40)
+ const stage = await getKonvaStage() as unknown as {
+ findOne: (sel: string) => unknown
+ }
+ const group = stage.findOne(`#${placement.id}`) as unknown as {
+ find: (sel: string) => Array<{
+ stroke: () => string | undefined
+ strokeWidth: () => number
+ }>
+ }
+ const stroked = group.find('Rect').find((r) => !!r.stroke())
+ expect(stroked).toBeTruthy()
+ expect(stroked!.strokeWidth()).toBeCloseTo(6, 4)
+ } finally {
+ handle.unmount()
+ }
+ })
})
suite('svg asset — thumbnail rendering', () => {
diff --git a/src/version.ts b/src/version.ts
index a4fec72..339b23c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.2.0'
+export const APP_VERSION = '1.2.1'
export const APP_NAME = 'RUWIO'
|