fix(editor): complete boolean toolbar ops
This commit is contained in:
parent
1a2e97c2bd
commit
83fd78f293
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue