diff --git a/Cargo.lock b/Cargo.lock index 264a38d5..3e1b3359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1bb949ae..eb4501ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/brush/Cargo.toml b/crates/brush/Cargo.toml new file mode 100644 index 00000000..9e7da924 --- /dev/null +++ b/crates/brush/Cargo.toml @@ -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 } diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs new file mode 100644 index 00000000..2720a8ef --- /dev/null +++ b/crates/brush/src/calli.rs @@ -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 { + let s = samples(t); + let left: Vec = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect(); + let right: Vec = 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| { + 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 { + 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 = 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 { + 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 { + 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 = 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 +} diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs new file mode 100644 index 00000000..c7fee1a1 --- /dev/null +++ b/crates/brush/src/lib.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub inner_corner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + /// 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 { + 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 { + 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 { + library(doc).into_iter().find(|b| b.name == name) +} + +/// The current brush name (Paintbrush tool, Brushes panel highlight). +pub fn current(doc: &Document) -> Option { + 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 { + 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> { + 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 { + 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 { + let pieces = node_pieces(doc, n)?; + let mut children: Vec> = 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 { + nodes.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.visual_bounds())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs new file mode 100644 index 00000000..9450b590 --- /dev/null +++ b/crates/brush/src/track.rs @@ -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, + /// Cumulative arc length at each vertex. + pub cum: Vec, + pub closed: bool, + /// Unit direction of each segment. + pub dirs: Vec, + /// Smoothed unit tangent at each vertex. + pub tangents: Vec, + /// Is the vertex a corner (turn > [`CORNER_DEG`])? Open ends are never corners. + pub corner: Vec, +} + +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, closed: bool) -> Option { + 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 = (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 { + let mut out = vec![]; + let mut cur: Vec = vec![]; + let mut flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { + 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 = 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)); + } +} diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs new file mode 100644 index 00000000..0875fb0b --- /dev/null +++ b/crates/brush/src/warp.rs @@ -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 { + 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> = 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 = vec![]; + let mut els = vec![]; + kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e)); + let mut finish = |cur: &mut Vec, closed: bool, subs: &mut Vec| { + 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 = 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 = 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() +} diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 4b6253cc..ece98c75 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -130,9 +130,16 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } (!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, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 788e46cf..87420aae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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}; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs new file mode 100644 index 00000000..60febe61 --- /dev/null +++ b/crates/doc/src/live.rs @@ -0,0 +1,1495 @@ +//! Live blends, envelope distortions and gradient meshes: their data and pure evaluation. +//! +//! These are *live* objects. The document stores the source objects plus parameters; the +//! renderer and the exporters evaluate them on demand ([`expand_live`], [`expand_deep`]). +//! Everything here is pure geometry so the SVG/PDF exporters (which only depend on this crate) +//! can export the expanded form. +//! +//! - **Blend**: key objects are matched anchor-by-anchor (subpaths resampled to equal anchor +//! counts), paints/strokes/opacity interpolated; optional spine (keys spread along it by arc +//! length, optionally rotated to its tangent). +//! - **Envelope**: all descendant geometry of the content is pushed through a point map from the +//! content's bounding box to a warp style, a Catmull-Rom point mesh or a top object (Coons +//! patch built from the top path's outline split at its corners). +//! - **Gradient mesh**: a grid of points with colours and tangent handles; each patch is a Coons +//! patch coloured by bilinear interpolation of its four corners. + +use std::f64::consts::PI; +use std::sync::Arc; + +use drawcraft_color::{Color, Gradient, GradientPaint, Paint}; +use drawcraft_geom::{Affine, Anchor, BezPath, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2}; +use serde::{Deserialize, Serialize}; + +use crate::appearance::{Appearance, AppearanceItem, FillLayer}; +use crate::node::{Node, NodeId, NodeKind}; + +fn yes() -> bool { + true +} +fn one() -> f32 { + 1.0 +} +fn fifty() -> f64 { + 50.0 +} + +// ===================================================================================== +// Data +// ===================================================================================== + +/// Blend Options → Spacing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpacing { + /// Step count chosen from the colour difference (or the distance when colours match). + #[default] + SmoothColor, + /// Specified number of steps between each pair of key objects (1–1000). + Steps(u32), + /// Specified distance (pt) between the steps. + Distance(f64), +} + +/// Blend Options → Orientation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendOrientation { + #[default] + AlignToPage, + AlignToPath, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendSpec { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, + /// Replaced spine. `None` = the straight lines between the key objects' centres. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spine: Option, +} + +/// How an envelope distorts its content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum EnvelopeKind { + /// One of the 15 warp styles (`arc`, `arcLower`, … — see [`WarpStyle::from_id`]); bend and + /// distortions in percent (-100..100). + Warp { + style: String, + #[serde(default)] + bend: f64, + #[serde(default)] + h: f64, + #[serde(default)] + v: f64, + #[serde(default = "yes")] + horizontal: bool, + }, + /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. + Mesh { rows: u32, cols: u32, points: Vec }, + /// The content's bounding box maps onto the outline of this path. + TopObject { path: PathData }, +} + +/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. +pub const H_RIGHT: usize = 0; +pub const H_LEFT: usize = 1; +pub const H_DOWN: usize = 2; +pub const H_UP: usize = 3; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MeshPoint { + pub p: Point, + pub color: Color, + #[serde(default = "one")] + pub opacity: f32, + /// Tangent handles as offsets from `p` (see [`H_RIGHT`] …). + #[serde(default)] + pub handles: [Vec2; 4], +} + +/// A gradient mesh: `(rows+1)×(cols+1)` points, row-major. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientMesh { + pub rows: u32, + pub cols: u32, + pub points: Vec, +} + +/// One tessellated piece of a mesh (a small quad with a solid colour). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshQuad { + pub pts: [Point; 4], + pub color: Color, + pub opacity: f32, +} + +/// Create Gradient Mesh → Appearance. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MeshAppearance { + #[default] + Flat, + ToCenter, + ToEdge, +} + +impl MeshAppearance { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "flat" => Some(Self::Flat), + "center" | "tocenter" | "to center" => Some(Self::ToCenter), + "edge" | "toedge" | "to edge" => Some(Self::ToEdge), + _ => None, + } + } +} + +/// Default envelope fidelity. +pub fn default_fidelity() -> f64 { + fifty() +} + +// ===================================================================================== +// Colour / paint interpolation +// ===================================================================================== + +/// Interpolate two colours in their shared model (display RGB when the models differ). +pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color { + let l = |x: f32, y: f32| x + (y - x) * t; + match (*a, *b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), + _ => a.lerp(b, t), + } +} + +fn solid_gradient(c: Color, like: &Gradient) -> Gradient { + let mut g = like.clone(); + for s in &mut g.stops { + s.color = c; + s.opacity = 1.0; + } + g +} + +fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option { + if a.gradient.kind != b.gradient.kind || a.gradient.stops.len() != b.gradient.stops.len() { + return None; + } + let mut out = if t < 0.5 { a.clone() } else { b.clone() }; + let l = |x: f32, y: f32| x + (y - x) * t; + for (i, s) in out.gradient.stops.iter_mut().enumerate() { + let (sa, sb) = (&a.gradient.stops[i], &b.gradient.stops[i]); + s.offset = l(sa.offset, sb.offset); + s.color = lerp_color(&sa.color, &sb.color, t); + s.opacity = l(sa.opacity, sb.opacity); + s.midpoint = l(sa.midpoint, sb.midpoint); + } + out.angle = a.angle + (b.angle - a.angle) * t as f64; + out.swatch = None; + if let (Some(ga), Some(gb)) = (a.geom, b.geom) { + let mut g = ga; + g.start = ga.start.lerp(gb.start, t as f64); + g.end = ga.end.lerp(gb.end, t as f64); + g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64; + out.geom = Some(g); + } else { + out.geom = None; + } + Some(out) +} + +/// Interpolate paints: solid↔solid, compatible gradients, solid↔gradient; otherwise switch halfway. +pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { + match (a, b) { + (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)), + (Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) { + Some(g) => Paint::Gradient(Box::new(g)), + None => (if t < 0.5 { a } else { b }).clone(), + }, + (Paint::Solid { color, .. }, Paint::Gradient(g)) => { + let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone()))) + } + (Paint::Gradient(g), Paint::Solid { color, .. }) => { + let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone()))) + } + _ => (if t < 0.5 { a } else { b }).clone(), + } +} + +/// Interpolate appearance stacks: item by item when their structure matches, else the top fill +/// and stroke only (on the structure of the nearer key). +pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { + let tf = t as f32; + let mut out = if t < 0.5 { a.clone() } else { b.clone() }; + let same = a.items.len() == b.items.len() + && a.items.iter().zip(&b.items).all(|(x, y)| matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_)))); + if same { + for (i, it) in out.items.iter_mut().enumerate() { + match (it, &a.items[i], &b.items[i]) { + (AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => { + o.paint = lerp_paint(&x.paint, &y.paint, tf); + o.opacity = x.opacity + (y.opacity - x.opacity) * tf; + } + (AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => { + o.paint = lerp_paint(&x.paint, &y.paint, tf); + o.width = x.width + (y.width - x.width) * t; + o.opacity = x.opacity + (y.opacity - x.opacity) * tf; + } + _ => {} + } + } + return out; + } + let (fa, fb) = (a.fill_paint(), b.fill_paint()); + if !(fa.is_none() && fb.is_none()) { + out.set_fill(lerp_paint(&fa, &fb, tf)); + } + let (sa, sb) = (a.stroke_paint(), b.stroke_paint()); + if !(sa.is_none() && sb.is_none()) { + out.set_stroke(lerp_paint(&sa, &sb, tf)); + let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t; + if let Some(s) = out.stroke_mut() { + s.width = w; + } + } + out +} + +// ===================================================================================== +// Path interpolation +// ===================================================================================== + +/// Grow a subpath to `n` anchors by splitting its longest segments. +fn grow(sp: &mut SubPath, n: usize) { + let mut guard = 0; + while sp.anchors.len() < n && guard < 20_000 { + guard += 1; + if sp.segment_count() == 0 { + match sp.anchors.last().copied() { + Some(a) => sp.anchors.push(Anchor::corner(a.p)), + None => return, + } + continue; + } + let seg = (0..sp.segment_count()) + .max_by(|i, j| { + let (ci, cj) = (sp.segment(*i), sp.segment(*j)); + (ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot()) + }) + .unwrap_or(0); + sp.insert_anchor(seg, 0.5); + } +} + +fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor { + Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } } +} + +/// Interpolate two paths anchor-by-anchor. Subpath and anchor counts are equalised first (missing +/// subpaths grow out of a point; shorter subpaths are resampled); closed subpaths of opposite +/// winding are reversed so they don't flip through themselves. +pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData { + let n = a.subpaths.len().max(b.subpaths.len()); + let ca = a.bounds().map(|r| r.center()).unwrap_or_default(); + let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); + let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { + (Some(x), Some(y)) => (x.clone(), y.clone()), + (Some(x), None) => (x.clone(), degenerate(x, cb)), + (None, Some(y)) => (degenerate(y, ca), y.clone()), + (None, None) => continue, + }; + if sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2 && (sa.area() * sb.area()) < 0.0 { + sb.reverse(); + } + let m = sa.anchors.len().max(sb.anchors.len()); + grow(&mut sa, m); + grow(&mut sb, m); + let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect(); + out.push(SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed })); + } + PathData::new(out) +} + +/// Path data of a path or compound path node (compound children concatenated). +fn node_path(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); + Some((PathData::new(subs), *rule)) + } + _ => None, + } +} + +/// Interpolate two objects at `t` (0 = `a`, 1 = `b`). +pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { + let base = if t < 0.5 { a } else { b }; + let mut n = match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if ca.len() == cb.len() && k1 == k2 => { + let children = ca.iter().zip(cb).map(|(x, y)| Arc::new(lerp_node(x, y, t))).collect(); + Node::new(NodeId(0), NodeKind::Group { children, clip: *k1 }) + } + (NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => { + let mut m = ma.clone(); + for (i, p) in m.points.iter_mut().enumerate() { + let q = &mb.points[i]; + p.p = p.p.lerp(q.p, t); + p.color = lerp_color(&p.color, &q.color, t as f32); + p.opacity += (q.opacity - p.opacity) * t as f32; + for h in 0..4 { + p.handles[h] = p.handles[h].lerp(q.handles[h], t); + } + } + Node::new(NodeId(0), NodeKind::Mesh(m)) + } + _ => match (node_path(a), node_path(b)) { + (Some((pa, ra)), Some((pb, rb))) => { + let path = lerp_path(&pa, &pb, t); + let mut n = Node::path(NodeId(0), path, Appearance::default()); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = if t < 0.5 { ra } else { rb }; + } + n + } + _ => { + // Different structure: move/scale a copy of the nearer key into the interpolated box. + let mut n = base.clone(); + if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) { + let w = ba.width() + (bb.width() - ba.width()) * t; + let h = ba.height() + (bb.height() - ba.height()) * t; + let c = ba.center().lerp(bb.center(), t); + let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 }; + let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 }; + n.transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()), false); + } + if let NodeKind::Path { live, .. } = &mut n.kind { + *live = None; + } + n.id = NodeId(0); + n + } + }, + }; + n.appearance = lerp_appearance(&a.appearance, &b.appearance, t); + n.opacity = a.opacity + (b.opacity - a.opacity) * t as f32; + n.blend = base.blend; + n.visible = true; + n +} + +// ===================================================================================== +// Blend evaluation +// ===================================================================================== + +/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke. +fn color_distance(a: &Node, b: &Node) -> f32 { + let mut d = 0.0f32; + let mut cmp = |x: &Paint, y: &Paint| { + let cols = |p: &Paint| -> Vec { + match p { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), + _ => vec![], + } + }; + let (cx, cy) = (cols(x), cols(y)); + for (i, c) in cx.iter().enumerate() { + if let Some(o) = cy.get(i).or(cy.first()) { + let (p, q) = (c.to_rgb(), o.to_rgb()); + for k in 0..3 { + d = d.max((p[k] - q[k]).abs()); + } + } + } + }; + cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint()); + cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint()); + d +} + +fn center_of(n: &Node) -> Point { + n.geometric_bounds().map(|b| b.center()).unwrap_or_default() +} + +/// Number of intermediate steps between two keys `len` apart (along the spine). +pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize { + match spacing { + BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, + BlendSpacing::Distance(d) => { + let d = d.max(0.01); + ((len / d).round() as i64 - 1).clamp(0, 1000) as usize + } + BlendSpacing::SmoothColor => { + let cd = color_distance(a, b); + if cd > 1.0 / 255.0 { + ((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256) + } else { + // Same colours: base the count on the distance between the objects. + let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds()); + let dist = match (ba, bb) { + (Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()), + _ => len, + }; + ((dist / 2.0).ceil() as usize).clamp(1, 256) + } + } + } +} + +/// A spine flattened to a polyline with cumulative lengths. +pub struct Spine { + pts: Vec, + cum: Vec, +} + +impl Spine { + pub fn new(path: &PathData) -> Option { + let sp = path.subpaths.first()?; + let pts = flatten_subpath(sp, 0.1); + if pts.len() < 2 { + return None; + } + let mut cum = vec![0.0]; + for w in pts.windows(2) { + cum.push(cum.last().unwrap() + w[0].distance(w[1])); + } + Some(Self { pts, cum }) + } + pub fn length(&self) -> f64 { + *self.cum.last().unwrap_or(&0.0) + } + /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). + pub fn at(&self, f: f64) -> (Point, f64) { + let total = self.length(); + let s = f.clamp(0.0, 1.0) * total; + let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { + Ok(i) => i.min(self.pts.len() - 2), + Err(i) => i.saturating_sub(1).min(self.pts.len() - 2), + }; + let seg = (self.cum[i + 1] - self.cum[i]).max(1e-12); + let u = ((s - self.cum[i]) / seg).clamp(0.0, 1.0); + let d = self.pts[i + 1] - self.pts[i]; + (self.pts[i].lerp(self.pts[i + 1], u), d.y.atan2(d.x)) + } +} + +/// Evaluate a blend: the keys and generated steps in paint order. +pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { + let k = keys.len(); + if k < 2 { + return keys.iter().map(|n| (**n).clone()).collect(); + } + let spine = spec.spine.as_ref().and_then(Spine::new); + let place = |mut n: Node, f: f64| -> Node { + if let Some(sp) = &spine { + let (p, ang) = sp.at(f); + let c = center_of(&n); + let rot = if spec.orientation == BlendOrientation::AlignToPath { Affine::rotate(ang) } else { Affine::IDENTITY }; + n.transform(Affine::translate(p.to_vec2()) * rot * Affine::translate(-c.to_vec2()), false); + } + n + }; + let mut out = Vec::new(); + for i in 0..k { + let a = &keys[i]; + out.push(place((**a).clone(), i as f64 / (k - 1) as f64)); + let Some(b) = keys.get(i + 1) else { break }; + let len = match &spine { + Some(sp) => sp.length() / (k - 1) as f64, + None => center_of(a).distance(center_of(b)), + }; + let n = blend_step_count(a, b, spec.spacing, len); + for j in 1..=n { + let t = j as f64 / (n + 1) as f64; + let mut s = lerp_node(a, b, t); + s.id = NodeId(0); + out.push(place(s, (i as f64 + t) / (k - 1) as f64)); + } + } + out +} + +// ===================================================================================== +// Warp maps (shared with drawcraft-effects' Warp effects) +// ===================================================================================== + +#[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 { + 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) +} + +/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. +fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { + let c = sp.segment(i); + 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 = Vec::new(); + let mut pending_in: Option = 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) +} + +// ===================================================================================== +// Coons patches from outlines +// ===================================================================================== + +fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut pts: Vec = Vec::new(); + drawcraft_geom::kurbo::flatten(bp.iter(), tol, |el| match el { + drawcraft_geom::PathEl::MoveTo(p) | drawcraft_geom::PathEl::LineTo(p) => { + if pts.last().is_none_or(|q| q.distance(p) > 1e-9) { + pts.push(p) + } + } + _ => {} + }); + pts +} + +fn resample(poly: &[Point], m: usize) -> Vec { + if poly.is_empty() { + return vec![Point::ZERO; m]; + } + if poly.len() == 1 { + return vec![poly[0]; m]; + } + let mut cum = vec![0.0]; + for w in poly.windows(2) { + cum.push(cum.last().unwrap() + w[0].distance(w[1])); + } + let total = *cum.last().unwrap(); + let mut out = Vec::with_capacity(m); + let mut i = 0; + for k in 0..m { + let s = if m > 1 { total * k as f64 / (m - 1) as f64 } else { 0.0 }; + while i + 2 < cum.len() && cum[i + 1] < s { + i += 1; + } + let seg = (cum[i + 1] - cum[i]).max(1e-12); + out.push(poly[i].lerp(poly[i + 1], ((s - cum[i]) / seg).clamp(0.0, 1.0))); + } + out +} + +const SIDE: usize = 65; + +/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to +/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding +/// box corners. +#[derive(Clone, Debug)] +pub struct Coons { + top: Vec, + right: Vec, + bottom: Vec, + left: Vec, +} + +impl Coons { + /// A rectangle. + pub fn rect(r: Rect) -> Self { + let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::>(); + let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) } + } + + pub fn from_path(path: &PathData) -> Option { + // The largest subpath is the outline. + let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; + let r = path.bounds()?; + let tol = (r.width() + r.height()).max(1e-6) / 2000.0; + let mut poly = flatten_subpath(sp, tol); + if poly.len() > 1 && poly[0].distance(*poly.last().unwrap()) < 1e-9 { + poly.pop(); + } + if poly.len() < 3 { + return Some(Self::rect(r)); + } + // Orient clockwise in y-down (TL → TR → BR → BL). + let area: f64 = (0..poly.len()).map(|i| { + let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); + p.x * q.y - q.x * p.y + }).sum(); + if area < 0.0 { + poly.reverse(); + } + let nearest = |c: Point| (0..poly.len()).min_by(|i, j| poly[*i].distance_squared(c).total_cmp(&poly[*j].distance_squared(c))).unwrap_or(0); + let tl = nearest(Point::new(r.x0, r.y0)); + let tr = nearest(Point::new(r.x1, r.y0)); + let br = nearest(Point::new(r.x1, r.y1)); + let bl = nearest(Point::new(r.x0, r.y1)); + let n = poly.len(); + let arc = |i: usize, j: usize| -> Vec { + let mut v = vec![poly[i]]; + let mut k = i; + while k != j { + k = (k + 1) % n; + v.push(poly[k]); + } + v + }; + let top = resample(&arc(tl, tr), SIDE); + let right = resample(&arc(tr, br), SIDE); + let mut bottom = resample(&arc(br, bl), SIDE); + bottom.reverse(); + let mut left = resample(&arc(bl, tl), SIDE); + left.reverse(); + Some(Self { top, right, bottom, left }) + } + + fn sample(side: &[Point], u: f64) -> Point { + let s = u.clamp(0.0, 1.0) * (side.len() - 1) as f64; + let i = (s.floor() as usize).min(side.len() - 2); + side[i].lerp(side[i + 1], s - i as f64) + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn eval(&self, u: f64, v: f64) -> Point { + let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v)); + let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]); + let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2()); + c.to_point() + } + + /// The four corners TL, TR, BR, BL. + pub fn corners(&self) -> [Point; 4] { + [self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]] + } +} + +// ===================================================================================== +// Gradient meshes +// ===================================================================================== + +impl GradientMesh { + pub fn idx(&self, r: usize, c: usize) -> usize { + r * (self.cols as usize + 1) + c + } + pub fn is_valid(&self) -> bool { + self.rows >= 1 && self.cols >= 1 && self.points.len() == (self.rows as usize + 1) * (self.cols as usize + 1) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. + pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { + let (rows, cols) = (rows.clamp(1, 200), cols.clamp(1, 200)); + let (du, dv) = (1.0 / cols as f64, 1.0 / rows as f64); + let eps = 1e-3; + let mut points = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); + for r in 0..=rows { + for c in 0..=cols { + let (u, v) = (c as f64 * du, r as f64 * dv); + let p = s(u, v); + let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); + let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); + let hu = d_u * (du / 3.0); + let hv = d_v * (dv / 3.0); + points.push(MeshPoint { p, color: color(u, v), opacity: 1.0, handles: [hu, -hu, hv, -hv] }); + } + } + Self { rows, cols, points } + } + + /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. + pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { + let coons = Coons::from_path(path)?; + let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; + let color = |u: f64, v: f64| -> Color { + let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge + let w = match appearance { + MeshAppearance::Flat => 0.0, + MeshAppearance::ToCenter => (1.0 - d) * h, + MeshAppearance::ToEdge => d * h, + }; + if w > 0.0 { lerp_color(&base, &Color::WHITE, w) } else { base } + }; + let s = |u: f64, v: f64| coons.eval(u, v); + Some(Self::from_surface(rows, cols, &s, &color)) + } + + /// Horizontal edge from (r, c) to (r, c+1). + pub fn edge_u(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)]); + CubicBez::new(a.p, a.p + a.handles[H_RIGHT], b.p + b.handles[H_LEFT], b.p) + } + /// Vertical edge from (r, c) to (r+1, c). + pub fn edge_v(&self, r: usize, c: usize) -> CubicBez { + let (a, b) = (&self.points[self.idx(r, c)], &self.points[self.idx(r + 1, c)]); + CubicBez::new(a.p, a.p + a.handles[H_DOWN], b.p + b.handles[H_UP], b.p) + } + + /// Coons patch point of patch (r, c) at local (u, v). + pub fn eval(&self, r: usize, c: usize, u: f64, v: f64) -> Point { + let (t, b) = (self.edge_u(r, c).eval(u), self.edge_u(r + 1, c).eval(u)); + let (l, rr) = (self.edge_v(r, c).eval(v), self.edge_v(r, c + 1).eval(v)); + let p00 = self.points[self.idx(r, c)].p.to_vec2(); + let p10 = self.points[self.idx(r, c + 1)].p.to_vec2(); + let p01 = self.points[self.idx(r + 1, c)].p.to_vec2(); + let p11 = self.points[self.idx(r + 1, c + 1)].p.to_vec2(); + ((1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * rr.to_vec2() + - ((1.0 - u) * (1.0 - v) * p00 + u * (1.0 - v) * p10 + (1.0 - u) * v * p01 + u * v * p11)) + .to_point() + } + + /// Bilinear colour and opacity of patch (r, c) at local (u, v). + pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) { + let q = [&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]]; + let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v]; + let mut rgb = [0.0f32; 3]; + let mut op = 0.0f32; + for k in 0..4 { + let c = q[k].color.to_rgb(); + for (ch, x) in rgb.iter_mut().zip(c) { + *ch += x * w[k] as f32; + } + op += q[k].opacity * w[k] as f32; + } + (Color::rgb(rgb[0], rgb[1], rgb[2]), op) + } + + /// Tessellate into `n`×`n` quads per patch, each coloured at its centre. + pub fn quads(&self, n: usize) -> Vec { + if !self.is_valid() { + return vec![]; + } + let n = n.clamp(1, 64); + let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); + let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; + for r in 0..self.rows as usize { + for c in 0..self.cols as usize { + for j in 0..=n { + for i in 0..=n { + grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64); + } + } + for j in 0..n { + for i in 0..n { + let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); + let g = |a: usize, b: usize| grid[b * (n + 1) + a]; + out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity }); + } + } + } + } + out + } + + /// The mesh's outer boundary as a closed path. + pub fn outline(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut segs: Vec = vec![]; + for c in 0..cols { + segs.push(self.edge_u(0, c)); + } + for r in 0..rows { + segs.push(self.edge_v(r, cols)); + } + for c in (0..cols).rev() { + let e = self.edge_u(rows, c); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + for r in (0..rows).rev() { + let e = self.edge_v(r, 0); + segs.push(CubicBez::new(e.p3, e.p2, e.p1, e.p0)); + } + let mut anchors: Vec = Vec::with_capacity(segs.len()); + for (i, s) in segs.iter().enumerate() { + let prev = &segs[(i + segs.len() - 1) % segs.len()]; + anchors.push(Anchor::with_handles(s.p0, prev.p2, s.p1)); + } + PathData::single(SubPath::new(anchors, true)) + } + + /// Mesh lines (all row and column edges) as open paths, for outline view and editing overlays. + pub fn lines(&self) -> PathData { + if !self.is_valid() { + return PathData::default(); + } + let (rows, cols) = (self.rows as usize, self.cols as usize); + let mut subs = vec![]; + let cub = |e: CubicBez| SubPath::new(vec![Anchor::with_handles(e.p0, e.p0, e.p1), Anchor::with_handles(e.p3, e.p2, e.p3)], false); + for r in 0..=rows { + for c in 0..cols { + subs.push(cub(self.edge_u(r, c))); + } + } + for c in 0..=cols { + for r in 0..rows { + subs.push(cub(self.edge_v(r, c))); + } + } + PathData::new(subs) + } + + /// Control bounds (points and handles). + pub fn bounds(&self) -> Option { + let mut it = self.points.iter().flat_map(|p| std::iter::once(p.p).chain(p.handles.iter().map(move |h| p.p + *h))); + let first = it.next()?; + Some(it.fold(Rect::from_points(first, first), |r, q| r.union_pt(q))) + } + + pub fn transform(&mut self, a: Affine) { + let lin = Affine::new({ + let c = a.as_coeffs(); + [c[0], c[1], c[2], c[3], 0.0, 0.0] + }); + for p in &mut self.points { + p.p = a * p.p; + for h in &mut p.handles { + *h = (lin * h.to_point()).to_vec2(); + } + } + } + + /// Map points (and handle ends) through a non-linear function. + pub fn map(&mut self, f: &dyn Fn(Point) -> Point) { + for p in &mut self.points { + let q = f(p.p); + for h in &mut p.handles { + *h = f(p.p + *h) - q; + } + p.p = q; + } + } + + /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). + pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { + if !self.is_valid() { + return None; + } + let bp = self.outline().to_bezpath(); + if bp.winding(p) == 0 { + return None; + } + const N: usize = 16; + let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); + for r in 0..self.rows as usize { + for c in 0..self.cols as usize { + for j in 0..=N { + for i in 0..=N { + let (u, v) = (i as f64 / N as f64, j as f64 / N as f64); + let d = self.eval(r, c, u, v).distance_squared(p); + if d < best.0 { + best = (d, r, c, u, v); + } + } + } + } + } + // Refine with a few Newton-free local searches. + let (_, r, c, mut u, mut v) = best; + let mut step = 0.5 / N as f64; + for _ in 0..12 { + let mut improved = false; + for (du, dv) in [(step, 0.0), (-step, 0.0), (0.0, step), (0.0, -step)] { + let (nu, nv) = ((u + du).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)); + if self.eval(r, c, nu, nv).distance_squared(p) < self.eval(r, c, u, v).distance_squared(p) { + u = nu; + v = nv; + improved = true; + } + } + if !improved { + step /= 2.0; + } + } + Some((r, c, u, v)) + } + + /// Index of the mesh point within `tol` of `p`. + pub fn point_near(&self, p: Point, tol: f64) -> Option { + self.points + .iter() + .enumerate() + .filter(|(_, q)| q.p.distance(p) <= tol) + .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) + .map(|(i, _)| i) + } + + fn surface_du(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((u - e).max(0.0), (u + e).min(1.0)); + (self.eval(r, c, b, v) - self.eval(r, c, a, v)) / (b - a) + } + fn surface_dv(&self, r: usize, c: usize, u: f64, v: f64) -> Vec2 { + let e = 1e-3; + let (a, b) = ((v - e).max(0.0), (v + e).min(1.0)); + (self.eval(r, c, u, b) - self.eval(r, c, u, a)) / (b - a) + } + + /// Insert a mesh row through patch row `r` at local `v`. + pub fn insert_row(&mut self, r: usize, v: f64) { + let cols = self.cols as usize; + let v = v.clamp(0.01, 0.99); + let mut row = Vec::with_capacity(cols + 1); + // Split the vertical edges. + let mut ups = vec![]; + for c in 0..=cols { + let e = self.edge_v(r, c); + let (l, rr) = (e.subsegment(0.0..v), e.subsegment(v..1.0)); + ups.push((l.p1 - l.p0, rr.p2 - rr.p3)); + let pc = c.min(cols - 1); + let u = if c == cols { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(r, pc, u, v); + // Horizontal handles: isocurve derivatives on either side. + let right = if c < cols { self.surface_du(r, c, 0.0, v) / (3.0) } else { Vec2::ZERO }; + let left = if c > 0 { -self.surface_du(r, c - 1, 1.0, v) / 3.0 } else { Vec2::ZERO }; + row.push(MeshPoint { p: l.p3, color, opacity, handles: [right, left, rr.p1 - rr.p0, l.p2 - l.p3] }); + } + for (c, (down, up)) in ups.into_iter().enumerate() { + let i0 = self.idx(r, c); + let i1 = self.idx(r + 1, c); + self.points[i0].handles[H_DOWN] = down; + self.points[i1].handles[H_UP] = up; + } + let at = self.idx(r + 1, 0); + self.points.splice(at..at, row); + self.rows += 1; + } + + /// Insert a mesh column through patch column `c` at local `u`. + pub fn insert_col(&mut self, c: usize, u: f64) { + let rows = self.rows as usize; + let u = u.clamp(0.01, 0.99); + let mut col = Vec::with_capacity(rows + 1); + for r in 0..=rows { + let e = self.edge_u(r, c); + let (l, rr) = (e.subsegment(0.0..u), e.subsegment(u..1.0)); + let pr = r.min(rows - 1); + let v = if r == rows { 1.0 } else { 0.0 }; + let (color, opacity) = self.color_at(pr, c, u, v); + let down = if r < rows { self.surface_dv(r, c, u, 0.0) / 3.0 } else { Vec2::ZERO }; + let up = if r > 0 { -self.surface_dv(r - 1, c, u, 1.0) / 3.0 } else { Vec2::ZERO }; + col.push((MeshPoint { p: l.p3, color, opacity, handles: [rr.p1 - rr.p0, l.p2 - l.p3, down, up] }, l.p1 - l.p0, rr.p2 - rr.p3)); + } + let cols = self.cols as usize; + let mut pts = Vec::with_capacity((rows + 1) * (cols + 2)); + for (r, (mp, right, left)) in col.into_iter().enumerate() { + for cc in 0..=cols { + let mut q = self.points[r * (cols + 1) + cc].clone(); + if cc == c { + q.handles[H_RIGHT] = right; + } + if cc == c + 1 { + q.handles[H_LEFT] = left; + } + pts.push(q); + if cc == c { + pts.push(mp.clone()); + } + } + } + self.points = pts; + self.cols += 1; + } + + /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. + pub fn add_lines_at(&mut self, p: Point) -> Option { + let (r, c, u, v) = self.locate(p)?; + let mut rr = r; + let mut cc = c; + let row_new = v > 0.02 && v < 0.98; + let col_new = u > 0.02 && u < 0.98; + if row_new { + self.insert_row(r, v); + rr = r + 1; + } else if v >= 0.98 { + rr = r + 1; + } + if col_new { + self.insert_col(c, u); + cc = c + 1; + } else if u >= 0.98 { + cc = c + 1; + } + Some(self.idx(rr, cc)) + } + + /// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only). + pub fn remove_point_lines(&mut self, index: usize) -> bool { + if index >= self.points.len() { + return false; + } + let w = self.cols as usize + 1; + let (r, c) = (index / w, index % w); + let mut changed = false; + if r > 0 && r < self.rows as usize { + // Merge the edges above and below: keep the outer handles. + for cc in 0..w { + let above = self.idx(r - 1, cc); + let below = self.idx(r + 1, cc); + let mid = self.idx(r, cc); + let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p); + let _ = pm; + self.points[above].handles[H_DOWN] = (pb - pa) / 3.0; + self.points[below].handles[H_UP] = (pa - pb) / 3.0; + } + self.points.drain(r * w..(r + 1) * w); + self.rows -= 1; + changed = true; + } + let w = self.cols as usize + 1; + if c > 0 && c < self.cols as usize { + let rows = self.rows as usize; + for rr in 0..=rows { + let (l, rt) = (rr * w + c - 1, rr * w + c + 1); + let (pl, pr) = (self.points[l].p, self.points[rt].p); + self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0; + self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0; + } + let mut k = 0; + self.points.retain(|_| { + let keep = k % w != c; + k += 1; + keep + }); + self.cols -= 1; + changed = true; + } + changed + } +} + +// ===================================================================================== +// Envelopes +// ===================================================================================== + +/// Union of the geometric bounds of `nodes`. +pub fn nodes_bounds(nodes: &[Arc]) -> Option { + nodes.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.geometric_bounds())) +} + +/// A `(rows+1)×(cols+1)` grid over `r` (row-major). +pub fn grid_points(r: Rect, rows: u32, cols: u32) -> Vec { + let mut v = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); + for i in 0..=rows { + for j in 0..=cols { + v.push(Point::new(r.x0 + r.width() * j as f64 / cols.max(1) as f64, r.y0 + r.height() * i as f64 / rows.max(1) as f64)); + } + } + v +} + +fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { + let t2 = t * t; + let t3 = t2 * t; + 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3) +} + +/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear +/// extrapolation at the borders so an undistorted grid is the identity). +fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { + let at = |r: i64, c: i64| -> Vec2 { + let rr = r.clamp(0, rows as i64); + let cc = c.clamp(0, cols as i64); + let base = pts[rr as usize * (cols + 1) + cc as usize].to_vec2(); + // Linear extrapolation outside the grid. + let er = r - rr; + let ec = c - cc; + let mut p = base; + if er != 0 && rows >= 1 { + let inner = (rr - er.signum()).clamp(0, rows as i64); + p += (base - pts[inner as usize * (cols + 1) + cc as usize].to_vec2()) * er.abs() as f64; + } + if ec != 0 && cols >= 1 { + let inner = (cc - ec.signum()).clamp(0, cols as i64); + p += (base - pts[rr as usize * (cols + 1) + inner as usize].to_vec2()) * ec.abs() as f64; + } + p + }; + let su = (u.clamp(0.0, 1.0) * cols as f64).min(cols as f64 - 1e-9); + let sv = (v.clamp(0.0, 1.0) * rows as f64).min(rows as f64 - 1e-9); + let (ci, ri) = (su.floor() as i64, sv.floor() as i64); + let (fu, fv) = (su - ci as f64, sv - ri as f64); + let mut rowsv = [Vec2::ZERO; 4]; + for (k, rv) in rowsv.iter_mut().enumerate() { + let r = ri - 1 + k as i64; + *rv = catmull(at(r, ci - 1), at(r, ci), at(r, ci + 1), at(r, ci + 2), fu); + } + catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() +} + +/// The envelope's point map for content whose bounding box is `src`. +pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { + let w = src.width().max(1e-9); + let h = src.height().max(1e-9); + match kind { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); + let b = bend.clamp(-100.0, 100.0) / 100.0; + let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + let c = src.center(); + let (hw, hh) = (w / 2.0, h / 2.0); + let horizontal = *horizontal; + Box::new(move |q: Point| { + let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); + let (x2, y2) = if horizontal { + warp_point(st, b, dh, dv, x, y) + } else { + let (a, b2) = warp_point(st, b, dh, dv, y, x); + (b2, a) + }; + Point::new(c.x + x2 * hw, c.y + y2 * hh) + }) + } + EnvelopeKind::Mesh { rows, cols, points } => { + let (rows, cols) = (*rows as usize, *cols as usize); + if rows == 0 || cols == 0 || points.len() != (rows + 1) * (cols + 1) { + return Box::new(|q| q); + } + Box::new(move |q: Point| grid_eval(rows, cols, points, (q.x - src.x0) / w, (q.y - src.y0) / h)) + } + EnvelopeKind::TopObject { path } => match Coons::from_path(path) { + Some(co) => Box::new(move |q: Point| co.eval((q.x - src.x0) / w, (q.y - src.y0) / h)), + None => Box::new(|q| q), + }, + } +} + +/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. +pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; + +fn map_node(n: &Node, f: &dyn Fn(Point) -> Point, piece: f64, outline: Outliner) -> Node { + let mut out = n.clone(); + match &mut out.kind { + NodeKind::Path { path, live, .. } => { + *path = map_nonlinear(path, piece, f); + *live = None; + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + *c = Arc::new(map_node(c, f, piece, outline)); + } + } + NodeKind::Mesh(m) => m.map(f), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + let g = expanded_group(n, outline); + return map_node(&g, f, piece, outline); + } + NodeKind::Text(_) => { + if let Some(o) = outline.and_then(|h| h(n)) { + return map_node(&o, f, piece, outline); + } + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {} + } + out +} + +/// Evaluate an envelope: its content mapped through the envelope. +pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { + let Some(src) = nodes_bounds(content) else { return content.iter().map(|c| (**c).clone()).collect() }; + let f = envelope_mapper(kind, src); + let diag = src.width().hypot(src.height()).max(1e-6); + let piece = diag / (8.0 + fidelity.clamp(0.0, 100.0) / 100.0 * 56.0); + content.iter().map(|c| map_node(c, &*f, piece, outline)).collect() +} + +/// Envelope bounds (the image of the content box, sampled). +pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind) -> Option { + match kind { + EnvelopeKind::Mesh { points, .. } => { + let first = *points.first()?; + Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) + } + EnvelopeKind::TopObject { path } => path.bounds(), + EnvelopeKind::Warp { .. } => { + let src = nodes_bounds(content)?; + let f = envelope_mapper(kind, src); + const N: usize = 16; + let mut r: Option = None; + for j in 0..=N { + for i in 0..=N { + let q = f(Point::new(src.x0 + src.width() * i as f64 / N as f64, src.y0 + src.height() * j as f64 / N as f64)); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r + } + } +} + +// ===================================================================================== +// Expansion +// ===================================================================================== + +/// Is this one of the live kinds? +pub fn is_live(n: &Node) -> bool { + matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_)) +} + +/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec { + m.quads(n) + .into_iter() + .map(|q| { + let mut ap = Appearance::default(); + let mut fl = FillLayer::new(Paint::solid(q.color)); + fl.opacity = q.opacity; + ap.items.push(AppearanceItem::Fill(fl)); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&q.pts, true)), ap) + }) + .collect() +} + +/// One level of evaluation of a live object (non-live nodes return themselves). Blend steps and +/// envelope results may still contain live objects (e.g. a blend of two meshes). +pub fn expand_live(n: &Node) -> Vec { + expand_live_with(n, None) +} + +pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { + match &n.kind { + NodeKind::Blend { children, spec } => blend_expand(children, spec), + NodeKind::Envelope { content, kind, fidelity, .. } => envelope_expand(content, kind, *fidelity, outline), + NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), + _ => vec![n.clone()], + } +} + +/// A live object evaluated into a plain group (keeping the object's id, name and transparency). +pub fn expanded_group(n: &Node, outline: Outliner) -> Node { + let children = expand_live_with(n, outline).into_iter().map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); + g.name = n.name.clone(); + g.visible = n.visible; + g.locked = n.locked; + g.opacity = n.opacity; + g.blend = n.blend; + g.isolate = n.isolate; + g.knockout = n.knockout; + g +} + +/// Recursively replace every live object in `n` by plain geometry (for exporters). +pub fn expand_deep(n: &Node, outline: Outliner) -> Node { + let mut out = if is_live(n) { expanded_group(n, outline) } else { n.clone() }; + if let Some(ch) = out.children_mut() { + for c in ch.iter_mut() { + if subtree_has_live(c) { + *c = Arc::new(expand_deep(c, outline)); + } + } + } + out +} + +/// Does `n` or any descendant need live evaluation? +pub fn subtree_has_live(n: &Node) -> bool { + let mut any = false; + n.walk(&mut |c| any |= is_live(c)); + any +} + +/// Largest stroke outset in a subtree (for visual bounds of envelopes). +pub(crate) fn max_outset(nodes: &[Arc]) -> f64 { + let mut o = 0.0f64; + for n in nodes { + n.walk(&mut |c| o = o.max(c.appearance.outset())); + } + o +} + +#[cfg(test)] +mod tests { + use super::*; + use drawcraft_geom::shapes; + + fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::basic(Paint::solid(c), Paint::None, 0.0))) + } + + #[test] + fn lerp_path_equalises_anchor_counts() { + let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)); + let t = lerp_path(&a, &shapes::polygon(Point::new(5.0, 5.0), 5.0, 7, 0.0), 0.5); + assert_eq!(t.subpaths[0].anchors.len(), 7); + let m = lerp_path(&a, &b, 0.0); + assert_eq!(m.subpaths[0].anchors.len(), 4); + } + + #[test] + fn blend_steps_and_colours() { + let keys = vec![rect_node(0.0, 0.0, 10.0, 10.0, Color::BLACK), rect_node(100.0, 0.0, 10.0, 10.0, Color::WHITE)]; + let out = blend_expand(&keys, &BlendSpec { spacing: BlendSpacing::Steps(3), ..Default::default() }); + assert_eq!(out.len(), 5); + let mid = out[2].appearance.fill_paint().color().unwrap().to_rgb()[0]; + assert!((mid - 0.5).abs() < 1e-3); + } + + #[test] + fn coons_rect_is_identity() { + let c = Coons::from_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))).unwrap(); + let p = c.eval(0.25, 0.5); + assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); + } + + #[test] + fn grid_identity() { + let r = Rect::new(0.0, 0.0, 100.0, 100.0); + let pts = grid_points(r, 3, 4); + let p = grid_eval(3, 4, &pts, 0.37, 0.81); + assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}"); + } + + #[test] + fn mesh_insert_and_remove_lines() { + let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap(); + assert_eq!((m.rows, m.cols), (2, 2)); + assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p); + assert!(m.remove_point_lines(i)); + assert_eq!((m.rows, m.cols), (1, 1)); + } +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 659744a8..8c28bc79 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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>, + #[serde(default)] + spec: BlendSpec, + }, + /// Live envelope distortion of `content`. + Envelope { + content: Vec>, + 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>> { 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>> { 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 ``. @@ -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). diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index d2281d19..6defd176 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -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 = Vec::new(); - let mut pending_in: Option = 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 { diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 6280aa5e..b6ff4473 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -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 { - 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 { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 259b193c..8320930f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs new file mode 100644 index 00000000..ffe80760 --- /dev/null +++ b/crates/engine/src/cmd/recolor.rs @@ -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 { + 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 { + let st = s.doc()?; + let mut counts: BTreeMap = 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::>() })) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + let map: BTreeMap = 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 })) +} diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index a144ca4b..376d158d 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -127,3 +127,21 @@ fn export_for_screens_writes_every_combination() { assert!(files.iter().any(|f| f.as_str().unwrap().ends_with("Artboard-2@2x.png"))); 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:?}"), + } +} diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs new file mode 100644 index 00000000..29308a86 --- /dev/null +++ b/crates/text/src/composer.rs @@ -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, +} + +/// 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> { + 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 = 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 = 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) +} diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs new file mode 100644 index 00000000..0a3752ec --- /dev/null +++ b/crates/text/src/edit.rs @@ -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) { + let first_style = runs.first().map(|r| r.style.clone()); + let mut out: Vec = 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, 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, 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, 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::() +} + +/// Apply `f` to the character style of `start..end`, splitting runs at the range ends. +pub fn style_range(runs: &mut Vec, 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 { + 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 { + 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 { + 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 +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs new file mode 100644 index 00000000..d6272c57 --- /dev/null +++ b/crates/text/src/features.rs @@ -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) -> 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 { + 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 + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 4155cd3e..696ee91b 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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>>, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, + /// System fallback state: scanned yet, and characters no system font covers. + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex, } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct SysFallback { + enabled: bool, + scanned: bool, + misses: std::collections::HashSet, +} + +/// 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 = 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> { + 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> { let faces = self.read_faces(); let mut order: Vec<&Arc> = 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 = 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 = 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 { + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return p.clone(); diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs new file mode 100644 index 00000000..06b19038 --- /dev/null +++ b/crates/text/src/hyphen.rs @@ -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 { + let c: Vec = 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 = 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 +} diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 45796555..b19ca123 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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, runs: &[(Range, &CharStyle)], out: &mut Vec) { +pub(crate) fn shape_range( + db: &FontDb, + text: &str, + range: Range, + runs: &[(Range, &CharStyle)], + feats: &OtFeatures, + out: &mut Vec, +) { 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, 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, run: usize, st: &CharStyle, face: &Arc, out: &mut Vec) { +fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, face: &Arc, feats: &OtFeatures, out: &mut Vec) { 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, run: usize, st: &CharStyle, fa } buf.set_direction(Direction::LeftToRight); buf.guess_segment_properties(); - let feats: Vec = if st.kerning.is_some() { vec![Feature::new(Tag::new(b"kern"), 0, ..)] } else { Vec::new() }; + let feats: Vec = 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, 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, run: usize, st: &CharStyle, fa ascent, descent, leading, - ch: first_char(cl), + ch, }); } } diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml new file mode 100644 index 00000000..fe12a80b --- /dev/null +++ b/crates/trace/Cargo.toml @@ -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 } diff --git a/crates/trace/src/contour.rs b/crates/trace/src/contour.rs new file mode 100644 index 00000000..ebd3d6a0 --- /dev/null +++ b/crates/trace/src/contour.rs @@ -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, + /// 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, Vec) { + 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 { + 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> = vec![None; sizes.len()]; + let mut holes: Vec> = 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 +} diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs new file mode 100644 index 00000000..299d2a58 --- /dev/null +++ b/crates/trace/src/fit.rs @@ -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 { + let pts: Vec = 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 = (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 { + 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; + } + } +} diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs new file mode 100644 index 00000000..1aedd3a6 --- /dev/null +++ b/crates/trace/src/lib.rs @@ -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, +} + +impl Raster { + /// A raster from RGBA bytes (`width * height * 4` of them). + pub fn new(width: u32, height: u32, rgba: Vec) -> 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 { + 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 { + 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, + /// 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 = (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; diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs new file mode 100644 index 00000000..6ae38a4f --- /dev/null +++ b/crates/trace/src/quantize.rs @@ -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, + 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 = vec![Bin::default(); 1 << 15]; + for p in img.rgba.chunks_exact(4) { + if p[3] >= 128 { + let b = &mut bins[key(p[0], p[1], p[2])]; + b.n += 1; + b.sum[0] += p[0] as u64; + b.sum[1] += p[1] as u64; + b.sum[2] += p[2] as u64; + } + } + let used: Vec<(usize, [f64; 3], f64)> = + bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); + let k = params.colors.clamp(2, 256) as usize; + let centers = kmeans_rgb(&used, k); + let mut lut = vec![0u16; 1 << 15]; + for (i, c, _) in &used { + lut[*i] = nearest_rgb(¢ers, *c) as u16; + } + let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[key(p[0], p[1], p[2])] }).collect(); + let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect(); + compact(labels, palette) + } + } +} + +/// Drop unused palette entries and merge duplicates. +fn compact(mut labels: Vec, 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 { + 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 = (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![(0..used.len()).collect()]; + while boxes.len() < k { + let mut best: Option<(f64, usize, usize)> = None; + for (bi, b) in boxes.iter().enumerate() { + if b.len() < 2 { + continue; + } + let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); + let mut wsum = 0.0; + for &i in b { + for ch in 0..3 { + lo[ch] = lo[ch].min(used[i].1[ch]); + hi[ch] = hi[ch].max(used[i].1[ch]); + } + wsum += used[i].2; + } + let (ch, range) = (0..3).map(|c| (c, hi[c] - lo[c])).fold((0, -1.0), |a, b| if b.1 > a.1 { b } else { a }); + if range <= 0.0 { + continue; + } + let score = range * wsum.sqrt(); + if best.is_none_or(|x| score > x.0) { + best = Some((score, bi, ch)); + } + } + let Some((_, bi, ch)) = best else { break }; + let mut b = boxes.swap_remove(bi); + b.sort_by(|x, y| used[*x].1[ch].total_cmp(&used[*y].1[ch])); + let total: f64 = b.iter().map(|&i| used[i].2).sum(); + let mut acc = 0.0; + let mut cut = 1; + for (j, &i) in b.iter().enumerate() { + acc += used[i].2; + if acc >= total / 2.0 { + cut = (j + 1).clamp(1, b.len() - 1); + break; + } + } + let tail = b.split_off(cut); + boxes.push(b); + boxes.push(tail); + } + let mut centers: Vec<[f64; 3]> = boxes + .iter() + .map(|b| { + let mut s = [0.0; 3]; + let mut w = 0.0; + for &i in b { + for ch in 0..3 { + s[ch] += used[i].1[ch] * used[i].2; + } + w += used[i].2; + } + [s[0] / w, s[1] / w, s[2] / w] + }) + .collect(); + // Lloyd refinement. + for _ in 0..12 { + let mut sum = vec![[0.0; 3]; centers.len()]; + let mut cnt = vec![0.0; centers.len()]; + for (_, c, n) in used { + let j = nearest_rgb(¢ers, *c); + for ch in 0..3 { + sum[j][ch] += c[ch] * n; + } + cnt[j] += n; + } + let mut moved = 0.0f64; + for j in 0..centers.len() { + if cnt[j] > 0.0 { + let m = [sum[j][0] / cnt[j], sum[j][1] / cnt[j], sum[j][2] / cnt[j]]; + moved = moved.max(dist2(m, centers[j])); + centers[j] = m; + } + } + if moved < 0.25 { + break; + } + } + centers +} + +/// Merge 4-connected same-label components smaller than `min_area` pixels into their most common +/// neighbouring label. Transparent pixels are never relabelled. +pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) { + if min_area <= 1 || w == 0 || h == 0 { + return; + } + let mut seen = vec![false; w * h]; + let mut stack: Vec = Vec::new(); + let mut comp: Vec = 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; + } + } + } +} diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs new file mode 100644 index 00000000..b2a3e7f6 --- /dev/null +++ b/crates/trace/src/tests.rs @@ -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 = 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"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 54f5ac43..63470cd9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { 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 = 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 = 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 +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a76d20..ea6c9827 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 Err(format!("unknown effect `{id}`")), } } + "ui.recolorDialog" => match app.run("recolor.colors", json!({})) { + Ok(v) => { + let pairs: Vec = 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)> { 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"), diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index c1a78a25..3fffd6ba 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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));