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
16 changes: 16 additions & 0 deletions editor/src/messages/menu_bar/menu_bar_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions node-graph/libraries/vector-types/src/vector/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
137 changes: 115 additions & 22 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 `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)]
Expand All @@ -36,24 +34,29 @@ async fn boolean_operation<I: graphic_types::IntoGraphicTable + 'n + Send + Clon

// The first index is the bottom of the stack
let flattened = flatten_vector(&content);
let mut result_vector_table = boolean_operation_on_vector_table(&flattened, operation);
let mut result_vector_table = match operation {
BooleanOperation::Union | BooleanOperation::SubtractFront | BooleanOperation::SubtractBack | BooleanOperation::Intersect | BooleanOperation::Difference => {
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);
}
Comment on lines +45 to 60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The transformation logic in this loop is redundant because the helper functions (boolean_operation_on_vector_table and cascading_subtract) already set the ATTR_TRANSFORM attribute to DAffine2::IDENTITY and bake the original transforms into the geometry. Consequently, transform will always be identity here, making Vector::transform a no-op.

Additionally, fetching merge_transform from the table after it has been set to identity is unnecessary; you can simply pass DAffine2::IDENTITY to merge_by_distance_spatial.

	for i in 0..result_vector_table.len() {
		// 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, i, content.clone());

		let result_vector = result_vector_table.element_mut(i).unwrap();
		result_vector.style.set_stroke_transform(DAffine2::IDENTITY);

		// Clean up the boolean operation result by merging duplicated points
		result_vector.merge_by_distance_spatial(DAffine2::IDENTITY, 0.0001);
	}
References
  1. The attributes field in Table and TableRow is intentionally not serialized because it contains type-erased data, and serialization for this is not currently implemented.
  2. When a Vello render is requested, the GPU executor is guaranteed to be available, making the use of unwrap() acceptable in this context.


result_vector_table
Expand Down Expand Up @@ -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<Vector>, boolean_operation: BooleanOperation) -> Table<Vector> {
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 }
Expand All @@ -137,29 +162,97 @@ fn boolean_operation_on_vector_table(vector: &Table<Vector>, boolean_operation:
TableRow::<Vector>::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);
Comment on lines +170 to +174
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This code pushes a row to the result table even if the boolean operation produced no geometry (empty contours). It is better to skip adding the row if the result is empty to avoid polluting the document with invisible layers.

Suggested change
let contours = top.contours(|winding| winding.is_inside(boolean_operation));
append_linesweeper_contours(row.element_mut(), &contours);
table.push(row);
let contours = top.contours(|winding| winding.is_inside(boolean_operation));
if contours.contours().next().is_some() {
append_linesweeper_contours(row.element_mut(), &contours);
table.push(row);
}

table
}

fn cascading_subtract(vector: &Table<Vector>, boolean_operation: BooleanOperation) -> Table<Vector> {
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);
}
Comment on lines +196 to +216
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the general boolean operation, cascading_subtract should avoid adding empty layers to the result table if a layer is completely removed by the operation.

	for i in 0..end_index {
		let contours = top.contours(|w| predicate(w, i));
		if contours.contours().next().is_none() {
			continue;
		}

		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);
	}
References
  1. The attributes field in Table and TableRow is intentionally not serialized because it contains type-erased data, and serialization for this is not currently implemented.


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);
}
}
Comment on lines +218 to +226
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling boolean_operation_on_vector_table here is inefficient because it re-calculates the entire topology and re-runs the sweep-line algorithm. Since the topology top is already available in this function, you can extract the SubtractBack contours directly.

Suggested change
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);
}
}
if boolean_operation == BooleanOperation::Crop {
let contours = top.contours(|w| w.is_inside(BooleanOperation::SubtractBack));
if contours.contours().next().is_some() {
let index = vector.len().saturating_sub(1);
let mut attributes = vector.clone_row_attributes(index);
attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY);
let source = vector.element(index).unwrap();
let mut element = Vector {
style: source.style.clone(),
..Default::default()
};
element.style.clear_fill();
element.style.clear_stroke();
append_linesweeper_contours(&mut element, &contours);
table.push(TableRow::from_parts(element, attributes));
}
}
References
  1. The attributes field in Table and TableRow is intentionally not serialized because it contains type-erased data, and serialization for this is not currently implemented.
  2. When a Vello render is requested, the GPU executor is guaranteed to be available, making the use of unwrap() acceptable in this context.


table
}

fn try_create_topology(vector: &Table<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}");
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<Graphic>) -> Table<Vector> {
Expand Down