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
+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) {