mirror of
https://github.com/penpot/penpot.git
synced 2026-03-29 15:52:17 +02:00
🎉 Add stroke to path
This commit is contained in:
committed by
Belén Albeza
parent
ab404340f8
commit
1641eec672
@@ -165,7 +165,8 @@
|
||||
:nitrate
|
||||
|
||||
:mcp
|
||||
:background-blur})
|
||||
:background-blur
|
||||
:stroke-path})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
64
frontend/playwright/data/workspace/shapes-with-strokes.json
Normal file
64
frontend/playwright/data/workspace/shapes-with-strokes.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"fdata/objects-map",
|
||||
"fdata/shape-data-type",
|
||||
"render-wasm/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"components/v2"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "stroke_to_path",
|
||||
"~:revn": 34,
|
||||
"~:modified-at": "~m1774513054500",
|
||||
"~:vern": 0,
|
||||
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a27f",
|
||||
"~:is-shared": false,
|
||||
"~:version": 67,
|
||||
"~:project-id": "~ud7430f09-4f59-8049-8007-6277bb765abd",
|
||||
"~:created-at": "~m1774512709966",
|
||||
"~:backend": "legacy-db",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~ud9a19a61-ed94-818f-8007-c590e153a280"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~ud9a19a61-ed94-818f-8007-c590e153a280": {
|
||||
"~:objects": {
|
||||
"~#penpot/objects-map/v2": {
|
||||
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u25475404-d141-8046-8007-c590e43ccb53\",\"~u25475404-d141-8046-8007-c590e8c99f60\",\"~u25475404-d141-8046-8007-c590eac93f27\",\"~u25475404-d141-8046-8007-c5911aba8cc3\",\"~u25475404-d141-8046-8007-c5912a27368c\",\"~u25475404-d141-8046-8007-c5912e12bb56\",\"~u334565ad-19c1-804c-8007-c591d0ab5634\",\"~u334565ad-19c1-804c-8007-c591fecf5ce9\",\"~u334565ad-19c1-804c-8007-c59204711ada\"]]]",
|
||||
"~u25475404-d141-8046-8007-c590e43ccb53": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-inner\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",482,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",742,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",742,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",482,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590e43ccb53\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",482,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",482,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",482,\"~:y1\",343,\"~:x2\",742,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
|
||||
"~u334565ad-19c1-804c-8007-c591d0ab5634": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAACjiDFEYtF3RAMAAABuujBE8KN2RHuFL0QVwnZEe4UvRBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAAEaEAkSSwHZEAwAAAEaEAkSSwHZEd0QBRHKmdkShdwBEBNN3RAMAAADUVv9Dtv94RIUiAETlKXpEhSIAROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAEB7CkTT5YlEAwAAAG08DkSaq49EQBkjRC67j0SGjydE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAXdsxROotekQDAAAAXdsxROotekS3VjJEtv94RKOIMURi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-inner\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",512.0000270184611,\"~:y\",987.0000230312763]],[\"^=\",[\"^ \",\"~:x\",711.9999730908373,\"~:y\",987.0000230312763]],[\"^=\",[\"^ \",\"~:x\",711.9999730908373,\"~:y\",1137.9999747273034]],[\"^=\",[\"^ \",\"~:x\",512.0000270184611,\"~:y\",1137.9999747273034]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c591d0ab5634\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",512.0000270184612,\"~:y\",987.0000230312764,\"^7\",199.99994607237613,\"^E\",150.9999516960272,\"^F\",512.0000270184612,\"^G\",987.0000230312764,\"^H\",711.9999730908373,\"^I\",1137.9999747273037]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
|
||||
"~u25475404-d141-8046-8007-c5912e12bb56": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-outer\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1324.000000655651,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1324.000000655651,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5912e12bb56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",1123.9999994039536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",1123.9999994039536,\"~:y1\",650.0000177025795,\"~:x2\",1324.000000655651,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]",
|
||||
"~u334565ad-19c1-804c-8007-c59204711ada": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAABSRKVEYtF3RAMAAAA43aRE8KN2RL5CpEQVwnZEvkKkRBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAACPCjUSSwHZEAwAAACPCjUSSwHZEOyKNRHKmdkTQu4xEBNN3RAMAAAC0VYxEtv94REKRjETlKXpEQpGMROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAKC9kUTT5YlEAwAAADaek0Saq49EoAyeRC67j0TDR6BE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAr22lROotekQDAAAAr22lROotekRcq6VEtv94RFJEpURi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-outer\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",987.0000591039244]],[\"^=\",[\"^ \",\"~:x\",1324.0000326954682,\"~:y\",987.0000591039244]],[\"^=\",[\"^ \",\"~:x\",1324.0000326954682,\"~:y\",1138.0000107999515]],[\"^=\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",1138.0000107999515]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c59204711ada\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",987.0000591039245,\"^7\",200.0000652816334,\"^E\",150.9999516960272,\"^F\",1123.9999674138348,\"^G\",987.0000591039245,\"^H\",1324.0000326954682,\"^I\",1138.0000107999517]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
|
||||
"~u25475404-d141-8046-8007-c5911aba8cc3": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-inner\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",712.0000006556511,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",712.0000006556511,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5911aba8cc3\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",511.99999940395355,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",511.99999940395355,\"~:y1\",650.0000177025795,\"~:x2\",712.0000006556511,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]",
|
||||
"~u25475404-d141-8046-8007-c590e8c99f60": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-center\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",788,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1048,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1048,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",788,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590e8c99f60\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",788,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",788,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",788,\"~:y1\",343,\"~:x2\",1048,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
|
||||
"~u25475404-d141-8046-8007-c590eac93f27": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-outer\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1094,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1354,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1354,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",1094,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590eac93f27\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",1094,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1094,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",1094,\"~:y1\",343,\"~:x2\",1354,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
|
||||
"~u334565ad-19c1-804c-8007-c591fecf5ce9": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAACkCH5EYtF3RAMAAABwOn1E8KN2RHwFfEQVwnZEfAV8RBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAAEYET0SSwHZEAwAAAEYET0SSwHZEd8RNRHKmdkSg90xEBNN3RAMAAABpK0xEtv94RISiTETlKXpEhKJMROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAED7VkTT5YlEAwAAAG28WkSaq49EQZlvRC67j0SHD3RE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAXlt+ROotekQDAAAAXlt+ROotekS41n5Etv94RKQIfkRi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-center\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",817.9999676522441,\"~:y\",987.0000410676004]],[\"^=\",[\"^ \",\"~:x\",1018.0000329338777,\"~:y\",987.0000410676004]],[\"^=\",[\"^ \",\"~:x\",1018.0000329338777,\"~:y\",1137.9999927636275]],[\"^=\",[\"^ \",\"~:x\",817.9999676522441,\"~:y\",1137.9999927636275]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c591fecf5ce9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",817.9999676522442,\"~:y\",987.0000410676005,\"^7\",200.0000652816335,\"^E\",150.9999516960272,\"^F\",817.9999676522442,\"^G\",987.0000410676005,\"^H\",1018.0000329338777,\"^I\",1137.9999927636277]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
|
||||
"~u25475404-d141-8046-8007-c5912a27368c": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-center\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1018.0000054240227,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1018.0000054240227,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5912a27368c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",818.0000041723251,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",818.0000041723251,\"~:y1\",650.0000177025795,\"~:x2\",1018.0000054240227,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]"
|
||||
}
|
||||
},
|
||||
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a280",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a27f",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
80
frontend/playwright/ui/specs/stroke-to-path.spec.js
Normal file
80
frontend/playwright/ui/specs/stroke-to-path.spec.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
await WasmWorkspacePage.mockConfigFlags(page, [
|
||||
"enable-feature-render-wasm",
|
||||
"enable-render-wasm-dpr",
|
||||
"enable-stroke-path",
|
||||
]);
|
||||
});
|
||||
|
||||
async function setupShapesWithStrokes(page) {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("workspace/shapes-with-strokes.json");
|
||||
await workspace.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/shapes-with-strokes.json",
|
||||
);
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.waitForFirstRender();
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function strokeToPath(workspace, page, shapeName, expectedStrokeName) {
|
||||
await workspace.clickLayers();
|
||||
await workspace.clickLeafLayer(shapeName, { button: "right" });
|
||||
await page.getByText("Stroke to path").click();
|
||||
|
||||
await expect(
|
||||
workspace.layers.getByText(expectedStrokeName),
|
||||
).toBeVisible();
|
||||
await expect(workspace.layers.getByText(shapeName).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Stroke to path: rectangle with center stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "rectangle-center", "rectangle-center (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: rectangle with inner stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "rectangle-inner", "rectangle-inner (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: rectangle with outer stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "rectangle-outer", "rectangle-outer (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: circle with center stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "ellipse-center", "ellipse-center (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: circle with inner stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "ellipse-inner", "ellipse-inner (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: circle with outer stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "ellipse-outer", "ellipse-outer (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: path with center stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "path-center", "path-center (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: path with inner stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "path-inner", "path-inner (stroke)");
|
||||
});
|
||||
|
||||
test("Stroke to path: path with outer stroke", async ({ page }) => {
|
||||
const workspace = await setupShapesWithStrokes(page);
|
||||
await strokeToPath(workspace, page, "path-outer", "path-outer (stroke)");
|
||||
});
|
||||
@@ -1508,6 +1508,7 @@
|
||||
|
||||
;; Shapes to path
|
||||
(dm/export dwps/convert-selected-to-path)
|
||||
(dm/export dwps/convert-selected-strokes-to-path)
|
||||
|
||||
;; Guides
|
||||
(dm/export dwgu/update-guides)
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cph]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.features :as features]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -81,3 +85,135 @@
|
||||
(pcb/remove-objects children-ids))]
|
||||
|
||||
(rx/of (dch/commit-changes changes))))))))
|
||||
|
||||
(defn- stroke->fill
|
||||
"Converts stroke color properties to fill color properties."
|
||||
[stroke]
|
||||
(d/without-nils
|
||||
{:fill-color (:stroke-color stroke)
|
||||
:fill-opacity (:stroke-opacity stroke)
|
||||
:fill-color-gradient (:stroke-color-gradient stroke)
|
||||
:fill-image (:stroke-image stroke)
|
||||
:fill-color-ref-id (:stroke-color-ref-id stroke)
|
||||
:fill-color-ref-file (:stroke-color-ref-file stroke)}))
|
||||
|
||||
(defn- make-stroke-paths
|
||||
"Given a shape with strokes, returns a vector of new path shapes
|
||||
created from each stroke. Uses the provided parent-id and frame-id."
|
||||
[shape parent-id frame-id]
|
||||
(into []
|
||||
(keep-indexed
|
||||
(fn [idx stroke]
|
||||
(let [content (wasm.api/stroke-to-path (:id shape) idx)]
|
||||
(when (some? content)
|
||||
(cts/setup-shape
|
||||
{:type :path
|
||||
:id (uuid/next)
|
||||
:name (str (:name shape) " (stroke)")
|
||||
:parent-id parent-id
|
||||
:frame-id frame-id
|
||||
:content content
|
||||
:fills [(stroke->fill stroke)]
|
||||
:strokes []})))))
|
||||
(:strokes shape)))
|
||||
|
||||
(defn convert-selected-strokes-to-path
|
||||
"For each selected shape, converts each stroke into a new sibling
|
||||
path shape. When the selected shape is a group/frame with stroked
|
||||
descendants, a new group is created as a sibling containing all
|
||||
the stroke paths. Strokes are then removed from processed shapes."
|
||||
([]
|
||||
(convert-selected-strokes-to-path nil))
|
||||
([ids]
|
||||
(ptk/reify ::convert-selected-strokes-to-path
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(when (features/active-feature? state "render-wasm/v1")
|
||||
(let [page-id (:current-page-id state)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
selected (->> (or ids (dsh/lookup-selected state))
|
||||
(remove #(ctn/has-any-copy-parent? objects (get objects %))))
|
||||
|
||||
result
|
||||
(reduce
|
||||
(fn [acc shape-id]
|
||||
(let [shape (get objects shape-id)]
|
||||
(if (seq (:strokes shape))
|
||||
;; Shape itself has strokes: create stroke paths as siblings
|
||||
(let [position (cph/get-position-on-parent objects shape-id)
|
||||
new-shapes (make-stroke-paths shape (:parent-id shape) (:frame-id shape))]
|
||||
(-> acc
|
||||
(update :entries into (map-indexed #(hash-map :new-shape %2 :index (+ (inc position) %1)) new-shapes))
|
||||
(update :updated-ids conj shape-id)))
|
||||
|
||||
;; Check descendants for strokes (groups, SVGs, etc.)
|
||||
(let [child-ids (->> (cph/get-children-ids objects shape-id)
|
||||
(filter #(seq (:strokes (get objects %)))))
|
||||
group-id (uuid/next)
|
||||
new-shapes (into []
|
||||
(mapcat (fn [cid]
|
||||
(make-stroke-paths (get objects cid)
|
||||
group-id
|
||||
(:frame-id shape))))
|
||||
child-ids)]
|
||||
(if (seq new-shapes)
|
||||
;; Wrap all stroke paths in a new group
|
||||
(let [position (cph/get-position-on-parent objects shape-id)
|
||||
selrect (gsh/shapes->rect new-shapes)
|
||||
group (cts/setup-shape
|
||||
{:id group-id
|
||||
:type :group
|
||||
:name (str (:name shape) " (strokes)")
|
||||
:shapes (mapv :id new-shapes)
|
||||
:selrect selrect
|
||||
:x (:x selrect)
|
||||
:y (:y selrect)
|
||||
:width (:width selrect)
|
||||
:height (:height selrect)
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)})]
|
||||
(-> acc
|
||||
(update :groups conj {:group group :children new-shapes :index (inc position)})
|
||||
(update :updated-ids into child-ids)))
|
||||
acc)))))
|
||||
{:entries []
|
||||
:groups []
|
||||
:updated-ids []}
|
||||
selected)
|
||||
|
||||
new-shape-ids (into []
|
||||
(concat
|
||||
(map (comp :id :new-shape) (:entries result))
|
||||
(map (comp :id :group) (:groups result))))
|
||||
|
||||
changes
|
||||
(as-> (pcb/empty-changes it page-id) changes
|
||||
(pcb/with-objects changes objects)
|
||||
|
||||
;; Add ungrouped stroke path shapes as siblings
|
||||
(reduce
|
||||
(fn [changes {:keys [new-shape index]}]
|
||||
(pcb/add-object changes new-shape {:index index}))
|
||||
changes
|
||||
(:entries result))
|
||||
|
||||
;; Add groups with their stroke path children
|
||||
(reduce
|
||||
(fn [changes {:keys [group children index]}]
|
||||
(as-> changes changes
|
||||
(pcb/add-object changes group {:index index})
|
||||
(reduce
|
||||
(fn [changes child]
|
||||
(pcb/add-object changes child {:parent-id (:id group)}))
|
||||
changes
|
||||
children)))
|
||||
changes
|
||||
(:groups result))
|
||||
|
||||
;; Remove strokes from original shapes
|
||||
(pcb/update-shapes changes
|
||||
(:updated-ids result)
|
||||
(fn [shape] (assoc shape :strokes []))))]
|
||||
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(dws/select-shapes (into (d/ordered-set) new-shape-ids)))))))))
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.shortcuts :as sc]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
@@ -411,7 +412,7 @@
|
||||
(mf/defc context-menu-path*
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [shapes disable-flatten disable-booleans]}]
|
||||
[{:keys [shapes objects disable-flatten disable-booleans]}]
|
||||
(let [multiple? (> (count shapes) 1)
|
||||
single? (= (count shapes) 1)
|
||||
|
||||
@@ -424,8 +425,17 @@
|
||||
is-bool? (and single? has-bool?)
|
||||
is-frame? (and single? has-frame?)
|
||||
|
||||
has-strokes? (or (->> shapes (d/seek #(seq (:strokes %))))
|
||||
(when objects
|
||||
(->> shapes
|
||||
(d/seek
|
||||
(fn [shape]
|
||||
(->> (cfh/get-children-ids objects (:id shape))
|
||||
(d/seek #(seq (:strokes (get objects %))))))))))
|
||||
|
||||
do-start-editing (fn [] (timers/schedule #(st/emit! (dw/start-editing-selected))))
|
||||
do-transform-to-path #(st/emit! (dw/convert-selected-to-path))
|
||||
do-strokes-to-path #(st/emit! (dw/convert-selected-strokes-to-path))
|
||||
|
||||
make-do-bool
|
||||
(fn [bool-type]
|
||||
@@ -448,6 +458,12 @@
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
|
||||
:on-click do-transform-to-path}])
|
||||
|
||||
(when (and has-strokes?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
(contains? cf/flags :stroke-path))
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.stroke-to-path")
|
||||
:on-click do-strokes-to-path}])
|
||||
|
||||
(when (and (not has-frame?)
|
||||
(not disable-booleans)
|
||||
(or multiple? (and single? (or is-group? is-bool?))))
|
||||
@@ -652,6 +668,7 @@
|
||||
is-not-variant-container? (->> shapes (d/seek #(not (ctk/is-variant-container? %))))
|
||||
props (mf/props
|
||||
{:shapes shapes
|
||||
:objects objects
|
||||
:disable-booleans disable-booleans
|
||||
:disable-flatten disable-flatten})]
|
||||
(when-not (empty? shapes)
|
||||
|
||||
@@ -1540,6 +1540,25 @@
|
||||
(mem/free)
|
||||
content))
|
||||
|
||||
(defn stroke-to-path
|
||||
"Converts a shape's stroke at the given index into a filled path.
|
||||
Returns the stroke outline as PathData content."
|
||||
[id stroke-index]
|
||||
(use-shape id)
|
||||
(let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index)
|
||||
(mem/->offset-32))
|
||||
heap (mem/get-heap-u32)
|
||||
length (aget heap offset)]
|
||||
(if (pos? length)
|
||||
(let [data (mem/slice heap
|
||||
(+ offset 1)
|
||||
(* length path.impl/SEGMENT-U32-SIZE))
|
||||
content (path/from-bytes data)]
|
||||
(mem/free)
|
||||
content)
|
||||
(do (mem/free)
|
||||
nil))))
|
||||
|
||||
(defn calculate-bool*
|
||||
[bool-type ids]
|
||||
(let [size (mem/get-alloc-size ids UUID-U8-SIZE)
|
||||
|
||||
@@ -7667,6 +7667,10 @@ msgstr "Exclude"
|
||||
msgid "workspace.shape.menu.flatten"
|
||||
msgstr "Flatten"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs
|
||||
msgid "workspace.shape.menu.stroke-to-path"
|
||||
msgstr "Stroke to path"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs:299
|
||||
msgid "workspace.shape.menu.flip-horizontal"
|
||||
msgstr "Flip horizontal"
|
||||
|
||||
@@ -7593,6 +7593,10 @@ msgstr "Exclusión"
|
||||
msgid "workspace.shape.menu.flatten"
|
||||
msgstr "Aplanar"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs
|
||||
msgid "workspace.shape.menu.stroke-to-path"
|
||||
msgstr "Borde a ruta"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs:299
|
||||
msgid "workspace.shape.menu.flip-horizontal"
|
||||
msgstr "Voltear horizontal"
|
||||
|
||||
@@ -22,6 +22,7 @@ mod paths;
|
||||
mod rects;
|
||||
mod shadows;
|
||||
mod shape_to_path;
|
||||
mod stroke_paths;
|
||||
mod strokes;
|
||||
mod svg_attrs;
|
||||
mod svgraw;
|
||||
@@ -43,6 +44,7 @@ pub use paths::*;
|
||||
pub use rects::*;
|
||||
pub use shadows::*;
|
||||
pub use shape_to_path::*;
|
||||
pub use stroke_paths::*;
|
||||
pub use strokes::*;
|
||||
pub use svg_attrs::*;
|
||||
pub use svgraw::*;
|
||||
|
||||
@@ -114,6 +114,109 @@ impl Path {
|
||||
Path::new(segments)
|
||||
}
|
||||
|
||||
/// Like `from_skia_path` but properly converts conics to cubic beziers
|
||||
/// (using Skia's conic-to-quad + quad-to-cubic elevation). Use this when
|
||||
/// accurate curve conversion matters (e.g. stroke-to-path on circles).
|
||||
pub fn from_skia_path_accurate(path: skia::Path) -> Self {
|
||||
let verbs = path.verbs();
|
||||
let points = path.points();
|
||||
let conic_weights = path.conic_weights();
|
||||
|
||||
let mut segments = Vec::new();
|
||||
let mut current_point = 0;
|
||||
let mut current_conic = 0;
|
||||
let mut last_point = skia::Point::new(0.0, 0.0);
|
||||
|
||||
for verb in verbs {
|
||||
match verb {
|
||||
skia::PathVerb::Move => {
|
||||
let p = points[current_point];
|
||||
segments.push(Segment::MoveTo((p.x, p.y)));
|
||||
last_point = p;
|
||||
current_point += 1;
|
||||
}
|
||||
skia::PathVerb::Line => {
|
||||
let p = points[current_point];
|
||||
segments.push(Segment::LineTo((p.x, p.y)));
|
||||
last_point = p;
|
||||
current_point += 1;
|
||||
}
|
||||
skia::PathVerb::Quad => {
|
||||
let ctrl = points[current_point];
|
||||
let end = points[current_point + 1];
|
||||
let cp1x = last_point.x + (2.0 / 3.0) * (ctrl.x - last_point.x);
|
||||
let cp1y = last_point.y + (2.0 / 3.0) * (ctrl.y - last_point.y);
|
||||
let cp2x = end.x + (2.0 / 3.0) * (ctrl.x - end.x);
|
||||
let cp2y = end.y + (2.0 / 3.0) * (ctrl.y - end.y);
|
||||
segments.push(Segment::CurveTo((
|
||||
(cp1x, cp1y),
|
||||
(cp2x, cp2y),
|
||||
(end.x, end.y),
|
||||
)));
|
||||
last_point = end;
|
||||
current_point += 2;
|
||||
}
|
||||
skia::PathVerb::Conic => {
|
||||
let ctrl = points[current_point];
|
||||
let end = points[current_point + 1];
|
||||
let w = conic_weights[current_conic];
|
||||
current_conic += 1;
|
||||
|
||||
// pow2=0: 1 quad per conic. A circle (4 conics) becomes
|
||||
// 4 cubics, matching the standard bezier approximation.
|
||||
const POW2: usize = 0;
|
||||
let quad_count = 1 << POW2;
|
||||
let pts_count = 1 + 2 * quad_count;
|
||||
let mut quad_pts = vec![skia::Point::default(); pts_count];
|
||||
if skia::Path::convert_conic_to_quads(
|
||||
last_point,
|
||||
ctrl,
|
||||
end,
|
||||
w,
|
||||
&mut quad_pts,
|
||||
POW2,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let mut qp = last_point;
|
||||
for i in 0..quad_count {
|
||||
let qctrl = quad_pts[1 + i * 2];
|
||||
let qend = quad_pts[2 + i * 2];
|
||||
let cp1x = qp.x + (2.0 / 3.0) * (qctrl.x - qp.x);
|
||||
let cp1y = qp.y + (2.0 / 3.0) * (qctrl.y - qp.y);
|
||||
let cp2x = qend.x + (2.0 / 3.0) * (qctrl.x - qend.x);
|
||||
let cp2y = qend.y + (2.0 / 3.0) * (qctrl.y - qend.y);
|
||||
segments.push(Segment::CurveTo((
|
||||
(cp1x, cp1y),
|
||||
(cp2x, cp2y),
|
||||
(qend.x, qend.y),
|
||||
)));
|
||||
qp = qend;
|
||||
}
|
||||
last_point = qp;
|
||||
} else {
|
||||
segments.push(Segment::LineTo((end.x, end.y)));
|
||||
last_point = end;
|
||||
}
|
||||
current_point += 2;
|
||||
}
|
||||
skia::PathVerb::Cubic => {
|
||||
let p1 = points[current_point];
|
||||
let p2 = points[current_point + 1];
|
||||
let p3 = points[current_point + 2];
|
||||
segments.push(Segment::CurveTo(((p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y))));
|
||||
last_point = p3;
|
||||
current_point += 3;
|
||||
}
|
||||
skia::PathVerb::Close => {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Path::new(segments)
|
||||
}
|
||||
|
||||
pub fn to_skia_path(&self) -> skia::Path {
|
||||
self.skia_path.snapshot()
|
||||
}
|
||||
|
||||
87
render-wasm/src/shapes/stroke_paths.rs
Normal file
87
render-wasm/src/shapes/stroke_paths.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use skia_safe::{self as skia};
|
||||
|
||||
use super::paths::Path;
|
||||
use super::strokes::{Stroke, StrokeKind};
|
||||
use super::svg_attrs::SvgAttrs;
|
||||
use crate::math::Rect;
|
||||
|
||||
/// Converts a stroke into a filled path outline.
|
||||
///
|
||||
/// Uses Skia's `fill_path_with_paint` to expand the stroke into a filled region,
|
||||
/// then clips it via boolean ops for inner/outer alignment. The optional
|
||||
/// `path_transform` maps from local shape coords to the drawing space (and back).
|
||||
pub fn stroke_to_path(
|
||||
stroke: &Stroke,
|
||||
shape_path: &Path,
|
||||
path_transform: Option<&skia::Matrix>,
|
||||
selrect: &Rect,
|
||||
svg_attrs: Option<&SvgAttrs>,
|
||||
) -> Option<Path> {
|
||||
let skia_shape_path = shape_path.to_skia_path();
|
||||
|
||||
let transformed_shape_path = if let Some(pt) = path_transform {
|
||||
skia_shape_path.make_transform(pt)
|
||||
} else {
|
||||
skia_shape_path.clone()
|
||||
};
|
||||
|
||||
let is_open = shape_path.is_open();
|
||||
let mut paint = stroke.to_paint(selrect, svg_attrs, true);
|
||||
|
||||
let render_kind = stroke.render_kind(is_open);
|
||||
if render_kind != StrokeKind::Center {
|
||||
paint.set_stroke_width(stroke.width * 2.0);
|
||||
}
|
||||
|
||||
let mut stroke_outline = skia::Path::default();
|
||||
let success = skia::path_utils::fill_path_with_paint(
|
||||
&transformed_shape_path,
|
||||
&paint,
|
||||
&mut stroke_outline,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if !success {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For inner/outer strokes, use boolean ops to clip
|
||||
// the 2×-width stroke outline to the correct region.
|
||||
// Set EvenOdd to preserve the annular ring's inner hole,
|
||||
// then as_winding() on the result fixes contour winding
|
||||
// for Penpot's NonZero fill rule.
|
||||
let final_path = match render_kind {
|
||||
StrokeKind::Inner => {
|
||||
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
|
||||
let inner = stroke_outline
|
||||
.op(&transformed_shape_path, skia::PathOp::Intersect)
|
||||
.unwrap_or(stroke_outline);
|
||||
inner.as_winding().unwrap_or(inner)
|
||||
}
|
||||
StrokeKind::Outer => {
|
||||
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
|
||||
let outer = stroke_outline
|
||||
.op(&transformed_shape_path, skia::PathOp::Difference)
|
||||
.unwrap_or(stroke_outline);
|
||||
outer.as_winding().unwrap_or(outer)
|
||||
}
|
||||
StrokeKind::Center => {
|
||||
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
|
||||
stroke_outline.as_winding().unwrap_or(stroke_outline)
|
||||
}
|
||||
};
|
||||
|
||||
// If there was a path_transform, invert it back to local coords
|
||||
let final_path = if let Some(pt) = path_transform {
|
||||
if let Some(inv) = pt.invert() {
|
||||
final_path.make_transform(&inv)
|
||||
} else {
|
||||
final_path
|
||||
}
|
||||
} else {
|
||||
final_path
|
||||
};
|
||||
|
||||
Some(Path::from_skia_path_accurate(final_path))
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::mem::size_of;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::shapes::{Path, Segment, ToPath};
|
||||
use crate::shapes::{stroke_to_path, Path, Segment, ToPath};
|
||||
use crate::{mem, with_current_shape, with_current_shape_mut, STATE};
|
||||
|
||||
const RAW_SEGMENT_DATA_SIZE: usize = size_of::<RawSegmentData>();
|
||||
@@ -235,6 +235,40 @@ pub extern "C" fn current_to_path() -> *mut u8 {
|
||||
mem::write_vec(result)
|
||||
}
|
||||
|
||||
/// Converts a shape's stroke (at the given index) into a filled path.
|
||||
///
|
||||
/// This uses Skia's `fill_path_with_paint` to convert the stroke outline
|
||||
/// into a filled path, properly handling inner/outer/center alignment
|
||||
/// via boolean path operations.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 {
|
||||
let mut result = Vec::<RawSegmentData>::default();
|
||||
with_current_shape!(state, |shape: &Shape| {
|
||||
let idx = stroke_index as usize;
|
||||
if let Some(stroke) = shape.strokes.get(idx) {
|
||||
let shape_path = shape.to_path(&state.shapes);
|
||||
let path_transform = shape.to_path_transform();
|
||||
|
||||
if let Some(path) = stroke_to_path(
|
||||
stroke,
|
||||
&shape_path,
|
||||
path_transform.as_ref(),
|
||||
&shape.selrect,
|
||||
shape.svg_attrs.as_ref(),
|
||||
) {
|
||||
result = path
|
||||
.segments()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(RawSegmentData::from_segment)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mem::write_vec(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user