use core_types::table::{Table, TableRow}; use core_types::uuid::NodeId; use core_types::{ATTR_ALPHA_BLENDING, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AlphaBlending, Color, Ctx}; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath}; use graphic_types::vector_types::vector::PointId; use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use graphic_types::vector_types::vector::style::Fill; use graphic_types::{Graphic, Vector}; use linesweeper::topology::Topology; use linesweeper::{BinaryOp, FillRule, binary_op}; use smallvec::SmallVec; use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathSeg, Point, QuadBez}; 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)] async fn boolean_operation( _: impl Ctx, /// The `Table` of vector paths to perform the boolean operation on. Nested `Table`s are automatically flattened. #[implementations(Table, Table)] content: I, /// Which boolean operation to perform on the paths. /// /// Union combines all paths while cutting out overlapping areas (even the interiors of a single path). /// Subtraction cuts overlapping areas out from the last (Subtract Front) or first (Subtract Back) path. /// Intersection cuts away all but the overlapping areas shared by every path. /// Difference cuts away the overlapping areas shared by every path, leaving only the non-overlapping areas. operation: BooleanOperation, ) -> Table { let content = content.into_graphic_table(); // 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); // 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); let result_vector = result_vector_table.element_mut(0).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()); // 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); } result_vector_table } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct WindingNumber { elems: SmallVec<[i16; 8]>, } impl linesweeper::topology::WindingNumber for WindingNumber { type Tag = (usize, usize); fn single((tag, out_of): (usize, usize), positive: bool) -> Self { let mut elems = SmallVec::with_capacity(out_of); elems.resize(out_of, 0); elems[tag] = if positive { 1 } else { -1 }; Self { elems } } } impl std::ops::AddAssign for WindingNumber { fn add_assign(&mut self, rhs: Self) { if rhs.elems.is_empty() { return; } if self.elems.is_empty() { self.elems = rhs.elems; } else { for (me, them) in self.elems.iter_mut().zip(&rhs.elems) { *me += *them; } } } } impl std::ops::Add for WindingNumber { type Output = WindingNumber; fn add(mut self, rhs: Self) -> Self::Output { self += rhs; self } } impl WindingNumber { fn is_inside(&self, op: BooleanOperation) -> bool { let is_in = |w: &i16| *w != 0; let is_out = |w: &i16| *w == 0; match op { BooleanOperation::Union => self.elems.iter().any(is_in), 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), } } } 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 } } else { if !vector.is_empty() { Some(vector.len() - 1) } else { None } }; let mut row = if let Some(index) = copy_from_index { let mut attributes = vector.clone_row_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); let copy_from = vector.element(index).unwrap(); let element = Vector { style: copy_from.style.clone(), ..Default::default() }; TableRow::from_parts(element, attributes) } else { TableRow::::default() }; 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, Err(e) => { log::error!("Boolean operation failed while building topology: {e}"); table.push(row); return table; } }; let contours = top.contours(|winding| winding.is_inside(boolean_operation)); for subpath in from_bez_paths(contours.contours().map(|c| &c.path)) { row.element_mut().append_subpath(subpath, false); } table.push(row); table } fn flatten_vector(graphic_table: &Table) -> Table { (0..graphic_table.len()) .flat_map(|index| { let graphic = graphic_table.element(index).unwrap(); match graphic.clone() { Graphic::Vector(vector) => { // Apply the parent graphic's transform to each element of the `Table` let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default(ATTR_TRANSFORM, index); vector .into_iter() .map(|mut sub_vector| { let current_transform: DAffine2 = sub_vector.attribute_cloned_or_default(ATTR_TRANSFORM); *sub_vector.attribute_mut_or_insert_default(ATTR_TRANSFORM) = parent_transform * current_transform; sub_vector }) .collect::>() } Graphic::RasterCPU(image) => { let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default(ATTR_TRANSFORM, index); let make_row = |transform, layer, alpha_blending| { let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); subpath.apply_transform(transform); let mut element = Vector::from_subpath(subpath); element.style.set_fill(Fill::Solid(Color::BLACK)); TableRow::new_from_element(element) .with_attribute(ATTR_ALPHA_BLENDING, alpha_blending) .with_attribute(ATTR_EDITOR_LAYER_PATH, layer) }; // Apply the parent graphic's transform to each raster element, preserving each item's layer // and alpha_blending so the boolean op downstream can route clicks (and inherit blending state) // back to the originating raster layer (0..image.len()) .map(|i| { let row_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM, i); let layer: Table = image.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, i); let alpha_blending: AlphaBlending = image.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, i); make_row(parent_transform * row_transform, layer, alpha_blending) }) .collect::>() } Graphic::RasterGPU(image) => { let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default(ATTR_TRANSFORM, index); let make_row = |transform, layer, alpha_blending| { let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); subpath.apply_transform(transform); let mut element = Vector::from_subpath(subpath); element.style.set_fill(Fill::Solid(Color::BLACK)); TableRow::new_from_element(element) .with_attribute(ATTR_ALPHA_BLENDING, alpha_blending) .with_attribute(ATTR_EDITOR_LAYER_PATH, layer) }; // Apply the parent graphic's transform to each raster element, preserving each item's layer // and alpha_blending so the boolean op downstream can route clicks (and inherit blending state) // back to the originating raster layer (0..image.len()) .map(|i| { let row_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM, i); let layer: Table = image.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, i); let alpha_blending: AlphaBlending = image.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, i); make_row(parent_transform * row_transform, layer, alpha_blending) }) .collect::>() } Graphic::Graphic(mut graphic) => { let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default(ATTR_TRANSFORM, index); // Apply the parent graphic's transform to each element of the inner `Table` for transform in graphic.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { *transform = parent_transform * *transform; } // Recursively flatten the inner `Table` into the output `Table` let flattened = flatten_vector(&graphic); let unioned = boolean_operation_on_vector_table(&flattened, BooleanOperation::Union); unioned.into_iter().collect::>() } Graphic::Color(color) => color .into_iter() .map(|row| { let (color, attributes) = row.into_parts(); let mut element = Vector::default(); element.style.set_fill(Fill::Solid(color)); element.style.set_stroke_transform(DAffine2::IDENTITY); TableRow::from_parts(element, attributes) }) .collect::>(), Graphic::Gradient(gradient) => gradient .into_iter() .map(|row| { let (stops, attributes) = row.into_parts(); let mut element = Vector::default(); element.style.set_fill(Fill::Gradient(graphic_types::vector_types::gradient::Gradient { stops, ..Default::default() })); element.style.set_stroke_transform(DAffine2::IDENTITY); TableRow::from_parts(element, attributes) }) .collect::>(), } }) .collect() } // This quantization should potentially be removed since it's not conceptually necessary, // but without it, the oak leaf in the Changing Seasons demo artwork is funky because // quantization is needed for the top and bottom points to line up vertically. fn quantize_segment(seg: PathSeg) -> PathSeg { const QUANTIZE_EPS: f64 = 1e-8; fn q(p: Point) -> Point { Point::new((p.x / QUANTIZE_EPS).round() * QUANTIZE_EPS, (p.y / QUANTIZE_EPS).round() * QUANTIZE_EPS) } match seg { PathSeg::Line(s) => PathSeg::Line(Line::new(q(s.p0), q(s.p1))), PathSeg::Quad(s) => PathSeg::Quad(QuadBez::new(q(s.p0), q(s.p1), q(s.p2))), PathSeg::Cubic(s) => PathSeg::Cubic(CubicBez::new(q(s.p0), q(s.p1), q(s.p2), q(s.p3))), } } fn to_bez_path(vector: &Vector, transform: DAffine2) -> BezPath { let mut path = BezPath::new(); for subpath in vector.stroke_bezier_paths() { push_subpath(&mut path, &subpath, transform); } path } fn push_subpath(path: &mut BezPath, subpath: &Subpath, transform: DAffine2) { let transform = Affine::new(transform.to_cols_array()); let mut first = true; for seg in subpath.iter_closed() { let quantized = quantize_segment(transform * seg); if first { first = false; path.move_to(quantized.start()); } path.push(quantized.as_path_el()); } path.close_path(); } fn from_bez_paths<'a>(paths: impl Iterator) -> Vec> { let mut all_subpaths = Vec::new(); for path in paths { let cubics: Vec = path.segments().map(|segment| segment.to_cubic()).collect(); let mut manipulators_list = Vec::new(); let mut current_start = None; for (index, cubic) in cubics.iter().enumerate() { let d = |p: Point| DVec2::new(p.x, p.y); let [start, handle1, handle2, end] = [d(cubic.p0), d(cubic.p1), d(cubic.p2), d(cubic.p3)]; if current_start.is_none() { // Use the correct in-handle (None) and out-handle for the start point manipulators_list.push(ManipulatorGroup::new(start, None, Some(handle1))); } else { // Update the out-handle of the previous point if let Some(last) = manipulators_list.last_mut() { last.out_handle = Some(handle1); } } // Add the end point with the correct in-handle and out-handle (None) manipulators_list.push(ManipulatorGroup::new(end, Some(handle2), None)); current_start = Some(end); // Check if this is the last segment if index == cubics.len() - 1 { all_subpaths.push(Subpath::new(manipulators_list, true)); manipulators_list = Vec::new(); // Reset manipulators for the next path } } } all_subpaths } pub fn boolean_intersect(a: &BezPath, b: &BezPath) -> Vec { match binary_op(a, b, FillRule::NonZero, BinaryOp::Intersection) { Ok(contours) => contours.contours().map(|c| c.path.clone()).collect(), Err(e) => { log::error!("Boolean Operation failed (a: {} segments, b: {} segments): {e}", a.segments().count(), b.segments().count()); Vec::new() } } }