1.2.1: strokes scale with the layer (incl. inside SVG-asset placements)
- 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.
This commit is contained in:
1 parent
d03fbe4ecb
commit
3bc329813c
4 files changed
+213
-13
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -540,6 +540,18 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
|||||||
return { ...cfg, draggable: false, listening: false }
|
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<T extends Record<string, unknown>>(cfg: T): T {
|
||||||
|
return { ...cfg, draggable: false, listening: false, strokeScaleEnabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
/** Invisible Konva rect that covers the SVG asset's canvas rect.
|
/** Invisible Konva rect that covers the SVG asset's canvas rect.
|
||||||
* Children are non-listening (buttonChildConfig) so without a hit
|
* Children are non-listening (buttonChildConfig) so without a hit
|
||||||
* surface the layer would fall through to the stage and stay
|
* 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.
|
/** 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
|
* 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(
|
function svgStackForAsset(
|
||||||
asset: SvgAsset,
|
asset: SvgAsset,
|
||||||
|
outerScale = 1,
|
||||||
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
|
||||||
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
|
||||||
const layers = asset.layers.slice().reverse()
|
const layers = asset.layers.slice().reverse()
|
||||||
@@ -588,7 +609,18 @@ function svgStackForAsset(
|
|||||||
if (l.editorHidden) continue
|
if (l.editorHidden) continue
|
||||||
const a = assetMap.value.get(l.assetId)
|
const a = assetMap.value.get(l.assetId)
|
||||||
if (!a) continue
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -1198,7 +1230,12 @@ function strokeCfg(
|
|||||||
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
|
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
|
||||||
const stroke = (props.stroke ?? asset.stroke) || '#000000'
|
const stroke = (props.stroke ?? asset.stroke) || '#000000'
|
||||||
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
|
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<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
||||||
@@ -1385,7 +1422,12 @@ function polygonConfig(
|
|||||||
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
|
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
|
||||||
cfg.stroke = stroke
|
cfg.stroke = stroke
|
||||||
cfg.strokeWidth = strokeWidth
|
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 {
|
} else {
|
||||||
cfg.stroke = undefined
|
cfg.stroke = undefined
|
||||||
cfg.strokeWidth = 0
|
cfg.strokeWidth = 0
|
||||||
@@ -1607,7 +1649,11 @@ function clipPolygonConfig(item: RenderedClipLayer) {
|
|||||||
if (stroke && strokeWidth > 0) {
|
if (stroke && strokeWidth > 0) {
|
||||||
cfg.stroke = stroke
|
cfg.stroke = stroke
|
||||||
cfg.strokeWidth = strokeWidth
|
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
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -4077,28 +4123,34 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
|
:config="svgHitCatcherConfig(svgAssetFor(entry.item.asset)!, entry.item.layer)"
|
||||||
/>
|
/>
|
||||||
<template
|
<template
|
||||||
v-for="(sub, sli) in svgStackForAsset(svgAssetFor(entry.item.asset)!)"
|
v-for="(sub, sli) in svgStackForAsset(
|
||||||
|
svgAssetFor(entry.item.asset)!,
|
||||||
|
Math.sqrt(
|
||||||
|
Math.abs(entry.item.props.scaleX || 1) *
|
||||||
|
Math.abs(entry.item.props.scaleY || 1),
|
||||||
|
),
|
||||||
|
)"
|
||||||
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
|
:key="`${entry.item.layer.id}:${sub.layer.id}:${sli}`"
|
||||||
>
|
>
|
||||||
<v-rect
|
<v-rect
|
||||||
v-if="sub.asset.kind === 'rect'"
|
v-if="sub.asset.kind === 'rect'"
|
||||||
:config="buttonChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
|
:config="svgChildConfig(rectConfig(sub.layer, sub.asset, sub.props))"
|
||||||
/>
|
/>
|
||||||
<v-circle
|
<v-circle
|
||||||
v-else-if="sub.asset.kind === 'circle'"
|
v-else-if="sub.asset.kind === 'circle'"
|
||||||
:config="buttonChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
|
:config="svgChildConfig(circleConfig(sub.layer, sub.asset, sub.props))"
|
||||||
/>
|
/>
|
||||||
<v-text
|
<v-text
|
||||||
v-else-if="sub.asset.kind === 'text'"
|
v-else-if="sub.asset.kind === 'text'"
|
||||||
:config="buttonChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
|
:config="svgChildConfig(textConfig(sub.layer, sub.asset, sub.props))"
|
||||||
/>
|
/>
|
||||||
<v-line
|
<v-line
|
||||||
v-else-if="sub.asset.kind === 'polygon'"
|
v-else-if="sub.asset.kind === 'polygon'"
|
||||||
:config="buttonChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
|
:config="svgChildConfig(polygonConfig(sub.layer, sub.asset, sub.props))"
|
||||||
/>
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
|
v-else-if="sub.asset.kind === 'image' && imageConfig(sub.layer, sub.asset, sub.props)"
|
||||||
:config="buttonChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
|
:config="svgChildConfig(imageConfig(sub.layer, sub.asset, sub.props)!)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-group>
|
</v-group>
|
||||||
|
|||||||
@@ -13956,6 +13956,154 @@ export function registerAllTests(): void {
|
|||||||
expect(restoredAsset.canvas.height).toBe(80)
|
expect(restoredAsset.canvas.height).toBe(80)
|
||||||
expect(restoredAsset.layers.length).toBe(1)
|
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', () => {
|
suite('svg asset — thumbnail rendering', () => {
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.0'
|
export const APP_VERSION = '1.2.1'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user