* 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.
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
||
|
||
import type { Vector } from '@open-pencil/core'
|
||
import {
|
||
computeDescendantVisualBounds,
|
||
computeVisualBounds
|
||
} from '@open-pencil/scene-graph/geometry'
|
||
|
||
function commandsBlobFromPoints(points: Vector[]): Uint8Array {
|
||
const blob = new Uint8Array(points.length * 9 + 1)
|
||
const view = new DataView(blob.buffer)
|
||
let offset = 0
|
||
for (const point of points) {
|
||
blob[offset] = 1
|
||
view.setFloat32(offset + 1, point.x, true)
|
||
view.setFloat32(offset + 5, point.y, true)
|
||
offset += 9
|
||
}
|
||
blob[offset] = 0
|
||
return blob
|
||
}
|
||
|
||
describe('computeVisualBounds', () => {
|
||
const idPos = (id: string) => {
|
||
const map: Record<string, Vector> = {
|
||
r1: { x: 100, y: 200 },
|
||
r2: { x: 300, y: 400 },
|
||
rotated: { x: 0, y: 0 }
|
||
}
|
||
return map[id] ?? { x: 0, y: 0 }
|
||
}
|
||
|
||
test('empty iterable returns zero rect', () => {
|
||
expect(computeVisualBounds([], idPos)).toEqual({ x: 0, y: 0, width: 0, height: 0 })
|
||
})
|
||
|
||
test('single axis-aligned node returns its bounds', () => {
|
||
const result = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
expect(result).toEqual({ x: 100, y: 200, width: 50, height: 60 })
|
||
})
|
||
|
||
test('multiple nodes returns union', () => {
|
||
const result = computeVisualBounds(
|
||
[
|
||
{ id: 'r1', width: 50, height: 60 },
|
||
{ id: 'r2', width: 30, height: 30 }
|
||
],
|
||
idPos
|
||
)
|
||
expect(result).toEqual({ x: 100, y: 200, width: 230, height: 230 })
|
||
})
|
||
|
||
test('rotated node has expanded bbox', () => {
|
||
const noRot = computeVisualBounds(
|
||
[{ id: 'rotated', width: 100, height: 50, rotation: 0 }],
|
||
idPos
|
||
)
|
||
const rotated = computeVisualBounds(
|
||
[{ id: 'rotated', width: 100, height: 50, rotation: 45 }],
|
||
idPos
|
||
)
|
||
// Rotated bbox should be larger
|
||
expect(rotated.width).toBeGreaterThan(noRot.width)
|
||
expect(rotated.height).toBeGreaterThan(noRot.height)
|
||
})
|
||
|
||
test('strokes expand bounds', () => {
|
||
const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60, strokes: [] }], idPos)
|
||
const withStroke = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 10,
|
||
visible: true,
|
||
align: 'OUTSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// OUTSIDE stroke of weight 10 adds 10 to each side
|
||
expect(withStroke.width).toBe(noStroke.width + 20)
|
||
expect(withStroke.height).toBe(noStroke.height + 20)
|
||
})
|
||
|
||
test('invisible strokes do not expand bounds', () => {
|
||
const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const invisibleStroke = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 100,
|
||
visible: false,
|
||
align: 'OUTSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
expect(invisibleStroke.width).toBe(noStroke.width)
|
||
expect(invisibleStroke.height).toBe(noStroke.height)
|
||
})
|
||
|
||
test('CENTER stroke expands bounds by half weight', () => {
|
||
const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const centerStroke = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 10,
|
||
visible: true,
|
||
align: 'CENTER' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// CENTER stroke of weight 10 adds 5 to each side
|
||
expect(centerStroke.width).toBe(noStroke.width + 10)
|
||
expect(centerStroke.height).toBe(noStroke.height + 10)
|
||
})
|
||
|
||
test('INSIDE stroke does not expand bounds', () => {
|
||
const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const insideStroke = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 10,
|
||
visible: true,
|
||
align: 'INSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
expect(insideStroke.width).toBe(noStroke.width)
|
||
expect(insideStroke.height).toBe(noStroke.height)
|
||
})
|
||
|
||
test('DROP_SHADOW expands bounds asymmetrically based on offset', () => {
|
||
const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const withShadow = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
effects: [
|
||
{
|
||
type: 'DROP_SHADOW' as const,
|
||
visible: true,
|
||
radius: 10,
|
||
spread: 0,
|
||
offset: { x: 5, y: -3 },
|
||
color: { r: 0, g: 0, b: 0, a: 0.5 }
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// blurSpread = radius + spread = 10 + 0 = 10
|
||
// left expansion: max(0, blurSpread + max(0, -offset.x)) = max(0, 10 + 0) = 10
|
||
// right expansion: max(0, blurSpread + max(0, offset.x)) = max(0, 10 + 5) = 15
|
||
// top expansion: max(0, blurSpread + max(0, -offset.y)) = max(0, 10 + 3) = 13
|
||
// bottom expansion: max(0, blurSpread + max(0, offset.y)) = max(0, 10 + 0) = 10
|
||
expect(withShadow.x).toBe(noEffect.x - 10)
|
||
expect(withShadow.y).toBe(noEffect.y - 13)
|
||
expect(withShadow.width).toBe(noEffect.width + 10 + 15)
|
||
expect(withShadow.height).toBe(noEffect.height + 13 + 10)
|
||
})
|
||
|
||
test('LAYER_BLUR expands bounds by radius', () => {
|
||
const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const withBlur = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
effects: [
|
||
{
|
||
type: 'LAYER_BLUR' as const,
|
||
visible: true,
|
||
radius: 20,
|
||
spread: 0,
|
||
offset: { x: 0, y: 0 },
|
||
color: { r: 0, g: 0, b: 0, a: 1 }
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// blurSpread = 20, offset = (0,0)
|
||
// All sides expand by 20
|
||
expect(withBlur.width).toBe(noEffect.width + 40)
|
||
expect(withBlur.height).toBe(noEffect.height + 40)
|
||
})
|
||
|
||
test('invisible effect does not expand bounds', () => {
|
||
const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const invisibleEffect = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
effects: [
|
||
{
|
||
type: 'DROP_SHADOW' as const,
|
||
visible: false,
|
||
radius: 100,
|
||
spread: 50,
|
||
offset: { x: 200, y: 200 },
|
||
color: { r: 0, g: 0, b: 0, a: 1 }
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
expect(invisibleEffect).toEqual(noEffect)
|
||
})
|
||
|
||
test('combined OUTSIDE stroke and DROP_SHADOW expands bounds correctly', () => {
|
||
const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const combined = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 5,
|
||
visible: true,
|
||
align: 'OUTSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
],
|
||
effects: [
|
||
{
|
||
type: 'DROP_SHADOW' as const,
|
||
visible: true,
|
||
radius: 10,
|
||
spread: 0,
|
||
offset: { x: 3, y: 3 },
|
||
color: { r: 0, g: 0, b: 0, a: 0.5 }
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// strokeOverflow: OUTSIDE weight=5 → overflow=5
|
||
// effectOverflow: blurSpread=10, offset=(3,3)
|
||
// left = 10 + max(0, -3) = 10
|
||
// right = 10 + max(0, 3) = 13
|
||
// top = 10 + max(0, -3) = 10
|
||
// bottom = 10 + max(0, 3) = 13
|
||
// Total left expansion: 5 (stroke) + 10 (effect) = 15
|
||
// Total right expansion: 5 (stroke) + 13 (effect) = 18
|
||
// Total top expansion: 5 (stroke) + 10 (effect) = 15
|
||
// Total bottom expansion: 5 (stroke) + 13 (effect) = 18
|
||
expect(combined.x).toBe(noEffects.x - 15)
|
||
expect(combined.y).toBe(noEffects.y - 15)
|
||
expect(combined.width).toBe(noEffects.width + 15 + 18) // 50 + 33 = 83
|
||
expect(combined.height).toBe(noEffects.height + 15 + 18) // 60 + 33 = 93
|
||
})
|
||
|
||
test('multiple strokes takes maximum overflow', () => {
|
||
const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const multiStroke = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 2,
|
||
visible: true,
|
||
align: 'OUTSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
},
|
||
{
|
||
type: 'SOLID',
|
||
weight: 8,
|
||
visible: true,
|
||
align: 'CENTER' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
},
|
||
{
|
||
type: 'SOLID',
|
||
weight: 4,
|
||
visible: true,
|
||
align: 'INSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// strokeOverflow takes max: OUTSIDE(2)=2, CENTER(4)=4, INSIDE(0)=0 → max=4
|
||
expect(multiStroke.width).toBe(noEffects.width + 8) // 4 per side × 2
|
||
expect(multiStroke.height).toBe(noEffects.height + 8)
|
||
})
|
||
|
||
test('inside stroke geometry does not expand export bounds', () => {
|
||
const strokeGeometry = [
|
||
{
|
||
commandsBlob: commandsBlobFromPoints([
|
||
{ x: -1, y: -1 },
|
||
{ x: 101, y: 51 }
|
||
])
|
||
}
|
||
]
|
||
const nodes = {
|
||
inside: {
|
||
id: 'inside',
|
||
type: 'COMPONENT',
|
||
width: 100,
|
||
height: 50,
|
||
visible: true,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 1,
|
||
visible: true,
|
||
align: 'INSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
],
|
||
strokeGeometry,
|
||
childIds: []
|
||
},
|
||
outside: {
|
||
id: 'outside',
|
||
type: 'FRAME',
|
||
width: 100,
|
||
height: 50,
|
||
visible: true,
|
||
strokes: [
|
||
{
|
||
type: 'SOLID',
|
||
weight: 1,
|
||
visible: true,
|
||
align: 'OUTSIDE' as const,
|
||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||
opacity: 1
|
||
}
|
||
],
|
||
strokeGeometry,
|
||
childIds: []
|
||
}
|
||
}
|
||
|
||
const insideBounds = computeDescendantVisualBounds(
|
||
['inside'],
|
||
(id) => nodes[id as keyof typeof nodes],
|
||
() => ({ x: 10, y: 20 })
|
||
)
|
||
const outsideBounds = computeDescendantVisualBounds(
|
||
['outside'],
|
||
(id) => nodes[id as keyof typeof nodes],
|
||
() => ({ x: 10, y: 20 })
|
||
)
|
||
|
||
expect(insideBounds).toEqual({ minX: 10, minY: 20, maxX: 110, maxY: 70 })
|
||
expect(outsideBounds).toEqual({ minX: 9, minY: 19, maxX: 111, maxY: 71 })
|
||
})
|
||
|
||
test('descendant bounds include arrow overflow', () => {
|
||
const nodes = {
|
||
root: {
|
||
id: 'root',
|
||
type: 'FRAME',
|
||
width: 100,
|
||
height: 50,
|
||
visible: true,
|
||
childIds: ['line']
|
||
},
|
||
line: {
|
||
id: 'line',
|
||
type: 'LINE',
|
||
width: 100,
|
||
height: 0,
|
||
visible: true,
|
||
strokeCap: 'ARROW_EQUILATERAL' as const,
|
||
strokes: [{ type: 'SOLID', weight: 4, visible: true, align: 'CENTER' as const }],
|
||
childIds: []
|
||
}
|
||
}
|
||
const bounds = computeDescendantVisualBounds(
|
||
['root'],
|
||
(id) => nodes[id as keyof typeof nodes],
|
||
() => ({ x: 0, y: 0 })
|
||
)
|
||
expect(bounds?.minY).toBe(-18)
|
||
})
|
||
|
||
test('nested clipping stops descendants outside the ancestor clip', () => {
|
||
const nodes = {
|
||
root: {
|
||
id: 'root',
|
||
type: 'FRAME',
|
||
width: 100,
|
||
height: 100,
|
||
visible: true,
|
||
clipsContent: true,
|
||
childIds: ['row']
|
||
},
|
||
row: {
|
||
id: 'row',
|
||
type: 'FRAME',
|
||
width: 100,
|
||
height: 50,
|
||
visible: true,
|
||
clipsContent: true,
|
||
childIds: ['cell']
|
||
},
|
||
cell: {
|
||
id: 'cell',
|
||
type: 'FRAME',
|
||
width: 50,
|
||
height: 50,
|
||
visible: true,
|
||
childIds: []
|
||
}
|
||
}
|
||
const positions: Record<keyof typeof nodes, Vector> = {
|
||
root: { x: 0, y: 0 },
|
||
row: { x: 0, y: 120 },
|
||
cell: { x: 0, y: 120 }
|
||
}
|
||
|
||
const bounds = computeDescendantVisualBounds(
|
||
['root'],
|
||
(id) => nodes[id as keyof typeof nodes],
|
||
(id) => positions[id as keyof typeof positions]
|
||
)
|
||
|
||
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 })
|
||
})
|
||
|
||
test('multiple effects accumulate directional overflow', () => {
|
||
const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||
const multiEffect = computeVisualBounds(
|
||
[
|
||
{
|
||
id: 'r1',
|
||
width: 50,
|
||
height: 60,
|
||
effects: [
|
||
{
|
||
type: 'DROP_SHADOW' as const,
|
||
visible: true,
|
||
radius: 5,
|
||
spread: 0,
|
||
offset: { x: 10, y: 0 },
|
||
color: { r: 0, g: 0, b: 0, a: 1 }
|
||
},
|
||
{
|
||
type: 'DROP_SHADOW' as const,
|
||
visible: true,
|
||
radius: 3,
|
||
spread: 0,
|
||
offset: { x: -10, y: 0 },
|
||
color: { r: 0, g: 0, b: 0, a: 1 }
|
||
}
|
||
]
|
||
}
|
||
],
|
||
idPos
|
||
)
|
||
// Effect 1: blurSpread=5, offset=(10,0) → left=5, right=15, top=5, bottom=5
|
||
// Effect 2: blurSpread=3, offset=(-10,0) → left=13, right=3, top=3, bottom=3
|
||
// After Math.max: left=max(5,13)=13, right=max(15,3)=15, top=max(5,3)=5, bottom=max(5,3)=5
|
||
expect(multiEffect.x).toBe(noEffects.x - 13)
|
||
expect(multiEffect.width).toBe(noEffects.width + 13 + 15) // 50 + 28 = 78
|
||
expect(multiEffect.y).toBe(noEffects.y - 5)
|
||
expect(multiEffect.height).toBe(noEffects.height + 5 + 5) // 60 + 10 = 70
|
||
})
|
||
})
|