fix(editor): complete boolean toolbar ops

This commit is contained in:
Kayshen-X 2026-05-31 21:05:28 +08:00
parent 1a2e97c2bd
commit 83fd78f293
3 changed files with 143 additions and 17 deletions

View file

@ -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<Vec<PenFill>>,
stroke: Option<PenStroke>,
effects: Option<Vec<PenEffect>>,
}
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<PenPathAnchor> = 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");
}
}

View file

@ -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]

View file

@ -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<String>,
@ -72,7 +72,7 @@ pub fn compute_boolean_op(
}
fn build_node_path(node: &SceneNode) -> Option<SkPath> {
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<SkPath> {
}
}
NodeKind::Group | NodeKind::Text | NodeKind::Other(_) => None,
}
}?;
Some(apply_node_rotation(path, node))
}
fn build_rect_path(bounds: Rect) -> Option<SkPath> {
@ -147,6 +148,17 @@ fn build_polyline_path(points: &[Point2D]) -> Option<SkPath> {
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