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::*;