diff --git a/frontend/playwright/data/render-wasm/get-subpath-stroke-shadow.json b/frontend/playwright/data/render-wasm/get-subpath-stroke-shadow.json new file mode 100644 index 0000000000..fe83df6227 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-subpath-stroke-shadow.json @@ -0,0 +1,567 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u6bd7c17d-4f59-815e-8006-5c1f6882469a", + "~: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": "small_closed_path", + "~:revn": 25, + "~:modified-at": "~m1758717395171", + "~:vern": 0, + "~:id": "~u3f7c3cc4-556d-80fa-8006-da2505231c2b", + "~: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", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "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" + ] + }, + "~:version": 67, + "~:project-id": "~uf084c276-e46f-8168-8006-ce89321fde44", + "~:created-at": "~m1758713852044", + "~:data": { + "~:pages": [ + "~u3f7c3cc4-556d-80fa-8006-da2505231c2c" + ], + "~:pages-index": { + "~u3f7c3cc4-556d-80fa-8006-da2505231c2c": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 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, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ue758a369-49fb-801a-8006-da250da25b70" + ] + } + }, + "~ue758a369-49fb-801a-8006-da250da25b70": { + "~#shape": { + "~:y": 289.0000254924257, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "test", + "~:width": 1269.396990548183, + "~:type": "~:group", + "~:svg-attrs": { + "~:width": "36.938", + "~:height": "39.605" + }, + "~:points": [ + { + "~#point": { + "~:x": 15.99999664735742, + "~:y": 289.0000254924257 + } + }, + { + "~#point": { + "~:x": 1285.3969871955405, + "~:y": 289.0000254924257 + } + }, + { + "~#point": { + "~:x": 1285.3969871955405, + "~:y": 1473.2282874172934 + } + }, + { + "~#point": { + "~:x": 15.99999664735742, + "~:y": 1473.2282874172934 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~ue758a369-49fb-801a-8006-da250da25b70", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 15.99999664735742, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 15.99999664735742, + "~:y": 289.0000254924257, + "~:width": 1269.396990548183, + "~:height": 1184.2282619248676, + "~:x1": 15.99999664735742, + "~:y1": 289.0000254924257, + "~:x2": 1285.3969871955405, + "~:y2": 1473.2282874172934 + } + }, + "~:fills": [], + "~:flip-x": false, + "~:height": 1184.2282619248676, + "~:flip-y": false, + "~:shapes": [ + "~ue758a369-49fb-801a-8006-da250da460cc", + "~ue758a369-49fb-801a-8006-da250da8073d" + ] + } + }, + "~ue758a369-49fb-801a-8006-da250da460cc": { + "~#shape": { + "~:y": 289.00001521099387, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "base-background", + "~:width": 1269.3871566880562, + "~:type": "~:rect", + "~:svg-attrs": { + "~:fill": "none", + "~:id": "base-background" + }, + "~:points": [ + { + "~#point": { + "~:x": 15.99999664735742, + "~:y": 289.000015210994 + } + }, + { + "~#point": { + "~:x": 1285.3871533354136, + "~:y": 289.000015210994 + } + }, + { + "~#point": { + "~:x": 1285.3871533354136, + "~:y": 1473.2279626015984 + } + }, + { + "~#point": { + "~:x": 15.99999664735742, + "~:y": 1473.2279626015984 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:hidden": true, + "~:id": "~ue758a369-49fb-801a-8006-da250da460cc", + "~:parent-id": "~ue758a369-49fb-801a-8006-da250da25b70", + "~:svg-viewbox": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 9.773, + "~:height": 10.479, + "~:x1": 0, + "~:y1": 0, + "~:x2": 9.773, + "~:y2": 10.479 + } + }, + "~:svg-defs": {}, + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 10, + "~:stroke-color": "#cd0e8f", + "~:stroke-opacity": 1 + }, + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 10, + "~:stroke-color": "#0c31e0", + "~:stroke-opacity": 1 + } + ], + "~:x": 15.99999664735742, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 15.99999664735742, + "~:y": 289.00001521099387, + "~:width": 1269.3871566880562, + "~:height": 1184.2279473906046, + "~:x1": 15.99999664735742, + "~:y1": 289.00001521099387, + "~:x2": 1285.3871533354136, + "~:y2": 1473.2279626015984 + } + }, + "~:fills": [ + { + "~:fill-color": "#5dde7f", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 1184.2279473906046, + "~:flip-y": null + } + }, + "~ue758a369-49fb-801a-8006-da250da8073d": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:content": { + "~#penpot/path-data": "~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" + }, + "~:name": "svg-path", + "~:width": null, + "~:type": "~:path", + "~:svg-attrs": { + "~:style": { + "~:strokeWidth": "0.296442" + } + }, + "~:points": [ + { + "~#point": { + "~:x": 16.000007651096666, + "~:y": 289.0000106633722 + } + }, + { + "~#point": { + "~:x": 1285.3965909402777, + "~:y": 289.0000106633722 + } + }, + { + "~#point": { + "~:x": 1285.3965909402777, + "~:y": 1473.196270402294 + } + }, + { + "~#point": { + "~:x": 16.000007651096666, + "~:y": 1473.196270402294 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:svg-transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": -83.989157, + "~:f": -73.223098 + } + }, + "~:id": "~ue758a369-49fb-801a-8006-da250da8073d", + "~:parent-id": "~ue758a369-49fb-801a-8006-da250da25b70", + "~:svg-viewbox": { + "~#rect": { + "~:x": 0.000003135483506132297, + "~:y": -0.0000027832518292754405, + "~:width": 9.773072575314877, + "~:height": 10.478719602207821, + "~:x1": 0.000003135483506132297, + "~:y1": -0.0000027832518292754405, + "~:x2": 9.773075710798384, + "~:y2": 10.478716818955991 + } + }, + "~:svg-defs": {}, + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 10, + "~:stroke-color": "#cd0e8f", + "~:stroke-opacity": 1 + }, + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 10, + "~:stroke-color": "#0c31e0", + "~:stroke-opacity": 1 + } + ], + "~:x": null, + "~:proportion": 1, + "~:shadow": [ + { + "~:color": { + "~:color": "#000000", + "~:opacity": 0.2 + }, + "~:spread": 0, + "~:offset-y": 20, + "~:style": "~:drop-shadow", + "~:blur": 4, + "~:hidden": false, + "~:id": "~u055fbfc6-9f69-80cb-8006-da327c4b1584", + "~:offset-x": 20 + } + ], + "~:selrect": { + "~#rect": { + "~:x": 16.00000765109678, + "~:y": 289.0000106633721, + "~:width": 1269.3965832891809, + "~:height": 1184.196259738922, + "~:x1": 16.00000765109678, + "~:y1": 289.0000106633721, + "~:x2": 1285.3965909402777, + "~:y2": 1473.196270402294 + } + }, + "~:fills": [ + { + "~:fill-color": "#5dde7f", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + } + }, + "~:id": "~u3f7c3cc4-556d-80fa-8006-da2505231c2c", + "~:name": "Page 1" + } + }, + "~:id": "~u3f7c3cc4-556d-80fa-8006-da2505231c2b", + "~: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 b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 8b4533ef57..c7b8810d22 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -195,3 +195,19 @@ test("Renders a file with shadows applied to any kind of shape", async ({ await expect(workspace.canvas).toHaveScreenshot(); }); + +test("Renders a file with a closed path shape with multiple segments using strokes and shadow", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-subpath-stroke-shadow.json"); + + await workspace.goToWorkspace({ + id: "3f7c3cc4-556d-80fa-8006-da2505231c2b", + pageId: "3f7c3cc4-556d-80fa-8006-da2505231c2c", + }); + await workspace.waitForFirstRender(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); 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 new file mode 100644 index 0000000000..e57814f403 Binary files /dev/null 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/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 8e033aa3d1..ac1372037c 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -10,6 +10,7 @@ ["react-dom/server" :as rds] [app.common.data :as d :refer [not-empty?]] [app.common.data.macros :as dm] + [app.common.math :as mth] [app.common.types.fills :as types.fills] [app.common.types.fills.impl :as types.fills.impl] [app.common.types.path :as path] @@ -51,6 +52,8 @@ (def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8) (def ^:const GRID-LAYOUT-CELL-U8-SIZE 36) +(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) + (def dpr (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) @@ -317,15 +320,27 @@ (h/call wasm/internal-module "stringToUTF8" str offset size) (h/call wasm/internal-module "_set_shape_path_attrs" (count attrs)))) -;; FIXME: revisit on heap refactor is merged to use u32 instead u8 (defn set-shape-path-content + "Upload path content in chunks to WASM." [content] - (let [pdata (path/content content) - size (path/get-byte-size content) - offset (mem/alloc size) - heap (mem/get-heap-u8)] - (path/write-to pdata (.-buffer heap) offset) - (h/call wasm/internal-module "_set_shape_path_content"))) + (let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4) + buffer-size (path/get-byte-size content) + padded-size (* 4 (mth/ceil (/ buffer-size 4))) + buffer (js/Uint8Array. padded-size)] + (path/write-to content (.-buffer buffer) 0) + (h/call wasm/internal-module "_start_shape_path_buffer") + (let [heapu32 (mem/get-heap-u32)] + (loop [offset 0] + (when (< offset padded-size) + (let [end (min padded-size (+ offset (* chunk-size 4))) + chunk (.subarray buffer offset end) + chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4)) + offset-size (.-length chunk-u32) + heap-offset (mem/alloc->offset-32 (* 4 offset-size))] + (.set heapu32 chunk-u32 heap-offset) + (h/call wasm/internal-module "_set_shape_path_chunk_buffer") + (recur end))))) + (h/call wasm/internal-module "_set_shape_path_buffer"))) (defn set-shape-svg-raw-content [content] diff --git a/render-wasm/src/math.rs b/render-wasm/src/math.rs index 41460a7bae..58b395fd6a 100644 --- a/render-wasm/src/math.rs +++ b/render-wasm/src/math.rs @@ -24,6 +24,7 @@ pub fn is_close_to(current: f32, value: f32) -> bool { (current - value).abs() <= THRESHOLD } +#[allow(dead_code)] pub fn are_close_points(a: impl Into<(f32, f32)>, b: impl Into<(f32, f32)>) -> bool { let (a_x, a_y) = a.into(); let (b_x, b_y) = b.into(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 92c7251278..80144fe7e4 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1334,6 +1334,9 @@ impl RenderState { // Nested shapes shadowing - apply black shadow to child shapes too for shadow_shape_id in element.children.iter() { let shadow_shape = tree.get(shadow_shape_id).unwrap(); + if shadow_shape.hidden { + continue; + } let clip_bounds = node_render_state.get_nested_shadow_clip_bounds( element, modifiers.get(&element.id), diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs index da04d49d2c..dfdc06ae01 100644 --- a/render-wasm/src/shapes/paths.rs +++ b/render-wasm/src/shapes/paths.rs @@ -14,23 +14,7 @@ pub enum Segment { Close, } -impl Segment { - fn xy(&self) -> Option { - match self { - Segment::MoveTo(xy) => Some(*xy), - Segment::LineTo(xy) => Some(*xy), - Segment::CurveTo((_, _, xy)) => Some(*xy), - Segment::Close => None, - } - } - - pub fn is_close_to(&self, other: &Segment) -> bool { - match (self.xy(), other.xy()) { - (Some(a), Some(b)) => math::are_close_points(a, b), - _ => false, - } - } -} +impl Segment {} #[derive(Debug, Clone, PartialEq)] pub struct Path { @@ -92,8 +76,7 @@ impl Path { } } - // TODO: handle error - let open = subpaths::is_open_path(&segments).expect("Failed to determine if path is open"); + let open = subpaths::is_open_path(&segments); Self { segments, diff --git a/render-wasm/src/shapes/paths/subpaths.rs b/render-wasm/src/shapes/paths/subpaths.rs index 688c9f8a0e..aa076e1ecd 100644 --- a/render-wasm/src/shapes/paths/subpaths.rs +++ b/render-wasm/src/shapes/paths/subpaths.rs @@ -1,11 +1,9 @@ -use super::Segment; -use crate::math::are_close_points; +use crate::shapes::paths::Point; +use crate::shapes::paths::Segment; -type Result = std::result::Result; - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct Subpath { - segments: Vec, + pub segments: Vec, closed: Option, } @@ -17,230 +15,188 @@ impl Subpath { } } - pub fn starts_in(&self, other_segment: Option<&Segment>) -> bool { - if let (Some(start), Some(end)) = (self.start(), other_segment) { - start.is_close_to(end) - } else { - false - } + pub fn start(&self) -> Option { + self.segments.first().and_then(|s| match s { + Segment::MoveTo(p) | Segment::LineTo(p) => Some(*p), + _ => None, + }) } - pub fn ends_in(&self, other_segment: Option<&Segment>) -> bool { - if let (Some(end), Some(start)) = (self.end(), other_segment) { - end.is_close_to(start) - } else { - false - } - } - - pub fn start(&self) -> Option<&Segment> { - self.segments.first() - } - - pub fn end(&self) -> Option<&Segment> { - self.segments.last() - } - - pub fn is_empty(&self) -> bool { - self.segments.is_empty() + pub fn end(&self) -> Option { + self.segments.iter().rev().find_map(|s| match s { + Segment::MoveTo(p) | Segment::LineTo(p) => Some(*p), + Segment::CurveTo((_, _, p)) => Some(*p), + _ => None, + }) } pub fn is_closed(&self) -> bool { self.closed.unwrap_or_else(|| self.calculate_closed()) } - pub fn add_segment(&mut self, segment: Segment) { - self.segments.push(segment); - self.closed = None; - } - pub fn reversed(&self) -> Self { - let mut reversed = self.clone(); - reversed.segments.reverse(); - reversed + let mut rev = self.clone(); + rev.segments.reverse(); + rev.closed = None; + rev } fn calculate_closed(&self) -> bool { if self.segments.is_empty() { - return false; + return true; } - - // Check if the path ends with a Close segment if let Some(Segment::Close) = self.segments.last() { return true; } - - // Check if the first and last points are close to each other - if let (Some(first), Some(last)) = (self.segments.first(), self.segments.last()) { - let first_point = match first { - Segment::MoveTo(xy) => xy, - _ => return false, - }; - - let last_point = match last { - Segment::LineTo(xy) => xy, - Segment::CurveTo((_, _, xy)) => xy, - _ => return false, - }; - - return are_close_points(*first_point, *last_point); + if let (Some(first), Some(last)) = (self.start(), self.end()) { + return are_close_points(first, last); } - false } } -impl Default for Subpath { - fn default() -> Self { - Self::new(vec![]) +fn are_close_points(a: Point, b: Point) -> bool { + let tol = 1e-1; + (a.0 - b.0).abs() < tol && (a.1 - b.1).abs() < tol +} + +#[derive(Debug, Clone)] +enum MergeMode { + EndStart, + StartEnd, + EndEnd, + StartStart, +} + +impl TryFrom<(&Subpath, &Subpath)> for Subpath { + type Error = &'static str; + fn try_from((a, b): (&Subpath, &Subpath)) -> Result { + let mut segs = a.segments.clone(); + segs.extend_from_slice(&b.segments); + Ok(Subpath::new(segs)) } } -/// Joins two subpaths into a single subpath -impl TryFrom<(&Subpath, &Subpath)> for Subpath { - type Error = String; +pub fn closed_subpaths(subpaths: Vec) -> Vec { + let n = subpaths.len(); + if n == 0 { + return vec![]; + } - fn try_from((subpath, other): (&Subpath, &Subpath)) -> Result { - if subpath.is_empty() || other.is_empty() || subpath.end() != other.start() { - return Err("Subpaths cannot be joined".to_string()); + let mut used = vec![false; n]; + let mut result = Vec::with_capacity(n); + + for i in 0..n { + if used[i] { + continue; } - let mut segments = subpath.segments.clone(); - segments.extend_from_slice(&other.segments); - Ok(Subpath::new(segments)) + let mut current = subpaths[i].clone(); + used[i] = true; + let mut merged_any = false; + + loop { + if current.is_closed() { + break; + } + + let mut did_merge = false; + + for j in 0..n { + if used[j] || subpaths[j].is_closed() { + continue; + } + + let candidate = &subpaths[j]; + let maybe_merge = [ + (current.end(), candidate.start(), MergeMode::EndStart), + (current.start(), candidate.end(), MergeMode::StartEnd), + (current.end(), candidate.end(), MergeMode::EndEnd), + (current.start(), candidate.start(), MergeMode::StartStart), + ] + .iter() + .find_map(|(p1, p2, mode)| { + if let (Some(a), Some(b)) = (p1, p2) { + if are_close_points(*a, *b) { + Some(mode.clone()) + } else { + None + } + } else { + None + } + }); + + if let Some(mode) = maybe_merge { + if let Some(new_current) = try_merge(¤t, candidate, mode) { + used[j] = true; + current = new_current; + merged_any = true; + did_merge = true; + break; + } + } + } + + if !did_merge { + break; + } + } + + if !current.is_closed() && merged_any { + if let Some(start) = current.start() { + let mut segs = current.segments.clone(); + segs.push(Segment::LineTo(start)); + segs.push(Segment::Close); + current = Subpath::new(segs); + } + } + + result.push(current); + } + + result +} + +fn try_merge(current: &Subpath, candidate: &Subpath, mode: MergeMode) -> Option { + match mode { + MergeMode::EndStart => Subpath::try_from((current, candidate)).ok(), + MergeMode::StartEnd => Subpath::try_from((candidate, current)).ok(), + MergeMode::EndEnd => Subpath::try_from((current, &candidate.reversed())).ok(), + MergeMode::StartStart => Subpath::try_from((&candidate.reversed(), current)).ok(), } } -/// Groups segments into subpaths based on MoveTo segments -fn get_subpaths(segments: &[Segment]) -> Vec { - let mut subpaths: Vec = vec![]; - let mut current_subpath = Subpath::default(); +pub fn split_into_subpaths(segments: &[Segment]) -> Vec { + let mut subpaths = Vec::new(); + let mut current_segments = Vec::new(); for segment in segments { match segment { Segment::MoveTo(_) => { - if !current_subpath.is_empty() { - subpaths.push(current_subpath); + // Start new subpath unless current is empty + if !current_segments.is_empty() { + subpaths.push(Subpath::new(current_segments.clone())); + current_segments.clear(); } - current_subpath = Subpath::default(); - // Add the MoveTo segment to the new subpath - current_subpath.add_segment(*segment); - } - _ => { - current_subpath.add_segment(*segment); + current_segments.push(*segment); } + _ => current_segments.push(*segment), } } - if !current_subpath.is_empty() { - subpaths.push(current_subpath); + // Push last subpath if any + if !current_segments.is_empty() { + subpaths.push(Subpath::new(current_segments)); } subpaths } -/// Computes the merged candidate and the remaining, unmerged subpaths -fn merge_paths(candidate: Subpath, others: Vec) -> Result<(Subpath, Vec)> { - if candidate.is_closed() { - return Ok((candidate, others)); - } - - let mut merged = candidate.clone(); - let mut other_without_merged = vec![]; - let mut merged_any = false; - - for subpath in others { - // Only merge if the candidate is not already closed and the subpath can be meaningfully connected - if !merged.is_closed() && !subpath.is_closed() { - if merged.ends_in(subpath.start()) { - if let Ok(new_merged) = Subpath::try_from((&merged, &subpath)) { - merged = new_merged; - merged_any = true; - } else { - other_without_merged.push(subpath); - } - } else if merged.starts_in(subpath.end()) { - if let Ok(new_merged) = Subpath::try_from((&subpath, &merged)) { - merged = new_merged; - merged_any = true; - } else { - other_without_merged.push(subpath); - } - } else if merged.ends_in(subpath.end()) { - if let Ok(new_merged) = Subpath::try_from((&merged, &subpath.reversed())) { - merged = new_merged; - merged_any = true; - } else { - other_without_merged.push(subpath); - } - } else if merged.starts_in(subpath.start()) { - if let Ok(new_merged) = Subpath::try_from((&subpath.reversed(), &merged)) { - merged = new_merged; - merged_any = true; - } else { - other_without_merged.push(subpath); - } - } else { - other_without_merged.push(subpath); - } - } else { - // If either subpath is closed, don't merge - other_without_merged.push(subpath); - } - } - - // If we tried to merge but failed to close, force close the merged subpath - if !merged.is_closed() && merged_any { - let mut closed_segments = merged.segments.clone(); - if let Some(Segment::MoveTo(start)) = closed_segments.first() { - closed_segments.push(Segment::LineTo(*start)); - closed_segments.push(Segment::Close); - } - merged = Subpath::new(closed_segments); - } - - Ok((merged, other_without_merged)) -} - -/// Searches a path for potential subpaths that can be closed and merges them -fn closed_subpaths( - current: &Subpath, - others: &[Subpath], - partial: &[Subpath], -) -> Result> { - let mut result = partial.to_vec(); - - let (new_current, new_others) = if current.is_closed() { - (current.clone(), others.to_vec()) - } else { - merge_paths(current.clone(), others.to_vec())? - }; - - // we haven't found any matching subpaths -> advance - if new_current == *current { - result.push(current.clone()); - if new_others.is_empty() { - return Ok(result); - } - - closed_subpaths(&new_others[0], &new_others[1..], &result) - } - // if diffrent, we have to search again with the merged subpaths - else { - closed_subpaths(&new_current, &new_others, &result) - } -} - -pub fn is_open_path(segments: &[Segment]) -> Result { - let subpaths = get_subpaths(segments); - let closed_subpaths = if subpaths.len() > 1 { - closed_subpaths(&subpaths[0], &subpaths[1..], &[])? - } else { - subpaths - }; - - // return true if any subpath is open - Ok(closed_subpaths.iter().any(|subpath| !subpath.is_closed())) +pub fn is_open_path(segments: &[Segment]) -> bool { + let subpaths = split_into_subpaths(segments); + let closed_subpaths = closed_subpaths(subpaths); + closed_subpaths.iter().any(|sp| !sp.is_closed()) } #[cfg(test)] @@ -266,8 +222,7 @@ mod tests { Segment::Close, ]; - let result = - subpaths::is_open_path(&segments).expect("Failed to determine if path is open"); + let result = subpaths::is_open_path(&segments); assert!(result, "Path should be open"); } @@ -280,8 +235,7 @@ mod tests { Segment::LineTo((223.0, 582.0)), ]; - let result = - subpaths::is_open_path(&segments).expect("Failed to determine if path is open"); + let result = subpaths::is_open_path(&segments); assert!(!result, "Path should be closed"); } @@ -331,16 +285,14 @@ mod tests { Segment::LineTo((400.1158, 610.0)), ]; - let result = - subpaths::is_open_path(&segments).expect("Failed to determine if path is open"); + let result = subpaths::is_open_path(&segments); assert!(result, "Path should be open"); } #[test] fn test_is_open_path_4() { let segments = vec![]; - let result = - subpaths::is_open_path(&segments).expect("Failed to determine if path is open"); + let result = subpaths::is_open_path(&segments); assert!(!result, "Path should be closed"); } } diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index c08c3c076a..aa70cbe3a2 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -2,6 +2,7 @@ use macros::ToJs; use mem::SerializableResult; use std::mem::size_of; +use std::sync::{Mutex, OnceLock}; use crate::shapes::{Path, Segment, ToPath}; use crate::{mem, with_current_shape, with_current_shape_mut, STATE}; @@ -151,17 +152,59 @@ impl From> for Path { } } +static PATH_UPLOAD_BUFFER: OnceLock>> = OnceLock::new(); + +fn get_path_upload_buffer() -> &'static Mutex> { + PATH_UPLOAD_BUFFER.get_or_init(|| Mutex::new(Vec::new())) +} + +#[no_mangle] +pub extern "C" fn start_shape_path_buffer() { + let buffer = get_path_upload_buffer(); + let mut buffer = buffer.lock().unwrap(); + buffer.clear(); +} + +#[no_mangle] +pub extern "C" fn set_shape_path_chunk_buffer() { + let bytes = mem::bytes(); + let buffer = get_path_upload_buffer(); + let mut buffer = buffer.lock().unwrap(); + buffer.extend_from_slice(&bytes); + mem::free_bytes(); +} + +#[no_mangle] +pub extern "C" fn set_shape_path_buffer() { + with_current_shape_mut!(state, |shape: &mut Shape| { + let buffer = get_path_upload_buffer(); + let mut buffer = buffer.lock().unwrap(); + let chunk_size = size_of::(); + if buffer.len() % chunk_size != 0 { + // FIXME + println!("Warning: buffer length is not a multiple of chunk size!"); + } + let mut segments = Vec::new(); + for (i, chunk) in buffer.chunks(chunk_size).enumerate() { + match RawSegmentData::try_from(chunk) { + Ok(seg) => segments.push(Segment::from(seg)), + Err(e) => println!("Error at segment {}: {}", i, e), + } + } + shape.set_path_segments(segments); + buffer.clear(); + }); +} + #[no_mangle] pub extern "C" fn set_shape_path_content() { with_current_shape_mut!(state, |shape: &mut Shape| { let bytes = mem::bytes(); - let segments = bytes .chunks(size_of::()) .map(|chunk| RawSegmentData::try_from(chunk).expect("Invalid path data")) .map(Segment::from) .collect(); - shape.set_path_segments(segments); }); }