From f8136f1e357c97bf228b9a690d0980c753d6110a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:02:16 -0400 Subject: [PATCH] Live Blends/Envelopes/Gradient Mesh, Shape Builder, Live Paint, Image Trace (drawcraft-trace), Recolor Artwork, Export for Screens, Actions panel, prefs persistence, real layer thumbnails; menus bound Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 +- Cargo.toml | 1 + apps/drawcraft/src/main.rs | 5 +- crates/brush/Cargo.toml | 1 - crates/brush/src/calli.rs | 9 +- crates/brush/src/colorize.rs | 103 +++ crates/brush/src/defaults.rs | 218 +++++ crates/brush/src/lib.rs | 14 +- crates/brush/src/tests.rs | 336 ++++++++ crates/brush/src/track.rs | 18 +- crates/brush/src/warp.rs | 18 +- crates/doc/src/live.rs | 37 +- crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/brushsym.rs | 1020 ++++++++++++++++++++++++ crates/engine/src/cmd/buildcmds.rs | 738 +++++++++++++++++ crates/engine/src/cmd/file.rs | 17 +- crates/engine/src/cmd/live.rs | 852 ++++++++++++++++++++ crates/engine/src/cmd/menucmds.rs | 351 +------- crates/engine/src/cmd/mod.rs | 10 +- crates/engine/src/cmd/recolor.rs | 10 +- crates/engine/src/cmd/textedit.rs | 390 +++++++++ crates/engine/src/cmd/typemenu.rs | 23 +- crates/engine/src/lib.rs | 8 + crates/engine/src/tests_brushsym.rs | 398 +++++++++ crates/engine/src/tests_build.rs | 420 ++++++++++ crates/engine/src/tests_file.rs | 6 +- crates/engine/src/tests_live.rs | 702 ++++++++++++++++ crates/engine/src/tests_menucmds.rs | 8 +- crates/engine/src/tests_textedit.rs | 323 ++++++++ crates/pdf/src/export.rs | 7 + crates/render/Cargo.toml | 1 + crates/render/src/brush_fx.rs | 198 +++++ crates/render/src/lib.rs | 22 +- crates/render/src/live.rs | 186 +++++ crates/svg/src/export.rs | 9 + crates/text/src/composer.rs | 8 +- crates/text/src/edit.rs | 24 +- crates/text/src/fontdb.rs | 9 +- crates/text/src/hyphen.rs | 8 +- crates/text/src/layout.rs | 439 ++++++++-- crates/text/src/lib.rs | 211 ++++- crates/text/src/shape.rs | 14 + crates/text/src/tests_typo.rs | 404 ++++++++++ crates/tools/Cargo.toml | 1 + crates/tools/src/builder.rs | 723 +++++++++++++++++ crates/tools/src/lib.rs | 14 +- crates/tools/src/meshblend.rs | 220 +++++ crates/tools/src/symbolism.rs | 323 ++++++++ crates/tools/src/text.rs | 557 ++++++++++--- crates/tools/src/text_tests.rs | 220 +++++ crates/trace/src/fit.rs | 56 +- crates/trace/src/lib.rs | 8 + crates/trace/src/quantize.rs | 16 +- crates/trace/src/tests.rs | 4 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/dialogs.rs | 17 +- crates/ui-egui/src/menus.rs | 48 +- crates/ui-egui/src/panels/actions.rs | 26 +- crates/ui-egui/src/panels/brushes.rs | 236 ++++-- crates/ui-egui/src/panels/character.rs | 203 ++++- crates/ui-egui/src/panels/glyphs.rs | 219 +++++ crates/ui-egui/src/panels/layers.rs | 43 +- crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/paragraph.rs | 15 +- crates/ui-egui/src/panels/symbols.rs | 171 +++- crates/ui-egui/src/shortcuts.rs | 9 +- crates/ui-egui/src/state.rs | 3 +- xtask/src/layers.rs | 1 + 68 files changed, 9970 insertions(+), 753 deletions(-) create mode 100644 crates/brush/src/colorize.rs create mode 100644 crates/brush/src/defaults.rs create mode 100644 crates/brush/src/tests.rs create mode 100644 crates/engine/src/cmd/brushsym.rs create mode 100644 crates/engine/src/cmd/buildcmds.rs create mode 100644 crates/engine/src/cmd/live.rs create mode 100644 crates/engine/src/cmd/textedit.rs create mode 100644 crates/engine/src/tests_brushsym.rs create mode 100644 crates/engine/src/tests_build.rs create mode 100644 crates/engine/src/tests_live.rs create mode 100644 crates/engine/src/tests_textedit.rs create mode 100644 crates/render/src/brush_fx.rs create mode 100644 crates/render/src/live.rs create mode 100644 crates/text/src/tests_typo.rs create mode 100644 crates/tools/src/builder.rs create mode 100644 crates/tools/src/meshblend.rs create mode 100644 crates/tools/src/symbolism.rs create mode 100644 crates/tools/src/text_tests.rs create mode 100644 crates/ui-egui/src/panels/glyphs.rs diff --git a/Cargo.lock b/Cargo.lock index 3e1b3359f..fc6499a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,7 +844,6 @@ dependencies = [ "drawcraft-color", "drawcraft-doc", "drawcraft-geom", - "drawcraft-pathops", "kurbo 0.13.1", "serde", "serde_json", @@ -898,6 +897,7 @@ dependencies = [ name = "drawcraft-engine" version = "0.1.0" dependencies = [ + "drawcraft-brush", "drawcraft-color", "drawcraft-doc", "drawcraft-format", @@ -909,6 +909,7 @@ dependencies = [ "drawcraft-testkit", "drawcraft-text", "drawcraft-tools", + "drawcraft-trace", "kurbo 0.13.1", "log", "proptest", @@ -993,6 +994,7 @@ dependencies = [ name = "drawcraft-render" version = "0.1.0" dependencies = [ + "drawcraft-brush", "drawcraft-color", "drawcraft-doc", "drawcraft-effects", @@ -1059,6 +1061,7 @@ dependencies = [ "drawcraft-color", "drawcraft-doc", "drawcraft-geom", + "drawcraft-pathops", "drawcraft-text", "kurbo 0.13.1", "serde", diff --git a/Cargo.toml b/Cargo.toml index eb4501ab2..88e995b62 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-brush = { path = "crates/brush" } drawcraft-trace = { path = "crates/trace" } drawcraft-text = { path = "crates/text" } drawcraft-effects = { path = "crates/effects" } diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index dde772ac3..c40f24138 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -50,7 +50,10 @@ fn prefs_path() -> Option { } else if cfg!(windows) { std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DrawCraft")) } else { - std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))).map(|c| c.join("drawcraft")) + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("drawcraft")) }; base.map(|b| b.join("ui.json")) } diff --git a/crates/brush/Cargo.toml b/crates/brush/Cargo.toml index 9e7da924c..784232aa7 100644 --- a/crates/brush/Cargo.toml +++ b/crates/brush/Cargo.toml @@ -12,7 +12,6 @@ workspace = true 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 index 2720a8ef0..0a5c69799 100644 --- a/crates/brush/src/calli.rs +++ b/crates/brush/src/calli.rs @@ -36,10 +36,6 @@ impl Nib { 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. @@ -150,7 +146,10 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam 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 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; diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs new file mode 100644 index 000000000..a1ef4bf46 --- /dev/null +++ b/crates/brush/src/colorize.rs @@ -0,0 +1,103 @@ +//! Brush colorization (None / Tints / Tints and Shades / Hue Shift) and symbol staining. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{AppearanceItem, Node}; + +use crate::Colorization; + +fn luminance(c: &Color) -> f32 { + let [r, g, b] = c.to_rgb(); + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color { + let lum = luminance(c); + match mode { + Colorization::None => *c, + Colorization::Tints => key.lerp(&Color::WHITE, lum), + Colorization::TintsAndShades => { + if lum < 0.5 { + Color::BLACK.lerp(key, lum * 2.0) + } else { + key.lerp(&Color::WHITE, (lum - 0.5) * 2.0) + } + } + Colorization::HueShift { key: art_key } => { + let [kh, _, _] = art_key.to_hsb(); + let [th, _, _] = key.to_hsb(); + let [h, s, v] = c.to_hsb(); + if (c.to_rgb().iter().zip(art_key.to_rgb()).map(|(a, b)| (a - b).abs()).sum::()) < 0.02 { + return *key; + } + Color::from_hsb((h + th - kh).rem_euclid(360.0), s, v) + } + } +} + +fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) { + if let Paint::Solid { color, swatch } = p { + *color = map_color(color, mode, key); + *swatch = None; + } +} + +/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so +/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. +pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) { + let key = match (mode, stroke.color()) { + (Colorization::None, _) | (_, None) => None, + (_, Some(c)) => Some(c), + }; + fn walk(n: &mut Node, mode: Colorization, key: Option) { + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(f) => { + if let Some(k) = &key { + map_paint(&mut f.paint, mode, k); + } + } + AppearanceItem::Stroke(s) => { + s.brush = None; + if let Some(k) = &key { + map_paint(&mut s.paint, mode, k); + } + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + walk(Arc::make_mut(c), mode, key); + } + } + } + walk(n, mode, key); +} + +/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. +pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { + let amount = amount.clamp(0.0, 1.0); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Solid { color: c, swatch } = p { + *c = c.lerp(color, amount); + *swatch = None; + } + } + if let drawcraft_doc::NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + if let Paint::Solid { color: c, .. } = &mut r.style.fill { + *c = c.lerp(color, amount); + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + tint_node(Arc::make_mut(c), color, amount); + } + } +} diff --git a/crates/brush/src/defaults.rs b/crates/brush/src/defaults.rs new file mode 100644 index 000000000..1acd7afdf --- /dev/null +++ b/crates/brush/src/defaults.rs @@ -0,0 +1,218 @@ +//! The default brush library. All art is original and drawn here in code. +//! +//! Art is authored at 1 pt stroke weight, left → right, centred on y = 0. + +use std::sync::{Arc, OnceLock}; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId, NodeKind}; +use drawcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes}; + +use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter}; + +fn fill(c: Color) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], effects: vec![] } +} + +fn path(p: PathData, c: Color) -> Node { + Node::path(NodeId(0), p, fill(c)) +} + +fn group(children: Vec) -> Node { + Node::group(NodeId(0), children.into_iter().map(Arc::new).collect()) +} + +/// Even-odd compound of `paths`, filled `c`. +fn compound(paths: Vec, c: Color) -> Node { + let children = paths.into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = fill(c); + n +} + +fn smooth_closed(pts: &[Point]) -> PathData { + let n = pts.len(); + let anchors = (0..n) + .map(|i| { + let (prev, p, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]); + let t = (next - prev) / 6.0; + Anchor::with_handles(p, p - t, p + t) + }) + .collect(); + PathData::single(SubPath::new(anchors, true)) +} + +/// A 4 pt dot (Dots scatter brush, default scatter art). +pub(crate) fn dot_art() -> Node { + path(shapes::ellipse(Rect::new(-2.0, -2.0, 2.0, 2.0)), Color::BLACK) +} + +/// A smooth lens tapering to points at both ends, 100 × 4 pt. +pub(crate) fn taper_art() -> Node { + let sp = SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(30.0, 2.7), Point::new(30.0, -2.7)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, -2.7), Point::new(70.0, 2.7)), + ], + true, + ); + path(PathData::single(sp), Color::BLACK) +} + +/// A rough, tapered charcoal smear, 100 × 6 pt. +fn charcoal_art() -> Node { + let n = 24; + let jitter = + [0.82, 1.0, 0.9, 0.7, 0.95, 0.85, 1.0, 0.75, 0.92, 0.8, 0.98, 0.72, 0.88, 1.0, 0.78, 0.93, 0.86, 0.7, 0.97, 0.83, 0.9, 0.76, 1.0, 0.85]; + let mut pts = vec![]; + for i in 0..=n { + let x = 100.0 * i as f64 / n as f64; + let env = (std::f64::consts::PI * x / 100.0).sin().powf(0.6); + pts.push(Point::new(x, -3.0 * env * jitter[i % jitter.len()])); + } + for i in (1..n).rev() { + let x = 100.0 * i as f64 / n as f64; + let env = (std::f64::consts::PI * x / 100.0).sin().powf(0.6); + pts.push(Point::new(x, 3.0 * env * jitter[(i * 7 + 3) % jitter.len()])); + } + let main = path(PathData::single(SubPath::polyline(&pts, true)), Color::gray(0.85)); + // A few grainy streaks inside. + let streak = |y: f64, x0: f64, x1: f64| path(shapes::ellipse(Rect::new(x0, y - 0.35, x1, y + 0.35)), Color::WHITE); + group(vec![main, streak(-1.2, 20.0, 45.0), streak(0.9, 55.0, 85.0), streak(0.1, 8.0, 22.0)]) +} + +/// A shaft with a triangular head at the right end, 100 × 8 pt. +fn arrow_art() -> Node { + let shaft = shapes::rectangle(Rect::new(0.0, -1.0, 84.0, 1.0)); + let head = PathData::single(SubPath::polyline(&[Point::new(80.0, -4.0), Point::new(100.0, 0.0), Point::new(80.0, 4.0)], true)); + group(vec![path(shaft, Color::BLACK), path(head, Color::BLACK)]) +} + +/// A small leaf, 12 × 6 pt, pointing right. +fn leaf_art() -> Node { + let body = smooth_closed(&[Point::new(0.0, 0.0), Point::new(6.0, -3.0), Point::new(12.0, 0.0), Point::new(6.0, 3.0)]); + let vein = shapes::rectangle(Rect::new(1.0, -0.2, 11.0, 0.2)); + group(vec![path(body, Color::rgb8(0x3f, 0x8f, 0x4a)), path(vein, Color::rgb8(0xc9, 0xe8, 0xb5))]) +} + +/// Two confetti squares of different greys (tinted by the stroke colour). +fn confetti_art() -> Node { + group(vec![ + path(shapes::rectangle(Rect::new(-1.5, -1.5, 1.5, 1.5)), Color::BLACK), + path(shapes::rectangle(Rect::new(0.5, 0.5, 2.5, 2.5)), Color::gray(0.45)), + ]) +} + +/// One chain link (an oval ring) plus half of the connecting links, 14 × 6 pt. +fn chain_tile() -> Node { + let ring = compound(vec![shapes::ellipse(Rect::new(1.0, -3.0, 13.0, 3.0)), shapes::ellipse(Rect::new(3.0, -1.4, 11.0, 1.4))], Color::BLACK); + let bar_l = path(shapes::rectangle(Rect::new(0.0, -0.6, 1.5, 0.6)), Color::BLACK); + let bar_r = path(shapes::rectangle(Rect::new(12.5, -0.6, 14.0, 0.6)), Color::BLACK); + group(vec![ring, bar_l, bar_r]) +} + +/// A round ring for chain corners, 8 × 8 pt. +fn chain_corner() -> Node { + compound(vec![shapes::ellipse(Rect::new(-4.0, -4.0, 4.0, 4.0)), shapes::ellipse(Rect::new(-2.2, -2.2, 2.2, 2.2))], Color::BLACK) +} + +/// A dash with a clear gap (running stitches), 10 × 1.5 pt. +pub(crate) fn stitch_tile() -> Node { + let dash = path(shapes::rounded_rectangle(Rect::new(1.5, -0.75, 8.5, 0.75), 0.75), Color::BLACK); + // Invisible spacer that fixes the tile width. + let mut spacer = path(shapes::rectangle(Rect::new(0.0, -0.75, 10.0, 0.75)), Color::BLACK); + spacer.appearance = Appearance::default(); + group(vec![spacer, dash]) +} + +/// A diamond tile for borders, 8 × 6 pt. +fn diamond_tile() -> Node { + let d = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(4.0, -3.0), Point::new(8.0, 0.0), Point::new(4.0, 3.0)], true)); + path(d, Color::BLACK) +} + +fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { + Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) } +} + +/// The default library. +pub fn defaults() -> &'static [Brush] { + static LIB: OnceLock> = OnceLock::new(); + LIB.get_or_init(|| { + vec![ + calli("3 pt. Round", 0.0, 100.0, 3.0), + calli("6 pt. Flat", 45.0, 15.0, 6.0), + calli("10 pt. Oval", 30.0, 45.0, 10.0), + Brush { + name: "Charcoal".into(), + kind: BrushKind::Art(ArtBrush { art: charcoal_art(), colorization: Colorization::Tints, ..Default::default() }), + }, + Brush { name: "Tapered Stroke".into(), kind: BrushKind::Art(ArtBrush::default()) }, + Brush { + name: "Arrow".into(), + kind: BrushKind::Art(ArtBrush { + art: arrow_art(), + direction: Direction::LeftToRight, + scale: ArtScale::BetweenGuides { start: 0.0, end: 0.78 }, + colorization: Colorization::Tints, + ..Default::default() + }), + }, + Brush { + name: "Dots".into(), + kind: BrushKind::Scatter(Scatter { spacing: (160.0, 160.0), colorization: Colorization::Tints, ..Default::default() }), + }, + Brush { + name: "Confetti".into(), + kind: BrushKind::Scatter(Scatter { + art: confetti_art(), + size: (60.0, 140.0), + spacing: (120.0, 220.0), + scatter: (-150.0, 150.0), + rotation: (-180.0, 180.0), + rotation_relative_to_path: false, + colorization: Colorization::Tints, + }), + }, + Brush { + name: "Leaves".into(), + kind: BrushKind::Scatter(Scatter { + art: leaf_art(), + size: (80.0, 120.0), + spacing: (90.0, 130.0), + scatter: (-60.0, 60.0), + rotation: (-35.0, 35.0), + rotation_relative_to_path: true, + colorization: Colorization::None, + }), + }, + Brush { + name: "Chain".into(), + kind: BrushKind::Pattern(PatternBrush { + side: chain_tile(), + outer_corner: Some(chain_corner()), + inner_corner: Some(chain_corner()), + fit: PatternFit::Stretch, + ..Default::default() + }), + }, + Brush { name: "Stitches".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, + Brush { + name: "Diamonds".into(), + kind: BrushKind::Pattern(PatternBrush { side: diamond_tile(), spacing: 25.0, fit: PatternFit::AddSpace, ..Default::default() }), + }, + Brush { name: "Bristle Round".into(), kind: BrushKind::Bristle(Bristle::default()) }, + Brush { + name: "Bristle Flat Fan".into(), + kind: BrushKind::Bristle(Bristle { + shape: BristleShape::FlatFan, + size: 9.0, + density: 80.0, + thickness: 25.0, + opacity: 55.0, + ..Default::default() + }), + }, + ] + }) +} diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index c7fee1a1c..c78937334 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -25,6 +25,7 @@ use serde::{Deserialize, Serialize}; pub use colorize::{colorize, tint_node}; pub use defaults::defaults; +pub use serde_json; /// Key of the brush library in `Document::unknown`. pub const DOC_KEY: &str = "brushes"; @@ -39,6 +40,8 @@ pub struct Brush { pub kind: BrushKind, } +// Brushes are few and long-lived; boxing the art-carrying variants buys nothing. +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum BrushKind { @@ -419,16 +422,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option { 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)); - } + children.extend(pieces.into_iter().map(Arc::new)); let mut g = Node::group(NodeId(0), children); g.opacity = n.opacity; g.blend = n.blend; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs new file mode 100644 index 000000000..c01d0a2f4 --- /dev/null +++ b/crates/brush/src/tests.rs @@ -0,0 +1,336 @@ +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; +use drawcraft_geom::{BezPath, Point, Rect, shapes}; + +use super::*; + +fn line(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + let mut bp = BezPath::new(); + bp.move_to((x0, y0)); + bp.line_to((x1, y1)); + bp +} + +fn stroke(w: f64) -> StrokeLayer { + StrokeLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), w) +} + +fn calli(angle: f64, roundness: f64, size: f64) -> Brush { + Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) } +} + +fn bounds(nodes: &[Node]) -> Rect { + nodes.iter().fold(None, |a, n| drawcraft_geom::union_opt(a, n.geometric_bounds())).unwrap() +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +fn all_points(n: &Node, out: &mut Vec) { + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.extend(p.anchors().map(|(_, _, a)| a.p)); + } + }); +} + +#[test] +fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() { + let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert_eq!(out.len(), 1); + let b = bounds(&out); + assert!(near(b.height(), 10.0, 0.05), "{b:?}"); + assert!(near(b.x0, -1.0, 0.05) && near(b.x1, 101.0, 0.05), "caps add half the minor axis: {b:?}"); +} + +#[test] +fn calligraphic_flat_nib_is_thin_along_its_angle() { + let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out)); +} + +#[test] +fn calligraphic_width_at_45_degrees_matches_ellipse_support() { + let (a, b) = (5.0_f64, 1.0_f64); + let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt(); + assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height()); + // A vertical stroke with the same nib has the same width (45° is symmetric). + let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0)); + assert!(near(bounds(&v).width(), expect, 0.05)); +} + +#[test] +fn calligraphic_scales_with_stroke_weight() { + let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0)); + assert!(near(bounds(&out).height(), 6.0, 0.05)); +} + +#[test] +fn round_nib_is_direction_independent() { + let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0)); + let b = bounds(&out); + // Round caps: the bounds grow by the radius on every side. + assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}"); +} + +#[test] +fn closed_calligraphic_makes_a_ring() { + let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0)); + let pd = out[0].path_data().unwrap(); + assert_eq!(pd.subpaths.len(), 2); + let poly_area = |sp: &drawcraft_geom::SubPath| { + let a = &sp.anchors; + (0..a.len()).map(|i| a[i].p.x * a[(i + 1) % a.len()].p.y - a[(i + 1) % a.len()].p.x * a[i].p.y).sum::() / 2.0 + }; + let (outer, inner) = (poly_area(&pd.subpaths[0]), poly_area(&pd.subpaths[1])); + assert!(outer * inner < 0.0, "opposite windings make a hole"); + let area = (outer.abs() - inner.abs()).abs(); + let expect = std::f64::consts::PI * (53.0f64.powi(2) - 47.0f64.powi(2)); + assert!((area - expect).abs() / expect < 0.03, "{area} vs {expect}"); +} + +fn art_brush(art: Node, scale: ArtScale) -> Brush { + Brush { name: "a".into(), kind: BrushKind::Art(ArtBrush { art, scale, colorization: Colorization::None, ..Default::default() }) } +} + +fn rect_art(w: f64, h: f64) -> Node { + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -h / 2.0, w, h / 2.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)) +} + +#[test] +fn art_brush_stretches_to_path_length() { + let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0)); + let b = bounds(&out); + assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}"); + assert!(near(b.height(), 4.0, 1e-6)); + // 3 pt stroke → 3× wider. + let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0)); + assert!(near(bounds(&out).height(), 12.0, 1e-6)); +} + +#[test] +fn art_brush_arc_length_mapping_is_linear() { + // A marker square at art x ∈ [5, 6] of a 10 wide art lands at 50..60 % of the path. + let marker = + Node::path(NodeId(0), shapes::rectangle(Rect::new(5.0, -1.0, 6.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let frame = rect_art(10.0, 2.0); + let mut spacer = frame.clone(); + spacer.appearance = Appearance::default(); + let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]); + let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let g = &out[0]; + let m = g.children().unwrap()[1].geometric_bounds().unwrap(); + assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}"); +} + +#[test] +fn art_brush_proportional_keeps_aspect() { + let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + assert!(near(bounds(&out).height(), 8.0, 1e-6)); +} + +#[test] +fn art_brush_between_guides_keeps_ends() { + // Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path. + let head = + Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let shaft = rect_art(80.0, 2.0); + let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]); + let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let h = out[0].children().unwrap()[1].geometric_bounds().unwrap(); + assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}"); +} + +#[test] +fn art_brush_follows_curves() { + let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath(); + let mut bp_open = BezPath::new(); + // Upper half of the circle, as an open path. + for (i, el) in bp.elements().iter().enumerate() { + if i < 3 { + bp_open.push(*el); + } + } + let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); + let mut pts = vec![]; + all_points(&out[0], &mut pts); + assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len()); + for p in pts { + let r = p.distance(Point::new(100.0, 100.0)); + assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre"); + } +} + +fn scatter_brush(spacing: f64) -> Brush { + Brush { + name: "s".into(), + kind: BrushKind::Scatter(Scatter { + art: rect_art(10.0, 4.0), + spacing: (spacing, spacing), + colorization: Colorization::None, + ..Default::default() + }), + } +} + +#[test] +fn scatter_count_and_spacing() { + let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert_eq!(out.len(), 10); + let centres: Vec = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect(); + for (i, c) in centres.iter().enumerate() { + assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}"); + } + let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert_eq!(out.len(), 5); +} + +#[test] +fn scatter_is_deterministic_and_randomised() { + let b = Brush { + name: "r".into(), + kind: BrushKind::Scatter(Scatter { + art: rect_art(4.0, 4.0), + size: (50.0, 150.0), + scatter: (-100.0, 100.0), + rotation: (-90.0, 90.0), + ..Default::default() + }), + }; + let p = line(0.0, 0.0, 300.0, 0.0); + let a1 = stroke_pieces(&b, &p, &stroke(1.0)); + let a2 = stroke_pieces(&b, &p, &stroke(1.0)); + assert_eq!(a1, a2); + let ys: Vec = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect(); + assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}"); +} + +fn pattern_brush(fit: PatternFit, corners: bool) -> Brush { + let side = rect_art(10.0, 4.0); + let corner = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -3.0, 6.0, 3.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { + side, + outer_corner: corners.then(|| corner.clone()), + inner_corner: corners.then_some(corner), + fit, + colorization: Colorization::None, + ..Default::default() + }), + } +} + +#[test] +fn pattern_tile_count_by_fit() { + let p = line(0.0, 0.0, 100.0, 0.0); + assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10); + let p2 = line(0.0, 0.0, 104.0, 0.0); + let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0)); + assert_eq!(st.len(), 10); + assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly"); + let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0)); + assert_eq!(sp.len(), 10); + assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size"); + assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5); +} + +#[test] +fn pattern_places_corner_tiles() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); + let corners = out + .iter() + .filter( + |n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0), + ) + .count(); + assert_eq!(corners, 4); + // Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each. + assert_eq!(out.len() - corners, 36); +} + +#[test] +fn bristle_makes_translucent_strands() { + let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) }; + let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + assert!(out.len() >= 5, "{}", out.len()); + assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0)); + let bb = bounds(&out); + assert!(bb.height() <= 6.0 * 1.3 && bb.height() >= 3.0, "{bb:?}"); +} + +#[test] +fn tints_colorization_uses_stroke_colour() { + let b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch); + let BrushKind::Art(mut a) = b.kind else { unreachable!() }; + a.colorization = Colorization::Tints; + let b = Brush { name: "t".into(), kind: BrushKind::Art(a) }; + let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0)); + assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let mut white = rect_art(1.0, 1.0); + white.appearance.set_fill(Paint::solid(Color::WHITE)); + colorize(&mut white, Colorization::Tints, &Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]); +} + +#[test] +fn library_defaults_store_and_find() { + let mut d = Document::new(100.0, 100.0); + let lib = library(&d); + assert!(lib.len() >= 10); + for kind in ["calligraphic", "scatter", "art", "pattern", "bristle"] { + assert!(lib.iter().any(|b| b.kind.type_id() == kind), "{kind}"); + } + assert!(find(&d, "Chain").is_some()); + let mut lib = lib; + lib.retain(|b| b.name != "Chain"); + store(&mut d, &lib); + assert!(find(&d, "Chain").is_none()); + assert_eq!(library(&d).len(), lib.len()); + assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2"); +} + +#[test] +fn brushes_serde_round_trip() { + let lib = defaults().to_vec(); + let s = serde_json::to_string(&lib).unwrap(); + let back: Vec = serde_json::from_str(&s).unwrap(); + assert_eq!(back, lib); + // Partial definitions fill in defaults. + let b: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "angle": 30})).unwrap(); + assert_eq!(b.kind, BrushKind::Calligraphic(Calligraphic { angle: 30.0, ..Default::default() })); +} + +#[test] +fn every_default_brush_draws_something() { + let bp = preview_path(120.0, 24.0); + for b in defaults() { + let out = stroke_pieces(b, &bp, &stroke(1.0)); + assert!(!out.is_empty(), "{}", b.name); + let bb = bounds(&out); + assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name); + } +} + +#[test] +fn expand_keeps_fill_and_adds_pieces() { + let mut d = Document::new(200.0, 200.0); + let mut ap = Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), ap); + assert!(has_brush(&n)); + let g = expand(&d, &n).unwrap(); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 2); + assert!(ch[0].appearance.stroke().is_none() && !ch[0].appearance.fill_paint().is_none()); + assert_eq!(ch[1].appearance.fill_paint(), Paint::solid(Color::BLACK)); + let b = ch[1].geometric_bounds().unwrap(); + assert!(near(b.x0, 8.5, 0.05) && near(b.x1, 91.5, 0.05), "{b:?}"); + let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()); + assert!(expand(&d, &plain).is_none()); +} diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 9450b5907..34002f411 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -99,8 +99,20 @@ impl Track { /// 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] }; + 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) } @@ -148,7 +160,7 @@ impl Track { 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| { + let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { if let Some(t) = Track::new(std::mem::take(cur), closed) { out.push(t); } diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 0875fb0be..9215ae852 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -49,7 +49,7 @@ fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f6 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 finish = |cur: &mut Vec, closed: bool, subs: &mut Vec| { let pts = std::mem::take(cur); if pts.len() < 2 { return; @@ -260,7 +260,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec 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 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); @@ -289,7 +292,8 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec if cb && corner_tiles { b -= corner_half; } - if !t.closed && ri == 0 + if !t.closed + && ri == 0 && let Some(st) = &start { let w = st.1.width() * k; @@ -298,7 +302,8 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec a += w; } } - if !t.closed && ri + 1 == nruns + if !t.closed + && ri + 1 == nruns && let Some(en) = &end { let w = en.1.width() * k; @@ -345,8 +350,3 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec } 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/live.rs b/crates/doc/src/live.rs index 60febe615..ef0c8b453 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -228,7 +228,9 @@ 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(_)))); + && 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]) { @@ -370,7 +372,10 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { 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); + 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; @@ -684,11 +689,7 @@ fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { 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) - } - } + 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 @@ -753,10 +754,12 @@ impl Coons { 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(); + 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(); } @@ -831,7 +834,11 @@ impl GradientMesh { 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] }); + // Outward handles on the border are unused: keep them at the point. + let z = Vec2::ZERO; + let handles = + [if c < cols { hu } else { z }, if c > 0 { -hu } else { z }, if r < rows { hv } else { z }, if r > 0 { -hv } else { z }]; + points.push(MeshPoint { p, color: color(u, v), opacity: 1.0, handles }); } } Self { rows, cols, points } @@ -880,7 +887,8 @@ impl GradientMesh { /// 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 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; @@ -1485,7 +1493,8 @@ mod tests { #[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 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); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 04debbe69..45c5b89b0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,8 @@ drawcraft-tools = { workspace = true } drawcraft-text = { workspace = true } drawcraft-render = { workspace = true } drawcraft-pathops = { workspace = true } +drawcraft-brush = { workspace = true } +drawcraft-trace = { workspace = true } drawcraft-svg = { workspace = true } drawcraft-format = { workspace = true } drawcraft-pdf = { workspace = true } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs new file mode 100644 index 000000000..137fd60af --- /dev/null +++ b/crates/engine/src/cmd/brushsym.rs @@ -0,0 +1,1020 @@ +//! Brushes (Brushes panel, Paintbrush, Expand) and Symbols (Symbols panel, symbolism tools). +//! +//! Brush definitions live in `drawcraft-brush` and are stored in `Document::unknown["brushes"]`. +//! Symbol definitions are `Document::symbols`; symbols made here are *normalised*: the art is +//! centred on the origin and scaled (non-uniformly, strokes untouched) into the ±10 pt box that +//! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The +//! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right. + +use std::sync::Arc; + +use drawcraft_brush::{self as brush, Brush}; +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; +use drawcraft_geom::{Affine, Point, Vec2}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; +use crate::EngineError; + +const SIZES_KEY: &str = "symbolSizes"; +const CURRENT_SYMBOL: &str = "currentSymbol"; +/// Half the instance box `Node::geometric_bounds` uses for symbol instances. +const HALF: f64 = 10.0; + +pub fn specs() -> Vec { + vec![ + // ----- brushes ----- + cmd!(query "brush.list", "Brushes", [], None, "{} → {brushes: [{name, type}], current}", has_doc, brush_list), + cmd!(query "brush.get", "Brush Definition", [], None, "{name} → the brush definition (JSON)", has_doc, brush_get), + cmd!( + "brush.apply", + "Apply Brush", + [], + None, + "{name, ids?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}", + has_doc, + brush_apply + ), + cmd!("brush.remove", "Remove Brush Stroke", [], None, "{ids?} remove brushes from the selected strokes → {ids}", has_selection, brush_remove), + cmd!( + "brush.setCurrent", + "Current Brush", + [], + None, + "{name: string|null} the brush the Paintbrush tool paints with (not an undo step)", + has_doc, + brush_set_current + ), + cmd!( + "brush.new", + "New Brush…", + [], + None, + "{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}", + has_doc, + brush_new + ), + cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete), + cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate), + cmd!( + "brush.options", + "Brush Options…", + [], + None, + "{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}", + has_doc, + brush_options + ), + cmd!( + "object.expandBrush", + "Expand Brush Strokes", + [], + None, + "{ids?} replace brushed paths (in the selection) with groups of the brush art → {ids}", + has_selection, + expand_brush + ), + cmd!( + "brush.freehand", + "Paintbrush", + [], + None, + "{…path.freehand params, brush} paint a freehand stroke with a brush → {id}", + has_doc, + brush_freehand + ), + // ----- symbols ----- + cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h]}], current}", has_doc, symbol_list), + cmd!( + "symbol.new", + "New Symbol…", + [], + None, + "{name?, ids?} make a symbol from the selection and replace it with an instance → {name, id}", + has_selection, + symbol_new + ), + cmd!( + "symbol.place", + "Place Symbol Instance", + [], + None, + "{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}", + has_doc, + symbol_place + ), + cmd!( + "symbol.breakLink", + "Break Link to Symbol", + [], + None, + "{ids?} expand selected instances into plain art → {ids}", + has_selection, + symbol_break_link + ), + cmd!( + "symbol.edit", + "Edit Symbol", + [], + None, + "{id?} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}", + has_selection, + symbol_edit + ), + cmd!( + "symbol.update", + "Redefine Symbol", + [], + None, + "{name, ids?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}", + has_selection, + symbol_update + ), + cmd!( + "symbol.delete", + "Delete Symbol", + [], + None, + "{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)", + has_doc, + symbol_delete + ), + cmd!("symbol.duplicate", "Duplicate Symbol", [], None, "{name, newName?} → {name}", has_doc, symbol_duplicate), + cmd!( + "symbol.replace", + "Replace Symbol", + [], + None, + "{name, ids?} swap the symbol of the selected instances → {ids}", + has_selection, + symbol_replace + ), + cmd!( + "symbol.setCurrent", + "Current Symbol", + [], + None, + "{name} the symbol the symbolism tools spray (not an undo step)", + has_doc, + symbol_set_current + ), + cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), + cmd!( + "symbol.spray", + "Symbol Sprayer", + [], + None, + "{points: [[x,y]…], name?, radius?: 40, density?: 1..10 (5), scale?: 1, alt?: bool (remove instances)} spray instances into a symbol set → {id, count}", + has_doc, + symbol_spray + ), + cmd!( + "symbol.adjust", + "Symbolism Tool", + [], + None, + "{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}", + has_doc, + symbol_adjust + ), + ] +} + +fn ids_json(ids: &[NodeId]) -> Value { + Value::Array(ids.iter().map(|i| Value::from(i.0)).collect()) +} + +fn roots(s: &Session, p: &Value) -> Result> { + match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(mut v) => { + // Explicit ids: only existing, non-layer objects. + let d = &s.doc()?.doc; + v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer())); + v.dedup(); + Ok(v) + } + None => selected_roots(s), + } +} + +fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { + str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`")) +} + +// ---------- brushes ---------- + +/// Brushable nodes (paths and compound paths) under `ids`. +fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |c| { + let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); + let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if own && !in_compound && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + out +} + +fn brush_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({ "brushes": list, "current": brush::current(d) })) +} + +fn brush_get(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.get")?; + let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; + Ok(serde_json::to_value(b).unwrap_or_default()) +} + +fn brush_apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.apply"; + let name = name_param(p, C)?.to_string(); + if brush::find(&s.doc()?.doc, &name).is_none() { + return Err(bad(C, format!("no brush named `{name}`"))); + } + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let stroke = if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() }; + s.edit("Apply Brush", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + let st = n.appearance.stroke_mut().expect("stroke"); + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.clone()); + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn brush_remove(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + s.edit("Remove Brush Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = it { + st.brush = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +/// Set a document preference outside the undo history. +fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { + let st = s.doc_mut()?; + let d = Arc::make_mut(&mut st.doc); + match v { + Some(v) => d.unknown.insert(key.into(), v), + None => d.unknown.remove(key), + }; + Ok(()) +} + +fn brush_set_current(s: &mut Session, p: &Value) -> Result { + match str_param(p, "name") { + Some(n) => { + if brush::find(&s.doc()?.doc, n).is_none() { + return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); + } + set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; + } + None => set_unknown(s, brush::CURRENT_KEY, None)?, + } + ok() +} + +/// The selection as one art node (a group for several objects), ids kept. +fn selection_art(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let ids = roots(s, p)?; + let nodes: Vec = ids.iter().filter_map(|id| d.node(*id).cloned()).collect(); + Ok(match nodes.len() { + 0 => None, + 1 => nodes.into_iter().next(), + _ => Some(Node::group(NodeId(0), nodes.into_iter().map(Arc::new).collect())), + }) +} + +fn merge(base: &mut Value, patch: &Value) { + if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { + for (k, v) in pt { + b.insert(k.clone(), v.clone()); + } + } +} + +fn save_library(s: &mut Session, label: &str, lib: Vec, rename: Option<(String, Option)>) -> Result<()> { + s.edit(label, |d, _| { + brush::store(d, &lib); + if let Some((from, to)) = rename { + let ids: Vec = { + let mut v = vec![]; + d.walk(|n| { + if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) { + v.push(n.id); + } + }); + v + }; + for id in ids { + if let Some(n) = d.node_mut(id) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from.as_str()) + { + st.brush = to.clone(); + } + } + } + } + if brush::current(d).as_deref() == Some(from.as_str()) { + match to { + Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), + None => d.unknown.remove(brush::CURRENT_KEY), + }; + } + } + Ok(()) + }) +} + +fn brush_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.new"; + let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; + let art_key = match ty { + "calligraphic" | "bristle" => None, + "scatter" | "art" => Some("art"), + "pattern" => Some("side"), + o => return Err(bad(C, format!("unknown brush type `{o}`"))), + }; + let mut lib = brush::library(&s.doc()?.doc); + let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..])); + let name = brush::unique_name(&lib, &base_name); + let mut v = json!({"name": name, "type": ty}); + if let Some(params) = p.get("params") { + merge(&mut v, params); + v["name"] = json!(name); + v["type"] = json!(ty); + } + if let Some(k) = art_key + && v.get(k).is_none() + { + let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; + if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + return Err(bad(C, "the selected art has no width")); + } + v[k] = serde_json::to_value(&art).unwrap_or_default(); + } + let b: Brush = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?; + lib.push(b); + save_library(s, "New Brush", lib, None)?; + Ok(json!({ "name": name })) +} + +fn brush_delete(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.delete")?.to_string(); + let mut lib = brush::library(&s.doc()?.doc); + let n = lib.len(); + lib.retain(|b| b.name != name); + if lib.len() == n { + return Err(bad("brush.delete", format!("no brush named `{name}`"))); + } + save_library(s, "Delete Brush", lib, Some((name, None)))?; + ok() +} + +fn brush_duplicate(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.duplicate")?; + let mut lib = brush::library(&s.doc()?.doc); + let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?; + b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy"))); + let out = b.name.clone(); + lib.push(b); + save_library(s, "Duplicate Brush", lib, None)?; + Ok(json!({ "name": out })) +} + +fn brush_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.options"; + let name = name_param(p, C)?.to_string(); + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); + if let Some(params) = p.get("params") { + merge(&mut v, params); + } + let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string); + if let Some(nn) = &new_name { + if lib.iter().any(|b| &b.name == nn) { + return Err(bad(C, format!("a brush named `{nn}` exists"))); + } + v["name"] = json!(nn); + } else { + v["name"] = json!(name); + } + v["type"] = json!(lib[i].kind.type_id()); + lib[i] = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?; + let out = lib[i].name.clone(); + let rename = new_name.map(|nn| (name, Some(nn))); + save_library(s, "Brush Options", lib, rename)?; + Ok(json!({ "name": out })) +} + +fn expand_brush(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); + if ids.is_empty() { + return Err(EngineError::Other("Expand: no brush strokes selected".into())); + } + let out = s.edit("Expand", |d, sel| { + let mut out = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned() else { continue }; + let Some(g) = brush::expand(d, &n) else { continue }; + let g = d.reid(&g); + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let gid = g.id; + d.remove(*id)?; + d.insert(par, idx, g)?; + out.push(gid); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +fn brush_freehand(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); + let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; + let r = (spec.run)(s, p)?; + let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; + s.edit("Paintbrush", |d, _| { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.clone()); + } + Ok(()) + })?; + Ok(r) +} + +// ---------- symbols ---------- + +fn natural_size(d: &Document, name: &str) -> (f64, f64) { + d.unknown + .get(SIZES_KEY) + .and_then(|m| m.get(name)) + .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .unwrap_or((2.0 * HALF, 2.0 * HALF)) +} + +fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { + let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); + if let Some(o) = m.as_object_mut() { + match size { + Some((w, h)) => o.insert(name.into(), json!([w, h])), + None => o.remove(name), + }; + } +} + +/// Instance transform for a symbol of natural size `(w, h)` centred at `c`. +fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine { + Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) +} + +/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). +fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { + let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; + let (w, h) = (b.width().max(1.0), b.height().max(1.0)); + let c = b.center(); + let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); + art.transform(n, false); + Ok((art, (w, h), c)) +} + +fn unique_symbol_name(d: &Document, base: &str) -> String { + let taken = |n: &str| d.symbols.iter().any(|s| s.name == n); + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap() +} + +fn instances_of(d: &Document, name: Option<&str>) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && name.is_none_or(|x| x == symbol) + { + v.push(n.id); + } + }); + v +} + +fn symbol_names(d: &Document) -> Vec { + d.symbols.iter().map(|s| s.name.clone()).collect() +} + +fn current_symbol(d: &Document) -> Option { + d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) +} + +fn symbol_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({"name": sym.name, "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h]}) + }) + .collect(); + Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) +} + +/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. +fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { + let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let id = d.alloc_id(); + d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; + for r in ids { + d.remove(*r)?; + } + Ok(id) +} + +fn symbol_new(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + let (art, size, c) = normalize(art)?; + let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol")); + let id = s.edit("New Symbol", |d, sel| { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); + set_natural_size(d, &name, Some(size)); + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); + let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "name": name, "id": id.0 })) +} + +fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { + match str_param(p, "name") { + Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), + Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), + None => { + current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) + } + } +} + +fn symbol_place(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let name = resolve_symbol(d, p, "symbol.place")?; + let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); + let size = natural_size(d, &name); + let parent = s.doc()?.insertion_parent(); + let id = s.edit("Place Symbol Instance", |d, sel| { + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The plain art of instance `inst` in document space (fresh ids). +fn expanded_instance(d: &mut Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = d.symbols.iter().find(|s| &s.name == symbol)?; + let mut art = (*sym.art).clone(); + if let Some(f) = inst.appearance.fill() + && let Some(c) = f.paint.color() + { + brush::tint_node(&mut art, &c, f.opacity); + } + art.transform(*xf, false); + let mut art = d.reid(&art); + if inst.opacity < 1.0 || inst.blend != drawcraft_color::BlendMode::Normal { + if !matches!(art.kind, NodeKind::Group { .. }) { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.opacity *= inst.opacity; + art.blend = inst.blend; + } + Some(art) +} + +fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { + let mut out = vec![]; + for id in ids { + let Some(inst) = d.node(*id).cloned() else { continue }; + let Some(art) = expanded_instance(d, &inst) else { continue }; + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let aid = art.id; + d.remove(*id)?; + d.insert(par, idx, art)?; + out.push(aid); + } + Ok(out) +} + +fn selected_instances(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in roots(s, p)? { + if let Some(n) = d.node(id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + out.push(c.id); + } + }); + } + } + Ok(out) +} + +fn symbol_break_link(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let out = s.edit("Break Link to Symbol", |d, sel| { + let out = break_links(d, &ids)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +fn symbol_edit(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; + let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { + Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), + _ => unreachable!(), + }; + let out = s.edit("Edit Symbol", |d, sel| { + let out = break_links(d, &[first])?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "name": name, "ids": ids_json(&out) })) +} + +fn symbol_update(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.update"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = roots(s, p)?; + let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + let (art, size, c) = normalize(art)?; + let old = natural_size(&s.doc()?.doc, &name); + let id = s.edit("Redefine Symbol", |d, sel| { + let art = d.reid(&art); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.art = Arc::new(art); + } + set_natural_size(d, &name, Some(size)); + let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); + for i in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + *xf *= fix; + } + } + let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "name": name, "id": id.0 })) +} + +fn symbol_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.delete"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let expand = bool_or(p, "expandInstances", true); + s.edit("Delete Symbol", |d, _| { + let inst = instances_of(d, Some(&name)); + if expand { + break_links(d, &inst)?; + } else { + for i in inst { + d.remove(i)?; + } + } + d.symbols.retain(|x| x.name != name); + set_natural_size(d, &name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } + Ok(()) + })?; + ok() +} + +fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.duplicate"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; + let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy"))); + let size = natural_size(d, &name); + s.edit("Duplicate Symbol", |d, _| { + let art = d.reid(&sym.art); + d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + set_natural_size(d, &new, Some(size)); + Ok(()) + })?; + Ok(json!({ "name": new })) +} + +fn symbol_replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.replace"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + s.edit("Replace Symbol", |d, _| { + let new = natural_size(d, &name); + for id in &ids { + let old = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + _ => continue, + }; + if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + *symbol = name.clone(); + *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn symbol_set_current(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; + set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; + ok() +} + +fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; + let ids = instances_of(&s.doc()?.doc, Some(&name)); + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": n })) +} + +// ---------- symbolism tools ---------- + +fn points(p: &Value, cmd: &str) -> Result> { + let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; + let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); + if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(cmd, "points must be finite [x,y] pairs")); + } + Ok(v) +} + +/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). +fn jitter(i: usize, q: Point) -> (f64, f64, f64) { + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; + for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { + h ^= v; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + h ^= h >> 29; + } + let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; + (f(0), f(1), f(2)) +} + +fn is_symbol_set(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") +} + +fn symbol_spray(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.spray"; + let pts = points(p, C)?; + let st = s.doc()?; + let name = resolve_symbol(&st.doc, p, C)?; + let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); + let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0); + let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0); + let alt = bool_or(p, "alt", false); + let size = natural_size(&st.doc, &name); + let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); + let parent = st.insertion_parent(); + if alt { + // Remove instances of this symbol under the brush (within the set, or anywhere). + let d = &st.doc; + let scope: Vec = match set { + Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), + None => instances_of(d, Some(&name)), + }; + let doomed: Vec = scope + .into_iter() + .filter(|id| { + d.node(*id).is_some_and(|n| { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) + && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) + }) + }) + .collect(); + let n = doomed.len(); + if n > 0 { + s.edit("Symbol Sprayer", |d, _| { + for id in &doomed { + d.remove(*id)?; + } + Ok(()) + })?; + } + return Ok(json!({ "count": n })); + } + // Keep instances at least this far apart along the stroke (denser = closer). + let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); + let mut placed: Vec = vec![]; + for (i, q) in pts.iter().enumerate() { + let (jx, jy, _) = jitter(i, *q); + let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); + if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { + continue; + } + placed.push(c); + } + let count = placed.len(); + let id = s.edit("Symbol Sprayer", |d, sel| { + let mut nodes = vec![]; + for (i, c) in placed.iter().enumerate() { + let (_, _, js) = jitter(i + 7, *c); + let k = scale * (1.0 + js * 0.1); + let id = d.alloc_id(); + nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size) * Affine::scale(k) })); + } + let gid = match set { + Some(g) => { + for n in nodes { + d.insert(Some(g), usize::MAX, n)?; + } + g + } + None => { + let gid = d.alloc_id(); + let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); + g.name = Some("Symbol Set".into()); + d.insert(parent, usize::MAX, g)?; + gid + } + }; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "count": count })) +} + +fn instance_center(n: &Node) -> Option { + match &n.kind { + NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), + _ => None, + } +} + +fn symbol_adjust(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.adjust"; + let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); + if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { + return Err(bad(C, format!("unknown tool `{tool}`"))); + } + let pts = points(p, C)?; + let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); + let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0; + let alt = bool_or(p, "alt", false); + let st = s.doc()?; + let color = match p.get("color").and_then(color_value) { + Some(c) => c, + None => s.paint.fill.color().unwrap_or(Color::BLACK), + }; + let style_paint = match str_param(p, "style") { + Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), + None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), + }; + let candidates: Vec = { + let d = &st.doc; + let mut v = vec![]; + if st.selection.is_empty() { + v = instances_of(d, None); + } else { + for id in &st.selection.objects { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { + v.push(c.id); + } + }); + } + } + } + v.retain(|id| d.is_editable(*id)); + v + }; + let label = match tool.as_str() { + "shift" => "Symbol Shifter", + "scrunch" => "Symbol Scruncher", + "size" => "Symbol Sizer", + "spin" => "Symbol Spinner", + "stain" => "Symbol Stainer", + "screen" => "Symbol Screener", + _ => "Symbol Styler", + }; + let count = s.edit(label, |d, _| { + let mut touched = std::collections::BTreeSet::new(); + for (j, q) in pts.iter().enumerate() { + let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; + for id in &candidates { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(c) = instance_center(n) else { continue }; + let dist = c.distance(*q); + if dist > radius { + continue; + } + let w = intensity * (1.0 - dist / radius).max(0.0); + if w <= 0.0 { + continue; + } + touched.insert(*id); + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + match tool.as_str() { + "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), + "scrunch" => { + let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; + n.transform(Affine::translate(v), false); + } + "size" => { + let k = 1.0 + 0.08 * w; + n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); + } + "spin" => { + // Turn towards the drag direction; a click spins a little. + let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; + n.transform(about(Affine::rotate(if alt { -a } else { a })), false); + } + "stain" | "style" => { + let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; + if tool == "style" && alt { + n.appearance.items.clear(); + continue; + } + let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); + let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; + let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if o > 0.0 { + let mut f = FillLayer::new(paint); + f.opacity = o; + n.appearance.items.insert(0, AppearanceItem::Fill(f)); + } + } + _ => { + let step = (0.1 * w) as f32; + n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; + } + } + } + } + Ok(touched.len()) + })?; + Ok(json!({ "count": count })) +} diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs new file mode 100644 index 000000000..6ee45ce59 --- /dev/null +++ b/crates/engine/src/cmd/buildcmds.rs @@ -0,0 +1,738 @@ +//! Shape Builder (`shapeBuilder.*`), Live Paint (`livePaint.*`) and Image Trace (`imageTrace.*`). +//! +//! **Live Paint groups** need no special node kind: a Live Paint group is a plain group whose name +//! starts with "Live Paint". Its first child is a hidden group named "Live Paint Sources" holding +//! the original paths (the planar map's inputs, used by Release, Merge and re-computation); then +//! come the faces (paths named "Face", fill only) and the edges (open paths named "Edge", stroke +//! only). See `drawcraft_tools::builder` for the shared helpers. +//! +//! **Image Trace** makes a group named "Image Trace" holding the source image (hidden, bottom) +//! and the traced filled paths above it, mapped with the image's transform. Expand drops the +//! image (a plain group of paths remains); Release puts the image back in place of the group. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; +use drawcraft_geom::{FillRule, PathData, Point, Rect, Shape as _}; +use drawcraft_pathops as po; +use drawcraft_tools::builder::{ + self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots, +}; +use drawcraft_trace as tr; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::paint::paint_from; +use super::pathops::shape_node; +use super::*; + +/// Name of an Image Trace group. +pub const TRACE_NAME: &str = "Image Trace"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "shapeBuilder.merge", + "Shape Builder", + [], + None, + "{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point)} → {ids, merged, regions}", + has_selection_or_ids, + sb_merge + ), + cmd!( + query "shapeBuilder.regions", + "Shape Builder Regions", + [], + None, + "{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}]} faces of the planar arrangement", + has_selection_or_ids, + sb_regions + ), + cmd!( + "livePaint.make", + "Make", + ["Object", "Live Paint"], + Some("Cmd+Alt+X"), + "{ids?} build a Live Paint group from the selected paths → {id, faces, edges}", + has_selection_or_ids, + lp_make + ), + cmd!( + "livePaint.merge", + "Merge", + ["Object", "Live Paint"], + None, + "{ids?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}", + has_multi_or_ids, + lp_merge + ), + cmd!( + "livePaint.release", + "Release", + ["Object", "Live Paint"], + None, + "{id?} replace Live Paint groups with their original paths → {ids}", + has_selection_or_ids, + lp_release + ), + cmd!( + "livePaint.expand", + "Expand", + ["Object", "Live Paint"], + None, + "{id?} turn Live Paint groups into plain groups of their painted faces and edges → {ids}", + has_selection_or_ids, + lp_expand + ), + cmd!( + "livePaint.fill", + "Live Paint Bucket", + [], + None, + "{group?: id | ids?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill)} fill the face containing point → {group, face}", + has_doc, + lp_fill + ), + cmd!( + "livePaint.strokeEdge", + "Live Paint Bucket (Stroke)", + [], + None, + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4)} paint the edge nearest point → {group, edge}", + has_doc, + lp_stroke_edge + ), + cmd!( + query "livePaint.info", + "Live Paint Info", + [], + None, + "{group} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}", + has_doc, + lp_info + ), + cmd!( + "imageTrace.make", + "Make", + ["Object", "Image Trace"], + None, + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}", + has_selection_or_ids, + |s, p| trace_make(s, p, false) + ), + cmd!( + "imageTrace.makeAndExpand", + "Make and Expand", + ["Object", "Image Trace"], + None, + "same as imageTrace.make, then expand → {id, paths, anchors, colors}", + has_selection_or_ids, + |s, p| trace_make(s, p, true) + ), + cmd!( + "imageTrace.release", + "Release", + ["Object", "Image Trace"], + None, + "{id?} put the source image back in place of Image Trace groups → {ids}", + has_selection_or_ids, + trace_release + ), + cmd!( + "imageTrace.expand", + "Expand", + ["Object", "Image Trace"], + None, + "{id?} keep only the traced paths (drop the source image) → {ids}", + has_selection_or_ids, + trace_expand + ), + cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| { + Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::>() })) + }), + ] +} + +fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { + // Agents may pass ids explicitly; menus need a selection. Accept either (params are checked in run). + has_doc(s) +} + +fn has_multi_or_ids(s: &Session) -> std::result::Result<(), String> { + has_doc(s) +} + +fn root_ids(s: &Session, p: &Value) -> Result> { + let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => v, + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let d = &s.doc()?.doc; + if let Some(bad_id) = ids.iter().find(|i| d.node(**i).is_none()) { + return Err(EngineError::NoNode(*bad_id)); + } + // Layers are containers, not objects. + let ids: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(|n| !n.is_layer())).collect(); + if ids.is_empty() { + return Err(EngineError::Other("select objects, not layers".into())); + } + Ok(sorted_roots(d, &ids)) +} + +fn points_param(p: &Value, key: &str) -> Option> { + p.get(key)?.as_array()?.iter().map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect() +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!(ids.iter().map(|i| i.0).collect::>()) +} + +fn finite(p: Point) -> bool { + p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD +} + +fn fill_only(paint: Paint) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], effects: vec![] } +} + +fn stroke_only(paint: Paint, width: f64) -> Appearance { + Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], effects: vec![] } +} + +/// A point strictly inside a filled path (for re-finding faces after a rebuild). +pub(crate) fn interior_point(path: &PathData) -> Option { + let bb = path.bounds()?; + let bp = path.to_bezpath(); + let mut edges: Vec<(Point, Point)> = vec![]; + let (mut first, mut last) = (Point::ZERO, Point::ZERO); + kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((last, first)); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((last, p)); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((last, first)); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((last, first)); + } + let mut best: Option<(f64, Point)> = None; + for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { + let y = bb.y0 + bb.height() * f; + let mut xs: Vec = + edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); + xs.sort_by(f64::total_cmp); + for w in xs.windows(2) { + let m = Point::new((w[0] + w[1]) / 2.0, y); + let width = w[1] - w[0]; + if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { + best = Some((width, m)); + } + } + } + best.map(|b| b.1) +} + +// ---------- Shape Builder ---------- + +fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result> { + match p.get("fill") { + None | Some(Value::Null) => Ok(None), + Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => { + paint_from(s, v) + } + Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))), + } +} + +fn sb_merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "shapeBuilder.merge"; + let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?; + if pts.is_empty() || !pts.iter().all(|q| finite(*q)) { + return Err(bad(C, "points must be a non-empty list of finite [x,y]")); + } + let erase = bool_or(p, "erase", false); + let fill = fill_param(s, p, C)?; + let roots = root_ids(s, p)?; + let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; + let (ids, merged, count) = s.edit(label, |d, sel| { + let (shapes, leaves) = shapes_for(d, &roots); + let leaves: Vec = leaves.into_iter().cloned().collect(); + if shapes.is_empty() { + return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); + } + let regs = po::regions(&shapes); + let shapes_r: Vec<(kurbo::BezPath, Rect)> = regs.iter().map(|r| (r.path.to_bezpath(), r.path.bounds().unwrap_or(Rect::ZERO))).collect(); + let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25); + let mut touched: Vec = vec![]; + for q in sample_polyline(&pts, step) { + if let Some(i) = shapes_r.iter().position(|(bp, bb)| bb.contains(q) && bp.winding(q) != 0) + && !touched.contains(&i) + { + touched.push(i); + } + } + if touched.is_empty() { + return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); + } + let picked: Vec<&po::Region> = touched.iter().map(|&i| ®s[i]).collect(); + let union = po::merge_regions(&picked); + let affected: Vec = (0..shapes.len()).map(|i| picked.iter().any(|r| r.sources.contains(&i))).collect(); + let style_src = regs[touched[0]].top(); + let mut new_nodes: Vec = vec![]; + let mut merged_id = None; + for (i, (sh, leaf)) in shapes.iter().zip(&leaves).enumerate() { + if !affected[i] { + new_nodes.push(d.reid(leaf)); + } else { + let rest = po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference); + if !rest.is_empty() { + new_nodes.push(shape_node(d, rest, Some(leaf))); + } + } + if !erase && i == style_src { + let mut m = shape_node(d, union.clone(), Some(leaf)); + if let Some(f) = &fill { + m.appearance.set_fill(f.clone()); + } + merged_id = Some(m.id); + new_nodes.push(m); + } + } + let new_ids = replace_roots(d, &roots, new_nodes)?; + sel.set(new_ids.iter().copied()); + Ok((new_ids, merged_id, touched.len())) + })?; + Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": count })) +} + +/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids. +/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.) +fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result> { + let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count(); + for r in roots { + if d.node(*r).is_some() { + d.remove(*r)?; + } + } + let at = idx - before; + let mut ids = vec![]; + for (k, n) in nodes.into_iter().enumerate() { + ids.push(d.insert(par, at + k, n)?); + } + Ok(ids) +} + +fn sb_regions(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let d = &s.doc()?.doc; + let (shapes, leaves) = shapes_for(d, &roots); + let regs = po::regions(&shapes); + let out: Vec = regs + .iter() + .filter_map(|r| { + let bb = r.path.bounds()?; + Some(json!({ + "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], + "area": po::area(&r.path, FillRule::NonZero), + "sources": r.sources.iter().map(|&i| leaves[i].id.0).collect::>(), + })) + }) + .collect(); + Ok(json!({ "regions": out })) +} + +// ---------- Live Paint ---------- + +/// The source nodes a root contributes (a Live Paint group contributes its own sources). +fn source_nodes(n: &Node) -> Vec> { + if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] } +} + +/// Planar map children (sources group, faces, edges) for `sources`. +fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec>, usize, usize)> { + let mut lv = vec![]; + for n in &sources { + b::leaf_shapes(n, &mut lv); + } + let mut shapes = vec![]; + let mut styles: Vec<(Paint, Paint, f64)> = vec![]; + for l in lv { + if let Some((path, rule)) = b::node_outline(l) + && !path.is_empty() + { + shapes.push(po::Shape::new(path, rule, styles.len() as u64)); + let a = &l.appearance; + styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0))); + } + } + if shapes.is_empty() { + return Err(EngineError::Other("Live Paint: select paths or compound paths".into())); + } + let mut children: Vec> = vec![]; + let gid = d.alloc_id(); + let mut src_group = Node::group(gid, sources); + src_group.name = Some(SOURCES_NAME.into()); + src_group.visible = false; + children.push(Arc::new(src_group)); + let regs = po::regions(&shapes); + let nfaces = regs.len(); + for r in regs { + let id = d.alloc_id(); + let fill = styles[r.top()].0.clone(); + let mut n = Node::path(id, r.path, fill_only(fill)); + n.name = Some(FACE_NAME.into()); + children.push(Arc::new(n)); + } + let edges = po::pathfinder(po::PathfinderOp::Outline, &shapes); + let nedges = edges.len(); + for e in edges { + let (_, stroke, w) = &styles[e.key as usize]; + let id = d.alloc_id(); + let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w)); + n.name = Some(EDGE_NAME.into()); + children.push(Arc::new(n)); + } + Ok((children, nfaces, nedges)) +} + +/// Make a Live Paint group from `roots`; returns (group, faces, edges). +fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + let mut sources = vec![]; + for r in roots { + let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; + sources.extend(source_nodes(n)); + } + let (children, nf, ne) = build_children(d, sources)?; + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some(LIVE_PAINT_NAME.into()); + replace_roots(d, roots, vec![g])?; + sel.set([gid]); + Ok((gid, nf, ne)) +} + +fn lp_make(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +fn lp_merge(s: &mut Session, p: &Value) -> Result { + let roots = root_ids(s, p)?; + let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| { + let target = roots + .iter() + .copied() + .find(|r| d.node(*r).is_some_and(is_live_paint)) + .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; + let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + // Paint to carry over: (point inside face, fill) and (point on edge, stroke). + let keep_faces: Vec<(Point, Paint)> = b::faces(&old) + .iter() + .filter(|f| !f.appearance.fill_paint().is_none()) + .filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .collect(); + let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + .iter() + .filter_map(|e| { + let pd = e.path_data()?; + let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), e.appearance.clone())) + }) + .collect(); + let mut sources = source_nodes(&old); + for r in &roots { + if *r != target { + sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); + } + } + let (children, nf, ne) = build_children(d, sources)?; + let mut g = Node::group(target, children); + g.name = old.name.clone(); + g.opacity = old.opacity; + g.blend = old.blend; + for (pt, paint) in keep_faces { + if let Some(fid) = face_at(&g, pt).map(|f| f.id) + && let Some(ch) = g.children_mut() + && let Some(f) = ch.iter_mut().find(|c| c.id == fid) + { + Arc::make_mut(f).appearance.set_fill(paint); + } + } + for (pt, app) in keep_edges { + if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) + && let Some(ch) = g.children_mut() + && let Some(e) = ch.iter_mut().find(|c| c.id == eid) + { + Arc::make_mut(e).appearance = app; + } + } + for r in roots.iter().filter(|r| **r != target) { + if d.node(*r).is_some() { + d.remove(*r)?; + } + } + let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; + d.remove(target)?; + d.insert(par, idx, g)?; + sel.set([target]); + Ok((target, nf, ne)) + })?; + Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) +} + +fn live_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let ids = root_ids(s, p)?; + let v: Vec = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select a Live Paint group".into())); + } + Ok(v) +} + +fn lp_release(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + let ids = s.edit("Release Live Paint", |d, sel| { + let mut out = vec![]; + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); + out.extend(replace_roots(d, &[*g], srcs)?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn lp_expand(s: &mut Session, p: &Value) -> Result { + let targets = live_targets(s, p)?; + s.edit("Expand Live Paint", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + if let Some(ch) = n.children_mut() { + ch.retain(|c| match c.name.as_deref() { + Some(SOURCES_NAME) => false, + Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), + Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, + _ => true, + }); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} + +fn lp_fill(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.fill"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); + let group = id_param(p, "group"); + let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; + let (g, face) = s.edit("Live Paint Bucket", |d, sel| { + let gid = match group { + Some(g) => { + if !d.node(g).is_some_and(is_live_paint) { + return Err(bad(C, format!("{g} is not a Live Paint group"))); + } + g + } + None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) { + Some(g) => g, + None => make_live(d, sel, &roots)?.0, + }, + }; + let fid = + d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint); + Ok((gid, fid)) + })?; + Ok(json!({ "group": g.0, "face": face.0 })) +} + +fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.strokeEdge"; + let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); + let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0)); + if !(0.0..=1000.0).contains(&width) { + return Err(bad(C, "width out of range")); + } + let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0); + let edge = s.edit("Live Paint Bucket", |d, _| { + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; + let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; + e.appearance.set_stroke(paint); + if let Some(st) = e.appearance.stroke_mut() { + st.width = width; + } + Ok(eid) + })?; + Ok(json!({ "group": g.0, "edge": edge.0 })) +} + +fn lp_info(s: &mut Session, p: &Value) -> Result { + let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let d = &s.doc()?.doc; + let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; + let faces: Vec = b::faces(n) + .iter() + .map(|f| { + let bb = f.geometric_bounds().unwrap_or(Rect::ZERO); + json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] }) + }) + .collect(); + let edges: Vec = b::edges(n) + .iter() + .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) + .collect(); + let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); + Ok(json!({ "faces": faces, "edges": edges, "sources": sources })) +} + +// ---------- Image Trace ---------- + +fn is_trace_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.name.as_deref() == Some(TRACE_NAME) + && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) +} + +fn trace_params(p: &Value) -> Result { + let name = str_param(p, "preset").unwrap_or("Default"); + let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?; + let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) }; + let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; + let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) }; + for (k, val) in src { + o.insert(k.clone(), val.clone()); + } + serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string())) +} + +fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { + const C: &str = "imageTrace.make"; + let params = trace_params(p)?; + let roots = root_ids(s, p)?; + // The image (inside a trace group, or selected directly). + let (target, img) = { + let d = &s.doc()?.doc; + roots + .iter() + .find_map(|r| { + let n = d.node(*r)?; + match &n.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ if is_trace_group(n) => match &n.children()?.first()?.kind { + NodeKind::Image(i) => Some((*r, i.clone())), + _ => None, + }, + _ => None, + } + }) + .ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))? + }; + let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; + let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let res = tr::trace(&raster, ¶ms); + // Pixel space of the decoded raster → the image object's pixel space → document. + let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; + let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; + let xf = img.xf * drawcraft_geom::Affine::scale_non_uniform(sx, sy); + let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); + let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; + let id = s.edit(label, |d, sel| { + let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + let mut image = if is_trace_group(&src) { (**src.children().unwrap().first().unwrap()).clone() } else { src.clone() }; + image.visible = true; + let mut children: Vec> = vec![]; + if !expand { + let mut hidden = image.clone(); + hidden.visible = false; + children.push(Arc::new(hidden)); + } + for tp in res.paths { + let path = tp.path.transformed(xf); + let mut n = shape_node(d, path, None); + n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2]))); + children.push(Arc::new(n)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = if expand { None } else { Some(TRACE_NAME.into()) }; + let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; + d.remove(target)?; + d.insert(par, idx, g)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors })) +} + +fn trace_targets(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); + if v.is_empty() { + return Err(EngineError::Other("select an Image Trace object".into())); + } + Ok(v) +} + +fn trace_release(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + let ids = s.edit("Release Image Trace", |d, sel| { + let mut out = vec![]; + for g in &targets { + let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone(); + img.visible = true; + out.extend(replace_roots(d, &[*g], vec![img])?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn trace_expand(s: &mut Session, p: &Value) -> Result { + let targets = trace_targets(s, p)?; + s.edit("Expand Image Trace", |d, sel| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + n.name = None; + if let Some(ch) = n.children_mut() { + ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets) })) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ff093a283..92008d6c3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -22,7 +22,15 @@ pub fn specs() -> Vec { cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save), cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize), cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export), - cmd!("document.exportForScreens", "Export for Screens", ["File", "Export"], None, "{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}", has_doc, export_for_screens), + cmd!( + "document.exportForScreens", + "Export for Screens", + ["File", "Export"], + None, + "{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}", + has_doc, + export_for_screens + ), cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch), ] } @@ -158,7 +166,9 @@ fn export_for_screens(s: &mut Session, p: &Value) -> Result { .map(|f| { let fmt = str_param(f, "format").unwrap_or("png").to_string(); let sc = f64_or(f, "scale", 1.0); - let suffix = str_param(f, "suffix").map(str::to_string).unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") }); + let suffix = str_param(f, "suffix") + .map(str::to_string) + .unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") }); (fmt, sc, suffix) }) .collect(), @@ -168,7 +178,8 @@ fn export_for_screens(s: &mut Session, p: &Value) -> Result { std::fs::create_dir_all(&folder).map_err(|e| EngineError::Other(format!("{folder}: {e}")))?; let mut files = vec![]; for b in boards { - let name: String = s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); + let name: String = + s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); for (fmt, sc, suffix) in &formats { let bytes = encode(s, fmt, *sc, b)?; let ext = if fmt == "jpeg" { "jpg" } else { fmt.as_str() }; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs new file mode 100644 index 000000000..41a0de821 --- /dev/null +++ b/crates/engine/src/cmd/live.rs @@ -0,0 +1,852 @@ +//! Live Blends (Object → Blend), Envelope Distort (Object → Envelope Distort) and Gradient Mesh +//! (Object → Create Gradient Mesh, Mesh tool edits). +//! +//! The objects are live document nodes (`NodeKind::Blend`, `NodeKind::Envelope`, +//! `NodeKind::Mesh`); evaluation lives in `drawcraft_doc::live` and the renderer. **Expand** +//! replaces a live object by its evaluated geometry (`drawcraft_render::expand_live`). + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::live::{self, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshAppearance, Spine}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; +use drawcraft_geom::{Affine, PathData, Point}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + vec![ + // ---------- Blend ---------- + cmd!( + "object.blend.make", + "Make", + ["Object", "Blend"], + Some("Cmd+Alt+B"), + "{ids?, steps?: n | distance?: pt | smooth?: bool (default smooth colour), orientation?: page|path} blend the selected objects (paint order) into a live blend → {id}", + has_doc, + blend_make + ), + cmd!( + "object.blend.release", + "Release", + ["Object", "Blend"], + Some("Cmd+Alt+Shift+B"), + "{} release the selected blends, keeping the key objects → {ids}", + has_blend, + blend_release + ), + cmd!( + "object.blend.options", + "Blend Options…", + ["Object", "Blend"], + None, + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends", + has_blend, + blend_options + ), + cmd!( + "object.blend.expand", + "Expand", + ["Object", "Blend"], + None, + "{} replace the selected blends by groups of their steps → {ids}", + has_blend, + blend_expand + ), + cmd!( + "object.blend.replaceSpine", + "Replace Spine", + ["Object", "Blend"], + None, + "{} use the selected path as the spine of the selected blend (the path is consumed)", + has_blend, + blend_replace_spine + ), + cmd!( + "object.blend.reverseSpine", + "Reverse Spine", + ["Object", "Blend"], + None, + "{} reverse the order of the key objects along the spine", + has_blend, + blend_reverse_spine + ), + cmd!( + "object.blend.reverseFrontToBack", + "Reverse Front to Back", + ["Object", "Blend"], + None, + "{} reverse the stacking order of the selected blends", + has_blend, + blend_reverse_stack + ), + // ---------- Envelope ---------- + cmd!( + "object.envelope.makeWithWarp", + "Make with Warp…", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+Shift+W"), + "{ids?, style?: arc|arcLower|…|twist (arc), bend?: % (50), h?: %, v?: %, horizontal?: bool (true)} envelope the selected objects with a warp → {id}", + has_selection, + env_make_warp + ), + cmd!( + "object.envelope.makeWithMesh", + "Make with Mesh…", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+M"), + "{ids?, rows?: n (4), cols?: n (4)} envelope the selected objects with a point mesh → {id}", + has_selection, + env_make_mesh + ), + cmd!( + "object.envelope.makeWithTopObject", + "Make with Top Object", + ["Object", "Envelope Distort"], + Some("Cmd+Alt+C"), + "{ids?} the topmost selected path becomes the envelope of the others → {id}", + has_multi, + env_make_top + ), + cmd!( + "object.envelope.release", + "Release", + ["Object", "Envelope Distort"], + None, + "{} release the selected envelopes → {ids}", + has_envelope, + env_release + ), + cmd!( + "object.envelope.options", + "Envelope Options…", + ["Object", "Envelope Distort"], + None, + "{fidelity?: 0..100, style?, bend?, h?, v?, horizontal?} set envelope options (warp params for warp envelopes)", + has_envelope, + env_options + ), + cmd!( + "object.envelope.expand", + "Expand", + ["Object", "Envelope Distort"], + None, + "{} replace the selected envelopes by their distorted content → {ids}", + has_envelope, + env_expand + ), + cmd!( + "object.envelope.editContents", + "Edit Contents", + ["Object", "Envelope Distort"], + Some("Cmd+Shift+V"), + "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", + has_envelope, + env_edit_contents + ), + cmd!( + "object.envelope.setMeshPoint", + "Move Envelope Mesh Point", + [], + None, + "{id, index, x, y} move one point of a mesh envelope", + has_doc, + env_set_mesh_point + ), + // ---------- Gradient mesh ---------- + cmd!( + "object.mesh.create", + "Create Gradient Mesh…", + ["Object"], + None, + "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}", + has_selection, + mesh_create + ), + cmd!( + "object.mesh.setPointColor", + "Set Mesh Point Color", + [], + None, + "{id, index, color, opacity?: 0..1} colour of one mesh point", + has_doc, + mesh_set_color + ), + cmd!( + "object.mesh.movePoint", + "Move Mesh Point", + [], + None, + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a mesh point (its handles follow), or with `handle` place that handle end at (x, y)", + has_doc, + mesh_move_point + ), + cmd!( + "object.mesh.addLine", + "Add Mesh Line", + [], + None, + "{id, x, y, color?} add a mesh row and column through (x, y) → {index}", + has_doc, + mesh_add_line + ), + cmd!( + "object.mesh.deletePoint", + "Delete Mesh Point", + [], + None, + "{id, index} delete the mesh lines through a point", + has_doc, + mesh_delete_point + ), + cmd!( + "object.mesh.expand", + "Expand Gradient Mesh", + ["Object"], + None, + "{} replace the selected meshes by groups of flat-coloured pieces → {ids}", + has_mesh, + mesh_expand + ), + ] +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Explicit `ids` (in paint order) or the selected roots. +fn roots_param(s: &Session, p: &Value) -> Result> { + if let Some(ids) = ids_param(p, "ids") { + let st = s.doc()?; + for id in &ids { + if st.doc.node(*id).is_none() { + return Err(EngineError::NoNode(*id)); + } + } + let mut sel = Selection::default(); + sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))); + let ordered = sel.in_paint_order(&st.doc); + // Drop ids nested inside other given ids. + return Ok(ordered + .iter() + .copied() + .filter(|id| { + let anc = st.doc.ancestry(*id).unwrap_or_default(); + !anc[..anc.len().saturating_sub(1)].iter().any(|a| ordered.contains(a)) + }) + .collect()); + } + selected_roots(s) +} + +/// Selected nodes of a kind (the selection itself or an ancestor). +fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let mut out = vec![]; + for id in &st.selection.objects { + for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { + if st.doc.node(a).is_some_and(pred) { + if !out.contains(&a) { + out.push(a); + } + break; + } + } + } + out +} + +fn is_blend(n: &Node) -> bool { + matches!(n.kind, NodeKind::Blend { .. }) +} +fn is_envelope(n: &Node) -> bool { + matches!(n.kind, NodeKind::Envelope { .. }) +} +fn is_mesh(n: &Node) -> bool { + matches!(n.kind, NodeKind::Mesh(_)) +} + +fn has_blend(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } +} +fn has_envelope(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } +} +fn has_mesh(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } +} + +/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was. +fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec>) -> Node) -> Result { + let top = *roots.last().ok_or_else(|| EngineError::Other("nothing to wrap".into()))?; + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let nodes: Vec> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect(); + let id = d.alloc_id(); + d.insert(par, idx + 1, make(id, nodes))?; + for r in roots { + d.remove(*r)?; + } + sel.set([id]); + Ok(id) +} + +/// Put `nodes` where `id` was (replacing it). Returns their ids. +fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result> { + let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + d.remove(id)?; + let mut out = vec![]; + for (k, n) in nodes.into_iter().enumerate() { + out.push(n.id); + d.insert(par, idx + k, n)?; + } + Ok(out) +} + +/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids. +fn fix_ids(d: &mut Document, n: &mut Node) { + if n.id == NodeId(0) { + n.id = d.alloc_id(); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + fix_ids(d, Arc::make_mut(c)); + } + } +} + +// ---------- Blend ---------- + +fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let mode = str_param(p, "spacing"); + let value = num("value"); + let sp = match mode { + Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, + Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(5.0) as u32), + Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(10.0)), + Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))), + None => { + if let Some(n) = num("steps") { + BlendSpacing::Steps(n as u32) + } else if let Some(d) = num("distance") { + BlendSpacing::Distance(d) + } else if bool_or(p, "smooth", false) { + BlendSpacing::SmoothColor + } else { + current + } + } + }; + match sp { + BlendSpacing::Steps(n) if !(1..=1000).contains(&n) => Err(bad(cmd, "steps must be 1..1000")), + BlendSpacing::Distance(d) if !(d.is_finite() && d > 0.0) => Err(bad(cmd, "distance must be > 0")), + s => Ok(s), + } +} + +fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { + match str_param(p, "orientation") { + Some("path") | Some("alignToPath") => BlendOrientation::AlignToPath, + Some("page") | Some("alignToPage") => BlendOrientation::AlignToPage, + _ => current, + } +} + +fn blend_make(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.make"; + let spacing = spacing_param(p, C, BlendSpacing::SmoothColor)?; + let orientation = orientation_param(p, BlendOrientation::AlignToPage); + let roots = roots_param(s, p)?; + if roots.len() < 2 { + return Err(bad(C, "select at least two objects")); + } + let id = s.edit("Make Blend", |d, sel| { + wrap(d, sel, &roots, |id, children| Node::new(id, NodeKind::Blend { children, spec: BlendSpec { spacing, orientation, spine: None } })) + })?; + Ok(json!({ "id": id.0 })) +} + +fn blend_release(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let ids = s.edit("Release Blend", |d, sel| { + let mut out = vec![]; + for b in &blends { + let Some(n) = d.node(*b).cloned() else { continue }; + let keys: Vec = n.children().into_iter().flatten().map(|c| (**c).clone()).collect(); + out.extend(replace_with(d, *b, keys)?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mut BlendSpec)) -> Result { + let blends = selected_of(s, is_blend); + s.edit(label, |d, _| { + for b in &blends { + if let Some(NodeKind::Blend { children, spec }) = d.node_mut(*b).map(|n| &mut n.kind) { + f(children, spec); + } + } + Ok(()) + })?; + ok() +} + +fn blend_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.options"; + let blends = selected_of(s, is_blend); + let cur = match blends.first().and_then(|b| s.doc().ok()?.doc.node(*b)).map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => spec.clone(), + _ => BlendSpec::default(), + }; + let spacing = spacing_param(p, C, cur.spacing)?; + let orientation = orientation_param(p, cur.orientation); + edit_blends(s, "Blend Options", |_, spec| { + spec.spacing = spacing; + spec.orientation = orientation; + }) +} + +fn blend_expand(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let ids = s.edit("Expand Blend", |d, sel| { + let mut out = vec![]; + for b in &blends { + let Some(n) = d.node(*b).cloned() else { continue }; + let children = drawcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); + g.opacity = n.opacity; + g.blend = n.blend; + g.visible = n.visible; + fix_ids(d, &mut g); + out.extend(replace_with(d, *b, vec![g])?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +/// Move the keys onto their spine positions (so what's stored matches what's drawn). +fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { + let Some(sp) = spec.spine.as_ref().and_then(Spine::new) else { return }; + let k = children.len(); + for (i, c) in children.iter_mut().enumerate() { + let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; + let (pt, _) = sp.at(f); + if let Some(b) = c.geometric_bounds() { + Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false); + } + } +} + +fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let st = s.doc()?; + let path = st + .selection + .objects + .iter() + .copied() + .find(|id| { + !blends.contains(id) + && st.doc.node(*id).is_some_and(|n| n.path_data().is_some()) + && !blends.iter().any(|b| st.doc.ancestry(*id).unwrap_or_default().contains(b)) + }) + .ok_or_else(|| EngineError::Other("Replace Spine: select a blend and a path".into()))?; + let b = *blends.first().unwrap(); + s.edit("Replace Spine", |d, sel| { + let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; + d.remove(path)?; + if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + spec.spine = Some(spine); + keys_to_spine(children, spec); + } + sel.set([b]); + Ok(()) + })?; + ok() +} + +fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + edit_blends(s, "Reverse Spine", |children, spec| { + if let Some(sp) = &mut spec.spine { + sp.reverse(); + keys_to_spine(children, spec); + } else { + let centers: Vec = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); + let n = children.len(); + for (i, k) in children.iter_mut().enumerate() { + Arc::make_mut(k).transform(Affine::translate(centers[n - 1 - i] - centers[i]), false); + } + } + }) +} + +fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { + edit_blends(s, "Reverse Front to Back", |children, spec| { + children.reverse(); + if let Some(sp) = &mut spec.spine { + sp.reverse(); + } + }) +} + +// ---------- Envelope ---------- + +fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { + let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { + (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + } + if let Some(s) = str_param(p, "style") { + let s = s.strip_prefix("warp.").unwrap_or(s); + if live::WarpStyle::from_id(s).is_none() { + return Err(bad(cmd, format!("unknown warp style `{s}`"))); + } + style = s.to_string(); + } + bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0); + h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0); + v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0); + horizontal = match str_param(p, "orientation") { + Some("vertical") => false, + Some("horizontal") => true, + _ => bool_or(p, "horizontal", horizontal), + }; + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) +} + +fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind) -> Node { + Node::new(id, NodeKind::Envelope { content, kind, fidelity: live::default_fidelity(), editing: false }) +} + +fn env_make_warp(s: &mut Session, p: &Value) -> Result { + let kind = warp_kind(p, "object.envelope.makeWithWarp", None)?; + let roots = roots_param(s, p)?; + if roots.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?; + Ok(json!({ "id": id.0 })) +} + +fn env_make_mesh(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.makeWithMesh"; + let rows = f64_or(p, "rows", 4.0); + let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0)); + if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { + return Err(bad(C, "rows and cols must be 1..50")); + } + let roots = roots_param(s, p)?; + let st = s.doc()?; + let src = roots + .iter() + .filter_map(|id| st.doc.node(*id)) + .fold(None, |a, n| drawcraft_geom::union_opt(a, n.geometric_bounds())) + .ok_or_else(|| bad(C, "selection has no bounds"))?; + let kind = EnvelopeKind::Mesh { rows: rows as u32, cols: cols as u32, points: live::grid_points(src, rows as u32, cols as u32) }; + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?; + Ok(json!({ "id": id.0 })) +} + +/// Path data of a path or compound path. +fn outline_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.clone()), + NodeKind::Compound { children, .. } => { + Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect())) + } + _ => None, + } +} + +fn env_make_top(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.makeWithTopObject"; + let roots = roots_param(s, p)?; + if roots.len() < 2 { + return Err(bad(C, "select the content and a path on top")); + } + let top = *roots.last().unwrap(); + let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; + let content = roots[..roots.len() - 1].to_vec(); + let id = s.edit("Make Envelope", |d, sel| { + let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }))?; + d.remove(top)?; + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn env_release(s: &mut Session, _: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let ids = s.edit("Release Envelope", |d, sel| { + let mut out = vec![]; + for e in &envs { + let Some(n) = d.node(*e).cloned() else { continue }; + let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; + let mut nodes: Vec = content.iter().map(|c| (**c).clone()).collect(); + // The envelope shape comes back as a grey path above the content. + let shape = match kind { + EnvelopeKind::TopObject { path } => Some(path.clone()), + EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. } => None, + }; + if let Some(path) = shape { + let id = d.alloc_id(); + nodes.push(Node::path(id, path, Appearance::basic(Paint::solid(Color::gray(0.25)), Paint::None, 0.0))); + } + out.extend(replace_with(d, *e, nodes)?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn env_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.options"; + let envs = selected_of(s, is_envelope); + let fid = p.get("fidelity").and_then(Value::as_f64); + if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { + return Err(bad(C, "fidelity must be 0..100")); + } + // Validate warp params up front. + let doc = &s.doc()?.doc; + let mut kinds = vec![]; + for e in &envs { + if let Some(NodeKind::Envelope { kind: k @ EnvelopeKind::Warp { .. }, .. }) = doc.node(*e).map(|n| &n.kind) { + kinds.push((*e, warp_kind(p, C, Some(k))?)); + } + } + s.edit("Envelope Options", |d, _| { + for e in &envs { + if let Some(NodeKind::Envelope { fidelity, kind, .. }) = d.node_mut(*e).map(|n| &mut n.kind) { + if let Some(f) = fid { + *fidelity = f; + } + if let Some((_, k)) = kinds.iter().find(|(id, _)| id == e) { + *kind = k.clone(); + } + } + } + Ok(()) + })?; + ok() +} + +fn env_expand(s: &mut Session, _: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let ids = s.edit("Expand Envelope", |d, sel| { + let mut out = vec![]; + for e in &envs { + let Some(n) = d.node(*e).cloned() else { continue }; + let children = drawcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); + g.opacity = n.opacity; + g.blend = n.blend; + fix_ids(d, &mut g); + out.extend(replace_with(d, *e, vec![g])?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn env_edit_contents(s: &mut Session, p: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. })); + let want = bool_or(p, "editing", !cur); + s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| { + let mut content_ids = vec![]; + for e in &envs { + if let Some(n) = d.node_mut(*e) + && let NodeKind::Envelope { editing, content, .. } = &mut n.kind + { + *editing = want; + content_ids.extend(content.iter().map(|c| c.id)); + } + } + if want { + sel.set(content_ids); + } else { + sel.set(envs.iter().copied()); + } + Ok(()) + })?; + Ok(json!({ "editing": want })) +} + +fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.setMeshPoint"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let index = f64_req(p, "index", C)? as usize; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + s.edit("Move Envelope Point", |d, _| { + match d.node_mut(id).map(|n| &mut n.kind) { + Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { points, .. }, .. }) if index < points.len() => points[index] = q, + _ => return Err(bad(C, "not a mesh envelope point")), + } + Ok(()) + })?; + ok() +} + +// ---------- Gradient mesh ---------- + +fn mesh_create(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.create"; + let rows = f64_or(p, "rows", 4.0); + let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0)); + if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { + return Err(bad(C, "rows and cols must be 1..50")); + } + let app = match str_param(p, "appearance") { + Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, + None => MeshAppearance::Flat, + }; + let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); + let at = point_param(p, "at"); + let (rows, cols) = if at.is_some() { (1, 1) } else { (rows as u32, cols as u32) }; + let roots = roots_param(s, p)?; + let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { + let mut out = vec![]; + let mut index = None; + for id in &roots { + let Some(n) = d.node(*id).cloned() else { continue }; + let Some(path) = outline_of(&n) else { continue }; + let base = match n.appearance.fill_paint() { + Paint::Solid { color, .. } => color, + Paint::Gradient(g) => g.gradient.sample(0.5).0, + _ => Color::WHITE, + }; + let Some(mut m) = GradientMesh::for_path(&path, rows, cols, base, app, highlight) else { continue }; + if let Some(q) = at { + index = m.add_lines_at(q); + } + let mut mn = Node::new(n.id, NodeKind::Mesh(m)); + mn.name = n.name.clone(); + mn.opacity = n.opacity; + mn.blend = n.blend; + mn.locked = n.locked; + mn.visible = n.visible; + *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = mn; + out.push(*id); + } + if out.is_empty() { + return Err(bad(C, "select a path or compound path")); + } + sel.set(out.iter().copied()); + Ok((out, index)) + })?; + let mut r = ids_json(&ids); + if let Some(i) = index { + r["index"] = json!(i); + } + Ok(r) +} + +fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let c = cmd.to_string(); + s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { + Some(NodeKind::Mesh(m)) => f(m), + Some(_) => Err(bad(&c, "not a gradient mesh")), + None => Err(EngineError::NoNode(id)), + }) +} + +fn index_param(p: &Value, cmd: &str) -> Result { + let i = f64_req(p, "index", cmd)?; + if i < 0.0 { + return Err(bad(cmd, "index must be ≥ 0")); + } + Ok(i as usize) +} + +fn mesh_set_color(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.setPointColor"; + let index = index_param(p, C)?; + let color = p.get("color").and_then(color_value); + let opacity = p.get("opacity").and_then(Value::as_f64); + if color.is_none() && opacity.is_none() { + return Err(bad(C, "missing `color` or `opacity`")); + } + with_mesh(s, p, C, "Mesh Point Color", |m| { + let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + if let Some(c) = color { + pt.color = c; + } + if let Some(o) = opacity { + pt.opacity = o.clamp(0.0, 1.0) as f32; + } + Ok(()) + })?; + ok() +} + +fn mesh_move_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.movePoint"; + let index = index_param(p, C)?; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize); + if handle.is_some_and(|h| h > 3) { + return Err(bad(C, "handle must be 0..3")); + } + with_mesh(s, p, C, "Move Mesh Point", |m| { + let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + match handle { + Some(h) => pt.handles[h] = q - pt.p, + None => pt.p = q, + } + Ok(()) + })?; + ok() +} + +fn mesh_add_line(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.addLine"; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let color = p.get("color").and_then(color_value); + let index = with_mesh(s, p, C, "Add Mesh Line", |m| { + let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; + if let Some(c) = color { + m.points[i].color = c; + } + Ok(i) + })?; + Ok(json!({ "index": index })) +} + +fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.deletePoint"; + let index = index_param(p, C)?; + with_mesh(s, p, C, "Delete Mesh Point", |m| { + if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } + })?; + ok() +} + +fn mesh_expand(s: &mut Session, _: &Value) -> Result { + let meshes = selected_of(s, is_mesh); + let ids = s.edit("Expand Gradient Mesh", |d, sel| { + let mut out = vec![]; + for m in &meshes { + let Some(n) = d.node(*m).cloned() else { continue }; + let mut g = live::expanded_group(&n, None); + fix_ids(d, &mut g); + out.extend(replace_with(d, *m, vec![g])?); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 475641035..e85e86266 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1,26 +1,17 @@ //! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset -//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape, Blend -//! (expanded blends) and Artboards → Convert / Rearrange. -//! -//! Blends: the document model has no live blend object, so **Make** builds an *expanded* blend — -//! a group named `Blend` holding the key objects with generated intermediate steps (named -//! `Blend Step`) between them. Release / Blend Options / Reverse work on such groups by -//! recognising those names; Blend → Expand drops the names, leaving an ordinary group. +//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and +//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.) use std::sync::Arc; use drawcraft_color::{Color, Paint}; -use drawcraft_doc::{Appearance, AppearanceItem, ImageObject, LiveShape, Node, NodeId, NodeKind}; -use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, Vec2, shapes}; +use drawcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind}; +use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2, shapes}; use serde_json::{Value, json}; -use super::colorcmds::lerp_model; use super::edit::{duplicate_in, selected_roots}; use super::*; -pub const BLEND_NAME: &str = "Blend"; -pub const BLEND_STEP_NAME: &str = "Blend Step"; - /// Session-level (not saved) state owned by the menu commands. #[derive(Clone, Debug, Default)] pub struct MenuState { @@ -131,52 +122,6 @@ pub fn specs() -> Vec { has_selection, convert_to_shape ), - cmd!( - "object.blend.make", - "Make", - ["Object", "Blend"], - Some("Cmd+Alt+B"), - "{steps?: n (5)} blend the selected objects (paint order) into an expanded blend group → {id}", - has_multi, - blend_make - ), - cmd!( - "object.blend.release", - "Release", - ["Object", "Blend"], - Some("Cmd+Alt+Shift+B"), - "{} remove the generated steps of the selected blend groups, keeping the key objects → {ids}", - has_blend, - blend_release - ), - cmd!( - "object.blend.options", - "Blend Options…", - ["Object", "Blend"], - None, - "{steps: n} regenerate the steps of the selected blend groups", - has_blend, - blend_options - ), - cmd!("object.blend.expand", "Expand", ["Object", "Blend"], None, "{} turn blend groups into ordinary groups", has_blend, blend_expand), - cmd!( - "object.blend.reverseFrontToBack", - "Reverse Front to Back", - ["Object", "Blend"], - None, - "{} reverse the stacking order inside the selected blends", - has_blend, - blend_reverse_stack - ), - cmd!( - "object.blend.reverseSpine", - "Reverse Spine", - ["Object", "Blend"], - None, - "{} swap the positions of the key objects along the blend and regenerate", - has_blend, - blend_reverse_spine - ), cmd!( "artboard.convertToArtboards", "Convert to Artboards", @@ -673,294 +618,6 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result { Ok(json!({ "converted": n })) } -// ---------- Blend ---------- - -fn is_blend(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some(BLEND_NAME) -} - -fn blend_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; - let mut out = vec![]; - for id in &st.selection.objects { - // The blend itself or any ancestor that is a blend. - for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { - if st.doc.node(a).is_some_and(is_blend) { - if !out.contains(&a) { - out.push(a); - } - break; - } - } - } - out -} - -fn has_blend(s: &Session) -> std::result::Result<(), String> { - has_selection(s)?; - if blend_groups(s).is_empty() { Err("select a blend".into()) } else { Ok(()) } -} - -/// Bring two subpaths to the same anchor count by splitting the longest segments. -fn equalize(a: &mut SubPath, b: &mut SubPath) { - fn grow(sp: &mut SubPath, n: usize) { - let mut guard = 0; - while sp.anchors.len() < n && guard < 10_000 && sp.segment_count() > 0 { - let seg = (0..sp.segment_count()) - .max_by(|i, j| { - let li = { - let c = sp.segment(*i); - (c.p3 - c.p0).hypot() - }; - let lj = { - let c = sp.segment(*j); - (c.p3 - c.p0).hypot() - }; - li.total_cmp(&lj) - }) - .unwrap_or(0); - sp.insert_anchor(seg, 0.5); - guard += 1; - } - } - let n = a.anchors.len().max(b.anchors.len()); - grow(a, n); - grow(b, n); -} - -fn lerp_pt(a: Point, b: Point, t: f64) -> Point { - a.lerp(b, t) -} - -fn lerp_path(a: &PathData, b: &PathData, t: f64) -> Option { - if a.subpaths.len() != b.subpaths.len() || a.subpaths.is_empty() { - return None; - } - let mut out = vec![]; - for (sa, sb) in a.subpaths.iter().zip(&b.subpaths) { - let (mut sa, mut sb) = (sa.clone(), sb.clone()); - equalize(&mut sa, &mut sb); - if sa.anchors.len() != sb.anchors.len() { - return None; - } - let anchors = sa - .anchors - .iter() - .zip(&sb.anchors) - .map(|(x, y)| Anchor { p: lerp_pt(x.p, y.p, t), h_in: lerp_pt(x.h_in, y.h_in, t), h_out: lerp_pt(x.h_out, y.h_out, t), kind: x.kind }) - .collect(); - out.push(SubPath::new(anchors, sa.closed)); - } - Some(PathData::new(out)) -} - -fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { - match (a.color(), b.color()) { - (Some(x), Some(y)) => Paint::solid(lerp_model(x, y, t)), - _ => { - if t < 0.5 { - a.clone() - } else { - b.clone() - } - } - } -} - -fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { - let mut out = if t < 0.5 { a.clone() } else { b.clone() }; - out.set_fill(lerp_paint(&a.fill_paint(), &b.fill_paint(), t as f32)); - let (sa, sb) = (a.stroke_paint(), b.stroke_paint()); - if !(sa.is_none() && sb.is_none()) { - out.set_stroke(lerp_paint(&sa, &sb, t as f32)); - let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t; - for it in &mut out.items { - if let AppearanceItem::Stroke(s) = it { - s.width = w; - } - } - } - out -} - -/// One intermediate step between key objects `a` and `b`. -fn blend_step(d: &mut drawcraft_doc::Document, a: &Node, b: &Node, t: f64) -> Node { - let ap = a.path_data(); - let bp = b.path_data(); - let mut n = match (ap, bp.and_then(|bp| ap.and_then(|ap| lerp_path(ap, bp, t)))) { - (Some(_), Some(path)) => { - let mut n = Node::path(NodeId(0), path, Appearance::default()); - if let NodeKind::Path { rule, .. } = &a.kind - && let NodeKind::Path { rule: r, .. } = &mut n.kind - { - *r = *rule; - } - n - } - _ => { - // Different structure: move/scale a copy of `a` towards `b`'s bounds. - let mut n = a.clone(); - if let (Some(ba), Some(bb)) = (a.geometric_bounds(), b.geometric_bounds()) { - let w = ba.width() + (bb.width() - ba.width()) * t; - let h = ba.height() + (bb.height() - ba.height()) * t; - let c = lerp_pt(ba.center(), bb.center(), t); - let sx = if ba.width() > 1e-9 { w / ba.width() } else { 1.0 }; - let sy = if ba.height() > 1e-9 { h / ba.height() } else { 1.0 }; - n.transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ba.center().to_vec2()), false); - } - if let NodeKind::Path { live, .. } = &mut n.kind { - *live = None; - } - n - } - }; - let mut n = d.reid(&{ - 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.name = Some(BLEND_STEP_NAME.into()); - n.visible = true; - n.locked = false; - n -} - -/// Key objects + generated steps, in paint order. -fn build_blend(d: &mut drawcraft_doc::Document, keys: &[Node], steps: usize) -> Vec> { - let mut out = vec![]; - for (i, k) in keys.iter().enumerate() { - out.push(Arc::new(k.clone())); - if let Some(next) = keys.get(i + 1) { - for j in 1..=steps { - let t = j as f64 / (steps + 1) as f64; - out.push(Arc::new(blend_step(d, k, next, t))); - } - } - } - out -} - -fn steps_param(p: &Value, cmd: &str) -> Result { - let n = f64_or(p, "steps", 5.0); - if !(1.0..=1000.0).contains(&n) { - return Err(bad(cmd, "steps must be 1..1000")); - } - Ok(n as usize) -} - -fn blend_make(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.make"; - let steps = steps_param(p, C)?; - let roots = selected_roots(s)?; - if roots.len() < 2 { - return Err(bad(C, "select at least two objects")); - } - let top = *roots.last().unwrap(); - let id = s.edit("Make Blend", |d, sel| { - let keys: Vec = roots.iter().filter_map(|id| d.node(*id).cloned()).collect(); - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - let children = build_blend(d, &keys, steps); - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = Some(BLEND_NAME.into()); - d.insert(par, idx + 1, g)?; - for r in &roots { - d.remove(*r)?; - } - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0 })) -} - -fn keys_of(n: &Node) -> Vec { - n.children().into_iter().flatten().filter(|c| c.name.as_deref() != Some(BLEND_STEP_NAME)).map(|c| (**c).clone()).collect() -} - -fn blend_release(s: &mut Session, _: &Value) -> Result { - let groups = blend_groups(s); - let ids = s.edit("Release Blend", |d, sel| { - let mut out = vec![]; - for g in &groups { - let Some(n) = d.node(*g).cloned() else { continue }; - let (par, idx, _) = d.position(*g).ok_or(EngineError::NoNode(*g))?; - d.remove(*g)?; - for (k, key) in keys_of(&n).into_iter().enumerate() { - out.push(key.id); - d.insert(par, idx + k, key)?; - } - } - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(ids_json(&ids)) -} - -fn regenerate(s: &mut Session, label: &str, steps: Option, f: impl Fn(&mut Vec)) -> Result { - let groups = blend_groups(s); - s.edit(label, |d, _| { - for g in &groups { - let Some(n) = d.node(*g).cloned() else { continue }; - let mut keys = keys_of(&n); - let old_steps = n.children().map(|c| c.len()).unwrap_or(0).saturating_sub(keys.len()) / keys.len().saturating_sub(1).max(1); - f(&mut keys); - let children = build_blend(d, &keys, steps.unwrap_or(old_steps.max(1))); - if let Some(ch) = d.node_mut(*g).and_then(|n| n.children_mut()) { - *ch = children; - } - } - Ok(()) - })?; - ok() -} - -fn blend_options(s: &mut Session, p: &Value) -> Result { - let steps = steps_param(p, "object.blend.options")?; - regenerate(s, "Blend Options", Some(steps), |_| {}) -} - -fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { - regenerate(s, "Reverse Spine", None, |keys| { - let centers: Vec = keys.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); - let n = keys.len(); - for (i, k) in keys.iter_mut().enumerate() { - let d: Vec2 = centers[n - 1 - i] - centers[i]; - k.transform(Affine::translate(d), false); - } - }) -} - -fn blend_expand(s: &mut Session, _: &Value) -> Result { - let groups = blend_groups(s); - s.edit("Expand Blend", |d, _| { - for g in &groups { - let Some(n) = d.node_mut(*g) else { continue }; - n.name = None; - for c in n.children_mut().into_iter().flatten() { - if c.name.as_deref() == Some(BLEND_STEP_NAME) { - Arc::make_mut(c).name = None; - } - } - } - Ok(()) - })?; - ok() -} - -fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { - let groups = blend_groups(s); - s.edit("Reverse Front to Back", |d, _| { - for g in &groups { - if let Some(ch) = d.node_mut(*g).and_then(|n| n.children_mut()) { - ch.reverse(); - } - } - Ok(()) - })?; - ok() -} - // ---------- Artboards ---------- fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8320930f7..f321124b0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,21 +1,25 @@ //! The command registry. Ids follow Illustrator's menu structure. +mod brushsym; +mod buildcmds; mod colorcmds; mod create; mod docmenu; mod draw2; mod edit; -mod recolor; mod effectcmd; mod file; mod layer; +mod live; pub(crate) mod menucmds; mod object; mod paint; mod panelcmds; mod path; mod pathops; +mod recolor; mod select; +mod textedit; mod typecmd; mod typemenu; mod xform; @@ -131,10 +135,14 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(pathops::specs()); v.extend(typecmd::specs()); v.extend(menucmds::specs()); + v.extend(live::specs()); v.extend(colorcmds::specs()); v.extend(typemenu::specs()); + v.extend(textedit::specs()); v.extend(docmenu::specs()); v.extend(panelcmds::specs()); + v.extend(buildcmds::specs()); + v.extend(brushsym::specs()); v }) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index ffe80760a..b280cdd43 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -11,7 +11,15 @@ 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), + cmd!( + "recolor.apply", + "Recolor Artwork", + ["Edit", "Edit Colors"], + None, + "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", + has_selection, + apply + ), ] } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs new file mode 100644 index 000000000..a67b2bb25 --- /dev/null +++ b/crates/engine/src/cmd/textedit.rs @@ -0,0 +1,390 @@ +//! Rich text editing commands: range edits and range styling (what the Type tool and the +//! Character panel use while editing), creating area / on-path type from a path, and Fit Headline. + +use drawcraft_color::Paint; +use drawcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; +use drawcraft_geom::Affine; +use drawcraft_text::edit; +use serde_json::{Value, json}; + +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "text.editRange", + "Edit Text", + [], + None, + "{id, start: byte, end: byte, insert?: string, runs?: [{text, style}]} replace bytes start..end of the plain text (inserted text takes the replaced text's style unless styled `runs` are given) → {id, caret}", + has_doc, + edit_range + ), + cmd!( + "text.setRangeStyle", + "Character", + [], + None, + "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool} style a character range (runs are split at the range ends) → {id, runs}", + has_doc, + set_range_style + ), + cmd!( + query "text.getRange", + "Get Text Range", + [], + None, + "{id, start?, end?} → {text, runs: [{text, style}], length}", + has_doc, + get_range + ), + cmd!( + "text.createInPath", + "Area / Path Type", + [], + None, + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped, like Illustrator) → {id}", + has_doc, + create_in_path + ), + cmd!( + "type.fitHeadline", + "Fit Headline", + ["Type"], + None, + "{ids?} track the first line of area type so it fills the frame width → {ids, tracking}", + has_selection, + fit_headline + ), + ] +} + +fn text_mut(d: &mut drawcraft_doc::Document, id: NodeId) -> Option<&mut TextObject> { + match d.node_mut(id).map(|n| &mut n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + } +} + +fn text_ref(s: &Session, id: NodeId) -> Result { + match s.doc()?.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Ok((**t).clone()), + Some(_) => Err(EngineError::Other(format!("node {} is not text", id.0))), + None => Err(EngineError::NoNode(id)), + } +} + +fn byte_param(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_u64).map(|v| v as usize) +} + +fn range_of(p: &Value, len: usize) -> (usize, usize) { + let a = byte_param(p, "start").unwrap_or(0).min(len); + let b = byte_param(p, "end").unwrap_or(len).min(len); + (a.min(b), a.max(b)) +} + +fn edit_range(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.editRange"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let t = text_ref(s, id)?; + let len = edit::runs_len(&t.runs); + let (a, b) = range_of(p, len); + let styled: Option> = match p.get("runs") { + None | Some(Value::Null) => None, + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?), + }; + let insert = str_param(p, "insert").unwrap_or("").to_string(); + let caret = s.edit("Typing", |d, _| { + let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + let caret = match &styled { + Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r), + None => edit::replace_range(&mut t.runs, a, b, &insert), + }; + refresh_bounds(t); + Ok(caret) + })?; + Ok(json!({"id": id.0, "caret": caret})) +} + +fn get_range(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let t = text_ref(s, id)?; + let len = edit::runs_len(&t.runs); + let (a, b) = range_of(p, len); + let runs = edit::slice_runs(&t.runs, a, b); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + Ok(json!({"text": text, "runs": runs, "length": len})) +} + +/// Character attribute changes parsed from params (shared by range and whole-object styling). +#[derive(Default)] +pub(crate) struct CharChange { + font: Option, + style: Option, + size: Option, + leading: Option>, + tracking: Option, + kerning: Option>, + baseline_shift: Option, + h_scale: Option, + v_scale: Option, + rotation: Option, + fill: Option, + stroke: Option, + stroke_width: Option, + underline: Option, + strikethrough: Option, + all_caps: Option, +} + +fn paint_param(p: &Value, k: &str, cmd: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Ok(Some(Paint::None)), + Some(v) => Ok(Some(Paint::solid(color_value(v).ok_or_else(|| bad(cmd, format!("bad {k} colour")))?))), + } +} + +fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result>> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)), + Some(v) => Ok(Some(Some(v.as_f64().ok_or_else(|| bad(cmd, format!("{k} must be a number or \"auto\"")))?))), + } +} + +impl CharChange { + pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let c = Self { + font: str_param(p, "font").map(str::to_string), + style: str_param(p, "style").map(str::to_string), + size: num("size"), + leading: auto_or_num(p, "leading", cmd)?, + tracking: num("tracking"), + kerning: auto_or_num(p, "kerning", cmd)?, + baseline_shift: num("baselineShift"), + h_scale: num("hScale"), + v_scale: num("vScale"), + rotation: num("rotation"), + fill: paint_param(p, "fill", cmd)?, + stroke: paint_param(p, "stroke", cmd)?, + stroke_width: num("strokeWidth"), + underline: flag("underline"), + strikethrough: flag("strikethrough"), + all_caps: flag("allCaps"), + }; + if c.size.is_some_and(|v| v <= 0.0) { + return Err(bad(cmd, "size must be positive")); + } + Ok(c) + } + + pub(crate) fn is_empty(&self) -> bool { + self.font.is_none() + && self.style.is_none() + && self.size.is_none() + && self.leading.is_none() + && self.tracking.is_none() + && self.kerning.is_none() + && self.baseline_shift.is_none() + && self.h_scale.is_none() + && self.v_scale.is_none() + && self.rotation.is_none() + && self.fill.is_none() + && self.stroke.is_none() + && self.stroke_width.is_none() + && self.underline.is_none() + && self.strikethrough.is_none() + && self.all_caps.is_none() + } + + pub(crate) fn apply(&self, st: &mut CharStyle) { + if let Some(f) = &self.font { + st.font_family = f.clone(); + // Keep the style when the new family has it, else its closest match. + if self.style.is_none() { + let styles = drawcraft_text::FontDb::global().styles(f); + if !styles.is_empty() && !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style)) { + st.font_style = drawcraft_text::FontDb::global().face(f, &st.font_style).style.clone(); + } + } + } + if let Some(v) = &self.style { + st.font_style = v.clone(); + } + if let Some(v) = self.size { + st.size = v.clamp(0.1, 1296.0); + } + if let Some(v) = self.leading { + st.leading = v.map(|l| l.clamp(0.1, 5000.0)); + } + if let Some(v) = self.tracking { + st.tracking = v.clamp(-1000.0, 10000.0); + } + if let Some(v) = self.kerning { + st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); + } + if let Some(v) = self.baseline_shift { + st.baseline_shift = v.clamp(-1296.0, 1296.0); + } + if let Some(v) = self.h_scale { + st.h_scale = v.clamp(1.0, 10000.0); + } + if let Some(v) = self.v_scale { + st.v_scale = v.clamp(1.0, 10000.0); + } + if let Some(v) = self.rotation { + st.rotation = (v + 180.0).rem_euclid(360.0) - 180.0; + } + if let Some(v) = &self.fill { + st.fill = v.clone(); + } + if let Some(v) = &self.stroke { + st.stroke = v.clone(); + } + if let Some(v) = self.stroke_width { + st.stroke_width = v.clamp(0.0, 1000.0); + } + if let Some(v) = self.underline { + st.underline = v; + } + if let Some(v) = self.strikethrough { + st.strikethrough = v; + } + if let Some(v) = self.all_caps { + st.all_caps = v; + } + } +} + +fn set_range_style(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setRangeStyle"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let change = CharChange::parse(p, C)?; + if change.is_empty() { + return Err(bad(C, "nothing to change")); + } + let t = text_ref(s, id)?; + let (a, b) = range_of(p, edit::runs_len(&t.runs)); + let n = s.edit("Character", |d, _| { + let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + if a == b && !t.plain_text().is_empty() { + // An empty range inside text styles nothing (Illustrator keeps it for the next typing). + return Ok(t.runs.len()); + } + edit::style_range(&mut t.runs, a, b, |st| change.apply(st)); + refresh_bounds(t); + Ok(t.runs.len()) + })?; + Ok(json!({"id": id.0, "runs": n})) +} + +fn create_in_path(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.createInPath"; + let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let on_path = match str_param(p, "mode").unwrap_or("area") { + "area" => false, + "onPath" | "path" => true, + o => return Err(bad(C, format!("mode must be area|onPath, got {o}"))), + }; + let node = s.doc()?.doc.node(pid).cloned().ok_or(EngineError::NoNode(pid))?; + let NodeKind::Path { path, .. } = &node.kind else { return Err(bad(C, "`path` must be a path object")) }; + if path.is_empty() { + return Err(bad(C, "the path is empty")); + } + if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) { + return Err(bad(C, "area type needs a path that encloses an area")); + } + let mut style = CharStyle::default(); + if let Some(v) = p.get("size").and_then(Value::as_f64) { + style.size = v.clamp(0.1, 1296.0); + } + if let Some(f) = str_param(p, "font") { + style.font_family = f.to_string(); + } + if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(drawcraft_color::Color::WHITE) { + style.fill = s.paint.fill.clone(); + } + let text = str_param(p, "text").unwrap_or("").to_string(); + let start = match point_param(p, "at") { + Some(at) if on_path => drawcraft_text::path_fraction_at(&path.to_bezpath(), at).0, + _ => 0.0, + }; + let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } }; + let mut t = TextObject { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], para: Default::default(), cached_bounds: None }; + refresh_bounds(&mut t); + let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| { + let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?; + d.remove(pid)?; + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(Box::new(t))); + n.appearance = Appearance::default(); + n.opacity = node.opacity; + n.name = node.name.clone(); + d.insert(par, idx, n)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits +/// on one line at all. +fn headline_tracking(t: &TextObject, target: f64) -> Option { + let plain = t.plain_text(); + let end = plain.find('\n').unwrap_or(plain.len()); + if end == 0 { + return None; + } + let head = edit::slice_runs(&t.runs, 0, end); + let measure = |tr: f64| { + let mut h = TextObject { kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), para: Default::default(), cached_bounds: None }; + for r in &mut h.runs { + r.style.tracking = tr; + } + let l = drawcraft_text::layout(drawcraft_text::FontDb::global(), &h); + l.lines.first().map_or(0.0, |li| li.x1 - li.x0) + }; + // Width is linear in tracking: w(tr) = w0 + tr * slope. + let (w0, w1) = (measure(0.0), measure(100.0)); + let slope = (w1 - w0) / 100.0; + if slope <= 1e-9 { + return None; + } + // A hair under the target so the line doesn't wrap. + Some(((target - w0) / slope - 0.05).clamp(-1000.0, 10000.0)) +} + +fn fit_headline(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.fitHeadline"; + let ids: Vec = { + let st = s.doc()?; + targets(s, p)? + .into_iter() + .filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }))) + .collect() + }; + if ids.is_empty() { + return Err(bad(C, "select area type")); + } + let mut applied = vec![]; + s.edit("Fit Headline", |d, _| { + for id in &ids { + let Some(t) = text_mut(d, *id) else { continue }; + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let Some(first) = lay.lines.first() else { continue }; + let target = first.avail.1 - first.avail.0; + let Some(tr) = headline_tracking(t, target) else { continue }; + let end = t.plain_text().find('\n').unwrap_or(t.plain_text().len()); + edit::style_range(&mut t.runs, 0, end, |st| st.tracking = tr); + refresh_bounds(t); + applied.push(tr); + } + Ok(()) + })?; + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "tracking": applied})) +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 41a2d4774..8cc0e1848 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -2,7 +2,7 @@ //! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text //! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). -use drawcraft_doc::{CharStyle, Document, NodeId, NodeKind, TextKind, TextRun}; +use drawcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, TextKind, TextRun}; use drawcraft_geom::{Affine, Rect, shapes}; use serde_json::{Value, json}; @@ -291,7 +291,14 @@ fn to_area(s: &mut Session, p: &Value) -> Result { let size = t.first_style().size; // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); - let frame = Rect::new(b.x0, top, b.x1 + size * 0.5, b.y1.max(top + 1.0)); + let slack = size * 0.5; + // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. + let (sl, sr) = match t.para.justify { + Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), + Justify::Right | Justify::JustifyRight => (slack, 0.0), + _ => (0.0, slack), + }; + let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); } @@ -336,8 +343,18 @@ fn to_point(s: &mut Session, p: &Value) -> Result { t.runs[ri].text.insert(bi, '\n'); } } + // The point origin sits where the alignment anchors the first line. + let (x0, x1) = lay + .lines + .first() + .map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent)); + let ox = match t.para.justify { + Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, + Justify::Right | Justify::JustifyRight => x1, + _ => x0, + }; t.kind = TextKind::Point; - t.xf *= Affine::translate((fb.x0, baseline)); + t.xf *= Affine::translate((ox, baseline)); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1be7d4c93..7a57cab75 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -409,14 +409,22 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_brushsym; +#[cfg(test)] +mod tests_build; +#[cfg(test)] mod tests_draw2; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_live; +#[cfg(test)] mod tests_menucmds; #[cfg(test)] mod tests_panelcmds; #[cfg(test)] mod tests_pathops; #[cfg(test)] +mod tests_textedit; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs new file mode 100644 index 000000000..aa808b88c --- /dev/null +++ b/crates/engine/src/tests_brushsym.rs @@ -0,0 +1,398 @@ +//! Brushes and symbols: commands, tools, rendering and persistence. + +use drawcraft_doc::{Node, NodeKind}; +use drawcraft_geom::Rect; +use drawcraft_tools::{Mods, PointerEvent, PointerKind}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn line(s: &mut Session) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 250, "y2": 100})); + match r { + Ok(v) if v["id"].is_u64() => NodeId(v["id"].as_u64().unwrap()), + _ => { + let r = s.execute("path.freehand", &json!({"points": [[50, 100], [150, 100], [250, 100]]})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + } + } +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn brush_of(s: &Session, id: NodeId) -> Option { + node(s, id).appearance.stroke().and_then(|st| st.brush.clone()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + let ids = ids.to_vec(); + s.select(|_, sel| sel.set(ids)).unwrap(); +} + +fn close(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +// ---------- brushes ---------- + +#[test] +fn brush_list_has_the_default_library() { + let mut s = session(); + let l = s.execute("brush.list", &json!({})).unwrap(); + let names: Vec<&str> = l["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); + for n in ["3 pt. Round", "6 pt. Flat", "Charcoal", "Arrow", "Dots", "Chain", "Bristle Round"] { + assert!(names.contains(&n), "{n} in {names:?}"); + } + let def = s.execute("brush.get", &json!({"name": "6 pt. Flat"})).unwrap(); + assert_eq!(def["type"], "calligraphic"); + assert_eq!(def["angle"], json!(45.0)); +} + +#[test] +fn apply_and_remove_brush_with_undo() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + s.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap(); + assert_eq!(brush_of(&s, a).as_deref(), Some("Charcoal")); + assert_eq!(s.execute("brush.list", &json!({})).unwrap()["current"], "Charcoal"); + s.execute("brush.remove", &json!({})).unwrap(); + assert_eq!(brush_of(&s, a), None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, a).as_deref(), Some("Charcoal")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, a), None); + assert!(s.execute("brush.apply", &json!({"name": "Nope"})).is_err()); +} + +#[test] +fn apply_adds_a_stroke_when_missing_and_reaches_into_groups() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + let b = rect(&mut s, 80.0, 10.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"color": null, "ids": [a.0]})).ok(); + select(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("brush.apply", &json!({"name": "Dots"})).unwrap(); + assert_eq!(brush_of(&s, a).as_deref(), Some("Dots")); + assert_eq!(brush_of(&s, b).as_deref(), Some("Dots")); + assert!(!node(&s, a).appearance.stroke_paint().is_none()); +} + +#[test] +fn new_brushes_from_params_and_selection() { + let mut s = session(); + let r = s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 20, "roundness": 30, "size": 8}})).unwrap(); + assert_eq!(r["name"], "Nib"); + assert_eq!(s.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(8.0)); + // Art brushes need art. + s.select(|_, sel| sel.clear()).unwrap(); + assert!(s.execute("brush.new", &json!({"type": "art"})).is_err()); + let a = rect(&mut s, 0.0, 0.0, 40.0, 8.0); + let r = s.execute("brush.new", &json!({"type": "art", "name": "Bar"})).unwrap(); + assert_eq!(r["name"], "Bar"); + let r = s.execute("brush.new", &json!({"type": "pattern"})).unwrap(); + assert_eq!(r["name"], "New Pattern Brush"); + let def = s.execute("brush.get", &json!({"name": "New Pattern Brush"})).unwrap(); + assert!(def["side"].is_object()); + // The new art brush draws the rectangle along another path. + let l = line(&mut s); + s.execute("brush.apply", &json!({"name": "Bar", "ids": [l.0]})).unwrap(); + let g = s.execute("object.expandBrush", &json!({"ids": [l.0]})).unwrap(); + let gid = NodeId(g["ids"][0].as_u64().unwrap()); + let b = bounds(&s, gid); + assert!((b.width() - 200.0).abs() < 0.5 && (b.height() - 8.0).abs() < 0.5, "{b:?}"); + let _ = a; + // Undo removes the brush again; the library was not there before. + assert!(s.execute("brush.new", &json!({"type": "nope"})).is_err()); +} + +#[test] +fn brush_options_rename_duplicate_and_delete_update_strokes() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + s.execute("brush.apply", &json!({"name": "3 pt. Round"})).unwrap(); + s.execute("brush.options", &json!({"name": "3 pt. Round", "params": {"size": 5}, "newName": "5 pt. Round"})).unwrap(); + assert_eq!(brush_of(&s, a).as_deref(), Some("5 pt. Round")); + assert_eq!(s.execute("brush.get", &json!({"name": "5 pt. Round"})).unwrap()["size"], json!(5.0)); + assert_eq!(s.execute("brush.list", &json!({})).unwrap()["current"], "5 pt. Round"); + let d = s.execute("brush.duplicate", &json!({"name": "5 pt. Round"})).unwrap(); + assert_eq!(d["name"], "5 pt. Round copy"); + s.execute("brush.delete", &json!({"name": "5 pt. Round"})).unwrap(); + assert_eq!(brush_of(&s, a), None); + assert!(s.execute("brush.get", &json!({"name": "5 pt. Round"})).is_err()); + assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err()); +} + +#[test] +fn expand_brush_replaces_path_with_art_group() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("brush.apply", &json!({"name": "10 pt. Oval"})).unwrap(); + let r = s.execute("object.expandBrush", &json!({})).unwrap(); + let g = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.node(a).is_none()); + let gn = node(&s, g); + assert!(matches!(gn.kind, NodeKind::Group { .. })); + // White fill kept + the black nib outline. + assert_eq!(gn.children().unwrap().len(), 2); + let b = bounds(&s, g); + assert!(b.width() > 104.0 && b.width() < 112.0, "{b:?}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert!(s.execute("object.expandBrush", &json!({})).is_err(), "nothing brushed left"); +} + +#[test] +fn brush_freehand_paints_with_a_brush_in_one_step() { + let mut s = session(); + let n = s.doc().unwrap().history.undo.len(); + s.begin_interaction("Paintbrush").unwrap(); + s.preview("brush.freehand", &json!({"points": [[10, 10], [60, 40], [120, 10]], "style": "brush", "brush": "Charcoal"})).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); +} + +#[test] +fn paintbrush_tool_uses_the_current_brush() { + let mut s = session(); + s.execute("brush.setCurrent", &json!({"name": "Arrow"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("paintbrush", v).unwrap(); + for (k, x, y) in + [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 50.0, 30.0), (PointerKind::Drag, 90.0, 20.0), (PointerKind::Up, 130.0, 10.0)] + { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(brush_of(&s, id).as_deref(), Some("Arrow")); + // A tool option overrides the document's current brush. + s.set_tool_option("brush", &json!("Dots")); + for (k, x, y) in [(PointerKind::Down, 10.0, 110.0), (PointerKind::Drag, 50.0, 130.0), (PointerKind::Up, 130.0, 110.0)] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(brush_of(&s, id).as_deref(), Some("Dots")); + assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err()); +} + +// ---------- symbols ---------- + +fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) { + let a = rect(s, 100.0, 50.0, 80.0, 40.0); + let b = bounds(s, a); + let r = s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + (NodeId(r["id"].as_u64().unwrap()), r["name"].as_str().unwrap().to_string(), b) +} + +#[test] +fn new_symbol_replaces_selection_with_instance_of_same_bounds() { + let mut s = session(); + let (inst, name, b) = make_symbol(&mut s); + assert_eq!(name, "Box"); + let n = node(&s, inst); + assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Box")); + assert!(close(n.geometric_bounds().unwrap(), b, 1e-9), "{:?} vs {b:?}", n.geometric_bounds()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.symbols.len(), 1); + assert_eq!(d.layers[0].children().unwrap().len(), 1); + let l = s.execute("symbol.list", &json!({})).unwrap(); + assert_eq!(l["symbols"][0]["instances"], 1); + assert_eq!(l["symbols"][0]["size"], json!([80.0, 40.0])); + // A second symbol gets a unique name. + rect(&mut s, 0.0, 0.0, 10.0, 10.0); + assert_eq!(s.execute("symbol.new", &json!({"name": "Box"})).unwrap()["name"], "Box 2"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbols.len(), 1); +} + +#[test] +fn place_and_break_link_round_trip_geometry() { + let mut s = session(); + let (_, name, _) = make_symbol(&mut s); + let r = s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + assert!(close(bounds(&s, p), Rect::new(260.0, 180.0, 340.0, 220.0), 1e-9), "{:?}", bounds(&s, p)); + let r = s.execute("symbol.breakLink", &json!({"ids": [p.0]})).unwrap(); + let art = NodeId(r["ids"][0].as_u64().unwrap()); + let n = node(&s, art); + assert!(matches!(n.kind, NodeKind::Path { .. })); + assert!(close(n.geometric_bounds().unwrap(), Rect::new(260.0, 180.0, 340.0, 220.0), 1e-6)); + assert_eq!(n.appearance.stroke_width(), 1.0, "strokes are not scaled by normalisation"); +} + +#[test] +fn edit_then_update_redefines_all_instances() { + let mut s = session(); + let (inst, name, _) = make_symbol(&mut s); + let other = NodeId(s.execute("symbol.place", &json!({"x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[inst]); + let r = s.execute("symbol.edit", &json!({})).unwrap(); + assert_eq!(r["name"], name); + let art = NodeId(r["ids"][0].as_u64().unwrap()); + // Make the art twice as wide, then redefine. + s.execute("object.transform", &json!({"ids": [art.0], "matrix": [2, 0, 0, 1, -140, 0]})).ok(); + let w = bounds(&s, art).width(); + let r = s.execute("symbol.update", &json!({"name": name})).unwrap(); + let new_inst = NodeId(r["id"].as_u64().unwrap()); + assert!((bounds(&s, new_inst).width() - w).abs() < 1e-6); + assert!((bounds(&s, other).width() - w).abs() < 1e-6, "other instances pick up the new size"); + assert!((bounds(&s, other).center().x - 300.0).abs() < 1e-6); +} + +#[test] +fn delete_duplicate_and_replace_symbols() { + let mut s = session(); + let (inst, name, _) = make_symbol(&mut s); + let d = s.execute("symbol.duplicate", &json!({"name": name})).unwrap(); + assert_eq!(d["name"], "Box copy"); + rect(&mut s, 0.0, 0.0, 20.0, 20.0); + s.execute("symbol.new", &json!({"name": "Small"})).unwrap(); + select(&mut s, &[inst]); + s.execute("symbol.replace", &json!({"name": "Small"})).unwrap(); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Small")); + assert!(close(bounds(&s, inst), Rect::new(130.0, 60.0, 150.0, 80.0), 1e-6), "{:?}", bounds(&s, inst)); + s.execute("symbol.delete", &json!({"name": "Small"})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().all(|x| x.name != "Small")); + assert!(doc.node(inst).is_none()); + let mut plain = 0; + doc.walk(|n| plain += matches!(n.kind, NodeKind::Path { .. }) as i32); + assert_eq!(plain, 2, "both instances of Small were expanded"); + assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err()); +} + +#[test] +fn sprayer_command_creates_a_symbol_set_and_alt_removes() { + let mut s = session(); + make_symbol(&mut s); + s.select(|_, sel| sel.clear()).unwrap(); + let pts: Vec = (0..20).map(|i| json!([20.0 + i as f64 * 15.0, 150.0])).collect(); + let r = s.execute("symbol.spray", &json!({"points": pts, "radius": 10, "density": 8})).unwrap(); + let set = NodeId(r["id"].as_u64().unwrap()); + let n = r["count"].as_u64().unwrap() as usize; + assert!(n >= 3, "{n}"); + let g = node(&s, set); + assert_eq!(g.name.as_deref(), Some("Symbol Set")); + assert_eq!(g.children().unwrap().len(), n); + // Spraying again with the set selected adds to it. + s.execute("symbol.spray", &json!({"points": [[200, 250]]})).unwrap(); + assert_eq!(node(&s, set).children().unwrap().len(), n + 1); + let r = s.execute("symbol.spray", &json!({"points": [[200, 250]], "radius": 60, "alt": true})).unwrap(); + assert!(r["count"].as_u64().unwrap() >= 1); + assert_eq!(node(&s, set).children().unwrap().len(), n + 1 - r["count"].as_u64().unwrap() as usize); +} + +#[test] +fn symbolism_adjustments() { + let mut s = session(); + let (inst, _, b) = make_symbol(&mut s); + let c = b.center(); + let at = json!([[c.x, c.y]]); + s.execute("symbol.adjust", &json!({"tool": "size", "points": at, "radius": 50, "intensity": 10})).unwrap(); + assert!(bounds(&s, inst).width() > b.width() + 1.0); + s.execute("symbol.adjust", &json!({"tool": "screen", "points": at, "intensity": 10})).unwrap(); + assert!(node(&s, inst).opacity < 1.0); + s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10})).unwrap(); + let f = node(&s, inst).appearance.fill().cloned().unwrap(); + assert!(f.opacity > 0.0 && f.paint.color().unwrap().to_hex() == "#ff0000"); + s.execute("symbol.adjust", &json!({"tool": "shift", "points": [[c.x, c.y], [c.x + 20.0, c.y]], "intensity": 10})).unwrap(); + assert!(bounds(&s, inst).center().x > c.x + 5.0); + let before = bounds(&s, inst); + s.execute("symbol.adjust", &json!({"tool": "spin", "points": at, "intensity": 10})).unwrap(); + assert!(!close(bounds(&s, inst), before, 1e-6), "spin rotates"); + let far = s.execute("symbol.adjust", &json!({"tool": "size", "points": [[0, 290]], "radius": 5})).unwrap(); + assert_eq!(far["count"], 0); + assert!(s.execute("symbol.adjust", &json!({"tool": "nope", "points": at})).is_err()); +} + +#[test] +fn symbol_sprayer_tool_gesture_is_one_undo_step() { + let mut s = session(); + make_symbol(&mut s); + s.select(|_, sel| sel.clear()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let v = ViewInfo::default(); + s.select_tool("symbolSprayer", v).unwrap(); + let mut evs = vec![PointerEvent::new(PointerKind::Down, 50.0, 200.0)]; + for i in 1..15 { + evs.push(PointerEvent::new(PointerKind::Drag, 50.0 + i as f64 * 20.0, 200.0)); + } + evs.push(PointerEvent::new(PointerKind::Up, 350.0, 200.0).with_mods(Mods::default())); + for e in evs { + s.pointer(&e, v).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let set = s.doc().unwrap().selection.objects[0]; + assert!(node(&s, set).children().unwrap().len() >= 3); + // The sizer tool grows the sprayed instances. + let first = node(&s, set).children().unwrap()[0].clone(); + let c = first.geometric_bounds().unwrap().center(); + s.select_tool("symbolSizer", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap(); + let after = s.doc().unwrap().doc.node(first.id).unwrap().geometric_bounds().unwrap(); + assert!(after.width() > first.geometric_bounds().unwrap().width()); +} + +// ---------- rendering & persistence ---------- + +#[test] +fn brushed_strokes_and_instances_render() { + let mut s = session(); + make_symbol(&mut s); + let l = line(&mut s); + s.execute("brush.apply", &json!({"name": "Chain", "ids": [l.0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let img = drawcraft_render::Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true); + let dark = |x: u32, y: u32| { + let p = img.pixel(x, y); + (p[0] as u32 + p[1] as u32 + p[2] as u32) < 300 + }; + // The instance's outline (the rectangle's black stroke) is drawn. + assert!((45..=55).any(|y| dark(100, y)), "instance stroke rendered"); + // Chain links are thicker than the 1 pt stroke, with gaps. + let hits = (50..250).filter(|x| (95..=105).any(|y| dark(*x, y))).count(); + assert!(hits > 100, "chain covers most of the line: {hits}"); +} + +#[test] +fn brushes_and_symbols_survive_drawcraft_round_trip() { + let mut s = session(); + make_symbol(&mut s); + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Mine", "params": {"size": 7}})).unwrap(); + let l = line(&mut s); + s.execute("brush.apply", &json!({"name": "Mine", "ids": [l.0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = drawcraft_format::save(&doc, false); + let back = drawcraft_format::load(&bytes).unwrap(); + assert_eq!(drawcraft_brush::library(&back), drawcraft_brush::library(&doc)); + assert_eq!(drawcraft_brush::find(&back, "Mine").map(|b| b.kind.type_id()), Some("calligraphic")); + assert_eq!(back.symbols, doc.symbols); + assert_eq!(back.unknown.get("symbolSizes"), doc.unknown.get("symbolSizes")); + let bl = back.node(l).unwrap(); + assert_eq!(bl.appearance.stroke().unwrap().brush.as_deref(), Some("Mine")); + // A reloaded document places instances at the natural size. + let mut s2 = Session::new(); + s2.add_document(back, None); + let r = s2.execute("symbol.place", &json!({"name": "Box", "x": 100, "y": 100})).unwrap(); + let b = s2.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().geometric_bounds().unwrap(); + assert!(close(b, Rect::new(60.0, 80.0, 140.0, 120.0), 1e-9)); +} diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs new file mode 100644 index 000000000..5c6c00d93 --- /dev/null +++ b/crates/engine/src/tests_build.rs @@ -0,0 +1,420 @@ +//! Tests for Shape Builder, Live Paint and Image Trace commands and tools. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; +use drawcraft_geom::{Affine, FillRule, PathData, Rect}; +use drawcraft_tools::{Mods, PointerEvent, PointerKind}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn set_fill(s: &mut Session, id: NodeId, c: Color) { + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(c)); + Ok(()) + }) + .unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn outline(n: &Node) -> (PathData, FillRule) { + drawcraft_tools::builder::node_outline(n).expect("path") +} + +fn area(s: &Session, id: NodeId) -> f64 { + let (p, r) = outline(&node(s, id)); + drawcraft_pathops::area(&p, r) +} + +/// Rect A (0..100) and rect B (50..150), both 100 tall, selected. A red, B blue. +fn two(s: &mut Session) -> (NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 100.0, 100.0); + let b = rect(s, 50.0, 0.0, 100.0, 100.0); + set_fill(s, a, Color::rgb(1.0, 0.0, 0.0)); + set_fill(s, b, Color::rgb(0.0, 0.0, 1.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + (a, b) +} + +fn layer_children(s: &Session) -> usize { + let d = &s.doc().unwrap().doc; + d.children(d.default_layer()).map(|c| c.len()).unwrap_or(0) +} + +// ---------- Shape Builder ---------- + +#[test] +fn shape_builder_drag_merges_touched_regions() { + let mut s = session(); + two(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + assert_eq!(r["regions"], 2); + let out = ids(&r); + assert_eq!(out.len(), 2, "merged shape + remaining part of B"); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert!((area(&s, merged) - 10000.0).abs() < 1.0, "{}", area(&s, merged)); + let rest = out.iter().copied().find(|i| *i != merged).unwrap(); + assert!((area(&s, rest) - 5000.0).abs() < 1.0); + // Merged shape takes the fill of the object under the drag start (A, red). + assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(layer_children(&s), 2); +} + +#[test] +fn shape_builder_click_splits_out_one_region() { + let mut s = session(); + two(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]]})).unwrap(); + let out = ids(&r); + assert_eq!(out.len(), 3); + let total: f64 = out.iter().map(|i| area(&s, *i)).sum(); + assert!((total - 15000.0).abs() < 1.0, "{total}"); + let merged = NodeId(r["merged"].as_u64().unwrap()); + // Overlap is covered by B on top → blue. + assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let bb = node(&s, merged).geometric_bounds().unwrap(); + assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6); +} + +#[test] +fn shape_builder_erase_deletes_regions() { + let mut s = session(); + two(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "erase": true})).unwrap(); + assert!(r["merged"].is_null()); + let out = ids(&r); + assert_eq!(out.len(), 2); + for i in out { + assert!((area(&s, i) - 5000.0).abs() < 1.0); + } +} + +#[test] +fn shape_builder_fill_option_and_untouched_shapes_kept() { + let mut s = session(); + let (a, b) = two(&mut s); + let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0, c.0], "points": [[25, 50], [125, 50]], "fill": "#00ff00"})).unwrap(); + let out = ids(&r); + assert_eq!(out.len(), 2, "one merged + the untouched square"); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert!((area(&s, merged) - 15000.0).abs() < 1.0); + assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + let sq = out.iter().copied().find(|i| *i != merged).unwrap(); + assert_eq!(node(&s, sq).geometric_bounds().unwrap(), Rect::new(300.0, 300.0, 350.0, 350.0)); + assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4); +} + +#[test] +fn shape_builder_miss_is_an_error_and_undo_restores() { + let mut s = session(); + let (a, b) = two(&mut s); + let undo_before = s.doc().unwrap().history.undo.len(); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[500, 500]]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some()); + assert!(s.execute("shapeBuilder.merge", &json!({"points": []})).is_err()); + assert!(s.execute("shapeBuilder.merge", &json!({})).is_err()); +} + +#[test] +fn shape_builder_regions_query() { + let mut s = session(); + two(&mut s); + let r = s.execute("shapeBuilder.regions", &json!({})).unwrap(); + let regs = r["regions"].as_array().unwrap(); + assert_eq!(regs.len(), 3); + let total: f64 = regs.iter().map(|x| x["area"].as_f64().unwrap()).sum(); + assert!((total - 15000.0).abs() < 1.0); + assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2)); +} + +#[test] +fn shape_builder_tool_drives_the_command() { + let mut s = session(); + two(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + assert_eq!(s.tool_id(), "shapeBuilder"); + s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "hover highlight"); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap(); + assert_eq!(layer_children(&s), 2); + assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge"); + // Alt-click deletes a region. + let alt = Mods { alt: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0).with_mods(alt), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0).with_mods(alt), v).unwrap(); + assert_eq!(layer_children(&s), 1); +} + +// ---------- Live Paint ---------- + +fn live(s: &mut Session) -> NodeId { + two(s); + let r = s.execute("livePaint.make", &json!({})).unwrap(); + assert_eq!(r["faces"], 3); + assert!(r["edges"].as_u64().unwrap() >= 4); + NodeId(r["id"].as_u64().unwrap()) +} + +#[test] +fn live_paint_make_builds_group_with_hidden_sources() { + let mut s = session(); + let g = live(&mut s); + let n = node(&s, g); + assert!(drawcraft_tools::builder::is_live_paint(&n)); + let src = drawcraft_tools::builder::sources(&n).unwrap(); + assert!(!src.visible); + assert_eq!(src.children().unwrap().len(), 2); + assert_eq!(layer_children(&s), 1); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + // Faces start with the fill of the top-most covering object. + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + assert_eq!(info["faces"].as_array().unwrap().len(), 3); + assert_eq!(info["sources"], 2); +} + +#[test] +fn live_paint_fill_assigns_the_face_under_the_point() { + let mut s = session(); + let g = live(&mut s); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap(); + let face = node(&s, NodeId(r["face"].as_u64().unwrap())); + let bb = face.geometric_bounds().unwrap(); + assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}"); + assert_eq!(face.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + // The other faces are untouched. + let gn = node(&s, g); + let green = drawcraft_tools::builder::faces(&gn).iter().filter(|f| f.appearance.fill_paint() == face.appearance.fill_paint()).count(); + assert_eq!(green, 1); + // Default paint = current fill; a miss is an error. + s.paint.fill = Paint::solid(Color::rgb(1.0, 1.0, 0.0)); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [10, 10]})).unwrap(); + assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), s.paint.fill); + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [400, 400]})).is_err()); +} + +#[test] +fn live_paint_fill_on_plain_paths_makes_the_group() { + let mut s = session(); + let (a, b) = two(&mut s); + let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [125, 50], "color": "#123456"})).unwrap(); + let g = NodeId(r["group"].as_u64().unwrap()); + assert!(drawcraft_tools::builder::is_live_paint(&node(&s, g))); + assert_eq!(layer_children(&s), 1); + // One undo step removes both the make and the fill. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_children(&s), 2); +} + +#[test] +fn live_paint_stroke_edge() { + let mut s = session(); + let g = live(&mut s); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3})).unwrap(); + let e = node(&s, NodeId(r["edge"].as_u64().unwrap())); + assert_eq!(e.appearance.stroke_width(), 3.0); + assert!(e.appearance.fill_paint().is_none()); + let bb = e.geometric_bounds().unwrap(); + assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "the vertical edge at x=100: {bb:?}"); + assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err()); +} + +#[test] +fn live_paint_release_and_expand() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap(); + let r = s.execute("livePaint.expand", &json!({})).unwrap(); + assert_eq!(ids(&r), vec![g]); + let n = node(&s, g); + assert!(!drawcraft_tools::builder::is_live_paint(&n)); + // Unpainted face and the hidden sources are gone: 2 faces + stroked edges. + let ch = n.children().unwrap(); + assert!(ch.iter().all(|c| c.visible)); + assert_eq!(ch.iter().filter(|c| c.name.as_deref() == Some("Face")).count(), 2); + + let mut s = session(); + let (a, b) = two(&mut s); + live_group_only(&mut s); + let r = s.execute("livePaint.release", &json!({})).unwrap(); + assert_eq!(ids(&r), vec![a, b]); + assert!((area(&s, a) - 10000.0).abs() < 1.0); +} + +fn live_group_only(s: &mut Session) -> NodeId { + let r = s.execute("livePaint.make", &json!({})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +#[test] +fn live_paint_merge_keeps_paint_and_adds_faces() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap(); + let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0); + s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap(); + let r = s.execute("livePaint.merge", &json!({})).unwrap(); + assert_eq!(NodeId(r["id"].as_u64().unwrap()), g); + assert!(r["faces"].as_u64().unwrap() > 3); + let gn = node(&s, g); + let f = drawcraft_tools::builder::face_at(&gn, drawcraft_geom::Point::new(25.0, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + let src = drawcraft_tools::builder::sources(&gn).unwrap(); + assert_eq!(src.children().unwrap().len(), 3, "the rectangle joined the sources"); + assert_eq!(s.doc().unwrap().doc.parent_of(c), Some(src.id)); + assert_eq!(layer_children(&s), 1); +} + +#[test] +fn live_paint_bucket_tool_fills_face() { + let mut s = session(); + let g = live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "red face highlight"); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); + let gn = node(&s, g); + let f = drawcraft_tools::builder::face_at(&gn, drawcraft_geom::Point::new(125.0, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill); + // Live Paint Selection selects the face. + s.select_tool("livePaintSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); +} + +// ---------- Image Trace ---------- + +/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2. +fn image_doc(s: &mut Session) -> NodeId { + let r = drawcraft_trace::Raster::from_fn(100, 100, |x, y| { + let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0); + if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }); + add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0)) +} + +fn add_image(s: &mut Session, r: &drawcraft_trace::Raster, xf: Affine) -> NodeId { + let png = r.encode_png(); + let (w, h) = (r.width, r.height); + let id = s + .edit("img", |d, sel| { + d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + let id = d.alloc_id(); + let l = d.default_layer(); + d.insert(l, 0, Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None })))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + id +} + +fn traced_paths(n: &Node) -> Vec> { + n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect() +} + +#[test] +fn image_trace_make_places_paths_over_the_image() { + let mut s = session(); + let img = image_doc(&mut s); + let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap(); + assert_eq!(r["paths"], 1); + let g = NodeId(r["id"].as_u64().unwrap()); + let n = node(&s, g); + assert_eq!(n.name.as_deref(), Some("Image Trace")); + let first = &n.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Image(_)) && !first.visible && first.id == img); + let paths = traced_paths(&n); + assert_eq!(paths.len(), 1); + let (p, rule) = outline(&paths[0]); + let want = std::f64::consts::PI * 60.0 * 60.0; // r = 30 px × 2 + let got = drawcraft_pathops::area(&p, rule); + assert!((got - want).abs() / want < 0.03, "{got} vs {want}"); + let bb = p.bounds().unwrap(); + assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5, "{bb:?}"); + assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0))); +} + +#[test] +fn image_trace_release_and_expand() { + let mut s = session(); + let img = image_doc(&mut s); + s.execute("imageTrace.make", &json!({"preset": "Black and White Logo"})).unwrap(); + let r = s.execute("imageTrace.release", &json!({})).unwrap(); + assert_eq!(ids(&r), vec![img]); + assert!(node(&s, img).visible); + assert_eq!(layer_children(&s), 1); + + let r = s.execute("imageTrace.make", &json!({"id": img.0})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + // Re-trace an existing Image Trace group with other parameters. + let r2 = s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true}})).unwrap(); + assert_eq!(r2["paths"], 1); + let g = NodeId(r2["id"].as_u64().unwrap()); + s.execute("imageTrace.expand", &json!({})).unwrap(); + let n = node(&s, g); + assert!(n.name.is_none()); + assert!(n.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_)))); + assert!(s.execute("imageTrace.release", &json!({})).is_err()); +} + +#[test] +fn image_trace_make_and_expand_and_color_presets() { + let mut s = session(); + let r = drawcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, true) => [230, 20, 20, 255], + (false, true) => [20, 200, 30, 255], + (true, false) => [20, 30, 220, 255], + (false, false) => [250, 230, 10, 255], + }); + add_image(&mut s, &r, Affine::IDENTITY); + let res = s.execute("imageTrace.makeAndExpand", &json!({"preset": "16 Colors", "params": {"colors": 4}})).unwrap(); + assert_eq!(res["colors"], 4); + assert_eq!(res["paths"], 4); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + assert!(g.name.is_none()); + assert_eq!(g.children().unwrap().len(), 4); + assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err()); + assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); +} + +#[test] +fn image_trace_presets_query_and_errors() { + let mut s = session(); + let r = s.execute("imageTrace.presets", &json!({})).unwrap(); + let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(names.len(), 12); + assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray")); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image"); + assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]); + assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]); +} diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 376d158d2..839181591 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -133,7 +133,11 @@ 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( + "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(); diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs new file mode 100644 index 000000000..00b7cf6f3 --- /dev/null +++ b/crates/engine/src/tests_live.rs @@ -0,0 +1,702 @@ +//! Live blends, envelopes and gradient meshes, driven through `Session::execute`. + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind}; +use drawcraft_doc::{Node, NodeKind}; +use drawcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn fill(s: &mut Session, id: NodeId, hex: &str) { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap(); +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-3 +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Two 10×10 rects (black at x=0, white at x=100), blended with `params`. +fn blend(s: &mut Session, params: Value) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 10.0, 10.0); + let b = rect(s, 100.0, 0.0, 10.0, 10.0); + fill(s, a, "#000000"); + fill(s, b, "#ffffff"); + sel(s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", ¶ms).unwrap()); + (a, b, g) +} + +fn steps_of(s: &Session, id: NodeId) -> Vec { + live::expand_live(&node(s, id)) +} + +fn render(s: &Session, region: Rect) -> drawcraft_render::Rendered { + drawcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 1.0, true) +} + +fn all_anchors(nodes: &[Node]) -> Vec { + let mut v = vec![]; + for n in nodes { + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + v.extend(p.anchors().map(|(_, _, a)| a.p)); + } + }); + } + v +} + +// ---------- Blend ---------- + +#[test] +fn blend_make_is_live_with_keys_as_children() { + let mut s = session(); + let (a, b, g) = blend(&mut s, json!({"steps": 3})); + let n = node(&s, g); + assert!(matches!(n.kind, NodeKind::Blend { .. })); + let ch: Vec = n.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(ch, vec![a, b]); + assert_eq!(n.kind_label(), "Blend"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); +} + +#[test] +fn blend_specified_steps_count() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 3})); + assert_eq!(steps_of(&s, g).len(), 5); +} + +#[test] +fn blend_mid_step_interpolates_geometry_and_colour() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 30.0, 30.0); + fill(&mut s, a, "#000000"); + fill(&mut s, b, "#ffffff"); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + let st = steps_of(&s, g); + let mb = st[2].geometric_bounds().unwrap(); + assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}"); + let c = st[2].appearance.fill_paint().color().unwrap().to_rgb(); + assert!((c[0] - 0.5).abs() < 1e-3); +} + +#[test] +fn blend_smooth_colour_steps_from_colour_distance() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({})); + let n = steps_of(&s, g).len() - 2; + assert_eq!(n, 128, "black→white: one step per two levels"); + // Consecutive steps differ by at most ~2 levels. + let st = steps_of(&s, g); + for w in st.windows(2) { + let x = w[0].appearance.fill_paint().color().unwrap().to_rgb()[0]; + let y = w[1].appearance.fill_paint().color().unwrap().to_rgb()[0]; + assert!((x - y).abs() <= 2.5 / 255.0); + } +} + +#[test] +fn blend_smooth_colour_same_colours_uses_distance() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"smooth": true})).unwrap()); + assert_eq!(steps_of(&s, g).len() - 2, 20); +} + +#[test] +fn blend_specified_distance() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"distance": 10})); + assert_eq!(steps_of(&s, g).len() - 2, 9); + let st = steps_of(&s, g); + let d = st[1].geometric_bounds().unwrap().x0 - st[0].geometric_bounds().unwrap().x0; + assert!(close(d, 10.0), "{d}"); +} + +#[test] +fn blend_interpolates_opacity_and_stroke_weight() { + let mut s = session(); + let (a, b, g) = blend(&mut s, json!({"steps": 1})); + s.edit("t", |d, _| { + d.node_mut(a).unwrap().opacity = 0.2; + d.node_mut(b).unwrap().appearance.stroke_mut().unwrap().width = 9.0; + Ok(()) + }) + .unwrap(); + // Keys live inside the blend now; edit them there. + let _ = g; + let st = steps_of(&s, g); + assert!((st[1].opacity - 0.6).abs() < 1e-5); + assert!(close(st[1].appearance.stroke_width(), 5.0)); +} + +#[test] +fn blend_resamples_different_anchor_counts() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + let b = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 25, "radius1": 25, "radius2": 10, "points": 5})).unwrap()); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + let st = steps_of(&s, g); + let n = st[1].path_data().unwrap().subpaths[0].anchors.len(); + assert_eq!(n, 10, "rect resampled to the star's 10 anchors"); + let b1 = st[1].geometric_bounds().unwrap(); + assert!(b1.x0 > 50.0 && b1.x1 < 325.0, "{b1:?}"); +} + +#[test] +fn blend_three_keys() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0); + let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b, c]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap()); + let st = steps_of(&s, g); + assert_eq!(st.len(), 3 + 2 * 4); + assert_eq!(st[5].id, b); +} + +#[test] +fn blend_options_change_spacing_and_orientation() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 3})); + s.execute("object.blend.options", &json!({"spacing": "steps", "value": 1, "orientation": "path"})).unwrap(); + assert_eq!(steps_of(&s, g).len(), 3); + match &node(&s, g).kind { + NodeKind::Blend { spec, .. } => { + assert_eq!(spec.spacing, BlendSpacing::Steps(1)); + assert_eq!(spec.orientation, BlendOrientation::AlignToPath); + } + _ => panic!(), + } + assert!(s.execute("object.blend.options", &json!({"steps": 0})).is_err()); + assert!(s.execute("object.blend.options", &json!({"spacing": "bogus"})).is_err()); +} + +#[test] +fn blend_release_restores_keys() { + let mut s = session(); + let (a, b, g) = blend(&mut s, json!({"steps": 3})); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 2); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none() && d.node(a).is_some() && d.node(b).is_some()); + assert!(s.execute("object.blend.release", &json!({})).is_ok() || s.execute("object.blend.release", &json!({})).is_err()); +} + +#[test] +fn blend_expand_makes_group_with_unique_ids() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 3})); + s.execute("object.blend.expand", &json!({})).unwrap(); + let n = node(&s, g); + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(n.children().unwrap().len(), 5); + let mut ids = vec![]; + s.doc().unwrap().doc.walk(|n| ids.push(n.id)); + let len = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), len); + assert!(!ids.contains(&NodeId(0))); + assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend"); +} + +#[test] +fn blend_make_undo_is_one_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let n = undo_len(&s); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert_eq!(d.parent_of(a), d.parent_of(b)); + assert!(d.node(a).is_some()); +} + +#[test] +fn blend_replace_spine_follows_path() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 5})); + // Half circle of radius 100 around (200, 300). + let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap()); + sel(&mut s, &[g, arc]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(arc).is_none(), "the path is consumed"); + let st = steps_of(&s, g); + assert_eq!(st.len(), 7); + for n in &st { + let c = n.geometric_bounds().unwrap().center(); + let r = c.distance(Point::new(200.0, 300.0)); + assert!((r - 100.0).abs() < 1.5, "{c:?} r={r}"); + } + let first = st[0].geometric_bounds().unwrap().center(); + assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}"); +} + +#[test] +fn blend_align_to_path_rotates_steps() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 4.0); + let b = rect(&mut s, 100.0, 0.0, 40.0, 4.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 1, "orientation": "path"})).unwrap()); + let spine = id_of(&s.execute("path.create", &json!({"d": "M300 100 L300 300"})).unwrap()); + sel(&mut s, &[g, spine]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let st = steps_of(&s, g); + let b1 = st[1].geometric_bounds().unwrap(); + assert!(b1.height() > b1.width(), "vertical spine turns the bars upright: {b1:?}"); + s.execute("object.blend.options", &json!({"orientation": "page"})).unwrap(); + let b1 = steps_of(&s, g)[1].geometric_bounds().unwrap(); + assert!(b1.width() > b1.height()); +} + +#[test] +fn blend_reverse_spine_and_front_to_back() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 2})); + s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + assert!(close(node(&s, a).geometric_bounds().unwrap().x0, 100.0)); +} + +#[test] +fn blend_renders_intermediate_steps() { + let mut s = session(); + let (_, _, _) = blend(&mut s, json!({"steps": 9})); + let img = render(&s, Rect::new(0.0, 0.0, 120.0, 20.0)); + let p = img.pixel(55, 5); + assert!(p[0] < 200 && p[0] > 60, "a grey step is drawn: {p:?}"); +} + +// ---------- Envelope ---------- + +fn text_rect(s: &mut Session) -> NodeId { + let a = rect(s, 100.0, 100.0, 200.0, 100.0); + fill(s, a, "#ff0000"); + a +} + +#[test] +fn envelope_warp_make_and_content_stays_inside_bounds() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); + let n = node(&s, e); + assert!(matches!(n.kind, NodeKind::Envelope { .. })); + assert_eq!(n.children().unwrap()[0].id, a); + let b = n.geometric_bounds().unwrap().inflate(0.5, 0.5); + let out = live::expand_live(&n); + let pts = all_anchors(&out); + assert!(pts.len() > 4, "curves are subdivided"); + assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}"); + // It really is bent: the sides moved down. + assert!(pts.iter().any(|p| p.y > 201.0)); +} + +#[test] +fn envelope_warp_zero_bend_is_identity() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 0})).unwrap()); + for p in all_anchors(&live::expand_live(&node(&s, e))) { + assert!(p.x >= 99.99 && p.x <= 300.01 && p.y >= 99.99 && p.y <= 200.01, "{p:?}"); + } + assert!(s.execute("object.envelope.makeWithWarp", &json!({"style": "nope"})).is_err()); +} + +#[test] +fn envelope_mesh_identity_then_point_moves_content() { + let mut s = session(); + let a = text_rect(&mut s); + let inner = rect(&mut s, 190.0, 140.0, 20.0, 20.0); + sel(&mut s, &[a, inner]); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let b0 = Rect::from_points(Point::new(100.0, 100.0), Point::new(300.0, 200.0)); + for p in all_anchors(&live::expand_live(&node(&s, e))) { + assert!(b0.inflate(1e-6, 1e-6).contains(p), "{p:?}"); + } + // Centre point of the 3×3 grid is index 4 (200, 150); pull it up by 40. + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 200, "y": 110})).unwrap(); + let out = live::expand_live(&node(&s, e)); + let pts = all_anchors(&out); + // Corners stay; content near the centre moves up. + assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6)); + let inner_top = all_anchors(&out[1..]).iter().map(|p| p.y).fold(f64::INFINITY, f64::min); + assert!(inner_top < 125.0, "{inner_top}"); +} + +#[test] +fn envelope_top_object_maps_corners() { + let mut s = session(); + let a = text_rect(&mut s); + // A trapezoid on top. + let t = id_of(&s.execute("path.create", &json!({"d": "M400 100 L500 100 L550 250 L350 250 Z"})).unwrap()); + sel(&mut s, &[a, t]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + assert!(s.doc().unwrap().doc.node(t).is_none(), "top object consumed"); + let pts = all_anchors(&live::expand_live(&node(&s, e))); + for c in [Point::new(400.0, 100.0), Point::new(500.0, 100.0), Point::new(550.0, 250.0), Point::new(350.0, 250.0)] { + assert!(pts.iter().any(|p| p.distance(c) < 0.5), "corner {c:?} missing"); + } + let b = node(&s, e).geometric_bounds().unwrap(); + assert!(close(b.x0, 350.0) && close(b.y1, 250.0)); +} + +#[test] +fn envelope_release_and_expand() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap()); + let r = s.execute("object.envelope.release", &json!({})).unwrap(); + assert_eq!(r["ids"][0].as_u64(), Some(a.0)); + assert!(s.doc().unwrap().doc.node(e).is_none()); + assert_eq!(node(&s, a).geometric_bounds(), Some(Rect::new(100.0, 100.0, 300.0, 200.0))); + s.execute("edit.undo", &json!({})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let g = node(&s, e); + assert!(matches!(g.kind, NodeKind::Group { .. })); + let p = g.children().unwrap()[0].path_data().unwrap().anchor_count(); + assert!(p > 4); +} + +#[test] +fn envelope_options_and_edit_contents() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); + s.execute("object.envelope.options", &json!({"fidelity": 90, "bend": -30})).unwrap(); + match &node(&s, e).kind { + NodeKind::Envelope { fidelity, kind: EnvelopeKind::Warp { bend, style, .. }, .. } => { + assert!(close(*fidelity, 90.0) && close(*bend, -30.0) && style == "arc"); + } + k => panic!("{k:?}"), + } + assert!(s.execute("object.envelope.options", &json!({"fidelity": 101})).is_err()); + let r = s.execute("object.envelope.editContents", &json!({})).unwrap(); + assert_eq!(r["editing"], true); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + let r = s.execute("object.envelope.editContents", &json!({})).unwrap(); + assert_eq!(r["editing"], false); + assert_eq!(s.doc().unwrap().selection.objects, vec![e]); +} + +#[test] +fn envelope_renders_distorted() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 60})).unwrap(); + let img = render(&s, Rect::new(0.0, 0.0, 400.0, 300.0)); + // Arch lifts the middle: red above the original top edge at the centre, not at the sides. + let mid = img.pixel(200, 85); + assert!(mid[0] > 200 && mid[1] < 60, "{mid:?}"); + let side = img.pixel(102, 85); + assert!(side[1] > 200, "{side:?}"); +} + +#[test] +fn envelope_transform_moves_mesh_points() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap()); + s.edit("move", |d, _| { + d.node_mut(e).unwrap().transform(drawcraft_geom::Affine::translate((10.0, 20.0)), false); + Ok(()) + }) + .unwrap(); + let b = node(&s, e).geometric_bounds().unwrap(); + assert_eq!(b, Rect::new(110.0, 120.0, 310.0, 220.0)); + let pts = all_anchors(&live::expand_live(&node(&s, e))); + assert!(pts.iter().any(|p| p.distance(Point::new(110.0, 120.0)) < 1e-6)); +} + +// ---------- Gradient mesh ---------- + +fn mesh_of(s: &Session, id: NodeId) -> drawcraft_doc::GradientMesh { + match &node(s, id).kind { + NodeKind::Mesh(m) => m.clone(), + k => panic!("{k:?}"), + } +} + +#[test] +fn mesh_create_flat() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + fill(&mut s, a, "#0000ff"); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 3, "cols": 2})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols, m.points.len()), (3, 2, 12)); + assert!(m.points.iter().all(|p| p.color.to_hex() == "#0000ff")); + assert_eq!(node(&s, a).geometric_bounds().map(|b| (b.x0.round(), b.x1.round())), Some((0.0, 100.0))); + assert_eq!(node(&s, a).kind_label(), "Mesh"); +} + +#[test] +fn mesh_to_center_highlight() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + fill(&mut s, a, "#ff0000"); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!(m.points[4].color.to_hex(), "#ffffff"); + assert_eq!(m.points[0].color.to_hex(), "#ff0000"); + assert!(close(m.points[4].p.x, 50.0) && close(m.points[4].p.y, 50.0)); + // Colour inside patch (0,0) at its far corner = the centre. + let (c, _) = m.color_at(0, 0, 1.0, 1.0); + assert_eq!(c.to_hex(), "#ffffff"); +} + +#[test] +fn mesh_renders_corner_and_centre_colours() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + fill(&mut s, a, "#ff0000"); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})).unwrap(); + let img = render(&s, Rect::new(0.0, 0.0, 100.0, 100.0)); + let c = img.pixel(50, 50); + assert!(c[0] > 240 && c[1] > 220 && c[2] > 220, "centre near white {c:?}"); + let k = img.pixel(2, 2); + assert!(k[0] > 240 && k[1] < 40, "corner red {k:?}"); + let e = img.pixel(25, 25); + assert!(e[1] > 40 && e[1] < 220, "in between {e:?}"); +} + +#[test] +fn mesh_point_colour_move_and_undo() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": 0.5})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!(m.points[3].color.to_hex(), "#00ff00"); + assert!((m.points[3].opacity - 0.5).abs() < 1e-6); + s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 3, "x": 120, "y": 130})).unwrap(); + assert_eq!(mesh_of(&s, a).points[3].p, Point::new(120.0, 130.0)); + s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 3, "x": 125, "y": 130, "handle": 1})).unwrap(); + assert_eq!(mesh_of(&s, a).points[3].handles[1], drawcraft_geom::Vec2::new(5.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(mesh_of(&s, a).points[3].p, Point::new(100.0, 100.0)); + assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err()); +} + +#[test] +fn mesh_add_line_and_delete_point() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 40, "y": 70, "color": "#123456"})).unwrap(); + let i = r["index"].as_u64().unwrap() as usize; + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (2, 2)); + assert!(m.points[i].p.distance(Point::new(40.0, 70.0)) < 0.5); + assert_eq!(m.points[i].color.to_hex(), "#123456"); + assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 400, "y": 70})).is_err()); + s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": i})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (1, 1)); + assert!(s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": 0})).is_err(), "corner lines can't go"); +} + +#[test] +fn mesh_create_at_point_single_undo() { + let mut s = session(); + let a = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + sel(&mut s, &[a]); + let n = undo_len(&s); + let r = s.execute("object.mesh.create", &json!({"at": [50, 40]})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (2, 2)); + let i = r["index"].as_u64().unwrap() as usize; + assert!(m.points[i].p.distance(Point::new(50.0, 40.0)) < 0.5); + // Mesh boundary follows the ellipse. + let b = node(&s, a).geometric_bounds().unwrap(); + assert!(b.x0 > -3.0 && b.x1 < 103.0, "{b:?}"); +} + +#[test] +fn mesh_expand_to_flat_pieces() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap(); + s.execute("object.mesh.expand", &json!({})).unwrap(); + let g = node(&s, a); + assert!(matches!(g.kind, NodeKind::Group { .. })); + assert_eq!(g.children().unwrap().len(), 2 * 64); + assert!(g.children().unwrap().iter().all(|c| c.id != NodeId(0))); +} + +#[test] +fn mesh_hit_test_inside() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let d = &s.doc().unwrap().doc; + let h = drawcraft_doc::hit::hit_test(d, Point::new(50.0, 30.0), drawcraft_doc::hit::HitOptions { tol: 1.0, outline: false, path_only: false }); + assert_eq!(h.map(|h| h.leaf), Some(a)); +} + +#[test] +fn blend_of_meshes_interpolates_mesh_points() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + let b = rect(&mut s, 200.0, 0.0, 50.0, 50.0); + fill(&mut s, a, "#000000"); + fill(&mut s, b, "#ffffff"); + sel(&mut s, &[a, b]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 1})).unwrap()); + let st = steps_of(&s, g); + match &st[1].kind { + NodeKind::Mesh(m) => { + assert!(close(m.points[0].p.x, 100.0)); + assert!((m.points[0].color.to_rgb()[0] - 0.5).abs() < 1e-3); + } + k => panic!("{k:?}"), + } + let img = render(&s, Rect::new(0.0, 0.0, 250.0, 50.0)); + assert!(img.pixel(125, 25)[3] == 255); +} + +// ---------- Persistence and export ---------- + +fn live_doc() -> Session { + let mut s = session(); + let (_, _, _) = blend(&mut s, json!({"steps": 2})); + let a = rect(&mut s, 100.0, 300.0, 100.0, 50.0); + sel(&mut s, &[a]); + s.execute("object.envelope.makeWithWarp", &json!({"style": "wave", "bend": 30})).unwrap(); + let m = rect(&mut s, 400.0, 300.0, 100.0, 100.0); + sel(&mut s, &[m]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "edge", "highlight": 50})).unwrap(); + s +} + +#[test] +fn live_objects_round_trip_through_native_format() { + let s = live_doc(); + let d = &s.doc().unwrap().doc; + let bytes = drawcraft_format::save(d, false); + let back = drawcraft_format::load(&bytes).unwrap(); + assert_eq!(&back.layers, &d.layers); + let mut kinds = vec![]; + back.walk(|n| kinds.push(n.kind_label())); + for k in ["Blend", "Envelope", "Mesh"] { + assert!(kinds.contains(&k), "{k}"); + } +} + +#[test] +fn svg_export_expands_live_objects() { + let s = live_doc(); + let svg = drawcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + // 4 blend objects + envelope content + 4 patches × 64 mesh pieces. + let paths = svg.matches("= 4 + 1 + 256, "{paths}"); + assert!(drawcraft_svg::import(&svg).is_ok()); +} + +#[test] +fn pdf_export_expands_live_objects() { + let s = live_doc(); + let pdf = drawcraft_pdf::export(&s.doc().unwrap().doc, &Default::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF")); +} + +#[test] +fn old_documents_still_load() { + let s = session(); + let d = &s.doc().unwrap().doc; + let back = drawcraft_format::load(&drawcraft_format::save(d, true)).unwrap(); + assert_eq!(back.layers.len(), d.layers.len()); +} + +#[test] +fn live_commands_have_menus_and_params() { + for id in [ + "object.blend.make", + "object.blend.replaceSpine", + "object.envelope.makeWithWarp", + "object.envelope.makeWithMesh", + "object.envelope.makeWithTopObject", + "object.envelope.release", + "object.envelope.expand", + "object.envelope.editContents", + "object.envelope.options", + "object.mesh.create", + ] { + let c = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}")); + assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}"); + } + for id in ["object.mesh.setPointColor", "object.mesh.movePoint", "object.mesh.addLine", "object.mesh.expand"] { + assert!(crate::cmd::find_command(id).is_some(), "{id}"); + } +} + +#[test] +fn disabled_without_live_selection() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + assert!(s.execute("object.blend.release", &json!({})).is_err()); + assert!(s.execute("object.envelope.release", &json!({})).is_err()); + assert!(s.execute("object.mesh.expand", &json!({})).is_err()); + assert!(s.execute("object.blend.make", &json!({})).is_err(), "needs two objects"); + let _ = Paint::solid(Color::BLACK); +} diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 7e23345e3..00586fc72 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -307,8 +307,8 @@ fn blend_make_release_options() { let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); assert_eq!(undo_len(&s), n + 1); let gn = node(&s, g); - assert_eq!(gn.name.as_deref(), Some("Blend")); - let ch = gn.children().unwrap().clone(); + assert!(matches!(gn.kind, NodeKind::Blend { .. }), "live blend"); + let ch = drawcraft_doc::live::expand_live(&gn); assert_eq!(ch.len(), 5); // Middle step: halfway in position, size and colour. let mid = &ch[2]; @@ -318,7 +318,7 @@ fn blend_make_release_options() { assert!((c[0] - 0.5).abs() < 1e-3); // Options: 1 step. s.execute("object.blend.options", &json!({"steps": 1})).unwrap(); - assert_eq!(node(&s, g).children().unwrap().len(), 3); + assert_eq!(drawcraft_doc::live::expand_live(&node(&s, g)).len(), 3); // Release: keys only, back in the layer. let r = s.execute("object.blend.release", &json!({})).unwrap(); assert_eq!(r["ids"].as_array().unwrap().len(), 2); @@ -338,7 +338,7 @@ fn blend_expand_and_reverse() { s.execute("object.blend.reverseSpine", &json!({})).unwrap(); assert!(close(bounds(&s, a).x0, 100.0)); s.execute("object.blend.expand", &json!({})).unwrap(); - assert!(node(&s, g).name.is_none()); + assert!(matches!(node(&s, g).kind, NodeKind::Group { .. })); assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend"); } diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs new file mode 100644 index 000000000..5db973c6d --- /dev/null +++ b/crates/engine/src/tests_textedit.rs @@ -0,0 +1,323 @@ +//! Rich text editing: range edit/style commands, the Type tools through the session, area and +//! on-path type from paths, Fit Headline, Convert To Area/Point Type. + +use drawcraft_doc::{NodeId, NodeKind, TextKind, TextObject}; +use drawcraft_geom::Point; +use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn text(s: &mut Session, t: &str) -> NodeId { + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn click(s: &mut Session, x: f64, y: f64) { + let v = ViewInfo::default(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } +} + +fn key(s: &mut Session, k: ToolKey, m: Mods) { + s.tool_key(k, m, ViewInfo::default()).unwrap(); +} + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn edit_range_replaces_and_undoes() { + let mut s = session(); + let id = text(&mut s, "Hello world"); + let r = s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 11, "insert": "there"})).unwrap(); + assert_eq!(r["caret"], json!(11)); + assert_eq!(obj(&s, id).plain_text(), "Hello there"); + // Out-of-range and reversed offsets are clamped. + s.execute("text.editRange", &json!({"id": id.0, "start": 99, "end": 5, "insert": "!"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello!"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello there"); + assert!(s.execute("text.editRange", &json!({"start": 0})).is_err(), "id required"); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("text.editRange", &json!({"id": rect["id"], "insert": "x"})).is_err(), "not text"); +} + +#[test] +fn set_range_style_splits_runs() { + let mut s = session(); + let id = text(&mut s, "Hello brave world"); + let w0 = obj(&s, id).cached_bounds.unwrap().width(); + let r = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 11, "size": 40, "fill": "#ff0000", "tracking": 50})).unwrap(); + assert_eq!(r["runs"], json!(3)); + let t = obj(&s, id); + assert_eq!(t.runs[1].text, "brave"); + assert_eq!((t.runs[0].style.size, t.runs[1].style.size, t.runs[2].style.size), (20.0, 40.0, 20.0)); + assert_eq!(t.runs[1].style.tracking, 50.0); + assert!(t.cached_bounds.unwrap().width() > w0 * 1.2, "bounds refreshed"); + // Kerning / scales / baseline / caps on another range; "auto" leading. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 5, "kerning": 20, "hScale": 80, "vScale": 120, "baselineShift": 3, "rotation": 370, "allCaps": true, "leading": "auto", "font": "Inter", "strokeWidth": 1})).unwrap(); + let st = obj(&s, id).runs[0].style.clone(); + assert_eq!((st.kerning, st.h_scale, st.v_scale, st.baseline_shift, st.all_caps), (Some(20.0), 80.0, 120.0, 3.0, true)); + assert!((st.rotation - 10.0).abs() < 1e-9); + assert_eq!(st.font_family, "Inter"); + // One undo step per command. + assert_eq!(undo_labels(&s).iter().filter(|l| *l == "Character").count(), 2); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0})).is_err(), "nothing to change"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); +} + +#[test] +fn set_range_style_defaults_to_all_text_and_get_range() { + let mut s = session(); + let id = text(&mut s, "abc"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap(); + assert_eq!(obj(&s, id).runs.len(), 1); + assert_eq!(obj(&s, id).runs[0].style.size, 30.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "size": 10})).unwrap(); + let r = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 2})).unwrap(); + assert_eq!(r["text"], json!("ab")); + assert_eq!(r["runs"].as_array().unwrap().len(), 2); + assert_eq!(r["length"], json!(3)); +} + +#[test] +fn type_tool_typing_is_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + s.tool_text(" world", v).unwrap(); + assert!(s.tool_wants_text()); + assert_eq!(obj(&s, id).plain_text(), "Hello world"); + key(&mut s, ToolKey::Left, Mods::default()); + assert!(!s.in_interaction(), "moving the caret commits"); + assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Typing")); + s.tool_text("!", v).unwrap(); + key(&mut s, ToolKey::Escape, Mods::default()); + assert_eq!(obj(&s, id).plain_text(), "Hello worl!d"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello world"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), ""); +} + +#[test] +fn type_tool_selection_replace_and_delete() { + let mut s = session(); + let v = ViewInfo::default(); + let id = text(&mut s, "Hello brave world"); + s.select_tool("type", v).unwrap(); + // Click at the start of the text to edit it. + let t = obj(&s, id); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + let (a, b) = drawcraft_text::caret_position(&lay, 6); + let p = t.xf * a.midpoint(b); + click(&mut s, p.x + 0.1, p.y); + assert!(s.tool_wants_text()); + assert_eq!(s.tool_options()["caret"], json!(6)); + let shift_word = Mods { shift: true, alt: true, ..Default::default() }; + key(&mut s, ToolKey::Right, shift_word); + assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(6), Some(11))); + s.tool_text("bold", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello bold world"); + key(&mut s, ToolKey::Home, Mods { shift: true, ..Default::default() }); + key(&mut s, ToolKey::Delete, Mods::default()); + assert_eq!(obj(&s, id).plain_text(), " world"); + key(&mut s, ToolKey::End, Mods::default()); + key(&mut s, ToolKey::Backspace, Mods { alt: true, ..Default::default() }); + assert_eq!(obj(&s, id).plain_text(), " "); +} + +#[test] +fn character_panel_flow_styles_the_selected_range_while_editing() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 50.0, 300.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Make this big", v).unwrap(); + // Select "this" (still inside the typing session). + s.set_tool_option("select", &json!({"start": 5, "end": 9})); + // What the panel does: end the typing session, then style the range. + s.set_tool_option("commitTyping", &json!(true)); + s.commit_interaction().unwrap(); + let o = s.tool_options(); + s.execute("text.setRangeStyle", &json!({"id": o["editing"], "start": o["start"], "end": o["end"], "size": 48})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.runs.len(), 3); + assert_eq!(t.runs[1].text, "this"); + assert_eq!(t.runs[1].style.size, 48.0); + // Typing continues in a new session, over the styled selection (keeps its style). + s.tool_text("THAT", v).unwrap(); + key(&mut s, ToolKey::Escape, Mods::default()); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Make THAT big"); + assert_eq!(t.runs[1].text, "THAT"); + assert_eq!(t.runs[1].style.size, 48.0); + let labels = undo_labels(&s); + assert_eq!(&labels[labels.len() - 3..], ["Typing", "Character", "Typing"]); +} + +#[test] +fn select_all_and_cut_like_ui() { + let mut s = session(); + let v = ViewInfo::default(); + let id = text(&mut s, "abc def"); + s.select_tool("type", v).unwrap(); + let t = obj(&s, id); + let p = t.xf * Point::new(2.0, -5.0); + click(&mut s, p.x, p.y); + s.set_tool_option("selectAll", &json!(true)); + let o = s.tool_options(); + let len = obj(&s, id).plain_text().len() as u64; + let r = s.execute("text.getRange", &json!({"id": o["editing"], "start": o["start"], "end": o["end"].as_u64().unwrap().min(len)})).unwrap(); + assert_eq!(r["text"], json!("abc def")); + s.set_tool_option("copy", &r["runs"]); + key(&mut s, ToolKey::Delete, Mods::default()); + assert_eq!(obj(&s, id).plain_text(), ""); + s.tool_text("abc def", v).unwrap(); + s.tool_text("abc def", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "abc defabc def"); +} + +#[test] +fn area_type_from_a_circle_keeps_glyphs_inside() { + let mut s = session(); + let e = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 240, "height": 240})).unwrap(); + let pid = e["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "area", "text": drawcraft_engine_placeholder()})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.node(NodeId(pid)).is_none(), "the path became the frame"); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() > 5); + let c = Point::new(220.0, 220.0); + for g in lay.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) { + let l = &lay.lines[g.line]; + for q in [Point::new(g.origin.x, l.baseline - l.ascent), Point::new(g.origin.x + g.advance, l.baseline + l.descent)] { + assert!((t.xf * q - c).hypot() <= 120.5, "{q:?}"); + } + } + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert!(s.execute("text.createInPath", &json!({"path": 9999})).is_err()); +} + +fn drawcraft_engine_placeholder() -> String { + "Type flows inside any closed shape, line by line, trimmed to the shape's edges at each band. ".repeat(4) +} + +#[test] +fn type_on_path_starts_where_clicked() { + let mut s = session(); + let r = s + .execute("shape.line", &json!({"x1": 100, "y1": 300, "x2": 500, "y2": 300})) + .or_else(|_| s.execute("path.create", &json!({"points": [[100, 300], [500, 300]], "closed": false}))); + let pid = r.unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "On the line", "at": [200, 310]})).unwrap(); + let t = obj(&s, NodeId(r["id"].as_u64().unwrap())); + let TextKind::OnPath { start, .. } = t.kind else { panic!() }; + assert!((start - 0.25).abs() < 0.01, "{start}"); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + let g0 = &lay.glyphs[0]; + assert!((g0.origin.y - 300.0).abs() < 1e-6 && (g0.origin.x - 200.0).abs() < 1.0, "{:?}", g0.origin); +} + +#[test] +fn type_on_path_tool_through_session() { + let mut s = session(); + let v = ViewInfo::default(); + let e = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + s.select_tool("typeOnPath", v).unwrap(); + assert_eq!(s.tool_id(), "typeOnPath"); + click(&mut s, 300.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + assert_ne!(id.0, e["id"].as_u64().unwrap()); + s.tool_text("Around", v).unwrap(); + key(&mut s, ToolKey::Escape, Mods::default()); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::OnPath { .. })); + assert_eq!(t.plain_text(), "Around"); +} + +#[test] +fn fit_headline_fills_the_frame_width() { + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 50, "y": 50, "text": "Headline\nBody copy follows here", "size": 24, "area": {"width": 400, "height": 200}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let r = s.execute("type.fitHeadline", &json!({"ids": [id.0]})).unwrap(); + assert!(r["tracking"][0].as_f64().unwrap() > 100.0, "{r}"); + let t = obj(&s, id); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + let l0 = &lay.lines[0]; + assert_eq!(&t.plain_text()[l0.start..l0.end], "Headline", "still one line"); + assert!((l0.x1 - l0.x0 - 400.0).abs() < 1.0, "{}", l0.x1 - l0.x0); + // The body keeps its tracking. + assert_eq!(t.runs.last().unwrap().style.tracking, 0.0); + let p = s.execute("text.create", &json!({"x": 1, "y": 1, "text": "point"})).unwrap(); + assert!(s.execute("type.fitHeadline", &json!({"ids": [p["id"]]})).is_err(), "area type only"); +} + +fn first_glyph_doc_pos(t: &TextObject) -> Point { + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + t.xf * lay.glyphs[0].origin +} + +#[test] +fn convert_area_point_keeps_text_in_place_for_every_alignment() { + for j in ["left", "center", "right"] { + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 100, "y": 100, "text": "Centered words wrap in a frame", "size": 18, "area": {"width": 150, "height": 200}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = first_glyph_doc_pos(&obj(&s, id)); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + let after = first_glyph_doc_pos(&t); + assert!((after - before).hypot() < 0.01, "{j}: {before:?} → {after:?}"); + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + let back = first_glyph_doc_pos(&t); + assert!((back - before).hypot() < 0.01, "{j} (area again): {before:?} → {back:?}"); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t); + assert!(!lay.overflow); + } +} + +#[test] +fn commands_are_listed_with_params() { + let s = session(); + let cmds: Vec = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect(); + for id in ["text.editRange", "text.setRangeStyle", "text.getRange", "text.createInPath", "type.fitHeadline"] { + assert!(cmds.iter().any(|c| c["id"] == id), "{id}"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 6392def53..f5b44716f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -376,6 +376,13 @@ impl Exporter<'_> { self.node(s, &art, page, true); } } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => { + let g = drawcraft_doc::live::expand_deep(n, None); + for c in g.children().into_iter().flatten() { + self.node(s, c, page, false); + } + } } for _ in 0..pushes { s.pop(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 68671e8dc..70b727a8a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -14,6 +14,7 @@ drawcraft-color = { workspace = true } drawcraft-doc = { workspace = true } drawcraft-text = { workspace = true } drawcraft-effects = { workspace = true } +drawcraft-brush = { workspace = true } vello_cpu = { workspace = true } # Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. vello_common = { version = "0.2", default-features = false } diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs new file mode 100644 index 000000000..e3d0bf3d4 --- /dev/null +++ b/crates/render/src/brush_fx.rs @@ -0,0 +1,198 @@ +//! Brushed strokes and symbol-instance staining in the renderer. +//! +//! A stroke with `brush: Some(name)` is painted as the filled art `drawcraft_brush::stroke_pieces` +//! generates. Pieces are cached per (node, stroke item) and validated by the node's path, the +//! stroke layer and the brush definition (an `Arc` from the per-frame parsed library), so edits to +//! any of them regenerate the art and nothing else does. + +use std::collections::HashMap; +use std::sync::Arc; + +use drawcraft_brush::Brush; +use drawcraft_doc::{AppearanceItem, Document, Node, StrokeLayer}; +use drawcraft_geom::{BezPath, Rect}; +use vello_cpu::RenderContext; + +use crate::{Frame, Renderer, blend_mode}; + +struct Entry { + path: BezPath, + stroke: StrokeLayer, + brush: Arc, + pieces: Arc>, + stamp: u64, +} + +/// Parsed brush library and generated brush art. +#[derive(Default)] +pub(crate) struct BrushCache { + /// The document value the library was parsed from (`None` = defaults). + src: Option>, + /// (frame stamp, document address) the library was last validated for. + checked: (u64, usize), + lib: HashMap>, + entries: HashMap<(u64, usize), Entry>, +} + +impl BrushCache { + fn library(&mut self, doc: &Document, stamp: u64) { + let key = (stamp, doc as *const Document as usize); + if self.checked == key { + return; + } + self.checked = key; + let cur = doc.unknown.get(drawcraft_brush::DOC_KEY); + if self.src.as_ref().is_some_and(|s| s.as_ref() == cur) { + return; + } + self.src = Some(cur.cloned()); + self.lib = drawcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect(); + } +} + +/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs). +pub(crate) fn cull_bounds(n: &Node) -> Option { + let b = crate::fx::cull_bounds(n)?; + if !drawcraft_brush::has_brush(n) { + return Some(b); + } + let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + let pad = 16.0 * w + 8.0; + Some(b.inflate(pad, pad)) +} + +/// A symbol's art for instance `inst`, stained by the instance's fill (Symbol Stainer: the fill +/// colour mixed in by the fill layer's opacity). +pub(crate) fn instance_art(art: &Node, inst: &Node) -> Node { + let mut a = art.clone(); + if let Some(f) = inst.appearance.fill() + && f.visible + && let Some(c) = f.paint.color() + { + drawcraft_brush::tint_node(&mut a, &c, f.opacity); + } + a +} + +impl Renderer { + /// Paint stroke `st` of `n` (outline `bp`) with its brush. Returns false when the brush is + /// unknown (the caller then draws a plain stroke). + pub(crate) fn draw_brush(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, st: &StrokeLayer) -> bool { + let Some(name) = st.brush.as_deref() else { return false }; + self.brushes.library(f.doc, self.stamp); + let Some(brush) = self.brushes.lib.get(name).cloned() else { return false }; + let idx = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))).unwrap_or(usize::MAX); + let key = (n.id.0, idx); + let stamp = self.stamp; + let pieces = match self.brushes.entries.get_mut(&key) { + Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => { + e.stamp = stamp; + e.pieces.clone() + } + _ => { + let pieces = Arc::new(drawcraft_brush::stroke_pieces(&brush, bp, st)); + if self.brushes.entries.len() > 2048 { + self.brushes.entries.retain(|_, e| stamp.saturating_sub(e.stamp) <= 3); + } + self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone(), stamp }); + pieces + } + }; + let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(drawcraft_geom::Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None); + } + for p in pieces.iter() { + self.draw_node(ctx, f, p, true); + } + if layered { + ctx.pop_layer(); + } + true + } +} + +#[cfg(test)] +mod tests { + use drawcraft_color::{Color, Paint}; + use drawcraft_doc::{Appearance, Document, Node}; + use drawcraft_geom::{Affine, Rect, shapes}; + + use crate::{RenderOptions, Rendered, Renderer}; + + fn doc_with_line(brush: Option<&str>, width: f64) -> Document { + let mut d = Document::new(200.0, 100.0); + let id = d.alloc_id(); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), width); + ap.stroke_mut().unwrap().brush = brush.map(str::to_string); + let n = Node::path(id, shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + } + + fn render(r: &mut Renderer, d: &Document) -> Rendered { + r.render(d, 200, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) + } + + fn dark(p: [u8; 4]) -> bool { + (p[0] as u32 + p[1] as u32 + p[2] as u32) < 200 + } + + #[test] + fn calligraphic_brush_paints_wider_than_the_stroke() { + let mut r = Renderer::new(); + let plain = render(&mut r, &doc_with_line(None, 1.0)); + assert!(!dark(plain.pixel(100, 53))); + let d = doc_with_line(Some("10 pt. Oval"), 1.0); + let img = render(&mut r, &d); + assert!(dark(img.pixel(100, 50)) && dark(img.pixel(100, 52)), "brush art is painted: {:?}", img.pixel(100, 52)); + assert!(!dark(img.pixel(100, 60))); + // Second frame hits the cache and paints the same. + let again = render(&mut r, &d); + assert_eq!(again.pixels, img.pixels); + } + + #[test] + fn unknown_brush_falls_back_to_plain_stroke_and_scatter_renders() { + let mut r = Renderer::new(); + let img = render(&mut r, &doc_with_line(Some("No Such Brush"), 2.0)); + assert!(dark(img.pixel(100, 50))); + let img = render(&mut r, &doc_with_line(Some("Dots"), 1.0)); + let dark_px = (20..180).filter(|x| dark(img.pixel(*x, 50))).count(); + assert!(dark_px > 20 && dark_px < 150, "dots leave gaps: {dark_px}"); + } + + #[test] + fn brush_definition_edits_rerender() { + let mut r = Renderer::new(); + let mut d = doc_with_line(Some("3 pt. Round"), 1.0); + let a = render(&mut r, &d); + let mut lib = drawcraft_brush::library(&d); + if let drawcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind { + c.size = 20.0; + } + drawcraft_brush::store(&mut d, &lib); + let b = render(&mut r, &d); + assert!(!dark(a.pixel(100, 58)) && dark(b.pixel(100, 58))); + } + + #[test] + fn stained_symbol_instance() { + let mut d = Document::new(100.0, 100.0); + let art = Node::path( + drawcraft_doc::NodeId(0), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + d.symbols.push(drawcraft_doc::Symbol { name: "S".into(), art: std::sync::Arc::new(art) }); + let id = d.alloc_id(); + let mut inst = Node::new(id, drawcraft_doc::NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 50.0)) }); + inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let l = d.layers[0].id; + d.insert(Some(l), 0, inst).unwrap(); + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(img.pixel(50, 50), [255, 0, 0, 255]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4c3d5075..4c633b381 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,7 +6,9 @@ //! gradients, images and text (via `drawcraft-text` glyph outlines). #![forbid(unsafe_code)] +mod brush_fx; mod fx; +mod live; mod paint; use std::collections::HashMap; @@ -19,6 +21,7 @@ use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{Pixmap, RenderContext, Resources}; pub use drawcraft_effects as effects; +pub use live::expand_live; pub use vello_cpu; /// Rendering options. @@ -132,6 +135,10 @@ pub struct Renderer { images: HashMap>, /// Statistics of the last frame. pub stats: FrameStats, + /// Brush art per brushed stroke. + brushes: brush_fx::BrushCache, + /// Evaluated blends/envelopes and tessellated meshes. + live: live::LiveCache, } #[derive(Clone, Copy, Debug, Default)] @@ -170,6 +177,8 @@ impl Renderer { resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default(), + brushes: Default::default(), + live: live::LiveCache::default(), } } @@ -245,7 +254,7 @@ impl Renderer { /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option { let n = doc.node(id)?; - let b = fx::cull_bounds(n)?; + let b = brush_fx::cull_bounds(n)?; let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let w = size.clamp(1, u16::MAX as u32) as u16; @@ -277,7 +286,7 @@ impl Renderer { } acc } - _ => fx::cull_bounds(a), + _ => brush_fx::cull_bounds(a), }; self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); b @@ -343,6 +352,9 @@ impl Renderer { self.stats.drawn += 1; return; } + if drawcraft_doc::live::is_live(a) { + return self.draw_live(ctx, f, a); + } self.draw_node(ctx, f, a, true); } @@ -416,11 +428,12 @@ impl Renderer { NodeKind::Image(im) => self.draw_image(ctx, f, im), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { - let mut art = (*sym.art).clone(); + let mut art = brush_fx::instance_art(&sym.art, n); art.transform(*xf, false); self.draw_node(ctx, f, &art, true); } } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => self.draw_live_node(ctx, f, n), } self.stats.drawn += 1; for _ in 0..layers { @@ -471,6 +484,9 @@ impl Renderer { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } + if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) { + continue; + } self.draw_stroke(ctx, f, bp, rule, st, bounds); } } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs new file mode 100644 index 000000000..dfbb5c96d --- /dev/null +++ b/crates/render/src/live.rs @@ -0,0 +1,186 @@ +//! Live objects in the renderer. +//! +//! Blends and envelopes are evaluated by `drawcraft_doc::live` and the resulting objects cached +//! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache +//! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello +//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is +//! grown by half a device pixel so neighbours overlap and no antialiasing seams show. + +use std::collections::HashMap; +use std::sync::Arc; + +use drawcraft_doc::live::{self, GradientMesh, MeshQuad}; +use drawcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use drawcraft_geom::{Affine, BezPath, PathData, Point}; +use vello_cpu::RenderContext; +use vello_cpu::peniko; + +use crate::{Frame, Renderer, blend_mode, text_geom}; + +type Expanded = Arc>>; +type MeshEntry = (Arc, Arc>, u64); + +/// Per-renderer cache of evaluated live objects. +#[derive(Default)] +pub(crate) struct LiveCache { + expanded: HashMap, Expanded, u64)>, + meshes: HashMap<(usize, usize), MeshEntry>, + stamp: u64, +} + +impl LiveCache { + fn tick(&mut self, stamp: u64) { + self.stamp = stamp; + if self.expanded.len() > 256 { + self.expanded.retain(|_, e| stamp - e.2 <= 3); + } + if self.meshes.len() > 256 { + self.meshes.retain(|_, e| stamp - e.2 <= 3); + } + } +} + +/// Text converted to one outline path (for envelopes): glyphs in document space, painted with the +/// first run's fill/stroke. +pub(crate) fn outline_text(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let g = text_geom(t); + let mut bp = g.all.clone(); + bp.apply_affine(t.xf); + let mut out = Node::path(n.id, PathData::from_bezpath(&bp), n.appearance.clone()); + if out.appearance.items.is_empty() + && let Some(r) = t.runs.first() + { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(r.style.fill.clone()))); + if !r.style.stroke.is_none() && r.style.stroke_width > 0.0 { + ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(r.style.stroke.clone(), r.style.stroke_width))); + } + out.appearance = ap; + } + out.opacity = n.opacity; + out.blend = n.blend; + Some(out) +} + +/// Evaluate a live node one level (blend steps / envelope result, text outlined; mesh → flat +/// pieces). Non-live nodes return themselves. +pub fn expand_live(n: &Node) -> Vec { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + live::expand_live_with(n, Some(hook)) +} + +/// Subdivisions per patch for a patch about `size_px` device pixels across. +fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize { + let per = bounds_px / (m.rows.max(m.cols).max(1) as f64); + ((per / 5.0).ceil() as usize).clamp(2, 32) +} + +impl Renderer { + fn live_expanded(&mut self, a: &Arc, cache: bool) -> Expanded { + let key = Arc::as_ptr(a) as usize; + if cache + && let Some(e) = self.live.expanded.get_mut(&key) + && Arc::ptr_eq(&e.0, a) + { + e.2 = self.live.stamp; + return e.1.clone(); + } + let v: Expanded = Arc::new(expand_live(a).into_iter().map(Arc::new).collect()); + if cache { + self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp)); + } + v + } + + fn mesh_quads(&mut self, a: &Arc, m: &GradientMesh, level: usize, cache: bool) -> Arc> { + let key = (Arc::as_ptr(a) as usize, level); + if cache + && let Some(e) = self.live.meshes.get_mut(&key) + && Arc::ptr_eq(&e.0, a) + { + e.2 = self.live.stamp; + return e.1.clone(); + } + let q = Arc::new(m.quads(level)); + if cache { + self.live.meshes.insert(key, (a.clone(), q.clone(), self.live.stamp)); + } + q + } + + /// Draw a live node reached through the tree walk (handles its transparency group). + pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + let stamp = self.stamp; + self.live.tick(stamp); + let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != drawcraft_color::BlendMode::Normal || a.isolate); + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None); + } + self.draw_live_body(ctx, f, a, true); + if layered { + ctx.pop_layer(); + } + self.stats.drawn += 1; + } + + /// Draw the evaluated content of a live node (no transparency group). + pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { + match &a.kind { + NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + let items = self.live_expanded(a, cache); + for c in items.iter() { + self.draw_arc(ctx, f, c); + } + } + _ => {} + } + } + + fn draw_mesh(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, m: &GradientMesh, cache: bool) { + if !m.is_valid() { + return; + } + if f.opts.outline { + let bp = m.lines().to_bezpath(); + self.hairline(ctx, f, &bp, [0, 0, 0, 255]); + return; + } + let Some(b) = m.bounds() else { return }; + let level = mesh_level(m, b.width().max(b.height()) / f.px.max(1e-12)); + let quads = self.mesh_quads(a, m, level, cache); + ctx.set_transform(f.view); + ctx.set_fill_rule(peniko::Fill::NonZero); + let grow = f.px * 0.5; + let alpha = self.alpha; + for q in quads.iter() { + if q.opacity <= 0.0 { + continue; + } + let c = Point::new((q.pts[0].x + q.pts[1].x + q.pts[2].x + q.pts[3].x) / 4.0, (q.pts[0].y + q.pts[1].y + q.pts[2].y + q.pts[3].y) / 4.0); + let mut bp = BezPath::new(); + for (i, p) in q.pts.iter().enumerate() { + let d = *p - c; + let len = d.hypot(); + let p2 = if len > 1e-12 { *p + d * (grow / len) } else { *p }; + if i == 0 { + bp.move_to(p2); + } else { + bp.line_to(p2); + } + } + bp.close_path(); + let [r, g, bl, al] = q.color.to_rgba8(q.opacity * alpha); + ctx.set_paint(peniko::Color::from_rgba8(r, g, bl, al)); + ctx.fill_path(&bp); + } + } + + /// Draw a live node reached without its `Arc` (thumbnails, clip fallbacks): uncached. + pub(crate) fn draw_live_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { + let a = Arc::new(n.clone()); + self.draw_live_body(ctx, f, &a, false); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 09daaa2ca..084f92c6c 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -486,6 +486,10 @@ impl Writer<'_> { out.push((self.path_d(&p, self.xf), FillRule::NonZero)); } NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {} + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => { + let g = drawcraft_doc::live::expand_deep(n, None); + self.clip_shapes(&g, out); + } } } @@ -580,6 +584,11 @@ impl Writer<'_> { self.line(""); self.xf = saved; } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => { + let g = drawcraft_doc::live::expand_deep(n, None); + self.node(&g); + } } } diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index 29308a863..03b594f83 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -33,7 +33,13 @@ struct Node { /// `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> { +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![]); diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index 0a3752ec2..f3b53d68e 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -206,15 +206,27 @@ pub fn next_word(s: &str, i: usize) -> usize { /// 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 (next, prev) = (s[i..].chars().next(), s[..i].chars().next_back()); + // At a word's end, the word wins over the following space or punctuation. + let (c, at) = match (next, prev) { + (Some(n), Some(p)) if !is_word_char(n) && is_word_char(p) => (p, i - p.len_utf8()), + (Some(n), _) => (n, i), + (None, Some(p)) => (p, i - p.len_utf8()), + (None, None) => 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(); + return at..at + 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 { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 696ee91bc..faf833c03 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -48,6 +48,10 @@ pub struct FontFace { pub(crate) ascent: f64, /// Descender in font units (positive = down). pub(crate) descent: f64, + /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 + /// values). + pub(crate) cap_height: f64, + pub(crate) x_height: f64, pub(crate) shaper: harfrust::ShaperData, } @@ -206,6 +210,8 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Opt upem: m.units_per_em.max(1) as f64, ascent: m.ascent as f64, descent: -(m.descent as f64), + cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), + x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), shaper, bytes, index, @@ -495,7 +501,8 @@ impl FontDb { 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())); + 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; } diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 06b190380..0b44114b2 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -14,7 +14,13 @@ fn is_vowel(c: char) -> bool { /// 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')) + 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). diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 771251f49..f2acfbdeb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,9 +5,11 @@ use std::ops::Range; use drawcraft_doc::{CharStyle, Justify, ParaStyle, TextKind, TextObject}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2}; +use crate::composer::{Breakpoint, compose}; use crate::fontdb::FontDb; -use crate::shape::{SGlyph, shape_range, style_metrics}; -use crate::{LineInfo, PositionedGlyph, TextLayout}; +use crate::hyphen::hyphen_points; +use crate::shape::{SGlyph, cap_x_heights, hyphen_glyph, shape_range, style_metrics}; +use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, TextLayout}; const EPS: f64 = 1e-6; @@ -16,6 +18,7 @@ struct Ctx<'a> { text: &'a str, runs: Vec<(Range, &'a CharStyle)>, default: CharStyle, + opts: &'a LayoutOptions, out: TextLayout, } @@ -27,26 +30,35 @@ impl Ctx<'_> { fn shape_para(&self, r: Range) -> Vec { let mut v = Vec::with_capacity(r.len()); - shape_range(self.db, self.text, r, &self.runs, &mut v); + shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v); v } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { - let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); let src = self.db.outline(&g.face, g.gid); - let outline = if src.elements().is_empty() { + let outline = if src.elements().is_empty() || g.is_soft_hyphen() { BezPath::new() } else { - let mut p = (*src).clone(); - p.apply_affine(pre * local); + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + let m = pre * local; + let mut p = BezPath::with_capacity(src.elements().len()); + for el in src.elements() { + p.push(m * *el); + } p }; self.out.glyphs.push(PositionedGlyph { outline, run: g.run, byte: g.byte, origin, advance, len: g.len, angle, line, font_id: g.face.id() }); } } -/// Lay out a text object into text-space glyph outlines. +/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { + layout_with(db, t, &LayoutOptions::default()) +} + +/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, +/// composer, OpenType features). +pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); let mut off = 0; @@ -63,12 +75,13 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { } } paras.push(s..text.len()); - let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), out: TextLayout::default() }; + let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() }; match &t.kind { TextKind::Point => flow(&mut cx, ¶s, &t.para, None), TextKind::Area { frame } => { - let region = Region::new(&frame.to_bezpath()); - flow(&mut cx, ¶s, &t.para, Some(®ion)); + let regions = Region::cells(&frame.to_bezpath(), opts); + cx.out.frames = regions.iter().map(|r| r.cell).collect(); + flow(&mut cx, ¶s, &t.para, Some(®ions)); } TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed()), } @@ -92,14 +105,19 @@ fn finish_bounds(out: &mut TextLayout) { out.bounds = b.unwrap_or_default(); } -/// A flattened area-type frame. +/// One cell of a flattened area-type frame (the whole frame, or one row/column of it). struct Region { - bbox: Rect, + /// The cell (frame bounds, or a grid cell of them). + cell: Rect, + /// Inset applied inside the frame edges. + inset: f64, polys: Vec>, + /// The frame is its own bounding rectangle (spans need no polygon intersection). + rect: bool, } impl Region { - fn new(path: &BezPath) -> Self { + fn cells(path: &BezPath, opts: &LayoutOptions) -> Vec { let mut polys: Vec> = Vec::new(); kurbo::flatten(path, 0.1, |el| match el { PathEl::MoveTo(p) => polys.push(vec![p]), @@ -111,7 +129,33 @@ impl Region { _ => {} }); polys.retain(|p| p.len() >= 3); - Self { bbox: path.bounding_box(), polys } + let bbox = path.bounding_box(); + let rect = polys.len() == 1 && { + let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() * 0.5; + (area - bbox.area()).abs() <= bbox.area() * 1e-6 + 1e-9 + }; + let (rows, cols) = (opts.rows.max(1), opts.columns.max(1)); + let gutter = opts.gutter.max(0.0); + let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0); + let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0); + let mut out = Vec::with_capacity(rows * cols); + // Text flows down each column, then across (Illustrator's default "by columns"). + for c in 0..cols { + for r in 0..rows { + let x0 = bbox.x0 + c as f64 * (cw + gutter); + let y0 = bbox.y0 + r as f64 * (rh + gutter); + let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; + out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect }); + } + } + out + } + + fn top(&self) -> f64 { + self.cell.y0 + self.inset + } + fn bottom(&self) -> f64 { + self.cell.y1 - self.inset } /// Inside intervals (even-odd) of the horizontal line at `y`. @@ -130,35 +174,126 @@ impl Region { xs.chunks_exact(2).map(|c| (c[0], c[1])).collect() } - /// Widest horizontal span inside the frame over the band `top..bottom`. + /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> { - if self.polys.is_empty() { - return (self.bbox.width() > 0.0).then_some((self.bbox.x0, self.bbox.x1)); + let clip = |(a, b): (f64, f64)| { + let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); + (b > a).then_some((a, b)) + }; + if self.polys.is_empty() || self.rect { + return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip); } - let clamp = |y: f64| y.clamp(self.bbox.y0 + 1e-4, self.bbox.y1 - 1e-4); - let mid = self.intervals(clamp((top + bottom) * 0.5)); - let others = [self.intervals(clamp(top)), self.intervals(clamp(bottom))]; + let fb = self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let clamp = |y: f64| y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4); + // Sample the band densely enough for curved frames (circles, blobs). + let samples = 5; + let mut rows: Vec> = + (0..samples).map(|k| self.intervals(clamp(top + (bottom - top) * k as f64 / (samples - 1) as f64))).collect(); + let mid = rows.swap_remove(samples / 2); mid.into_iter() .filter_map(|(mut a, mut b)| { - for o in &others { + for o in &rows { let best = o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?; a = best.0; b = best.1; } - (b > a).then_some((a, b)) + clip((a, b)) }) .max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) } } -/// Greedy break: returns the end glyph index for a line starting at `i` of the given width. -fn break_line(g: &[SGlyph], i: usize, width: f64) -> usize { +/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Metrics { + asc: f64, + desc: f64, + lead: f64, + cap: f64, + xh: f64, +} + +impl Metrics { + fn of(g: &SGlyph) -> Self { + Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh } + } + fn max(g: &[SGlyph]) -> Option { + let mut it = g.iter(); + let first = Self::of(it.next()?); + Some(it.fold(first, |m, g| Self { + asc: m.asc.max(g.ascent), + desc: m.desc.max(g.descent), + lead: m.lead.max(g.leading), + cap: m.cap.max(g.cap), + xh: m.xh.max(g.xh), + })) + } + /// Distance from the frame top to the first baseline. + fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 { + let v = match fb { + FirstBaseline::Ascent => self.asc, + FirstBaseline::CapHeight => self.cap, + FirstBaseline::XHeight => self.xh, + FirstBaseline::Leading => self.lead, + FirstBaseline::Fixed => 0.0, + }; + v.max(min) + } +} + +/// Where the next line goes: the region being filled and the previous baseline in it. +#[derive(Clone)] +struct Pen<'r> { + regions: Option<&'r [Region]>, + ri: usize, + prev: Option, + pending: f64, + fb: FirstBaseline, + fb_min: f64, +} + +impl Pen<'_> { + /// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` = + /// the frame is full (overflow). + fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64)> { + let Some(regions) = self.regions else { + let b = self.prev.map_or(0.0, |b| b + est.lead + self.pending); + return Some((b, f64::NEG_INFINITY, f64::INFINITY)); + }; + loop { + let r = regions.get(self.ri)?; + let mut baseline = match self.prev { + None => r.top() + est.first_baseline(self.fb, self.fb_min), + Some(b) => b + est.lead + self.pending, + }; + loop { + if baseline + est.desc > r.bottom() + 0.01 { + break; + } + match r.span(baseline - est.asc, baseline + est.desc) { + Some((a, b)) if b - a - ind_l - ind_r > est.asc.max(1.0) => return Some((baseline, a, b)), + _ => baseline += est.lead.max(1.0), + } + } + // Next row/column. + self.ri += 1; + self.prev = None; + self.pending = 0.0; + } + } + fn bottom(&self) -> f64 { + self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom()) + } +} + +/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. +fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -> (usize, bool) { if !width.is_finite() { - return g.len(); + return (g.len(), false); } let mut x = 0.0; - let mut last_break = None; + let mut last_break: Option<(usize, bool)> = None; let mut j = i; while j < g.len() { let gl = &g[j]; @@ -167,80 +302,200 @@ fn break_line(g: &[SGlyph], i: usize, width: f64) -> usize { } x += gl.adv; if gl.break_after() { - last_break = Some(j + 1); + if gl.is_soft_hyphen() { + if x + hyphen_glyph(gl).adv <= width + EPS { + last_break = Some((j + 1, true)); + } + } else { + last_break = Some((j + 1, false)); + } } j += 1; } if j >= g.len() { - return g.len(); + return (g.len(), false); + } + // Hyphenate the word that overflows. + let word_start = last_break.map_or(i, |b| b.0); + if hyphenate && g[j].is_letter() { + let mut we = j; + while we < g.len() && !g[we].is_space() && !g[we].break_after() { + we += 1; + } + let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); + let pts = hyphen_breaks(text, g, word_start, we); + for &k in pts.iter().rev() { + if k <= j && k > i { + let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; + if w <= width + EPS { + return (k, true); + } + } + } } // Don't separate a cluster's glyphs. - let mut end = match last_break { - Some(b) if b > i => b, - _ => j, + let (mut end, hy) = match last_break { + Some(b) if b.0 > i => b, + _ => (j, false), }; while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte { end -= 1; } - end + (end, hy) } -fn max_metrics(g: &[SGlyph]) -> Option<(f64, f64, f64)> { - (!g.is_empty()).then(|| g.iter().fold((0.0f64, 0.0f64, 0.0f64), |m, g| (m.0.max(g.ascent), m.1.max(g.descent), m.2.max(g.leading)))) +/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go. +fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec { + // Strip leading/trailing punctuation (quotes, commas). + let (mut a, mut b) = (ws, we); + while a < b && !g[a].is_letter() { + a += 1; + } + while b > a && !g[b - 1].is_letter() { + b -= 1; + } + if b <= a || g[a..b].iter().any(|g| !g.is_letter()) { + return vec![]; + } + let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len); + let Some(word) = text.get(s..e) else { return vec![] }; + let offs: Vec = word.char_indices().map(|(o, _)| s + o).collect(); + hyphen_points(word) + .into_iter() + .filter_map(|ci| { + let byte = *offs.get(ci)?; + // Only at cluster starts (not inside a ligature). + (a + 1..b).find(|&k| g[k].byte == byte && g[k - 1].byte != byte) + }) + .collect() } -fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, region: Option<&Region>) { - let mut prev_baseline: Option = None; - let mut pending_space = 0.0; +/// Break candidates for the every-line composer. +fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec { + let mut v = vec![]; + let mut ws = 0; + for (j, gl) in g.iter().enumerate() { + let end_of_word = gl.is_space() || gl.break_after(); + if end_of_word { + if hyphenate && j > ws { + for k in hyphen_breaks(text, g, ws, j) { + v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); + } + } + let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; + if j + 1 < g.len() && g[j + 1].byte != gl.byte { + v.push(Breakpoint { end: j + 1, hyphen: hy }); + } + ws = j + 1; + } + } + if hyphenate && g.len() > ws { + for k in hyphen_breaks(text, g, ws, g.len()) { + v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); + } + } + v.sort_by_key(|b| b.end); + v.dedup_by_key(|b| b.end); + v +} + +/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform +/// line metrics); `None` falls back to the greedy single-line composer. +fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option> { + let justified = !matches!(para.justify, Justify::Left | Justify::Center | Justify::Right); + if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 { + return None; + } + let m = Metrics::of(&sg[0]); + if sg.iter().any(|g| Metrics::of(g) != m) { + return None; + } + // Line widths are independent of the breaks when every line has the same metrics. + let total: f64 = sg.iter().map(|g| g.adv).sum(); + let mut sim = pen.clone(); + let mut widths = vec![]; + let mut acc = 0.0; + while widths.len() < sg.len() { + let first = widths.is_empty(); + let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + let Some((b, x0, x1)) = sim.place(m, ind_l, para.right_indent) else { break }; + let w = x1 - x0 - ind_l - para.right_indent; + widths.push(w); + acc += w.max(1.0); + sim.prev = Some(b); + sim.pending = 0.0; + if acc > total * 1.6 + 4.0 * w.max(1.0) { + break; + } + } + let last = *widths.last()?; + let width = |k: usize| widths.get(k).copied().unwrap_or(last); + let cands = candidates(cx.text, sg, para.hyphenate); + let justify_last = para.justify == Justify::JustifyAll; + compose(sg, &width, &cands, justify_last, 1.0).or_else(|| compose(sg, &width, &cands, justify_last, 4.0)) +} + +fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { + let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min }; 'paras: for (pi, pr) in paras.iter().enumerate() { let sg = cx.shape_para(pr.clone()); - let pm = style_metrics(cx.db, cx.style_at(pr.start)); + let pm = { + let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start)); + let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start)); + Metrics { asc, desc, lead, cap, xh } + }; if pi > 0 { - pending_space += para.space_before; + pen.pending += para.space_before; } let n = sg.len(); + let composed = compose_para(cx, &sg, para, &pen); + let mut li_para = 0; let mut i = 0; - let mut first_line = true; loop { - let est = if i < n { (sg[i].ascent, sg[i].descent, sg[i].leading) } else { pm }; - let first_ind = if first_line { para.first_line_indent } else { 0.0 }; - let ind_l = para.left_indent + first_ind; - let mut baseline = match (prev_baseline, region) { - (None, Some(r)) => r.bbox.y0 + est.0, - (None, None) => 0.0, - (Some(b), _) => b + est.2 + pending_space, + let est = if i < n { Metrics::of(&sg[i]) } else { pm }; + let first_line = li_para == 0; + let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 }; + // Place, break, then settle the baseline on the line's real metrics (moving on to the + // next row/column if it no longer fits). + let (baseline, x0, x1, end, hyph, m) = loop { + let first_in_region = pen.prev.is_none(); + let Some((mut baseline, x0, x1)) = pen.place(est, ind_l, para.right_indent) else { + cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; + break 'paras; + }; + let width = x1 - x0 - ind_l - para.right_indent; + let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { + Some(&(e, h)) if e > i => (e, h), + _ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate), + _ => (n, false), + }; + let m = Metrics::max(&sg[i..end]).unwrap_or(pm); + baseline += if pen.regions.is_none() && first_in_region { + 0.0 + } else if first_in_region { + m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) + } else { + m.lead - est.lead + }; + if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { + // Try the next row/column; overflow if there is none. + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + continue; + } + break (baseline, x0, x1, end, hyph, m); }; - let (x0, x1) = match region { - None => (f64::NEG_INFINITY, f64::INFINITY), - Some(r) => loop { - if baseline + est.1 > r.bbox.y1 + 0.01 { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - } - match r.span(baseline - est.0, baseline + est.1) { - Some((a, b)) if b - a - ind_l - para.right_indent > est.0.max(1.0) => break (a, b), - _ => baseline += est.2.max(1.0), - } - }, - }; - let (ax0, ax1) = (x0 + ind_l, x1 - para.right_indent); + let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) }; let width = ax1 - ax0; - let end = if i < n { break_line(&sg, i, width) } else { n }; - let m = max_metrics(&sg[i..end]).unwrap_or(pm); - baseline += if prev_baseline.is_none() { m.0 - est.0 } else { m.2 - est.2 }; - if let Some(r) = region - && baseline + m.1 > r.bbox.y1 + 0.01 - { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - } - pending_space = 0.0; + pen.pending = 0.0; let last_of_para = end >= n; let mut trimmed = end; while trimmed > i && sg[trimmed - 1].is_space() { trimmed -= 1; } - let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum(); + let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); + let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv); let (align, justify) = match para.justify { Justify::Left => (0, false), Justify::Center => (1, false), @@ -250,19 +505,21 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, region: Opti Justify::JustifyRight => (2, !last_of_para), Justify::JustifyAll => (0, true), }; - let justify = justify && region.is_some(); + let justify = justify && regions.is_some(); let (mut per_space, mut per_gap) = (0.0, 0.0); - if justify && width - w > EPS { - let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count(); + let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count(); + if justify && (width - w).abs() > EPS { if spaces > 0 { - per_space = (width - w) / spaces as f64; - } else if para.justify == Justify::JustifyAll && trimmed - i > 1 { + // Composed lines may shrink word spaces (never below zero). + per_space = + ((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min)); + } else if para.justify == Justify::JustifyAll && trimmed - i > 1 && width > w { per_gap = (width - w) / (trimmed - i - 1) as f64; } } let start_x = if justify { ax0 - } else if region.is_none() { + } else if regions.is_none() { match align { 0 => ind_l, 1 => (ind_l - para.right_indent - w) * 0.5, @@ -294,25 +551,32 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, region: Opti x_end = x; } } + if let Some(h) = &hyphen { + // The hyphen follows the last non-space glyph. + let hx = x_end; + cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); + x_end = hx + h.adv; + } cx.out.lines.push(LineInfo { baseline, x0: start_x, x1: x_end, - ascent: m.0, - descent: m.1, + ascent: m.asc, + descent: m.desc, start: if i < n { sg[i].byte } else { pr.start }, end: if last_of_para { pr.end } else { sg[end].byte }, glyph_start, glyph_end: cx.out.glyphs.len(), + avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, }); - prev_baseline = Some(baseline); - first_line = false; + pen.prev = Some(baseline); + li_para += 1; i = end; if i >= n { break; } } - pending_space += para.space_after; + pen.pending += para.space_after; } } @@ -357,7 +621,10 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &Be sg.extend(cx.shape_para(pr.clone())); } let ap = ArcPath::new(path); - let m = max_metrics(&sg).unwrap_or_else(|| style_metrics(cx.db, cx.style_at(0))); + let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { + let s = style_metrics(cx.db, cx.style_at(0)); + (s.0, s.1) + }); let text_len = cx.text.len(); if ap.segs.is_empty() { cx.out.overflow = !sg.is_empty(); @@ -371,6 +638,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &Be end: text_len, glyph_start: 0, glyph_end: 0, + avail: (0.0, 0.0), }); return; } @@ -411,5 +679,6 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &Be end: text_len, glyph_start: 0, glyph_end: cx.out.glyphs.len(), + avail: (0.0, ap.len), }); } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index c9aad2be1..46e878c3d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -9,14 +9,76 @@ //! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space. #![forbid(unsafe_code)] +mod composer; +pub mod edit; +mod features; mod fontdb; +pub mod hyphen; mod layout; mod shape; pub use drawcraft_doc::TextObject; +pub use features::OtFeatures; pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace}; use kurbo::{BezPath, Point, Rect, Vec2}; -pub use layout::layout; +pub use layout::{layout, layout_with}; + +/// Area Type Options "First Baseline" offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FirstBaseline { + /// The tallest glyph ascent touches the frame top (Illustrator's default). + #[default] + Ascent, + CapHeight, + XHeight, + /// The first line's leading. + Leading, + /// Exactly `first_baseline_min` below the top. + Fixed, +} + +/// Paragraph composer (Paragraph panel menu). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Composer { + /// Break each line as soon as it is full. + SingleLine, + /// Knuth–Plass total fit over the paragraph (justified area text only). + #[default] + EveryLine, +} + +/// Layout parameters that the document model doesn't store per object (Area Type Options, +/// composer, OpenType features). [`layout`] uses the defaults. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutOptions { + /// Area type rows and columns (text flows down each column, then across). + pub rows: usize, + pub columns: usize, + /// Gutter between rows/columns in points. + pub gutter: f64, + /// Inset from the frame edges in points. + pub inset: f64, + pub first_baseline: FirstBaseline, + /// Minimum first-baseline offset in points. + pub first_baseline_min: f64, + pub composer: Composer, + pub features: OtFeatures, +} + +impl Default for LayoutOptions { + fn default() -> Self { + Self { + rows: 1, + columns: 1, + gutter: 18.0, + inset: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + composer: Composer::EveryLine, + features: OtFeatures::default(), + } + } +} /// One laid-out glyph. #[derive(Clone, Debug)] @@ -60,6 +122,9 @@ pub struct LineInfo { /// Range of the line's glyphs in [`TextLayout::glyphs`]. pub glyph_start: usize, pub glyph_end: usize, + /// Horizontal span available to the line (frame span minus indents; the content extent for + /// point type). Used for hit testing across columns. + pub avail: (f64, f64), } #[derive(Clone, Debug, Default)] @@ -73,6 +138,8 @@ pub struct TextLayout { pub overflow: bool, /// True for type on a path (glyphs have individual `angle`s). pub on_path: bool, + /// Area type: the frame cells text flowed into (one per row/column). + pub frames: Vec, } impl TextLayout { @@ -128,22 +195,150 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { None => layout.lines.first().map_or(0, |l| l.start), }; } - let Some(li) = layout.lines.iter().position(|l| p.y < l.baseline + l.descent).or(layout.lines.len().checked_sub(1)) else { + // Nearest line band (vertical distance first, then distance to the line's column). + let dist = |l: &LineInfo| { + let dy = if p.y < l.baseline - l.ascent { + l.baseline - l.ascent - p.y + } else if p.y > l.baseline + l.descent { + p.y - l.baseline - l.descent + } else { + 0.0 + }; + let (a, b) = (l.avail.0.min(l.x0), l.avail.1.max(l.x1)); + let dx = if p.x < a { + a - p.x + } else if p.x > b { + p.x - b + } else { + 0.0 + }; + // Vertical distance first (to 1/100 pt), then the nearest column. + ((dy * 100.0).round(), dx) + }; + let Some(li) = (0..layout.lines.len()).min_by(|&a, &b| { + let (da, db) = (dist(&layout.lines[a]), dist(&layout.lines[b])); + da.0.total_cmp(&db.0).then(da.1.total_cmp(&db.1)) + }) else { return 0; }; - let line = &layout.lines[li]; + byte_in_line(layout, li, p.x) +} + +/// Byte offset nearest to `x` on line `li`. +pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { + let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - if let Some(g) = glyphs.iter().find(|g| p.x < g.origin.x + g.advance * 0.5) { + if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) { return g.byte; } - // Past the end: a soft-wrapped line ends before its last (usually space) glyph so the caret - // stays on this line. + line_end(layout, li) +} + +/// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the +/// caret stays on the line. +pub fn line_end(layout: &TextLayout, li: usize) -> usize { + let Some(line) = layout.lines.get(li) else { return 0 }; + let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end); - match glyphs.last() { - Some(g) if soft_wrap => g.byte, + match glyphs.iter().rev().find(|g| g.len > 0) { + Some(g) if soft_wrap && g.byte + g.len == line.end => g.byte, _ => line.end, } } +/// Caret byte at the start of the line containing `byte` (Home). +pub fn line_home(layout: &TextLayout, byte: usize) -> usize { + layout.lines.get(layout.line_of(byte)).map_or(0, |l| l.start) +} + +/// Caret byte at the end of the line containing `byte` (End). +pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize { + line_end(layout, layout.line_of(byte)) +} + +/// Caret x position (text space) of `byte` on line `li`. +fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { + let line = &layout.lines[li]; + let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; + if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) { + let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; + return g.origin.x + g.advance * frac; + } + glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance) +} + +/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x` +/// (text space). Moving past the first/last line goes to the start/end of the text. +pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64) -> usize { + if layout.lines.is_empty() { + return byte; + } + let li = layout.line_of(byte) as i64 + delta as i64; + if li < 0 { + return layout.lines[0].start; + } + if li as usize >= layout.lines.len() { + return layout.lines.last().map_or(byte, |l| l.end); + } + byte_in_line(layout, li as usize, goal_x) +} + +/// Nearest point on `path` to `p`: (fraction of the path's arc length 0..1, distance). Used to +/// start type on a path where the user clicked. +pub fn path_fraction_at(path: &BezPath, p: Point) -> (f64, f64) { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let mut total = 0.0; + let mut best = (0.0, f64::INFINITY); + for seg in path.segments() { + let len = seg.arclen(1e-3); + let n = seg.nearest(p, 1e-4); + let d = n.distance_sq.sqrt(); + if d < best.1 { + best = (total + seg.subsegment(0.0..n.t).arclen(1e-3), d); + } + total += len; + } + if total <= 0.0 { (0.0, best.1) } else { ((best.0 / total).clamp(0.0, 1.0), best.1) } +} + +/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`. +pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> { + let (a, b) = (a.min(b), a.max(b)); + let mut out = vec![]; + if a == b { + return out; + } + if layout.on_path { + for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b) { + let li = &layout.lines[0]; + let d = dir(g.angle); + let up = Vec2::new(d.y, -d.x); + let (p0, p1) = (g.origin, g.origin + d * g.advance); + out.push([p0 + up * li.ascent, p1 + up * li.ascent, p1 - up * li.descent, p0 - up * li.descent]); + } + return out; + } + for (li, l) in layout.lines.iter().enumerate() { + if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) { + continue; + } + let xa = if a <= l.start { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a) }; + let mut xb = x_in_line(layout, li, b.min(l.end)); + if b > l.end { + // The selection continues past the line: include the line break / trailing space. + let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance); + xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25; + } + if xb - xa <= 1e-9 { + continue; + } + let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent); + out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]); + } + out +} + #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_typo; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index b19ca123a..2722968f8 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -35,6 +35,9 @@ pub(crate) struct SGlyph { pub ascent: f64, pub descent: f64, pub leading: f64, + /// Cap height and x height in points. + pub cap: f64, + pub xh: f64, /// First source character of the cluster. pub ch: char, } @@ -86,6 +89,13 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } +/// Cap height and x height (points) of a style's resolved face. +pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { + let face = db.face(&st.font_family, &st.font_style); + let k = st.size / face.upem * st.v_scale / 100.0; + (face.cap_height * k, face.x_height * k) +} + /// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style. pub(crate) fn shape_range( db: &FontDb, @@ -149,6 +159,8 @@ fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, fa let ascent = face.ascent * k * vs; let descent = face.descent * k * vs; let leading = st.effective_leading(); + let cap = face.cap_height * k * vs; + let xh = face.x_height * k * vs; let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(seg.len()); // gid, cluster, xadv, xoff, yoff @@ -218,6 +230,8 @@ fn shape_segment(text: &str, range: Range, run: usize, st: &CharStyle, fa ascent, descent, leading, + cap, + xh, ch, }); } diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs new file mode 100644 index 000000000..ed2c1571c --- /dev/null +++ b/crates/text/src/tests_typo.rs @@ -0,0 +1,404 @@ +//! Typography tests: rich-text editing, caret navigation, selection, area flow, composer, +//! hyphenation, OpenType features, area type options. + +use super::*; +use drawcraft_doc::{CharStyle, Justify, TextKind, TextRun}; +use drawcraft_geom::PathData; +use kurbo::{Affine, Circle, Shape}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +fn run(text: &str, size: f64) -> TextRun { + TextRun { text: text.into(), style: style(size) } +} + +fn area_path(text: &str, st: CharStyle, frame: &BezPath, justify: Justify) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, st); + t.kind = TextKind::Area { frame: PathData::from_bezpath(frame) }; + t.xf = Affine::IDENTITY; + t.para.justify = justify; + t +} + +fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject { + area_path(text, st, &frame.to_path(0.1), justify) +} + +const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \ +The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing, and adjusting the space \ +between pairs of letters. Designers balance hyphenation against justification to produce even texture across the paragraph."; + +// ---------- run editing ---------- + +#[test] +fn range_style_splits_runs() { + let mut runs = vec![run("Hello brave world", 12.0)]; + edit::style_range(&mut runs, 6, 11, |s| s.size = 24.0); + assert_eq!(runs.len(), 3); + assert_eq!((runs[0].text.as_str(), runs[1].text.as_str(), runs[2].text.as_str()), ("Hello ", "brave", " world")); + assert_eq!((runs[0].style.size, runs[1].style.size, runs[2].style.size), (12.0, 24.0, 12.0)); + // Restyling back merges the runs again. + edit::style_range(&mut runs, 6, 11, |s| s.size = 12.0); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text, "Hello brave world"); +} + +#[test] +fn range_style_across_run_boundaries() { + let mut runs = vec![run("aaa", 10.0), run("bbb", 20.0), run("ccc", 30.0)]; + edit::style_range(&mut runs, 2, 7, |s| s.tracking = 50.0); + let sizes: Vec<(String, f64, f64)> = runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.tracking)).collect(); + assert_eq!( + sizes, + vec![("aa".into(), 10.0, 0.0), ("a".into(), 10.0, 50.0), ("bbb".into(), 20.0, 50.0), ("c".into(), 30.0, 50.0), ("cc".into(), 30.0, 0.0)] + ); + // Reversed and out-of-range offsets are clamped. + edit::style_range(&mut runs, 100, 0, |s| s.tracking = 0.0); + assert!(runs.iter().all(|r| r.style.tracking == 0.0)); + assert_eq!(runs.len(), 3); +} + +#[test] +fn replace_range_takes_replaced_style_and_normalizes() { + let mut runs = vec![run("Hello ", 12.0), run("brave", 24.0), run(" world", 12.0)]; + // Typing over the big word keeps its size. + let caret = edit::replace_range(&mut runs, 6, 11, "bold"); + assert_eq!(caret, 10); + assert_eq!(runs[1].text, "bold"); + assert_eq!(runs[1].style.size, 24.0); + // Inserting at a run end continues the preceding run. + let caret = edit::replace_range(&mut runs, 10, 10, "er"); + assert_eq!((caret, runs[1].text.as_str()), (12, "bolder")); + // Deleting a whole run merges its neighbours. + edit::replace_range(&mut runs, 6, 12, ""); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text, "Hello world"); + // Deleting everything keeps one empty run with the style. + edit::replace_range(&mut runs, 0, 100, ""); + assert_eq!(runs.len(), 1); + assert!(runs[0].text.is_empty()); + assert_eq!(runs[0].style.size, 12.0); +} + +#[test] +fn replace_respects_char_boundaries_and_slices() { + let mut runs = vec![run("héllo", 12.0)]; + // Offset 2 is inside 'é' (2 bytes): clamped back to the boundary. + edit::replace_range(&mut runs, 2, 3, "E"); + assert_eq!(runs[0].text, "hEllo"); + let mut runs = vec![run("ab", 10.0), run("cd", 20.0)]; + let s = edit::slice_runs(&runs, 1, 3); + assert_eq!(s.len(), 2); + assert_eq!((s[0].text.as_str(), s[1].text.as_str()), ("b", "c")); + let caret = edit::replace_range_styled(&mut runs, 4, 4, &s); + assert_eq!(caret, 6); + assert_eq!(runs.iter().map(|r| r.text.as_str()).collect::(), "abcdbc"); +} + +#[test] +fn word_and_paragraph_navigation() { + let s = "Hello, big world\nNext line"; + assert_eq!(edit::next_word(s, 0), 5); + assert_eq!(edit::next_word(s, 5), 10); + assert_eq!(edit::prev_word(s, 10), 7); + assert_eq!(edit::prev_word(s, 7), 0); + assert_eq!(edit::word_at(s, 8), 7..10); + assert_eq!(edit::word_at(s, 10), 7..10, "at a word end"); + assert_eq!(edit::word_at(s, 5), 0..5, "word end wins over punctuation"); + assert_eq!(edit::word_at("x ,y", 2), 2..3, "punctuation alone"); + assert_eq!(edit::word_at(s, 6), 6..7, "spaces"); + assert_eq!(edit::paragraph_at(s, 3), 0..16); + assert_eq!(edit::paragraph_at(s, 20), 17..26); + assert_eq!(edit::next_char(s, 0), 1); + assert_eq!(edit::prev_char("é", 2), 0); + assert_eq!(edit::next_word(s, s.len()), s.len()); +} + +// ---------- caret navigation / selection ---------- + +#[test] +fn caret_up_down_keeps_column() { + let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 200.0, 600.0), Justify::Left); + let l = layout(db(), &t); + assert!(l.lines.len() > 5); + let b = l.lines[2].start + 6; + let (top, _) = caret_position(&l, b); + let down = caret_vertical(&l, b, 1, top.x); + assert_eq!(l.line_of(down), 3); + let (t2, _) = caret_position(&l, down); + assert!((t2.x - top.x).abs() < 12.0, "{} vs {}", t2.x, top.x); + let up = caret_vertical(&l, down, -1, top.x); + assert_eq!(up, b); + assert_eq!(caret_vertical(&l, b, -10, top.x), 0); + assert_eq!(caret_vertical(&l, b, 100, top.x), COPY.len()); +} + +#[test] +fn home_end_on_wrapped_lines() { + let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 200.0, 600.0), Justify::Left); + let l = layout(db(), &t); + let b = l.lines[1].start + 3; + assert_eq!(line_home(&l, b), l.lines[1].start); + let e = line_end_of(&l, b); + // Before the trailing space: the caret stays on line 1. + assert_eq!(l.line_of(e), 1); + assert_eq!(©[e..e + 1], " "); +} + +#[test] +fn selection_quads_cover_lines() { + let t = TextObject::point(Point::ZERO, "One two\nThree four", style(20.0)); + let l = layout(db(), &t); + let q = selection_quads(&l, 4, 7); + assert_eq!(q.len(), 1); + let (a, _) = caret_position(&l, 4); + let (b, _) = caret_position(&l, 7); + assert!((q[0][0].x - a.x).abs() < 1e-6 && (q[0][1].x - b.x).abs() < 1e-6); + // Across the paragraph break: two quads. + let q = selection_quads(&l, 4, 12); + assert_eq!(q.len(), 2); + assert!(q[1][0].y > q[0][0].y); + assert!(selection_quads(&l, 3, 3).is_empty()); +} + +#[test] +fn hit_testing_picks_the_right_column() { + let text: String = COPY.repeat(3); + let opts = LayoutOptions { columns: 2, gutter: 20.0, ..Default::default() }; + let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 420.0, 200.0), Justify::Left); + let l = layout_with(db(), &t, &opts); + assert_eq!(l.frames.len(), 2); + let right: Vec<&LineInfo> = l.lines.iter().filter(|li| li.x0 >= 220.0 - 1e-6).collect(); + assert!(!right.is_empty(), "text flowed into the second column"); + let r0 = right[0]; + let b = hit_byte(&l, Point::new(r0.x0 + 1.0, r0.baseline)); + assert!(b >= r0.start && b <= r0.end, "{b} not in {}..{}", r0.start, r0.end); + for li in &l.lines { + assert!((li.x0 >= -1e-6 && li.x1 <= 200.0 + 1e-6) || (li.x0 >= 220.0 - 1e-6 && li.x1 <= 420.0 + 1e-6), "{li:?}"); + } +} + +// ---------- area flow ---------- + +#[test] +fn area_flow_inside_circle_keeps_glyphs_inside() { + let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1); + let t = area_path(COPY, style(11.0), &circle, Justify::Left); + let l = layout(db(), &t); + assert!(l.lines.len() > 8); + let inside = |p: Point| (p - Point::new(150.0, 150.0)).hypot() <= 120.0 + 0.5; + for g in &l.glyphs { + let li = &l.lines[g.line]; + for p in [ + Point::new(g.origin.x, li.baseline - li.ascent), + Point::new(g.origin.x + g.advance, li.baseline - li.ascent), + Point::new(g.origin.x, li.baseline + li.descent), + Point::new(g.origin.x + g.advance, li.baseline + li.descent), + ] { + if g.outline.elements().is_empty() { + continue; + } + assert!(inside(p), "glyph box corner {p:?} outside the circle (line {})", g.line); + } + } + // Lines near the middle are wider than lines near the top. + let w = |i: usize| l.lines[i].avail.1 - l.lines[i].avail.0; + assert!(w(l.lines.len() / 2) > w(0)); +} + +#[test] +fn justify_all_line_widths_equal() { + let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 240.0, 800.0), Justify::JustifyAll); + let l = layout(db(), &t); + assert!(l.lines.len() > 4); + for li in &l.lines { + assert!((li.x1 - li.x0 - 240.0).abs() < 1e-6, "{li:?}"); + } +} + +#[test] +fn composer_lines_never_exceed_frame() { + for (hy, composer) in [(false, Composer::EveryLine), (true, Composer::EveryLine), (false, Composer::SingleLine), (true, Composer::SingleLine)] { + let mut t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, 180.0, 2000.0), Justify::JustifyLeft); + t.para.hyphenate = hy; + let l = layout_with(db(), &t, &LayoutOptions { composer, ..Default::default() }); + assert!(l.lines.len() > 10); + for (i, li) in l.lines.iter().enumerate() { + assert!(li.x1 - li.x0 <= 180.0 + 1e-6, "{composer:?} hy={hy} line {i} too wide: {li:?}"); + assert!(li.x0 >= -1e-6); + if i + 1 < l.lines.len() { + assert!((li.x1 - li.x0 - 180.0).abs() < 1e-6, "{composer:?} hy={hy} justified line {i}: {li:?}"); + } + } + let clusters: std::collections::BTreeSet<(usize, usize)> = l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len)).collect(); + assert_eq!(clusters.iter().map(|c| c.1).sum::(), COPY.len() * 2, "every character placed"); + } +} + +/// Every-line composition spreads the spacing more evenly than greedy breaking. +#[test] +fn every_line_composer_is_more_even() { + let t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, 190.0, 2000.0), Justify::JustifyLeft); + let spread = |c: Composer| { + let l = layout_with(db(), &t, &LayoutOptions { composer: c, ..Default::default() }); + // Worst word-space stretch (space glyph advance) over all justified lines. + let mut worst: f64 = 0.0; + for li in &l.lines[..l.lines.len() - 1] { + for g in &l.glyphs[li.glyph_start..li.glyph_end] { + if COPY.as_bytes().get(g.byte % COPY.len()) == Some(&b' ') { + worst = worst.max(g.advance); + } + } + } + worst + }; + let (kp, greedy) = (spread(Composer::EveryLine), spread(Composer::SingleLine)); + assert!(kp <= greedy + 1e-6, "every-line worst space {kp} vs greedy {greedy}"); +} + +#[test] +fn hyphenation_rules() { + assert_eq!(hyphen::hyphenate_word("typography"), "typo-gra-phy"); + assert_eq!(hyphen::hyphenate_word("happen"), "hap-pen"); + assert_eq!(hyphen::hyphenate_word("hyphenation"), "hyphe-na-tion"); + assert!(hyphen::hyphen_points("short").is_empty()); + assert!(hyphen::hyphen_points("NASAJPL").is_empty(), "all caps stay whole"); + assert!(hyphen::hyphen_points("abc123def").is_empty()); + for w in ["justification", "arrangement", "paragraph", "legible"] { + let h = hyphen::hyphenate_word(w); + for part in h.split('-') { + assert!(part.chars().count() >= 2, "{h}"); + } + let pts = hyphen::hyphen_points(w); + assert!(pts.iter().all(|&p| p >= hyphen::MIN_BEFORE && p <= w.chars().count() - hyphen::MIN_AFTER), "{w}: {pts:?}"); + } +} + +#[test] +fn hyphenated_layout_adds_hyphens() { + let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything."; + let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left); + let plain = layout(db(), &t); + t.para.hyphenate = true; + let hy = layout(db(), &t); + let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count(); + assert_eq!(hyphens(&plain), 0); + assert!(hyphens(&hy) > 0, "some words hyphenated"); + for li in &hy.lines { + assert!(li.x1 - li.x0 <= 120.0 + 1e-6, "{li:?}"); + } + // Hyphenation fills lines better: no more lines than without it. + assert!(hy.lines.len() <= plain.lines.len()); + // The hyphen glyph is the last glyph of its line and has an outline. + for g in hy.glyphs.iter().filter(|g| g.len == 0) { + let li = &hy.lines[g.line]; + assert_eq!(li.glyph_end - 1, hy.glyphs.iter().position(|x| std::ptr::eq(x, g)).unwrap()); + assert!(!g.outline.elements().is_empty()); + } +} + +#[test] +fn soft_hyphen_is_invisible_mid_line() { + let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0))); + let sh = l.glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!(sh.outline.elements().is_empty()); + assert_eq!(sh.advance, 0.0); +} + +// ---------- area type options ---------- + +#[test] +fn inset_and_first_baseline_options() { + let frame = Rect::new(0.0, 0.0, 300.0, 300.0); + let t = area(COPY, style(12.0), frame, Justify::Left); + let base = layout(db(), &t); + let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, ..Default::default() }); + for li in &inset.lines { + assert!(li.x0 >= 10.0 - 1e-6 && li.x1 <= 290.0 + 1e-6); + } + assert!((inset.lines[0].baseline - base.lines[0].baseline - 10.0).abs() < 1e-6); + let lead = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::Leading, ..Default::default() }); + assert!((lead.lines[0].baseline - 14.4).abs() < 1e-6, "{}", lead.lines[0].baseline); + let fixed = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::Fixed, first_baseline_min: 30.0, ..Default::default() }); + assert!((fixed.lines[0].baseline - 30.0).abs() < 1e-6); + let cap = layout_with(db(), &t, &LayoutOptions { first_baseline: FirstBaseline::CapHeight, ..Default::default() }); + assert!(cap.lines[0].baseline < base.lines[0].baseline && cap.lines[0].baseline > 5.0); +} + +#[test] +fn rows_and_columns_flow_in_order() { + let text = COPY.repeat(2); + let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 400.0, 300.0), Justify::Left); + let l = layout_with(db(), &t, &LayoutOptions { rows: 2, columns: 2, gutter: 10.0, ..Default::default() }); + assert_eq!(l.frames.len(), 4); + // Text order: column 1 (top then bottom cell), then column 2. + let first_col2 = l.lines.iter().position(|li| li.x0 > 200.0).expect("reaches column 2"); + assert!(l.lines[..first_col2].iter().any(|li| li.baseline > 155.0), "fills the lower cell of column 1 first"); + for w in l.lines.windows(2) { + assert!(w[1].start >= w[0].start); + } +} + +// ---------- OpenType ---------- + +#[test] +fn opentype_ligature_switches() { + let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(20.0) }; + let t = TextObject::point(Point::ZERO, "fi", serif.clone()); + assert_eq!(layout(db(), &t).glyphs.len(), 1, "fi ligature by default"); + let off = LayoutOptions { features: OtFeatures { ligatures: false, ..Default::default() }, ..Default::default() }; + assert_eq!(layout_with(db(), &t, &off).glyphs.len(), 2); + // Tracking suppresses ligatures (as letterspaced type should). + let tracked = TextObject::point(Point::ZERO, "fi", CharStyle { tracking: 100.0, ..serif }); + assert_eq!(layout(db(), &tracked).glyphs.len(), 2); + let f = OtFeatures::from_tags(["dlig", "-liga", "smcp", "onum", "bogus"]); + assert!(f.discretionary_ligatures && !f.ligatures && f.small_caps && f.oldstyle_figures && !f.fractions); +} + +#[test] +fn face_charmap_for_glyphs_panel() { + let f = db().face("Source Sans 3", "Regular"); + let chars = f.chars(); + assert!(chars.len() > 200, "{}", chars.len()); + assert!(chars.windows(2).all(|w| w[0].0 < w[1].0)); + let a = f.glyph_for('A'); + assert!(a != 0 && chars.iter().any(|&(c, g)| c == 'A' && g == a)); + assert!(f.advance(a) > 0.0); + assert!(!db().outline(&f, a).elements().is_empty()); + assert_eq!(f.units_per_em(), 1000.0); +} + +#[test] +fn uncovered_characters_do_not_break_layout() { + // Private-use code point: no font covers it (system fallback is tried at most once and + // remembered); layout still places a .notdef-width glyph. + let l = layout(db(), &TextObject::point(Point::ZERO, "a\u{F8FF}\u{E000}b", style(12.0))); + assert_eq!(l.lines.len(), 1); + assert!(l.glyphs.len() >= 3); +} + +// ---------- performance ---------- + +#[test] +fn layout_10k_area_text_is_fast() { + let text: String = COPY.chars().cycle().take(10_000).collect(); + let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), Justify::JustifyLeft); + t.para.hyphenate = true; + let _ = layout(db(), &t); + let n = 5; + let start = std::time::Instant::now(); + for _ in 0..n { + let l = layout(db(), &t); + assert!(!l.overflow); + } + let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64; + eprintln!("layout of 10k chars (justified, hyphenated): {per:.3} ms"); + let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 }; + assert!(per < budget, "{per} ms"); +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 147e76b1c..fdec31d7d 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -12,6 +12,7 @@ workspace = true drawcraft-geom = { workspace = true } drawcraft-color = { workspace = true } drawcraft-doc = { workspace = true } +drawcraft-pathops = { workspace = true } drawcraft-text = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs new file mode 100644 index 000000000..3f6a5a97e --- /dev/null +++ b/crates/tools/src/builder.rs @@ -0,0 +1,723 @@ +//! Shape Builder (Shift+M), Live Paint Bucket (K) and Live Paint Selection (Shift+L). +//! +//! The tools only emit commands (`shapeBuilder.merge`, `livePaint.fill`, `livePaint.strokeEdge`, +//! `select.*`); the planar arrangement comes from `drawcraft_pathops::regions`. +//! +//! **Live Paint groups** are ordinary groups (no special node kind): the group's name starts with +//! [`LIVE_PAINT_PREFIX`]; its first child is a hidden group named [`SOURCES_NAME`] holding the +//! original paths (the planar map's inputs, kept for Release / Merge / re-computation), followed +//! by the face paths (named [`FACE_NAME`], fill only) and the edge paths (named [`EDGE_NAME`], +//! stroke only, open). Everything else in the document sees a plain group of paths, so rendering, +//! export, transforms and undo need no special cases. + +use std::sync::Arc; + +use drawcraft_doc::{Document, Node, NodeId, NodeKind}; +use drawcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _}; +use drawcraft_pathops as po; +use serde_json::{Value, json}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids}; + +/// Names starting with this mark a Live Paint group. +pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; +/// Default name of a new Live Paint group. +pub const LIVE_PAINT_NAME: &str = "Live Paint Group"; +/// The hidden child group holding the source paths. +pub const SOURCES_NAME: &str = "Live Paint Sources"; +pub const FACE_NAME: &str = "Face"; +pub const EDGE_NAME: &str = "Edge"; + +/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. +pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80]; +pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a]; + +/// Is `n` a Live Paint group? +pub fn is_live_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX)) +} + +fn named(n: &Node, name: &str) -> bool { + n.name.as_deref() == Some(name) +} + +/// Face children of a Live Paint group (paint order). +pub fn faces(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default() +} + +/// Edge children of a Live Paint group. +pub fn edges(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default() +} + +/// The hidden sources group of a Live Paint group. +pub fn sources(group: &Node) -> Option<&Arc> { + group.children()?.iter().find(|n| named(n, SOURCES_NAME)) +} + +/// Does the path (filled) contain `p`? +pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { + if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) { + return false; + } + let w = path.to_bezpath().winding(p); + match rule { + FillRule::EvenOdd => w % 2 != 0, + _ => w != 0, + } +} + +/// The face of `group` under `p` (front-most). +pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { + faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) +} + +/// The edge of `group` nearest to `p` within `tol`. +pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + let mut best: Option<(f64, &Arc)> = None; + for e in edges(group) { + if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && d <= tol + && best.is_none_or(|b| d < b.0) + { + best = Some((d, e)); + } + } + best.map(|b| b.1) +} + +/// The visible Live Paint group with a face under `p` (front-most in paint order). +pub fn live_paint_at(doc: &Document, p: Point) -> Option { + let mut hit = None; + doc.walk(|n| { + if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() { + hit = Some(n.id); + } + }); + hit +} + +/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and +/// hidden objects are skipped. Live Paint groups contribute their faces. +pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + leaf_shapes(c, out); + } + } + _ => {} + } +} + +/// Filled outline of a path or compound path node. +pub fn node_outline(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, + } +} + +/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, +/// with the leaves they came from. +pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { + let mut lv = vec![]; + for id in roots { + if let Some(n) = doc.node(*id) { + leaf_shapes(n, &mut lv); + } + } + let mut shapes = vec![]; + let mut used = vec![]; + for l in lv { + if let Some((p, r)) = node_outline(l) + && !p.is_empty() + && p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())) + { + shapes.push(po::Shape::new(p, r, used.len() as u64)); + used.push(l); + } + } + (shapes, used) +} + +/// Selected roots sorted back → front in document order. +pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec { + let mut v: Vec<(Vec, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); + v.sort(); + v.dedup_by(|a, b| a.1 == b.1); + v.into_iter().map(|(_, id)| id).collect() +} + +/// Sample points along a polyline every `step` units (always including the vertices). +pub fn sample_polyline(pts: &[Point], step: f64) -> Vec { + let mut out = vec![]; + let step = step.max(1e-3); + for (i, &p) in pts.iter().enumerate() { + if i > 0 { + let a = pts[i - 1]; + let n = ((a.distance(p) / step).ceil() as usize).min(4000); + for k in 1..n { + out.push(a.lerp(p, k as f64 / n as f64)); + } + } + out.push(p); + } + out +} + +/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart. +pub fn hatch(path: &BezPath, gap: f64) -> BezPath { + let mut out = BezPath::new(); + let b = path.bounding_box(); + if b.width() <= 0.0 || b.height() <= 0.0 { + return out; + } + // Rotate by -45° so the hatch lines become horizontal scanlines. + let s = std::f64::consts::FRAC_1_SQRT_2; + let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s); + let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s); + let mut edges: Vec<(Point, Point)> = vec![]; + let mut first = Point::ZERO; + let mut last = Point::ZERO; + kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((rot(last), rot(first))); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((rot(last), rot(p))); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((rot(last), rot(first))); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((rot(last), rot(first))); + } + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for (a, c) in &edges { + y0 = y0.min(a.y.min(c.y)); + y1 = y1.max(a.y.max(c.y)); + } + let gap = gap.max((y1 - y0) / 400.0).max(1e-6); + let mut y = (y0 / gap).floor() * gap + gap * 0.5; + let mut xs: Vec = vec![]; + while y < y1 { + xs.clear(); + for (a, c) in &edges { + if (a.y <= y) != (c.y <= y) { + xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.chunks_exact(2) { + out.move_to(unrot(Point::new(pair[0], y))); + out.line_to(unrot(Point::new(pair[1], y))); + } + y += gap; + } + out +} + +/// Regions of the current selection, cached by document identity and selection. +#[derive(Default)] +struct RegionCache { + key: Option<(usize, Vec)>, + regions: Vec<(po::Region, BezPath, Rect)>, +} + +impl RegionCache { + fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] { + let key = (cx.doc as *const Document as usize, cx.selection.objects.clone()); + if self.key.as_ref() != Some(&key) { + let roots = sorted_roots(cx.doc, &cx.selection.objects); + let (shapes, _) = shapes_for(cx.doc, &roots); + self.regions = if shapes.is_empty() { + vec![] + } else { + po::regions(&shapes) + .into_iter() + .filter_map(|r| { + let bp = r.path.to_bezpath(); + let b = r.path.bounds()?; + Some((r, bp, b)) + }) + .collect() + }; + self.key = Some(key); + } + &self.regions + } + fn at(&mut self, cx: &ToolContext, p: Point) -> Option { + self.get(cx).iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) + } +} + +// ---------- Shape Builder ---------- + +/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them. +#[derive(Default)] +pub struct ShapeBuilderTool { + cache: RegionCache, + hover: Option, + points: Vec, + touched: Vec, + dragging: bool, + alt: bool, +} + +impl ShapeBuilderTool { + fn touch(&mut self, cx: &ToolContext, from: Option, to: Point) { + let pts = match from { + Some(a) => sample_polyline(&[a, to], cx.tol(2.0)), + None => vec![to], + }; + for p in pts { + if let Some(i) = self.cache.at(cx, p) + && !self.touched.contains(&i) + { + self.touched.push(i); + } + } + } +} + +impl Tool for ShapeBuilderTool { + fn id(&self) -> &'static str { + "shapeBuilder" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.hover = self.cache.at(cx, p); + vec![] + } + PointerKind::Down => { + if cx.selection.is_empty() { + return vec![]; + } + self.dragging = true; + self.alt = ev.mods.alt; + self.points = vec![p]; + self.touched.clear(); + self.touch(cx, None, p); + vec![] + } + PointerKind::Drag if self.dragging => { + let last = self.points.last().copied(); + if last.is_none_or(|q| q.distance(p) >= cx.tol(1.0)) { + self.touch(cx, last, p); + self.points.push(p); + } + vec![] + } + PointerKind::Up if self.dragging => { + self.dragging = false; + if self.points.last().is_none_or(|q| *q != p) { + let last = self.points.last().copied(); + self.touch(cx, last, p); + self.points.push(p); + } + let points = std::mem::take(&mut self.points); + if std::mem::take(&mut self.touched).is_empty() { + return vec![]; + } + self.cache.key = None; + let roots = sorted_roots(cx.doc, &cx.selection.objects); + let pts: Vec = points.iter().map(|q| json!([q.x, q.y])).collect(); + vec![Action::Exec("shapeBuilder.merge".into(), json!({ "ids": json_ids(&roots), "points": pts, "erase": self.alt || ev.mods.alt }))] + } + _ => vec![], + } + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + let regions = &self.cache.regions; + let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH }; + let show: Vec = if self.dragging { self.touched.clone() } else { self.hover.into_iter().collect() }; + for i in show { + if let Some((_, bp, _)) = regions.get(i) { + out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false }); + out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false }); + } + } + if self.dragging && self.points.len() > 1 { + let mut bp = BezPath::new(); + bp.move_to(self.points[0]); + for q in &self.points[1..] { + bp.line_to(*q); + } + out.push(Overlay::Path { path: bp, color, width: 1.0, dashed: false }); + } + out + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + + fn busy(&self) -> bool { + self.dragging + } +} + +// ---------- Live Paint Bucket ---------- + +/// What the Live Paint tools highlight under the cursor. +#[derive(Clone, Debug, Default, PartialEq)] +enum LpHover { + #[default] + None, + /// A face (or edge) of an existing Live Paint group. + Part(BezPath), + /// A region of the (not yet live) selection. + Region(usize), +} + +fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover { + if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) { + let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; + return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); + } + if edge { + // Edges of every Live Paint group near the pointer. + let mut found = None; + cx.doc.walk(|n| { + if is_live_paint(n) + && let Some(e) = edge_near(n, p, cx.tol(4.0)) + { + found = e.path_data().map(|pd| pd.to_bezpath()); + } + }); + if let Some(bp) = found { + return LpHover::Part(bp); + } + } + let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); + if !sel_is_live && let Some(i) = cache.at(cx, p) { + return LpHover::Region(i); + } + LpHover::None +} + +fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec { + match h { + LpHover::None => vec![], + LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }], + LpHover::Region(i) => cache + .regions + .get(*i) + .map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }]) + .unwrap_or_default(), + } +} + +/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click +/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a +/// Live Paint group first. +#[derive(Default)] +pub struct LivePaintBucketTool { + cache: RegionCache, + hover: LpHover, +} + +impl Tool for LivePaintBucketTool { + fn id(&self) -> &'static str { + "livePaintBucket" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.hover = lp_hover(cx, &mut self.cache, p, ev.mods.shift); + vec![] + } + PointerKind::Down => { + self.hover = LpHover::None; + let point = json!([p.x, p.y]); + if ev.mods.shift { + let mut group = live_paint_at(cx.doc, p); + if group.is_none() { + cx.doc.walk(|n| { + if is_live_paint(n) && edge_near(n, p, cx.tol(4.0)).is_some() { + group = Some(n.id); + } + }); + } + return match group { + Some(g) => { + vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] + } + None => vec![], + }; + } + if let Some(g) = live_paint_at(cx.doc, p) { + return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; + } + if self.cache.at(cx, p).is_some() { + self.cache.key = None; + let roots = sorted_roots(cx.doc, &cx.selection.objects); + return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; + } + vec![] + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } +} + +// ---------- Live Paint Selection ---------- + +/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. +#[derive(Default)] +pub struct LivePaintSelectionTool { + cache: RegionCache, + hover: LpHover, +} + +impl Tool for LivePaintSelectionTool { + fn id(&self) -> &'static str { + "livePaintSelection" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.hover = match lp_hover(cx, &mut self.cache, p, ev.mods.alt) { + h @ LpHover::Part(_) => h, + _ => LpHover::None, + }; + vec![] + } + PointerKind::Down => { + let mut hit = None; + if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) { + let part = if ev.mods.alt { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; + hit = part.map(|n| n.id); + } + match (hit, ev.mods.shift) { + (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], + (None, true) => vec![], + (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + } + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Arrow + } +} + +/// Create a builder-family tool by id (None = not one of ours). +pub fn create(id: &str) -> Option> { + Some(match id { + "shapeBuilder" => Box::new(ShapeBuilderTool::default()), + "livePaintBucket" => Box::new(LivePaintBucketTool::default()), + "livePaintSelection" => Box::new(LivePaintSelectionTool::default()), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::{Appearance, Selection}; + use drawcraft_geom::shapes; + + fn two_rects() -> (Document, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let a = d.alloc_id(); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art())).unwrap(); + let b = d.alloc_id(); + d.insert(Some(l), 1, Node::path(b, shapes::rectangle(Rect::new(50.0, 0.0, 150.0, 100.0)), Appearance::default_art())).unwrap(); + (d, a, b) + } + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn shape_builder_hover_highlights_region() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)).is_empty()); + let ov = t.overlays(&cx); + assert_eq!(ov.len(), 2); + let Overlay::Path { path, .. } = &ov[1] else { panic!() }; + let bb = path.bounding_box(); + assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}"); + t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0)); + assert!(t.overlays(&cx).is_empty()); + } + + #[test] + fn shape_builder_drag_emits_merge() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([b, a]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0)); + assert_eq!(t.touched.len(), 2); + assert!(t.busy()); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(id, "shapeBuilder.merge"); + assert_eq!(v["erase"], false); + assert_eq!(v["ids"], json!([a.0, b.0]), "sorted back to front"); + assert!(v["points"].as_array().unwrap().len() >= 2); + } + + #[test] + fn shape_builder_alt_drag_erases_and_empty_drag_does_nothing() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0).with_mods(alt)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 125.0, 50.0).with_mods(alt)); + let Action::Exec(_, v) = &acts[0] else { panic!() }; + assert_eq!(v["erase"], true); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty()); + } + + #[test] + fn hatch_stays_inside_region() { + let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath(); + let h = hatch(&bp, 4.0); + let bb = h.bounding_box(); + assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 40.0 + 1e-6 && bb.y1 <= 20.0 + 1e-6); + assert!(h.elements().len() > 10); + } + + fn live_group_doc() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let f1 = d.alloc_id(); + let f2 = d.alloc_id(); + let e = d.alloc_id(); + let mut face1 = Node::path(f1, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + face1.name = Some(FACE_NAME.into()); + let mut face2 = Node::path(f2, shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 50.0)), Appearance::default()); + face2.name = Some(FACE_NAME.into()); + let mut edge = Node::path(e, shapes::line(Point::new(50.0, 0.0), Point::new(50.0, 50.0)), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + let gid = d.alloc_id(); + let mut g = Node::group(gid, vec![Arc::new(face1), Arc::new(face2), Arc::new(edge)]); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + (d, gid, f2, e) + } + + #[test] + fn bucket_click_fills_face_and_shift_click_strokes_edge() { + let (d, gid, f2, _e) = live_group_doc(); + assert_eq!(live_paint_at(&d, Point::new(75.0, 25.0)), Some(gid)); + assert_eq!(face_at(d.node(gid).unwrap(), Point::new(75.0, 25.0)).unwrap().id, f2); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + let ov = t.overlays(&cx); + assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. })); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + let Action::Exec(id, v) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["group"], gid.0); + let shift = Mods { shift: true, ..Default::default() }; + let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); + let Action::Exec(id, _) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.strokeEdge"); + } + + #[test] + fn bucket_on_plain_selection_makes_live_paint() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!() }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["ids"], json!([a.0, b.0])); + } + + #[test] + fn live_paint_selection_selects_faces() { + let (d, _gid, f2, e) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintSelectionTool::default(); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]); + let alt = Mods { alt: true, ..Default::default() }; + let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]); + } + + #[test] + fn create_builds_the_three_tools() { + for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] { + assert_eq!(create(id).unwrap().id(), id); + } + assert!(create("pen").is_none()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f63ad78f7..f4433abe9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,13 +8,16 @@ #![forbid(unsafe_code)] pub mod bbox; +pub mod builder; pub mod catalog; pub mod direct; pub mod draw2; pub mod guides; +pub mod meshblend; pub mod pen; pub mod select; pub mod shape; +pub mod symbolism; pub mod text; pub mod xform; @@ -97,6 +100,8 @@ pub enum ToolKey { BracketLeft, BracketRight, Tab, + Home, + End, } /// What a tool asks the engine to do. @@ -170,6 +175,8 @@ pub enum Overlay { Label { p: Point, text: String, color: [u8; 3] }, /// Measurement pill near the cursor (grey box with white text). Measure { p: Point, text: String }, + /// Translucent filled quad (text selection highlight), RGBA. + Highlight { quad: [Point; 4], color: [u8; 4] }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -241,10 +248,13 @@ pub fn create(id: &str) -> Box { "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), "pen" => Box::new(pen::PenTool::default()), - "type" => Box::new(text::TypeTool::default()), + "type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), - other => draw2::create(other) + other => symbolism::create(other) + .or_else(|| builder::create(other)) + .or_else(|| draw2::create(other)) .or_else(|| xform::create(other)) + .or_else(|| meshblend::create(other)) .unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), } } diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs new file mode 100644 index 000000000..0bea1f73c --- /dev/null +++ b/crates/tools/src/meshblend.rs @@ -0,0 +1,220 @@ +//! Blend tool (W) and Mesh tool (U). +//! +//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`. Double-click opens +//! Blend Options. Clicking empty canvas resets. +//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through +//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill +//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh +//! point → `object.mesh.deletePoint`. + +use drawcraft_color::Paint; +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::Point; +use serde_json::json; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; + +const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; + +pub fn create(id: &str) -> Option> { + match id { + "blend" => Some(Box::new(BlendTool::default())), + "mesh" => Some(Box::new(MeshTool::default())), + _ => None, + } +} + +fn hit_top(cx: &ToolContext, p: Point) -> Option<(NodeId, NodeId)> { + let h = drawcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?; + Some((h.top_object(cx.isolation), h.leaf)) +} + +#[derive(Default)] +pub struct BlendTool { + first: Option, +} + +impl Tool for BlendTool { + fn id(&self) -> &'static str { + "blend" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::DoubleClick => { + self.first = None; + vec![Action::Dialog("object.blend.options".into(), json!({}))] + } + PointerKind::Down => match (self.first, hit_top(cx, ev.pos)) { + (_, None) => { + self.first = None; + vec![] + } + (None, Some((top, _))) => { + self.first = Some(top); + vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))] + } + (Some(a), Some((b, _))) if a != b && cx.doc.node(a).is_some() => { + self.first = None; + vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))] + } + (Some(_), Some(_)) => vec![], + }, + _ => vec![], + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(b) = self.first.and_then(|a| cx.doc.node(a)).and_then(|n| n.geometric_bounds()) else { return vec![] }; + vec![Overlay::Anchor { p: b.center(), color: FEEDBACK, filled: true, size: 6.0 }] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } + fn busy(&self) -> bool { + self.first.is_some() + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.first = None; + vec![] + } +} + +#[derive(Default)] +pub struct MeshTool { + drag: Option<(NodeId, usize)>, +} + +impl MeshTool { + /// Meshes to consider for point hits: selected ones first, then any mesh in the document. + fn meshes(cx: &ToolContext) -> Vec { + let mut v: Vec = + cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)))).collect(); + let mut rest = vec![]; + cx.doc.walk(|n| { + if matches!(n.kind, NodeKind::Mesh(_)) { + rest.push(n.id); + } + }); + for id in rest.into_iter().rev() { + if !v.contains(&id) { + v.push(id); + } + } + v.retain(|id| cx.doc.is_editable(*id)); + v + } + + fn point_at(cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> { + let tol = cx.tol(5.0); + Self::meshes(cx).into_iter().find_map(|id| match &cx.doc.node(id)?.kind { + NodeKind::Mesh(m) => m.point_near(p, tol).map(|i| (id, i)), + _ => None, + }) + } +} + +impl Tool for MeshTool { + fn id(&self) -> &'static str { + "mesh" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Down => { + if let Some((id, index)) = Self::point_at(cx, p) { + if ev.mods.alt { + return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": id.0, "index": index}))]; + } + self.drag = Some((id, index)); + return vec![Action::Begin("Move Mesh Point".into())]; + } + let Some((top, leaf)) = hit_top(cx, p) else { return vec![] }; + let color = match (&cx.paint.fill, ev.mods.shift) { + (Paint::Solid { color, .. }, false) => Some(color.to_hex()), + _ => None, + }; + for id in [leaf, top] { + match cx.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Mesh(_)) => { + let mut params = json!({"id": id.0, "x": p.x, "y": p.y}); + if let Some(c) = &color { + params["color"] = json!(c); + } + return vec![Action::Exec("object.mesh.addLine".into(), params)]; + } + Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => { + return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))]; + } + _ => {} + } + } + vec![] + } + PointerKind::Drag => match self.drag { + Some((id, index)) => vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": id.0, "index": index, "x": p.x, "y": p.y}))], + None => vec![], + }, + PointerKind::Up => match self.drag.take() { + Some(_) => vec![Action::Commit], + None => vec![], + }, + _ => vec![], + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + for id in &cx.selection.objects { + if let Some(NodeKind::Mesh(m)) = cx.doc.node(*id).map(|n| &n.kind) { + out.push(Overlay::Path { path: m.lines().to_bezpath(), color: FEEDBACK, width: 1.0, dashed: false }); + for q in &m.points { + out.push(Overlay::Anchor { p: q.p, color: FEEDBACK, filled: false, size: 5.0 }); + } + } + } + out + } + fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor { + match Self::point_at(cx, p) { + Some(_) if mods.alt => Cursor::PenDelete, + Some(_) => Cursor::Move, + None => Cursor::PenAdd, + } + } + fn busy(&self) -> bool { + self.drag.is_some() + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn blend_tool_two_clicks_make_blend() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let r = drawcraft_geom::shapes::rectangle(drawcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, drawcraft_doc::Node::path(b, r, drawcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = create("blend").unwrap(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 120.0)); + assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]); + } + + #[test] + fn mesh_tool_click_path_creates_mesh() { + let (d, a) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = create("mesh").unwrap(); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)); + assert_eq!(acts, vec![Action::Exec("object.mesh.create".into(), json!({"ids": [a.0], "at": [150.0, 160.0]}))]); + } +} diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs new file mode 100644 index 000000000..b3eab0470 --- /dev/null +++ b/crates/tools/src/symbolism.rs @@ -0,0 +1,323 @@ +//! Symbolism tools (Symbol Sprayer, Shifter, Scruncher, Sizer, Spinner, Stainer, Screener, +//! Styler) and the brush-aware Paintbrush. +//! +//! Symbolism gestures preview one command (`symbol.spray` / `symbol.adjust`) with every point of +//! the drag so far, re-applied on the interaction snapshot, and commit on release — one undo +//! step, replayable from the journal. The Paintbrush wraps the freehand gesture tool and turns its +//! `path.freehand` previews into `brush.freehand` when a brush is current (tool option `brush`, +//! else the document's current brush). + +use drawcraft_geom::{Point, Shape}; +use serde_json::{Value, json}; + +use crate::draw2::{FEEDBACK, GestureTool, points_json}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// Create a symbolism tool or the brush-aware Paintbrush (None = not one of ours). +pub fn create(id: &str) -> Option> { + Some(match id { + "paintbrush" => Box::new(BrushPaintTool::default()), + "symbolSprayer" | "symbolShifter" | "symbolScruncher" | "symbolSizer" | "symbolSpinner" | "symbolStainer" | "symbolScreener" + | "symbolStyler" => Box::new(SymbolismTool::new(id)), + _ => return None, + }) +} + +pub struct SymbolismTool { + id: &'static str, + points: Vec, + active: bool, + hover: Option, + alt: bool, + /// Brush diameter in points. + pub diameter: f64, + /// 1..10. + pub intensity: f64, + /// Symbol set density 1..10 (Sprayer). + pub density: f64, + /// Symbol to spray (None = the document's current symbol). + pub symbol: Option, + /// Graphic style for the Styler (None = the document's first non-default style). + pub style: Option, +} + +impl SymbolismTool { + pub fn new(id: &str) -> Self { + let id: &'static str = match id { + "symbolShifter" => "symbolShifter", + "symbolScruncher" => "symbolScruncher", + "symbolSizer" => "symbolSizer", + "symbolSpinner" => "symbolSpinner", + "symbolStainer" => "symbolStainer", + "symbolScreener" => "symbolScreener", + "symbolStyler" => "symbolStyler", + _ => "symbolSprayer", + }; + Self { id, points: vec![], active: false, hover: None, alt: false, diameter: 80.0, intensity: 5.0, density: 5.0, symbol: None, style: None } + } + + fn label(&self) -> &'static str { + match self.id { + "symbolShifter" => "Symbol Shifter", + "symbolScruncher" => "Symbol Scruncher", + "symbolSizer" => "Symbol Sizer", + "symbolSpinner" => "Symbol Spinner", + "symbolStainer" => "Symbol Stainer", + "symbolScreener" => "Symbol Screener", + "symbolStyler" => "Symbol Styler", + _ => "Symbol Sprayer", + } + } + + /// The command + params for the gesture so far. + pub fn command(&self, cx: &ToolContext) -> (String, Value) { + let radius = self.diameter / 2.0; + let pts = points_json(&self.points); + if self.id == "symbolSprayer" { + let mut v = json!({"points": pts, "radius": radius, "density": self.density, "alt": self.alt}); + if let Some(s) = &self.symbol { + v["name"] = json!(s); + } + return ("symbol.spray".into(), v); + } + let tool = match self.id { + "symbolShifter" => "shift", + "symbolScruncher" => "scrunch", + "symbolSizer" => "size", + "symbolSpinner" => "spin", + "symbolStainer" => "stain", + "symbolScreener" => "screen", + _ => "style", + }; + let mut v = json!({"tool": tool, "points": pts, "radius": radius, "intensity": self.intensity, "alt": self.alt}); + if tool == "stain" + && let Some(c) = cx.paint.fill.color() + { + v["color"] = serde_json::to_value(c).unwrap_or(Value::Null); + } + if let Some(s) = &self.style { + v["style"] = json!(s); + } + ("symbol.adjust".into(), v) + } +} + +impl Tool for SymbolismTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.active + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + self.hover = Some(p); + match ev.kind { + PointerKind::Down => { + self.points = vec![p]; + self.active = true; + self.alt = ev.mods.alt; + let (c, v) = self.command(cx); + vec![Action::Begin(self.label().into()), Action::Preview(c, v)] + } + PointerKind::Drag if self.active => { + // Sprayer: thin the samples to a fraction of the brush; others: every few pixels. + let step = if self.id == "symbolSprayer" { (self.diameter * 0.15).max(cx.tol(3.0)) } else { cx.tol(3.0) }; + if self.points.last().is_some_and(|l| l.distance(p) < step) { + return vec![]; + } + self.points.push(p); + let (c, v) = self.command(cx); + vec![Action::Preview(c, v)] + } + PointerKind::Up if self.active => { + self.active = false; + self.points.clear(); + vec![Action::Commit] + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, m: Mods) -> Vec { + match key { + ToolKey::BracketLeft if m.shift => self.intensity = (self.intensity - 1.0).max(1.0), + ToolKey::BracketRight if m.shift => self.intensity = (self.intensity + 1.0).min(10.0), + ToolKey::BracketLeft => self.diameter = (self.diameter - 10.0).max(2.0), + ToolKey::BracketRight => self.diameter = (self.diameter + 10.0).min(2000.0), + ToolKey::Escape if self.active => { + self.active = false; + self.points.clear(); + return vec![Action::Cancel]; + } + _ => {} + } + vec![] + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + if self.active { + self.active = false; + self.points.clear(); + return vec![Action::Commit]; + } + vec![] + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + let Some(p) = self.hover else { return vec![] }; + let circle = kurbo::Circle::new(p, self.diameter / 2.0).to_path(0.1); + vec![Overlay::Path { path: circle, color: FEEDBACK, width: 1.0, dashed: false }] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + json!({"diameter": self.diameter, "intensity": self.intensity, "density": self.density, "symbol": self.symbol, "style": self.style}) + } + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "diameter" => self.diameter = v.as_f64().unwrap_or(self.diameter).clamp(2.0, 2000.0), + "intensity" => self.intensity = v.as_f64().unwrap_or(self.intensity).clamp(1.0, 10.0), + "density" => self.density = v.as_f64().unwrap_or(self.density).clamp(1.0, 10.0), + "symbol" => self.symbol = v.as_str().map(str::to_string), + "style" => self.style = v.as_str().map(str::to_string), + _ => {} + } + } +} + +/// Paintbrush: the freehand gesture, painting with the current brush. +pub struct BrushPaintTool { + inner: GestureTool, + /// Brush override (None = the document's current brush, if any). + pub brush: Option, +} + +impl Default for BrushPaintTool { + fn default() -> Self { + Self { inner: GestureTool::new("paintbrush"), brush: None } + } +} + +impl BrushPaintTool { + fn brush_name(&self, cx: &ToolContext) -> Option { + self.brush.clone().or_else(|| cx.doc.unknown.get("currentBrush").and_then(Value::as_str).map(str::to_string)) + } + fn map(&self, cx: &ToolContext, acts: Vec) -> Vec { + let Some(b) = self.brush_name(cx) else { return acts }; + acts.into_iter() + .map(|a| match a { + Action::Preview(c, mut v) if c == "path.freehand" => { + v["brush"] = json!(b); + Action::Preview("brush.freehand".into(), v) + } + o => o, + }) + .collect() + } +} + +impl Tool for BrushPaintTool { + fn id(&self) -> &'static str { + "paintbrush" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let acts = self.inner.pointer(cx, ev); + self.map(cx, acts) + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, m: Mods) -> Vec { + let acts = self.inner.key(cx, key, m); + self.map(cx, acts) + } + fn overlays(&self, cx: &ToolContext) -> Vec { + self.inner.overlays(cx) + } + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + self.inner.cursor(cx, p, m) + } + fn options(&self) -> Value { + let mut v = self.inner.options(); + v["brush"] = json!(self.brush); + v + } + fn set_option(&mut self, key: &str, v: &Value) { + if key == "brush" { + self.brush = v.as_str().map(str::to_string); + } else { + self.inner.set_option(key, v); + } + } + fn busy(&self) -> bool { + self.inner.busy() + } + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + let acts = self.inner.deactivate(cx); + self.map(cx, acts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use drawcraft_doc::Selection; + + #[test] + fn sprayer_previews_all_points_and_commits() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + let mut t = SymbolismTool::new("symbolSprayer"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + assert_eq!(a[0], Action::Begin("Symbol Sprayer".into())); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 0.0)); + assert!(matches!(&a[0], Action::Preview(c, v) if c == "symbol.spray" && v["points"].as_array().unwrap().len() == 2)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 51.0, 0.0)).is_empty(), "thinned"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 51.0, 0.0)), vec![Action::Commit]); + assert!(!t.busy()); + } + + #[test] + fn adjust_tools_map_to_symbol_adjust() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + for (id, tool) in [("symbolShifter", "shift"), ("symbolSizer", "size"), ("symbolStainer", "stain"), ("symbolStyler", "style")] { + let mut t = create(id).unwrap(); + assert_eq!(t.id(), id); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 5.0, 5.0).with_mods(Mods { alt: true, ..Default::default() })); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "symbol.adjust" && v["tool"] == tool && v["alt"] == true), "{a:?}"); + } + } + + #[test] + fn brackets_change_diameter_and_intensity() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + let mut t = SymbolismTool::new("symbolSizer"); + t.key(&cx, ToolKey::BracketRight, Mods::default()); + t.key(&cx, ToolKey::BracketLeft, Mods { shift: true, ..Default::default() }); + assert_eq!(t.options()["diameter"], json!(90.0)); + assert_eq!(t.options()["intensity"], json!(4.0)); + } + + #[test] + fn paintbrush_rewrites_freehand_to_brush_freehand() { + let (mut d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + { + let cx = cx(&d, &s, &p); + let mut t = BrushPaintTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 30.0, 0.0)); + assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.freehand"), "no brush: plain freehand"); + } + d.unknown.insert("currentBrush".into(), json!("Charcoal")); + let cx = cx(&d, &s, &p); + let mut t = BrushPaintTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 30.0, 0.0)); + assert!(matches!(&a[1], Action::Preview(c, v) if c == "brush.freehand" && v["brush"] == "Charcoal")); + t.set_option("brush", &json!("Dots")); + assert_eq!(t.options()["brush"], "Dots"); + } +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index a1c8a1a84..85a38c772 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -1,60 +1,296 @@ -//! The Type tool (T): click places point type, drag draws an area-type frame, clicking into -//! existing text places the caret. Typing is coalesced into one undo step per editing session. +//! The Type tools: Type (T), Area Type and Type on a Path. +//! +//! Type: click places point type, drag draws an area-type frame, clicking into existing text +//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a +//! baseline (`text.createInPath`). +//! +//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends +//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a +//! word and triple-click a paragraph, typing replaces the selection. Edits are coalesced into one +//! undo step per typing session: the tool keeps the styled runs locally and previews a single +//! `text.editRange` (the changed span, with its styled runs) against the session snapshot. +//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the +//! selection from [`Tool::options`]). + +use std::sync::{Arc, Mutex}; use drawcraft_doc::hit::hit_test; -use drawcraft_doc::{NodeId, NodeKind}; -use drawcraft_geom::{Point, Rect}; -use serde_json::json; +use drawcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun}; +use drawcraft_geom::{BezPath, Point, Rect, Shape}; +use drawcraft_text::{FontDb, TextLayout, edit}; +use serde_json::{Value, json}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +/// Which of the Type tools this is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Mode { + #[default] + Type, + Area, + OnPath, +} + +/// A coalesced typing session: the runs at its start and the runs now. +struct Typing { + base: Vec, + cur: Vec, +} + #[derive(Default)] pub struct TypeTool { - /// Text object being edited and the caret (byte offset). - editing: Option<(NodeId, usize)>, - /// Current text of the edited object (mirrors the preview). - buffer: String, - typing: bool, + mode: Mode, + /// Text object being edited. + editing: Option, + /// Caret and selection anchor (byte offsets into the plain text). + caret: usize, + anchor: usize, + /// Horizontal position kept by Up/Down (text space). + goal_x: Option, + typing: Option, press: Option, drag: Option, + /// The press landed in the edited text: dragging selects. + selecting: bool, + /// Click counting for double/triple click (position of the last click, count). + clicks: (Option, u8), + /// Styled copy of the last copied range (pasting the same plain text keeps its styles). + clipboard: Vec, + cache: Mutex)>>, } impl TypeTool { - fn text_of(cx: &ToolContext, id: NodeId) -> Option { + pub fn new(id: &str) -> Self { + let mode = match id { + "areaType" => Mode::Area, + "typeOnPath" => Mode::OnPath, + _ => Mode::Type, + }; + Self { mode, ..Default::default() } + } + + fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> { match &cx.doc.node(id)?.kind { - NodeKind::Text(t) => Some(t.plain_text()), + NodeKind::Text(t) => Some(t), _ => None, } } + + /// Layout of `t`, cached by content (overlays run every frame). + fn layout(&self, t: &TextObject) -> Arc { + let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((k, l)) = c.as_ref() + && k == t + { + return l.clone(); + } + let l = Arc::new(drawcraft_text::layout(FontDb::global(), t)); + *c = Some((t.clone(), l.clone())); + l + } + + fn plain(&self, cx: &ToolContext) -> String { + match &self.typing { + Some(ty) => ty.cur.iter().map(|r| r.text.as_str()).collect(), + None => self.editing.and_then(|id| Self::text(cx, id)).map(|t| t.plain_text()).unwrap_or_default(), + } + } + + /// The edited object as it currently is (with the typing session's runs). + fn current(&self, cx: &ToolContext) -> Option { + let mut t = Self::text(cx, self.editing?)?.clone(); + if let Some(ty) = &self.typing { + t.runs = ty.cur.clone(); + } + Some(t) + } + + fn sel(&self) -> (usize, usize) { + (self.caret.min(self.anchor), self.caret.max(self.anchor)) + } + + /// End the typing session (one undo step). + fn commit(&mut self) -> Vec { + if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] } + } + fn finish(&mut self) -> Vec { - let mut out = vec![]; - if self.typing { - out.push(Action::Commit); - } - self.typing = false; + let out = self.commit(); self.editing = None; + self.selecting = false; + self.goal_x = None; out } - fn edit(&mut self, new_text: String, caret: usize) -> Vec { - let Some((id, _)) = self.editing else { return vec![] }; + + /// Replace the selection with `insert` (styled `runs` when given) inside the typing session. + fn replace(&mut self, cx: &ToolContext, insert: &str, styled: Option>) -> Vec { + let Some(id) = self.editing else { return vec![] }; + let Some(t) = Self::text(cx, id) else { + self.editing = None; + return vec![]; + }; let mut out = vec![]; - if !self.typing { - out.push(Action::Begin("Typing".into())); - self.typing = true; + // The document changed under us (undo, a panel edit): start a new session. + if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) { + out.push(Action::Commit); + self.typing = None; } - self.buffer = new_text; - self.editing = Some((id, caret)); - out.push(Action::Preview("text.setText".into(), json!({"id": id.0, "text": self.buffer}))); + if self.typing.is_none() { + out.push(Action::Begin("Typing".into())); + self.typing = Some(Typing { base: t.runs.clone(), cur: t.runs.clone() }); + } + let ty = self.typing.as_mut().expect("typing session"); + let len = edit::runs_len(&ty.cur); + let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); + let caret = match &styled { + Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r), + None => edit::replace_range(&mut ty.cur, a, b, insert), + }; + self.caret = caret; + self.anchor = caret; + self.goal_x = None; + // One preview for the whole session: the changed span of base → cur, with its runs. + let base: String = ty.base.iter().map(|r| r.text.as_str()).collect(); + let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect(); + let (p, s) = common_affixes(&base, &cur); + let runs = edit::slice_runs(&ty.cur, p, cur.len() - s); + let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) }; + out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs}))); out } + + /// Move the caret (Shift keeps the anchor). Moving ends the typing session. + fn move_to(&mut self, byte: usize, extend: bool) -> Vec { + let out = self.commit(); + self.caret = byte; + if !extend { + self.anchor = byte; + } + out + } + + fn start_editing(&mut self, id: NodeId, caret: usize) { + self.editing = Some(id); + self.caret = caret; + self.anchor = caret; + self.goal_x = None; + self.typing = None; + } + + /// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds). + fn hit_edited(&self, cx: &ToolContext, p: Point) -> Option { + let t = self.current(cx)?; + let lay = self.layout(&t); + let local = t.xf.inverse() * p; + let tol = cx.tol(4.0); + let inside = match &t.kind { + TextKind::Area { frame } => frame.bounds().is_some_and(|b| b.inflate(tol, tol).contains(local)), + _ => lay.bounds.inflate(tol, tol).contains(local), + }; + inside.then(|| drawcraft_text::hit_byte(&lay, local)) + } + + fn select_word_or_para(&mut self, cx: &ToolContext, para: bool) { + let s = self.plain(cx); + let r = if para { edit::paragraph_at(&s, self.caret) } else { edit::word_at(&s, self.caret) }; + self.anchor = r.start; + self.caret = r.end; + } + + /// Path under `p` for the Area Type / Type on a Path tools. + fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option { + let h = hit_test(cx.doc, p, drawcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, path_only: false })?; + match &cx.doc.node(h.leaf)?.kind { + NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf), + _ => None, + } + } + + fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec { + let mut out = self.finish(); + // Click into existing text: place the caret. + if let Some(h) = hit_test(cx.doc, start, cx.hit_options()) + && let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) + { + let lay = self.layout(t); + let byte = drawcraft_text::hit_byte(&lay, t.xf.inverse() * start); + self.start_editing(h.leaf, byte); + self.clicks = (Some(start), 1); + out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))); + return out; + } + let drag = self.drag.take(); + // Area Type / Type on a Path: click a path. + if self.mode != Mode::Type && drag.is_none() { + let on_path = self.mode == Mode::OnPath; + if let Some(pid) = Self::path_at(cx, start, !on_path) { + let mode = if on_path { "onPath" } else { "area" }; + out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]}))); + out.push(Action::Notify("text.editNew".into())); + return out; + } + } + let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); + let params = match area { + Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}), + None => json!({"x": start.x, "y": start.y, "text": ""}), + }; + out.push(Action::Exec("text.create".into(), params)); + out.push(Action::Notify("text.editNew".into())); + out + } + + fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec) { + if !lay.overflow { + return; + } + // The out port: bottom-right of the frame (end of the path for type on a path). + let port = match &t.kind { + TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), + TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * drawcraft_geom::ParamCurve::end(&s)), + TextKind::Point => None, + }; + let Some(c) = port else { return }; + let h = cx.tol(4.5); + let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); + let red = [230, 30, 30]; + o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); + let (k, m) = (cx.tol(2.5), r.center()); + o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); + o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); + } +} + +/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping). +fn common_affixes(a: &str, b: &str) -> (usize, usize) { + let mut p = 0; + for ((i, x), y) in a.char_indices().zip(b.chars()) { + if x != y { + break; + } + p = i + x.len_utf8(); + } + let max_s = (a.len() - p).min(b.len() - p); + let mut s = 0; + for (x, y) in a[p..].chars().rev().zip(b[p..].chars().rev()) { + if x != y || s + x.len_utf8() > max_s { + break; + } + s += x.len_utf8(); + } + (p, s) } impl Tool for TypeTool { fn id(&self) -> &'static str { - "type" + match self.mode { + Mode::Type => "type", + Mode::Area => "areaType", + Mode::OnPath => "typeOnPath", + } } fn busy(&self) -> bool { - self.press.is_some() + self.press.is_some() || self.selecting } fn wants_text(&self) -> bool { self.editing.is_some() @@ -62,87 +298,149 @@ impl Tool for TypeTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { + if let Some(byte) = self.hit_edited(cx, ev.pos) { + let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0)); + let n = if same { self.clicks.1 + 1 } else { 1 }; + self.clicks = (Some(ev.pos), n); + let out = self.move_to(byte, ev.mods.shift); + self.goal_x = None; + if n >= 3 { + self.select_word_or_para(cx, true); + self.clicks.1 = 0; + } else { + self.selecting = true; + } + return out; + } + self.clicks = (None, 0); self.press = Some(ev.pos); self.drag = None; vec![] } PointerKind::Drag => { - if self.press.is_some() { + if self.selecting { + if let Some(t) = self.current(cx) { + let lay = self.layout(&t); + self.caret = drawcraft_text::hit_byte(&lay, t.xf.inverse() * ev.pos); + self.clicks = (None, 0); + } + } else if self.press.is_some() { self.drag = Some(ev.pos); } vec![] } PointerKind::Up => { - let Some(start) = self.press.take() else { return vec![] }; - let mut out = self.finish(); - // Click into existing text: place the caret. - if let Some(h) = hit_test(cx.doc, start, cx.hit_options()) - && let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) - { - let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); - let local = t.xf.inverse() * start; - let byte = drawcraft_text::hit_byte(&lay, local); - self.editing = Some((h.leaf, byte)); - self.buffer = t.plain_text(); - out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))); - return out; + if self.selecting { + self.selecting = false; + return vec![]; } - let area = self.drag.take().map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); - let params = match area { - Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}), - None => json!({"x": start.x, "y": start.y, "text": ""}), - }; - out.push(Action::Exec("text.create".into(), params)); - out.push(Action::Notify("text.editNew".into())); - out + let Some(start) = self.press.take() else { return vec![] }; + self.on_up(cx, start) } - _ => vec![], + PointerKind::DoubleClick => { + if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() { + self.select_word_or_para(cx, false); + self.clicks = (Some(ev.pos), 2); + } + vec![] + } + PointerKind::Move => vec![], } } fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec { - let Some((id, caret)) = self.editing else { return vec![] }; - if !self.typing { - self.buffer = Self::text_of(cx, id).unwrap_or_default(); + if self.editing.is_none() { + return vec![]; } - let caret = caret.min(self.buffer.len()); - let mut t = self.buffer.clone(); - t.insert_str(caret, s); - self.edit(t, caret + s.len()) + let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect(); + if s.is_empty() { + return vec![]; + } + self.clicks = (None, 0); + // Pasting what we copied keeps its formatting. + let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); + if s.len() > 1 && s == clip { + let runs = self.clipboard.clone(); + return self.replace(cx, "", Some(runs)); + } + self.replace(cx, &s, None) } - fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - let Some((id, caret)) = self.editing else { return vec![] }; - if !self.typing { - self.buffer = Self::text_of(cx, id).unwrap_or_default(); + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + let Some(t) = self.current(cx) else { + self.editing = None; + return vec![]; + }; + let text = t.plain_text(); + let len = text.len(); + self.caret = self.caret.min(len); + self.anchor = self.anchor.min(len); + let (a, b) = self.sel(); + let word = mods.cmd || mods.alt; + let lay = self.layout(&t); + let vertical = matches!(key, ToolKey::Up | ToolKey::Down); + if !vertical { + self.goal_x = None; } - let caret = caret.min(self.buffer.len()); match key { - ToolKey::Escape => { - let out = self.finish(); - self.editing = None; - out + ToolKey::Escape => self.finish(), + ToolKey::Enter => self.replace(cx, "\n", None), + ToolKey::Tab => self.replace(cx, "\t", None), + ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), + ToolKey::Backspace if self.caret > 0 => { + self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; + self.replace(cx, "", None) } - ToolKey::Enter => self.text_input(cx, "\n"), - ToolKey::Backspace if caret > 0 => { - let prev = self.buffer[..caret].char_indices().last().map(|(i, _)| i).unwrap_or(0); - let mut t = self.buffer.clone(); - t.replace_range(prev..caret, ""); - self.edit(t, prev) - } - ToolKey::Delete if caret < self.buffer.len() => { - let next = self.buffer[caret..].char_indices().nth(1).map(|(i, _)| caret + i).unwrap_or(self.buffer.len()); - let mut t = self.buffer.clone(); - t.replace_range(caret..next, ""); - self.edit(t, caret) + ToolKey::Delete if self.caret < len => { + self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_char(&text, self.caret) }; + self.replace(cx, "", None) } ToolKey::Left => { - let prev = self.buffer[..caret].char_indices().last().map(|(i, _)| i).unwrap_or(0); - self.editing = Some((id, prev)); - vec![] + let to = if a != b && !mods.shift { + a + } else if word { + edit::prev_word(&text, self.caret) + } else { + edit::prev_char(&text, self.caret) + }; + self.move_to(to, mods.shift) } ToolKey::Right => { - let next = self.buffer[caret..].char_indices().nth(1).map(|(i, _)| caret + i).unwrap_or(self.buffer.len()); - self.editing = Some((id, next)); - vec![] + let to = if a != b && !mods.shift { + b + } else if word { + edit::next_word(&text, self.caret) + } else { + edit::next_char(&text, self.caret) + }; + self.move_to(to, mods.shift) + } + ToolKey::Up | ToolKey::Down => { + let d = if key == ToolKey::Up { -1 } else { 1 }; + let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret }; + let x = self.goal_x.unwrap_or_else(|| drawcraft_text::caret_position(&lay, from).0.x); + let to = if mods.cmd { + // Cmd+Up/Down: paragraph start / end. + let p = edit::paragraph_at(&text, from); + if d < 0 { + if from == p.start { edit::paragraph_at(&text, p.start.saturating_sub(1)).start } else { p.start } + } else if from == p.end { + edit::paragraph_at(&text, (p.end + 1).min(len)).end + } else { + p.end + } + } else { + drawcraft_text::caret_vertical(&lay, from, d, x) + }; + let out = self.move_to(to, mods.shift); + self.goal_x = Some(x); + out + } + ToolKey::Home => { + let to = if mods.cmd { 0 } else { drawcraft_text::line_home(&lay, self.caret) }; + self.move_to(to, mods.shift) + } + ToolKey::End => { + let to = if mods.cmd { len } else { drawcraft_text::line_end_of(&lay, self.caret) }; + self.move_to(to, mods.shift) } _ => vec![], } @@ -150,13 +448,51 @@ impl Tool for TypeTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.finish() } - /// After `text.create` the engine selects the new object; start editing it. + /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. fn notify(&mut self, cx: &ToolContext, what: &str) { if what == "text.editNew" && let Some(id) = cx.selection.objects.first().copied() { - self.editing = Some((id, 0)); - self.buffer.clear(); + self.start_editing(id, 0); + } + } + /// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`. + fn options(&self) -> Value { + match self.editing { + Some(id) => { + // Offsets may exceed the text after Select All; callers clamp to the text length. + let (a, b) = self.sel(); + json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some()}) + } + None => json!({"editing": null}), + } + } + /// `select {start, end}` / `selectAll` / `copy` (with `runs` of the selection) / + /// `commitTyping` (the caller commits the engine interaction). + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "commitTyping" | "endTyping" => self.typing = None, + "selectAll" if self.editing.is_some() => { + self.anchor = 0; + self.caret = usize::MAX / 4; + } + "select" if self.editing.is_some() => { + let g = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize); + if let (Some(a), Some(b)) = (g("start"), g("end")) { + self.anchor = a; + self.caret = b; + } + } + "copy" => { + if let Ok(runs) = serde_json::from_value::>(v.clone()) { + self.clipboard = runs; + } + } + "stopEditing" => { + self.typing = None; + self.editing = None; + } + _ => {} } } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -164,16 +500,49 @@ impl Tool for TypeTool { if let (Some(s), Some(d)) = (self.press, self.drag) { o.push(Overlay::Marquee(Rect::from_points(s, d))); } - if let Some((id, caret)) = self.editing - && let Some(NodeKind::Text(t)) = cx.doc.node(id).map(|n| &n.kind) - { - let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); - let (a, b) = drawcraft_text::caret_position(&lay, caret.min(t.plain_text().len())); - o.push(Overlay::Line { a: t.xf * a, b: t.xf * b, color: [0, 0, 0], dashed: false }); + // Overflow markers on selected text. + for id in &cx.selection.objects { + if Some(*id) == self.editing { + continue; + } + if let Some(t) = Self::text(cx, *id) { + let lay = self.layout(t); + Self::overflow_marker(t, &lay, cx, &mut o); + } } + let Some(t) = self.current(cx) else { return o }; + let lay = self.layout(&t); + let len = t.plain_text().len(); + // Frame / baseline path of the edited object. + let frame: Option = match &t.kind { + TextKind::Area { frame } => Some(frame.to_bezpath()), + TextKind::OnPath { path, .. } => Some(path.to_bezpath()), + TextKind::Point => None, + }; + if let Some(mut f) = frame { + f.apply_affine(t.xf); + o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false }); + } + let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); + if a == b { + let (p, q) = drawcraft_text::caret_position(&lay, self.caret.min(len)); + o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); + } else { + for q in drawcraft_text::selection_quads(&lay, a, b) { + o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] }); + } + } + Self::overflow_marker(&t, &lay, cx, &mut o); o } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() { + return Cursor::Crosshair; + } Cursor::Text } } + +#[cfg(test)] +#[path = "text_tests.rs"] +mod tests; diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs new file mode 100644 index 000000000..f4253a3c4 --- /dev/null +++ b/crates/tools/src/text_tests.rs @@ -0,0 +1,220 @@ +//! Type tool state-machine tests (the engine side is covered in drawcraft-engine's +//! `tests_textedit`). + +use super::*; +use crate::testutil::{cx, paint}; +use drawcraft_doc::{CharStyle, Document, Node, Selection}; + +fn doc_with_text(text: &str) -> (Document, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let t = TextObject::point(Point::new(100.0, 100.0), text, CharStyle { size: 20.0, ..Default::default() }); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + (d, id) +} + +fn editing(text: &str) -> (Document, NodeId, TypeTool) { + let (d, id) = doc_with_text(text); + let mut tool = TypeTool::new("type"); + tool.start_editing(id, 0); + (d, id, tool) +} + +fn key(tool: &mut TypeTool, d: &Document, k: ToolKey, mods: Mods) -> Vec { + let (sel, p) = (Selection::default(), paint()); + tool.key(&cx(d, &sel, &p), k, mods) +} + +const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; +const CMD: Mods = Mods { shift: false, alt: false, cmd: true, ctrl: false, space: false }; + +/// Apply the tool's previews like the engine would (on top of the session snapshot). +fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) { + for a in acts { + if let Action::Preview(cmd, p) = a { + assert_eq!(cmd, "text.editRange"); + *d = snapshot.clone(); + let id = NodeId(p["id"].as_u64().unwrap()); + let runs: Vec = serde_json::from_value(p["runs"].clone()).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + edit::replace_range_styled(&mut t.runs, p["start"].as_u64().unwrap() as usize, p["end"].as_u64().unwrap() as usize, &runs); + } + } + } +} + +#[test] +fn typing_is_one_coalesced_preview() { + let (mut d, id, mut tool) = editing("Hello"); + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 5; + tool.anchor = 5; + let a1 = tool.text_input(&cx(&d, &sel, &p), " w"); + assert_eq!(a1[0], Action::Begin("Typing".into())); + apply(&mut d, &snap, &a1); + let a2 = tool.text_input(&cx(&d, &sel, &p), "orld"); + apply(&mut d, &snap, &a2); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "Hello world"); + assert!(!a2.iter().any(|a| matches!(a, Action::Begin(_))), "same session"); + let Action::Preview(cmd, params) = a2.last().unwrap() else { panic!("{a2:?}") }; + assert_eq!(cmd, "text.editRange"); + assert_eq!(params["id"], json!(id.0)); + assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(5), Some(5))); + let runs: Vec = serde_json::from_value(params["runs"].clone()).unwrap(); + assert_eq!(runs[0].text, " world"); + assert_eq!(tool.caret, 11); + // Moving the caret ends the session. + let a3 = key(&mut tool, &d, ToolKey::Left, Mods::default()); + assert_eq!(a3, vec![Action::Commit]); + assert_eq!(tool.caret, 10); + // A document change behind the tool's back (undo) starts a fresh session. + let a4 = tool.text_input(&cx(&snap, &sel, &p), "!"); + assert!(a4.contains(&Action::Begin("Typing".into()))); +} + +#[test] +fn shift_arrows_select_and_typing_replaces() { + let (d, _, mut tool) = editing("Hello world"); + key(&mut tool, &d, ToolKey::End, Mods::default()); + assert_eq!(tool.caret, 11); + key(&mut tool, &d, ToolKey::Left, Mods { alt: true, ..SHIFT }); + assert_eq!(tool.sel(), (6, 11)); + let (sel, p) = (Selection::default(), paint()); + let acts = tool.text_input(&cx(&d, &sel, &p), "there"); + let Action::Preview(_, params) = acts.last().unwrap() else { panic!() }; + assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(6), Some(11))); + assert_eq!(tool.caret, 11); + assert_eq!(tool.sel(), (11, 11)); +} + +#[test] +fn word_and_line_navigation_keys() { + let (d, _, mut tool) = editing("One two three"); + key(&mut tool, &d, ToolKey::Right, CMD); + assert_eq!(tool.caret, 3); + key(&mut tool, &d, ToolKey::Right, CMD); + assert_eq!(tool.caret, 7); + key(&mut tool, &d, ToolKey::Left, CMD); + assert_eq!(tool.caret, 4); + key(&mut tool, &d, ToolKey::End, SHIFT); + assert_eq!(tool.sel(), (4, 13)); + // Left collapses a selection to its start. + key(&mut tool, &d, ToolKey::Left, Mods::default()); + assert_eq!(tool.sel(), (4, 4)); + key(&mut tool, &d, ToolKey::Home, Mods::default()); + assert_eq!(tool.caret, 0); + key(&mut tool, &d, ToolKey::End, CMD); + assert_eq!(tool.caret, 13); +} + +#[test] +fn up_down_move_between_lines() { + let (d, _, mut tool) = editing("First line\nSecond line"); + tool.caret = 3; + tool.anchor = 3; + key(&mut tool, &d, ToolKey::Down, Mods::default()); + assert!(tool.caret > 11 && tool.caret < 16, "{}", tool.caret); + key(&mut tool, &d, ToolKey::Up, Mods::default()); + assert_eq!(tool.caret, 3, "goal column kept"); + key(&mut tool, &d, ToolKey::Down, SHIFT); + key(&mut tool, &d, ToolKey::Down, SHIFT); + assert_eq!(tool.sel(), (3, 22)); +} + +#[test] +fn backspace_deletes_selection_or_word() { + let (d, _, mut tool) = editing("Hello big world"); + tool.caret = 9; + tool.anchor = 9; + let acts = key(&mut tool, &d, ToolKey::Backspace, Mods { alt: true, ..Default::default() }); + let Action::Preview(_, params) = acts.last().unwrap() else { panic!() }; + assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(6), Some(9))); + assert_eq!(tool.caret, 6); +} + +#[test] +fn double_and_triple_click_select_word_and_paragraph() { + let (d, id, mut tool) = editing("Alpha beta\ngamma"); + let (sel, p) = (Selection::default(), paint()); + let t = TypeTool::text(&cx(&d, &sel, &p), id).unwrap().clone(); + let lay = drawcraft_text::layout(FontDb::global(), &t); + let (top, bot) = drawcraft_text::caret_position(&lay, 7); + let at = t.xf * top.midpoint(bot); + let c = cx(&d, &sel, &p); + for k in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + tool.pointer(&c, &PointerEvent::new(k, at.x, at.y)); + } + assert_eq!(tool.sel(), (6, 10), "word"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x, at.y)); + assert_eq!(tool.sel(), (0, 10), "paragraph"); +} + +#[test] +fn drag_selects_and_overlay_highlights() { + let (d, id, mut tool) = editing("Select me please"); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let t = TypeTool::text(&c, id).unwrap().clone(); + let lay = drawcraft_text::layout(FontDb::global(), &t); + let pt = |b: usize| { + let (a, bb) = drawcraft_text::caret_position(&lay, b); + t.xf * a.midpoint(bb) + }; + let (a, b) = (pt(7), pt(9)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y)); + assert!(tool.busy()); + tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, b.x + 0.1, b.y)); + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, b.x + 0.1, b.y)); + assert_eq!(tool.sel(), (7, 9)); + let o = tool.overlays(&c); + assert!(o.iter().any(|o| matches!(o, Overlay::Highlight { .. }))); + let opts = tool.options(); + assert_eq!((opts["start"].as_u64(), opts["end"].as_u64()), (Some(7), Some(9))); +} + +#[test] +fn select_all_and_styled_paste() { + let (d, _, mut tool) = editing("abc"); + tool.set_option("selectAll", &json!(true)); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + tool.key(&c, ToolKey::Right, SHIFT); + assert_eq!(tool.sel(), (0, 3)); + let big = TextRun { text: "XY".into(), style: CharStyle { size: 40.0, ..Default::default() } }; + tool.set_option("copy", &serde_json::to_value(vec![big.clone()]).unwrap()); + let acts = tool.text_input(&c, "XY"); + let Action::Preview(_, params) = acts.last().unwrap() else { panic!() }; + let runs: Vec = serde_json::from_value(params["runs"].clone()).unwrap(); + assert_eq!(runs, vec![big]); +} + +#[test] +fn common_affixes_are_char_safe() { + assert_eq!(common_affixes("hello", "help"), (3, 0)); + assert_eq!(common_affixes("aXb", "ab"), (1, 1)); + assert_eq!(common_affixes("aaa", "aaaa"), (3, 0)); + assert_eq!(common_affixes("é1", "è1"), (0, 1)); +} + +#[test] +fn area_and_path_tools_convert_clicked_paths() { + let (d, rect) = crate::testutil::doc_with_rect(); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let mut tool = TypeTool::new("areaType"); + assert_eq!(tool.id(), "areaType"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 150.0)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0)); + assert!( + acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["path"] == json!(rect.0) && p["mode"] == "area")), + "{acts:?}" + ); + let mut tool = TypeTool::new("typeOnPath"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0)); + assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}"); +} diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 299d2a58f..625a8353f 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -1,6 +1,6 @@ //! Loop → polygon → Bézier curves. -use drawcraft_geom::{AnchorKind, PathData, Point, SubPath}; +use drawcraft_geom::{AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath}; use drawcraft_pathops::{SimplifyOptions, simplify_with}; use crate::contour::Loop; @@ -32,8 +32,8 @@ fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) { } 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); + for (i, &q) in pts.iter().enumerate().take(b).skip(a + 1) { + let d = seg_dist(q, pa, pb); if d > best.0 { best = (d, i); } @@ -48,7 +48,12 @@ fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) { /// 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 raw: Vec = loop_pts.iter().map(|&(x, y)| Point::new(x as f64, y as f64)).collect(); + if raw.len() < 4 { + return raw; + } + let pts = dejag(loop_pts); + let pts = if pts.len() < 3 { raw.clone() } else { pts }; let n = pts.len(); if n < 4 { return pts; @@ -70,6 +75,42 @@ pub(crate) fn polygon(loop_pts: &[(i32, i32)], tol: f64) -> Vec { out.remove(0); } } + if out.len() < 3 { raw } else { out } +} + +/// Replace pixel-staircase jogs by their midpoints. +/// +/// A unit-length edge whose two ends turn in opposite directions (an S-shaped step) is a jog of +/// a staircase approximating a sloped line or curve: its midpoint lies on the ideal boundary, its +/// end vertices don't. Vertices not adjacent to any jog (true corners, e.g. of rectangles, or the +/// U-turns at the end of one-pixel-wide lines) are kept. +fn dejag(v: &[(i32, i32)]) -> Vec { + let n = v.len(); + let edge = |i: usize| { + let (a, b) = (v[i % n], v[(i + 1) % n]); + (b.0 - a.0, b.1 - a.1) + }; + // Turn sign at vertex i (between edge i-1 and edge i). + let turn = |i: usize| { + let (a, b) = (edge((i + n - 1) % n), edge(i)); + (a.0 as i64 * b.1 as i64 - a.1 as i64 * b.0 as i64).signum() + }; + let jog: Vec = (0..n) + .map(|i| { + let (dx, dy) = edge(i); + dx.abs() + dy.abs() == 1 && turn(i) * turn(i + 1) < 0 + }) + .collect(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + if !jog[i] && !jog[(i + n - 1) % n] { + out.push(Point::new(v[i].0 as f64, v[i].1 as f64)); + } + if jog[i] { + let (a, b) = (v[i], v[(i + 1) % n]); + out.push(Point::new((a.0 + b.0) as f64 / 2.0, (a.1 + b.1) as f64 / 2.0)); + } + } out } @@ -87,12 +128,12 @@ pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option { return None; } if o.snap_lines { - snap_to_lines(&mut sp, o.fit_tol.max(0.75)); + snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5)); } Some(sp) } -/// Retract the handles of curves whose control points lie within `tol` of their chord. +/// Retract the handles of curves that stay 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() { @@ -101,7 +142,8 @@ fn snap_to_lines(sp: &mut SubPath, tol: f64) { 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 { + let c = CubicBez::new(a.p, a.h_out, b.h_in, b.p); + if (1..8).all(|k| seg_dist(c.eval(k as f64 / 8.0), a.p, b.p) <= tol) { sp.anchors[i].h_out = a.p; sp.anchors[j].h_in = b.p; } diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 1aedd3a68..4fc30d87f 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -74,6 +74,14 @@ impl Raster { } Ok(Self { width: w, height: h, rgba: img.into_raw() }) } + /// Encode as PNG. + pub fn encode_png(&self) -> Vec { + let mut out = Vec::new(); + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); + } + out + } 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]] diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 6ae38a4f1..0b26de74f 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -23,7 +23,17 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized { 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(); + 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 => { @@ -231,8 +241,8 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> { 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; + for (acc, v) in s.iter_mut().zip(used[i].1) { + *acc += v * used[i].2; } w += used[i].2; } diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index b2a3e7f66..61be59ff5 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -245,9 +245,7 @@ fn params_json_roundtrip_and_partial() { #[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 bytes = img.encode_png(); let r = Raster::decode(&bytes).unwrap(); assert_eq!(r, img); assert!(Raster::decode(b"not an image").is_err()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2af343c2f..2fa6027c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -779,6 +779,10 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { let sp = xf.to_screen(*pt) + vec2(8.0, -14.0); p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color)); } + Overlay::Highlight { quad, color } => { + let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); + p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE)); + } Overlay::Measure { p: pt, text } => { let sp = xf.to_screen(*pt) + vec2(14.0, 14.0); let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 63470cd91..e7f564070 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -132,7 +132,12 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result { 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 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")}); app.ui.dialog = None; let r = app.run("document.exportForScreens", params); @@ -431,7 +436,8 @@ fn new_document(ui: &mut egui::Ui, d: &mut Dialog) { fn export_for_screens_ui(app: &mut DrawcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let t = Tokens::get(ui.ctx()); let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - let mut boards: Vec = d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); + let mut boards: Vec = + d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); boards.resize(names.len(), true); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -454,7 +460,12 @@ fn export_for_screens_ui(app: &mut DrawcraftApp, ui: &mut egui::Ui, d: &mut Dial let sz = tex.size_vec2(); let s = (46.0 / sz.x.max(sz.y)).min(1.0); let ir = egui::Rect::from_center_size(r.center(), sz * s); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + ui.painter().image( + tex.id(), + ir, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); } None => { ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea6c9827c..dcb63815d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -100,6 +100,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ /// Handle a UI command. `None` = not a UI command (the engine handles it). pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option> { + if let Some(r) = crate::panels::character::intercept_text_command(app, id) { + return Some(r); + } let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); let flag = |b: &mut bool| { *b = !*b; @@ -602,7 +605,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Expand Appearance", "effect.expandAppearance"), c("Crop Image", "object.cropImage"), cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})), - todo("Create Gradient Mesh…"), + cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), todo("Create Object Mosaic…"), c("Create Trim Marks", "object.createTrimMarks"), todo("Flatten Transparency…"), @@ -643,7 +646,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Expand", "object.blend.expand"), Sep, - todo("Replace Spine"), + c("Replace Spine", "object.blend.replaceSpine"), c("Reverse Spine", "object.blend.reverseSpine"), c("Reverse Front to Back", "object.blend.reverseFrontToBack"), ], @@ -651,18 +654,43 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Envelope Distort", vec![ - todos("Make with Warp…", "Cmd+Alt+Shift+W"), - todos("Make with Mesh…", "Cmd+Alt+M"), - todos("Make with Top Object", "Cmd+Alt+C"), + cp( + "Make with Warp…", + "object.envelope.makeWithWarp", + json!({"style": "arc", "bend": 50, "h": 0, "v": 0, "horizontal": true}), + ), + cp("Make with Mesh…", "object.envelope.makeWithMesh", json!({"rows": 4, "cols": 4})), + c("Make with Top Object", "object.envelope.makeWithTopObject"), Sep, - todo("Release"), - todo("Envelope Options…"), - todo("Expand"), + c("Release", "object.envelope.release"), + cp("Envelope Options…", "object.envelope.options", json!({"fidelity": 50})), + c("Expand", "object.envelope.expand"), + Sep, + c("Edit Contents", "object.envelope.editContents"), ], ), sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]), - sub("Live Paint", vec![todos("Make", "Cmd+Alt+X"), todo("Merge"), todo("Release"), Sep, todo("Gap Options…"), Sep, todo("Expand")]), - sub("Image Trace", vec![todo("Make"), todo("Make and Expand"), todo("Release"), todo("Expand")]), + sub( + "Live Paint", + vec![ + c("Make", "livePaint.make"), + c("Merge", "livePaint.merge"), + c("Release", "livePaint.release"), + Sep, + todo("Gap Options…"), + Sep, + c("Expand", "livePaint.expand"), + ], + ), + sub( + "Image Trace", + vec![ + cp("Make…", "imageTrace.make", json!({"preset": "Black and White Logo"})), + cp("Make and Expand…", "imageTrace.makeAndExpand", json!({"preset": "6 Colors"})), + c("Release", "imageTrace.release"), + c("Expand", "imageTrace.expand"), + ], + ), sub("Text Wrap", vec![todo("Make"), todo("Release"), todo("Text Wrap Options…")]), Sep, sub("Clipping Mask", vec![c("Make", "object.clippingMask.make"), c("Release", "object.clippingMask.release"), todo("Edit Contents")]), diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index e673cd342..f7b9d3c4c 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -28,8 +28,14 @@ pub fn default_sets() -> Vec { name: "Default Actions".into(), actions: vec![ Action { name: "Duplicate and Offset 10 pt".into(), steps: vec![step("object.move", json!({"dx": 10, "dy": 10, "copy": true}))] }, - Action { name: "Unite and Simplify".into(), steps: vec![step("object.pathfinder.unite", json!({})), step("object.path.simplify", json!({"tolerance": 0.5}))] }, - Action { name: "Center on Artboard".into(), steps: vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))] }, + Action { + name: "Unite and Simplify".into(), + steps: vec![step("object.pathfinder.unite", json!({})), step("object.path.simplify", json!({"tolerance": 0.5}))], + }, + Action { + name: "Center on Artboard".into(), + steps: vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))], + }, Action { name: "Outline Text".into(), steps: vec![step("type.createOutlines", json!({}))] }, Action { name: "Rotate 90° CW".into(), steps: vec![step("object.rotate", json!({"angle": -90}))] }, Action { name: "Reflect Horizontal".into(), steps: vec![step("object.reflect", json!({"axis": "vertical"}))] }, @@ -60,8 +66,20 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut egui::Ui) { } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - ui.painter().text(r.left_center() + egui::vec2(18.0, 0.0), egui::Align2::LEFT_CENTER, &a.name, egui::FontId::proportional(12.5), t.text_strong); - ui.painter().text(r.right_center() - egui::vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{} step{}", a.steps.len(), if a.steps.len() == 1 { "" } else { "s" }), egui::FontId::proportional(11.0), t.text_dim); + ui.painter().text( + r.left_center() + egui::vec2(18.0, 0.0), + egui::Align2::LEFT_CENTER, + &a.name, + egui::FontId::proportional(12.5), + t.text_strong, + ); + ui.painter().text( + r.right_center() - egui::vec2(6.0, 0.0), + egui::Align2::RIGHT_CENTER, + format!("{} step{}", a.steps.len(), if a.steps.len() == 1 { "" } else { "s" }), + egui::FontId::proportional(11.0), + t.text_dim, + ); if resp.clicked() { selected = Some((si, ai)); } diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 5ec0a66b2..54950c59f 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -1,96 +1,219 @@ -//! Brushes panel: the brushes referenced by this document's strokes (list or thumbnails), with an -//! honest empty state until brush definitions land. +//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush +//! applies it to the selected paths (and makes it the Paintbrush's current brush). -use std::collections::BTreeSet; +use std::cell::RefCell; +use std::collections::HashMap; -use drawcraft_doc::{AppearanceItem, Node}; +use drawcraft_doc::{Appearance, Document, Node, color::Color, color::Paint}; use egui::{Sense, Ui, vec2}; -use serde_json::json; +use serde_json::{Value, json}; use super::{first_selected, pstate, set_pstate}; use crate::DrawcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; -fn collect(n: &Node, out: &mut BTreeSet) { - n.walk(&mut |c| { - for it in &c.appearance.items { - if let AppearanceItem::Stroke(s) = it - && let Some(b) = &s.brush - { - out.insert(b.clone()); - } - } - }); +const KINDS: [(&str, &str); 5] = + [("calligraphic", "Calligraphic"), ("scatter", "Scatter"), ("art", "Art"), ("bristle", "Bristle"), ("pattern", "Pattern")]; + +/// (name, type) of every brush in the active document's library. +pub fn brushes(app: &mut DrawcraftApp) -> (Vec<(String, String)>, Option) { + let Ok(v) = app.run("brush.list", json!({})) else { return (vec![], None) }; + let list = v["brushes"] + .as_array() + .map(|a| a.iter().map(|b| (b["name"].as_str().unwrap_or("").to_string(), b["type"].as_str().unwrap_or("").to_string())).collect()) + .unwrap_or_default(); + (list, v["current"].as_str().map(str::to_string)) } -/// Brush names used anywhere in the document. -pub fn used_brushes(app: &DrawcraftApp) -> Vec { - let mut set = BTreeSet::new(); - if let Some(st) = app.session.active() { - for l in &st.doc.layers { - collect(l, &mut set); - } +/// A stroke preview for brush definition `def`, rendered at `size` pixels and cached by the +/// definition's JSON. +fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option { + thread_local! { + static RENDERER: RefCell = RefCell::new(drawcraft_render::Renderer::new()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); } - set.into_iter().collect() + let ppp = ui.ctx().pixels_per_point() as f64; + let (w, h) = (size.x as f64, size.y as f64); + let key = format!("{w}x{h}@{ppp}:{def}"); + if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let mut doc = Document::new(w, h); + doc.unknown.insert("brushes".into(), json!([def])); + let name = def["name"].as_str()?.to_string(); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut()?.brush = Some(name); + // A gentle S-curve across the swatch. + let mut bp = drawcraft_geom::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.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5)); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, drawcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; + let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, drawcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("brush-{key}"), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} + +fn selected_brush(app: &DrawcraftApp) -> Option { + first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())) } pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let brushes = used_brushes(app); - let sel_brush = first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())); + let (list, current) = brushes(app); + let sel_brush = selected_brush(app); + let list_view: bool = pstate(ui.ctx(), "br-list"); + let hidden: Vec = pstate(ui.ctx(), "br-hidden"); + let defs: HashMap = + list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect(); + let mut clicked: Option = None; widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); - if brushes.is_empty() { - super::empty_state( - ui, - "paintbrush", - "No brushes in this document", - "Brush definitions (calligraphic, art, scatter, pattern) are on the roadmap.", - ); + if list.is_empty() { + super::empty_state(ui, "paintbrush", "No brushes", "Select art and use New Brush to make one."); return; } - for b in &brushes { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); - if sel_brush.as_deref() == Some(b.as_str()) { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); + for (ty, _) in KINDS { + if hidden.iter().any(|h| h == ty) { + continue; } - let stroke_r = egui::Rect::from_min_size(r.left_center() + vec2(6.0, -5.0), vec2(60.0, 10.0)); - super::stroke::paint_profile(ui, stroke_r, Some(&drawcraft_doc::WidthProfile::lens()), t.text_strong); - ui.painter().text(r.left_center() + vec2(76.0, 0.0), egui::Align2::LEFT_CENTER, b, egui::FontId::proportional(12.5), t.text); - if resp.clicked() { - app.run("stroke.setAdvanced", json!({"brush": b})).ok(); + let group: Vec<&(String, String)> = list.iter().filter(|b| b.1 == ty).collect(); + if group.is_empty() { + continue; } + let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response { + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let on = sel_brush.as_deref() == Some(name) || (sel_brush.is_none() && current.as_deref() == Some(name)); + if on { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let pw = if label { 72.0 } else { size.x - 4.0 }; + let pr = egui::Rect::from_min_size(r.left_top() + vec2(2.0, 2.0), vec2(pw, size.y - 4.0)); + ui.painter().rect_filled(pr, 0.0, egui::Color32::WHITE); + if let Some(def) = defs.get(name) + && let Some(tex) = preview(ui, def, pr.size()) + { + ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } + if label { + ui.painter().text( + r.left_center() + vec2(pw + 10.0, 0.0), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(12.5), + t.text, + ); + } + resp.on_hover_text(name) + }; + if list_view { + for (name, _) in group { + if row(ui, name, vec2(ui.available_width(), 26.0), true).clicked() { + clicked = Some(name.clone()); + } + } + } else { + ui.horizontal_wrapped(|ui| { + for (name, _) in group { + if row(ui, name, vec2(64.0, 28.0), false).clicked() { + clicked = Some(name.clone()); + } + } + }); + } + ui.separator(); } }); + if let Some(name) = clicked { + let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); + if has_sel { + app.run("brush.apply", json!({"name": name})).ok(); + } else { + app.run("brush.setCurrent", json!({"name": name})).ok(); + } + } let has_brush = sel_brush.is_some(); + let target = sel_brush.clone().or(current.clone()); + let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", "Brush Libraries (on the roadmap)", false, false, 24.0); if widgets::icon_button_enabled(ui, "dc-remove-brush", "Remove Brush Stroke", false, has_brush, 24.0).clicked() { - app.run("stroke.setAdvanced", json!({"brush": null})).ok(); + app.run("brush.remove", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "dc-options", "Expand Brush Strokes", false, has_brush, 24.0).clicked() { + app.run("object.expandBrush", json!({})).ok(); } - widgets::icon_button_enabled(ui, "dc-options", "Options of Selected Object (on the roadmap)", false, false, 24.0); ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); - widgets::icon_button_enabled(ui, "dc-new-item", "New Brush (on the roadmap)", false, false, 24.0); - widgets::icon_button_enabled(ui, "trash-2", "Delete Brush (on the roadmap)", false, false, 24.0); + if widgets::icon_button_enabled(ui, "dc-new-item", "New Art Brush from Selection", false, has_sel, 24.0).clicked() { + app.run("brush.new", json!({"type": "art"})).ok(); + } + if widgets::icon_button_enabled(ui, "trash-2", "Delete Brush", false, target.is_some(), 24.0).clicked() + && let Some(n) = &target + { + app.run("brush.delete", json!({"name": n})).ok(); + } }); } pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { - let has_brush = first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())).is_some(); - for l in ["New Brush…", "Duplicate Brush", "Delete Brush"] { - menu_item(ui, l, false, false); + let sel = selected_brush(app); + let (_, current) = brushes(app); + let target = sel.clone().or(current); + let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); + for (label, ty) in [("New Calligraphic Brush", "calligraphic"), ("New Bristle Brush", "bristle")] { + if menu_item(ui, label, true, false) { + app.run("brush.new", json!({"type": ty})).ok(); + } } - if menu_item(ui, "Remove Brush Stroke", has_brush, false) { - app.run("stroke.setAdvanced", json!({"brush": null})).ok(); + for (label, ty) in + [("New Art Brush from Selection", "art"), ("New Scatter Brush from Selection", "scatter"), ("New Pattern Brush from Selection", "pattern")] + { + if menu_item(ui, label, has_sel, false) { + app.run("brush.new", json!({"type": ty})).ok(); + } + } + if menu_item(ui, "Duplicate Brush", target.is_some(), false) + && let Some(n) = &target + { + app.run("brush.duplicate", json!({"name": n})).ok(); + } + if menu_item(ui, "Delete Brush", target.is_some(), false) + && let Some(n) = &target + { + app.run("brush.delete", json!({"name": n})).ok(); + } + if menu_item(ui, "Remove Brush Stroke", sel.is_some(), false) { + app.run("brush.remove", json!({})).ok(); + } + if menu_item(ui, "Expand Brush Strokes", sel.is_some(), false) { + app.run("object.expandBrush", json!({})).ok(); } - menu_item(ui, "Select All Unused", false, false); ui.separator(); - for l in ["Show Calligraphic Brushes", "Show Scatter Brushes", "Show Art Brushes", "Show Bristle Brushes", "Show Pattern Brushes"] { - menu_item(ui, l, false, true); + let mut hidden: Vec = pstate(ui.ctx(), "br-hidden"); + for (ty, label) in KINDS { + let shown = !hidden.iter().any(|h| h == ty); + if menu_item(ui, &format!("Show {label} Brushes"), true, shown) { + if shown { + hidden.push(ty.to_string()); + } else { + hidden.retain(|h| h != ty); + } + set_pstate(ui.ctx(), "br-hidden", hidden.clone()); + } } ui.separator(); let list: bool = pstate(ui.ctx(), "br-list"); @@ -100,7 +223,4 @@ pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { if menu_item(ui, "List View", true, list) { set_pstate(ui.ctx(), "br-list", true); } - ui.separator(); - menu_item(ui, "Brush Options…", false, false); - menu_item(ui, "Open Brush Library", false, false); } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 54ae1f084..8fc5afced 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1,8 +1,11 @@ //! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal //! scale, baseline shift, character rotation and the caps / underline / strikethrough toggles. -use drawcraft_doc::{CharStyle, NodeKind, Unit}; -use egui::{Ui, vec2}; +use std::sync::{Mutex, OnceLock}; + +use drawcraft_doc::{CharStyle, NodeId, NodeKind, Unit}; +use drawcraft_tools::{Mods, ToolKey}; +use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; use super::{first_selected, pstate, set_pstate}; @@ -14,7 +17,14 @@ pub const SIZE_PRESETS: [f64; 16] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, pub const TRACKING_PRESETS: [f64; 13] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0]; pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; +/// The character style shown by the panel: the selected range's (or the caret's) while the Type +/// tool edits text, else the first selected text object's first run. pub(crate) fn text_style(app: &DrawcraftApp) -> Option<(CharStyle, drawcraft_doc::ParaStyle)> { + if let Some((id, a, b)) = text_editing(app) + && let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) + { + return Some((drawcraft_text::edit::insertion_style(&t.runs, a, b), t.para.clone())); + } let n = first_selected(app)?; match &n.kind { NodeKind::Text(t) => Some((t.first_style(), t.para.clone())), @@ -23,10 +33,190 @@ pub(crate) fn text_style(app: &DrawcraftApp) -> Option<(CharStyle, drawcraft_doc } fn style(app: &mut DrawcraftApp, p: Value) { - app.run("text.setStyle", p).ok(); + char_cmd(app, "text.setStyle", p); } fn format(app: &mut DrawcraftApp, p: Value) { - app.run("text.setFormat", p).ok(); + char_cmd(app, "text.setFormat", p); +} + +/// Apply character attributes: to the selected range while the Type tool edits text (the whole +/// object when nothing is selected), else to the selected text objects. +fn char_cmd(app: &mut DrawcraftApp, cmd: &str, p: Value) { + let Some((id, a, b)) = text_editing(app) else { + app.run(cmd, p).ok(); + return; + }; + end_typing(app); + let mut q = p; + q["id"] = json!(id.0); + if b > a { + q["start"] = json!(a); + q["end"] = json!(b); + } + app.run("text.setRangeStyle", q).ok(); +} + +// ---------- Type tool editing glue (selection, clipboard, keys) ---------- + +/// The text object the Type tool is editing and its selection (clamped to the text). +pub(crate) fn text_editing(app: &DrawcraftApp) -> Option<(NodeId, usize, usize)> { + let o = app.session.tool_options(); + let id = NodeId(o.get("editing")?.as_u64()?); + let len = match &app.session.active()?.doc.node(id)?.kind { + NodeKind::Text(t) => t.plain_text().len(), + _ => return None, + }; + let g = |k: &str| o.get(k).and_then(Value::as_u64).map_or(0, |v| (v as usize).min(len)); + Some((id, g("start"), g("end"))) +} + +/// Close the Type tool's typing session (one undo step) before another command edits the text. +pub(crate) fn end_typing(app: &mut DrawcraftApp) { + if app.session.tool_options().get("typing").and_then(Value::as_bool) == Some(true) { + app.session.set_tool_option("commitTyping", &json!(true)); + app.session.commit_interaction().ok(); + app.sync_views(); + } +} + +static CTX: OnceLock = OnceLock::new(); +/// Plain text of the last text copied inside the Type tool (paste from the native menu, which +/// can't read the system clipboard). +static CLIP: Mutex = Mutex::new(String::new()); + +fn text_copy(app: &mut DrawcraftApp) -> bool { + let Some((id, a, b)) = text_editing(app) else { return false }; + if a == b { + return false; + } + let Ok(r) = app.session.execute("text.getRange", &json!({"id": id.0, "start": a, "end": b})) else { return false }; + app.session.set_tool_option("copy", &r["runs"]); + let text = r["text"].as_str().unwrap_or("").to_string(); + if let Some(ctx) = CTX.get() { + ctx.copy_text(text.clone()); + } + *CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text; + true +} + +fn text_key(app: &mut DrawcraftApp, k: ToolKey, m: Mods) { + let v = app.view_info(); + if let Err(e) = app.session.tool_key(k, m, v) { + app.status(e.to_string()); + } +} + +fn text_paste(app: &mut DrawcraftApp, s: Option) { + let s = s.unwrap_or_else(|| CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone()); + if !s.is_empty() { + let v = app.view_info(); + if let Err(e) = app.session.tool_text(&s, v) { + app.status(e.to_string()); + } + } +} + +/// Edit-menu commands while the Type tool edits text act on the text (Cut/Copy/Paste/Select +/// All/Clear). `None` = not intercepted. +pub(crate) fn intercept_text_command(app: &mut DrawcraftApp, id: &str) -> Option> { + if !app.session.tool_wants_text() { + return None; + } + match id { + "edit.copy" => { + text_copy(app); + } + "edit.cut" => { + if text_copy(app) { + text_key(app, ToolKey::Delete, Mods::default()); + } + } + "edit.paste" | "edit.pasteWithoutFormatting" => text_paste(app, None), + "select.all" => app.session.set_tool_option("selectAll", &json!(true)), + "edit.clear" => text_key(app, ToolKey::Delete, Mods::default()), + _ => return None, + } + app.sync_views(); + Some(Ok(Value::Null)) +} + +enum TextInput { + Key(ToolKey, Mods), + Copy, + Cut, + Paste(Option), + SelectAll, +} + +/// Route editing keys (with modifiers), clipboard events and Cmd+A/C/X/V to the Type tool. +pub(crate) fn route_type_input(app: &mut DrawcraftApp, ctx: &egui::Context) { + CTX.get_or_init(|| ctx.clone()); + let mut todo = vec![]; + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Copy => { + todo.push(TextInput::Copy); + false + } + egui::Event::Cut => { + todo.push(TextInput::Cut); + false + } + egui::Event::Paste(s) => { + todo.push(TextInput::Paste(Some(s.clone()))); + false + } + egui::Event::Key { key, pressed, modifiers: m, .. } => { + let mods = Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl && !m.command, space: false }; + let tk = match key { + Key::ArrowLeft => Some(ToolKey::Left), + Key::ArrowRight => Some(ToolKey::Right), + Key::ArrowUp => Some(ToolKey::Up), + Key::ArrowDown => Some(ToolKey::Down), + Key::Home => Some(ToolKey::Home), + Key::End => Some(ToolKey::End), + Key::Backspace => Some(ToolKey::Backspace), + Key::Delete => Some(ToolKey::Delete), + Key::Tab if !m.command => Some(ToolKey::Tab), + _ => None, + }; + if let Some(tk) = tk { + if *pressed { + todo.push(TextInput::Key(tk, mods)); + } + return false; + } + if m.command && !m.shift && !m.alt && matches!(key, Key::A | Key::C | Key::X | Key::V) { + if *pressed { + todo.push(match key { + Key::A => TextInput::SelectAll, + Key::C => TextInput::Copy, + Key::X => TextInput::Cut, + _ => TextInput::Paste(None), + }); + } + return false; + } + true + } + _ => true, + }) + }); + for t in todo { + match t { + TextInput::Key(k, m) => text_key(app, k, m), + TextInput::Copy => { + text_copy(app); + } + TextInput::Cut => { + if text_copy(app) { + text_key(app, ToolKey::Delete, Mods::default()); + } + } + TextInput::Paste(s) => text_paste(app, s), + TextInput::SelectAll => app.session.set_tool_option("selectAll", &json!(true)), + } + } } /// A compact labelled cell: short glyph label + spinner field. @@ -205,6 +395,11 @@ pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { menu_item(ui, l, false, l == "Fractional Widths"); } ui.separator(); + #[cfg(not(target_arch = "wasm32"))] + if menu_item(ui, "Show System Fonts", true, drawcraft_text::FontDb::global().families().len() > 4) { + let n = drawcraft_text::FontDb::global().load_system_fonts(); + app.ui.status = format!("{n} system font faces available"); + } if menu_item(ui, "Reset Panel", has, false) { style(app, json!({"tracking": 0, "leading": "auto"})); format( diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs new file mode 100644 index 000000000..e8306976e --- /dev/null +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -0,0 +1,219 @@ +//! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own +//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the +//! selected text objects). + +use std::cell::RefCell; +use std::collections::HashMap; + +use drawcraft_geom::{Affine, BezPath, PathEl}; +use drawcraft_text::FontDb; +use egui::{Color32, Sense, Ui, vec2}; +use serde_json::json; + +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +const CELL: f32 = 30.0; + +/// Character subsets offered by the Show menu. +const SUBSETS: [(&str, u32, u32); 7] = [ + ("Entire Font", 0, 0x10FFFF), + ("Basic Latin", 0x20, 0x7F), + ("Latin-1 Supplement", 0xA0, 0xFF), + ("Latin Extended", 0x100, 0x24F), + ("Punctuation", 0x2000, 0x206F), + ("Currency & Symbols", 0x20A0, 0x2BFF), + ("Greek & Cyrillic", 0x370, 0x52F), +]; + +thread_local! { + static TEX: RefCell> = RefCell::new(HashMap::new()); +} + +/// Antialiased coverage mask (nonzero winding) of `path` in a `w`×`h` pixel grid. +pub(crate) fn rasterize(path: &BezPath, w: usize, h: usize) -> Vec { + let mut edges: Vec<(f64, f64, f64, f64)> = vec![]; + let (mut start, mut last) = (None, (0.0, 0.0)); + drawcraft_geom::kurbo::flatten(path, 0.1, |el| match el { + PathEl::MoveTo(p) => { + if let Some(s) = start.replace((p.x, p.y)) { + edges.push((last.0, last.1, s.0, s.1)); + } + last = (p.x, p.y); + } + PathEl::LineTo(p) => { + edges.push((last.0, last.1, p.x, p.y)); + last = (p.x, p.y); + } + PathEl::ClosePath => { + if let Some(s) = start.take() { + edges.push((last.0, last.1, s.0, s.1)); + last = s; + } + } + _ => {} + }); + if let Some(s) = start { + edges.push((last.0, last.1, s.0, s.1)); + } + const SUB: usize = 4; + let mut acc = vec![0f32; w * h]; + let mut xs: Vec<(f64, i32)> = vec![]; + for row in 0..h { + for sy in 0..SUB { + let y = row as f64 + (sy as f64 + 0.5) / SUB as f64; + xs.clear(); + for &(x0, y0, x1, y1) in &edges { + if (y0 <= y) != (y1 <= y) { + xs.push((x0 + (y - y0) / (y1 - y0) * (x1 - x0), if y1 > y0 { 1 } else { -1 })); + } + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut wind = 0; + for k in 0..xs.len() { + wind += xs[k].1; + if wind == 0 || k + 1 >= xs.len() { + continue; + } + let (a, b) = (xs[k].0.max(0.0), xs[k + 1].0.min(w as f64)); + if b <= a { + continue; + } + for px in (a.floor() as usize)..(b.ceil() as usize).min(w) { + let cov = (b.min(px as f64 + 1.0) - a.max(px as f64)).max(0.0); + acc[row * w + px] += (cov / SUB as f64) as f32; + } + } + } + } + acc.into_iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect() +} + +fn glyph_texture(ctx: &egui::Context, face: &drawcraft_text::FontFace, gid: u32, px: u32) -> egui::TextureHandle { + let key = (face.id(), gid, px); + if let Some(t) = TEX.with(|c| c.borrow().get(&key).cloned()) { + return t; + } + let (asc, desc) = face.vertical_metrics(); + let em = (asc + desc).max(1.0); + let s = px as f64 * 0.8 / em; + let adv = face.advance(gid); + let mut path = (*FontDb::global().outline(face, gid)).clone(); + // Font units (y down, baseline at 0) → pixels, centred on the advance. + let ox = (px as f64 - adv * s) * 0.5; + let oy = px as f64 * 0.1 + asc * s; + path.apply_affine(Affine::translate((ox, oy)) * Affine::scale(s)); + let mask = rasterize(&path, px as usize, px as usize); + let img = egui::ColorImage::new([px as usize, px as usize], mask.iter().map(|&a| Color32::from_rgba_premultiplied(a, a, a, a)).collect()); + let tex = ctx.load_texture(format!("glyph-{}-{gid}-{px}", face.id()), img, egui::TextureOptions::LINEAR); + TEX.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 4000 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + tex +} + +/// Insert `ch` at the Type tool's caret, else append it to the selected text. +fn insert(app: &mut DrawcraftApp, ch: char) { + let s = ch.to_string(); + if app.session.tool_wants_text() { + let v = app.view_info(); + if let Err(e) = app.session.tool_text(&s, v) { + app.status(e.to_string()); + } + app.sync_views(); + } else if super::character::text_style(app).is_some() { + app.run("type.insert", json!({"text": s})).ok(); + } else { + app.ui.status = "Select text or click into text with the Type tool to insert glyphs".into(); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let db = FontDb::global(); + let current = super::character::text_style(app).map(|(s, _)| (s.font_family, s.font_style)); + // The panel browses the current text's font unless another font was picked here. + let picked: Option<(String, String)> = pstate(ui.ctx(), "gl-font"); + let (family, style) = picked.or(current).unwrap_or_else(|| (drawcraft_text::FALLBACK_FAMILY.to_string(), "Regular".to_string())); + let face = db.face(&family, &style); + let subset: usize = pstate(ui.ctx(), "gl-subset"); + let (_, lo, hi) = SUBSETS[subset.min(SUBSETS.len() - 1)]; + let w = ui.available_width(); + let names: Vec<&str> = SUBSETS.iter().map(|s| s.0).collect(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Show:").color(t.text_dim)); + if let Some(i) = widgets::dropdown(ui, "gl-subset", names[subset.min(names.len() - 1)], &names, (w - 50.0).max(80.0)) { + set_pstate(ui.ctx(), "gl-subset", i); + } + }); + let chars: Vec<(char, u32)> = + face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); + let cols = ((w / CELL).floor() as usize).max(1); + let rows = chars.len().div_ceil(cols); + let px = (CELL * ui.ctx().pixels_per_point()).round() as u32; + let list_h = (ui.available_height() - 64.0).max(CELL * 3.0); + let hover_id = egui::Id::new("gl-hover"); + egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, CELL, rows, |ui, range| { + for r in range { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for &(c, gid) in chars.iter().skip(r * cols).take(cols) { + let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); + } + ui.painter().rect_stroke(rect.shrink(0.5), 0, egui::Stroke::new(0.5, t.border), egui::StrokeKind::Inside); + let tex = glyph_texture(ui.ctx(), &face, gid, px); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); + let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\nDouble-click to insert", c as u32)); + if resp.double_clicked() { + insert(app, c); + } + } + }); + } + }); + ui.add_space(4.0); + let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id)); + ui.label( + egui::RichText::new(match info { + Some((c, g)) => format!("U+{:04X} glyph {g} · {} glyphs", c as u32, chars.len()), + None => format!("{} glyphs", chars.len()), + }) + .size(11.0) + .color(t.text_dim), + ); + let fams = db.families(); + let fnames: Vec<&str> = fams.iter().map(String::as_str).collect(); + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "gl-family", &family, &fnames, (w * 0.6).max(80.0)) { + let st = db.styles(fnames[i]).into_iter().next().unwrap_or_else(|| "Regular".into()); + set_pstate(ui.ctx(), "gl-font", Some((fnames[i].to_string(), st))); + } + let styles = db.styles(&family); + let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "gl-style", &style, &snames, (w * 0.38 - 8.0).max(60.0)) { + set_pstate(ui.ctx(), "gl-font", Some((family.clone(), snames[i].to_string()))); + } + }); +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + if menu_item(ui, "Use the Selected Text's Font", true, false) { + set_pstate::>(ui.ctx(), "gl-font", None); + } + ui.separator(); + for (i, (name, _, _)) in SUBSETS.iter().enumerate() { + let cur: usize = pstate(ui.ctx(), "gl-subset"); + if menu_item(ui, name, true, cur == i) { + set_pstate(ui.ctx(), "gl-subset", i); + } + } +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 14e1151bd..8eb8f578f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -186,7 +186,9 @@ fn row( let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(22.0, 22.0)); ui.painter().rect_filled(th, 0.0, Color32::WHITE); ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); - thumb(ui, n, th); + if !real_thumb(ui, doc, n, th) { + thumb(ui, n, th); + } x += 26.0; // Name. let name = n.display_name(); @@ -288,7 +290,44 @@ fn row( } } -/// Tiny vector thumbnail painted with egui (fast, no raster). +/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` +/// allocation, so the address is a free change detector). Only rendered for visible rows. +fn real_thumb(ui: &Ui, doc: &drawcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static RENDERER: RefCell = RefCell::new(drawcraft_render::Renderer::new()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + if !ui.is_rect_visible(r) { + return true; + } + let key = (n as *const Node as usize, n.id.0); + let ppp = ui.ctx().pixels_per_point(); + let px = (r.width() * ppp).round() as u32; + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { + let img = RENDERER.with(|rr| rr.borrow_mut().render_thumbnail(doc, n.id, px.max(8)))?; + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("layer-thumb-{}", n.id.0), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 2000 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) + }); + match tex { + Some(t) => { + ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + true + } + None => false, + } +} + +/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible). fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { let Some(b) = n.visual_bounds() else { return }; let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6db615452..2f1c59424 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod brushes; pub mod character; pub mod color; pub mod color_guide; +pub mod glyphs; pub mod gradient; pub mod graphic_styles; pub mod history; @@ -65,6 +66,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "gradient" => gradient::show(app, ui), "character" => character::show(app, ui), "paragraph" => paragraph::show(app, ui), + "glyphs" => glyphs::show(app, ui), "navigator" => navigator::show(app, ui), "brushes" => brushes::show(app, ui), "symbols" => symbols::show(app, ui), @@ -93,6 +95,7 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "gradient" => gradient::menu(app, ui), "character" => character::menu(app, ui), "paragraph" => paragraph::menu(app, ui), + "glyphs" => glyphs::menu(app, ui), "navigator" => navigator::menu(app, ui), "brushes" => brushes::menu(app, ui), "symbols" => symbols::menu(app, ui), diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 191231ec6..4f90c513c 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -20,8 +20,17 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [ (Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"), ]; +/// Paragraph attributes apply to the whole text object (ending a Type tool typing session first). fn format(app: &mut DrawcraftApp, p: Value) { - app.run("text.setFormat", p).ok(); + para_cmd(app, "text.setFormat", p); +} + +fn para_cmd(app: &mut DrawcraftApp, cmd: &str, mut p: Value) { + if let Some((id, _, _)) = super::character::text_editing(app) { + super::character::end_typing(app); + p["ids"] = json!([id.0]); + } + app.run(cmd, p).ok(); } pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { @@ -34,7 +43,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.x = 3.0; for (j, icon, tip, id) in ALIGNMENTS { if widgets::icon_button(ui, icon, tip, para.justify == j, 28.0).clicked() { - app.run("text.setStyle", json!({"justify": id})).ok(); + para_cmd(app, "text.setStyle", json!({"justify": id})); } } }); @@ -91,7 +100,7 @@ pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { menu_item(ui, "Every-line Composer", false, true); ui.separator(); if menu_item(ui, "Reset Panel", has, false) { - app.run("text.setStyle", json!({"justify": "left"})).ok(); + para_cmd(app, "text.setStyle", json!({"justify": "left"})); format(app, json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false})); } } diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 25e4eb86f..566b970ca 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -1,49 +1,112 @@ -//! Symbols panel: the document's symbols as thumbnails or a list, with an honest empty state. +//! Symbols panel: the document's symbols as rendered thumbnails or a list. Click selects a symbol +//! (it becomes the Symbol Sprayer's current symbol); double-click places an instance. +use std::cell::RefCell; +use std::collections::HashMap; + +use drawcraft_doc::{Document, Node, NodeKind}; use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::{Value, json}; use super::{pstate, set_pstate}; +use crate::DrawcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; -use crate::{DrawcraftApp, icons}; + +/// A thumbnail of symbol `name` (an instance at its natural size), cached by the art's identity. +fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option { + thread_local! { + static RENDERER: RefCell = RefCell::new(drawcraft_render::Renderer::new()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + let sym = doc.symbols.iter().find(|s| s.name == name)?; + let px = (size * ui.ctx().pixels_per_point()).round().max(8.0) as u32; + let key = (std::sync::Arc::as_ptr(&sym.art) as usize, name.to_string(), px); + if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let (w, h) = doc + .unknown + .get("symbolSizes") + .and_then(|m| m.get(name)) + .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .unwrap_or((20.0, 20.0)); + let mut d = Document::new(w.max(1.0), h.max(1.0)); + d.symbols.push(sym.clone()); + let id = d.alloc_id(); + let xf = drawcraft_geom::Affine::scale_non_uniform(w / 20.0, h / 20.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf })).ok()?; + let img = RENDERER.with(|r| r.borrow_mut().render_thumbnail(&d, id, px))?; + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("sym-{name}-{px}"), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 512 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} + +fn current(app: &mut DrawcraftApp) -> Option { + app.run("symbol.list", json!({})).ok().and_then(|v| v["current"].as_str().map(str::to_string)) +} + +fn has_instances_selected(app: &DrawcraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| { + let mut any = false; + if let Some(n) = st.doc.node(*id) { + n.walk(&mut |c| any |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + } + any + }) + }) +} pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let names: Vec = app.session.active().map(|d| d.doc.symbols.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let names: Vec = doc.symbols.iter().map(|s| s.name.clone()).collect(); let list: bool = pstate(ui.ctx(), "sym-list"); - let sel: Option = pstate(ui.ctx(), "sym-sel"); + let sel = current(app); + let mut clicked: Option<(String, bool)> = None; widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); if names.is_empty() { - super::empty_state( - ui, - "spray-can", - "No symbols in this document", - "Symbols come in through SVG/PDF import; creating them is on the roadmap.", - ); + super::empty_state(ui, "spray-can", "No symbols in this document", "Select art and click New Symbol to make one."); return; } + let paint_thumb = |ui: &Ui, r: egui::Rect, n: &str| { + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + if let Some(tex) = thumb(ui, &doc, n, r.width()) { + ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } + }; if list { for n in &names { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); if sel.as_deref() == Some(n.as_str()) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - icons::paint(ui, "shapes", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); - ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); - if resp.clicked() { - set_pstate(ui.ctx(), "sym-sel", Some(n.clone())); + paint_thumb(ui, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n); + ui.painter().text(r.left_center() + vec2(32.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); + if resp.double_clicked() { + clicked = Some((n.clone(), true)); + } else if resp.clicked() { + clicked = Some((n.clone(), false)); } } } else { ui.horizontal_wrapped(|ui| { for n in &names { - let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - icons::paint(ui, "shapes", r.shrink(8.0), egui::Color32::DARK_GRAY); + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); + paint_thumb(ui, r.shrink(2.0), n); let on = sel.as_deref() == Some(n.as_str()); ui.painter().rect_stroke( r, @@ -51,41 +114,66 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Inside, ); - if resp.on_hover_text(n).clicked() { - set_pstate(ui.ctx(), "sym-sel", Some(n.clone())); + let resp = resp.on_hover_text(n); + if resp.double_clicked() { + clicked = Some((n.clone(), true)); + } else if resp.clicked() { + clicked = Some((n.clone(), false)); } } }); } }); + if let Some((n, place)) = clicked { + app.run("symbol.setCurrent", json!({"name": n})).ok(); + if place { + app.run("symbol.place", json!({"name": n})).ok(); + } + } + let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); + let inst = has_instances_selected(app); widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", "Symbol Libraries (on the roadmap)", false, false, 24.0); - widgets::icon_button_enabled(ui, "dc-place-symbol", "Place Symbol Instance (on the roadmap)", false, false, 24.0); - widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Symbol (on the roadmap)", false, false, 24.0); + if widgets::icon_button_enabled(ui, "dc-place-symbol", "Place Symbol Instance", false, sel.is_some(), 24.0).clicked() { + app.run("symbol.place", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Symbol", false, inst, 24.0).clicked() { + app.run("symbol.breakLink", json!({})).ok(); + } ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); - widgets::icon_button_enabled(ui, "dc-new-item", "New Symbol (on the roadmap)", false, false, 24.0); - widgets::icon_button_enabled(ui, "trash-2", "Delete Symbol (on the roadmap)", false, false, 24.0); + if widgets::icon_button_enabled(ui, "dc-new-item", "New Symbol", false, has_sel, 24.0).clicked() { + app.run("symbol.new", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "trash-2", "Delete Symbol", false, sel.is_some(), 24.0).clicked() + && let Some(n) = &sel + { + app.run("symbol.delete", json!({"name": n})).ok(); + } }); } -pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { - for l in [ - "New Symbol…", - "Redefine Symbol", - "Duplicate Symbol", - "Delete Symbol", - "Edit Symbol", - "Place Symbol Instance", - "Replace Symbol", - "Break Link to Symbol", - ] { - menu_item(ui, l, false, false); +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let sel = current(app); + let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); + let inst = has_instances_selected(app); + let name = json!({"name": sel}); + let items: [(&str, bool, &str, Value); 9] = [ + ("New Symbol…", has_sel, "symbol.new", json!({})), + ("Redefine Symbol", has_sel && sel.is_some(), "symbol.update", name.clone()), + ("Duplicate Symbol", sel.is_some(), "symbol.duplicate", name.clone()), + ("Delete Symbol", sel.is_some(), "symbol.delete", name.clone()), + ("Edit Symbol", inst, "symbol.edit", json!({})), + ("Place Symbol Instance", sel.is_some(), "symbol.place", name.clone()), + ("Replace Symbol", inst && sel.is_some(), "symbol.replace", name.clone()), + ("Break Link to Symbol", inst, "symbol.breakLink", json!({})), + ("Select All Instances", sel.is_some(), "symbol.selectInstances", name), + ]; + for (label, enabled, cmd, params) in items { + if menu_item(ui, label, enabled, false) { + app.run(cmd, params).ok(); + } } ui.separator(); - menu_item(ui, "Select All Unused", false, false); - menu_item(ui, "Select All Instances", false, false); - menu_item(ui, "Sort by Name", false, false); - ui.separator(); let list: bool = pstate(ui.ctx(), "sym-list"); if menu_item(ui, "Thumbnail View", true, !list) { set_pstate(ui.ctx(), "sym-list", false); @@ -93,7 +181,4 @@ pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { if menu_item(ui, "List View", true, list) { set_pstate(ui.ctx(), "sym-list", true); } - ui.separator(); - menu_item(ui, "Symbol Options…", false, false); - menu_item(ui, "Open Symbol Library", false, false); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index fe940064e..ec926ab96 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -96,13 +96,8 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { for t in texts { let _ = app.session.tool_text(&t, view); } - for (k, tk) in - [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] - { - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); - } - } + // Editing keys with modifiers, clipboard and Cmd+A. + crate::panels::character::route_type_input(app, ctx); // Enter was already delivered above as ToolKey::Enter (newline). let fire = all_shortcuts() .into_iter() diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3c0bf965d..439246eff 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -74,6 +74,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("pathfinder", "Pathfinder", "dc-pathfinder"), ("character", "Character", "type"), ("paragraph", "Paragraph", "pilcrow"), + ("glyphs", "Glyphs", "text-cursor-input"), ("history", "History", "history"), ("actions", "Actions", "dc-actions"), ("info", "Info", "info"), @@ -88,7 +89,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["appearance", "graphicStyles"], &["artboards"], &["transform", "align", "pathfinder"], - &["character", "paragraph"], + &["character", "paragraph", "glyphs"], &["history", "actions", "info", "navigator"], ]; diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3fffd6baa..77e44ec75 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)), + ("brush", Class::Layer(2)), ("trace", Class::Layer(2)), ("text", Class::Layer(2)), ("effects", Class::Layer(2)),