mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Shipped code no longer unwraps, expects or panics: every one of the 124 unwrap()/expect()/panic!/unreachable! sites outside tests now handles the failure (Result/Option, ?, let-else, defaults) and reports errors to the user. - Lints: workspace clippy denies unwrap_used, expect_used, panic, todo, unimplemented and unreachable; clippy.toml allows them in tests, and integration tests, examples and testkit opt out explicitly. - Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls the document back and returns EngineError::Internal; a panicking tool is reset; the MCP server answers with an internal error and keeps serving; the UI frame and each control request are guarded. - APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp return Result (JPEG/WebP errors used to write empty files), FontDb::face returns Option, Region::top returns Option. - Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG and PDF through import → render → export (30k cases locally, no panics). - Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section, README pointer and a PR template checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.0 KiB
Rust
179 lines
7.0 KiB
Rust
//! Calligraphic and bristle brushes: an elliptical nib swept along the path.
|
|
//!
|
|
//! The swept area of an ellipse moving along a curve is bounded by the ellipse's support points
|
|
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
|
|
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
|
|
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
|
|
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::Node;
|
|
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
|
|
|
|
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
|
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
|
|
|
|
/// An elliptical nib.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) struct Nib {
|
|
u: Vec2,
|
|
v: Vec2,
|
|
a: f64,
|
|
b: f64,
|
|
}
|
|
|
|
impl Nib {
|
|
/// `angle` in degrees counter-clockwise on the page (y-down document space), `size` the
|
|
/// major diameter, `roundness` the minor/major ratio (0..1].
|
|
pub fn new(angle_deg: f64, roundness: f64, size: f64) -> Self {
|
|
let t = angle_deg.to_radians();
|
|
let u = Vec2::new(t.cos(), -t.sin());
|
|
let v = Vec2::new(-u.y, u.x);
|
|
let a = (size / 2.0).max(1e-4);
|
|
Nib { u, v, a, b: (a * roundness.clamp(0.01, 1.0)).max(1e-4) }
|
|
}
|
|
pub fn support(&self, d: Vec2) -> Vec2 {
|
|
let (du, dv) = (self.u.dot(d), self.v.dot(d));
|
|
let den = ((self.a * du).powi(2) + (self.b * dv).powi(2)).sqrt().max(1e-12);
|
|
(self.u * (self.a * self.a * du) + self.v * (self.b * self.b * dv)) / den
|
|
}
|
|
}
|
|
|
|
/// Unit vector at `angle` radians from `base` towards its left normal.
|
|
fn rot(base: Vec2, angle: f64) -> Vec2 {
|
|
base * angle.cos() + normal(base) * angle.sin()
|
|
}
|
|
|
|
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
|
|
/// outer edge of the sweep follows the nib around the corner.
|
|
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
|
|
let mut out = vec![];
|
|
let n = t.segments();
|
|
for i in 0..=n {
|
|
let p = t.verts[i];
|
|
if t.corner[i] {
|
|
let (a, b) = t.in_out(i);
|
|
let turn = a.cross(b).atan2(a.dot(b));
|
|
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
|
|
for k in 0..=steps {
|
|
out.push((p, rot(a, turn * k as f64 / steps as f64)));
|
|
}
|
|
} else {
|
|
out.push((p, t.tangents[i]));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The swept outline of `nib` along `t` as closed subpaths.
|
|
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
|
|
let s = samples(t);
|
|
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
|
|
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
|
|
if t.closed {
|
|
let mut r = right;
|
|
r.reverse();
|
|
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
|
|
}
|
|
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
|
|
let steps = 16;
|
|
for k in 1..steps {
|
|
let ang = from + (to - from) * k as f64 / steps as f64;
|
|
out.push(p + nib.support(rot(tg, ang)));
|
|
}
|
|
};
|
|
let half = std::f64::consts::FRAC_PI_2;
|
|
let mut poly = left.clone();
|
|
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
|
|
cap(pe, te, half, -half, &mut poly);
|
|
poly.extend(right.iter().rev());
|
|
cap(ps, ts, -half, -3.0 * half, &mut poly);
|
|
vec![SubPath::polyline(&poly, true)]
|
|
}
|
|
|
|
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
|
|
let [va, vr, vs] = c.variation;
|
|
let angle = c.angle + rng.range(-va, va);
|
|
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
|
|
let size = (c.size + rng.range(-vs, vs)).max(0.05);
|
|
(angle, round, size)
|
|
}
|
|
|
|
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
|
|
let mut rng = Rng::new(seed_of(bp, name));
|
|
let (angle, round, size) = randomized(c, &mut rng);
|
|
let size = size * weight;
|
|
let nib = Nib::new(angle, round / 100.0, size);
|
|
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
|
|
if subs.is_empty() {
|
|
return vec![];
|
|
}
|
|
vec![filled(PathData::new(subs), paint)]
|
|
}
|
|
|
|
/// Offset a track sideways by `d` (a polyline following it).
|
|
fn offset_track(t: &Track, d: f64, trim0: f64, trim1: f64) -> Option<BezPath> {
|
|
let l = t.len();
|
|
let (s0, s1) = (trim0.clamp(0.0, l), (l - trim1).clamp(0.0, l));
|
|
if s1 - s0 < 1e-6 {
|
|
return None;
|
|
}
|
|
let mut bp = BezPath::new();
|
|
let mut first = true;
|
|
let mut push = |p: Point, bp: &mut BezPath| {
|
|
if first {
|
|
bp.move_to(p);
|
|
first = false;
|
|
} else {
|
|
bp.line_to(p);
|
|
}
|
|
};
|
|
push(t.map(s0, d), &mut bp);
|
|
for (i, c) in t.cum.iter().enumerate() {
|
|
if *c > s0 && *c < s1 {
|
|
push(t.verts[i] + normal(t.tangents[i]) * d, &mut bp);
|
|
}
|
|
}
|
|
push(t.map(s1, d), &mut bp);
|
|
if t.closed && trim0 <= 0.0 && trim1 <= 0.0 {
|
|
bp.close_path();
|
|
}
|
|
Some(bp)
|
|
}
|
|
|
|
pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
|
|
let mut rng = Rng::new(seed_of(bp, name));
|
|
let size = b.size.max(0.1) * weight;
|
|
let strands = (3.0 + b.density.clamp(1.0, 100.0) / 100.0 * 13.0).round() as usize;
|
|
let thick = (size * b.thickness.clamp(1.0, 100.0) / 100.0 * 0.45).max(0.15);
|
|
let flat = matches!(
|
|
b.shape,
|
|
BristleShape::FlatPoint | BristleShape::FlatBlunt | BristleShape::FlatCurve | BristleShape::FlatAngle | BristleShape::FlatFan
|
|
);
|
|
let wobble = (100.0 - b.stiffness.clamp(1.0, 100.0)) / 100.0;
|
|
let ragged = b.length.clamp(25.0, 300.0) / 100.0;
|
|
let opacity = (b.opacity.clamp(1.0, 100.0) / 100.0) as f32;
|
|
let mut out = vec![];
|
|
for t in tracks(bp, tolerance(size)) {
|
|
let l = t.len();
|
|
for k in 0..strands {
|
|
// Round tips concentrate strands near the centre; flat tips spread them evenly.
|
|
let lane = (k as f64 + 0.5) / strands as f64 * 2.0 - 1.0;
|
|
let off = if flat { lane } else { lane.signum() * lane.abs().powf(1.4) } * size / 2.0 + rng.range(-wobble, wobble) * size * 0.08;
|
|
let edge = 1.0 - lane.abs();
|
|
let trim = |r: &mut Rng| r.unit() * ragged * size * (1.2 - edge) * 0.8;
|
|
let (t0, t1) = (trim(&mut rng), trim(&mut rng));
|
|
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
|
|
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
|
|
let nib = Nib::new(0.0, 1.0, w);
|
|
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
|
|
if subs.is_empty() {
|
|
continue;
|
|
}
|
|
let mut n = filled(PathData::new(subs), paint);
|
|
n.opacity = (opacity * rng.range(0.7, 1.0) as f32).clamp(0.0, 1.0);
|
|
out.push(n);
|
|
}
|
|
}
|
|
out
|
|
}
|