diff --git a/Cargo.lock b/Cargo.lock index dcc71e997b..236858b523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3728,11 +3728,15 @@ version = "0.1.0" dependencies = [ "core-types", "glam", + "graphene-core", "graphic-types", + "kurbo", "linesweeper", "log", "node-macro", "smallvec", + "tokio", + "vector-nodes", "vector-types", ] diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 689a6a1439..789da32fd9 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -486,11 +486,27 @@ impl LayoutHolder for MenuBarMessageHandler { DocumentMessage::GroupSelectedLayers { group_folder_type }.into() }) .disabled(no_active_document || !has_selected_layers), - MenuListEntry::new("Difference") - .label("Difference") - .icon("BooleanDifference") + MenuListEntry::new("Exclude") + .label("Exclude") + .icon("BooleanExclude") .on_commit(|_| { - let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Difference); + let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Exclude); + DocumentMessage::GroupSelectedLayers { group_folder_type }.into() + }) + .disabled(no_active_document || !has_selected_layers), + MenuListEntry::new("Trim") + .label("Trim") + .icon("BooleanSubtractFront") + .on_commit(|_| { + let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Trim); + DocumentMessage::GroupSelectedLayers { group_folder_type }.into() + }) + .disabled(no_active_document || !has_selected_layers), + MenuListEntry::new("Crop") + .label("Crop") + .icon("BooleanSubtractFront") + .on_commit(|_| { + let group_folder_type = GroupFolderType::BooleanOperation(BooleanOperation::Crop); DocumentMessage::GroupSelectedLayers { group_folder_type }.into() }) .disabled(no_active_document || !has_selected_layers), diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 54d3ae01dc..8da666e90b 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -110,7 +110,7 @@ import AlignRight from "/../branding/assets/icon-16px-solid/align-right.svg"; import AlignTop from "/../branding/assets/icon-16px-solid/align-top.svg"; import AlignVerticalCenter from "/../branding/assets/icon-16px-solid/align-vertical-center.svg"; import Artboard from "/../branding/assets/icon-16px-solid/artboard.svg"; -import BooleanDifference from "/../branding/assets/icon-16px-solid/boolean-difference.svg"; +import BooleanExclude from "/../branding/assets/icon-16px-solid/boolean-difference.svg"; import BooleanDivide from "/../branding/assets/icon-16px-solid/boolean-divide.svg"; import BooleanIntersect from "/../branding/assets/icon-16px-solid/boolean-intersect.svg"; import BooleanSubtractBack from "/../branding/assets/icon-16px-solid/boolean-subtract-back.svg"; @@ -249,7 +249,7 @@ const SOLID_16PX = { AlignTop: { svg: AlignTop, size: 16 }, AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 }, Artboard: { svg: Artboard, size: 16 }, - BooleanDifference: { svg: BooleanDifference, size: 16 }, + BooleanExclude: { svg: BooleanExclude, size: 16 }, BooleanDivide: { svg: BooleanDivide, size: 16 }, BooleanIntersect: { svg: BooleanIntersect, size: 16 }, BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 }, diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index bda89fcd0e..7cbef7cc01 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -21,8 +21,12 @@ pub enum BooleanOperation { SubtractBack, #[icon("BooleanIntersect")] Intersect, - #[icon("BooleanDifference")] - Difference, + #[icon("BooleanExclude")] + Exclude, + #[icon("BooleanSubtractFront")] + Trim, + #[icon("BooleanSubtractFront")] + Crop, } /// Represents different geometric interpretations of calculating the centroid (center of mass). diff --git a/node-graph/nodes/path-bool/Cargo.toml b/node-graph/nodes/path-bool/Cargo.toml index 34e44b9d0c..e0cab5198d 100644 --- a/node-graph/nodes/path-bool/Cargo.toml +++ b/node-graph/nodes/path-bool/Cargo.toml @@ -16,3 +16,9 @@ linesweeper = { workspace = true } log = { workspace = true } smallvec = { workspace = true } vector-types = { workspace = true } + +[dev-dependencies] +graphene-core = { workspace = true } +vector-nodes = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } +kurbo = { workspace = true } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 14c0025d52..2db66ddde6 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -14,8 +14,6 @@ use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathSeg, pub use vector_types::vector::misc::BooleanOperation; // TODO: Fix boolean ops to work by removing .transform() and .one_instance_*() calls, -// TODO: since before we used a Vec of single-item `List`s and now we use a single `List` -// TODO: with multiple items while still assuming a single item for the boolean operations. /// Combines the geometric forms of one or more closed paths into a new vector path that results from cutting or joining the paths by the chosen method. #[node_macro::node(category("Vector: Modifier"), memoize)] @@ -36,24 +34,30 @@ async fn boolean_operation { + single_pass_boolean_operation(&flattened, operation) + } + BooleanOperation::Trim | BooleanOperation::Crop => cascading_subtract(&flattened, operation), + }; // Replace the transformation matrix with a mutation of the vector points themselves - if result_vector_list.element_mut(0).is_some() { - let transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - result_vector_list.set_attribute(ATTR_TRANSFORM, 0, DAffine2::IDENTITY); + for i in 0..result_vector_list.len() { + let transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, i); + result_vector_list.set_attribute(ATTR_TRANSFORM, i, DAffine2::IDENTITY); - let result_vector = result_vector_list.element_mut(0).unwrap(); + let result_vector = result_vector_list.element_mut(i).unwrap(); Vector::transform(result_vector, transform); result_vector.style.set_stroke_transform(DAffine2::IDENTITY); // Snapshot the input layers as the `editor:merged_layers` attribute so the renderer can recurse into them // for editor click-target preservation. - result_vector_list.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, content.clone()); + result_vector_list.set_attribute(ATTR_EDITOR_MERGED_LAYERS, i, content.clone()); // Clean up the boolean operation result by merging duplicated points - let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - result_vector_list.element_mut(0).unwrap().merge_by_distance_spatial(merge_transform, 0.0001); + let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, i); + result_vector_list.element_mut(i).unwrap().merge_by_distance_spatial(merge_transform, 0.0001); } result_vector_list @@ -108,22 +112,44 @@ impl WindingNumber { BooleanOperation::SubtractFront => self.elems.first().is_some_and(is_in) && self.elems.iter().skip(1).all(is_out), BooleanOperation::SubtractBack => self.elems.last().is_some_and(is_in) && self.elems.iter().rev().skip(1).all(is_out), BooleanOperation::Intersect => !self.elems.is_empty() && self.elems.iter().all(is_in), - BooleanOperation::Difference => self.elems.iter().any(is_in) && !self.elems.iter().all(is_in), + BooleanOperation::Exclude => self.elems.iter().any(is_in) && !self.elems.iter().all(is_in), + BooleanOperation::Trim => unreachable!(), + BooleanOperation::Crop => unreachable!(), + } + } + + fn subtract_front_at(&self, i: usize) -> bool { + let is_in = |v: &i16| *v != 0; + + self.elems.get(i).is_some_and(is_in) && self.elems.iter().skip(i + 1).all(|v| !is_in(v)) + } + + fn crop_visible_at(&self, i: usize) -> bool { + let is_in = |v: &i16| *v != 0; + + if self.elems.is_empty() { + return false; + } + + let top_index = self.elems.len() - 1; + + if i >= top_index { + return false; } + + self.elems.get(i).is_some_and(is_in) && self.elems.get(top_index).is_some_and(is_in) && self.elems[i + 1..top_index].iter().all(|v| !is_in(v)) } } -fn boolean_operation_on_vector_list(vector: &List, boolean_operation: BooleanOperation) -> List { - const EPSILON: f64 = 1e-5; +fn single_pass_boolean_operation(vector: &List, boolean_operation: BooleanOperation) -> List { let mut list = List::new(); - let mut paths = Vec::new(); let copy_from_index = if matches!(boolean_operation, BooleanOperation::SubtractFront) { if !vector.is_empty() { Some(0) } else { None } } else { if !vector.is_empty() { Some(vector.len() - 1) } else { None } }; - let mut row = if let Some(index) = copy_from_index { + let mut item = if let Some(index) = copy_from_index { let mut attributes = vector.clone_item_attributes(index); // The boolean op bakes input transforms into the output geometry, so the result item carries no transform of its own attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY); @@ -137,29 +163,97 @@ fn boolean_operation_on_vector_list(vector: &List, boolean_operation: Bo Item::::default() }; + let top = match try_create_topology(vector) { + Some(top) => top, + None => { + list.push(item); + return list; + } + }; + + let contours = top.contours(|winding| winding.is_inside(boolean_operation)); + + if contours.contours().next().is_some() { + append_linesweeper_contours(item.element_mut(), &contours); + list.push(item); + } + + list +} + +fn cascading_subtract(vector: &List, boolean_operation: BooleanOperation) -> List { + let mut list = List::new(); + + let top = match try_create_topology(vector) { + Some(top) => top, + None => return list, + }; + + for i in 0..vector.len() { + let contours = match boolean_operation { + BooleanOperation::Crop if i == vector.len() - 1 => top.contours(|winding| winding.is_inside(BooleanOperation::SubtractBack)), + + BooleanOperation::Crop => top.contours(|winding| winding.crop_visible_at(i)), + + _ => top.contours(|winding| winding.subtract_front_at(i)), + }; + + if contours.contours().next().is_none() { + continue; + } + + let source = match vector.element(i) { + Some(source) => source, + None => continue, + }; + + let mut attributes = vector.clone_item_attributes(i); + attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY); + + let mut element = Vector { + style: source.style.clone(), + ..Default::default() + }; + + if boolean_operation == BooleanOperation::Crop && i == vector.len() - 1 { + element.style.clear_fill(); + element.style.clear_stroke(); + } + + append_linesweeper_contours(&mut element, &contours); + + let item = Item::from_parts(element, attributes); + list.push(item); + } + + list +} + +fn try_create_topology(vector: &List) -> Option> { + const EPSILON: f64 = 1e-5; + + let mut paths = Vec::new(); + for index in 0..vector.len() { let element = vector.element(index).unwrap(); paths.push(to_bez_path(element, vector.attribute_cloned_or_default(ATTR_TRANSFORM, index))); } - let top = match Topology::::from_paths(paths.iter().enumerate().map(|(idx, path)| (path, (idx, paths.len()))), EPSILON) { - Ok(top) => top, + match Topology::::from_paths(paths.iter().enumerate().map(|(idx, path)| (path, (idx, paths.len()))), EPSILON) { + Ok(top) => Some(top), Err(e) => { log::error!("Boolean operation failed while building topology: {e}"); - list.push(row); - return list; + None } - }; - let contours = top.contours(|winding| winding.is_inside(boolean_operation)); + } +} +fn append_linesweeper_contours(vector: &mut Vector, contours: &linesweeper::topology::Contours) { // TODO: Linesweeper emits contours in the opposite winding direction from the rest of Kurbo's and Graphite's vector graphics system (clockwise in screen coordinates). // TODO: Report this upstream to Linesweeper and remove this `.reverse()` workaround once fixed. for subpath in from_bez_paths(contours.contours().map(|c| &c.path)) { - row.element_mut().append_subpath(subpath.reverse(), false); + vector.append_subpath(subpath.reverse(), false); } - - list.push(row); - list } fn flatten_vector(graphic_list: &List) -> List { @@ -252,7 +346,7 @@ fn flatten_vector(graphic_list: &List) -> List { // Recursively flatten the inner `List` into the output `List` let flattened = flatten_vector(&graphic); - let unioned = boolean_operation_on_vector_list(&flattened, BooleanOperation::Union); + let unioned = single_pass_boolean_operation(&flattened, BooleanOperation::Union); unioned.into_iter().collect::>() } @@ -369,3 +463,240 @@ pub fn boolean_intersect(a: &BezPath, b: &BezPath) -> Vec { } } } +//TODO: Add styles for inputs and style asserts for outputs once the requirements are defined +#[cfg(test)] +mod test { + use super::*; + use core_types::OwnedContextImpl; + use core_types::list::{Item, List}; + use kurbo::{DEFAULT_ACCURACY, Rect, Shape}; + use vector_types::Vector; + + fn create_input_shapes(include_third_shape: bool) -> List { + let square = Vector::from_bezpath(Rect::new(-4., -4., 4., 4.).to_path(DEFAULT_ACCURACY)); + let rectangle = Vector::from_bezpath(Rect::new(2., -2., 8., 2.).to_path(DEFAULT_ACCURACY)); + let mut shapes = List::new_from_element(square); + shapes.push(Item::new_from_element(rectangle)); + + if include_third_shape { + let rectangle = Vector::from_bezpath(Rect::new(-2., -6., 5., 0.).to_path(DEFAULT_ACCURACY)); + shapes.push(Item::new_from_element(rectangle)); + } + + shapes + } + + fn create_no_overlap_input_shapes() -> List { + let square = Vector::from_bezpath(Rect::new(-4., -4., 4., 4.).to_path(DEFAULT_ACCURACY)); + let rectangle = Vector::from_bezpath(Rect::new(5., -2., 5., 2.).to_path(DEFAULT_ACCURACY)); + let mut shapes = List::new_from_element(square); + shapes.push(Item::new_from_element(rectangle)); + + shapes + } + + fn assert_anchor_positions(vector: &Vector, expected_anchors: &[DVec2]) { + const EPSILON: f64 = 1e-5; + let anchors = vector.point_domain.positions(); + + assert_eq!(anchors.len(), expected_anchors.len(), "Anchor count mismatch"); + + for (i, expected) in expected_anchors.iter().enumerate() { + let actual = anchors[i]; + let distance = (actual - *expected).length(); + + assert!(distance < EPSILON, "Anchor {i} mismatch: expected {expected:?}, got {actual:?}, distance {distance}"); + } + } + + fn assert_shapes_geometry(generated: &List, expected_anchors: Vec>) { + assert_eq!(generated.len(), expected_anchors.len(), "Shape count mismatch"); + + for (i, expected) in expected_anchors.iter().enumerate() { + let result_shape = generated.element(i).unwrap(); + + assert_anchor_positions(result_shape, &expected); + + assert_eq!(result_shape.segment_domain.ids().len(), expected.len(), "Segment count mismatch"); + assert_eq!(result_shape.segment_domain.end_point().last(), Some(&0), "The result shape is not closed"); + } + } + + #[tokio::test] + async fn union() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(false); + let generated = boolean_operation(context, shapes, BooleanOperation::Union).await; + + let expected_anchors = vec![vec![ + DVec2::new(-4., -4.), + DVec2::new(4., -4.), + DVec2::new(4., -2.), + DVec2::new(8., -2.), + DVec2::new(8., 2.), + DVec2::new(4., 2.), + DVec2::new(4., 4.), + DVec2::new(-4., 4.), + ]]; + + assert_shapes_geometry(&generated, expected_anchors); + } + + #[tokio::test] + async fn subtract_front() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(false); + let generated = boolean_operation(context, shapes, BooleanOperation::SubtractFront).await; + + let expected_anchors = vec![vec![ + DVec2::new(-4., -4.), + DVec2::new(4., -4.), + DVec2::new(4., -2.), + DVec2::new(2., -2.), + DVec2::new(2., 2.), + DVec2::new(4., 2.), + DVec2::new(4., 4.), + DVec2::new(-4., 4.), + ]]; + + assert_shapes_geometry(&generated, expected_anchors); + } + + #[tokio::test] + async fn subtract_back() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(false); + let generated = boolean_operation(context, shapes, BooleanOperation::SubtractBack).await; + + let expected_anchors = vec![vec![DVec2::new(4., -2.), DVec2::new(8., -2.), DVec2::new(8., 2.), DVec2::new(4., 2.)]]; + + assert_shapes_geometry(&generated, expected_anchors); + } + + #[tokio::test] + async fn intersect() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(false); + let generated = boolean_operation(context, shapes, BooleanOperation::Intersect).await; + + let expected_anchors = vec![vec![DVec2::new(2., -2.), DVec2::new(4., -2.), DVec2::new(4., 2.), DVec2::new(2., 2.)]]; + + assert_shapes_geometry(&generated, expected_anchors); + } + + #[tokio::test] + async fn intersect_no_overlap() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_no_overlap_input_shapes(); + let generated = boolean_operation(context, shapes, BooleanOperation::Intersect).await; + + assert_eq!(generated.len(), 0); + } + + #[tokio::test] + async fn exclude() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(false); + let generated = boolean_operation(context, shapes, BooleanOperation::Exclude).await; + + let expected_anchors = [ + DVec2::new(-4., -4.), + DVec2::new(4., -4.), + DVec2::new(4., -2.), + DVec2::new(2., -2.), + DVec2::new(2., 2.), + DVec2::new(4., 2.), + DVec2::new(4., 4.), + DVec2::new(-4., 4.), + DVec2::new(8., -2.), + DVec2::new(8., 2.), + ]; + + assert_eq!(generated.len(), 1); + let result_shape = generated.element(0).unwrap(); + + assert_anchor_positions(result_shape, &expected_anchors); + + assert_eq!(result_shape.segment_domain.ids().len(), 12); + assert_eq!(result_shape.region_domain.ids().len(), 2); + assert_eq!(result_shape.region_domain.segment_range().len(), 2); + } + + #[tokio::test] + async fn trim() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(true); + let generated = boolean_operation(context, shapes, BooleanOperation::Trim).await; + + let expected_anchors = vec![ + vec![ + DVec2::new(-4., -4.), + DVec2::new(-2., -4.), + DVec2::new(-2., 0.), + DVec2::new(2., 0.), + DVec2::new(2., 2.), + DVec2::new(4., 2.), + DVec2::new(4., 4.), + DVec2::new(-4., 4.), + ], + vec![ + DVec2::new(5., -2.), + DVec2::new(8., -2.), + DVec2::new(8., 2.), + DVec2::new(4., 2.), + DVec2::new(2., 2.), + DVec2::new(2., 0.), + DVec2::new(4., 0.), + DVec2::new(5., 0.), + ], + vec![ + DVec2::new(-2., -6.), + DVec2::new(5., -6.), + DVec2::new(5., -2.), + DVec2::new(5., 0.), + DVec2::new(4., 0.), + DVec2::new(2., 0.), + DVec2::new(-2., 0.), + DVec2::new(-2., -4.), + ], + ]; + + assert_shapes_geometry(&generated, expected_anchors); + } + + #[tokio::test] + async fn crop() { + let context = OwnedContextImpl::default().into_context(); + let shapes = create_input_shapes(true); + let generated = boolean_operation(context, shapes, BooleanOperation::Crop).await; + + let expected_anchors = vec![ + vec![ + DVec2::new(-2., -4.), + DVec2::new(4., -4.), + DVec2::new(4., -2.), + DVec2::new(2., -2.), + DVec2::new(2., 0.), + DVec2::new(-2., 0.), + ], + vec![ + DVec2::new(2., -2.), + DVec2::new(4., -2.), + DVec2::new(5., -2.), + DVec2::new(5., 0.), + DVec2::new(4., 0.), + DVec2::new(2., 0.), + ], + vec![ + DVec2::new(-2., -6.), + DVec2::new(5., -6.), + DVec2::new(5., -2.), + DVec2::new(4., -2.), + DVec2::new(4., -4.), + DVec2::new(-2., -4.), + ], + ]; + + assert_shapes_geometry(&generated, expected_anchors); + } +}