Compare commits

..

1 Commits

Author SHA1 Message Date
Alejandro Alonso
782adec4e6 🐛 Fix stroke artifacts 2026-02-23 09:34:47 +01:00
15 changed files with 151 additions and 4329 deletions

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
{
"~:file-id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:id": "~u3a4d7ec7-c391-8146-8007-9dd6c998fbc4",
"~:created-at": "~m1771846681191",
"~:modified-at": "~m1771846681191",
"~:type": "fragment",
"~:backend": "db",
"~:data": {
"~:id": "~u95b23c15-79f9-81ba-8007-99d81b5290dd",
"~:name": "Page 1",
"~: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]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~: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,\"^I\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\"]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be0": "[\"~#shape\",[\"^ \",\"~:y\",-218.99999605032087,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",5,\"~:p2\",5,\"~:p3\",5,\"~:p4\",5],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Container\",\"~:layout-align-items\",\"~:start\",\"~:width\",431.99994866329087,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",608.9999813066789,\"~:y\",-218.99999605032087]],[\"^J\",[\"^ \",\"~:x\",1040.9999299699698,\"~:y\",-218.99999605032087]],[\"^J\",[\"^ \",\"~:x\",1040.9999299699698,\"~:y\",-177.00001533586985]],[\"^J\",[\"^ \",\"~:x\",608.9999813066789,\"~:y\",-177.00001533586985]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fill\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",4,\"~:column-gap\",4],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"~:auto\",\"~:layout-justify-content\",\"^C\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:strokes\",[],\"~:x\",608.9999813066788,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",608.9999813066788,\"~:y\",-218.99999605032087,\"^D\",431.99994866329087,\"~:height\",41.99998071445103,\"~:x1\",608.9999813066788,\"~:y1\",-218.99999605032087,\"~:x2\",1040.9999299699698,\"~:y2\",-177.00001533586985]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffc0cb\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1:\",41.99998071445103,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043be2\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be3\"]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be2": "[\"~#shape\",[\"^ \",\"~:y\",-178.00000568505413,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",false,\"~:name\",\"show / hide me\",\"~:width\",99.98206911702209,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-178.00000568505413]],[\"^:\",[\"^ \",\"~:x\",713.9820693746558,\"~:y\",-178.00000568505413]],[\"^:\",[\"^ \",\"~:x\",713.9820693746558,\"~:y\",-148.0000135081636]],[\"^:\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-148.0000135081636]]],\"~:r2\",0,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:layout-item-v-sizing\",\"^=\",\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"~:hidden\",true,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be2\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:strokes\",[],\"~:x\",614.0000002576337,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",614.0000002576337,\"~:y\",-178.00000568505413,\"^6\",99.98206911702209,\"~:height\",29.999992176890544,\"~:x1\",614.0000002576337,\"~:y1\",-178.00000568505413,\"~:x2\",713.9820693746558,\"~:y2\",-148.0000135081636]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^P\",29.999992176890544,\"~:flip-y\",null]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043be3": "[\"~#shape\",[\"^ \",\"~:y\",-213.99999587313152,\"~:hide-fill-on-export\",false,\"~:rx\",8,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:hide-in-viewer\",true,\"~:name\",\"Full width\",\"~:width\",422.00001200500014,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-213.99999587313152]],[\"^<\",[\"^ \",\"~:x\",1036.0000059112394,\"~:y\",-213.99999587313152]],[\"^<\",[\"^ \",\"~:x\",1036.0000059112394,\"~:y\",-182.00001303926604]],[\"^<\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-182.00001303926604]]],\"~:r2\",8,\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^4\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"^@\",\"~:r3\",8,\"~:r1\",8,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be3\",\"~:parent-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:frame-id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\",\"~:strokes\",[],\"~:x\",613.9999939062393,\"~:proportion\",1,\"~:r4\",8,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",613.9999939062393,\"~:y\",-213.99999587313152,\"^8\",422.00001200500014,\"~:height\",31.999982833865488,\"~:x1\",613.9999939062393,\"~:y1\",-213.99999587313152,\"~:x2\",1036.0000059112394,\"~:y2\",-182.00001303926604]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#212426\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"~:ry\",8,\"^O\",31.999982833865488,\"~:flip-y\",null,\"~:shapes\",[]]]",
"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf": "[\"~#shape\",[\"^ \",\"~:y\",-228.99999763039506,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",10,\"~:p2\",10,\"~:p3\",10,\"~:p4\",10],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"Parent\",\"~:layout-align-items\",\"~:start\",\"~:width\",451.999905143128,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-228.99999763039506]],[\"^J\",[\"^ \",\"~:x\",1050.999920103893,\"~:y\",-228.99999763039506]],[\"^J\",[\"^ \",\"~:x\",1050.999920103893,\"~:y\",-167.0000160450801]],[\"^J\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-167.0000160450801]]],\"~:r2\",0,\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:fix\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",10,\"~:column-gap\",8],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u95b23c15-79f9-81ba-8007-99d81b5290dd\",\"~:layout-item-v-sizing\",\"~:auto\",\"~:r3\",0,\"~:layout-justify-content\",\"^C\",\"~:r1\",0,\"~:id\",\"~ucfb31a9c-83c2-806f-8007-9dbf43043bdf\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",599.0000149607649,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",599.0000149607649,\"~:y\",-228.99999763039506,\"^D\",451.999905143128,\"~:height\",61.99998158531497,\"~:x1\",599.0000149607649,\"~:y1\",-228.99999763039506,\"~:x2\",1050.999920103893,\"~:y2\",-167.0000160450801]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#000000\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^1:\",61.99998158531497,\"~:flip-y\",null,\"~:shapes\",[\"~ucfb31a9c-83c2-806f-8007-9dbf43043be0\"]]]"
}
}
}
}

View File

@@ -1,131 +0,0 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud715d0a5-a44e-8056-8005-a79999e18b64",
"~: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": "test-bug-flex",
"~:revn": 114,
"~:modified-at": "~m1771846681183",
"~:vern": 0,
"~:id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~u76eab896-accf-81a5-8007-2b264ebe7817",
"~:created-at": "~m1771590560885",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u95b23c15-79f9-81ba-8007-99d81b5290dd"
],
"~:pages-index": {
"~u95b23c15-79f9-81ba-8007-99d81b5290dd": {
"~#penpot/pointer": [
"~u3a4d7ec7-c391-8146-8007-9dd6c998fbc4",
{
"~:created-at": "~m1771846681187"
}
]
}
},
"~:id": "~u3a4d7ec7-c391-8146-8007-9a05c41da6b9",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -243,46 +243,6 @@ test("Renders a file with a closed path shape with multiple segments using strok
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders solid shadows after select all and zoom to selected", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-solid-shadows.json");
await workspace.goToWorkspace({
id: "93113137-fe66-80fb-8007-99ca9fd96841",
pageId: "93113137-fe66-80fb-8007-99ca9fd96842",
});
await workspace.waitForFirstRender();
await workspace.viewport.click();
await page.keyboard.press("ControlOrMeta+A");
const previousRenderCount = await workspace.getRenderCount();
await page.keyboard.press("f");
await workspace.waitForNextRender(previousRenderCount);
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders strokes with solid shadows", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-solid-strokes-shadows.json");
await workspace.goToWorkspace({
id: "93113137-fe66-80fb-8007-99cfd5cbf361",
pageId: "93113137-fe66-80fb-8007-99cfd5cbf362",
});
await workspace.waitForFirstRender();
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with paths and svg attrs", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -55,31 +55,3 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => {
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("340");
});
test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13468.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-13468-fragment.json",
);
await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json");
await workspacePage.goToWorkspace({
fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9",
pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd",
});
0
await workspacePage.clickToggableLayer("Parent");
await workspacePage.clickToggableLayer("Container");
await workspacePage.sidebar.getByRole('button', { name: 'Show' }).click();
await workspacePage.clickLeafLayer("Container");
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76");
});

View File

@@ -642,7 +642,7 @@ impl RenderState {
apply_to_current_surface: bool,
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
spread: Option<f32>,
outset: Option<f32>,
) {
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
@@ -701,14 +701,7 @@ impl RenderState {
canvas.translate(translation);
});
fills::render(
self,
shape,
&shape.fills,
antialias,
SurfaceId::Current,
None,
);
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current, outset);
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
@@ -718,7 +711,7 @@ impl RenderState {
&visible_strokes,
Some(SurfaceId::Current),
antialias,
spread,
outset,
);
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
@@ -1070,7 +1063,7 @@ impl RenderState {
&fills_to_render,
antialias,
fills_surface_id,
spread,
outset,
);
}
} else {
@@ -1080,7 +1073,7 @@ impl RenderState {
&shape.fills,
antialias,
fills_surface_id,
spread,
outset,
);
}
@@ -1096,7 +1089,7 @@ impl RenderState {
&visible_strokes,
Some(strokes_surface_id),
antialias,
spread,
outset,
);
if !fast_mode {
for stroke in &visible_strokes {
@@ -1651,54 +1644,12 @@ impl RenderState {
return;
}
// blur=0 at high zoom: draw directly on DropShadows with geometric spread (no filter).
if scale > 1.0 && shadow.blur <= 0.0 {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save();
drop_canvas.scale((scale, scale));
drop_canvas.translate(translation);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
&plain_shape,
clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
false,
Some(shadow.offset),
None,
Some(shadow.spread),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
return;
}
// Create filter with blur only (no offset, no spread - handled geometrically)
let blur_only_filter = if transformed_shadow.blur > 0.0 {
Some(skia::image_filters::blur(
(transformed_shadow.blur, transformed_shadow.blur),
None,
None,
None,
))
} else {
None
};
let mut shadow_paint = skia::Paint::default();
if let Some(blur_filter) = blur_only_filter {
shadow_paint.set_image_filter(blur_filter);
}
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
// Low zoom path: use blur filter but apply offset and spread geometrically
if use_low_zoom_path {
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter);
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save_layer(&layer_rec);
drop_canvas.scale((scale, scale));
@@ -1713,9 +1664,9 @@ impl RenderState {
SurfaceId::DropShadows,
SurfaceId::DropShadows,
false,
Some(shadow.offset), // Offset is geometric
Some(shadow.offset),
None,
None,
Some(shadow.spread), // Spread is geometric
);
});
@@ -1725,7 +1676,7 @@ impl RenderState {
// Adaptive downscale for large blur values (lossless GPU optimization).
// Bounds above were computed from the original sigma so filter surface coverage is correct.
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8×): beyond that the
// filter surface becomes too small and quality degrades noticeably.
const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD;
let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD {
@@ -1734,18 +1685,23 @@ impl RenderState {
1.0
};
// High zoom with blur: use render_into_filter_surface to ensure blur has enough space
// Apply spread geometrically to avoid dilate filter rounding issues
let filter_result = filters::render_into_filter_surface(
self,
bounds,
blur_downscale,
|state, temp_surface| {
let canvas = state.surfaces.canvas(temp_surface);
canvas.save_layer(&layer_rec);
{
let canvas = state.surfaces.canvas(temp_surface);
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter);
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
canvas.save_layer(&layer_rec);
}
state.with_nested_blurs_suppressed(|state| {
// Apply offset and spread geometrically
state.render_shape(
&plain_shape,
clip_bounds,
@@ -1754,13 +1710,16 @@ impl RenderState {
temp_surface,
temp_surface,
false,
Some(shadow.offset), // Offset is geometric
Some(shadow.offset),
None,
None,
Some(shadow.spread), // Spread is geometric
);
});
state.surfaces.canvas(temp_surface).restore();
{
let canvas = state.surfaces.canvas(temp_surface);
canvas.restore();
}
},
);

View File

@@ -2,7 +2,15 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, StrokeKind, Type};
/// True when the shape has at least one visible inner stroke.
fn has_inner_stroke(shape: &Shape) -> bool {
let is_open = shape.is_open();
shape
.visible_strokes()
.any(|s| s.render_kind(is_open) == StrokeKind::Inner)
}
fn draw_image_fill(
render_state: &mut RenderState,
@@ -97,7 +105,7 @@ pub fn render(
fills: &[Fill],
antialias: bool,
surface_id: SurfaceId,
spread: Option<f32>,
outset: Option<f32>,
) {
if fills.is_empty() {
return;
@@ -107,8 +115,14 @@ pub fn render(
// and sampling options that get_fill_shader (used by merge_fills) lacks.
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
} else {
None
};
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id, spread);
render_single_fill(render_state, shape, fill, antialias, surface_id, outset, inset);
}
return;
}
@@ -116,6 +130,13 @@ pub fn render(
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(antialias);
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
} else {
None
};
if let Some(image_filter) = shape.image_filter(1.) {
let bounds = image_filter.compute_fast_bounds(shape.selrect);
if filters::render_with_filter_surface(
@@ -125,7 +146,7 @@ pub fn render(
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, spread);
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, outset, inset);
},
) {
return;
@@ -134,33 +155,35 @@ pub fn render(
}
}
draw_fill_to_surface(render_state, shape, surface_id, &paint, spread);
draw_fill_to_surface(render_state, shape, surface_id, &paint, outset, inset);
}
/// Draws a single paint (with a merged shader) to the appropriate surface
/// based on the shape type.
/// When `inset` is Some(eps), the fill is inset by eps (e.g. to avoid seam with inner strokes).
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
surface_id: SurfaceId,
paint: &Paint,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, spread);
.draw_rect_to(surface_id, shape, paint, outset, inset);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint, spread);
.draw_circle_to(surface_id, shape, paint, outset, inset);
}
Type::Path(_) | Type::Bool(_) => {
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, spread);
.draw_path_to(surface_id, shape, paint, outset, inset);
}
Type::Group(_) => {}
_ => unreachable!("This shape should not have fills"),
@@ -173,7 +196,8 @@ fn render_single_fill(
fill: &Fill,
antialias: bool,
surface_id: SurfaceId,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
let mut paint = fill.to_paint(&shape.selrect, antialias);
if let Some(image_filter) = shape.image_filter(1.) {
@@ -192,7 +216,8 @@ fn render_single_fill(
antialias,
temp_surface,
&filtered_paint,
spread,
outset,
inset,
);
},
) {
@@ -209,7 +234,8 @@ fn render_single_fill(
antialias,
surface_id,
&paint,
spread,
outset,
inset,
);
}
@@ -220,7 +246,8 @@ fn draw_single_fill_to_surface(
antialias: bool,
surface_id: SurfaceId,
paint: &Paint,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
match (fill, &shape.shape_type) {
(Fill::Image(image_fill), _) => {
@@ -236,17 +263,17 @@ fn draw_single_fill_to_surface(
(_, Type::Rect(_) | Type::Frame(_)) => {
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, spread);
.draw_rect_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Circle) => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint, spread);
.draw_circle_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Path(_)) | (_, Type::Bool(_)) => {
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, spread);
.draw_path_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children

View File

@@ -47,7 +47,7 @@ pub fn render_stroke_inner_shadows(
Some(surface_id),
filter.as_ref(),
antialias,
None, // Inner shadows don't use spread
None,
)
}
}
@@ -109,17 +109,17 @@ fn render_shadow_paint(
Type::Rect(_) | Type::Frame(_) => {
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, None);
.draw_rect_to(surface_id, shape, paint, None, None);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint, None);
.draw_circle_to(surface_id, shape, paint, None, None);
}
Type::Path(_) | Type::Bool(_) => {
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, None);
.draw_path_to(surface_id, shape, paint, None, None);
}
_ => {}
}

View File

@@ -526,7 +526,7 @@ pub fn render(
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
spread: Option<f32>,
outset: Option<f32>,
) {
if strokes.is_empty() {
return;
@@ -541,8 +541,8 @@ pub fn render(
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let max_margin = strokes
@@ -588,7 +588,7 @@ pub fn render(
antialias,
true,
true,
spread,
outset,
);
}
@@ -608,7 +608,7 @@ pub fn render(
surface_id,
None,
antialias,
spread,
outset,
);
}
return;
@@ -621,7 +621,7 @@ pub fn render(
surface_id,
antialias,
false,
spread,
outset,
);
}
@@ -642,7 +642,7 @@ fn render_merged(
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
spread: Option<f32>,
outset: Option<f32>,
) {
let representative = *strokes
.last()
@@ -658,8 +658,8 @@ fn render_merged(
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = representative.bounds_width(shape.is_open());
@@ -694,7 +694,7 @@ fn render_merged(
Some(temp_surface),
antialias,
true,
spread,
outset,
);
state.surfaces.apply_mut(temp_surface as u32, |surface| {
@@ -711,8 +711,8 @@ fn render_merged(
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
// Expand selrect if spread is provided
let selrect = if let Some(s) = spread.filter(|&s| s > 0.0) {
// Expand selrect if outset is provided
let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
@@ -724,6 +724,7 @@ fn render_merged(
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let path_transform = shape.to_path_transform();
@@ -790,7 +791,7 @@ pub fn render_single(
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
spread: Option<f32>,
outset: Option<f32>,
) {
render_single_internal(
render_state,
@@ -801,7 +802,7 @@ pub fn render_single(
antialias,
false,
false,
spread,
outset,
);
}
@@ -815,13 +816,13 @@ fn render_single_internal(
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
spread: Option<f32>,
outset: Option<f32>,
) {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for spread if provided
if let Some(s) = spread.filter(|&s| s > 0.0) {
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = stroke.bounds_width(shape.is_open());
@@ -849,7 +850,7 @@ fn render_single_internal(
antialias,
true,
true,
spread,
outset,
);
},
) {
@@ -920,18 +921,18 @@ fn render_single_internal(
let is_open = path.is_open();
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply spread by increasing stroke width
if let Some(s) = spread.filter(|&s| s > 0.0) {
// Apply outset by increasing stroke width
if let Some(s) = outset.filter(|&s| s > 0.0) {
let current_width = paint.stroke_width();
// Path stroke kinds are built differently:
// - Center uses the stroke width directly.
// - Inner/Outer use a doubled width plus clipping/clearing logic.
// Compensate spread so visual growth is comparable across kinds.
let spread_growth = match stroke.render_kind(is_open) {
// Compensate outset so visual growth is comparable across kinds.
let outset_growth = match stroke.render_kind(is_open) {
StrokeKind::Center => s * 2.0,
StrokeKind::Inner | StrokeKind::Outer => s * 4.0,
};
paint.set_stroke_width(current_width + spread_growth);
paint.set_stroke_width(current_width + outset_growth);
}
draw_stroke_on_path(
canvas,

View File

@@ -74,7 +74,7 @@ impl Surfaces {
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
let target = gpu_state.create_target_surface(width, height);
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims);
let filter = gpu_state.create_surface_with_dimensions("filter".to_string(), width, height);
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height);
let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims);
let drop_shadows =
@@ -360,16 +360,30 @@ impl Surfaces {
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
let rect = if let Some(s) = spread.filter(|&s| s > 0.0) {
let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
if let Some(eps) = inset.filter(|&e| e > 0.0) {
rect.inset((eps, eps));
}
if let Some(corners) = shape.shape_type.corners() {
let corners = if let Some(eps) = inset.filter(|&e| e > 0.0) {
let mut c = corners;
for r in c.iter_mut() {
r.x = (r.x - eps).max(0.0);
r.y = (r.y - eps).max(0.0);
}
c
} else {
corners
};
let rrect = RRect::new_rect_radii(rect, &corners);
self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint);
} else {
@@ -382,15 +396,19 @@ impl Surfaces {
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
let rect = if let Some(s) = spread.filter(|&s| s > 0.0) {
let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
if let Some(eps) = inset.filter(|&e| e > 0.0) {
rect.inset((eps, eps));
}
self.canvas_and_mark_dirty(id).draw_oval(rect, paint);
}
@@ -399,17 +417,28 @@ impl Surfaces {
id: SurfaceId,
shape: &Shape,
paint: &Paint,
spread: Option<f32>,
outset: Option<f32>,
inset: Option<f32>,
) {
if let Some(path) = shape.get_skia_path() {
let canvas = self.canvas_and_mark_dirty(id);
if let Some(s) = spread.filter(|&s| s > 0.0) {
if let Some(s) = outset.filter(|&s| s > 0.0) {
// Draw path as a thick stroke to get outset (expanded) silhouette
let mut stroke_paint = paint.clone();
stroke_paint.set_style(skia::PaintStyle::Stroke);
stroke_paint.set_stroke_width(s * 2.0);
canvas.draw_path(&path, &stroke_paint);
} else {
canvas.draw_path(&path, paint);
// Inset: avoid seam with inner strokes by clearing a thin border from the fill
if let Some(eps) = inset.filter(|&e| e > 0.0) {
let mut clear_paint = skia::Paint::default();
clear_paint.set_style(skia::PaintStyle::Stroke);
clear_paint.set_stroke_width(eps * 2.0);
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(paint.is_anti_alias());
canvas.draw_path(&path, &clear_paint);
}
}
}
}

View File

@@ -300,7 +300,20 @@ fn propagate_reflow(
Type::Frame(Frame {
layout: Some(_), ..
}) => {
layout_reflows.insert(*id);
let mut skip_reflow = false;
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
if let Some(parent_id) = shape.parent_id {
if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
// If this is a fill layout but the parent has not been reflown yet
// we wait for the next iteration for reflow
skip_reflow = true;
}
}
}
if !skip_reflow {
layout_reflows.insert(*id);
}
}
Type::Group(Group { masked: true }) => {
let children_ids = shape.children_ids(true);