diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3fc2c3e..1e27bc6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 7187dc2ac..e288bf3e1 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -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. diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index 9d422e2f9..d5deabfa2 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -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) => { + 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 } diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 99ef6c298..7096b8ebc 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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) { diff --git a/packages/core/src/canvas/strokes.ts b/packages/core/src/canvas/strokes.ts index 3fe8df0ee..ef0432719 100644 --- a/packages/core/src/canvas/strokes.ts +++ b/packages/core/src/canvas/strokes.ts @@ -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, diff --git a/packages/core/tests/io/formats/fig/roundtrip/stroke-paints.test.ts b/packages/core/tests/io/formats/fig/roundtrip/stroke-paints.test.ts new file mode 100644 index 000000000..a06a20105 --- /dev/null +++ b/packages/core/tests/io/formats/fig/roundtrip/stroke-paints.test.ts @@ -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 { + 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' }) + }) +}) diff --git a/packages/fig/src/node-change/export/context.ts b/packages/fig/src/node-change/export/context.ts index 5eb7b6734..51db46957 100644 --- a/packages/fig/src/node-change/export/context.ts +++ b/packages/fig/src/node-change/export/context.ts @@ -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` ) ) diff --git a/packages/fig/src/node-change/paint.ts b/packages/fig/src/node-change/paint.ts index 4336f1761..4a02d00c5 100644 --- a/packages/fig/src/node-change/paint.ts +++ b/packages/fig/src/node-change/paint.ts @@ -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[] { diff --git a/packages/fig/tests/node-change/index.test.ts b/packages/fig/tests/node-change/index.test.ts index 21c92e759..546a64b67 100644 --- a/packages/fig/tests/node-change/index.test.ts +++ b/packages/fig/tests/node-change/index.test.ts @@ -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: [ diff --git a/packages/vue/src/controls/color-model/index.ts b/packages/vue/src/controls/color-model/index.ts index d16863c4f..61e73552e 100644 --- a/packages/vue/src/controls/color-model/index.ts +++ b/packages/vue/src/controls/color-model/index.ts @@ -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' diff --git a/packages/vue/src/controls/color-model/model.ts b/packages/vue/src/controls/color-model/model.ts index 870b20f3d..d50c03e50 100644 --- a/packages/vue/src/controls/color-model/model.ts +++ b/packages/vue/src/controls/color-model/model.ts @@ -193,6 +193,18 @@ export function applySolidStrokeColor(color: Color): Partial { 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) } diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3edb16bd7..d3ccbd714 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -126,6 +126,7 @@ export { isStrokeCapValue } from '#vue/controls/stroke/helpers' export { applySolidFillColor, applySolidStrokeColor, + applyStrokePaint, BUILT_IN_COLOR_FORMATS, fromPercent, toPercent, diff --git a/packages/vue/tests/controls/color-model/stroke-paint.test.ts b/packages/vue/tests/controls/color-model/stroke-paint.test.ts new file mode 100644 index 000000000..872fc8d66 --- /dev/null +++ b/packages/vue/tests/controls/color-model/stroke-paint.test.ts @@ -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) + }) +}) diff --git a/src/components/fill-picker/FillPicker.vue b/src/components/fill-picker/FillPicker.vue index a2d77583b..ebc2f5b52 100644 --- a/src/components/fill-picker/FillPicker.vue +++ b/src/components/fill-picker/FillPicker.vue @@ -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) {