fix(core): draw gradient and image strokes as the paint they are (#868)
* fix(core): draw gradient and image strokes as the paint they are A stroke carried the paint vocabulary already, but nothing read it: the .fig reader sent every stroke paint through resolvedPaintColor, which returns black for a gradient or image, the renderer set a flat color on strokePaint, and the writer emitted a SOLID paint. Strokes now go through the same conversion fills do in both directions, and applyGradientFill and applyImageFill take the target Paint so a stroke reuses the fill shader path instead of growing a second one. forVisibleStrokes is the single place every stroke draw passes through, so the shader is set and cleared there rather than threaded through each draw helper. Closes #797 for rendering and .fig; authoring a gradient stroke from the stroke panel is still to come. * feat(app): author gradient and image strokes from the stroke panel StrokeSection opened a solid-only colour picker and synthesised a fake fill for the swatch, so a stroke could never be anything but one flat colour. It now opens FillPicker like the fill panel does, and applyStrokePaint keeps the stroke's weight, align, cap, join and dashes across a paint change. Completes #797. * fix(core): let a gradient stroke reach vector outlines and arrowheads A vector stroke draws its outline as a filled shape with fillPaint, a dashed one strokes the path, and arrowheads are filled shapes of their own; each cleared the shader first, so a gradient or image stroke on a vector drew black. The stroke pass now configures both paints and owns clearing them, and those helpers keep what it set. Resolve each gradient stop against the stroke's own colour binding rather than the stop's position, which looked up another stroke's. Reported in review of #868. * fix(core): release the shaders a paint no longer owns Every gradient and image shader was handed to a paint and then leaked: the paint takes its own reference, so the caller's handle has to go or WASM memory grows with each redraw. Only the diamond branch did this. A gradient stroke now configures two paints, which doubled the leak. Reported in review of #868. * test(render): model a shader handle the caller deletes The pattern shader double returned a plain string, so deleting the handle the paint no longer owns threw instead of passing.
This commit is contained in:
parent
fa3672c39c
commit
d2e380ea9d
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
### Breaking changes
|
||||
|
||||
- `Stroke` from `@open-pencil/scene-graph` extends `Fill`, so every stroke states a paint `type` that code constructing one must set to `'SOLID'`, and `copyStroke` deep-copies the paint fields a fill already copied. Rendering, `.fig` round-trips, and the stroke panel still handle solid strokes only.
|
||||
- `Stroke` from `@open-pencil/scene-graph` extends `Fill`, so every stroke states a paint `type` that code constructing one must set to `'SOLID'`, and `copyStroke` deep-copies the paint fields a fill already copied.
|
||||
- The editor state's `remoteCursors` is now `presenceCursors`, typed `PresenceCursor[]` from `@open-pencil/core/canvas`, and each cursor has a `kind` of `'person'` or `'agent'`.
|
||||
- `VariableBinding` and the `colorVariableBinding` paint field are gone from `@open-pencil/core`, `@open-pencil/core/kiwi`, and the Kiwi `Paint` type. `fig.kiwi` never defined the field, so only `.fig` files OpenPencil itself wrote before `colorVar` contain one; reopening such a file leaves the paint's colour unbound, and binding it again records it the way Figma does.
|
||||
- `encodeNodeChangeWithVariables`, `encodePaintWithVariableBinding`, and `encodeVarint` are removed from `@open-pencil/core` and `@open-pencil/core/kiwi`. They spliced a colour-variable binding into encoded bytes because the field had no schema entry; exports now write `colorVar`, which `fig.kiwi` defines, so nothing needs them. `parseVariableId` is unchanged.
|
||||
|
|
@ -58,6 +58,7 @@
|
|||
- Show `.fig` thumbnails in the desktop app's recent files, which the app was not permitted to read.
|
||||
- Give paints set through the plugin API, `eval`, and AI and MCP scripts an opacity of 1 and make them visible when the script leaves those out, as Figma does. Such paints were stored without them, which the Design panel could not show.
|
||||
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
|
||||
- Give strokes the same paints fills have. A `.fig` file's gradient or image stroke imported as opaque black, because a stroke could hold only one color; it now keeps its stops, transform, and image, and renders and saves the way the same paint does as a fill. The stroke panel opens the fill picker, so you can give a stroke a gradient or an image and its weight, align, cap, join, and dashes stay as they were ([#797](https://github.com/open-pencil/open-pencil/issues/797)).
|
||||
- Open Figma files that use slots with each instance's own slot content instead of its component's default, keep slot properties, their settings, and instance content when saving back to `.fig`, and keep an instance's slot content when you switch its variant.
|
||||
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.
|
||||
- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ Canvas is CanvasKit (Skia WASM) on a WebGL surface, not DOM.
|
|||
- Viewport culling skips off-screen nodes; unclipped parents are not culled because children may extend beyond bounds.
|
||||
- Overscan images accelerate navigation; settled scenes rasterize existing retained pictures at the live viewport size and origin. Pixel-grid alignment alone does not guarantee Skia anti-aliasing parity. Keep settlement pending until the viewport pass completes; do not add a second viewport image cache.
|
||||
|
||||
### Paints
|
||||
|
||||
- A gradient or image paint builds a Skia shader through `applyGradientFill` and `applyImageFill` (`packages/core/src/canvas/fills.ts`), which take the target `Paint`, so a stroke reuses them instead of a second shader path.
|
||||
- `forVisibleStrokes` (`packages/core/src/canvas/scene.ts`) is where a stroke's shader is set and cleared; stroke draw helpers take an already-configured `strokePaint` and must not reset its shader.
|
||||
|
||||
### Caches
|
||||
|
||||
- Bounded rendering caches share `packages/core/src/cache/resource.ts` for recency, count/weight accounting, and removal disposal. Domain adapters own keys, font/page/dependency invalidation, and sizing units; use non-touching `peek()` for FIFO or planning reads. Rejected insertions leave ownership with the caller.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Canvas, Paint } from 'canvaskit-wasm'
|
||||
|
||||
import type { SceneNode, SceneGraph, Fill } from '@open-pencil/scene-graph'
|
||||
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
import type { Color, Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { figmaBlendModeToSkia } from './blend'
|
||||
import { makeDiamondGradient } from './gradients/diamond'
|
||||
|
|
@ -274,6 +274,7 @@ function applyPatternFill(
|
|||
tileRect
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
shader.delete()
|
||||
picture.delete()
|
||||
return true
|
||||
}
|
||||
|
|
@ -311,29 +312,28 @@ export function linearGradientEndpoints(
|
|||
}
|
||||
}
|
||||
|
||||
/** Resolves one gradient stop's color, so a stroke resolves its own bindings, not a fill's. */
|
||||
export type ResolveGradientStop = (color: Color, stopIndex: number) => Color
|
||||
|
||||
export function applyGradientFill(
|
||||
r: SkiaRenderer,
|
||||
fill: Fill,
|
||||
node: SceneNode,
|
||||
graph: SceneGraph
|
||||
graph: SceneGraph,
|
||||
paint: Paint = r.fillPaint,
|
||||
resolveStop: ResolveGradientStop = (color, stopIndex) =>
|
||||
r.resolveFillColorInfo(
|
||||
{ ...fill, type: 'SOLID', color, opacity: color.a, visible: true },
|
||||
stopIndex,
|
||||
node,
|
||||
graph
|
||||
).color
|
||||
): void {
|
||||
const stops = fill.gradientStops
|
||||
const t = fill.gradientTransform
|
||||
if (!stops || !t) return
|
||||
const colors = stops.map((s, index) => {
|
||||
const resolved = r.resolveFillColorInfo(
|
||||
{
|
||||
...fill,
|
||||
type: 'SOLID',
|
||||
color: s.color,
|
||||
opacity: s.color.a,
|
||||
visible: true
|
||||
},
|
||||
index,
|
||||
node,
|
||||
graph
|
||||
)
|
||||
const c = resolved.color
|
||||
const c = resolveStop(s.color, index)
|
||||
return r.ck.Color4f(c.r, c.g, c.b, c.a)
|
||||
})
|
||||
const positions = stops.map((s) => s.position)
|
||||
|
|
@ -341,6 +341,15 @@ export function applyGradientFill(
|
|||
const w = node.width
|
||||
const h = node.height
|
||||
|
||||
/** The paint keeps its own reference, so the caller's handle has to go or WASM memory grows. */
|
||||
const setShader = (shader: ReturnType<typeof makeDiamondGradient>) => {
|
||||
try {
|
||||
paint.setShader(shader)
|
||||
} finally {
|
||||
shader.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (fill.type === 'GRADIENT_LINEAR') {
|
||||
const { start, end } = linearGradientEndpoints(w, h, t)
|
||||
const startX = start.x
|
||||
|
|
@ -354,14 +363,9 @@ export function applyGradientFill(
|
|||
positions,
|
||||
r.ck.TileMode.Clamp
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
setShader(shader)
|
||||
} else if (fill.type === 'GRADIENT_DIAMOND') {
|
||||
const shader = makeDiamondGradient(r, colors, positions, makeGradientLocalMatrix(r, w, h, t))
|
||||
try {
|
||||
r.fillPaint.setShader(shader)
|
||||
} finally {
|
||||
shader.delete()
|
||||
}
|
||||
setShader(makeDiamondGradient(r, colors, positions, makeGradientLocalMatrix(r, w, h, t)))
|
||||
} else if (fill.type === 'GRADIENT_RADIAL') {
|
||||
// Figma's gradientTransform maps gradient space (center 0.5,0.5, radius 0.5)
|
||||
// to the node's normalized [0,1] coordinate space. The full local matrix
|
||||
|
|
@ -375,7 +379,7 @@ export function applyGradientFill(
|
|||
r.ck.TileMode.Clamp,
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
setShader(shader)
|
||||
} else if (fill.type === 'GRADIENT_ANGULAR') {
|
||||
const localMatrix = makeGradientLocalMatrix(r, w, h, t)
|
||||
const shader = r.ck.Shader.MakeSweepGradient(
|
||||
|
|
@ -386,7 +390,7 @@ export function applyGradientFill(
|
|||
r.ck.TileMode.Clamp,
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
setShader(shader)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +441,8 @@ export function applyImageFill(
|
|||
r: SkiaRenderer,
|
||||
fill: Fill,
|
||||
node: SceneNode,
|
||||
graph: SceneGraph
|
||||
graph: SceneGraph,
|
||||
paint: Paint = r.fillPaint
|
||||
): boolean {
|
||||
const hash = fill.imageHash
|
||||
if (!hash) return false
|
||||
|
|
@ -466,7 +471,8 @@ export function applyImageFill(
|
|||
1 / 3,
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
paint.setShader(shader)
|
||||
shader.delete()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -478,7 +484,8 @@ export function applyImageFill(
|
|||
r.ck.MipmapMode.Linear,
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
paint.setShader(shader)
|
||||
shader.delete()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shap
|
|||
import {
|
||||
configureStrokePaint,
|
||||
drawArrowHeads,
|
||||
applyStrokeShader,
|
||||
drawDashedRRectWithSolidCorners,
|
||||
drawStyledRRectStroke,
|
||||
getStrokeCapEntity,
|
||||
|
|
@ -406,6 +407,10 @@ function makeNodeRRect(r: SkiaRenderer, node: SceneNode, radius: number): Float3
|
|||
return r.ck.RRectXY(rect, radius, radius)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stroke a node draws passes through here, so a gradient or image stroke gets its shader
|
||||
* here rather than in each draw helper, and the shader is cleared before the next stroke.
|
||||
*/
|
||||
function forVisibleStrokes(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
|
|
@ -415,7 +420,13 @@ function forVisibleStrokes(
|
|||
for (let index = 0; index < node.strokes.length; index++) {
|
||||
const stroke = node.strokes[index]
|
||||
if (!stroke.visible) continue
|
||||
draw(stroke, r.resolveStrokeColor(stroke, index, node, graph))
|
||||
applyStrokeShader(r, stroke, index, node, graph)
|
||||
try {
|
||||
draw(stroke, r.resolveStrokeColor(stroke, index, node, graph))
|
||||
} finally {
|
||||
r.strokePaint.setShader(null)
|
||||
r.fillPaint.setShader(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,7 +625,6 @@ function drawVectorStrokeGeometry(
|
|||
): void {
|
||||
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
||||
r.fillPaint.setAlphaf(opacity)
|
||||
r.fillPaint.setShader(null)
|
||||
for (const p of sg) canvas.drawPath(p, r.fillPaint)
|
||||
}
|
||||
|
||||
|
|
@ -672,7 +682,6 @@ function drawVectorPathStrokes(
|
|||
r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap ?? 'NONE'))
|
||||
r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? 'MITER'))
|
||||
r.strokePaint.setStrokeMiter(miterLimit)
|
||||
r.strokePaint.setShader(null)
|
||||
const effect = r.ck.PathEffect.MakeDash(dash, 0)
|
||||
r.strokePaint.setPathEffect(effect)
|
||||
for (const vp of vectorPaths) canvas.drawPath(vp, r.strokePaint)
|
||||
|
|
@ -688,7 +697,6 @@ function drawVectorPathStrokes(
|
|||
}
|
||||
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
||||
r.fillPaint.setAlphaf(stroke.opacity)
|
||||
r.fillPaint.setShader(null)
|
||||
|
||||
let outlines = outlineCacheKey ? r.vectorStrokeOutlineCache.get(outlineCacheKey) : undefined
|
||||
if (!outlines) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { Canvas, EmbindEnumEntity, Paint } from 'canvaskit-wasm'
|
||||
|
||||
import type { SceneNode, Stroke } from '@open-pencil/scene-graph'
|
||||
import type { SceneGraph, SceneNode, Stroke } from '@open-pencil/scene-graph'
|
||||
import type { ArrowEndpoint } from '@open-pencil/scene-graph/arrow-caps'
|
||||
import { arrowLinesSegments, equilateralArrowPoints } from '@open-pencil/scene-graph/arrow-caps'
|
||||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { applyGradientFill, applyImageFill } from './fills'
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
|
||||
|
||||
|
|
@ -133,7 +134,6 @@ export function drawArrowHeads(
|
|||
const path = builder.detachAndDelete()
|
||||
r.fillPaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
|
||||
r.fillPaint.setAlphaf(opacity)
|
||||
r.fillPaint.setShader(null)
|
||||
canvas.drawPath(path, r.fillPaint)
|
||||
path.delete()
|
||||
} else {
|
||||
|
|
@ -142,7 +142,6 @@ export function drawArrowHeads(
|
|||
r.strokePaint.setStrokeWidth(weight)
|
||||
r.strokePaint.setStrokeCap(r.ck.StrokeCap.Butt)
|
||||
r.strokePaint.setPathEffect(null)
|
||||
r.strokePaint.setShader(null)
|
||||
for (const wing of arrowLinesSegments(endpoint.x, endpoint.y, endpoint.angle, weight)) {
|
||||
canvas.drawLine(wing.from.x, wing.from.y, wing.to.x, wing.to.y, r.strokePaint)
|
||||
}
|
||||
|
|
@ -150,6 +149,52 @@ export function drawArrowHeads(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A gradient or image stroke paints through a shader, the way the same paint does as a fill.
|
||||
* Both paints get it: a vector stroke's outline and an arrowhead are filled shapes drawn with
|
||||
* `fillPaint`, so the shader has to be on whichever paint the draw helper reaches for.
|
||||
* Returns false for a solid stroke, which paints through the paint's color instead.
|
||||
*/
|
||||
export function applyStrokeShader(
|
||||
r: SkiaRenderer,
|
||||
stroke: Stroke,
|
||||
strokeIndex: number,
|
||||
node: SceneNode,
|
||||
graph: SceneGraph
|
||||
): boolean {
|
||||
r.strokePaint.setShader(null)
|
||||
r.fillPaint.setShader(null)
|
||||
if (stroke.type.startsWith('GRADIENT') && stroke.gradientStops && stroke.gradientTransform) {
|
||||
for (const paint of [r.strokePaint, r.fillPaint]) {
|
||||
applyGradientFill(
|
||||
r,
|
||||
stroke,
|
||||
node,
|
||||
graph,
|
||||
paint,
|
||||
// Every stop resolves against this stroke's own binding, not the stop's position.
|
||||
(color) =>
|
||||
r.resolveStrokeColorInfo(
|
||||
{ ...stroke, type: 'SOLID', color, opacity: color.a, visible: true },
|
||||
strokeIndex,
|
||||
node,
|
||||
graph
|
||||
).color
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (stroke.type === 'IMAGE' && stroke.imageHash) {
|
||||
let applied = false
|
||||
for (const paint of [r.strokePaint, r.fillPaint]) {
|
||||
applied = applyImageFill(r, stroke, node, graph, paint) || applied
|
||||
}
|
||||
return applied
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Leaves any shader in place: a shaded stroke's color is unused, but its alpha still applies. */
|
||||
export function configureStrokePaint(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, setDefaultTimeout, test } from 'bun:test'
|
||||
|
||||
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
|
||||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
import { collectAllNodes } from '#core-tests/helpers/fig/traversal'
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
const STOPS = [
|
||||
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
|
||||
]
|
||||
const TRANSFORM = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
|
||||
async function reImport(graph: SceneGraph): Promise<SceneGraph> {
|
||||
const bytes = await exportFigFile(graph)
|
||||
return await parseFigFile(bytes.buffer as ArrayBuffer)
|
||||
}
|
||||
|
||||
function strokeOf(graph: SceneGraph, name: string) {
|
||||
const node = collectAllNodes(graph).find((candidate) => candidate.name === name)
|
||||
return expectDefined(node, `node ${name}`).strokes[0]
|
||||
}
|
||||
|
||||
/** A stroke is a paint, so a gradient or image stroke has to survive the archive like a fill. */
|
||||
describe('roundtrip: stroke paints', () => {
|
||||
test('a gradient stroke keeps its stops and transform', async () => {
|
||||
await initCodec()
|
||||
const graph = new SceneGraph()
|
||||
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
|
||||
name: 'Gradient stroke',
|
||||
width: 100,
|
||||
height: 100,
|
||||
strokes: [
|
||||
{
|
||||
type: 'GRADIENT_LINEAR',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
gradientStops: STOPS,
|
||||
gradientTransform: TRANSFORM,
|
||||
weight: 4,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'OUTSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const stroke = strokeOf(await reImport(graph), 'Gradient stroke')
|
||||
|
||||
expect(stroke.type).toBe('GRADIENT_LINEAR')
|
||||
expect(stroke.gradientStops?.map((stop) => stop.position)).toEqual([0, 1])
|
||||
expect(expectDefined(stroke.gradientStops?.[0], 'first stop').color).toMatchObject({
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0
|
||||
})
|
||||
expect(stroke.gradientTransform).toMatchObject({ m00: 1, m11: 1 })
|
||||
expect(stroke).toMatchObject({ weight: 4, align: 'OUTSIDE' })
|
||||
})
|
||||
|
||||
test('an image stroke keeps its hash and scale mode', async () => {
|
||||
await initCodec()
|
||||
const graph = new SceneGraph()
|
||||
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
|
||||
name: 'Image stroke',
|
||||
width: 100,
|
||||
height: 100,
|
||||
strokes: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
imageHash: 'abc123',
|
||||
imageScaleMode: 'FIT',
|
||||
weight: 3,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'CENTER'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const stroke = strokeOf(await reImport(graph), 'Image stroke')
|
||||
|
||||
expect(stroke.type).toBe('IMAGE')
|
||||
expect(stroke.imageHash).toBe('abc123')
|
||||
expect(stroke.imageScaleMode).toBe('FIT')
|
||||
expect(stroke).toMatchObject({ weight: 3, align: 'CENTER' })
|
||||
})
|
||||
|
||||
test('a solid stroke still round-trips unchanged', async () => {
|
||||
await initCodec()
|
||||
const graph = new SceneGraph()
|
||||
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
|
||||
name: 'Solid stroke',
|
||||
width: 100,
|
||||
height: 100,
|
||||
strokes: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 0.2, g: 0.4, b: 0.6, a: 1 },
|
||||
weight: 2,
|
||||
opacity: 0.5,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const stroke = strokeOf(await reImport(graph), 'Solid stroke')
|
||||
|
||||
expect(stroke.type).toBe('SOLID')
|
||||
// The archive stores colours as float32, so compare approximately.
|
||||
expect(stroke.color.r).toBeCloseTo(0.2, 5)
|
||||
expect(stroke.color.g).toBeCloseTo(0.4, 5)
|
||||
expect(stroke.color.b).toBeCloseTo(0.6, 5)
|
||||
expect(stroke).toMatchObject({ weight: 2, opacity: 0.5, align: 'INSIDE' })
|
||||
})
|
||||
})
|
||||
|
|
@ -207,18 +207,13 @@ export function createFillPaints(context: SceneNodeToKiwiContext, node: SceneNod
|
|||
)
|
||||
}
|
||||
|
||||
/** A stroke is a paint, so it writes the same way a fill does, gradients and images included. */
|
||||
export function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
|
||||
return node.strokes.map((stroke, index) =>
|
||||
applyColorVariableBinding(
|
||||
context,
|
||||
node,
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: context.safeColor(stroke.color),
|
||||
opacity: stroke.opacity,
|
||||
visible: stroke.visible,
|
||||
blendMode: 'NORMAL'
|
||||
},
|
||||
context.fillToKiwiPaint(stroke),
|
||||
`strokes/${index}/color`
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,15 +143,18 @@ function applySchemaPaintFields(fill: Fill, p: Paint): void {
|
|||
if (p.customEffectId?.guid) fill.customEffectId = guidToString(p.customEffectId.guid)
|
||||
}
|
||||
|
||||
/** One Figma paint as a Scene Graph paint, whether it ends up a fill or a stroke. */
|
||||
function convertPaint(p: Paint): Fill {
|
||||
const fill = convertBaseFill(p)
|
||||
applyGradientPaintFields(fill, p)
|
||||
applyImagePaintFields(fill, p)
|
||||
applySchemaPaintFields(fill, p)
|
||||
return fill
|
||||
}
|
||||
|
||||
export function convertFills(paints?: Paint[]): Fill[] {
|
||||
if (!paints) return []
|
||||
return paints.map((p) => {
|
||||
const fill = convertBaseFill(p)
|
||||
applyGradientPaintFields(fill, p)
|
||||
applyImagePaintFields(fill, p)
|
||||
applySchemaPaintFields(fill, p)
|
||||
return fill
|
||||
})
|
||||
return paints.map(convertPaint)
|
||||
}
|
||||
|
||||
export function convertStrokes(
|
||||
|
|
@ -167,20 +170,14 @@ export function convertStrokes(
|
|||
if (align === 'INSIDE') strokeAlign = 'INSIDE'
|
||||
else if (align === 'OUTSIDE') strokeAlign = 'OUTSIDE'
|
||||
|
||||
return paints.map((p) => {
|
||||
const { color, opacity } = resolvedPaintColor(p)
|
||||
return {
|
||||
type: 'SOLID',
|
||||
color,
|
||||
weight: weight ?? 1,
|
||||
opacity,
|
||||
visible: p.visible ?? true,
|
||||
align: strokeAlign,
|
||||
cap: cap ?? 'NONE',
|
||||
join: join ?? 'MITER',
|
||||
dashPattern: dashPattern ?? []
|
||||
}
|
||||
})
|
||||
return paints.map((p) => ({
|
||||
...convertPaint(p),
|
||||
weight: weight ?? 1,
|
||||
align: strokeAlign,
|
||||
cap: cap ?? 'NONE',
|
||||
join: join ?? 'MITER',
|
||||
dashPattern: dashPattern ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
export function convertEffects(effects?: KiwiEffect[]): Effect[] {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,33 @@ describe('@open-pencil/fig NodeChange policy', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("carries a gradient stroke's stops and transform, not a black solid", () => {
|
||||
const paint: Paint = {
|
||||
type: 'GRADIENT_LINEAR',
|
||||
stops: [
|
||||
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
|
||||
],
|
||||
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
}
|
||||
const [stroke] = convertStrokes([paint], 2, 'OUTSIDE')
|
||||
|
||||
expect(stroke.type).toBe('GRADIENT_LINEAR')
|
||||
expect(stroke.gradientStops?.map((stop) => stop.position)).toEqual([0, 1])
|
||||
expect(stroke.gradientStops?.[0].color).toMatchObject({ r: 1, g: 0, b: 0 })
|
||||
expect(stroke.gradientTransform).toMatchObject({ m00: 1, m11: 1 })
|
||||
expect(stroke).toMatchObject({ weight: 2, align: 'OUTSIDE' })
|
||||
})
|
||||
|
||||
test("carries an image stroke's hash and scale mode", () => {
|
||||
const paint: Paint = { type: 'IMAGE', image: { hash: 'abc123' }, imageScaleMode: 'FIT' }
|
||||
const [stroke] = convertStrokes([paint], 1, 'INSIDE')
|
||||
|
||||
expect(stroke.type).toBe('IMAGE')
|
||||
expect(stroke.imageHash).toBe('abc123')
|
||||
expect(stroke.imageScaleMode).toBe('FIT')
|
||||
})
|
||||
|
||||
test('uses vector-region winding rules for rendered geometry', () => {
|
||||
const network = {
|
||||
vertices: [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export { BUILT_IN_COLOR_FORMATS, useColorModel } from '#vue/controls/color-model
|
|||
export {
|
||||
applySolidFillColor,
|
||||
applySolidStrokeColor,
|
||||
applyStrokePaint,
|
||||
fromPercent,
|
||||
toPercent
|
||||
} from '#vue/controls/color-model/model'
|
||||
|
|
|
|||
|
|
@ -193,6 +193,18 @@ export function applySolidStrokeColor(color: Color): Partial<Stroke> {
|
|||
return { color, opacity: color.a }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stroke's geometry survives a paint change; every paint field comes from the new paint, so
|
||||
* the stroke carries exactly what the picker produced rather than a mix of both paints.
|
||||
*/
|
||||
export function applyStrokePaint(stroke: Stroke, paint: Fill): Stroke {
|
||||
const next: Stroke = { ...paint, weight: stroke.weight, align: stroke.align }
|
||||
if (stroke.cap !== undefined) next.cap = stroke.cap
|
||||
if (stroke.join !== undefined) next.join = stroke.join
|
||||
if (stroke.dashPattern) next.dashPattern = [...stroke.dashPattern]
|
||||
return next
|
||||
}
|
||||
|
||||
export function toPercent(value: number): number {
|
||||
return Math.round(value * 100)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export { isStrokeCapValue } from '#vue/controls/stroke/helpers'
|
|||
export {
|
||||
applySolidFillColor,
|
||||
applySolidStrokeColor,
|
||||
applyStrokePaint,
|
||||
BUILT_IN_COLOR_FORMATS,
|
||||
fromPercent,
|
||||
toPercent,
|
||||
|
|
|
|||
84
packages/vue/tests/controls/color-model/stroke-paint.test.ts
Normal file
84
packages/vue/tests/controls/color-model/stroke-paint.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { Fill, Stroke } from '@open-pencil/scene-graph'
|
||||
import { applyStrokePaint } from '@open-pencil/vue'
|
||||
|
||||
const GRADIENT: Fill = {
|
||||
type: 'GRADIENT_LINEAR',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
gradientStops: [
|
||||
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
|
||||
],
|
||||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
}
|
||||
|
||||
function solidStroke(): Stroke {
|
||||
return {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.1, g: 0.2, b: 0.3, a: 1 },
|
||||
weight: 6,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'OUTSIDE',
|
||||
cap: 'ROUND',
|
||||
join: 'BEVEL',
|
||||
dashPattern: [4, 2]
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyStrokePaint', () => {
|
||||
test('keeps the stroke geometry when the paint changes', () => {
|
||||
const next = applyStrokePaint(solidStroke(), GRADIENT)
|
||||
|
||||
expect(next.type).toBe('GRADIENT_LINEAR')
|
||||
expect(next.gradientStops).toHaveLength(2)
|
||||
expect(next).toMatchObject({
|
||||
weight: 6,
|
||||
align: 'OUTSIDE',
|
||||
cap: 'ROUND',
|
||||
join: 'BEVEL',
|
||||
dashPattern: [4, 2]
|
||||
})
|
||||
})
|
||||
|
||||
test('drops the old paint rather than leaving its stops behind', () => {
|
||||
const gradientStroke = applyStrokePaint(solidStroke(), GRADIENT)
|
||||
const back = applyStrokePaint(gradientStroke, {
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 1, b: 1, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
})
|
||||
|
||||
expect(back.type).toBe('SOLID')
|
||||
expect(back.gradientStops).toBeUndefined()
|
||||
expect(back.gradientTransform).toBeUndefined()
|
||||
expect(back.weight).toBe(6)
|
||||
})
|
||||
|
||||
test('does not share the dash pattern with the stroke it came from', () => {
|
||||
const original = solidStroke()
|
||||
const next = applyStrokePaint(original, GRADIENT)
|
||||
next.dashPattern?.push(99)
|
||||
|
||||
expect(original.dashPattern).toEqual([4, 2])
|
||||
})
|
||||
|
||||
test('leaves cap and join unset when the stroke had none', () => {
|
||||
const plain: Stroke = {
|
||||
type: 'SOLID',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
const next = applyStrokePaint(plain, GRADIENT)
|
||||
|
||||
expect('cap' in next).toBe(false)
|
||||
expect('join' in next).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -28,6 +28,8 @@ const {
|
|||
fill: Fill
|
||||
okhcl?: OkHCLControls | null
|
||||
swatchBackground?: string
|
||||
/** Names the trigger for a paint that is not a fill, such as a stroke. */
|
||||
label?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
update: [fill: Fill]
|
||||
|
|
@ -53,7 +55,7 @@ function cancelFromEscape(event: KeyboardEvent) {
|
|||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="panels.fill"
|
||||
:aria-label="label ?? panels.fill"
|
||||
data-test-id="fill-picker-swatch"
|
||||
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
|
|||
import { colorToHexRaw } from '@open-pencil/scene-graph/color'
|
||||
import {
|
||||
applySolidStrokeColor,
|
||||
applyStrokePaint,
|
||||
BindableValueRoot,
|
||||
useColorBindingProvider,
|
||||
useI18n,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
} from '@open-pencil/vue'
|
||||
import type { BindableValueActions } from '@open-pencil/vue'
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
|
||||
import FillPicker from '@/components/fill-picker/FillPicker.vue'
|
||||
import NumberField from '@/components/inputs/NumberField.vue'
|
||||
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
|
||||
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
|
||||
|
|
@ -30,7 +31,6 @@ import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
|
|||
import { useSharedStylePicker } from '@/components/properties/shared-style/useSharedStylePicker'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import Tip from '@/components/ui/overlay/Tip.vue'
|
||||
import FillSwatchTrigger from '@/components/ui/paint/FillSwatchTrigger.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
import AppSelect from '@/components/ui/select/AppSelect.vue'
|
||||
const {
|
||||
|
|
@ -50,13 +50,19 @@ const okhcl = useOkHCL()
|
|||
const { panels, common } = useI18n()
|
||||
const expandedSides = ref(false)
|
||||
|
||||
function strokePreview(stroke: Stroke, color: Color): Fill {
|
||||
return {
|
||||
type: 'SOLID',
|
||||
color,
|
||||
opacity: stroke.opacity,
|
||||
visible: stroke.visible
|
||||
}
|
||||
/** A bound variable colours the swatch, but only a solid stroke has one colour to replace. */
|
||||
function displayStroke(stroke: Stroke, resolvedColor: Color | undefined): Fill {
|
||||
return stroke.type === 'SOLID' && resolvedColor ? { ...stroke, color: resolvedColor } : stroke
|
||||
}
|
||||
|
||||
function updateStrokePaint(
|
||||
binding: BindableValueActions<Color>,
|
||||
flush: () => void,
|
||||
stroke: Stroke,
|
||||
paint: Fill,
|
||||
update: (stroke: Stroke) => void
|
||||
) {
|
||||
applyPaintMutation(binding, flush, () => update(applyStrokePaint(stroke, paint)))
|
||||
}
|
||||
|
||||
function updateStrokeColor(
|
||||
|
|
@ -147,32 +153,23 @@ function onToggleSides(activeNode: SceneNode | null) {
|
|||
@update:opacity="actions.patch(index, { opacity: $event })"
|
||||
>
|
||||
<template #preview>
|
||||
<ColorPicker
|
||||
:color="binding.resolvedValue ?? stroke.color"
|
||||
<FillPicker
|
||||
:label="panels.stroke"
|
||||
:fill="displayStroke(stroke, binding.resolvedValue)"
|
||||
:okhcl="createStrokeOkhclAdapter(okhcl, activeNode, index)"
|
||||
@update="
|
||||
updateStrokeColor(
|
||||
binding.actions,
|
||||
flush,
|
||||
$event,
|
||||
(changes) => actions.patch(index, changes),
|
||||
false
|
||||
updateStrokePaint(binding.actions, flush, stroke, $event, (next) =>
|
||||
actions.update(index, next)
|
||||
)
|
||||
"
|
||||
@open-change="!$event && commitPaintMutation(binding.actions)"
|
||||
@cancel="cancelPaintMutation(binding.actions)"
|
||||
>
|
||||
<template #trigger>
|
||||
<FillSwatchTrigger
|
||||
:label="panels.stroke"
|
||||
:fill="strokePreview(stroke, binding.resolvedValue ?? stroke.color)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #value>
|
||||
<PaintValue
|
||||
v-if="stroke.type === 'SOLID'"
|
||||
:color="stroke.color"
|
||||
:resolved-color="binding.resolvedValue"
|
||||
:variable-name="binding.variable?.name ?? binding.bindingId"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ test('arrow stroke caps', async () => {
|
|||
weight: number,
|
||||
dashPattern?: number[],
|
||||
align: 'INSIDE' | 'CENTER' | 'OUTSIDE' = 'CENTER'
|
||||
) => [{ color: ink, weight, visible: true, opacity: 1, align, dashPattern }]
|
||||
) => [
|
||||
{ type: 'SOLID' as const, color: ink, weight, visible: true, opacity: 1, align, dashPattern }
|
||||
]
|
||||
|
||||
store.graph.createNode('LINE', pageId, {
|
||||
name: 'Equilateral line',
|
||||
|
|
|
|||
112
tests/e2e/canvas/stroke-paint-visual.spec.ts
Normal file
112
tests/e2e/canvas/stroke-paint-visual.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
||||
|
||||
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
|
||||
|
||||
/**
|
||||
* A gradient stroke used to draw opaque black, because a stroke could only hold one color.
|
||||
* The strokes are heavy so the ring crosses each gradient's falloff: a radial or diamond
|
||||
* gradient is near-uniform at a node's perimeter, so a thin ring would not tell them apart.
|
||||
*/
|
||||
test('gradient strokes', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const pageId = store.state.currentPageId
|
||||
const stops = [
|
||||
{ color: { r: 0.96, g: 0.35, b: 0.12, a: 1 }, position: 0 },
|
||||
{ color: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, position: 1 }
|
||||
]
|
||||
const kinds = [
|
||||
'GRADIENT_LINEAR',
|
||||
'GRADIENT_RADIAL',
|
||||
'GRADIENT_ANGULAR',
|
||||
'GRADIENT_DIAMOND'
|
||||
] as const
|
||||
for (const [index, type] of kinds.entries()) {
|
||||
store.graph.createNode('RECTANGLE', pageId, {
|
||||
name: `${type} stroke visual`,
|
||||
x: 70 + index * 200,
|
||||
y: 80,
|
||||
width: 130,
|
||||
height: 130,
|
||||
fills: [],
|
||||
strokes: [
|
||||
{
|
||||
type,
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
gradientStops: stops,
|
||||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
||||
weight: 44,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
align: 'CENTER'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// A solid stroke beside them must be unaffected by the shader the others set.
|
||||
store.graph.createNode('RECTANGLE', pageId, {
|
||||
name: 'solid stroke visual',
|
||||
x: 70,
|
||||
y: 260,
|
||||
width: 130,
|
||||
height: 130,
|
||||
fills: [],
|
||||
strokes: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 0.13, g: 0.72, b: 0.33, a: 1 },
|
||||
weight: 16,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
align: 'CENTER'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Vector strokes draw through a different path: dashed ones stroke the path, undashed ones
|
||||
// fill an outline with fillPaint, and arrowheads are filled shapes of their own.
|
||||
const vectorStroke = (extra: Record<string, unknown>) => ({
|
||||
type: 'GRADIENT_LINEAR' as const,
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
gradientStops: stops,
|
||||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
||||
weight: 18,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
align: 'CENTER' as const,
|
||||
...extra
|
||||
})
|
||||
const lines = [{}, { dashPattern: [24, 12] }, { cap: 'ARROW_EQUILATERAL' }]
|
||||
for (const [index, extra] of lines.entries()) {
|
||||
store.graph.createNode('VECTOR', pageId, {
|
||||
name: `vector gradient stroke ${index}`,
|
||||
x: 300,
|
||||
y: 260 + index * 70,
|
||||
width: 260,
|
||||
height: 1,
|
||||
strokeCap: extra.cap ?? 'NONE',
|
||||
vectorNetwork: {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 260, y: 0 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: []
|
||||
},
|
||||
fills: [],
|
||||
strokes: [vectorStroke(extra)]
|
||||
})
|
||||
}
|
||||
|
||||
store.clearSelection()
|
||||
store.requestRender()
|
||||
})
|
||||
await editor.canvas.waitForRender()
|
||||
editor.canvas.assertNoErrors()
|
||||
const buffer = await editor.canvas.screenshotCanvasRegion()
|
||||
expect(buffer).toMatchSnapshot('gradient-strokes.png')
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
49
tests/e2e/stroke-picker/gradient.spec.ts
Normal file
49
tests/e2e/stroke-picker/gradient.spec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { propertyItems, propertySection } from '#tests/helpers/properties'
|
||||
|
||||
async function selectedStroke(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = [...store.state.selectedIds][0]
|
||||
return store.graph.getNode(id)?.strokes?.[0] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
/** Until the stroke panel opened the fill picker, a stroke could only ever be one flat color. */
|
||||
test('a stroke can be made a gradient and taken back to solid', async ({ page }) => {
|
||||
const canvas = new CanvasHelper(page)
|
||||
await page.goto('/')
|
||||
await canvas.waitForInit()
|
||||
|
||||
await canvas.drawRect(120, 120, 180, 120)
|
||||
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
||||
await canvas.waitForRender()
|
||||
expect(await selectedStroke(page)).toMatchObject({ type: 'SOLID' })
|
||||
|
||||
await propertyItems(page, 'strokes')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Stroke', exact: true })
|
||||
.click()
|
||||
await expect(page.getByTestId('fill-picker-tab-gradient')).toBeVisible()
|
||||
|
||||
await page.getByTestId('fill-picker-tab-gradient').click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
const gradient = await selectedStroke(page)
|
||||
expect(gradient?.type).toBe('GRADIENT_LINEAR')
|
||||
expect(gradient?.gradientStops?.length).toBeGreaterThan(1)
|
||||
// The stroke keeps its geometry across the paint change.
|
||||
expect(gradient).toMatchObject({ weight: 1, align: 'CENTER' })
|
||||
|
||||
await page.getByTestId('fill-picker-tab-solid').click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
// Switching back keeps the stops, as it does for a fill, so the gradient returns on re-pick.
|
||||
const solid = await selectedStroke(page)
|
||||
expect(solid?.type).toBe('SOLID')
|
||||
expect(solid).toMatchObject({ weight: 1, align: 'CENTER' })
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
|
|
@ -91,6 +91,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
|
|||
},
|
||||
strokePaint: {
|
||||
setColor: mock(() => undefined),
|
||||
setShader: mock(() => undefined),
|
||||
setStrokeWidth: mock(() => undefined),
|
||||
setAlphaf: mock(() => undefined),
|
||||
setPathEffect: mock(() => undefined),
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ describe('Renderer effect ordering (Behavioral)', () => {
|
|||
spread: 0
|
||||
}
|
||||
],
|
||||
strokes: [{ visible: true, weight: 1, opacity: 1, color: { r: 0, g: 0, b: 0, a: 1 } }]
|
||||
strokes: [
|
||||
{ type: 'SOLID', visible: true, weight: 1, opacity: 1, color: { r: 0, g: 0, b: 0, a: 1 } }
|
||||
]
|
||||
})
|
||||
const graph: Partial<SceneGraph> = {
|
||||
getNode: mock(() => node)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import { applyFill } from '#core/canvas/fills'
|
|||
import type { SkiaRenderer } from '#core/canvas/renderer'
|
||||
|
||||
function createRenderer() {
|
||||
// A real CanvasKit shader is a WASM handle the caller deletes once the paint holds it.
|
||||
const patternShader = { id: 'pattern-shader', delete: mock(() => undefined) }
|
||||
const picture = {
|
||||
makeShader: mock(() => 'pattern-shader'),
|
||||
makeShader: mock(() => patternShader),
|
||||
delete: mock(() => undefined)
|
||||
}
|
||||
const recorder = {
|
||||
|
|
@ -74,7 +76,9 @@ describe('schema fill fallback rendering', () => {
|
|||
}
|
||||
|
||||
expect(applyFill(renderer, fill, node, graph)).toBe(true)
|
||||
expect(renderer.fillPaint.setShader).toHaveBeenLastCalledWith('pattern-shader')
|
||||
expect(renderer.fillPaint.setShader).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'pattern-shader' })
|
||||
)
|
||||
expect(renderer.fillPaint.setColor).not.toHaveBeenCalledWith(['color', 0.2, 0.3, 0.4, 0.8])
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue