0
Fork 0
mirror of https://github.com/penpot/penpot-exporter-figma-plugin.git synced 2024-12-22 05:33:02 -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:
Jordi Sala Morales 2024-06-14 10:33:23 +02:00 committed by GitHub
parent b85a4f7279
commit af81fc7e92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 235 additions and 138 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Apply rotations to lines

View file

@ -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';

View file

@ -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,

View file

@ -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
};
};

View file

@ -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, '');
};

View 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)
};
};

View file

@ -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),

View file

@ -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':

View file

@ -1,3 +1,4 @@
export * from './translateCommandsToSegments';
export * from './translateLineNode';
export * from './translateVectorPaths';
export * from './translateWindingRule';

View file

@ -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
}
};
};

View file

@ -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(
[
{
x: 0,
y: 0,
command: 'moveto',
code: 'M'
},
{
x: node.width,
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: 0,
y: 0
code: 'M'
},
{
x: endPoint.x,
y: endPoint.y,
command: 'lineto',
code: 'L',
x: node.width,
y: node.height
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);
};

View file

@ -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
}
};
};

View 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
});

View file

@ -1,4 +1,5 @@
export * from './applyMatrixToPoint';
export * from './applyRotation';
export * from './calculateAdjustment';
export * from './calculateLinearGradient';
export * from './calculateRadialGradient';