diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs index d3190f0bc..adb9ec0e3 100644 --- a/crates/op-editor-core/src/host_support.rs +++ b/crates/op-editor-core/src/host_support.rs @@ -13,8 +13,17 @@ use crate::fills::{set_primary_fill_hex, set_primary_stroke_hex}; use crate::node_id::NodeId; use crate::state::EditorState; use crate::tool::Tool; -use crate::walkers::find_node_mut; +use crate::walkers::{find_node, find_node_mut}; use jian_ops_schema::node::{IconFontNode, PathNode, PenNode, PenNodeBase, PenPathAnchor}; +use jian_ops_schema::sizing::SizingBehavior; +use jian_ops_schema::style::{PenEffect, PenFill, PenStroke}; + +#[derive(Default)] +struct BooleanPathStyle { + fill: Option>, + stroke: Option, + effects: Option>, +} impl EditorState { /// Build an editor state seeded with the demo sample document — @@ -364,16 +373,32 @@ impl EditorState { *next_id = (*next_id).max(safe); let id = NodeId::new(format!("n{}", *next_id)); *next_id = (*next_id).checked_add(1)?; - // Remove every source path from the active page. + let style = source_ids + .iter() + .find_map(|src| find_node(self.active_children(), src).map(boolean_path_style)) + .unwrap_or_default(); + let (min_x, min_y, max_x, max_y) = points.iter().fold( + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ), + |(min_x, min_y, max_x, max_y), (x, y)| { + (min_x.min(*x), min_y.min(*y), max_x.max(*x), max_y.max(*y)) + }, + ); + let width = (max_x - min_x).max(0.0); + let height = (max_y - min_y).max(0.0); + // Remove every source shape from the active page. for src in source_ids { crate::walkers::remove_from_children(self.active_children_mut(), src); } - let first = points[0]; let anchors: Vec = points .iter() .map(|(x, y)| PenPathAnchor { - x: *x, - y: *y, + x: *x - min_x, + y: *y - min_y, handle_in: None, handle_out: None, point_type: None, @@ -383,19 +408,19 @@ impl EditorState { base: PenNodeBase { id: id.as_str().to_string(), name: Some("Boolean Result".to_string()), - x: Some(first.0), - y: Some(first.1), + x: Some(min_x), + y: Some(min_y), ..Default::default() }, icon_id: None, d: None, anchors: Some(anchors), closed: Some(true), - width: None, - height: None, - fill: None, - stroke: None, - effects: None, + width: Some(SizingBehavior::Number(width)), + height: Some(SizingBehavior::Number(height)), + fill: style.fill, + stroke: style.stroke, + effects: style.effects, state: None, bindings: None, events: None, @@ -409,6 +434,42 @@ impl EditorState { } } +fn boolean_path_style(node: &PenNode) -> BooleanPathStyle { + match node { + PenNode::Frame(n) => BooleanPathStyle { + fill: n.container.fill.clone(), + stroke: n.container.stroke.clone(), + effects: n.container.effects.clone(), + }, + PenNode::Rectangle(n) => BooleanPathStyle { + fill: n.container.fill.clone(), + stroke: n.container.stroke.clone(), + effects: n.container.effects.clone(), + }, + PenNode::Ellipse(n) => BooleanPathStyle { + fill: n.fill.clone(), + stroke: n.stroke.clone(), + effects: n.effects.clone(), + }, + PenNode::Polygon(n) => BooleanPathStyle { + fill: n.fill.clone(), + stroke: n.stroke.clone(), + effects: n.effects.clone(), + }, + PenNode::Path(n) => BooleanPathStyle { + fill: n.fill.clone(), + stroke: n.stroke.clone(), + effects: n.effects.clone(), + }, + PenNode::Line(n) => BooleanPathStyle { + fill: None, + stroke: n.stroke.clone(), + effects: n.effects.clone(), + }, + _ => BooleanPathStyle::default(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -487,13 +548,23 @@ mod tests { let result = s .replace_paths_with_polyline( &[a, b], - &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], + &[(10.0, 10.0), (0.0, 0.0), (10.0, 0.0)], &mut next, ) .expect("polyline committed"); assert!(result.is_real()); // Both sources gone, one result node remains. assert_eq!(s.active_children().len(), 1); + let PenNode::Path(path) = &s.active_children()[0] else { + panic!("boolean result should be a Path"); + }; + assert_eq!(path.base.x, Some(0.0)); + assert_eq!(path.base.y, Some(0.0)); + assert_eq!(path.width, Some(SizingBehavior::Number(10.0))); + assert_eq!(path.height, Some(SizingBehavior::Number(10.0))); + let anchors = path.anchors.as_ref().expect("anchors"); + assert_eq!(anchors[0].x, 10.0); + assert_eq!(anchors[0].y, 10.0); } #[test] @@ -502,4 +573,31 @@ mod tests { let mut next = 100u64; assert!(s.replace_paths_with_polyline(&[], &[], &mut next).is_none()); } + + #[test] + fn replace_paths_with_polyline_inherits_first_source_style() { + let src = r##"{ + "version": "0.8.0", + "children": [ + {"type":"rectangle","id":"n10","x":0,"y":0,"width":20,"height":20, + "fill":[{"type":"solid","color":"#ff0000"}]}, + {"type":"rectangle","id":"n11","x":10,"y":0,"width":20,"height":20} + ] + }"##; + let doc = jian_ops_schema::load_str(src) + .expect("fixture parses") + .value; + let mut s = EditorState::from_document(doc); + let mut next = 100u64; + s.replace_paths_with_polyline( + &[NodeId::new("n10"), NodeId::new("n11")], + &[(0.0, 0.0), (20.0, 0.0), (20.0, 20.0)], + &mut next, + ) + .expect("polyline committed"); + let PenNode::Path(path) = &s.active_children()[0] else { + panic!("boolean result should be a Path"); + }; + assert!(path.fill.is_some(), "result should keep first source fill"); + } } diff --git a/crates/op-editor-ui/src/widgets/align_toolbar.rs b/crates/op-editor-ui/src/widgets/align_toolbar.rs index a71561f3a..514044bdd 100644 --- a/crates/op-editor-ui/src/widgets/align_toolbar.rs +++ b/crates/op-editor-ui/src/widgets/align_toolbar.rs @@ -72,11 +72,18 @@ const SQUARES_INTERSECT: &[&str] = &[ "M8 10a2 2 0 0 1 2-2h5a1 1 0 0 1 1 1v5a2 2 0 0 1-2 2H9a1 1 0 0 1-1-1z", "M8 2h2", ]; +const SQUARES_EXCLUDE: &[&str] = &[ + "M4 2h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z", + "M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z", + "M9.5 9.5l5 5", + "M14.5 9.5l-5 5", +]; const BOOLEAN_ITEMS: &[(BooleanOp, &[&str])] = &[ (BooleanOp::Union, SQUARES_UNITE), (BooleanOp::Subtract, SQUARES_SUBTRACT), (BooleanOp::Intersect, SQUARES_INTERSECT), + (BooleanOp::Exclude, SQUARES_EXCLUDE), ]; /// Group divider indices (after these positions, insert a `GROUP_GAP` @@ -373,6 +380,15 @@ mod tests { Some(AlignToolbarHit::Boolean(BooleanOp::Union)) ); assert_eq!(tb.hit_test(center), None); + let exclude = tb.button_rect(ITEMS.len() + BOOLEAN_ITEMS.len() - 1); + let center = Point2D::new( + exclude.origin.x + exclude.size.x / 2.0, + exclude.origin.y + exclude.size.y / 2.0, + ); + assert_eq!( + tb.hit_test_action(center), + Some(AlignToolbarHit::Boolean(BooleanOp::Exclude)) + ); } #[test] diff --git a/crates/op-host-native/src/boolean_ops.rs b/crates/op-host-native/src/boolean_ops.rs index ac1782491..39f6449c9 100644 --- a/crates/op-host-native/src/boolean_ops.rs +++ b/crates/op-host-native/src/boolean_ops.rs @@ -15,9 +15,9 @@ use op_editor_core::BooleanOp; use op_editor_ui::layout_scene::{regular_polygon_points, LayoutScene, NodeKind, SceneNode}; use op_editor_ui::{Point2D, Rect}; -use skia_safe::{Path as SkPath, PathBuilder, PathOp, Rect as SkRect}; +use skia_safe::{Matrix, Path as SkPath, PathBuilder, PathOp, Rect as SkRect}; -/// Result of a boolean-op computation — the source path ids to +/// Result of a boolean-op computation — the source shape ids to /// remove + the new polyline (doc-space `(x, y)` pairs) to commit. pub struct BooleanResult { pub source_ids: Vec, @@ -72,7 +72,7 @@ pub fn compute_boolean_op( } fn build_node_path(node: &SceneNode) -> Option { - match node.kind { + let path = match node.kind { NodeKind::Frame | NodeKind::Rect => build_rect_path(node.bounds), NodeKind::Ellipse => build_oval_path(node.bounds), NodeKind::Polygon => { @@ -93,7 +93,8 @@ fn build_node_path(node: &SceneNode) -> Option { } } NodeKind::Group | NodeKind::Text | NodeKind::Other(_) => None, - } + }?; + Some(apply_node_rotation(path, node)) } fn build_rect_path(bounds: Rect) -> Option { @@ -147,6 +148,17 @@ fn build_polyline_path(points: &[Point2D]) -> Option { Some(b.detach()) } +fn apply_node_rotation(path: SkPath, node: &SceneNode) -> SkPath { + if node.rotation.abs() <= f32::EPSILON { + return path; + } + let b = node.aggregate_bounds(); + let pivot = skia_safe::Point::new(b.origin.x + b.size.x / 2.0, b.origin.y + b.size.y / 2.0); + let mut matrix = Matrix::new_identity(); + matrix.set_rotate(node.rotation.to_degrees(), Some(pivot)); + path.with_transform(&matrix) +} + /// Walk the result Path and yield a flat polyline. Curves (Quad / /// Conic / Cubic) degrade to their endpoint (TS Paper.js also emits /// curve segments here; full handle support arrives with the