fix(figma-api): match Figma vector editing (#444)
- Support vectorPaths, vectorNetwork, and async network assignment - Normalize geometry bounds and preserve winding and region paint semantics - Validate network topology and expose handle mirroring only on vectors Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
parent
db15d8d583
commit
2949bd648f
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444)
|
||||
- Let AI and MCP tools create arbitrary vectors from SVG path data without leaving blank layers after invalid input. (#440)
|
||||
- Show stroke colors and weights in AI visual descriptions. (#447)
|
||||
- Stop warning AI agents that supported inline SVG attributes were ignored. (#445)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,317 @@
|
|||
import { raw, type NodeProxyInternals, type ProxyThis } from '#core/figma-api/accessor-utils'
|
||||
import {
|
||||
mergeVectorNetworks,
|
||||
normalizeVectorNetwork,
|
||||
transformVectorNetwork,
|
||||
validateVectorNetwork
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type {
|
||||
Fill,
|
||||
GeometryPath,
|
||||
HandleMirroring,
|
||||
VectorNetwork as SceneVectorNetwork,
|
||||
VectorSegment,
|
||||
VectorVertex,
|
||||
WindingRule
|
||||
} from '@open-pencil/scene-graph'
|
||||
import { copyFills } from '@open-pencil/scene-graph/copy'
|
||||
import { parsePluginVectorPath } from '@open-pencil/scene-graph/parse-path'
|
||||
|
||||
import {
|
||||
raw,
|
||||
updateNode,
|
||||
type NodeProxyInternals,
|
||||
type ProxyThis
|
||||
} from '#core/figma-api/accessor-utils'
|
||||
import { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from '#core/io/formats/svg/paths'
|
||||
import { computeAccurateBounds } from '#core/vector/curve-math'
|
||||
import { regenerateFillGeometry } from '#core/vector/fill-geometry'
|
||||
|
||||
export interface FigmaVectorPath {
|
||||
readonly windingRule: 'NONZERO' | 'EVENODD'
|
||||
readonly windingRule: WindingRule | 'NONE'
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export function installVectorNodeProxyAccessors(
|
||||
prototype: object,
|
||||
internals: NodeProxyInternals
|
||||
interface FigmaVectorRegion {
|
||||
readonly windingRule: WindingRule
|
||||
readonly loops: ReadonlyArray<ReadonlyArray<number>>
|
||||
readonly fills?: readonly Fill[]
|
||||
readonly fillStyleId?: string
|
||||
}
|
||||
|
||||
export interface FigmaVectorNetwork {
|
||||
readonly vertices: readonly VectorVertex[]
|
||||
readonly segments: readonly (Omit<VectorSegment, 'tangentStart' | 'tangentEnd'> & {
|
||||
readonly tangentStart?: VectorSegment['tangentStart']
|
||||
readonly tangentEnd?: VectorSegment['tangentEnd']
|
||||
})[]
|
||||
readonly regions?: readonly FigmaVectorRegion[]
|
||||
}
|
||||
|
||||
const EMPTY_NETWORK: SceneVectorNetwork = { vertices: [], segments: [], regions: [] }
|
||||
const HANDLE_MIRRORING_VALUES = new Set<HandleMirroring>(['NONE', 'ANGLE', 'ANGLE_AND_LENGTH'])
|
||||
|
||||
function networkFromVectorPaths(paths: readonly FigmaVectorPath[]): SceneVectorNetwork {
|
||||
const networks: SceneVectorNetwork[] = []
|
||||
for (let index = 0; index < paths.length; index++) {
|
||||
const path: unknown = paths[index]
|
||||
if (typeof path !== 'object' || path === null || !('data' in path)) {
|
||||
throw new TypeError(`vectorPaths[${index}].data must be a string`)
|
||||
}
|
||||
const data = path.data
|
||||
if (typeof data !== 'string') {
|
||||
throw new TypeError(`vectorPaths[${index}].data must be a string`)
|
||||
}
|
||||
const windingRule: unknown = 'windingRule' in path ? path.windingRule : undefined
|
||||
if (windingRule !== 'NONZERO' && windingRule !== 'EVENODD' && windingRule !== 'NONE') {
|
||||
throw new TypeError(`vectorPaths[${index}].windingRule is invalid`)
|
||||
}
|
||||
const parsed = parsePluginVectorPath(data, windingRule)
|
||||
if (!parsed.ok) throw new TypeError(`Invalid vector path: ${parsed.error}`)
|
||||
if (parsed.network.segments.length > 0) networks.push(parsed.network)
|
||||
}
|
||||
return networks.length > 0 ? mergeVectorNetworks(networks) : structuredClone(EMPTY_NETWORK)
|
||||
}
|
||||
|
||||
function normalizeInputNetwork(value: FigmaVectorNetwork): SceneVectorNetwork {
|
||||
const regions = value.regions ?? []
|
||||
return normalizeVectorNetwork({
|
||||
vertices: value.vertices.map((vertex) => ({ ...vertex })),
|
||||
segments: value.segments.map((segment) => ({
|
||||
...segment,
|
||||
tangentStart: {
|
||||
x: segment.tangentStart?.x ?? 0,
|
||||
y: segment.tangentStart?.y ?? 0
|
||||
},
|
||||
tangentEnd: {
|
||||
x: segment.tangentEnd?.x ?? 0,
|
||||
y: segment.tangentEnd?.y ?? 0
|
||||
}
|
||||
})),
|
||||
regions: regions.map((region) => ({
|
||||
windingRule: region.windingRule,
|
||||
loops: region.loops.map((loop) => [...loop])
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function validateRegionPaintMetadata(regions: readonly FigmaVectorRegion[]): void {
|
||||
for (let index = 0; index < regions.length; index++) {
|
||||
const region = regions[index]
|
||||
if (region.fills !== undefined && !Array.isArray(region.fills)) {
|
||||
throw new TypeError(`vectorNetwork.regions[${index}].fills must be an array`)
|
||||
}
|
||||
if (region.fillStyleId !== undefined && typeof region.fillStyleId !== 'string') {
|
||||
throw new TypeError(`vectorNetwork.regions[${index}].fillStyleId must be a string`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function geometryForRegions(regions: readonly FigmaVectorRegion[]): GeometryPath[] {
|
||||
if (!regions.some((region) => region.fills !== undefined || region.fillStyleId !== undefined)) {
|
||||
return []
|
||||
}
|
||||
return regions.map((region) => ({
|
||||
windingRule: region.windingRule,
|
||||
commandsBlob: new Uint8Array(),
|
||||
fills: region.fills ? copyFills([...region.fills]) : [],
|
||||
fillStyleId: region.fillStyleId ?? ''
|
||||
}))
|
||||
}
|
||||
|
||||
function setGeometry(
|
||||
target: ProxyThis,
|
||||
internals: NodeProxyInternals,
|
||||
network: SceneVectorNetwork,
|
||||
fillGeometry: GeometryPath[]
|
||||
): void {
|
||||
Object.defineProperty(prototype, 'vectorPaths', {
|
||||
get(this: ProxyThis): readonly FigmaVectorPath[] {
|
||||
const node = raw(this, internals)
|
||||
const paths =
|
||||
node.fillGeometry.length > 0
|
||||
? node.fillGeometry.map((geometry) => ({
|
||||
windingRule: geometry.windingRule,
|
||||
data: geometryBlobToSVGPath(geometry.commandsBlob)
|
||||
}))
|
||||
: (node.vectorNetwork ? vectorNetworkToSVGPaths(node.vectorNetwork) : []).map((data) => ({
|
||||
windingRule: 'NONZERO' as const,
|
||||
data
|
||||
}))
|
||||
return Object.freeze(paths.map((path) => Object.freeze(path)))
|
||||
const node = raw(target, internals)
|
||||
const bounds = computeAccurateBounds(network)
|
||||
const hasGeometry = network.vertices.length > 0
|
||||
const normalized = hasGeometry
|
||||
? transformVectorNetwork([1, 0, -bounds.x, 0, 1, -bounds.y, 0, 0, 1], network)
|
||||
: structuredClone(EMPTY_NETWORK)
|
||||
const normalizedGeometry =
|
||||
fillGeometry.length > 0 ? regenerateFillGeometry(normalized, fillGeometry) : []
|
||||
|
||||
updateNode(target, internals, {
|
||||
x: hasGeometry ? node.x + bounds.x : node.x,
|
||||
y: hasGeometry ? node.y + bounds.y : node.y,
|
||||
width: hasGeometry ? bounds.width : 0,
|
||||
height: hasGeometry ? bounds.height : 0,
|
||||
vectorNetwork: normalized,
|
||||
fillGeometry: normalizedGeometry,
|
||||
strokeGeometry: []
|
||||
})
|
||||
}
|
||||
|
||||
function assignVectorNetwork(
|
||||
target: ProxyThis,
|
||||
internals: NodeProxyInternals,
|
||||
value: FigmaVectorNetwork
|
||||
): void {
|
||||
const errors = validateVectorNetwork(value)
|
||||
if (errors.length > 0) throw new TypeError(`Invalid vectorNetwork: ${errors.join('; ')}`)
|
||||
validateRegionPaintMetadata(value.regions ?? [])
|
||||
const network = normalizeInputNetwork(value)
|
||||
setGeometry(target, internals, network, geometryForRegions(value.regions ?? []))
|
||||
}
|
||||
|
||||
function vectorPathsForNetwork(network: SceneVectorNetwork): FigmaVectorPath[] {
|
||||
if (network.segments.length === 0) return []
|
||||
const regions = (network as Partial<SceneVectorNetwork>).regions ?? []
|
||||
if (regions.length === 0) {
|
||||
return vectorNetworkToSVGPaths({ ...network, regions }, null).map((data) => ({
|
||||
windingRule: 'NONE',
|
||||
data
|
||||
}))
|
||||
}
|
||||
|
||||
const paths: FigmaVectorPath[] = []
|
||||
const usedSegments = new Set<number>()
|
||||
for (const region of regions) {
|
||||
for (const loop of region.loops) for (const segmentIndex of loop) usedSegments.add(segmentIndex)
|
||||
const data = vectorNetworkToSVGPaths({ ...network, regions: [region] }, null)[0]
|
||||
if (data) paths.push({ windingRule: region.windingRule, data })
|
||||
}
|
||||
|
||||
const remainingSegments = network.segments.filter((_, index) => !usedSegments.has(index))
|
||||
if (remainingSegments.length > 0) {
|
||||
const data = vectorNetworkToSVGPaths(
|
||||
{
|
||||
vertices: network.vertices,
|
||||
segments: remainingSegments,
|
||||
regions: []
|
||||
},
|
||||
null
|
||||
)[0]
|
||||
if (data) paths.push({ windingRule: 'NONE', data })
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function readVectorPaths(
|
||||
target: ProxyThis,
|
||||
internals: NodeProxyInternals
|
||||
): readonly FigmaVectorPath[] {
|
||||
const node = raw(target, internals)
|
||||
const paths =
|
||||
node.vectorNetwork && node.vectorNetwork.segments.length > 0
|
||||
? vectorPathsForNetwork(node.vectorNetwork)
|
||||
: node.fillGeometry.map((geometry) => ({
|
||||
windingRule: geometry.windingRule,
|
||||
data: geometryBlobToSVGPath(geometry.commandsBlob, null)
|
||||
}))
|
||||
return Object.freeze(paths.map((path) => Object.freeze(path)))
|
||||
}
|
||||
|
||||
function readVectorNetwork(target: ProxyThis, internals: NodeProxyInternals): FigmaVectorNetwork {
|
||||
const node = raw(target, internals)
|
||||
const network = node.vectorNetwork ?? EMPTY_NETWORK
|
||||
const vertices = network.vertices.map((vertex) =>
|
||||
Object.freeze({
|
||||
...vertex,
|
||||
strokeCap: vertex.strokeCap ?? node.strokeCap,
|
||||
strokeJoin: vertex.strokeJoin ?? node.strokeJoin,
|
||||
cornerRadius: vertex.cornerRadius ?? 0,
|
||||
handleMirroring: vertex.handleMirroring ?? 'NONE'
|
||||
})
|
||||
)
|
||||
const segments = network.segments.map((segment) =>
|
||||
Object.freeze({
|
||||
...segment,
|
||||
tangentStart: Object.freeze({ ...segment.tangentStart }),
|
||||
tangentEnd: Object.freeze({ ...segment.tangentEnd })
|
||||
})
|
||||
)
|
||||
const sourceRegions = (network as Partial<SceneVectorNetwork>).regions ?? []
|
||||
const regions = sourceRegions.map((region, index) => {
|
||||
const geometry = node.fillGeometry.at(index)
|
||||
return Object.freeze({
|
||||
windingRule: region.windingRule,
|
||||
loops: Object.freeze(region.loops.map((loop) => Object.freeze([...loop]))),
|
||||
fillStyleId: geometry?.fillStyleId ?? '',
|
||||
fills: Object.freeze(copyFills(geometry?.fills ?? []))
|
||||
})
|
||||
})
|
||||
return Object.freeze({
|
||||
vertices: Object.freeze(vertices),
|
||||
segments: Object.freeze(segments),
|
||||
regions: Object.freeze(regions)
|
||||
})
|
||||
}
|
||||
|
||||
function readHandleMirroring(
|
||||
target: ProxyThis,
|
||||
internals: NodeProxyInternals,
|
||||
mixed: symbol
|
||||
): HandleMirroring | symbol {
|
||||
const node = raw(target, internals)
|
||||
const values = new Set(
|
||||
(node.vectorNetwork?.vertices ?? []).map((vertex) => vertex.handleMirroring ?? 'NONE')
|
||||
)
|
||||
if (values.size === 0) return node.handleMirroring
|
||||
if (values.size > 1) return mixed
|
||||
return values.values().next().value ?? node.handleMirroring
|
||||
}
|
||||
|
||||
export function installVectorNodeProxyAccessors(
|
||||
target: object,
|
||||
internals: NodeProxyInternals,
|
||||
mixed: symbol
|
||||
): void {
|
||||
Object.defineProperties(target, {
|
||||
vectorPaths: {
|
||||
get(this: ProxyThis): readonly FigmaVectorPath[] {
|
||||
return readVectorPaths(this, internals)
|
||||
},
|
||||
set(this: ProxyThis, value: readonly FigmaVectorPath[]) {
|
||||
if (!Array.isArray(value)) throw new TypeError('vectorPaths must be an array')
|
||||
setGeometry(this, internals, networkFromVectorPaths(value), [])
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
vectorNetwork: {
|
||||
get(this: ProxyThis): FigmaVectorNetwork {
|
||||
return readVectorNetwork(this, internals)
|
||||
},
|
||||
set(this: ProxyThis, value: FigmaVectorNetwork) {
|
||||
assignVectorNetwork(this, internals, value)
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
setVectorNetworkAsync: {
|
||||
value(this: ProxyThis, value: FigmaVectorNetwork): Promise<void> {
|
||||
return Promise.resolve().then(() => assignVectorNetwork(this, internals, value))
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
handleMirroring: {
|
||||
get(this: ProxyThis): HandleMirroring | symbol {
|
||||
return readHandleMirroring(this, internals, mixed)
|
||||
},
|
||||
set(this: ProxyThis, value: HandleMirroring) {
|
||||
if (!HANDLE_MIRRORING_VALUES.has(value)) {
|
||||
throw new TypeError(`Invalid handleMirroring: ${String(value)}`)
|
||||
}
|
||||
const node = raw(this, internals)
|
||||
updateNode(this, internals, {
|
||||
handleMirroring: value,
|
||||
vectorNetwork: node.vectorNetwork
|
||||
? {
|
||||
...node.vectorNetwork,
|
||||
vertices: node.vectorNetwork.vertices.map((vertex) => ({
|
||||
...vertex,
|
||||
handleMirroring: value
|
||||
}))
|
||||
}
|
||||
: null
|
||||
})
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import type { OkHCLColor, OkHCLPayload } from '#core/color/okhcl'
|
|||
import { installBasicNodeProxyAccessors } from './accessors/basic'
|
||||
import { installLayoutNodeProxyAccessors } from './accessors/layout'
|
||||
import { installVariableModeNodeProxyAccessors } from './accessors/variables'
|
||||
import { installVectorNodeProxyAccessors, type FigmaVectorPath } from './accessors/vector'
|
||||
import {
|
||||
installVectorNodeProxyAccessors,
|
||||
type FigmaVectorNetwork,
|
||||
type FigmaVectorPath
|
||||
} from './accessors/vector'
|
||||
import { installVisualNodeProxyAccessors } from './accessors/visual'
|
||||
import type { FigmaFontName } from './fonts'
|
||||
import * as PluginData from './plugin-data'
|
||||
|
|
@ -105,7 +109,10 @@ export class FigmaNodeProxy {
|
|||
declare maxWidth: number | null
|
||||
declare minHeight: number | null
|
||||
declare maxHeight: number | null
|
||||
declare readonly vectorPaths: readonly FigmaVectorPath[]
|
||||
declare vectorPaths: readonly FigmaVectorPath[]
|
||||
declare vectorNetwork: FigmaVectorNetwork
|
||||
declare setVectorNetworkAsync: (vectorNetwork: FigmaVectorNetwork) => Promise<void>
|
||||
declare handleMirroring: SceneNode['handleMirroring'] | typeof MIXED
|
||||
declare readonly explicitVariableModes: Readonly<Record<string, string>>
|
||||
declare readonly resolvedVariableModes: Readonly<Record<string, string>>
|
||||
|
||||
|
|
@ -113,6 +120,13 @@ export class FigmaNodeProxy {
|
|||
this[INTERNAL_ID] = id
|
||||
this[INTERNAL_GRAPH] = graph
|
||||
this[INTERNAL_API] = api
|
||||
if (graph.getNode(id)?.type === 'VECTOR') {
|
||||
installVectorNodeProxyAccessors(
|
||||
this,
|
||||
{ id: INTERNAL_ID, graph: INTERNAL_GRAPH, api: INTERNAL_API },
|
||||
MIXED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _raw(): SceneNode {
|
||||
|
|
@ -559,4 +573,3 @@ const proxyInternals = {
|
|||
|
||||
installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
||||
installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
||||
installVectorNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ export function round(n: number, decimals = 2): number {
|
|||
return Math.round(n * factor) / factor
|
||||
}
|
||||
|
||||
export function geometryBlobToSVGPath(blob: Uint8Array): string {
|
||||
function coordinate(value: number, decimals: number | null): number {
|
||||
return decimals === null ? value : round(value, decimals)
|
||||
}
|
||||
|
||||
export function geometryBlobToSVGPath(blob: Uint8Array, decimals: number | null = 2): string {
|
||||
if (blob.length === 0) return ''
|
||||
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
|
||||
let o = 0
|
||||
|
|
@ -32,35 +36,35 @@ export function geometryBlobToSVGPath(blob: Uint8Array): string {
|
|||
parts.push('Z')
|
||||
break
|
||||
case CMD_MOVE_TO: {
|
||||
const x = round(dv.getFloat32(o, true))
|
||||
const y = round(dv.getFloat32(o + 4, true))
|
||||
const x = coordinate(dv.getFloat32(o, true), decimals)
|
||||
const y = coordinate(dv.getFloat32(o + 4, true), decimals)
|
||||
o += 8
|
||||
parts.push(`M${x} ${y}`)
|
||||
break
|
||||
}
|
||||
case CMD_LINE_TO: {
|
||||
const x = round(dv.getFloat32(o, true))
|
||||
const y = round(dv.getFloat32(o + 4, true))
|
||||
const x = coordinate(dv.getFloat32(o, true), decimals)
|
||||
const y = coordinate(dv.getFloat32(o + 4, true), decimals)
|
||||
o += 8
|
||||
parts.push(`L${x} ${y}`)
|
||||
break
|
||||
}
|
||||
case CMD_QUAD_TO: {
|
||||
const x1 = round(dv.getFloat32(o, true))
|
||||
const y1 = round(dv.getFloat32(o + 4, true))
|
||||
const x = round(dv.getFloat32(o + 8, true))
|
||||
const y = round(dv.getFloat32(o + 12, true))
|
||||
const x1 = coordinate(dv.getFloat32(o, true), decimals)
|
||||
const y1 = coordinate(dv.getFloat32(o + 4, true), decimals)
|
||||
const x = coordinate(dv.getFloat32(o + 8, true), decimals)
|
||||
const y = coordinate(dv.getFloat32(o + 12, true), decimals)
|
||||
o += 16
|
||||
parts.push(`Q${x1} ${y1} ${x} ${y}`)
|
||||
break
|
||||
}
|
||||
case CMD_CUBIC_TO: {
|
||||
const x1 = round(dv.getFloat32(o, true))
|
||||
const y1 = round(dv.getFloat32(o + 4, true))
|
||||
const x2 = round(dv.getFloat32(o + 8, true))
|
||||
const y2 = round(dv.getFloat32(o + 12, true))
|
||||
const x = round(dv.getFloat32(o + 16, true))
|
||||
const y = round(dv.getFloat32(o + 20, true))
|
||||
const x1 = coordinate(dv.getFloat32(o, true), decimals)
|
||||
const y1 = coordinate(dv.getFloat32(o + 4, true), decimals)
|
||||
const x2 = coordinate(dv.getFloat32(o + 8, true), decimals)
|
||||
const y2 = coordinate(dv.getFloat32(o + 12, true), decimals)
|
||||
const x = coordinate(dv.getFloat32(o + 16, true), decimals)
|
||||
const y = coordinate(dv.getFloat32(o + 20, true), decimals)
|
||||
o += 24
|
||||
parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`)
|
||||
break
|
||||
|
|
@ -73,7 +77,12 @@ export function geometryBlobToSVGPath(blob: Uint8Array): string {
|
|||
return parts.join('')
|
||||
}
|
||||
|
||||
function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string {
|
||||
function segmentToSVG(
|
||||
seg: VectorSegment,
|
||||
vertices: VectorVertex[],
|
||||
forward: boolean,
|
||||
decimals: number | null
|
||||
): string {
|
||||
const start = forward ? vertices[seg.start] : vertices[seg.end]
|
||||
const end = forward ? vertices[seg.end] : vertices[seg.start]
|
||||
const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y }
|
||||
|
|
@ -86,42 +95,135 @@ function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boo
|
|||
Math.abs(te.y) < 0.001
|
||||
|
||||
if (isStraight) {
|
||||
return `L${round(end.x)} ${round(end.y)}`
|
||||
return `L${coordinate(end.x, decimals)} ${coordinate(end.y, decimals)}`
|
||||
}
|
||||
|
||||
const cp1x = round(start.x + ts.x)
|
||||
const cp1y = round(start.y + ts.y)
|
||||
const cp2x = round(end.x + te.x)
|
||||
const cp2y = round(end.y + te.y)
|
||||
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}`
|
||||
const cp1x = coordinate(start.x + ts.x, decimals)
|
||||
const cp1y = coordinate(start.y + ts.y, decimals)
|
||||
const cp2x = coordinate(end.x + te.x, decimals)
|
||||
const cp2y = coordinate(end.y + te.y, decimals)
|
||||
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${coordinate(end.x, decimals)} ${coordinate(end.y, decimals)}`
|
||||
}
|
||||
|
||||
export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] {
|
||||
function traceOrderedSegments(
|
||||
segmentIndices: readonly number[],
|
||||
segments: VectorSegment[],
|
||||
vertices: VectorVertex[],
|
||||
decimals: number | null
|
||||
): string {
|
||||
if (segmentIndices.length === 0) return ''
|
||||
const first = segments[segmentIndices[0]]
|
||||
const second = segmentIndices.length > 1 ? segments[segmentIndices[1]] : null
|
||||
// Start at the first segment endpoint that does not connect to the second segment.
|
||||
const secondConnectsStart = second?.start === first.start || second?.end === first.start
|
||||
const secondConnectsEnd = second?.start === first.end || second?.end === first.end
|
||||
const firstForward = !second || secondConnectsEnd || !secondConnectsStart
|
||||
const startIndex = firstForward ? first.start : first.end
|
||||
let currentIndex = startIndex
|
||||
const parts = [
|
||||
`M${coordinate(vertices[startIndex].x, decimals)} ${coordinate(vertices[startIndex].y, decimals)}`
|
||||
]
|
||||
|
||||
for (const segmentIndex of segmentIndices) {
|
||||
const segment = segments[segmentIndex]
|
||||
const isConnected = segment.start === currentIndex || segment.end === currentIndex
|
||||
const forward = segment.start === currentIndex || !isConnected
|
||||
if (!isConnected) {
|
||||
const segmentStart = forward ? segment.start : segment.end
|
||||
parts.push(
|
||||
`M${coordinate(vertices[segmentStart].x, decimals)} ${coordinate(vertices[segmentStart].y, decimals)}`
|
||||
)
|
||||
currentIndex = segmentStart
|
||||
}
|
||||
parts.push(segmentToSVG(segment, vertices, forward, decimals))
|
||||
currentIndex = forward ? segment.end : segment.start
|
||||
}
|
||||
if (parts.length === segmentIndices.length + 1 && currentIndex === startIndex) parts.push('Z')
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
type SegmentAdjacency = ReadonlyMap<number, readonly number[]>
|
||||
|
||||
function buildSegmentAdjacency(segments: VectorSegment[]): SegmentAdjacency {
|
||||
const adjacency = new Map<number, number[]>()
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
for (const vertexIndex of [segment.start, segment.end]) {
|
||||
const incidentSegments = adjacency.get(vertexIndex)
|
||||
if (incidentSegments) incidentSegments.push(index)
|
||||
else adjacency.set(vertexIndex, [index])
|
||||
}
|
||||
}
|
||||
return adjacency
|
||||
}
|
||||
|
||||
function findConnectedSegment(
|
||||
remaining: ReadonlySet<number>,
|
||||
adjacency: SegmentAdjacency,
|
||||
vertexIndex: number
|
||||
): number | undefined {
|
||||
return adjacency.get(vertexIndex)?.find((index) => remaining.has(index))
|
||||
}
|
||||
|
||||
function extendSegmentChain(
|
||||
chain: number[],
|
||||
startVertex: number,
|
||||
remaining: Set<number>,
|
||||
segments: VectorSegment[],
|
||||
adjacency: SegmentAdjacency
|
||||
): void {
|
||||
let currentIndex = startVertex
|
||||
let nextIndex = findConnectedSegment(remaining, adjacency, currentIndex)
|
||||
while (nextIndex !== undefined) {
|
||||
chain.push(nextIndex)
|
||||
remaining.delete(nextIndex)
|
||||
const segment = segments[nextIndex]
|
||||
currentIndex = segment.start === currentIndex ? segment.end : segment.start
|
||||
nextIndex = findConnectedSegment(remaining, adjacency, currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
function unfilledSegmentsToPath(network: VectorNetwork, decimals: number | null): string {
|
||||
const { vertices, segments } = network
|
||||
const remaining = new Set(segments.map((_, index) => index))
|
||||
const adjacency = buildSegmentAdjacency(segments)
|
||||
const parts: string[] = []
|
||||
|
||||
while (remaining.size > 0) {
|
||||
const firstIndex = remaining.values().next().value
|
||||
if (firstIndex === undefined) break
|
||||
remaining.delete(firstIndex)
|
||||
const first = segments[firstIndex]
|
||||
|
||||
const backward: number[] = []
|
||||
extendSegmentChain(backward, first.start, remaining, segments, adjacency)
|
||||
backward.reverse()
|
||||
|
||||
const forward: number[] = []
|
||||
extendSegmentChain(forward, first.end, remaining, segments, adjacency)
|
||||
|
||||
parts.push(
|
||||
traceOrderedSegments([...backward, firstIndex, ...forward], segments, vertices, decimals)
|
||||
)
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
export function vectorNetworkToSVGPaths(
|
||||
network: VectorNetwork,
|
||||
decimals: number | null = 2
|
||||
): string[] {
|
||||
const { vertices, segments, regions } = network
|
||||
|
||||
if (regions.length > 0) {
|
||||
return regions.map((region) => {
|
||||
const parts: string[] = []
|
||||
for (const loop of region.loops) {
|
||||
if (loop.length === 0) continue
|
||||
const firstSeg = segments[loop[0]]
|
||||
parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`)
|
||||
for (const segIdx of loop) {
|
||||
parts.push(segmentToSVG(segments[segIdx], vertices, true))
|
||||
}
|
||||
parts.push('Z')
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
return regions.map((region) =>
|
||||
region.loops.map((loop) => traceOrderedSegments(loop, segments, vertices, decimals)).join('')
|
||||
)
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
for (const seg of segments) {
|
||||
parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`)
|
||||
parts.push(segmentToSVG(seg, vertices, true))
|
||||
}
|
||||
|
||||
return parts.length > 0 ? [parts.join('')] : []
|
||||
const path = unfilledSegmentsToPath(network, decimals)
|
||||
return path ? [path] : []
|
||||
}
|
||||
|
||||
export function makePolygonPoints(node: SceneNode): string {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export function createDefaultNode(
|
|||
layoutGrow: 0,
|
||||
layoutAlignSelf: 'AUTO',
|
||||
vectorNetwork: null,
|
||||
handleMirroring: 'NONE',
|
||||
fillGeometry: [],
|
||||
strokeGeometry: [],
|
||||
arcData: null,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ interface SubPath {
|
|||
closed: boolean
|
||||
}
|
||||
|
||||
export type VectorPathWindingRule = WindingRule | 'NONE'
|
||||
export type SVGPathParseResult = { ok: true; network: VectorNetwork } | { ok: false; error: string }
|
||||
|
||||
interface ParseOptions {
|
||||
includeOpenRegions: boolean
|
||||
strictCommands: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SVG path `d` attribute into a VectorNetwork.
|
||||
*
|
||||
|
|
@ -15,6 +23,29 @@ interface SubPath {
|
|||
* (arcs → cubics via `.unarc()`, smooth curves → explicit via `.unshort()`).
|
||||
*/
|
||||
export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): VectorNetwork {
|
||||
const parsed = parsePath(d, windingRule, {
|
||||
includeOpenRegions: false,
|
||||
strictCommands: false
|
||||
})
|
||||
return parsed.ok ? parsed.network : { vertices: [], segments: [], regions: [] }
|
||||
}
|
||||
|
||||
/** Parse the strict absolute command subset accepted by Figma's VectorPath API. */
|
||||
export function parsePluginVectorPath(
|
||||
d: string,
|
||||
windingRule: VectorPathWindingRule
|
||||
): SVGPathParseResult {
|
||||
return parsePath(d, windingRule, {
|
||||
includeOpenRegions: windingRule !== 'NONE',
|
||||
strictCommands: true
|
||||
})
|
||||
}
|
||||
|
||||
function parsePath(
|
||||
d: string,
|
||||
windingRule: VectorPathWindingRule,
|
||||
options: ParseOptions
|
||||
): SVGPathParseResult {
|
||||
const vertices: VectorVertex[] = []
|
||||
const segments: VectorSegment[] = []
|
||||
const subPaths: SubPath[] = []
|
||||
|
|
@ -77,7 +108,18 @@ export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): V
|
|||
}
|
||||
|
||||
const parsed = svgpath(d)
|
||||
if ('err' in parsed && parsed.err) return { vertices, segments, regions: [] }
|
||||
const parseError = 'err' in parsed && typeof parsed.err === 'string' ? parsed.err : null
|
||||
if (parseError) return { ok: false, error: parseError }
|
||||
if (options.strictCommands) {
|
||||
const unsupportedCommands = new Set<string>()
|
||||
parsed.iterate((segment) => {
|
||||
if (!['M', 'L', 'Q', 'C', 'Z'].includes(segment[0])) unsupportedCommands.add(segment[0])
|
||||
})
|
||||
const unsupportedCommand = unsupportedCommands.values().next().value
|
||||
if (unsupportedCommand !== undefined) {
|
||||
return { ok: false, error: `Unsupported path command ${unsupportedCommand}` }
|
||||
}
|
||||
}
|
||||
const normalized = parsed.abs().unshort().unarc()
|
||||
|
||||
normalized.iterate((seg) => {
|
||||
|
|
@ -132,13 +174,18 @@ export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): V
|
|||
})
|
||||
|
||||
const regions: VectorRegion[] = []
|
||||
const closedPaths = subPaths.filter((sp) => sp.closed && sp.segmentIndices.length > 0)
|
||||
if (closedPaths.length > 0) {
|
||||
const regionPaths = subPaths.filter(
|
||||
(subPath) =>
|
||||
windingRule !== 'NONE' &&
|
||||
subPath.segmentIndices.length > 0 &&
|
||||
(subPath.closed || options.includeOpenRegions)
|
||||
)
|
||||
if (regionPaths.length > 0 && windingRule !== 'NONE') {
|
||||
regions.push({
|
||||
windingRule,
|
||||
loops: closedPaths.map((sp) => sp.segmentIndices)
|
||||
loops: regionPaths.map((subPath) => subPath.segmentIndices)
|
||||
})
|
||||
}
|
||||
|
||||
return { vertices, segments, regions }
|
||||
return { ok: true, network: { vertices, segments, regions } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export interface GeometryPath {
|
|||
commandsBlob: Uint8Array
|
||||
/** Resolved paints for geometry using a format-specific style override. */
|
||||
fills?: Fill[]
|
||||
/** Shared fill style attached to this geometry region, when available. */
|
||||
fillStyleId?: string
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
|
|
@ -438,6 +440,7 @@ export interface SceneNode {
|
|||
layoutAlignSelf: LayoutAlignSelf
|
||||
|
||||
vectorNetwork: VectorNetwork | null
|
||||
handleMirroring: HandleMirroring
|
||||
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
||||
fillGeometry: GeometryPath[]
|
||||
strokeGeometry: GeometryPath[]
|
||||
|
|
|
|||
|
|
@ -104,10 +104,18 @@ export function validateVectorNetwork(value: unknown): string[] {
|
|||
const errors: string[] = []
|
||||
validateVertices(value.vertices, errors)
|
||||
validateSegments(value.segments, value.vertices.length, errors)
|
||||
if (Array.isArray(value.regions)) {
|
||||
validateRegions(value.regions, value.segments.length, errors)
|
||||
} else {
|
||||
errors.push('regions must be an array')
|
||||
const typedSegments = value.segments.filter(isSegmentRecord)
|
||||
if (value.regions !== undefined) {
|
||||
if (Array.isArray(value.regions)) {
|
||||
validateRegions(
|
||||
value.regions,
|
||||
value.segments.length,
|
||||
typedSegments.length === value.segments.length ? typedSegments : null,
|
||||
errors
|
||||
)
|
||||
} else {
|
||||
errors.push('regions must be an array when provided')
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
|
@ -154,7 +162,12 @@ function validateSegmentTangents(
|
|||
}
|
||||
}
|
||||
|
||||
function validateRegions(regions: unknown[], segmentCount: number, errors: string[]): void {
|
||||
function validateRegions(
|
||||
regions: unknown[],
|
||||
segmentCount: number,
|
||||
segments: Array<Record<'start' | 'end', number>> | null,
|
||||
errors: string[]
|
||||
): void {
|
||||
for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) {
|
||||
const region = regions[regionIndex]
|
||||
if (!isRecord(region) || !Array.isArray(region.loops)) {
|
||||
|
|
@ -164,7 +177,10 @@ function validateRegions(regions: unknown[], segmentCount: number, errors: strin
|
|||
if (region.windingRule !== 'NONZERO' && region.windingRule !== 'EVENODD') {
|
||||
errors.push(`region[${regionIndex}]: windingRule must be NONZERO or EVENODD`)
|
||||
}
|
||||
validateRegionLoops(region.loops, regionIndex, segmentCount, errors)
|
||||
if (region.loops.length === 0) {
|
||||
errors.push(`region[${regionIndex}]: loops must contain at least one loop`)
|
||||
}
|
||||
validateRegionLoops(region.loops, regionIndex, segmentCount, segments, errors)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +188,7 @@ function validateRegionLoops(
|
|||
loops: unknown[],
|
||||
regionIndex: number,
|
||||
segmentCount: number,
|
||||
segments: Array<Record<'start' | 'end', number>> | null,
|
||||
errors: string[]
|
||||
): void {
|
||||
for (let loopIndex = 0; loopIndex < loops.length; loopIndex++) {
|
||||
|
|
@ -180,16 +197,60 @@ function validateRegionLoops(
|
|||
errors.push(`region[${regionIndex}].loop[${loopIndex}] must be an array`)
|
||||
continue
|
||||
}
|
||||
if (loop.length === 0) {
|
||||
errors.push(`region[${regionIndex}].loop[${loopIndex}] must contain at least one segment`)
|
||||
continue
|
||||
}
|
||||
|
||||
const segmentIndices: number[] = []
|
||||
for (const segmentIndex of loop) {
|
||||
if (!isInteger(segmentIndex) || segmentIndex < 0 || segmentIndex >= segmentCount) {
|
||||
errors.push(
|
||||
`region[${regionIndex}].loop[${loopIndex}]: segment index ${String(segmentIndex)} out of range`
|
||||
)
|
||||
} else {
|
||||
segmentIndices.push(segmentIndex)
|
||||
}
|
||||
}
|
||||
if (segmentIndices.length !== loop.length) continue
|
||||
if (new Set(segmentIndices).size !== segmentIndices.length) {
|
||||
errors.push(`region[${regionIndex}].loop[${loopIndex}] must not repeat segments`)
|
||||
continue
|
||||
}
|
||||
if (segments && !formsContinuousChain(segmentIndices, segments)) {
|
||||
errors.push(`region[${regionIndex}].loop[${loopIndex}] segments must form a continuous chain`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formsContinuousChain(
|
||||
indices: number[],
|
||||
segments: Array<Record<'start' | 'end', number>>
|
||||
): boolean {
|
||||
if (indices.length <= 1) return true
|
||||
const first = segments[indices[0]]
|
||||
return followsChain(indices, segments, first.end) || followsChain(indices, segments, first.start)
|
||||
}
|
||||
|
||||
function followsChain(
|
||||
indices: number[],
|
||||
segments: Array<Record<'start' | 'end', number>>,
|
||||
initialEnd: number
|
||||
): boolean {
|
||||
let current = initialEnd
|
||||
for (let index = 1; index < indices.length; index++) {
|
||||
const segment = segments[indices[index]]
|
||||
if (segment.start === current) current = segment.end
|
||||
else if (segment.end === current) current = segment.start
|
||||
else return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isSegmentRecord(value: unknown): value is Record<'start' | 'end', number> {
|
||||
return isRecord(value) && isInteger(value.start) && isInteger(value.end)
|
||||
}
|
||||
|
||||
function isFiniteVector(value: unknown): value is Vector {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
|
|
@ -208,12 +269,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export type NormalizableVectorNetwork = Omit<VectorNetwork, 'regions'> & {
|
||||
regions?: VectorNetwork['regions']
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every segment has tangentStart/tangentEnd.
|
||||
* Ensure every segment has tangentStart/tangentEnd and a regions array.
|
||||
* Missing tangents default to {x:0, y:0} (straight line segments).
|
||||
* Use at system boundaries where input may come from JSON/MCP.
|
||||
*/
|
||||
export function normalizeVectorNetwork(vn: VectorNetwork): VectorNetwork {
|
||||
export function normalizeVectorNetwork(vn: NormalizableVectorNetwork): VectorNetwork {
|
||||
const ZERO: Vector = { x: 0, y: 0 }
|
||||
return {
|
||||
vertices: vn.vertices,
|
||||
|
|
@ -223,6 +288,6 @@ export function normalizeVectorNetwork(vn: VectorNetwork): VectorNetwork {
|
|||
tangentStart: (s as Partial<VectorSegment>).tangentStart ?? { ...ZERO },
|
||||
tangentEnd: (s as Partial<VectorSegment>).tangentEnd ?? { ...ZERO }
|
||||
})),
|
||||
regions: vn.regions
|
||||
regions: vn.regions ?? []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { encodePathCommandsBlob } from '@open-pencil/fig/node-change'
|
|||
|
||||
import { createAPI } from '../helpers'
|
||||
|
||||
const CURVED_PATH = 'M10 20 C20 0 40 40 50 20 Z'
|
||||
|
||||
describe('vector paths', () => {
|
||||
test('exposes imported geometry as Figma SVG paths', () => {
|
||||
const api = createAPI()
|
||||
|
|
@ -25,4 +27,221 @@ describe('vector paths', () => {
|
|||
expect(Object.isFrozen(vector.vectorPaths)).toBe(true)
|
||||
expect(Object.isFrozen(vector.vectorPaths[0])).toBe(true)
|
||||
})
|
||||
|
||||
test('matches Figma defaults and exposes vector properties only on vectors', () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
const rectangle = api.createRectangle()
|
||||
|
||||
expect(vector.vectorPaths).toEqual([])
|
||||
expect(vector.vectorNetwork).toEqual({ vertices: [], segments: [], regions: [] })
|
||||
expect(vector.handleMirroring).toBe('NONE')
|
||||
expect(Object.isFrozen(vector.vectorNetwork)).toBe(true)
|
||||
expect('vectorPaths' in rectangle).toBe(false)
|
||||
expect('vectorNetwork' in rectangle).toBe(false)
|
||||
})
|
||||
|
||||
test('normalizes assigned path geometry and updates bounds like Figma', () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
|
||||
vector.vectorPaths = [{ windingRule: 'EVENODD', data: CURVED_PATH }]
|
||||
|
||||
expect(vector.x).toBeCloseTo(10)
|
||||
expect(vector.y).toBeCloseTo(14.2265, 3)
|
||||
expect(vector.width).toBeCloseTo(40)
|
||||
expect(vector.height).toBeCloseTo(11.547, 3)
|
||||
expect(vector.vectorPaths[0].windingRule).toBe('EVENODD')
|
||||
expect(vector.vectorNetwork.vertices[0]?.x).toBeCloseTo(0)
|
||||
expect(vector.vectorNetwork.vertices[0]?.y).toBeCloseTo(5.7735, 3)
|
||||
|
||||
vector.vectorPaths = []
|
||||
expect(vector.width).toBe(0)
|
||||
expect(vector.height).toBe(0)
|
||||
expect(vector.vectorPaths).toEqual([])
|
||||
expect(vector.vectorNetwork.vertices).toEqual([])
|
||||
})
|
||||
|
||||
test('preserves filled, open, and unfilled path winding semantics', () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
|
||||
vector.vectorPaths = [
|
||||
{ windingRule: 'EVENODD', data: 'M 0 0 L 10 0 L 0 10 Z' },
|
||||
{ windingRule: 'NONZERO', data: 'M 20 20 L 30 30' },
|
||||
{ windingRule: 'NONE', data: 'M 40 40 L 50 40 L 40 50 Z' }
|
||||
]
|
||||
|
||||
expect(vector.vectorPaths.map((path) => path.windingRule)).toEqual([
|
||||
'EVENODD',
|
||||
'NONZERO',
|
||||
'NONE'
|
||||
])
|
||||
expect(vector.vectorPaths[1]?.data.endsWith('Z')).toBe(false)
|
||||
expect(vector.vectorPaths[2]?.data.endsWith('Z')).toBe(true)
|
||||
expect(vector.vectorNetwork.regions?.map((region) => region.windingRule)).toEqual([
|
||||
'EVENODD',
|
||||
'NONZERO'
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts optional regions, freezes readback, and preserves region fills', () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
|
||||
vector.vectorNetwork = {
|
||||
vertices: [
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 30, y: 20 },
|
||||
{ x: 10, y: 40 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 0 }
|
||||
],
|
||||
regions: [
|
||||
{
|
||||
windingRule: 'NONZERO',
|
||||
loops: [[0, 1, 2]],
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const network = vector.vectorNetwork
|
||||
expect(vector.x).toBe(10)
|
||||
expect(vector.y).toBe(20)
|
||||
expect(network.regions?.[0]?.fills?.[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 })
|
||||
expect(Object.isFrozen(network.vertices)).toBe(true)
|
||||
expect(Object.isFrozen(network.vertices[0])).toBe(true)
|
||||
expect(Object.isFrozen(network.segments[0]?.tangentStart)).toBe(true)
|
||||
expect(Object.isFrozen(network.regions?.[0]?.loops[0])).toBe(true)
|
||||
|
||||
const tangentStart = { x: 2, y: 3 }
|
||||
vector.vectorNetwork = {
|
||||
vertices: [
|
||||
{ x: 5, y: 6 },
|
||||
{ x: 15, y: 16 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1, tangentStart }]
|
||||
}
|
||||
tangentStart.x = 99
|
||||
expect(vector.vectorNetwork.regions).toEqual([])
|
||||
expect(vector.vectorNetwork.segments[0]?.tangentStart).toEqual({ x: 2, y: 3 })
|
||||
expect(vector.vectorPaths[0]?.windingRule).toBe('NONE')
|
||||
})
|
||||
|
||||
test('supports async network assignment and rejects Figma-invalid input', async () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
|
||||
await vector.setVectorNetworkAsync({
|
||||
vertices: [
|
||||
{ x: 5, y: 6 },
|
||||
{ x: 15, y: 16 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1 }]
|
||||
})
|
||||
expect(vector.vectorNetwork.vertices).toHaveLength(2)
|
||||
|
||||
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 0 L 10 10' }]
|
||||
const openPathNetwork = vector.vectorNetwork
|
||||
vector.vectorNetwork = openPathNetwork
|
||||
expect(vector.vectorPaths).toEqual([{ windingRule: 'NONZERO', data: 'M0 0L10 10' }])
|
||||
|
||||
await expect(
|
||||
vector.setVectorNetworkAsync({
|
||||
vertices: [{ x: 0, y: 0 }],
|
||||
segments: [{ start: 0, end: 2 }]
|
||||
})
|
||||
).rejects.toThrow('end index 2 out of range')
|
||||
|
||||
expect(() => {
|
||||
vector.vectorNetwork = null as never
|
||||
}).toThrow('network must be an object')
|
||||
expect(() => {
|
||||
vector.vectorNetwork = {
|
||||
vertices: [{ x: 0, y: 0 }],
|
||||
segments: [{ start: 0, end: 2 }]
|
||||
}
|
||||
}).toThrow('end index 2 out of range')
|
||||
expect(() => {
|
||||
vector.vectorNetwork = {
|
||||
vertices: [{ x: 0, y: 0 }],
|
||||
segments: [],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[]] }]
|
||||
}
|
||||
}).toThrow('must contain at least one segment')
|
||||
expect(() => {
|
||||
vector.vectorNetwork = {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 10 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 0 }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]], fills: 'red' as never }]
|
||||
}
|
||||
}).toThrow('fills must be an array')
|
||||
expect(() => {
|
||||
vector.vectorNetwork = {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 10 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 0 }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]], fillStyleId: 42 as never }]
|
||||
}
|
||||
}).toThrow('fillStyleId must be a string')
|
||||
expect(() => {
|
||||
vector.vectorPaths = [{ windingRule: 1 as never, data: 'M 0 0 L 10 0' }]
|
||||
}).toThrow('windingRule is invalid')
|
||||
expect(() => {
|
||||
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'm 0 0 l 10 0' }]
|
||||
}).toThrow('Unsupported path command')
|
||||
expect(() => {
|
||||
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 0 L' }]
|
||||
}).toThrow('Invalid vector path')
|
||||
})
|
||||
|
||||
test('matches Figma handle mirroring and mixed behavior', () => {
|
||||
const api = createAPI()
|
||||
const vector = api.createVector()
|
||||
|
||||
vector.handleMirroring = 'ANGLE'
|
||||
expect(vector.handleMirroring).toBe('ANGLE')
|
||||
|
||||
vector.vectorNetwork = {
|
||||
vertices: [
|
||||
{ x: 0, y: 0, handleMirroring: 'ANGLE' },
|
||||
{ x: 10, y: 0, handleMirroring: 'NONE' }
|
||||
],
|
||||
segments: [{ start: 0, end: 1 }]
|
||||
}
|
||||
expect(vector.handleMirroring).toBe(api.mixed)
|
||||
|
||||
vector.handleMirroring = 'ANGLE_AND_LENGTH'
|
||||
expect(vector.handleMirroring).toBe('ANGLE_AND_LENGTH')
|
||||
expect(vector.vectorNetwork.vertices.map((vertex) => vertex.handleMirroring)).toEqual([
|
||||
'ANGLE_AND_LENGTH',
|
||||
'ANGLE_AND_LENGTH'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -108,6 +108,25 @@ describe('vectorNetworkToSVGPaths()', () => {
|
|||
expect(paths[0]).toContain('C')
|
||||
})
|
||||
|
||||
test('traces arbitrarily ordered open segments as one path', () => {
|
||||
const paths = vectorNetworkToSVGPaths({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 30, y: 0 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: []
|
||||
})
|
||||
|
||||
expect(paths).toEqual(['M0 0L10 0L20 0L30 0'])
|
||||
})
|
||||
|
||||
test('region with loop', () => {
|
||||
const paths = vectorNetworkToSVGPaths({
|
||||
vertices: [
|
||||
|
|
@ -127,6 +146,60 @@ describe('vectorNetworkToSVGPaths()', () => {
|
|||
expect(paths[0]).toContain('Z')
|
||||
})
|
||||
|
||||
test('traces non-directional region segments continuously', () => {
|
||||
const paths = vectorNetworkToSVGPaths({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 50, y: 100 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 1, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 0, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }]
|
||||
})
|
||||
|
||||
expect(paths).toEqual(['M0 0L100 0L50 100L0 0Z'])
|
||||
})
|
||||
|
||||
test('starts a new subpath for disconnected region segments', () => {
|
||||
const paths = vectorNetworkToSVGPaths({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 30, y: 0 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1]] }]
|
||||
})
|
||||
|
||||
expect(paths).toEqual(['M0 0L10 0M20 0L30 0'])
|
||||
})
|
||||
|
||||
test('recognizes closed unfilled segment chains', () => {
|
||||
const paths = vectorNetworkToSVGPaths({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 10 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
||||
{ start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: []
|
||||
})
|
||||
|
||||
expect(paths).toEqual(['M10 0L0 10L0 0L10 0Z'])
|
||||
})
|
||||
|
||||
test('empty network', () => {
|
||||
const paths = vectorNetworkToSVGPaths({ vertices: [], segments: [], regions: [] })
|
||||
expect(paths).toHaveLength(0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { validateVectorNetwork, type VectorNetwork } from '@open-pencil/core'
|
||||
import {
|
||||
normalizeVectorNetwork,
|
||||
validateVectorNetwork,
|
||||
type VectorNetwork
|
||||
} from '@open-pencil/core'
|
||||
|
||||
describe('validateVectorNetwork', () => {
|
||||
test('valid network returns no errors', () => {
|
||||
|
|
@ -59,17 +63,51 @@ describe('validateVectorNetwork', () => {
|
|||
expect(validateVectorNetwork('not a network')).toEqual(['network must be an object'])
|
||||
})
|
||||
|
||||
test('rejects invalid region topology', () => {
|
||||
const errors = validateVectorNetwork({
|
||||
test('accepts and normalizes omitted regions like the Figma Plugin API', () => {
|
||||
const network = {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1 }],
|
||||
regions: [{ windingRule: 'INVALID', loops: [[1]] }]
|
||||
segments: [{ start: 0, end: 1 }]
|
||||
}
|
||||
|
||||
expect(validateVectorNetwork(network)).toEqual([])
|
||||
expect(normalizeVectorNetwork(network).regions).toEqual([])
|
||||
})
|
||||
|
||||
test('rejects a non-array regions value', () => {
|
||||
const errors = validateVectorNetwork({ vertices: [], segments: [], regions: {} } as never)
|
||||
expect(errors).toContain('regions must be an array when provided')
|
||||
})
|
||||
|
||||
test('rejects invalid region topology', () => {
|
||||
const errors = validateVectorNetwork({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 20, y: 0 },
|
||||
{ x: 30, y: 0 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 2, end: 3 },
|
||||
{ start: 1, end: 2 }
|
||||
],
|
||||
regions: [
|
||||
{ windingRule: 'INVALID', loops: [[3]] },
|
||||
{ windingRule: 'NONZERO', loops: [] },
|
||||
{ windingRule: 'NONZERO', loops: [[]] },
|
||||
{ windingRule: 'NONZERO', loops: [[0, 0]] },
|
||||
{ windingRule: 'NONZERO', loops: [[0, 1]] }
|
||||
]
|
||||
})
|
||||
|
||||
expect(errors).toContain('region[0]: windingRule must be NONZERO or EVENODD')
|
||||
expect(errors).toContain('region[0].loop[0]: segment index 1 out of range')
|
||||
expect(errors).toContain('region[0].loop[0]: segment index 3 out of range')
|
||||
expect(errors).toContain('region[1]: loops must contain at least one loop')
|
||||
expect(errors).toContain('region[2].loop[0] must contain at least one segment')
|
||||
expect(errors).toContain('region[3].loop[0] must not repeat segments')
|
||||
expect(errors).toContain('region[4].loop[0] segments must form a continuous chain')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue