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 88dce26897..cb98fa2648 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -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/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/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/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 1ec591f2ff..b8dd92eb6a 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/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, ,