diff --git a/Cargo.lock b/Cargo.lock index dc08d4542e..00fe4ca454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -978,11 +978,33 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "craft-codepage" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" + [[package]] name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +[[package]] +name = "craft-dxf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "craft-codepage", + "encoding_rs", + "kurbo 0.13.1", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-eps" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1057,6 +1079,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "craft-svg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "craft-xml", + "flate2", + "kurbo 0.13.1", + "roxmltree 0.21.2", + "thiserror 2.0.21", + "usvg 0.48.1", + "vectorcraft-geom 0.18.0", + "vectorcraft-pathops 0.18.0", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1146,6 +1183,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -6386,13 +6432,14 @@ dependencies = [ "serde_json", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", ] [[package]] name = "vectorcraft-cad" version = "0.24.1" dependencies = [ + "craft-dxf", "image", "kurbo 0.13.1", "serde_json", @@ -6400,8 +6447,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-text", ] @@ -6418,7 +6465,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-mcp", "vectorcraft-render", "vectorcraft-svg", @@ -6455,7 +6502,7 @@ dependencies = [ "thiserror 2.0.21", "tiff", "vectorcraft-color", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "zune-jpeg 0.5.15", ] @@ -6470,8 +6517,8 @@ dependencies = [ "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-plugins", "vectorcraft-text", "vectorcraft-three-d", @@ -6515,10 +6562,10 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-eps", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-js", "vectorcraft-metafile", - "vectorcraft-pathops", + "vectorcraft-pathops 0.24.1", "vectorcraft-pdf", "vectorcraft-plugins", "vectorcraft-render", @@ -6537,6 +6584,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.24.1" dependencies = [ + "craft-eps", "craft-pdf", "encoding_rs", "fax", @@ -6552,9 +6600,9 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-metafile", - "vectorcraft-pathops", + "vectorcraft-pathops 0.24.1", "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-testkit", @@ -6573,10 +6621,20 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", ] +[[package]] +name = "vectorcraft-geom" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "vectorcraft-geom" version = "0.24.1" @@ -6611,7 +6669,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", @@ -6628,10 +6686,21 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-text", ] +[[package]] +name = "vectorcraft-pathops" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "linesweeper", + "thiserror 2.0.21", + "vectorcraft-geom 0.18.0", +] + [[package]] name = "vectorcraft-pathops" version = "0.24.1" @@ -6640,7 +6709,7 @@ dependencies = [ "linesweeper", "proptest", "thiserror 2.0.21", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", ] @@ -6673,7 +6742,7 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", "vectorcraft-text", "weezl 0.2.1", @@ -6692,7 +6761,7 @@ dependencies = [ "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "wasmi", "wat", "web-time", @@ -6717,7 +6786,7 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", @@ -6741,6 +6810,7 @@ name = "vectorcraft-svg" version = "0.24.1" dependencies = [ "craft-docpath", + "craft-svg", "flate2", "image", "kurbo 0.13.1", @@ -6757,8 +6827,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-testkit", "vectorcraft-text", ] @@ -6776,7 +6846,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-render", "vectorcraft-svg", ] @@ -6796,7 +6866,7 @@ dependencies = [ "unicode-script", "unicode-segmentation", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vello_cpu", "windows-registry", "write-fonts", @@ -6806,8 +6876,8 @@ dependencies = [ name = "vectorcraft-three-d" version = "0.24.1" dependencies = [ - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", ] [[package]] @@ -6822,8 +6892,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-text", ] @@ -6836,7 +6906,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", ] [[package]] @@ -6865,7 +6935,7 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-eps", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-pdf", "vectorcraft-plugins", "vectorcraft-render", diff --git a/Cargo.toml b/Cargo.toml index 5bed166e17..c7856d589e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,12 @@ craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", re # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } +# SVG parsing, limits, SVGZ and writer (craft-libs#778). +craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2", features = ["tree"] } +# EPS container, previews, ASCII85 and DSC comments (craft-libs#778). +craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } +# DXF tags, reader and writer (craft-libs#778, #926, #927). +craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fdbec59bfc..3397ada7a5 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode { // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), // and the other choices do so when a pen is attached, where the pen draws (#2028, #4967). #[cfg(target_os = "linux")] + let mut forced_to_x11 = false; + #[cfg(target_os = "linux")] let options = { let mut options = options; let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) { if xwayland::x11_libraries_found() { + forced_to_x11 = true; if pref != Some("x11") { log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)"); } else { @@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode { } options }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + // Preferences › User Interface notes under System Title Bar when the desktop draws the frame + // with it on or off. + let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11); // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end // is down; the message hook notes each pointer contact and whether it starts with the barrel // button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the @@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode { app.custom_titlebar = custom_titlebar; app.caption_layout = caption_layout; app.desktop_frame = !custom_titlebar; + app.desktop_keeps_frame = desktop_keeps_frame; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 99ca880bc9..1994569c0c 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { } } +/// Preferences says under System Title Bar that the desktop draws the frame with it on or off +/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's +/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs +/// only. +#[test] +fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() { + use crate::titlebar::desktop_keeps_frame_from; + use craft_window::{Frame, Layout}; + let linux = cfg!(not(any(target_os = "macos", target_os = "windows"))); + for probed in [Frame::Desktop, Frame::App(Layout::default())] { + for forced_to_x11 in [false, true] { + let frame = super::start_frame(probed.clone(), None, forced_to_x11, false); + let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true); + assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}"); + } + } + // KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not. + assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux); + assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux); + assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false)); + // While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note. + for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] { + for forced_to_x11 in [false, true] { + assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}"); + } + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index f8007d612e..cb98fa2648 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -31,7 +31,7 @@ pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) } -/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). +/// Windows: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). /// macOS keeps its traffic lights over the integrated title strip. /// /// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the @@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> ( (custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame)) } +/// Whether the desktop would draw this window's frame with System Title Bar on or off while +/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`, +/// `system` or `desktop` (`overridden` is `None`), and the probe chose +/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`) +/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value +/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on +/// macOS and Windows, where System Title Bar decides the frame by itself. +pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option, forced_to_x11: bool) -> bool { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false) +} + +/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the +/// window through XWayland. +pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool { + let choice = probed_choice(); + desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11) +} + pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 2cf9f43c97..00edeb3113 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) { } } -/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. -pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { - let amount = amount.clamp(0.0, 1.0); - for it in &mut n.appearance.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - for p in [&mut r.style.fill, &mut r.style.stroke] { - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - } - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - tint_node(Arc::make_mut(c), color, amount); - } - } -} - -/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's -/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one -/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. -pub fn stain(inst: &Node) -> Option<(Color, f32)> { - let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; - Some((f.paint.color()?, f.opacity)) -} - -/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic -/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). -pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); - if let Some((c, amount)) = stain(inst) { - tint_node(&mut a, &c, amount); - } - a -} +/// The Symbol Stainer's mix, an instance's stain and its stained art, from +/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read. +pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node}; /// The solid colours of the fills and strokes under `nodes`, most used first (then in the order /// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 34f77ec929..e529e1d4e2 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 workspace = true [dependencies] +craft-dxf = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } diff --git a/crates/cad/src/aci.rs b/crates/cad/src/aci.rs index 63fbb180f9..39818a78ef 100644 --- a/crates/cad/src/aci.rs +++ b/crates/cad/src/aci.rs @@ -1,50 +1,11 @@ -//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are -//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a -//! half-saturated tone, and 250–255 are greys. +//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth +//! of the writer may use. use crate::ColorDepth; -/// The nine standard colours (index 7 shows white on a dark background and black on paper). -const STANDARD: [[u8; 3]; 9] = - [[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]]; - -/// The brightness of the five shades of each hue. -const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15]; - /// The RGB of colour index `i` (1–255; anything else is index 7's white). pub fn aci_rgb(i: u8) -> [u8; 3] { - match i { - 1..=9 => STANDARD[usize::from(i - 1)], - 10..=249 => { - let hue = f64::from(i / 10 - 1) * 15.0; - let k = i % 10; - let v = SHADES[usize::from(k / 2)]; - let pastel = k % 2 == 1; - hue_rgb(hue).map(|f| { - let f = if pastel { f + (1.0 - f) / 2.0 } else { f }; - (255.0 * v * f).floor() as u8 - }) - } - 250..=255 => { - let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8; - [g; 3] - } - _ => STANDARD[6], - } -} - -/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels. -fn hue_rgb(hue: f64) -> [f64; 3] { - let h = hue.rem_euclid(360.0) / 60.0; - let x = 1.0 - (h % 2.0 - 1.0).abs(); - match h as u8 { - 0 => [1.0, x, 0.0], - 1 => [x, 1.0, 0.0], - 2 => [0.0, 1.0, x], - 3 => [0.0, x, 1.0], - 4 => [x, 0.0, 1.0], - _ => [1.0, 0.0, x], - } + craft_dxf::model::aci::rgb(i) } /// The indices a colour depth may use (true colour: all of them, for the fallback index). @@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box> { /// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index). /// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast. pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 { - let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::(); - candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7) + craft_dxf::model::aci::nearest(rgb, candidates(depth)) } #[cfg(test)] mod tests { use super::*; - #[test] - fn the_palette_matches_its_known_entries() { - let known: [(u8, [u8; 3]); 14] = [ - (1, [255, 0, 0]), - (10, [255, 0, 0]), - (11, [255, 127, 127]), - (12, [165, 0, 0]), - (13, [165, 82, 82]), - (17, [76, 38, 38]), - (19, [38, 19, 19]), - (20, [255, 63, 0]), - (21, [255, 159, 127]), - (30, [255, 127, 0]), - (140, [0, 191, 255]), - (250, [51, 51, 51]), - (252, [132, 132, 132]), - (255, [255, 255, 255]), - ]; - for (i, rgb) in known { - assert_eq!(aci_rgb(i), rgb, "index {i}"); - } - } - #[test] fn nearest_index_by_depth() { assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1); diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs index f1f4104863..9216c57c29 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -1,4 +1,4 @@ -//! DXF import: an ASCII DXF drawing of any version as a document. +//! DXF import: a DXF drawing (ASCII or binary) of any version as a document. //! //! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N //! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred @@ -10,7 +10,8 @@ //! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve //! them. What can't come in is listed in the warnings. //! -//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts. +//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables, +//! blocks and layouts. //! - `entity`: entities to items (geometry and properties). //! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths. //! - `text`: text values to plain text. @@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE}; /// Letter, the box Fit to Artboard fills unless told otherwise (points). pub const FIT_TO: (f64, f64) = (612.0, 792.0); -/// Why a binary DXF can't be opened. -pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that"; - /// How a drawing comes in. #[derive(Clone, Debug, PartialEq)] pub struct ImportOptions { @@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool { false } -/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused). -fn text(bytes: &[u8]) -> Result, String> { - if bytes.starts_with(reader::BINARY_SENTINEL) { - return Err(BINARY.into()); - } +/// The tags of a DXF file and where it is damaged (DWG files are refused). +fn tags(bytes: &[u8]) -> Result<(Vec, Option), String> { if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") { - return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into()); + return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into()); } - Ok(reader::decode(bytes)) + let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e { + craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(), + _ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(), + _ => "not a DXF drawing".to_string(), + })?; + if tags.is_empty() { + return Err("not a DXF drawing".into()); + } + Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out")))) } /// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it. @@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str { /// Read what `bytes` holds: its version, units, layouts and layers. pub fn info(bytes: &[u8]) -> Result { - let text = text(bytes)?; - let (pairs, _) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, _) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } @@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result { }) } -/// Import an ASCII DXF drawing with `o`. +/// Import a DXF drawing with `o`. pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { let (w, h) = o.fit_to; if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) { @@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { { return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end())); } - let text = text(bytes)?; - let (pairs, damaged) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, damaged) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index 59e1283cce..309a4ea228 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl}; use vectorcraft_doc::Justify; use vectorcraft_geom::{Affine, Point, Vec2}; +use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath}; + use super::curve::{self, Vertex}; -use super::reader::{Groups, Pair, Record, groups, number}; -use crate::aci::aci_rgb; +use super::reader::{aci_paper, point}; /// How far below the baseline the lowest descender reaches, as a fraction of the cap height. const DESCENT: f64 = 0.3; @@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12; /// factor 1, the 1 to 10,000% horizontal scale the Character panel allows. const SPAN_SCALE: (f64, f64) = (0.01, 100.0); -/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. -pub(crate) fn aci_paper(i: u64) -> [u8; 3] { - match u8::try_from(i) { - Ok(7) | Err(_) => [0; 3], - Ok(i) => aci_rgb(i), - } -} - -/// The true colour (group 420, `0xRRGGBB`) of an entity or layer. -pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> { - let v = g.int(420)?; - let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes(); - Some([r, gr, b]) -} - /// An entity's colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum Col { @@ -79,30 +65,30 @@ pub(crate) struct Props { } impl Props { - fn read(g: &[Pair<'_>]) -> Self { - let color = match (true_color(g), g.int(62)) { - (Some(rgb), _) => Col::Rgb(rgb), - (None, Some(0)) => Col::ByBlock, - (None, None | Some(256..)) => Col::ByLayer, - (None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())), + fn read(c: &model::Common) -> Self { + let color = match c.color { + model::Color::Rgb(rgb) => Col::Rgb(rgb), + model::Color::ByBlock => Col::ByBlock, + model::Color::ByLayer => Col::ByLayer, + model::Color::Index(i) => Col::Rgb(aci_paper(i)), }; - let lineweight = match g.int(370) { - Some(-2) => Lw::ByBlock, - Some(-3) => Lw::Default, - Some(v @ 0..=211) => Lw::Hundredths(v as i32), + let lineweight = match c.lineweight { + model::Lineweight::ByBlock => Lw::ByBlock, + model::Lineweight::Default => Lw::Default, + model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)), _ => Lw::ByLayer, }; - let alpha = match g.int(440) { - Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0), - Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock, - _ => Alpha::ByLayer, + let alpha = match c.transparency { + model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0), + model::Transparency::ByBlock => Alpha::ByBlock, + model::Transparency::ByLayer => Alpha::ByLayer, }; Self { - layer: g.name(8).unwrap_or("0").to_string(), + layer: c.layer.clone(), color, lineweight, - linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(), - ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0), + linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(), + ltscale: c.ltscale, alpha, } } @@ -209,90 +195,87 @@ impl Converter { } /// The items of `records` (an entity section or a block), in drawing order. - pub fn items(&mut self, records: &[Record<'_>]) -> Vec { + pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec { let mut out = Vec::new(); - for group in groups(records) { - let Some(head) = group.first() else { continue }; + for e in records { + let m = model::Entity::read(e); // Invisible entities. - if head.g.int(60) == Some(1) { + if !m.common.visible { continue; } - match self.geom(head, group) { + match self.geom(e, &m) { Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY), - Some(geom) => out.push(Item { props: Props::read(head.g), geom }), - None if is_known(head.kind) => {} - None => self.skip(head.kind), + Some(geom) => out.push(Item { props: Props::read(&m.common), geom }), + None if is_known(e.kind) => {} + None => self.skip(e.kind), } } out } - fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option { - let g = head.g; + fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option { + let g = e.groups(); let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 }); - match head.kind { - "LINE" => { + let ocs = ocs(m.common.extrusion); + match &m.kind { + model::EntityKind::Line { start, end } => { let mut bp = BezPath::new(); - bp.move_to(g.point(10)); - bp.line_to(g.point(11)); + bp.move_to(point(*start)); + bp.line_to(point(*end)); path(bp) } - "CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))), - "ARC" => { - let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0)); - let sweep = ccw_sweep(start, end); - path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g))) + model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)), + model::EntityKind::Arc { center, radius, start_angle, end_angle } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs)) } - "ELLIPSE" => path(ellipse(g)?), - "LWPOLYLINE" => lwpolyline(g), - "POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])), - "SPLINE" => path(spline(g)?), - "SOLID" | "TRACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; + model::EntityKind::Ellipse { center, major, ratio, start, end } => { + path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?) + } + model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs), + model::EntityKind::Polyline(p) => self.polyline(p, ocs), + model::EntityKind::Spline(s) => path(spline(s)?), + model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => { + let [a, b, c, d] = corners.map(point); // The corners zigzag: the outline runs 1, 2, 4, 3. - Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 }) + Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 }) } - "3DFACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; - path(polygon(&[a, b, c, d])) - } - "HATCH" => self.hatch(g), - "TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text), - "MTEXT" => mtext(g).map(Geom::Text), - "INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])), + model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))), + model::EntityKind::Hatch(h) => self.hatch(h, ocs), + model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text), + model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text), + model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs), // A dimension draws the anonymous block of its lines, arrows and text. - "DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })), + model::EntityKind::Dimension(d) => { + Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })) + } _ => None, } } - fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let flags = g.int_or(70, 0); - if flags & (16 | 64) != 0 { + fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option { + if p.is_mesh() || p.is_polyface() { self.warn(MESHES); return None; } // Spline frame control points and polyface faces aren't on the line. - let verts: Vec = rest - .iter() - .filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0) - .map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) }) - .collect(); - let bp = curve::polyline(&verts, flags & 1 != 0)?; + let verts: Vec = + p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect(); + let bp = curve::polyline(&verts, p.closed())?; // 3D polylines are in world coordinates; 2D ones in their plane's. - let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) }; - Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) }) + let bp = if p.is_3d() { bp } else { transformed(bp, ocs) }; + Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) }) } - fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let block = g.name(2)?.to_uppercase(); - let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0)); + fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option { + let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase(); + let [sx, sy, _] = ins.scale; if sx.abs() < 1e-12 || sy.abs() < 1e-12 { return None; } - let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians()); - let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1)); - let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0)); + let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians()); + let (cols, rows) = (ins.columns, ins.rows); + let (cs, rs) = (ins.column_spacing, ins.row_spacing); let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS { self.warn(BIG_ARRAY); (cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows)) @@ -312,27 +295,41 @@ impl Converter { .flat_map(|r| (0..cols).map(move |c| (r, c))) .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) .collect(); - let attribs = self.items(rest); + let attribs = self.attribs(followers); Some(Geom::Insert(InsertItem { block, cells, attribs })) } - fn hatch(&mut self, g: &[Pair<'_>]) -> Option { - let start = g.iter().position(|p| p.code == 91)?; - let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize; - let mut c = Cursor { g, i: start + 1 }; + /// An insert's attributes as text. + fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec { + let mut out = Vec::new(); + for r in followers { + let c = model::Common::read(r.tags); + if !c.visible { + continue; + } + let a = model::Attrib::read("ATTRIB", r.tags); + match text(&a.text, &r.groups(), true, ocs(c.extrusion)) { + Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY), + Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }), + None => {} + } + } + out + } + + fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option { let mut bp = BezPath::new(); - for _ in 0..count { - let Some(path) = boundary(&mut c) else { break }; + for l in &h.loops { + let Some(path) = boundary(&l.path) else { break }; bp.extend(path); } if bp.elements().is_empty() { return None; } - let bp = transformed(bp, ocs(g)); - let solid = g.int_or(70, 0) == 1; - if g.int_or(450, 0) == 1 { + let bp = transformed(bp, ocs); + if h.gradient.is_some() { self.warn(GRADIENT_HATCH); - } else if !solid { + } else if !h.solid { self.warn(PATTERN_HATCH); return Some(Geom::Path { bp, fill: false, width: 0.0 }); } @@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath { bp } -/// The extrusion direction (210, 220, 230) of a planar entity, normalised. -fn extrusion(g: &[Pair<'_>]) -> [f64; 3] { - let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)]; - let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] } -} - fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } -fn unit(v: [f64; 3]) -> [f64; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if len > 1e-12 { v.map(|x| x / len) } else { v } -} - /// A planar entity's coordinate system seen from above: its x and y axes in world space by the /// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it /// mirrors x). -fn ocs(g: &[Pair<'_>]) -> Affine { - let n = extrusion(g); - if n[2] > 1.0 - 1e-12 { +fn ocs(extrusion: model::Point) -> Affine { + let [ax, ay, _] = model::ocs_axes(extrusion); + if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] { return Affine::IDENTITY; } - let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) }); - let ay = unit(cross(n, ax)); Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0]) } -fn ellipse(g: &[Pair<'_>]) -> Option { - let centre = g.point(10); - let major = g.point(11).to_vec2(); - let ratio = g.num_or(40, 1.0); +fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option { + let centre = point(centre); + let major = point(major).to_vec2(); if ratio <= 0.0 || major.hypot() < 1e-12 { return None; } // The minor axis is the major one turned a quarter about the extrusion direction. - let n = extrusion(g); + let n = model::ocs_axes(extrusion)[2]; let m = cross(n, [major.x, major.y, 0.0]); let minor = Vec2::new(m[0], m[1]) * ratio; - let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU)); let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians(); curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep) } -fn lwpolyline(g: &[Pair<'_>]) -> Option { - let mut verts: Vec = Vec::new(); - let (mut widths, mut uniform) = (None, true); - for p in g { - match p.code { - 10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }), - 20 => verts.last_mut()?.p.y = number(p.value)?, - 42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0), - 40 | 41 => { - let w = number(p.value).unwrap_or(0.0); - uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12); - widths = Some(w); - } - _ => {} - } - } - let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g)); - let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0); +fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option { + let verts: Vec = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect(); + let bp = transformed(curve::polyline(&verts, p.closed)?, ocs); + // One width for every vertex is the line's width. + let widths: Vec = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect(); + let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied(); + let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0); Some(Geom::Path { bp, fill: false, width }) } -/// The points at `code` (x) and `code + 10` (y), in order. -fn points(g: &[Pair<'_>], code: i32) -> Option> { - let mut out: Vec = Vec::new(); - for p in g { - if p.code == code { - out.push(Point::new(number(p.value)?, 0.0)); - } else if p.code == code + 10 { - out.last_mut()?.y = number(p.value)?; - } - } - Some(out) -} - -fn spline(g: &[Pair<'_>]) -> Option { - let flags = g.int_or(70, 0); - let ctrl = points(g, 10)?; - let degree = usize::try_from(g.int_or(71, 3)).ok()?; - let knots: Vec = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::>()?; - let weights: Vec = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect(); - let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice()); - curve::nurbs(degree, &knots, &ctrl, weights) +fn spline(s: &model::Spline) -> Option { + let ctrl: Vec = s.control.iter().map(|p| point(*p)).collect(); + let degree = usize::try_from(s.degree).ok()?; + let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice()); + curve::nurbs(degree, &s.knots, &ctrl, weights) .map(|mut path| { - if flags & 1 != 0 { + if s.flags & 1 != 0 { path.close_path(); } path }) - .or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0)) -} - -/// Reads a hatch's boundary groups in order. -struct Cursor<'g, 'a> { - g: &'g [Pair<'a>], - i: usize, -} - -impl Cursor<'_, '_> { - /// The next group's value when it has `code` (consumed). - fn opt(&mut self, code: i32) -> Option<&str> { - let p = self.g.get(self.i).filter(|p| p.code == code)?; - self.i += 1; - Some(p.value) - } - fn num(&mut self, code: i32) -> Option { - self.opt(code).and_then(number) - } - fn count(&mut self, code: i32) -> Option { - self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize) - } - fn point(&mut self, code: i32) -> Option { - Some(Point::new(self.num(code)?, self.num(code + 10)?)) - } + .or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::>(), s.flags & 1 != 0)) } /// One hatch boundary loop, closed. -fn boundary(c: &mut Cursor<'_, '_>) -> Option { - let flags = c.num(92)? as i64; - let mut bp = BezPath::new(); - if flags & 2 != 0 { - let bulges = c.num(72).unwrap_or(0.0) != 0.0; - let _closed = c.opt(73); - let n = c.count(93)?; - let mut verts = Vec::with_capacity(n.min(4096)); - for _ in 0..n { - let p = c.point(10)?; - let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 }; - verts.push(Vertex { p, bulge }); +fn boundary(path: &HatchPath) -> Option { + let xy = |p: &[f64; 2]| Point::new(p[0], p[1]); + match path { + HatchPath::Polyline { vertices, .. } => { + let verts: Vec = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect(); + curve::polyline(&verts, true) } - bp = curve::polyline(&verts, true)?; - } else { - let n = c.count(93)?; - for _ in 0..n { - let edge = edge(c)?; - append(&mut bp, &edge); - } - if bp.elements().is_empty() { - return None; - } - bp.close_path(); - } - // The boundary objects it was made from. - if let Some(n) = c.count(97) { - for _ in 0..n { - if c.opt(330).is_none() { - break; - } - } - } - Some(bp) -} - -/// One edge of a hatch boundary. -fn edge(c: &mut Cursor<'_, '_>) -> Option { - match c.num(72)? as i64 { - 1 => { + HatchPath::Edges(edges) => { let mut bp = BezPath::new(); - bp.move_to(c.point(10)?); - bp.line_to(c.point(11)?); + for e in edges { + let seg = match e { + HatchEdge::Line { start, end } => { + let mut bp = BezPath::new(); + bp.move_to(xy(start)); + bp.line_to(xy(end)); + Some(bp) + } + HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + // Clockwise arcs have their angles measured clockwise. + let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) }; + curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians()) + } + HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => { + let (centre, major) = (xy(center), xy(major).to_vec2()); + let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 }; + let sweep = ccw_sweep(*start_angle, *end_angle).to_radians(); + curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep) + } + HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => { + let ctrl: Vec = control.iter().map(xy).collect(); + let degree = usize::try_from(*degree).ok()?; + let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice()); + curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::>(), false)) + } + }; + append(&mut bp, &seg?); + } + if bp.elements().is_empty() { + return None; + } + bp.close_path(); Some(bp) } - 2 => { - let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let sweep = ccw_sweep(start, end); - // Clockwise arcs have their angles measured clockwise. - let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) }; - curve::arc(centre, r, start.to_radians(), sweep.to_radians()) - } - 3 => { - let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 }; - let sweep = ccw_sweep(start, end).to_radians(); - curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep) - } - 4 => { - let degree = c.count(94)?; - let rational = c.num(73).unwrap_or(0.0) != 0.0; - let _periodic = c.opt(74); - let (nk, nc) = (c.count(95)?, c.count(96)?); - let knots: Vec = (0..nk).map(|_| c.num(40)).collect::>()?; - let mut ctrl = Vec::with_capacity(nc.min(4096)); - let mut weights = Vec::with_capacity(nc.min(4096)); - for _ in 0..nc { - ctrl.push(c.point(10)?); - weights.push(c.num(42).unwrap_or(1.0)); - } - // Fit data (DXF 2010 and later): a count, then that many points. Older files have none, - // and the 97 that may follow is the boundary's count of source objects (then their - // handles), so it is only the fit count when it is 0 or points follow. - let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) { - (Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11), - _ => false, - }; - let fit: Vec = match fit_count.then(|| c.count(97)).flatten() { - Some(n) => (0..n).map(|_| c.point(11)).collect::>()?, - None => vec![], - }; - // Start and end tangents. - for code in [12, 13] { - c.point(code); - } - curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false)) - } - _ => None, } } @@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 { } } -fn text(g: &[Pair<'_>], attrib: bool) -> Option { - if attrib && g.int_or(70, 0) & 1 != 0 { +/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and +/// percent codes are turned into text once, by `super::text`. +fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option { + if attrib && g.i_or(70, 0) & 1 != 0 { return None; } - let s = super::text::plain(g.raw(1)?); + let s = super::text::plain(&g.s(1)?); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); - let (p1, p2) = (g.point(10), g.point_opt(11)); - let mut angle = g.num_or(50, 0.0).to_radians(); + let height = Some(t.height).filter(|h| *h > 0.0)?; + // An ATTRIB's 73 is its field length: its vertical alignment is 74. + let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign }); + let (p1, p2) = (point(t.insert), t.align.map(point)); + let mut angle = t.rotation.to_radians(); let mut span = None; // Aligned and fit text run from the first point to the second. let anchor = match (h, p2) { @@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { _ => Justify::Left, }; let v = if h == 4 { 2 } else { v }; - let flags = g.int_or(71, 0); + let flags = t.generation; let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 }); - let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); + let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); Some(TextItem { text: s, height, xf, - width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0), + width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0), justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + style: t.style.to_uppercase(), family: None, leading: None, span, }) } -fn mtext(g: &[Pair<'_>]) -> Option { - // The text comes in 250-character chunks (3) before the last (1). - let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect(); +/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`]. +fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option { + // The text comes in 250-character chunks (3) before the last (1), up to an embedded object. + let (own, _) = model::text::split_embedded(tags); + let g = Groups(own); + let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect(); let (s, family) = super::text::mtext(&raw); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0); + let height = Some(t.height).filter(|h| *h > 0.0)?; + let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0); let lines = s.lines().count().max(1) as f64; - let attach = g.int_or(71, 1).clamp(1, 9) - 1; + let attach = i64::from(t.attach) - 1; let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default(); // The first baseline below the attachment point: under the top, around the middle, above // the bottom of the lines. @@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option { 1 => total / 2.0 - height, _ => (lines - 1.0) * leading, }; - let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); - let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); - let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); - Some(TextItem { - text: s, - height, - xf, - width: 1.0, - justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), - family, - leading: Some(leading), - span: None, - }) + let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first)); + Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None }) } diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 0da950ead1..762cfdb721 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -1,156 +1,14 @@ -//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its -//! sections (header variables; the layer, linetype, text style and block record tables; the -//! blocks; the entities; the layouts). +//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer, +//! linetype and text style tables; the blocks; the entities; the layouts. -use std::borrow::Cow; use std::collections::HashMap; +use craft_dxf::model::{self, EntityRecord}; use vectorcraft_geom::Point; -/// The most group-code pairs a drawing may hold (about 300 MB of DXF). -const MAX_PAIRS: usize = 20_000_000; - /// The first bytes of a binary DXF file (its format's signature). pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00"; -/// One group: its code and its value as written. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Pair<'a> { - pub code: i32, - pub value: &'a str, -} - -/// Values of a run of pairs by group code. -pub(crate) trait Groups<'a> { - /// The value of the first `code`, as written. - fn raw(&self, code: i32) -> Option<&'a str>; - /// The first `code` as a name (trimmed, never empty). - fn name(&self, code: i32) -> Option<&'a str> { - self.raw(code).map(str::trim).filter(|s| !s.is_empty()) - } - /// The first `code` as a finite number. - fn num(&self, code: i32) -> Option { - self.raw(code).and_then(number) - } - fn num_or(&self, code: i32, default: f64) -> f64 { - self.num(code).unwrap_or(default) - } - /// The first `code` as a whole number. - fn int(&self, code: i32) -> Option { - self.raw(code).and_then(integer) - } - fn int_or(&self, code: i32, default: i64) -> i64 { - self.int(code).unwrap_or(default) - } - /// The point at `code` (x) and `code + 10` (y); 0 for what is missing. - fn point(&self, code: i32) -> Point { - Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0)) - } - /// [`Groups::point`] when its x is given. - fn point_opt(&self, code: i32) -> Option { - self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0))) - } -} - -impl<'a> Groups<'a> for [Pair<'a>] { - fn raw(&self, code: i32) -> Option<&'a str> { - self.iter().find(|p| p.code == code).map(|p| p.value) - } -} - -/// A finite number (`1e999`, `NaN` and junk are none). -pub(crate) fn number(s: &str) -> Option { - s.trim().parse::().ok().filter(|v| v.is_finite()) -} - -/// A whole number; some writers put integers as `1.0`. -pub(crate) fn integer(s: &str) -> Option { - let s = s.trim(); - s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64)) -} - -/// One object: its type (the value of its `0` group) and the groups up to the next object. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Record<'a> { - pub kind: &'a str, - pub g: &'a [Pair<'a>], -} - -/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions -/// write (a byte order mark is dropped). -pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> { - let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); - match std::str::from_utf8(bytes) { - Ok(s) => Cow::Borrowed(s), - Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()), - } -} - -/// A byte of the Windows Western code page. -fn windows_1252(b: u8) -> char { - const HIGH: [char; 32] = [ - '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', - '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ', - ]; - match b { - 0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'), - _ => char::from(b), - } -} - -/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the -/// reading: what came before is kept and the second value says where the file is damaged. -pub(crate) fn pairs(text: &str) -> Result<(Vec>, Option), String> { - let mut out = Vec::new(); - let mut lines = text.lines().enumerate(); - let mut damaged = None; - while let Some((n, code)) = lines.next() { - let c = code.trim(); - if c.is_empty() && out.is_empty() { - continue; - } - let Ok(code) = c.parse::() else { - damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1)); - break; - }; - let value = lines.next().map_or("", |(_, v)| v); - if code == 0 && value.trim() == "EOF" { - break; - } - if out.len() >= MAX_PAIRS { - return Err("the drawing is too large to open".into()); - } - out.push(Pair { code, value }); - } - if out.is_empty() { - return Err("not a DXF drawing".into()); - } - Ok((out, damaged)) -} - -/// The objects in `pairs`: each from a `0` group up to the next. -fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec> { - let mut out = Vec::new(); - let mut start: Option = None; - for (i, p) in pairs.iter().enumerate() { - if p.code == 0 { - if let Some(s) = start { - out.extend(record(pairs, s, i)); - } - start = Some(i); - } - } - if let Some(s) = start { - out.extend(record(pairs, s, pairs.len())); - } - out -} - -fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option> { - let kind = pairs.get(start)?.value.trim(); - Some(Record { kind, g: pairs.get(start + 1..end)? }) -} - /// The header variables the import reads. #[derive(Clone, Debug, Default)] pub(crate) struct Header { @@ -186,7 +44,7 @@ pub(crate) struct LayerDef { pub(crate) struct BlockDef<'a> { pub name: String, pub base: Point, - pub records: Vec>, + pub records: Vec>, /// A reference to another drawing (its art isn't in this file). pub xref: bool, } @@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> { /// Blocks by upper-case name. pub blocks: HashMap>, /// The ENTITIES section. - pub entities: Vec>, + pub entities: Vec>, /// Model space first, then the paper layouts in tab order. pub layouts: Vec, /// How many sections the file has (none: not a drawing). pub sections: usize, } -impl<'a> Drawing<'a> { - /// Read the sections of `pairs`. - pub fn read(pairs: &'a [Pair<'a>]) -> Self { - let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() }; - let all = records(pairs); - let mut section: Option<&str> = None; - let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]); - for r in all { - match (r.kind, section) { - ("SECTION", _) => { - d.sections += 1; - section = r.g.name(2); - if section == Some("HEADER") { - d.header = header(r.g); - } - } - ("ENDSEC", _) => section = None, - (_, Some("TABLES")) => tables.push(r), - (_, Some("BLOCKS")) => blocks.push(r), - (_, Some("ENTITIES")) => d.entities.push(r), - (_, Some("OBJECTS")) => objects.push(r), - _ => {} - } - } - let mut block_records = HashMap::new(); - let mut table = ""; - for r in &tables { - match r.kind { - "TABLE" => table = r.g.name(2).unwrap_or(""), - "LAYER" if table == "LAYER" => { - if let Some(l) = layer(r.g) { - d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); - d.layers.push(l); - } - } - "LTYPE" if table == "LTYPE" => { - if let Some(name) = r.g.name(2) { - let pattern: Vec = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect(); - d.linetypes.insert(name.to_uppercase(), pattern); - } - } - "STYLE" if table == "STYLE" => { - if let Some(name) = r.g.name(2) { - d.styles.insert(name.to_uppercase(), font_family(r.g)); - } - } - "BLOCK_RECORD" if table == "BLOCK_RECORD" => { - if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) { - block_records.insert(h.to_uppercase(), name.to_uppercase()); - } - } - _ => {} - } - } - d.read_blocks(&blocks, &mut block_records); - d.layouts = layouts(&objects, &block_records, &d.entities); - d - } +/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. +pub(crate) fn aci_paper(i: u8) -> [u8; 3] { + if i == 7 { [0; 3] } else { model::aci::rgb(i) } +} - fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap) { - let mut current: Option> = None; - for r in records { - match r.kind { - "BLOCK" => { - let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string(); - // R13+ blocks name their block record: layouts find their block through it. - if let Some(owner) = r.g.name(330) { - block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase()); - } - let xref = r.g.int_or(70, 0) & 4 != 0; - current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref }); - } - "ENDBLK" => { - if let Some(b) = current.take().filter(|b| !b.name.is_empty()) { - self.blocks.insert(b.name.to_uppercase(), b); - } - } - _ => { - if let Some(b) = &mut current { - b.records.push(*r); - } - } - } +impl<'a> Drawing<'a> { + /// What the import uses of `doc`. + pub fn read(doc: model::Document<'a>) -> Self { + let h = &doc.header; + let header = Header { + acadver: h.acadver().unwrap_or_default(), + insunits: h.insunits(), + measurement: h.measurement(), + ltscale: h.ltscale(), + lwdefault: i32::from(h.lwdefault()), + }; + let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() }; + for l in doc.layers() { + d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); + d.layers.push(layer(l)); } + for lt in doc.linetypes() { + d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect()); + } + for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) { + d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default()); + } + let block_names = doc.block_names(); + d.layouts = layouts(&doc, &block_names); + for b in doc.blocks { + if b.name.is_empty() { + continue; + } + let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities }; + d.blocks.insert(b.name.to_uppercase(), def); + } + d.entities = doc.entities; + d } /// The layout named `name` (any case; `model` is model space). @@ -317,7 +129,7 @@ impl<'a> Drawing<'a> { } /// The entity records of `layout`. - pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { + pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default(); // The active paper layout keeps its art in the ENTITIES section, flagged paper space. let (block_name, paper) = match &layout.block { @@ -325,7 +137,7 @@ impl<'a> Drawing<'a> { Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true), Some(b) => return block(b), }; - let mut out: Vec> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect(); + let mut out: Vec> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect(); out.extend(block(block_name)); out } @@ -336,111 +148,47 @@ impl<'a> Drawing<'a> { } } -/// Is an entity (its first record) in paper space? -fn in_paper_space(group: &[Record<'_>]) -> bool { - group.first().is_some_and(|r| r.g.int(67) == Some(1)) +pub(crate) fn point(p: model::Point) -> Point { + Point::new(p[0], p[1]) } -/// The entities of `records`, each with the records that belong to it (a polyline's vertices, -/// an insert's attributes, and the SEQEND closing them). -pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator]> { - let mut i = 0; - std::iter::from_fn(move || { - let start = i; - let head = records.get(start)?; - i += 1; - if matches!(head.kind, "POLYLINE" | "INSERT") { - while let Some(r) = records.get(i) { - match r.kind { - "VERTEX" | "ATTRIB" => i += 1, - "SEQEND" => { - i += 1; - break; - } - _ => break, - } - } - } - records.get(start..i) - }) -} - -fn header(g: &[Pair<'_>]) -> Header { - let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }; - let mut var = ""; - for p in g { - if p.code == 9 { - var = p.value.trim(); - continue; - } - match (var, p.code) { - ("$ACADVER", 1) => h.acadver = p.value.trim().to_string(), - ("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0), - ("$MEASUREMENT", 70) => h.measurement = integer(p.value), - ("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0), - ("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25), - _ => {} - } - } - h -} - -fn layer(g: &[Pair<'_>]) -> Option { - let name = g.name(2)?.to_string(); - let aci = g.int_or(62, 7); - let flags = g.int_or(70, 0); - let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs())); - Some(LayerDef { +fn layer(l: model::Layer) -> LayerDef { + let rgb = match l.color { + model::Color::Rgb(rgb) => rgb, + model::Color::Index(i) => aci_paper(i), + model::Color::ByLayer | model::Color::ByBlock => aci_paper(7), + }; + LayerDef { // CAD apps never plot this layer of dimension points. - plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"), - name, + plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"), rgb, - hidden: aci < 0 || flags & 1 != 0, - locked: flags & 4 != 0, - linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(), - lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3), - }) -} - -/// A text style's font family: the one 2000-and-later files name, else the font file's name -/// (TrueType); CAD fonts (`.shx`) have none. -fn font_family(g: &[Pair<'_>]) -> String { - if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD") - && let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000)) - { - return f.to_string(); - } - let file = g.name(3).unwrap_or(""); - let path = std::path::Path::new(file); - let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - match ext.as_str() { - "ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), - _ => String::new(), + hidden: !l.on || l.frozen, + locked: l.locked, + linetype: l.linetype.to_uppercase(), + lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3), + name: l.name, } } -/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout -/// objects but with paper-space art has one paper layout. /// The most bytes of layout and block names the layouts keep together. Each layout keeps its own /// copy of its block's name, so many layouts naming one long block name multiplied it; real /// drawings have a few dozen layouts with short names. const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20; -fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { +/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout +/// objects but with paper-space art has one paper layout. +fn layouts(doc: &model::Document<'_>, block_names: &HashMap) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; let mut seen = std::collections::HashSet::new(); let mut name_bytes = 0usize; - for r in objects.iter().filter(|r| r.kind == "LAYOUT") { - // The layout's own groups follow its subclass marker. - let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); - let Some(name) = own.name(1) else { continue }; - let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())); - if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) { + for l in doc.layouts() { + let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase()); + if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) { continue; } let Some(block) = block else { continue }; - let key = name.to_ascii_lowercase(); - let cost = name.len() + block.len(); + let key = l.name.to_ascii_lowercase(); + let cost = l.name.len() + block.len(); // A layout past the allowance is skipped alone: a later short one is still offered, and a // skipped one reserves neither its name nor its bytes. if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES { @@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti } seen.insert(key); name_bytes += cost; - paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) })); + paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) })); } paper.sort_by_key(|(order, _)| *order); - if paper.is_empty() && groups(entities).any(in_paper_space) { + if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) { paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) })); } std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect() } - -#[cfg(test)] -mod tests { - use super::integer; - - #[test] - fn integer_rejects_fractional_float_values() { - assert_eq!(integer("1.0"), Some(1)); - assert_eq!(integer("1.9"), None); - } -} diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d28a48553d..67ceef03a0 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() { binary.extend([0u8; 40]); assert!(is_dxf(&binary)); let e = import(&binary, &ImportOptions::default()).unwrap_err(); - assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}"); + assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}"); + // A binary DXF opens as its ASCII form does. + let ascii = entities(LINE); + let tags = craft_dxf::parse(&ascii).unwrap(); + let r = open(&craft_dxf::write_binary(&tags, true), &points()); + assert_eq!(leaves(&r.document).len(), 1); let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err(); assert!(e.contains("DWG"), "{e}"); for junk in [&b""[..], b"hello", b"", b"999\nnote\n"] { @@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")]; let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect(); let bytes = entities(&e); - let text = reader::decode(&bytes); - let (pairs, _) = reader::pairs(&text).unwrap(); - let d = reader::Drawing::read(&pairs); + let tags = craft_dxf::parse(&bytes).unwrap(); + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); let model = d.layout("Model").or(d.layouts.first()).unwrap(); let mut conv = entity::Converter::default(); let items = conv.items(&d.layout_records(model)); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 1443829806..66a6a36baa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader and writer. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on +//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,7 +11,7 @@ //! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns, //! clipping) is approximated or left out, and each such loss comes back as a warning. //! -//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`]. //! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 4860e4989a..ea3c962053 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -683,7 +683,7 @@ impl<'a> Scene<'a> { { // Symbols holding instances of symbols are written in full for each instance, // empty or not: every instance's art counts against one budget for the export. - let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); let count = art.count_with_masks() as u64; // Counted as `place_symbol_art_unclipped` places it: no clip groups. let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 96c037a4a7..a14af8d929 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } +/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it. +#[test] +fn a_stained_instance_is_written_in_its_stained_color() { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) }); + n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into(); + vec![n] + }); + let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + let out = export(&d, &opts(&d)).unwrap(); + let colors: Vec> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect(); + assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 2bb655236a..fb6e8de8d9 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -763,33 +763,19 @@ impl Node { children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1) } - /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. + /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. Only + /// layers that hold one are copied. pub fn strip_ruler_guides(&mut self) { - if !self.holds_ruler_guides() { - return; - } - if let Some(ch) = self.children_mut() { - ch.retain(|c| !c.is_ruler_guide()); - for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) { - Arc::make_mut(c).strip_ruler_guides(); - } + if let Some(held) = held_guides(self, &Self::is_ruler_guide) { + strip_held(self, held); } } /// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and /// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied. pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) { - if !self.holds_ruler_guides() { - return; - } - if let Some(ch) = self.children_mut() { - for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) { - let c = Arc::make_mut(c); - match &mut c.kind { - NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos), - _ => c.map_ruler_guide_positions(f), - } - } + if let Some(held) = held_guides(self, &Self::is_ruler_guide) { + map_held(self, held, f); } } @@ -834,6 +820,61 @@ impl Node { } } +/// The ruler guides a layer holds: its children that are one, by index in order (each with +/// nothing inside), and its sublayers holding one (with what they hold). +#[derive(Debug, Default)] +pub(super) struct HeldGuides(Vec<(usize, HeldGuides)>); + +/// The ruler guides, by `is_guide`, that `n` holds if it is a layer, or `None` when it holds +/// none. `is_guide` looks at each child of `n` and of its sublayers once. +pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option { + if !n.is_layer() { + return None; + } + let held: Vec<_> = n + .children() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, c)| if is_guide(c) { Some((i, HeldGuides::default())) } else { held_guides(c, is_guide).map(|h| (i, h)) }) + .collect(); + (!held.is_empty()).then_some(HeldGuides(held)) +} + +/// Remove the ruler guides of `layer` and of its sublayers that [`held_guides`] found (`held`). +/// Only the sublayers holding one are copied, and only those are visited. +pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) { + let Some(ch) = layer.children_mut() else { return }; + let mut held = held.0.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 ruler guide has nothing inside. + if inner.0.is_empty() { + return false; + } + strip_held(Arc::make_mut(c), inner); + true + }); +} + +/// Call `f` (whether it is vertical, its position) on each ruler guide of `layer` and of its +/// sublayers that [`held_guides`] found (`held`), in the order they lie in. Only the sublayers +/// holding one, and the guides, are copied, and only those sublayers are visited. +pub(super) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) { + let Some(ch) = layer.children_mut() else { return }; + for (i, inner) in held.0 { + let Some(c) = ch.get_mut(i) else { continue }; + if !inner.0.is_empty() { + map_held(Arc::make_mut(c), inner, f); + } else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } +} + impl Node { /// The Appearance panel's row for what this object's own fills and strokes paint around: /// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters" diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 9981514879..06e68a79d0 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -598,3 +598,207 @@ fn compound_stroke_path_is_its_members_in_order() { assert_eq!(bp.elements(), expected.elements()); assert!(!bp.elements().is_empty()); } + +/// The sublayers in [`guide_layers`]'s chain, each inside the one before. +const GUIDE_CHAIN: u64 = 30; + +/// How many children the layers of [`guide_layers`] hold: the layer's 6, 1 in the sublayer +/// without a guide, 2 at each of the two places the shared sublayer lies, the innermost +/// sublayer's 2 and 3 in each sublayer around it. +const GUIDE_LAYER_CHILDREN: usize = 6 + 1 + 2 * 2 + 2 + (GUIDE_CHAIN as usize - 1) * 3; + +/// How many ruler guides the layers of [`guide_layers`] hold: one in each sublayer of the chain, +/// one at each place the shared sublayer lies and the layer's own. +const GUIDE_LAYER_GUIDES: usize = GUIDE_CHAIN as usize + 2 + 1; + +/// A layer holding, in order: [`GUIDE_CHAIN`] sublayers, each inside the one before and holding an +/// object and a ruler guide; a sublayer with an object only; a sublayer with an object and a +/// guide; an object; that last sublayer again; and a guide. Guides differ in direction, artboard +/// and lock. Returns the layer, the sublayer without a guide and the one that lies twice. +fn guide_layers() -> (Node, Arc, Arc) { + let rect = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); + let guide = |id: u64| { + let kind = NodeKind::RulerGuide { vertical: id.is_multiple_of(2), pos: id as f64, artboard: id.is_multiple_of(3).then_some(7) }; + let mut g = Node::new(NodeId(id), kind); + g.locked = id.is_multiple_of(5); + Arc::new(g) + }; + let sublayer = |id, children: Vec>| { + let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + Arc::new(l) + }; + let mut chain = sublayer(100, vec![rect(150), guide(200)]); + for depth in 1..GUIDE_CHAIN { + chain = sublayer(100 + depth, vec![rect(600 + depth), chain, guide(300 + depth)]); + } + let still = sublayer(500, vec![rect(501)]); + let twice = sublayer(510, vec![rect(511), guide(512)]); + let mut layer = Node::layer(NodeId(1), "Layer", LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = vec![chain, Arc::clone(&still), Arc::clone(&twice), rect(2), Arc::clone(&twice), guide(3)]; + } + (layer, still, twice) +} + +/// Whether `a` and `b` hold the same children, none of them copied. +fn same_children(a: &Node, b: &Node) -> bool { + let (a, b) = (a.children().unwrap(), b.children().unwrap()); + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)) +} + +/// Stripping the ruler guides of a layer looks at each child of the layer and of its sublayers +/// once, however deep the sublayers nest. The art and its order stay, a sublayer without a guide is +/// not copied, and a copy of the layer held elsewhere keeps its guides. +#[test] +fn stripping_ruler_guides_looks_at_each_child_of_nested_layers_once() { + use std::cell::Cell; + let (whole, still, twice) = guide_layers(); + let ids = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |c| out.push(c.id)); + out + }; + let mut art = vec![]; + whole.walk(&mut |c| { + if !c.is_ruler_guide() { + art.push(c.id); + } + }); + assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES); + + let mut n = whole.clone(); + let looked = Cell::new(0_usize); + let pick = |c: &Node| { + looked.set(looked.get() + 1); + c.is_ruler_guide() + }; + let held = super::ops::held_guides(&n, &pick).unwrap(); + assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once"); + super::ops::strip_held(&mut n, held); + assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "removing looks at nothing again"); + assert_eq!(ids(&n), art, "the art stays, in its order"); + let ch = n.children().unwrap(); + assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied"); + assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied"); + assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4])); + assert_eq!( + (ch[2].children().map(Vec::len), ch[4].children().map(Vec::len)), + (Some(1), Some(1)), + "a sublayer lying twice is stripped at both places" + ); + // The copies held elsewhere (an undo step's, say) keep their guides. + assert_eq!(twice.children().map(Vec::len), Some(2)); + assert_eq!(ids(&whole).len(), art.len() + GUIDE_LAYER_GUIDES); + + // The method strips the same guides. + let mut same = whole.clone(); + same.strip_ruler_guides(); + assert_eq!(same, n); + assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still)); + // With no guide left there is nothing to remove, and nothing is copied. + let before = n.clone(); + n.strip_ruler_guides(); + assert!(same_children(&n, &before)); + let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0)); + empty.strip_ruler_guides(); + assert_eq!(empty.children().map(Vec::len), Some(0)); + // Only a layer holds ruler guides: a group is left as it is, on its own and in a layer. + let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]); + assert!(super::ops::held_guides(&group, &Node::is_ruler_guide).is_none()); + group.strip_ruler_guides(); + assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice)); + let group = Arc::new(group); + let mut around = Node::layer(NodeId(902), "Around", LayerColor::Preset(0)); + around.children_mut().unwrap().push(Arc::clone(&group)); + around.strip_ruler_guides(); + assert!(Arc::ptr_eq(&around.children().unwrap()[0], &group)); +} + +/// Changing the positions of the ruler guides of a layer looks at each child of the layer and of +/// its sublayers once, however deep the sublayers nest. Each guide is changed once, in the order +/// the guides lie in, and keeps everything else about it; the art is unchanged, a sublayer without +/// a guide is not copied, and a copy of the layer held elsewhere keeps its positions. +#[test] +fn mapping_ruler_guide_positions_looks_at_each_child_of_nested_layers_once() { + use std::cell::Cell; + let (whole, still, twice) = guide_layers(); + let mut lines = vec![]; + whole.walk(&mut |c| { + if let NodeKind::RulerGuide { vertical, pos, .. } = c.kind { + lines.push((vertical, pos)); + } + }); + assert_eq!(lines.len(), GUIDE_LAYER_GUIDES); + assert!(lines.iter().any(|l| l.0) && lines.iter().any(|l| !l.0), "guides in both directions"); + + let mut n = whole.clone(); + let looked = Cell::new(0_usize); + let pick = |c: &Node| { + looked.set(looked.get() + 1); + c.is_ruler_guide() + }; + let held = super::ops::held_guides(&n, &pick).unwrap(); + assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "each child of the layers is looked at once"); + let mut seen = vec![]; + super::ops::map_held(&mut n, held, &mut |vertical, pos| { + seen.push((vertical, *pos)); + *pos = *pos * 2.0 + 1.0; + }); + assert_eq!(looked.get(), GUIDE_LAYER_CHILDREN, "changing looks at nothing again"); + assert_eq!(seen, lines, "each guide once, in the order the guides lie in"); + + // The same nodes in the same order: a guide differs in its position alone, and the art not + // at all. + let (mut was, mut now) = (vec![], vec![]); + whole.walk(&mut |c| was.push(c)); + n.walk(&mut |c| now.push(c)); + assert_eq!(was.len(), now.len()); + for (a, b) in was.into_iter().zip(now) { + if a.is_layer() { + assert_eq!((b.id, b.is_layer(), b.children().map(Vec::len)), (a.id, true, a.children().map(Vec::len))); + continue; + } + let mut want = a.clone(); + if let NodeKind::RulerGuide { pos, .. } = &mut want.kind { + *pos = *pos * 2.0 + 1.0; + } + assert_eq!(b, &want); + } + let ch = n.children().unwrap(); + assert!(Arc::ptr_eq(&ch[1], &still), "a sublayer without a guide is not copied"); + assert!(Arc::ptr_eq(&ch[3], &whole.children().unwrap()[3]), "an object is not copied"); + assert!(!Arc::ptr_eq(&ch[2], &twice) && !Arc::ptr_eq(&ch[4], &twice) && !Arc::ptr_eq(&ch[2], &ch[4])); + // The copy held elsewhere keeps its position. + assert!(matches!(twice.children().unwrap()[1].kind, NodeKind::RulerGuide { pos, .. } if pos == 512.0)); + + // The method changes the same guides, each once. + let mut same = whole.clone(); + let mut calls = 0; + same.map_ruler_guide_positions(&mut |_, pos| { + calls += 1; + *pos = *pos * 2.0 + 1.0; + }); + assert_eq!((calls, &same), (GUIDE_LAYER_GUIDES, &n)); + assert!(Arc::ptr_eq(&same.children().unwrap()[1], &still)); + // A function that leaves the positions as they are is still called on each guide. + let mut kept = whole.clone(); + let mut calls = 0; + kept.map_ruler_guide_positions(&mut |_, _| calls += 1); + assert_eq!((calls, &kept), (GUIDE_LAYER_GUIDES, &whole)); + // A layer without a guide, an empty layer and a group: nothing to change, nothing copied. + let mut bare = whole.clone(); + bare.strip_ruler_guides(); + let before = bare.clone(); + let mut calls = 0; + bare.map_ruler_guide_positions(&mut |_, _| calls += 1); + assert!(same_children(&bare, &before)); + let mut empty = Node::layer(NodeId(900), "Empty", LayerColor::Preset(0)); + empty.map_ruler_guide_positions(&mut |_, _| calls += 1); + let mut group = Node::group(NodeId(901), vec![Arc::clone(&twice)]); + group.map_ruler_guide_positions(&mut |_, _| calls += 1); + assert!(Arc::ptr_eq(&group.children().unwrap()[0], &twice)); + assert_eq!(calls, 0); +} diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index e524d6ca37..a2ff76d83f 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -3,6 +3,8 @@ //! square to it: scaling by a side handle stretches it along its own width or height. Path //! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0. +use std::sync::Arc; + use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt}; use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes}; @@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 { } } +/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's +/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform, +/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can +/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the +/// one before at each step); the turn is then 180°, so the art's boxes match the instance's. +pub fn placed_turn(angle: f64, xf: Affine) -> f64 { + let turn = normalize_deg(angle - transformed_angle(0.0, xf)); + if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 } +} + impl Node { + /// Turns the box of this object, and of every object that turns with it ([`Node::transform`]: + /// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise. + /// Layers have no box. + pub fn turn_boxes(&mut self, by: f64) { + if !self.is_layer() { + let a = normalize_deg(self.bbox_angle + by); + self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a }; + } + for c in self.children_mut().into_iter().flatten() { + Arc::make_mut(c).turn_boxes(by); + } + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + Arc::make_mut(&mut m.art).turn_boxes(by); + } + } + /// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes), /// without changing it, symbol instances measured by their art from `symbols` /// ([`Node::geometric_bounds_with`]). @@ -185,6 +215,44 @@ mod tests { assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0); } + #[test] + fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() { + let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5); + // The box turns with the transform: no turn. + assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0); + assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0); + // Reset to 0: the placed art turns back by the 20°. + assert!(close(placed_turn(0.0, xf), -20.0)); + // Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the + // whole transform gives in one step, and the art turns the half turn to match it. + let flip = Affine::scale_non_uniform(1.0, -1.0); + let flipped = flip * rot(170.0); + let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip); + assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0)); + assert!(close(placed_turn(stepwise, flipped).abs(), 180.0)); + assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0)); + // Degenerate and non-finite transforms turn nothing. + assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0); + assert_eq!(placed_turn(f64::NAN, xf), 0.0); + } + + #[test] + fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() { + let mut d = Document::new(500.0, 500.0); + let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]); + g.bbox_angle = 170.0; + g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true))); + let mut layer = (*d.layers[0]).clone(); + layer.children_mut().unwrap().push(Arc::new(g)); + layer.turn_boxes(20.0); + assert_eq!(layer.bbox_angle, 0.0); + let g = &layer.children().unwrap()[0]; + assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]"); + assert!(close(g.children().unwrap()[0].bbox_angle, 20.0)); + assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0)); + } + #[test] fn oriented_bounds_hug_a_rotated_rectangle() { let mut d = Document::new(500.0, 500.0); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 53f84e869c..3c8c1b324d 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -614,6 +614,8 @@ impl NineSliceMap { /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`]. + /// The clip groups and clip paths take the angle of the cell map that places them. The group + /// and each copy keep `n`'s box angle, turned by the map that places them. /// /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own /// effects, after its last other visible effect, go on that group with its opacity, blending @@ -647,11 +649,15 @@ impl NineSliceMap { area.transform(local); let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = Appearance::default().into(); + clip.bbox_angle = crate::orient::transformed_angle(0.0, local); let copy = transformed(n, local * self.natural); - cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }))); + let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }); + cell.bbox_angle = crate::orient::transformed_angle(0.0, local); + cells.push(Arc::new(cell)); } } let mut out = Node::group(n.id, cells); + out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center); out.visible = n.visible; out.locked = n.locked; out.name = n.name.clone(); diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index 33f0fd7695..241c61fba8 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -2,14 +2,15 @@ //! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays //! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops //! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and -//! applied wherever an instance is drawn or exported ([`dynamic_art`]). +//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill +//! stains the art after them ([`instance_art`]). use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; -use crate::{Node, NodeKind}; +use crate::{AppearanceItem, Node, NodeKind}; /// Deepest object an override may name: groups nested this deep in the symbol's art. pub const MAX_PATH_DEPTH: usize = 64; @@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node { a } +/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer. +pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { + let amount = amount.clamp(0.0, 1.0); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + for p in [&mut r.style.fill, &mut r.style.stroke] { + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + tint_node(Arc::make_mut(c), color, amount); + } + } +} + +/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's +/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one +/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. +pub fn stain(inst: &Node) -> Option<(Color, f32)> { + let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; + Some((f.paint.color()?, f.opacity)) +} + +/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance +/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]). +pub fn instance_art(art: &Node, inst: &Node) -> Node { + let mut a = dynamic_art(art, inst); + if let Some((c, amount)) = stain(inst) { + tint_node(&mut a, &c, amount); + } + a +} + /// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest. pub fn retain_parts(overrides: &mut Vec, art: &Node) { overrides.retain(|o| part(art, &o.path).is_some()); @@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec, art: &Node) { mod tests { use super::*; use crate::{Appearance, NodeId}; - use vectorcraft_color::Color; use vectorcraft_geom::{Rect, shapes}; fn square(id: u64) -> Arc { diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 4193b085ef..1b9ac80452 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) { /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in /// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` -/// when given, otherwise the symbol's art with the instance's own appearance changes. +/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by +/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]). pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { let symbol = symbol_art - .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 7e42147443..90a0d85ec8 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -148,9 +148,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = moved_type_group(n) { return Some(bake_pieces(d, m)); } + // A symbol instance's art as the canvas draws it: with its own appearance changes, stained + // by its fill. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { - d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)) + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)) } _ => None, }; diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 2bcc026340..f3855f57ca 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() { assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); } +/// The fill colors of the paths under the layers of `d`, in paint order. +fn path_fills(d: &Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. }) + && let Some(c) = n.appearance.fill_paint().color() + { + out.push(c); + } + }); + out +} + +/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it: +/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the +/// stain. +#[test] +fn a_stained_instance_is_baked_in_its_stained_colors() { + let blue = Color::rgb8(30, 90, 220); + let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) }); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut n = inst.clone(); + n.appearance.effects.push(fx(id, params)); + let mut d = doc_with(n); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) }); + let fills = path_fills(&bake_document(&d).unwrap()); + assert_eq!(fills.iter().map(Color::to_hex).collect::>(), [want.to_hex()], "{id}"); + } +} + #[test] fn a_baked_transform_moves_a_placed_gradient_with_the_path() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 2c02f886c0..5a878d42d3 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> { /// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance, /// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None` /// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and -/// Pathfinder operations the symbol instances among their members. +/// Pathfinder operations the symbol instances among their members. The group around the art takes +/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them. pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let doc = doc?; let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - let art = doc.place_symbol_art(symbol, &art, *xf)?; - Some(Node::group(n.id, vec![Arc::new(art)])) + let mut art = doc.place_symbol_art(symbol, &art, *xf)?; + let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf); + if turn != 0.0 { + art.turn_boxes(turn); + } + let mut out = Node::group(n.id, vec![Arc::new(art)]); + out.bbox_angle = n.bbox_angle; + Some(out) } _ => outline_text(n), } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 627cc1be03..0023fc5b16 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1570,6 +1570,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() { assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b)); } +/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the +/// instance's box angle and the art turns with it, turned with the instance or reset to 0. +#[test] +fn outline_object_turns_an_instances_art_with_its_box() { + use std::sync::Arc; + + use vectorcraft_doc::orient::transformed_angle; + use vectorcraft_doc::{Appearance, Document, NodeId, Symbol}; + let mut d = Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) }); + let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35); + for box_angle in [transformed_angle(0.0, xf), 0.0] { + let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf }); + inst.bbox_angle = box_angle; + let out = outline_object(Some(&d), &inst).unwrap(); + let mut angles = vec![]; + out.walk(&mut |c| angles.push(c.bbox_angle)); + assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}"); + } +} + #[test] fn convert_to_shape_sizes() { let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap(); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 94d8add68b..d4fb2c39cd 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), } } +/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer +/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole +/// number", as [`object_param`] words it. +pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))), + Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other /// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}". pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index fa8182c4c2..2fe9c77a36 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", has_selection, filter ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index f98880e204..a72075d233 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() { } } +#[test] +fn corner_commands_refuse_an_unknown_item_key() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap(); + let remove = json!({"items": [{"id": i, "anchor": [0]}]}); + let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]); + assert_eq!(drawn(&s, id, 0), 10.0); +} + +#[test] +fn corner_items_take_their_documented_keys() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0)); + // A member without `anchors` takes the request's. + s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap(); + assert_eq!(drawn(&s, id, 2), 6.0); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0)); + // A null `ids` beside `items` counts as not given. + s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap(); + assert_eq!(drawn(&s, id, 2), 0.0); +} + +#[test] +fn corner_commands_refuse_items_with_ids() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[both]); + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap(); + let both = json!({"items": [{"id": id.0}], "ids": [id.0]}); + let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[both]); +} + +#[test] +fn saved_corner_steps_with_older_item_keys_still_play() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5}); + s.replay("addon.pathtools.cornerSet", &saved).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0)); + s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap(); + assert_eq!(drawn(&s, id, 0), 0.0); +} + /// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd /// numbers counting from 1), its inner corners at the odd ones. fn star_path(s: &mut Session) -> NodeId { diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02bc111746..bed4b33b76 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -36,7 +36,7 @@ use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, - object_param, ok, paint_targets, str_param, switch_or, targets, + object_param, ok, paint_targets, str_param, switch_or, targets, u64_req, }; use crate::EngineError; @@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } -/// The whole number of 0 or more that `key` holds, read with [`index`]. +/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + u64_req(p, key, cmd).map(index) } fn set_active_item(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 1ebde5c026..e30e6efcdc 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), @@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } q.insert("assets".into(), json!(all)); } - export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) + export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e)) } fn settings_set(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 50c63b59cd..32b0f9fcd0 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -226,7 +226,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 2fb31c5f6b..1bfb8edea7 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it -/// when the art has a mask of its own. +/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after +/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on +/// the art, or on a group around it when the art has a mask of its own; a group added around the +/// art takes the instance's box angle. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { if let Some((color, amount)) = brush::stain(inst) { brush::tint_node(&mut art, &color, amount); } - let art = d.place_symbol_art(symbol, &art, *xf)?; + let mut art = d.place_symbol_art(symbol, &art, *xf)?; + let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf); + if turn != 0.0 { + art.turn_boxes(turn); + } let mut art = d.reid(&art); if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { if !matches!(art.kind, NodeKind::Group { .. }) { let id = d.alloc_id(); art = Node::group(id, vec![Arc::new(art)]); + art.bbox_angle = inst.bbox_angle; } art.opacity *= inst.opacity; art.blend = inst.blend; @@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { if art.extras().mask.is_some() { let id = d.alloc_id(); art = Node::group(id, vec![Arc::new(art)]); + art.bbox_angle = inst.bbox_angle; } art.extras_mut().mask = Some(mask.clone()); } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 94fe55c119..3fc5953619 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Merge", ["Object", "Live Paint"], None, - "{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}", + "{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link); a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_multi_or_ids, lp_merge ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Gap Options", [], None, - "{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}", + "{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint; when every group already has the options and closeGaps finds no gap, nothing changes (no undo step) → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}", has_selection_or_ids, lp_gap_options ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}", has_doc, lp_stroke_edge ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index e5e7757486..fb9e6c32ac 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -52,8 +52,34 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result< Ok((gid, nf, ne)) } +/// Select `ids` alone, without an undo step, when they are not selected that way already. +fn select_only(s: &mut Session, ids: &[NodeId]) -> Result<()> { + let st = s.doc()?; + let mut sel = st.selection.clone(); + sel.set(ids.iter().copied()); + if sel != st.selection { + s.select(|_, selection| *selection = sel)?; + } + Ok(()) +} + +/// Make or Merge given one Live Paint group and nothing else has nothing to add to it, so it leaves +/// the document as it is, records no undo step and selects the group: → its reply. `None` for any +/// other `roots`. +fn lone_live_group(s: &mut Session, roots: &[NodeId]) -> Result> { + let d = &s.doc()?.doc; + let Some(&g) = roots.first().filter(|g| roots.iter().all(|r| r == *g)) else { return Ok(None) }; + let Some(n) = d.node(g).filter(|n| is_live_paint(n)) else { return Ok(None) }; + let reply = json!({ "id": g.0, "faces": b::faces(n).len(), "edges": b::edges(n).len() }); + select_only(s, &[g])?; + Ok(Some(reply)) +} + pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { let roots = root_ids(s, p)?; + if let Some(reply) = lone_live_group(s, &roots)? { + return Ok(reply); + } let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?; Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } @@ -130,6 +156,9 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result { let roots = root_ids(s, p)?; + if let Some(reply) = lone_live_group(s, &roots)? { + return Ok(reply); + } let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } @@ -230,27 +259,58 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { }; let close = switch_or(p, "closeGaps", false, C)?; let targets = live_targets(s, p)?; + let with_params = |mut options: GapOptions| { + options.detect = detect.unwrap_or(options.detect); + options.size = size.unwrap_or(options.size); + options.color = color.unwrap_or(options.color); + options + }; + // When every group already has these options and Close Gaps With Paths has no gap to close, + // nothing changes: no undo step, and the reply is the same. + let unchanged = { + let d = &s.doc()?.doc; + let (mut faces, mut gaps, mut same) = (0, vec![], true); + for g in &targets { + let old = d.node(*g).ok_or(EngineError::NoNode(*g))?; + let options = old.gap_options(); + if with_params(options) != options { + same = false; + break; + } + let open = open_gaps(old, options); + if close && !open.is_empty() { + same = false; + break; + } + faces += b::faces(old).len(); + gaps.extend(open); + } + same.then_some((faces, gaps)) + }; + if let Some((faces, gaps)) = unchanged { + select_only(s, &targets)?; + return Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": 0 })); + } let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { let (mut faces, mut gaps, mut closed) = (0, vec![], 0); let mut changed = Beside::replacing(d, &targets); for g in &targets { let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; - let mut options = old.gap_options(); - options.detect = detect.unwrap_or(options.detect); - options.size = size.unwrap_or(options.size); - options.color = color.unwrap_or(options.color); + let options = with_params(old.gap_options()); let mut sources = source_nodes(&old); + let mut added = 0; if close { // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { let line = PathData::single(SubPath::polyline(&gap, false)); sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); - closed += 1; + added += 1; } } + closed += added; let mut n = old.clone(); // The faces change only with what paint stops at, or with new paths. - if close || options.max_gap() != old.gap_options().max_gap() { + if added > 0 || options.max_gap() != old.gap_options().max_gap() { let (children, _, _) = build_children(d, sources, options.max_gap())?; if let Some(ch) = n.children_mut() { *ch = children; @@ -305,27 +365,9 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; - let (g, face) = s.edit("Live Paint Bucket", |d, sel| { - let gid = match group { - Some(g) => { - if !d.node(g).is_some_and(is_live_paint) { - return Err(bad(C, format!("{g} is not a Live Paint group"))); - } - g - } - None => { - let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { - roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) - }; - match (live_at(b::visible_face_at), live_at(face_at)) { - (Some(g), _) => g, - // At a point on a hidden face of a Live Paint group among the objects, the command returns an error - // and does not merge the objects. - (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), - (None, None) => make_live(d, sel, &roots)?.0, - } - } - }; + // The faces the click or drag paints in Live Paint group `gid`: the visible face at the point, + // and every visible face the drag crosses; → (the face at the point, the faces). + let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet)> { let fid = d .node(gid) .and_then(|n| b::visible_face_at(n, pt)) @@ -355,11 +397,58 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); } } - for target in targets { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + Ok((fid, targets)) + }; + // The Live Paint group the fill paints; `None` when the objects are made into one first. + let live = { + let d = &s.doc()?.doc; + match group { + Some(g) => { + if !d.node(g).is_some_and(is_live_paint) { + return Err(bad(C, format!("{g} is not a Live Paint group"))); + } + Some(g) + } + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) + }; + match (live_at(b::visible_face_at), live_at(face_at)) { + (Some(g), _) => Some(g), + // At a point on a hidden face of a Live Paint group among the objects, the command returns an error + // and does not merge the objects. + (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), + (None, None) => None, + } + } } - Ok((gid, fid)) - })?; + }; + let (g, face) = match live { + Some(gid) => { + let (fid, targets) = targets_in(&s.doc()?.doc, gid)?; + // Faces that already have the paint stay as they are: when every face has it, nothing + // changes and no undo step is recorded. + let d = &s.doc()?.doc; + let unpainted: Vec = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); + if unpainted.is_empty() { + return Ok(json!({ "group": gid.0, "face": fid.0 })); + } + s.edit("Live Paint Bucket", |d, _| { + for target in unpainted { + d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + } + Ok((gid, fid)) + })? + } + None => s.edit("Live Paint Bucket", |d, sel| { + let gid = make_live(d, sel, &roots)?.0; + let (fid, targets) = targets_in(d, gid)?; + for target in targets { + d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + } + Ok((gid, fid)) + })?, + }; s.remember_paint(&paint); Ok(json!({ "group": g.0, "face": face.0 })) } @@ -375,9 +464,22 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "width out of range")); } let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); - let edge = s.edit("Live Paint Bucket", |d, _| { + let (eid, unchanged) = { + let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + // An edge that already has the paint and the width (or has no stroke and gets none) stays as + // it is, and no undo step is recorded. + let unchanged = match e.appearance.stroke() { + Some(st) => st.paint == paint && st.width == width, + None => paint.is_none(), + }; + (e.id, unchanged) + }; + if unchanged { + return Ok(json!({ "group": g.0, "edge": eid.0 })); + } + let edge = s.edit("Live Paint Bucket", |d, _| { let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; e.appearance.set_stroke(paint.clone()); if let Some(st) = e.appearance.stroke_mut() { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 6de2fbf9a2..2395c51f8e 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -104,7 +104,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md); no undo step when no fill changes → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Blend Horizontally", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}", + "{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost; no undo step when no fill changes → {changed}", has_selection, |s, _| blend(s, BlendOrder::Horizontal) ), @@ -122,7 +122,7 @@ pub fn specs() -> Vec { "Blend Vertically", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}", + "{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost; no undo step when no fill changes → {changed}", has_selection, |s, _| blend(s, BlendOrder::Vertical) ), @@ -457,8 +457,8 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p, cmd)?, f, links)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; -/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`, +/// or none when the pass changes nothing; errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the /// selection left out ([`left_out`]) that the pass would have changed. fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result { let (ids, left) = match ids_param(p, "ids", cmd)? { @@ -471,20 +471,47 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - } }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let (n, not_changed) = s.edit(label, |d, _| { + let (n, not_changed) = edit_if_changed(s, label, |_, d| { let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); let n = keeping_series_paint(d, &ids, |d| { let n = pass.run(d, &ids); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; Ok(n) })?; - Ok((n, not_changed)) + Ok(((n, not_changed), n > 0)) })?; let mut out = json!({ "changed": n }); not_changed.report(&mut out); Ok(out) } +/// [`Session::edit`] for an Edit Colors pass, which may change nothing: `f` runs on a copy of the +/// document (it gets the document as it is too) and returns its result with whether it changed the +/// copy. Only a changed copy becomes the document, as one undo step named `label`; otherwise the +/// undo and redo steps and the saved state stay as they were. +pub(crate) fn edit_if_changed(s: &mut Session, label: &str, f: impl FnOnce(&Document, &mut Document) -> Result<(T, bool)>) -> Result { + if s.script_running() { + return Err(EngineError::Busy); + } + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let before = s.doc()?.doc.clone(); + let mut doc = (*before).clone(); + let (v, changed) = f(&before, &mut doc)?; + // The document is shared with `before` no longer, so `s.edit` does not copy it again. + drop(before); + if changed { + s.edit(label, |d, _| { + *d = doc; + Ok(()) + })?; + } + Ok(v) +} + /// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed /// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn /// again. @@ -597,11 +624,6 @@ fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { fn to_indexed(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colors.toIndexed"; let indexing = Indexing::of(s, p)?; - // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the - // copy holds what was typed. - if !s.previewing && super::typing_in_progress(s) { - super::settle_interaction(s)?; - } let remake = |b: &ImageBlob| indexing.convert(b); let images = Scope { fill: false, stroke: false, images: true, patterns: false }; let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; @@ -609,22 +631,18 @@ fn to_indexed(s: &mut Session, p: &Value) -> Result { // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); let (ids, left) = pass_targets(s, p, C)?; - let st = s.doc()?; - let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); - let not_changed = NotChanged::of( - Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), - ); - let mut doc = (*st.doc).clone(); - let n = pass.run(&mut doc, &ids); - if let Some((w, h)) = indexing.too_large.get() { - return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); - } - if n > 0 { - s.edit("Convert to Indexed Color", |d, _| { - *d = doc; - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) - })?; - } + let (n, not_changed) = edit_if_changed(s, "Convert to Indexed Color", |now, doc| { + let colorized_ok = super::paint::check_colorized(now).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(now, &left).into_iter().map(|(_, hidden)| hidden), + ); + let n = pass.run(doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + super::paint::check_colorized_kept(colorized_ok, doc).map_err(|e| bad(C, e))?; + Ok(((n, not_changed), n > 0)) + })?; let mut out = json!({ "changed": n }); not_changed.report(&mut out); Ok(out) @@ -923,7 +941,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) }; - let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?; + let changed = edit_if_changed(s, label, |_, d| keeping_series_paint(d, &ids, run).map(|n| (n, n > 0)))?; Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 54da0562b1..4068c2506c 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -781,7 +781,10 @@ fn info(s: &mut Session, p: &Value) -> Result { // 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. - let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); + let mut kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = HashMap::new(); + for b in &library { + kinds.entry(b.name.as_str()).or_insert(&b.kind); + } let brush_entries: Vec = brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); let brush_rows: Vec<(String, String)> = brushes diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index 0ca376b243..fd8967f3fa 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool { q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT } -/// The whole number of 0 or more that `key` holds, read with [`index`]. +/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`]. pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + crate::cmd::u64_req(p, key, cmd).map(index) } /// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 31e184e195..0439aaa081 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -11,7 +11,9 @@ use super::{ // ---------- Pencil / Paintbrush ---------- /// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay -/// corners) and simplify it to `tol`. +/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a +/// straight segment from its end back to its start: fitting round the gap would curve that segment +/// on from both ends, past them and back. fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3); let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; @@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { (kept[i], corner) }) .collect(); - let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); + let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false); if sp.anchors.len() < 3 { - return sp; + // Two anchors closed would run back over the line itself. + return SubPath::new(sp.anchors, closed && n > 2); } let fitted = po::simplify_with( &PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() }, ); - fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) + let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors; + if closed { + // No handle on either side of the closing segment. + if let Some(l) = anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = anchors.first_mut() { + f.h_in = f.p; + } + } + SubPath::new(anchors, closed) } /// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 990ac1a1c6..576b05ffd6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, - object_param, ok, point_param, str_param, switch_or, + object_param, ok, point_param, str_param, switch_or, u64_req, }; use crate::{Clipboard, EngineError, SourceLayer, inspect}; @@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result { } fn doc_activate(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?; + let i = match p.get("index") { + None | Some(Value::Null) => return Err(bad("document.activate", "missing index")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?, + }; if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) } } @@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { if from >= n { return Err(bad("document.move", "no such document")); } - let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?; + let to = match p.get("to") { + None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?, + }; let to = to.min(n.saturating_sub(1)); if !s.move_document(from, to) { return Err(bad("document.move", "no such document")); @@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { /// Document ids scope node ids: two tabs may contain objects with the same node id. fn drop_selection(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.dropSelection"; - let key = "sourceDocument"; - let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?; + let uid = u64_req(p, "sourceDocument", CMD)?; let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates"))); let grab = point("grab")?; let at = point("at")?; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d4a6c60802..02dbad5d23 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -25,7 +25,7 @@ use super::dimension::EndedLink; use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; -use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; +use super::pathops::{assemble, node_path, outlinable, outline_strokes, replace_leaves, styled_copy, with_transparency}; use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param}; const C: &str = "object.expand"; @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines (its own fills and strokes on paths of its whole outline, which Fill and Stroke expand), live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Expand Options", [], None, - "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", + "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character, or of the whole type for the type object's own fills), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { let roots = selected_roots(s)?; @@ -189,21 +189,37 @@ impl Expandable { e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); + // Type's outlines take its characters' stroke and fill, and a path of its whole outline + // takes the type object's own fills and strokes (see `type.createOutlines`). + // Any stroke Outline Stroke outlines, not only the topmost one. + let own_stroke = c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))); + let own_pattern = c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()); + let own_gradient = c.appearance.items.iter().any(|i| gradient_fill(i).is_some()); + // Type without an outline (empty, or only spaces) gets no path for its own fills and strokes. + let whole = match &c.kind { + NodeKind::Text(t) if own_stroke || own_pattern || own_gradient => text_whole_outline(t), + _ => None, + }; e.stroke |= match &c.kind { - // Type's outlines take its characters' stroke (see `type.createOutlines`). - NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), - _ => shape && !c.appearance.stroke_paint().is_none(), + NodeKind::Text(t) => (own_stroke && whole.is_some()) || t.runs.iter().any(|r| r.style.has_stroke()), + _ => shape && own_stroke, }; // Once one is found, the next path is not measured. e.gradient = e.gradient || match &c.kind { - NodeKind::Text(t) => text_gradient_expands(d, t), + NodeKind::Text(t) => { + text_gradient_expands(d, t) + || (own_gradient && whole.as_ref().is_some_and(|(path, tb)| text_object_gradient_expands(d, c, path, *tb))) + } _ => shape && gradient_expands(d, c), }; e.fill |= e.gradient; e.fill |= match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())), - _ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()), + NodeKind::Text(t) => { + (own_pattern && whole.is_some()) + || t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())) + } + _ => shape && own_pattern, }; }); } @@ -275,6 +291,33 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar)) } +/// The path of type `t`'s whole outline in the document (glyphs, ruby glyphs and underline and +/// strikethrough bars), which `type.createOutlines` paints with the type object's own fills and +/// strokes, and the layout's bounds there; `None` when the type has no outline. +fn text_whole_outline(t: &TextObject) -> Option<(PathData, Rect)> { + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = BezPath::new(); + for g in lay.glyphs.iter().chain(&lay.ruby) { + all.extend(g.outline.iter()); + } + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let path = PathData::from_bezpath(&all).transformed(t.xf); + (!path.is_empty()).then(|| (path, t.xf.transform_rect_bbox(lay.bounds))) +} + +/// Whether type node `n` has a gradient fill of its own (not its characters') that Fill expands +/// once Object has outlined it: [`gradient_expands`] on `path`, the type's whole outline +/// ([`text_whole_outline`]), with its unplaced gradients fixed to `tb`, the layout's bounds in the +/// document. Leaves the document as it is. +fn text_object_gradient_expands(d: &Document, n: &Node, path: &PathData, tb: Rect) -> bool { + let mut appearance = Appearance { items: n.appearance.items.clone(), ..Default::default() }; + appearance.pin_gradients(tb); + gradient_expands(d, &Node::path(NodeId(u64::MAX), path.clone(), appearance)) +} + /// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). fn piece_gradient_expands(d: &Document, l: &Node) -> bool { if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d20c6c46b2..1ff1897c85 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads, leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 28e12c115d..03736c6022 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,