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..02ef2fe3fc 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -494,6 +494,22 @@ impl LayoutHolder for MenuBarMessageHandler { 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), ]]), MenuListEntry::new("Blend") .label("Blend") diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index bda89fcd0e..371733b7fb 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -23,6 +23,10 @@ pub enum BooleanOperation { Intersect, #[icon("BooleanDifference")] Difference, + #[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/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index da94d9f633..843a8e19d4 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 `Table`s and now we use a single `Table` -// 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,29 @@ async fn boolean_operation { + boolean_operation_on_vector_table(&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_table.element_mut(0).is_some() { - let transform: DAffine2 = result_vector_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - result_vector_table.set_attribute(ATTR_TRANSFORM, 0, DAffine2::IDENTITY); + for i in 0..result_vector_table.len() { + let transform: DAffine2 = result_vector_table.attribute_cloned_or_default(ATTR_TRANSFORM, i); + result_vector_table.set_attribute(ATTR_TRANSFORM, i, DAffine2::IDENTITY); - let result_vector = result_vector_table.element_mut(0).unwrap(); + let result_vector = result_vector_table.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_table.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, content.clone()); + result_vector_table.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_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - result_vector_table.element_mut(0).unwrap().merge_by_distance_spatial(merge_transform, 0.0001); + let merge_transform: DAffine2 = result_vector_table.attribute_cloned_or_default(ATTR_TRANSFORM, i); + result_vector_table.element_mut(i).unwrap().merge_by_distance_spatial(merge_transform, 0.0001); } result_vector_table @@ -109,14 +112,36 @@ impl WindingNumber { 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::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_table(vector: &Table, boolean_operation: BooleanOperation) -> Table { - const EPSILON: f64 = 1e-5; let mut table = Table::new(); - let mut paths = Vec::new(); let copy_from_index = if matches!(boolean_operation, BooleanOperation::SubtractFront) { if !vector.is_empty() { Some(0) } else { None } @@ -137,29 +162,97 @@ fn boolean_operation_on_vector_table(vector: &Table, boolean_operation: TableRow::::default() }; + let top = match try_create_topology(vector) { + Some(top) => top, + None => return table, + }; + + let contours = top.contours(|winding| winding.is_inside(boolean_operation)); + + append_linesweeper_contours(row.element_mut(), &contours); + + table.push(row); + table +} + +fn cascading_subtract(vector: &Table, boolean_operation: BooleanOperation) -> Table { + let mut table = Table::new(); + + let top = match try_create_topology(vector) { + Some(top) => top, + None => return table, + }; + + let end_index = match boolean_operation { + BooleanOperation::Crop => vector.len().saturating_sub(1), + _ => vector.len(), + }; + + let predicate = match boolean_operation { + BooleanOperation::Crop => WindingNumber::crop_visible_at, + _ => WindingNumber::subtract_front_at, + }; + + for i in 0..end_index { + let contours = top.contours(|w| predicate(w, i)); + + let source = match vector.element(i) { + Some(source) => source, + None => continue, + }; + + let mut attributes = vector.clone_row_attributes(i); + attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY); + + let mut element = Vector { + style: source.style.clone(), + ..Default::default() + }; + + append_linesweeper_contours(&mut element, &contours); + + let row = TableRow::from_parts(element, attributes); + table.push(row); + } + + if boolean_operation == BooleanOperation::Crop { + let top_remainder = boolean_operation_on_vector_table(vector, BooleanOperation::SubtractBack); + if let Some(mut row) = top_remainder.clone_row(0) { + let result_vector = row.element_mut(); + result_vector.style.clear_fill(); + result_vector.style.clear_stroke(); + table.push(row); + } + } + + table +} + +fn try_create_topology(vector: &Table) -> 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}"); - table.push(row); - return table; + 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); } - - table.push(row); - table } fn flatten_vector(graphic_table: &Table) -> Table {