From 2f8d799a5fbe9cf430f7ab25b5d396a40e628ce5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 19:01:42 +0300 Subject: [PATCH] feat(editor): add stroke geometry controls - Add mixed-selection cap, join, and miter-limit controls with one-step undo - Apply node miter limits across CanvasKit stroke paths and vector outline caches - Preserve imported and edited stroke geometry through Plugin API and .fig roundtrips --- CHANGELOG.md | 1 + packages/core/src/canvas/scene.ts | 31 ++---- packages/core/src/canvas/strokes.ts | 23 +++- packages/core/src/figma-api/proxy.ts | 14 ++- .../core/src/kiwi/fig/node-change/convert.ts | 2 +- .../src/kiwi/fig/node-change/export-node.ts | 12 +- packages/docs/development/roadmap.md | 2 +- .../api/composables/use-stroke-controls.md | 18 ++- packages/vue/src/controls/stroke/helpers.ts | 82 +++++++++++++- packages/vue/src/controls/stroke/use.ts | 24 +++- packages/vue/src/i18n/locales/de/panels.json | 9 ++ packages/vue/src/i18n/locales/es/panels.json | 9 ++ packages/vue/src/i18n/locales/fr/panels.json | 9 ++ packages/vue/src/i18n/locales/it/panels.json | 9 ++ packages/vue/src/i18n/locales/ja/panels.json | 9 ++ packages/vue/src/i18n/locales/pl/panels.json | 9 ++ packages/vue/src/i18n/locales/ru/panels.json | 9 ++ .../vue/src/i18n/locales/zh-cn/panels.json | 9 ++ packages/vue/src/i18n/messages/panels.ts | 9 ++ src/components/properties/StrokeSection.vue | 70 ++++++++++++ src/theme/panel/grid.ts | 1 + .../e2e/canvas/stroke-geometry-visual.spec.ts | 80 ++++++++++++++ ...s-joins-miter-limits-openpencil-darwin.png | Bin 0 -> 37708 bytes tests/e2e/properties/stroke-geometry.spec.ts | 103 ++++++++++++++++++ tests/engine/figma/api/stroke/details.test.ts | 4 + .../io/fig/export/stroke-geometry.test.ts | 58 ++++++++++ .../io/fig/import/legacy/strokes.test.ts | 4 +- tests/engine/vue/controls/stroke.test.ts | 103 ++++++++++++++++++ 28 files changed, 676 insertions(+), 37 deletions(-) create mode 100644 tests/e2e/canvas/stroke-geometry-visual.spec.ts create mode 100644 tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png create mode 100644 tests/e2e/properties/stroke-geometry.spec.ts create mode 100644 tests/engine/io/fig/export/stroke-geometry.test.ts create mode 100644 tests/engine/vue/controls/stroke.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bc65535..80cd51bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Refine Design panel foundations with 26px controls, consistently aligned action rails, shared Tailwind themes, and Storybook component states. - Scale the Layers panel to 5,000-node documents with virtualized rows, indexed updates, scroll-to-selection, range selection, and focus-aware themed states. - Add Figma-style horizontal and vertical constraint controls with pin interactions, mixed-selection editing, undo, and responsive frame resizing. +- Add mixed-selection stroke cap, join, and miter-limit controls with CanvasKit rendering and `.fig` roundtrip support. - Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`. - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 1356f4a4b..2b05a4faa 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -16,6 +16,7 @@ import { renderMaskedChildIds } from './masks' import type { SkiaRenderer, RenderOverlays } from './renderer' import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes' import { + configureStrokePaint, drawDashedRRectWithSolidCorners, drawStyledRRectStroke, getStrokeCapEntity, @@ -301,9 +302,7 @@ export function renderSection( drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint)) forVisibleStrokes(r, node, graph, (stroke, color) => { - r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) - r.strokePaint.setStrokeWidth(stroke.weight) - r.strokePaint.setAlphaf(stroke.opacity) + configureStrokePaint(r, node, stroke, color) if (node.independentStrokeWeights) r.drawIndividualSideStrokes(canvas, node, stroke.align) else r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke) @@ -441,6 +440,7 @@ function drawVectorPathStrokes( vectorPaths: Path[], stroke: SceneNode['strokes'][0], sc: Color, + miterLimit: number, outlineCacheKey?: string ): void { const dash = stroke.dashPattern @@ -450,6 +450,7 @@ function drawVectorPathStrokes( r.strokePaint.setStrokeWidth(stroke.weight) 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) @@ -460,7 +461,7 @@ function drawVectorPathStrokes( } const strokeOpts = { width: stroke.weight, - miter_limit: 4, + miter_limit: miterLimit, cap: getStrokeCapEntity(r, stroke.cap ?? 'NONE'), join: getStrokeJoinEntity(r, stroke.join ?? 'MITER') } @@ -489,16 +490,7 @@ function drawRegularStroke( stroke: SceneNode['strokes'][0], sc: Color ): void { - r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) - r.strokePaint.setStrokeWidth(stroke.weight) - r.strokePaint.setAlphaf(stroke.opacity) - - if (stroke.cap) { - r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap)) - } - if (stroke.join) { - r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join)) - } + configureStrokePaint(r, node, stroke, sc) if (stroke.dashPattern && stroke.dashPattern.length > 0) { r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0)) } else { @@ -531,13 +523,14 @@ function drawNodeStroke( node.type === 'VECTOR' && !node.fills.some((fill) => fill.visible) if (shouldStrokeVectorCenterline) { - const outlineKey = `${node.id}|${stroke.weight}|${stroke.cap ?? 'NONE'}|${stroke.join ?? 'MITER'}` - drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc, outlineKey) + const outlineKey = `${node.id}|${stroke.weight}|${stroke.cap ?? node.strokeCap}|${stroke.join ?? node.strokeJoin}|${node.strokeMiterLimit}` + drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc, node.strokeMiterLimit, outlineKey) return } if (!sg) { - if (vectorPaths) drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc) - else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc) + if (vectorPaths) { + drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc, node.strokeMiterLimit) + } else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc) return } if (stroke.align !== 'INSIDE') { @@ -587,7 +580,7 @@ export function renderShapeUncached( node.vectorNetwork ) { const centerline = vectorNetworkToCenterlinePath(r.ck, node.vectorNetwork) - drawVectorPathStrokes(r, canvas, [centerline], stroke, color) + drawVectorPathStrokes(r, canvas, [centerline], stroke, color, node.strokeMiterLimit) centerline.delete() return } diff --git a/packages/core/src/canvas/strokes.ts b/packages/core/src/canvas/strokes.ts index 0794c3f39..b74b482fe 100644 --- a/packages/core/src/canvas/strokes.ts +++ b/packages/core/src/canvas/strokes.ts @@ -55,7 +55,8 @@ export function drawDashedRRectWithSolidCorners( r.strokePaint.setStrokeWidth(stroke.weight) r.strokePaint.setAlphaf(stroke.opacity) r.strokePaint.setStrokeCap(r.ck.StrokeCap.Butt) - r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join)) + r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? node.strokeJoin)) + r.strokePaint.setStrokeMiter(node.strokeMiterLimit) r.strokePaint.setPathEffect(null) canvas.drawArc( @@ -95,6 +96,20 @@ export function drawDashedRRectWithSolidCorners( r.strokePaint.setPathEffect(null) } +export function configureStrokePaint( + r: SkiaRenderer, + node: SceneNode, + stroke: Stroke, + color: Color +): void { + r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) + r.strokePaint.setStrokeWidth(stroke.weight) + r.strokePaint.setAlphaf(stroke.opacity) + r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap ?? node.strokeCap)) + r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join ?? node.strokeJoin)) + r.strokePaint.setStrokeMiter(node.strokeMiterLimit) +} + export function drawStyledRRectStroke( r: SkiaRenderer, canvas: Canvas, @@ -105,11 +120,7 @@ export function drawStyledRRectStroke( dashPhase = 0 ): void { const dash = stroke.dashPattern ?? [] - r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) - r.strokePaint.setStrokeWidth(stroke.weight) - r.strokePaint.setAlphaf(stroke.opacity) - r.strokePaint.setStrokeCap(getStrokeCapEntity(r, stroke.cap)) - r.strokePaint.setStrokeJoin(getStrokeJoinEntity(r, stroke.join)) + configureStrokePaint(r, node, stroke, color) r.strokePaint.setPathEffect(dash.length > 0 ? r.ck.PathEffect.MakeDash(dash, dashPhase) : null) r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke) r.strokePaint.setPathEffect(null) diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 4b063b823..b1b6bc817 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -149,7 +149,12 @@ export class FigmaNodeProxy { } set strokeCap(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeCap: v as SceneNode['strokeCap'] }) + const strokeCap = v as SceneNode['strokeCap'] + const node = this._raw() + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + strokeCap, + strokes: node.strokes.map((stroke) => ({ ...stroke, cap: strokeCap })) + }) } get strokeJoin(): string { @@ -157,7 +162,12 @@ export class FigmaNodeProxy { } set strokeJoin(v: string) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokeJoin: v as SceneNode['strokeJoin'] }) + const strokeJoin = v as SceneNode['strokeJoin'] + const node = this._raw() + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + strokeJoin, + strokes: node.strokes.map((stroke) => ({ ...stroke, join: strokeJoin })) + }) } get strokeMiterLimit(): number { diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 2d0aa8b7f..f0ee07ab2 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -517,7 +517,7 @@ function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) { borderBottomWeight: (nc.borderBottomWeight ?? 0) as number, borderLeftWeight: (nc.borderLeftWeight ?? 0) as number, independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean, - strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT + strokeMiterLimit: (nc.miterLimit ?? DEFAULT_STROKE_MITER_LIMIT) as number } } diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 64089f046..a240581bc 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -5,6 +5,7 @@ import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primi /* eslint-disable max-lines */ import { bytesToHex } from '#core/bytes/hex' +import { DEFAULT_STROKE_MITER_LIMIT } from '#core/constants' import { applyExportSettingsPluginData, @@ -650,8 +651,15 @@ function applyNodeVisualProps( if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap - if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin - if (!node.source.id && node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit + if (node.strokeJoin !== 'MITER' || 'strokeJoin' in node.source.fig.rawNodeFields) { + nc.strokeJoin = node.strokeJoin + } + if ( + node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || + 'miterLimit' in node.source.fig.rawNodeFields + ) { + nc.miterLimit = node.strokeMiterLimit + } if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern if (node.arcData) { nc.arcData = { diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index c8aed67dd..0b3fb0561 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -141,7 +141,7 @@ Figma's design documentation groups features into these areas: | Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain partial. | | Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. | | Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. | -| Stroke caps / joins / miter limit | ✅ | ✅ | ◐ | ✅ | ✅ | Renderer/export support exists; inspector controls are limited. | +| Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. | | Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. | | Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. | | Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. | diff --git a/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md b/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md index d98c89fc7..770eb4af0 100644 --- a/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md +++ b/packages/docs/programmable/sdk/api/composables/use-stroke-controls.md @@ -1,6 +1,6 @@ --- title: useStrokeControls -description: Stroke-panel helpers for alignment, side selection, and per-side stroke weights. +description: Stroke-panel state and actions for alignment, sides, caps, joins, and miter limits. --- # useStrokeControls @@ -13,6 +13,9 @@ It provides: - side presets like all, top, bottom, left, right, custom - default stroke data - helpers for per-side border weights +- mixed-selection cap, join, and miter-limit state +- undo-batched cap and join updates +- preview and commit actions for miter-limit fields ## Usage @@ -42,6 +45,19 @@ strokes.updateAlign('INSIDE', activeNode) strokes.selectSide('TOP', activeNode) ``` +### Edit stroke geometry + +```ts +strokes.setCap('ROUND') +strokes.setJoin('BEVEL') + +strokes.updateMiterLimit(8) +strokes.commitMiterLimit(8) +``` + +`advancedActive` is true only when every selected node has at least one stroke. `cap`, `join`, and +`miterLimit` return the shared value or `MIXED` for a mixed selection. + ## Related APIs - [PropertyListRoot](../components/property-list-root) diff --git a/packages/vue/src/controls/stroke/helpers.ts b/packages/vue/src/controls/stroke/helpers.ts index de23b8359..f05b989c6 100644 --- a/packages/vue/src/controls/stroke/helpers.ts +++ b/packages/vue/src/controls/stroke/helpers.ts @@ -1,8 +1,10 @@ -import type { Ref } from 'vue' +import { computed, type ComputedRef, type Ref } from 'vue' import { BLACK } from '@open-pencil/core/constants' import type { Editor } from '@open-pencil/core/editor' -import type { SceneNode, Stroke } from '@open-pencil/scene-graph' +import type { SceneNode, Stroke, StrokeCap, StrokeJoin } from '@open-pencil/scene-graph' + +import type { MixedValue } from '#vue/controls/node-props/use' export type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM' @@ -24,6 +26,82 @@ export const DEFAULT_STROKE: Stroke = { align: 'CENTER' } +export interface StrokeGeometryStateInput { + nodes: ComputedRef + merged: (key: K) => MixedValue +} + +export interface StrokeGeometryActions { + setCap: (value: StrokeCap) => void + setJoin: (value: StrokeJoin) => void + updateMiterLimit: (value: number) => void + commitMiterLimit: (value: number) => void +} + +export function createStrokeGeometryState({ nodes, merged }: StrokeGeometryStateInput) { + return { + advancedActive: computed( + () => nodes.value.length > 0 && nodes.value.every((node) => node.strokes.length > 0) + ), + cap: computed(() => merged('strokeCap')), + join: computed(() => merged('strokeJoin')), + miterLimit: computed(() => merged('strokeMiterLimit')) + } +} + +export function createStrokeGeometryActions( + editor: Editor, + nodes: ComputedRef +): StrokeGeometryActions { + const originalMiterLimits = new Map() + + function runForSelection(label: string, action: (node: SceneNode) => void) { + const selected = nodes.value + const run = () => selected.forEach(action) + if (selected.length > 1) editor.undo.runBatch(label, run) + else run() + } + + function setCap(value: StrokeCap) { + runForSelection('Change stroke cap', (node) => { + editor.updateNodeWithUndo( + node.id, + { strokeCap: value, strokes: node.strokes.map((stroke) => ({ ...stroke, cap: value })) }, + 'Change stroke cap' + ) + }) + } + + function setJoin(value: StrokeJoin) { + runForSelection('Change stroke join', (node) => { + editor.updateNodeWithUndo( + node.id, + { strokeJoin: value, strokes: node.strokes.map((stroke) => ({ ...stroke, join: value })) }, + 'Change stroke join' + ) + }) + } + + function updateMiterLimit(value: number) { + for (const node of nodes.value) { + if (!originalMiterLimits.has(node.id)) originalMiterLimits.set(node.id, node.strokeMiterLimit) + editor.updateNode(node.id, { strokeMiterLimit: Math.max(1, value) }) + } + } + + function commitMiterLimit(value: number) { + if (originalMiterLimits.size === 0) updateMiterLimit(value) + runForSelection('Change stroke miter limit', (node) => { + const previous = originalMiterLimits.get(node.id) + if (previous === undefined) return + editor.commitNodeUpdate(node.id, { strokeMiterLimit: previous }, 'Change stroke miter limit') + }) + originalMiterLimits.clear() + } + + return { setCap, setJoin, updateMiterLimit, commitMiterLimit } +} + export function updateAlign(editor: Editor, align: Stroke['align'], activeNode: SceneNode | null) { if (!activeNode) return const strokes = activeNode.strokes.map((s) => ({ ...s, align })) diff --git a/packages/vue/src/controls/stroke/use.ts b/packages/vue/src/controls/stroke/use.ts index 319846bf7..7c2db5a71 100644 --- a/packages/vue/src/controls/stroke/use.ts +++ b/packages/vue/src/controls/stroke/use.ts @@ -1,10 +1,13 @@ import { ref } from 'vue' +import { useNodeProps } from '#vue/controls/node-props/use' import { BORDER_SIDES, DEFAULT_STROKE, SIDE_OPTIONS, borderWeight, + createStrokeGeometryActions, + createStrokeGeometryState, createStrokeSideActions, currentAlign, currentSides, @@ -20,11 +23,12 @@ import { useI18n } from '#vue/i18n' /** * Returns stroke-related helpers for property panels. * - * This composable provides alignment options, side presets, a default stroke, - * and helpers for per-side border weight editing. + * This composable provides alignment and side helpers plus mixed-selection + * state and undo-aware actions for caps, joins, and miter limits. */ export function useStrokeControls() { const store = useEditor() + const { nodes, merged } = useNodeProps() const { panels } = useI18n() const sideMenuOpen = ref(false) const alignOptions = [ @@ -32,10 +36,26 @@ export function useStrokeControls() { { value: 'CENTER' as const, label: panels.value.strokeAlignCenter }, { value: 'OUTSIDE' as const, label: panels.value.strokeAlignOutside } ] + const capOptions = [ + { value: 'NONE' as const, label: panels.value.strokeCapButt }, + { value: 'ROUND' as const, label: panels.value.strokeCapRound }, + { value: 'SQUARE' as const, label: panels.value.strokeCapSquare } + ] + const joinOptions = [ + { value: 'MITER' as const, label: panels.value.strokeJoinMiter }, + { value: 'BEVEL' as const, label: panels.value.strokeJoinBevel }, + { value: 'ROUND' as const, label: panels.value.strokeJoinRound } + ] + const geometryState = createStrokeGeometryState({ nodes, merged }) + const geometryActions = createStrokeGeometryActions(store, nodes) const { selectSide, updateBorderWeight } = createStrokeSideActions(store, sideMenuOpen) return { alignOptions, + capOptions, + joinOptions, + ...geometryState, + ...geometryActions, sideOptions: SIDE_OPTIONS, borderSides: BORDER_SIDES, sideMenuOpen, diff --git a/packages/vue/src/i18n/locales/de/panels.json b/packages/vue/src/i18n/locales/de/panels.json index 31da4514a..e0cdbc522 100644 --- a/packages/vue/src/i18n/locales/de/panels.json +++ b/packages/vue/src/i18n/locales/de/panels.json @@ -120,6 +120,15 @@ "create": "Erstellen", "createNumberVariable": "Zahlenvariable aus {value} erstellen", "strokeDash": "Gestrichelter Strich", + "strokeCap": "Linienende", + "strokeCapButt": "Flaches Ende", + "strokeCapRound": "Rundes Ende", + "strokeCapSquare": "Quadratisches Ende", + "strokeJoin": "Linienverbindung", + "strokeJoinMiter": "Gehrungsverbindung", + "strokeJoinBevel": "Abgeschrägte Verbindung", + "strokeJoinRound": "Runde Verbindung", + "strokeMiterLimit": "Gehrungsgrenze", "add": "Hinzufügen", "variants": "Varianten", "gapAuto": "Automatischer Abstand", diff --git a/packages/vue/src/i18n/locales/es/panels.json b/packages/vue/src/i18n/locales/es/panels.json index 823e8909e..89bf3dcb2 100644 --- a/packages/vue/src/i18n/locales/es/panels.json +++ b/packages/vue/src/i18n/locales/es/panels.json @@ -136,6 +136,15 @@ "create": "Crear", "createNumberVariable": "Crear variable numérica desde {value}", "strokeDash": "Trazo discontinuo", + "strokeCap": "Extremo del trazo", + "strokeCapButt": "Extremo plano", + "strokeCapRound": "Extremo redondo", + "strokeCapSquare": "Extremo cuadrado", + "strokeJoin": "Unión del trazo", + "strokeJoinMiter": "Unión en inglete", + "strokeJoinBevel": "Unión biselada", + "strokeJoinRound": "Unión redonda", + "strokeMiterLimit": "Límite de inglete", "add": "Añadir", "variants": "Variantes", "gapAuto": "Espaciado auto", diff --git a/packages/vue/src/i18n/locales/fr/panels.json b/packages/vue/src/i18n/locales/fr/panels.json index b0d436f73..41f698057 100644 --- a/packages/vue/src/i18n/locales/fr/panels.json +++ b/packages/vue/src/i18n/locales/fr/panels.json @@ -120,6 +120,15 @@ "create": "Créer", "createNumberVariable": "Créer une variable numérique depuis {value}", "strokeDash": "Trait en pointillés", + "strokeCap": "Extrémité du trait", + "strokeCapButt": "Extrémité plate", + "strokeCapRound": "Extrémité arrondie", + "strokeCapSquare": "Extrémité carrée", + "strokeJoin": "Jonction du trait", + "strokeJoinMiter": "Jonction en onglet", + "strokeJoinBevel": "Jonction biseautée", + "strokeJoinRound": "Jonction arrondie", + "strokeMiterLimit": "Limite d’onglet", "add": "Ajouter", "variants": "Variantes", "gapAuto": "Espacement auto", diff --git a/packages/vue/src/i18n/locales/it/panels.json b/packages/vue/src/i18n/locales/it/panels.json index ba9f6944e..97ecce591 100644 --- a/packages/vue/src/i18n/locales/it/panels.json +++ b/packages/vue/src/i18n/locales/it/panels.json @@ -120,6 +120,15 @@ "create": "Crea", "createNumberVariable": "Crea variabile numerica da {value}", "strokeDash": "Tratto tratteggiato", + "strokeCap": "Estremità del tratto", + "strokeCapButt": "Estremità piatta", + "strokeCapRound": "Estremità arrotondata", + "strokeCapSquare": "Estremità quadrata", + "strokeJoin": "Giunzione del tratto", + "strokeJoinMiter": "Giunzione a mitra", + "strokeJoinBevel": "Giunzione smussata", + "strokeJoinRound": "Giunzione arrotondata", + "strokeMiterLimit": "Limite mitra", "add": "Aggiungi", "variants": "Varianti", "gapAuto": "Spaziatura auto", diff --git a/packages/vue/src/i18n/locales/ja/panels.json b/packages/vue/src/i18n/locales/ja/panels.json index e1eb016be..6520ad619 100644 --- a/packages/vue/src/i18n/locales/ja/panels.json +++ b/packages/vue/src/i18n/locales/ja/panels.json @@ -136,6 +136,15 @@ "create": "作成", "createNumberVariable": "{value} から数値変数を作成", "strokeDash": "破線", + "strokeCap": "線端", + "strokeCapButt": "フラット線端", + "strokeCapRound": "丸型線端", + "strokeCapSquare": "角型線端", + "strokeJoin": "線の結合", + "strokeJoinMiter": "マイター結合", + "strokeJoinBevel": "ベベル結合", + "strokeJoinRound": "ラウンド結合", + "strokeMiterLimit": "マイター制限", "add": "追加", "variants": "バリアント", "gapAuto": "間隔自動", diff --git a/packages/vue/src/i18n/locales/pl/panels.json b/packages/vue/src/i18n/locales/pl/panels.json index ef916d571..5ae6daa58 100644 --- a/packages/vue/src/i18n/locales/pl/panels.json +++ b/packages/vue/src/i18n/locales/pl/panels.json @@ -120,6 +120,15 @@ "create": "Utwórz", "createNumberVariable": "Utwórz zmienną liczbową z {value}", "strokeDash": "Obrys przerywany", + "strokeCap": "Zakończenie obrysu", + "strokeCapButt": "Płaskie zakończenie", + "strokeCapRound": "Okrągłe zakończenie", + "strokeCapSquare": "Kwadratowe zakończenie", + "strokeJoin": "Łączenie obrysu", + "strokeJoinMiter": "Łączenie ostre", + "strokeJoinBevel": "Łączenie ścięte", + "strokeJoinRound": "Łączenie okrągłe", + "strokeMiterLimit": "Limit łączenia ostrego", "add": "Dodaj", "variants": "Warianty", "gapAuto": "Automatyczny odstep", diff --git a/packages/vue/src/i18n/locales/ru/panels.json b/packages/vue/src/i18n/locales/ru/panels.json index 3011c4b9a..d51e5753b 100644 --- a/packages/vue/src/i18n/locales/ru/panels.json +++ b/packages/vue/src/i18n/locales/ru/panels.json @@ -120,6 +120,15 @@ "create": "Создать", "createNumberVariable": "Создать числовую переменную из {value}", "strokeDash": "Пунктирная обводка", + "strokeCap": "Конец обводки", + "strokeCapButt": "Плоский конец", + "strokeCapRound": "Круглый конец", + "strokeCapSquare": "Квадратный конец", + "strokeJoin": "Соединение обводки", + "strokeJoinMiter": "Острое соединение", + "strokeJoinBevel": "Скошенное соединение", + "strokeJoinRound": "Круглое соединение", + "strokeMiterLimit": "Предел острого соединения", "add": "Добавить", "variants": "Варианты", "gapAuto": "Авто отступ", diff --git a/packages/vue/src/i18n/locales/zh-cn/panels.json b/packages/vue/src/i18n/locales/zh-cn/panels.json index ec4e5e42c..412d250be 100644 --- a/packages/vue/src/i18n/locales/zh-cn/panels.json +++ b/packages/vue/src/i18n/locales/zh-cn/panels.json @@ -120,6 +120,15 @@ "create": "创建", "createNumberVariable": "从 {value} 创建数字变量", "strokeDash": "虚线描边", + "strokeCap": "描边端点", + "strokeCapButt": "平直端点", + "strokeCapRound": "圆形端点", + "strokeCapSquare": "方形端点", + "strokeJoin": "描边连接", + "strokeJoinMiter": "尖角连接", + "strokeJoinBevel": "斜角连接", + "strokeJoinRound": "圆角连接", + "strokeMiterLimit": "尖角限制", "add": "添加", "variants": "变体", "gapAuto": "自动间距", diff --git a/packages/vue/src/i18n/messages/panels.ts b/packages/vue/src/i18n/messages/panels.ts index d5459520b..663bc1773 100644 --- a/packages/vue/src/i18n/messages/panels.ts +++ b/packages/vue/src/i18n/messages/panels.ts @@ -141,6 +141,15 @@ export const panelMessageDefaults = { mixedEffectsHelp: 'Click + to replace mixed effects', strokeSides: 'Stroke sides', strokeDash: 'Dashed stroke', + strokeCap: 'Stroke cap', + strokeCapButt: 'Butt cap', + strokeCapRound: 'Round cap', + strokeCapSquare: 'Square cap', + strokeJoin: 'Stroke join', + strokeJoinMiter: 'Miter join', + strokeJoinBevel: 'Bevel join', + strokeJoinRound: 'Round join', + strokeMiterLimit: 'Miter limit', strokeAlignInside: 'Inside', strokeAlignCenter: 'Center', strokeAlignOutside: 'Outside', diff --git a/src/components/properties/StrokeSection.vue b/src/components/properties/StrokeSection.vue index a34d13a35..f3a7d34ca 100644 --- a/src/components/properties/StrokeSection.vue +++ b/src/components/properties/StrokeSection.vue @@ -4,6 +4,7 @@ import { ref } from 'vue' import { applySolidStrokeColor, BindableValueRoot, + MIXED, useColorBindingProvider, useI18n, useOkHCL, @@ -27,7 +28,10 @@ import VariableBindingPicker from '@/components/properties/binding/VariableBindi import AppSelect from '@/components/ui/AppSelect.vue' import FillSwatch from '@/components/ui/FillSwatch.vue' import IconButton from '@/components/ui/IconButton.vue' +import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue' +import PanelGrid from '@/components/ui/panel/PanelGrid.vue' import PanelSection from '@/components/ui/panel/PanelSection.vue' +import SegmentedControl from '@/components/ui/SegmentedControl.vue' import Tip from '@/components/ui/Tip.vue' import { colorToHexRaw } from '@open-pencil/core/color' @@ -35,6 +39,7 @@ import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph' import type { BindableValueActions } from '@open-pencil/vue' const strokeCtx = useStrokeControls() +const { advancedActive, cap, join, miterLimit } = strokeCtx const colorProvider = useColorBindingProvider() const okhcl = useOkHCL() const { panels, dialogs } = useI18n() @@ -60,6 +65,18 @@ function updateStrokeColor( if (commit) commitPaintMutation(binding) } +function setCap(value: string) { + if (value === 'NONE' || value === 'ROUND' || value === 'SQUARE') { + strokeCtx.setCap(value) + } +} + +function setJoin(value: string) { + if (value === 'MITER' || value === 'BEVEL' || value === 'ROUND') { + strokeCtx.setJoin(value) + } +} + function onToggleSides(activeNode: SceneNode | null) { if (!activeNode) return const next = !expandedSides.value @@ -246,6 +263,59 @@ function onToggleSides(activeNode: SceneNode | null) { + + + + + + + + + + + + + + + + + + + +
{ + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const pageId = store.state.currentPageId + const color = { r: 0.23, g: 0.51, b: 0.96, a: 1 } + const caps = ['NONE', 'ROUND', 'SQUARE'] as const + for (const [index, cap] of caps.entries()) { + store.graph.createNode('VECTOR', pageId, { + name: `${cap} cap visual`, + x: 92 + index * 190, + y: 72, + width: 120, + height: 41, + vectorNetwork: { + vertices: [ + { x: 0, y: 0 }, + { x: 120, y: 40 } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [] + }, + strokeCap: cap, + strokes: [ + { + color, + weight: 20, + visible: true, + opacity: 1, + align: 'CENTER', + cap + } + ] + }) + } + + const joins = [ + { join: 'MITER' as const, limit: 1 }, + { join: 'MITER' as const, limit: 12 }, + { join: 'BEVEL' as const, limit: 4 }, + { join: 'ROUND' as const, limit: 4 } + ] + for (const [index, { join, limit }] of joins.entries()) { + store.graph.createNode('STAR', pageId, { + name: `${join} ${limit} join visual`, + x: 72 + index * 150, + y: 170, + width: 110, + height: 110, + pointCount: 5, + starInnerRadius: 0.18, + strokeJoin: join, + strokeMiterLimit: limit, + strokes: [ + { + color: { r: 0.96, g: 0.35, b: 0.12, a: 1 }, + weight: 10, + visible: true, + opacity: 1, + align: 'CENTER', + join + } + ] + }) + } + + store.clearSelection() + store.requestRender() + }) + await editor.canvas.waitForRender() + editor.canvas.assertNoErrors() + const buffer = await editor.canvas.canvas.screenshot() + expect(buffer).toMatchSnapshot('stroke-caps-joins-miter-limits.png') +}) diff --git a/tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png b/tests/e2e/canvas/stroke-geometry-visual.spec.ts-snapshots/stroke-caps-joins-miter-limits-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..61a326ea09b2e25ea49922e6542d62bcf4a9df2a GIT binary patch literal 37708 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 aQ~)yN_<5It { + page = await browser.newPage() + await page.goto('/') + canvas = new CanvasHelper(page) + await canvas.waitForInit() +}) + +test.afterAll(async () => { + await page.close() +}) + +async function drawStrokedRectangle(x: number, y: number) { + await canvas.pressKey('r') + await canvas.drag(x, y, x + 120, y + 80) + await canvas.waitForRender() + await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click() + await canvas.waitForRender() +} + +async function selectedStrokeGeometry() { + return page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return [...store.state.selectedIds].map((id) => { + const node = store.graph.getNode(id) + return node + ? { + cap: node.strokeCap, + join: node.strokeJoin, + miterLimit: node.strokeMiterLimit, + paintCaps: node.strokes.map((stroke) => stroke.cap), + paintJoins: node.strokes.map((stroke) => stroke.join) + } + : null + }) + }) +} + +test('shows geometry controls only after a stroke is added', async () => { + await canvas.pressKey('r') + await canvas.drag(100, 100, 220, 180) + await canvas.waitForRender() + await expect(page.locator('[data-property="stroke-cap"]')).not.toBeVisible() + + await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click() + await canvas.waitForRender() + await expect(page.locator('[data-property="stroke-cap"]')).toBeVisible() + await expect(page.locator('[data-property="stroke-join"]')).toBeVisible() + await expect(page.locator('[data-property="stroke-miter-limit"]')).toBeVisible() +}) + +test('updates cap, join, and miter state from compact controls', async () => { + const section = propertySection(page, 'Stroke') + await section.getByRole('button', { name: 'Round cap' }).click() + await section.getByRole('button', { name: 'Bevel join' }).click() + await section.getByRole('spinbutton', { name: 'Miter limit' }).focus() + const miter = section.getByRole('spinbutton', { name: 'Miter limit' }) + await miter.fill('12') + await miter.press('Enter') + await canvas.waitForRender() + + expect(await selectedStrokeGeometry()).toEqual([ + { + cap: 'ROUND', + join: 'BEVEL', + miterLimit: 12, + paintCaps: ['ROUND'], + paintJoins: ['BEVEL'] + } + ]) + + await canvas.pressKey('Meta+z') + await canvas.waitForRender() + expect((await selectedStrokeGeometry())[0]?.miterLimit).toBe(4) +}) + +test('applies mixed multi-selection joins in one undo step', async () => { + await canvas.clearCanvas() + await drawStrokedRectangle(80, 80) + await drawStrokedRectangle(260, 80) + await canvas.pressKey('Meta+a') + await canvas.waitForRender() + + const roundJoin = propertySection(page, 'Stroke').getByRole('button', { name: 'Round join' }) + await expect(roundJoin).toBeVisible() + await roundJoin.click() + await canvas.waitForRender() + expect((await selectedStrokeGeometry()).map((value) => value?.join)).toEqual(['ROUND', 'ROUND']) + + await canvas.pressKey('Meta+z') + await canvas.waitForRender() + expect((await selectedStrokeGeometry()).map((value) => value?.join)).toEqual(['MITER', 'MITER']) +}) diff --git a/tests/engine/figma/api/stroke/details.test.ts b/tests/engine/figma/api/stroke/details.test.ts index db62d33b1..e204dcc83 100644 --- a/tests/engine/figma/api/stroke/details.test.ts +++ b/tests/engine/figma/api/stroke/details.test.ts @@ -18,12 +18,16 @@ describe('stroke details', () => { test('strokeCap and strokeJoin', () => { const api = createAPI() const line = api.createLine() + line.strokes = [ + { color: { r: 0, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'CENTER' } + ] expect(line.strokeCap).toBe('NONE') expect(line.strokeJoin).toBe('MITER') line.strokeCap = 'ROUND' line.strokeJoin = 'BEVEL' expect(line.strokeCap).toBe('ROUND') expect(line.strokeJoin).toBe('BEVEL') + expect(line.strokes[0]).toMatchObject({ cap: 'ROUND', join: 'BEVEL' }) }) test('strokeMiterLimit', () => { diff --git a/tests/engine/io/fig/export/stroke-geometry.test.ts b/tests/engine/io/fig/export/stroke-geometry.test.ts new file mode 100644 index 000000000..df46e2514 --- /dev/null +++ b/tests/engine/io/fig/export/stroke-geometry.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph } from '@open-pencil/scene-graph' + +import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize' + +function serializeMiterLimit(strokeMiterLimit: number, importedMiterLimit?: number) { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const node = graph.createNode('VECTOR', page.id, { strokeMiterLimit }) + if (importedMiterLimit !== undefined) { + graph.updateNode(node.id, { + source: { + ...node.source, + id: '1:2', + fig: { + ...node.source.fig, + rawNodeFields: { ...node.source.fig.rawNodeFields, miterLimit: importedMiterLimit } + } + } + }) + } + const current = graph.getNode(node.id) + if (!current) throw new Error('Expected vector node') + return sceneNodeToKiwi(current, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])[0] + .miterLimit +} + +function serializeImportedDefaultJoin() { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const node = graph.createNode('VECTOR', page.id, { strokeJoin: 'MITER' }) + graph.updateNode(node.id, { + source: { + ...node.source, + id: '1:2', + fig: { + ...node.source.fig, + rawNodeFields: { ...node.source.fig.rawNodeFields, strokeJoin: 'BEVEL' } + } + } + }) + const current = graph.getNode(node.id) + if (!current) throw new Error('Expected vector node') + return sceneNodeToKiwi(current, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])[0] + .strokeJoin +} + +describe('Figma stroke geometry export', () => { + test('exports non-default miter limits', () => { + expect(serializeMiterLimit(12)).toBe(12) + }) + + test('overrides stale imported raw values when edited back to the default', () => { + expect(serializeMiterLimit(4, 9)).toBe(4) + expect(serializeImportedDefaultJoin()).toBe('MITER') + }) +}) diff --git a/tests/engine/io/fig/import/legacy/strokes.test.ts b/tests/engine/io/fig/import/legacy/strokes.test.ts index 40e3b7211..1e7c91162 100644 --- a/tests/engine/io/fig/import/legacy/strokes.test.ts +++ b/tests/engine/io/fig/import/legacy/strokes.test.ts @@ -22,12 +22,14 @@ describe('fig-import: stroke options', () => { strokeWeight: 3, strokeAlign: 'CENTER', strokeCap: 'ROUND', - strokeJoin: 'BEVEL' + strokeJoin: 'BEVEL', + miterLimit: 9 } as Partial) ]) const n = graph.getChildren(graph.getPages()[0].id)[0] expect(n.strokes[0].cap).toBe('ROUND') expect(n.strokes[0].join).toBe('BEVEL') + expect(n.strokeMiterLimit).toBe(9) }) test('dash pattern', () => { diff --git a/tests/engine/vue/controls/stroke.test.ts b/tests/engine/vue/controls/stroke.test.ts new file mode 100644 index 000000000..973718427 --- /dev/null +++ b/tests/engine/vue/controls/stroke.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' + +import { computed, ref } from 'vue' + +import { createEditor } from '@open-pencil/core/editor' +import type { SceneNode } from '@open-pencil/scene-graph' + +import { MIXED, type MixedValue } from '#vue/controls/node-props/use' +import { + DEFAULT_STROKE, + createStrokeGeometryActions, + createStrokeGeometryState +} from '#vue/controls/stroke/helpers' + +import { firstPageId, makeSceneGraph } from '#tests/helpers/scene' + +function strokedRect(overrides: Partial = {}) { + const graph = makeSceneGraph() + const node = graph.createNode('RECTANGLE', firstPageId(graph), { + strokes: [{ ...DEFAULT_STROKE }], + ...overrides + }) + return { graph, node } +} + +function merged(nodes: SceneNode[]) { + return (key: K): MixedValue => { + const first = nodes[0]?.[key] + if (first === undefined || nodes.some((node) => node[key] !== first)) return MIXED + return first + } +} + +describe('stroke geometry controls', () => { + test('is active only when every selected node has a stroke and reports mixed values', () => { + const { node } = strokedRect() + const other = structuredClone(node) + other.id = 'other' + other.strokeJoin = 'BEVEL' + const nodes = ref([node, other]) + const state = createStrokeGeometryState({ + nodes: computed(() => nodes.value), + merged: (key) => merged(nodes.value)(key) + }) + + expect(state.advancedActive.value).toBe(true) + expect(state.cap.value).toBe('NONE') + expect(state.join.value).toBe(MIXED) + + nodes.value[1].strokes = [] + expect(state.advancedActive.value).toBe(false) + }) + + test('synchronizes node and paint cap and join with one multi-selection undo', () => { + const graph = makeSceneGraph() + const pageId = firstPageId(graph) + const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] }) + const second = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] }) + const editor = createEditor({ graph }) + const nodes = computed(() => + [graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean) + ) + const actions = createStrokeGeometryActions(editor, nodes) + + actions.setCap('ROUND') + actions.setJoin('BEVEL') + expect(graph.getNode(first.id)).toMatchObject({ + strokeCap: 'ROUND', + strokeJoin: 'BEVEL', + strokes: [{ cap: 'ROUND', join: 'BEVEL' }] + }) + expect(graph.getNode(second.id)?.strokes[0]).toMatchObject({ cap: 'ROUND', join: 'BEVEL' }) + + editor.undo.undo() + expect(graph.getNode(first.id)?.strokeJoin).toBe('MITER') + expect(graph.getNode(second.id)?.strokeJoin).toBe('MITER') + expect(graph.getNode(first.id)?.strokeCap).toBe('ROUND') + }) + + test('previews and commits miter limit as one multi-selection undo step', () => { + const graph = makeSceneGraph() + const pageId = firstPageId(graph) + const first = graph.createNode('RECTANGLE', pageId, { strokes: [{ ...DEFAULT_STROKE }] }) + const second = graph.createNode('RECTANGLE', pageId, { + strokes: [{ ...DEFAULT_STROKE }], + strokeMiterLimit: 8 + }) + const editor = createEditor({ graph }) + const nodes = computed(() => + [graph.getNode(first.id), graph.getNode(second.id)].filter(Boolean) + ) + const actions = createStrokeGeometryActions(editor, nodes) + + actions.updateMiterLimit(12) + expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(12) + expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(12) + actions.commitMiterLimit(12) + + editor.undo.undo() + expect(graph.getNode(first.id)?.strokeMiterLimit).toBe(4) + expect(graph.getNode(second.id)?.strokeMiterLimit).toBe(8) + }) +})