feat(svg): flatten compatible imported fills
- Merge adjacent filled SVG paths into editable multi-color vector networks - Preserve stroke ordering and remap gradients into flattened bounds - Export path-level solid and gradient fills without losing paint fidelity - Cover real provider artwork, SVG round trips, and CanvasKit output Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
parent
d57a920646
commit
1f2f2468cc
|
|
@ -24,6 +24,7 @@
|
|||
- Use Figma-style number-key opacity shortcuts: `1`–`9` set 10%–90%, `0` sets 100%, and two-digit sequences set exact values.
|
||||
- Drag image files directly into the desktop app and paste Figma layers with their remote image fills.
|
||||
- Drop SVG files onto the canvas to import them as editable vector layers alongside raster images. (#392)
|
||||
- Import compatible SVG fills as a single editable multi-color vector. (#394)
|
||||
- Drag with the Text tool to create a fixed-size text box, or click to create auto-width text.
|
||||
- Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents.
|
||||
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
|
||||
|
|
|
|||
|
|
@ -39,18 +39,38 @@ import type { SVGNode } from './node'
|
|||
function vectorShapeElements(
|
||||
node: SceneNode,
|
||||
common: Record<string, string | number | undefined>,
|
||||
strokeAttrs: Record<string, string | number | undefined>
|
||||
strokeAttrs: Record<string, string | number | undefined>,
|
||||
ctx: SVGExportContext,
|
||||
fallbackFills?: Fill[]
|
||||
): SVGNode[] {
|
||||
const elements: SVGNode[] = []
|
||||
if (node.fillGeometry.length > 0) {
|
||||
for (const geo of node.fillGeometry) {
|
||||
const d = geometryBlobToSVGPath(geo.commandsBlob)
|
||||
if (d) {
|
||||
if (!d) continue
|
||||
const attrs = {
|
||||
d,
|
||||
'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined,
|
||||
...common
|
||||
}
|
||||
const pathFills = geo.fills?.length ? geo.fills : fallbackFills
|
||||
if (!pathFills) {
|
||||
elements.push(svg('path', attrs))
|
||||
continue
|
||||
}
|
||||
const visibleFills = pathFills.filter((fill) => fill.visible)
|
||||
if (visibleFills.length === 0) {
|
||||
elements.push(svg('path', { ...attrs, fill: 'none' }))
|
||||
continue
|
||||
}
|
||||
for (const [index, fill] of visibleFills.entries()) {
|
||||
const fillAttr = resolveFill(fill, node, ctx)
|
||||
if (!fillAttr) continue
|
||||
elements.push(
|
||||
svg('path', {
|
||||
d,
|
||||
'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined,
|
||||
...common
|
||||
...attrs,
|
||||
fill: fillAttr,
|
||||
stroke: index === visibleFills.length - 1 ? common.stroke : 'none'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -84,7 +104,8 @@ function vectorShapeElements(
|
|||
function nodeShapeElements(
|
||||
node: SceneNode,
|
||||
fillAttr: string | null,
|
||||
strokeAttrs: Record<string, string | number | undefined>
|
||||
strokeAttrs: Record<string, string | number | undefined>,
|
||||
ctx: SVGExportContext
|
||||
): SVGNode[] {
|
||||
const common: Record<string, string | number | undefined> = {
|
||||
fill: fillAttr ?? 'none',
|
||||
|
|
@ -124,7 +145,7 @@ function nodeShapeElements(
|
|||
return [svg('polygon', { points: makePolygonPoints(node), ...common })]
|
||||
|
||||
case 'VECTOR':
|
||||
return vectorShapeElements(node, common, strokeAttrs)
|
||||
return vectorShapeElements(node, common, strokeAttrs, ctx)
|
||||
|
||||
default: {
|
||||
if (hasRadius(node)) {
|
||||
|
|
@ -311,6 +332,10 @@ function buildSVGStrokeAttrs(
|
|||
return attrs
|
||||
}
|
||||
|
||||
function hasPathLevelFills(node: SceneNode): boolean {
|
||||
return node.type === 'VECTOR' && node.fillGeometry.some((geometry) => geometry.fills?.length)
|
||||
}
|
||||
|
||||
function buildShapeChildren(
|
||||
node: SceneNode,
|
||||
visibleFills: Fill[],
|
||||
|
|
@ -319,6 +344,16 @@ function buildShapeChildren(
|
|||
visibleStrokeCount: number,
|
||||
ctx: SVGExportContext
|
||||
): SVGNode[] {
|
||||
if (hasPathLevelFills(node)) {
|
||||
return vectorShapeElements(
|
||||
node,
|
||||
{ fill: fillAttr ?? 'none', ...strokeAttrs },
|
||||
strokeAttrs,
|
||||
ctx,
|
||||
visibleFills
|
||||
)
|
||||
}
|
||||
|
||||
if (visibleFills.length > 1) {
|
||||
const elements: SVGNode[] = []
|
||||
for (const fill of visibleFills) {
|
||||
|
|
@ -328,7 +363,8 @@ function buildShapeChildren(
|
|||
...nodeShapeElements(
|
||||
node,
|
||||
ref,
|
||||
fill === visibleFills[visibleFills.length - 1] ? strokeAttrs : {}
|
||||
fill === visibleFills[visibleFills.length - 1] ? strokeAttrs : {},
|
||||
ctx
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -338,7 +374,7 @@ function buildShapeChildren(
|
|||
|
||||
const hasFillOrStroke = fillAttr || visibleStrokeCount > 0
|
||||
if (hasFillOrStroke && !isGroupLike(node)) {
|
||||
return nodeShapeElements(node, fillAttr, strokeAttrs)
|
||||
return nodeShapeElements(node, fillAttr, strokeAttrs, ctx)
|
||||
}
|
||||
|
||||
return []
|
||||
|
|
@ -358,7 +394,10 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
|
|||
|
||||
const visibleFills = node.fills.filter((f) => f.visible)
|
||||
const visibleStrokes = node.strokes.filter((s) => s.visible)
|
||||
const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null
|
||||
const fillAttr =
|
||||
visibleFills.length > 0 && !hasPathLevelFills(node)
|
||||
? resolveFill(visibleFills[0], node, ctx)
|
||||
: null
|
||||
const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes, ctx.colorSpace)
|
||||
|
||||
const children: (SVGNode | null)[] = buildShapeChildren(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { Size } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { createVectorFrameChildren } from '#core/vector/vectorize/placement'
|
||||
import { createFlattenedVectorFrameChildren } from '#core/vector/vectorize/placement'
|
||||
import { svgToVectorPaths, type SVGVectorizeResult } from '#core/vector/vectorize/svg/to-vectors'
|
||||
|
||||
import { parseSVGSize } from './metadata'
|
||||
|
|
@ -47,7 +47,7 @@ export function createSVGNodesFromImport(
|
|||
})
|
||||
|
||||
try {
|
||||
createVectorFrameChildren(graph, frame.id, data, {
|
||||
createFlattenedVectorFrameChildren(graph, frame.id, data, {
|
||||
x: frame.x,
|
||||
y: frame.y,
|
||||
width: frame.width,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
|
||||
import type {
|
||||
Fill,
|
||||
GeometryPath,
|
||||
SceneGraph,
|
||||
SceneNode,
|
||||
VectorNetwork
|
||||
} from '@open-pencil/scene-graph'
|
||||
import { mergeVectorNetworks } from '@open-pencil/scene-graph'
|
||||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { computeAccurateBounds } from '#core/vector/curve-math'
|
||||
import { regenerateFillGeometry } from '#core/vector/fill-geometry'
|
||||
|
||||
import type { SVGVectorizeResult } from './svg/to-vectors'
|
||||
import type { SVGVectorizeResult, VectorizedPath } from './svg/to-vectors'
|
||||
|
||||
export interface VectorFramePlacement {
|
||||
x: number
|
||||
|
|
@ -14,6 +22,13 @@ export interface VectorFramePlacement {
|
|||
offsetY: number
|
||||
}
|
||||
|
||||
interface NormalizedVectorGeometry {
|
||||
network: VectorNetwork
|
||||
bounds: Rect
|
||||
}
|
||||
|
||||
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'>
|
||||
|
||||
function shouldTightenToContent(
|
||||
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
|
||||
content: Rect
|
||||
|
|
@ -64,10 +79,7 @@ function offsetVectorNetwork(
|
|||
}
|
||||
|
||||
/** Fit vector geometry to node-local coordinates and a tight width/height (pen-tool pattern). */
|
||||
function normalizeVectorToNodeBounds(network: VectorNetwork): {
|
||||
network: VectorNetwork
|
||||
bounds: Rect
|
||||
} | null {
|
||||
function normalizeVectorToNodeBounds(network: VectorNetwork): NormalizedVectorGeometry | null {
|
||||
if (network.vertices.length === 0) return null
|
||||
const bounds = computeAccurateBounds(network)
|
||||
|
||||
|
|
@ -85,6 +97,104 @@ function normalizeVectorToNodeBounds(network: VectorNetwork): {
|
|||
}
|
||||
}
|
||||
|
||||
function createNormalizedVectorChild(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
normalized: NormalizedVectorGeometry,
|
||||
index: number,
|
||||
paints: VectorChildPaints
|
||||
): void {
|
||||
graph.createNode('VECTOR', frameId, {
|
||||
name: `path ${index + 1}`,
|
||||
x: normalized.bounds.x,
|
||||
y: normalized.bounds.y,
|
||||
width: normalized.bounds.width,
|
||||
height: normalized.bounds.height,
|
||||
vectorNetwork: normalized.network,
|
||||
...paints
|
||||
})
|
||||
}
|
||||
|
||||
function createVectorChild(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
path: VectorizedPath,
|
||||
placement: VectorFramePlacement,
|
||||
index: number
|
||||
): void {
|
||||
const inFrame = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY)
|
||||
const normalized = normalizeVectorToNodeBounds(inFrame)
|
||||
if (!normalized) return
|
||||
createNormalizedVectorChild(graph, frameId, normalized, index, {
|
||||
fillGeometry: [],
|
||||
fills: path.fills,
|
||||
strokes: path.strokes
|
||||
})
|
||||
}
|
||||
|
||||
function remapFillsToBounds(fills: Fill[], source: Rect, target: Rect): Fill[] {
|
||||
return structuredClone(fills).map((fill) => {
|
||||
const transform = fill.gradientTransform
|
||||
if (
|
||||
!transform ||
|
||||
source.width <= 0 ||
|
||||
source.height <= 0 ||
|
||||
target.width <= 0 ||
|
||||
target.height <= 0
|
||||
) {
|
||||
return fill
|
||||
}
|
||||
const scaleX = source.width / target.width
|
||||
const scaleY = source.height / target.height
|
||||
return {
|
||||
...fill,
|
||||
gradientTransform: {
|
||||
m00: transform.m00 * scaleX,
|
||||
m01: transform.m01 * scaleX,
|
||||
m02: (source.x - target.x) / target.width + transform.m02 * scaleX,
|
||||
m10: transform.m10 * scaleY,
|
||||
m11: transform.m11 * scaleY,
|
||||
m12: (source.y - target.y) / target.height + transform.m12 * scaleY
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createFlattenedVectorChild(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
paths: VectorizedPath[],
|
||||
placement: VectorFramePlacement,
|
||||
index: number
|
||||
): void {
|
||||
const prepared = paths.map((path) => {
|
||||
const network = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY)
|
||||
return { path, network, bounds: computeAccurateBounds(network) }
|
||||
})
|
||||
const normalized = normalizeVectorToNodeBounds(
|
||||
mergeVectorNetworks(prepared.map(({ network }) => network))
|
||||
)
|
||||
if (!normalized) return
|
||||
|
||||
const placeholders: GeometryPath[] = prepared.flatMap(({ path, network, bounds }) => {
|
||||
const fills = remapFillsToBounds(path.fills, bounds, normalized.bounds)
|
||||
return network.regions.map((region) => ({
|
||||
windingRule: region.windingRule,
|
||||
commandsBlob: new Uint8Array(0),
|
||||
fills: structuredClone(fills)
|
||||
}))
|
||||
})
|
||||
const fills = prepared[0]
|
||||
? remapFillsToBounds(prepared[0].path.fills, prepared[0].bounds, normalized.bounds)
|
||||
: []
|
||||
|
||||
createNormalizedVectorChild(graph, frameId, normalized, index, {
|
||||
fillGeometry: regenerateFillGeometry(normalized.network, placeholders),
|
||||
fills,
|
||||
strokes: []
|
||||
})
|
||||
}
|
||||
|
||||
export function createVectorFrameChildren(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
|
|
@ -92,19 +202,44 @@ export function createVectorFrameChildren(
|
|||
placement: VectorFramePlacement
|
||||
): void {
|
||||
for (const [index, path] of vectorized.paths.entries()) {
|
||||
const inFrame = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY)
|
||||
const normalized = normalizeVectorToNodeBounds(inFrame)
|
||||
if (!normalized) continue
|
||||
|
||||
graph.createNode('VECTOR', frameId, {
|
||||
name: `path ${index + 1}`,
|
||||
x: normalized.bounds.x,
|
||||
y: normalized.bounds.y,
|
||||
width: normalized.bounds.width,
|
||||
height: normalized.bounds.height,
|
||||
vectorNetwork: normalized.network,
|
||||
fills: path.fills,
|
||||
strokes: path.strokes
|
||||
})
|
||||
createVectorChild(graph, frameId, path, placement, index)
|
||||
}
|
||||
}
|
||||
|
||||
function isFlattenableVectorPath(path: VectorizedPath): boolean {
|
||||
return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0
|
||||
}
|
||||
|
||||
/** Merge adjacent fill-only SVG paths without changing their paint order. */
|
||||
export function createFlattenedVectorFrameChildren(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
vectorized: SVGVectorizeResult,
|
||||
placement: VectorFramePlacement
|
||||
): void {
|
||||
let run: { path: VectorizedPath; index: number }[] = []
|
||||
const flush = () => {
|
||||
if (run.length > 1) {
|
||||
createFlattenedVectorChild(
|
||||
graph,
|
||||
frameId,
|
||||
run.map(({ path }) => path),
|
||||
placement,
|
||||
run[0].index
|
||||
)
|
||||
} else if (run[0]) {
|
||||
createVectorChild(graph, frameId, run[0].path, placement, run[0].index)
|
||||
}
|
||||
run = []
|
||||
}
|
||||
|
||||
for (const [index, path] of vectorized.paths.entries()) {
|
||||
if (isFlattenableVectorPath(path)) {
|
||||
run.push({ path, index })
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
createVectorChild(graph, frameId, path, placement, index)
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import type {
|
|||
|
||||
export {
|
||||
cloneVectorNetwork,
|
||||
mergeVectorNetworks,
|
||||
normalizeVectorNetwork,
|
||||
transformVectorNetwork,
|
||||
validateVectorNetwork,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,41 @@ import type { Mat3 } from './matrix'
|
|||
import type { Vector } from './primitives'
|
||||
import type { VectorNetwork, VectorSegment } from './types'
|
||||
|
||||
/** Concatenate vector networks while preserving independent geometry. */
|
||||
export function mergeVectorNetworks(networks: readonly VectorNetwork[]): VectorNetwork {
|
||||
const vertices: VectorNetwork['vertices'] = []
|
||||
const segments: VectorNetwork['segments'] = []
|
||||
const regions: VectorNetwork['regions'] = []
|
||||
let vertexOffset = 0
|
||||
let segmentOffset = 0
|
||||
|
||||
for (const network of networks) {
|
||||
for (const vertex of network.vertices) vertices.push({ ...vertex })
|
||||
for (const segment of network.segments) {
|
||||
segments.push({
|
||||
...segment,
|
||||
start: segment.start + vertexOffset,
|
||||
end: segment.end + vertexOffset,
|
||||
tangentStart: { ...segment.tangentStart },
|
||||
tangentEnd: { ...segment.tangentEnd }
|
||||
})
|
||||
}
|
||||
for (const region of network.regions) {
|
||||
const loops: number[][] = []
|
||||
for (const loop of region.loops) {
|
||||
const shifted: number[] = []
|
||||
for (const segmentIndex of loop) shifted.push(segmentIndex + segmentOffset)
|
||||
loops.push(shifted)
|
||||
}
|
||||
regions.push({ windingRule: region.windingRule, loops })
|
||||
}
|
||||
vertexOffset += network.vertices.length
|
||||
segmentOffset += network.segments.length
|
||||
}
|
||||
|
||||
return { vertices, segments, regions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a VectorNetwork through an affine matrix: vertices as points,
|
||||
* tangents as direction vectors (linear part only, no translation).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ async function dispatchMixedFileDrop() {
|
|||
const bounds = canvas.getBoundingClientRect()
|
||||
const svg = new File(
|
||||
[
|
||||
'<svg width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#f00"/></svg>'
|
||||
'<svg width="40" height="20" viewBox="0 0 40 20"><rect width="20" height="20" fill="#f00"/><rect x="20" width="20" height="20" fill="#0c8"/></svg>'
|
||||
],
|
||||
'mark.svg'
|
||||
)
|
||||
|
|
@ -72,6 +72,8 @@ test('mixed SVG and raster drops share placement, selection, and undo', async ()
|
|||
selected: 2,
|
||||
undoLabel: 'Place files'
|
||||
})
|
||||
await editor.canvas.waitForRender()
|
||||
expect(await editor.canvas.canvas.screenshot()).toMatchSnapshot('dropped-svg-multicolor.png')
|
||||
|
||||
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
|
||||
await expect
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { FigmaAPI, SceneGraph } from '@open-pencil/core'
|
||||
import { FigmaAPI, renderNodesToSVG, SceneGraph } from '@open-pencil/core'
|
||||
import { importSvg } from '@open-pencil/core/tools'
|
||||
|
||||
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
||||
|
|
@ -35,18 +37,82 @@ describe('import_svg', () => {
|
|||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('imports multiple shapes', async () => {
|
||||
test('flattens compatible filled shapes into one vector', async () => {
|
||||
const result = (await importSvg.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="30" height="30"/>
|
||||
<circle cx="70" cy="30" r="20"/>
|
||||
<path d="M10 70 L90 70"/>
|
||||
<rect x="0" y="0" width="50" height="50" fill="#ff0000"/>
|
||||
<rect x="50" y="0" width="50" height="50" fill="#00ff00"/>
|
||||
<rect x="0" y="50" width="100" height="50" fill="#0000ff"/>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const children = graph.getChildren(result.id)
|
||||
expect(children.length).toBe(3)
|
||||
expect(children.every((c) => c.type === 'VECTOR')).toBe(true)
|
||||
expect(children).toHaveLength(1)
|
||||
const vector = expectDefined(children[0])
|
||||
expect(vector.type).toBe('VECTOR')
|
||||
expect(expectDefined(vector.vectorNetwork).regions).toHaveLength(3)
|
||||
expect(vector.fillGeometry).toHaveLength(3)
|
||||
expect(expectDefined(vector.fillGeometry[0]?.fills?.[0]).color.r).toBeCloseTo(1)
|
||||
expect(expectDefined(vector.fillGeometry[1]?.fills?.[0]).color.g).toBeCloseTo(1)
|
||||
expect(expectDefined(vector.fillGeometry[2]?.fills?.[0]).color.b).toBeCloseTo(1)
|
||||
expect(vector.fillGeometry.every(({ commandsBlob }) => commandsBlob.length > 0)).toBe(true)
|
||||
|
||||
const page = expectDefined(graph.getPages()[0])
|
||||
const exported = expectDefined(renderNodesToSVG(graph, page.id, [result.id]))
|
||||
expect(exported.match(/fill="#FF0000"/g)).toHaveLength(1)
|
||||
expect(exported.match(/fill="#00FF00"/g)).toHaveLength(1)
|
||||
expect(exported.match(/fill="#0000FF"/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('flattens a real multi-path provider SVG', async () => {
|
||||
const svg = readFileSync(
|
||||
join(process.cwd(), 'tests/fixtures/vectorize/euro_shield.recraft.svg'),
|
||||
'utf8'
|
||||
)
|
||||
const result = (await importSvg.execute(figma, { svg })) as { id: string }
|
||||
|
||||
const vector = expectDefined(graph.getChildren(result.id)[0])
|
||||
const network = expectDefined(vector.vectorNetwork)
|
||||
expect(graph.getChildren(result.id)).toHaveLength(1)
|
||||
expect(network.regions.length).toBeGreaterThan(10)
|
||||
expect(vector.fillGeometry).toHaveLength(network.regions.length)
|
||||
})
|
||||
|
||||
test('preserves paint order around stroked paths', async () => {
|
||||
const result = (await importSvg.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 100">
|
||||
<rect x="0" y="0" width="20" height="20" fill="#ff0000"/>
|
||||
<rect x="20" y="0" width="20" height="20" fill="#ff8800"/>
|
||||
<path d="M0 30H100" fill="none" stroke="#000000"/>
|
||||
<rect x="0" y="40" width="20" height="20" fill="#0000ff"/>
|
||||
<rect x="20" y="40" width="20" height="20" fill="#00ff00"/>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const children = graph.getChildren(result.id)
|
||||
expect(children).toHaveLength(3)
|
||||
expect(expectDefined(children[0]).fillGeometry).toHaveLength(2)
|
||||
expect(expectDefined(children[1]).strokes).toHaveLength(1)
|
||||
expect(expectDefined(children[2]).fillGeometry).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('remaps path gradients into flattened vector bounds', async () => {
|
||||
const result = (await importSvg.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 50"><defs><linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="50" height="50" fill="url(#g)"/><rect x="50" width="50" height="50" fill="url(#g)"/></svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const vector = expectDefined(graph.getChildren(result.id)[0])
|
||||
const left = expectDefined(vector.fillGeometry[0]?.fills?.[0]?.gradientTransform)
|
||||
const right = expectDefined(vector.fillGeometry[1]?.fills?.[0]?.gradientTransform)
|
||||
expect(left.m00).toBeCloseTo(0.5)
|
||||
expect(left.m02).toBeCloseTo(0)
|
||||
expect(right.m00).toBeCloseTo(0.5)
|
||||
expect(right.m02).toBeCloseTo(0.5)
|
||||
|
||||
const page = expectDefined(graph.getPages()[0])
|
||||
const exported = expectDefined(renderNodesToSVG(graph, page.id, [result.id]))
|
||||
expect(exported.match(/<linearGradient/g)).toHaveLength(2)
|
||||
expect(exported.match(/fill="url\(#/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('respects viewBox dimensions', async () => {
|
||||
|
|
@ -146,7 +212,8 @@ describe('import_svg', () => {
|
|||
})) as { id: string }
|
||||
|
||||
const children = graph.getChildren(result.id)
|
||||
expect(children.length).toBe(2)
|
||||
expect(children).toHaveLength(2)
|
||||
expect(children.every(({ vectorNetwork }) => vectorNetwork !== null)).toBe(true)
|
||||
})
|
||||
|
||||
test('applies nested transforms through the XML tree', async () => {
|
||||
|
|
|
|||
45
tests/engine/scene-graph/vector-network.test.ts
Normal file
45
tests/engine/scene-graph/vector-network.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { mergeVectorNetworks, type VectorNetwork } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
function lineNetwork(x: number): VectorNetwork {
|
||||
return {
|
||||
vertices: [
|
||||
{ x, y: 0 },
|
||||
{ x: x + 1, y: 1 }
|
||||
],
|
||||
segments: [
|
||||
{
|
||||
start: 0,
|
||||
end: 1,
|
||||
tangentStart: { x: 0, y: 0 },
|
||||
tangentEnd: { x: 0, y: 0 }
|
||||
}
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0]] }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergeVectorNetworks', () => {
|
||||
test('offsets indices and returns independent geometry', () => {
|
||||
const first = lineNetwork(0)
|
||||
const second = lineNetwork(10)
|
||||
const merged = mergeVectorNetworks([first, second])
|
||||
|
||||
expect(merged.vertices).toHaveLength(4)
|
||||
expect(merged.segments.map(({ start, end }) => [start, end])).toEqual([
|
||||
[0, 1],
|
||||
[2, 3]
|
||||
])
|
||||
expect(merged.regions.map(({ loops }) => loops)).toEqual([[[0]], [[1]]])
|
||||
|
||||
expectDefined(merged.vertices[0]).x = 50
|
||||
expectDefined(merged.segments[0]).tangentStart.x = 50
|
||||
expectDefined(expectDefined(merged.regions[0]).loops[0])[0] = 50
|
||||
expect(expectDefined(first.vertices[0]).x).toBe(0)
|
||||
expect(expectDefined(first.segments[0]).tangentStart.x).toBe(0)
|
||||
expect(expectDefined(expectDefined(first.regions[0]).loops[0])[0]).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue