mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2024-12-22 13:43:03 -05:00
Implement rotation for vector lines (#160)
* Implement rotation for vector lines * wip * Improve rotations for lines * add changelog * add layout attributes to line
This commit is contained in:
parent
b85a4f7279
commit
af81fc7e92
14 changed files with 235 additions and 138 deletions
5
.changeset/sour-radios-know.md
Normal file
5
.changeset/sour-radios-know.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"penpot-exporter": minor
|
||||
---
|
||||
|
||||
Apply rotations to lines
|
|
@ -5,6 +5,7 @@ export * from './transformEllipseNode';
|
|||
export * from './transformFrameNode';
|
||||
export * from './transformGroupNode';
|
||||
export * from './transformInstanceNode';
|
||||
export * from './transformLineNode';
|
||||
export * from './transformPageNode';
|
||||
export * from './transformPathNode';
|
||||
export * from './transformRectangleNode';
|
||||
|
|
|
@ -11,6 +11,17 @@ export const transformDimension = (
|
|||
};
|
||||
};
|
||||
|
||||
export const transformPosition = (
|
||||
node: DimensionAndPositionMixin,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Pick<ShapeGeomAttributes, 'x' | 'y'> => {
|
||||
return {
|
||||
x: node.x + baseX,
|
||||
y: node.y + baseY
|
||||
};
|
||||
};
|
||||
|
||||
export const transformDimensionAndPosition = (
|
||||
node: DimensionAndPositionMixin,
|
||||
baseX: number,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { applyInverseRotation, hasRotation } from '@plugin/utils';
|
||||
|
||||
import { ShapeBaseAttributes, ShapeGeomAttributes } from '@ui/lib/types/shapes/shape';
|
||||
import { Point } from '@ui/lib/types/utils/point';
|
||||
|
||||
export const transformRotationAndPosition = (
|
||||
node: LayoutMixin,
|
||||
|
@ -11,7 +12,7 @@ export const transformRotationAndPosition = (
|
|||
const x = node.x + baseX;
|
||||
const y = node.y + baseY;
|
||||
|
||||
if (rotation === 0 || !node.absoluteBoundingBox) {
|
||||
if (!hasRotation(rotation) || !node.absoluteBoundingBox) {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
|
@ -21,10 +22,14 @@ export const transformRotationAndPosition = (
|
|||
};
|
||||
}
|
||||
|
||||
const point = getRotatedPoint({ x, y }, node.absoluteTransform, node.absoluteBoundingBox);
|
||||
const referencePoint = applyInverseRotation(
|
||||
{ x, y },
|
||||
node.absoluteTransform,
|
||||
node.absoluteBoundingBox
|
||||
);
|
||||
|
||||
return {
|
||||
...point,
|
||||
...referencePoint,
|
||||
rotation: -rotation < 0 ? -rotation + 360 : -rotation,
|
||||
transform: {
|
||||
a: node.absoluteTransform[0][0],
|
||||
|
@ -44,25 +49,3 @@ export const transformRotationAndPosition = (
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getRotatedPoint = (point: Point, transform: Transform, boundingBox: Rect): Point => {
|
||||
const centerPoint = {
|
||||
x: boundingBox.x + boundingBox.width / 2,
|
||||
y: boundingBox.y + boundingBox.height / 2
|
||||
};
|
||||
|
||||
const relativePoint = {
|
||||
x: point.x - centerPoint.x,
|
||||
y: point.y - centerPoint.y
|
||||
};
|
||||
|
||||
const rotatedPoint = {
|
||||
x: relativePoint.x * transform[0][0] + relativePoint.y * transform[1][0],
|
||||
y: relativePoint.x * transform[0][1] + relativePoint.y * transform[1][1]
|
||||
};
|
||||
|
||||
return {
|
||||
x: centerPoint.x + rotatedPoint.x,
|
||||
y: centerPoint.y + rotatedPoint.y
|
||||
};
|
||||
};
|
||||
|
|
|
@ -10,26 +10,9 @@ import {
|
|||
transformStrokesFromVector
|
||||
} from '@plugin/transformers/partials';
|
||||
import { translateFills } from '@plugin/translators/fills';
|
||||
import {
|
||||
translateCommandsToSegments,
|
||||
translateLineNode,
|
||||
translateVectorPaths,
|
||||
translateWindingRule
|
||||
} from '@plugin/translators/vectors';
|
||||
import { translateCommandsToSegments, translateWindingRule } from '@plugin/translators/vectors';
|
||||
|
||||
import { PathAttributes, PathShape } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
export const transformVectorPathsAsContent = (
|
||||
node: StarNode | LineNode | PolygonNode,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): PathAttributes => {
|
||||
const vectorPaths = getVectorPaths(node);
|
||||
|
||||
return {
|
||||
content: translateVectorPaths(vectorPaths, baseX + node.x, baseY + node.y)
|
||||
};
|
||||
};
|
||||
import { PathShape } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
export const transformVectorPaths = (
|
||||
node: VectorNode,
|
||||
|
@ -59,21 +42,12 @@ export const transformVectorPaths = (
|
|||
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, '');
|
||||
};
|
||||
|
|
37
plugin-src/transformers/transformLineNode.ts
Normal file
37
plugin-src/transformers/transformLineNode.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
transformBlend,
|
||||
transformConstraints,
|
||||
transformEffects,
|
||||
transformFigmaIds,
|
||||
transformLayoutAttributes,
|
||||
transformPosition,
|
||||
transformProportion,
|
||||
transformSceneNode,
|
||||
transformStrokes
|
||||
} from '@plugin/transformers/partials';
|
||||
import { translateLineNode } from '@plugin/translators/vectors';
|
||||
|
||||
import { PathShape } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
/**
|
||||
* In order to match the normal representation of a line in Penpot, we will assume that
|
||||
* the line is never rotated, so we calculate its normal position.
|
||||
*
|
||||
* To represent the line rotated we do take into account the rotation of the line, but only in its content.
|
||||
*/
|
||||
export const transformLineNode = (node: LineNode, baseX: number, baseY: number): PathShape => {
|
||||
return {
|
||||
type: 'path',
|
||||
name: node.name,
|
||||
content: translateLineNode(node, baseX, baseY),
|
||||
...transformFigmaIds(node),
|
||||
...transformStrokes(node),
|
||||
...transformEffects(node),
|
||||
...transformPosition(node, baseX, baseY),
|
||||
...transformSceneNode(node),
|
||||
...transformBlend(node),
|
||||
...transformProportion(node),
|
||||
...transformLayoutAttributes(node),
|
||||
...transformConstraints(node)
|
||||
};
|
||||
};
|
|
@ -8,29 +8,25 @@ import {
|
|||
transformLayoutAttributes,
|
||||
transformProportion,
|
||||
transformSceneNode,
|
||||
transformStrokes,
|
||||
transformVectorPathsAsContent
|
||||
transformStrokes
|
||||
} from '@plugin/transformers/partials';
|
||||
import { translateVectorPaths } from '@plugin/translators/vectors';
|
||||
|
||||
import { PathShape } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
const hasFillGeometry = (node: StarNode | LineNode | PolygonNode): boolean => {
|
||||
return 'fillGeometry' in node && node.fillGeometry.length > 0;
|
||||
};
|
||||
|
||||
export const transformPathNode = (
|
||||
node: StarNode | LineNode | PolygonNode,
|
||||
node: StarNode | PolygonNode,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): PathShape => {
|
||||
return {
|
||||
type: 'path',
|
||||
name: node.name,
|
||||
content: translateVectorPaths(node.fillGeometry, baseX + node.x, baseY + node.y),
|
||||
...transformFigmaIds(node),
|
||||
...(hasFillGeometry(node) ? transformFills(node) : []),
|
||||
...transformFills(node),
|
||||
...transformStrokes(node),
|
||||
...transformEffects(node),
|
||||
...transformVectorPathsAsContent(node, baseX, baseY),
|
||||
...transformDimensionAndPosition(node, baseX, baseY),
|
||||
...transformSceneNode(node),
|
||||
...transformBlend(node),
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
transformFrameNode,
|
||||
transformGroupNode,
|
||||
transformInstanceNode,
|
||||
transformLineNode,
|
||||
transformPathNode,
|
||||
transformRectangleNode,
|
||||
transformTextNode,
|
||||
|
@ -46,9 +47,11 @@ export const transformSceneNode = async (
|
|||
case 'VECTOR':
|
||||
penpotNode = transformVectorNode(node, baseX, baseY);
|
||||
break;
|
||||
case 'LINE':
|
||||
penpotNode = transformLineNode(node, baseX, baseY);
|
||||
break;
|
||||
case 'STAR':
|
||||
case 'POLYGON':
|
||||
case 'LINE':
|
||||
penpotNode = transformPathNode(node, baseX, baseY);
|
||||
break;
|
||||
case 'BOOLEAN_OPERATION':
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './translateCommandsToSegments';
|
||||
export * from './translateLineNode';
|
||||
export * from './translateVectorPaths';
|
||||
export * from './translateWindingRule';
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { Command, CurveToCommand, LineToCommand, MoveToCommand } from 'svg-path-parser';
|
||||
|
||||
import { Segment } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
export const translateCommandsToSegments = (
|
||||
commands: Command[],
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Segment[] => {
|
||||
return commands.map(command => {
|
||||
switch (command.command) {
|
||||
case 'moveto':
|
||||
return translateMoveToCommand(command, baseX, baseY);
|
||||
case 'lineto':
|
||||
return translateLineToCommand(command, baseX, baseY);
|
||||
case 'curveto':
|
||||
return translateCurveToCommand(command, baseX, baseY);
|
||||
case 'closepath':
|
||||
default:
|
||||
return {
|
||||
command: 'close-path'
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const translateMoveToCommand = (command: MoveToCommand, baseX: number, baseY: number): Segment => {
|
||||
return {
|
||||
command: 'move-to',
|
||||
params: { x: command.x + baseX, y: command.y + baseY }
|
||||
};
|
||||
};
|
||||
|
||||
const translateLineToCommand = (command: LineToCommand, baseX: number, baseY: number): Segment => {
|
||||
return {
|
||||
command: 'line-to',
|
||||
params: { x: command.x + baseX, y: command.y + baseY }
|
||||
};
|
||||
};
|
||||
|
||||
const translateCurveToCommand = (
|
||||
command: CurveToCommand,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Segment => {
|
||||
return {
|
||||
command: 'curve-to',
|
||||
params: {
|
||||
c1x: command.x1 + baseX,
|
||||
c1y: command.y1 + baseY,
|
||||
c2x: command.x2 + baseX,
|
||||
c2y: command.y2 + baseY,
|
||||
x: command.x + baseX,
|
||||
y: command.y + baseY
|
||||
}
|
||||
};
|
||||
};
|
|
@ -1,23 +1,65 @@
|
|||
export const translateLineNode = (node: LineNode): VectorPaths => {
|
||||
const commands = [
|
||||
import { Command } from 'svg-path-parser';
|
||||
|
||||
import { applyInverseRotation, applyRotation, hasRotation } from '@plugin/utils';
|
||||
|
||||
import { Segment } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
import { translateCommandsToSegments } from '.';
|
||||
|
||||
export const translateLineNode = (node: LineNode, baseX: number, baseY: number): Segment[] => {
|
||||
if (!hasRotation(node.rotation) || !node.absoluteBoundingBox) {
|
||||
return translateCommandsToSegments(
|
||||
[
|
||||
{
|
||||
command: 'moveto',
|
||||
code: 'M',
|
||||
x: 0,
|
||||
y: 0
|
||||
y: 0,
|
||||
command: 'moveto',
|
||||
code: 'M'
|
||||
},
|
||||
{
|
||||
command: 'lineto',
|
||||
code: 'L',
|
||||
x: node.width,
|
||||
y: node.height
|
||||
y: 0,
|
||||
command: 'lineto',
|
||||
code: 'L'
|
||||
}
|
||||
],
|
||||
baseX + node.x,
|
||||
baseY + node.y
|
||||
);
|
||||
}
|
||||
|
||||
const startPoint = applyRotation(
|
||||
{ x: 0, y: 0 },
|
||||
node.absoluteTransform,
|
||||
node.absoluteBoundingBox
|
||||
);
|
||||
|
||||
const endPoint = applyRotation(
|
||||
{ x: node.width, y: 0 },
|
||||
node.absoluteTransform,
|
||||
node.absoluteBoundingBox
|
||||
);
|
||||
|
||||
const commands: Command[] = [
|
||||
{
|
||||
x: startPoint.x,
|
||||
y: startPoint.y,
|
||||
command: 'moveto',
|
||||
code: 'M'
|
||||
},
|
||||
{
|
||||
x: endPoint.x,
|
||||
y: endPoint.y,
|
||||
command: 'lineto',
|
||||
code: 'L'
|
||||
}
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
windingRule: 'NONE',
|
||||
data: commands.reduce((acc, { code, x, y }) => acc + `${code} ${x} ${y}`, '')
|
||||
}
|
||||
];
|
||||
const referencePoint = applyInverseRotation(
|
||||
{ x: node.x, y: node.y },
|
||||
node.absoluteTransform,
|
||||
node.absoluteBoundingBox
|
||||
);
|
||||
|
||||
return translateCommandsToSegments(commands, baseX + referencePoint.x, baseY + referencePoint.y);
|
||||
};
|
||||
|
|
|
@ -1,73 +1,19 @@
|
|||
import { Command, CurveToCommand, LineToCommand, MoveToCommand, parseSVG } from 'svg-path-parser';
|
||||
import { parseSVG } from 'svg-path-parser';
|
||||
|
||||
import { Segment } from '@ui/lib/types/shapes/pathShape';
|
||||
|
||||
import { translateCommandsToSegments } from '.';
|
||||
|
||||
export const translateVectorPaths = (
|
||||
paths: VectorPaths,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Segment[] => {
|
||||
let segments: Segment[] = [];
|
||||
const segments: Segment[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const normalizedPaths = parseSVG(path.data);
|
||||
|
||||
segments = [...segments, ...translateCommandsToSegments(normalizedPaths, baseX, baseY)];
|
||||
segments.push(...translateCommandsToSegments(parseSVG(path.data), baseX, baseY));
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const translateCommandsToSegments = (
|
||||
commands: Command[],
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Segment[] => {
|
||||
return commands.map(command => {
|
||||
switch (command.command) {
|
||||
case 'moveto':
|
||||
return translateMoveToCommand(command, baseX, baseY);
|
||||
case 'lineto':
|
||||
return translateLineToCommand(command, baseX, baseY);
|
||||
case 'curveto':
|
||||
return translateCurveToCommand(command, baseX, baseY);
|
||||
case 'closepath':
|
||||
default:
|
||||
return {
|
||||
command: 'close-path'
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const translateMoveToCommand = (command: MoveToCommand, baseX: number, baseY: number): Segment => {
|
||||
return {
|
||||
command: 'move-to',
|
||||
params: { x: command.x + baseX, y: command.y + baseY }
|
||||
};
|
||||
};
|
||||
|
||||
const translateLineToCommand = (command: LineToCommand, baseX: number, baseY: number): Segment => {
|
||||
return {
|
||||
command: 'line-to',
|
||||
params: { x: command.x + baseX, y: command.y + baseY }
|
||||
};
|
||||
};
|
||||
|
||||
const translateCurveToCommand = (
|
||||
command: CurveToCommand,
|
||||
baseX: number,
|
||||
baseY: number
|
||||
): Segment => {
|
||||
return {
|
||||
command: 'curve-to',
|
||||
params: {
|
||||
c1x: command.x1 + baseX,
|
||||
c1y: command.y1 + baseY,
|
||||
c2x: command.x2 + baseX,
|
||||
c2y: command.y2 + baseY,
|
||||
x: command.x + baseX,
|
||||
y: command.y + baseY
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
40
plugin-src/utils/applyRotation.ts
Normal file
40
plugin-src/utils/applyRotation.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Point } from '@ui/lib/types/utils/point';
|
||||
|
||||
const ROTATION_TOLERANCE = 0.000001;
|
||||
|
||||
export const applyRotation = (point: Point, transform: Transform, boundingBox: Rect): Point => {
|
||||
const centerPoint = calculateCenter(boundingBox);
|
||||
|
||||
const rotatedPoint = applyMatrix(transform, {
|
||||
x: point.x - centerPoint.x,
|
||||
y: point.y - centerPoint.y
|
||||
});
|
||||
|
||||
return {
|
||||
x: centerPoint.x + rotatedPoint.x,
|
||||
y: centerPoint.y + rotatedPoint.y
|
||||
};
|
||||
};
|
||||
|
||||
export const applyInverseRotation = (
|
||||
point: Point,
|
||||
transform: Transform,
|
||||
boundingBox: Rect
|
||||
): Point => applyRotation(point, inverseMatrix(transform), boundingBox);
|
||||
|
||||
export const hasRotation = (rotation: number): boolean => Math.abs(rotation) > ROTATION_TOLERANCE;
|
||||
|
||||
const inverseMatrix = (matrix: Transform): Transform => [
|
||||
[matrix[0][0], matrix[1][0], matrix[0][2]],
|
||||
[matrix[0][1], matrix[1][1], matrix[1][2]]
|
||||
];
|
||||
|
||||
const applyMatrix = (matrix: Transform, point: Point): Point => ({
|
||||
x: point.x * matrix[0][0] + point.y * matrix[0][1],
|
||||
y: point.x * matrix[1][0] + point.y * matrix[1][1]
|
||||
});
|
||||
|
||||
const calculateCenter = (boundingBox: Rect): Point => ({
|
||||
x: boundingBox.x + boundingBox.width / 2,
|
||||
y: boundingBox.y + boundingBox.height / 2
|
||||
});
|
|
@ -1,4 +1,5 @@
|
|||
export * from './applyMatrixToPoint';
|
||||
export * from './applyRotation';
|
||||
export * from './calculateAdjustment';
|
||||
export * from './calculateLinearGradient';
|
||||
export * from './calculateRadialGradient';
|
||||
|
|
Loading…
Reference in a new issue