From d32534bcb4107914fae54063ab20927652a9508c Mon Sep 17 00:00:00 2001 From: seam0s Date: Sun, 21 Jun 2026 14:25:58 +0300 Subject: [PATCH 1/2] Implement Vectorize node --- Cargo.lock | 310 ++++++++++++-- Cargo.toml | 2 + .../document/document_message_handler.rs | 1 + .../graph_operation_message_handler.rs | 395 +----------------- .../document/graph_operation/utility_types.rs | 217 +++++++++- .../graph_modification_utils.rs | 1 + .../rendering/src/convert_usvg_path.rs | 47 --- node-graph/libraries/rendering/src/lib.rs | 2 +- .../libraries/rendering/src/usvg_utils.rs | 339 +++++++++++++++ node-graph/nodes/raster/Cargo.toml | 7 + node-graph/nodes/raster/src/std_nodes.rs | 120 ++++++ 11 files changed, 977 insertions(+), 464 deletions(-) delete mode 100644 node-graph/libraries/rendering/src/convert_usvg_path.rs create mode 100644 node-graph/libraries/rendering/src/usvg_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 88e882908e..67eee4d821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,12 +27,24 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "ahash" version = "0.8.12" @@ -109,6 +121,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.20" @@ -201,6 +222,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -238,7 +270,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -277,6 +309,12 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -579,7 +617,7 @@ dependencies = [ "ash", "cargo_metadata 0.23.1", "cef-dll-sys", - "clap", + "clap 4.5.46", "libc", "libloading 0.9.0", "objc2 0.6.4", @@ -667,6 +705,21 @@ dependencies = [ "half", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width 0.1.14", + "vec_map", +] + [[package]] name = "clap" version = "4.5.46" @@ -686,7 +739,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -763,7 +816,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -822,7 +875,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.1", "windows-sys 0.60.2", ] @@ -908,7 +961,7 @@ dependencies = [ "dyn-any", "glam", "graphene-hash", - "image", + "image 0.25.6", "kurbo", "log", "lyon_geom", @@ -985,9 +1038,9 @@ dependencies = [ "anes", "cast", "ciborium", - "clap", + "clap 4.5.46", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "plotters", @@ -1006,7 +1059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] [[package]] @@ -1118,6 +1171,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "deflate" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" +dependencies = [ + "adler32", + "byteorder", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1281,7 +1344,7 @@ version = "2.3.2" source = "git+https://github.com/timon-schelling/cef-rs.git?branch=graphite#b36fbe355fdb0cb101295da4554dbb285f2d91f2" dependencies = [ "bzip2", - "clap", + "clap 4.5.46", "fs-err", "indicatif", "regex", @@ -1522,7 +1585,18 @@ checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "libz-rs-sys", - "miniz_oxide", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "flo_curves" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c227ffb4f98baa9073c033e23243089d0ed3dd1f5cd620894452074db48a29b" +dependencies = [ + "itertools 0.8.2", + "roots", + "smallvec", ] [[package]] @@ -1755,7 +1829,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -1785,6 +1859,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.13.3" @@ -1975,12 +2059,12 @@ name = "graphene-cli" version = "0.1.0" dependencies = [ "chrono", - "clap", + "clap 4.5.46", "fern", "futures", "graph-craft", "graphene-std", - "image", + "image 0.25.6", "interpreted-executor", "log", "preprocessor", @@ -2051,7 +2135,7 @@ dependencies = [ "graphene-core", "graphic-nodes", "graphic-types", - "image", + "image 0.25.6", "log", "math-nodes", "node-macro", @@ -2111,7 +2195,7 @@ dependencies = [ "bytemuck", "cef", "cef-dll-sys", - "clap", + "clap 4.5.46", "ctrlc", "derivative", "dirs", @@ -2189,7 +2273,7 @@ dependencies = [ "graph-craft", "graphene-std", "graphite-editor", - "image", + "image 0.25.6", "keyboard-types", "ron", "serde", @@ -2217,7 +2301,7 @@ dependencies = [ "graphene-hash", "graphene-std", "graphite-proc-macros", - "image", + "image 0.25.6", "interpreted-executor", "js-sys", "kurbo", @@ -2405,6 +2489,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2703,6 +2796,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif 0.11.4", + "jpeg-decoder", + "num-iter", + "num-rational", + "num-traits", + "png 0.16.8", + "scoped_threadpool", + "tiff", +] + [[package]] name = "image" version = "0.25.6" @@ -2712,7 +2824,7 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.13.3", "num-traits", "png 0.17.16", "zune-core", @@ -2762,7 +2874,7 @@ checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" dependencies = [ "console", "portable-atomic", - "unicode-width", + "unicode-width 0.2.1", "unit-prefix", "web-time", ] @@ -2861,6 +2973,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f56a2d0bc861f9165be4eb3442afd3c236d8a98afd426f65d92324ae1091a484" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2932,6 +3053,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -3219,6 +3349,25 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" +dependencies = [ + "adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3456,6 +3605,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4092,6 +4263,18 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "deflate", + "miniz_oxide 0.3.7", +] + [[package]] name = "png" version = "0.17.16" @@ -4102,7 +4285,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4115,7 +4298,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4126,7 +4309,7 @@ checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix", "windows-sys 0.60.2", @@ -4407,7 +4590,7 @@ dependencies = [ "glam", "graphene-hash", "graphene-resource", - "image", + "image 0.25.6", "kurbo", "ndarray", "no-std-types", @@ -4418,11 +4601,15 @@ dependencies = [ "rand_chacha", "raster-nodes-shaders", "raster-types", + "rendering", "serde", "spirv-std", "tokio", "tsify", + "usvg", "vector-types", + "visioncortex", + "vtracer", "wasm-bindgen", "wgpu-executor", ] @@ -4453,7 +4640,7 @@ dependencies = [ "dyn-any", "glam", "graphene-hash", - "image", + "image 0.25.6", "node-macro", "serde", "serde_json", @@ -4738,6 +4925,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roots" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84348444bd7ad45729d0c49a4240d7cdc11c9d512c06c5ad1835c1ad4acda6db" + [[package]] name = "roxmltree" version = "0.20.0" @@ -4957,6 +5150,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + [[package]] name = "scopeguard" version = "1.2.0" @@ -5434,6 +5633,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -5615,6 +5820,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "third-party-licenses" version = "0.0.0" @@ -5676,6 +5890,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a53f4706d65497df0c4349241deddf35f84cee19c87ed86ea8ca590f4464437" +dependencies = [ + "jpeg-decoder", + "miniz_oxide 0.4.4", + "weezl", +] + [[package]] name = "time" version = "0.3.47" @@ -6121,6 +6346,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" @@ -6243,6 +6474,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "vector-nodes" version = "0.1.0" @@ -6344,6 +6581,29 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visioncortex" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9a7e6cc136c7c79b4adbd311c46c323a19db7baf446822b5f90f84375934e3" +dependencies = [ + "bit-vec 0.6.3", + "flo_curves", + "num-traits", +] + +[[package]] +name = "vtracer" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2307187c5c99e7387f3158a29b0be541b99b06c5c492598b77644bf5603bd0c" +dependencies = [ + "clap 2.34.0", + "fastrand", + "image 0.23.14", + "visioncortex", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 444fdffa9f..8c8fcc6158 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -206,6 +206,8 @@ petgraph = { version = "0.7", default-features = false, features = ["graphmap"] half = { version = "2.4", default-features = false, features = ["bytemuck"] } tinyvec = { version = "1", features = ["std"] } criterion = { version = "0.7", features = ["html_reports"] } +vtracer = { version = "0.6.5" } +visioncortex = { version = "0.8.9" } gungraun = { version = "0.18" } ndarray = "0.16" strum = { version = "0.27", features = ["derive"] } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 1629232be9..8200ebc5d9 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -810,6 +810,7 @@ impl MessageHandler> for DocumentMes // Force chosen tool to be Select Tool after importing image. responses.add(ToolMessage::ActivateTool { tool_type: ToolType::Select }); } + DocumentMessage::PasteSvg { name, svg, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 0ad37b4dfb..08941141dc 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -1,7 +1,5 @@ -use super::transform_utils; use super::utility_types::ModifyInputsContext; -use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP}; -use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::graph_operation::utility_types::{TransformIn, import_usvg_node}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; @@ -10,12 +8,10 @@ use crate::messages::tool::common_functionality::graph_modification_utils::get_c use glam::{DAffine2, DVec2, IVec2}; use graph_craft::descriptor; use graph_craft::document::{NodeId, NodeInput}; +use graphene_std::Artboard; use graphene_std::list::List; -use graphene_std::renderer::Quad; -use graphene_std::renderer::convert_usvg_path::convert_usvg_path; -use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; -use graphene_std::{Artboard, Color}; +use graphene_std::renderer::usvg_utils::extract_graphite_gradient_stops; +use graphene_std::vector::style::Stroke; #[derive(ExtractField)] pub struct GraphOperationMessageContext<'a> { @@ -416,6 +412,7 @@ impl MessageHandler> for responses.add(NodeGraphMessage::SelectedNodesUpdated); responses.add(NodeGraphMessage::SendGraph); } + GraphOperationMessage::NewSvg { id, svg, @@ -483,385 +480,3 @@ struct ArtboardInfo { output_nodes: Vec, merge_node: NodeId, } - -fn usvg_color(c: usvg::Color, a: f32) -> Color { - // `usvg::Color` channels are u8 sRGB display values (gamma-encoded); lift to linear-light for the internal `Color` - Color::from_gamma_srgb_channels(c.red as f32 / 255., c.green as f32 / 255., c.blue as f32 / 255., a) -} - -fn usvg_transform(c: usvg::Transform) -> DAffine2 { - DAffine2::from_cols_array(&[c.sx as f64, c.ky as f64, c.kx as f64, c.sy as f64, c.tx as f64, c.ty as f64]) -} - -const GRAPHITE_NAMESPACE: &str = "https://graphite.art"; - -/// Pre-parses the raw SVG XML to extract gradient stops that have `graphite:midpoint` attributes. -/// Graphite exports gradients with midpoint curve data by writing interpolated approximation stops -/// alongside the real stops. Real stops are tagged with `graphite:midpoint` attributes. -/// Returns a map from gradient element `id` to `GradientStops` containing only the real stops. -fn extract_graphite_gradient_stops(svg: &str) -> HashMap { - let mut result = HashMap::new(); - - // Quick check: if the SVG doesn't reference `graphite:midpoint` at all, skip parsing - if !svg.contains("graphite:midpoint") { - return result; - } - - let doc = match usvg::roxmltree::Document::parse(svg) { - Ok(doc) => doc, - Err(_) => return result, - }; - - for node in doc.descendants() { - match node.tag_name().name() { - "linearGradient" | "radialGradient" => {} - _ => continue, - } - - let gradient_id = match node.attribute("id") { - Some(id) => id.to_string(), - None => continue, - }; - - let mut real_stops = Vec::new(); - let mut has_any_midpoint = false; - - for child in node.children() { - if child.tag_name().name() != "stop" { - continue; - } - - let midpoint = child.attribute((GRAPHITE_NAMESPACE, "midpoint")).and_then(|v| v.parse::().ok()); - - if let Some(midpoint) = midpoint { - has_any_midpoint = true; - - let offset = child.attribute("offset").and_then(|v| v.parse::().ok()).unwrap_or(0.); - let opacity = child.attribute("stop-opacity").and_then(|v| v.parse::().ok()).unwrap_or(1.); - let color = child.attribute("stop-color").and_then(|hex| parse_hex_stop_color(hex, opacity)).unwrap_or(Color::BLACK); - - real_stops.push(GradientStop { position: offset, midpoint, color }); - } - } - - if has_any_midpoint && !real_stops.is_empty() { - result.insert(gradient_id, GradientStops::new(real_stops)); - } - } - - result -} - -fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { - let hex = hex.strip_prefix('#')?; - if hex.len() != 6 { - return None; - } - let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.; - let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.; - let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.; - Some(Color::from_rgbaf32_unchecked(r, g, b, opacity)) -} - -/// Import a usvg node as the root of an SVG import operation. -/// -/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly -/// interact with any existing layers in the parent stack. All descendant layers use a lightweight -/// O(n) import path that skips collision detection and instead calculates positions directly from -/// the known tree structure. -fn import_usvg_node( - modify_inputs: &mut ModifyInputsContext, - node: &usvg::Node, - id: NodeId, - parent: LayerNodeIdentifier, - insert_index: usize, - graphite_gradient_stops: &HashMap, -) { - let layer = modify_inputs.create_layer(id); - - modify_inputs.network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); - modify_inputs.layer_node = Some(layer); - if let Some(upstream_layer) = layer.next_sibling(modify_inputs.network_interface.document_metadata()) { - modify_inputs.network_interface.shift_node(&upstream_layer.to_node(), IVec2::new(0, STACK_VERTICAL_GAP), &[]); - } - - match node { - usvg::Node::Group(group) => { - // Collect child extents for O(n) position calculation - let mut child_extents_svg_order: Vec = Vec::new(); - let mut group_extents_map: HashMap> = HashMap::new(); - - // Enable import mode: skips expensive is_acyclic checks and per-node cache invalidation - // during wiring since we're building a known tree structure where cycles are impossible - modify_inputs.import = true; - - for child in group.children() { - let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map); - child_extents_svg_order.push(extent); - } - - modify_inputs.import = false; - modify_inputs.layer_node = Some(layer); - - // Rebuild the layer tree once now that all wiring is complete - modify_inputs.network_interface.load_structure(); - - // Set positions for all imported descendants in a single O(n) pass - let parent_pos = modify_inputs.network_interface.position(&layer.to_node(), &[]).unwrap_or(IVec2::ZERO); - set_import_child_positions(modify_inputs.network_interface, layer, parent_pos, &child_extents_svg_order, &group_extents_map); - - // Invalidate caches once after all positions are set - modify_inputs.network_interface.unload_all_nodes_click_targets(&[]); - modify_inputs.network_interface.unload_all_nodes_bounding_box(&[]); - } - usvg::Node::Path(path) => { - import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops); - } - usvg::Node::Image(_image) => { - warn!("Skip image"); - } - usvg::Node::Text(text) => { - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); - modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); - modify_inputs.fill_set(Fill::Solid(Color::BLACK)); - } - } -} - -/// Recursively import a usvg node as a descendant of the root import layer. -/// Uses lightweight wiring (no push/collision) and returns the subtree extent for position calculation. -/// -/// The subtree extent represents the additional vertical grid units that this node's descendants -/// occupy below the node's position. This is used to calculate correct y_offsets between siblings. -fn import_usvg_node_inner( - modify_inputs: &mut ModifyInputsContext, - node: &usvg::Node, - id: NodeId, - parent: LayerNodeIdentifier, - insert_index: usize, - graphite_gradient_stops: &HashMap, - group_extents_map: &mut HashMap>, -) -> u32 { - let layer = modify_inputs.create_layer(id); - modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]); - modify_inputs.layer_node = Some(layer); - - match node { - usvg::Node::Group(group) => { - let mut child_extents: Vec = Vec::new(); - for child in group.children() { - let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map); - child_extents.push(extent); - } - modify_inputs.layer_node = Some(layer); - - let n = child_extents.len(); - let total_extent = if n == 0 { - 0 - } else { - (2 * STACK_VERTICAL_GAP as u32) * n as u32 - STACK_VERTICAL_GAP as u32 + child_extents.iter().sum::() - }; - group_extents_map.insert(layer, child_extents); - total_extent - } - usvg::Node::Path(path) => { - import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops); - 0 - } - usvg::Node::Image(_image) => { - warn!("Skip image"); - 0 - } - usvg::Node::Text(text) => { - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); - modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); - modify_inputs.fill_set(Fill::Solid(Color::BLACK)); - 0 - } - } -} - -/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer. -fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap) { - let subpaths = convert_usvg_path(path); - let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); - - // Skip creating a Transform node entirely when the SVG-native transform is identity. - let node_transform = usvg_transform(node.abs_transform()); - let has_transform = node_transform != DAffine2::IDENTITY; - - modify_inputs.insert_vector(subpaths, layer, has_transform, path.fill().is_some(), path.stroke().is_some()); - - if has_transform && let Some(transform_node_id) = modify_inputs.existing_proto_node_id(graphene_std::transform_nodes::transform::IDENTIFIER, false) { - transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, node_transform); - } - - if let Some(fill) = path.fill() { - let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - apply_usvg_fill(fill, modify_inputs, bounds_transform, graphite_gradient_stops); - } - if let Some(stroke) = path.stroke() { - apply_usvg_stroke(stroke, modify_inputs, node_transform); - } -} - -/// Set correct positions for all imported layers in a single top-down O(n) pass. -/// -/// For each group's child stack: -/// - The top-of-stack child (last SVG child) gets an `Absolute` position at `(parent_x - LAYER_INDENT_OFFSET, parent_y + STACK_VERTICAL_GAP)` -/// - All other children get `Stack(y_offset)` where `y_offset` accounts for the subtree extent of the sibling above them in the stack, ensuring no overlap. -fn set_import_child_positions( - network_interface: &mut NodeNetworkInterface, - group: LayerNodeIdentifier, - group_pos: IVec2, - child_extents_svg_order: &[u32], - group_extents_map: &HashMap>, -) { - use crate::messages::portfolio::document::utility_types::network_interface::LayerPosition; - - let layer_children: Vec<_> = group.children(network_interface.document_metadata()).collect(); - let n = child_extents_svg_order.len(); - - if n == 0 || layer_children.is_empty() { - return; - } - - // Children in the layer tree are in stack order (top to bottom), which is the REVERSE of SVG order. - // SVG order: [s_0, s_1, ..., s_{n-1}] with extents [e_0, e_1, ..., e_{n-1}] - // Stack order: [s_{n-1}, s_{n-2}, ..., s_0 ] (top to bottom) - // - // For stack child at index i: - // - SVG index = n - 1 - i - // - Previous stack sibling's SVG index = n - i - // - y_offset = extent_of_previous_sibling + STACK_VERTICAL_GAP - - let child_x = group_pos.x - LAYER_INDENT_OFFSET; - let mut current_y = group_pos.y + STACK_VERTICAL_GAP; - - for (i, child_layer) in layer_children.iter().enumerate() { - let child_pos = IVec2::new(child_x, current_y); - - if i == 0 { - // Top of stack: set to `Absolute` position - network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Absolute(child_pos), &[]); - } else { - // Below top: set `Stack` with `y_offset` based on previous sibling's subtree extent - let prev_sibling_svg_index = n - i; - let y_offset = child_extents_svg_order[prev_sibling_svg_index] + STACK_VERTICAL_GAP as u32; - network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Stack(y_offset), &[]); - } - - // Recurse into group children to set their descendants' positions - if let Some(grandchild_extents) = group_extents_map.get(child_layer) { - set_import_child_positions(network_interface, *child_layer, child_pos, grandchild_extents, group_extents_map); - } - - // Advance `current_y` for the next child: node height (STACK_VERTICAL_GAP) + gap (STACK_VERTICAL_GAP) + subtree extent - let child_svg_index = n - 1 - i; - let child_extent = child_extents_svg_order[child_svg_index]; - current_y += 2 * STACK_VERTICAL_GAP + child_extent as i32; - } -} - -fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) { - if let usvg::Paint::Color(color) = &stroke.paint() { - modify_inputs.stroke_set(Stroke { - color: Some(usvg_color(*color, stroke.opacity().get())), - weight: stroke.width().get() as f64, - dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), - dash_offset: stroke.dashoffset() as f64, - cap: match stroke.linecap() { - usvg::LineCap::Butt => StrokeCap::Butt, - usvg::LineCap::Round => StrokeCap::Round, - usvg::LineCap::Square => StrokeCap::Square, - }, - join: match stroke.linejoin() { - usvg::LineJoin::Miter => StrokeJoin::Miter, - usvg::LineJoin::MiterClip => StrokeJoin::Miter, - usvg::LineJoin::Round => StrokeJoin::Round, - usvg::LineJoin::Bevel => StrokeJoin::Bevel, - }, - join_miter_limit: stroke.miterlimit().get() as f64, - align: StrokeAlign::Center, - paint_order: PaintOrder::StrokeAbove, - transform, - }) - } -} - -fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSpreadMethod { - match spread_method { - usvg::SpreadMethod::Pad => GradientSpreadMethod::Pad, - usvg::SpreadMethod::Reflect => GradientSpreadMethod::Reflect, - usvg::SpreadMethod::Repeat => GradientSpreadMethod::Repeat, - } -} - -fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) { - modify_inputs.fill_set(match &fill.paint() { - usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), - usvg::Paint::LinearGradient(linear) => { - let gradient_transform = usvg_transform(linear.transform()); - let (start, end) = (DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64)); - let (start, end) = (gradient_transform.transform_point2(start), gradient_transform.transform_point2(end)); - let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); - - let gradient_type = GradientType::Linear; - - let stops = match graphite_gradient_stops.get(linear.id()) { - Some(graphite_stops) => graphite_stops.clone(), - None => { - let stops = linear.stops().iter().map(|stop| GradientStop { - position: stop.offset().get() as f64, - midpoint: 0.5, - color: usvg_color(stop.color(), stop.opacity().get()), - }); - GradientStops::new(stops) - } - }; - let spread_method = convert_spread_method(linear.spread_method()); - - Fill::Gradient(Gradient { - start, - end, - gradient_type, - stops, - spread_method, - }) - } - usvg::Paint::RadialGradient(radial) => { - let gradient_transform = usvg_transform(radial.transform()); - let center = DVec2::new(radial.cx() as f64, radial.cy() as f64); - let edge = center + DVec2::X * radial.r().get() as f64; - let (start, end) = (gradient_transform.transform_point2(center), gradient_transform.transform_point2(edge)); - let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); - - let gradient_type = GradientType::Radial; - - let stops = match graphite_gradient_stops.get(radial.id()) { - Some(graphite_stops) => graphite_stops.clone(), - None => { - let stops = radial.stops().iter().map(|stop| GradientStop { - position: stop.offset().get() as f64, - midpoint: 0.5, - color: usvg_color(stop.color(), stop.opacity().get()), - }); - GradientStops::new(stops) - } - }; - let spread_method = convert_spread_method(radial.spread_method()); - - Fill::Gradient(Gradient { - start, - end, - gradient_type, - stops, - spread_method, - }) - } - usvg::Paint::Pattern(_) => { - warn!("SVG patterns are not currently supported"); - return; - } - }); -} diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 84774cf0f2..1b0d23205e 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -1,10 +1,11 @@ use super::transform_utils; +use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP}; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input}; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; @@ -13,6 +14,8 @@ use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::list::List; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; +use graphene_std::renderer::Quad; +use graphene_std::renderer::usvg_utils::{convert_usvg_path, extract_usvg_fill, extract_usvg_stroke, usvg_transform}; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, GradientSpreadMethod, GradientType, Stroke}; @@ -798,6 +801,218 @@ impl<'a> ModifyInputsContext<'a> { } } +/// Import a usvg node as the root of an SVG import operation. +/// +/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly +/// interact with any existing layers in the parent stack. All descendant layers use a lightweight +/// O(n) import path that skips collision detection and instead calculates positions directly from +/// the known tree structure. +pub fn import_usvg_node( + modify_inputs: &mut ModifyInputsContext, + node: &usvg::Node, + id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + graphite_gradient_stops: &HashMap, +) { + let layer = modify_inputs.create_layer(id); + + modify_inputs.network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); + modify_inputs.layer_node = Some(layer); + if let Some(upstream_layer) = layer.next_sibling(modify_inputs.network_interface.document_metadata()) { + modify_inputs.network_interface.shift_node(&upstream_layer.to_node(), IVec2::new(0, STACK_VERTICAL_GAP), &[]); + } + + match node { + usvg::Node::Group(group) => { + // Collect child extents for O(n) position calculation + let mut child_extents_svg_order: Vec = Vec::new(); + let mut group_extents_map: HashMap> = HashMap::new(); + + // Enable import mode: skips expensive is_acyclic checks and per-node cache invalidation + // during wiring since we're building a known tree structure where cycles are impossible + modify_inputs.import = true; + + for child in group.children() { + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map); + child_extents_svg_order.push(extent); + } + + modify_inputs.import = false; + modify_inputs.layer_node = Some(layer); + + // Rebuild the layer tree once now that all wiring is complete + modify_inputs.network_interface.load_structure(); + + // Set positions for all imported descendants in a single O(n) pass + let parent_pos = modify_inputs.network_interface.position(&layer.to_node(), &[]).unwrap_or(IVec2::ZERO); + set_import_child_positions(modify_inputs.network_interface, layer, parent_pos, &child_extents_svg_order, &group_extents_map); + + // Invalidate caches once after all positions are set + modify_inputs.network_interface.unload_all_nodes_click_targets(&[]); + modify_inputs.network_interface.unload_all_nodes_bounding_box(&[]); + } + usvg::Node::Path(path) => { + import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops); + } + usvg::Node::Image(_image) => { + warn!("Skip image"); + } + usvg::Node::Text(text) => { + let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); + modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); + modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + } + } +} + +/// Recursively import a usvg node as a descendant of the root import layer. +/// Uses lightweight wiring (no push/collision) and returns the subtree extent for position calculation. +/// +/// The subtree extent represents the additional vertical grid units that this node's descendants +/// occupy below the node's position. This is used to calculate correct y_offsets between siblings. +pub fn import_usvg_node_inner( + modify_inputs: &mut ModifyInputsContext, + node: &usvg::Node, + id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + graphite_gradient_stops: &HashMap, + group_extents_map: &mut HashMap>, +) -> u32 { + let layer = modify_inputs.create_layer(id); + modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]); + modify_inputs.layer_node = Some(layer); + + match node { + usvg::Node::Group(group) => { + let mut child_extents: Vec = Vec::new(); + for child in group.children() { + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map); + child_extents.push(extent); + } + modify_inputs.layer_node = Some(layer); + + let n = child_extents.len(); + let total_extent = if n == 0 { + 0 + } else { + (2 * STACK_VERTICAL_GAP as u32) * n as u32 - STACK_VERTICAL_GAP as u32 + child_extents.iter().sum::() + }; + group_extents_map.insert(layer, child_extents); + total_extent + } + usvg::Node::Path(path) => { + import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops); + 0 + } + usvg::Node::Image(_image) => { + warn!("Skip image"); + 0 + } + usvg::Node::Text(text) => { + let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); + modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); + modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + 0 + } + } +} + +/// Set correct positions for all imported layers in a single top-down O(n) pass. +/// +/// For each group's child stack: +/// - The top-of-stack child (last SVG child) gets an `Absolute` position at `(parent_x - LAYER_INDENT_OFFSET, parent_y + STACK_VERTICAL_GAP)` +/// - All other children get `Stack(y_offset)` where `y_offset` accounts for the subtree extent of the sibling above them in the stack, ensuring no overlap. +pub fn set_import_child_positions( + network_interface: &mut NodeNetworkInterface, + group: LayerNodeIdentifier, + group_pos: IVec2, + child_extents_svg_order: &[u32], + group_extents_map: &HashMap>, +) { + use crate::messages::portfolio::document::utility_types::network_interface::LayerPosition; + + let layer_children: Vec<_> = group.children(network_interface.document_metadata()).collect(); + let n = child_extents_svg_order.len(); + + if n == 0 || layer_children.is_empty() { + return; + } + + // Children in the layer tree are in stack order (top to bottom), which is the REVERSE of SVG order. + // SVG order: [s_0, s_1, ..., s_{n-1}] with extents [e_0, e_1, ..., e_{n-1}] + // Stack order: [s_{n-1}, s_{n-2}, ..., s_0 ] (top to bottom) + // + // For stack child at index i: + // - SVG index = n - 1 - i + // - Previous stack sibling's SVG index = n - i + // - y_offset = extent_of_previous_sibling + STACK_VERTICAL_GAP + + let child_x = group_pos.x - LAYER_INDENT_OFFSET; + let mut current_y = group_pos.y + STACK_VERTICAL_GAP; + + for (i, child_layer) in layer_children.iter().enumerate() { + let child_pos = IVec2::new(child_x, current_y); + + if i == 0 { + // Top of stack: set to `Absolute` position + network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Absolute(child_pos), &[]); + } else { + // Below top: set `Stack` with `y_offset` based on previous sibling's subtree extent + let prev_sibling_svg_index = n - i; + let y_offset = child_extents_svg_order[prev_sibling_svg_index] + STACK_VERTICAL_GAP as u32; + network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Stack(y_offset), &[]); + } + + // Recurse into group children to set their descendants' positions + if let Some(grandchild_extents) = group_extents_map.get(child_layer) { + set_import_child_positions(network_interface, *child_layer, child_pos, grandchild_extents, group_extents_map); + } + + // Advance `current_y` for the next child: node height (STACK_VERTICAL_GAP) + gap (STACK_VERTICAL_GAP) + subtree extent + let child_svg_index = n - 1 - i; + let child_extent = child_extents_svg_order[child_svg_index]; + current_y += 2 * STACK_VERTICAL_GAP + child_extent as i32; + } +} + +/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer. +/// +pub fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap) { + let subpaths = convert_usvg_path(path); + + let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); + + // Skip creating a Transform node entirely when the SVG-native transform is identity. + let node_transform = usvg_transform(node.abs_transform()); + let has_transform = node_transform != DAffine2::IDENTITY; + modify_inputs.insert_vector(subpaths, layer, has_transform, path.fill().is_some(), path.stroke().is_some()); + if has_transform && let Some(transform_node_id) = modify_inputs.existing_proto_node_id(graphene_std::transform_nodes::transform::IDENTIFIER, false) { + transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, node_transform); + } + + if let Some(fill) = path.fill() { + let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + apply_usvg_fill(fill, modify_inputs, bounds_transform, graphite_gradient_stops); + } + if let Some(stroke) = path.stroke() { + apply_usvg_stroke(stroke, modify_inputs, node_transform); + } +} + +pub fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) { + if let Some(fill) = extract_usvg_fill(fill, bounds_transform, graphite_gradient_stops) { + modify_inputs.fill_set(fill); + } +} + +pub fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) { + if let Some(stroke) = extract_usvg_stroke(stroke, transform) { + modify_inputs.stroke_set(stroke) + } +} + /// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both /// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint /// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks. diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 266564c713..a7b65f4b94 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -225,6 +225,7 @@ pub fn new_image_layer(image: Image, id: NodeId, parent: LayerNodeIdentif } /// Create a new group layer from an SVG string. +/// pub fn new_svg_layer(svg: String, transform: glam::DAffine2, center: bool, id: NodeId, parent: LayerNodeIdentifier, responses: &mut VecDeque) -> LayerNodeIdentifier { let insert_index = 0; responses.add(GraphOperationMessage::NewSvg { diff --git a/node-graph/libraries/rendering/src/convert_usvg_path.rs b/node-graph/libraries/rendering/src/convert_usvg_path.rs deleted file mode 100644 index 2f07db846b..0000000000 --- a/node-graph/libraries/rendering/src/convert_usvg_path.rs +++ /dev/null @@ -1,47 +0,0 @@ -use glam::DVec2; -use vector_types::subpath::{ManipulatorGroup, Subpath}; -use vector_types::vector::PointId; - -pub fn convert_usvg_path(path: &usvg::Path) -> Vec> { - let mut subpaths = Vec::new(); - let mut manipulators_list = Vec::new(); - - let mut points = path.data().points().iter(); - let to_vec = |p: &usvg::tiny_skia_path::Point| DVec2::new(p.x as f64, p.y as f64); - - for verb in path.data().verbs() { - match verb { - usvg::tiny_skia_path::PathVerb::Move => { - subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false)); - let Some(start) = points.next().map(to_vec) else { continue }; - manipulators_list.push(ManipulatorGroup::new(start, Some(start), Some(start))); - } - usvg::tiny_skia_path::PathVerb::Line => { - let Some(end) = points.next().map(to_vec) else { continue }; - manipulators_list.push(ManipulatorGroup::new(end, Some(end), Some(end))); - } - usvg::tiny_skia_path::PathVerb::Quad => { - let Some(handle) = points.next().map(to_vec) else { continue }; - let Some(end) = points.next().map(to_vec) else { continue }; - if let Some(last) = manipulators_list.last_mut() { - last.out_handle = Some(last.anchor + (2. / 3.) * (handle - last.anchor)); - } - manipulators_list.push(ManipulatorGroup::new(end, Some(end + (2. / 3.) * (handle - end)), Some(end))); - } - usvg::tiny_skia_path::PathVerb::Cubic => { - let Some(first_handle) = points.next().map(to_vec) else { continue }; - let Some(second_handle) = points.next().map(to_vec) else { continue }; - let Some(end) = points.next().map(to_vec) else { continue }; - if let Some(last) = manipulators_list.last_mut() { - last.out_handle = Some(first_handle); - } - manipulators_list.push(ManipulatorGroup::new(end, Some(second_handle), Some(end))); - } - usvg::tiny_skia_path::PathVerb::Close => { - subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true)); - } - } - } - subpaths.push(Subpath::new(manipulators_list, false)); - subpaths -} diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 418f4e9c0c..5c69355066 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,6 +1,6 @@ -pub mod convert_usvg_path; pub mod render_ext; mod renderer; pub mod to_peniko; +pub mod usvg_utils; pub use renderer::*; diff --git a/node-graph/libraries/rendering/src/usvg_utils.rs b/node-graph/libraries/rendering/src/usvg_utils.rs new file mode 100644 index 0000000000..0a9879804d --- /dev/null +++ b/node-graph/libraries/rendering/src/usvg_utils.rs @@ -0,0 +1,339 @@ +use std::collections::HashMap; + +use core_types::{Color, math::quad::Quad}; +use glam::{DAffine2, DVec2}; +use vector_types::{ + subpath::{ManipulatorGroup, Subpath}, + vector::PointId, + vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}, +}; + +pub fn convert_usvg_path(path: &usvg::Path) -> Vec> { + let mut subpaths = Vec::new(); + let mut manipulators_list = Vec::new(); + + let mut points = path.data().points().iter(); + let to_vec = |p: &usvg::tiny_skia_path::Point| DVec2::new(p.x as f64, p.y as f64); + + for verb in path.data().verbs() { + match verb { + usvg::tiny_skia_path::PathVerb::Move => { + subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false)); + let Some(start) = points.next().map(to_vec) else { continue }; + manipulators_list.push(ManipulatorGroup::new(start, Some(start), Some(start))); + } + usvg::tiny_skia_path::PathVerb::Line => { + let Some(end) = points.next().map(to_vec) else { continue }; + manipulators_list.push(ManipulatorGroup::new(end, Some(end), Some(end))); + } + usvg::tiny_skia_path::PathVerb::Quad => { + let Some(handle) = points.next().map(to_vec) else { continue }; + let Some(end) = points.next().map(to_vec) else { continue }; + if let Some(last) = manipulators_list.last_mut() { + last.out_handle = Some(last.anchor + (2. / 3.) * (handle - last.anchor)); + } + manipulators_list.push(ManipulatorGroup::new(end, Some(end + (2. / 3.) * (handle - end)), Some(end))); + } + usvg::tiny_skia_path::PathVerb::Cubic => { + let Some(first_handle) = points.next().map(to_vec) else { continue }; + let Some(second_handle) = points.next().map(to_vec) else { continue }; + let Some(end) = points.next().map(to_vec) else { continue }; + if let Some(last) = manipulators_list.last_mut() { + last.out_handle = Some(first_handle); + } + manipulators_list.push(ManipulatorGroup::new(end, Some(second_handle), Some(end))); + } + usvg::tiny_skia_path::PathVerb::Close => { + subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true)); + } + } + } + subpaths.push(Subpath::new(manipulators_list, false)); + subpaths +} + +pub fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSpreadMethod { + match spread_method { + usvg::SpreadMethod::Pad => GradientSpreadMethod::Pad, + usvg::SpreadMethod::Reflect => GradientSpreadMethod::Reflect, + usvg::SpreadMethod::Repeat => GradientSpreadMethod::Repeat, + } +} + +pub fn usvg_color(c: usvg::Color, a: f32) -> Color { + Color::from_rgbaf32_unchecked(c.red as f32 / 255., c.green as f32 / 255., c.blue as f32 / 255., a) +} + +pub fn usvg_transform(c: usvg::Transform) -> DAffine2 { + DAffine2::from_cols_array(&[c.sx as f64, c.ky as f64, c.kx as f64, c.sy as f64, c.tx as f64, c.ty as f64]) +} + +const GRAPHITE_NAMESPACE: &str = "https://graphite.art"; + +// Pre-parses the raw SVG XML to extract gradient stops that have `graphite:midpoint` attributes. +// Graphite exports gradients with midpoint curve data by writing interpolated approximation stops +// alongside the real stops. Real stops are tagged with `graphite:midpoint` attributes. +// Returns a map from gradient element `id` to `GradientStops` containing only the real stops. +pub fn extract_graphite_gradient_stops(svg: &str) -> HashMap { + let mut result = HashMap::new(); + + // Quick check: if the SVG doesn't reference `graphite:midpoint` at all, skip parsing + if !svg.contains("graphite:midpoint") { + return result; + } + + let doc = match usvg::roxmltree::Document::parse(svg) { + Ok(doc) => doc, + Err(_) => return result, + }; + + for node in doc.descendants() { + match node.tag_name().name() { + "linearGradient" | "radialGradient" => {} + _ => continue, + } + + let gradient_id = match node.attribute("id") { + Some(id) => id.to_string(), + None => continue, + }; + + let mut real_stops = Vec::new(); + let mut has_any_midpoint = false; + + for child in node.children() { + if child.tag_name().name() != "stop" { + continue; + } + + let midpoint = child.attribute((GRAPHITE_NAMESPACE, "midpoint")).and_then(|v| v.parse::().ok()); + + if let Some(midpoint) = midpoint { + has_any_midpoint = true; + + let offset = child.attribute("offset").and_then(|v| v.parse::().ok()).unwrap_or(0.); + let opacity = child.attribute("stop-opacity").and_then(|v| v.parse::().ok()).unwrap_or(1.); + let color = child.attribute("stop-color").and_then(|hex| parse_hex_stop_color(hex, opacity)).unwrap_or(Color::BLACK); + + real_stops.push(GradientStop { position: offset, midpoint, color }); + } + } + + if has_any_midpoint && !real_stops.is_empty() { + result.insert(gradient_id, GradientStops::new(real_stops)); + } + } + + result +} + +pub fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { + let hex = hex.strip_prefix('#')?; + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.; + let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.; + let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.; + Some(Color::from_rgbaf32_unchecked(r, g, b, opacity)) +} + +// Create an intermidate representation that holds data extracted from usvg data structures +// Rewrite all the functions below to be independent of the ModifyInputsContext data structure +// These functions should be able to convert data from usvg into Graphite internal data structures (Fill, Stroke, Vector) +// Use that functions to do the same functions as importing an svg as well as for implementing the Vectorize node +// Vectorize node should also be able to insert a fill, stroke or path node according to the resulting SVG data from vtracer +// Implement tooling in adjacent to Vectorize node to be able to insert Fill, Stroke or Text nodes into the node graph +pub enum ParsedSvgNode { + Group(Box), + Path(Box), + Text(Box), + Image { msg: String }, +} + +pub struct ParsedSvgGroup { + pub children: Vec, + pub transform: DAffine2, + // pub child_extents_svg_order: Vec, + // pub group_extents_map: HashMap>, +} + +pub struct ParsedSvgPath { + pub subpaths: Vec>, + pub fill: Option, + pub stroke: Option, + pub transform: DAffine2, +} + +pub struct ParsedSvgText { + text: String, + transform: DAffine2, +} + +pub fn extract_usvg_fill(fill: &usvg::Fill, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) -> Option { + match &fill.paint() { + usvg::Paint::Color(color) => Some(Fill::solid(usvg_color(*color, fill.opacity().get()))), + usvg::Paint::LinearGradient(linear) => { + let gradient_transform = usvg_transform(linear.transform()); + let (start, end) = (DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64)); + let (start, end) = (gradient_transform.transform_point2(start), gradient_transform.transform_point2(end)); + let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); + + let gradient_type = GradientType::Linear; + + let stops = match graphite_gradient_stops.get(linear.id()) { + Some(graphite_stops) => graphite_stops.clone(), + None => { + let stops = linear.stops().iter().map(|stop| GradientStop { + position: stop.offset().get() as f64, + midpoint: 0.5, + color: usvg_color(stop.color(), stop.opacity().get()), + }); + GradientStops::new(stops) + } + }; + let spread_method = convert_spread_method(linear.spread_method()); + + Some(Fill::Gradient(Gradient { + start, + end, + gradient_type, + stops, + spread_method, + })) + } + usvg::Paint::RadialGradient(radial) => { + let gradient_transform = usvg_transform(radial.transform()); + let center = DVec2::new(radial.cx() as f64, radial.cy() as f64); + let edge = center + DVec2::X * radial.r().get() as f64; + let (start, end) = (gradient_transform.transform_point2(center), gradient_transform.transform_point2(edge)); + let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end)); + + let gradient_type = GradientType::Radial; + + let stops = match graphite_gradient_stops.get(radial.id()) { + Some(graphite_stops) => graphite_stops.clone(), + None => { + let stops = radial.stops().iter().map(|stop| GradientStop { + position: stop.offset().get() as f64, + midpoint: 0.5, + color: usvg_color(stop.color(), stop.opacity().get()), + }); + GradientStops::new(stops) + } + }; + let spread_method = convert_spread_method(radial.spread_method()); + + Some(Fill::Gradient(Gradient { + start, + end, + gradient_type, + stops, + spread_method, + })) + } + usvg::Paint::Pattern(_) => { + // warn!("SVG patterns are not currently supported"); + None + } + } +} + +pub fn extract_usvg_stroke(stroke: &usvg::Stroke, transform: DAffine2) -> Option { + if let usvg::Paint::Color(color) = &stroke.paint() { + Some(Stroke { + color: Some(usvg_color(*color, stroke.opacity().get())), + weight: stroke.width().get() as f64, + dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), + dash_offset: stroke.dashoffset() as f64, + cap: match stroke.linecap() { + usvg::LineCap::Butt => StrokeCap::Butt, + usvg::LineCap::Round => StrokeCap::Round, + usvg::LineCap::Square => StrokeCap::Square, + }, + join: match stroke.linejoin() { + usvg::LineJoin::Miter => StrokeJoin::Miter, + usvg::LineJoin::MiterClip => StrokeJoin::Miter, + usvg::LineJoin::Round => StrokeJoin::Round, + usvg::LineJoin::Bevel => StrokeJoin::Bevel, + }, + join_miter_limit: stroke.miterlimit().get() as f64, + align: StrokeAlign::Center, + paint_order: PaintOrder::StrokeAbove, + transform, + }) + } else { + None + } +} + +pub fn extract_usvg_path(node: &usvg::Node, path: &usvg::Path, graphite_gradient_stops: &HashMap) -> ParsedSvgPath { + let subpaths = convert_usvg_path(path); + + let transform = usvg_transform(node.abs_transform()); + let bounds = subpaths.iter().filter_map(|s| s.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); + let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + + ParsedSvgPath { + subpaths, + fill: path.fill().and_then(|fill| extract_usvg_fill(fill, bounds_transform, graphite_gradient_stops)), + stroke: path.stroke().and_then(|stroke| extract_usvg_stroke(stroke, transform)), + transform, + } +} + +pub fn extract_usvg_node(node: &usvg::Node, graphite_gradient_stops: &HashMap) -> ParsedSvgNode { + match node { + usvg::Node::Group(group) => { + // let mut child_extents_svg_order: Vec = Vec::new(); + // let mut group_extents_map: HashMap> = HashMap::new(); + + // let get_child_extents = |group: &Box, group_extents_map: HashMap>| { + // let mut child_extents: Vec = Vec::new(); + // for child in group.children() { + // let extent = get_child_extend(); + // child_extents.push(extent); + // } + + // let n = child_extents.len(); + // let total_extent = if n == 0 { + // 0 + // } else { + // (2 * STACK_VERTICAL_GAP as u32) * n as u32 - STACK_VERTICAL_GAP as u32 + child_extents.iter().sum::() + // }; + // group_extents_map.insert(layer, child_extents); + // total_extent + // }; + let group = Box::new(ParsedSvgGroup { + children: group + .children() + .iter() + .map(|child| { + let child = extract_usvg_node(child, graphite_gradient_stops); + // match child { + // ParsedSvgNode::Group(parsed_group) => { + // parsed_group.extent = get_child_extents(group, group_extents_map); + // child_extents_svg_order.push(parsed_group.extent); + // } + // _ => {} + // } + child + }) + .collect(), + transform: usvg_transform(node.abs_transform()), + }); + + ParsedSvgNode::Group(group) + } + usvg::Node::Path(path) => ParsedSvgNode::Path(Box::new(extract_usvg_path(node, path, graphite_gradient_stops))), + // No support for SVG image node + usvg::Node::Image(_) => ParsedSvgNode::Image { msg: String::from("Not supported") }, + usvg::Node::Text(text) => { + let text = ParsedSvgText { + text: text.chunks().iter().map(|c| c.text()).collect(), + transform: usvg_transform(node.abs_transform()), + }; + ParsedSvgNode::Text(Box::new(text)) + } + } +} diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index 77f7a648cc..46451d5add 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -40,6 +40,7 @@ wasm = [ # Local dependencies no-std-types = { workspace = true } node-macro = { workspace = true } +rendering = { workspace = true } # Local std dependencies dyn-any = { workspace = true, optional = true } @@ -57,6 +58,7 @@ glam = { workspace = true } spirv-std = { workspace = true } num-traits = { workspace = true } num_enum = { workspace = true } +usvg = {workspace = true } # Workspace std dependencies image = { workspace = true, optional = true } @@ -71,6 +73,11 @@ kurbo = { workspace = true, optional = true } tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } +[target.'cfg(not(target_family = "wasm"))'.dependencies] +vtracer = { workspace = true } +visioncortex = { workspace = true } + + [dev-dependencies] tokio = { workspace = true } futures = { workspace = true } diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 6ff551843d..3d9ae28d0b 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -15,8 +15,12 @@ use rand_chacha::ChaCha8Rng; use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; use raster_types::{CPU, Raster}; +use rendering::usvg_utils::extract_graphite_gradient_stops; +use rendering::usvg_utils::{ParsedSvgGroup, ParsedSvgNode, extract_usvg_node, extract_usvg_path}; use std::fmt::Debug; use std::hash::Hash; +use vector_types::vector::PointId; +use vector_types::{Subpath, Vector}; #[derive(Debug, DynAny)] pub enum Error { @@ -226,6 +230,122 @@ pub fn mask( .collect() } +#[cfg(not(target_family = "wasm"))] +#[node_macro::node(category("Raster"), path(core_types::vector))] +pub fn vectorize(_ctx: impl Ctx, image: List>) -> List { + use visioncortex::PathSimplifyMode; + use vtracer::{ColorImage, ColorMode, Config, Hierarchical, convert}; + + image + .into_iter() + .map(|row| { + // let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); + let image_data = row.element(); + let color_image = ColorImage { + width: image_data.width() as usize, + height: image_data.height() as usize, + pixels: image_data.to_flat_u8().0, + }; + let config: Config = Config { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + mode: PathSimplifyMode::Spline, + corner_threshold: 60, + length_threshold: 4., + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(6), + }; + + let vectorized_image = convert(color_image, config).expect("failed to obtain an SvgFile from vtracer."); + let image_svg = vectorized_image.to_string(); + let svg_tree = match usvg::Tree::from_str(&image_svg, &usvg::Options::default()) { + Ok(t) => t, + Err(e) => { + // TODO: use proper error statements + panic!("Failed to create a usvg tree:\n{e}"); + } + }; + // println!("vectorized_image: {}", image_svg); + + let mut subpaths: Vec> = vec![]; + let graphite_gradient_stops = extract_graphite_gradient_stops(&image_svg); + let mut i = 1; + for child in svg_tree.root().children() { + match child { + usvg::Node::Path(path) => { + let parsed_path = extract_usvg_path(child, path, &graphite_gradient_stops); + let mut child_subpaths = parsed_path.subpaths.clone(); + child_subpaths.iter_mut().for_each(|s| s.apply_transform(parsed_path.transform)); + subpaths.extend(child_subpaths); + + println!("Reading path {} from a total of {}.", i, svg_tree.root().children().len()); + i += 1; + } + usvg::Node::Group(_) => { + let parsed_node = extract_usvg_node(child, &graphite_gradient_stops); + let parsed_group = if let ParsedSvgNode::Group(group) = parsed_node { + group + } else { + panic!("Must return a SVG group."); + }; + + for child in parsed_group.children { + if let ParsedSvgNode::Path(path) = child { + let mut child_subpaths = path.subpaths.clone(); + child_subpaths.iter_mut().for_each(|s| s.apply_transform(path.transform)); + subpaths.extend(child_subpaths); + + println!("Reading path (in a group) {} from a total of {}.", i, svg_tree.root().children().len()); + i += 1; + } + } + } + _ => {} + } + } + + /* + let mut path = BezPath::new(); + for subpath in vectorized_image.paths.iter() { + let svg_path = subpath.to_string(); + println!("svg_path: {}", svg_path); + // TODO: use usvg instead to read from the svg path + let bezpath: BezPath = kurbo::BezPath::from_svg(svg_path.as_str()).expect("failed to convert SVG into BezPath."); + + for curve in bezpath.segments() { + match curve { + PathSeg::Line(line) => { + println!("Inserting line: {:?}", line); + let a = transform.transform_point2(point_to_dvec2(line.p1)); + path.line_to(kurbo::Point::new(a.x, a.y)); + } + PathSeg::Quad(quad_bez) => { + println!("Inserting quad_bez: {:?}", quad_bez); + let a = transform.transform_point2(point_to_dvec2(quad_bez.p1)); + let b = transform.transform_point2(point_to_dvec2(quad_bez.p2)); + path.quad_to(kurbo::Point::new(a.x, a.y), kurbo::Point::new(b.x, b.y)); + } + PathSeg::Cubic(cubic_bez) => { + println!("Inserting cubic_bez: {:?}", cubic_bez); + let a = transform.transform_point2(point_to_dvec2(cubic_bez.p1)); + let b = transform.transform_point2(point_to_dvec2(cubic_bez.p2)); + let c = transform.transform_point2(point_to_dvec2(cubic_bez.p3)); + path.curve_to(kurbo::Point::new(a.x, a.y), kurbo::Point::new(b.x, b.y), kurbo::Point::new(c.x, c.y)); + } + } + } + } + */ + let vector = Vector::from_subpaths(subpaths, true); + Item::from_parts(vector, row.attributes().clone()) + }) + .collect::>() +} + #[node_macro::node(category(""))] pub fn extend_image_to_bounds(_: impl Ctx, image: List>, bounds: DAffine2) -> List> { image From 7e6176746723fd14adc8302e8f579dcf6549f7f7 Mon Sep 17 00:00:00 2001 From: seam0s Date: Wed, 24 Jun 2026 14:42:21 +0300 Subject: [PATCH 2/2] Extend Vectorize node support to web --- Cargo.lock | 6 ++-- .../graph_operation_message_handler.rs | 1 - node-graph/libraries/rendering/Cargo.toml | 2 ++ node-graph/libraries/rendering/src/lib.rs | 1 + .../libraries/rendering/src/usvg_utils.rs | 5 ++-- .../libraries/rendering/src/vtracer_utils.rs | 26 +++++++++++++++++ node-graph/nodes/raster/Cargo.toml | 4 --- node-graph/nodes/raster/src/std_nodes.rs | 28 ++----------------- 8 files changed, 38 insertions(+), 35 deletions(-) create mode 100644 node-graph/libraries/rendering/src/vtracer_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 5a90ec1dfe..cd621495ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2129,7 +2129,7 @@ name = "graphene-cli" version = "0.1.0" dependencies = [ "chrono", - "clap", + "clap 4.5.46", "document-container", "document-format", "fern", @@ -4773,8 +4773,6 @@ dependencies = [ "tsify", "usvg", "vector-types", - "visioncortex", - "vtracer", "wasm-bindgen", "wgpu-executor", ] @@ -4958,6 +4956,8 @@ dependencies = [ "vector-types", "vello", "vello_encoding", + "visioncortex", + "vtracer", ] [[package]] diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 08941141dc..354dab5d4b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -11,7 +11,6 @@ use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Artboard; use graphene_std::list::List; use graphene_std::renderer::usvg_utils::extract_graphite_gradient_stops; -use graphene_std::vector::style::Stroke; #[derive(ExtractField)] pub struct GraphOperationMessageContext<'a> { diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 13facc359c..e7a87a70eb 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -31,6 +31,8 @@ vello = { workspace = true } vello_encoding = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } +vtracer = { workspace = true } +visioncortex = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 5c69355066..66ff183647 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -2,5 +2,6 @@ pub mod render_ext; mod renderer; pub mod to_peniko; pub mod usvg_utils; +pub mod vtracer_utils; pub use renderer::*; diff --git a/node-graph/libraries/rendering/src/usvg_utils.rs b/node-graph/libraries/rendering/src/usvg_utils.rs index cb708f6e9d..56cbd8ad97 100644 --- a/node-graph/libraries/rendering/src/usvg_utils.rs +++ b/node-graph/libraries/rendering/src/usvg_utils.rs @@ -60,8 +60,9 @@ pub fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSprea } } -pub fn usvg_color(c: usvg::Color, a: f32) -> Color { - Color::from_rgbaf32_unchecked(c.red as f32 / 255., c.green as f32 / 255., c.blue as f32 / 255., a) +fn usvg_color(c: usvg::Color, a: f32) -> Color { + // `usvg::Color` channels are u8 sRGB display values (gamma-encoded); lift to linear-light for the internal `Color` + Color::from_gamma_srgb_channels(c.red as f32 / 255., c.green as f32 / 255., c.blue as f32 / 255., a) } pub fn usvg_transform(c: usvg::Transform) -> DAffine2 { diff --git a/node-graph/libraries/rendering/src/vtracer_utils.rs b/node-graph/libraries/rendering/src/vtracer_utils.rs new file mode 100644 index 0000000000..1d6987fab9 --- /dev/null +++ b/node-graph/libraries/rendering/src/vtracer_utils.rs @@ -0,0 +1,26 @@ +use graphic_types::raster_types::{Bitmap, CPU, Raster}; +use visioncortex::PathSimplifyMode; +use vtracer::{ColorImage, ColorMode, Config, Hierarchical, SvgFile, convert}; + +pub fn convert_to_svg(image_data: &Raster) -> SvgFile { + let color_image = ColorImage { + width: image_data.width() as usize, + height: image_data.height() as usize, + pixels: image_data.to_flat_u8().0, + }; + let config: Config = Config { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + mode: PathSimplifyMode::Spline, + corner_threshold: 60, + length_threshold: 4., + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(6), + }; + + convert(color_image, config).expect("failed to obtain an SvgFile from vtracer.") +} diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index 46451d5add..f887f11db9 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -73,10 +73,6 @@ kurbo = { workspace = true, optional = true } tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } -[target.'cfg(not(target_family = "wasm"))'.dependencies] -vtracer = { workspace = true } -visioncortex = { workspace = true } - [dev-dependencies] tokio = { workspace = true } diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 3d9ae28d0b..5974bad084 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -16,7 +16,8 @@ use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; use raster_types::{CPU, Raster}; use rendering::usvg_utils::extract_graphite_gradient_stops; -use rendering::usvg_utils::{ParsedSvgGroup, ParsedSvgNode, extract_usvg_node, extract_usvg_path}; +use rendering::usvg_utils::{ParsedSvgNode, extract_usvg_node, extract_usvg_path}; +use rendering::vtracer_utils::convert_to_svg; use std::fmt::Debug; use std::hash::Hash; use vector_types::vector::PointId; @@ -230,37 +231,14 @@ pub fn mask( .collect() } -#[cfg(not(target_family = "wasm"))] #[node_macro::node(category("Raster"), path(core_types::vector))] pub fn vectorize(_ctx: impl Ctx, image: List>) -> List { - use visioncortex::PathSimplifyMode; - use vtracer::{ColorImage, ColorMode, Config, Hierarchical, convert}; - image .into_iter() .map(|row| { // let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); let image_data = row.element(); - let color_image = ColorImage { - width: image_data.width() as usize, - height: image_data.height() as usize, - pixels: image_data.to_flat_u8().0, - }; - let config: Config = Config { - color_mode: ColorMode::Color, - hierarchical: Hierarchical::Stacked, - filter_speckle: 4, - color_precision: 6, - layer_difference: 16, - mode: PathSimplifyMode::Spline, - corner_threshold: 60, - length_threshold: 4., - max_iterations: 10, - splice_threshold: 45, - path_precision: Some(6), - }; - - let vectorized_image = convert(color_image, config).expect("failed to obtain an SvgFile from vtracer."); + let vectorized_image = convert_to_svg(&image_data); let image_svg = vectorized_image.to_string(); let svg_tree = match usvg::Tree::from_str(&image_svg, &usvg::Options::default()) { Ok(t) => t,