diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc
index d2ec5636fd..51832b5e27 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/docker/devenv/files/bashrc b/docker/devenv/files/bashrc
index 98fc4a96dc..79ef2bd532 100644
--- a/docker/devenv/files/bashrc
+++ b/docker/devenv/files/bashrc
@@ -5,6 +5,9 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh;
export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
export CARGO_HOME="/home/penpot/.cargo"
+export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0
+export PENPOT_MCP_SERVER_HOST=0.0.0.0
+
alias l='ls --color -GFlh'
alias ll='ls --color -GFlh'
alias rm='rm -rf'
diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp
index f4d5544c89..14b1172035 100644
--- a/docker/images/Dockerfile.mcp
+++ b/docker/images/Dockerfile.mcp
@@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
- PATH=/opt/node/bin:$PATH
+ PATH=/opt/node/bin:$PATH \
+ PENPOT_MCP_SERVER_HOST=0.0.0.0
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
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/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png
index 2836870086..770c417a65 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13551---Blurs-affecting-other-elements-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png
index 633bd8ad2d..15b7b7a514 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/BUG-13610---Huge-inner-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png
index 8b0fdc9c61..ed80d4675f 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Correct-stroke-closing-at-self-intersection-of-overlapping-shapes-with-outer-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Correct-stroke-closing-at-self-intersection-of-overlapping-shapes-with-outer-strokes-1.png
index 3b041ad6ed..d7469cdf2c 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Correct-stroke-closing-at-self-intersection-of-overlapping-shapes-with-outer-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Correct-stroke-closing-at-self-intersection-of-overlapping-shapes-with-outer-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Keeps-component-visible-when-focusing-after-creating-it-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Keeps-component-visible-when-focusing-after-creating-it-1.png
index ebdf07ab64..ffac3df5fd 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Keeps-component-visible-when-focusing-after-creating-it-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Keeps-component-visible-when-focusing-after-creating-it-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/No-white-seam-at-intersections-of-overlapping-shapes-with-inner-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/No-white-seam-at-intersections-of-overlapping-shapes-with-inner-strokes-1.png
index 171b678f26..522877404d 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/No-white-seam-at-intersections-of-overlapping-shapes-with-inner-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/No-white-seam-at-intersections-of-overlapping-shapes-with-inner-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-clipped-frame-with-a-large-blur-drop-shadow-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-clipped-frame-with-a-large-blur-drop-shadow-1.png
index b40ae4275e..7ea4cfb5ee 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-clipped-frame-with-a-large-blur-drop-shadow-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-clipped-frame-with-a-large-blur-drop-shadow-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-a-closed-path-shape-with-multiple-segments-using-strokes-and-shadow-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-a-closed-path-shape-with-multiple-segments-using-strokes-and-shadow-1.png
index e57814f403..0aad9def32 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-a-closed-path-shape-with-multiple-segments-using-strokes-and-shadow-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-a-closed-path-shape-with-multiple-segments-using-strokes-and-shadow-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-basic-shapes-boards-and-groups-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-basic-shapes-boards-and-groups-1.png
index 3a97677c9d..fd959da42a 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-basic-shapes-boards-and-groups-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-basic-shapes-boards-and-groups-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-blurs-applied-to-any-kind-of-shape-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-blurs-applied-to-any-kind-of-shape-1.png
index de3fd45317..a4bc0725d6 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-blurs-applied-to-any-kind-of-shape-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-blurs-applied-to-any-kind-of-shape-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-flex-layouts-and-different-directions-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-flex-layouts-and-different-directions-1.png
index 395acd5771..8cb1618f97 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-flex-layouts-and-different-directions-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-flex-layouts-and-different-directions-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-mutliple-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-mutliple-strokes-1.png
index 3adb6fa54d..06695e8169 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-mutliple-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-mutliple-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png
index 67c2af4f41..773187d32c 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-frames-with-inherited-blur-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-frames-with-inherited-blur-1.png
index b7659b92b4..de03900463 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-frames-with-inherited-blur-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-frames-with-inherited-blur-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-paths-and-svg-attrs-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-paths-and-svg-attrs-1.png
index 10684dbe0c..260ea400d5 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-paths-and-svg-attrs-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-paths-and-svg-attrs-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png
index 80ec09332e..c772401b18 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shadows-applied-to-any-kind-of-shape-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shapes-with-multiple-fills-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shapes-with-multiple-fills-1.png
index 47d8a5c323..46f0e58a8a 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shapes-with-multiple-fills-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-shapes-with-multiple-fills-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png
index f23422a736..ece3f90376 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-gradient-and-image-fills-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-gradient-and-image-fills-1.png
index 263c4d87e1..85e85536f0 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-gradient-and-image-fills-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-gradient-and-image-fills-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-strokes-1.png
index f11e7402ba..efd1a5f5b8 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-on-shapes-overlapping-other-shapes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-on-shapes-overlapping-other-shapes-1.png
index 69f126eeaf..c6e50dd3d6 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-on-shapes-overlapping-other-shapes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-on-shapes-overlapping-other-shapes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png
index e8d4a136b8..c334e522da 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-clipped-frames-with-strokes-correctly-no-double-painting-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-taking-into-account-blend-modes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-taking-into-account-blend-modes-1.png
index 570536f930..899acd5ff9 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-taking-into-account-blend-modes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-taking-into-account-blend-modes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-exif-rotated-images-fills-and-strokes-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-exif-rotated-images-fills-and-strokes-1.png
index 7b92260bd5..7e689be2ed 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-exif-rotated-images-fills-and-strokes-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-exif-rotated-images-fills-and-strokes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png
index 353dfca842..bc800b9fde 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-shapes-with-multiple-fills-and-blur-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-solid-shadows-after-select-all-and-zoom-to-selected-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-solid-shadows-after-select-all-and-zoom-to-selected-1.png
index cb3c5e6135..c337e569d8 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-solid-shadows-after-select-all-and-zoom-to-selected-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-solid-shadows-after-select-all-and-zoom-to-selected-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-strokes-with-solid-shadows-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-strokes-with-solid-shadows-1.png
index e91a91e8ab..f523ad3421 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-strokes-with-solid-shadows-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-strokes-with-solid-shadows-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png
index 417af39655..a4d2213a63 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Updates-canvas-background-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png
index 25dab21adc..101315c965 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png
index e2d835c4d2..906a1e5b76 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png
index 2b764d3201..c2ed2f2321 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png
index 989559cc02..54dca6599c 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-text-with-inherited-shadows-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-text-with-inherited-shadows-1.png
index c8ff437984..5d738f355a 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-text-with-inherited-shadows-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-text-with-inherited-shadows-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png
index 58f65a7339..5a76424cbd 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-emoji-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-in-order-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-in-order-1.png
index c55afad8f0..f637a6c457 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-in-order-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-in-order-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png
index 6475826679..9ef0018566 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png
index 01eb369e51..3d9abd0bae 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png
index 15543f806e..5725d511bb 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-in-frames-and-different-strokes-shadows-and-blurs-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-in-frames-and-different-strokes-shadows-and-blurs-1.png
index d82eb0dfd4..cd8cf24692 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-in-frames-and-different-strokes-shadows-and-blurs-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-in-frames-and-different-strokes-shadows-and-blurs-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-1.png
index e90590339b..8c4be8cf3b 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-custom-fonts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-custom-fonts-1.png
index 932baa3da2..4dcfc6a349 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-custom-fonts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-custom-fonts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-google-fonts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-google-fonts-1.png
index 054fb9ddaf..ee8f290d24 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-google-fonts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-that-use-google-fonts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png
index 5585141830..4ce228ad4d 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png
index f38c2336a4..f0ae6555ba 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png
index 649a0751da..ded0cc7465 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png
index c64a86d59d..2a83a06ac5 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png
index 2ece118027..a5f78ca8f7 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Updates-a-text-font-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Updates-a-text-font-1.png
index bdf401d309..6fc006e0f5 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Updates-a-text-font-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Updates-a-text-font-1.png differ
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 ca426f6e9e..a441963ea7 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..a8b0b69cf8 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -23,6 +23,7 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.refs :as refs]
+ [app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
@@ -1410,6 +1411,15 @@
(contains? cf/flags :render-wasm-info)
(bit-or 2r00000000000000000000000000001000)))
+(defn- wasm-aa-threshold-from-route-params
+ "Reads optional `aa_threshold` query param from the router"
+ []
+ (when-let [raw (let [p (rt/get-params @st/state)]
+ (:aa_threshold p))]
+ (let [n (if (string? raw) (js/parseFloat raw) raw)]
+ (when (and (number? n) (not (js/isNaN n)) (pos? n))
+ n))))
+
(defn set-canvas-size
[canvas]
(let [width (or (.-clientWidth ^js canvas) (.-width ^js canvas))
@@ -1445,6 +1455,8 @@
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)
+ (when-let [t (wasm-aa-threshold-from-route-params)]
+ (h/call wasm/internal-module "_set_antialias_threshold" t))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
@@ -1540,6 +1552,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/text-editor/src/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js
index 4ef7ea69db..d855387603 100644
--- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js
+++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js
@@ -291,6 +291,13 @@ export class TextNodeIterator {
break;
}
}
+ // The loop exits when currentNode === endNode without yielding endNode.
+ // Callers (e.g. selection style merge) must visit every text/BR node in the
+ // range, including the last one, or the final span is omitted (e.g. empty
+ // paragraph with only
) and the sidebar shows "mixed" incorrectly.
+ if (this.#currentNode === endNode) {
+ yield this.#currentNode;
+ }
}
}
diff --git a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.test.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.test.js
index 66bbb27d30..a0a783973d 100644
--- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.test.js
+++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.test.js
@@ -70,4 +70,29 @@ describe("TextNodeIterator", () => {
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
});
+
+ test("collectFrom includes the end node (iterateFrom must yield end inclusive)", () => {
+ const rootNode = createRoot([
+ createParagraph([createTextSpan(new Text("Hello"))]),
+ createParagraph([createTextSpan(createLineBreak())]),
+ ]);
+ const firstText = rootNode.firstChild.firstChild.firstChild;
+ const br = rootNode.lastChild.firstChild.firstChild;
+ const textNodeIterator = new TextNodeIterator(rootNode);
+ const nodes = textNodeIterator.collectFrom(firstText, br);
+ expect(nodes.length).toBe(2);
+ expect(nodes[0]).toBe(firstText);
+ expect(nodes[1]).toBe(br);
+ });
+
+ test("collectFrom with identical start and end returns one node", () => {
+ const rootNode = createRoot([
+ createParagraph([createTextSpan(new Text("Hi"))]),
+ ]);
+ const text = rootNode.firstChild.firstChild.firstChild;
+ const textNodeIterator = new TextNodeIterator(rootNode);
+ const nodes = textNodeIterator.collectFrom(text, text);
+ expect(nodes.length).toBe(1);
+ expect(nodes[0]).toBe(text);
+ });
});
diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js
index e78282fa0e..d647e6a948 100644
--- a/frontend/text-editor/src/editor/controllers/SelectionController.js
+++ b/frontend/text-editor/src/editor/controllers/SelectionController.js
@@ -403,7 +403,12 @@ export class SelectionController extends EventTarget {
this.#updateCurrentStyle(textSpan);
} else {
// SELECTION.
- this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
+ // Use range boundaries normalized to text nodes, not anchor/focus.
+ // Firefox may set anchorNode on the paragraph element and focusNode on a
+ // text node for word selection; passing those to #updateCurrentStyleFrom
+ // breaks TextNodeIterator and yields wrong styles (e.g. default 14px).
+ const { startNode, endNode } = this.getRanges();
+ this.#updateCurrentStyleFrom(startNode, endNode);
}
this.dispatchEvent(
new CustomEvent("stylechange", {
diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js
index eb2deede42..076db92f41 100644
--- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js
+++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js
@@ -1683,6 +1683,30 @@ describe("SelectionController", () => {
expect(selectionController.focusAtEnd).toBeTruthy();
})
+ test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => {
+ const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
+ createParagraph([
+ createTextSpan(new Text("Hello World"), { "font-size": "36" }),
+ ]),
+ ]);
+ const root = textEditorMock.root;
+ const paragraph = root.firstChild;
+ const textNode = paragraph.firstChild.firstChild;
+ const selection = document.getSelection();
+ const selectionController = new SelectionController(
+ textEditorMock,
+ selection,
+ );
+ textEditorMock.element.focus();
+ // Anchor on the paragraph (child offset 0) and focus in the text node — matches
+ // Firefox when double-click selects a word; anchor/focus are not both text nodes.
+ selection.setBaseAndExtent(paragraph, 0, textNode, 5);
+ document.dispatchEvent(new Event("selectionchange"));
+ expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
+ "36px",
+ );
+ });
+
test("`dispose` should release every held reference", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 54eb69f84d..1f1ca31b51 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -7687,6 +7687,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 26e9bf2737..23ab6c4cc8 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -7604,6 +7604,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/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json
index 2aad3625f1..0ccf276181 100644
--- a/mcp/packages/plugin/package.json
+++ b/mcp/packages/plugin/package.json
@@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
- "start": "cross-env PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 vite build --watch --config vite.config.ts",
+ "start": "vite build --watch --config vite.config.ts",
"start:multi-user": "pnpm run start",
"build": "tsc && vite build --config vite.release.config.ts",
"types:check": "tsc --noEmit",
diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json
index c64de4c303..922be86975 100644
--- a/mcp/packages/server/package.json
+++ b/mcp/packages/server/package.json
@@ -38,6 +38,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
+ "cross-env": "^7.0.3",
"@penpot/mcp-common": "workspace:../common",
"@types/express": "^4.17.0",
"@types/js-yaml": "^4.0.9",
diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml
index 15088c9791..c90b714cc5 100644
--- a/mcp/pnpm-lock.yaml
+++ b/mcp/pnpm-lock.yaml
@@ -97,6 +97,9 @@ importers:
'@types/ws':
specifier: ^8.5.10
version: 8.18.1
+ cross-env:
+ specifier: ^7.0.3
+ version: 7.0.3
esbuild:
specifier: ^0.25.0
version: 0.25.12
diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs
index cff52ad821..42a8c46671 100644
--- a/render-wasm/src/main.rs
+++ b/render-wasm/src/main.rs
@@ -145,6 +145,15 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
Ok(())
}
+#[no_mangle]
+#[wasm_error]
+pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
+ with_state_mut!(state, {
+ state.render_state_mut().set_antialias_threshold(threshold);
+ });
+ Ok(())
+}
+
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 3b8e353213..133df97cf0 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -617,6 +617,10 @@ impl RenderState {
Ok(())
}
+ pub fn set_antialias_threshold(&mut self, value: f32) {
+ self.options.set_antialias_threshold(value);
+ }
+
pub fn set_background_color(&mut self, color: skia::Color) {
self.background_color = color;
}
@@ -798,7 +802,8 @@ impl RenderState {
});
}
- let antialias = shape.should_use_antialias(self.get_scale());
+ let antialias =
+ shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let fast_mode = self.options.is_fast_mode();
let has_nested_fills = self
.nested_fills
@@ -2164,7 +2169,7 @@ impl RenderState {
}
if let Some(clips) = clip_bounds.as_ref() {
- let antialias = element.should_use_antialias(scale);
+ let antialias = element.should_use_antialias(scale, self.options.antialias_threshold);
self.surfaces.canvas(target_surface).save();
for (bounds, corners, transform) in clips.iter() {
if target_surface == SurfaceId::Export {
diff --git a/render-wasm/src/render/grid_layout.rs b/render-wasm/src/render/grid_layout.rs
index 9f5067469e..18d1d7be60 100644
--- a/render-wasm/src/render/grid_layout.rs
+++ b/render-wasm/src/render/grid_layout.rs
@@ -4,14 +4,20 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
use crate::shapes::Shape;
use crate::state::ShapesPoolRef;
-pub fn render_overlay(zoom: f32, canvas: &skia::Canvas, shape: &Shape, shapes: ShapesPoolRef) {
+pub fn render_overlay(
+ zoom: f32,
+ antialias_threshold: f32,
+ canvas: &skia::Canvas,
+ shape: &Shape,
+ shapes: ShapesPoolRef,
+) {
let cells: Vec> = grid_cell_data(shape, shapes, true);
let bounds = shape.bounds();
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_rgb(255, 111, 224));
- paint.set_anti_alias(shape.should_use_antialias(zoom));
+ paint.set_anti_alias(shape.should_use_antialias(zoom, antialias_threshold));
paint.set_stroke_width(1.0 / zoom);
diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs
index 9505d0b254..7b1a49f540 100644
--- a/render-wasm/src/render/options.rs
+++ b/render-wasm/src/render/options.rs
@@ -4,11 +4,24 @@ const PROFILE_REBUILD_TILES: u32 = 0x02;
const TEXT_EDITOR_V3: u32 = 0x04;
const SHOW_WASM_INFO: u32 = 0x08;
-#[derive(Debug, Copy, Clone, PartialEq, Default)]
+#[derive(Debug, Copy, Clone, PartialEq)]
pub struct RenderOptions {
pub flags: u32,
pub dpr: Option,
fast_mode: bool,
+ /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
+ pub antialias_threshold: f32,
+}
+
+impl Default for RenderOptions {
+ fn default() -> Self {
+ Self {
+ flags: 0,
+ dpr: None,
+ fast_mode: false,
+ antialias_threshold: 15.0,
+ }
+ }
}
impl RenderOptions {
@@ -40,4 +53,10 @@ impl RenderOptions {
pub fn show_wasm_info(&self) -> bool {
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
}
+
+ pub fn set_antialias_threshold(&mut self, value: f32) {
+ if value.is_finite() && value > 0.0 {
+ self.antialias_threshold = value;
+ }
+ }
}
diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs
index 7d6436fbc8..bb7cc72c0f 100644
--- a/render-wasm/src/render/ui.rs
+++ b/render-wasm/src/render/ui.rs
@@ -23,7 +23,13 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
if let Some(id) = show_grid_id {
if let Some(shape) = shapes.get(&id) {
- grid_layout::render_overlay(zoom, canvas, shape, shapes);
+ grid_layout::render_overlay(
+ zoom,
+ render_state.options.antialias_threshold,
+ canvas,
+ shape,
+ shapes,
+ );
}
}
@@ -50,7 +56,13 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
}
if let Some(shape) = shapes.get(&shape.id) {
- grid_layout::render_overlay(zoom, canvas, shape, shapes);
+ grid_layout::render_overlay(
+ zoom,
+ render_state.options.antialias_threshold,
+ canvas,
+ shape,
+ shapes,
+ );
}
}
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 0cf0576f5a..22f8cf3529 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::*;
@@ -54,7 +56,6 @@ use crate::math::{self, Bounds, Matrix, Point};
use crate::state::ShapesPoolRef;
const MIN_VISIBLE_SIZE: f32 = 2.0;
-const ANTIALIAS_THRESHOLD: f32 = 15.0;
const MIN_STROKE_WIDTH: f32 = 0.001;
#[derive(Debug, Clone, PartialEq)]
@@ -766,9 +767,8 @@ impl Shape {
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
}
- pub fn should_use_antialias(&self, scale: f32) -> bool {
- self.selrect.width() * scale > ANTIALIAS_THRESHOLD
- || self.selrect.height() * scale > ANTIALIAS_THRESHOLD
+ pub fn should_use_antialias(&self, scale: f32, threshold: f32) -> bool {
+ self.selrect.width() * scale > threshold || self.selrect.height() * scale > threshold
}
pub fn calculate_bounds(&self, apply_transform: bool) -> Bounds {
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::*;