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.
This commit is contained in:
BFlat
2026-10-11 10:04:25 +07:00
committed by GitHub
parent 1444754f66
commit 08d91869cb
47 changed files with 2328 additions and 326 deletions
+30 -33
View File
@@ -6,6 +6,10 @@
//! dashes, caps, joins, arrowheads and variable width profiles ([`width`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(test)]
mod ops_tests;
#[cfg(test)]
mod sides_tests;
pub mod width;
#[cfg(test)]
mod width_tests;
@@ -79,44 +83,37 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
}
/// Combine `acc` (the operands so far) with the next operand `p` by `op`.
pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result<BezPath, PathOpsError> {
let op = match op {
BoolOp::Union => linesweeper::BinaryOp::Union,
BoolOp::Intersect => linesweeper::BinaryOp::Intersection,
BoolOp::Exclude => linesweeper::BinaryOp::Xor,
BoolOp::Subtract => linesweeper::BinaryOp::Difference,
};
binary(acc, p, op)
}
/// Apply a boolean operation to `paths` in order (bottom to top, like a boolean group's children):
/// Subtract removes every later shape from the first one.
pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result<BezPath, PathOpsError> {
let mut it = paths.iter();
let Some(first) = it.next() else { return Ok(BezPath::new()) };
let mut acc = first.clone();
match op {
BoolOp::Union => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Union)?;
}
}
BoolOp::Intersect => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Intersection)?;
}
}
BoolOp::Exclude => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Xor)?;
}
}
BoolOp::Subtract => {
let rest: Vec<BezPath> = it.cloned().collect();
if rest.is_empty() {
return Ok(acc);
}
let mut cut = BezPath::new();
for p in &rest {
cut = binary(&cut, p, linesweeper::BinaryOp::Union)?;
}
acc = binary(&acc, &cut, linesweeper::BinaryOp::Difference)?;
}
boolean_ops(paths.iter().map(|p| (p, op)))
}
/// Fold `operands` bottom to top, each combined with the result of those below it by its own
/// operation (the bottom operand's operation is unused): what a boolean group whose operands carry
/// their own operations paints. A lone operand comes out simplified.
pub fn boolean_ops<'a>(operands: impl IntoIterator<Item = (&'a BezPath, BoolOp)>) -> Result<BezPath, PathOpsError> {
let mut it = operands.into_iter();
let Some((first, _)) = it.next() else { return Ok(BezPath::new()) };
let mut acc: Option<BezPath> = None;
for (p, op) in it {
acc = Some(boolean_step(acc.as_ref().unwrap_or(first), p, op)?);
}
if paths.len() == 1 {
return simplify(&acc);
match acc {
Some(a) => Ok(a),
None => simplify(first),
}
Ok(acc)
}
/// Per-input winding numbers for [`faces`] (index = input path; trailing zeros trimmed so equal
+83
View File
@@ -0,0 +1,83 @@
//! 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());
}
+70
View File
@@ -0,0 +1,70 @@
//! One-sided variable widths in stroke outlines: each side offset by its own width, lopsided
//! caps around the middle of the end, dashes and closed paths.
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
use crate::stroke_outline;
/// A horizontal line from (0, 0) to (100, 0): looking along it, its right side is +y (y down).
fn line() -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((100.0, 0.0));
p
}
fn props(points: Vec<WidthPoint>) -> StrokeProps {
StrokeProps { weight: 10.0, align: StrokeAlign::Center, width_profile: Some(WidthProfile { points }.sanitized()), ..Default::default() }
}
#[test]
fn each_side_is_offset_by_its_own_width() {
// All on the right: 0 to the left, 10 to the right (side scale 2 × weight 10 / 2).
let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6 && b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "{b:?}");
assert!((stroke_outline(&line(), &p, false).area().abs() - 1000.0).abs() < 1.0);
// Lopsided: 2 on the left, 8 on the right.
let p = props(vec![WidthPoint::from_sides(0.5, 0.4, 1.6)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 8.0).abs() < 1e-6, "{b:?}");
// The right side swells in the middle while the left stays straight.
let p = props(vec![WidthPoint::new(0.0, 1.0), WidthPoint::from_sides(0.5, 1.0, 4.0), WidthPoint::new(1.0, 1.0)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.y0 + 5.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn caps_wrap_the_middle_of_a_lopsided_end() {
// 0 on the left, 10 on the right: a round cap is a half circle of radius 5 around (100, 5).
let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
p.cap_start = StrokeCap::Round;
p.cap_end = StrokeCap::Round;
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.x1 - 105.0).abs() < 0.05 && (b.x0 + 5.0).abs() < 0.05, "{b:?}");
assert!(b.y0 > -0.05 && (b.y1 - 10.0).abs() < 0.05, "{b:?}");
p.cap_end = StrokeCap::Square;
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.x1 - 105.0).abs() < 1e-6 && b.y0 > -1e-6, "{b:?}");
}
#[test]
fn dashes_and_closed_paths_keep_their_sides() {
let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
p.dashes = vec![10.0, 10.0];
let s = stroke_outline(&line(), &p, false);
let b = s.bounding_box();
assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6, "{b:?}");
assert!((s.area().abs() - 500.0).abs() < 5.0, "{}", s.area());
// A closed square drawn clockwise on screen: its right side is the inside.
let mut sq = BezPath::new();
sq.move_to((0.0, 0.0));
sq.line_to((100.0, 0.0));
sq.line_to((100.0, 100.0));
sq.line_to((0.0, 100.0));
sq.close_path();
let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
let b = stroke_outline(&sq, &p, true).bounding_box();
assert!(b.x0 > -1e-6 && b.y0 > -1e-6 && b.x1 < 100.0 + 1e-6 && b.y1 < 100.0 + 1e-6, "all inside: {b:?}");
}
+46 -27
View File
@@ -2,9 +2,10 @@
//! follows a [`WidthProfile`] along each subpath.
//!
//! The path is flattened into polylines (sampled densely enough for the width to vary smoothly),
//! each point is offset along the polyline's normals by the interpolated half-width, the sides are
//! joined (round, miter or bevel on the outer side, through the centre on the inner side) and
//! capped. The result may overlap itself; fill it with the non-zero rule.
//! each point is offset along the polyline's normals by the interpolated half-width of that side
//! (the two sides can differ: one-sided widths), the sides are joined (round, miter or bevel on the
//! outer side, through the centre on the inner side) and capped. The result may overlap itself;
//! fill it with the non-zero rule.
use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile};
use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
@@ -132,13 +133,22 @@ pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> {
lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2))
}
/// Half-widths at a sample: `[along the normal, against it]`. The normal is the direction turned
/// clockwise on screen (y down), so the first is the right side looking along the path.
type Half = [f64; 2];
/// The half-width of `h` on side `sigma` (+1 = along the normal).
fn on(h: Half, sigma: f64) -> f64 {
if sigma > 0.0 { h[0] } else { h[1] }
}
/// One straight piece of an offset polyline: its ends, the half-widths there, its direction and
/// normal.
struct Seg {
a: Point,
b: Point,
ha: f64,
hb: f64,
ha: Half,
hb: Half,
d: Vec2,
n: Vec2,
}
@@ -179,8 +189,9 @@ fn arc(out: &mut Vec<Point>, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64)
/// The corner between segments `p` and `c` on side `sigma` (+1 = the normal's side).
fn join(out: &mut Vec<Point>, p: &Seg, c: &Seg, sigma: f64, style: Style) {
let pe = p.b + p.n * (sigma * p.hb);
let cs = c.a + c.n * (sigma * c.ha);
let (phb, cha) = (on(p.hb, sigma), on(c.ha, sigma));
let pe = p.b + p.n * (sigma * phb);
let cs = c.a + c.n * (sigma * cha);
out.push(pe);
let (cross, dot) = (p.d.cross(c.d), p.d.dot(c.d));
if cross.abs() < 1e-12 && dot > 0.0 {
@@ -193,12 +204,12 @@ fn join(out: &mut Vec<Point>, p: &Seg, c: &Seg, sigma: f64, style: Style) {
if outer {
let theta = if cross.abs() < 1e-12 { std::f64::consts::PI } else { cross.atan2(dot) };
match style.join {
StrokeJoin::Round => arc(out, v, pe - v, theta, p.hb, c.ha),
StrokeJoin::Round => arc(out, v, pe - v, theta, phb, cha),
StrokeJoin::Miter => {
let half = (theta / 2.0).cos();
let bis = p.n + c.n;
if half > 1e-6 && 1.0 / half <= style.miter_limit && bis.hypot() > 1e-9 {
out.push(v + bis * (sigma * (p.hb + c.ha) / 2.0 / half / bis.hypot()));
out.push(v + bis * (sigma * (phb + cha) / 2.0 / half / bis.hypot()));
}
}
StrokeJoin::Bevel => {}
@@ -217,25 +228,26 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec<Point> {
let prev = if j == 0 { closed.then(|| segs.last()).flatten() } else { segs.get(j - 1) };
match prev {
Some(p) => join(&mut out, p, s, sigma, style),
None => out.push(s.a + s.n * (sigma * s.ha)),
None => out.push(s.a + s.n * (sigma * on(s.ha, sigma))),
}
}
if !closed && let Some(l) = segs.last() {
out.push(l.b + l.n * (sigma * l.hb));
out.push(l.b + l.n * (sigma * on(l.hb, sigma)));
}
out
}
/// A cap at `c` facing `dir` (unit, outwards) with half-width `h`: from the `dir.turn_90()` side
/// to the other one.
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, h: f64, kind: StrokeCap) {
/// A cap at `c` facing `dir` (unit, outwards) from the `dir.turn_90()` side, `from` wide, to the
/// other side, `to` wide. Lopsided ends are capped around the middle of the end.
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) {
let n = dir.turn_90();
let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0);
match cap_shape(kind) {
StrokeCap::Square => {
out.push(c + n * h + dir * h);
out.push(c - n * h + dir * h);
out.push(m + n * r + dir * r);
out.push(m - n * r + dir * r);
}
StrokeCap::Round => arc(out, c, n, -std::f64::consts::PI, h, h),
StrokeCap::Round => arc(out, m, n, -std::f64::consts::PI, r, r),
_ => {}
}
}
@@ -251,7 +263,7 @@ fn push_contour(path: &mut BezPath, pts: impl IntoIterator<Item = Point>) {
}
/// Outline one piece (a subpath or a dash) with its half-widths.
fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, style: Style) {
fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, style: Style) {
let segs: Vec<Seg> = pts
.windows(2)
.zip(hw.windows(2))
@@ -266,11 +278,12 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st
.collect();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else {
// A zero-length piece is a dot with round caps.
if let (Some(p), Some(h)) = (pts.first(), hw.first())
let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0);
if let Some(p) = pts.first()
&& cap_shape(style.cap_start) == StrokeCap::Round
&& *h > 0.0
&& r > 0.0
{
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, *h), FLATTEN_TOL));
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL));
}
return;
};
@@ -282,18 +295,18 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st
return;
}
let mut ring = left;
cap(&mut ring, last.b, last.d, last.hb, style.cap_end);
cap(&mut ring, last.b, last.d, last.hb[0], last.hb[1], style.cap_end);
ring.extend(right.into_iter().rev());
cap(&mut ring, first.a, -first.d, first.ha, style.cap_start);
cap(&mut ring, first.a, -first.d, first.ha[1], first.ha[0], style.cap_start);
push_contour(path, ring);
}
/// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths).
fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<f64>)> {
fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<Half>)> {
let mut out = Vec::new();
let Some(&first) = pattern.first() else { return out };
let (mut idx, mut left, mut on) = (0usize, first, true);
let mut cur: (Vec<Point>, Vec<f64>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
let mut cur: (Vec<Point>, Vec<Half>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
for (p, h) in pts.windows(2).zip(hw.windows(2)) {
let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]);
let len = (b - a).hypot();
@@ -301,7 +314,7 @@ fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<f6
while len - used > left {
used += left;
let f = if len > 0.0 { used / len } else { 0.0 };
let (q, hq) = (a.lerp(b, f), ha + (hb - ha) * f);
let (q, hq) = (a.lerp(b, f), [ha[0] + (hb[0] - ha[0]) * f, ha[1] + (hb[1] - ha[1]) * f]);
if on {
cur.0.push(q);
cur.1.push(hq);
@@ -381,7 +394,13 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
continue;
}
let (pts, lens) = resample(line, len / SAMPLES_PER_SUBPATH, &mut budget)?;
let hw: Vec<f64> = lens.iter().map(|s| 0.5 * width * profile.scale_at(s / len)).collect();
let hw: Vec<Half> = lens
.iter()
.map(|s| {
let (left, right) = profile.sides_at(s / len);
[0.5 * width * right, 0.5 * width * left]
})
.collect();
match &pattern {
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
for (p, h) in dashes(&pts, &hw, pat) {