Skip to content

Commit

Permalink
Refactor Strokes (#112)
Browse files Browse the repository at this point in the history
* Wip

* refactor strokes

* fix

* fix

* fix

* refactor

* more refactor

* add changeset and fix issue fix line node

* refactor

* continue the refactor

* refactor fills

* fix

* wip

* try another approach

* refactor

* refactor

* more refactor

* refactor

* wip

* wip

* minor improvements

* minor fixes

* refactor

---------

Co-authored-by: Jordi Sala Morales <jordism91@gmail.com>
  • Loading branch information
Cenadros and jordisala1991 authored May 21, 2024
1 parent a734c8a commit cc5553c
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 137 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-pears-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"penpot-exporter": patch
---

Arrows on complex svgs are now better represented inside Penpot
5 changes: 5 additions & 0 deletions .changeset/popular-rats-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"penpot-exporter": patch
---

Fix line node svg path
65 changes: 52 additions & 13 deletions plugin-src/transformers/partials/transformStrokes.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
import { translateStrokes } from '@plugin/translators';
import { Command } from 'svg-path-parser';

import { translateStrokeCap, translateStrokes } from '@plugin/translators';

import { ShapeAttributes } from '@ui/lib/types/shapes/shape';
import { Stroke } from '@ui/lib/types/utils/stroke';

const isVectorLike = (node: GeometryMixin | VectorLikeMixin): node is VectorLikeMixin => {
return 'vectorNetwork' in node;
};

const isIndividualStrokes = (
node: GeometryMixin | IndividualStrokesMixin
): node is IndividualStrokesMixin => {
return 'strokeTopWeight' in node;
};

const hasFillGeometry = (node: GeometryMixin): boolean => {
return node.fillGeometry.length > 0;
};

export const transformStrokes = async (
node: GeometryMixin | (GeometryMixin & IndividualStrokesMixin)
): Promise<Partial<ShapeAttributes>> => {
const vectorNetwork = isVectorLike(node) ? node.vectorNetwork : undefined;

const strokeCaps = (stroke: Stroke) => {
if (!hasFillGeometry(node) && vectorNetwork && vectorNetwork.vertices.length > 0) {
stroke.strokeCapStart = translateStrokeCap(vectorNetwork.vertices[0]);
stroke.strokeCapEnd = translateStrokeCap(
vectorNetwork.vertices[vectorNetwork.vertices.length - 1]
);
}

return stroke;
};

return {
strokes: await translateStrokes(
node,
hasFillGeometry(node),
isVectorLike(node) ? node.vectorNetwork : undefined,
isIndividualStrokes(node) ? node : undefined
)
strokes: await translateStrokes(node, strokeCaps)
};
};

export const transformStrokesFromVector = async (
node: VectorNode,
vector: Command[],
vectorRegion: VectorRegion | undefined
): Promise<Partial<ShapeAttributes>> => {
const strokeCaps = (stroke: Stroke) => {
if (vectorRegion !== undefined) return stroke;

const startVertex = findVertex(node.vectorNetwork.vertices, vector[0]);
const endVertex = findVertex(node.vectorNetwork.vertices, vector[vector.length - 1]);

if (!startVertex || !endVertex) return stroke;

stroke.strokeCapStart = translateStrokeCap(startVertex);
stroke.strokeCapEnd = translateStrokeCap(endVertex);

return stroke;
};

return {
strokes: await translateStrokes(node, strokeCaps)
};
};

const findVertex = (
vertexs: readonly VectorVertex[],
command: Command
): VectorVertex | undefined => {
if (command.command !== 'moveto' && command.command !== 'lineto' && command.command !== 'curveto')
return;

return vertexs.find(vertex => vertex.x === command.x && vertex.y === command.y);
};
99 changes: 73 additions & 26 deletions plugin-src/transformers/partials/transformVectorPaths.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
import { parseSVG } from 'svg-path-parser';

import {
transformBlend,
transformDimensionAndPositionFromVectorPath,
transformEffects,
transformProportion,
transformSceneNode,
transformStrokes
transformStrokesFromVector
} from '@plugin/transformers/partials';
import { createLineGeometry, translateVectorPath, translateVectorPaths } from '@plugin/translators';
import { translateFills } from '@plugin/translators/fills';
import {
translateCommandsToSegments,
translateLineNode,
translateVectorPaths,
translateWindingRule
} from '@plugin/translators/vectors';

import { PathAttributes } from '@ui/lib/types/shapes/pathShape';
import { PathShape } from '@ui/lib/types/shapes/pathShape';
import { Children } from '@ui/lib/types/utils/children';

const getVectorPaths = (node: VectorNode | StarNode | LineNode | PolygonNode): VectorPaths => {
switch (node.type) {
case 'STAR':
case 'POLYGON':
return node.fillGeometry;
case 'VECTOR':
return node.vectorPaths;
case 'LINE':
return createLineGeometry(node);
}
};

export const transformVectorPathsAsContent = (
node: VectorNode | StarNode | LineNode | PolygonNode,
node: StarNode | LineNode | PolygonNode,
baseX: number,
baseY: number
): PathAttributes => {
Expand All @@ -37,14 +31,20 @@ export const transformVectorPathsAsContent = (
};
};

export const transformVectorPathsAsChildren = async (
export const transformVectorPaths = async (
node: VectorNode,
baseX: number,
baseY: number
): Promise<Children> => {
return {
children: await Promise.all(
node.vectorPaths.map((vectorPath, index) =>
): Promise<PathShape[]> => {
const pathShapes = await Promise.all(
node.vectorPaths
.filter((vectorPath, index) => {
return (
nodeHasFills(node, vectorPath, (node.vectorNetwork.regions ?? [])[index]) ||
node.strokes.length > 0
);
})
.map((vectorPath, index) =>
transformVectorPath(
node,
vectorPath,
Expand All @@ -53,8 +53,47 @@ export const transformVectorPathsAsChildren = async (
baseY
)
)
)
};
);

const geometryShapes = await Promise.all(
node.fillGeometry
.filter(
geometry =>
!node.vectorPaths.find(
vectorPath => normalizePath(vectorPath.data) === normalizePath(geometry.data)
)
)
.map(geometry => transformVectorPath(node, geometry, undefined, baseX, baseY))
);

return [...geometryShapes, ...pathShapes];
};

const getVectorPaths = (node: StarNode | LineNode | PolygonNode): VectorPaths => {
switch (node.type) {
case 'STAR':
case 'POLYGON':
return node.fillGeometry;
case 'LINE':
return translateLineNode(node);
}
};

const normalizePath = (path: string): string => {
// Round to 2 decimal places all numbers
const str = path.replace(/(\d+\.\d+|\d+)/g, (match: string) => {
return parseFloat(match).toFixed(2);
});
// remove spaces
return str.replace(/\s/g, '');
};

const nodeHasFills = (
node: VectorNode,
vectorPath: VectorPath,
vectorRegion: VectorRegion | undefined
): boolean => {
return !!(vectorPath.windingRule !== 'NONE' && (vectorRegion?.fills || node.fills));
};

const transformVectorPath = async (
Expand All @@ -64,12 +103,20 @@ const transformVectorPath = async (
baseX: number,
baseY: number
): Promise<PathShape> => {
const normalizedPaths = parseSVG(vectorPath.data);

return {
type: 'path',
name: 'svg-path',
content: translateVectorPath(vectorPath, baseX + node.x, baseY + node.y),
fills: await translateFills(vectorRegion?.fills ?? node.fills),
...(await transformStrokes(node)),
content: translateCommandsToSegments(normalizedPaths, baseX + node.x, baseY + node.y),
fills:
vectorPath.windingRule === 'NONE'
? []
: await translateFills(vectorRegion?.fills ?? node.fills),
svgAttrs: {
fillRule: translateWindingRule(vectorPath.windingRule)
},
...(await transformStrokesFromVector(node, normalizedPaths, vectorRegion)),
...transformEffects(node),
...transformDimensionAndPositionFromVectorPath(vectorPath, baseX, baseY),
...transformSceneNode(node),
Expand Down
8 changes: 4 additions & 4 deletions plugin-src/transformers/transformGroupNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@ export const transformGroupNode = async (
): Promise<GroupShape> => {
return {
...transformGroupNodeLike(node, baseX, baseY),
...transformEffects(node),
...transformBlend(node),
...(await transformChildren(node, baseX, baseY))
};
};

export const transformGroupNodeLike = (
node: BaseNodeMixin & DimensionAndPositionMixin & BlendMixin & SceneNodeMixin & MinimalBlendMixin,
node: BaseNodeMixin & DimensionAndPositionMixin & SceneNodeMixin,
baseX: number,
baseY: number
): GroupShape => {
return {
type: 'group',
name: node.name,
...transformDimensionAndPosition(node, baseX, baseY),
...transformEffects(node),
...transformSceneNode(node),
...transformBlend(node)
...transformSceneNode(node)
};
};
4 changes: 2 additions & 2 deletions plugin-src/transformers/transformPathNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {

import { PathShape } from '@ui/lib/types/shapes/pathShape';

const hasFillGeometry = (node: VectorNode | StarNode | LineNode | PolygonNode): boolean => {
const hasFillGeometry = (node: StarNode | LineNode | PolygonNode): boolean => {
return 'fillGeometry' in node && node.fillGeometry.length > 0;
};

export const transformPathNode = async (
node: VectorNode | StarNode | LineNode | PolygonNode,
node: StarNode | LineNode | PolygonNode,
baseX: number,
baseY: number
): Promise<PathShape> => {
Expand Down
15 changes: 10 additions & 5 deletions plugin-src/transformers/transformVectorNode.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { transformVectorPathsAsChildren } from '@plugin/transformers/partials';
import { transformVectorPaths } from '@plugin/transformers/partials';

import { GroupShape } from '@ui/lib/types/shapes/groupShape';
import { PathShape } from '@ui/lib/types/shapes/pathShape';

import { transformGroupNodeLike, transformPathNode } from '.';
import { transformGroupNodeLike } from '.';

/*
* Vector nodes can have multiple vector paths, each with its own fills.
Expand All @@ -16,12 +16,17 @@ export const transformVectorNode = async (
baseX: number,
baseY: number
): Promise<GroupShape | PathShape> => {
if ((node.vectorNetwork.regions ?? []).length === 0) {
return transformPathNode(node, baseX, baseY);
const children = await transformVectorPaths(node, baseX, baseY);

if (children.length === 1) {
return {
...children[0],
name: node.name
};
}

return {
...transformGroupNodeLike(node, baseX, baseY),
...(await transformVectorPathsAsChildren(node, baseX, baseY))
children
};
};
7 changes: 4 additions & 3 deletions plugin-src/translators/fills/translateFills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ export const translateFill = async (fill: Paint): Promise<Fill | undefined> => {
};

export const translateFills = async (
fills: readonly Paint[] | typeof figma.mixed
fills: readonly Paint[] | typeof figma.mixed | undefined
): Promise<Fill[]> => {
const figmaFills = fills === figma.mixed ? [] : fills;
if (fills === undefined || fills === figma.mixed) return [];

const penpotFills: Fill[] = [];

for (const fill of figmaFills) {
for (const fill of fills) {
const penpotFill = await translateFill(fill);
if (penpotFill) {
// fills are applied in reverse order in Figma, that's why we unshift
Expand Down
1 change: 0 additions & 1 deletion plugin-src/translators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,3 @@ export * from './translateBlendMode';
export * from './translateBlurEffects';
export * from './translateShadowEffects';
export * from './translateStrokes';
export * from './translateVectorPaths';
Loading

0 comments on commit cc5553c

Please sign in to comment.