Files
figcraft/crates/engine/src/shapebuilder_tests.rs
T
BFlat 08d91869cb M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)
Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
2026-10-11 10:04:25 +07:00

286 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The shape builder: regions, merge and remove commands, the tool's gestures, keys and undo.
use serde_json::{Value, json};
use crate::shapebuilder::{arrangement, current};
use crate::tools::{PointerEvent, PointerKind};
use crate::{EngineError, Session, ToolId, params};
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str) -> NodeId {
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h, "props": {"fill": fill}})))
}
/// Two 10×10 squares overlapping in a 5×5 square: A (red) at the origin, B (blue) above it at 5,5.
fn squares(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 10.0, 10.0, "#ff0000");
let b = rect(s, 5.0, 5.0, 10.0, 10.0, "#0000ff");
s.set_selection(vec![a, b]);
(a, b)
}
/// A layer's filled area in page units.
fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
let (mut p, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
p.apply_affine(doc.world_transform(id));
p.area().abs()
}
fn fill_hex(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent {
PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn drag(s: &mut Session, pts: &[(f64, f64)], alt: bool) {
let (first, rest) = pts.split_first().unwrap();
s.pointer(&ev(PointerKind::Down, first.0, first.1, alt)).unwrap();
for p in rest {
s.pointer(&ev(PointerKind::Move, p.0, p.1, alt)).unwrap();
}
let last = pts.last().unwrap();
s.pointer(&ev(PointerKind::Up, last.0, last.1, alt)).unwrap();
}
#[test]
fn overlapping_squares_make_three_regions() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let r = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(r["sources"], json!([a, b]));
let regions = r["regions"].as_array().unwrap();
assert_eq!(regions.len(), 3, "{r}");
let arr = arrangement(&mut s, &[a, b]).unwrap();
let only_a = arr.region_at((2.0, 2.0).into()).unwrap();
let both = arr.region_at((7.0, 7.0).into()).unwrap();
let only_b = arr.region_at((12.0, 12.0).into()).unwrap();
assert_eq!(arr.regions[only_a].sources, vec![a]);
assert_eq!(arr.regions[both].sources, vec![b, a], "top-most first");
assert_eq!(arr.regions[only_b].sources, vec![b]);
assert!(arr.region_at((20.0, 20.0).into()).is_none());
// Every region reports a point inside itself.
for (i, reg) in regions.iter().enumerate() {
let p = reg["point"].as_array().unwrap();
assert_eq!(arr.region_at((p[0].as_f64().unwrap(), p[1].as_f64().unwrap()).into()), Some(i));
}
// Cached per document version and ids.
assert!(std::sync::Arc::ptr_eq(&arr, &arrangement(&mut s, &[a, b]).unwrap()));
assert!(current(&s).is_some_and(|c| std::sync::Arc::ptr_eq(&c, &arr)));
rect(&mut s, 40.0, 40.0, 5.0, 5.0, "#00ff00");
assert!(current(&s).is_none(), "a new document version (and selection) invalidates the cache");
}
#[test]
fn merge_the_overlap_into_a_new_shape() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let undo = s.ds().unwrap().history.undo.len();
let r = ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]}));
let m = id(&r);
assert!((area(&s, m) - 25.0).abs() < 0.01, "{}", area(&s, m));
assert_eq!(fill_hex(&s, m), "0000FF", "styled like the top-most shape over the region");
assert!((area(&s, a) - 75.0).abs() < 0.01, "A loses the overlap: {}", area(&s, a));
assert!((area(&s, b) - 75.0).abs() < 0.01, "B loses the overlap: {}", area(&s, b));
assert_eq!(fill_hex(&s, a), "FF0000");
let doc = s.doc().unwrap();
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Vector { .. }));
assert_eq!(doc.index_in_parent(m), doc.index_in_parent(b).map(|i| i + 1), "just above the style source");
let sel = s.selection();
assert!(sel.contains(&a) && sel.contains(&b) && sel.contains(&m), "{sel:?}");
// One undo step brings the rectangles back.
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
assert!(s.undo().unwrap());
let doc = s.doc().unwrap();
assert!(!doc.contains(m));
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Rectangle));
assert!((area(&s, a) - 100.0).abs() < 1e-6);
}
#[test]
fn merging_every_region_unites_the_shapes() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let r = ex(&mut s, "shapeBuilder.merge", json!({"regions": [0, 1, 2]}));
let m = id(&r);
assert_eq!(r["removed"], json!([a, b]));
assert!((area(&s, m) - 175.0).abs() < 0.01, "{}", area(&s, m));
assert_eq!(s.selection(), vec![m]);
// Merging a lone shape's only region is a no-op.
let before = s.doc().unwrap().clone();
let r = ex(&mut s, "shapeBuilder.merge", json!({"regions": [0]}));
assert!(r["id"].is_null());
assert_eq!(*s.doc().unwrap(), before);
}
#[test]
fn remove_regions() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[7, 7], [12, 12]]}));
assert!(!s.doc().unwrap().contains(b), "B had nothing left");
assert!((area(&s, a) - 75.0).abs() < 0.01);
assert_eq!(s.selection(), vec![a]);
let e = s.execute("shapeBuilder.remove", &json!({"points": [[100, 100]]}));
assert!(matches!(e, Err(EngineError::BadParams { .. })), "{e:?}");
}
#[test]
fn curved_shapes_keep_their_curves() {
let mut s = Session::new();
let a = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let b = id(&ex(&mut s, "shape.ellipse", json!({"x": 50, "y": 0, "width": 100, "height": 100})));
s.set_selection(vec![a, b]);
let r = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(r["regions"].as_array().unwrap().len(), 3);
let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[75, 50]]})));
// The lens of two r=50 circles 50 apart: 2r²·acos(d/2r) − (d/2)·√(4r² − d²).
let lens = 2.0 * 2500.0 * (0.5f64).acos() - 25.0 * (10000.0f64 - 2500.0).sqrt();
assert!((area(&s, m) - lens).abs() < lens * 0.01, "{} vs {lens}", area(&s, m));
let doc = s.doc().unwrap();
let NodeKind::Vector { network } = &doc.node(m).unwrap().kind else { panic!("not a vector") };
assert!(network.segments.iter().any(|g| !g.is_straight()), "the lens keeps curved edges");
let full = std::f64::consts::PI * 2500.0;
assert!((area(&s, a) - (full - lens)).abs() < full * 0.01);
}
#[test]
fn tool_click_drag_alt_and_keys() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
ex(&mut s, "tool.shapeBuilder", json!({}));
assert_eq!(s.tool.tool, ToolId::ShapeBuilder);
// Hover highlights the region under the pointer.
s.pointer(&ev(PointerKind::Hover, 7.0, 7.0, false)).unwrap();
let both = current(&s).unwrap().region_at((7.0, 7.0).into()).unwrap();
assert_eq!(s.tool.overlay.builder_regions, vec![both]);
assert!(!s.tool.overlay.builder_remove);
s.pointer(&ev(PointerKind::Hover, 7.0, 7.0, true)).unwrap();
assert!(s.tool.overlay.builder_remove, "Alt removes");
// Alt-drag from A's own part across the overlap removes both regions: one undo step.
let undo = s.ds().unwrap().history.undo.len();
drag(&mut s, &[(2.0, 2.0), (4.0, 4.0), (7.0, 7.0)], true);
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
assert!(!s.doc().unwrap().contains(a));
assert!((area(&s, b) - 75.0).abs() < 0.01);
assert!(s.tool.overlay.builder_regions.is_empty());
assert!(s.undo().unwrap());
// A click merges the region under it: B keeps only the overlap.
s.set_selection(vec![a, b]);
drag(&mut s, &[(12.0, 12.0)], false);
let sel = s.selection();
assert_eq!(sel.len(), 3, "{sel:?}");
assert!((area(&s, b) - 25.0).abs() < 0.01);
// A click outside every region selects the layer there; Enter finishes the tool.
let c = {
s.set_tool(ToolId::Move);
let c = rect(&mut s, 40.0, 40.0, 5.0, 5.0, "#00ff00");
s.set_selection(vec![a]);
s.set_tool(ToolId::ShapeBuilder);
c
};
drag(&mut s, &[(42.0, 42.0)], false);
assert_eq!(s.selection(), vec![c]);
assert!(s.tool_key("Enter", false, false, false).unwrap());
assert_eq!(s.tool.tool, ToolId::Move);
assert!(!s.tool_key("Enter", false, false, false).unwrap(), "Enter is not the tool's outside it");
}
#[test]
fn remove_mode_and_escape() {
let mut s = Session::new();
let (a, _) = squares(&mut s);
ex(&mut s, "tool.shapeBuilder", json!({}));
ex(&mut s, "shapeBuilder.mode", json!({"mode": "remove"}));
s.pointer(&ev(PointerKind::Down, 2.0, 2.0, false)).unwrap();
assert!(s.tool.busy());
// Escape cancels the gesture, then leaves the tool.
s.escape().unwrap();
assert!(!s.tool.busy());
assert!(s.doc().unwrap().contains(a));
// In Remove mode a plain click removes and Alt merges.
drag(&mut s, &[(2.0, 2.0)], false);
assert!((area(&s, a) - 25.0).abs() < 0.01);
s.escape().unwrap();
assert_eq!(s.tool.tool, ToolId::Move);
assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
}
#[test]
fn groups_rotated_and_ineligible_layers() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
let line = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 5, "width": 20})));
let frame = id(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 50, "height": 50})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": [g, line, frame]}));
assert_eq!(r["sources"], json!([a, b, line]), "groups give their shapes; lines take part through their strokes; frames don't");
// The merged shape lands in the group, which stays selected.
s.set_selection(vec![g]);
let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]})));
assert_eq!(s.doc().unwrap().node(m).unwrap().parent, Some(g));
assert_eq!(s.selection(), vec![g]);
// Rotated shapes reshape in their own axes and keep their rotation.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000");
let b = rect(&mut s, 5.0, 0.0, 10.0, 10.0, "#0000ff");
ex(&mut s, "node.set", json!({"id": b, "props": {"rotation": 30}}));
let t_before = s.doc().unwrap().node(b).unwrap().transform;
let total = area(&s, a) + area(&s, b);
s.set_selection(vec![a, b]);
let regions = ex(&mut s, "shapeBuilder.regions", json!({}));
let n = regions["regions"].as_array().unwrap().len();
assert!(n >= 3, "{regions}");
let both = arrangement(&mut s, &[a, b]).unwrap().regions.iter().position(|r| r.sources.len() == 2).unwrap();
let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"regions": [both]})));
let after = area(&s, a) + area(&s, b) + 2.0 * area(&s, m);
assert!((after - total).abs() < 0.05, "areas are conserved: {after} vs {total}");
let t_after = s.doc().unwrap().node(b).unwrap().transform;
let rot = |t: figcraft_geom::Affine| t.as_coeffs()[1].atan2(t.as_coeffs()[0]);
assert!((rot(t_before) - rot(t_after)).abs() < 1e-9);
}
#[test]
fn junk_params_never_panic() {
let junk = [
json!({}),
json!(null),
json!({"regions": [-1, 1e99, "x", null, 0.5, 2]}),
json!({"points": [[f64::MAX, 1], ["NaN", "x"], [1], 5, null]}),
json!({"ids": ["x", -1, 99999], "regions": [0]}),
json!({"mode": 7}),
];
for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove", "shapeBuilder.mode"] {
for j in &junk {
let mut s = Session::new();
squares(&mut s);
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
// Degenerate geometry: a shape scaled to nothing, a huge one and a self-overlapping vector.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 0.0, 10.0, "#ff0000");
let b = rect(&mut s, -1e8, -1e8, 2e8, 2e8, "#0000ff");
let v = id(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [20, 20], [20, 0], [0, 20]], "closed": true})));
s.set_selection(vec![a, b, v]);
let _ = s.execute("shapeBuilder.regions", &json!({}));
for p in [[10.0, 5.0], [5.0, 10.0], [1e7, 1e7]] {
if let Err(EngineError::Internal { msg, .. }) = s.execute("shapeBuilder.merge", &json!({"points": [p]})) {
panic!("merge panicked: {msg}");
}
}
}