continued work

This commit is contained in:
Brandon Thomas committed 2026-09-30 09:43:24 -04:00
1 parent 6235ea5e10
commit a7e49bfa72
31 files changed
+5067 -165

No files matched your search

Generated
+26
View File
@@ -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"
+1
View File
@@ -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" }
+18
View File
@@ -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 }
+180
View File
@@ -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
}
+459
View File
@@ -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;
+267
View File
@@ -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));
}
}
+352
View File
@@ -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()
}
+8 -1
View File
@@ -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,
}
}
+2
View File
@@ -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
View File
@@ -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).
+3 -47
View File
@@ -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
View File
@@ -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 {
+2
View File
@@ -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());
+85
View File
@@ -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 }))
}
+18
View File
@@ -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:?}"),
}
}
+124
View File
@@ -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)
}
+241
View File
@@ -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
}
+107
View File
@@ -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
View File
@@ -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();
+65
View File
@@ -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
}
+45 -9
View File
@@ -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,
});
}
}
+20
View File
@@ -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 }
+185
View File
@@ -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
}
+114
View File
@@ -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;
}
}
}
+318
View File
@@ -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;
+329
View File
@@ -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(&centers, *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(&centers, *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;
}
}
}
}
+282
View File
@@ -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");
}
+93 -1
View File
@@ -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
}
+11 -2
View File
@@ -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
View File
@@ -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));