* feat(scene-graph)!: make a stroke a paint Stroke extends Fill, so a stroke carries the same paint vocabulary a fill does instead of a lone color. Every construction site now states a solid paint type, which keeps today's behaviour exactly; rendering, .fig conversion, and the stroke panel still read solid strokes only. copyFill is generic over the paint shape so copyStroke reuses it rather than repeating the deep copy of gradient stops, transforms and pattern fields. Groundwork for the gradient and image strokes in #797. * test(scene-graph): cover a stroke's nested paint data in copyStroke A stroke is a paint now, so its gradient stops and transform must copy as deeply as a fill's; the fixture was solid and proved only the color and dash pattern.
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
|
|
import { SceneGraph, FigmaAPI, sceneNodeToKiwi, type Stroke } from '@open-pencil/core'
|
|
|
|
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
return graph.getPages()[0].id
|
|
}
|
|
|
|
function rectWithStroke(graph: SceneGraph, name: string, strokeOverrides: Partial<Stroke> = {}) {
|
|
const stroke: Stroke = {
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
weight: 2,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'CENTER',
|
|
...strokeOverrides
|
|
}
|
|
return graph.createNode('RECTANGLE', pageId(graph), {
|
|
name,
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
strokes: [stroke]
|
|
})
|
|
}
|
|
|
|
describe('Stroke align', () => {
|
|
test('default stroke align is stored per stroke', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
expect(node.strokes[0].align).toBe('CENTER')
|
|
})
|
|
|
|
test('stroke align can be set to INSIDE', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R', { align: 'INSIDE' })
|
|
expect(node.strokes[0].align).toBe('INSIDE')
|
|
})
|
|
|
|
test('stroke align can be set to OUTSIDE', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R', { align: 'OUTSIDE' })
|
|
expect(node.strokes[0].align).toBe('OUTSIDE')
|
|
})
|
|
|
|
test('updating stroke align preserves other stroke properties', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R', {
|
|
type: 'SOLID',
|
|
weight: 5,
|
|
align: 'CENTER'
|
|
})
|
|
const strokes = [...node.strokes]
|
|
strokes[0] = { ...strokes[0], align: 'INSIDE' }
|
|
graph.updateNode(node.id, { strokes })
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
expect(updated.strokes[0].align).toBe('INSIDE')
|
|
expect(updated.strokes[0].weight).toBe(5)
|
|
expect(updated.strokes[0].color).toEqual({ r: 0, g: 0, b: 0, a: 1 })
|
|
})
|
|
})
|
|
|
|
describe('Individual stroke weights', () => {
|
|
test('default independentStrokeWeights is false', () => {
|
|
const graph = new SceneGraph()
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'R' })
|
|
expect(node.independentStrokeWeights).toBe(false)
|
|
expect(node.borderTopWeight).toBe(0)
|
|
expect(node.borderRightWeight).toBe(0)
|
|
expect(node.borderBottomWeight).toBe(0)
|
|
expect(node.borderLeftWeight).toBe(0)
|
|
})
|
|
|
|
test('enable independent stroke weights', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 2,
|
|
borderRightWeight: 0,
|
|
borderBottomWeight: 4,
|
|
borderLeftWeight: 0
|
|
})
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
expect(updated.independentStrokeWeights).toBe(true)
|
|
expect(updated.borderTopWeight).toBe(2)
|
|
expect(updated.borderRightWeight).toBe(0)
|
|
expect(updated.borderBottomWeight).toBe(4)
|
|
expect(updated.borderLeftWeight).toBe(0)
|
|
})
|
|
|
|
test('disable independent stroke weights', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 2,
|
|
borderRightWeight: 3,
|
|
borderBottomWeight: 4,
|
|
borderLeftWeight: 5
|
|
})
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: false,
|
|
borderTopWeight: 0,
|
|
borderRightWeight: 0,
|
|
borderBottomWeight: 0,
|
|
borderLeftWeight: 0
|
|
})
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
expect(updated.independentStrokeWeights).toBe(false)
|
|
})
|
|
|
|
test('single side: top only', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 3,
|
|
borderRightWeight: 0,
|
|
borderBottomWeight: 0,
|
|
borderLeftWeight: 0
|
|
})
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
expect(updated.borderTopWeight).toBe(3)
|
|
expect(updated.borderRightWeight).toBe(0)
|
|
expect(updated.borderBottomWeight).toBe(0)
|
|
expect(updated.borderLeftWeight).toBe(0)
|
|
})
|
|
|
|
test('individual weights are independent from stroke weight', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R', { weight: 10 })
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 1,
|
|
borderRightWeight: 2,
|
|
borderBottomWeight: 3,
|
|
borderLeftWeight: 4
|
|
})
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
expect(updated.strokes[0].weight).toBe(10)
|
|
expect(updated.borderTopWeight).toBe(1)
|
|
expect(updated.borderRightWeight).toBe(2)
|
|
expect(updated.borderBottomWeight).toBe(3)
|
|
expect(updated.borderLeftWeight).toBe(4)
|
|
})
|
|
})
|
|
|
|
describe('Instance sync with stroke weights', () => {
|
|
test('syncInstances propagates independentStrokeWeights', () => {
|
|
const graph = new SceneGraph()
|
|
const comp = graph.createNode('COMPONENT', pageId(graph), {
|
|
name: 'Comp',
|
|
strokes: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
weight: 2,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'INSIDE'
|
|
}
|
|
],
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 3,
|
|
borderRightWeight: 0,
|
|
borderBottomWeight: 3,
|
|
borderLeftWeight: 0
|
|
})
|
|
|
|
const instance = expectDefined(
|
|
graph.createInstance(comp.id, pageId(graph)),
|
|
'component instance'
|
|
)
|
|
expect(instance.independentStrokeWeights).toBe(true)
|
|
expect(instance.borderTopWeight).toBe(3)
|
|
expect(instance.borderBottomWeight).toBe(3)
|
|
|
|
graph.updateNode(comp.id, { borderTopWeight: 5 })
|
|
graph.syncInstances(comp.id)
|
|
|
|
const synced = getNodeOrThrow(graph, instance.id)
|
|
expect(synced.borderTopWeight).toBe(5)
|
|
})
|
|
})
|
|
|
|
describe('Kiwi serialization', () => {
|
|
test('exports stroke align from first stroke', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R', { align: 'OUTSIDE' })
|
|
|
|
const blobs: Uint8Array[] = []
|
|
const changes = sceneNodeToKiwi(
|
|
node,
|
|
{ sessionID: 1, localID: 0 },
|
|
0,
|
|
{ value: 100 },
|
|
graph,
|
|
blobs
|
|
)
|
|
|
|
expect(changes[0].strokeAlign).toBe('OUTSIDE')
|
|
})
|
|
|
|
test('exports default CENTER align', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
|
|
const blobs: Uint8Array[] = []
|
|
const changes = sceneNodeToKiwi(
|
|
node,
|
|
{ sessionID: 1, localID: 0 },
|
|
0,
|
|
{ value: 100 },
|
|
graph,
|
|
blobs
|
|
)
|
|
|
|
expect(changes[0].strokeAlign).toBe('CENTER')
|
|
})
|
|
|
|
test('exports individual stroke weights', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
graph.updateNode(node.id, {
|
|
independentStrokeWeights: true,
|
|
borderTopWeight: 1,
|
|
borderRightWeight: 2,
|
|
borderBottomWeight: 3,
|
|
borderLeftWeight: 4
|
|
})
|
|
|
|
const updated = getNodeOrThrow(graph, node.id)
|
|
const blobs: Uint8Array[] = []
|
|
const changes = sceneNodeToKiwi(
|
|
updated,
|
|
{ sessionID: 1, localID: 0 },
|
|
0,
|
|
{ value: 100 },
|
|
graph,
|
|
blobs
|
|
)
|
|
|
|
expect(changes[0].borderStrokeWeightsIndependent).toBe(true)
|
|
expect(changes[0].borderTopWeight).toBe(1)
|
|
expect(changes[0].borderRightWeight).toBe(2)
|
|
expect(changes[0].borderBottomWeight).toBe(3)
|
|
expect(changes[0].borderLeftWeight).toBe(4)
|
|
})
|
|
|
|
test('does not export individual weights when disabled', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rectWithStroke(graph, 'R')
|
|
|
|
const blobs: Uint8Array[] = []
|
|
const changes = sceneNodeToKiwi(
|
|
node,
|
|
{ sessionID: 1, localID: 0 },
|
|
0,
|
|
{ value: 100 },
|
|
graph,
|
|
blobs
|
|
)
|
|
|
|
expect(changes[0].borderStrokeWeightsIndependent).toBeUndefined()
|
|
expect(changes[0].borderTopWeight).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('FigmaAPI stroke properties', () => {
|
|
test('strokeTopWeight sets independentStrokeWeights', () => {
|
|
const api = new FigmaAPI(new SceneGraph())
|
|
const rect = api.createRectangle()
|
|
rect.strokes = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
weight: 2,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'CENTER'
|
|
}
|
|
]
|
|
rect.strokeTopWeight = 5
|
|
|
|
const raw = getNodeOrThrow(api.graph, rect.id)
|
|
expect(raw.independentStrokeWeights).toBe(true)
|
|
expect(raw.borderTopWeight).toBe(5)
|
|
})
|
|
|
|
test('strokeBottomWeight getter returns borderBottomWeight', () => {
|
|
const api = new FigmaAPI(new SceneGraph())
|
|
const frame = api.createFrame()
|
|
api.graph.updateNode(frame.id, { borderBottomWeight: 7 })
|
|
expect(frame.strokeBottomWeight).toBe(7)
|
|
})
|
|
|
|
test('strokeAlign getter returns first stroke align', () => {
|
|
const api = new FigmaAPI(new SceneGraph())
|
|
const rect = api.createRectangle()
|
|
rect.strokes = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
weight: 2,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'INSIDE'
|
|
}
|
|
]
|
|
expect(rect.strokeAlign).toBe('INSIDE')
|
|
})
|
|
|
|
test('strokeAlign setter updates stroke', () => {
|
|
const api = new FigmaAPI(new SceneGraph())
|
|
const rect = api.createRectangle()
|
|
rect.strokes = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
weight: 2,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: 'CENTER'
|
|
}
|
|
]
|
|
rect.strokeAlign = 'OUTSIDE'
|
|
|
|
const raw = getNodeOrThrow(api.graph, rect.id)
|
|
expect(raw.strokes[0].align).toBe('OUTSIDE')
|
|
})
|
|
|
|
test('all four individual stroke weight setters', () => {
|
|
const api = new FigmaAPI(new SceneGraph())
|
|
const frame = api.createFrame()
|
|
frame.strokeTopWeight = 1
|
|
frame.strokeRightWeight = 2
|
|
frame.strokeBottomWeight = 3
|
|
frame.strokeLeftWeight = 4
|
|
|
|
expect(frame.strokeTopWeight).toBe(1)
|
|
expect(frame.strokeRightWeight).toBe(2)
|
|
expect(frame.strokeBottomWeight).toBe(3)
|
|
expect(frame.strokeLeftWeight).toBe(4)
|
|
|
|
const raw = getNodeOrThrow(api.graph, frame.id)
|
|
expect(raw.independentStrokeWeights).toBe(true)
|
|
})
|
|
})
|