🐛 Fix parsing large paths with multiple subpaths

This commit is contained in:
Elena Torro
2025-09-24 14:16:03 +02:00
parent 32770c685a
commit 9d8ad0ea6e
8 changed files with 798 additions and 221 deletions

View File

@@ -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"
}
}
}

View File

@@ -195,3 +195,19 @@ test("Renders a file with shadows applied to any kind of shape", async ({
await expect(workspace.canvas).toHaveScreenshot(); 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();
});

View File

@@ -10,6 +10,7 @@
["react-dom/server" :as rds] ["react-dom/server" :as rds]
[app.common.data :as d :refer [not-empty?]] [app.common.data :as d :refer [not-empty?]]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.math :as mth]
[app.common.types.fills :as types.fills] [app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl] [app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path] [app.common.types.path :as path]
@@ -51,6 +52,8 @@
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8) (def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36) (def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def dpr (def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -309,15 +312,27 @@
(h/call wasm/internal-module "stringToUTF8" str offset size) (h/call wasm/internal-module "stringToUTF8" str offset size)
(h/call wasm/internal-module "_set_shape_path_attrs" (count attrs)))) (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 (defn set-shape-path-content
"Upload path content in chunks to WASM."
[content] [content]
(let [pdata (path/content content) (let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
size (path/get-byte-size content) buffer-size (path/get-byte-size content)
offset (mem/alloc size) padded-size (* 4 (mth/ceil (/ buffer-size 4)))
heap (mem/get-heap-u8)] buffer (js/Uint8Array. padded-size)]
(path/write-to pdata (.-buffer heap) offset) (path/write-to content (.-buffer buffer) 0)
(h/call wasm/internal-module "_set_shape_path_content"))) (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 (defn set-shape-svg-raw-content
[content] [content]

View File

@@ -24,6 +24,7 @@ pub fn is_close_to(current: f32, value: f32) -> bool {
(current - value).abs() <= THRESHOLD (current - value).abs() <= THRESHOLD
} }
#[allow(dead_code)]
pub fn are_close_points(a: impl Into<(f32, f32)>, b: impl Into<(f32, f32)>) -> bool { pub fn are_close_points(a: impl Into<(f32, f32)>, b: impl Into<(f32, f32)>) -> bool {
let (a_x, a_y) = a.into(); let (a_x, a_y) = a.into();
let (b_x, b_y) = b.into(); let (b_x, b_y) = b.into();

View File

@@ -14,23 +14,7 @@ pub enum Segment {
Close, Close,
} }
impl Segment { impl Segment {}
fn xy(&self) -> Option<Point> {
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,
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Path { pub struct Path {
@@ -92,8 +76,7 @@ impl Path {
} }
} }
// TODO: handle error let open = subpaths::is_open_path(&segments);
let open = subpaths::is_open_path(&segments).expect("Failed to determine if path is open");
Self { Self {
segments, segments,

View File

@@ -1,11 +1,9 @@
use super::Segment; use crate::shapes::paths::Point;
use crate::math::are_close_points; use crate::shapes::paths::Segment;
type Result<T> = std::result::Result<T, String>; #[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct Subpath { pub struct Subpath {
segments: Vec<Segment>, pub segments: Vec<Segment>,
closed: Option<bool>, closed: Option<bool>,
} }
@@ -17,230 +15,188 @@ impl Subpath {
} }
} }
pub fn starts_in(&self, other_segment: Option<&Segment>) -> bool { pub fn start(&self) -> Option<Point> {
if let (Some(start), Some(end)) = (self.start(), other_segment) { self.segments.first().and_then(|s| match s {
start.is_close_to(end) Segment::MoveTo(p) | Segment::LineTo(p) => Some(*p),
} else { _ => None,
false })
}
} }
pub fn ends_in(&self, other_segment: Option<&Segment>) -> bool { pub fn end(&self) -> Option<Point> {
if let (Some(end), Some(start)) = (self.end(), other_segment) { self.segments.iter().rev().find_map(|s| match s {
end.is_close_to(start) Segment::MoveTo(p) | Segment::LineTo(p) => Some(*p),
} else { Segment::CurveTo((_, _, p)) => Some(*p),
false _ => None,
} })
}
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 is_closed(&self) -> bool { pub fn is_closed(&self) -> bool {
self.closed.unwrap_or_else(|| self.calculate_closed()) 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 { pub fn reversed(&self) -> Self {
let mut reversed = self.clone(); let mut rev = self.clone();
reversed.segments.reverse(); rev.segments.reverse();
reversed rev.closed = None;
rev
} }
fn calculate_closed(&self) -> bool { fn calculate_closed(&self) -> bool {
if self.segments.is_empty() { 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() { if let Some(Segment::Close) = self.segments.last() {
return true; return true;
} }
if let (Some(first), Some(last)) = (self.start(), self.end()) {
// Check if the first and last points are close to each other return are_close_points(first, last);
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);
} }
false false
} }
} }
impl Default for Subpath { fn are_close_points(a: Point, b: Point) -> bool {
fn default() -> Self { let tol = 1e-1;
Self::new(vec![]) (a.0 - b.0).abs() < tol && (a.1 - b.1).abs() < tol
} }
#[derive(Debug, Clone)]
enum MergeMode {
EndStart,
StartEnd,
EndEnd,
StartStart,
} }
/// Joins two subpaths into a single subpath
impl TryFrom<(&Subpath, &Subpath)> for Subpath { impl TryFrom<(&Subpath, &Subpath)> for Subpath {
type Error = String; type Error = &'static str;
fn try_from((a, b): (&Subpath, &Subpath)) -> Result<Self, Self::Error> {
fn try_from((subpath, other): (&Subpath, &Subpath)) -> Result<Self> { let mut segs = a.segments.clone();
if subpath.is_empty() || other.is_empty() || subpath.end() != other.start() { segs.extend_from_slice(&b.segments);
return Err("Subpaths cannot be joined".to_string()); Ok(Subpath::new(segs))
}
let mut segments = subpath.segments.clone();
segments.extend_from_slice(&other.segments);
Ok(Subpath::new(segments))
} }
} }
/// Groups segments into subpaths based on MoveTo segments pub fn closed_subpaths(subpaths: Vec<Subpath>) -> Vec<Subpath> {
fn get_subpaths(segments: &[Segment]) -> Vec<Subpath> { let n = subpaths.len();
let mut subpaths: Vec<Subpath> = vec![]; if n == 0 {
let mut current_subpath = Subpath::default(); return vec![];
}
let mut used = vec![false; n];
let mut result = Vec::with_capacity(n);
for i in 0..n {
if used[i] {
continue;
}
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(&current, 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<Subpath> {
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(),
}
}
pub fn split_into_subpaths(segments: &[Segment]) -> Vec<Subpath> {
let mut subpaths = Vec::new();
let mut current_segments = Vec::new();
for segment in segments { for segment in segments {
match segment { match segment {
Segment::MoveTo(_) => { Segment::MoveTo(_) => {
if !current_subpath.is_empty() { // Start new subpath unless current is empty
subpaths.push(current_subpath); if !current_segments.is_empty() {
subpaths.push(Subpath::new(current_segments.clone()));
current_segments.clear();
} }
current_subpath = Subpath::default(); current_segments.push(*segment);
// Add the MoveTo segment to the new subpath
current_subpath.add_segment(*segment);
}
_ => {
current_subpath.add_segment(*segment);
} }
_ => current_segments.push(*segment),
} }
} }
if !current_subpath.is_empty() { // Push last subpath if any
subpaths.push(current_subpath); if !current_segments.is_empty() {
subpaths.push(Subpath::new(current_segments));
} }
subpaths subpaths
} }
/// Computes the merged candidate and the remaining, unmerged subpaths pub fn is_open_path(segments: &[Segment]) -> bool {
fn merge_paths(candidate: Subpath, others: Vec<Subpath>) -> Result<(Subpath, Vec<Subpath>)> { let subpaths = split_into_subpaths(segments);
if candidate.is_closed() { let closed_subpaths = closed_subpaths(subpaths);
return Ok((candidate, others)); closed_subpaths.iter().any(|sp| !sp.is_closed())
}
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<Vec<Subpath>> {
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<bool> {
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()))
} }
#[cfg(test)] #[cfg(test)]
@@ -266,8 +222,7 @@ mod tests {
Segment::Close, Segment::Close,
]; ];
let result = let result = subpaths::is_open_path(&segments);
subpaths::is_open_path(&segments).expect("Failed to determine if path is open");
assert!(result, "Path should be open"); assert!(result, "Path should be open");
} }
@@ -280,8 +235,7 @@ mod tests {
Segment::LineTo((223.0, 582.0)), Segment::LineTo((223.0, 582.0)),
]; ];
let result = let result = subpaths::is_open_path(&segments);
subpaths::is_open_path(&segments).expect("Failed to determine if path is open");
assert!(!result, "Path should be closed"); assert!(!result, "Path should be closed");
} }
@@ -331,16 +285,14 @@ mod tests {
Segment::LineTo((400.1158, 610.0)), Segment::LineTo((400.1158, 610.0)),
]; ];
let result = let result = subpaths::is_open_path(&segments);
subpaths::is_open_path(&segments).expect("Failed to determine if path is open");
assert!(result, "Path should be open"); assert!(result, "Path should be open");
} }
#[test] #[test]
fn test_is_open_path_4() { fn test_is_open_path_4() {
let segments = vec![]; let segments = vec![];
let result = let result = subpaths::is_open_path(&segments);
subpaths::is_open_path(&segments).expect("Failed to determine if path is open");
assert!(!result, "Path should be closed"); assert!(!result, "Path should be closed");
} }
} }

View File

@@ -2,6 +2,7 @@
use macros::ToJs; use macros::ToJs;
use mem::SerializableResult; use mem::SerializableResult;
use std::mem::size_of; use std::mem::size_of;
use std::sync::{Mutex, OnceLock};
use crate::shapes::{Path, Segment, ToPath}; use crate::shapes::{Path, Segment, ToPath};
use crate::{mem, with_current_shape, with_current_shape_mut, STATE}; use crate::{mem, with_current_shape, with_current_shape_mut, STATE};
@@ -151,17 +152,59 @@ impl From<Vec<RawSegmentData>> for Path {
} }
} }
static PATH_UPLOAD_BUFFER: OnceLock<Mutex<Vec<u8>>> = OnceLock::new();
fn get_path_upload_buffer() -> &'static Mutex<Vec<u8>> {
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::<RawSegmentData>();
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] #[no_mangle]
pub extern "C" fn set_shape_path_content() { pub extern "C" fn set_shape_path_content() {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let bytes = mem::bytes(); let bytes = mem::bytes();
let segments = bytes let segments = bytes
.chunks(size_of::<RawSegmentData>()) .chunks(size_of::<RawSegmentData>())
.map(|chunk| RawSegmentData::try_from(chunk).expect("Invalid path data")) .map(|chunk| RawSegmentData::try_from(chunk).expect("Invalid path data"))
.map(Segment::from) .map(Segment::from)
.collect(); .collect();
shape.set_path_segments(segments); shape.set_path_segments(segments);
}); });
} }