From 1641eec6724081eb50e33a1cc60d6126f178d986 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 18 Mar 2026 09:51:24 +0100 Subject: [PATCH] :tada: Add stroke to path --- common/src/app/common/flags.cljc | 3 +- .../data/workspace/shapes-with-strokes.json | 64 +++++++++ .../ui/specs/stroke-to-path.spec.js | 80 +++++++++++ frontend/src/app/main/data/workspace.cljs | 1 + .../data/workspace/path/shapes_to_path.cljs | 136 ++++++++++++++++++ .../app/main/ui/workspace/context_menu.cljs | 19 ++- frontend/src/app/render_wasm/api.cljs | 19 +++ frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + render-wasm/src/shapes.rs | 2 + render-wasm/src/shapes/paths.rs | 103 +++++++++++++ render-wasm/src/shapes/stroke_paths.rs | 87 +++++++++++ render-wasm/src/wasm/paths.rs | 36 ++++- 13 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 frontend/playwright/data/workspace/shapes-with-strokes.json create mode 100644 frontend/playwright/ui/specs/stroke-to-path.spec.js create mode 100644 render-wasm/src/shapes/stroke_paths.rs diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 9e70950566..d2232b4439 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -165,7 +165,8 @@ :nitrate :mcp - :background-blur}) + :background-blur + :stroke-path}) (def all-flags (set/union email login varia)) diff --git a/frontend/playwright/data/workspace/shapes-with-strokes.json b/frontend/playwright/data/workspace/shapes-with-strokes.json new file mode 100644 index 0000000000..c07903cbfa --- /dev/null +++ b/frontend/playwright/data/workspace/shapes-with-strokes.json @@ -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" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/specs/stroke-to-path.spec.js b/frontend/playwright/ui/specs/stroke-to-path.spec.js new file mode 100644 index 0000000000..d3647e97d3 --- /dev/null +++ b/frontend/playwright/ui/specs/stroke-to-path.spec.js @@ -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)"); +}); diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 64221eecb8..dd03d7601e 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index db61b5b026..33dde5df4f 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -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))))))))) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 1fedf00e64..1703cee2f1 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 60b06f596e..0254324696 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3eaf69ce36..a259a95a05 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 63512cb790..8dfcbb6c75 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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" diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 0cf0576f5a..dc129e8695 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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::*; diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs index 8fa81c6b77..2debf44958 100644 --- a/render-wasm/src/shapes/paths.rs +++ b/render-wasm/src/shapes/paths.rs @@ -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() } diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs new file mode 100644 index 0000000000..14f9c09229 --- /dev/null +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -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 { + 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)) +} diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index f700317633..7690a9001f 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -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::(); @@ -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::::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::*;