Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d32534b
Implement Vectorize node
seam0s-dev Jun 21, 2026
c9c84d7
Merge remote-tracking branch 'origin/master' into 2332-raster-image-v…
seam0s-dev Jun 22, 2026
7e61767
Extend Vectorize node support to web
seam0s-dev Jun 24, 2026
474236a
Move Vectorize node to "Vector" category
seam0s-dev Jun 26, 2026
44af68c
Add vtracer config as node properties
seam0s-dev Jun 27, 2026
5864226
Merge branch 'master' into 2332-raster-image-vectorization
seam0s-dev Jun 27, 2026
67c9ad6
refactor: separate usvg parsing from ModifyInputsContext in SVG import
c-mateo Jun 29, 2026
cecaaed
Add fills and strokes to the List<Vector> output of vectorize node
c-mateo Jun 29, 2026
1a5a8f3
Implement the VectorizeMode
c-mateo Jul 1, 2026
5d5e0af
Refactor extract_all_paths into usvg_utils.rs
seam0s-dev Jul 3, 2026
f3e20c3
Fix error handling
seam0s-dev Jul 3, 2026
29a146a
Merge branch 'master' into 2332-raster-image-vectorization
seam0s-dev Jul 3, 2026
675776b
Merge remote-tracking branch 'origin/master' into 2332-raster-image-v…
seam0s-dev Jul 16, 2026
82a5f72
Merge branch '2332-raster-image-vectorization' of https://github.com/…
seam0s-dev Jul 17, 2026
e9a688d
Migrate usvg_utils to use fill and stroke attributes
seam0s-dev Jul 19, 2026
b97cbf0
Merge remote-tracking branch 'origin/master' into 2332-raster-image-v…
seam0s-dev Jul 19, 2026
86bc691
Merge branch 'master' into 2332-raster-image-vectorization
seam0s-dev Jul 28, 2026
b498eb7
Fix import_usvg_node
seam0s-dev Jul 28, 2026
e1fceab
Merge 'origin/master' and refactor
seam0s-dev Aug 8, 2026
e203403
Merge remote-tracking branch 'origin/master' into 2332-raster-image-v…
seam0s-dev Aug 8, 2026
6f23d9d
Move tests to usvg_utils.rs
seam0s-dev Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 289 additions & 25 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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"] }
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::transform_utils;
use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP};
use crate::messages::portfolio::document::node_graph::document_node_definitions::{
ARTBOARD_DIMENSIONS_INPUT_INDEX, ARTBOARD_LOCATION_INPUT_INDEX, DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type,
};
Expand All @@ -16,6 +17,7 @@ use graph_craft::{ProtoNodeIdentifier, list};
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::renderer::usvg_utils::{ParsedSvgNode, ParsedSvgPath, SvgGradientInfo, extract_usvg_node};
use graphene_std::subpath::Subpath;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke};
Expand Down Expand Up @@ -993,3 +995,233 @@ impl<'a> ModifyInputsContext<'a> {
}
}
}

/// Import a usvg node as the root of an SVG import operation.
///
/// Phase 1: Parse the usvg tree into a `ParsedSvgNode` tree (pure data, no graphite dependencies).
/// Phase 2: Walk the parsed tree and build graphite layers.
///
/// 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, gradient_info: &SvgGradientInfo) {
// Phase 1: parse usvg tree into intermediate representation (pure, no ModifyInputsContext)
let parsed = extract_usvg_node(node, &gradient_info);

// Phase 2: build graphite layers from parsed tree
import_parsed_svg_root(modify_inputs, parsed, id, parent, insert_index);
}

/// Handle a leaf parsed SVG node (Path, Image, Text). Returns 0 (no subtree extent).
fn import_parsed_svg_leaf(modify_inputs: &mut ModifyInputsContext, node: ParsedSvgNode, layer: LayerNodeIdentifier) -> u32 {
match node {
ParsedSvgNode::Path(path) => {
import_parsed_svg_path(modify_inputs, *path, layer);
0
}
ParsedSvgNode::Image { .. } => {
warn!("Skip SVG image node");
0
}
ParsedSvgNode::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.text, font, TypesettingConfig::default(), layer);
if text.transform != DAffine2::IDENTITY
&& 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, text.transform);
}
modify_inputs.fill_color_set(Some(Color::BLACK));
0
}
ParsedSvgNode::Group(_) => unreachable!("import_parsed_svg_leaf called on Group"),
}
}

/// Phase 2 root handler: build graphite layers for a parsed SVG root node.
fn import_parsed_svg_root(modify_inputs: &mut ModifyInputsContext, node: ParsedSvgNode, id: NodeId, parent: LayerNodeIdentifier, insert_index: usize) {
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 {
ParsedSvgNode::Group(group) => {
// Collect child extents for O(n) position calculation
let mut child_extents_svg_order: Vec<u32> = Vec::new();
let mut group_extents_map: HashMap<LayerNodeIdentifier, Vec<u32>> = 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_parsed_svg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, &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(&[]);
}
_ => {
import_parsed_svg_leaf(modify_inputs, node, layer);
}
}
}

/// Recursively build graphite layers for a parsed SVG 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_parsed_svg_node_inner(
modify_inputs: &mut ModifyInputsContext,
node: ParsedSvgNode,
id: NodeId,
parent: LayerNodeIdentifier,
insert_index: usize,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> 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 {
ParsedSvgNode::Group(group) => {
let mut child_extents: Vec<u32> = Vec::new();
for child in group.children {
let extent = import_parsed_svg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, 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::<u32>()
};
group_extents_map.insert(layer, child_extents);
total_extent
}
_ => import_parsed_svg_leaf(modify_inputs, node, layer),
}
}

/// Apply a parsed SVG path to a graphite layer: insert vector geometry, transform, fill, stroke.
fn import_parsed_svg_path(modify_inputs: &mut ModifyInputsContext, path: ParsedSvgPath, layer: LayerNodeIdentifier) {
let has_transform = path.transform != DAffine2::IDENTITY;
modify_inputs.insert_vector(path.subpaths, layer, has_transform, path.fill_paint.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, path.transform);
}

if let Some(fill_paint) = path.fill_paint {
let fill_paint = fill_paint.element(0).expect("failed to access the first Graphic in List<Graphic>");
match fill_paint {
Graphic::Color(color) => {
let color = color.clone_item(0).expect("failed to access the first color in List<Color>");
modify_inputs.fill_color_set(Some(*color.element()));
}
Graphic::Gradient(gradient) => {
let gradient = gradient.clone_item(0).expect("failed to access the first gradient in List<Gradient>");
let gradient_form: &GradientForm = gradient.attribute(graphene_std::ATTR_GRADIENT_FORM).expect("failed to access GradientForm of the first gradient");
let settings = GradientSettings {
spread: *gradient.attribute(graphene_std::ATTR_GRADIENT_SPREAD).expect("failed to access GradientSpread of the first gradient"),
space: *gradient.attribute(graphene_std::ATTR_GRADIENT_SPACE).expect("failed to access GradientSpace of the first gradient"),
..Default::default()
};
let transform: &DAffine2 = gradient.attribute(graphene_std::ATTR_TRANSFORM).expect("failed to access DAffine2 of the first gradient");
modify_inputs.fill_gradient_set(gradient.element().clone(), *gradient_form, settings, *transform);
}
_ => {}
}
}
if let Some(stroke) = path.stroke
&& let Some(stroke_paint) = path.stroke_paint
{
let stroke_paint = stroke_paint.element(0).expect("failed to access the first Graphic in List<Graphic>");
match stroke_paint {
Graphic::Color(color) => {
let color = color.clone_item(0).expect("failed to access the first color in List<Color>");
modify_inputs.stroke_set(Some(*color.element()), stroke);
}
Graphic::Gradient(_) => {}
_ => {}
}
}
}

/// 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<LayerNodeIdentifier, Vec<u32>>,
) {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,7 @@ fn static_node_properties() -> NodeProperties {
"monitor_properties".to_string(),
Box::new(|_node_id, _context| node_properties::string_properties("Used internally by the editor to obtain a layer thumbnail.")),
);
map.insert("vectorize_properties".to_string(), Box::new(node_properties::vectorize_properties));
map
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ use graphene_std::vector::style::{
FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap,
StrokeJoin, build_transform_with_y_preservation,
};
use graphene_std::vector::vectorize::{
ColorModeInput, ColorPrecisionInput, CornerThresholdInput, FilterSpeckleInput, HierarchicalInput, LayerDifferenceInput, LengthThresholdInput, MaxIterationsInput, PathPrecisionInput,
PathSimplifyModeInput, SpliceThresholdInput, VectorizeModeInput,
};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
use graphene_std::vector_types::vectorize_config;
use graphene_std::{NodeParameter, ParameterRef};

pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
Expand Down Expand Up @@ -2781,6 +2786,45 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) ->
]
}

pub fn vectorize_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let vector_mode = enum_choice::<vectorize_config::VectorizeMode>()
.for_socket(ParameterWidgetsInfo::new(node_id, VectorizeModeInput, true, context))
.property_row();
let color_mode = enum_choice::<vectorize_config::ColorMode>()
.for_socket(ParameterWidgetsInfo::new(node_id, ColorModeInput, true, context))
.property_row();
let hierarchical = enum_choice::<vectorize_config::Hierarchical>()
.for_socket(ParameterWidgetsInfo::new(node_id, HierarchicalInput, true, context))
.property_row();
let filter_speckle = number_widget(ParameterWidgetsInfo::new(node_id, FilterSpeckleInput, true, context), NumberInput::default().int().min(0.));
let color_precision = number_widget(ParameterWidgetsInfo::new(node_id, ColorPrecisionInput, true, context), NumberInput::default().int().min(0.).max(8.));
let layer_difference = number_widget(ParameterWidgetsInfo::new(node_id, LayerDifferenceInput, true, context), NumberInput::default().int().min(0.));
let path_simplify_mode = enum_choice::<vectorize_config::PathSimplifyMode>()
.for_socket(ParameterWidgetsInfo::new(node_id, PathSimplifyModeInput, true, context))
.property_row();
let corner_threshold = number_widget(ParameterWidgetsInfo::new(node_id, CornerThresholdInput, true, context), NumberInput::default().int().min(0.));
let length_threshold = number_widget(ParameterWidgetsInfo::new(node_id, LengthThresholdInput, true, context), NumberInput::default().min(0.));
let max_iterations = number_widget(ParameterWidgetsInfo::new(node_id, MaxIterationsInput, true, context), NumberInput::default().int().min(0.));
let splice_threshold = number_widget(ParameterWidgetsInfo::new(node_id, SpliceThresholdInput, true, context), NumberInput::default().int().min(0.));
let path_precision = number_widget(ParameterWidgetsInfo::new(node_id, PathPrecisionInput, true, context), NumberInput::default().int().min(0.));
// let separate_layers = bool_widget(ParameterWidgetsInfo::new(node_id, SeparateLayersInput::INDEX, true, context), CheckboxInput::default());
vec![
vector_mode,
color_mode,
hierarchical,
LayoutGroup::row(filter_speckle),
LayoutGroup::row(color_precision),
LayoutGroup::row(layer_difference),
path_simplify_mode,
// LayoutGroup::row(separate_layers),
LayoutGroup::row(corner_threshold),
LayoutGroup::row(length_threshold),
LayoutGroup::row(max_iterations),
LayoutGroup::row(splice_threshold),
LayoutGroup::row(path_precision),
]
}

pub struct ParameterWidgetsInfo<'a> {
network_interface: &'a NodeNetworkInterface,
resources: &'a ResourceMessageHandler,
Expand Down
1 change: 1 addition & 0 deletions node-graph/graph-craft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ graphene-application-io = { workspace = true, features = ["serde"] }
rendering = { workspace = true, features = ["serde"] }
raster-nodes = { workspace = true, features = ["serde"] }
vector-nodes = { workspace = true, features = ["serde"] }
vector-types = { workspace = true, features = ["serde"] }
graphic-types = { workspace = true, features = ["serde"] }
text-nodes = { workspace = true, features = ["serde"] }

Expand Down
4 changes: 4 additions & 0 deletions node-graph/graph-craft/src/document/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,10 @@ tagged_value! {
// Legacy
#[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill),
ColorMode(vector_types::vectorize_config::ColorMode),
Hierarchical(vector_types::vectorize_config::Hierarchical),
PathSimplifyMode(vector_types::vectorize_config::PathSimplifyMode),
VectorizeMode(vector_types::vectorize_config::VectorizeMode)
}

impl TaggedValue {
Expand Down
1 change: 1 addition & 0 deletions node-graph/libraries/rendering/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ graphic-types = { workspace = true }
vello = { workspace = true }
vello_encoding = { workspace = true }
parley = { workspace = true }
simplecss = { workspace = true }
skrifa = { workspace = true }

# Optional workspace dependencies
Expand Down
47 changes: 0 additions & 47 deletions node-graph/libraries/rendering/src/convert_usvg_path.rs

This file was deleted.

Loading
Loading