mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
continued work
This commit is contained in:
1 parent
6235ea5e10
commit
a7e49bfa72
31 files changed
+5067
-165
No files matched your search
Generated
+26
@@ -837,6 +837,19 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drawcraft-brush"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-color",
|
||||
"drawcraft-doc",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-pathops",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drawcraft-cli"
|
||||
version = "0.1.0"
|
||||
@@ -1052,6 +1065,19 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drawcraft-trace"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-geom",
|
||||
"drawcraft-pathops",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drawcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -16,6 +16,7 @@ drawcraft-geom = { path = "crates/geom" }
|
||||
drawcraft-color = { path = "crates/color" }
|
||||
drawcraft-doc = { path = "crates/doc" }
|
||||
drawcraft-pathops = { path = "crates/pathops" }
|
||||
drawcraft-trace = { path = "crates/trace" }
|
||||
drawcraft-text = { path = "crates/text" }
|
||||
drawcraft-effects = { path = "crates/effects" }
|
||||
drawcraft-render = { path = "crates/render" }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "drawcraft-brush"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DrawCraft brushes: calligraphic, scatter, art, pattern and bristle brush definitions and stroke geometry"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
drawcraft-geom = { workspace = true }
|
||||
drawcraft-color = { workspace = true }
|
||||
drawcraft-doc = { workspace = true }
|
||||
drawcraft-pathops = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 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 drawcraft_color::Paint;
|
||||
use drawcraft_doc::Node;
|
||||
use drawcraft_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
|
||||
}
|
||||
/// Half the swept width across direction `n` (unit).
|
||||
pub fn half_width(&self, n: Vec2) -> f64 {
|
||||
((self.a * self.u.dot(n)).powi(2) + (self.b * self.v.dot(n)).powi(2)).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (pe, te) = *s.last().unwrap();
|
||||
cap(pe, te, half, -half, &mut poly);
|
||||
poly.extend(right.iter().rev());
|
||||
let (ps, ts) = s[0];
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! DrawCraft brushes: definitions (calligraphic, scatter, art, pattern, bristle), the default
|
||||
//! library, and the geometry that turns a brushed stroke into filled art.
|
||||
//!
|
||||
//! Brush definitions live in the document under `Document::unknown["brushes"]` (a JSON array of
|
||||
//! [`Brush`]). A document without that key uses the default library ([`defaults`]); the first
|
||||
//! edit to the library materialises the full list into the document. Strokes refer to brushes by
|
||||
//! name (`StrokeLayer::brush`).
|
||||
//!
|
||||
//! [`stroke_pieces`] is the single source of brush geometry: the renderer paints the pieces it
|
||||
//! returns (caching them per node) and `object.expandBrush` inserts them as real objects.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod calli;
|
||||
mod colorize;
|
||||
mod defaults;
|
||||
pub mod track;
|
||||
mod warp;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_color::Paint;
|
||||
use drawcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use drawcraft_geom::{BezPath, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use colorize::{colorize, tint_node};
|
||||
pub use defaults::defaults;
|
||||
|
||||
/// Key of the brush library in `Document::unknown`.
|
||||
pub const DOC_KEY: &str = "brushes";
|
||||
/// Key of the current brush name (used by the Paintbrush tool) in `Document::unknown`.
|
||||
pub const CURRENT_KEY: &str = "currentBrush";
|
||||
|
||||
/// A named brush.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Brush {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub kind: BrushKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BrushKind {
|
||||
Calligraphic(Calligraphic),
|
||||
Scatter(Scatter),
|
||||
Art(ArtBrush),
|
||||
Pattern(PatternBrush),
|
||||
Bristle(Bristle),
|
||||
}
|
||||
|
||||
impl BrushKind {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
BrushKind::Calligraphic(_) => "Calligraphic",
|
||||
BrushKind::Scatter(_) => "Scatter",
|
||||
BrushKind::Art(_) => "Art",
|
||||
BrushKind::Pattern(_) => "Pattern",
|
||||
BrushKind::Bristle(_) => "Bristle",
|
||||
}
|
||||
}
|
||||
pub fn type_id(&self) -> &'static str {
|
||||
match self {
|
||||
BrushKind::Calligraphic(_) => "calligraphic",
|
||||
BrushKind::Scatter(_) => "scatter",
|
||||
BrushKind::Art(_) => "art",
|
||||
BrushKind::Pattern(_) => "pattern",
|
||||
BrushKind::Bristle(_) => "bristle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How brush art takes the stroke colour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "method")]
|
||||
pub enum Colorization {
|
||||
/// Keep the art's own colours.
|
||||
#[default]
|
||||
None,
|
||||
/// Black becomes the stroke colour, lighter colours become tints of it, white stays white.
|
||||
Tints,
|
||||
/// Black and white stay; mid tones become the stroke colour, darker ones shades of it.
|
||||
TintsAndShades,
|
||||
/// The key colour becomes the stroke colour; other colours rotate by the same hue offset.
|
||||
HueShift { key: drawcraft_color::Color },
|
||||
}
|
||||
|
||||
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
|
||||
/// (the stroke weight scales the brush).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Calligraphic {
|
||||
/// Nib angle in degrees, counter-clockwise from horizontal.
|
||||
pub angle: f64,
|
||||
/// Minor/major axis ratio in percent (1..=100).
|
||||
pub roundness: f64,
|
||||
/// Nib diameter in points.
|
||||
pub size: f64,
|
||||
/// Random variation per stroke: angle (°), roundness (%), size (pt).
|
||||
pub variation: [f64; 3],
|
||||
}
|
||||
|
||||
impl Default for Calligraphic {
|
||||
fn default() -> Self {
|
||||
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Scatter brush: copies of the art along the path.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Scatter {
|
||||
pub art: Node,
|
||||
/// Size range in percent of the art.
|
||||
pub size: (f64, f64),
|
||||
/// Spacing range in percent of the (scaled) art width.
|
||||
pub spacing: (f64, f64),
|
||||
/// Perpendicular offset range in percent of the (scaled) art height.
|
||||
pub scatter: (f64, f64),
|
||||
/// Rotation range in degrees.
|
||||
pub rotation: (f64, f64),
|
||||
/// Rotation relative to the path direction (else to the page).
|
||||
pub rotation_relative_to_path: bool,
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl Default for Scatter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
art: defaults::dot_art(),
|
||||
size: (100.0, 100.0),
|
||||
spacing: (100.0, 100.0),
|
||||
scatter: (0.0, 0.0),
|
||||
rotation: (0.0, 0.0),
|
||||
rotation_relative_to_path: false,
|
||||
colorization: Colorization::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction the art runs in the brush definition.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Direction {
|
||||
#[default]
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
TopToBottom,
|
||||
BottomToTop,
|
||||
}
|
||||
|
||||
/// How art brush art is scaled along the path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "mode")]
|
||||
pub enum ArtScale {
|
||||
/// Keep the art's proportions (length follows the path; width scales with it).
|
||||
Proportional,
|
||||
/// Stretch the art to the path length.
|
||||
#[default]
|
||||
Stretch,
|
||||
/// Keep the ends outside the guides at their size and stretch the middle.
|
||||
/// `start`/`end` are fractions (0..1) of the art length.
|
||||
BetweenGuides { start: f64, end: f64 },
|
||||
}
|
||||
|
||||
/// Art brush: one copy of the art stretched along the whole path.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ArtBrush {
|
||||
pub art: Node,
|
||||
pub direction: Direction,
|
||||
pub scale: ArtScale,
|
||||
/// Width in percent.
|
||||
pub width: f64,
|
||||
pub flip_along: bool,
|
||||
pub flip_across: bool,
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl Default for ArtBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
art: defaults::taper_art(),
|
||||
direction: Direction::LeftToRight,
|
||||
scale: ArtScale::Stretch,
|
||||
width: 100.0,
|
||||
flip_along: false,
|
||||
flip_across: false,
|
||||
colorization: Colorization::Tints,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How pattern tiles fit the path length.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PatternFit {
|
||||
/// Stretch tiles so a whole number fits.
|
||||
#[default]
|
||||
Stretch,
|
||||
/// Keep tile size and add space between tiles.
|
||||
AddSpace,
|
||||
/// Keep tile size; the path is approximated (tiles may overrun slightly).
|
||||
Approximate,
|
||||
}
|
||||
|
||||
/// Pattern brush: tiles repeated along the path, with corner and end tiles.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PatternBrush {
|
||||
pub side: Node,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outer_corner: Option<Node>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inner_corner: Option<Node>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start: Option<Node>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<Node>,
|
||||
/// Scale in percent.
|
||||
pub scale: f64,
|
||||
/// Space between tiles in percent of the tile width.
|
||||
pub spacing: f64,
|
||||
pub fit: PatternFit,
|
||||
pub flip_along: bool,
|
||||
pub flip_across: bool,
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl Default for PatternBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
side: defaults::stitch_tile(),
|
||||
outer_corner: None,
|
||||
inner_corner: None,
|
||||
start: None,
|
||||
end: None,
|
||||
scale: 100.0,
|
||||
spacing: 0.0,
|
||||
fit: PatternFit::Stretch,
|
||||
flip_along: false,
|
||||
flip_across: false,
|
||||
colorization: Colorization::Tints,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bristle tip shapes (names only affect the strand distribution here).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BristleShape {
|
||||
#[default]
|
||||
RoundPoint,
|
||||
FlatPoint,
|
||||
RoundBlunt,
|
||||
FlatBlunt,
|
||||
RoundCurve,
|
||||
FlatCurve,
|
||||
RoundAngle,
|
||||
FlatAngle,
|
||||
RoundFan,
|
||||
FlatFan,
|
||||
}
|
||||
|
||||
/// Bristle brush, approximated by several thin, semi-transparent calligraphic strands.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Bristle {
|
||||
pub shape: BristleShape,
|
||||
/// Brush size in points.
|
||||
pub size: f64,
|
||||
/// Bristle length in percent (25..300) — longer bristles start/end more raggedly.
|
||||
pub length: f64,
|
||||
/// Density in percent (1..100) — number of strands.
|
||||
pub density: f64,
|
||||
/// Thickness in percent (1..100) — strand width.
|
||||
pub thickness: f64,
|
||||
/// Paint opacity in percent (1..100).
|
||||
pub opacity: f64,
|
||||
/// Stiffness in percent (1..100) — how straight strands follow the path.
|
||||
pub stiffness: f64,
|
||||
}
|
||||
|
||||
impl Default for Bristle {
|
||||
fn default() -> Self {
|
||||
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- library ----------
|
||||
|
||||
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
|
||||
pub fn library(doc: &Document) -> Vec<Brush> {
|
||||
match doc.unknown.get(DOC_KEY) {
|
||||
Some(v) => parse_library(v),
|
||||
None => defaults().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored library value (malformed entries are skipped).
|
||||
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Store `lib` as the document's brush library.
|
||||
pub fn store(doc: &mut Document, lib: &[Brush]) {
|
||||
doc.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Find a brush by name.
|
||||
pub fn find(doc: &Document, name: &str) -> Option<Brush> {
|
||||
library(doc).into_iter().find(|b| b.name == name)
|
||||
}
|
||||
|
||||
/// The current brush name (Paintbrush tool, Brushes panel highlight).
|
||||
pub fn current(doc: &Document) -> Option<String> {
|
||||
doc.unknown.get(CURRENT_KEY).and_then(|v| v.as_str()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// A unique name based on `base` ("Base", "Base 2", …).
|
||||
pub fn unique_name(lib: &[Brush], base: &str) -> String {
|
||||
if !lib.iter().any(|b| b.name == base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !lib.iter().any(|b| &b.name == n)).unwrap()
|
||||
}
|
||||
|
||||
// ---------- geometry ----------
|
||||
|
||||
/// Flattening tolerance for brush geometry, relative to its size.
|
||||
fn tolerance(scale: f64) -> f64 {
|
||||
(0.02 * scale).clamp(0.01, 0.25)
|
||||
}
|
||||
|
||||
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
|
||||
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
|
||||
/// caller's job (`StrokeLayer::opacity`).
|
||||
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
|
||||
let mut out = match &brush.kind {
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
|
||||
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
|
||||
};
|
||||
let colorization = match &brush.kind {
|
||||
BrushKind::Art(a) => a.colorization,
|
||||
BrushKind::Scatter(s) => s.colorization,
|
||||
BrushKind::Pattern(p) => p.colorization,
|
||||
_ => Colorization::None,
|
||||
};
|
||||
for n in &mut out {
|
||||
colorize(n, colorization, &stroke.paint);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
|
||||
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
|
||||
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
let bp = node_bezpath(n)?;
|
||||
let lib = library(doc);
|
||||
let mut out = vec![];
|
||||
let mut any = false;
|
||||
for item in &n.appearance.items {
|
||||
let AppearanceItem::Stroke(st) = item else { continue };
|
||||
let Some(b) = st.brush.as_deref().and_then(|name| lib.iter().find(|x| x.name == name)) else { continue };
|
||||
if !st.visible || st.paint.is_none() {
|
||||
any = true;
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
for mut p in stroke_pieces(b, &bp, st) {
|
||||
p.opacity *= st.opacity;
|
||||
if st.blend != drawcraft_color::BlendMode::Normal {
|
||||
p.blend = st.blend;
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
/// The outline of a path or compound path as one BezPath.
|
||||
pub fn node_bezpath(n: &Node) -> Option<BezPath> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
let mut bp = BezPath::new();
|
||||
for c in children {
|
||||
if let Some(p) = c.path_data() {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` have a stroke with a brush?
|
||||
pub fn has_brush(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && s.visible))
|
||||
}
|
||||
|
||||
/// Expand a brushed path: a group of [the path with its fills and unbrushed strokes (if any
|
||||
/// paint remains), the brush art…]. Ids are 0 (the caller re-ids). `None` if not brushed.
|
||||
pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
|
||||
let pieces = node_pieces(doc, n)?;
|
||||
let mut children: Vec<Arc<Node>> = vec![];
|
||||
let mut base = n.clone();
|
||||
base.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some()));
|
||||
let painted = base.appearance.items.iter().any(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
|
||||
});
|
||||
if painted {
|
||||
base.id = NodeId(0);
|
||||
children.push(Arc::new(base));
|
||||
}
|
||||
for mut p in pieces {
|
||||
if let NodeKind::Path { path, .. } = &mut p.kind
|
||||
&& p.appearance.stroke().is_none()
|
||||
&& let Ok(clean) = drawcraft_pathops::try_normalize(path, drawcraft_geom::FillRule::NonZero)
|
||||
&& !clean.is_empty()
|
||||
{
|
||||
*path = clean;
|
||||
}
|
||||
children.push(Arc::new(p));
|
||||
}
|
||||
let mut g = Node::group(NodeId(0), children);
|
||||
g.opacity = n.opacity;
|
||||
g.blend = n.blend;
|
||||
g.name = n.name.clone();
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// A filled path node painted with `paint` (brush output).
|
||||
pub(crate) fn filled(path: drawcraft_geom::PathData, paint: &Paint) -> Node {
|
||||
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], effects: vec![] })
|
||||
}
|
||||
|
||||
/// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).
|
||||
pub fn preview_path(w: f64, h: f64) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
||||
bp.move_to((x0, h * 0.5));
|
||||
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.05), (x0 + (x1 - x0) * 0.65, h * 0.95), (x1, h * 0.5));
|
||||
bp
|
||||
}
|
||||
|
||||
/// Union of the geometric bounds of `nodes`.
|
||||
pub fn pieces_bounds(nodes: &[Node]) -> Option<Rect> {
|
||||
nodes.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.visual_bounds()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Arc-length parameterised polylines ("tracks") that brushes lay art along.
|
||||
//!
|
||||
//! A track is one flattened subpath. `frame(s)` gives the point and unit tangent at arc length
|
||||
//! `s`; tangents are blended across the gentle vertices of flattened curves (so warped art bends
|
||||
//! smoothly) but stay discontinuous at real corners (so art folds there, like Illustrator).
|
||||
|
||||
use drawcraft_geom::{BezPath, PathEl, Point, Vec2};
|
||||
|
||||
/// Turn angle (degrees) above which a vertex is a corner.
|
||||
pub const CORNER_DEG: f64 = 30.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
/// Vertices; closed tracks repeat the first vertex at the end.
|
||||
pub verts: Vec<Point>,
|
||||
/// Cumulative arc length at each vertex.
|
||||
pub cum: Vec<f64>,
|
||||
pub closed: bool,
|
||||
/// Unit direction of each segment.
|
||||
pub dirs: Vec<Vec2>,
|
||||
/// Smoothed unit tangent at each vertex.
|
||||
pub tangents: Vec<Vec2>,
|
||||
/// Is the vertex a corner (turn > [`CORNER_DEG`])? Open ends are never corners.
|
||||
pub corner: Vec<bool>,
|
||||
}
|
||||
|
||||
fn unit(v: Vec2) -> Vec2 {
|
||||
let l = v.hypot();
|
||||
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l }
|
||||
}
|
||||
|
||||
/// Left-hand normal in y-down document space (for a path running +x it points +y).
|
||||
pub fn normal(t: Vec2) -> Vec2 {
|
||||
Vec2::new(-t.y, t.x)
|
||||
}
|
||||
|
||||
impl Track {
|
||||
fn new(mut pts: Vec<Point>, closed: bool) -> Option<Track> {
|
||||
pts.dedup_by(|a, b| a.distance(*b) < 1e-9);
|
||||
if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) < 1e-9 {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let closed = closed && pts.len() > 2;
|
||||
if closed {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
let nseg = pts.len() - 1;
|
||||
let dirs: Vec<Vec2> = (0..nseg).map(|i| unit(pts[i + 1] - pts[i])).collect();
|
||||
let mut cum = vec![0.0];
|
||||
for i in 0..nseg {
|
||||
cum.push(cum[i] + pts[i].distance(pts[i + 1]));
|
||||
}
|
||||
let mut tangents = Vec::with_capacity(nseg + 1);
|
||||
let mut corner = Vec::with_capacity(nseg + 1);
|
||||
let cos_limit = CORNER_DEG.to_radians().cos();
|
||||
for i in 0..=nseg {
|
||||
let incoming = if i > 0 {
|
||||
Some(dirs[i - 1])
|
||||
} else if closed {
|
||||
Some(dirs[nseg - 1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outgoing = if i < nseg {
|
||||
Some(dirs[i])
|
||||
} else if closed {
|
||||
Some(dirs[0])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (incoming, outgoing) {
|
||||
(Some(a), Some(b)) => {
|
||||
corner.push(a.dot(b) < cos_limit);
|
||||
tangents.push(unit(a + b));
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => {
|
||||
corner.push(false);
|
||||
tangents.push(a);
|
||||
}
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
Some(Track { verts: pts, cum, closed, dirs, tangents, corner })
|
||||
}
|
||||
|
||||
pub fn len(&self) -> f64 {
|
||||
*self.cum.last().unwrap_or(&0.0)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() <= 1e-9
|
||||
}
|
||||
pub fn segments(&self) -> usize {
|
||||
self.dirs.len()
|
||||
}
|
||||
|
||||
/// Incoming and outgoing unit directions at vertex `i`.
|
||||
pub fn in_out(&self, i: usize) -> (Vec2, Vec2) {
|
||||
let n = self.segments();
|
||||
let inc = if i > 0 { self.dirs[i - 1] } else if self.closed { self.dirs[n - 1] } else { self.dirs[0] };
|
||||
let out = if i < n { self.dirs[i] } else if self.closed { self.dirs[0] } else { self.dirs[n - 1] };
|
||||
(inc, out)
|
||||
}
|
||||
|
||||
/// Arc-length positions of the corner vertices (closed tracks list vertex 0 once).
|
||||
pub fn corner_positions(&self) -> Vec<(usize, f64)> {
|
||||
let last = if self.closed { self.segments() } else { self.segments() + 1 };
|
||||
(0..last).filter(|i| self.corner[*i]).map(|i| (i, self.cum[i])).collect()
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`. Open tracks extrapolate beyond their ends along
|
||||
/// the end tangents; closed tracks wrap.
|
||||
pub fn frame(&self, s: f64) -> (Point, Vec2) {
|
||||
let l = self.len();
|
||||
let n = self.segments();
|
||||
let s = if self.closed && l > 0.0 { s.rem_euclid(l) } else { s };
|
||||
if s <= 0.0 && !self.closed {
|
||||
let t = self.dirs[0];
|
||||
return (self.verts[0] + t * s, t);
|
||||
}
|
||||
if s >= l && !self.closed {
|
||||
let t = self.dirs[n - 1];
|
||||
return (self.verts[n] + t * (s - l), t);
|
||||
}
|
||||
// Binary search the segment.
|
||||
let i = match self.cum.binary_search_by(|c| c.partial_cmp(&s).unwrap_or(std::cmp::Ordering::Less)) {
|
||||
Ok(i) => i.min(n - 1),
|
||||
Err(i) => i.saturating_sub(1).min(n - 1),
|
||||
};
|
||||
let seg = self.cum[i + 1] - self.cum[i];
|
||||
let u = if seg > 1e-12 { ((s - self.cum[i]) / seg).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = self.verts[i].lerp(self.verts[i + 1], u);
|
||||
let t0 = if self.corner[i] { self.dirs[i] } else { self.tangents[i] };
|
||||
let t1 = if self.corner[i + 1] { self.dirs[i] } else { self.tangents[i + 1] };
|
||||
(p, unit(t0 + (t1 - t0) * u))
|
||||
}
|
||||
|
||||
/// The point at arc length `s`, offset `d` along the left normal.
|
||||
pub fn map(&self, s: f64, d: f64) -> Point {
|
||||
let (p, t) = self.frame(s);
|
||||
p + normal(t) * d
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten `bp` into tracks (one per subpath) with `tol` accuracy.
|
||||
pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
|
||||
let mut out = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let mut flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Track>| {
|
||||
if let Some(t) = Track::new(std::mem::take(cur), closed) {
|
||||
out.push(t);
|
||||
}
|
||||
};
|
||||
let mut els = vec![];
|
||||
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| els.push(el));
|
||||
for el in els {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut cur, false, &mut out);
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => flush(&mut cur, true, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut cur, false, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers (SplitMix64), seeded from the path so a brushed stroke
|
||||
/// looks the same on every frame and in exports.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Rng(seed ^ 0x9e37_79b9_7f4a_7c15)
|
||||
}
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// Uniform in [0, 1).
|
||||
pub fn unit(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
/// Uniform in [a, b] (a == b returns a without consuming randomness differently).
|
||||
pub fn range(&mut self, a: f64, b: f64) -> f64 {
|
||||
let u = self.unit();
|
||||
if (b - a).abs() < 1e-12 { a } else { a + (b - a) * u }
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for a path (FNV-1a over its coordinates' bits, quantised to 1/64 pt).
|
||||
pub fn seed_of(bp: &BezPath, salt: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut eat = |v: u64| {
|
||||
h ^= v;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
};
|
||||
for b in salt.bytes() {
|
||||
eat(b as u64);
|
||||
}
|
||||
for el in bp.elements() {
|
||||
let pts: Vec<Point> = match *el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p],
|
||||
PathEl::QuadTo(a, b) => vec![a, b],
|
||||
PathEl::CurveTo(a, b, c) => vec![a, b, c],
|
||||
PathEl::ClosePath => vec![],
|
||||
};
|
||||
for p in pts {
|
||||
eat((p.x * 64.0).round() as i64 as u64);
|
||||
eat((p.y * 64.0).round() as i64 as u64);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn straight_track_frames() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((100.0, 0.0));
|
||||
let t = &tracks(&bp, 0.1)[0];
|
||||
assert_eq!(t.len(), 100.0);
|
||||
let (p, d) = t.frame(25.0);
|
||||
assert_eq!(p, Point::new(25.0, 0.0));
|
||||
assert_eq!(d, Vec2::new(1.0, 0.0));
|
||||
assert_eq!(t.map(50.0, 5.0), Point::new(50.0, 5.0));
|
||||
assert_eq!(t.map(110.0, 0.0), Point::new(110.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_square_has_four_corners() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
bp.line_to((10.0, 10.0));
|
||||
bp.line_to((0.0, 10.0));
|
||||
bp.close_path();
|
||||
let t = &tracks(&bp, 0.1)[0];
|
||||
assert!(t.closed);
|
||||
assert_eq!(t.len(), 40.0);
|
||||
assert_eq!(t.corner_positions().len(), 4);
|
||||
assert_eq!(t.frame(45.0).0, Point::new(5.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rng_is_deterministic() {
|
||||
let (mut a, mut b) = (Rng::new(7), Rng::new(7));
|
||||
for _ in 0..10 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
let v = Rng::new(1).range(2.0, 3.0);
|
||||
assert!((2.0..=3.0).contains(&v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Art, scatter and pattern brushes: art bent along the path by arc length.
|
||||
//!
|
||||
//! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by
|
||||
//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and
|
||||
//! subdivided first so long straight edges bend with the path instead of cutting chords.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{Node, NodeKind};
|
||||
use drawcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
|
||||
|
||||
use crate::track::{Rng, Track, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
|
||||
|
||||
/// Longest mapped segment (points in document space).
|
||||
const MAX_SEG: f64 = 1.5;
|
||||
/// Hard cap on subdivisions per art segment.
|
||||
const MAX_SUB: usize = 4000;
|
||||
|
||||
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
|
||||
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
|
||||
/// `width_scale`. Text, images and symbol instances can't bend and are dropped.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option<Node> {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(width_scale);
|
||||
match &mut out.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
*path = warp_path(path, f, flat_tol, max_du);
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
|
||||
if kids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
*children = kids;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData {
|
||||
let bp = p.to_bezpath();
|
||||
let mut subs = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let mut els = vec![];
|
||||
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
|
||||
let mut finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
|
||||
let pts = std::mem::take(cur);
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let mut dense = vec![pts[0]];
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
|
||||
for j in 1..=k {
|
||||
let q = a.lerp(b, j as f64 / k as f64);
|
||||
if closed && i == segs - 1 && j == k {
|
||||
break;
|
||||
}
|
||||
dense.push(q);
|
||||
}
|
||||
}
|
||||
let mapped: Vec<Point> = dense.into_iter().map(f).collect();
|
||||
subs.push(SubPath::polyline(&mapped, closed));
|
||||
};
|
||||
for e in els {
|
||||
match e {
|
||||
PathEl::MoveTo(q) => {
|
||||
finish(&mut cur, false, &mut subs);
|
||||
cur.push(q);
|
||||
}
|
||||
PathEl::LineTo(q) => cur.push(q),
|
||||
PathEl::ClosePath => {
|
||||
if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 {
|
||||
cur.pop();
|
||||
}
|
||||
finish(&mut cur, true, &mut subs);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish(&mut cur, false, &mut subs);
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
/// Art turned so it runs left → right, flipped as requested; returns it with its bounds.
|
||||
fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
|
||||
let mut a = art.clone();
|
||||
let rot = match dir {
|
||||
Direction::LeftToRight => Affine::IDENTITY,
|
||||
Direction::RightToLeft => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Direction::TopToBottom => Affine::rotate(-std::f64::consts::FRAC_PI_2),
|
||||
Direction::BottomToTop => Affine::rotate(std::f64::consts::FRAC_PI_2),
|
||||
};
|
||||
let flip = Affine::scale_non_uniform(if flip_along { -1.0 } else { 1.0 }, if flip_across { -1.0 } else { 1.0 });
|
||||
let m = flip * rot;
|
||||
if m != Affine::IDENTITY {
|
||||
a.transform(m, false);
|
||||
}
|
||||
let b = a.geometric_bounds()?;
|
||||
(b.width() > 1e-9).then_some((a, b))
|
||||
}
|
||||
|
||||
/// Piecewise-linear map from art x (offset from the art's left edge) to arc length.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct AlongMap {
|
||||
/// Art-space breakpoints (x0 = 0, x1, x2, x3 = width) and their arc lengths.
|
||||
xs: [f64; 4],
|
||||
ss: [f64; 4],
|
||||
}
|
||||
|
||||
impl AlongMap {
|
||||
fn linear(width: f64, s0: f64, s1: f64) -> Self {
|
||||
AlongMap { xs: [0.0, 0.0, width, width], ss: [s0, s0, s1, s1] }
|
||||
}
|
||||
pub fn at(&self, x: f64) -> f64 {
|
||||
let (xs, ss) = (&self.xs, &self.ss);
|
||||
if x <= xs[0] {
|
||||
return ss[0] + (x - xs[0]) * self.slope(0);
|
||||
}
|
||||
for i in 0..3 {
|
||||
if x <= xs[i + 1] {
|
||||
let w = xs[i + 1] - xs[i];
|
||||
return if w <= 1e-12 { ss[i + 1] } else { ss[i] + (x - xs[i]) / w * (ss[i + 1] - ss[i]) };
|
||||
}
|
||||
}
|
||||
ss[3] + (x - xs[3]) * self.slope(2)
|
||||
}
|
||||
fn slope(&self, i: usize) -> f64 {
|
||||
let w = self.xs[i + 1] - self.xs[i];
|
||||
if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w }
|
||||
}
|
||||
/// Largest along-scale (arc length per art unit) of the pieces.
|
||||
fn max_scale(&self) -> f64 {
|
||||
(0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`.
|
||||
fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> {
|
||||
let cy = b.center().y;
|
||||
let x0 = b.x0;
|
||||
let f = move |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
}
|
||||
|
||||
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
|
||||
let cross = weight * a.width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let w = b.width();
|
||||
let (along, cross) = match a.scale {
|
||||
ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, 0.0, l), k * a.width / 100.0)
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, 0.0, l), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(n) = bend(&t, &art, b, along, cross) {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
|
||||
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
|
||||
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
let mut s: Option<f64> = None;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
guard += 1;
|
||||
if guard > 20000 {
|
||||
break;
|
||||
}
|
||||
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
|
||||
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
|
||||
let step = aw * size * spacing;
|
||||
let pos = match s {
|
||||
None => step / 2.0,
|
||||
Some(prev) => prev + step,
|
||||
};
|
||||
if pos > l + 1e-9 || (t.closed && pos >= l - 1e-9 && s.is_some()) {
|
||||
break;
|
||||
}
|
||||
s = Some(pos);
|
||||
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
|
||||
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
|
||||
let (p, tg) = t.frame(pos);
|
||||
let place = p + crate::track::normal(tg) * off;
|
||||
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.
|
||||
let base = if sc.rotation_relative_to_path { tg.y.atan2(tg.x) } else { 0.0 };
|
||||
let xf = Affine::translate(place.to_vec2()) * Affine::rotate(base - rot) * Affine::scale(size) * Affine::translate(-b.center().to_vec2());
|
||||
let mut n = sc.art.clone();
|
||||
n.transform(xf, true);
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A tile placed on [s0, s1] of the track (stretched along, `cross` across).
|
||||
fn tile_on(t: &Track, tile: &(Node, Rect), s0: f64, s1: f64, cross: f64) -> Option<Node> {
|
||||
bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross)
|
||||
}
|
||||
|
||||
pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across);
|
||||
let Some(side) = orient(&pb.side) else { return vec![] };
|
||||
let k = weight * pb.scale.max(1.0) / 100.0;
|
||||
let outer = pb.outer_corner.as_ref().and_then(orient);
|
||||
let inner = pb.inner_corner.as_ref().and_then(orient);
|
||||
let start = pb.start.as_ref().and_then(orient);
|
||||
let end = pb.end.as_ref().and_then(orient);
|
||||
let tw = side.1.width() * k;
|
||||
let gap = tw * pb.spacing.max(0.0) / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let corners = t.corner_positions();
|
||||
// Corner tiles, centred on the corner and turned to the bisector.
|
||||
let mut corner_half = 0.0_f64;
|
||||
for &(i, _) in &corners {
|
||||
let (a, b) = t.in_out(i);
|
||||
// Turning towards the left normal puts the tile's +y side on the inside.
|
||||
let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) };
|
||||
let Some((art, bb)) = tile else { continue };
|
||||
corner_half = corner_half.max(bb.width() * k / 2.0);
|
||||
let bis = a + b;
|
||||
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
|
||||
let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(bis.y.atan2(bis.x)) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2());
|
||||
let mut n = art.clone();
|
||||
n.transform(xf, true);
|
||||
out.push(n);
|
||||
}
|
||||
// Runs between corners (closed tracks wrap past the end).
|
||||
let mut bounds: Vec<f64> = corners.iter().map(|c| c.1).collect();
|
||||
let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() {
|
||||
vec![(0.0, l, false, false)]
|
||||
} else if t.closed {
|
||||
let first = bounds[0];
|
||||
bounds.push(first + l);
|
||||
bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect()
|
||||
} else {
|
||||
let mut v = vec![0.0];
|
||||
v.extend(bounds);
|
||||
v.push(l);
|
||||
let n = v.len();
|
||||
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect()
|
||||
};
|
||||
let nruns = runs.len();
|
||||
let corner_tiles = inner.is_some() || outer.is_some();
|
||||
for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() {
|
||||
if ca && corner_tiles {
|
||||
a += corner_half;
|
||||
}
|
||||
if cb && corner_tiles {
|
||||
b -= corner_half;
|
||||
}
|
||||
if !t.closed && ri == 0
|
||||
&& let Some(st) = &start
|
||||
{
|
||||
let w = st.1.width() * k;
|
||||
if b - a > w {
|
||||
out.extend(tile_on(&t, st, a, a + w, k));
|
||||
a += w;
|
||||
}
|
||||
}
|
||||
if !t.closed && ri + 1 == nruns
|
||||
&& let Some(en) = &end
|
||||
{
|
||||
let w = en.1.width() * k;
|
||||
if b - a > w {
|
||||
out.extend(tile_on(&t, en, b - w, b, k));
|
||||
b -= w;
|
||||
}
|
||||
}
|
||||
let avail = b - a;
|
||||
if avail <= 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let unit = tw + gap;
|
||||
match pb.fit {
|
||||
PatternFit::Stretch => {
|
||||
let n = ((avail + gap) / unit).round().max(1.0) as usize;
|
||||
let f = avail / (n as f64 * tw + (n as f64 - 1.0).max(0.0) * gap);
|
||||
let (w, g) = (tw * f, gap * f);
|
||||
for i in 0..n.min(20000) {
|
||||
let s0 = a + i as f64 * (w + g);
|
||||
out.extend(tile_on(&t, &side, s0, s0 + w, k));
|
||||
}
|
||||
}
|
||||
PatternFit::AddSpace => {
|
||||
let n = ((avail + gap) / unit).floor().max(1.0) as usize;
|
||||
let g = if n > 1 { (avail - n as f64 * tw) / (n as f64 - 1.0) } else { 0.0 };
|
||||
let lead = if n == 1 { (avail - tw) / 2.0 } else { 0.0 };
|
||||
for i in 0..n.min(20000) {
|
||||
let s0 = a + lead + i as f64 * (tw + g.max(0.0));
|
||||
out.extend(tile_on(&t, &side, s0, s0 + tw, k));
|
||||
}
|
||||
}
|
||||
PatternFit::Approximate => {
|
||||
let n = ((avail + gap) / unit).round().max(1.0) as usize;
|
||||
let used = n as f64 * tw + (n as f64 - 1.0) * gap;
|
||||
let lead = (avail - used) / 2.0;
|
||||
for i in 0..n.min(20000) {
|
||||
let s0 = a + lead + i as f64 * unit;
|
||||
out.extend(tile_on(&t, &side, s0, s0 + tw, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Arc length of the first track of `bp` (tests, previews).
|
||||
pub fn path_length(bp: &BezPath) -> f64 {
|
||||
tracks(bp, 0.05).iter().map(|t| t.len()).sum()
|
||||
}
|
||||
@@ -130,9 +130,16 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
}
|
||||
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
(!opt.path_only && fill_contains(&bp, drawcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod appearance;
|
||||
pub mod hit;
|
||||
pub mod live;
|
||||
pub mod node;
|
||||
pub mod selection;
|
||||
pub mod text;
|
||||
@@ -18,6 +19,7 @@ pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLa
|
||||
pub use drawcraft_color as color;
|
||||
pub use drawcraft_geom as geom;
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+72
-2
@@ -7,6 +7,7 @@ use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
@@ -204,6 +205,26 @@ pub enum NodeKind {
|
||||
symbol: String,
|
||||
xf: Affine,
|
||||
},
|
||||
/// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate
|
||||
/// steps are evaluated on demand (`live::blend_expand`).
|
||||
Blend {
|
||||
children: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
spec: BlendSpec,
|
||||
},
|
||||
/// Live envelope distortion of `content`.
|
||||
Envelope {
|
||||
content: Vec<Arc<Node>>,
|
||||
kind: EnvelopeKind,
|
||||
/// Envelope Options → Fidelity (0–100).
|
||||
#[serde(default = "crate::live::default_fidelity")]
|
||||
fidelity: f64,
|
||||
/// Edit Contents mode (the content, not the envelope, is edited).
|
||||
#[serde(default)]
|
||||
editing: bool,
|
||||
},
|
||||
/// Gradient mesh.
|
||||
Mesh(GradientMesh),
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -266,13 +287,21 @@ impl Node {
|
||||
}
|
||||
pub fn children(&self) -> Option<&Vec<Arc<Node>>> {
|
||||
match &self.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children),
|
||||
NodeKind::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Node>>> {
|
||||
match &mut self.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children),
|
||||
NodeKind::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -295,6 +324,9 @@ impl Node {
|
||||
NodeKind::Text(_) => "Text",
|
||||
NodeKind::Image(_) => "Image",
|
||||
NodeKind::SymbolInstance { .. } => "Symbol",
|
||||
NodeKind::Blend { .. } => "Blend",
|
||||
NodeKind::Envelope { .. } => "Envelope",
|
||||
NodeKind::Mesh(_) => "Mesh",
|
||||
}
|
||||
}
|
||||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||||
@@ -335,6 +367,12 @@ impl Node {
|
||||
NodeKind::Text(t) => t.bounds(),
|
||||
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
|
||||
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = crate::live::nodes_bounds(children);
|
||||
drawcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
}
|
||||
}
|
||||
/// Visual bounds (includes stroke outset).
|
||||
@@ -344,6 +382,15 @@ impl Node {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.visual_bounds()))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.visual_bounds()));
|
||||
let o = crate::live::max_outset(children);
|
||||
drawcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
|
||||
}
|
||||
NodeKind::Envelope { content, .. } => {
|
||||
let o = crate::live::max_outset(content);
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
@@ -375,6 +422,29 @@ impl Node {
|
||||
NodeKind::Text(t) => t.transform(a),
|
||||
NodeKind::Image(im) => im.xf = a * im.xf,
|
||||
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
|
||||
NodeKind::Blend { children, spec } => {
|
||||
for c in children.iter_mut() {
|
||||
Arc::make_mut(c).transform(a, scale_strokes);
|
||||
}
|
||||
if let Some(s) = &mut spec.spine {
|
||||
s.transform(a);
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => {
|
||||
for c in content.iter_mut() {
|
||||
Arc::make_mut(c).transform(a, scale_strokes);
|
||||
}
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
for p in points.iter_mut() {
|
||||
*p = a * *p;
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.transform(a),
|
||||
}
|
||||
}
|
||||
/// Visit this node and all descendants depth first (paint order).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Parameter parsing, deterministic noise and path-mapping helpers.
|
||||
|
||||
use drawcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
use drawcraft_geom::{Anchor, CubicBez, ParamCurve, Point, Rect, SubPath, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default.
|
||||
@@ -69,52 +69,8 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
|
||||
if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }
|
||||
}
|
||||
|
||||
fn poly_len(c: &CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
|
||||
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
|
||||
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
|
||||
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
|
||||
let max_piece = max_piece.max(1e-3);
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let segs = sp.segment_count();
|
||||
let mut res: Vec<Anchor> = Vec::new();
|
||||
let mut pending_in: Option<Point> = None;
|
||||
for i in 0..n {
|
||||
let a = &sp.anchors[i];
|
||||
let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in));
|
||||
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
|
||||
if i < segs {
|
||||
let c = seg_cubic(sp, i);
|
||||
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
|
||||
for j in 0..k {
|
||||
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
|
||||
res.last_mut().unwrap().h_out = f(sub.p1);
|
||||
if j + 1 < k {
|
||||
res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() });
|
||||
} else {
|
||||
pending_in = Some(f(sub.p2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = pending_in
|
||||
&& sp.closed
|
||||
{
|
||||
res[0].h_in = h;
|
||||
}
|
||||
let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect();
|
||||
subs.push(SubPath::new(anchors, sp.closed));
|
||||
}
|
||||
PathData::new(subs)
|
||||
}
|
||||
/// Non-linear path mapping (shared with live envelopes in `drawcraft-doc`).
|
||||
pub use drawcraft_doc::live::map_nonlinear;
|
||||
|
||||
/// Smooth subpath through `pts` (Catmull-Rom tangents).
|
||||
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
|
||||
|
||||
+1
-100
@@ -3,111 +3,12 @@
|
||||
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
|
||||
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use drawcraft_geom::{PathData, Point, Rect};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::util::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WarpStyle {
|
||||
Arc,
|
||||
ArcLower,
|
||||
ArcUpper,
|
||||
Arch,
|
||||
Bulge,
|
||||
ShellLower,
|
||||
ShellUpper,
|
||||
Flag,
|
||||
Wave,
|
||||
Fish,
|
||||
Rise,
|
||||
Fisheye,
|
||||
Inflate,
|
||||
Squeeze,
|
||||
Twist,
|
||||
}
|
||||
|
||||
impl WarpStyle {
|
||||
/// From the id suffix (`arc`, `arcLower`, …).
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
use WarpStyle::*;
|
||||
Some(match s {
|
||||
"arc" => Arc,
|
||||
"arcLower" => ArcLower,
|
||||
"arcUpper" => ArcUpper,
|
||||
"arch" => Arch,
|
||||
"bulge" => Bulge,
|
||||
"shellLower" => ShellLower,
|
||||
"shellUpper" => ShellUpper,
|
||||
"flag" => Flag,
|
||||
"wave" => Wave,
|
||||
"fish" => Fish,
|
||||
"rise" => Rise,
|
||||
"fisheye" => Fisheye,
|
||||
"inflate" => Inflate,
|
||||
"squeeze" => Squeeze,
|
||||
"twist" => Twist,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
use WarpStyle::*;
|
||||
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
|
||||
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
|
||||
let (mut x2, mut y2) = match style {
|
||||
Arc => {
|
||||
if b.abs() < 1e-6 {
|
||||
(x, y)
|
||||
} else {
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
|
||||
let sweep = b.abs() * PI;
|
||||
let r0 = 2.0 / sweep;
|
||||
let yy = if b > 0.0 { y } else { -y };
|
||||
let a = x / r0;
|
||||
let r = r0 - yy;
|
||||
let (s, c) = a.sin_cos();
|
||||
let (nx, ny) = (r * s, r0 - r * c);
|
||||
(nx, if b > 0.0 { ny } else { -ny })
|
||||
}
|
||||
}
|
||||
ArcLower => (x, y + b * par * t),
|
||||
ArcUpper => (x, y - b * par * (1.0 - t)),
|
||||
Arch => (x, y - b * par),
|
||||
Bulge => (x, y + b * par * y),
|
||||
ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t),
|
||||
ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)),
|
||||
Flag => (x, y + 0.5 * b * (PI * x).sin()),
|
||||
Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)),
|
||||
Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())),
|
||||
Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b),
|
||||
Fisheye => {
|
||||
let r2 = x * x + y * y;
|
||||
let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 };
|
||||
(x * s, y * s)
|
||||
}
|
||||
Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)),
|
||||
Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)),
|
||||
Twist => {
|
||||
let r = (x * x + y * y).sqrt();
|
||||
let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0);
|
||||
let (s, c) = a.sin_cos();
|
||||
(x * c - y * s, x * s + y * c)
|
||||
}
|
||||
};
|
||||
// Perspective-like distortion: horizontal narrows one side, vertical one end.
|
||||
if dh != 0.0 {
|
||||
y2 *= (1.0 + dh * x2 * 0.5).max(0.0);
|
||||
}
|
||||
if dv != 0.0 {
|
||||
x2 *= (1.0 + dv * y2 * 0.5).max(0.0);
|
||||
}
|
||||
(x2, y2)
|
||||
}
|
||||
pub use drawcraft_doc::live::{WarpStyle, warp_point};
|
||||
|
||||
/// Apply a warp to `path` using box `b`.
|
||||
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
|
||||
@@ -5,6 +5,7 @@ mod create;
|
||||
mod docmenu;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
mod recolor;
|
||||
mod effectcmd;
|
||||
mod file;
|
||||
mod layer;
|
||||
@@ -116,6 +117,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(edit::specs());
|
||||
v.extend(recolor::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(object::specs());
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Edit → Edit Colors → Recolor Artwork: list the selection's colours and remap them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "recolor.colors", "Artwork Colors", [], None, "{} → {colors: [{hex, count}]} unique colours used by the selection (fills, strokes, gradient stops, text)", has_selection, colors),
|
||||
cmd!("recolor.apply", "Recolor Artwork", ["Edit", "Edit Colors"], None, "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", has_selection, apply),
|
||||
]
|
||||
}
|
||||
|
||||
fn visit_paints(n: &mut Node, f: &mut impl FnMut(&mut Color)) {
|
||||
let mut paint = |p: &mut Paint| match p {
|
||||
Paint::Solid { color, .. } => f(color),
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
f(&mut s.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(fl) => paint(&mut fl.paint),
|
||||
AppearanceItem::Stroke(st) => paint(&mut st.paint),
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
paint(&mut r.style.fill);
|
||||
paint(&mut r.style.stroke);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
visit_paints(std::sync::Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for id in &st.selection.objects {
|
||||
if let Some(n) = st.doc.node(*id) {
|
||||
let mut n = n.clone();
|
||||
visit_paints(&mut n, &mut |c| *counts.entry(c.to_hex()).or_default() += 1);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
Ok(json!({ "colors": v.iter().map(|(h, n)| json!({"hex": h, "count": n})).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let map: BTreeMap<String, Color> = p
|
||||
.get("map")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| bad("recolor.apply", "missing map"))?
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((Color::from_hex(k)?.to_hex(), color_value(v)?)))
|
||||
.collect();
|
||||
let ids = s.doc()?.selection.objects.clone();
|
||||
let mut changed = 0usize;
|
||||
s.edit("Recolor Artwork", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
visit_paints(n, &mut |c| {
|
||||
if let Some(to) = map.get(&c.to_hex()) {
|
||||
*c = *to;
|
||||
changed += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
@@ -127,3 +127,21 @@ fn export_for_screens_writes_every_combination() {
|
||||
assert!(files.iter().any(|f| f.as_str().unwrap().ends_with("[email protected]")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolor_maps_fills_strokes_gradients() {
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let c = s.execute("recolor.colors", &json!({})).unwrap();
|
||||
let hexes: Vec<&str> = c["colors"].as_array().unwrap().iter().map(|v| v["hex"].as_str().unwrap()).collect();
|
||||
assert!(hexes.contains(&"#ff0000") && hexes.contains(&"#0000ff") && hexes.contains(&"#000000"));
|
||||
s.execute("recolor.apply", &json!({"map": {"#FF0000": "#00ff00"}})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone();
|
||||
match n.appearance.fill_paint() {
|
||||
drawcraft_color::Paint::Gradient(g) => assert_eq!(g.gradient.stops[0].color.to_hex(), "#00ff00"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Every-line composer: a Knuth–Plass style total-fit line breaker for justified paragraphs.
|
||||
//!
|
||||
//! Boxes are the paragraph's glyphs, glue is its spaces (word spacing may stretch to 150% and
|
||||
//! shrink to 80%), and penalties are break opportunities after hyphens/dashes/CJK and at
|
||||
//! hyphenation points. The breaks minimise the sum of squared (badness + penalty) demerits over
|
||||
//! the whole paragraph, so loose lines are traded for evenly spaced ones.
|
||||
|
||||
use crate::shape::SGlyph;
|
||||
|
||||
const STRETCH: f64 = 0.5;
|
||||
const SHRINK: f64 = 0.2;
|
||||
const HYPHEN_PENALTY: f64 = 50.0;
|
||||
const DOUBLE_HYPHEN_DEMERITS: f64 = 3000.0;
|
||||
const LINE_PENALTY: f64 = 10.0;
|
||||
|
||||
/// A place a line may end: glyph index `end` (exclusive), plus the width of the hyphen that is
|
||||
/// added when breaking there (0 = no hyphen).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Breakpoint {
|
||||
pub end: usize,
|
||||
pub hyphen: f64,
|
||||
}
|
||||
|
||||
struct Node {
|
||||
pos: usize,
|
||||
line: usize,
|
||||
demerits: f64,
|
||||
hyphenated: bool,
|
||||
prev: Option<usize>,
|
||||
}
|
||||
|
||||
/// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`;
|
||||
/// `cands` are the break opportunities in increasing order (the paragraph end is added).
|
||||
/// `justify_last`: the last line is justified too (Justify All). Returns `(end, hyphen)` per line,
|
||||
/// or `None` if no set of breaks fits within `tolerance` (maximum stretch ratio).
|
||||
pub(crate) fn compose(g: &[SGlyph], width: &dyn Fn(usize) -> f64, cands: &[Breakpoint], justify_last: bool, tolerance: f64) -> Option<Vec<(usize, bool)>> {
|
||||
let n = g.len();
|
||||
if n == 0 {
|
||||
return Some(vec![]);
|
||||
}
|
||||
// Prefix sums of advances and of space advances.
|
||||
let mut w = vec![0.0; n + 1];
|
||||
let mut sp = vec![0.0; n + 1];
|
||||
for (i, gl) in g.iter().enumerate() {
|
||||
w[i + 1] = w[i] + gl.adv;
|
||||
sp[i + 1] = sp[i] + if gl.is_space() { gl.adv } else { 0.0 };
|
||||
}
|
||||
// Trailing spaces are dropped at a break.
|
||||
let trim = |mut b: usize, a: usize| {
|
||||
while b > a && g[b - 1].is_space() {
|
||||
b -= 1;
|
||||
}
|
||||
b
|
||||
};
|
||||
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
|
||||
all.push(Breakpoint { end: n, hyphen: 0.0 });
|
||||
let mut nodes = vec![Node { pos: 0, line: 0, demerits: 0.0, hyphenated: false, prev: None }];
|
||||
let mut active: Vec<usize> = vec![0];
|
||||
for bp in &all {
|
||||
let last = bp.end == n;
|
||||
let mut best: Vec<(usize, f64, usize)> = vec![]; // (line, demerits, from)
|
||||
let mut keep = Vec::with_capacity(active.len());
|
||||
for &ai in &active {
|
||||
let a = &nodes[ai];
|
||||
let t = trim(bp.end, a.pos);
|
||||
let natural = w[t] - w[a.pos] + bp.hyphen;
|
||||
let spaces = sp[t] - sp[a.pos];
|
||||
let lw = width(a.line);
|
||||
let ratio = if natural < lw {
|
||||
if last && !justify_last {
|
||||
0.0
|
||||
} else if spaces > 0.0 {
|
||||
(lw - natural) / (spaces * STRETCH)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
} else if natural > lw {
|
||||
if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if ratio < -1.0 {
|
||||
// Too long already; longer lines from this node only get worse.
|
||||
continue;
|
||||
}
|
||||
keep.push(ai);
|
||||
if ratio > tolerance && !(last && ratio.is_infinite() && justify_last) {
|
||||
continue;
|
||||
}
|
||||
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 };
|
||||
let penalty = if bp.hyphen > 0.0 { HYPHEN_PENALTY } else { 0.0 };
|
||||
let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty;
|
||||
if bp.hyphen > 0.0 && a.hyphenated {
|
||||
d += DOUBLE_HYPHEN_DEMERITS;
|
||||
}
|
||||
let total = a.demerits + d;
|
||||
let line = a.line + 1;
|
||||
match best.iter_mut().find(|b| b.0 == line) {
|
||||
Some(b) if b.1 <= total => {}
|
||||
Some(b) => *b = (line, total, ai),
|
||||
None => best.push((line, total, ai)),
|
||||
}
|
||||
}
|
||||
active = keep;
|
||||
for (line, demerits, from) in best {
|
||||
nodes.push(Node { pos: bp.end, line, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) });
|
||||
active.push(nodes.len() - 1);
|
||||
}
|
||||
if active.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let end = nodes.iter().enumerate().filter(|(_, nd)| nd.pos == n && nd.prev.is_some()).min_by(|a, b| a.1.demerits.total_cmp(&b.1.demerits))?.0;
|
||||
let mut out = vec![];
|
||||
let mut cur = Some(end);
|
||||
while let Some(i) = cur {
|
||||
if nodes[i].prev.is_some() {
|
||||
out.push((nodes[i].pos, nodes[i].hyphenated));
|
||||
}
|
||||
cur = nodes[i].prev;
|
||||
}
|
||||
out.reverse();
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Rich-text editing on style runs (range replace, range styling with run splitting) and
|
||||
//! character/word/paragraph navigation on plain text. Shared by the engine commands
|
||||
//! (`text.editRange`, `text.setRangeStyle`) and the Type tool.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use drawcraft_doc::{CharStyle, TextRun};
|
||||
|
||||
/// Total byte length of `runs`.
|
||||
pub fn runs_len(runs: &[TextRun]) -> usize {
|
||||
runs.iter().map(|r| r.text.len()).sum()
|
||||
}
|
||||
|
||||
/// Clamp `b` to `s.len()` and back to a char boundary.
|
||||
pub fn floor_char(s: &str, b: usize) -> usize {
|
||||
let mut b = b.min(s.len());
|
||||
while b > 0 && !s.is_char_boundary(b) {
|
||||
b -= 1;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Style of the character at `byte` (the character before it when `byte` is at a run end or the
|
||||
/// text end), used for inserted text.
|
||||
pub fn style_at(runs: &[TextRun], byte: usize) -> CharStyle {
|
||||
let mut off = 0;
|
||||
let mut last = None;
|
||||
for r in runs {
|
||||
let end = off + r.text.len();
|
||||
if byte < end || (byte == end && byte == off) {
|
||||
return r.style.clone();
|
||||
}
|
||||
if !r.text.is_empty() {
|
||||
last = Some(&r.style);
|
||||
if byte == end {
|
||||
return r.style.clone();
|
||||
}
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
last.or(runs.first().map(|r| &r.style)).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Style for text typed over `start..end` (Illustrator: the first replaced character's style, or
|
||||
/// the character before the caret).
|
||||
pub fn insertion_style(runs: &[TextRun], start: usize, end: usize) -> CharStyle {
|
||||
if end > start {
|
||||
// The character at `start` (not the one before it).
|
||||
let mut off = 0;
|
||||
for r in runs {
|
||||
if start < off + r.text.len() {
|
||||
return r.style.clone();
|
||||
}
|
||||
off += r.text.len();
|
||||
}
|
||||
}
|
||||
style_at(runs, start)
|
||||
}
|
||||
|
||||
/// Merge adjacent runs with equal styles and drop empty runs (keeping one run so the object
|
||||
/// remembers its style).
|
||||
pub fn normalize(runs: &mut Vec<TextRun>) {
|
||||
let first_style = runs.first().map(|r| r.style.clone());
|
||||
let mut out: Vec<TextRun> = Vec::with_capacity(runs.len());
|
||||
for r in runs.drain(..) {
|
||||
if r.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.style == r.style => l.text.push_str(&r.text),
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(TextRun { text: String::new(), style: first_style.unwrap_or_default() });
|
||||
}
|
||||
*runs = out;
|
||||
}
|
||||
|
||||
/// Split runs so that `byte` falls on a run boundary; returns the index of the run starting at
|
||||
/// `byte` (== `runs.len()` at the end).
|
||||
fn split_at(runs: &mut Vec<TextRun>, byte: usize) -> usize {
|
||||
let mut off = 0;
|
||||
for i in 0..runs.len() {
|
||||
let len = runs[i].text.len();
|
||||
if byte == off {
|
||||
return i;
|
||||
}
|
||||
if byte < off + len {
|
||||
let at = floor_char(&runs[i].text, byte - off);
|
||||
let tail = runs[i].text.split_off(at);
|
||||
let style = runs[i].style.clone();
|
||||
runs.insert(i + 1, TextRun { text: tail, style });
|
||||
return i + 1;
|
||||
}
|
||||
off += len;
|
||||
}
|
||||
runs.len()
|
||||
}
|
||||
|
||||
/// Replace `start..end` of the plain text with `insert`, styled like the replaced text (see
|
||||
/// [`insertion_style`]). Offsets are clamped to char boundaries. Returns the caret after the
|
||||
/// insertion.
|
||||
pub fn replace_range(runs: &mut Vec<TextRun>, start: usize, end: usize, insert: &str) -> usize {
|
||||
let style = insertion_style(runs, start, end);
|
||||
replace_range_styled(runs, start, end, &[TextRun { text: insert.to_string(), style }])
|
||||
}
|
||||
|
||||
/// Replace `start..end` with styled runs (paste with formatting). Returns the caret after them.
|
||||
pub fn replace_range_styled(runs: &mut Vec<TextRun>, start: usize, end: usize, insert: &[TextRun]) -> usize {
|
||||
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let (mut a, mut b) = (floor_char(&plain, start), floor_char(&plain, end));
|
||||
if a > b {
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
let i = split_at(runs, a);
|
||||
let j = split_at(runs, b);
|
||||
runs.splice(i..j, insert.iter().cloned());
|
||||
normalize(runs);
|
||||
a + insert.iter().map(|r| r.text.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Apply `f` to the character style of `start..end`, splitting runs at the range ends.
|
||||
pub fn style_range(runs: &mut Vec<TextRun>, start: usize, end: usize, mut f: impl FnMut(&mut CharStyle)) {
|
||||
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let (mut a, mut b) = (floor_char(&plain, start), floor_char(&plain, end));
|
||||
if a > b {
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
if a == b {
|
||||
// Empty text: style the (single, empty) run so the next typed text uses it.
|
||||
if plain.is_empty() {
|
||||
for r in runs.iter_mut() {
|
||||
f(&mut r.style);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let i = split_at(runs, a);
|
||||
let j = split_at(runs, b);
|
||||
for r in &mut runs[i..j] {
|
||||
f(&mut r.style);
|
||||
}
|
||||
normalize(runs);
|
||||
}
|
||||
|
||||
/// Styled copy of `start..end` (for copy/cut with formatting).
|
||||
pub fn slice_runs(runs: &[TextRun], start: usize, end: usize) -> Vec<TextRun> {
|
||||
let mut out = vec![];
|
||||
let mut off = 0;
|
||||
for r in runs {
|
||||
let (rs, re) = (off, off + r.text.len());
|
||||
let (a, b) = (start.max(rs), end.min(re));
|
||||
if a < b {
|
||||
let t = &r.text[floor_char(&r.text, a - rs)..floor_char(&r.text, b - rs)];
|
||||
out.push(TextRun { text: t.to_string(), style: r.style.clone() });
|
||||
}
|
||||
off = re;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- navigation on plain text ----------
|
||||
|
||||
pub fn prev_char(s: &str, i: usize) -> usize {
|
||||
let i = floor_char(s, i);
|
||||
s[..i].char_indices().next_back().map(|(k, _)| k).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn next_char(s: &str, i: usize) -> usize {
|
||||
let i = floor_char(s, i);
|
||||
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '\'' || c == '’'
|
||||
}
|
||||
|
||||
/// Start of the previous word (Cmd/Alt+Left).
|
||||
pub fn prev_word(s: &str, i: usize) -> usize {
|
||||
let i = floor_char(s, i);
|
||||
let chars: Vec<(usize, char)> = s[..i].char_indices().collect();
|
||||
let mut k = chars.len();
|
||||
while k > 0 && !is_word_char(chars[k - 1].1) {
|
||||
k -= 1;
|
||||
}
|
||||
while k > 0 && is_word_char(chars[k - 1].1) {
|
||||
k -= 1;
|
||||
}
|
||||
chars.get(k).map(|c| c.0).unwrap_or(i)
|
||||
}
|
||||
|
||||
/// End of the next word (Cmd/Alt+Right).
|
||||
pub fn next_word(s: &str, i: usize) -> usize {
|
||||
let i = floor_char(s, i);
|
||||
let mut it = s[i..].char_indices().peekable();
|
||||
while it.peek().is_some_and(|(_, c)| !is_word_char(*c)) {
|
||||
it.next();
|
||||
}
|
||||
while it.peek().is_some_and(|(_, c)| is_word_char(*c)) {
|
||||
it.next();
|
||||
}
|
||||
it.peek().map(|(k, _)| i + k).unwrap_or(s.len())
|
||||
}
|
||||
|
||||
/// The word (or run of spaces / single punctuation) at `i` (double-click selection).
|
||||
pub fn word_at(s: &str, i: usize) -> Range<usize> {
|
||||
let i = floor_char(s, i);
|
||||
let cur = s[i..].chars().next().or_else(|| s[..i].chars().next_back());
|
||||
let Some(c) = cur else { return i..i };
|
||||
let class = |c: char| if is_word_char(c) { 0 } else if c.is_whitespace() && c != '\n' { 1 } else { 2 };
|
||||
let k = class(c);
|
||||
if k == 2 {
|
||||
let a = if s[i..].starts_with(c) { i } else { i - c.len_utf8() };
|
||||
return a..a + c.len_utf8();
|
||||
}
|
||||
let at = if s[i..].chars().next().is_some_and(|d| class(d) == k) { i } else { i - c.len_utf8() };
|
||||
let mut a = at;
|
||||
for (p, d) in s[..at].char_indices().rev() {
|
||||
if class(d) != k {
|
||||
break;
|
||||
}
|
||||
a = p;
|
||||
}
|
||||
let mut b = at;
|
||||
for (p, d) in s[at..].char_indices() {
|
||||
if class(d) != k {
|
||||
break;
|
||||
}
|
||||
b = at + p + d.len_utf8();
|
||||
}
|
||||
a..b
|
||||
}
|
||||
|
||||
/// The paragraph containing `i`, excluding its `\n` (triple-click selection).
|
||||
pub fn paragraph_at(s: &str, i: usize) -> Range<usize> {
|
||||
let i = floor_char(s, i);
|
||||
let a = s[..i].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
||||
let b = s[i..].find('\n').map(|p| i + p).unwrap_or(s.len());
|
||||
a..b
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! OpenType feature selection (the OpenType panel), resolved per character style.
|
||||
|
||||
use drawcraft_doc::CharStyle;
|
||||
use harfrust::{Feature, Tag};
|
||||
|
||||
/// OpenType features applied during shaping. Kerning, `case` and ligature suppression are driven
|
||||
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OtFeatures {
|
||||
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
|
||||
pub ligatures: bool,
|
||||
/// Contextual alternates (`calt`).
|
||||
pub contextual: bool,
|
||||
/// Discretionary ligatures (`dlig`).
|
||||
pub discretionary_ligatures: bool,
|
||||
/// Small capitals (`smcp`).
|
||||
pub small_caps: bool,
|
||||
/// Diagonal fractions (`frac`).
|
||||
pub fractions: bool,
|
||||
/// Oldstyle figures (`onum`).
|
||||
pub oldstyle_figures: bool,
|
||||
/// Tabular figures (`tnum`).
|
||||
pub tabular_figures: bool,
|
||||
/// Ordinals (`ordn`).
|
||||
pub ordinals: bool,
|
||||
/// Swashes (`swsh`).
|
||||
pub swash: bool,
|
||||
}
|
||||
|
||||
impl Default for OtFeatures {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ligatures: true,
|
||||
contextual: true,
|
||||
discretionary_ligatures: false,
|
||||
small_caps: false,
|
||||
fractions: false,
|
||||
oldstyle_figures: false,
|
||||
tabular_figures: false,
|
||||
ordinals: false,
|
||||
swash: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn f(tag: &[u8; 4], on: bool) -> Feature {
|
||||
Feature::new(Tag::new(tag), on as u32, ..)
|
||||
}
|
||||
|
||||
impl OtFeatures {
|
||||
/// Parse a feature list like `["dlig", "smcp", "-liga"]` (unknown tags are ignored).
|
||||
pub fn from_tags<'a>(tags: impl IntoIterator<Item = &'a str>) -> Self {
|
||||
let mut o = Self::default();
|
||||
for t in tags {
|
||||
let (on, t) = match t.strip_prefix('-') {
|
||||
Some(r) => (false, r),
|
||||
None => (true, t.strip_prefix('+').unwrap_or(t)),
|
||||
};
|
||||
match t {
|
||||
"liga" => o.ligatures = on,
|
||||
"calt" => o.contextual = on,
|
||||
"dlig" => o.discretionary_ligatures = on,
|
||||
"smcp" => o.small_caps = on,
|
||||
"frac" => o.fractions = on,
|
||||
"onum" => o.oldstyle_figures = on,
|
||||
"tnum" => o.tabular_figures = on,
|
||||
"ordn" => o.ordinals = on,
|
||||
"swsh" => o.swash = on,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The harfrust features for text in style `st`.
|
||||
pub(crate) fn resolve(&self, st: &CharStyle) -> Vec<Feature> {
|
||||
let mut v = Vec::with_capacity(8);
|
||||
if st.kerning.is_some() {
|
||||
v.push(f(b"kern", false));
|
||||
}
|
||||
let liga = self.ligatures && st.tracking.abs() < 1e-9;
|
||||
if !liga {
|
||||
v.push(f(b"liga", false));
|
||||
v.push(f(b"clig", false));
|
||||
}
|
||||
if !self.contextual {
|
||||
v.push(f(b"calt", false));
|
||||
}
|
||||
if st.all_caps {
|
||||
v.push(f(b"case", true));
|
||||
}
|
||||
for (on, tag) in [
|
||||
(self.discretionary_ligatures, b"dlig"),
|
||||
(self.small_caps, b"smcp"),
|
||||
(self.fractions, b"frac"),
|
||||
(self.oldstyle_figures, b"onum"),
|
||||
(self.tabular_figures, b"tnum"),
|
||||
(self.ordinals, b"ordn"),
|
||||
(self.swash, b"swsh"),
|
||||
] {
|
||||
if on {
|
||||
v.push(f(tag, true));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
+142
-2
@@ -78,6 +78,33 @@ impl FontFace {
|
||||
pub fn covers(&self, c: char) -> bool {
|
||||
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
|
||||
}
|
||||
/// Units per em.
|
||||
pub fn units_per_em(&self) -> f64 {
|
||||
self.upem
|
||||
}
|
||||
/// (ascent, descent) in font units, both positive.
|
||||
pub fn vertical_metrics(&self) -> (f64, f64) {
|
||||
(self.ascent, self.descent)
|
||||
}
|
||||
/// Every mapped character and its glyph id, sorted by code point (the Glyphs panel).
|
||||
pub fn chars(&self) -> Vec<(char, u32)> {
|
||||
let Some(f) = self.skrifa() else { return vec![] };
|
||||
let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect();
|
||||
v.sort_unstable_by_key(|x| x.0);
|
||||
v.dedup_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
/// Advance width of glyph `gid` in font units.
|
||||
pub fn advance(&self, gid: u32) -> f64 {
|
||||
self.skrifa()
|
||||
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
|
||||
.map(|a| a as f64)
|
||||
.unwrap_or(self.upem * 0.5)
|
||||
}
|
||||
/// Glyph id for `c` (0 = .notdef).
|
||||
pub fn glyph_for(&self, c: char) -> u32 {
|
||||
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -94,8 +121,49 @@ pub struct FontDb {
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// System fallback state: scanned yet, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SysFallback {
|
||||
enabled: bool,
|
||||
scanned: bool,
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Segoe UI",
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Apple SD Gothic Neo",
|
||||
"Heiti SC",
|
||||
"STHeiti",
|
||||
"Microsoft YaHei",
|
||||
"Yu Gothic",
|
||||
"Malgun Gothic",
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans CJK JP",
|
||||
"Arial Unicode MS",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols",
|
||||
"Noto Sans Symbols2",
|
||||
"Noto Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Apple Color Emoji",
|
||||
"Noto Color Emoji",
|
||||
];
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
|
||||
@@ -189,9 +257,22 @@ impl FontDb {
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the lazy system-font fallback for characters the loaded fonts lack
|
||||
/// (native only; on by default).
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
|
||||
/// Process-wide database preloaded with the bundled fonts.
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
@@ -356,16 +437,75 @@ impl FontDb {
|
||||
.map(|f| (*f).clone())
|
||||
}
|
||||
|
||||
/// First face (fallback family first, then load order) that covers `c`.
|
||||
/// First face (fallback family first, then load order) that covers `c`; on native, system
|
||||
/// fonts are cataloged and loaded lazily the first time no loaded face covers a character.
|
||||
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback(c, exclude) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback(c) {
|
||||
return self.loaded_fallback(c, exclude);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback(&self, c: char) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
if !sys.scanned {
|
||||
sys.scanned = true;
|
||||
if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
|
||||
drop(sys);
|
||||
self.load_system_fonts();
|
||||
}
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let cataloged: Vec<String> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
if covered(self) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
|
||||
if hit && self.add_font(data) > 0 && covered(self) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub(crate) fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
|
||||
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
|
||||
//! letters before or [`MIN_AFTER`] after the hyphen.
|
||||
|
||||
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
|
||||
pub const MIN_WORD: usize = 6;
|
||||
pub const MIN_BEFORE: usize = 3;
|
||||
pub const MIN_AFTER: usize = 3;
|
||||
|
||||
fn is_vowel(c: char) -> bool {
|
||||
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||
}
|
||||
|
||||
/// Consonant pairs that stay together at the start of a syllable ("gra-phy", "tea-cher").
|
||||
fn onset_pair(a: char, b: char) -> bool {
|
||||
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
|
||||
matches!((a, b), ('c' | 's' | 't' | 'p' | 'w' | 'g', 'h') | ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r') | ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l') | ('q', 'u'))
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
|
||||
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
|
||||
pub fn hyphen_points(word: &str) -> Vec<usize> {
|
||||
let c: Vec<char> = word.chars().collect();
|
||||
let n = c.len();
|
||||
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
|
||||
return vec![];
|
||||
}
|
||||
// A capitalised run like "NASA" or a proper name stays whole only when all caps.
|
||||
if c.iter().all(|c| c.is_uppercase()) {
|
||||
return vec![];
|
||||
}
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
let mut out = vec![];
|
||||
for i in MIN_BEFORE..=n.saturating_sub(MIN_AFTER) {
|
||||
if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() {
|
||||
continue;
|
||||
}
|
||||
let ok = if v[i - 1] && !v[i] {
|
||||
// V-CV: a single consonant (or an onset pair) followed by a vowel starts the syllable.
|
||||
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
|
||||
} else if !v[i - 1] && !v[i] {
|
||||
// VC-CV: split a consonant cluster after the first consonant unless it's an onset pair.
|
||||
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i])
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if ok {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `word` with `-` at every hyphenation point (for tests and diagnostics).
|
||||
pub fn hyphenate_word(word: &str) -> String {
|
||||
let pts = hyphen_points(word);
|
||||
let mut s = String::with_capacity(word.len() + pts.len());
|
||||
for (i, ch) in word.chars().enumerate() {
|
||||
if pts.contains(&i) {
|
||||
s.push('-');
|
||||
}
|
||||
s.push(ch);
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -4,10 +4,11 @@ use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::CharStyle;
|
||||
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
|
||||
use crate::features::OtFeatures;
|
||||
use crate::fontdb::{FontDb, FontFace};
|
||||
|
||||
/// A shaped glyph with all character-style effects resolved, in points (y down).
|
||||
@@ -44,8 +45,33 @@ impl SGlyph {
|
||||
}
|
||||
/// A line may break after this glyph.
|
||||
pub fn break_after(&self) -> bool {
|
||||
self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch)
|
||||
self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN) || is_cjk(self.ch)
|
||||
}
|
||||
/// A soft (discretionary) hyphen: invisible unless a line breaks after it.
|
||||
pub fn is_soft_hyphen(&self) -> bool {
|
||||
self.ch == SOFT_HYPHEN
|
||||
}
|
||||
/// Part of a word that may be hyphenated (letters and apostrophes).
|
||||
pub fn is_letter(&self) -> bool {
|
||||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
|
||||
/// A visible hyphen in the face and size of `g`, placed at the end of `g`'s cluster (zero source
|
||||
/// length) for a line broken inside a word.
|
||||
pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
let gid = ['-', '\u{2010}', SOFT_HYPHEN].into_iter().map(|c| g.face.glyph_for(c)).find(|&id| id != 0).unwrap_or(0);
|
||||
let mut h = g.clone();
|
||||
h.gid = gid;
|
||||
h.adv = g.face.advance(gid) * g.sx;
|
||||
h.dx = 0.0;
|
||||
h.dy = 0.0;
|
||||
h.byte = g.byte + g.len;
|
||||
h.len = 0;
|
||||
h.ch = '-';
|
||||
h
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
@@ -61,7 +87,14 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||||
}
|
||||
|
||||
/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style.
|
||||
pub(crate) fn shape_range(db: &FontDb, text: &str, range: Range<usize>, runs: &[(Range<usize>, &CharStyle)], out: &mut Vec<SGlyph>) {
|
||||
pub(crate) fn shape_range(
|
||||
db: &FontDb,
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
runs: &[(Range<usize>, &CharStyle)],
|
||||
feats: &OtFeatures,
|
||||
out: &mut Vec<SGlyph>,
|
||||
) {
|
||||
for (ri, (rr, st)) in runs.iter().enumerate() {
|
||||
let a = rr.start.max(range.start);
|
||||
let b = rr.end.min(range.end);
|
||||
@@ -89,14 +122,14 @@ pub(crate) fn shape_range(db: &FontDb, text: &str, range: Range<usize>, runs: &[
|
||||
// Combining marks stay with their base.
|
||||
if face.id() != seg_face.id() && !is_mark(c) {
|
||||
if i > seg_start {
|
||||
shape_segment(text, seg_start..i, ri, st, &seg_face, out);
|
||||
shape_segment(text, seg_start..i, ri, st, &seg_face, feats, out);
|
||||
}
|
||||
seg_start = i;
|
||||
seg_face = face;
|
||||
}
|
||||
}
|
||||
if b > seg_start {
|
||||
shape_segment(text, seg_start..b, ri, st, &seg_face, out);
|
||||
shape_segment(text, seg_start..b, ri, st, &seg_face, feats, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +138,7 @@ fn is_mark(c: char) -> bool {
|
||||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
|
||||
}
|
||||
|
||||
fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, face: &Arc<FontFace>, out: &mut Vec<SGlyph>) {
|
||||
fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, face: &Arc<FontFace>, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
|
||||
let seg = &text[range.clone()];
|
||||
let size = st.size.max(0.0);
|
||||
let k = size / face.upem;
|
||||
@@ -134,7 +167,7 @@ fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, fa
|
||||
}
|
||||
buf.set_direction(Direction::LeftToRight);
|
||||
buf.guess_segment_properties();
|
||||
let feats: Vec<Feature> = if st.kerning.is_some() { vec![Feature::new(Tag::new(b"kern"), 0, ..)] } else { Vec::new() };
|
||||
let feats: Vec<Feature> = feats.resolve(st);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
@@ -162,8 +195,11 @@ fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, fa
|
||||
// Cluster end: the next larger cluster value in the segment, else the segment end.
|
||||
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
let ch = first_char(cl);
|
||||
let mut adv = xa as f64 * k * hs;
|
||||
if last_in_cluster {
|
||||
if ch == SOFT_HYPHEN {
|
||||
adv = 0.0;
|
||||
} else if last_in_cluster {
|
||||
adv += tracking + manual_kern;
|
||||
}
|
||||
out.push(SGlyph {
|
||||
@@ -182,7 +218,7 @@ fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, fa
|
||||
ascent,
|
||||
descent,
|
||||
leading,
|
||||
ch: first_char(cl),
|
||||
ch,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "drawcraft-trace"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DrawCraft Image Trace: raster to vector tracing (quantize, boundary follow, polygon, Bezier fit)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
drawcraft-geom = { workspace = true }
|
||||
drawcraft-pathops = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Boundary following on the pixel-crack grid.
|
||||
//!
|
||||
//! Vertices are pixel corners `(x, y)` with `0 ≤ x ≤ w`, `0 ≤ y ≤ h`. Every edge between an inside
|
||||
//! and an outside pixel becomes a directed edge with the inside pixel on its right (y down), so
|
||||
//! outer boundaries run clockwise and holes counter-clockwise. At a saddle (two inside pixels
|
||||
//! touching only diagonally) the walk turns right, which keeps diagonal neighbours apart — the
|
||||
//! same 4-connectivity used to label components.
|
||||
|
||||
/// A closed boundary loop.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Loop {
|
||||
/// Corner vertices in order (the loop closes back to the first).
|
||||
pub pts: Vec<(i32, i32)>,
|
||||
/// Twice the signed area (positive = outer boundary, negative = hole).
|
||||
pub area2: i64,
|
||||
}
|
||||
|
||||
/// One 4-connected component of the mask: its outer loop and holes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Component {
|
||||
pub outer: Loop,
|
||||
pub holes: Vec<Loop>,
|
||||
/// Inside pixel count.
|
||||
pub pixels: usize,
|
||||
}
|
||||
|
||||
const R: u8 = 1;
|
||||
const D: u8 = 2;
|
||||
const L: u8 = 4;
|
||||
const U: u8 = 8;
|
||||
|
||||
fn right_of(d: u8) -> u8 {
|
||||
match d {
|
||||
R => D,
|
||||
D => L,
|
||||
L => U,
|
||||
_ => R,
|
||||
}
|
||||
}
|
||||
|
||||
fn step(d: u8) -> (i32, i32) {
|
||||
match d {
|
||||
R => (1, 0),
|
||||
D => (0, 1),
|
||||
L => (-1, 0),
|
||||
_ => (0, -1),
|
||||
}
|
||||
}
|
||||
|
||||
/// 4-connected component labels (`u32::MAX` outside) and per-component pixel counts.
|
||||
fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let mut comp = vec![u32::MAX; w * h];
|
||||
let mut sizes = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
for s in 0..w * h {
|
||||
if !mask[s] || comp[s] != u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = sizes.len() as u32;
|
||||
let mut n = 0usize;
|
||||
comp[s] = id;
|
||||
stack.push(s);
|
||||
while let Some(i) = stack.pop() {
|
||||
n += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut go = |j: usize| {
|
||||
if mask[j] && comp[j] == u32::MAX {
|
||||
comp[j] = id;
|
||||
stack.push(j);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
go(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
go(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
go(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
go(i + w);
|
||||
}
|
||||
}
|
||||
sizes.push(n);
|
||||
}
|
||||
(comp, sizes)
|
||||
}
|
||||
|
||||
/// Trace every component of `mask` (row-major, `w × h`).
|
||||
pub fn trace_mask(mask: &[bool], w: usize, h: usize) -> Vec<Component> {
|
||||
assert_eq!(mask.len(), w * h);
|
||||
let (comp, sizes) = label(mask, w, h);
|
||||
let vw = w + 1;
|
||||
let mut dirs = vec![0u8; vw * (h + 1)];
|
||||
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if !mask[y * w + x] {
|
||||
continue;
|
||||
}
|
||||
let (xi, yi) = (x as isize, y as isize);
|
||||
if !inside(xi, yi - 1) {
|
||||
dirs[y * vw + x] |= R;
|
||||
}
|
||||
if !inside(xi + 1, yi) {
|
||||
dirs[y * vw + x + 1] |= D;
|
||||
}
|
||||
if !inside(xi, yi + 1) {
|
||||
dirs[(y + 1) * vw + x + 1] |= L;
|
||||
}
|
||||
if !inside(xi - 1, yi) {
|
||||
dirs[(y + 1) * vw + x] |= U;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut outers: Vec<Option<Loop>> = vec![None; sizes.len()];
|
||||
let mut holes: Vec<Vec<Loop>> = vec![Vec::new(); sizes.len()];
|
||||
for start in 0..dirs.len() {
|
||||
// Every loop has an R edge (the top edge of some inside pixel); start there so the loop
|
||||
// closes exactly (right-turn rule at the start vertex, too).
|
||||
while dirs[start] & R != 0 {
|
||||
let (sx, sy) = ((start % vw) as i32, (start / vw) as i32);
|
||||
let owner = comp[sy as usize * w + sx as usize];
|
||||
dirs[start] &= !R;
|
||||
let mut pts = vec![(sx, sy)];
|
||||
let (mut x, mut y) = (sx + 1, sy);
|
||||
let mut d = R;
|
||||
let mut area2: i64 = 0;
|
||||
let mut prev = (sx, sy);
|
||||
loop {
|
||||
area2 += prev.0 as i64 * y as i64 - x as i64 * prev.1 as i64;
|
||||
prev = (x, y);
|
||||
let v = y as usize * vw + x as usize;
|
||||
let at_start = (x, y) == (sx, sy);
|
||||
let mut cand = dirs[v];
|
||||
if at_start {
|
||||
cand |= R;
|
||||
}
|
||||
let next = if cand.count_ones() == 1 {
|
||||
cand
|
||||
} else if cand & right_of(d) != 0 {
|
||||
right_of(d)
|
||||
} else if cand & d != 0 {
|
||||
d
|
||||
} else {
|
||||
// left turn
|
||||
right_of(right_of(right_of(d)))
|
||||
};
|
||||
if at_start && next == R {
|
||||
break;
|
||||
}
|
||||
if next == 0 || dirs[v] & next == 0 {
|
||||
// Malformed (cannot happen for a valid mask); stop safely.
|
||||
break;
|
||||
}
|
||||
dirs[v] &= !next;
|
||||
// Keep only turn vertices.
|
||||
if next != d {
|
||||
pts.push((x, y));
|
||||
}
|
||||
let (dx, dy) = step(next);
|
||||
x += dx;
|
||||
y += dy;
|
||||
d = next;
|
||||
}
|
||||
let l = Loop { pts, area2 };
|
||||
if owner == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if l.area2 > 0 {
|
||||
outers[owner as usize] = Some(l);
|
||||
} else {
|
||||
holes[owner as usize].push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(sizes.len());
|
||||
for (i, o) in outers.into_iter().enumerate() {
|
||||
if let Some(outer) = o {
|
||||
out.push(Component { outer, holes: std::mem::take(&mut holes[i]), pixels: sizes[i] });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Loop → polygon → Bézier curves.
|
||||
|
||||
use drawcraft_geom::{AnchorKind, PathData, Point, SubPath};
|
||||
use drawcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
|
||||
use crate::contour::Loop;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FitOptions {
|
||||
pub polygon_tol: f64,
|
||||
pub fit_tol: f64,
|
||||
pub corner_angle: f64,
|
||||
pub snap_lines: bool,
|
||||
}
|
||||
|
||||
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return p.distance(a);
|
||||
}
|
||||
let t = ((p - a).dot(ab) / l2).clamp(0.0, 1.0);
|
||||
p.distance(a + ab * t)
|
||||
}
|
||||
|
||||
/// Douglas–Peucker on `pts[a..=b]`, marking kept indices.
|
||||
fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) {
|
||||
let mut stack = vec![(a, b)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
if b <= a + 1 {
|
||||
continue;
|
||||
}
|
||||
let (pa, pb) = (pts[a], pts[b % pts.len()]);
|
||||
let mut best = (0.0, a);
|
||||
for i in a + 1..b {
|
||||
let d = seg_dist(pts[i], pa, pb);
|
||||
if d > best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
}
|
||||
if best.0 > tol {
|
||||
keep[best.1] = true;
|
||||
stack.push((a, best.1));
|
||||
stack.push((best.1, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The polygon approximating a closed loop of pixel-corner vertices within `tol`.
|
||||
pub(crate) fn polygon(loop_pts: &[(i32, i32)], tol: f64) -> Vec<Point> {
|
||||
let pts: Vec<Point> = loop_pts.iter().map(|&(x, y)| Point::new(x as f64, y as f64)).collect();
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return pts;
|
||||
}
|
||||
// Split the closed loop at vertex 0 and the vertex farthest from it.
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(pts[0]).total_cmp(&pts[j].distance_squared(pts[0]))).unwrap_or(n / 2);
|
||||
let mut ext = pts.clone();
|
||||
ext.push(pts[0]);
|
||||
let mut keep = vec![false; n + 1];
|
||||
keep[0] = true;
|
||||
keep[far] = true;
|
||||
rdp(&ext, 0, far, tol, &mut keep);
|
||||
rdp(&ext, far, n, tol, &mut keep);
|
||||
let mut out: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
// Vertex 0 was forced; drop it when it's not needed.
|
||||
if out.len() > 3 {
|
||||
let (p, a, b) = (out[0], out[out.len() - 1], out[1]);
|
||||
if seg_dist(p, a, b) <= tol * 0.5 {
|
||||
out.remove(0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fit one loop into a closed Bézier subpath (pixel coordinates).
|
||||
pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option<SubPath> {
|
||||
let poly = polygon(&l.pts, o.polygon_tol);
|
||||
if poly.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let sp = SubPath::polyline(&poly, true);
|
||||
let fitted =
|
||||
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
|
||||
let mut sp = fitted.subpaths.into_iter().next()?;
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if o.snap_lines {
|
||||
snap_to_lines(&mut sp, o.fit_tol.max(0.75));
|
||||
}
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// Retract the handles of curves whose control points lie within `tol` of their chord.
|
||||
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
|
||||
let n = sp.anchors.len();
|
||||
for i in 0..sp.segment_count() {
|
||||
let j = (i + 1) % n;
|
||||
let (a, b) = (sp.anchors[i], sp.anchors[j]);
|
||||
if !a.has_out() && !b.has_in() {
|
||||
continue;
|
||||
}
|
||||
if seg_dist(a.h_out, a.p, b.p) <= tol && seg_dist(b.h_in, a.p, b.p) <= tol {
|
||||
sp.anchors[i].h_out = a.p;
|
||||
sp.anchors[j].h_in = b.p;
|
||||
}
|
||||
}
|
||||
for a in &mut sp.anchors {
|
||||
if !a.has_in() || !a.has_out() {
|
||||
a.kind = AnchorKind::Corner;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! DrawCraft Image Trace: raster → vector.
|
||||
//!
|
||||
//! A clean-room tracer built from the published ideas behind bitmap tracers (the potrace paper
|
||||
//! and classic colour quantisation), not from any existing implementation:
|
||||
//!
|
||||
//! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D
|
||||
//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted
|
||||
//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out.
|
||||
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
|
||||
//! into their most common neighbouring colour.
|
||||
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
|
||||
//! outside are linked into closed loops (inside on the right, right turns at saddles, so
|
||||
//! diagonal pixels stay separate, matching 4-connectivity). Outer loops run clockwise and
|
||||
//! holes counter-clockwise (y down), so the result fills correctly with either fill rule.
|
||||
//! 4. **Polygon**: each loop is reduced to a polygon whose vertices stay within a fidelity
|
||||
//! dependent distance of the pixel boundary (Douglas–Peucker on the crack vertices, which
|
||||
//! removes the staircase).
|
||||
//! 5. **Curves**: the polygon is fitted with cubic Béziers (`drawcraft_pathops::simplify_with`,
|
||||
//! least squares with corner detection); optionally nearly-straight curves snap to lines.
|
||||
//!
|
||||
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
|
||||
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod contour;
|
||||
mod fit;
|
||||
mod quantize;
|
||||
|
||||
use drawcraft_geom::PathData;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use quantize::{Quantized, TRANSPARENT, denoise, quantize};
|
||||
|
||||
/// Errors decoding a raster.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TraceError {
|
||||
#[error("could not decode image: {0}")]
|
||||
Decode(String),
|
||||
#[error("image is empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// An RGBA8 raster (row-major, top row first).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Raster {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Raster {
|
||||
/// A raster from RGBA bytes (`width * height * 4` of them).
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
|
||||
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
|
||||
Self { width, height, rgba }
|
||||
}
|
||||
/// A raster whose pixel `(x, y)` is `f(x, y)`.
|
||||
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
|
||||
let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
rgba.extend_from_slice(&f(x, y));
|
||||
}
|
||||
}
|
||||
Self { width, height, rgba }
|
||||
}
|
||||
/// Decode PNG / JPEG / WebP / GIF bytes.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
|
||||
let img = image::load_from_memory(bytes).map_err(|e| TraceError::Decode(e.to_string()))?.to_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return Err(TraceError::Empty);
|
||||
}
|
||||
Ok(Self { width: w, height: h, rgba: img.into_raw() })
|
||||
}
|
||||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = (y as usize * self.width as usize + x as usize) * 4;
|
||||
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracing mode (Illustrator's Mode popup).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Mode {
|
||||
#[default]
|
||||
#[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")]
|
||||
BlackAndWhite,
|
||||
#[serde(alias = "gray", alias = "Grayscale")]
|
||||
Grayscale,
|
||||
#[serde(alias = "Color")]
|
||||
Color,
|
||||
}
|
||||
|
||||
/// How colour layers relate (Advanced → Method).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Method {
|
||||
/// Each colour traced on its own; neighbouring shapes share their edge.
|
||||
#[default]
|
||||
Abutting,
|
||||
/// Stacked: every layer extends under the layers above it.
|
||||
Overlapping,
|
||||
}
|
||||
|
||||
/// Image Trace parameters (the Image Trace panel).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TraceParams {
|
||||
pub mode: Mode,
|
||||
/// Black and White: pixels darker than this (luminance 0–255) are black.
|
||||
pub threshold: u8,
|
||||
/// Palette size for Color, number of grays for Grayscale (2–256).
|
||||
pub colors: u32,
|
||||
/// Paths (fidelity) 0–100: higher follows the pixels more tightly.
|
||||
pub paths: f64,
|
||||
/// Corners 0–100: higher keeps more corners.
|
||||
pub corners: f64,
|
||||
/// Noise: areas smaller than this many pixels are ignored.
|
||||
pub noise: u32,
|
||||
pub method: Method,
|
||||
/// Drop white areas (no white background shape).
|
||||
pub ignore_white: bool,
|
||||
/// Replace nearly-straight curves with straight lines.
|
||||
pub snap_curves_to_lines: bool,
|
||||
}
|
||||
|
||||
impl Default for TraceParams {
|
||||
/// Illustrator's [Default]: black and white, threshold 128.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: Mode::BlackAndWhite,
|
||||
threshold: 128,
|
||||
colors: 6,
|
||||
paths: 50.0,
|
||||
corners: 75.0,
|
||||
noise: 25,
|
||||
method: Method::Abutting,
|
||||
ignore_white: false,
|
||||
snap_curves_to_lines: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TraceParams {
|
||||
/// Maximum distance (px) of polygon vertices from the pixel boundary.
|
||||
fn polygon_tolerance(&self) -> f64 {
|
||||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||||
0.55 + 0.75 * (1.0 - f)
|
||||
}
|
||||
/// Curve fitting tolerance (px).
|
||||
fn fit_tolerance(&self) -> f64 {
|
||||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||||
0.2 + 1.6 * (1.0 - f)
|
||||
}
|
||||
/// Turn angle (degrees) above which a polygon vertex stays a corner.
|
||||
fn corner_angle(&self) -> f64 {
|
||||
let c = (self.corners / 100.0).clamp(0.0, 1.0);
|
||||
150.0 - 115.0 * c
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in preset names in panel order (our own parameter sets).
|
||||
pub const PRESET_NAMES: &[&str] = &[
|
||||
"Default",
|
||||
"High Fidelity Photo",
|
||||
"Low Fidelity Photo",
|
||||
"3 Colors",
|
||||
"6 Colors",
|
||||
"16 Colors",
|
||||
"Shades of Gray",
|
||||
"Black and White Logo",
|
||||
"Sketched Art",
|
||||
"Silhouettes",
|
||||
"Line Art",
|
||||
"Technical Drawing",
|
||||
];
|
||||
|
||||
/// A built-in preset by name (case-insensitive; `[Default]` is accepted).
|
||||
pub fn preset(name: &str) -> Option<TraceParams> {
|
||||
let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
|
||||
let d = TraceParams::default();
|
||||
let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams {
|
||||
mode: Mode::Color,
|
||||
colors,
|
||||
paths,
|
||||
corners,
|
||||
noise,
|
||||
method,
|
||||
..TraceParams::default()
|
||||
};
|
||||
let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams {
|
||||
mode: Mode::BlackAndWhite,
|
||||
threshold,
|
||||
paths,
|
||||
corners,
|
||||
noise,
|
||||
ignore_white: true,
|
||||
snap_curves_to_lines: snap,
|
||||
..TraceParams::default()
|
||||
};
|
||||
Some(match key.as_str() {
|
||||
"default" => d,
|
||||
"high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping),
|
||||
"low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping),
|
||||
"3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting),
|
||||
"6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting),
|
||||
"16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting),
|
||||
"shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d },
|
||||
"black and white logo" => bw(128, 95.0, 80.0, 8, true),
|
||||
"sketched art" => bw(150, 50.0, 50.0, 100, false),
|
||||
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
|
||||
"line art" => bw(128, 80.0, 70.0, 10, false),
|
||||
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// All built-in presets in panel order.
|
||||
pub fn presets() -> Vec<(&'static str, TraceParams)> {
|
||||
PRESET_NAMES.iter().map(|n| (*n, preset(n).expect("built-in preset"))).collect()
|
||||
}
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TracedPath {
|
||||
/// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`).
|
||||
pub path: PathData,
|
||||
pub color: [u8; 3],
|
||||
/// Filled pixel count of the component the path came from.
|
||||
pub pixels: usize,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TraceResult {
|
||||
pub paths: Vec<TracedPath>,
|
||||
/// Colours actually used.
|
||||
pub palette: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl TraceResult {
|
||||
pub fn anchor_count(&self) -> usize {
|
||||
self.paths.iter().map(|p| p.path.anchor_count()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_white(c: [u8; 3]) -> bool {
|
||||
c.iter().all(|&v| v >= 245)
|
||||
}
|
||||
|
||||
/// Trace `img` with `params`.
|
||||
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return TraceResult::default();
|
||||
}
|
||||
let mut q = quantize(img, params);
|
||||
denoise(&mut q.labels, w, h, params.noise as usize);
|
||||
// Pixel count per palette entry.
|
||||
let mut counts = vec![0usize; q.palette.len()];
|
||||
for &l in &q.labels {
|
||||
if l != TRANSPARENT {
|
||||
counts[l as usize] += 1;
|
||||
}
|
||||
}
|
||||
// Layers bottom → top: largest area first.
|
||||
let mut order: Vec<usize> = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect();
|
||||
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b)));
|
||||
let mut rank = vec![usize::MAX; q.palette.len()];
|
||||
for (r, &i) in order.iter().enumerate() {
|
||||
rank[i] = r;
|
||||
}
|
||||
let opts = fit::FitOptions {
|
||||
polygon_tol: params.polygon_tolerance(),
|
||||
fit_tol: params.fit_tolerance(),
|
||||
corner_angle: params.corner_angle(),
|
||||
snap_lines: params.snap_curves_to_lines,
|
||||
};
|
||||
let min_hole = params.noise.max(1) as i64;
|
||||
let mut out = TraceResult::default();
|
||||
let mut mask = vec![false; w * h];
|
||||
for (r, &ci) in order.iter().enumerate() {
|
||||
let color = q.palette[ci];
|
||||
if params.ignore_white && is_white(color) {
|
||||
continue;
|
||||
}
|
||||
let overlapping = params.method == Method::Overlapping;
|
||||
for (m, &l) in mask.iter_mut().zip(&q.labels) {
|
||||
*m = l != TRANSPARENT && if overlapping { rank[l as usize] >= r } else { l as usize == ci };
|
||||
}
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
let mut subs = Vec::with_capacity(1 + comp.holes.len());
|
||||
if let Some(sp) = fit::fit_loop(&comp.outer, &opts) {
|
||||
subs.push(sp);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
for hole in &comp.holes {
|
||||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(sp) = fit::fit_loop(hole, &opts) {
|
||||
subs.push(sp);
|
||||
}
|
||||
}
|
||||
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: comp.pixels });
|
||||
}
|
||||
if !out.palette.contains(&color) {
|
||||
out.palette.push(color);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Colour reduction and noise removal.
|
||||
|
||||
use crate::{Mode, Raster, TraceParams};
|
||||
|
||||
/// Label of a transparent (untraced) pixel.
|
||||
pub const TRANSPARENT: u16 = u16::MAX;
|
||||
|
||||
/// A palette-indexed image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Quantized {
|
||||
/// One palette index per pixel (row-major), or [`TRANSPARENT`].
|
||||
pub labels: Vec<u16>,
|
||||
pub palette: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
fn luma(p: [u8; 4]) -> u8 {
|
||||
((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114 + 500) / 1000) as u8
|
||||
}
|
||||
|
||||
/// Reduce `img` to a palette according to `params.mode`.
|
||||
pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
let px = img.rgba.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]);
|
||||
match params.mode {
|
||||
Mode::BlackAndWhite => {
|
||||
let t = params.threshold;
|
||||
let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else if luma(p) < t { 1 } else { 0 }).collect();
|
||||
Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }
|
||||
}
|
||||
Mode::Grayscale => {
|
||||
let mut hist = [0u64; 256];
|
||||
for p in img.rgba.chunks_exact(4) {
|
||||
if p[3] >= 128 {
|
||||
hist[luma([p[0], p[1], p[2], p[3]]) as usize] += 1;
|
||||
}
|
||||
}
|
||||
let levels = kmeans_1d(&hist, params.colors.clamp(2, 256) as usize);
|
||||
let mut lut = [0u16; 256];
|
||||
for (v, slot) in lut.iter_mut().enumerate() {
|
||||
*slot = nearest_1d(&levels, v as f64) as u16;
|
||||
}
|
||||
let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[luma(p) as usize] }).collect();
|
||||
let palette = levels.iter().map(|&l| {
|
||||
let g = l.round().clamp(0.0, 255.0) as u8;
|
||||
[g, g, g]
|
||||
});
|
||||
compact(labels, palette.collect())
|
||||
}
|
||||
Mode::Color => {
|
||||
// 15-bit colour histogram with per-bin sums (exact bin means).
|
||||
let mut bins: Vec<Bin> = vec![Bin::default(); 1 << 15];
|
||||
for p in img.rgba.chunks_exact(4) {
|
||||
if p[3] >= 128 {
|
||||
let b = &mut bins[key(p[0], p[1], p[2])];
|
||||
b.n += 1;
|
||||
b.sum[0] += p[0] as u64;
|
||||
b.sum[1] += p[1] as u64;
|
||||
b.sum[2] += p[2] as u64;
|
||||
}
|
||||
}
|
||||
let used: Vec<(usize, [f64; 3], f64)> =
|
||||
bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
|
||||
let k = params.colors.clamp(2, 256) as usize;
|
||||
let centers = kmeans_rgb(&used, k);
|
||||
let mut lut = vec![0u16; 1 << 15];
|
||||
for (i, c, _) in &used {
|
||||
lut[*i] = nearest_rgb(¢ers, *c) as u16;
|
||||
}
|
||||
let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[key(p[0], p[1], p[2])] }).collect();
|
||||
let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect();
|
||||
compact(labels, palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop unused palette entries and merge duplicates.
|
||||
fn compact(mut labels: Vec<u16>, palette: Vec<[u8; 3]>) -> Quantized {
|
||||
let mut used = vec![false; palette.len()];
|
||||
for &l in &labels {
|
||||
if l != TRANSPARENT {
|
||||
used[l as usize] = true;
|
||||
}
|
||||
}
|
||||
let mut remap = vec![0u16; palette.len()];
|
||||
let mut out: Vec<[u8; 3]> = vec![];
|
||||
for (i, c) in palette.iter().enumerate() {
|
||||
if !used[i] {
|
||||
continue;
|
||||
}
|
||||
remap[i] = match out.iter().position(|o| o == c) {
|
||||
Some(j) => j as u16,
|
||||
None => {
|
||||
out.push(*c);
|
||||
(out.len() - 1) as u16
|
||||
}
|
||||
};
|
||||
}
|
||||
for l in &mut labels {
|
||||
if *l != TRANSPARENT {
|
||||
*l = remap[*l as usize];
|
||||
}
|
||||
}
|
||||
Quantized { labels, palette: out }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Bin {
|
||||
n: u64,
|
||||
sum: [u64; 3],
|
||||
}
|
||||
|
||||
impl Bin {
|
||||
fn mean(&self) -> [f64; 3] {
|
||||
let n = self.n.max(1) as f64;
|
||||
[self.sum[0] as f64 / n, self.sum[1] as f64 / n, self.sum[2] as f64 / n]
|
||||
}
|
||||
}
|
||||
|
||||
fn key(r: u8, g: u8, b: u8) -> usize {
|
||||
((r as usize >> 3) << 10) | ((g as usize >> 3) << 5) | (b as usize >> 3)
|
||||
}
|
||||
|
||||
fn nearest_1d(levels: &[f64], v: f64) -> usize {
|
||||
let mut best = (f64::INFINITY, 0);
|
||||
for (i, &l) in levels.iter().enumerate() {
|
||||
let d = (l - v).abs();
|
||||
if d < best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// 1-D k-means over a 256-bin histogram (Lloyd iterations, evenly spaced start).
|
||||
fn kmeans_1d(hist: &[u64; 256], k: usize) -> Vec<f64> {
|
||||
let lo = hist.iter().position(|&n| n > 0).unwrap_or(0) as f64;
|
||||
let hi = hist.iter().rposition(|&n| n > 0).unwrap_or(255) as f64;
|
||||
let mut levels: Vec<f64> = (0..k).map(|i| if k == 1 { lo } else { lo + (hi - lo) * i as f64 / (k - 1) as f64 }).collect();
|
||||
for _ in 0..20 {
|
||||
let mut sum = vec![0.0; k];
|
||||
let mut cnt = vec![0.0; k];
|
||||
for (v, &n) in hist.iter().enumerate() {
|
||||
if n > 0 {
|
||||
let j = nearest_1d(&levels, v as f64);
|
||||
sum[j] += v as f64 * n as f64;
|
||||
cnt[j] += n as f64;
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
for j in 0..k {
|
||||
if cnt[j] > 0.0 {
|
||||
let m = sum[j] / cnt[j];
|
||||
moved |= (m - levels[j]).abs() > 0.01;
|
||||
levels[j] = m;
|
||||
}
|
||||
}
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
fn dist2(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
// Slightly perceptual weights.
|
||||
let (dr, dg, db) = (a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
2.0 * dr * dr + 4.0 * dg * dg + 3.0 * db * db
|
||||
}
|
||||
|
||||
fn nearest_rgb(centers: &[[f64; 3]], c: [f64; 3]) -> usize {
|
||||
let mut best = (f64::INFINITY, 0);
|
||||
for (i, &m) in centers.iter().enumerate() {
|
||||
let d = dist2(m, c);
|
||||
if d < best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// Median cut for the initial palette, then weighted k-means refinement over histogram bins.
|
||||
fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
|
||||
if used.is_empty() {
|
||||
return vec![[255.0; 3]];
|
||||
}
|
||||
// Median cut: repeatedly split the box with the largest weighted extent.
|
||||
let mut boxes: Vec<Vec<usize>> = vec![(0..used.len()).collect()];
|
||||
while boxes.len() < k {
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for (bi, b) in boxes.iter().enumerate() {
|
||||
if b.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
let mut wsum = 0.0;
|
||||
for &i in b {
|
||||
for ch in 0..3 {
|
||||
lo[ch] = lo[ch].min(used[i].1[ch]);
|
||||
hi[ch] = hi[ch].max(used[i].1[ch]);
|
||||
}
|
||||
wsum += used[i].2;
|
||||
}
|
||||
let (ch, range) = (0..3).map(|c| (c, hi[c] - lo[c])).fold((0, -1.0), |a, b| if b.1 > a.1 { b } else { a });
|
||||
if range <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let score = range * wsum.sqrt();
|
||||
if best.is_none_or(|x| score > x.0) {
|
||||
best = Some((score, bi, ch));
|
||||
}
|
||||
}
|
||||
let Some((_, bi, ch)) = best else { break };
|
||||
let mut b = boxes.swap_remove(bi);
|
||||
b.sort_by(|x, y| used[*x].1[ch].total_cmp(&used[*y].1[ch]));
|
||||
let total: f64 = b.iter().map(|&i| used[i].2).sum();
|
||||
let mut acc = 0.0;
|
||||
let mut cut = 1;
|
||||
for (j, &i) in b.iter().enumerate() {
|
||||
acc += used[i].2;
|
||||
if acc >= total / 2.0 {
|
||||
cut = (j + 1).clamp(1, b.len() - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let tail = b.split_off(cut);
|
||||
boxes.push(b);
|
||||
boxes.push(tail);
|
||||
}
|
||||
let mut centers: Vec<[f64; 3]> = boxes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut s = [0.0; 3];
|
||||
let mut w = 0.0;
|
||||
for &i in b {
|
||||
for ch in 0..3 {
|
||||
s[ch] += used[i].1[ch] * used[i].2;
|
||||
}
|
||||
w += used[i].2;
|
||||
}
|
||||
[s[0] / w, s[1] / w, s[2] / w]
|
||||
})
|
||||
.collect();
|
||||
// Lloyd refinement.
|
||||
for _ in 0..12 {
|
||||
let mut sum = vec![[0.0; 3]; centers.len()];
|
||||
let mut cnt = vec![0.0; centers.len()];
|
||||
for (_, c, n) in used {
|
||||
let j = nearest_rgb(¢ers, *c);
|
||||
for ch in 0..3 {
|
||||
sum[j][ch] += c[ch] * n;
|
||||
}
|
||||
cnt[j] += n;
|
||||
}
|
||||
let mut moved = 0.0f64;
|
||||
for j in 0..centers.len() {
|
||||
if cnt[j] > 0.0 {
|
||||
let m = [sum[j][0] / cnt[j], sum[j][1] / cnt[j], sum[j][2] / cnt[j]];
|
||||
moved = moved.max(dist2(m, centers[j]));
|
||||
centers[j] = m;
|
||||
}
|
||||
}
|
||||
if moved < 0.25 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
centers
|
||||
}
|
||||
|
||||
/// Merge 4-connected same-label components smaller than `min_area` pixels into their most common
|
||||
/// neighbouring label. Transparent pixels are never relabelled.
|
||||
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
if min_area <= 1 || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut comp: Vec<usize> = Vec::new();
|
||||
let mut neigh: Vec<(u16, u32)> = Vec::new();
|
||||
for start in 0..w * h {
|
||||
if seen[start] || labels[start] == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let l = labels[start];
|
||||
comp.clear();
|
||||
neigh.clear();
|
||||
stack.push(start);
|
||||
seen[start] = true;
|
||||
let mut small = true;
|
||||
while let Some(i) = stack.pop() {
|
||||
if small {
|
||||
comp.push(i);
|
||||
if comp.len() >= min_area {
|
||||
small = false;
|
||||
}
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut visit = |j: usize| {
|
||||
let lj = labels[j];
|
||||
if lj == l {
|
||||
if !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
} else if lj != TRANSPARENT {
|
||||
match neigh.iter_mut().find(|(n, _)| *n == lj) {
|
||||
Some(e) => e.1 += 1,
|
||||
None => neigh.push((lj, 1)),
|
||||
}
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
visit(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
visit(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
visit(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
visit(i + w);
|
||||
}
|
||||
}
|
||||
if small && let Some(&(to, _)) = neigh.iter().max_by_key(|(n, c)| (*c, u16::MAX - *n)) {
|
||||
for &i in &comp {
|
||||
labels[i] = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
use super::*;
|
||||
|
||||
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
|
||||
fn bw() -> TraceParams {
|
||||
TraceParams { ignore_white: true, ..TraceParams::default() }
|
||||
}
|
||||
|
||||
fn disc(size: u32, cx: f64, cy: f64, r: f64) -> Raster {
|
||||
Raster::from_fn(size, size, |x, y| {
|
||||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||||
if dx * dx + dy * dy <= r * r { BLACK } else { WHITE }
|
||||
})
|
||||
}
|
||||
|
||||
/// Net filled area (outer contours positive, holes negative).
|
||||
fn net_area(p: &drawcraft_geom::PathData) -> f64 {
|
||||
p.subpaths.iter().map(|s| s.area()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_disc_is_one_closed_path_with_disc_area() {
|
||||
let r = 80.0;
|
||||
let res = trace(&disc(200, 100.0, 100.0, r), &bw());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let p = &res.paths[0];
|
||||
assert_eq!(p.path.subpaths.len(), 1);
|
||||
assert!(p.path.subpaths[0].closed);
|
||||
assert_eq!(p.color, [0, 0, 0]);
|
||||
let want = std::f64::consts::PI * r * r;
|
||||
let got = net_area(&p.path);
|
||||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||||
// Curves, not a pixel staircase.
|
||||
assert!(p.path.anchor_count() < 40, "anchors {}", p.path.anchor_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_disc_area_within_tolerance() {
|
||||
let r = 20.0;
|
||||
let res = trace(&disc(64, 32.0, 32.0, r), &bw());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let want = std::f64::consts::PI * r * r;
|
||||
let got = net_area(&res.paths[0].path);
|
||||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_has_outer_and_hole() {
|
||||
let img = Raster::from_fn(200, 200, |x, y| {
|
||||
let d2 = (x as f64 + 0.5 - 100.0).powi(2) + (y as f64 + 0.5 - 100.0).powi(2);
|
||||
if (40.0 * 40.0..=80.0 * 80.0).contains(&d2) { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &bw());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
assert_eq!(res.paths[0].path.subpaths.len(), 2);
|
||||
let want = std::f64::consts::PI * (80.0f64.powi(2) - 40.0f64.powi(2));
|
||||
let got = net_area(&res.paths[0].path);
|
||||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||||
// Opposite orientations → fills the same under non-zero.
|
||||
let a: Vec<f64> = res.paths[0].path.subpaths.iter().map(|s| s.area()).collect();
|
||||
assert!(a[0] * a[1] < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripes_give_one_path_per_stripe() {
|
||||
// 5 black stripes, 10 px wide, separated by 10 px of white.
|
||||
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
||||
let res = trace(&img, &bw());
|
||||
assert_eq!(res.paths.len(), 5);
|
||||
for p in &res.paths {
|
||||
let a = net_area(&p.path);
|
||||
assert!((a - 600.0).abs() < 1.0, "stripe area {a}");
|
||||
assert_eq!(p.path.anchor_count(), 4, "rectangles keep their 4 corners");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_colour_stripes_without_ignore_white_trace_both_colours() {
|
||||
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
||||
let res = trace(&img, &TraceParams::default());
|
||||
assert_eq!(res.paths.len(), 10);
|
||||
assert_eq!(res.palette.len(), 2);
|
||||
let total: f64 = res.paths.iter().map(|p| net_area(&p.path)).sum();
|
||||
assert!((total - 6000.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_removal_drops_speckles() {
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let square = (20..60).contains(&x) && (20..60).contains(&y);
|
||||
let speck = (x % 13 == 3 && y % 11 == 5) && !(15..65).contains(&x);
|
||||
if square || speck { BLACK } else { WHITE }
|
||||
});
|
||||
let noisy = trace(&img, &TraceParams { noise: 0, ..bw() });
|
||||
assert!(noisy.paths.len() > 10, "{} paths", noisy.paths.len());
|
||||
let clean = trace(&img, &TraceParams { noise: 10, ..bw() });
|
||||
assert_eq!(clean.paths.len(), 1);
|
||||
assert!((net_area(&clean.paths[0].path) - 1600.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_removal_fills_pinholes() {
|
||||
let img = Raster::from_fn(60, 60, |x, y| {
|
||||
let square = (10..50).contains(&x) && (10..50).contains(&y);
|
||||
let hole = x == 30 && y == 30;
|
||||
if square && !hole { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &TraceParams { noise: 4, ..bw() });
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
assert_eq!(res.paths[0].path.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_pixels_stay_separate() {
|
||||
let img = Raster::from_fn(4, 4, |x, y| if (x, y) == (1, 1) || (x, y) == (2, 2) { BLACK } else { WHITE });
|
||||
let res = trace(&img, &TraceParams { noise: 0, ..bw() });
|
||||
assert_eq!(res.paths.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_loops_are_consistent() {
|
||||
let mask = vec![true, true, true, true, false, true, true, true, true];
|
||||
let comps = trace_mask(&mask, 3, 3);
|
||||
assert_eq!(comps.len(), 1);
|
||||
assert_eq!(comps[0].pixels, 8);
|
||||
assert_eq!(comps[0].outer.area2, 18);
|
||||
assert_eq!(comps[0].holes.len(), 1);
|
||||
assert_eq!(comps[0].holes[0].area2, -2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_mode_palette_size_matches_request() {
|
||||
let quad = |x: u32, y: u32| match (x < 50, y < 50) {
|
||||
(true, true) => [220, 30, 30, 255],
|
||||
(false, true) => [30, 200, 40, 255],
|
||||
(true, false) => [30, 40, 210, 255],
|
||||
(false, false) => [240, 220, 20, 255],
|
||||
};
|
||||
let img = Raster::from_fn(100, 100, quad);
|
||||
let p4 = trace(&img, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() });
|
||||
assert_eq!(p4.palette.len(), 4);
|
||||
assert_eq!(p4.paths.len(), 4);
|
||||
let p2 = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
||||
assert_eq!(p2.palette.len(), 2);
|
||||
// Palette colours are close to the originals.
|
||||
assert!(p4.palette.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_mode_gradient_uses_at_most_n_colors() {
|
||||
let img = Raster::from_fn(128, 32, |x, _| [(x * 2) as u8, 255 - (x * 2) as u8, 128, 255]);
|
||||
for n in [3, 6, 16] {
|
||||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: n, ..TraceParams::default() });
|
||||
assert!(q.palette.len() <= n as usize && q.palette.len() >= 2, "n={n}: {}", q.palette.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_levels() {
|
||||
let img = Raster::from_fn(256, 8, |x, _| [x as u8, x as u8, x as u8, 255]);
|
||||
let q = quantize(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, ..TraceParams::default() });
|
||||
assert_eq!(q.palette.len(), 4);
|
||||
assert!(q.palette.iter().all(|c| c[0] == c[1] && c[1] == c[2]));
|
||||
let res = trace(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, noise: 0, ..TraceParams::default() });
|
||||
assert_eq!(res.paths.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_layers_stack() {
|
||||
// White background with a black square: overlapping → bottom layer covers everything.
|
||||
let img = Raster::from_fn(50, 50, |x, y| if (10..30).contains(&x) && (10..30).contains(&y) { BLACK } else { WHITE });
|
||||
let ab = trace(&img, &TraceParams::default());
|
||||
let ov = trace(&img, &TraceParams { method: Method::Overlapping, ..TraceParams::default() });
|
||||
assert_eq!(ab.paths.len(), 2);
|
||||
assert_eq!(ov.paths.len(), 2);
|
||||
// Abutting: white has a hole; overlapping: white is a full rectangle underneath.
|
||||
assert_eq!(ab.paths[0].path.subpaths.len(), 2);
|
||||
assert_eq!(ov.paths[0].path.subpaths.len(), 1);
|
||||
assert!((net_area(&ov.paths[0].path) - 2500.0).abs() < 1.0);
|
||||
assert_eq!(ov.paths[1].color, [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_are_not_traced() {
|
||||
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [0, 0, 0, 0] } else { BLACK });
|
||||
let res = trace(&img, &TraceParams::default());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
assert!((net_area(&res.paths[0].path) - 800.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_controls_black_and_white() {
|
||||
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE });
|
||||
assert_eq!(trace(&img, &TraceParams { threshold: 128, ..bw() }).paths.len(), 1);
|
||||
assert_eq!(trace(&img, &TraceParams { threshold: 90, ..bw() }).paths.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fidelity_changes_anchor_count() {
|
||||
let img = Raster::from_fn(200, 200, |x, y| {
|
||||
let t = (y as f64 / 12.0).sin() * 20.0 + 100.0;
|
||||
if (x as f64) < t { BLACK } else { WHITE }
|
||||
});
|
||||
let lo = trace(&img, &TraceParams { paths: 0.0, ..bw() }).anchor_count();
|
||||
let hi = trace(&img, &TraceParams { paths: 100.0, ..bw() }).anchor_count();
|
||||
assert!(hi >= lo, "hi {hi} lo {lo}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_curves_to_lines_straightens_near_lines() {
|
||||
// A slightly jagged, almost straight edge.
|
||||
let img = Raster::from_fn(200, 100, |x, y| if y as f64 > 30.0 + (x as f64 * 0.05) + if x % 37 < 2 { 1.0 } else { 0.0 } { BLACK } else { WHITE });
|
||||
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() });
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
let curves = (0..sp.segment_count()).filter(|&i| !sp.segment_is_line(i)).count();
|
||||
assert_eq!(curves, 0, "{sp:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_resolve() {
|
||||
assert_eq!(presets().len(), 12);
|
||||
for n in PRESET_NAMES {
|
||||
assert!(preset(n).is_some(), "{n}");
|
||||
}
|
||||
assert!(preset("[Default]").is_some());
|
||||
assert_eq!(preset("16 colors").unwrap().colors, 16);
|
||||
assert_eq!(preset("Shades of Gray").unwrap().mode, Mode::Grayscale);
|
||||
assert!(preset("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_json_roundtrip_and_partial() {
|
||||
let p = preset("6 Colors").unwrap();
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["mode"], "color");
|
||||
let back: TraceParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, p);
|
||||
let partial: TraceParams = serde_json::from_value(serde_json::json!({"threshold": 90, "ignoreWhite": true})).unwrap();
|
||||
assert_eq!(partial.threshold, 90);
|
||||
assert!(partial.ignore_white);
|
||||
assert_eq!(partial.mode, Mode::BlackAndWhite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_png() {
|
||||
let img = disc(32, 16.0, 16.0, 10.0);
|
||||
let buf = image::RgbaImage::from_raw(32, 32, img.rgba.clone()).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
buf.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let r = Raster::decode(&bytes).unwrap();
|
||||
assert_eq!(r, img);
|
||||
assert!(Raster::decode(b"not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_presets_trace_a_colour_image() {
|
||||
let img = Raster::from_fn(64, 64, |x, y| [(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8, 255]);
|
||||
for (name, p) in presets() {
|
||||
let r = trace(&img, &p);
|
||||
assert!(!r.paths.is_empty() || p.ignore_white, "{name}");
|
||||
for tp in &r.paths {
|
||||
assert!(tp.path.subpaths.iter().all(|s| s.closed && s.anchors.iter().all(|a| a.p.x.is_finite())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing; run with --release --ignored"]
|
||||
fn timing_1000x1000_bw_under_300ms() {
|
||||
let img = Raster::from_fn(1000, 1000, |x, y| {
|
||||
let (fx, fy) = (x as f64, y as f64);
|
||||
let v = (fx / 37.0).sin() * (fy / 23.0).cos() + ((fx + fy) / 91.0).sin() * 0.5;
|
||||
if v > 0.2 { BLACK } else { WHITE }
|
||||
});
|
||||
let t = std::time::Instant::now();
|
||||
let res = trace(&img, &TraceParams { ignore_white: true, ..TraceParams::default() });
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
eprintln!("1000x1000 B&W: {} paths, {} anchors in {ms:.1} ms", res.paths.len(), res.anchor_count());
|
||||
assert!(!res.paths.is_empty());
|
||||
assert!(ms < 300.0, "{ms} ms");
|
||||
}
|
||||
@@ -113,6 +113,7 @@ fn title(kind: &str) -> &'static str {
|
||||
"artboardOptions" => "Artboard Options",
|
||||
"allTools" => "All Tools",
|
||||
"exportForScreens" => "Export for Screens",
|
||||
"recolor" => "Recolor Artwork",
|
||||
_ => "Dialog",
|
||||
}
|
||||
}
|
||||
@@ -121,6 +122,15 @@ fn title(kind: &str) -> &'static str {
|
||||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
let copy = d.bool("copy");
|
||||
if d.kind == "recolor" {
|
||||
let map = recolor_map(&d);
|
||||
app.ui.dialog = None;
|
||||
if app.session.in_interaction() {
|
||||
let _ = app.session.preview("recolor.apply", &json!({"map": map}));
|
||||
return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
|
||||
}
|
||||
return app.run("recolor.apply", json!({"map": map}));
|
||||
}
|
||||
if d.kind == "exportForScreens" {
|
||||
let boards: Vec<usize> = d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()).unwrap_or_default();
|
||||
let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")});
|
||||
@@ -263,6 +273,12 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(12.0);
|
||||
match d.kind.as_str() {
|
||||
"exportForScreens" => export_for_screens_ui(app, ui, &mut d),
|
||||
"recolor" => {
|
||||
if recolor_ui(ui, &mut d) || !app.session.in_interaction() {
|
||||
let _ = app.session.begin_interaction("Recolor Artwork");
|
||||
let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(&d)}));
|
||||
}
|
||||
}
|
||||
"command" => {
|
||||
effect_fields(ui, &mut d);
|
||||
}
|
||||
@@ -334,7 +350,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" {
|
||||
ok = true;
|
||||
}
|
||||
let is_effect = d.kind == "effect";
|
||||
let is_effect = d.kind == "effect" || d.kind == "recolor";
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
if is_effect {
|
||||
@@ -528,3 +544,79 @@ fn artboard_thumb(app: &mut DrawcraftApp, ctx: &egui::Context, i: usize) -> Opti
|
||||
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
fn recolor_map(d: &Dialog) -> Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) {
|
||||
m.insert(a.to_string(), json!(b));
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Recolor Artwork: current → new colour rows, harmony rules, randomize. Returns true when changed.
|
||||
fn recolor_ui(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
use drawcraft_color::Color;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut pairs: Vec<Value> = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let mut changed = false;
|
||||
let chip = |ui: &mut egui::Ui, hex: &str| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover());
|
||||
let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0);
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
|
||||
ui.add_space(70.0);
|
||||
ui.label(egui::RichText::new("New").color(t.text_dim));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for p in pairs.iter_mut() {
|
||||
let from = p[0].as_str().unwrap_or("#000000").to_string();
|
||||
let mut to = p[1].as_str().unwrap_or("#000000").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
chip(ui, &from);
|
||||
ui.label(egui::RichText::new("→").color(t.text_dim));
|
||||
chip(ui, &to);
|
||||
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() {
|
||||
p[1] = json!(to);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Randomize").clicked() {
|
||||
// Deterministic scramble of hues (seeded by the colour itself), keeping brightness.
|
||||
for (i, p) in pairs.iter_mut().enumerate() {
|
||||
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
|
||||
let [h, s, v] = c.to_hsb();
|
||||
let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0;
|
||||
p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] {
|
||||
if ui.button(label).clicked() {
|
||||
for (i, p) in pairs.iter_mut().enumerate() {
|
||||
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
|
||||
let [h, s, v] = c.to_hsb();
|
||||
let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) };
|
||||
p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if ui.button("Reset").clicked() {
|
||||
for p in pairs.iter_mut() {
|
||||
p[1] = p[0].clone();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
d.fields.insert("pairs".into(), Value::Array(pairs));
|
||||
changed
|
||||
}
|
||||
@@ -90,6 +90,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("tool.setOption", "Tool Option", "", "{key, value}"),
|
||||
("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"),
|
||||
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
|
||||
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?}"),
|
||||
("help.about", "About DrawCraft", "", "{}"),
|
||||
@@ -303,6 +304,14 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
||||
None => Err(format!("unknown effect `{id}`")),
|
||||
}
|
||||
}
|
||||
"ui.recolorDialog" => match app.run("recolor.colors", json!({})) {
|
||||
Ok(v) => {
|
||||
let pairs: Vec<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect();
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("recolor", json!({"pairs": pairs, "preview": true})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
"ui.paramDialog" => {
|
||||
let cmd = s("command").unwrap_or_default();
|
||||
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
@@ -413,7 +422,7 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool {
|
||||
| "view.fitAll"
|
||||
| "view.actualSize" => app.session.active().is_some(),
|
||||
"file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()),
|
||||
"effect.dialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"effect.applyLast" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"file.export.pdf" => app.session.active().is_some(),
|
||||
_ => true,
|
||||
@@ -503,7 +512,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
sub(
|
||||
"Edit Colors",
|
||||
vec![
|
||||
todo("Recolor Artwork…"),
|
||||
c("Recolor Artwork…", "ui.recolorDialog"),
|
||||
cp("Adjust Color Balance…", "edit.colors.adjustBalance", json!({"r": 0, "g": 0, "b": 0})),
|
||||
c("Blend Front to Back", "edit.colors.blendFrontToBack"),
|
||||
c("Blend Horizontally", "edit.colors.blendHorizontally"),
|
||||
|
||||
+2
-1
@@ -37,6 +37,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("color", Class::Layer(0)),
|
||||
("doc", Class::Layer(1)),
|
||||
("pathops", Class::Layer(2)),
|
||||
("trace", Class::Layer(2)),
|
||||
("text", Class::Layer(2)),
|
||||
("effects", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
@@ -59,7 +60,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
||||
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
||||
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"]];
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"]];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
|
||||
Reference in new issue
Block a user