0
Fork 0
mirror of https://github.com/penpot/penpot-exporter-figma-plugin.git synced 2024-12-22 05:33:02 -05:00

Apply rotations to any figure (#168)

* Apply rotations to any figure

* add changelog

* apply rotations to curves too
This commit is contained in:
Jordi Sala Morales 2024-06-17 12:14:16 +02:00 committed by GitHub
parent 4591369e3c
commit 202e7f4fda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 246 additions and 196 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Implement rotation for any arbitrary figure

View file

@ -1,7 +1,21 @@
import { translateRotation, translateZeroRotation } from '@plugin/translators';
import { applyInverseRotation, hasRotation } from '@plugin/utils';
import { ShapeBaseAttributes, ShapeGeomAttributes } from '@ui/lib/types/shapes/shape';
export const transformRotation = (
node: LayoutMixin,
baseRotation: number
): Pick<ShapeBaseAttributes, 'transform' | 'transformInverse' | 'rotation'> => {
const rotation = node.rotation + baseRotation;
if (!hasRotation(rotation)) {
return translateZeroRotation();
}
return translateRotation(node.absoluteTransform, rotation);
};
export const transformRotationAndPosition = (
node: LayoutMixin,
baseRotation: number
@ -15,9 +29,7 @@ export const transformRotationAndPosition = (
return {
x,
y,
rotation,
transform: undefined,
transformInverse: undefined
...translateZeroRotation()
};
}
@ -29,22 +41,6 @@ export const transformRotationAndPosition = (
return {
...referencePoint,
rotation: -rotation < 0 ? -rotation + 360 : -rotation,
transform: {
a: node.absoluteTransform[0][0],
b: node.absoluteTransform[1][0],
c: node.absoluteTransform[0][1],
d: node.absoluteTransform[1][1],
e: 0,
f: 0
},
transformInverse: {
a: node.absoluteTransform[0][0],
b: node.absoluteTransform[0][1],
c: node.absoluteTransform[1][0],
d: node.absoluteTransform[1][1],
e: 0,
f: 0
}
...translateRotation(node.absoluteTransform, rotation)
};
};

View file

@ -9,11 +9,11 @@ import {
transformStrokesFromVector
} from '@plugin/transformers/partials';
import { translateFills } from '@plugin/translators/fills';
import { translateCommandsToSegments, translateWindingRule } from '@plugin/translators/vectors';
import { translateCommands, translateWindingRule } from '@plugin/translators/vectors';
import { PathShape } from '@ui/lib/types/shapes/pathShape';
export const transformVectorPaths = (node: VectorNode): PathShape[] => {
export const transformVectorPaths = (node: VectorNode, baseRotation: number): PathShape[] => {
const pathShapes = node.vectorPaths
.filter((vectorPath, index) => {
return (
@ -22,7 +22,7 @@ export const transformVectorPaths = (node: VectorNode): PathShape[] => {
);
})
.map((vectorPath, index) =>
transformVectorPath(node, vectorPath, (node.vectorNetwork.regions ?? [])[index])
transformVectorPath(node, vectorPath, (node.vectorNetwork.regions ?? [])[index], baseRotation)
);
const geometryShapes = node.fillGeometry
@ -32,7 +32,7 @@ export const transformVectorPaths = (node: VectorNode): PathShape[] => {
vectorPath => normalizePath(vectorPath.data) === normalizePath(geometry.data)
)
)
.map(geometry => transformVectorPath(node, geometry, undefined));
.map(geometry => transformVectorPath(node, geometry, undefined, baseRotation));
return [...geometryShapes, ...pathShapes];
};
@ -58,18 +58,15 @@ const nodeHasFills = (
const transformVectorPath = (
node: VectorNode,
vectorPath: VectorPath,
vectorRegion: VectorRegion | undefined
vectorRegion: VectorRegion | undefined,
baseRotation: number
): PathShape => {
const normalizedPaths = parseSVG(vectorPath.data);
return {
type: 'path',
name: 'svg-path',
content: translateCommandsToSegments(
normalizedPaths,
node.absoluteTransform[0][2],
node.absoluteTransform[1][2]
),
content: translateCommands(node, normalizedPaths, baseRotation),
fills:
vectorPath.windingRule === 'NONE' ? [] : translateFills(vectorRegion?.fills ?? node.fills),
svgAttrs: {

View file

@ -9,9 +9,10 @@ import {
transformSceneNode,
transformStrokes
} from '@plugin/transformers/partials';
import { translateLineNode } from '@plugin/translators/vectors';
import { translateCommands } from '@plugin/translators/vectors';
import { PathShape } from '@ui/lib/types/shapes/pathShape';
import { Segment } from '@ui/lib/types/shapes/pathShape';
/**
* In order to match the normal representation of a line in Penpot, we will assume that
@ -35,3 +36,24 @@ export const transformLineNode = (node: LineNode, baseRotation: number): PathSha
...transformOverrides(node)
};
};
const translateLineNode = (node: LineNode, baseRotation: number): Segment[] => {
return translateCommands(
node,
[
{
x: 0,
y: 0,
command: 'moveto',
code: 'M'
},
{
x: node.width,
y: 0,
command: 'lineto',
code: 'L'
}
],
baseRotation
);
};

View file

@ -1,3 +1,5 @@
import { parseSVG } from 'svg-path-parser';
import {
transformBlend,
transformConstraints,
@ -7,12 +9,13 @@ import {
transformLayoutAttributes,
transformOverrides,
transformProportion,
transformRotation,
transformSceneNode,
transformStrokes
} from '@plugin/transformers/partials';
import { translatePathNode } from '@plugin/translators/vectors';
import { translateCommands } from '@plugin/translators/vectors';
import { PathShape } from '@ui/lib/types/shapes/pathShape';
import { PathShape, Segment } from '@ui/lib/types/shapes/pathShape';
export const transformPathNode = (
node: StarNode | PolygonNode,
@ -29,8 +32,12 @@ export const transformPathNode = (
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node),
...transformRotation(node, baseRotation),
...transformLayoutAttributes(node),
...transformConstraints(node),
...transformOverrides(node)
};
};
const translatePathNode = (node: StarNode | PolygonNode, baseRotation: number): Segment[] =>
translateCommands(node, parseSVG(node.fillGeometry[0].data), baseRotation);

View file

@ -20,7 +20,7 @@ export const transformVectorNode = (
node: VectorNode,
baseRotation: number
): GroupShape | PathShape => {
const children = transformVectorPaths(node);
const children = transformVectorPaths(node, baseRotation);
if (children.length === 1) {
return {

View file

@ -4,5 +4,6 @@ export * from './translateBoolType';
export * from './translateChildren';
export * from './translateConstraints';
export * from './translateLayout';
export * from './translateRotation';
export * from './translateShadowEffects';
export * from './translateStrokes';

View file

@ -0,0 +1,33 @@
import { ShapeBaseAttributes } from '@ui/lib/types/shapes/shape';
export const translateRotation = (
transform: Transform,
rotation: number
): Pick<ShapeBaseAttributes, 'transform' | 'transformInverse' | 'rotation'> => ({
rotation: -rotation < 0 ? -rotation + 360 : -rotation,
transform: {
a: transform[0][0],
b: transform[1][0],
c: transform[0][1],
d: transform[1][1],
e: 0,
f: 0
},
transformInverse: {
a: transform[0][0],
b: transform[0][1],
c: transform[1][0],
d: transform[1][1],
e: 0,
f: 0
}
});
export const translateZeroRotation = (): Pick<
ShapeBaseAttributes,
'transform' | 'transformInverse' | 'rotation'
> => ({
rotation: 0,
transform: undefined,
transformInverse: undefined
});

View file

@ -1,4 +1,4 @@
export * from './translateCommandsToSegments';
export * from './translateLineNode';
export * from './translatePathNode';
export * from './translateCommands';
export * from './translateNonRotatedCommands';
export * from './translateRotatedCommands';
export * from './translateWindingRule';

View file

@ -0,0 +1,18 @@
import { Command } from 'svg-path-parser';
import { hasRotation } from '@plugin/utils';
import { translateNonRotatedCommands } from '.';
import { translateRotatedCommands } from './translateRotatedCommands';
export const translateCommands = (node: LayoutMixin, commands: Command[], baseRotation: number) => {
if (hasRotation(node.rotation + baseRotation) && node.absoluteBoundingBox) {
return translateRotatedCommands(commands, node.absoluteTransform, node.absoluteBoundingBox);
}
return translateNonRotatedCommands(
commands,
node.absoluteTransform[0][2],
node.absoluteTransform[1][2]
);
};

View file

@ -1,57 +0,0 @@
import { Command, CurveToCommand, LineToCommand, MoveToCommand } from 'svg-path-parser';
import { Segment } from '@ui/lib/types/shapes/pathShape';
export const translateCommandsToSegments = (
commands: Command[],
baseX: number = 0,
baseY: number = 0
): 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,63 +0,0 @@
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, baseRotation: number): Segment[] => {
const rotation = node.rotation + baseRotation;
const x = node.absoluteTransform[0][2];
const y = node.absoluteTransform[1][2];
if (!hasRotation(rotation) || !node.absoluteBoundingBox) {
return translateCommandsToSegments(
[
{
x: 0,
y: 0,
command: 'moveto',
code: 'M'
},
{
x: node.width,
y: 0,
command: 'lineto',
code: 'L'
}
],
x,
y
);
}
const referencePoint = applyInverseRotation(
{ x, y },
node.absoluteTransform,
node.absoluteBoundingBox
);
const endPoint = applyRotation(
{ x: referencePoint.x + node.width, y: referencePoint.y },
node.absoluteTransform,
node.absoluteBoundingBox
);
const commands: Command[] = [
{
x,
y,
command: 'moveto',
code: 'M'
},
{
x: endPoint.x,
y: endPoint.y,
command: 'lineto',
code: 'L'
}
];
return translateCommandsToSegments(commands);
};

View file

@ -0,0 +1,59 @@
import { Command, CurveToCommand, LineToCommand, MoveToCommand } from 'svg-path-parser';
import { Segment } from '@ui/lib/types/shapes/pathShape';
export const translateNonRotatedCommands = (
commands: Command[],
baseX: number = 0,
baseY: number = 0
): Segment[] => {
return commands.map(command => translateNonRotatedCommand(command, baseX, baseY));
};
export const translateNonRotatedCommand = (
command: Command,
baseX: number,
baseY: number
): Segment => {
switch (command.command) {
case 'moveto':
return translateMoveTo(command, baseX, baseY);
case 'lineto':
return translateLineTo(command, baseX, baseY);
case 'curveto':
return translateCurveTo(command, baseX, baseY);
case 'closepath':
default:
return {
command: 'close-path'
};
}
};
const translateMoveTo = (command: MoveToCommand, baseX: number, baseY: number): Segment => {
return {
command: 'move-to',
params: { x: command.x + baseX, y: command.y + baseY }
};
};
const translateLineTo = (command: LineToCommand, baseX: number, baseY: number): Segment => {
return {
command: 'line-to',
params: { x: command.x + baseX, y: command.y + baseY }
};
};
const translateCurveTo = (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,37 +0,0 @@
import { parseSVG } from 'svg-path-parser';
import { applyInverseRotation, hasRotation } from '@plugin/utils';
import { Segment } from '@ui/lib/types/shapes/pathShape';
import { Point } from '@ui/lib/types/utils/point';
import { translateCommandsToSegments } from '.';
export const translatePathNode = (
node: StarNode | PolygonNode,
baseRotation: number
): Segment[] => {
let referencePoint: Point = {
x: node.absoluteTransform[0][2],
y: node.absoluteTransform[1][2]
};
if (hasRotation(node.rotation + baseRotation) && node.absoluteBoundingBox) {
referencePoint = applyInverseRotation(
{ x: referencePoint.x, y: referencePoint.y },
node.absoluteTransform,
node.absoluteBoundingBox
);
}
const segments: Segment[] = [];
for (const path of node.fillGeometry) {
segments.push(...translateVectorPath(path, referencePoint.x, referencePoint.y));
}
return segments;
};
const translateVectorPath = (path: VectorPath, baseX: number, baseY: number): Segment[] =>
translateCommandsToSegments(parseSVG(path.data), baseX, baseY);

View file

@ -0,0 +1,31 @@
import { Command } from 'svg-path-parser';
import { applyInverseRotation, applyRotationToSegment } from '@plugin/utils';
import { ClosePath, Segment } from '@ui/lib/types/shapes/pathShape';
import { translateNonRotatedCommand } from '.';
const isClosePath = (segment: Segment): segment is ClosePath => segment.command === 'close-path';
export const translateRotatedCommands = (
commands: Command[],
transform: Transform,
boundingBox: Rect
): Segment[] => {
const referencePoint = applyInverseRotation(
{ x: transform[0][2], y: transform[1][2] },
transform,
boundingBox
);
return commands.map(command => {
const segment = translateNonRotatedCommand(command, referencePoint.x, referencePoint.y);
if (isClosePath(segment)) {
return segment;
}
return applyRotationToSegment(segment, transform, boundingBox);
});
};

View file

@ -1,3 +1,4 @@
import { ClosePath, CurveTo, Segment } from '@ui/lib/types/shapes/pathShape';
import { Point } from '@ui/lib/types/utils/point';
const ROTATION_TOLERANCE = 0.000001;
@ -16,6 +17,41 @@ export const applyRotation = (point: Point, transform: Transform, boundingBox: R
};
};
export const applyRotationToSegment = (
segment: Exclude<Segment, ClosePath>,
transform: Transform,
boundingBox: Rect
): Segment => {
const rotated = applyRotation(
{ x: segment.params.x, y: segment.params.y },
transform,
boundingBox
);
if (isCurveTo(segment)) {
const curve1 = applyRotation(
{ x: segment.params.c1x, y: segment.params.c1y },
transform,
boundingBox
);
const curve2 = applyRotation(
{ x: segment.params.c2x, y: segment.params.c2y },
transform,
boundingBox
);
segment.params.c1x = curve1.x;
segment.params.c1y = curve1.y;
segment.params.c2x = curve2.x;
segment.params.c2y = curve2.y;
}
segment.params.x = rotated.x;
segment.params.y = rotated.y;
return segment;
};
export const applyInverseRotation = (
point: Point,
transform: Transform,
@ -38,3 +74,5 @@ const calculateCenter = (boundingBox: Rect): Point => ({
x: boundingBox.x + boundingBox.width / 2,
y: boundingBox.y + boundingBox.height / 2
});
const isCurveTo = (segment: Segment): segment is CurveTo => segment.command === 'curve-to';

View file

@ -41,7 +41,7 @@ type LineTo = {
};
};
type ClosePath = {
export type ClosePath = {
command: 'close-path' | typeof VECTOR_CLOSE_PATH;
};
@ -53,7 +53,7 @@ type MoveTo = {
};
};
type CurveTo = {
export type CurveTo = {
command: 'curve-to' | typeof VECTOR_CURVE_TO;
params: {
x: number;