mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -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.
84 lines
3.4 KiB
Rust
84 lines
3.4 KiB
Rust
//! Boolean groups whose operands carry their own operations: the fold bottom to top, its
|
||
//! agreement with a uniform operation, and single steps.
|
||
|
||
use figcraft_doc::BoolOp;
|
||
use figcraft_geom::Rect;
|
||
use figcraft_geom::kurbo::{BezPath, Shape};
|
||
|
||
use crate::{boolean, boolean_ops, boolean_step};
|
||
|
||
fn sq(x: f64, y: f64, s: f64) -> BezPath {
|
||
Rect::new(x, y, x + s, y + s).to_path(0.1)
|
||
}
|
||
|
||
fn area(p: &BezPath) -> f64 {
|
||
p.area().abs()
|
||
}
|
||
|
||
#[test]
|
||
fn each_operand_combines_with_the_result_below_it() {
|
||
let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 0.0, 10.0), sq(0.0, 0.0, 15.0));
|
||
// (A ∪ B) ∩ C: the union spans x 0..15, y 0..10 (150), all inside C.
|
||
let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&c, BoolOp::Intersect)]).unwrap();
|
||
assert!((area(&r) - 150.0).abs() < 0.5, "{}", area(&r));
|
||
// (A ∪ B) − a 5 × 5 square in the middle.
|
||
let hole = sq(5.0, 2.5, 5.0);
|
||
let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap();
|
||
assert!((area_eo(&r) - 125.0).abs() < 0.5, "{}", area_eo(&r));
|
||
// The bottom operand's own operation is unused.
|
||
let r2 = boolean_ops([(&a, BoolOp::Exclude), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap();
|
||
assert!((area_eo(&r2) - 125.0).abs() < 0.5);
|
||
}
|
||
|
||
/// Even-odd area: contours nested in another count negative (holes).
|
||
fn area_eo(p: &BezPath) -> f64 {
|
||
let mut total = 0.0;
|
||
let subs: Vec<BezPath> = split(p);
|
||
for (i, s) in subs.iter().enumerate() {
|
||
let inside = subs.iter().enumerate().filter(|(j, o)| *j != i && o.bounding_box().contains_rect(s.bounding_box())).count();
|
||
total += if inside % 2 == 0 { area(s) } else { -area(s) };
|
||
}
|
||
total
|
||
}
|
||
|
||
fn split(p: &BezPath) -> Vec<BezPath> {
|
||
use figcraft_geom::kurbo::PathEl;
|
||
let mut out: Vec<BezPath> = Vec::new();
|
||
for el in p.elements() {
|
||
if matches!(el, PathEl::MoveTo(_)) {
|
||
out.push(BezPath::new());
|
||
}
|
||
if let Some(last) = out.last_mut() {
|
||
last.push(*el);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn a_uniform_fold_matches_the_group_operation() {
|
||
let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0), sq(8.0, 0.0, 4.0));
|
||
for op in [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude] {
|
||
let all = boolean(&[a.clone(), b.clone(), c.clone()], op).unwrap();
|
||
let folded = boolean_ops([(&a, op), (&b, op), (&c, op)]).unwrap();
|
||
assert!((area_eo(&all) - area_eo(&folded)).abs() < 1e-6, "{op:?}");
|
||
}
|
||
// Subtract removes every later operand from the first.
|
||
let s = boolean(&[a.clone(), b.clone(), c.clone()], BoolOp::Subtract).unwrap();
|
||
assert!((area_eo(&s) - (100.0 - 25.0 - 8.0)).abs() < 0.5, "{}", area_eo(&s));
|
||
}
|
||
|
||
#[test]
|
||
fn single_steps_and_edge_cases() {
|
||
let (a, b) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0));
|
||
assert!((area(&boolean_step(&a, &b, BoolOp::Intersect).unwrap()) - 25.0).abs() < 0.5);
|
||
assert!((area(&boolean_step(&a, &b, BoolOp::Subtract).unwrap()) - 75.0).abs() < 0.5);
|
||
// Nothing, and a lone operand (simplified).
|
||
assert!(boolean_ops(std::iter::empty()).unwrap().elements().is_empty());
|
||
assert!((area(&boolean_ops([(&a, BoolOp::Subtract)]).unwrap()) - 100.0).abs() < 1e-6);
|
||
let mut bad = BezPath::new();
|
||
bad.move_to((f64::NAN, 0.0));
|
||
bad.line_to((1.0, 1.0));
|
||
assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err());
|
||
}
|