From 5dedb08092fe54a73af7534407bd9f2d87a0a6dc Mon Sep 17 00:00:00 2001 From: Alab Melendres Date: Sun, 16 Nov 2025 16:47:51 +0800 Subject: [PATCH 1/4] Render vector mesh fills correctly as separate subpaths (#3378) --- .../gcore/src/vector/vector_attributes.rs | 168 ++++- node-graph/gsvg-renderer/src/renderer.rs | 593 ++++++++++++------ 2 files changed, 577 insertions(+), 184 deletions(-) diff --git a/node-graph/gcore/src/vector/vector_attributes.rs b/node-graph/gcore/src/vector/vector_attributes.rs index 2817a7561d..862e4dea97 100644 --- a/node-graph/gcore/src/vector/vector_attributes.rs +++ b/node-graph/gcore/src/vector/vector_attributes.rs @@ -4,7 +4,7 @@ use crate::vector::vector_types::Vector; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use kurbo::{CubicBez, Line, PathSeg, QuadBez}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::iter::zip; @@ -678,6 +678,44 @@ impl FoundSubpath { pub fn contains(&self, segment_id: SegmentId) -> bool { self.edges.iter().any(|s| s.id == segment_id) } + + pub fn to_bezpath(&self, vector: &Vector) -> kurbo::BezPath { + let mut bezpath = kurbo::BezPath::new(); + + if let Some(first_edge) = self.edges.first() { + let start_pos = vector.point_domain.positions()[first_edge.start]; + bezpath.move_to(dvec2_to_point(start_pos)); + } + + for edge in &self.edges { + let segment_index = vector.segment_domain.ids().iter().position(|&id| id == edge.id).expect("Segment ID must exist"); + + let mut handles = vector.segment_domain.handles()[segment_index]; + if edge.reverse { + handles = handles.reversed(); + } + + let end_pos = vector.point_domain.positions()[edge.end]; + + match handles { + BezierHandles::Linear => { + bezpath.line_to(dvec2_to_point(end_pos)); + } + BezierHandles::Quadratic { handle } => { + bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(end_pos)); + } + BezierHandles::Cubic { handle_start, handle_end } => { + bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(end_pos)); + } + } + } + + if self.is_closed() { + bezpath.close_path(); + } + + bezpath + } } impl Vector { @@ -985,6 +1023,134 @@ impl Vector { self.segment_domain.map_ids(&id_map); self.region_domain.map_ids(&id_map); } + + pub fn is_branching_mesh(&self) -> bool { + for point_index in 0..self.point_domain.len() { + let connection_count = self.segment_domain.connected_count(point_index); + + if connection_count > 2 { + return true; + } + } + + false + } + + /// Find all minimal closed regions (faces) in a branching mesh vector. + pub fn find_closed_regions(&self) -> Vec { + let mut regions = Vec::new(); + let mut used_half_edges = HashSet::new(); + + // Build adjacency list sorted by angle for proper face traversal + let mut adjacency: HashMap> = HashMap::new(); + + for (segment_id, start, end, _) in self.segment_domain.iter() { + adjacency.entry(start).or_default().push((segment_id, end, false)); + adjacency.entry(end).or_default().push((segment_id, start, true)); + } + + // Sort neighbors by angle to enable finding the "rightmost" path + for (point_idx, neighbors) in adjacency.iter_mut() { + let point_pos = self.point_domain.positions()[*point_idx]; + neighbors.sort_by(|a, b| { + let pos_a = self.point_domain.positions()[a.1]; + let pos_b = self.point_domain.positions()[b.1]; + let angle_a = (pos_a - point_pos).y.atan2((pos_a - point_pos).x); + let angle_b = (pos_b - point_pos).y.atan2((pos_b - point_pos).x); + angle_a.partial_cmp(&angle_b).unwrap_or(std::cmp::Ordering::Equal) + }); + } + + for (segment_id, start, end, _) in self.segment_domain.iter() { + for &reversed in &[false, true] { + let (from, to) = if reversed { (end, start) } else { (start, end) }; + let half_edge_key = (segment_id, reversed); + + if used_half_edges.contains(&half_edge_key) { + continue; + } + + if let Some(face) = self.find_minimal_face_from_edge(segment_id, from, to, reversed, &adjacency, &mut used_half_edges) { + regions.push(face); + } + } + } + + regions + } + + /// Helper to find a minimal face (smallest cycle) starting from a half-edge + fn find_minimal_face_from_edge( + &self, + start_segment: SegmentId, + from_point: usize, + to_point: usize, + start_reversed: bool, + adjacency: &HashMap>, + used_half_edges: &mut HashSet<(SegmentId, bool)>, + ) -> Option { + let mut path = vec![HalfEdge::new(start_segment, from_point, to_point, start_reversed)]; + let mut current = to_point; + let target = from_point; + let mut prev_segment = start_segment; + + let mut iteration = 0; + let max_iterations = adjacency.len() * 2; + + // Follow the "rightmost" edge at each vertex to find minimal face + loop { + iteration += 1; + + if iteration > max_iterations { + return None; + } + + let neighbors = adjacency.get(¤t)?; + // Find the next edge in counterclockwise order (rightmost turn) + let prev_direction = self.point_domain.positions()[current] - self.point_domain.positions()[path.last()?.start]; + + let angle_between = |v1: DVec2, v2: DVec2| -> f64 { + let angle = v2.y.atan2(v2.x) - v1.y.atan2(v1.x); + if angle < 0.0 { angle + 2.0 * std::f64::consts::PI } else { angle } + }; + + let next = neighbors.iter().filter(|(seg, _next, _rev)| *seg != prev_segment).min_by(|a, b| { + let dir_a = self.point_domain.positions()[a.1] - self.point_domain.positions()[current]; + let dir_b = self.point_domain.positions()[b.1] - self.point_domain.positions()[current]; + let angle_a = angle_between(prev_direction, dir_a); + let angle_b = angle_between(prev_direction, dir_b); + angle_a.partial_cmp(&angle_b).unwrap_or(std::cmp::Ordering::Equal) + })?; + + let (next_segment, next_point, next_reversed) = *next; + + if next_point == target { + // Completed the cycle + path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); + + // Mark all half-edges as used + for edge in &path { + used_half_edges.insert((edge.id, edge.reverse)); + } + + return Some(FoundSubpath::new(path)); + } + + // Check if we've created a cycle (might not be back to start) + if path.iter().any(|e| e.end == next_point && e.id != next_segment) { + return None; + } + + path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); + prev_segment = next_segment; + current = next_point; + + // Prevent infinite loops + if path.len() > adjacency.len() { + return None; + } + } + } } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] diff --git a/node-graph/gsvg-renderer/src/renderer.rs b/node-graph/gsvg-renderer/src/renderer.rs index ff2c14325e..b2488d613f 100644 --- a/node-graph/gsvg-renderer/src/renderer.rs +++ b/node-graph/gsvg-renderer/src/renderer.rs @@ -3,8 +3,7 @@ use crate::to_peniko::BlendModeExt; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_core::blending::BlendMode; -use graphene_core::bounds::BoundingBox; -use graphene_core::bounds::RenderBoundingBox; +use graphene_core::bounds::{BoundingBox, RenderBoundingBox}; use graphene_core::color::Color; use graphene_core::gradient::GradientStops; use graphene_core::gradient::GradientType; @@ -719,12 +718,7 @@ impl Render for Table { let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]); let transformed_bounds_matrix = element_transform * DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]); - let mut path = String::new(); - - for mut bezpath in row.element.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - path.push_str(bezpath.to_svg().as_str()); - } + let is_branching = vector.is_branching_mesh(); let mask_type = if vector.style.stroke().map(|x| x.align) == Some(StrokeAlign::Inside) { MaskType::Clip @@ -732,130 +726,243 @@ impl Render for Table { MaskType::Mask }; - let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); - let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); - let can_use_paint_order = !(row.element.style.fill().is_none() || !row.element.style.fill().is_opaque() || mask_type == MaskType::Clip); + // Helper closure to render a single path with given path string and closed status + let render_path = |render: &mut SvgRender, path: String, path_is_closed: bool| { + let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); + let can_use_paint_order = !(row.element.style.fill().is_none() || !row.element.style.fill().is_opaque() || mask_type == MaskType::Clip); - let needs_separate_fill = can_draw_aligned_stroke && !can_use_paint_order; - let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); + let needs_separate_fill = can_draw_aligned_stroke && !can_use_paint_order; + let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); - if needs_separate_fill && !wants_stroke_below { + // Render fill before stroke if needed + if needs_separate_fill && !wants_stroke_below { + render.leaf_tag("path", |attributes| { + attributes.push("d", path.clone()); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + let mut style = row.element.style.clone(); + style.clear_stroke(); + let fill_and_stroke = style.render( + &mut attributes.0.svg_defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); + attributes.push_val(fill_and_stroke); + }); + } + + let push_id = needs_separate_fill.then_some({ + let id = format!("alignment-{}", generate_uuid()); + + let mut element = row.element.clone(); + element.style.clear_stroke(); + element.style.set_fill(Fill::solid(Color::BLACK)); + + let vector_row = Table::new_from_row(TableRow { + element, + alpha_blending: *row.alpha_blending, + transform: *row.transform, + source_node_id: None, + }); + + (id, mask_type, vector_row) + }); + + // Main path rendering render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push("transform", matrix); } + + let defs = &mut attributes.0.svg_defs; + if let Some((ref id, mask_type, ref vector_row)) = push_id { + let mut svg = SvgRender::new(); + vector_row.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); + let stroke = row.element.style.stroke().unwrap(); + let weight = stroke.effective_width() * max_scale(applied_stroke_transform); + let quad = Quad::from_box(transformed_bounds).inflate(weight); + let (x, y) = quad.top_left().into(); + let (width, height) = (quad.bottom_right() - quad.top_left()).into(); + + write!(defs, r##"{}"##, svg.svg_defs).unwrap(); + let rect = format!(r##""##); + + match mask_type { + MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), + MaskType::Mask => write!( + defs, + r##"{}{}"##, + rect, + svg.svg.to_svg_string() + ) + .unwrap(), + } + } + + let mut render_params = render_params.clone(); + render_params.aligned_strokes = can_draw_aligned_stroke; + render_params.override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + let mut style = row.element.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); - } + if needs_separate_fill { + style.clear_fill(); + } - let push_id = needs_separate_fill.then_some({ - let id = format!("alignment-{}", generate_uuid()); + let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params); - let mut element = row.element.clone(); - element.style.clear_stroke(); - element.style.set_fill(Fill::solid(Color::BLACK)); + if let Some((id, mask_type, _)) = push_id { + let selector = format!("url(#{id})"); + attributes.push(mask_type.to_attribute(), selector); + } + attributes.push_val(fill_and_stroke); - let vector_row = Table::new_from_row(TableRow { - element, - alpha_blending: *row.alpha_blending, - transform: *row.transform, - source_node_id: None, - }); + let opacity = row.alpha_blending.opacity(render_params.for_mask); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } - (id, mask_type, vector_row) - }); + if row.alpha_blending.blend_mode != BlendMode::default() { + attributes.push("style", row.alpha_blending.blend_mode.render()); + } + }); - render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push("transform", matrix); + // Render fill after stroke if needed + if needs_separate_fill && wants_stroke_below { + render.leaf_tag("path", |attributes| { + attributes.push("d", path); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + let mut style = row.element.style.clone(); + style.clear_stroke(); + let fill_and_stroke = style.render( + &mut attributes.0.svg_defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); + attributes.push_val(fill_and_stroke); + }); } + }; - let defs = &mut attributes.0.svg_defs; - if let Some((ref id, mask_type, ref vector_row)) = push_id { - let mut svg = SvgRender::new(); - vector_row.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); - let stroke = row.element.style.stroke().unwrap(); - let weight = stroke.effective_width() * max_scale(applied_stroke_transform); - let quad = Quad::from_box(transformed_bounds).inflate(weight); - let (x, y) = quad.top_left().into(); - let (width, height) = (quad.bottom_right() - quad.top_left()).into(); - - write!(defs, r##"{}"##, svg.svg_defs).unwrap(); - let rect = format!(r##""##); - - match mask_type { - MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), - MaskType::Mask => write!( - defs, - r##"{}{}"##, - rect, - svg.svg.to_svg_string() - ) - .unwrap(), + if is_branching { + // For branching meshes, we need to handle fills and strokes separately + let fill_regions = vector.find_closed_regions(); + let has_fill = vector.style.fill() != &Fill::None; + let has_stroke = vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()); + + // Render fills for each region separately + if has_fill { + for region in &fill_regions { + let mut bezpath = region.to_bezpath(vector); + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + let path = bezpath.to_svg(); + + // Render fill-only path + render.leaf_tag("path", |attributes| { + attributes.push("d", path); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + + let mut style = row.element.style.clone(); + style.clear_stroke(); + let fill_only = style.render( + &mut attributes.0.svg_defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); + attributes.push_val(fill_only); + + let opacity = row.alpha_blending.opacity(render_params.for_mask); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if row.alpha_blending.blend_mode != BlendMode::default() { + attributes.push("style", row.alpha_blending.blend_mode.render()); + } + }); } } - let mut render_params = render_params.clone(); - render_params.aligned_strokes = can_draw_aligned_stroke; - render_params.override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + // Render stroke once for the entire mesh + if has_stroke { + let mut combined_path = String::new(); + for mut bezpath in row.element.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + combined_path.push_str(bezpath.to_svg().as_str()); + } - let mut style = row.element.style.clone(); - if needs_separate_fill { - style.clear_fill(); - } + // Render the combined path with stroke only + render.leaf_tag("path", |attributes| { + attributes.push("d", combined_path); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } - let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params); + let mut style = row.element.style.clone(); + style.clear_fill(); // Critical: Remove fill to only render stroke + let stroke_only = style.render( + &mut attributes.0.svg_defs, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); + attributes.push_val(stroke_only); - if let Some((id, mask_type, _)) = push_id { - let selector = format!("url(#{id})"); - attributes.push(mask_type.to_attribute(), selector); + let opacity = row.alpha_blending.opacity(render_params.for_mask); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if row.alpha_blending.blend_mode != BlendMode::default() { + attributes.push("style", row.alpha_blending.blend_mode.render()); + } + }); } - attributes.push_val(fill_and_stroke); - let opacity = row.alpha_blending.opacity(render_params.for_mask); - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); + // If no fill and no stroke, still render something + if !has_fill && !has_stroke { + let mut combined_path = String::new(); + for region in &fill_regions { + let mut bezpath = region.to_bezpath(vector); + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + combined_path.push_str(bezpath.to_svg().as_str()); + } + let path_is_closed = fill_regions.iter().all(|r| r.is_closed()); + render_path(render, combined_path, path_is_closed); } + } else { + // For non-branching paths + let mut path = String::new(); - if row.alpha_blending.blend_mode != BlendMode::default() { - attributes.push("style", row.alpha_blending.blend_mode.render()); + for mut bezpath in row.element.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + path.push_str(bezpath.to_svg().as_str()); } - }); - // When splitting passes and stroke is below, draw the fill after the stroke. - if needs_separate_fill && wants_stroke_below { - render.leaf_tag("path", |attributes| { - attributes.push("d", path); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push("transform", matrix); - } - let mut style = row.element.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); + let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); + + render_path(render, path, path_is_closed); } } } @@ -869,7 +976,8 @@ impl Render for Table { for row in self.iter() { let multiplied_transform = parent_transform * *row.transform; - let has_real_stroke = row.element.style.stroke().filter(|stroke| stroke.weight() > 0.); + let vector = &row.element; + let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); let mut element_transform = set_stroke_transform @@ -883,16 +991,11 @@ impl Render for Table { multiplied_transform }; } - let layer_bounds = row.element.bounding_box().unwrap_or_default(); + let layer_bounds = vector.bounding_box().unwrap_or_default(); let to_point = |p: DVec2| kurbo::Point::new(p.x, p.y); - let mut path = kurbo::BezPath::new(); - for mut bezpath in row.element.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - for element in bezpath { - path.push(element); - } - } + + let is_branching = vector.is_branching_mesh(); // If we're using opacity or a blend mode, we need to push a layer let blend_mode = match render_params.render_mode { @@ -904,7 +1007,7 @@ impl Render for Table { let opacity = row.alpha_blending.opacity(render_params.for_mask); if opacity < 1. || row.alpha_blending.blend_mode != BlendMode::default() { layer = true; - let weight = row.element.style.stroke().as_ref().map_or(0., Stroke::effective_width); + let weight = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); let quad = Quad::from_box(layer_bounds).inflate(weight * max_scale(applied_stroke_transform)); let layer_bounds = quad.bounding_box(); scene.push_layer( @@ -916,16 +1019,16 @@ impl Render for Table { } let can_draw_aligned_stroke = - row.element.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && row.element.stroke_bezier_paths().all(|path| path.closed()); + vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && vector.stroke_bezier_paths().all(|path| path.closed()); let use_layer = can_draw_aligned_stroke; - let wants_stroke_below = row.element.style.stroke().is_some_and(|s| s.paint_order == graphene_core::vector::style::PaintOrder::StrokeBelow); + let wants_stroke_below = vector.style.stroke().is_some_and(|s| s.paint_order == graphene_core::vector::style::PaintOrder::StrokeBelow); - // Closures to avoid duplicated fill/stroke drawing logic - let do_fill = |scene: &mut Scene| match row.element.style.fill() { + // Closure to fill a path with the element's fill style + let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath| match vector.style.fill() { Fill::Solid(color) => { let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])); - scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, &path); + scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); } Fill::Gradient(gradient) => { let mut stops = peniko::ColorStops::new(); @@ -936,7 +1039,7 @@ impl Render for Table { }); } - let bounds = row.element.nonzero_bounding_box(); + let bounds = vector.nonzero_bounding_box(); let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { @@ -977,13 +1080,13 @@ impl Render for Table { Default::default() }; let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); - scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), &path); + scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); } Fill::None => {} }; - let do_stroke = |scene: &mut Scene, width_scale: f64| { - if let Some(stroke) = row.element.style.stroke() { + let do_stroke_path = |scene: &mut Scene, path: &kurbo::BezPath, width_scale: f64| { + if let Some(stroke) = vector.style.stroke() { let color = match stroke.color { Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]), None => peniko::Color::TRANSPARENT, @@ -1009,7 +1112,7 @@ impl Render for Table { }; if stroke.width > 0. { - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path); + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, path); } } }; @@ -1017,6 +1120,14 @@ impl Render for Table { // Render the path match render_params.render_mode { RenderMode::Outline => { + let mut path = kurbo::BezPath::new(); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + path.push(element); + } + } + let outline_stroke = kurbo::Stroke { width: LAYER_OUTLINE_STROKE_WEIGHT, miter_limit: 4., @@ -1036,70 +1147,188 @@ impl Render for Table { scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color, None, &path); } _ => { - if use_layer { - let mut element = row.element.clone(); - element.style.clear_stroke(); - element.style.set_fill(Fill::solid(Color::BLACK)); - - let vector_table = Table::new_from_row(TableRow { - element, - alpha_blending: *row.alpha_blending, - transform: *row.transform, - source_node_id: None, - }); - - let bounds = row.element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); - let weight = row.element.style.stroke().as_ref().map_or(0., Stroke::effective_width); - let quad = Quad::from_box(bounds).inflate(weight * max_scale(applied_stroke_transform)); - let bounds = quad.bounding_box(); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - let compose = if row.element.style.stroke().is_some_and(|x| x.align == StrokeAlign::Outside) { - peniko::Compose::SrcOut - } else { - peniko::Compose::SrcIn - }; - - if wants_stroke_below { - scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + if is_branching { + // For branching meshes, handle fills and strokes separately + let fill_regions = vector.find_closed_regions(); + let has_fill = vector.style.fill() != &Fill::None; + let has_stroke = vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()); + + // Render fills for each region separately + if has_fill { + for region in &fill_regions { + let mut bezpath = region.to_bezpath(vector); + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + + do_fill_path(scene, &bezpath); + } + } - do_stroke(scene, 2.); + // Render stroke once for the entire mesh + if has_stroke { + let mut combined_path = kurbo::BezPath::new(); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + combined_path.push(element); + } + } - scene.pop_layer(); - scene.pop_layer(); + do_stroke_path(scene, &combined_path, 1.); + } - do_fill(scene); - } else { - // Fill first (unclipped), then stroke (clipped) above - do_fill(scene); + // If no fill and no stroke, still render something + if !has_fill && !has_stroke { + let mut combined_path = kurbo::BezPath::new(); + for region in &fill_regions { + let mut bezpath = region.to_bezpath(vector); + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + combined_path.push(element); + } + } - scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + // Use the existing rendering logic for this fallback case + if use_layer { + let mut element = row.element.clone(); + element.style.clear_stroke(); + element.style.set_fill(Fill::solid(Color::BLACK)); + + let vector_table = Table::new_from_row(TableRow { + element, + alpha_blending: *row.alpha_blending, + transform: *row.transform, + source_node_id: None, + }); + + let bounds = vector.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); + let weight = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); + let quad = Quad::from_box(bounds).inflate(weight * max_scale(applied_stroke_transform)); + let bounds = quad.bounding_box(); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + let compose = if vector.style.stroke().is_some_and(|x| x.align == StrokeAlign::Outside) { + peniko::Compose::SrcOut + } else { + peniko::Compose::SrcIn + }; + + if wants_stroke_below { + scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke_path(scene, &combined_path, 2.); + + scene.pop_layer(); + scene.pop_layer(); + + do_fill_path(scene, &combined_path); + } else { + do_fill_path(scene, &combined_path); + + scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke_path(scene, &combined_path, 2.); + + scene.pop_layer(); + scene.pop_layer(); + } + } else { + enum Op { + Fill, + Stroke, + } - do_stroke(scene, 2.); + let order = match vector.style.stroke().is_some_and(|stroke| !stroke.paint_order.is_default()) { + true => [Op::Stroke, Op::Fill], + false => [Op::Fill, Op::Stroke], // Default + }; - scene.pop_layer(); - scene.pop_layer(); + for operation in order { + match operation { + Op::Fill => do_fill_path(scene, &combined_path), + Op::Stroke => do_stroke_path(scene, &combined_path, 1.), + } + } + } } } else { - // Non-aligned strokes or open paths: default order behavior - enum Op { - Fill, - Stroke, + // For non-branching paths, combine all bezpaths into a single path + let mut path = kurbo::BezPath::new(); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + path.push(element); + } } - let order = match row.element.style.stroke().is_some_and(|stroke| !stroke.paint_order.is_default()) { - true => [Op::Stroke, Op::Fill], - false => [Op::Fill, Op::Stroke], // Default - }; + if use_layer { + let mut element = row.element.clone(); + element.style.clear_stroke(); + element.style.set_fill(Fill::solid(Color::BLACK)); + + let vector_table = Table::new_from_row(TableRow { + element, + alpha_blending: *row.alpha_blending, + transform: *row.transform, + source_node_id: None, + }); + + let bounds = vector.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); + let weight = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); + let quad = Quad::from_box(bounds).inflate(weight * max_scale(applied_stroke_transform)); + let bounds = quad.bounding_box(); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + let compose = if vector.style.stroke().is_some_and(|x| x.align == StrokeAlign::Outside) { + peniko::Compose::SrcOut + } else { + peniko::Compose::SrcIn + }; + + if wants_stroke_below { + scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke_path(scene, &path, 2.); + + scene.pop_layer(); + scene.pop_layer(); + + do_fill_path(scene, &path); + } else { + // Fill first (unclipped), then stroke (clipped) above + do_fill_path(scene, &path); + + scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_table.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke_path(scene, &path, 2.); + + scene.pop_layer(); + scene.pop_layer(); + } + } else { + // Non-aligned strokes or open paths: default order behavior + enum Op { + Fill, + Stroke, + } + + let order = match vector.style.stroke().is_some_and(|stroke| !stroke.paint_order.is_default()) { + true => [Op::Stroke, Op::Fill], + false => [Op::Fill, Op::Stroke], // Default + }; - for operation in order { - match operation { - Op::Fill => do_fill(scene), - Op::Stroke => do_stroke(scene, 1.), + for operation in order { + match operation { + Op::Fill => do_fill_path(scene, &path), + Op::Stroke => do_stroke_path(scene, &path, 1.), + } } } } @@ -1302,14 +1531,13 @@ impl Render for Table> { let opacity = alpha_blending.opacity(render_params.for_mask); let mut layer = false; - if opacity < 1. || alpha_blending.blend_mode != BlendMode::default() { - if let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, false) { + if (opacity < 1. || alpha_blending.blend_mode != BlendMode::default()) + && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, false) { let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); scene.push_layer(blending, opacity, kurbo::Affine::IDENTITY, &rect); layer = true; } - } let image_brush = peniko::ImageBrush::new(peniko::ImageData { data: image.to_flat_u8().0.into(), @@ -1361,14 +1589,13 @@ impl Render for Table> { for row in self.iter() { let blend_mode = *row.alpha_blending; let mut layer = false; - if blend_mode != Default::default() { - if let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, true) { + if blend_mode != Default::default() + && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, true) { let blending = peniko::BlendMode::new(blend_mode.blend_mode.to_peniko(), peniko::Compose::SrcOver); let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); scene.push_layer(blending, blend_mode.opacity, kurbo::Affine::IDENTITY, &rect); layer = true; } - } let width = row.element.data().width(); let height = row.element.data().height(); From 326e00cbf10aa88c0f3ff53a2b9cef29e0c40adb Mon Sep 17 00:00:00 2001 From: Alab Melendres Date: Mon, 17 Nov 2025 12:27:23 +0800 Subject: [PATCH 2/4] Use derivative tiebreaker --- node-graph/gcore/src/subpath/structs.rs | 18 +++++++ .../gcore/src/vector/vector_attributes.rs | 48 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/node-graph/gcore/src/subpath/structs.rs b/node-graph/gcore/src/subpath/structs.rs index d5f868be2b..0e45b2c134 100644 --- a/node-graph/gcore/src/subpath/structs.rs +++ b/node-graph/gcore/src/subpath/structs.rs @@ -228,6 +228,24 @@ impl BezierHandles { _ => self, } } + + /// Returns the tangent vector at the start of the curve (t=0) + pub fn derivative_at_start(&self, start: DVec2, end: DVec2) -> DVec2 { + match self { + BezierHandles::Linear => end - start, + BezierHandles::Quadratic { handle } => 2.0 * (handle - start), + BezierHandles::Cubic { handle_start, .. } => 3.0 * (handle_start - start), + } + } + + /// Returns the tangent vector at the end of the curve (t=1) + pub fn derivative_at_end(&self, start: DVec2, end: DVec2) -> DVec2 { + match self { + BezierHandles::Linear => end - start, + BezierHandles::Quadratic { handle } => 2.0 * (end - handle), + BezierHandles::Cubic { handle_end, .. } => 3.0 * (end - handle_end), + } + } } /// Representation of a bezier curve with 2D points. diff --git a/node-graph/gcore/src/vector/vector_attributes.rs b/node-graph/gcore/src/vector/vector_attributes.rs index 862e4dea97..b1dac5d52d 100644 --- a/node-graph/gcore/src/vector/vector_attributes.rs +++ b/node-graph/gcore/src/vector/vector_attributes.rs @@ -1057,7 +1057,53 @@ impl Vector { let pos_b = self.point_domain.positions()[b.1]; let angle_a = (pos_a - point_pos).y.atan2((pos_a - point_pos).x); let angle_b = (pos_b - point_pos).y.atan2((pos_b - point_pos).x); - angle_a.partial_cmp(&angle_b).unwrap_or(std::cmp::Ordering::Equal) + + // Compare angles + match angle_a.partial_cmp(&angle_b) { + Some(std::cmp::Ordering::Equal) | None => { + // Angles are equal (within floating point precision), use derivative as tiebreaker + const EPSILON: f64 = 1e-10; + if (angle_a - angle_b).abs() < EPSILON { + // Get segment indices to access handles + let seg_idx_a = self.segment_domain.ids().iter().position(|&id| id == a.0); + let seg_idx_b = self.segment_domain.ids().iter().position(|&id| id == b.0); + + if let (Some(idx_a), Some(idx_b)) = (seg_idx_a, seg_idx_b) { + let handles_a = self.segment_domain.handles()[idx_a]; + let handles_b = self.segment_domain.handles()[idx_b]; + + // Get start and end positions for the segments + let start_a = self.point_domain.positions()[self.segment_domain.start_point()[idx_a]]; + let end_a = self.point_domain.positions()[self.segment_domain.end_point()[idx_a]]; + let start_b = self.point_domain.positions()[self.segment_domain.start_point()[idx_b]]; + let end_b = self.point_domain.positions()[self.segment_domain.end_point()[idx_b]]; + + // Determine derivative based on edge direction + // If reversed (a.2 == true), we're coming into the point, so use derivative_at_end + // Otherwise, we're leaving the point, so use derivative_at_start + let deriv_a = if a.2 { + handles_a.derivative_at_end(start_a, end_a) + } else { + handles_a.derivative_at_start(start_a, end_a) + }; + + let deriv_b = if b.2 { + handles_b.derivative_at_end(start_b, end_b) + } else { + handles_b.derivative_at_start(start_b, end_b) + }; + + // Compare derivative angles + let deriv_angle_a = deriv_a.y.atan2(deriv_a.x); + let deriv_angle_b = deriv_b.y.atan2(deriv_b.x); + + return deriv_angle_a.partial_cmp(&deriv_angle_b).unwrap_or(std::cmp::Ordering::Equal); + } + } + std::cmp::Ordering::Equal + } + Some(ordering) => ordering, + } }); } From 9ec1dece4ce89d55824959a8166b161e89a0ccab Mon Sep 17 00:00:00 2001 From: Alab Melendres Date: Mon, 17 Nov 2025 12:36:22 +0800 Subject: [PATCH 3/4] Miscellaneous improvements --- .../gcore/src/vector/vector_attributes.rs | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/node-graph/gcore/src/vector/vector_attributes.rs b/node-graph/gcore/src/vector/vector_attributes.rs index b1dac5d52d..53d0fc1262 100644 --- a/node-graph/gcore/src/vector/vector_attributes.rs +++ b/node-graph/gcore/src/vector/vector_attributes.rs @@ -4,6 +4,7 @@ use crate::vector::vector_types::Vector; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use kurbo::{CubicBez, Line, PathSeg, QuadBez}; +use log::debug; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::iter::zip; @@ -1036,6 +1037,18 @@ impl Vector { false } + /// Compute signed area of a face using the shoelace formula. + /// Negative area indicates clockwise winding, positive indicates counterclockwise. + fn compute_signed_area(&self, face: &FoundSubpath) -> f64 { + let mut area = 0.0; + for edge in &face.edges { + let start_pos = self.point_domain.positions()[edge.start]; + let end_pos = self.point_domain.positions()[edge.end]; + area += (end_pos.x - start_pos.x) * (end_pos.y + start_pos.y); + } + area * 0.5 + } + /// Find all minimal closed regions (faces) in a branching mesh vector. pub fn find_closed_regions(&self) -> Vec { let mut regions = Vec::new(); @@ -1122,6 +1135,19 @@ impl Vector { } } + // Filter out outer (counterclockwise) faces, keeping only inner (clockwise) faces + regions.retain(|face| { + // Always include 2-edge faces (floating point issues with area calculation) + if face.edges.len() == 2 { + return true; + } + + let area = self.compute_signed_area(face); + // Keep clockwise faces (negative area), exclude counterclockwise (positive area) + area < 0.0 + }); + + debug!("Found {} closed regions", regions.len()); regions } @@ -1140,62 +1166,44 @@ impl Vector { let target = from_point; let mut prev_segment = start_segment; - let mut iteration = 0; let max_iterations = adjacency.len() * 2; // Follow the "rightmost" edge at each vertex to find minimal face - loop { - iteration += 1; + for _iteration in 1..=max_iterations { + let neighbors = adjacency.get(¤t)?; - if iteration > max_iterations { - return None; - } + // Since neighbors are pre-sorted by angle, find the index of the edge we came from + // and take the next edge in the sorted circular list + let prev_index = neighbors.iter().position(|(seg, _next, _rev)| *seg == prev_segment)?; - let neighbors = adjacency.get(¤t)?; - // Find the next edge in counterclockwise order (rightmost turn) - let prev_direction = self.point_domain.positions()[current] - self.point_domain.positions()[path.last()?.start]; + // The next edge in the sorted list is the minimal face edge (rightmost turn) + // Wrap around using modulo to handle circular list + let next = neighbors[(prev_index + 1) % neighbors.len()]; - let angle_between = |v1: DVec2, v2: DVec2| -> f64 { - let angle = v2.y.atan2(v2.x) - v1.y.atan2(v1.x); - if angle < 0.0 { angle + 2.0 * std::f64::consts::PI } else { angle } - }; + let (next_segment, next_point, next_reversed) = next; - let next = neighbors.iter().filter(|(seg, _next, _rev)| *seg != prev_segment).min_by(|a, b| { - let dir_a = self.point_domain.positions()[a.1] - self.point_domain.positions()[current]; - let dir_b = self.point_domain.positions()[b.1] - self.point_domain.positions()[current]; - let angle_a = angle_between(prev_direction, dir_a); - let angle_b = angle_between(prev_direction, dir_b); - angle_a.partial_cmp(&angle_b).unwrap_or(std::cmp::Ordering::Equal) - })?; + // Check if we've created a cycle (might not be back to start) + if path.iter().any(|e| e.end == next_point && e.id != next_segment) { + return None; + } - let (next_segment, next_point, next_reversed) = *next; + path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); + // Check if we've completed the face if next_point == target { - // Completed the cycle - path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); - // Mark all half-edges as used for edge in &path { used_half_edges.insert((edge.id, edge.reverse)); } - return Some(FoundSubpath::new(path)); } - // Check if we've created a cycle (might not be back to start) - if path.iter().any(|e| e.end == next_point && e.id != next_segment) { - return None; - } - - path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); prev_segment = next_segment; current = next_point; - - // Prevent infinite loops - if path.len() > adjacency.len() { - return None; - } } + + // If we exit the loop without returning, the path didn't close + None } } From 384dccfd9d2ff3c4a9cc77923bece953c78eaa77 Mon Sep 17 00:00:00 2001 From: Nicholas Liu Date: Sun, 16 Nov 2025 23:07:20 -0800 Subject: [PATCH 4/4] Fix ordering/modify retain logic Ordering of "rightmost" half edge should actually always use the local direction at the vertex rather than the straight line segment direction 2 vertex case is handled by using <= instead of < on retain in the back face cull --- .../gcore/src/vector/vector_attributes.rs | 77 +++---------------- 1 file changed, 12 insertions(+), 65 deletions(-) diff --git a/node-graph/gcore/src/vector/vector_attributes.rs b/node-graph/gcore/src/vector/vector_attributes.rs index 53d0fc1262..2888726393 100644 --- a/node-graph/gcore/src/vector/vector_attributes.rs +++ b/node-graph/gcore/src/vector/vector_attributes.rs @@ -1063,60 +1063,18 @@ impl Vector { } // Sort neighbors by angle to enable finding the "rightmost" path - for (point_idx, neighbors) in adjacency.iter_mut() { - let point_pos = self.point_domain.positions()[*point_idx]; + for (&point_idx, neighbors) in adjacency.iter_mut() { + let start = self.point_domain.positions()[point_idx]; + neighbors.sort_by(|a, b| { - let pos_a = self.point_domain.positions()[a.1]; - let pos_b = self.point_domain.positions()[b.1]; - let angle_a = (pos_a - point_pos).y.atan2((pos_a - point_pos).x); - let angle_b = (pos_b - point_pos).y.atan2((pos_b - point_pos).x); - - // Compare angles - match angle_a.partial_cmp(&angle_b) { - Some(std::cmp::Ordering::Equal) | None => { - // Angles are equal (within floating point precision), use derivative as tiebreaker - const EPSILON: f64 = 1e-10; - if (angle_a - angle_b).abs() < EPSILON { - // Get segment indices to access handles - let seg_idx_a = self.segment_domain.ids().iter().position(|&id| id == a.0); - let seg_idx_b = self.segment_domain.ids().iter().position(|&id| id == b.0); - - if let (Some(idx_a), Some(idx_b)) = (seg_idx_a, seg_idx_b) { - let handles_a = self.segment_domain.handles()[idx_a]; - let handles_b = self.segment_domain.handles()[idx_b]; - - // Get start and end positions for the segments - let start_a = self.point_domain.positions()[self.segment_domain.start_point()[idx_a]]; - let end_a = self.point_domain.positions()[self.segment_domain.end_point()[idx_a]]; - let start_b = self.point_domain.positions()[self.segment_domain.start_point()[idx_b]]; - let end_b = self.point_domain.positions()[self.segment_domain.end_point()[idx_b]]; - - // Determine derivative based on edge direction - // If reversed (a.2 == true), we're coming into the point, so use derivative_at_end - // Otherwise, we're leaving the point, so use derivative_at_start - let deriv_a = if a.2 { - handles_a.derivative_at_end(start_a, end_a) - } else { - handles_a.derivative_at_start(start_a, end_a) - }; - - let deriv_b = if b.2 { - handles_b.derivative_at_end(start_b, end_b) - } else { - handles_b.derivative_at_start(start_b, end_b) - }; - - // Compare derivative angles - let deriv_angle_a = deriv_a.y.atan2(deriv_a.x); - let deriv_angle_b = deriv_b.y.atan2(deriv_b.x); - - return deriv_angle_a.partial_cmp(&deriv_angle_b).unwrap_or(std::cmp::Ordering::Equal); - } - } - std::cmp::Ordering::Equal - } - Some(ordering) => ordering, - } + let angles: [f64; 2] = [a, b].map(|&(segment_id, end_idx, reversed)| { + let end = self.point_domain.positions()[end_idx]; + let curve = self.segment_domain.handles()[self.segment_domain.id_to_index(segment_id).unwrap()]; + let curve = if reversed { curve.reversed() } else { curve }; + let direction = curve.derivative_at_start(start, end); + direction.y.atan2(direction.x) + }); + angles[0].partial_cmp(&angles[1]).unwrap_or(std::cmp::Ordering::Equal) }); } @@ -1136,18 +1094,7 @@ impl Vector { } // Filter out outer (counterclockwise) faces, keeping only inner (clockwise) faces - regions.retain(|face| { - // Always include 2-edge faces (floating point issues with area calculation) - if face.edges.len() == 2 { - return true; - } - - let area = self.compute_signed_area(face); - // Keep clockwise faces (negative area), exclude counterclockwise (positive area) - area < 0.0 - }); - - debug!("Found {} closed regions", regions.len()); + regions.retain(|face| self.compute_signed_area(face) <= 0.0); regions }