From fff0a5379962eb049214946e669f0e77687e1d73 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Wed, 19 Mar 2025 13:06:01 -0700 Subject: [PATCH] New nodes: Mirror, Round Corners, Box Warp, Remove/Generate Handles, Spatial Merge by Distance (#2448) * Add Mirror and Round Corner nodes * Removed perspective warp ride along node * Fixed naming, added back Box Warp * Add Soften, Sharpen and Subdivide * Code review --------- Co-authored-by: Keavon Chambers --- node-graph/gcore/src/vector/vector_nodes.rs | 590 +++++++++++++++++++- 1 file changed, 588 insertions(+), 2 deletions(-) diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index 275f6011..d22c83aa 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -2,13 +2,14 @@ use super::misc::CentroidType; use super::style::{Fill, Gradient, GradientStops, Stroke}; use super::{PointId, SegmentDomain, SegmentId, StrokeId, VectorData, VectorDataTable}; use crate::instances::{InstanceMut, Instances}; -use crate::registry::types::{Angle, Fraction, IntegerCount, Length, SeedValue}; +use crate::registry::types::{Angle, Fraction, IntegerCount, Length, Percentage, PixelLength, SeedValue}; use crate::renderer::GraphicElementRendered; use crate::transform::{Footprint, Transform, TransformMut}; use crate::vector::PointDomain; use crate::vector::style::LineJoin; use crate::{CloneVarArgs, Color, Context, Ctx, ExtractAll, GraphicElement, GraphicGroupTable, OwnedContextImpl}; -use bezier_rs::{Cap, Join, Subpath, SubpathTValue, TValue}; +use bezier_rs::{Cap, Join, ManipulatorGroup, Subpath, SubpathTValue, TValue}; +use core::f64::consts::PI; use glam::{DAffine2, DVec2}; use rand::{Rng, SeedableRng}; @@ -335,6 +336,591 @@ where result_table } +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn mirror( + _: impl Ctx, + #[implementations(VectorDataTable, GraphicGroupTable)] instance: Instances, + #[default(0., 0.)] center: DVec2, + #[range((-90., 90.))] angle: Angle, +) -> GraphicGroupTable +where + Instances: GraphicElementRendered, +{ + let mut result_table = GraphicGroupTable::default(); + let Some(bounding_box) = instance.bounding_box(DAffine2::IDENTITY) else { return result_table }; + // The mirror center is based on the bounding box for now + let mirror_center = (bounding_box[0] + bounding_box[1]) / 2. + center; + // Normalize direction vector + let normal = DVec2::from_angle(angle.to_radians()); + // Create reflection matrix + let reflection = DAffine2::from_mat2_translation( + glam::DMat2::from_cols( + DVec2::new(1. - 2. * normal.x * normal.x, -2. * normal.y * normal.x), + DVec2::new(-2. * normal.x * normal.y, 1. - 2. * normal.y * normal.y), + ), + DVec2::ZERO, + ); + // Apply reflection around the center point + let modification = DAffine2::from_translation(mirror_center) * reflection * DAffine2::from_translation(-mirror_center); + // Add original instance to result + let original_element = instance.to_graphic_element().clone(); + result_table.push(original_element); + // Create and add mirrored instance + let mut mirrored_element = instance.to_graphic_element().clone(); + mirrored_element.new_ids_from_hash(None); + // Finally, apply the transformation to the mirrored instance + let mirrored_instance = result_table.push(mirrored_element); + *mirrored_instance.transform = modification; + + result_table +} + +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn round_corners( + _: impl Ctx, + source: VectorDataTable, + #[min(0.)] + #[default(10.)] + radius: PixelLength, + #[range((0., 1.))] + #[default(0.5)] + roundness: f64, + #[default(100.)] edge_length_limit: Percentage, + #[range((0., 180.))] + #[default(5.)] + min_angle_threshold: Angle, +) -> VectorDataTable { + let source_transform = source.transform(); + let source_transform_inverse = source_transform.inverse(); + let source = source.one_instance().instance; + + // Flip the roundness to help with user intuition + let roundness = 1. - roundness; + // Convert 0-100 to 0-0.5 + let edge_length_limit = edge_length_limit * 0.005; + + let mut result = VectorData::empty(); + result.style = source.style.clone(); + + for mut subpath in source.stroke_bezier_paths() { + subpath.apply_transform(source_transform); + + if subpath.manipulator_groups().len() < 3 { + // Not enough points for corner rounding + result.append_subpath(subpath, false); + continue; + } + + let groups = subpath.manipulator_groups(); + let mut new_groups = Vec::new(); + let is_closed = subpath.closed(); + + for i in 0..groups.len() { + // Skip first and last points for open paths + if !is_closed && (i == 0 || i == groups.len() - 1) { + new_groups.push(groups[i]); + continue; + } + + // Not the prettiest, but it makes the rest of the logic more readable + let prev_idx = if i == 0 { if is_closed { groups.len() - 1 } else { 0 } } else { i - 1 }; + let curr_idx = i; + let next_idx = if i == groups.len() - 1 { if is_closed { 0 } else { i } } else { i + 1 }; + + let prev = groups[prev_idx].anchor; + let curr = groups[curr_idx].anchor; + let next = groups[next_idx].anchor; + + let dir1 = (curr - prev).normalize_or(DVec2::X); + let dir2 = (next - curr).normalize_or(DVec2::X); + + let theta = PI - dir1.angle_to(dir2).abs(); + + // Skip near-straight corners + if theta > PI - min_angle_threshold.to_radians() { + new_groups.push(groups[curr_idx]); + continue; + } + + // Calculate L, with limits to avoid extreme values + let distance_along_edge = radius / (theta / 2.).sin(); + let distance_along_edge = distance_along_edge.min(edge_length_limit * (curr - prev).length().min((next - curr).length())).max(0.01); + + // Find points on each edge at distance L from corner + let p1 = curr - dir1 * distance_along_edge; + let p2 = curr + dir2 * distance_along_edge; + + // Add first point with out handle + new_groups.push(ManipulatorGroup { + anchor: p1, + in_handle: None, + out_handle: Some(curr - dir1 * distance_along_edge * roundness), + id: PointId::generate(), + }); + + // Add second point with in handle + new_groups.push(ManipulatorGroup { + anchor: p2, + in_handle: Some(curr + dir2 * distance_along_edge * roundness), + out_handle: None, + id: PointId::generate(), + }); + } + + let mut rounded_subpath = Subpath::new(new_groups, is_closed); + rounded_subpath.apply_transform(source_transform_inverse); + result.append_subpath(rounded_subpath, false); + } + + let mut result_table = VectorDataTable::new(result); + *result_table.transform_mut() = source_transform; + result_table +} + +#[node_macro::node(name("Spatial Merge by Distance"), category("Debug"), path(graphene_core::vector))] +async fn spatial_merge_by_distance( + _: impl Ctx, + vector_data: VectorDataTable, + #[default(0.1)] + #[min(0.0001)] + distance: f64, +) -> VectorDataTable { + let vector_data_transform = vector_data.transform(); + let vector_data = vector_data.one_instance().instance; + let point_count = vector_data.point_domain.positions().len(); + + // Find min x and y for grid cell normalization + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + + // Calculate mins without collecting all positions + for &pos in vector_data.point_domain.positions() { + let transformed_pos = vector_data_transform.transform_point2(pos); + min_x = min_x.min(transformed_pos.x); + min_y = min_y.min(transformed_pos.y); + } + + // Create a spatial grid with cell size of 'distance' + use std::collections::HashMap; + let mut grid: HashMap<(i32, i32), Vec> = HashMap::new(); + + // Add points to grid cells without collecting all positions first + for i in 0..point_count { + let pos = vector_data_transform.transform_point2(vector_data.point_domain.positions()[i]); + let grid_x = ((pos.x - min_x) / distance).floor() as i32; + let grid_y = ((pos.y - min_y) / distance).floor() as i32; + + grid.entry((grid_x, grid_y)).or_default().push(i); + } + + // Create point index mapping for merged points + let mut point_index_map = vec![None; point_count]; + let mut merged_positions = Vec::new(); + let mut merged_indices = Vec::new(); + + // Process each point + for i in 0..point_count { + // Skip points that have already been processed + if point_index_map[i].is_some() { + continue; + } + + let pos_i = vector_data_transform.transform_point2(vector_data.point_domain.positions()[i]); + let grid_x = ((pos_i.x - min_x) / distance).floor() as i32; + let grid_y = ((pos_i.y - min_y) / distance).floor() as i32; + + let mut group = vec![i]; + + // Check only neighboring cells (3x3 grid around current cell) + for dx in -1..=1 { + for dy in -1..=1 { + let neighbor_cell = (grid_x + dx, grid_y + dy); + + if let Some(indices) = grid.get(&neighbor_cell) { + for &j in indices { + if j > i && point_index_map[j].is_none() { + let pos_j = vector_data_transform.transform_point2(vector_data.point_domain.positions()[j]); + if pos_i.distance(pos_j) <= distance { + group.push(j); + } + } + } + } + } + } + + // Create merged point - calculate positions as needed + let merged_position = group + .iter() + .map(|&idx| vector_data_transform.transform_point2(vector_data.point_domain.positions()[idx])) + .fold(DVec2::ZERO, |sum, pos| sum + pos) + / group.len() as f64; + + let merged_position = vector_data_transform.inverse().transform_point2(merged_position); + let merged_index = merged_positions.len(); + + merged_positions.push(merged_position); + merged_indices.push(vector_data.point_domain.ids()[group[0]]); + + // Update mapping for all points in the group + for &idx in &group { + point_index_map[idx] = Some(merged_index); + } + } + + // Create new point domain with merged points + let mut new_point_domain = PointDomain::new(); + for (idx, pos) in merged_indices.into_iter().zip(merged_positions) { + new_point_domain.push(idx, pos); + } + + // Update segment domain + let mut new_segment_domain = SegmentDomain::new(); + for segment_idx in 0..vector_data.segment_domain.ids().len() { + let id = vector_data.segment_domain.ids()[segment_idx]; + let start = vector_data.segment_domain.start_point()[segment_idx]; + let end = vector_data.segment_domain.end_point()[segment_idx]; + let handles = vector_data.segment_domain.handles()[segment_idx]; + let stroke = vector_data.segment_domain.stroke()[segment_idx]; + + // Get new indices for start and end points + let new_start = point_index_map[start].unwrap(); + let new_end = point_index_map[end].unwrap(); + + // Skip segments where start and end points were merged + if new_start != new_end { + new_segment_domain.push(id, new_start, new_end, handles, stroke); + } + } + + // Create new vector data + let mut result = vector_data.clone(); + result.point_domain = new_point_domain; + result.segment_domain = new_segment_domain; + + // Create and return the result + let mut result_table = VectorDataTable::new(result); + *result_table.transform_mut() = vector_data_transform; + result_table +} + +#[node_macro::node(category("Debug"), path(graphene_core::vector))] +async fn box_warp(_: impl Ctx, vector_data: VectorDataTable, #[expose] rectangle: VectorDataTable) -> VectorDataTable { + let vector_data_transform = vector_data.transform(); + let vector_data = vector_data.one_instance().instance.clone(); + + let target_transform = rectangle.transform(); + let target = rectangle.one_instance().instance; + + // Get the bounding box of the source vector data + let source_bbox = vector_data.bounding_box_with_transform(vector_data_transform).unwrap_or([DVec2::ZERO, DVec2::ONE]); + + // Extract first 4 points from target shape to form the quadrilateral + // Apply the target's transform to get points in world space + let target_points: Vec = target.point_domain.positions().iter().map(|&p| target_transform.transform_point2(p)).take(4).collect(); + + // If we have fewer than 4 points, use the corners of the source bounding box + // This handles the degenerative case + let dst_corners = if target_points.len() >= 4 { + [target_points[0], target_points[1], target_points[2], target_points[3]] + } else { + warn!("Target shape has fewer than 4 points. Using source bounding box instead."); + [ + source_bbox[0], + DVec2::new(source_bbox[1].x, source_bbox[0].y), + source_bbox[1], + DVec2::new(source_bbox[0].x, source_bbox[1].y), + ] + }; + + // Apply the warp + let mut result = vector_data.clone(); + + // Precompute source bounding box size for normalization + let source_size = source_bbox[1] - source_bbox[0]; + + // Transform points + for (_, position) in result.point_domain.positions_mut() { + // Get the point in world space + let world_pos = vector_data_transform.transform_point2(*position); + + // Normalize coordinates within the source bounding box + let t = ((world_pos - source_bbox[0]) / source_size).clamp(DVec2::ZERO, DVec2::ONE); + + // Apply bilinear interpolation + *position = bilinear_interpolate(t, &dst_corners); + } + + // Transform handles in bezier curves + for (_, handles, _, _) in result.handles_mut() { + *handles = handles.apply_transformation(|pos| { + // Get the handle in world space + let world_pos = vector_data_transform.transform_point2(pos); + + // Normalize coordinates within the source bounding box + let t = ((world_pos - source_bbox[0]) / source_size).clamp(DVec2::ZERO, DVec2::ONE); + + // Apply bilinear interpolation + bilinear_interpolate(t, &dst_corners) + }); + } + + result.style.set_stroke_transform(DAffine2::IDENTITY); + + // Create a new VectorDataTable with the result + let mut result_table = VectorDataTable::new(result); + + // Reset the transform since we've applied it directly to the points + *result_table.transform_mut() = DAffine2::IDENTITY; + + result_table +} + +// Interpolate within a quadrilateral using normalized coordinates (0-1) +fn bilinear_interpolate(t: DVec2, quad: &[DVec2; 4]) -> DVec2 { + let tl = quad[0]; // Top-left + let tr = quad[1]; // Top-right + let br = quad[2]; // Bottom-right + let bl = quad[3]; // Bottom-left + + // Bilinear interpolation + tl * (1. - t.x) * (1. - t.y) + tr * t.x * (1. - t.y) + br * t.x * t.y + bl * (1. - t.x) * t.y +} + +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn remove_handles( + _: impl Ctx, + vector_data: VectorDataTable, + #[default(10.)] + #[min(0.)] + max_handle_distance: f64, +) -> VectorDataTable { + let vector_data_transform = vector_data.transform(); + let mut vector_data = vector_data.one_instance().instance.clone(); + + for (_, handles, start, end) in vector_data.segment_domain.handles_mut() { + // Only convert to linear if handles are within the threshold distance + match *handles { + bezier_rs::BezierHandles::Cubic { handle_start, handle_end } => { + let start_pos = vector_data.point_domain.positions()[start]; + let end_pos = vector_data.point_domain.positions()[end]; + + let start_handle_distance = (handle_start - start_pos).length(); + let end_handle_distance = (handle_end - end_pos).length(); + + // If handles are close enough to their anchor points, make the segment linear + if start_handle_distance <= max_handle_distance && end_handle_distance <= max_handle_distance { + *handles = bezier_rs::BezierHandles::Linear; + } + } + bezier_rs::BezierHandles::Quadratic { handle } => { + let start_pos = vector_data.point_domain.positions()[start]; + let end_pos = vector_data.point_domain.positions()[end]; + + // Use average distance from handle to both points + let avg_distance = ((handle - start_pos).length() + (handle - end_pos).length()) / 2.; + + if avg_distance <= max_handle_distance { + *handles = bezier_rs::BezierHandles::Linear; + } + } + _ => {} + } + } + + let mut result = VectorDataTable::new(vector_data); + *result.transform_mut() = vector_data_transform; + result +} + +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn generate_handles( + _: impl Ctx, + source: VectorDataTable, + #[default(0.4)] + #[range((0., 1.))] + curvature: f64, +) -> VectorDataTable { + let source_transform = source.transform(); + let source = source.one_instance().instance; + + let mut result = VectorData::empty(); + result.style = source.style.clone(); + + for mut subpath in source.stroke_bezier_paths() { + subpath.apply_transform(source_transform); + + let groups = subpath.manipulator_groups(); + if groups.len() < 2 { + // Not enough points for softening + result.append_subpath(subpath, true); + continue; + } + + let mut new_groups = Vec::with_capacity(groups.len()); + let is_closed = subpath.closed(); + + for i in 0..groups.len() { + let curr = &groups[i]; + + // Check if this point has handles + let has_handles = + (curr.in_handle.is_some() && !curr.in_handle.unwrap().abs_diff_eq(curr.anchor, 1e-5)) || (curr.out_handle.is_some() && !curr.out_handle.unwrap().abs_diff_eq(curr.anchor, 1e-5)); + + if has_handles || (!is_closed && (i == 0 || i == groups.len() - 1)) { + new_groups.push(*curr); + continue; + } + + // Get previous and next points + let prev_idx = if i == 0 { if is_closed { groups.len() - 1 } else { i } } else { i - 1 }; + let next_idx = if i == groups.len() - 1 { if is_closed { 0 } else { i } } else { i + 1 }; + + let prev = groups[prev_idx].anchor; + let curr_pos = curr.anchor; + let next = groups[next_idx].anchor; + + // Calculate directions to adjacent points + let dir_prev = (prev - curr_pos).normalize_or_zero(); + let dir_next = (next - curr_pos).normalize_or_zero(); + + // Check if we have valid directions + if dir_prev.length_squared() < 1e-5 || dir_next.length_squared() < 1e-5 { + new_groups.push(*curr); + continue; + } + + // Calculate handle direction (perpendicular to the angle bisector) + let handle_dir = (dir_prev - dir_next).try_normalize().unwrap_or(dir_prev.perp()); + let handle_dir = if dir_prev.dot(handle_dir) < 0. { -handle_dir } else { handle_dir }; + + // Calculate handle lengths - 1/3 of distance to adjacent points, scaled by curvature + let in_length = (curr_pos - prev).length() / 3. * curvature; + let out_length = (next - curr_pos).length() / 3. * curvature; + + // Create new manipulator group with handles + new_groups.push(ManipulatorGroup { + anchor: curr_pos, + in_handle: Some(curr_pos + handle_dir * in_length), + out_handle: Some(curr_pos - handle_dir * out_length), + id: curr.id, + }); + } + + let mut softened_subpath = Subpath::new(new_groups, is_closed); + softened_subpath.apply_transform(source_transform.inverse()); + result.append_subpath(softened_subpath, true); + } + + let mut result_table = VectorDataTable::new(result); + *result_table.transform_mut() = source_transform; + result_table +} + +// TODO: Fix issues and reenable +// #[node_macro::node(category("Vector"), path(graphene_core::vector))] +// async fn subdivide( +// _: impl Ctx, +// source: VectorDataTable, +// #[default(1.)] +// #[min(1.)] +// #[max(8.)] +// subdivisions: f64, +// ) -> VectorDataTable { +// let source_transform = source.transform(); +// let source_vector_data = source.one_instance().instance; +// let subdivisions = subdivisions as usize; + +// let mut result = VectorData::empty(); +// result.style = source_vector_data.style.clone(); + +// for mut subpath in source_vector_data.stroke_bezier_paths() { +// subpath.apply_transform(source_transform); + +// if subpath.manipulator_groups().len() < 2 { +// // Not enough points to subdivide +// result.append_subpath(subpath, true); +// continue; +// } + +// // Apply subdivisions recursively +// let mut current_subpath = subpath; +// for _ in 0..subdivisions { +// current_subpath = subdivide_once(¤t_subpath); +// } + +// current_subpath.apply_transform(source_transform.inverse()); +// result.append_subpath(current_subpath, true); +// } + +// let mut result_table = VectorDataTable::new(result); +// *result_table.transform_mut() = source_transform; +// result_table +// } + +// fn subdivide_once(subpath: &Subpath) -> Subpath { +// let original_groups = subpath.manipulator_groups(); +// let mut new_groups = Vec::new(); +// let is_closed = subpath.closed(); +// let mut last_in_handle = None; + +// for i in 0..original_groups.len() { +// let start_idx = i; +// let end_idx = (i + 1) % original_groups.len(); + +// // Skip the last segment for open paths +// if !is_closed && end_idx == 0 { +// break; +// } + +// let current_bezier = original_groups[start_idx].to_bezier(&original_groups[end_idx]); + +// // Create modified start point with original ID, but updated in_handle & out_handle +// let mut start_point = original_groups[start_idx].clone(); +// let [first, _] = current_bezier.split(TValue::Euclidean(0.5)); +// start_point.out_handle = first.handle_start(); +// start_point.in_handle = last_in_handle; +// if new_groups.contains(&start_point) { +// debug!("start_point already in"); +// } else { +// new_groups.push(start_point); +// } + +// // Add midpoint +// let [first, second] = current_bezier.split(TValue::Euclidean(0.5)); + +// let new_point = ManipulatorGroup { +// anchor: first.end, +// in_handle: first.handle_end(), +// out_handle: second.handle_start(), +// id: start_point.id.generate_from_hash(u64::MAX), +// }; +// if new_groups.contains(&new_point) { +// debug!("new_point already in"); +// } else { +// new_groups.push(new_point); +// } + +// last_in_handle = second.handle_end(); +// } + +// // Handle the final point for open paths +// if !is_closed && !original_groups.is_empty() { +// let mut last_point = original_groups.last().unwrap().clone(); +// last_point.in_handle = last_in_handle; +// if new_groups.contains(&last_point) { +// debug!("last_point already in"); +// } else { +// new_groups.push(last_point); +// } +// } else if is_closed && !new_groups.is_empty() { +// // Update the first point's in_handle for closed paths +// new_groups[0].in_handle = last_in_handle; +// } + +// Subpath::new(new_groups, is_closed) +// } + #[node_macro::node(category("Vector"), path(graphene_core::vector))] async fn bounding_box(_: impl Ctx, vector_data: VectorDataTable) -> VectorDataTable { let vector_data_transform = vector_data.transform();