Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions editor/src/messages/menu_bar/menu_bar_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down
8 changes: 6 additions & 2 deletions node-graph/libraries/vector-types/src/vector/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
146 changes: 120 additions & 26 deletions node-graph/nodes/path-bool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -36,24 +34,30 @@ async fn boolean_operation<I: graphic_types::IntoGraphicList + 'n + Send + Clone

// The first index is the bottom of the stack
let flattened = flatten_vector(&content);
let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation);

let mut result_vector_list = match operation {
BooleanOperation::Union | BooleanOperation::SubtractFront | BooleanOperation::SubtractBack | BooleanOperation::Intersect | BooleanOperation::Exclude => {
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
Expand Down Expand Up @@ -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<Vector>, boolean_operation: BooleanOperation) -> List<Vector> {
const EPSILON: f64 = 1e-5;
fn single_pass_boolean_operation(vector: &List<Vector>, boolean_operation: BooleanOperation) -> List<Vector> {
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);
Expand All @@ -137,29 +163,97 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
Item::<Vector>::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<Vector>, boolean_operation: BooleanOperation) -> List<Vector> {
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<Vector>) -> Option<Topology<WindingNumber>> {
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::<WindingNumber>::from_paths(paths.iter().enumerate().map(|(idx, path)| (path, (idx, paths.len()))), EPSILON) {
Ok(top) => top,
match Topology::<WindingNumber>::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<Graphic>) -> List<Vector> {
Expand Down Expand Up @@ -252,7 +346,7 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {

// Recursively flatten the inner `List` into the output `List<Vector>`
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::<Vec<_>>()
}
Expand Down