diff --git a/ROADMAP.md b/ROADMAP.md index fe325ff849..fe038ec97a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,6 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. +- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 7594bc2344..fdbec59bfc 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -107,7 +107,7 @@ use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; #[cfg(all(test, feature = "wgpu"))] -use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies}; +use titlebar::{app_draws_frame, caption_layout_from, opted_into_app_title_bar, start_frame, system_title_bar_applies}; use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { @@ -367,7 +367,7 @@ fn main() -> std::process::ExitCode { }; #[cfg(feature = "wgpu")] let crash = left_out.crash.clone(); - let custom_titlebar = custom_titlebar(saved.as_ref()); + let (custom_titlebar, caption_layout) = custom_titlebar(saved.as_ref()); let options = native_options(custom_titlebar); // winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), @@ -593,6 +593,7 @@ fn main() -> std::process::ExitCode { } app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; + app.caption_layout = caption_layout; app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 6ae80944a6..99ca880bc9 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -220,8 +220,10 @@ fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { } /// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame -/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame -/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference. +/// with the default window buttons, an app frame the desktop left to the app keeps its own buttons, +/// one that `VECTORCRAFT_TITLEBAR=app` forced or the preference kept through XWayland gets the +/// default buttons, and the desktop frame stays +/// where the app frame is not drawn. System Title Bar takes precedence over the preference. #[test] fn the_start_frame_applies_the_app_title_bar_preference() { use craft_window::{Button, Frame, Layout}; @@ -233,9 +235,10 @@ fn the_start_frame_applies_the_app_title_bar_preference() { ((Frame::Desktop, None, true, true), Frame::App(Layout::default())), ((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())), ((Frame::App(gnome.clone()), None, true, false), Frame::Desktop), - ((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), None, true, true), Frame::App(Layout::default())), ((Frame::Desktop, Some(false), false, true), Frame::Desktop), - ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(Layout::default())), + ((Frame::App(gnome.clone()), Some(true), false, false), Frame::App(Layout::default())), ]; for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases { let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}"); @@ -246,6 +249,30 @@ fn the_start_frame_applies_the_app_title_bar_preference() { assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); } +/// The app bar's caption buttons follow the window-button layout of the app frame on Linux and the +/// BSDs (Close alone at the right, or all three at the left with Close outermost); the desktop frame, +/// macOS and Windows keep all three at the right. +#[test] +fn the_caption_buttons_follow_the_frame_layout() { + use craft_window::{Button, Frame, Layout}; + use vectorcraft_ui_egui::titlebar::{Caption, CaptionLayout}; + let close_only = Layout::parse("appmenu:close"); + let at_left = Layout { left: vec![Button::Close, Button::Minimize, Button::Maximize], right: vec![] }; + let split = Layout { left: vec![Button::Close], right: vec![Button::Minimize, Button::Maximize] }; + let probed = [ + (close_only, CaptionLayout { left: vec![], right: vec![Caption::Close] }), + (at_left, CaptionLayout { left: vec![Caption::Close, Caption::Minimize, Caption::Maximize], right: vec![] }), + (split, CaptionLayout { left: vec![Caption::Close], right: vec![Caption::Minimize, Caption::Maximize] }), + ]; + for (layout, expected) in probed { + let shown = super::caption_layout_from(&Frame::App(layout.clone())); + let expected = if cfg!(any(target_os = "macos", target_os = "windows")) { CaptionLayout::default() } else { expected }; + assert_eq!(shown, expected, "{layout:?}"); + } + assert_eq!(super::caption_layout_from(&Frame::Desktop), CaptionLayout::default()); + assert_eq!(CaptionLayout::default().right, vec![Caption::Minimize, Caption::Maximize, Caption::Close]); +} + /// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and /// on. The saved preference still decides while the variable is unset. #[test] @@ -303,7 +330,7 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { - let custom = super::custom_titlebar(saved); + let (custom, _) = super::custom_titlebar(saved); let expected = match value.as_str() { _ if not_read => !cfg!(target_os = "macos") && !opted_into_system, "app" => true, @@ -312,7 +339,7 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { // `craft-window` probe decides, the same with System Title Bar off as with no saved // preferences. _ if opted_into_system => false, - _ => super::custom_titlebar(None), + _ => super::custom_titlebar(None).0, }; assert_eq!(custom, expected, "{value}, preference {opted_into_system}"); assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index fc6e395927..f8007d612e 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -68,18 +68,45 @@ pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced } /// The frame this start uses, before the System Title Bar preference is applied -/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed -/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default -/// layout, since `custom_titlebar_from` reads only which frame it is. +/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. An app frame +/// the desktop left to the app (no `VECTORCRAFT_TITLEBAR` override, not forced onto XWayland) keeps +/// its window-button layout. An app frame turned on by `VECTORCRAFT_TITLEBAR=app` or by VectorCraft +/// Title Bar on Every Desktop gets the default layout: on those desktops the `button-layout` key that `craft-window` reads can +/// hold its schema default (`appmenu:close`) rather than the desktop's own buttons. pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame { let probed_app = matches!(probed, craft_window::Frame::App(_)); match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) { - (true, frame @ craft_window::Frame::App(_)) => frame, - (true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()), + (true, frame @ craft_window::Frame::App(_)) if overridden.is_none() && !forced_to_x11 => frame, + (true, _) => craft_window::Frame::App(craft_window::Layout::default()), (false, _) => craft_window::Frame::Desktop, } } +/// Where the app bar puts its caption buttons for `frame`: on Linux and the BSDs, the window-button +/// layout of an app frame, which `craft-window` reads from the desktop (the `button-layout` key of +/// `org.gnome.desktop.wm.preferences`); otherwise all three at the right. Each list is copied in the +/// key's order, first item leftmost, so `close,minimize,maximize:` puts Close at the left edge. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn caption_layout_from(frame: &craft_window::Frame) -> vectorcraft_ui_egui::titlebar::CaptionLayout { + use vectorcraft_ui_egui::titlebar::{Caption, CaptionLayout}; + let caption = |b: &craft_window::Button| match b { + craft_window::Button::Minimize => Caption::Minimize, + craft_window::Button::Maximize => Caption::Maximize, + craft_window::Button::Close => Caption::Close, + }; + match frame.layout() { + Some(layout) => CaptionLayout { left: layout.left.iter().map(caption).collect(), right: layout.right.iter().map(caption).collect() }, + None => CaptionLayout::default(), + } +} + +/// macOS and Windows: all three caption buttons at the right, whatever the probe says (see +/// [`custom_titlebar_from`]). +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn caption_layout_from(_frame: &craft_window::Frame) -> vectorcraft_ui_egui::titlebar::CaptionLayout { + vectorcraft_ui_egui::titlebar::CaptionLayout::default() +} + /// The `craft-window` probe's answer for this process (its override variable is /// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection. fn probed_choice() -> &'static craft_window::Choice { @@ -131,7 +158,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst CUSTOM_TITLEBAR && !opted_into_system } -/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its +/// Whether this start draws its own title bar, and where its caption buttons go +/// ([`caption_layout_from`]). Probes the shared `craft-window` crate once (its /// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System /// Title Bar preference, which does not apply while the variable is set to `app`, `system` or /// `desktop`. Call once at startup, @@ -142,7 +170,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native /// frame, the same as a real X11 session. A recognized `VECTORCRAFT_TITLEBAR` override still wins, /// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). -pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { +pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> (bool, vectorcraft_ui_egui::titlebar::CaptionLayout) { let choice = probed_choice(); let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env()); // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` @@ -160,7 +188,7 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> b #[cfg(not(target_os = "linux"))] let forced_to_x11 = false; let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app); - custom_titlebar_from(&frame, opted_into_system) + (custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame)) } pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs index 0b679497ce..888294356c 100644 --- a/crates/brush/src/calli.rs +++ b/crates/brush/src/calli.rs @@ -78,7 +78,11 @@ fn samples(t: &Track, step: Option) -> Vec<(Point, Vec2, f64)> { let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize; for k in 1..pieces { let u = k as f64 / pieces as f64; - out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len)); + let s = t.cum[i] + seg * u; + // On a chord of a flattened curve the samples follow the smooth frame through the + // vertices. + let (q, tg) = if t.curved.get(i).copied().unwrap_or(false) { t.frame_in(i, u) } else { (p.lerp(t.verts[i + 1], u), t.dirs[i]) }; + out.push((q, tg, s / len)); } } } diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 2d5156d162..2d62a1a993 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1037,6 +1037,26 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer { StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) } } +/// A nib that follows the pen is sampled between the vertices of the flattened path too. Along a +/// curve those samples sit on the curve, and the outline keeps its distance from it. +#[test] +fn pen_driven_nib_outline_follows_the_curve_between_vertices() { + // The top half of a circle of radius 50; a round nib 20 pt across, the same at any pressure. + let (r, half) = (50.0, 10.0); + let c = Point::new(r, r); + let ring = shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r)).to_bezpath(); + let half_circle: BezPath = ring.elements().iter().take(3).copied().collect(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(20.0, 0.0), &half_circle, &pressed(vec![(0.0, 0.5), (1.0, 0.5)])); + let mut pts = vec![]; + for n in &out { + all_points(n, &mut pts); + } + // Away from the round caps at the two ends, every outline point is `half` from the curve. + let middle = pts.iter().filter(|p| ((p.y - c.y).atan2(p.x - c.x).abs() - std::f64::consts::FRAC_PI_2).abs() <= 1.2); + let worst = middle.map(|p| ((p.distance(c) - (r + half)).abs()).min((p.distance(c) - (r - half)).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "an outline point strays {worst:.4} pt from the nib's reach"); +} + /// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to /// size + variation at the heaviest, along the stroke where the pen pressed. #[test] @@ -1686,6 +1706,35 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() { assert!(anchors(&out) < 2_000_000); } +/// A straight-sided art brush on a smooth curve draws smooth edges: they do not turn at the smooth +/// anchors the fit puts along them. +#[test] +fn art_brush_edges_stay_smooth_along_a_curve() { + for &(r, w, h, sw) in &[(100.0, 100.0, 10.0, 1.0), (300.0, 100.0, 10.0, 2.0), (30.0, 100.0, 10.0, 1.0), (2000.0, 100.0, 10.0, 1.0)] { + // The top half of a circle, as one open path of two curves. + let ring = shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r)).to_bezpath(); + let half_circle: BezPath = ring.elements().iter().take(3).copied().collect(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(w, h), ArtScale::Stretch), &half_circle, &stroke(sw)); + let NodeKind::Path { path, .. } = &out[0].kind else { panic!("an art brush on a path draws a path") }; + + // The turn at each smooth anchor of the edges: the angle between the incoming and the + // outgoing direction. Real corners (the ends of the art) turn by far more than 60°. + let mut kink = 0.0f64; + for sp in &path.subpaths { + for (k, a) in sp.anchors.iter().enumerate().skip(1).take(sp.anchors.len().saturating_sub(2)) { + let h_in = if a.h_in != a.p { a.h_in } else { sp.anchors[k - 1].p }; + let h_out = if a.h_out != a.p { a.h_out } else { sp.anchors[k + 1].p }; + let (u, v) = (a.p - h_in, h_out - a.p); + if u.hypot() < 1e-9 || v.hypot() < 1e-9 || u.dot(v) < 0.5 * u.hypot() * v.hypot() { + continue; + } + kink = kink.max(u.cross(v).atan2(u.dot(v)).abs().to_degrees()); + } + } + assert!(kink < 0.5, "r={r} art={w}x{h} weight {sw}: an edge turns by {kink:.3}° at a smooth anchor"); + } +} + /// Anchors under `n`, and how many of them keep a handle (so the segment is a curve). fn anchors_and_curved(n: &Node) -> (usize, usize) { let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) }; diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 6c65246176..3b5fb1add3 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -1,8 +1,9 @@ //! Arc-length parameterised polylines ("tracks") that brushes lay art along. //! //! A track is one flattened subpath. `frame(s)` gives the point and unit tangent at arc length -//! `s`; tangents are blended across the gentle vertices of flattened curves (so warped art bends -//! smoothly) but stay discontinuous at real corners (so art folds there, like Illustrator). +//! `s`. Along a flattened curve the frame runs on a smooth curve through the vertices, so warped +//! art bends smoothly; along a line of the path it stays on the line. At real corners the tangent +//! stays discontinuous, so art folds there. use vectorcraft_geom::{BezPath, PathEl, Point, Vec2}; @@ -22,6 +23,11 @@ pub struct Track { pub tangents: Vec, /// Is the vertex a corner (turn > [`CORNER_DEG`])? Open ends are never corners. pub corner: Vec, + /// Does the segment come from flattening a curve (rather than from a line of the path)? + pub curved: Vec, + /// How far (radians) a curved segment's end tangents may turn from its direction for `frame` + /// to follow a curve through them; 0 for a line. + turn: Vec, } fn unit(v: Vec2) -> Vec2 { @@ -35,16 +41,53 @@ fn aligned(a: Vec2, b: Vec2) -> bool { a.dot(b) > 1.0 - 1e-12 } +/// How far a vertex tangent may turn from a chord of a flattened curve, as a multiple of the +/// flattening tolerance over the chord's length. A circular arc needs 4; the rest is slack for +/// flattening that lands a little inside its tolerance. +const TURN_PER_TOL: f64 = 6.0; + +/// `t` turned by the smallest angle that brings it within `limit` radians of each `dir`. When no +/// angle satisfies every limit, `t` turns to the middle of the gap between them. +fn held_within(t: Vec2, limits: [Option<(Vec2, f64)>; 2]) -> Vec2 { + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for (d, limit) in limits.into_iter().flatten() { + let to = t.cross(d).atan2(t.dot(d)); + lo = lo.max(to - limit); + hi = hi.min(to + limit); + } + let turn = if lo <= hi { 0f64.max(lo).min(hi) } else { (lo + hi) / 2.0 }; + if turn == 0.0 || !turn.is_finite() { + return t; + } + let (sin, cos) = turn.sin_cos(); + Vec2::new(t.x * cos - t.y * sin, t.x * sin + t.y * cos) +} + /// Left-hand normal in y-down document space (for a path running +x it points +y). pub fn normal(t: Vec2) -> Vec2 { Vec2::new(-t.y, t.x) } impl Track { - fn new(mut pts: Vec, closed: bool) -> Option { - pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + /// `raw` pairs each vertex with the tolerance the segment ending at it was flattened with + /// when it comes from a curve, and `None` when it is a line of the path. + fn new(raw: Vec<(Point, Option)>, closed: bool) -> Option { + let mut pts: Vec = Vec::with_capacity(raw.len()); + // `ends_curve[k]`: the flattening tolerance of the segment ending at vertex `k`, when it + // comes from a curve. + let mut ends_curve: Vec> = Vec::with_capacity(raw.len()); + for (p, c) in raw { + // A duplicate vertex adds no segment, so it is dropped with its tolerance. + if pts.last().is_some_and(|q| q.distance(p) < 1e-9) { + continue; + } + pts.push(p); + ends_curve.push(c); + } + let mut closing_curve = None; if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) < 1e-9 { pts.pop(); + closing_curve = ends_curve.pop().flatten(); } if pts.len() < 2 { return None; @@ -52,8 +95,11 @@ impl Track { let closed = closed && pts.len() > 2; if closed { pts.push(pts[0]); + ends_curve.push(closing_curve); } let nseg = pts.len() - 1; + let curve_tol: Vec> = (0..nseg).map(|i| ends_curve.get(i + 1).copied().flatten()).collect(); + let curved: Vec = curve_tol.iter().map(Option::is_some).collect(); let dirs: Vec = (0..nseg).map(|i| unit(pts[i + 1] - pts[i])).collect(); let mut cum = vec![0.0]; for i in 0..nseg { @@ -62,34 +108,78 @@ impl Track { let mut tangents = Vec::with_capacity(nseg + 1); let mut corner = Vec::with_capacity(nseg + 1); let cos_limit = CORNER_DEG.to_radians().cos(); + let seg = |i: usize| (dirs[i], cum[i + 1] - cum[i], curved[i]); for i in 0..=nseg { let incoming = if i > 0 { - Some(dirs[i - 1]) + Some(seg(i - 1)) } else if closed { - Some(dirs[nseg - 1]) + Some(seg(nseg - 1)) } else { None }; let outgoing = if i < nseg { - Some(dirs[i]) + Some(seg(i)) } else if closed { - Some(dirs[0]) + Some(seg(0)) } else { None }; match (incoming, outgoing) { - (Some(a), Some(b)) => { + (Some((a, la, ca)), Some((b, lb, cb))) => { corner.push(a.dot(b) < cos_limit); - tangents.push(unit(a + b)); + tangents.push(match (ca, cb) { + // Between two chords of a flattened curve: each direction weighted by the + // length of the other chord, which is the tangent of the circle through + // the vertex and its two neighbors. + (true, true) => unit(a * lb + b * la), + // Where a line of the path meets a curve, the line's direction. + (false, true) => a, + (true, false) => b, + (false, false) => unit(a + b), + }); } (a, b) => { // An end of an open track (both `None` only for a track without segments). corner.push(false); - tangents.push(a.or(b).unwrap_or(Vec2::new(1.0, 0.0))); + tangents.push(a.or(b).map_or(Vec2::new(1.0, 0.0), |(d, _, _)| d)); } } } - Some(Track { verts: pts, cum, closed, dirs, tangents, corner }) + // An open track's end on a curve continues the bend of its last chord: the tangent there + // is the neighboring vertex's tangent mirrored about the chord, as on the circle through + // the last three vertices. + if !closed && nseg >= 2 { + for (end, inner, d) in [(0, 1, dirs[0]), (nseg, nseg - 1, dirs[nseg - 1])] { + let (s_end, s_inner) = if end == 0 { (0, 1) } else { (nseg - 1, nseg - 2) }; + if !corner[inner] && curved[s_end] && curved[s_inner] { + let t = tangents[inner]; + tangents[end] = unit(d * (2.0 * d.dot(t)) - t); + } + } + } + // A curve lies within its flattening tolerance `tol` of each of its chords, so its tangent + // at a vertex turns only a little from a chord: at most about `4 * tol / len` on a + // circular arc. Next to a long chord the estimates above can turn much further (a + // straight curve element beside a tight bend), and `frame` would then bow off the chord. + // Between curved chords, each vertex tangent is held within `TURN_PER_TOL * tol / len` of + // both chords. Where a line meets a curve the tangent stays the line's direction, and a + // curved chord whose end tangents turn further than that from it keeps to the chord. + let turn: Vec = (0..nseg) + .map(|j| match curve_tol.get(j) { + Some(Some(tol)) => (TURN_PER_TOL * tol / (cum[j + 1] - cum[j]).max(1e-12)).min(1.0).asin(), + _ => 0.0, + }) + .collect(); + for (i, t) in tangents.iter_mut().enumerate() { + let incoming = if i > 0 { Some(i - 1) } else { closed.then_some(nseg - 1) }; + let outgoing = if i < nseg { Some(i) } else { closed.then_some(0) }; + let sides = [incoming, outgoing]; + if corner[i] || sides.iter().flatten().any(|&j| !curved[j]) { + continue; + } + *t = held_within(*t, sides.map(|j| j.map(|j| (dirs[j], turn[j])))); + } + Some(Track { verts: pts, cum, closed, dirs, tangents, corner, curved, turn }) } pub fn len(&self) -> f64 { @@ -135,11 +225,11 @@ impl Track { let n = self.segments(); let s = if self.closed && l > 0.0 { s.rem_euclid(l) } else { s }; if s <= 0.0 && !self.closed { - let t = self.dirs[0]; + let t = self.tangents[0]; return (self.verts[0] + t * s, t); } if s >= l && !self.closed { - let t = self.dirs[n - 1]; + let t = self.tangents[n]; return (self.verts[n] + t * (s - l), t); } // Binary search the segment. @@ -149,10 +239,44 @@ impl Track { }; let seg = self.cum[i + 1] - self.cum[i]; let u = if seg > 1e-12 { ((s - self.cum[i]) / seg).clamp(0.0, 1.0) } else { 0.0 }; - let p = self.verts[i].lerp(self.verts[i + 1], u); - let t0 = if self.corner[i] { self.dirs[i] } else { self.tangents[i] }; - let t1 = if self.corner[i + 1] { self.dirs[i] } else { self.tangents[i + 1] }; - (p, unit(t0 + (t1 - t0) * u)) + self.frame_in(i, u) + } + + /// Segment `i`'s direction and the tangents [`Self::frame`] uses at its two ends: the vertex + /// tangents, or the segment's direction at a corner. + fn end_tangents(&self, i: usize) -> Option<(Vec2, Vec2, Vec2)> { + let (&d, &c0, &c1, &t0, &t1) = + (self.dirs.get(i)?, self.corner.get(i)?, self.corner.get(i + 1)?, self.tangents.get(i)?, self.tangents.get(i + 1)?); + Some((d, if c0 { d } else { t0 }, if c1 { d } else { t1 })) + } + + /// Point and unit tangent at the fraction `u` (0 to 1) of segment `i`, as [`Self::frame`] + /// gives them. `i` must be below [`Self::segments`]. + pub fn frame_in(&self, i: usize, u: f64) -> (Point, Vec2) { + let (Some(&p0), Some(&p1), Some((d, t0, t1))) = (self.verts.get(i), self.verts.get(i + 1), self.end_tangents(i)) else { + return (self.verts.last().copied().unwrap_or(Point::ZERO), Vec2::new(1.0, 0.0)); + }; + let seg = self.cum.get(i + 1).zip(self.cum.get(i)).map_or(0.0, |(b, a)| b - a); + if aligned(t0, d) && aligned(t1, d) { + return (p0.lerp(p1, u), d); + } + // The tangents may turn this far from a curved chord, a little beyond it for rounding. + let within = self.turn.get(i).map_or(-1.0, |a| a.sin() + 1e-9); + let bends = |t: Vec2| t.dot(d) <= 0.0 || t.cross(d).abs() > within; + if !self.curved.get(i).copied().unwrap_or(false) || bends(t0) || bends(t1) { + // A line of the path, or a curved chord at a sharper turn than the curve can make + // there: the point stays on it, and the tangent turns across it toward the vertex + // tangents. + return (p0.lerp(p1, u), unit(t0 + (t1 - t0) * u)); + } + // On a chord of a flattened curve the frame follows the cubic through both vertices with + // their tangents, at the chord's length per unit of `u`. Its position and tangent then + // meet those of the next segment at each vertex. + let (u2, u3) = (u * u, u * u * u); + let (m0, m1) = (t0 * seg, t1 * seg); + let p = p0 + (p1 - p0) * (3.0 * u2 - 2.0 * u3) + m0 * (u3 - 2.0 * u2 + u) + m1 * (u3 - u2); + let dp = (p1 - p0) * (6.0 * u - 6.0 * u2) + m0 * (3.0 * u2 - 4.0 * u + 1.0) + m1 * (3.0 * u2 - 2.0 * u); + (p, unit(dp)) } /// The point at arc length `s`, offset `d` along the left normal. @@ -183,8 +307,8 @@ impl Track { if self.closed && (lo < -1e-9 || hi > l + 1e-9) { return false; } - // Past an open track's ends `frame` extrapolates along the end segment's direction, so - // those segments are part of the span too. + // Past an open track's ends `frame` extrapolates along the end tangent, which is checked + // against the end segment's direction below, so those segments are part of the span too. let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)]; let touched = (0..n).filter(|&i| { let (a, b) = (self.cum.get(i), self.cum.get(i + 1)); @@ -193,9 +317,7 @@ impl Track { let mut dir: Option = None; let mut any = false; for i in touched.chain(ends.into_iter().flatten()) { - let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) = - (self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1)) - else { + let Some((d, t0, t1)) = self.end_tangents(i) else { return false; }; if dir.is_some_and(|first: Vec2| !aligned(first, d)) { @@ -203,11 +325,11 @@ impl Track { } dir = Some(d); any = true; - // `frame` blends the two vertex tangents across the segment unless the vertex is a - // corner, where it uses the segment's own direction. So a neighbour outside the span - // can still bend the tangent inside it, and checking the directions alone would miss - // that: segments d, d, e leave vertex 2's tangent at unit(d + e). - if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) { + // `frame` blends the two end tangents across a line, and follows a curve through them + // on a curved chord. So a neighbour outside the span can still bend the tangent + // inside it, and checking the directions alone would miss that: segments d, d, e + // leave vertex 2's tangent at unit(d + e). + if !aligned(t0, d) || !aligned(t1, d) { return false; } } @@ -220,9 +342,10 @@ const MAX_FLATTENED: usize = 4_000_000; /// Flatten `bp` into tracks (one per subpath) with `tol` accuracy. pub fn tracks(bp: &BezPath, tol: f64) -> Vec { + let tol = tol.max(1e-4); let mut out = vec![]; - let mut cur: Vec = vec![]; - let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { + let mut cur: Vec<(Point, Option)> = vec![]; + let flush = |cur: &mut Vec<(Point, Option)>, closed: bool, out: &mut Vec| { if let Some(t) = Track::new(std::mem::take(cur), closed) { out.push(t); } @@ -236,19 +359,40 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { return out; } let mut els = vec![]; + // When the element being flattened is a curve, the tolerance `flatten` uses for it: `flatten` + // pulls each element before it reports that element's lines, and raises the tolerance of a + // long curve as `vectorcraft_geom::flatten` describes. + let in_curve = std::cell::Cell::new(None); + let (last, start) = (std::cell::Cell::new(Point::ZERO), std::cell::Cell::new(Point::ZERO)); + let source = bp.iter().inspect(|el| { + let from = last.get(); + let (curve, to) = match *el { + PathEl::MoveTo(p) => { + start.set(p); + (None, p) + } + PathEl::LineTo(p) => (None, p), + PathEl::QuadTo(a, p) => (Some(from.distance(a) + a.distance(p)), p), + PathEl::CurveTo(a, b, p) => (Some(from.distance(a) + a.distance(b) + b.distance(p)), p), + PathEl::ClosePath => (None, start.get()), + }; + last.set(to); + let per = vectorcraft_geom::MAX_FLATTEN_PER_CURVE * vectorcraft_geom::MAX_FLATTEN_PER_CURVE; + in_curve.set(curve.map(|length| if length.is_finite() { tol.max(length / per) } else { f64::INFINITY })); + }); // Guarded: kurbo works out every subdivision of a curve even when the callback drops them. - vectorcraft_geom::flatten(bp.iter(), tol.max(1e-4), |el| { + vectorcraft_geom::flatten(source, tol, |el| { if els.len() < MAX_FLATTENED { - els.push(el); + els.push((el, in_curve.get())); } }); - for el in els { + for (el, from_curve) in els { match el { PathEl::MoveTo(p) => { flush(&mut cur, false, &mut out); - cur.push(p); + cur.push((p, None)); } - PathEl::LineTo(p) => cur.push(p), + PathEl::LineTo(p) => cur.push((p, from_curve)), PathEl::ClosePath => flush(&mut cur, true, &mut out), _ => {} } @@ -368,6 +512,173 @@ mod tests { assert_eq!(t.map(110.0, 0.0), Point::new(110.0, 0.0)); } + /// Between two lines that meet at a gentle turn, the frame stays on the lines: only the chords + /// of a flattened curve are smoothed. Where a line meets a curve at a gentle turn, the frame + /// stays on the line with the line's direction, and its tangent does not jump at the join. + #[test] + fn a_polylines_frame_stays_on_its_lines() { + // A line, then a line or a nearly straight curve at a 20° turn, below the corner angle, so + // the vertex is not a corner. + let turn = 20f64.to_radians(); + let (c, s) = (100.0 * turn.cos(), 100.0 * turn.sin()); + for curve in [false, true] { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + if curve { + bp.curve_to((100.0 + c / 3.0, s / 3.0 + 0.5), (100.0 + 2.0 * c / 3.0, 2.0 * s / 3.0 + 0.5), (100.0 + c, s)); + } else { + bp.line_to((100.0 + c, s)); + } + let t = &tracks(&bp, 0.05)[0]; + for at in [10.0, 30.0, 50.0, 70.0, 90.0] { + let (p, tg) = t.frame(at); + assert!(p.y.abs() < 1e-9 && (p.x - at).abs() < 1e-9, "curve {curve}: at {at} the frame left the first line: {p:?}"); + if curve { + assert!(tg.y.abs() < 1e-12, "at {at} the line's tangent turned toward the curve: {tg:?}"); + } + } + if curve { + // The curve's first chord keeps to the chord rather than bending toward the line. + let (a, b) = (t.cum[1], t.cum[2]); + let (p, q) = (t.verts[1], t.verts[2]); + let mid = t.frame((a + b) / 2.0).0; + assert!((mid - p.midpoint(q)).hypot() < 1e-9, "the first curve chord bows: {mid:?}"); + } + let (before, after) = (t.frame(100.0 - 1e-7).1, t.frame(100.0 + 1e-7).1); + assert!(before.dot(after) > 1.0 - 1e-9, "curve {curve}: the tangent jumps at the join: {before:?} {after:?}"); + } + } + + /// A curve of no length between two lines leaves them lines. + #[test] + fn a_curve_of_no_length_leaves_a_line_a_line() { + let turn = 20f64.to_radians(); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + bp.curve_to((100.0, 0.0), (100.0, 0.0), (100.0, 0.0)); + bp.line_to((100.0 + 100.0 * turn.cos(), 100.0 * turn.sin())); + let t = &tracks(&bp, 0.05)[0]; + assert!(t.curved.iter().all(|c| !c), "no segment is a curve: {:?}", t.curved); + for at in [10.0, 50.0, 90.0] { + let (p, _) = t.frame(at); + assert!(p.y.abs() < 1e-9 && (p.x - at).abs() < 1e-9, "at {at} the frame left the first line: {p:?}"); + } + } + + /// Where a line joins a curve smoothly, the line keeps its direction up to the join. + #[test] + fn a_line_into_a_smooth_curve_keeps_its_direction() { + // A line along y = 0 into a quarter circle of radius 50 that starts along +x. + let r = 50.0; + let mut bp = BezPath::new(); + bp.move_to((-100.0, 0.0)); + bp.line_to((0.0, 0.0)); + bp.curve_to((r * K, 0.0), (r, r - r * K), (r, r)); + let t = &tracks(&bp, 0.05)[0]; + for s in [10.0, 50.0, 90.0, 99.0] { + let (p, tg) = t.frame(s); + assert!(p.y.abs() < 1e-9 && tg.y.abs() < 1e-9, "at {s} the line's frame turned: {p:?} {tg:?}"); + } + } + + /// The handle length of a cubic quarter circle. + const K: f64 = 0.552_284_749_8; + + /// A curve element whose handles lie on its chord is a line drawn as a curve. Next to a tight + /// bend, at either end of a track, the frame along it stays within the flattening tolerance + /// of the line. + #[test] + fn a_straight_curve_element_beside_a_tight_bend_stays_on_its_line() { + let tol = 0.05; + for bend_first in [false, true] { + // A 200 pt line along y = 0 drawn as a cubic, joined smoothly to a quarter circle of + // radius 5 that turns toward +y. + let mut bp = BezPath::new(); + if bend_first { + bp.move_to((-5.0, -5.0)); + bp.curve_to((-5.0, -5.0 + 5.0 * K), (-5.0 * K, 0.0), (0.0, 0.0)); + bp.curve_to((50.0, 0.0), (150.0, 0.0), (200.0, 0.0)); + } else { + bp.move_to((0.0, 0.0)); + bp.curve_to((50.0, 0.0), (150.0, 0.0), (200.0, 0.0)); + bp.curve_to((200.0 + 5.0 * K, 0.0), (205.0, 5.0 - 5.0 * K), (205.0, 5.0)); + } + let t = &tracks(&bp, tol)[0]; + // The bend lies outside 0 <= x <= 200, so every frame point in that range is on the + // line's stretch of the track. + let on_line: Vec<(Point, Vec2)> = + (0..=2000).map(|k| t.frame(t.len() * k as f64 / 2000.0)).filter(|(p, _)| (0.0..=200.0).contains(&p.x)).collect(); + let worst = on_line.iter().map(|(p, _)| p.y.abs()).fold(0.0, f64::max); + assert!(worst <= 2.0 * tol, "bend first {bend_first}: the frame strays {worst:.4} pt off the line"); + // Along the line the tangent turns by no more than the line's flattening allows. + let lean = on_line.iter().map(|(_, tg)| tg.y.abs()).fold(0.0, f64::max); + assert!(lean <= 0.01, "bend first {bend_first}: the tangent turns {lean:.4} off the line"); + } + } + + /// A curve long enough that flattening raises its tolerance still has its frame follow the + /// curve between the vertices, not the chords. + #[test] + fn a_long_curve_flattened_coarser_is_still_followed() { + // A line along y = 0 into a cubic quarter circle of radius 5,000,000 that starts along +x. + let r = 5.0e6; + let curve = kurbo::CubicBez::new((0.0, 0.0), (r * K, 0.0), (r, r - r * K), (r, r)); + let mut bp = BezPath::new(); + bp.move_to((-100.0, 0.0)); + bp.line_to((0.0, 0.0)); + bp.curve_to(curve.p1, curve.p2, curve.p3); + let t = &tracks(&bp, 0.05)[0]; + let worst = (1..400) + .map(|k| t.frame(100.0 + (t.len() - 100.0) * k as f64 / 400.0).0) + .map(|p| kurbo::ParamCurveNearest::nearest(&curve, p, 1e-9).distance_sq.sqrt()) + .fold(0.0, f64::max); + assert!(worst < 0.05, "the frame strays {worst:.4} pt from the curve"); + } + + /// A closed curve is smooth across its seam: the frame stays on the circle and at right angles + /// to its radius all the way round, the start included. + #[test] + fn a_closed_curves_frame_is_smooth_across_its_seam() { + let (r, tol) = (50.0, 0.05); + let mut bp = BezPath::new(); + bp.move_to((r, 0.0)); + bp.curve_to((r, r * K), (r * K, r), (0.0, r)); + bp.curve_to((-r * K, r), (-r, r * K), (-r, 0.0)); + bp.curve_to((-r, -r * K), (-r * K, -r), (0.0, -r)); + bp.curve_to((r * K, -r), (r, -r * K), (r, 0.0)); + bp.close_path(); + let t = &tracks(&bp, tol)[0]; + assert!(t.closed && t.curved.iter().all(|c| *c), "every chord of the circle is a chord of a curve"); + for k in -20..=20 { + let (p, tg) = t.frame(k as f64 * 0.37); + let radial = p.to_vec2() / p.to_vec2().hypot(); + assert!((p.to_vec2().hypot() - r).abs() <= tol, "at {k} the frame is off the circle: {p:?}"); + assert!(tg.dot(radial).abs() < 0.5f64.to_radians().sin(), "at {k} the tangent leans off the circle: {tg:?}"); + } + } + + /// A closed path whose last side is the implicit line back to the start keeps that line + /// straight. + #[test] + fn an_implicit_closing_line_is_not_a_curve() { + let r = 50.0; + let mut bp = BezPath::new(); + bp.move_to((r, 0.0)); + bp.curve_to((r, r * K), (r * K, r), (0.0, r)); + bp.curve_to((-r * K, r), (-r, r * K), (-r, 0.0)); + bp.close_path(); + let t = &tracks(&bp, 0.05)[0]; + assert!(t.closed); + assert_eq!(t.curved.last(), Some(&false), "the closing line is a line"); + let n = t.segments(); + for u in [0.1, 0.5, 0.9] { + let (p, _) = t.frame(t.cum[n - 1] + (t.cum[n] - t.cum[n - 1]) * u); + assert!(p.y.abs() < 1e-9, "the frame leaves the closing line: {p:?}"); + } + } + #[test] fn closed_square_has_four_corners() { let mut bp = BezPath::new(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d936891514..b84de1cac3 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1464,7 +1464,7 @@ pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec< } /// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length -/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +/// (within a hundredth of a point, as [`SpreadLengths::anchor_at_length`] matches anchors)? fn keys_spread_along(keys: &[Arc], spine: &Spine) -> bool { let k = keys.len(); keys.iter().enumerate().all(|(i, key)| { @@ -1489,40 +1489,204 @@ fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { } /// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit -/// on, splitting segments where none is. +/// on, splitting segments where none is. `None`, leaving `path` as it was, when the subpath has no +/// length to spread along, when its length is not finite (a path so large its length overflows), +/// or when an arc length it would split at is not finite. fn spread_anchors(path: &mut PathData, k: usize) -> Option> { - let total = Spine::new(path)?.length(); - let closed = path.subpaths.first().is_some_and(|s| s.closed); + spread_anchors_with(path, k, |_| {}) +} + +/// [`spread_anchors`], calling `work` for each step of work it does. +fn spread_anchors_with(path: &mut PathData, k: usize, mut work: impl FnMut(SpreadWork)) -> Option> { + let sp = path.subpaths.first_mut()?; + let lengths = SpreadLengths::new(sp, &mut work); + let total = lengths.total().filter(|t| t.is_finite())?; + let targets = (1..k.saturating_sub(1)).map(|i| total * i as f64 / (k - 1) as f64); let mut out = vec![0usize]; - for i in 1..k.saturating_sub(1) { - let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(path, s)?); - } - let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); - out.push(last); + out.extend(anchors_at_lengths(sp, lengths, targets, &mut work)?); + out.push(if sp.closed { 0 } else { sp.anchors.len().saturating_sub(1) }); Some(out) } -/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a -/// point, else a new one splitting the segment there. -fn anchor_at_length(path: &mut PathData, s: f64) -> Option { - let spine = Spine::new(path)?; - let sp = path.subpaths.first_mut()?; - let n = sp.segment_count(); - let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); - let from = spine.anchor_length(seg).unwrap_or(0.0); - let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); - if s - from < 0.01 { - return Some(seg); +/// The anchors of `sp` at each arc length in `targets`, in order, splitting segments where none is +/// (see [`SpreadLengths::anchor_at_length`]); `lengths` measures `sp`. The splits are made on a copy +/// of `sp`, which replaces it only when every target has its anchor: `None` leaves `sp` as it was. +fn anchors_at_lengths( + sp: &mut SubPath, + mut lengths: SpreadLengths, + targets: impl IntoIterator, + work: &mut impl FnMut(SpreadWork), +) -> Option> { + let mut split = sp.clone(); + let mut out = Vec::new(); + for s in targets { + out.push(lengths.anchor_at_length(&mut split, s, work)?); } - if to - s < 0.01 { - return Some((seg + 1) % sp.anchors.len().max(1)); + *sp = split; + Some(out) +} + +/// A step of the work [`spread_anchors_with`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SpreadWork { + /// Flattening every segment of the subpath. + WholePath, + /// Flattening one segment after a split. + Segment, + /// Adding one segment's flattened points to the arc lengths. + Measure, + /// Comparing an anchor's arc length with the one looked for. + Compare, +} + +/// Where the measurement of a subpath's flattened points stands at the start of a segment: the last +/// point kept, the arc length there (0 before any point), and how many points were kept. +#[derive(Clone, Copy)] +struct Measured { + last: Option, + len: f64, + kept: usize, +} + +impl Measured { + const START: Self = Self { last: None, len: 0.0, kept: 0 }; + + /// The measurement after `pts`, keeping each point more than 1e-9 from the last one kept, as + /// [`Spine::new`] does. + fn then(mut self, pts: &[Point]) -> Self { + for &p in pts { + if self.last.is_none_or(|q| q.distance(p) > 1e-9) { + self.len = match self.last { + Some(q) => self.len + q.distance(p), + None => 0.0, + }; + self.last = Some(p); + self.kept += 1; + } + } + self + } +} + +/// The arc lengths of a subpath's anchors, as [`Spine::new`] measures them, kept up to date while +/// [`spread_anchors`] splits its segments. Each segment is flattened once, and again only when it +/// is split. A split leaves the arc lengths of the anchors before it as they are; those after it +/// are measured again, in order from the split, only as far as the next arc length looked for, so +/// the sums are the same as [`Spine::new`]'s on the split subpath. +struct SpreadLengths { + /// The finite points the segments flatten to, each segment's in one run. + pts: Vec, + /// Where each segment's run is in `pts`. A split segment's halves get runs at the end, and its + /// old run is not used again. + flat: Vec>, + /// The measurement at the start of each segment, up to the first one not measured since the + /// last split; after the last segment too once every segment is measured. + marks: Vec, +} + +impl SpreadLengths { + /// Flatten and measure every segment of `sp`. + fn new(sp: &SubPath, work: &mut impl FnMut(SpreadWork)) -> Self { + work(SpreadWork::WholePath); + let n = sp.segment_count(); + let mut lengths = Self { pts: Vec::new(), flat: Vec::with_capacity(n), marks: Vec::with_capacity(n + 1) }; + for i in 0..n { + let run = lengths.flatten(sp, i); + lengths.flat.push(run); + } + lengths.marks.push(Measured::START); + lengths.measure_to(f64::INFINITY, work); + lengths + } + + /// Flatten segment `i` of `sp` as [`Spine::new`] does, adding its finite points to `pts`, and + /// return where they are. + fn flatten(&mut self, sp: &SubPath, i: usize) -> std::ops::Range { + let start = self.pts.len(); + let c = sp.segment(i); + let mut bp = BezPath::new(); + bp.move_to(c.p0); + if sp.segment_is_line(i) { + bp.line_to(c.p3); + } else { + bp.curve_to(c.p1, c.p2, c.p3); + } + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| { + if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el + && p.x.is_finite() + && p.y.is_finite() + { + self.pts.push(p); + } + }); + start..self.pts.len() + } + + /// The subpath's flattened length, `None` when it flattens to fewer than two points. + fn total(&self) -> Option { + self.marks.last().filter(|m| self.marks.len() > self.flat.len() && m.kept >= 2).map(|m| m.len) + } + + /// Measure the segments that are not measured, from the first, until one starts past `s` (with + /// two points kept before it) or every one is measured. + fn measure_to(&mut self, s: f64, work: &mut impl FnMut(SpreadWork)) { + while let Some(run) = self.flat.get(self.marks.len() - 1) { + let Some(&m) = self.marks.last() else { return }; + if self.marks.len() >= 2 && m.kept >= 2 && m.len > s { + return; + } + work(SpreadWork::Measure); + self.marks.push(m.then(self.pts.get(run.clone()).unwrap_or_default())); + } + } + + /// The anchor of `sp` at arc length `s`: an existing one within a hundredth of a point, else a + /// new one splitting the segment there. The segment is the last one starting at or before `s` + /// (the first one when none does), found by a binary search. `None`, leaving `sp` as it was, + /// when `s` or the arc length at the end of the segment is not finite, or when `sp` flattens + /// to fewer than two points. + fn anchor_at_length(&mut self, sp: &mut SubPath, s: f64, work: &mut impl FnMut(SpreadWork)) -> Option { + if !s.is_finite() { + return None; + } + self.measure_to(s, work); + let n = self.flat.len(); + if self.marks.last().is_none_or(|m| m.kept < 2) { + return None; + } + let starts = &self.marks[..self.marks.len().min(n)]; + let seg = starts + .partition_point(|m| { + work(SpreadWork::Compare); + m.len <= s + }) + .saturating_sub(1); + let from = self.marks.get(seg)?.len; + let to = self.marks.get(seg + 1)?.len; + if s - from < 0.01 { + return Some(seg); + } + if to - s < 0.01 { + return Some((seg + 1) % sp.anchors.len().max(1)); + } + if !from.is_finite() || !to.is_finite() { + return None; + } + let c = sp.segment(seg); + // The flattened length and the curve's own differ slightly: scale into the curve's. + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + if !t.is_finite() { + return None; + } + let at = sp.insert_anchor(seg, t); + work(SpreadWork::Segment); + work(SpreadWork::Segment); + let halves = [self.flatten(sp, seg), self.flatten(sp, seg + 1)]; + self.flat.splice(seg..=seg, halves); + self.marks.truncate(seg + 1); + Some(at) } - let c = sp.segment(seg); - // The flattened length and the curve's own differ slightly: scale into the curve's. - let arc = c.arclen(1e-4).max(1e-12); - let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); - Some(sp.insert_anchor(seg, t)) } /// Where a blend's keys and steps go along its spine (see [`blend_spine`]). @@ -2012,6 +2176,150 @@ mod tests { } } + /// [`spread_anchors`] by measuring the whole spine again for each key and walking its segments + /// from the last, as a reference for the measurement kept up to date between keys. + fn rebuilt_spread_anchors(path: &mut PathData, k: usize) -> Option> { + let total = Spine::new(path)?.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + let mut out = vec![0usize]; + for i in 1..k.saturating_sub(1) { + let s = total * i as f64 / (k - 1) as f64; + let spine = Spine::new(path)?; + let sp = path.subpaths.first_mut()?; + let n = sp.segment_count(); + let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); + let from = spine.anchor_length(seg).unwrap_or(0.0); + let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); + out.push(if s - from < 0.01 { + seg + } else if to - s < 0.01 { + (seg + 1) % sp.anchors.len().max(1) + } else { + let c = sp.segment(seg); + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + sp.insert_anchor(seg, t) + }); + } + let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); + out.push(last); + Some(out) + } + + /// Spreading keys along a spine measured once and kept up to date gives the same anchors and + /// the same spine as measuring it again for each key: on open and closed polylines and curves, + /// zero-length segments, keys exactly on anchors, keys within, at and just past a hundredth of + /// a point from an anchor, a closed curve whose keys go round past its first anchor, many keys + /// in one segment, and paths with no segment. The closed spines open as [`pin_spine`] opens + /// them. + #[test] + fn spreading_keys_matches_measuring_the_spine_for_each_key() { + let p = Point::new; + let bow = SubPath::new( + vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))], + false, + ); + let mut spines = vec![ + PathData::default(), + PathData::single(SubPath::default()), + PathData::single(SubPath::polyline(&[p(5.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(5.0, 5.0), p(5.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], true)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(20.0, 0.0), p(30.0, 0.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true)), + PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true)), + PathData::single(bow.clone()), + PathData::single(SubPath { closed: true, ..bow }), + shapes::ellipse(Rect::new(0.0, 0.0, 120.0, 80.0)), + ]; + // An anchor near where the middle of three keys goes, 10 along a spine 20 long. + for d in [0.0, 0.005, 0.0099, 0.01, 0.0101, 0.02] { + for x in [10.0 - d, 10.0 + d] { + spines.push(PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(x, 0.0), p(20.0, 0.0)], false))); + } + } + let mut compared = 0; + for spine in &spines { + for k in (0..=12).chain([17, 50, 200]) { + let (mut a, mut b) = (spine.clone(), spine.clone()); + let (ka, kb) = (spread_anchors(&mut a, k), rebuilt_spread_anchors(&mut b, k)); + assert_eq!(ka, kb, "{k} keys on {spine:?}"); + assert_eq!(a, b, "{k} keys on {spine:?}"); + if let (Some(mut ka), Some(mut kb)) = (ka, kb) { + let (mut sa, mut sb) = (a.subpaths[0].clone(), b.subpaths[0].clone()); + open_at_start(&mut sa, &mut ka); + open_at_start(&mut sb, &mut kb); + assert_eq!((sa, ka), (sb, kb), "{k} keys on {spine:?}, opened"); + compared += 1; + } + } + } + assert!(compared > 200, "{compared} spreads compared"); + } + + /// Spreading 1,000 keys along a spine of 4,096 segments flattens the spine once and then only + /// the two halves of each split segment, finds each key's segment in a few comparisons, and + /// measures about as many segments as the spine ends up with, not the whole spine per key. + #[test] + fn spreading_keys_measures_the_spine_once() { + let pts: Vec = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect(); + let mut path = PathData::single(SubPath::polyline(&pts, false)); + let (mut whole, mut segments, mut measured, mut compared) = (0, 0, 0, 0); + let ka = spread_anchors_with(&mut path, 1000, |w| match w { + SpreadWork::WholePath => whole += 1, + SpreadWork::Segment => segments += 1, + SpreadWork::Measure => measured += 1, + SpreadWork::Compare => compared += 1, + }) + .unwrap(); + assert_eq!(ka.len(), 1000); + let n = path.subpaths[0].segment_count(); + assert!(n > 4096 + 900, "{n} segments"); + assert_eq!(whole, 1); + assert!(segments <= 2 * 998, "{segments} segments flattened"); + assert!(compared <= 998 * 14, "{compared} comparisons"); + assert!(measured <= 4096 + n + 3 * 1000, "{measured} segments measured for {n}"); + let mut reference = PathData::single(SubPath::polyline(&pts, false)); + assert_eq!(rebuilt_spread_anchors(&mut reference, 1000), Some(ka)); + assert_eq!(reference, path); + } + + /// A spine so large that its length overflows spreads no keys, and is left as it was, rather + /// than getting an anchor split at a parameter that is not a number. Two keys, which split + /// nothing, get no anchors either. + #[test] + fn spreading_keys_along_a_spine_whose_length_overflows_changes_nothing() { + let p = Point::new; + for closed in [false, true] { + for k in [2, 3, 5] { + let spine = PathData::single(SubPath::polyline(&[p(-1e308, 0.0), p(0.0, 0.0), p(1e308, 0.0)], closed)); + let mut path = spine.clone(); + assert_eq!(spread_anchors(&mut path, k), None, "closed {closed}, {k} keys"); + assert_eq!(path, spine); + assert!(path.subpaths[0].anchors.iter().all(|a| a.p.x.is_finite() && a.h_in.x.is_finite() && a.h_out.x.is_finite())); + } + } + } + + /// When a later arc length has no anchor (here one that is not finite, as when a spine's length + /// overflows only after earlier splits), the splits already made are dropped with it. + #[test] + fn a_failed_anchor_keeps_none_of_the_earlier_splits() { + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false); + for bad in [f64::NAN, f64::INFINITY] { + let mut split = sp.clone(); + let lengths = SpreadLengths::new(&split, &mut |_| {}); + assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0, bad], &mut |_| {}), None); + assert_eq!(split, sp); + } + let mut split = sp.clone(); + let lengths = SpreadLengths::new(&split, &mut |_| {}); + assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0], &mut |_| {}), Some(vec![1, 2])); + assert_eq!(split.anchors.len(), 4); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 491b79f158..d920c51332 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -3,6 +3,7 @@ //! the ruler origin and the objects an edit touches), so a file holding something beyond it could be //! opened but not edited: one object, artboard or guide out there made every move fail. +use std::collections::BTreeSet; use std::sync::Arc; use vectorcraft_geom::{Point, Rect}; @@ -72,27 +73,41 @@ fn clipping(n: &Node) -> Option { } } -/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by -/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its -/// clipping path. -fn reaches_beyond(n: &Node) -> bool { - match clipping(n) { - None => beyond(n), - Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))), +/// The objects of a layer or group that a walk picked, by index in order, each with what was +/// picked inside it (nothing for an object that is not a container). +#[derive(Debug, Default)] +struct Picked(Vec<(usize, Picked)>); + +/// What in `n` reaches beyond the canvas, by `far`, or `None` when nothing does. A container is +/// looked into object by object: its own bounds leave out a layer's hidden objects and, when it +/// clips, all but its clipping path. Each object is measured once, and the objects a clipping +/// path beyond the canvas clips are not measured: they go with it (see [`clips_beyond`]). +fn reach(n: &Node, far: &impl Fn(&Node) -> bool) -> Option { + let Some(clip) = clipping(n) else { return far(n).then(Picked::default) }; + let mut inner = Vec::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + if let Some(p) = reach(c, far) { + inner.push((i, p)); + if clip && i == 0 { + break; + } + } } + (!inner.is_empty()).then_some(Picked(inner)) } -/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips -/// go together, so no other object becomes the clipping path. -fn clips_beyond(n: &Node) -> bool { - clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c)) +/// Does `n`, whose objects beyond the canvas are `found`, clip by a path that reaches beyond it? +/// Then the path and the objects it clips go together, so no other object becomes the clipping +/// path. +fn clips_beyond(n: &Node, found: &Picked) -> bool { + clipping(n) == Some(true) && found.0.first().is_some_and(|(i, _)| *i == 0) } /// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go /// with them. -fn layers_in(n: &Node, layers: &mut Vec) { +fn layers_in(n: &Node, layers: &mut BTreeSet) { if matches!(n.kind, NodeKind::Layer { .. }) { - layers.push(n.id); + layers.insert(n.id); } if clipping(n).is_some() { for c in n.children().into_iter().flatten() { @@ -109,22 +124,22 @@ fn guides_in(n: &Node) -> usize { } } -/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its -/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything -/// else goes whole. Only containers holding such an object are copied. Recursion follows the -/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and -/// the ruler guides they held to `guides`. -fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec, guides: &mut usize) { - if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { - return; - } +/// Remove the objects in `node` that reach beyond the canvas, which [`reach`] `found`: a layer or +/// group keeps those of its objects within it, unless its clipping path reaches beyond (see +/// [`clips_beyond`]); anything else goes whole. Only containers holding such an object are +/// copied, and only those are visited again. Recursion follows the nesting, which +/// [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and the ruler +/// guides they held to `guides`. +fn drop_children_beyond(node: &mut Arc, found: Picked, dropped: &mut usize, layers: &mut BTreeSet, guides: &mut usize) { let Some(children) = Arc::make_mut(node).children_mut() else { return }; + let mut found = found.0.into_iter().peekable(); + let mut next = 0; children.retain_mut(|c| { - if !reaches_beyond(c) { - return true; - } - if clipping(c).is_some() && !clips_beyond(c) { - drop_children_beyond(c, dropped, layers, guides); + let at = next; + next += 1; + let Some((_, inner)) = found.next_if(|(i, _)| *i == at) else { return true }; + if clipping(c).is_some() && !clips_beyond(c, &inner) { + drop_children_beyond(c, inner, dropped, layers, guides); return true; } layers_in(c, layers); @@ -134,21 +149,49 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Is `id` unset, or not one of `ids`? The check takes time logarithmic in the number of ids. +fn not_in(id: Option, ids: &BTreeSet) -> bool { + id.is_none_or(|i| !ids.contains(&i)) +} + /// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many -/// to `dropped`. Only layers holding one are copied. +/// to `dropped`. `out` looks at each object in the layers once. Only layers holding one are +/// copied. fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { - fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { - n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + /// The objects of `n`, if it is a layer, that `out` picks (with nothing inside), and its + /// sublayers holding one (with what they hold), or `None` when it holds none. + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> Option { + if !n.is_layer() { + return None; + } + let inner: Vec<(usize, Picked)> = n + .children() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, c)| if out(c) { Some((i, Picked::default())) } else { holds(c, out).map(|p| (i, p)) }) + .collect(); + (!inner.is_empty()).then_some(Picked(inner)) } - if !holds(layer, out) { - return; + fn drop(layer: &mut Arc, held: Picked, dropped: &mut usize) { + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let mut held = held.0.into_iter().peekable(); + let mut next = 0; + children.retain_mut(|c| { + let at = next; + next += 1; + let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true }; + // A sublayer holds something; an object picked itself has nothing inside. + if inner.0.is_empty() { + *dropped += 1; + return false; + } + drop(c, inner, dropped); + true + }); } - let Some(children) = Arc::make_mut(layer).children_mut() else { return }; - let before = children.len(); - children.retain(|c| !out(c)); - *dropped += before - children.len(); - for c in children.iter_mut().filter(|c| c.is_layer()) { - drop_guides(c, out, dropped); + if let Some(held) = holds(layer, out) { + drop(layer, held, dropped); } } @@ -162,9 +205,10 @@ impl Document { /// whose artboards were all removed has none left. pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas { let mut gone = BeyondCanvas::default(); - let mut layers = vec![]; + let mut layers = BTreeSet::new(); for layer in &mut self.layers { - if clips_beyond(layer) { + let Some(found) = reach(layer, &beyond) else { continue }; + if clips_beyond(layer, &found) { let layer = Arc::make_mut(layer); if let NodeKind::Layer { children, clip, .. } = &mut layer.kind { for c in children.iter() { @@ -177,7 +221,7 @@ impl Document { *clip = false; } } else { - drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides); + drop_children_beyond(layer, found, &mut gone.objects, &mut layers, &mut gone.guides); } } if gone.objects > 0 { @@ -192,13 +236,13 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); + let removed: BTreeSet = removed.into_iter().collect(); // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). let guides = self.saved_guides.len(); - self.saved_guides - .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + self.saved_guides.retain(|g| in_range(g.pos) && not_in(g.artboard, &removed) && not_in(g.layer, &layers)); gone.guides += guides - self.saved_guides.len(); let out = |n: &Node| match n.kind { - NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || !not_in(artboard, &removed), _ => false, }; for layer in &mut self.layers { @@ -392,4 +436,153 @@ mod tests { assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); } + + #[test] + fn guides_of_many_removed_artboards_and_layers_go_and_the_rest_keep_their_order() { + let probe = Document::new(1.0, 1.0); + // Sublayers 40 to 43 clip by a path beyond the canvas, so they go whole; 50 stays. + let far: Vec> = (40..44).map(|id| sublayer(&probe, id, vec![rect(id * 10, 1e17), rect(id * 10 + 1, 0.0)], true)).collect(); + let near = sublayer(&probe, 50, vec![rect(500, 0.0)], false); + let mut d = doc(far.into_iter().chain([near, rect(10, 0.0)]).collect()); + let kept = d.artboards[0].id; + for id in 9..13 { + d.artboards.push(Artboard { id, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }); + } + let top = d.layers[0].id; + let guide = |pos: f64, artboard: Option, layer: Option| Guide { artboard, layer, ..Guide::new(false, pos) }; + d.saved_guides = vec![ + guide(1.0, Some(9), None), + guide(2.0, None, None), + guide(3.0, Some(kept), Some(NodeId(41))), + guide(4.0, Some(kept), None), + guide(5.0, Some(12), None), + guide(6.0, None, Some(NodeId(50))), + guide(7.0, None, Some(NodeId(43))), + guide(8.0, Some(10), Some(top)), + guide(9.0, None, Some(top)), + ]; + d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(42), NodeId(10), NodeId(500)] }].into(); + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 9); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 4, artboards: 4, guides: 5, ruler_origin: false }); + assert_eq!(d.artboards.iter().map(|a| a.id).collect::>(), [kept]); + let left: Vec = d.saved_guides.iter().map(|g| g.pos).collect(); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0], "the guides that stay keep their order"); + assert_eq!(d.saved_selections[0].objects, [NodeId(10), NodeId(500)]); + assert_eq!(nodes.drop_beyond_canvas(), gone, "guide nodes go as saved guides do"); + let mut left: Vec = nodes.ruler_guides().iter().map(|g| g.pos).collect(); + left.sort_by(f64::total_cmp); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0]); + assert_eq!(nodes.saved_selections, d.saved_selections); + } + + thread_local! { + static COMPARISONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// An id that counts how often it is compared. + #[derive(Debug)] + struct Counted(u32); + + impl Ord for Counted { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + COMPARISONS.with(|c| c.set(c.get() + 1)); + self.0.cmp(&other.0) + } + } + + impl PartialOrd for Counted { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + impl PartialEq for Counted { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } + } + + impl Eq for Counted {} + + #[test] + fn a_guide_is_checked_without_scanning_every_removed_id() { + let per_guide = |removed: u32| { + let ids: BTreeSet = (0..removed).map(|i| Counted(i * 2)).collect(); + COMPARISONS.with(|c| c.set(0)); + // Guides on removed ids, on ids between them, on ids past them and on none. + let guides: Vec> = (0..64).map(|i| (i % 4 != 3).then(|| Counted(i * 31 % (removed * 2 + 8)))).collect(); + let kept = guides.into_iter().filter(|g| not_in(g.as_ref().map(|c| Counted(c.0)), &ids)).count(); + assert!(kept > 0 && kept < 64); + COMPARISONS.with(|c| c.get()) as f64 / 64.0 + }; + let small = per_guide(16); + let large = per_guide(16_384); + // A sorted lookup grows with the depth of the tree; a scan of 16,384 ids compares thousands per guide. + assert!(large < small * 32.0 && large < 256.0, "{small} comparisons per guide among 16 ids, {large} among 16,384"); + } + + #[test] + fn each_object_is_measured_once_however_deep_the_nesting() { + // 32 groups, each inside the one before and holding an object within the canvas; the + // innermost also holds one beyond it. Beside them, a group with nothing beyond and a clip + // group whose clipping path reaches beyond. + let mut chain = Arc::new(Node::group(NodeId(100), vec![rect(200, 0.0), rect(201, 1e17)])); + for depth in 1..32 { + chain = Arc::new(Node::group(NodeId(100 + depth), vec![rect(300 + depth, 0.0), chain])); + } + let still = Arc::new(Node::group(NodeId(400), vec![rect(401, 0.0), rect(402, 5.0)])); + let clip = Arc::new(Node::new(NodeId(600), NodeKind::Group { children: vec![rect(601, 1e17), rect(602, 0.0), rect(603, 0.0)], clip: true })); + let mut d = doc(vec![chain, Arc::clone(&still), clip]); + let mut whole = d.clone(); + let measured = std::cell::Cell::new(0_usize); + let far = |n: &Node| { + measured.set(measured.get() + 1); + beyond(n) + }; + let layer = &mut d.layers[0]; + let found = reach(layer, &far).unwrap(); + assert_eq!( + measured.get(), + 36, + "the 33 objects in the groups, the 2 beside them and the clipping path, each measured once; not what the path clips" + ); + assert!(!clips_beyond(layer, &found)); + let (mut dropped, mut layers, mut guides) = (0, BTreeSet::new(), 0); + drop_children_beyond(layer, found, &mut dropped, &mut layers, &mut guides); + assert_eq!(measured.get(), 36, "removing measures nothing again"); + assert_eq!((dropped, layers.len(), guides), (2, 0, 0)); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a group with nothing beyond is not copied"); + assert_eq!(whole.drop_beyond_canvas(), BeyondCanvas { objects: 2, ..Default::default() }); + assert_eq!(ids(&whole), ids(&d)); + assert!(!ids(&d).contains(&201) && !ids(&d).contains(&600) && ids(&d).contains(&200) && ids(&d).contains(&331)); + } + + #[test] + fn each_object_is_looked_at_once_when_guides_go_from_nested_layers() { + let guide = |id: u64, pos: f64| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos, artboard: None })); + let probe = Document::new(1.0, 1.0); + // 32 sublayers, each inside the one before, each holding a guide within the canvas and one + // beyond it. Beside them, a sublayer with nothing to remove. + let mut chain = sublayer(&probe, 100, vec![guide(200, 5.0), guide(201, 1e17)], false); + for depth in 1..32 { + chain = sublayer(&probe, 100 + depth, vec![guide(300 + depth * 2, 5.0), guide(301 + depth * 2, 1e17), chain], false); + } + let still = sublayer(&probe, 500, vec![guide(501, 5.0), rect(502, 0.0)], false); + let mut d = doc(vec![chain, Arc::clone(&still)]); + let looked = std::cell::Cell::new(0_usize); + let out = |n: &Node| { + looked.set(looked.get() + 1); + matches!(n.kind, NodeKind::RulerGuide { pos, .. } if !in_range(pos)) + }; + let mut dropped = 0; + drop_guides(&mut d.layers[0], &out, &mut dropped); + assert_eq!(dropped, 32); + assert_eq!(looked.get(), 2 + 2 + 31 * 3 + 2, "each object in the layers is looked at once"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied"); + let left = d.ruler_guides(); + assert_eq!(left.len(), 33); + assert!(left.iter().all(|g| g.pos == 5.0)); + } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 14d0404bfc..df336fe09e 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -183,8 +183,8 @@ impl Document { self.layers.retain(|l| !l.is_ruler_guide()); gone -= self.layers.len(); for l in &mut self.layers { - if layer_untidy(l) { - gone += tidy_layer(Arc::make_mut(l)); + if let Some(found) = untidy(l, &Node::is_ruler_guide) { + gone += tidy_layer(Arc::make_mut(l), found); } } let others = self @@ -198,8 +198,8 @@ impl Document { if root.is_ruler_guide() { *root = Arc::new(Node::group(root.id, vec![])); gone += 1; - } else if any_guide(root) { - gone += drop_guides_below(Arc::make_mut(root)); + } else if let Some(found) = guides_below(root, &Node::is_ruler_guide) { + gone += drop_guides_below(Arc::make_mut(root), found); } } gone @@ -272,8 +272,8 @@ impl Document { return; } for l in &mut self.layers { - if l.holds_ruler_guides() { - Arc::make_mut(l).strip_ruler_guides(); + if let Some(held) = picked_in_layers(l, &Node::is_ruler_guide) { + drop_picked(l, held); } } self.saved_guides.extend(guides); @@ -347,16 +347,14 @@ impl Document { /// saved guide whose layer is gone opens. Returns the removed node, without the guides. pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { let mut layer = self.remove(id)?; - if !layer.holds_ruler_guides() { - return Ok(layer); - } + let Some(held) = picked_in_layers(&layer, &Node::is_ruler_guide) else { return Ok(layer) }; let mut guides = vec![]; layer.walk(&mut |c| { if c.is_ruler_guide() { guides.push(Arc::new(c.clone())); } }); - Arc::make_mut(&mut layer).strip_ruler_guides(); + drop_picked(&mut layer, held); let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); self.children_mut(Some(home))?.extend(guides); Ok(layer) @@ -418,21 +416,11 @@ impl Document { if gone.is_empty() { return 0; } - fn drop(layer: &mut Arc, gone: &HashSet) { - fn holds(n: &Node, gone: &HashSet) -> bool { - n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| gone.contains(&c.id) || holds(c, gone))) - } - if !holds(layer, gone) { - return; - } - let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; - ch.retain(|c| !(c.is_ruler_guide() && gone.contains(&c.id))); - for c in ch.iter_mut().filter(|c| c.is_layer()) { - drop(c, gone); - } - } + let pick = |n: &Node| n.is_ruler_guide() && gone.contains(&n.id); for l in &mut self.layers { - drop(l, &gone); + if let Some(held) = picked_in_layers(l, &pick) { + drop_picked(l, held); + } } sel.guides.retain(|id| !gone.contains(id)); if sel.key_guide.is_some_and(|k| gone.contains(&k)) { @@ -444,29 +432,11 @@ impl Document { /// Change the line of every ruler guide on artboard `id` with `f` (whether it is vertical, its /// position). fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { - fn visit(layer: &mut Arc, id: u32, f: &mut impl FnMut(bool, &mut f64)) { - fn holds(n: &Node, id: u32) -> bool { - n.is_layer() - && n.children().is_some_and(|ch| { - ch.iter().any(|c| matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) || holds(c, id)) - }) - } - if !holds(layer, id) { - return; - } - let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; - for c in ch.iter_mut() { - if matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) { - if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { - f(*vertical, pos); - } - } else if c.is_layer() { - visit(c, id, f); - } - } - } + let pick = |n: &Node| matches!(n.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id); for l in &mut self.layers { - visit(l, id, f); + if let Some(held) = picked_in_layers(l, &pick) { + edit_picked(l, held, f); + } } } @@ -580,65 +550,172 @@ impl Document { } } -/// Does `n`, or anything below it (opacity-mask art included), hold a ruler guide? -fn any_guide(n: &Node) -> bool { - let mut any = false; - crate::swatches::walk_with_masks(n, &mut |m| any |= m.is_ruler_guide()); - any +/// What a walk picked in a node: its children by index in order, each with what was picked +/// inside it, and what was picked in its opacity-mask art. Empty for a node picked itself. +#[derive(Debug, Default)] +struct Picked { + children: Vec<(usize, Picked)>, + mask: Option>, } -/// Remove every ruler guide below `n`, in its members and its opacity-mask art (`n` itself -/// stays). Mask art that is a ruler guide becomes an empty group. Returns how many went. -fn drop_guides_below(n: &mut Node) -> usize { - let mut gone = drop_mask_guides(n); - if let Some(ch) = n.children_mut() { - let before = ch.len(); - ch.retain(|c| !c.is_ruler_guide()); - gone += before - ch.len(); - for c in ch.iter_mut().filter(|c| any_guide(c)) { - gone += drop_guides_below(Arc::make_mut(c)); +impl Picked { + fn is_empty(&self) -> bool { + self.children.is_empty() && self.mask.is_none() + } + + /// `self`, or `None` when nothing was picked. + fn some(self) -> Option { + (!self.is_empty()).then_some(self) + } +} + +/// The children of `n`, if it is a layer, that `pick` picks (with nothing inside), and its +/// sublayers holding one (with what they hold), or `None` when it holds none. `pick` looks at +/// each child of the layers once. +fn picked_in_layers(n: &Node, pick: &impl Fn(&Node) -> bool) -> Option { + if !n.is_layer() { + return None; + } + let children = n + .children() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, c)| if pick(c) { Some((i, Picked::default())) } else { picked_in_layers(c, pick).map(|p| (i, p)) }) + .collect(); + Picked { children, mask: None }.some() +} + +/// Remove the children of `layer` and of its sublayers that [`picked_in_layers`] picked (`held`). +/// Only the layers holding one are copied, and only those are visited. +fn drop_picked(layer: &mut Arc, held: Picked) { + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + let mut held = held.children.into_iter().peekable(); + let mut next = 0; + ch.retain_mut(|c| { + let at = next; + next += 1; + let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true }; + // A sublayer holds something; a child picked itself has nothing inside. + if inner.is_empty() { + return false; } + drop_picked(c, inner); + true + }); +} + +/// Change with `f` (whether it is vertical, its position) the line of each ruler guide of `layer` +/// and of its sublayers that [`picked_in_layers`] picked (`held`), in the order they lie in. Only +/// the layers holding one, and the guides, are copied, and only those layers are visited. +fn edit_picked(layer: &mut Arc, held: Picked, f: &mut impl FnMut(bool, &mut f64)) { + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + for (i, inner) in held.children { + let Some(c) = ch.get_mut(i) else { continue }; + if !inner.is_empty() { + edit_picked(c, inner, f); + } else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } +} + +/// `n` picked itself when it is a ruler guide by `is_guide`, else the ruler guides below it +/// ([`guides_below`]). +fn guide_or_below(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { + if is_guide(n) { Some(Picked::default()) } else { guides_below(n, is_guide) } +} + +/// The ruler guides below `n`, by `is_guide`: those among its members and its opacity-mask art +/// (each with nothing inside), and the members and mask art holding one further down (with what +/// they hold), or `None` when there is none. `is_guide` looks at each node below `n` once, and at +/// nothing inside a ruler guide. +fn guides_below(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { + let mask = n.extras().mask.as_deref().and_then(|m| guide_or_below(&m.art, is_guide)).map(Box::new); + let children = n.children().into_iter().flatten().enumerate().filter_map(|(i, c)| guide_or_below(c, is_guide).map(|p| (i, p))).collect(); + Picked { children, mask }.some() +} + +/// Remove the ruler guides below `n` that [`guides_below`] `found`, in its members and its +/// opacity-mask art (`n` itself stays). Mask art that is a ruler guide becomes an empty group. +/// Only the nodes holding one are copied, and only those are visited. Returns how many went. +fn drop_guides_below(n: &mut Node, found: Picked) -> usize { + let mut gone = drop_mask_guides(n, found.mask); + if let Some(ch) = n.children_mut() { + let mut found = found.children.into_iter().peekable(); + let mut next = 0; + ch.retain_mut(|c| { + let at = next; + next += 1; + let Some((_, inner)) = found.next_if(|(i, _)| *i == at) else { return true }; + if inner.is_empty() { + gone += 1; + return false; + } + gone += drop_guides_below(Arc::make_mut(c), inner); + true + }); } gone } -/// Remove every ruler guide in the opacity-mask art of `n`. Mask art that is a ruler guide -/// becomes an empty group. Returns how many went. -fn drop_mask_guides(n: &mut Node) -> usize { +/// Remove the ruler guides `found` in the opacity-mask art of `n` ([`guides_below`]'s mask +/// part). Mask art that is a ruler guide becomes an empty group. Returns how many went. +fn drop_mask_guides(n: &mut Node, found: Option>) -> usize { + let Some(found) = found else { return 0 }; let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) else { return 0 }; - if mask.art.is_ruler_guide() { + if found.is_empty() { mask.art = Arc::new(Node::group(mask.art.id, vec![])); 1 - } else if any_guide(&mask.art) { - drop_guides_below(Arc::make_mut(&mut mask.art)) } else { - 0 + drop_guides_below(Arc::make_mut(&mut mask.art), *found) } } -/// Does layer `l` hold a ruler guide out of place: below art, in an object or in mask art, or so -/// in one of its sublayers? -fn layer_untidy(l: &Node) -> bool { - let Some(ch) = l.children() else { return false }; - let start = Node::guide_run_start(ch); - l.extras().mask.as_deref().is_some_and(|m| any_guide(&m.art)) - || ch.iter().take(start).any(|c| c.is_ruler_guide() || if c.is_layer() { layer_untidy(c) } else { any_guide(c) }) +/// The ruler guides, by `is_guide`, that layer `l` holds out of place, or `None` when it holds +/// none: those below art (each with nothing inside), those in its mask art and in its objects +/// ([`guides_below`]), and its sublayers holding one out of place (with what they hold). +/// `is_guide` looks at each node once; the guides at the end of a layer are in place and are +/// not looked at. +fn untidy(l: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { + let ch = l.children()?; + let mask = l.extras().mask.as_deref().and_then(|m| guide_or_below(&m.art, is_guide)).map(Box::new); + let children = ch + .iter() + .enumerate() + .take(Node::guide_run_start(ch)) + .filter_map(|(i, c)| { + let found = if is_guide(c) { + Some(Picked::default()) + } else if c.is_layer() { + untidy(c, is_guide) + } else { + guides_below(c, is_guide) + }; + found.map(|p| (i, p)) + }) + .collect(); + Picked { children, mask }.some() } -/// [`Document::tidy_ruler_guides`] for layer `l`. Returns how many guides were removed. -fn tidy_layer(l: &mut Node) -> usize { - let mut gone = drop_mask_guides(l); +/// [`Document::tidy_ruler_guides`] for layer `l`, whose guides out of place [`untidy`] `found`. +/// Only the nodes holding one are copied, and only those are visited. Returns how many guides +/// were removed. +fn tidy_layer(l: &mut Node, found: Picked) -> usize { + let mut gone = drop_mask_guides(l, found.mask); let Some(ch) = l.children_mut() else { return gone }; - for c in ch.iter_mut() { - if c.is_layer() { - if layer_untidy(c) { - gone += tidy_layer(Arc::make_mut(c)); - } - } else if !c.is_ruler_guide() && any_guide(c) { - gone += drop_guides_below(Arc::make_mut(c)); + let mut below_art = false; + for (i, inner) in found.children { + let Some(c) = ch.get_mut(i) else { continue }; + if inner.is_empty() { + below_art = true; + } else if c.is_layer() { + gone += tidy_layer(Arc::make_mut(c), inner); + } else { + gone += drop_guides_below(Arc::make_mut(c), inner); } } - if ch.iter().take(Node::guide_run_start(ch)).any(|c| c.is_ruler_guide()) { + if below_art { let (guides, art): (Vec<_>, Vec<_>) = std::mem::take(ch).into_iter().partition(|c| c.is_ruler_guide()); *ch = art; ch.extend(guides); @@ -698,3 +775,286 @@ impl std::fmt::Debug for GuideCache { f.write_str("GuideCache") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, OpacityMask, Symbol}; + use std::cell::Cell; + use vectorcraft_geom::shapes; + + /// The sublayers in [`nested`]'s chain: with the top-level layer around them and the objects in + /// the innermost, the deepest a document goes ([`crate::MAX_DEPTH`]). + const CHAIN: u64 = 30; + + fn rect(id: u64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())) + } + + fn guide(id: u64, artboard: Option) -> Arc { + Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos: id as f64, artboard })) + } + + fn group(id: u64, children: Vec>) -> Arc { + Arc::new(Node::group(NodeId(id), children)) + } + + /// `n` with `art` as its opacity-mask art. + fn masked(n: Arc, art: Arc) -> Arc { + let mut n = (*n).clone(); + n.extras_mut().mask = Some(Box::new(OpacityMask { art, clip: true, invert: false, disabled: false, linked: true })); + Arc::new(n) + } + + /// A sublayer like a new document's first layer, holding `children`. + fn sublayer(id: u64, children: Vec>) -> Arc { + let mut l = (*Document::new(1.0, 1.0).layers[0]).clone(); + l.id = NodeId(id); + if let Some(ch) = l.children_mut() { + *ch = children; + } + Arc::new(l) + } + + /// A document whose first layer holds `children`. + fn doc(children: Vec>) -> Document { + let mut d = Document::new(100.0, 100.0); + if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + *ch = children; + } + d + } + + /// The ids of the nodes in the layers, in tree order. + fn ids(d: &Document) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + out.push(n.id.0); + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + let mut out = vec![]; + for l in &d.layers { + walk(l, &mut out); + } + out + } + + /// [`CHAIN`] sublayers, each inside the one before and holding an object, a canvas guide and a + /// guide of artboard 7 (the innermost: object 150, guides 200 and 201). Beside the chain, a + /// sublayer with a canvas guide only. Returns the document and that sublayer. + fn nested() -> (Document, Arc) { + let mut chain = sublayer(100, vec![rect(150), guide(200, None), guide(201, Some(7))]); + for depth in 1..CHAIN { + chain = sublayer(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth * 2, None), guide(301 + depth * 2, Some(7))]); + } + let still = sublayer(500, vec![rect(502), guide(501, None)]); + (doc(vec![chain, Arc::clone(&still)]), still) + } + + /// How many children the layers of [`nested`] hold: the first layer's 2, the untouched + /// sublayer's 2, the innermost sublayer's 3 and 4 in each sublayer around it. + const NESTED_CHILDREN: usize = 2 + 2 + 3 + (CHAIN as usize - 1) * 4; + + fn on_artboard_7(n: &Node) -> bool { + matches!(n.kind, NodeKind::RulerGuide { artboard: Some(7), .. }) + } + + #[test] + fn removing_guides_looks_at_each_child_of_nested_layers_once() { + let (mut d, still) = nested(); + assert!(!d.clone().limit_depth(), "the chain is as deep as a document goes"); + let mut deeper = doc(vec![sublayer(999, d.layers[0].children().cloned().unwrap_or_default())]); + assert!(deeper.limit_depth(), "a document nested deeper is brought within the cap when it is read"); + let mut whole = d.clone(); + let looked = Cell::new(0_usize); + let pick = |n: &Node| { + looked.set(looked.get() + 1); + on_artboard_7(n) + }; + let held = picked_in_layers(&d.layers[0], &pick).unwrap(); + assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once"); + drop_picked(&mut d.layers[0], held); + assert_eq!(looked.get(), NESTED_CHILDREN, "removing looks at nothing again"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied"); + + // The command's own entry point removes the same guides. + let before = whole.clone(); + let mut sel = Selection::default(); + sel.set_guides([NodeId(200), NodeId(201), NodeId(501)]); + sel.objects.push(NodeId(150)); + sel.key_guide = Some(NodeId(201)); + assert_eq!(whole.retain_guides(&mut sel, |g| g.artboard != Some(7)), CHAIN as usize); + assert_eq!(ids(&whole), ids(&d)); + assert_eq!(ids(&whole).len(), ids(&before).len() - CHAIN as usize); + assert!(whole.ruler_guides().iter().all(|g| g.artboard.is_none())); + assert_eq!(whole.ruler_guides().len(), CHAIN as usize + 1); + assert_eq!((sel.objects.as_slice(), sel.guides.as_slice(), sel.key_guide), (&[NodeId(150)][..], &[NodeId(200), NodeId(501)][..], None)); + assert!(whole.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still))); + // The document as it was (an undo step's copy) still holds every guide. + assert_eq!(before.ruler_guides().len(), CHAIN as usize * 2 + 1); + + // Saving the guides as a list, and removing a layer but not its guides, take the guides + // out of the nested layers too. + let mut saved = before.clone(); + saved.guides_to_saved(); + assert_eq!(saved.saved_guides, before.ruler_guides()); + assert_eq!(ids(&saved).len(), 1 + CHAIN as usize * 2 + 2); + assert!(saved.layers[0].children().is_some_and(|c| !Arc::ptr_eq(&c[1], &still) && c[1].children().is_some_and(|c| c.len() == 1))); + let mut edited = before.clone(); + let chain = edited.remove_edit_layer(NodeId(100 + CHAIN - 1)).unwrap(); + assert!(!chain.holds_ruler_guides()); + assert_eq!(edited.ruler_guides().len(), before.ruler_guides().len(), "the removed layer's guides stay in the document"); + assert_eq!(edited.guides_on(edited.layers[0].id).len(), CHAIN as usize * 2); + + // Keeping every guide changes nothing and copies no layer. + let kept = whole.clone(); + assert_eq!(whole.retain_guides(&mut sel, |_| true), 0); + assert!(Arc::ptr_eq(&whole.layers[0], &kept.layers[0])); + // Removing every guide leaves the objects. + assert_eq!(whole.retain_guides(&mut sel, |_| false), CHAIN as usize + 1); + assert!(whole.ruler_guides().is_empty() && sel.guides.is_empty()); + assert_eq!(ids(&whole).len(), 1 + CHAIN as usize * 2 + 2); + assert_eq!(sel.objects, [NodeId(150)]); + // A document without layers, and one without guides, have nothing to remove. + let mut empty = Document::new(10.0, 10.0); + assert_eq!(empty.retain_guides(&mut sel, |_| false), 0); + empty.layers.clear(); + assert_eq!(empty.retain_guides(&mut sel, |_| false), 0); + } + + #[test] + fn moving_an_artboards_guides_looks_at_each_child_of_nested_layers_once() { + let (mut d, still) = nested(); + let mut whole = d.clone(); + let looked = Cell::new(0_usize); + let pick = |n: &Node| { + looked.set(looked.get() + 1); + on_artboard_7(n) + }; + let held = picked_in_layers(&d.layers[0], &pick).unwrap(); + assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once"); + let mut seen = vec![]; + edit_picked(&mut d.layers[0], held, &mut |vertical, pos: &mut f64| { + seen.push((vertical, *pos)); + *pos += 9.0; + }); + assert_eq!(looked.get(), NESTED_CHILDREN, "changing looks at nothing again"); + // A sublayer comes before its layer's own guides, so the innermost guide is changed first. + let order: Vec<(bool, f64)> = [201.0].into_iter().chain((1..CHAIN).map(|depth| (301 + depth * 2) as f64)).map(|pos| (false, pos)).collect(); + assert_eq!(seen, order, "each guide of the artboard is changed once, in the order the guides lie in"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with no guide of the artboard is not copied"); + + // The public moves change the same guides by the same amount. + let before = whole.clone(); + whole.move_artboard_guides(7, Vec2::new(5.0, 9.0)); + assert_eq!(whole, d); + assert_eq!(ids(&whole), ids(&before)); + for g in whole.ruler_guides() { + let id = g.id.map_or(0, |id| id.0) as f64; + assert_eq!(g.pos, if g.artboard == Some(7) { id + 9.0 } else { id }, "guide {id}"); + } + assert!(whole.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still))); + assert_eq!(before.ruler_guide(NodeId(201)).map(|g| g.pos), Some(201.0), "the document as it was keeps its guides"); + whole.map_artboard_guides(7, Affine::scale(2.0)); + assert_eq!(whole.ruler_guide(NodeId(201)).map(|g| g.pos), Some(420.0)); + assert_eq!(whole.ruler_guide(NodeId(200)).map(|g| g.pos), Some(200.0), "a canvas guide stays"); + + // An artboard without guides, and a document without layers, change nothing and copy no layer. + let kept = whole.clone(); + whole.move_artboard_guides(99, Vec2::new(5.0, 9.0)); + assert!(Arc::ptr_eq(&whole.layers[0], &kept.layers[0])); + let mut empty = Document::new(10.0, 10.0); + empty.layers.clear(); + empty.move_artboard_guides(7, Vec2::new(5.0, 9.0)); + assert!(empty.layers.is_empty()); + } + + #[test] + fn guides_in_objects_are_found_by_looking_at_each_node_once() { + // Groups, each inside the one before and holding an object and a guide. Beside them, an + // object whose mask art holds a guide, one whose mask art is a guide, and a group with none. + let mut chain = group(100, vec![rect(150), guide(200, None)]); + for depth in 1..CHAIN { + chain = group(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth, None)]); + } + let still = group(700, vec![rect(701)]); + let root = group( + 1, + vec![chain, masked(rect(800), group(801, vec![guide(802, None), rect(803)])), masked(rect(810), guide(811, None)), Arc::clone(&still)], + ); + let looked = Cell::new(0_usize); + let is_guide = |n: &Node| { + looked.set(looked.get() + 1); + n.is_ruler_guide() + }; + // The chain's groups, objects and guides; a masked object, its mask group and the two in + // it; a masked object and its mask guide; the last group and its object. + let nodes = CHAIN as usize * 3 + 4 + 2 + 2; + let found = guides_below(&root, &is_guide).unwrap(); + assert_eq!(looked.get(), nodes, "each node below the root is looked at once"); + let mut cleared = (*root).clone(); + assert_eq!(drop_guides_below(&mut cleared, found), CHAIN as usize + 2); + assert_eq!(looked.get(), nodes, "removing looks at nothing again"); + assert!(guides_below(&cleared, &Node::is_ruler_guide).is_none()); + let ch = cleared.children().unwrap(); + assert!(Arc::ptr_eq(&ch[3], &still), "a group without guides is not copied"); + assert_eq!( + ch[1].extras().mask.as_deref().map(|m| m.art.children().map(|c| c.iter().map(|c| c.id.0).collect::>())), + Some(Some(vec![803])) + ); + assert_eq!(ch[2].extras().mask.as_deref().map(|m| (m.art.id, m.art.children().map(Vec::len))), Some((NodeId(811), Some(0)))); + + // Opening a file removes the same guides from an object on a layer and from a symbol's art. + let mut d = doc(vec![Arc::clone(&root), guide(900, None)]); + d.symbols = vec![Symbol { name: "s".into(), art: Arc::clone(&root), dynamic: false }]; + assert_eq!(d.tidy_ruler_guides(), (CHAIN as usize + 2) * 2); + assert_eq!(d.layers[0].children().map(|c| (*c[0]).clone()), Some(cleared.clone())); + assert_eq!(*d.symbols[0].art, cleared); + assert_eq!(d.ruler_guides().iter().map(|g| g.id).collect::>(), [Some(NodeId(900))]); + } + + #[test] + fn guides_out_of_place_are_found_by_looking_at_each_node_once() { + // Sublayers, each inside the one before, with their guides in place; the innermost also + // holds a guide below its art. Beside them, a group holding a guide, a sublayer with its + // guide in place, and a sublayer whose mask art holds a guide. + let mut chain = sublayer(100, vec![guide(200, None), rect(150), guide(201, None)]); + for depth in 1..CHAIN { + chain = sublayer(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth, None)]); + } + let still = sublayer(500, vec![rect(502), guide(501, None)]); + let with_mask = masked(sublayer(510, vec![rect(513)]), group(511, vec![guide(512, None)])); + let mut d = doc(vec![chain, group(700, vec![rect(701), guide(702, None)]), Arc::clone(&still), with_mask, guide(900, None)]); + let mut whole = d.clone(); + let looked = Cell::new(0_usize); + let is_guide = |n: &Node| { + looked.set(looked.get() + 1); + n.is_ruler_guide() + }; + // Below the guides at the end of each layer: the first layer's 4, the group's 2, the + // untouched sublayer's 1, the masked sublayer's mask group, its guide and the object, 2 in + // the innermost sublayer and 2 in each sublayer around it. + let nodes = 4 + 2 + 1 + 3 + 2 + (CHAIN as usize - 1) * 2; + let found = untidy(&d.layers[0], &is_guide).unwrap(); + assert_eq!(looked.get(), nodes, "each node below the guides in place is looked at once"); + assert_eq!(tidy_layer(Arc::make_mut(&mut d.layers[0]), found), 2); + assert_eq!(looked.get(), nodes, "tidying looks at nothing again"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[2], &still)), "a sublayer with its guides in place is not copied"); + assert!(untidy(&d.layers[0], &Node::is_ruler_guide).is_none()); + + // Opening a file does the same. + let before = whole.clone(); + assert_eq!(whole.tidy_ruler_guides(), 2); + assert_eq!(whole, d); + let all = ids(&whole); + assert!(!all.contains(&702) && all.len() == ids(&before).len() - 1); + let innermost = whole.node(NodeId(100)).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id.0).collect::>()); + assert_eq!(innermost, Some(vec![150, 200, 201]), "a guide below art moves up to the other guides"); + assert!(whole.node(NodeId(510)).is_some_and(|n| n.extras().mask.as_deref().is_some_and(|m| m.art.children().is_some_and(Vec::is_empty)))); + // A tidy document is left alone, and no layer is copied. + let kept = whole.clone(); + assert_eq!(whole.tidy_ruler_guides(), 0); + assert!(Arc::ptr_eq(&whole.layers[0], &kept.layers[0])); + } +} diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index f3b655d76a..43074a4e80 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -166,6 +166,16 @@ pub struct LabelStyles { /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. #[serde(skip_serializing_if = "Option::is_none")] pub legend_anti_alias: Option, + /// The object fill and stroke rows the value axis numbers keep once set with the Appearance controls ([`LabelRows`]). + /// `None`: no rows, opaque and Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_rows: Option, + /// The object fill and stroke rows the category labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_rows: Option, + /// The object fill and stroke rows the legend labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_rows: Option, } impl LabelStyles { @@ -355,6 +365,22 @@ pub struct SeriesPaint { pub blend: Option, } +/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode +/// of a label's topmost visible fill and stroke row (an absent paint: no row of that kind), the label's object opacity and +/// blend mode, and where the two rows paint relative to each other and to the characters. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LabelRows { + #[serde(flatten)] + pub paint: SeriesPaint, + /// How many of the kept rows paint below the characters, counted from the bottom. Absent: none. + #[serde(skip_serializing_if = "Option::is_none")] + pub below: Option, + /// The stroke row paints below the fill row. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_under: bool, +} + /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each /// holding one value per series; `series` are the legend labels (the first row of the window). /// Scatter graphs read each series as (y, x) column pairs, like Illustrator. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e6986ad00e..c51e099840 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -92,8 +92,8 @@ pub use compound_shape::ShapeMode; pub use corners::LiveCorners; pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES}; pub use graph::{ - ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, - PieSort, SeriesPaint, TickLength, ValueAxisSide, + ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelRows, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, + PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; @@ -117,6 +117,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; +pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index ba79f7c280..844aa8c810 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -388,7 +388,9 @@ pub fn graph_paints(n: &Node) -> impl Iterator { let g = n.extras().graph.as_deref(); let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten(); let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten(); - series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])) + let l = g.map(|g| &g.label_styles); + let rows = l.into_iter().flat_map(|l| [&l.values_rows, &l.categories_rows, &l.legend_rows]).flatten(); + series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])).chain(rows.flat_map(|r| [r.paint.fill.as_ref(), r.paint.stroke.as_ref()]).flatten()) } /// Mutable [`graph_paints`] of graph `g`, in the same order. @@ -396,7 +398,10 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; - assert_eq!(d.map_solid_paints(&mut to_blue), 4); + assert_eq!(d.map_solid_paints(&mut to_blue), 5); let g = d.node(a).unwrap().extras().graph.clone().unwrap(); assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand"))); assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand"))); let legend = g.label_styles.legend.unwrap(); assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.fill, Some(linked(blue, "Brand"))); let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None); - assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three"); + assert_eq!(d.map_paints(&mut cleared), 5, "the fill and the graph's four"); assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d69da9bae2..440c379283 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,7 +17,10 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, ScriptMetrics}; +pub use char_style::{ + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + WARICHU_SCALE, WarichuAlign, +}; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, CompositeParts, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index a880a2a2bc..16b63e0809 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -145,6 +145,29 @@ pub struct CharStyle { /// #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] pub standard_vertical_roman: bool, + /// Warichu (Character panel menu, East Asian options): these characters are set at + /// [`Self::warichu_scale`] percent of their size and stacked on [`Self::warichu_lines`] lines + /// inside their line, one above the other in horizontal type and side by side in vertical type. + /// A stretch of marked characters is one block that takes the settings of its first character + /// and stays whole on one line. Type on a path sets them at their own size on the path. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub warichu: bool, + /// Warichu Settings › Lines: how many lines a warichu block is stacked on, within + /// [`WARICHU_LINES`]. Saved only when not 2. + #[serde(default = "two", rename = "warichuLines", skip_serializing_if = "is_two")] + pub warichu_lines: u8, + /// Warichu Settings › Line Gap, in points from 0 to [`MAX_WARICHU_GAP`]: the space between the + /// lines of a warichu block. Saved only when not 0. + #[serde(default, rename = "warichuGap", skip_serializing_if = "crate::skip::is_default")] + pub warichu_gap: f64, + /// Warichu Settings › Scale: the size of warichu characters as a percentage of their size, + /// within [`WARICHU_SCALE`]. Saved only when not 50. + #[serde(default = "fifty", rename = "warichuScale", skip_serializing_if = "is_fifty")] + pub warichu_scale: f64, + /// Warichu Settings › Alignment: how the lines of a warichu block line up along the block. + #[serde(default, rename = "warichuAlign", skip_serializing_if = "crate::skip::is_default")] + pub warichu_align: WarichuAlign, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] @@ -417,6 +440,82 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). +pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; + +/// The largest Warichu Settings Line Gap ([`CharStyle::warichu_gap`]), in points. +pub const MAX_WARICHU_GAP: f64 = 100.0; + +/// The Warichu Settings Scale ([`CharStyle::warichu_scale`]) range, in percent of the size. +pub const WARICHU_SCALE: std::ops::RangeInclusive = 10.0..=100.0; + +fn two() -> u8 { + 2 +} + +fn is_two(v: &u8) -> bool { + *v == 2 +} + +fn fifty() -> f64 { + 50.0 +} + +fn is_fifty(v: &f64) -> bool { + *v == 50.0 +} + +/// How the lines of a warichu block line up along the block ([`CharStyle::warichu_align`]): +/// each line set flush left (top, in vertical type), centered or flush right (bottom), or spread +/// to the block's length with the last line set flush left, centered or flush right, or spread as +/// well. Auto is the default and sets the lines as [`WarichuAlign::JustifyLeft`] does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WarichuAlign { + #[default] + Auto, + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +impl WarichuAlign { + /// Every alignment, in the order Warichu Settings lists them. + pub const ALL: [WarichuAlign; 8] = [ + WarichuAlign::Auto, + WarichuAlign::Left, + WarichuAlign::Center, + WarichuAlign::Right, + WarichuAlign::JustifyLeft, + WarichuAlign::JustifyCenter, + WarichuAlign::JustifyRight, + WarichuAlign::JustifyAll, + ]; + + /// The name files and commands give this alignment (as it is saved). + pub fn id(self) -> &'static str { + match self { + WarichuAlign::Auto => "auto", + WarichuAlign::Left => "left", + WarichuAlign::Center => "center", + WarichuAlign::Right => "right", + WarichuAlign::JustifyLeft => "justifyLeft", + WarichuAlign::JustifyCenter => "justifyCenter", + WarichuAlign::JustifyRight => "justifyRight", + WarichuAlign::JustifyAll => "justifyAll", + } + } + + /// The alignment [`WarichuAlign::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + WarichuAlign::ALL.into_iter().find(|a| a.id() == id) + } +} + pub(super) fn yes() -> bool { true } @@ -464,6 +563,11 @@ impl Default for CharStyle { tcy_up_down: 0.0, tcy_left_right: 0.0, standard_vertical_roman: false, + warichu: false, + warichu_lines: 2, + warichu_gap: 0.0, + warichu_scale: 50.0, + warichu_align: WarichuAlign::Auto, small_caps: None, digits: Digits::Default, font_height_reference: FontHeightReference::EmBox, @@ -484,7 +588,10 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. + /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to + /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its + /// own units. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; @@ -499,6 +606,10 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); + self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; + self.warichu_scale = + if self.warichu_scale.is_nan() { default.warichu_scale } else { self.warichu_scale.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()) }; } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 68f41abd5b..dd4002dd60 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -34,10 +34,19 @@ impl TextObject { style.baseline_shift *= scale.points; style.tcy_up_down *= scale.points; style.tcy_left_right *= scale.points; + style.warichu_gap *= scale.points; style.h_scale *= scale.horizontal; - if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale] - .iter() - .all(|v| v.is_finite()) + if ![ + style.size, + style.leading.unwrap_or(0.0), + style.baseline_shift, + style.tcy_up_down, + style.tcy_left_right, + style.warichu_gap, + style.h_scale, + ] + .iter() + .all(|v| v.is_finite()) { return None; } @@ -114,6 +123,12 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// Does layout stack some of this type's characters as warichu ([`CharStyle::warichu`])? Not + /// on a path, which sets them as they are. Exports that write live text write such type as + /// outlines, as they do ruby: their text can't place the stacked lines. + pub fn stacks_warichu(&self) -> bool { + !matches!(self.kind, TextKind::OnPath { .. }) && self.runs.iter().any(|r| r.style.warichu && !r.text.is_empty()) + } /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. /// /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a @@ -492,8 +507,23 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); + st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); + // Lines is a whole number: a stored number outside the range, or with a fraction, + // becomes the nearest whole number within it. + if let Some(old) = def.attrs.get_mut("warichuLines") + && old.is_number() + && old.as_u64().is_none_or(|v| !super::WARICHU_LINES.contains(&u8::try_from(v).unwrap_or(u8::MAX))) + { + let (lo, hi) = (*super::WARICHU_LINES.start(), *super::WARICHU_LINES.end()); + let v = old.as_f64().filter(|v| v.is_finite()).map_or(2.0, |v| v.round().clamp(f64::from(lo), f64::from(hi))); + // Within 1..6 after the clamp, so the cast keeps the value. + *old = (v as u8).into(); + } let kept = [ + ("warichuGap", Some(st.warichu_gap)), + ("warichuScale", Some(st.warichu_scale)), ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 4fcaa8d078..b8531b402d 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, underline and strikethrough. + /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -370,6 +370,7 @@ impl ParaList { aki_right: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, + warichu: false, underline: false, strikethrough: false, ..first.clone() diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2642fca878..7500af0617 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec { runs } +/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in +/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no +/// item of `n` and have none. +pub(crate) fn solid_paint_sources(n: &Node) -> (Vec>, Vec) { + let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + if own.is_empty() { + let paints = solid_paints(n); + return (vec![None; paints.len()], paints); + } + let paints = own.iter().map(|i| (*i).clone()).collect(); + (own.into_iter().map(Some).collect(), paints) +} + fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { @@ -416,6 +429,12 @@ const MAX_MEMBERS: usize = 4096; /// normals): past it the object is too complex to export. const MAX_MESH_TRIANGLES: usize = 2_000_000; +/// The most facets of solid colors under colored lights that one 3D effect draws as faces of their +/// own, to go in one order with other solids' faces. Each such face is about 16 nodes, a composite +/// of a whole surface about 6 per facet, so at this count the faces hold about as many nodes as the +/// composites do at the paint-copy allowance. Past it those solids draw as one composite each. +const MAX_FACE_COMPOSITES: usize = 30_720; + /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, /// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends /// and clipping groups), at any depth of its groups, each through its own geometry @@ -522,9 +541,12 @@ pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and -/// blend mode (so overlapping facets don't add up their transparency). -fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node { - if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) { +/// blend mode (so overlapping facets don't add up their transparency). Under colored lights a solid +/// color with smooth facets is one composite of all its facets ([`composites_facets`]) unless +/// `faces`: then each facet is a face of its own, as in a gradient, so the faces can go in one order +/// with the faces of other solids. +fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool, faces: bool) -> Node { + if !faces && composites_facets(&facets, paint, colored) { let mut group = crate::lighting::solid_surface(n.id, &facets, paint); group.opacity = opacity; group.blend = blend; @@ -570,6 +592,12 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } +/// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it +/// whole: a solid color under colored lights with smooth shading? +fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool { + colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) +} + /// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend. fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { match item { @@ -587,10 +615,10 @@ fn paint_ref(item: &AppearanceItem) -> &Paint { } /// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`). +/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for +/// each face). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some()); - facets.len().saturating_mul(if derived { 6 } else { 2 }) + facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -1252,8 +1280,9 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, -/// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// each in its own paint. When every one is opaque with normal blending, their faces are drawn in +/// one back-to-front order, a solid color under colored lights included up to +/// [`MAX_FACE_COMPOSITES`] facets (where depths tie, as /// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's /// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and /// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the @@ -1508,7 +1537,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) }); } } - let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + let art = + if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored, false) }; out.push((s.scopes.as_slice(), Arc::new(art))); } } @@ -1541,22 +1571,31 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile opacity >= 1.0 && blend == BlendMode::Normal }); let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path)); - // A solid whose surface is one composite of all its facets ([`surface`]: a solid color under - // colored lights with smooth shading) has no faces to order. - let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some()); - // The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under - // a member's opacity mask or blending, each member's faces go in one order of their own when its - // fills and strokes are opaque, blend normally, carry no opacity mask and are not composites. - let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f)); + // A solid color under colored lights with smooth shading draws as one composite of its facets + // ([`surface`]) unless its facets go in one order with other solids' faces. They do while all + // such facets together number at most MAX_FACE_COMPOSITES. + let composite = |item: &AppearanceItem, f: &[Facet]| composites_facets(f, paint_ref(item), colored); + let colored_faces: usize = solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum(); + let split = colored_faces <= MAX_FACE_COMPOSITES; + // The faces are drawn in one order (below) when every solid is opaque and blends normally and + // no composite draws a surface whole. Under a member's opacity mask or blending, each member's + // faces go in one order of their own when its fills and strokes are opaque, blend normally, + // carry no opacity mask and are not composites. + let one_order = opaque && (split || !solids.iter().any(|(item, _, _, f, _)| composite(item, f))); let shape_order: Vec = (0..shapes.len()) .map(|shape| { solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| { let (paint, opacity, blend) = paint_of(item, *member); - opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f) + opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && (split || !composite(item, f)) }) }) .collect(); let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order }; + // The solids whose faces share one order with another solid's: every solid in the one order of + // the object, or of its member under a mask. A lone solid draws its facets in their own order, + // so a solid color under colored lights stays one composite there ([`surface`]). + let shared: Vec = + (0..shapes.len()).map(|shape| ordered(shape) && solids.iter().filter(|s| !masks || s.4 == shape).nth(1).is_some()).collect(); if solids.iter().any(|s| ordered(s.4)) { // A stroke face that passes through a fill wall or another stroke's face is cut where it // does. The pieces count against the limits as the faces did: cutting stops before the @@ -1592,6 +1631,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; + // With the pieces of the cuts, solid colors under colored lights past MAX_FACE_COMPOSITES draw + // as one composite each, so their surfaces draw whole (below). + let split = + split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::() <= MAX_FACE_COMPOSITES; let members: Vec = solids.iter().map(|s| s.4).collect(); let surfaces: Vec = solids .into_iter() @@ -1600,7 +1643,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); - let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + let art = if classic { + classic::surface(n, f, &paint, opacity, blend, &p) + } else { + surface(n, f, &paint, opacity, blend, colored, split && shared.get(shape).copied().unwrap_or(false)) + }; (depths, art, scopes, ends) }) .collect(); @@ -1609,8 +1656,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, _ => extrude_options(&p).cap, }; - // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets. A composite + // One order takes a plain group of faces from each solid, one face for each facet. A lone + // solid's surface can instead be one clipped composite of all its facets ([`surface`]), which // has no faces to order, so it draws whole. let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()); if masks { @@ -1771,7 +1818,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { @@ -1789,9 +1836,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { crate::raster::follow_paints(&mut paints, &n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item))); let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc| { @@ -2086,16 +2133,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi } else { let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?); } } diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index c825159dda..eb9e81bbc0 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -491,8 +491,8 @@ fn a_masked_members_fill_and_stroke_faces_go_in_one_order() { } /// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the -/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color, -/// drawn as one composite of each surface's facets, still draws each surface whole. +/// fill's and the dashed stroke's faces in one order, as it does under a white light, and so does +/// Inflate in solid colors. #[test] fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; @@ -530,9 +530,111 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len(); assert_eq!(white.1, cut, "the white light's faces, cut"); assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light"); - // A solid color under the colored light: a composite for each of the fill and the stroke. + // A solid color under the colored light: the fill's and the stroke's faces in one order too. let solid = stroked(&letter_c()); - assert_eq!(drawn(&solid, Some(colored)).0, 2); + assert_eq!(drawn(&solid, Some(colored)).0, 1); +} + +/// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt. +fn drawn_face_bounds(f: &Node) -> Rect { + match &f.kind { + NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(), + NodeKind::Group { clip: true, children } => { + let cover = &children[1].children().unwrap()[0]; + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0) + } + _ => panic!("a face is a path or a clipped composite"), + } +} + +/// Under a light that is not white, a solid-color fill and a solid-color stroke, plain, dashed or +/// with a width profile, draw their faces in one order, the faces of both solids in the order of +/// their depths where they overlap, in Inflate and in Extrude & Bevel, with and without perspective +/// and in a member with an opacity mask. Drawing each solid whole, the fill's faces before the +/// stroke's, shows farther faces on top. +#[test] +fn colored_solid_fills_and_strokes_share_face_depth_order() { + use vectorcraft_doc::{Dash, WidthProfile}; + type Style<'a> = (&'a str, &'a dyn Fn(&mut StrokeLayer)); + let styles: [Style; 3] = [ + ("plain", &|_| {}), + ("dashed", &|s| s.dash = Some(Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false })), + ("profiled", &|s| s.profile = Some(WidthProfile::lens())), + ]; + let views = [ + json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), + json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}), + ]; + let mut whole_misdrawn = 0; + for id in [INFLATE, EXTRUDE] { + for view in &views { + for (style, set) in &styles { + let mut params = view.clone(); + params["depth"] = json!(60); + params["lights"] = json!([{"color": "#ff8080"}, {"color": "#4060ff", "rotation": 120}]); + let mut n = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + set(s); + } + let what = format!("{id} {view} {style}"); + let parts = drawn_parts(&n, id, ¶ms); + if id == INFLATE { + assert!(parts.iter().any(|(f, ..)| f.rgb_gradient.is_some()), "{what}: smooth colored facets"); + } + let mut with = n.clone(); + with.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&with).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 1, "{what}: one order"); + let faces = surfaces[0].children().unwrap(); + assert_eq!(faces.len(), parts.len(), "{what}: one face per facet"); + for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() { + let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap()); + let close = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6); + assert!(close, "{what}: face {k} drawn out of the order of the depths"); + } + let allowed = if id == INFLATE { 5 } else { 0 }; + let wrong = misdrawn(&parts); + assert!(wrong.len() <= allowed, "{what}: {} points show a farther face, first {:?}", wrong.len(), wrong.first()); + // The same faces drawn as two whole surfaces, the fill's then the stroke's. + let mut whole = parts.clone(); + whole.sort_by_key(|(_, part, _)| *part); + whole_misdrawn += misdrawn(&whole).len(); + // In a group, under the member's opacity mask, the faces keep the same order. + if id == INFLATE && *style == "plain" { + let cover = Node::path( + NodeId(900), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + let mut masked = n.clone(); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true))); + let mut plain = Node::group(NodeId(2), vec![Arc::new(n.clone())]); + plain.appearance.effects.push(fx(id, params.clone())); + let mut g = Node::group(NodeId(2), vec![Arc::new(masked)]); + g.appearance.effects.push(fx(id, params.clone())); + let bounds = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |n| { + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + }); + out + }; + let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); + let mut masks = 0; + scoped.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert!(masks > 0, "{what}: drawn in the member's mask"); + let ordered = bounds(&plain); + assert!(ordered.len() > 100, "{what}: clipped faces"); + assert_eq!(bounds(&scoped), ordered, "{what}: the member's faces in one order"); + } + } + } + } + assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they @@ -1322,11 +1424,12 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. }))); } +/// A solid color under colored lights is one composite of all its facets, with one clip and a fixed +/// number of groups, where its solid's faces draw in no order shared with another solid's: a lone +/// fill, or a fill and a translucent stroke. A fill and an opaque stroke draw each face on its own, +/// in one order. #[test] fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { - let mut n = square_node(); - n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]}))); - let art = solid_art(&n).unwrap(); fn count(n: &Node) -> (usize, usize, usize) { let (mut groups, mut clips, mut paths) = match &n.kind { NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0), @@ -1341,13 +1444,125 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { } (groups, clips, paths) } - let (groups, clips, paths) = count(&art); - assert!(paths > 1000, "exercise a tessellated surface"); - // The fill's solid and the stroke's, each one composite with its own clip. - let surfaces = art.children().unwrap().len(); - assert_eq!(surfaces, 2); - assert_eq!(clips, surfaces); - assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count"); + let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]}); + let lone = |n: &mut Node| n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + let translucent = |n: &mut Node| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = 0.5; + } + }; + for (what, set, surfaces) in [("a lone fill", &lone as &dyn Fn(&mut Node), 1), ("a translucent stroke", &translucent, 2)] { + let mut n = square_node(); + set(&mut n); + n.appearance.effects.push(fx(INFLATE, lights.clone())); + let art = solid_art(&n).unwrap(); + let (groups, clips, paths) = count(&art); + assert!(paths > 1000, "{what}: exercise a tessellated surface"); + // Each solid one composite with its own clip. + assert_eq!(art.children().unwrap().len(), surfaces, "{what}"); + assert_eq!(clips, surfaces, "{what}"); + assert_eq!(groups, 1 + 6 * surfaces, "{what}: group count must not grow with triangle count"); + } + // An opaque fill and stroke: one order of faces, each its own clipped composite or shaded path. + let mut n = square_node(); + n.appearance.effects.push(fx(INFLATE, lights)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 1, "one order"); + let faces = surfaces[0].children().unwrap(); + assert!(faces.len() > 100, "one face per facet"); + assert!(faces.iter().all(|f| matches!(&f.kind, NodeKind::Path { .. } | NodeKind::Group { clip: true, .. }))); + assert!(faces.iter().any(|f| matches!(&f.kind, NodeKind::Group { clip: true, .. })), "colored ramps"); +} + +/// Solid colors under colored lights go in one order of faces while their facets together, with the +/// pieces of the cuts, number at most `MAX_FACE_COMPOSITES`. Past it each solid draws whole, as one +/// composite. +#[test] +fn colored_solids_past_the_face_limit_draw_as_composites() { + let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]}); + let mut base = square_node(); + base.appearance.effects.push(fx(INFLATE, lights.clone())); + let stroke = base.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned().unwrap(); + // The facets each solid draws under these lights. Strokes alike each build the same solid. + let shapes = [Profile { path: profile(&base).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&base), opacity: 1.0, scopes: vec![] }]; + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let counts: Vec = built_solids(INFLATE, &crate::merged_params(INFLATE, &lights), &parts, bounds, MAX_OBJECT_FACES) + .map(|f| f.unwrap().map_or(0, |f| f.len())) + .collect(); + let [fill, each] = counts[..] else { panic!("a fill's solid and a stroke's") }; + let fits = (MAX_FACE_COMPOSITES - fill) / each; + assert!(fits >= 4, "{fill} + {fits} × {each}"); + // Under the limit; under it before the cuts but past it with their pieces; past it before them. + let mut seen = vec![]; + for strokes in [fits / 2, fits, fits + 1] { + let mut n = base.clone(); + n.appearance.items.extend(std::iter::repeat_n(stroke.clone(), strokes - 1)); + let uncut = fill + strokes * each; + let cut = drawn_parts(&n, INFLATE, &lights).len(); + let one_order = cut <= MAX_FACE_COMPOSITES; + seen.push((uncut <= MAX_FACE_COMPOSITES, one_order)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + if one_order { + assert_eq!(surfaces.len(), 1, "{uncut} faces, {cut} cut: one order"); + } else { + assert_eq!(surfaces.len(), 1 + strokes, "{uncut} faces, {cut} cut: each solid whole"); + assert!(surfaces.iter().all(|s| s.children().is_some_and(|c| c.len() < 10)), "each solid one composite"); + } + } + assert_eq!(seen, [(true, true), (true, false), (false, false)], "every case"); +} + +/// A solid-color face drawn on its own under colored lights takes the same colors as in the +/// surface's one composite: the paint, multiplied by the same red, green and blue light ramps, and +/// a seam of the same width. +#[test] +fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { + let n = square_node(); + let p = crate::merged_params(INFLATE, &json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]})); + let (path, rule) = profile(&n).unwrap(); + let all = facets(INFLATE, &path, rule, path.bounds().unwrap(), &p).unwrap(); + let face = all + .iter() + .find(|f| f.rgb_gradient.is_some_and(|g| g.channels[0] != g.channels[1] || g.channels[1] != g.channels[2])) + .expect("a facet with colored ramps") + .clone(); + let paint = Paint::solid(RED); + // Each node's own fill paint, and its seam stroke's width, for every node under `n`. + let paints = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |n| { + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(f) => out.push((Some(f.paint.clone()), None)), + AppearanceItem::Stroke(s) => out.push((None, Some(s.width))), + } + } + }); + out + }; + let alone = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, true); + let batch = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, false); + let [single] = alone.children().unwrap().as_slice() else { panic!("one face") }; + assert!(matches!(&single.kind, NodeKind::Group { clip: true, .. }), "a clipped face"); + // The face's paint cover and its three channel ramps. + let fills = |n: &Node| paints(n).into_iter().filter_map(|(f, _)| f).collect::>(); + let one = fills(single); + assert_eq!(one.len(), 4); + assert_eq!(one[0], paint, "the paint"); + // The composite: its paint cover, then each channel's black backdrop and the face's ramp. + let all_fills = fills(&batch); + let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + assert_eq!(all_fills.first(), Some(&paint)); + assert_eq!(ramps, one.iter().skip(1).collect::>(), "the same light ramps"); + // The single face's clip holds its seam: the clip reaches past the facet by half the seam. + let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect(); + assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}"); + let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") }; + let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap()); + assert!((clip.x0 - (facet.x0 - seam / 2.0)).abs() < 0.1 && (clip.x1 - (facet.x1 + seam / 2.0)).abs() < 0.1, "{clip:?} {facet:?}"); } /// `square_node` moved right by `dx`, with id `id` and fill `fill`. diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 00e741bfe6..e6e7d89ab5 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet; use super::{ Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, + reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 48665539d9..627cc1be03 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -8,6 +8,7 @@ mod imageoutline; mod painted; mod rasterfollow; mod scale; +mod solidcontext; mod transformfx; fn fx(id: &str, params: Value) -> Effect { diff --git a/crates/effects/src/tests/solidcontext.rs b/crates/effects/src/tests/solidcontext.rs new file mode 100644 index 0000000000..2de212dba1 --- /dev/null +++ b/crates/effects/src/tests/solidcontext.rs @@ -0,0 +1,74 @@ +//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it +//! is on, in the drawn art, the exported meshes and the wireframe. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId}; +use vectorcraft_geom::{PathData, Rect, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in}; + +/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks +/// for, `SMALL` otherwise. +const LARGE: f64 = 600.0; +const SMALL: f64 = 6.0; + +/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in +/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude & +/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise. +fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node { + let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w)); + let id = format!("test.effects.solidcontext.{name}"); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0); + appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))); + let effects = appearance.items[at].effects_mut(); + effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap()); + Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance) +} + +/// The width of the union of `paths`' bounds. +fn width(paths: impl IntoIterator) -> f64 { + paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width()) +} + +/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half +/// of `LARGE`, with the widths. +fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) { + let art = solid_art(n).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| paths.extend(c.path_data().cloned())); + let meshes = three_d_meshes_in(None, n).unwrap(); + let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0])); + let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min); + let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())]; + (widths.map(|w| w > LARGE / 2.0), widths) +} + +/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so +/// the art, meshes and wireframe are all built from the square it returns for that fill. +#[test] +fn a_plugin_before_a_fills_extrude_is_told_its_fill() { + let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`. +#[test] +fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() { + let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one +/// extruded. +#[test] +fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() { + let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#)); + assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 166436b0c8..94d8add68b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -46,7 +46,7 @@ pub mod importmatches; pub(crate) mod inline; mod interactivity; mod intertwine; -mod layer; +pub(crate) mod layer; mod layerpanel; pub mod library; pub mod linkfiles; diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 735d721335..d7cae868ed 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; +use super::super::appearance::series_children_in_paint_order; use super::super::edit::selected_roots; +use super::super::graph::capture_series_object_opacity; use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; @@ -90,7 +92,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -99,7 +101,7 @@ pub fn specs() -> Vec { "Sort Objects", ["Object", "Plug-ins", "Arrange Tools"], None, - "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}", has_multi, sort ), @@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); s.edit("Randomize Opacity", |d, _| { + let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); - if let Some(n) = d.node_mut(*id) { + if let Some(n) = d.node_mut(*id) + && n.opacity != o + { n.opacity = o; + changed.push(*id); } } + keep_series_opacity(d, &changed); Ok(()) })?; Ok(json!({ "count": ids.len() })) } +/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are +/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn +/// again; the frontmost of them in a series sets them. +fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) { + let marks = series_children_in_paint_order(d, changed); + capture_series_object_opacity(d, &marks); +} + /// An object Sort Objects sorts: its layer or group, its place in it, its box as /// `object.setBounds` measures it and its opacity (%). struct Sorted { @@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result { n.opacity = (v / 100.0) as f32; } } + let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); + keep_series_opacity(d, &changed); Ok(()) })?; return Ok(count); diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 52b20cdddc..be32cf0a83 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect}; use vectorcraft_pathops::addons as po; use super::super::super::edit::outermost; -use super::super::super::layer::empty_artboards; +use super::super::super::layer::{empty_artboards, remove_artboards}; use crate::Result; /// Sine of the turn below which an anchor on a straight run is collinear. @@ -508,35 +508,6 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result = HashSet::with_capacity(empty.len()); - let mut i = 0; - d.artboards.retain(|a| { - let keep = empty.binary_search(&i).is_err(); - i += 1; - if !keep { - gone.insert(a.id); - } - keep - }); - count_artboard_pass(); - for &id in &gone { - d.retire_artboard_id(id); - } - d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); - count_artboard_pass(); + remove_artboards(d, sel, &empty)?; Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) } - -#[cfg(test)] -thread_local! { - /// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this - /// thread. - pub(super) static ARTBOARD_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Tests: count one walk over the artboards or the ruler guides. -fn count_artboard_pass() { - #[cfg(test)] - ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); -} diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 2ccdce9dad..4d4ed6461b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -502,7 +502,7 @@ fn removing_many_selected_objects_looks_at_each_root_once_a_pass() { #[test] fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() { - use super::checks::ARTBOARD_PASSES; + use crate::cmd::layer::ARTBOARD_PASSES; let mut s = session(); rect(&mut s, 200.0, 20.0, 80.0, 60.0); let (keep, gone) = s diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 7d7dcbb197..052ba901ed 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -206,11 +206,31 @@ struct Source { text: String, } +#[cfg(test)] +thread_local! { + /// Tests: how many times [`thread_index`] went through the document's threads on this thread. + static THREAD_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Each object the document's text threads list, with the index of the first thread that lists it. +fn thread_index(d: &Document) -> std::collections::HashMap { + #[cfg(test)] + THREAD_SCANS.with(|c| c.set(c.get() + 1)); + let mut out = std::collections::HashMap::new(); + for (i, thread) in d.text_threads.iter().enumerate() { + for id in thread { + out.entry(*id).or_insert(i); + } + } + out +} + impl Source { - /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. - fn of(d: &Document, id: NodeId) -> Option { + /// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all + /// fits; None otherwise. + fn of(d: &Document, id: NodeId, threaded: bool) -> Option { let t = text_of(d, id)?; - if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded { return None; } let lay = layout(t); @@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result { }; let d = &s.doc()?.doc; let (mut items, mut skipped, mut count) = (vec![], vec![], 0); - for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let threads = thread_index(d); + for id in ids { + let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else { skipped.push(id); continue; }; @@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result { return Err(EngineError::Other("Click in type with a Type tool to split it".into())); }; let d = &s.doc()?.doc; - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + let threaded = d.text_threads.iter().any(|th| th.contains(&id)); + let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else { return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); }; let text = &src.text; @@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; // A threaded story whose frames are all among them is read as one text, once. let named: std::collections::HashSet = ids.iter().copied().collect(); + let threads = thread_index(d); let mut done = std::collections::HashSet::new(); let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![]; for &id in &ids { if !done.insert(id) { continue; } - let story = crate::cmd::threads::story_of(d, id); + let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) { + Some(thread) => crate::cmd::threads::story_in(d, thread, id), + None => vec![], + }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { done.extend(story.iter().copied()); out.extend(story_cuts(d, &story, brk)); diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 3769cf3b42..cfa78a5ce5 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() { assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); } +/// Two area frames threaded together, with `story` flowing through them → (first, second). +fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) { + let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}})); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap()); + select(s, &[a, b]); + s.execute("text.thread.create", &json!({})).unwrap(); + (a, b) +} + +fn thread_scans() -> usize { + super::THREAD_SCANS.with(|c| c.get()) +} + +/// Threaded frames in the selection stay as they are and are reported as skipped; the other type +/// is broken. The document's threads are gone through once for the whole selection, not once +/// per object. +#[test] +fn break_text_skips_threaded_frames_and_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "Threaded story text"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14}))); + } + let was = glyphs(&s, &plain); + let frames = (obj(&s, a), obj(&s, b)); + let before = doc_json(&s); + let steps = undo_steps(&s); + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]); + assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are"); + let pieces = ids(&r); + assert_eq!(pieces.len(), 12); + assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected"); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert_eq!(undo_steps(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn split_text_refuses_a_threaded_frame() { + let mut s = session(); + let (a, _) = threaded(&mut s, "Threaded story text"); + select(&mut s, &[a]); + edit_text(&mut s, a, 9, 9); + let before = doc_json(&s); + assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type")); + assert_eq!(doc_json(&s), before); +} + #[test] fn combine_puts_glyph_pieces_back_together_where_they_were() { let mut s = session(); @@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other +/// object on its own, going through the document's threads once for the whole selection. +#[test] +fn remove_breaks_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "one\ntwo"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12}))); + } + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(r["removed"], 7); + assert_eq!(obj(&s, a).plain_text(), "one two"); + for id in plain { + assert_eq!(obj(&s, id).plain_text(), "a b"); + } +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index ced10c9912..fa8182c4c2 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Apply Fill to Stroke", ["Object", "Plug-ins", "Color Effects"], None, - "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", + "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", has_doc, |s, p| copy_paint(s, p, true) ), @@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { if !ids.is_empty() { let before = s.doc()?.doc.clone(); super::super::overprint::edit_counted(s, label, |d| { + // Each object with the row the paint went to, for the paint a graph stores for its series. + let mut written = Vec::new(); for id in &ids { let Some(node) = d.node_mut(*id) else { continue }; if let NodeKind::Text(t) = &mut node.kind { @@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) }; // A missing item reads as None: copying None onto it would add an item that // changes nothing. - if (skip_none && from.is_none()) || from == to { + if skip_none && from.is_none() { continue; } - if to_stroke { - restore_unpainted_stroke(ap, None, &from); - ap.set_stroke(from); - } else { - ap.set_fill(from); + if from != to { + if to_stroke { + restore_unpainted_stroke(ap, None, &from); + ap.set_stroke(from); + } else { + ap.set_fill(from); + } } + // A row that already had the paint counts as written with it, as for `paint.setFill`. + written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at))); } objects = super::super::appearance::count_changed(d, &before, &ids); // The graph keeps the paint its series marks and labels now have. - let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke); + let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written); let labels = super::super::graph::capture_label_styles(d, &ids, ©_run); Ok(objects + usize::from(series || labels)) })?; diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 2734ca10b3..37e9f2fa79 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,9 +1,10 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together //! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art -//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are -//! the UI's. +//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner +//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's. +mod banner; mod stencil; mod weed; @@ -12,5 +13,6 @@ use super::super::CommandSpec; pub fn specs() -> Vec { let mut v = stencil::specs(); v.extend(weed::specs()); + v.extend(banner::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/banner.rs b/crates/engine/src/cmd/addons/cutting/banner.rs new file mode 100644 index 0000000000..f4ae71ed54 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner.rs @@ -0,0 +1,228 @@ +//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools +//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line, +//! where the edge is folded back and sewn (), and grommet +//! marks, where rings are set into the material to hang it +//! (), spread evenly at most a set spacing apart. The +//! banner is the selected art's bounds, or each artboard. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::length_given; +use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param}; +use super::weed::outside_clips; +use crate::{Result, Session}; + +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; +/// The sides of a banner, in the order the grommets go round it. +const SIDES: [&str; 4] = ["top", "right", "bottom", "left"]; +/// The largest hem, inset or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most grommets one run adds. +const MAX_GROMMETS: usize = 100_000; +/// Two grommet centers closer than this are one grommet (the corner that two sides share). +const SAME: f64 = 1e-6; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.banner", + "Banner Hems and Grommets…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}", + has_doc, + banner + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// The length `p` gives for `key` (0.01..1000), or `default`. +fn size(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.01..=1000.0).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))), + } +} + +/// Which sides are on, in [`SIDES`] order. +type Sides = [bool; 4]; + +/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside +/// the banner, and when both edges are chosen the two sets stay apart. +fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool { + match (near, far) { + (true, true) => 2.0 * depth < len, + (true, false) | (false, true) => depth < len, + (false, false) => true, + } +} + +/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the +/// other. +fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> { + if hem <= 0.0 { + return vec![]; + } + let lines = [ + (Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)), + (Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)), + (Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)), + (Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)), + ]; + lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect() +} + +/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end +/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them +/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two +/// chosen sides share has one grommet. `None` when there would be more than `limit`. +fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option> { + let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset); + // Each side from its start to its end, clockwise. + let runs = [ + (Point::new(l, t), Point::new(rt, t)), + (Point::new(rt, t), Point::new(rt, b)), + (Point::new(rt, b), Point::new(l, b)), + (Point::new(l, b), Point::new(l, t)), + ]; + let mut out: Vec = vec![]; + for ((a, z), on) in runs.into_iter().zip(sides) { + if !on { + continue; + } + let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0); + if !gaps.is_finite() || gaps > limit as f64 { + return None; + } + let gaps = gaps as usize; + for i in 0..=gaps { + let p = a.lerp(z, i as f64 / gaps as f64); + // A side starts where the one before it ends, and the last one ends where the first + // starts. + let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME); + if !shared(out.last()) && !shared(out.first()) { + out.push(p); + } + if out.len() > limit { + return None; + } + } + } + Some(out) +} + +fn banner(s: &mut Session, p: &Value) -> Result { + let hem = distance(p, "hem", 72.0)?; + let inset = distance(p, "grommetInset", 36.0)?; + let spacing = match length_given(p, "spacing", BANNER)? { + None => 1728.0, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => v, + Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + let diameter = size(p, "diameter", 27.0)?; + let weight = size(p, "weight", 0.3)?; + let with_grommets = switch_or(p, "grommets", true, BANNER)?; + let mut sides = [true; 4]; + for (on, key) in sides.iter_mut().zip(SIDES) { + *on = switch_or(p, key, true, BANNER)?; + } + if !sides.contains(&true) { + return Err(bad(BANNER, "turn on at least one side")); + } + if hem == 0.0 && !with_grommets { + return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark")); + } + let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?; + let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?; + let banners: Vec = if all { + s.doc()?.doc.artboards.iter().map(|a| a.rect).collect() + } else { + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(BANNER, "select the banner, or mark every artboard")); + } + s.doc()?.doc.bounds_of(&roots, false).into_iter().collect() + }; + if banners.is_empty() { + return Err(bad(BANNER, "there is no banner to mark")); + } + let [top, right, bottom, left] = sides; + let mut marks = vec![]; + let mut total = 0; + for r in &banners { + let (w, h) = (r.width(), r.height()); + if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad(BANNER, "the banner has no width or height")); + } + // A hem line must stay inside the banner and apart from the hem line on the far side. + if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) { + return Err(bad(BANNER, "the hem is too deep for the banner")); + } + let centers = if with_grommets { + // The grommets on a side run from `inset` past one end to `inset` before the other, + // stay inside the banner and stay apart from the grommets on the far side. + let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h); + if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) { + return Err(bad(BANNER, "the grommet inset is too large for the banner")); + } + grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total) + .ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))? + } else { + vec![] + }; + total += centers.len(); + marks.push((hem_lines(*r, hem, sides), centers)); + } + let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?); + let ap = Appearance::basic(Paint::None, stroke, weight); + let half = diameter / 2.0; + let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| { + let (mut ids, mut hems) = (vec![], 0); + for (lines, centers) in &marks { + let mut children = vec![]; + for (a, b) in lines { + let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()); + n.name = Some("Hem".into()); + children.push(Arc::new(n)); + } + hems += lines.len(); + for c in centers { + let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone()); + let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone()); + let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]); + g.name = Some("Grommet".into()); + children.push(Arc::new(g)); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Banner Marks".into()); + ids.push(g.id); + d.insert(parent, usize::MAX, g)?; + } + sel.set(ids.iter().copied()); + Ok((ids, hems)) + })?; + let first = ids.first().map(|i| i.0); + Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::>(), "hems": hems, "grommets": total })) +} diff --git a/crates/engine/src/cmd/addons/cutting/banner/tests.rs b/crates/engine/src/cmd/addons/cutting/banner/tests.rs new file mode 100644 index 0000000000..b186720b45 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner/tests.rs @@ -0,0 +1,263 @@ +//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::{grommet_centers, hem_lines}; +use crate::*; + +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).cloned().unwrap() +} + +/// Runs Banner Hems and Grommets with `p` and gives the first group it made. +fn banner(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.banner", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +fn round(p: Point) -> (f64, f64) { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + (r(p.x), r(p.y)) +} + +/// The hem lines in group `g` as their two ends. +fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + g.children() + .unwrap() + .iter() + .filter(|c| c.name.as_deref() == Some("Hem")) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a hem line is one straight segment"); + (round(a[0]), round(a[1])) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +/// The grommet marks in group `g` as their centers. +fn grommets(g: &Node) -> Vec<(f64, f64)> { + g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect() +} + +#[test] +fn hems_and_grommets_go_round_a_selected_banner() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8})); + assert_eq!(g.name.as_deref(), Some("Banner Marks")); + assert_eq!( + hems(&g), + vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))] + ); + // 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the + // corners are shared. + assert_eq!( + grommets(&g), + vec![ + (10.0, 10.0), + (136.667, 10.0), + (263.333, 10.0), + (390.0, 10.0), + (390.0, 100.0), + (390.0, 190.0), + (263.333, 190.0), + (136.667, 190.0), + (10.0, 190.0), + (10.0, 100.0) + ] + ); + assert_eq!(r["hems"], 4); + assert_eq!(r["grommets"], 10); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); +} + +#[test] +fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5})); + let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap(); + assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0))); + let parts = first.children().unwrap(); + assert_eq!(parts.len(), 3, "a circle and two lines"); + for c in parts.iter() { + assert!(matches!(c.kind, NodeKind::Path { .. }), "a path"); + assert_eq!(c.appearance.fill_paint(), Paint::None); + assert!(c.appearance.stroke_paint().is_registration()); + assert_eq!(c.appearance.stroke_width(), 0.5); + } +} + +#[test] +fn every_artboard_is_marked_when_nothing_is_selected() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let second = node(&s, ids[1]); + assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt"); + assert!(grommets(&second).is_empty()); + assert_eq!(r["grommets"], 0); +} + +#[test] +fn only_the_chosen_sides_are_marked() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false})); + assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]); + assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]); +} + +#[test] +fn units_are_read_and_bad_params_are_refused() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 400.0); + let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0))); + assert_eq!(grommets(&g)[0], (36.0, 36.0)); + let before = s.doc().unwrap().history.undo.len(); + for p in [ + json!({"hem": -1}), + json!({"hem": "x"}), + json!({"spacing": 0}), + json!({"diameter": 0}), + json!({"weight": 1e9}), + json!({"top": "yes"}), + json!({"top": false, "right": false, "bottom": false, "left": false}), + json!({"hem": 0, "grommets": false}), + json!({"hem": 250}), + json!({"grommetInset": 250}), + json!({"spacing": 0.001}), + json!({"swatch": "no such swatch"}), + ] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing"); +} + +#[test] +fn hems_and_grommets_on_one_side_may_go_past_the_middle() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000}); + let with = |extra: Value| { + let mut p = one.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70}))); + assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]); + assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]); + for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends"); + } + // A grommet inset of half the width puts the grommets of a side on one point: one grommet. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 300.0); + let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10}))); + assert_eq!(grommets(&g), vec![(50.0, 50.0)]); +} + +#[test] +fn hems_and_grommets_on_opposite_sides_stay_apart() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let pair = |p: Value| { + let mut q = json!({"right": false, "left": false, "spacing": 1000}); + q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone()); + q + }; + // Half the height would put the top and bottom marks on the same line. + let refused = [ + pair(json!({"hem": 50})), + pair(json!({"hem": 0, "grommetInset": 50})), + json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}), + ]; + for p in refused { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5}))); + assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]); + assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]); +} + +#[test] +fn the_marks_go_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (g, art) = s + .edit("clip group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default()); + let art_id = art.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + Ok((id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false})); + // The clipping path would hide marks inside the clip group. + assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent); + assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner"); +} + +#[test] +fn all_artboards_marks_the_artboards_even_with_a_selection() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0))); +} + +#[test] +fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() { + let mut s = session(); + assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err()); +} + +#[test] +fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap(); + assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]); + // Spacing that divides the side exactly does not add a grommet, even when the division + // rounds up (2.1 / 0.3 is 7.000000000000001). + let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap(); + assert_eq!(c.len(), 8); + assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit"); + assert!(hem_lines(r, 0.0, [true; 4]).is_empty()); +} diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs index 8a6375a595..183a66ad57 100644 --- a/crates/engine/src/cmd/addons/cutting/weed.rs +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option, spacing: Option) -> Resu /// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping /// sublayer (a group edited in isolation), the container around the outermost of them. A clipping /// path would hide the lines and the box, which lie outside the art. -fn outside_clips(d: &Document, parent: Option) -> Option { +pub(super) fn outside_clips(d: &Document, parent: Option) -> Option { let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); let (mut at, mut out) = (parent, parent); while let Some(p) = at { diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 9ddcc43559..8a271557a9 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -2,8 +2,8 @@ //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's //! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners //! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's -//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes -//! into Paths. +//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes +//! into Paths and Trim at Crossings ([`trim`]). //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. @@ -14,6 +14,7 @@ mod shape; mod shared_edge; #[cfg(test)] mod tests; +mod trim; use std::collections::{BTreeMap, BTreeSet}; @@ -143,7 +144,7 @@ pub fn specs() -> Vec { "Add Points at Crossings", ["Object", "Plug-ins", "Path Precision Tools"], None, - "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", + "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", has_selection, crossings ), @@ -178,6 +179,7 @@ pub fn specs() -> Vec { v.extend(shape::specs()); v.extend(corners::specs()); v.extend(shared_edge::specs()); + v.extend(trim::specs()); v } @@ -609,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { let n = sp.anchors.len().max(1); match segments.filter(|set| !set.is_empty()) { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + Some(set) => super::super::path::subpath_refs(set, si).collect(), None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), } } @@ -681,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result { // ---------- Split / Add Points at Crossings ---------- +/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in +/// the order they come in `on`. A place on a subpath past the last is left out. +fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec> { + let mut by_sub = vec![vec![]; subpaths]; + for &(si, seg, t) in on { + if let Some(here) = by_sub.get_mut(si) { + here.push((seg, t)); + } + } + by_sub +} + fn crossings(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.crossings"; let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?); @@ -702,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result { } let mut subs = vec![]; let mut marks = vec![]; - for (si, sp) in path.subpaths.iter().enumerate() { - let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) { let (out, m) = pt::insert_at(sp, &here); count += m.len(); subs.push(out); diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..f98880e204 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; use vectorcraft_geom::shapes::CornerKind; -use vectorcraft_geom::{AnchorKind, PathData, Point}; +use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath}; use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::{Session, ViewInfo}; @@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() { refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]); } +/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an +/// empty subpath. +fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId { + let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false); + let pd = + PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), false)).collect()); + s.edit("Test", |d, _| { + super::set_path(d, id, pd); + Ok(()) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// The anchor points of each subpath of `pd`, rounded to a millionth. +fn points(pd: &PathData) -> Vec> { + let r = |v: f64| (v * 1e6).round() / 1e6; + pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect() +} + +/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last). +const SUBS: [&[(f64, f64)]; 4] = + [&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]]; + +/// Direct-select `segments` of `id` and the anchors `anchors`. +fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) { + s.select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, anchors.iter().copied().collect()); + sel.segments.insert(id, segments.iter().copied().collect()); + }) + .unwrap(); +} + +#[test] +fn picked_segments_reads_only_its_own_subpath() { + let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into(); + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false); + let all: BTreeSet = (0..4).collect(); + let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::>(); + assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]); + // Without direct-selected segments, the segments between two selected anchors. + let anchors: BTreeSet = [0, 1, 3].into(); + for none in [None, Some(&BTreeSet::new())] { + assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::>(), [0]); + } +} + +#[test] +fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() { + let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)]; + let by_sub = super::places_by_subpath(&on, 4); + assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]); + // The same places as filtering the whole list for each subpath. + for (si, here) in by_sub.iter().enumerate() { + let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + assert_eq!(*here, filtered); + } + assert!(super::places_by_subpath(&on, 0).is_empty()); +} + +#[test] +fn add_points_adds_to_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2)); + let want = vec![ + vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)], + vec![], + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)], + ]; + let after = path(&s, c); + assert_eq!(points(&after), want); + assert_eq!(steps(&s), n + 1); + let st = s.doc().unwrap(); + assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, c), after); + // Without selected segments: those between two selected anchors, here on the last subpath only. + for segments in [None, Some(BTreeSet::new())] { + s.execute("edit.undo", &json!({})).unwrap(); + s.select(|_, sel| { + sel.set([c]); + sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into()); + sel.segments.remove(&c); + if let Some(set) = segments.clone() { + sel.segments.insert(c, set); + } + }) + .unwrap(); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1)); + assert_eq!( + points(&path(&s, c)), + [points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat() + ); + } +} + +#[test] +fn crossings_add_anchors_on_each_subpath_of_a_compound_path() { + let mut s = session(); + // A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not. + let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]); + let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false); + let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false); + select(&mut s, &[c, q, r]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8)); + let want = vec![ + vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), want); + assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + // Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths. + let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap()); + assert_eq!(out.len(), 7); + assert_eq!(out[0], c); + let pieces = vec![ + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(100.0, 50.0), (150.0, 50.0)], + vec![(150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0)], + vec![(100.0, 250.0), (150.0, 250.0)], + vec![(150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), pieces); + let qs: Vec>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect(); + assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]); + assert_eq!(steps(&s), n + 1); +} + // ---------- Parametric Shape Tool ---------- /// The parametric shape object `id` is now, as the tool reads it. @@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() { assert_eq!(layer, pieces, "in the path's place, in order"); } +#[test] +fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(r["dashes"], json!(8)); + let made: Vec>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect(); + let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]]; + let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]]; + want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0))); + want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]); + want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]); + want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0))); + assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order"); + assert_eq!(s.doc().unwrap().selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + // With no segments selected: those between two selected anchors, here on the last subpath only. + select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4))); + assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]); +} + #[test] fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/pathtools/trim.rs b/crates/engine/src/cmd/addons/pathtools/trim.rs new file mode 100644 index 0000000000..134edc6607 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim.rs @@ -0,0 +1,321 @@ +//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the +//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle +//! touches, or every stretch a line through some points crosses. It works on every path that can +//! be edited, selected or not. + +use std::collections::HashSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind}; +use vectorcraft_geom::kurbo::{Line, PathSeg}; +use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit}; +use vectorcraft_pathops::addons::pathtools as pt; + +use super::super::super::draw2::replace_with_pieces; +use super::super::super::pathops::Beside; +use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +/// The undo step's name. +const LABEL: &str = "Trim at Crossings"; +/// How near `at` a path counts as under it by default (pt), and the most. +const TOLERANCE: f64 = 3.0; +const MAX_TOLERANCE: f64 = 100.0; +/// Most points of `points`. +const MAX_POINTS: usize = 100_000; +/// The farthest coordinate the command takes. +const MAX_COORD: f64 = 1e7; + +pub(super) fn specs() -> Vec { + vec![cmd!( + "addon.pathtools.trim", + "Trim at Crossings", + [], + None, + "{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}", + has_doc, + trim + )] +} + +/// What picks the stretches to remove. +enum Pick { + /// The stretch of the nearest path within the tolerance of the point. + At(Point, f64), + /// Every stretch the rectangle touches. + Rect(Rect), + /// Every stretch the line through the points crosses. + Line(Vec, Rect), +} + +fn pick(p: &Value) -> Result { + const C: &str = "addon.pathtools.trim"; + let given = |key: &str| p.get(key).filter(|v| !v.is_null()); + let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD); + match (given("at"), given("rect"), given("points")) { + (Some(at), None, None) => { + let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?; + let tolerance = number_or(p, "tolerance", TOLERANCE, C)?; + if !(0.0..=MAX_TOLERANCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 100 pt")); + } + Ok(Pick::At(Point::new(x, y), tolerance)) + } + (None, Some(rect), None) => { + let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?; + Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs())) + } + (None, None, Some(points)) => { + let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?; + if !(2..=MAX_POINTS).contains(&list.len()) { + return Err(bad(C, "`points` must hold from 2 to 100000 points")); + } + let pts = list + .iter() + .map(|v| { + finite_numbers(v) + .filter(|xy| within(xy)) + .map(|[x, y]| Point::new(x, y)) + .ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]"))) + }) + .collect::>>()?; + let bounds = pts.iter().fold(None, |r: Option, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q)))); + Ok(Pick::Line(pts, bounds.unwrap_or_default())) + } + _ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")), + } +} + +/// A path that takes part: its id, geometry and control-point box. +type Part<'a> = (NodeId, &'a PathData, Rect); + +/// The paths that take part, bottom to top: the ones that can be edited, without guides and +/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the +/// members of a compound clipping path included. +fn parts(d: &Document) -> Vec> { + let editable = d.editable(); + let mut masks = HashSet::new(); + d.walk(|n| { + if n.clips() + && let Some(first) = n.children().and_then(|c| c.first()) + { + first.walk(&mut |m| { + masks.insert(m.id); + }); + } + }); + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind + && !masks.contains(&n.id) + && let Some(b) = path.control_bounds() + && editable(n.id) + { + out.push((n.id, path, b)); + } + }); + out +} + +/// Most box tests one command makes between a line's pieces and the paths' segments. +const MAX_LINE_TESTS: usize = 60_000_000; +/// Most tests one command makes between the boxes of the paths to trim and of the other paths. +const MAX_BOX_TESTS: usize = 50_000_000; + +/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`; +/// `None` once it is spent. +fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option { + let segs: Vec<_> = (0..sp.segment_count()) + .map(|i| { + let c = sp.segment(i); + (c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9)) + }) + .collect(); + let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) }; + for w in pts.windows(2) { + let [a, b] = w else { continue }; + let piece = Rect::from_points(*a, *b); + *budget = budget.checked_sub(1)?; + if !reach.overlaps(piece) { + continue; + } + for (c, r) in &segs { + *budget = budget.checked_sub(1)?; + if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() { + return Some(true); + } + } + } + Some(false) +} + +/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`. +fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result { + match pick { + Pick::At(..) => Ok(false), + Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)), + Pick::Line(pts, _) => crossed_by_line(piece, pts, budget) + .ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")), + } +} + +/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths +/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no +/// part of `path` leaves it out before its crossings are looked for. +fn trimmed( + path: &PathData, + others: &[&PathData], + pick: &Pick, + on: Option, + budget: &mut usize, + work: &mut pt::Work, +) -> Result, usize)>> { + const C: &str = "addon.pathtools.trim"; + let reached = match pick { + Pick::At(..) => true, + Pick::Rect(r) => hit::intersects_rect(path, *r), + Pick::Line(..) => { + let mut any = false; + for sp in &path.subpaths { + if touches(sp, pick, budget)? { + any = true; + break; + } + } + any + } + }; + if !reached { + return Ok(None); + } + let places = + pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?; + let mut subs = vec![]; + let mut removed = 0; + for (si, sp) in path.subpaths.iter().enumerate() { + // The places come in subpath order: this subpath's are one run of them. + let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si); + let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect(); + let spans = pt::spans(sp, &here); + let marks: Vec = match pick { + Pick::At(..) => { + let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t)); + (0..spans.len()).map(|i| Some(i) == at).collect() + } + _ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::>()?, + }; + match pt::without_spans(sp, &spans, &marks) { + Some(pieces) => { + removed += marks.iter().filter(|m| **m).count(); + subs.extend(pieces); + } + None => subs.push(sp.clone()), + } + } + Ok((removed > 0).then_some((subs, removed))) +} + +fn trim(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.trim"; + let pick = pick(p)?; + let st = s.doc()?; + let d = &st.doc; + let parts = parts(d); + // In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them. + let root = crate::cmd::ordinary_isolation(st); + let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id)); + // The paths to trim, each with the place `at` falls on. + let targets: Vec<(usize, Option)> = match &pick { + Pick::At(at, tolerance) => { + let mut best: Option<(usize, pt::Place, f64)> = None; + for (i, (id, path, b)) in parts.iter().enumerate() { + if !in_scope(*id) { + continue; + } + let b = b.inflate(*tolerance, *tolerance); + if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) { + continue; + } + // Of paths equally near, the one on top. + if let Some((si, seg, t, _, dist)) = path.nearest(*at) + && dist <= *tolerance + && best.is_none_or(|b| dist <= b.2) + { + best = Some((i, (si, seg, t), dist)); + } + } + best.map(|(i, on, _)| (i, Some(on))).into_iter().collect() + } + Pick::Rect(r) | Pick::Line(_, r) => { + parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect() + } + }; + // Each path to trim has its box tested against every other path's. + if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS { + return Err(bad(C, "the rectangle or line reaches too many paths to check")); + } + let mut plan: Vec<(NodeId, Vec)> = vec![]; + let mut removed = 0; + let mut budget = MAX_LINE_TESTS; + // One budget of crossing work for every path the command trims. + let mut work = pt::Work::default(); + for (i, on) in targets { + let Some((id, path, b)) = parts.get(i) else { continue }; + let reach = b.inflate(1e-3, 1e-3); + let others: Vec<&PathData> = + parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect(); + let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue }; + removed += n; + // A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths. + let pieces = match (path.subpaths.len(), subs.is_empty()) { + (_, true) => vec![], + (1, false) => subs.into_iter().map(PathData::single).collect(), + _ => vec![PathData::new(subs)], + }; + plan.push((*id, pieces)); + } + if plan.is_empty() { + return Ok(json!({ "ids": [], "removed": 0 })); + } + let ids = s.edit(LABEL, |d, sel| { + let trimmed: Vec = plan.iter().map(|(id, _)| *id).collect(); + // The compound paths that may lose their last member. + let mut compounds: Vec = trimmed + .iter() + .filter_map(|id| d.parent_of(*id)) + .filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .collect(); + compounds.dedup(); + let mut beside = Beside::replacing(d, &trimmed); + let mut all = vec![]; + for (id, pieces) in plan { + let selected = sel.contains(id); + sel.anchors.remove(&id); + sel.segments.remove(&id); + let made = replace_with_pieces(d, &mut beside, id, pieces)?; + // The pieces of a selected path are selected; what was not selected stays so. + if made.is_empty() { + sel.remove(id); + } else if selected { + for n in &made { + if !sel.contains(*n) { + sel.add(*n); + } + } + } + all.extend(made); + } + beside.finish(d)?; + for c in compounds { + if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) { + d.remove(c)?; + sel.remove(c); + } + } + Ok(all) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "removed": removed })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/trim/tests.rs b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs new file mode 100644 index 0000000000..42adb70ab9 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/trim/tests.rs @@ -0,0 +1,527 @@ +//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses, +//! one undo step, and what takes no part. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point}; + +use crate::Session; + +const TRIM: &str = "addon.pathtools.trim"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + id +} + +/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200. +fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false); + let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false); + let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false); + (h, v1, v2) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn exists(s: &Session, id: NodeId) -> bool { + s.doc().unwrap().doc.node(id).is_some() +} + +/// The first and last anchor of the path's only subpath. +fn ends(s: &Session, id: NodeId) -> (Point, Point) { + let p = path(s, id); + assert_eq!(p.subpaths.len(), 1); + (p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p) +} + +fn near(p: Point, x: f64, y: f64) -> bool { + p.distance(Point::new(x, y)) < 1e-6 +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +/// The ids of the first layer's objects, bottom to top. +fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) { + s.edit("flags", |d, _| { + let n = d.node_mut(id).unwrap(); + n.locked = locked; + n.visible = visible; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone(); + let (before, n) = (stack(&s), steps(&s)); + let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2, "{r}"); + assert_eq!(made[0], h, "the first piece keeps the id"); + let (a, b) = (ends(&s, made[0]), ends(&s, made[1])); + assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}"); + assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}"); + // Both pieces look as the path did and stand where it stood, under the uprights. + assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look)); + assert_eq!(stack(&s), [made[0], made[1], v1, v2]); + assert_eq!(before, [h, v1, v2]); + // The uprights are as they were, nothing got selected, and it is one undo step. + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + assert!(s.doc().unwrap().selection.is_empty()); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stack(&s), before); + assert!(near(ends(&s, h).1, 300.0, 100.0)); +} + +#[test] +fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1))); + let e = ends(&s, h); + assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}"); + // No anchor is added at the crossing the rest runs through. + assert_eq!(path(&s, h).anchor_count(), 2); + // A point on no path within the tolerance removes nothing and makes no step. + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0})); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 200.0, 100.0)); +} + +#[test] +fn a_closed_path_that_loses_a_stretch_opens() { + let mut s = session(); + let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false); + // The top half of the square's outline, between the two places the line crosses it. + let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1))); + let p = path(&s, sq); + assert_eq!(p.subpaths.len(), 1); + assert!(!p.subpaths[0].closed); + let pts: Vec = p.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}"); + assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0))); + // It is a plain path now. + assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. })); +} + +#[test] +fn a_rectangle_removes_every_stretch_it_touches_as_one_step() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let n = steps(&s); + // Over the middle of both lines across, touching neither upright. + let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(ids(&r).len(), 4); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0)); + // A rectangle given from its far corner is the same one; one over empty space removes nothing. + assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2)); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + // An upright's stretches: above the upper line, between the two, below the lower. A rectangle + // over its top end takes the first only. + assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0)); +} + +#[test] +fn a_line_through_points_removes_the_stretches_it_crosses() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + let n = steps(&s); + // Down through the middle of the line across, then right through the lower end of the second upright. + let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap(); + assert_eq!(r["removed"], json!(2)); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0)); + assert_eq!(path(&s, v1).anchor_count(), 2); +} + +#[test] +fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + set_flags(&mut s, v1, true, true); + set_flags(&mut s, v2, false, false); + let n = steps(&s); + // On the locked upright, and a rectangle over the hidden one's top end: nothing goes. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0)); + assert_eq!(steps(&s), n); + assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2)); + // Neither marks a place on the line across: with nothing else crossing it, it goes whole. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2)); + // A guide doesn't count either. + s.execute("edit.undo", &json!({})).unwrap(); + set_flags(&mut s, v1, false, true); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert!(!exists(&s, h)); +} + +#[test] +fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() { + let mut s = session(); + // Lines of different lengths, so the places on one fall at other parameters than on the other. + let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false); + let two = s + .edit("two", |d, _| { + let layer = d.layers[0].id; + let id = d.alloc_id(); + let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?; + Ok(id) + }) + .unwrap(); + line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false); + // The middle of the lower subpath goes; the upper one stays whole, both in the one path. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1)); + let p = path(&s, two); + let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect(); + assert_eq!(spans.len(), 3, "{spans:?}"); + assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}"); + assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}"); + assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}"); +} + +#[test] +fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() { + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + s.edit("clip", |d, _| { + if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind { + *clipping = true; + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(path(&s, v1).anchor_count(), 2); + // The line across is crossed by the second upright only: its stretch from the start goes. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole. + let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, h) && exists(&s, t)); +} + +#[test] +fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() { + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false); + s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap(); + let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let n = steps(&s); + // A click on the clipping path's member changes nothing, and the rectangle over it neither. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0)); + assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2)); + // The clipped line across is crossed by the second upright only: its stretch from the start goes. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); +} + +#[test] +fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() { + let mut s = session(); + let (h, v1, v2) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + // The line across is outside the group: a click or a rectangle on it leaves it whole. + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0)); + assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0)); + assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // It still crosses the uprights: a rectangle over the tops of both removes them down to it. + assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2)); + assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0)); + assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + // An isolated path is trimmed itself. + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": h.0})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); +} + +#[test] +fn a_rectangle_over_too_many_paths_to_check_is_refused() { + let mut s = session(); + // 7,072 short lines apart from each other: each of them against every other is past the limit. + s.edit("many", |d, _| { + let layer = d.layers[0].id; + for i in 0..7072 { + let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false); + let id = d.alloc_id(); + let top = d.node(layer).unwrap().children().unwrap().len(); + d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?; + } + Ok(()) + }) + .unwrap(); + let n = steps(&s); + let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string(); + assert!(err.contains("too many paths"), "{err}"); + assert_eq!((steps(&s), stack(&s).len()), (n, 7072)); + // A rectangle over a few of them is checked. + assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2)); +} + +#[test] +fn a_path_too_large_to_search_is_refused_only_when_touched() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // A border of 10,001 segments round all of them, more than a path to trim may have. + let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect(); + border.push((-500.0, 1500.0)); + let b = line(&mut s, &border, true); + // A rectangle and a line over the middle of the line across reach inside the border's box only. + assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1)); + // A rectangle on the border is refused, and changes nothing. + let n = steps(&s); + let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string(); + assert!(err.contains("too many or too tangled"), "{err}"); + assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003)); +} + +#[test] +fn a_path_nothing_crosses_goes_whole_open_or_closed() { + let mut s = session(); + let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false); + let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true); + let n = steps(&s); + assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(!exists(&s, open) && !exists(&s, closed)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() { + let mut s = session(); + let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false); + // The stub ends on the line: the line's stretch stops there, and the stub is one stretch. + s.execute(TRIM, &json!({"at": [50, 100]})).unwrap(); + assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0)); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1)); + assert!(!exists(&s, stub)); + // A second line along the first doesn't mark it: the lower one goes whole. + let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false); + s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap(); + assert!(!exists(&s, h) && exists(&s, along)); +} + +#[test] +fn a_self_crossing_path_loses_its_loop() { + let mut s = session(); + // A bow: its first and third segments cross at (50, 50). + let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false); + let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap(); + assert_eq!(r["removed"], json!(1)); + let made = ids(&r); + assert_eq!(made.len(), 2); + assert_eq!(made[0], bow); + assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0)); +} + +#[test] +fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() { + let mut s = session(); + let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len()); + assert_eq!(members(&s), Some(2)); + // The members cross each other: a middle stretch splits one member in the compound path. + assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(2)); + assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), Some(1)); + assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1)); + assert_eq!(members(&s), None, "the emptied compound path is removed"); + assert!(stack(&s).is_empty()); +} + +#[test] +fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() { + let mut s = session(); + let (_, v1, _) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [v1.0]})).unwrap(); + s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected"); + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + s.execute("select.set", &json!({"ids": [h.0]})).unwrap(); + let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()); + assert_eq!(made.len(), 2); + let sel = &s.doc().unwrap().selection; + assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects); + // The second piece now ends on an upright: nothing crosses it, and it goes whole. + s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1})); + assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection"); +} + +#[test] +fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() { + let mut s = session(); + let (h, ..) = across_and_uprights(&mut s); + // 3,000 short lines far below, each crossing its neighbor. + s.edit("many", |d, _| { + let layer = d.layers[0].id; + for i in 0..3000 { + let x = (i % 100) as f64 * 3.0; + let y = 1000.0 + (i / 100) as f64 * 3.0; + let sp = vectorcraft_geom::SubPath::polyline( + &[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)], + false, + ); + let id = d.alloc_id(); + let top = d.node(layer).unwrap().children().unwrap().len(); + d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?; + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1)); + assert!(near(ends(&s, h).1, 100.0, 100.0)); + // A rectangle over all of them takes every stretch it touches. + let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]); + assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights"); +} + +#[test] +fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let (h, v1, _) = across_and_uprights(&mut s); + let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false); + let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() }; + s.select_tool("trimAtCrossings", view).unwrap(); + assert_eq!(s.tool_id(), "trimAtCrossings"); + let n = steps(&s); + // A drag over the middle of both lines across: nothing changes until the release. + s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 300.0, 100.0)); + s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap(); + assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0)); + assert_eq!(steps(&s), n + 1, "one undo step for the drag"); + // Escape during a drag: the release removes nothing. + s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert!(near(ends(&s, v1).0, 100.0, 50.0)); + // A click on the upright's top stretch removes it. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap(); + assert!(near(ends(&s, v1).0, 100.0, 100.0)); + assert_eq!(steps(&s), n + 2); +} + +#[test] +fn a_line_too_long_to_check_against_the_paths_is_refused() { + let mut s = session(); + // A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none. + let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect(); + let z = line(&mut s, &zigzag, false); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + let points: Vec = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect(); + let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string(); + assert!(err.contains("too long"), "{err}"); + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); + // A short line there is checked, and removes nothing. + assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0)); + assert!(exists(&s, z)); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + across_and_uprights(&mut s); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for bad in [ + json!({}), + json!({"at": null}), + json!({"at": [150]}), + json!({"at": [150, "y"]}), + json!({"at": [150, 1e308]}), + json!({"at": [150, 100], "tolerance": -1}), + json!({"at": [150, 100], "tolerance": 101}), + json!({"at": [150, 100], "tolerance": "3"}), + json!({"at": [150, 100], "rect": [140, 90, 20, 40]}), + json!({"rect": [140, 90, 20]}), + json!({"rect": "all"}), + json!({"rect": [0, 0, 1e9, 5]}), + json!({"points": [[150, 80]]}), + json!({"points": [[150, 80], [150]]}), + json!({"points": "x"}), + json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}), + ] { + assert!(s.execute(TRIM, &bad).is_err(), "{bad}"); + } + assert_eq!(steps(&s), n); + assert!(*s.doc().unwrap().doc == *before); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5a129f6d32..02bc111746 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -419,6 +419,27 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) } } +/// The refusal of an appearance edit whose targets are all the art of graph designs drawn in graph series. +const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"; + +/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series +/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets +/// are all such art is refused. +pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { + let ids = appearance_targets(s, p)?; + without_design_art(&s.doc()?.doc, ids, "appearance") +} + +/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. +pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { + let given = ids.len(); + ids.retain(|id| !super::graph::is_design_art(d, *id)); + if given > 0 && ids.is_empty() { + return Err(bad(cmd, DESIGN_ART)); + } + Ok(ids) +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -453,17 +474,17 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n) } -/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing -/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. +/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the +/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = appearance_targets(s, p)?; + let ids = edit_targets(s, p)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = appearance_targets(s, p)?; + let own = edit_targets(s, p)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -545,15 +566,21 @@ pub(crate) fn edit_items_then( let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); + // The objects whose rows `f` edited: on type, no row means its characters. + let mut rows = Vec::new(); for (id, item) in ids.iter().zip(&per) { let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; edited.push(*id); + if index.is_some() { + rows.push(*id); + } } let more = after(&before, d, &edited)?; - Ok(count_changed(d, &before, &edited) + usize::from(more)) + let labels = super::graph::capture_label_rows(d, &rows); + Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; Ok(()) } @@ -690,6 +717,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -712,7 +740,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); let object = super::graph::capture_series_object_opacity(d, &ids); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; ok() @@ -798,12 +827,45 @@ pub(super) fn capture_series_writes( /// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, /// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + let marks = series_children_in_paint_order(d, ids); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + +/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier +/// one's, so the frontmost one sets what the graph stores. +pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); - // A later mark's capture replaces an earlier one's: take them in paint order. marks.sort_by_cached_key(|id| d.index_path(*id)); - !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) + marks +} + +/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke +/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote +/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint. +/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches several marks of one +/// series, the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint +/// changed. +pub(crate) fn capture_series_paint_writes( + before: &vectorcraft_doc::Document, + d: &mut vectorcraft_doc::Document, + ids: &[NodeId], + written: &[(NodeId, usize)], +) -> bool { + // Only objects directly in a series group can be marks: other edits skip the comparison of paints and the search of + // the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order, each with all of its rows. + marks.sort_by_cached_key(|id| d.index_path(*id)); + let mut rows = Vec::new(); + for id in &marks { + rows.extend(top_paint_changed(before, d, &[*id])); + let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?))); + rows.extend(painted_rows(d, shown.collect::>())); + } + super::graph::capture_series_top_paints(d, &rows) } /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. @@ -828,6 +890,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`source` and `target` are the same object")); } let copy = switch_or(p, "copy", false, C)?; + // The art of a graph design in a series is read as a source, but neither takes an appearance nor gives one up. + without_design_art(&s.doc()?.doc, vec![target], C)?; + if !copy { + without_design_art(&s.doc()?.doc, vec![source], C)?; + } // A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a // move clears them all; as the target, each of them other than a source takes the appearance. let sources = look_holders(&s.doc()?.doc, source); @@ -884,8 +951,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, both_kinds(&targets))); let painted = super::graph::capture_series_top_paints(d, &rows); let object = super::graph::capture_series_object_opacity(d, &changed); + // The targets last: on two labels of one kind, the target's rows are the ones the kind keeps. + let labels = super::graph::capture_label_rows(d, &changed); let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); - Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -941,7 +1010,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; s.active_appearance_item = None; ok() @@ -1016,7 +1086,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, written)); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -1062,7 +1133,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The rows removed from the active row's object, which need not come first in `ids`. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); @@ -1146,6 +1218,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -1185,6 +1258,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); + super::graph::capture_label_rows(d, &shown); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -1197,7 +1271,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = appearance_targets(s, p)?; + let ids = edit_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } @@ -1230,7 +1304,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1275,7 +1350,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { slot.get_or_insert(n.appearance.contents_at()); } slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?; - Ok(count_changed(d, &before, ids)) + // A graph label's kind keeps where its rows paint relative to the characters. + let labels = super::graph::capture_label_rows(d, ids); + Ok(count_changed(d, &before, ids) + usize::from(labels)) })?; Ok(json!({ "contents": slot })) } @@ -1395,6 +1473,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { (Some(clicked), leaf_targets(s, &selected)?) }; targets.retain(|id| Some(*id) != source); + let mut targets = without_design_art(&s.doc()?.doc, targets, C)?; let node = match source { Some(id) => Arc::new(s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone()), None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, @@ -1462,9 +1541,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(finish(s, &[])); } + // What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its + // characters, with a picked gradient fitted to each label, and the picked paragraph attributes. + let source_rows = picked.placed(None); + let kept_text = |id: NodeId, st: &mut CharStyle| { + Picked::run(st, &source_rows, &attrs, characters.get(&id)); + if attrs.appearance.fill.color { + st.fill = super::gradient::unplaced(&st.fill); + } + if attrs.appearance.stroke.color { + st.stroke = super::gradient::unplaced(&st.stroke); + } + }; + let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para); + // Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes + // what a kind keeps for its characters. + let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke); + let to_characters = (attrs.character && picked.text.is_some()) + || f.color + || f.overprint + || k.color + || k.overprint + || k.weight + || k.cap + || k.join + || k.miter + || k.dash; + let kept_text: Option = to_characters.then_some(&kept_text); if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); + // The labels whose whole text is highlighted. + let whole: Vec = ranges + .iter() + .filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) { + (None, _) => true, + (Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs), + _ => false, + }) + .map(|(id, _)| *id) + .collect(); super::overprint::edit_counted(s, "Eyedropper", |d| { for &(id, range) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -1477,7 +1593,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { super::typecmd::refresh_bounds(t); } check_text_bounds(d)?; - Ok(count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?; + Ok(count_changed(d, &before, &ids) + usize::from(labels)) })?; return Ok(finish(s, &ids)); } @@ -1532,7 +1649,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { // A pick of the object's transparency writes it on every target. let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; let object = super::graph::capture_series_object_opacity(d, &objects); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) + // Graph labels keep their new rows, characters and paragraphs per kind of label. + // On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other + // attributes go to the characters. + let rows = if append { + super::graph::capture_label_rows(d, &targets) + } else { + attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets) + }; + // `append` adds rows and leaves the characters as they were. + let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?; + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text)) })?; Ok(finish(s, &targets)) } @@ -1565,6 +1692,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator bool { (from.baseline_shift, to.baseline_shift), (from.tcy_up_down, to.tcy_up_down), (from.tcy_left_right, to.tcy_left_right), + (from.warichu_gap, to.warichu_gap), (from.h_scale, to.h_scale), ] .iter() diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index c94de5d3b2..66295f50f2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color map_paint_colors(&mut st.fill, f); map_paint_colors(&mut st.stroke, f); } + for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { + for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() { + map_paint_colors(paint, f); + } + } } /// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link. diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 7997ca8f64..434f2e383a 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -536,6 +536,80 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The type of document `index` with id `id`, wherever its tab is. +fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject { + match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A document replaced while in the back has its type measured in its own composite fonts at once, +/// while the front document stays in front with its own definition registered; coming to the front +/// does not measure that type again. +#[test] +fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() { + let (bytes, id, width) = full_roman_file("CF Pane"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Pane")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert_eq!(s.active_index(), Some(1)); + let front = vectorcraft_text::FontDb::global().composite_font("CF Pane").unwrap(); + assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0); + let measured = text_in(&s, 0, id); + assert!((measured.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let node = s.documents()[0].doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert!((node.width() - width).abs() < 1e-6); + let doc = std::sync::Arc::as_ptr(&s.documents()[0].doc); + assert!(s.set_active(0)); + assert!(std::ptr::eq(doc, std::sync::Arc::as_ptr(&s.documents()[0].doc))); + assert_eq!(text(&s, id).cached_bounds, measured.cached_bounds); +} + +/// Type a document replaced in the back sets in a composite font that only the front document +/// defines is measured in that font while the front one is registered, and again, as missing, when +/// the replaced document comes to the front. +#[test] +fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Lacked"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Lacked")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + let back = text_in(&s, 0, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &back).bounds; + assert_eq!(back.cached_bounds, Some(expected)); + assert!(s.set_active(0)); + assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font, in a document +/// replaced in the back, is measured with the graphic at the size the document's own definition +/// gives it before it comes to the front. +#[test] +fn a_document_replaced_in_the_back_measures_inline_graphics_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Back Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Back Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Back Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Back Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Inline")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!((text_in(&s, 0, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + /// A failed batch takes back the composite fonts it set, in the font database too. #[test] fn a_failed_batch_takes_back_its_composite_fonts() { diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index f1bd5ceb2c..54da0562b1 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document: {…, units (English), unitId: the units' Preferences › Units id (points, millimeters, feetInches, …), rasterEffectsPpi}, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), fontDetails: [{font, match: exact|style|missingWithFallback|missing, family, style, file, embedding: allowed|editing|previewPrint|forbidden|null}] (each font of fonts, in order: how it was found, the face shown (null when none), that face's file name (null for a built-in face) and what its license allows), images, imageObjects: [{name, width, height, linked, file}] (the image objects in the order Linked Images and Embedded Images list them; file: what a linked image's row shows, null for an embedded one), swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient that color a part of it above opacity 0 (a stop at opacity 0 counts only where it mixes with a stop above opacity 0 over a stretch of some width), at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients, kinds}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else its kind's name, such as Linear gradient; kinds gives each one's kind, linear|radial|conic|diamond|freeform, when it is named by its kind, else null; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", has_doc, info ), @@ -85,17 +85,65 @@ pub struct Section { /// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the /// most permissive usage bit wins, and bitmap-only fonts may not be embedded. pub fn embedding_label(fs_type: u16) -> &'static str { - if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { - "embedding not allowed" - } else if fs_type & 0x0008 != 0 { - "embedding for editing" - } else if fs_type & 0x0004 != 0 { - "embedding for preview and print" - } else { - "embedding allowed" + match embedding_id(fs_type) { + "forbidden" => "embedding not allowed", + "editing" => "embedding for editing", + "previewPrint" => "embedding for preview and print", + _ => "embedding allowed", } } +/// [`embedding_label`] as the `embedding` value of a `fontDetails` entry: `forbidden`, `editing`, +/// `previewPrint` or `allowed`. +pub fn embedding_id(fs_type: u16) -> &'static str { + if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { + "forbidden" + } else if fs_type & 0x0008 != 0 { + "editing" + } else if fs_type & 0x0004 != 0 { + "previewPrint" + } else { + "allowed" + } +} + +/// A font as Font Details lists it: its `fontDetails` entry and its row. The row gives the file +/// the font is drawn from (`built in` for a face with no file) and whether its license lets it be +/// embedded; for a style the family lacks, the face shown in its place; for a font that is not +/// installed, the face shown instead, or `missing`. The entry holds the same facts as values +/// (`match`: `exact`, `style`, `missingWithFallback` or `missing`), so the panel can show them in +/// the interface language. +fn font_detail(used: &super::fonts::UsedFont) -> (Value, (String, String)) { + let db = vectorcraft_text::FontDb::global(); + let (family, style, version) = used; + let label = super::fonts::font_label(used); + // Found by any of its names (in the version the type names); a style the family lacks shows in + // another. + let resolved = db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); + let (entry, detail) = match resolved { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { + let file = f.path().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let found = format!("{}; {}", file.as_deref().unwrap_or("built in"), embedding_label(f.fs_type())); + let substituted = m == vectorcraft_text::FontMatch::Style; + let entry = json!({ + "font": label, + "match": if substituted { "style" } else { "exact" }, + "family": f.family, + "style": f.style, + "file": file, + "embedding": embedding_id(f.fs_type()), + }); + (entry, if substituted { format!("substituted: shown in {} {} ({found})", f.family, f.style) } else { found }) + } + Some((f, _)) => ( + json!({ "font": label, "match": "missingWithFallback", "family": f.family, "style": f.style, "file": null, "embedding": null }), + format!("missing: shown in {} {}", f.family, f.style), + ), + None => (json!({ "font": label, "match": "missing", "family": null, "style": null, "file": null, "embedding": null }), "missing".into()), + }; + (entry, (label, detail)) +} + /// An image object as Document Info lists it: (name, width, height, linked file). type ImageRow = (String, u32, u32, Option); @@ -131,14 +179,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the -/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a -/// pattern and with a gradient. +/// `document.info` reports), its fonts and their Font Details rows, its image objects, the rows of +/// the brushes its art uses, the rows of its Repeat objects and the rows of its objects filled or +/// stroked with a spot color, with a pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, + font_details: &[(String, String)], images: &[ImageRow], brushes: &[(String, String)], repeats: &[(String, String)], @@ -192,34 +241,7 @@ fn sections( "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), - "fontDetails" => { - let db = vectorcraft_text::FontDb::global(); - fonts - .iter() - .map(|used| { - let (family, style, version) = used; - // Found by any of its names (in the version the type names); a style the - // family lacks shows in another. - let resolved = - db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); - let detail = match resolved { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { - let file = - f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); - let found = format!("{file}; {}", embedding_label(f.fs_type())); - if m == vectorcraft_text::FontMatch::Style { - format!("substituted: shown in {} {} ({found})", f.family, f.style) - } else { - found - } - } - Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), - None => "missing".into(), - }; - (super::fonts::font_label(used), detail) - }) - .collect() - } + "fontDetails" => font_details.to_vec(), "linkedImages" => { images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect() } @@ -277,6 +299,39 @@ fn gradient_label(p: &Paint) -> Option { } } +/// Where [`gradient_label`]'s name for gradient paint `p` comes from: `swatch:` for a +/// gradient linked to a swatch, else `kind:` (`linear`, `radial`, `conic`, `diamond` or +/// `freeform`). The two prefixes keep a swatch's name apart from a kind's name it reads like. +fn gradient_source(p: &Paint) -> Option { + match p { + Paint::Gradient(g) => Some(match g.swatch.as_deref().filter(|s| !s.is_empty()) { + Some(swatch) => format!("swatch:{swatch}"), + None => format!("kind:{}", g.gradient.kind.label().to_ascii_lowercase()), + }), + _ => None, + } +} + +/// For each of `names` (an object's [`gradient_label`]s), the kind it names when only a kind +/// gives that name: a name one of `sources` (the object's [`gradient_source`]s) gives as a kind's +/// and none gives as a swatch's, else null. A swatch named like a kind keeps its name even where +/// a gradient of that kind is listed under the same name. +fn gradient_kinds(names: &[String], sources: &[String]) -> Vec { + names + .iter() + .map(|name| { + if sources.iter().any(|s| s.strip_prefix("swatch:") == Some(name.as_str())) { + return Value::Null; + } + sources + .iter() + .filter_map(|s| s.strip_prefix("kind:")) + .find(|k| vectorcraft_color::GradientKind::parse(k).is_some_and(|kind| *name == format!("{} gradient", kind.label()))) + .map_or(Value::Null, |k| json!(k)) + }) + .collect() +} + /// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own /// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as /// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not @@ -289,10 +344,46 @@ fn spot_swatches(d: &Document) -> HashSet<&str> { first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() } +/// Which of gradient `g`'s stops color some part of it that draws, by the stretches +/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k` +/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s, +/// and both its stops count when that stretch has positive width and either stop is above opacity +/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its +/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop, +/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width +/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from +/// its points, so each of its stops counts while any stop is above opacity 0. +fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec { + let stops = &g.stops; + if g.kind == vectorcraft_color::GradientKind::Freeform { + let any = stops.iter().any(|s| s.opacity > 0.0); + return vec![any; stops.len()]; + } + let mut drawn = vec![false; stops.len()]; + if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { + *d = first.opacity > 0.0; + } + if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { + *d |= last.opacity > 0.0; + } + // `f32::max` skips a NaN offset, which the sampler never picks either. + let mut reach = f32::NEG_INFINITY; + for k in 0..stops.len().saturating_sub(1) { + reach = reach.max(stops[k].offset); + let (a, b) = (&stops[k], &stops[k + 1]); + if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) { + drawn[k] = true; + drawn[k + 1] = true; + } + } + drawn +} + /// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is -/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A -/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws -/// nothing, and a color that only looks like a spot color is not linked to one. +/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the +/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a +/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not +/// linked to one. fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { let mut out: Vec = vec![]; let mut add = |swatch: &Option, tint: f32| { @@ -306,7 +397,10 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { }; match p { Paint::Solid { swatch, tint, .. } => add(swatch, *tint), - Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + Paint::Gradient(g) => { + let drawn = drawn_stops(&g.gradient); + g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint)); + } _ => {} } out @@ -447,9 +541,11 @@ fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool /// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n` /// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of /// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with. -fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 3] { +/// The fourth list gives where each gradient comes from: a swatch or a kind ([`gradient_source`]). +fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 4] { let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label)); - let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient]; + let gradient_from = one_label(gradient_source); + let labels: [&PaintNames<'_>; 4] = [&spot, &pattern, &gradient, &gradient_from]; let painted_runs = painted_runs(d, spots, n); let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label)); // The characters' own paints: an inline graphic draws no outline and does not take its run's @@ -615,8 +711,10 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw. - let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; + // The spot colors, the patterns and the gradients this object paints, each once, and where each gradient comes from; + // none when it does not draw. + let [used_spots, used_patterns, used_gradients, used_sources] = + if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -636,7 +734,8 @@ fn info(s: &mut Session, p: &Value) -> Result { let name = n.display_name(); gradients.extend(used_gradients.iter().cloned()); gradient_rows.push((name.clone(), used_gradients.join(", "))); - gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients })); + let kinds = gradient_kinds(&used_gradients, &used_sources); + gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients, "kinds": kinds })); *counts.entry("gradients").or_default() += 1; } if n.extras().mask.is_some() { @@ -671,6 +770,14 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); + let (font_entries, font_rows): (Vec, Vec<(String, String)>) = fonts.iter().map(font_detail).unzip(); + // The image objects in walk order, as Linked Images and Embedded Images list them: `file` is a + // linked image's row value (its path, or its file name alone when the request may not read it), + // and null for an embedded one. + let image_objects: Vec = image_rows + .iter() + .map(|(name, w, h, file)| json!({ "name": name, "width": w, "height": h, "linked": file.is_some(), "file": file })) + .collect(); // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. @@ -688,7 +795,9 @@ fn info(s: &mut Session, p: &Value) -> Result { "objects": counts, "pathLength": path_length, "fonts": font_names, + "fontDetails": font_entries, "images": images, + "imageObjects": image_objects, "symbols": symbols, "spotColors": spot, "spotColorNames": spot, @@ -710,7 +819,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &font_rows, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -724,6 +833,7 @@ fn document_summary(d: &Document) -> Value { "title": d.title, "colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" }, "units": d.units.label(), + "unitId": d.units.key(), "artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::>(), "rasterEffectsPpi": d.raster_effects_ppi, }) @@ -1250,11 +1360,11 @@ mod tests { assert_eq!( i["gradientObjects"], json!([ - {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]}, - {"id": b, "name": "", "gradients": ["Radial gradient"]}, - {"id": f, "name": "", "gradients": ["Freeform gradient"]}, - {"id": t, "name": "Type", "gradients": ["Ramp"]}, - {"id": bar, "name": "Bar", "gradients": ["Ramp"]}, + {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"], "kinds": [null, "linear"]}, + {"id": b, "name": "", "gradients": ["Radial gradient"], "kinds": ["radial"]}, + {"id": f, "name": "", "gradients": ["Freeform gradient"], "kinds": ["freeform"]}, + {"id": t, "name": "Type", "gradients": ["Ramp"], "kinds": [null]}, + {"id": bar, "name": "Bar", "gradients": ["Ramp"], "kinds": [null]}, ]) ); assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"])); @@ -1493,6 +1603,95 @@ mod tests { assert_eq!(spots(&mut s), (vec![], None, None)); } + /// Spot Color Objects leaves out a spot color linked only from gradient stops at an opacity of 0 + /// whose stretches all lead to other stops at an opacity of 0 or have no width, while another + /// part of the gradient draws: an inner stop, an end stop and a stop at the offset of an opaque + /// stop. A clear stop next to an opaque stop tints the stretch between them, so it stays listed. + /// Type whose characters draw such a gradient follows the same rule, with and without Selection + /// Only, and the report writes the same rows. + #[test] + fn spot_color_objects_skip_spot_stops_only_in_clear_stretches() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0}); + for (k, name) in ["Ink", "Near", "Far", "Edge", "Seam"].into_iter().enumerate() { + s.execute("swatch.new", &json!({"name": name, "color": ink(0.2 * k as f64), "spot": true})).unwrap(); + } + let stop = |offset: f64, swatch: Option<&str>, opacity: f64| match swatch { + Some(sw) => json!({"offset": offset, "color": "#000", "swatch": sw, "opacity": opacity}), + None => json!({"offset": offset, "color": "#fff", "opacity": opacity}), + }; + let fill = |s: &mut Session, id: &Value, stops: Value| { + s.execute("paint.setFill", &json!({"ids": [id], "gradient": {"kind": "linear", "stops": stops}})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); + }; + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + // Near borders the opaque Ink stop; Far sits between clear stops; Edge ends the ramp after a + // clear stop. + let ramp = square(&mut s, 0.0); + fill( + &mut s, + &ramp, + json!([stop(0.0, Some("Ink"), 1.0), stop(0.4, Some("Near"), 0.0), stop(0.6, Some("Far"), 0.0), stop(1.0, Some("Edge"), 0.0)]), + ); + s.execute("layer.setProps", &json!({"id": ramp, "name": "Ramp"})).unwrap(); + // Seam meets the opaque half only at one offset, where nothing between them has width. + let seam = square(&mut s, 20.0); + fill(&mut s, &seam, json!([stop(0.0, None, 0.0), stop(0.5, Some("Seam"), 0.0), stop(0.5, None, 1.0), stop(1.0, None, 1.0)])); + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + fill(&mut s, &t, json!([stop(0.0, Some("Ink"), 1.0), stop(0.5, None, 0.0), stop(1.0, Some("Far"), 0.0)])); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + let check = |s: &mut Session, params: Value| { + let i = s.execute("document.info", ¶ms).unwrap(); + let listed: Vec<(String, Value)> = + i["spotObjects"].as_array().unwrap().iter().map(|o| (o["name"].as_str().unwrap().to_string(), o["spotColors"].clone())).collect(); + assert_eq!(listed, vec![("Ramp".to_string(), json!(["Ink", "Near"])), ("Type".to_string(), json!(["Ink"]))], "{params}"); + assert_eq!((i["spotColorNames"].clone(), i["objects"]["spots"].as_u64()), (json!(["Ink", "Near"]), Some(2)), "{params}"); + let rows = i["sections"].as_array().unwrap().iter().find(|c| c["id"] == "spotColors").unwrap()["rows"].clone(); + assert_eq!(rows, json!([["Ramp", "Ink, Near"], ["Type", "Ink"]]), "{params}"); + }; + check(&mut s, json!({})); + s.execute("select.set", &json!({"ids": [ramp, seam, t]})).unwrap(); + check(&mut s, json!({"selectionOnly": true})); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nRamp: Ink, Near\nType: Ink\n"), "{text}"); + assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}"); + } + + /// A gradient's stops print where they color a stretch that draws, for stops in any order: the + /// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an + /// out-of-order stop's pair can have no width. + #[test] + fn drawn_stops_follow_the_sampled_stretches() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop}; + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec| Gradient::new(GradientKind::Linear, stops); + // In order: the clear stop next to the opaque one, not the one after it. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]); + // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with + // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]); + // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the + // pairs next to the stop at 0.3 have no width and it colors nothing. + let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]); + // With the stop at 0.8 opaque, the pairs next to it with width are drawn. + assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]); + // The last stop colors the offsets above every stop's, even when it is out of order. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]); + // The first stop colors the offsets up to its own. + assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]); + // One stop, every stop clear, and no stops at all. + assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]); + assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]); + assert!(drawn_stops(&g(vec![])).is_empty()); + // A freeform gradient draws from its points: its stops count while any is above opacity 0. + let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); + free.kind = GradientKind::Freeform; + assert_eq!(drawn_stops(&free), vec![true, true, true]); + } + /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no @@ -1672,6 +1871,112 @@ mod tests { assert_eq!(embedding_label(0x0003), "embedding not allowed"); assert_eq!(embedding_label(0x0006), "embedding for preview and print"); assert_eq!(embedding_label(0x000c), "embedding for editing"); + // `fontDetails` names each permission by id. + assert_eq!( + [0, 0x0002, 0x0104, 0x0008, 0x0200, 0x000c].map(embedding_id), + ["allowed", "forbidden", "previewPrint", "editing", "forbidden", "editing"] + ); + } + + /// `fontDetails` gives each Font Details row's facts as values, in the rows' order: how the + /// font was found, the face shown, its file (null for a built-in face) and its embedding + /// permission. The rows and the report stay in English. + #[test] + fn info_exposes_structured_font_details_without_changing_english_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Gone", "font": "Qx Absent Family"})).unwrap(); + let i = s.execute("document.info", &json!({"category": "fontDetails"})).unwrap(); + let (entries, rows) = (i["fontDetails"].as_array().unwrap(), i["sections"][0]["rows"].as_array().unwrap()); + assert_eq!(entries.iter().map(|e| e["font"].clone()).collect::>(), rows.iter().map(|r| r[0].clone()).collect::>()); + let at = |prefix: &str| entries.iter().position(|e| e["font"].as_str().unwrap().starts_with(prefix)).unwrap(); + let regular = at("Source Sans 3 Regular"); + assert_eq!( + entries[regular], + json!({"font": "Source Sans 3 Regular", "match": "exact", "family": "Source Sans 3", "style": "Regular", "file": null, "embedding": "allowed"}) + ); + assert_eq!(rows[regular][1], "built in; embedding allowed"); + let (wide, e) = (at("Source Sans 3 Black Wide"), &entries[at("Source Sans 3 Black Wide")]); + assert_eq!((e["match"].as_str(), e["family"].as_str()), (Some("style"), Some("Source Sans 3")), "{e}"); + let file = e["file"].as_str().unwrap_or("built in"); + let fs_type = [("allowed", 0), ("forbidden", 0x0002), ("previewPrint", 0x0004), ("editing", 0x0008)] + .into_iter() + .find_map(|(id, bits)| (e["embedding"] == id).then_some(bits)) + .unwrap(); + let found = format!("{file}; {}", embedding_label(fs_type)); + assert_eq!(rows[wide][1], format!("substituted: shown in Source Sans 3 {} ({found})", e["style"].as_str().unwrap())); + let (gone, e) = (at("Qx Absent Family"), &entries[at("Qx Absent Family")]); + assert!(e["file"].is_null() && e["embedding"].is_null(), "{e}"); + match e["match"].as_str() { + Some("missingWithFallback") => { + assert_eq!(rows[gone][1], format!("missing: shown in {} {}", e["family"].as_str().unwrap(), e["style"].as_str().unwrap())) + } + Some("missing") => assert_eq!((rows[gone][1].as_str(), e["family"].is_null()), (Some("missing"), true)), + m => panic!("{m:?}"), + } + let text = s.execute("document.info", &json!({"category": "fontDetails", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("Source Sans 3 Regular: built in; embedding allowed\n"), "{text}"); + } + + /// `document.unitId` names the document's units as Preferences › Units does, beside the English + /// `units`, and `imageObjects` lists the image objects in the order Embedded Images and Linked + /// Images list them, with the file a linked one shows (null for an embedded one). + #[test] + fn info_exposes_ordered_images_and_unit_id() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.edit("Units", |d, _| { + d.units = vectorcraft_doc::Unit::Millimeters; + Ok(()) + }) + .unwrap(); + for x in [0.0, 50.0] { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + } + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!((i["document"]["units"].as_str(), i["document"]["unitId"].as_str()), (Some("Millimeters"), Some("millimeters"))); + let section = |id: &str| i["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone(); + assert!(section("document").iter().any(|r| *r == json!(["Units", "Millimeters"]))); + let embedded = section("embeddedImages"); + let objects = i["imageObjects"].as_array().unwrap(); + assert_eq!(objects.len(), 2, "{objects:?}"); + for (o, r) in objects.iter().zip(&embedded) { + assert_eq!((o["linked"].clone(), o["file"].clone(), o["name"].clone()), (json!(false), Value::Null, r[0].clone()), "{o}"); + assert_eq!(r[1], format!("{} × {} px", o["width"], o["height"])); + } + assert!(section("linkedImages").is_empty()); + } + + /// A `gradientObjects` entry's `kinds` gives the kind of each gradient listed by its kind + /// rather than by a swatch, and null for a swatch's name, even one that reads like a kind and + /// one an object's gradient of that kind shares; a type object's characters count as its own. + #[test] + fn gradient_kinds_tell_kinds_from_swatch_names() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("swatch.new", &json!({"name": "Radial gradient", "gradient": ramp("linear")})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [r], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [r], "gradient": ramp("conic")})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "gradient": ramp("diamond")})).unwrap(); + // The swatch's name and a radial gradient's kind name are listed once, as the name. + let both = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [both], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [both], "gradient": ramp("radial")})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let entry = |id: &Value| i["gradientObjects"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap().clone(); + assert_eq!( + (entry(&r)["gradients"].clone(), entry(&r)["kinds"].clone()), + (json!(["Radial gradient", "Conic gradient"]), json!([null, "conic"])) + ); + assert_eq!((entry(&t)["gradients"].clone(), entry(&t)["kinds"].clone()), (json!(["Diamond gradient"]), json!(["diamond"]))); + assert_eq!((entry(&both)["gradients"].clone(), entry(&both)["kinds"].clone()), (json!(["Radial gradient"]), json!([null]))); } #[test] diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 400e13baab..53949fdaf0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "Smooth", [], None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}", has_selection, smooth_region ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 660e5b8370..560990cec1 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0; /// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of /// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the -/// smoothing moved it, keeping a corner only where the path turns back on itself. -fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions { - SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true } +/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether +/// the start and the end of the stretch refitted are ends of an open path, whose direction then +/// follows the smoothed path within the reach rather than the first or last segment. +fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions { + SimplifyOptions { + tolerance: tol, + corner_angle_deg: 179.0, + straight_lines: false, + smooth: sigma, + smooth_moved_only: true, + free_start: free[0], + free_end: free[1], + } } /// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { +/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or +/// arriving at such an end can turn. +fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &refit_options(tol, sigma)); + let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free)); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -228,7 +240,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { // Everything is in range: smooth the whole closed path, across its start too. - let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma)); + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. @@ -239,14 +251,14 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol, sigma); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -278,8 +290,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez /// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted /// within `tol`, together with the stretch of the segments leading into the run that is in the /// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next -/// to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec { +/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first +/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends, +/// and those of the open run a closed path is cut into, keep their direction. +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -319,7 +333,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool head = Some(Anchor { h_out: far.p1, ..*outer }); *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } - let new = refit_run(&piece, tol, sigma); + let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()]; + let new = refit_run(&piece, tol, sigma, free); out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -329,7 +344,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.smoothRegion"; let pts = points_param(p, "points", C)?; let radius = number_or(p, "radius", 12.0, C)?.max(0.0); - let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE); let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; let mut plan = vec![]; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 17fcb63fe2..6ee3905d95 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -49,7 +49,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), @@ -298,7 +298,7 @@ fn ids_json(ids: &[NodeId]) -> Value { /// edits its equal item, or its equal object-level effect, each target matched on its own whatever /// the other targets have; in a target without them, `item` and `index` stand for themselves. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p)?; let d = &s.doc()?.doc; let per = item.per_target(s, &roots); let first = roots.first().and_then(|f| d.node(*f)); @@ -453,7 +453,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - let targets = appearance_targets(s, p)?; + let targets = edit_targets(s, p)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; @@ -492,8 +492,9 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -606,7 +607,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p)?; // Where an aligned effect would go in each of several different stacks isn't one place. if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 3f95ae0518..9f4b89d2f4 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; use vectorcraft_render::effects; -use super::super::appearance::{appearance_targets, index_param, item_target}; +use super::super::appearance::{edit_targets, index_param, item_target}; use super::super::brushsym::{normalize, selection_art, store_symbol}; use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_doc, has_selection, str_param}; use super::{effect_targets, ids_json}; @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in appearance_targets(s, p)? { + for id in edit_targets(s, p)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 97f957ed0e..1ff1897c85 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -123,7 +123,7 @@ mod formats; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::{write_file_with, write_save_file, write_save_file_with}; +pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_readonly; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 6488972d31..203a2262b0 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -121,6 +121,24 @@ pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String { out } +/// The error for an SVG of a document that links a JavaScript file outside the read roots. +pub(crate) const SCRIPT_OUTSIDE: &str = "a linked JavaScript file is outside the read roots"; + +/// `cmd` may write `st`'s document in format `format` (a format id): any format but SVG and SVGZ, +/// and those too unless read roots are in force and the document links a JavaScript file outside +/// them (`svg_scripts`, a relative path taken from the document's folder; a URL is not checked), +/// which the file's `