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