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'