From 3cc5ba970818ad90b5a190ba8b6787596bba5f74 Mon Sep 17 00:00:00 2001 From: Noah Baldwin Date: Tue, 11 Aug 2026 08:28:10 +1000 Subject: [PATCH 1/5] feat: #231 implement `path_union` action --- crates/oxvg/src/commands/action.rs | 6 +++++ crates/oxvg_actions/src/actions.rs | 5 ++++ .../src/actions/manipulate/path.rs | 27 ++++++++++++++++--- .../src/spec/manipulate/path_intersect.md | 2 +- .../src/spec/manipulate/path_union.md | 8 ++++++ crates/oxvg_actions/src/state.rs | 7 ++++- crates/oxvg_path/src/algorithm/bool_ops.rs | 4 ++- packages/napi/index.d.ts | 9 +++++++ packages/napi/src/lib.rs | 10 +++++++ packages/wasm/src/lib.rs | 11 ++++++++ 10 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 crates/oxvg_actions/src/spec/manipulate/path_union.md diff --git a/crates/oxvg/src/commands/action.rs b/crates/oxvg/src/commands/action.rs index bd3d7f13..842961b5 100644 --- a/crates/oxvg/src/commands/action.rs +++ b/crates/oxvg/src/commands/action.rs @@ -193,6 +193,10 @@ impl RunCommand for ActionList { println!("# Path Intersect\n"); println!(include_str!("../spec/manipulate/path_intersect.md")); } + if parts.is_empty() || parts.contains(PATH_UNION) { + println!("# Path Union\n"); + println!(include_str!("../spec/manipulate/path_union.md")); + } if parts.is_empty() || parts.contains(STYLE) { println!("# Style\n"); println!(include_str!("../spec/manipulate/style.md")); @@ -244,6 +248,7 @@ impl RunCommand for ActionList { const ATTR: &str = "-attr"; const CLASS: &str = "-class"; const PATH_INTERSECT: &str = "-path-intersect"; +const PATH_UNION: &str = "-path-union"; const STYLE: &str = "-style"; const MATRIX: &str = "-matrix"; const TRANSLATE: &str = "-translate"; @@ -297,6 +302,7 @@ fn parse(command_list: Vec) -> anyhow::Result oxvg_actions::Action::Class(get_part(&mut parts)?), PATH_INTERSECT => oxvg_actions::Action::PathIntersect, + PATH_UNION => oxvg_actions::Action::PathUnion, STYLE => oxvg_actions::Action::Style { property: get_part(&mut parts)?, value: get_part(&mut parts)?, diff --git a/crates/oxvg_actions/src/actions.rs b/crates/oxvg_actions/src/actions.rs index 593d1d16..d6339f5c 100644 --- a/crates/oxvg_actions/src/actions.rs +++ b/crates/oxvg_actions/src/actions.rs @@ -41,6 +41,8 @@ pub enum Action<'input> { Class(Atom<'input>), /// See [`Actor::path_intersect`] PathIntersect, + /// See [`Actor::path_union`] + PathUnion, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -85,6 +87,8 @@ pub enum ActionNapi { Class(String), /// See [`Actor::path_intersect`] PathIntersect, + /// See [`Actor::path_union`] + PathUnion, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -165,6 +169,7 @@ impl<'input, 'arena> Actor<'input, 'arena> { Action::Class(name) => return self.class(&name), Action::Style { property, value } => return self.style(&property, &value), Action::PathIntersect => return self.path_intersect(), + Action::PathUnion => return self.path_union(), Action::Matrix(a, b, c, d, e, f) => return self.matrix(a, b, c, d, e, f), Action::Translate(x, y) => return self.translate(x, y), Action::Scale(x, y) => return self.scale(x, y), diff --git a/crates/oxvg_actions/src/actions/manipulate/path.rs b/crates/oxvg_actions/src/actions/manipulate/path.rs index 8ade8b13..f7320c67 100644 --- a/crates/oxvg_actions/src/actions/manipulate/path.rs +++ b/crates/oxvg_actions/src/actions/manipulate/path.rs @@ -5,7 +5,7 @@ use oxvg_ast::{ style::{self, ComputedStyles}, }; use oxvg_collections::attribute::inheritable::Inheritable; -use oxvg_path::{geometry::Tolerance, paths::segment}; +use oxvg_path::{algorithm::bool_ops::OverlayRule, geometry::Tolerance, paths::segment}; use crate::{Action, Actor, Error}; @@ -20,7 +20,28 @@ impl<'input> Actor<'input, '_> { /// #[doc = include_str!("../../spec/manipulate/path_intersect.md")] pub fn path_intersect(&mut self) -> Result<(), Error<'input>> { - self.state.record(&Action::PathIntersect, &self.allocator); + self.boolean_op(&Action::PathIntersect, OverlayRule::Intersect) + } + + /// Unites selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + /// + /// # Spec + /// + #[doc = include_str!("../../spec/manipulate/path_union.md")] + pub fn path_union(&mut self) -> Result<(), Error<'input>> { + self.boolean_op(&Action::PathUnion, OverlayRule::Union) + } + + fn boolean_op( + &mut self, + action: &Action<'input>, + overlay_rule: OverlayRule, + ) -> Result<(), Error<'input>> { + self.state.record(action, &self.allocator); let Some(selections) = self.get_selections()? else { return Ok(()); }; @@ -65,7 +86,7 @@ impl<'input> Actor<'input, '_> { cumulative_path = Some(match cumulative_path { Some(inner) => oxvg_path::paths::bool::Path { - inner: inner.intersection(&segment_path), + inner: inner.boolean_op(&segment_path, overlay_rule), evenodd: true, }, None => segment_path, diff --git a/crates/oxvg_actions/src/spec/manipulate/path_intersect.md b/crates/oxvg_actions/src/spec/manipulate/path_intersect.md index 8742a39d..6f66f286 100644 --- a/crates/oxvg_actions/src/spec/manipulate/path_intersect.md +++ b/crates/oxvg_actions/src/spec/manipulate/path_intersect.md @@ -1,4 +1,4 @@ -For selected elements with path data, merges the paths by some operation. Each merge will be applied to the selections sequentially. Merges will be effective on the front-most element. +For selected elements with path data, merges the paths by intersecting, producing a path that fills the area that the paths overlapped. Each merge will be applied to the selections sequentially. Merges will be effective on the front-most element. A selected element will be filtered if the element is not a `path` element, has no `d` attribute, or has children. The merge will keep only the front-most element's attributes, omitting any attributes applied to other elements. diff --git a/crates/oxvg_actions/src/spec/manipulate/path_union.md b/crates/oxvg_actions/src/spec/manipulate/path_union.md new file mode 100644 index 00000000..2b03d65a --- /dev/null +++ b/crates/oxvg_actions/src/spec/manipulate/path_union.md @@ -0,0 +1,8 @@ +For selected elements with path data, merges the paths by uniting, producing a path that fills the area that both the paths covered. Each merge will be applied to the selections sequentially. Merges will be effective on the front-most element. + +A selected element will be filtered if the element is not a `path` element, has no `d` attribute, or has children. The merge will keep only the front-most element's attributes, omitting any attributes applied to other elements. + +```sh +# Effects: History, Document, Selection +-path-unite +``` diff --git a/crates/oxvg_actions/src/state.rs b/crates/oxvg_actions/src/state.rs index 6bd9a54c..70595da7 100644 --- a/crates/oxvg_actions/src/state.rs +++ b/crates/oxvg_actions/src/state.rs @@ -241,6 +241,7 @@ impl<'input> Action<'input> { const ATTR: &'static str = "Attr"; const CLASS: &'static str = "Class"; const PATH_INTERSECT: &'static str = "PathIntersect"; + const PATH_UNION: &'static str = "PathUnion"; const STYLE: &'static str = "Style"; const MATRIX: &'static str = "Matrix"; const TRANSLATE: &'static str = "Translate"; @@ -293,6 +294,7 @@ impl<'input> Action<'input> { Ok(Self::Class(class)) } Self::PATH_INTERSECT => Ok(Self::PathIntersect), + Self::PATH_UNION => Ok(Self::PathUnion), Self::STYLE => { let Some(property) = args.next().transpose()? else { return Err(Error::MissingStateAttribute(Self::ARG)); @@ -434,7 +436,7 @@ impl<'input> Action<'input> { Self::Class(arg) | Self::Select(arg) | Self::SelectMore(arg) => { Self::embed_arg(&element, allocator, arg.clone()); } - Self::PathIntersect | Self::Forget | Self::Deselect => {} + Self::PathIntersect | Self::PathUnion | Self::Forget | Self::Deselect => {} } } @@ -454,6 +456,7 @@ impl<'input> Action<'input> { Self::Attr { .. } => Self::ATTR, Self::Class(_) => Self::CLASS, Self::PathIntersect => Self::PATH_INTERSECT, + Self::PathUnion => Self::PATH_UNION, Self::Style { .. } => Self::STYLE, Self::Matrix(..) => Self::MATRIX, Self::Translate(..) => Self::TRANSLATE, @@ -479,6 +482,7 @@ impl<'input> Action<'input> { }, Self::Class(name) => ActionNapi::Class(name.to_string()), Self::PathIntersect => ActionNapi::PathIntersect, + Self::PathUnion => ActionNapi::PathUnion, Self::Style { property, value } => ActionNapi::Style { property: property.to_string(), value: value.to_string(), @@ -511,6 +515,7 @@ impl<'input> Action<'input> { }, ActionNapi::Class(name) => Action::Class(name.into()), ActionNapi::PathIntersect => Action::PathIntersect, + ActionNapi::PathUnion => Action::PathUnion, ActionNapi::Style { property, value } => Action::Style { property: property.into(), value: value.into(), diff --git a/crates/oxvg_path/src/algorithm/bool_ops.rs b/crates/oxvg_path/src/algorithm/bool_ops.rs index 7c86aef1..82a731e7 100644 --- a/crates/oxvg_path/src/algorithm/bool_ops.rs +++ b/crates/oxvg_path/src/algorithm/bool_ops.rs @@ -9,7 +9,7 @@ pub(crate) mod i_overlay_integration; use geo::{Winding, winding_order::WindingOrder}; use i_overlay::{ - core::{fill_rule::FillRule, overlay_rule::OverlayRule, solver::Solver}, + core::{fill_rule::FillRule, solver::Solver}, float::{ overlay::{FloatOverlay, OverlayOptions}, simplify::SimplifyShape, @@ -25,6 +25,8 @@ use crate::{ paths::{bool, segment}, }; +pub use i_overlay::core::overlay_rule::OverlayRule; + impl bool::Path { /// Wraps the path with the associated fill-rule. pub fn new(path: segment::Path, fill_rule: FillRule) -> Self { diff --git a/packages/napi/index.d.ts b/packages/napi/index.d.ts index 3306b83b..f23e70d2 100644 --- a/packages/napi/index.d.ts +++ b/packages/napi/index.d.ts @@ -7,6 +7,7 @@ export type ActionNapi = value: string } | { type: 'Class', field0: string } | { type: 'PathIntersect' } +| { type: 'PathUnion' } | { type: 'Style', /** The CSS name of the property */ property: string, /** The CSS value of the property */ value: string } @@ -734,6 +735,14 @@ export declare class Actor { * When root element is missing. */ pathIntersect(): void + /** + * Unites selected path definitions. + * + * # Errors + * + * When root element is missing. + */ + pathUnion(): void /** * Appends the style to the selected elements style list. * diff --git a/packages/napi/src/lib.rs b/packages/napi/src/lib.rs index 0402a896..06ebcc07 100644 --- a/packages/napi/src/lib.rs +++ b/packages/napi/src/lib.rs @@ -216,6 +216,16 @@ impl Actor { self.actor.path_intersect().map_err(generic_error) } + /// Unites selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[napi] + pub fn path_union(&mut self) -> napi::Result<()> { + self.actor.path_union().map_err(generic_error) + } + /// Appends the style to the selected elements style list. /// /// # Errors diff --git a/packages/wasm/src/lib.rs b/packages/wasm/src/lib.rs index 8b69c8d2..0be884fa 100644 --- a/packages/wasm/src/lib.rs +++ b/packages/wasm/src/lib.rs @@ -343,6 +343,17 @@ impl Actor { self.actor.path_intersect() } + /// Unites selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[wasm_bindgen] + #[wasm_bindgen(js_name = pathUnion)] + pub fn path_union(&mut self) -> Result<(), Error> { + self.actor.path_union() + } + /// Appends the style to the selected elements style list. /// /// # Errors From e6682ab3a06835b2ac380df18d6b1b0f7624b7f3 Mon Sep 17 00:00:00 2001 From: Noah Baldwin Date: Tue, 11 Aug 2026 18:46:54 +1000 Subject: [PATCH 2/5] feat: #231 implement `path_subtract` action --- crates/oxvg/src/commands/action.rs | 6 ++++++ crates/oxvg_actions/src/actions.rs | 5 +++++ crates/oxvg_actions/src/actions/manipulate/path.rs | 13 +++++++++++++ .../src/spec/manipulate/path_subtract.md | 8 ++++++++ crates/oxvg_actions/src/state.rs | 11 ++++++++++- packages/napi/index.d.ts | 9 +++++++++ packages/napi/src/lib.rs | 10 ++++++++++ packages/wasm/src/lib.rs | 11 +++++++++++ 8 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 crates/oxvg_actions/src/spec/manipulate/path_subtract.md diff --git a/crates/oxvg/src/commands/action.rs b/crates/oxvg/src/commands/action.rs index 842961b5..f26c3567 100644 --- a/crates/oxvg/src/commands/action.rs +++ b/crates/oxvg/src/commands/action.rs @@ -197,6 +197,10 @@ impl RunCommand for ActionList { println!("# Path Union\n"); println!(include_str!("../spec/manipulate/path_union.md")); } + if parts.is_empty() || parts.contains(PATH_SUBTRACT) { + println!("# Path Subtract\n"); + println!(include_str!("../spec/manipulate/path_subtract.md")); + } if parts.is_empty() || parts.contains(STYLE) { println!("# Style\n"); println!(include_str!("../spec/manipulate/style.md")); @@ -249,6 +253,7 @@ const ATTR: &str = "-attr"; const CLASS: &str = "-class"; const PATH_INTERSECT: &str = "-path-intersect"; const PATH_UNION: &str = "-path-union"; +const PATH_SUBTRACT: &str = "-path-subtract"; const STYLE: &str = "-style"; const MATRIX: &str = "-matrix"; const TRANSLATE: &str = "-translate"; @@ -303,6 +308,7 @@ fn parse(command_list: Vec) -> anyhow::Result oxvg_actions::Action::Class(get_part(&mut parts)?), PATH_INTERSECT => oxvg_actions::Action::PathIntersect, PATH_UNION => oxvg_actions::Action::PathUnion, + PATH_SUBTRACT => oxvg_actions::Action::PathSubtract, STYLE => oxvg_actions::Action::Style { property: get_part(&mut parts)?, value: get_part(&mut parts)?, diff --git a/crates/oxvg_actions/src/actions.rs b/crates/oxvg_actions/src/actions.rs index d6339f5c..ffb01134 100644 --- a/crates/oxvg_actions/src/actions.rs +++ b/crates/oxvg_actions/src/actions.rs @@ -43,6 +43,8 @@ pub enum Action<'input> { PathIntersect, /// See [`Actor::path_union`] PathUnion, + /// See [`Actor::path_subtract`] + PathSubtract, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -89,6 +91,8 @@ pub enum ActionNapi { PathIntersect, /// See [`Actor::path_union`] PathUnion, + /// See [`Actor::path_subtract`] + PathSubtract, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -170,6 +174,7 @@ impl<'input, 'arena> Actor<'input, 'arena> { Action::Style { property, value } => return self.style(&property, &value), Action::PathIntersect => return self.path_intersect(), Action::PathUnion => return self.path_union(), + Action::PathSubtract => return self.path_subtract(), Action::Matrix(a, b, c, d, e, f) => return self.matrix(a, b, c, d, e, f), Action::Translate(x, y) => return self.translate(x, y), Action::Scale(x, y) => return self.scale(x, y), diff --git a/crates/oxvg_actions/src/actions/manipulate/path.rs b/crates/oxvg_actions/src/actions/manipulate/path.rs index f7320c67..bfd2c16c 100644 --- a/crates/oxvg_actions/src/actions/manipulate/path.rs +++ b/crates/oxvg_actions/src/actions/manipulate/path.rs @@ -36,6 +36,19 @@ impl<'input> Actor<'input, '_> { self.boolean_op(&Action::PathUnion, OverlayRule::Union) } + /// Subtracts selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + /// + /// # Spec + /// + #[doc = include_str!("../../spec/manipulate/path_subtract.md")] + pub fn path_subtract(&mut self) -> Result<(), Error<'input>> { + self.boolean_op(&Action::PathSubtract, OverlayRule::Difference) + } + fn boolean_op( &mut self, action: &Action<'input>, diff --git a/crates/oxvg_actions/src/spec/manipulate/path_subtract.md b/crates/oxvg_actions/src/spec/manipulate/path_subtract.md new file mode 100644 index 00000000..6b616fbe --- /dev/null +++ b/crates/oxvg_actions/src/spec/manipulate/path_subtract.md @@ -0,0 +1,8 @@ +For selected elements with path data, merges the paths by subtracting, producing a path that fills the area that both the paths covered. Each merge will be applied to the selections sequentially. Merges will be effective on the front-most element. + +A selected element will be filtered if the element is not a `path` element, has no `d` attribute, or has children. The merge will keep only the front-most element's attributes, omitting any attributes applied to other elements. + +```sh +# Effects: History, Document, Selection +-path-unite +``` diff --git a/crates/oxvg_actions/src/state.rs b/crates/oxvg_actions/src/state.rs index 70595da7..ab1c96e0 100644 --- a/crates/oxvg_actions/src/state.rs +++ b/crates/oxvg_actions/src/state.rs @@ -242,6 +242,7 @@ impl<'input> Action<'input> { const CLASS: &'static str = "Class"; const PATH_INTERSECT: &'static str = "PathIntersect"; const PATH_UNION: &'static str = "PathUnion"; + const PATH_SUBTRACT: &'static str = "PathSubtract"; const STYLE: &'static str = "Style"; const MATRIX: &'static str = "Matrix"; const TRANSLATE: &'static str = "Translate"; @@ -295,6 +296,7 @@ impl<'input> Action<'input> { } Self::PATH_INTERSECT => Ok(Self::PathIntersect), Self::PATH_UNION => Ok(Self::PathUnion), + Self::PATH_SUBTRACT => Ok(Self::PathSubtract), Self::STYLE => { let Some(property) = args.next().transpose()? else { return Err(Error::MissingStateAttribute(Self::ARG)); @@ -436,7 +438,11 @@ impl<'input> Action<'input> { Self::Class(arg) | Self::Select(arg) | Self::SelectMore(arg) => { Self::embed_arg(&element, allocator, arg.clone()); } - Self::PathIntersect | Self::PathUnion | Self::Forget | Self::Deselect => {} + Self::PathIntersect + | Self::PathUnion + | Self::PathSubtract + | Self::Forget + | Self::Deselect => {} } } @@ -457,6 +463,7 @@ impl<'input> Action<'input> { Self::Class(_) => Self::CLASS, Self::PathIntersect => Self::PATH_INTERSECT, Self::PathUnion => Self::PATH_UNION, + Self::PathSubtract => Self::PATH_SUBTRACT, Self::Style { .. } => Self::STYLE, Self::Matrix(..) => Self::MATRIX, Self::Translate(..) => Self::TRANSLATE, @@ -483,6 +490,7 @@ impl<'input> Action<'input> { Self::Class(name) => ActionNapi::Class(name.to_string()), Self::PathIntersect => ActionNapi::PathIntersect, Self::PathUnion => ActionNapi::PathUnion, + Self::PathSubtract => ActionNapi::PathSubtract, Self::Style { property, value } => ActionNapi::Style { property: property.to_string(), value: value.to_string(), @@ -516,6 +524,7 @@ impl<'input> Action<'input> { ActionNapi::Class(name) => Action::Class(name.into()), ActionNapi::PathIntersect => Action::PathIntersect, ActionNapi::PathUnion => Action::PathUnion, + ActionNapi::PathSubtract => Action::PathSubtract, ActionNapi::Style { property, value } => Action::Style { property: property.into(), value: value.into(), diff --git a/packages/napi/index.d.ts b/packages/napi/index.d.ts index f23e70d2..c91f5494 100644 --- a/packages/napi/index.d.ts +++ b/packages/napi/index.d.ts @@ -8,6 +8,7 @@ value: string } | { type: 'Class', field0: string } | { type: 'PathIntersect' } | { type: 'PathUnion' } +| { type: 'PathSubtract' } | { type: 'Style', /** The CSS name of the property */ property: string, /** The CSS value of the property */ value: string } @@ -743,6 +744,14 @@ export declare class Actor { * When root element is missing. */ pathUnion(): void + /** + * Subtracts selected path definitions. + * + * # Errors + * + * When root element is missing. + */ + pathSubtract(): void /** * Appends the style to the selected elements style list. * diff --git a/packages/napi/src/lib.rs b/packages/napi/src/lib.rs index 06ebcc07..a042a85c 100644 --- a/packages/napi/src/lib.rs +++ b/packages/napi/src/lib.rs @@ -226,6 +226,16 @@ impl Actor { self.actor.path_union().map_err(generic_error) } + /// Subtracts selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[napi] + pub fn path_subtract(&mut self) -> napi::Result<()> { + self.actor.path_subtract().map_err(generic_error) + } + /// Appends the style to the selected elements style list. /// /// # Errors diff --git a/packages/wasm/src/lib.rs b/packages/wasm/src/lib.rs index 0be884fa..2bf25550 100644 --- a/packages/wasm/src/lib.rs +++ b/packages/wasm/src/lib.rs @@ -354,6 +354,17 @@ impl Actor { self.actor.path_union() } + /// Subtracts selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[wasm_bindgen] + #[wasm_bindgen(js_name = pathSubtract)] + pub fn path_subtract(&mut self) -> Result<(), Error> { + self.actor.path_subtract() + } + /// Appends the style to the selected elements style list. /// /// # Errors From 079ffd0e26dbdad4c9b854cbf9634657bfb27a88 Mon Sep 17 00:00:00 2001 From: Noah Baldwin Date: Tue, 11 Aug 2026 19:02:33 +1000 Subject: [PATCH 3/5] feat: #231 implement `path_xor` action --- crates/oxvg/src/commands/action.rs | 6 ++++++ crates/oxvg_actions/src/actions.rs | 5 +++++ crates/oxvg_actions/src/actions/manipulate/path.rs | 13 +++++++++++++ crates/oxvg_actions/src/spec/manipulate/path_xor.md | 8 ++++++++ crates/oxvg_actions/src/state.rs | 6 ++++++ packages/napi/index.d.ts | 9 +++++++++ packages/napi/src/lib.rs | 10 ++++++++++ packages/wasm/src/lib.rs | 11 +++++++++++ 8 files changed, 68 insertions(+) create mode 100644 crates/oxvg_actions/src/spec/manipulate/path_xor.md diff --git a/crates/oxvg/src/commands/action.rs b/crates/oxvg/src/commands/action.rs index f26c3567..3420d887 100644 --- a/crates/oxvg/src/commands/action.rs +++ b/crates/oxvg/src/commands/action.rs @@ -201,6 +201,10 @@ impl RunCommand for ActionList { println!("# Path Subtract\n"); println!(include_str!("../spec/manipulate/path_subtract.md")); } + if parts.is_empty() || parts.contains(PATH_XOR) { + println!("# Path Xor\n"); + println!(include_str!("../spec/manipulate/path_xor.md")); + } if parts.is_empty() || parts.contains(STYLE) { println!("# Style\n"); println!(include_str!("../spec/manipulate/style.md")); @@ -254,6 +258,7 @@ const CLASS: &str = "-class"; const PATH_INTERSECT: &str = "-path-intersect"; const PATH_UNION: &str = "-path-union"; const PATH_SUBTRACT: &str = "-path-subtract"; +const PATH_XOR: &str = "-path-xor"; const STYLE: &str = "-style"; const MATRIX: &str = "-matrix"; const TRANSLATE: &str = "-translate"; @@ -309,6 +314,7 @@ fn parse(command_list: Vec) -> anyhow::Result oxvg_actions::Action::PathIntersect, PATH_UNION => oxvg_actions::Action::PathUnion, PATH_SUBTRACT => oxvg_actions::Action::PathSubtract, + PATH_XOR => oxvg_actions::Action::PathXor, STYLE => oxvg_actions::Action::Style { property: get_part(&mut parts)?, value: get_part(&mut parts)?, diff --git a/crates/oxvg_actions/src/actions.rs b/crates/oxvg_actions/src/actions.rs index ffb01134..d7939154 100644 --- a/crates/oxvg_actions/src/actions.rs +++ b/crates/oxvg_actions/src/actions.rs @@ -45,6 +45,8 @@ pub enum Action<'input> { PathUnion, /// See [`Actor::path_subtract`] PathSubtract, + /// See [`Actor::path_xor`] + PathXor, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -93,6 +95,8 @@ pub enum ActionNapi { PathUnion, /// See [`Actor::path_subtract`] PathSubtract, + /// See [`Actor::path_xor`] + PathXor, /// See [`Actor::style`] Style { /// The CSS name of the property @@ -175,6 +179,7 @@ impl<'input, 'arena> Actor<'input, 'arena> { Action::PathIntersect => return self.path_intersect(), Action::PathUnion => return self.path_union(), Action::PathSubtract => return self.path_subtract(), + Action::PathXor => return self.path_xor(), Action::Matrix(a, b, c, d, e, f) => return self.matrix(a, b, c, d, e, f), Action::Translate(x, y) => return self.translate(x, y), Action::Scale(x, y) => return self.scale(x, y), diff --git a/crates/oxvg_actions/src/actions/manipulate/path.rs b/crates/oxvg_actions/src/actions/manipulate/path.rs index bfd2c16c..efa48c98 100644 --- a/crates/oxvg_actions/src/actions/manipulate/path.rs +++ b/crates/oxvg_actions/src/actions/manipulate/path.rs @@ -49,6 +49,19 @@ impl<'input> Actor<'input, '_> { self.boolean_op(&Action::PathSubtract, OverlayRule::Difference) } + /// XORs selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + /// + /// # Spec + /// + #[doc = include_str!("../../spec/manipulate/path_xor.md")] + pub fn path_xor(&mut self) -> Result<(), Error<'input>> { + self.boolean_op(&Action::PathXor, OverlayRule::Xor) + } + fn boolean_op( &mut self, action: &Action<'input>, diff --git a/crates/oxvg_actions/src/spec/manipulate/path_xor.md b/crates/oxvg_actions/src/spec/manipulate/path_xor.md new file mode 100644 index 00000000..a53500d6 --- /dev/null +++ b/crates/oxvg_actions/src/spec/manipulate/path_xor.md @@ -0,0 +1,8 @@ +For selected elements with path data, merges the paths by XOR-ing, producing a path that fills the area that neither the paths covered. Each merge will be applied to the selections sequentially. Merges will be effective on the front-most element. + +A selected element will be filtered if the element is not a `path` element, has no `d` attribute, or has children. The merge will keep only the front-most element's attributes, omitting any attributes applied to other elements. + +```sh +# Effects: History, Document, Selection +-path-unite +``` diff --git a/crates/oxvg_actions/src/state.rs b/crates/oxvg_actions/src/state.rs index ab1c96e0..a2dfb31b 100644 --- a/crates/oxvg_actions/src/state.rs +++ b/crates/oxvg_actions/src/state.rs @@ -243,6 +243,7 @@ impl<'input> Action<'input> { const PATH_INTERSECT: &'static str = "PathIntersect"; const PATH_UNION: &'static str = "PathUnion"; const PATH_SUBTRACT: &'static str = "PathSubtract"; + const PATH_XOR: &'static str = "PathXor"; const STYLE: &'static str = "Style"; const MATRIX: &'static str = "Matrix"; const TRANSLATE: &'static str = "Translate"; @@ -297,6 +298,7 @@ impl<'input> Action<'input> { Self::PATH_INTERSECT => Ok(Self::PathIntersect), Self::PATH_UNION => Ok(Self::PathUnion), Self::PATH_SUBTRACT => Ok(Self::PathSubtract), + Self::PATH_XOR => Ok(Self::PathXor), Self::STYLE => { let Some(property) = args.next().transpose()? else { return Err(Error::MissingStateAttribute(Self::ARG)); @@ -441,6 +443,7 @@ impl<'input> Action<'input> { Self::PathIntersect | Self::PathUnion | Self::PathSubtract + | Self::PathXor | Self::Forget | Self::Deselect => {} } @@ -464,6 +467,7 @@ impl<'input> Action<'input> { Self::PathIntersect => Self::PATH_INTERSECT, Self::PathUnion => Self::PATH_UNION, Self::PathSubtract => Self::PATH_SUBTRACT, + Self::PathXor => Self::PATH_XOR, Self::Style { .. } => Self::STYLE, Self::Matrix(..) => Self::MATRIX, Self::Translate(..) => Self::TRANSLATE, @@ -491,6 +495,7 @@ impl<'input> Action<'input> { Self::PathIntersect => ActionNapi::PathIntersect, Self::PathUnion => ActionNapi::PathUnion, Self::PathSubtract => ActionNapi::PathSubtract, + Self::PathXor => ActionNapi::PathXor, Self::Style { property, value } => ActionNapi::Style { property: property.to_string(), value: value.to_string(), @@ -525,6 +530,7 @@ impl<'input> Action<'input> { ActionNapi::PathIntersect => Action::PathIntersect, ActionNapi::PathUnion => Action::PathUnion, ActionNapi::PathSubtract => Action::PathSubtract, + ActionNapi::PathXor => Action::PathXor, ActionNapi::Style { property, value } => Action::Style { property: property.into(), value: value.into(), diff --git a/packages/napi/index.d.ts b/packages/napi/index.d.ts index c91f5494..107b1db8 100644 --- a/packages/napi/index.d.ts +++ b/packages/napi/index.d.ts @@ -9,6 +9,7 @@ value: string } | { type: 'PathIntersect' } | { type: 'PathUnion' } | { type: 'PathSubtract' } +| { type: 'PathXor' } | { type: 'Style', /** The CSS name of the property */ property: string, /** The CSS value of the property */ value: string } @@ -752,6 +753,14 @@ export declare class Actor { * When root element is missing. */ pathSubtract(): void + /** + * XORs selected path definitions. + * + * # Errors + * + * When root element is missing. + */ + pathXor(): void /** * Appends the style to the selected elements style list. * diff --git a/packages/napi/src/lib.rs b/packages/napi/src/lib.rs index a042a85c..d13a473a 100644 --- a/packages/napi/src/lib.rs +++ b/packages/napi/src/lib.rs @@ -236,6 +236,16 @@ impl Actor { self.actor.path_subtract().map_err(generic_error) } + /// XORs selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[napi] + pub fn path_xor(&mut self) -> napi::Result<()> { + self.actor.path_xor().map_err(generic_error) + } + /// Appends the style to the selected elements style list. /// /// # Errors diff --git a/packages/wasm/src/lib.rs b/packages/wasm/src/lib.rs index 2bf25550..c7aa6b94 100644 --- a/packages/wasm/src/lib.rs +++ b/packages/wasm/src/lib.rs @@ -365,6 +365,17 @@ impl Actor { self.actor.path_subtract() } + /// XORs selected path definitions. + /// + /// # Errors + /// + /// When root element is missing. + #[wasm_bindgen] + #[wasm_bindgen(js_name = pathXor)] + pub fn path_xor(&mut self) -> Result<(), Error> { + self.actor.path_xor() + } + /// Appends the style to the selected elements style list. /// /// # Errors From 2aa536fd32b19a3bd8b17c018ca6ec23aa723c9c Mon Sep 17 00:00:00 2001 From: Noah Baldwin Date: Wed, 12 Aug 2026 10:53:31 +1000 Subject: [PATCH 4/5] fix: record state after bool_op action --- .../src/actions/manipulate/path.rs | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/crates/oxvg_actions/src/actions/manipulate/path.rs b/crates/oxvg_actions/src/actions/manipulate/path.rs index efa48c98..fd8784dd 100644 --- a/crates/oxvg_actions/src/actions/manipulate/path.rs +++ b/crates/oxvg_actions/src/actions/manipulate/path.rs @@ -1,13 +1,13 @@ use lightningcss::values::shape::FillRule; use oxvg_ast::{ element::Element, - get_attribute, get_computed_style, is_element, set_attribute, + get_attribute, get_computed_style, has_attribute, is_element, set_attribute, style::{self, ComputedStyles}, }; use oxvg_collections::attribute::inheritable::Inheritable; use oxvg_path::{algorithm::bool_ops::OverlayRule, geometry::Tolerance, paths::segment}; -use crate::{Action, Actor, Error}; +use crate::{Action, Actor, Error, state::StateElement, utils::create_oxvg_attr}; impl<'input> Actor<'input, '_> { /// Intersects selected path definitions. @@ -77,27 +77,19 @@ impl<'input> Actor<'input, '_> { let mut cumulative_path: Option = None; let mut previous_element: Option = None; let styles: Vec<_> = style::root(&root).collect(); - for selection in selections { - #[allow(clippy::cast_sign_loss)] - let Some(node) = self.allocator.get(selection as usize) else { - continue; - }; - let Some(element) = node.element() else { - continue; - }; - if !is_element!(element, Path) { - continue; - } - if element.has_child_nodes() { - continue; - } - let Some(path) = get_attribute!(element, D) else { - continue; - }; - let segment_path = segment::Path::from_svg(&path, &Tolerance::default()); - drop(path); + #[allow(clippy::cast_sign_loss)] + let paths: Vec<_> = selections + .iter() + .filter_map(|s| self.allocator.get(*s as usize)) + .filter_map(oxvg_ast::node::Node::element) + .filter(|e| !e.has_child_nodes() && is_element!(e, Path) && has_attribute!(e, D)) + .collect(); + for path in paths { + let d = get_attribute!(path, D).unwrap(); + let segment_path = segment::Path::from_svg(&d, &Tolerance::default()); + drop(d); let computed_styles = ComputedStyles::default() - .with_all(&element, &styles) + .with_all(&path, &styles) .map_err(|err| Error::ComputedStylesError(err.to_string()))?; let evenodd = get_computed_style!(computed_styles, FillRule) .map(|fill_rule| match fill_rule { @@ -118,10 +110,10 @@ impl<'input> Actor<'input, '_> { None => segment_path, }); - if let Some(element) = previous_element { - element.remove(); + if let Some(previous_element) = previous_element { + previous_element.remove(); } - previous_element = Some(element); + previous_element = Some(path); } if let (Some(final_element), Some(cumulative_path)) = (previous_element, cumulative_path) { @@ -138,6 +130,18 @@ impl<'input> Actor<'input, '_> { ); } + if let Some(selection) = selections.last() { + self.state + .get_selections(&self.allocator) + .set_attribute(create_oxvg_attr( + StateElement::SELECTION_IDS, + #[allow(clippy::cast_sign_loss)] + ((*selection as usize) - selections.len()) + .to_string() + .into(), + )); + } + self.state.embed(self.root)?; Ok(()) } } From a5d03d4be31d0e5a18fcd12a132c2792d1b41e36 Mon Sep 17 00:00:00 2001 From: Noah Baldwin Date: Wed, 12 Aug 2026 10:54:16 +1000 Subject: [PATCH 5/5] fix: fix bad result on evenodd union, xor --- crates/oxvg_path/src/algorithm/bool_ops.rs | 61 +++++++++++- .../bool_ops/i_overlay_integration.rs | 95 ++++++++++++------- crates/oxvg_path/src/geometry/arc.rs | 20 ++++ crates/oxvg_path/src/paths/segment.rs | 20 ++++ 4 files changed, 158 insertions(+), 38 deletions(-) diff --git a/crates/oxvg_path/src/algorithm/bool_ops.rs b/crates/oxvg_path/src/algorithm/bool_ops.rs index 82a731e7..d60b34e8 100644 --- a/crates/oxvg_path/src/algorithm/bool_ops.rs +++ b/crates/oxvg_path/src/algorithm/bool_ops.rs @@ -171,7 +171,7 @@ mod test { }; #[test] - fn unite_evenodd() { + fn intersect_evenodd() { let background = Path::parse_string("M 2 2h8v8H2z").unwrap(); let foreground = Path::parse_string( "M7 7a4 4 0 1 0 0.001 -0.001zM7.35 7.35 a3.5 3.5 0 1 0 0.001 -0.001z", @@ -191,6 +191,63 @@ mod test { ); } + #[test] + fn unite_evenodd() { + let background = Path::parse_string("M 2 2h8v8H2z").unwrap(); + let foreground = Path::parse_string( + "M7 7a4 4 0 1 0 0.001 -0.001zM7.35 7.35 a3.5 3.5 0 1 0 0.001 -0.001z", + ) + .unwrap(); + + let tolerance = &Tolerance::default(); + let background = bool::Path::nonzero(segment::Path::from_svg(&background, tolerance)); + let foreground = bool::Path::evenodd(segment::Path::from_svg(&foreground, tolerance)); + + let output = background.union(&foreground).to_svg(tolerance, false); + assert_eq!( + &output.to_string(), + "M2 10V2h8v3.832A4 4 0 1 1 5.833 10ZM6.33 10A3.5 3.5 0 1 0 10 6.329V10Z" + ); + } + + #[test] + fn subtract_evenodd() { + let background = Path::parse_string("M 2 2h8v8H2z").unwrap(); + let foreground = Path::parse_string( + "M7 7a4 4 0 1 0 0.001 -0.001zM7.35 7.35 a3.5 3.5 0 1 0 0.001 -0.001z", + ) + .unwrap(); + + let tolerance = &Tolerance::default(); + let background = bool::Path::nonzero(segment::Path::from_svg(&background, tolerance)); + let foreground = bool::Path::evenodd(segment::Path::from_svg(&foreground, tolerance)); + + let output = background.difference(&foreground).to_svg(tolerance, false); + assert_eq!( + &output.to_string(), + "M2 10V2h8v3.832a4 4 0 0 0-4.167 4.166ZM6.33 9.997a3.5 3.5 0 0 1 3.669-3.668L10 10H6.33Z" + ); + } + + #[test] + fn xor_evenodd() { + let background = Path::parse_string("M 2 2h8v8H2z").unwrap(); + let foreground = Path::parse_string( + "M7 7a4 4 0 1 0 0.001 -0.001zM7.35 7.35 a3.5 3.5 0 1 0 0.001 -0.001z", + ) + .unwrap(); + + let tolerance = &Tolerance::default(); + let background = bool::Path::nonzero(segment::Path::from_svg(&background, tolerance)); + let foreground = bool::Path::evenodd(segment::Path::from_svg(&foreground, tolerance)); + + let output = background.xor(&foreground).to_svg(tolerance, false); + assert_eq!( + &output.to_string(), + "M2 10V2h8v3.832a4 4 0 0 0-4.167 4.166ZM5.834 10.025A4 4 0 0 1 5.833 10h.497A3.5 3.5 0 1 0 10 6.329v-.497a4 4 0 1 1-4.152 4.388ZM6.33 9.997a3.5 3.5 0 0 1 3.669-3.668L10 10H6.33Z" + ); + } + #[test] fn unite_squares_aligned_winding() { let background = Path::parse_string("M0,0 L0,10 L10,10 L10,0 L0,0").unwrap(); @@ -310,7 +367,7 @@ mod test { let output = background.union(&foreground).to_svg(tolerance, false); assert_eq!( output.to_string(), - "M5 5.061A5 5 0 0 1 5 5h2.848a5 5 0 0 0 3.559 3.049c6.225 1.593.113 1.914-1.173 1.947q-.085.003-.175.004C10.02 10 10 10 10 10a5 5 0 0 1-4-2l-1-.5h.67a5 5 0 0 1-.668-2.377Z" + "M5 5.061A5 5 0 0 1 5 5h2.848a5 5 0 1 1 8.331 1.556c1.453 3.086-4.659 3.407-5.945 3.44q-.085.003-.175.004C10.02 10 10 10 10 10a5 5 0 0 1-4-2l-1-.5h.67a5 5 0 0 1-.668-2.377Z" ); } diff --git a/crates/oxvg_path/src/algorithm/bool_ops/i_overlay_integration.rs b/crates/oxvg_path/src/algorithm/bool_ops/i_overlay_integration.rs index ca8f9dc0..697e4a9e 100644 --- a/crates/oxvg_path/src/algorithm/bool_ops/i_overlay_integration.rs +++ b/crates/oxvg_path/src/algorithm/bool_ops/i_overlay_integration.rs @@ -17,7 +17,7 @@ pub struct Source<'a> { contour: Vec>, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct BoolOpsCoord(Coord); struct RTreeEntry<'a> { @@ -138,54 +138,76 @@ pub mod convert { }) .collect(), ); + segment::Path( shapes .into_iter() .flat_map(|shape| { shape .into_iter() - .map(|ring| segment_from_ring(ring, &r_tree)) + .map(|ring| segment_from_ring(&ring, &r_tree)) }) .collect(), ) } - fn clamp_arc_for_edge(arc: &Arc, a: Point, b: Point) -> Arc { + fn clamp_arc_for_edge(arc: &Arc, a: Point, b: Point, near: Option) -> Arc { + const LOOP_EPSILON: f64 = 1e-2; let t1 = arc.t_at(a, ToleranceSquared(1e-3)).unwrap_or(0.0); let t2 = arc.t_at(b, ToleranceSquared(1e-3)).unwrap_or(1.0); - let arc_a = if t1 <= t2 { + let is_loop = (arc.sweep_angle().abs() - std::f64::consts::TAU).abs() < LOOP_EPSILON; + if !is_loop { + return if t1 <= t2 { + arc.clamp_t(t1, t2) + } else { + arc.reverse().clamp_t(1.0 - t1, 1.0 - t2) + }; + } + + let candidate_base = if t1 <= t2 { arc.clamp_t(t1, t2) } else { arc.reverse().clamp_t(1.0 - t1, 1.0 - t2) }; - - let arc_b = if t1 <= t2 { - let mut b_arc = arc.clone(); - b_arc.set_start_angle(arc.start_angle() + t1 * arc.sweep_angle()); - b_arc.set_sweep_angle((t2 - t1 - 1.0) * arc.sweep_angle()); - b_arc - } else { - let mut b_arc = arc.clone(); - b_arc.set_start_angle(arc.start_angle() + t1 * arc.sweep_angle()); - b_arc.set_sweep_angle((t2 - t1 + 1.0) * arc.sweep_angle()); - b_arc + let candidate_wrapped = { + let mut w_arc = arc.clone(); + w_arc.set_start_angle(arc.start_angle() + t1 * arc.sweep_angle()); + w_arc.set_sweep_angle(if t1 <= t2 { + (t2 - t1 - 1.0) * arc.sweep_angle() + } else { + (t2 - t1 + 1.0) * arc.sweep_angle() + }); + w_arc }; - let mid_m = a.midpoint(b); - if arc_a.mid_point().distance_squared(mid_m) <= arc_b.mid_point().distance_squared(mid_m) { - arc_a + if let Some(near) = near { + let t_near = arc.t_at(near, ToleranceSquared(1e-3)).unwrap_or(t1); + let (lo, hi) = if t1 <= t2 { (t1, t2) } else { (t2, t1) }; + let is_within_base = t_near > lo && t_near < hi; + if is_within_base { + candidate_base + } else { + candidate_wrapped + } } else { - arc_b + let mid_m = a.midpoint(b); + let is_within_base = candidate_base.mid_point().distance_squared(mid_m) + <= candidate_wrapped.mid_point().distance_squared(mid_m); + if is_within_base { + candidate_base + } else { + candidate_wrapped + } } } - fn segment_from_ring(ring: Vec, r_tree: &RTree) -> segment::Segment { + fn segment_from_ring(ring: &[BoolOpsCoord], r_tree: &RTree) -> segment::Segment { enum Action<'a> { Original { seg: &'a Source<'a>, - start: Coord, - end: Coord, + start: usize, + end: usize, }, Line { end: Coord, @@ -206,10 +228,8 @@ pub mod convert { let mut actions = vec![]; - for (a, b) in ring.into_iter().tuple_windows() { - if let Some(seg) = find_matching_segment(*a, *b, r_tree) { - let mut merged = false; - + for ((a_i, a), (b_i, b)) in ring.iter().enumerate().tuple_windows() { + if let Some(seg) = find_matching_segment(**a, **b, r_tree) { if let Some(Action::Original { seg: last_seg, end: p_end, @@ -217,18 +237,16 @@ pub mod convert { }) = actions.last_mut() && std::ptr::eq(seg.data, last_seg.data) { - *p_end = *b; - merged = true; - } - if !merged { + *p_end = b_i; + } else { actions.push(Action::Original { seg, - start: *a, - end: *b, + start: a_i, + end: b_i, }); } } else { - actions.push(Action::Line { end: *b }); + actions.push(Action::Line { end: **b }); } } @@ -239,8 +257,8 @@ pub mod convert { start: p_start, end: p_end, } => { - let t1 = Point(p_start); - let t2 = Point(p_end); + let t1 = Point(*ring[p_start]); + let t2 = Point(*ring[p_end]); match seg.data { segment::Data::LineTo(_) => { segment.data.push(segment::Data::LineTo(t2)); @@ -256,7 +274,12 @@ pub mod convert { })); } segment::Data::ArcTo(arc) => { - let clamped_arc = clamp_arc_for_edge(arc, t1, t2); + let near = if p_end > p_start + 1 { + Some(Point(*ring[p_start.midpoint(p_end)])) + } else { + None + }; + let clamped_arc = clamp_arc_for_edge(arc, t1, t2, near); segment.data.push(segment::Data::ArcTo(clamped_arc)); } } diff --git a/crates/oxvg_path/src/geometry/arc.rs b/crates/oxvg_path/src/geometry/arc.rs index 262108c1..def03681 100644 --- a/crates/oxvg_path/src/geometry/arc.rs +++ b/crates/oxvg_path/src/geometry/arc.rs @@ -458,6 +458,26 @@ impl Arc { self.ellipses().is_circle(tolerance) } + /// Returns whether the arc winds clockwise or not. + /// + /// # Example + /// + /// ``` + /// use oxvg_path::geometry::{Arc, Point, Tolerance}; + /// + /// let arc = Arc::new( + /// Point::ZERO, + /// Point::UNIT, + /// 0.0, + /// std::f64::consts::FRAC_PI_2, + /// 0.0, + /// ); + /// assert!(arc.is_clockwise()); + /// ``` + pub fn is_clockwise(&self) -> bool { + self.sweep_angle() >= 0.0 + } + /// Returns the tangent at the given percentage `t` along the arc. /// /// # Example diff --git a/crates/oxvg_path/src/paths/segment.rs b/crates/oxvg_path/src/paths/segment.rs index 536b5fa6..4b69412a 100644 --- a/crates/oxvg_path/src/paths/segment.rs +++ b/crates/oxvg_path/src/paths/segment.rs @@ -22,6 +22,17 @@ pub enum Data { ArcTo(Arc), } +/// One of the reduced representation of an SVG path command +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum DataID { + /// A line command + LineTo, + /// A bezier command + CurveTo, + /// An arc command + ArcTo, +} + #[derive(Debug, PartialEq, Clone)] /// A segment represents some contiguous shape made from a set of commands pub struct Segment { @@ -64,6 +75,15 @@ impl Data { ), } } + + /// Returns the variant of the data item. + pub fn id(&self) -> DataID { + match self { + Data::LineTo(_) => DataID::LineTo, + Data::CurveTo(_) => DataID::CurveTo, + Data::ArcTo(_) => DataID::ArcTo, + } + } } impl Segment {