From 4dea8c020a4232633e804514a66ef38767dcc8d0 Mon Sep 17 00:00:00 2001 From: Tanmay Maheshwari Date: Wed, 31 Dec 2025 17:17:06 +0530 Subject: [PATCH 1/2] fix(vector): warp groups using combined bounding box Groups of paths now warp together as a unit instead of independently. - union all subpath bounding boxes into one frame - normalize points and handles against shared bounds - apply bilinear interpolation into target quad - reset transforms after warp Fixes #3551 --- node-graph/nodes/vector/src/vector_nodes.rs | 41 ++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 778b9eeab5..97f4d72028 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -791,18 +791,34 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl return content; }; + // Compute combined bounding box across all vectors in the table + let mut group_bbox: Option<[DVec2; 2]> = None; + for row in content.iter() { + if let Some(bbox) = row.element.bounding_box_with_transform(*row.transform) { + group_bbox = Some(match group_bbox { + None => bbox, + Some(current) => [ + DVec2::new(current[0].x.min(bbox[0].x), current[0].y.min(bbox[0].y)), + DVec2::new(current[1].x.max(bbox[1].x), current[1].y.max(bbox[1].y)), + ], + }); + } + } + let group_bbox = group_bbox.unwrap_or([DVec2::ZERO, DVec2::ONE]); + let group_size = group_bbox[1] - group_bbox[0]; + content .into_iter() .map(|mut row| { - let transform = row.transform; let vector = row.element; // Get the bounding box of the source vector geometry - let source_bbox = vector.bounding_box_with_transform(transform).unwrap_or([DVec2::ZERO, DVec2::ONE]); + // (Replaced with group_bbox so all subpaths share the same frame) + let source_bbox = group_bbox; // 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(); + 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 @@ -817,20 +833,22 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl DVec2::new(source_bbox[0].x, source_bbox[1].y), ] }; - // Apply the warp let mut result = vector.clone(); // Precompute source bounding box size for normalization - let source_size = source_bbox[1] - source_bbox[0]; + let source_size = group_size; + + // Safe normalization size (prevents division by zero) + let eps = 1e-9; + let safe_size = DVec2::new(if source_size.x.abs() < eps { 1.0 } else { source_size.x }, if source_size.y.abs() < eps { 1.0 } else { source_size.y }); // Transform points for (_, position) in result.point_domain.positions_mut() { // Get the point in world space - let world_pos = transform.transform_point2(*position); + let world_pos = row.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); + let t = ((world_pos - source_bbox[0]) / safe_size).clamp(DVec2::ZERO, DVec2::ONE); // Apply bilinear interpolation *position = bilinear_interpolate(t, &dst_corners); @@ -839,13 +857,10 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl // 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 = transform.transform_point2(pos); + let world_pos = row.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); + let t = ((world_pos - source_bbox[0]) / safe_size).clamp(DVec2::ZERO, DVec2::ONE); - // Apply bilinear interpolation bilinear_interpolate(t, &dst_corners) }); } From 90b3f1376150bb5928db660f7a28857c9243be6c Mon Sep 17 00:00:00 2001 From: Tanmay Maheshwari Date: Thu, 12 Feb 2026 05:41:49 +0530 Subject: [PATCH 2/2] fix(vector): make the suggested changes on group bbox warp - move normalization epsilon to a file-level constant - use Rect::combine_bounds for bbox merging (wrapping [DVec2; 2]) - restore blank line after dst_corners block - restore and align inline comments across point and handle transforms --- node-graph/nodes/vector/src/vector_nodes.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 97f4d72028..0b46adfd55 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1,6 +1,7 @@ use core::f64::consts::PI; use core::hash::{Hash, Hasher}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::math::rect::Rect; use core_types::registry::types::{Angle, IntegerCount, Length, Multiplier, Percentage, PixelLength, PixelSize, Progression, SeedValue}; use core_types::table::{Table, TableRow, TableRowMut}; use core_types::transform::{Footprint, Transform}; @@ -28,6 +29,7 @@ use vector_types::vector::style::{Fill, Gradient, GradientStops, Stroke}; use vector_types::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, RegionId}; use vector_types::vector::{PointId, SegmentDomain, SegmentId, StrokeId, VectorExt}; +const NORMALIZATION_EPSILON: f64 = 1e-9; /// Implemented for types that can be converted to an iterator of vector rows. /// Used for the fill and stroke node so they can be used on `Table` or `Table`. @@ -797,10 +799,7 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl if let Some(bbox) = row.element.bounding_box_with_transform(*row.transform) { group_bbox = Some(match group_bbox { None => bbox, - Some(current) => [ - DVec2::new(current[0].x.min(bbox[0].x), current[0].y.min(bbox[0].y)), - DVec2::new(current[1].x.max(bbox[1].x), current[1].y.max(bbox[1].y)), - ], + Some(current) => Rect::combine_bounds(Rect(current), Rect(bbox)).0, }); } } @@ -833,6 +832,7 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl DVec2::new(source_bbox[0].x, source_bbox[1].y), ] }; + // Apply the warp let mut result = vector.clone(); @@ -840,14 +840,17 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl let source_size = group_size; // Safe normalization size (prevents division by zero) - let eps = 1e-9; - let safe_size = DVec2::new(if source_size.x.abs() < eps { 1.0 } else { source_size.x }, if source_size.y.abs() < eps { 1.0 } else { source_size.y }); + let safe_size = DVec2::new( + if source_size.x.abs() < NORMALIZATION_EPSILON { 1.0 } else { source_size.x }, + if source_size.y.abs() < NORMALIZATION_EPSILON { 1.0 } else { source_size.y }, + ); // Transform points for (_, position) in result.point_domain.positions_mut() { // Get the point in world space let world_pos = row.transform.transform_point2(*position); + // Normalize coordinates within the source bounding box let t = ((world_pos - source_bbox[0]) / safe_size).clamp(DVec2::ZERO, DVec2::ONE); // Apply bilinear interpolation @@ -857,10 +860,13 @@ async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Tabl // Transform handles in bezier curves for (_, handles, _, _) in result.handles_mut() { *handles = handles.apply_transformation(|pos| { + // Get the point in world space let world_pos = row.transform.transform_point2(pos); + // Normalize coordinates within the source bounding box let t = ((world_pos - source_bbox[0]) / safe_size).clamp(DVec2::ZERO, DVec2::ONE); + // Apply bilinear interpolation bilinear_interpolate(t, &dst_corners) }); }