mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
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.
157 lines
7.1 KiB
Rust
157 lines
7.1 KiB
Rust
//! The shape builder with strokes and open paths: strokes take part as their outlined areas, open
|
||
//! paths split the regions they cross, merging a stroke's region fills it with the stroke's paints,
|
||
//! and the strokes of the shapes involved become outlined vectors.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
use crate::params::id_of;
|
||
use figcraft_doc::{NodeId, NodeKind};
|
||
use figcraft_geom::Shape;
|
||
|
||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||
}
|
||
|
||
fn id(v: &Value) -> NodeId {
|
||
id_of(&v["id"]).unwrap()
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// A red 100 × 100 square and, above it, a black 10 wide open path across its middle from x = -20
|
||
/// to 120; both selected.
|
||
fn square_and_path(s: &mut Session) -> (NodeId, NodeId) {
|
||
let r = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
|
||
let l = id(&ex(s, "vector.create", json!({"points": [[-20, 50], [120, 50]], "closed": false})));
|
||
ex(s, "node.set", json!({"id": l, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
|
||
s.set_selection(vec![r, l]);
|
||
(r, l)
|
||
}
|
||
|
||
#[test]
|
||
fn a_stroked_open_path_splits_the_regions_it_crosses() {
|
||
let mut s = Session::new();
|
||
let (r, l) = square_and_path(&mut s);
|
||
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
|
||
assert_eq!(out["sources"], json!([r, l]));
|
||
let regions = out["regions"].as_array().unwrap();
|
||
// Above and below the path, the band across the square, and the path's two ends outside it.
|
||
assert_eq!(regions.len(), 5, "{out}");
|
||
let covered = |x: f64, y: f64| -> Value {
|
||
regions
|
||
.iter()
|
||
.find(|g| {
|
||
let b = &g["bounds"];
|
||
let (x0, y0) = (b[0].as_f64().unwrap(), b[1].as_f64().unwrap());
|
||
x >= x0 && y >= y0 && x <= x0 + b[2].as_f64().unwrap() && y <= y0 + b[3].as_f64().unwrap()
|
||
})
|
||
.map(|g| g["parts"].clone())
|
||
.unwrap_or(Value::Null)
|
||
};
|
||
assert_eq!(covered(50.0, 20.0), json!([{"id": r, "stroke": false}]));
|
||
assert_eq!(covered(50.0, 50.0), json!([{"id": l, "stroke": true}, {"id": r, "stroke": false}]), "the stroke is above the fill");
|
||
assert_eq!(covered(-10.0, 50.0), json!([{"id": l, "stroke": true}]));
|
||
// A path without a visible stroke paints nothing and takes no part.
|
||
ex(&mut s, "node.set", json!({"id": l, "props": {"strokes": []}}));
|
||
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
|
||
assert_eq!(out["sources"], json!([r]));
|
||
assert_eq!(out["regions"].as_array().unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn merging_a_stroke_region_fills_it_with_the_stroke() {
|
||
let mut s = Session::new();
|
||
let (r, l) = square_and_path(&mut s);
|
||
let out = ex(&mut s, "shapeBuilder.merge", json!({"points": [[50, 50]]}));
|
||
let m = id(&out);
|
||
// The band, black (the stroke's paint), no stroke of its own.
|
||
assert!((area(&s, m) - 1000.0).abs() < 1.0, "{}", area(&s, m));
|
||
assert_eq!(fill_hex(&s, m), "000000");
|
||
assert!(s.doc().unwrap().node(m).unwrap().strokes.is_empty());
|
||
// The square lost the band; the path (no fill) became an outline of what was left of its
|
||
// stroke: the two ends outside the square.
|
||
assert!((area(&s, r) - 9000.0).abs() < 1.0, "{}", area(&s, r));
|
||
assert_eq!(out["removed"], json!([l]));
|
||
let o = id_of(&out["outlines"][0]).unwrap();
|
||
assert!((area(&s, o) - 400.0).abs() < 1.0, "{}", area(&s, o));
|
||
assert_eq!(fill_hex(&s, o), "000000");
|
||
assert!(matches!(s.doc().unwrap().node(o).unwrap().kind, NodeKind::Vector { .. }));
|
||
// The selection keeps working: the square, the outline and the new shape.
|
||
let sel = s.selection();
|
||
assert!(sel.contains(&r) && sel.contains(&o) && sel.contains(&m), "{sel:?}");
|
||
// One undo step.
|
||
s.undo().unwrap();
|
||
let doc = s.doc().unwrap();
|
||
assert!(doc.contains(l) && !doc.contains(m) && !doc.contains(o));
|
||
assert!((area(&s, r) - 10000.0).abs() < 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn removing_regions_spares_strokes_that_arent_involved() {
|
||
let mut s = Session::new();
|
||
let (r, l) = square_and_path(&mut s);
|
||
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 20]]}));
|
||
// The square keeps the band and the part below; the path isn't touched.
|
||
assert!((area(&s, r) - 5500.0).abs() < 1.0, "{}", area(&s, r));
|
||
assert_eq!(out["outlines"], json!([]));
|
||
let n = s.doc().unwrap().node(l).unwrap();
|
||
assert!(!n.strokes.is_empty() && matches!(n.kind, NodeKind::Vector { .. }));
|
||
// Removing an end of the path leaves the rest of its stroke as an outline.
|
||
let out = ex(&mut s, "shapeBuilder.remove", json!({"ids": [r, l], "points": [[-10, 50]]}));
|
||
assert_eq!(out["removed"], json!([l]));
|
||
let o = id_of(&out["outlines"][0]).unwrap();
|
||
assert!((area(&s, o) - (1000.0 + 200.0)).abs() < 1.0, "the band and the other end: {}", area(&s, o));
|
||
}
|
||
|
||
#[test]
|
||
fn a_stroked_shape_gives_its_ring_and_keeps_its_look() {
|
||
let mut s = Session::new();
|
||
// An inside stroke 10 wide: a ring inside the square's edge.
|
||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
|
||
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#0000ff"], "strokeWeight": 10, "strokeAlign": "inside"}}));
|
||
s.set_selection(vec![r]);
|
||
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
|
||
assert_eq!(out["regions"].as_array().unwrap().len(), 2, "the middle and the ring: {out}");
|
||
// Removing the middle leaves the fill under the ring and the ring as a blue outline above it.
|
||
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 50]]}));
|
||
assert_eq!(out["changed"], json!([r]));
|
||
assert!((area(&s, r) - 3600.0).abs() < 1.0, "{}", area(&s, r));
|
||
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty());
|
||
let o = id_of(&out["outlines"][0]).unwrap();
|
||
assert!((area(&s, o) - 3600.0).abs() < 1.0);
|
||
assert_eq!(fill_hex(&s, o), "0000FF");
|
||
let doc = s.doc().unwrap();
|
||
let parent = doc.node(r).unwrap().parent.unwrap();
|
||
let kids = &doc.node(parent).unwrap().children;
|
||
assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape");
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_regions_survive_junk() {
|
||
for j in [
|
||
json!({"points": [[f64::MAX, 0]]}),
|
||
json!({"regions": [-1, 1e20, "x"]}),
|
||
json!({"ids": [123_456], "points": [[50, 50]]}),
|
||
json!({"points": "x"}),
|
||
] {
|
||
for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove"] {
|
||
let mut s = Session::new();
|
||
let _ = square_and_path(&mut s);
|
||
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) {
|
||
panic!("{cmd} panicked on {j}: {msg}");
|
||
}
|
||
}
|
||
}
|
||
}
|