diff --git a/Cargo.lock b/Cargo.lock index e44b0ec9..802870b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4089,6 +4089,22 @@ dependencies = [ "vectorcraft-geom", ] +[[package]] +name = "vectorcraft-cad" +version = "0.1.1" +dependencies = [ + "image", + "kurbo 0.13.1", + "serde_json", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-pathops", + "vectorcraft-text", +] + [[package]] name = "vectorcraft-cli" version = "0.1.1" @@ -4157,6 +4173,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "vectorcraft-brush", + "vectorcraft-cad", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-format", @@ -4362,6 +4379,7 @@ dependencies = [ "log", "serde", "serde_json", + "vectorcraft-cad", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", diff --git a/Cargo.toml b/Cargo.toml index a6995419..f74cf4ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ vectorcraft-engine = { path = "crates/engine" } vectorcraft-ui-egui = { path = "crates/ui-egui" } vectorcraft-mcp = { path = "crates/mcp" } vectorcraft-testkit = { path = "crates/testkit" } +vectorcraft-cad = { path = "crates/cad" } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml new file mode 100644 index 00000000..e8a8ea15 --- /dev/null +++ b/crates/cad/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vectorcraft-cad" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)" + +[lints] +workspace = true + +[dependencies] +vectorcraft-geom = { workspace = true } +vectorcraft-color = { workspace = true } +vectorcraft-doc = { workspace = true } +vectorcraft-effects = { workspace = true } +vectorcraft-brush = { workspace = true } +vectorcraft-pathops = { workspace = true } +vectorcraft-text = { workspace = true } +kurbo = { workspace = true } +image = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/cad/src/aci.rs b/crates/cad/src/aci.rs new file mode 100644 index 00000000..63fbb180 --- /dev/null +++ b/crates/cad/src/aci.rs @@ -0,0 +1,105 @@ +//! 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. + +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], + } +} + +/// The indices a colour depth may use (true colour: all of them, for the fallback index). +fn candidates(depth: ColorDepth) -> Box> { + match depth { + ColorDepth::Aci8 => Box::new(1..=8), + ColorDepth::Aci16 => Box::new((1..=9).chain([30]).chain(250..=255)), + ColorDepth::Aci256 | ColorDepth::True => Box::new(1..=255), + } +} + +/// 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) +} + +#[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); + assert_eq!(nearest_aci([0, 0, 0], ColorDepth::Aci256), 7, "black is index 7"); + assert_eq!(nearest_aci([255, 255, 255], ColorDepth::Aci16), 7); + assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci256), 30); + assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci16), 30); + assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci8), 1, "8 colours: red is nearest"); + assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci16), 250); + assert_eq!(nearest_aci([100, 100, 100], ColorDepth::Aci8), 8); + assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci8), 7, "dark greys go to black"); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs new file mode 100644 index 00000000..fd805d6e --- /dev/null +++ b/crates/cad/src/lib.rs @@ -0,0 +1,325 @@ +//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018. +//! +//! [`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 +//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text +//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the +//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per +//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`]. +//! +//! 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. +//! +//! - `aci`: the 256-colour index palette and the nearest index of a colour. +//! - `writer`: group codes, handles and the file's sections, tables and objects. +//! - `scene`: the document walk that turns objects into entities. + +mod aci; +mod scene; +mod writer; + +use vectorcraft_doc::{Document, Unit}; +use vectorcraft_geom::Rect; + +pub use aci::{aci_rgb, nearest_aci}; + +/// The DXF version a file is written for (the `$ACADVER` it declares). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum DxfVersion { + R12, + R13, + R14, + R2000, + R2004, + R2007, + R2010, + R2013, + #[default] + R2018, +} + +impl DxfVersion { + /// Newest first, as version menus list them. + pub const ALL: [Self; 9] = [Self::R2018, Self::R2013, Self::R2010, Self::R2007, Self::R2004, Self::R2000, Self::R14, Self::R13, Self::R12]; + + /// The `version` param value. + pub fn id(self) -> &'static str { + match self { + Self::R12 => "R12", + Self::R13 => "R13", + Self::R14 => "R14", + Self::R2000 => "2000", + Self::R2004 => "2004", + Self::R2007 => "2007", + Self::R2010 => "2010", + Self::R2013 => "2013", + Self::R2018 => "2018", + } + } + + /// The releases that write this version (menus). + pub fn label(self) -> &'static str { + match self { + Self::R12 => "R12", + Self::R13 => "R13", + Self::R14 => "R14", + Self::R2000 => "2000–2002", + Self::R2004 => "2004–2006", + Self::R2007 => "2007–2009", + Self::R2010 => "2010–2012", + Self::R2013 => "2013–2017", + Self::R2018 => "2018", + } + } + + /// The `$ACADVER` header value. + pub fn acadver(self) -> &'static str { + match self { + Self::R12 => "AC1009", + Self::R13 => "AC1012", + Self::R14 => "AC1014", + Self::R2000 => "AC1015", + Self::R2004 => "AC1018", + Self::R2007 => "AC1021", + Self::R2010 => "AC1024", + Self::R2013 => "AC1027", + Self::R2018 => "AC1032", + } + } + + /// A version by id (`2018`, `R2018`, `r12`) or `$ACADVER` (`AC1032`), any case. + pub fn from_id(s: &str) -> Option { + let s = s.trim(); + Self::ALL.into_iter().find(|v| { + let id = v.id(); + s.eq_ignore_ascii_case(id) || s.eq_ignore_ascii_case(v.acadver()) || s.strip_prefix(['R', 'r']).is_some_and(|y| y == id) + }) + } + + /// Objects carry handles and subclass markers; splines, hatches and images exist. + fn handles(self) -> bool { + self >= Self::R13 + } + /// Lightweight polylines. + fn lwpolyline(self) -> bool { + self >= Self::R14 + } + /// Lineweights, layouts, plot styles and long symbol names. + fn r2000(self) -> bool { + self >= Self::R2000 + } + /// True colour and transparency. + fn true_color(self) -> bool { + self >= Self::R2004 + } + /// Text is UTF-8 (earlier versions escape what isn't ASCII as `\U+XXXX`). + fn utf8(self) -> bool { + self >= Self::R2007 + } +} + +/// How many colours entities may use: indexed colours of the 256-colour palette, or true colour +/// (with the nearest index for apps that read only the index). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorDepth { + /// Indices 1–8: the seven standard colours and dark grey. + Aci8, + /// Indices 1–9, orange (30) and the six greys (250–255). + Aci16, + /// Every index 1–255. + Aci256, + /// 24-bit colour (DXF 2004 and later). + #[default] + True, +} + +impl ColorDepth { + pub const ALL: [Self; 4] = [Self::Aci8, Self::Aci16, Self::Aci256, Self::True]; + + /// The `colors` param value. + pub fn id(self) -> &'static str { + match self { + Self::Aci8 => "8", + Self::Aci16 => "16", + Self::Aci256 => "256", + Self::True => "true", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Aci8 => "8 Colors", + Self::Aci16 => "16 Colors", + Self::Aci256 => "256 Colors", + Self::True => "True Colors", + } + } + + /// A depth by id (`8`, `16`, `256`, `true`; `truecolor` and `24` also read). + pub fn from_id(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + match s.as_str() { + "truecolor" | "truecolors" | "true colors" | "24" => Some(Self::True), + _ => Self::ALL.into_iter().find(|d| d.id() == s), + } + } +} + +/// The file format placed images are written in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RasterFormat { + #[default] + Png, + /// No transparency: transparent pixels show white. + Jpeg, +} + +impl RasterFormat { + pub const ALL: [Self; 2] = [Self::Png, Self::Jpeg]; + + /// The `rasterFormat` param value. + pub fn id(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpeg", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Png => "PNG", + Self::Jpeg => "JPEG", + } + } + + /// The extension the image files get. + pub fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + } + } + + /// A format by id (`png`, `jpeg`, `jpg`), any case. + pub fn from_id(s: &str) -> Option { + let s = s.trim(); + if s.eq_ignore_ascii_case("jpg") { + return Some(Self::Jpeg); + } + Self::ALL.into_iter().find(|f| s.eq_ignore_ascii_case(f.id())) + } +} + +/// What the export favours when DXF can't hold an object's look as it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Preserve { + /// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or + /// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush + /// strokes become their brush art. + #[default] + Appearance, + /// Type stays text; every stroke is a line with its weight and dashes. + Editability, +} + +impl Preserve { + pub const ALL: [Self; 2] = [Self::Appearance, Self::Editability]; + + /// The `preserve` param value. + pub fn id(self) -> &'static str { + match self { + Self::Appearance => "appearance", + Self::Editability => "editability", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Appearance => "Preserve Appearance", + Self::Editability => "Maximize Editability", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } +} + +/// The DXF export settings. +#[derive(Clone, Debug)] +pub struct DxfOptions { + pub version: DxfVersion, + /// The unit of the art that `scale` drawing units stand for. + pub unit: Unit, + /// Drawing units per `unit` (1 mm = `scale` units). + pub scale: f64, + /// Lineweights grow with `scale` (else they keep their printed width). + pub scale_lineweights: bool, + pub colors: ColorDepth, + pub raster: RasterFormat, + pub preserve: Preserve, + /// Alter Paths for Appearance: every stroke is written as the filled area it paints. + pub alter_paths: bool, + /// Type is written as glyph outlines (always with [`Preserve::Appearance`]). + pub outline_text: bool, + /// The drawing's origin is this region's bottom-left corner (document space). + pub region: Rect, + /// Leave out objects entirely outside `region` (one drawing per artboard). + pub crop: bool, +} + +impl Default for DxfOptions { + fn default() -> Self { + Self { + version: DxfVersion::default(), + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::default(), + raster: RasterFormat::default(), + preserve: Preserve::default(), + alter_paths: false, + outline_text: false, + region: Rect::ZERO, + crop: false, + } + } +} + +/// An image file a drawing links to, to be written next to it under `name`. +#[derive(Clone, Debug)] +pub struct DxfImage { + pub name: String, + pub bytes: Vec, +} + +/// A written drawing. +#[derive(Clone, Debug, Default)] +pub struct DxfOutput { + pub bytes: Vec, + /// The image files the drawing links to (by name, next to it). + pub images: Vec, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// The smallest and largest `scale` (drawing units per unit). +pub const SCALE_RANGE: std::ops::RangeInclusive = 1e-6..=1e6; + +/// Write `doc` as a DXF drawing with `opts`. Live geometry effects are applied first; template +/// layers, guides and hidden objects are left out. +pub fn export(doc: &Document, opts: &DxfOptions) -> Result { + if !(opts.scale.is_finite() && SCALE_RANGE.contains(&opts.scale)) { + return Err(format!("scale must be a number from {} to {}, not {}", SCALE_RANGE.start(), SCALE_RANGE.end(), opts.scale)); + } + let r = opts.region; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return Err("the export region is not finite".into()); + } + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + Ok(scene::Scene::new(doc, opts).run()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs new file mode 100644 index 00000000..e3a2667c --- /dev/null +++ b/crates/cad/src/scene.rs @@ -0,0 +1,854 @@ +//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint +//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a +//! lineweight and dashes, or filled outlines where the look needs them. + +use std::collections::HashMap; +use std::io::Cursor; + +use kurbo::PathEl; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; + +use crate::aci::nearest_aci; +use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed}; +use crate::{ColorDepth, DxfImage, DxfOptions, DxfOutput, Preserve, RasterFormat}; + +/// Curves are flattened to within this distance (points) where DXF needs straight segments +/// (hatch boundaries, R12 polylines). +const FLATNESS: f64 = 0.05; +/// How deep symbols and brush art may nest in one another (deeper art is left out). +const MAX_NEST: u32 = 8; +/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals. +const CAP_HEIGHT: f64 = 0.7; +/// Longest text value older DXF versions read. +const MAX_TEXT: usize = 250; +/// The lineweights DXF knows (hundredths of a millimetre). +const LINEWEIGHTS: [i32; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]; + +const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are written as the nearest of the 256 indexed colours"; +const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque"; +const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines"; +const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out"; +const WIDE_LINES: &str = + "lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width"; +const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; +const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole"; +const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)"; + +/// One segment of a subpath, cubics for every curve. +#[derive(Clone, Copy)] +enum Seg { + Line(Point), + Cubic(Point, Point, Point), +} + +impl Seg { + fn end(self) -> Point { + match self { + Seg::Line(p) | Seg::Cubic(_, _, p) => p, + } + } +} + +/// A subpath: its start, its segments and whether it closes. +struct Sub { + start: Point, + segs: Vec, + closed: bool, +} + +/// The subpaths of `bp` (quadratics raised to cubics). +fn subpaths(bp: &BezPath) -> Vec { + let mut out = vec![]; + let mut cur: Option = None; + let mut last = Point::ZERO; + for el in bp.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + out.extend(cur.replace(Sub { start: p, segs: vec![], closed: false })); + last = p; + continue; + } + PathEl::ClosePath => { + if let Some(mut s) = cur.take() { + s.closed = true; + last = s.start; + out.push(s); + } + continue; + } + PathEl::LineTo(p) => Seg::Line(p), + PathEl::QuadTo(q, p) => Seg::Cubic(last + (q - last) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p), + PathEl::CurveTo(a, b, p) => Seg::Cubic(a, b, p), + }; + last = seg.end(); + cur.get_or_insert_with(|| Sub { start: last, segs: vec![], closed: false }).segs.push(seg); + } + out.extend(cur); + out +} + +/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. +fn polygons(bp: &BezPath) -> Vec> { + let mut out: Vec> = vec![]; + let mut cur: Vec = vec![]; + let push = |p: Point, cur: &mut Vec| { + if cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + cur.push(p); + } + }; + kurbo::flatten(bp.iter(), FLATNESS, |el| match el { + PathEl::MoveTo(p) => { + out.push(std::mem::take(&mut cur)); + push(p, &mut cur); + } + PathEl::LineTo(p) => push(p, &mut cur), + PathEl::ClosePath => out.push(std::mem::take(&mut cur)), + _ => {} + }); + out.push(cur); + for poly in &mut out { + if poly.len() > 1 && poly.first().zip(poly.last()).is_some_and(|(a, b)| a.distance(*b) <= 1e-9) { + poly.pop(); + } + } + out.retain(|p| p.len() >= 2); + out +} + +/// A DXF symbol name (layer, text style) from `name`: from DXF 2000 up to 255 characters +/// without `<>/\":;?*|=`` ` ``; before, up to 31 letters, digits, `$`, `-` and `_`, in capitals. +fn symbol_name(name: &str, long: bool) -> String { + let s: String = if long { + let bad = |c: char| c.is_control() || "<>/\\\":;?*|=`".contains(c); + name.trim().chars().map(|c| if bad(c) { '_' } else { c }).take(255).collect() + } else { + name.trim().chars().map(|c| if c.is_ascii_alphanumeric() || "$-_".contains(c) { c.to_ascii_uppercase() } else { '_' }).take(31).collect() + }; + s.trim().to_string() +} + +/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters. +fn unique(base: &str, taken: &[&str], max: usize) -> String { + let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() }; + let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); + if free(&base) { + return base; + } + (2..) + .map(|i| { + let suffix = format!("_{i}"); + let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect(); + format!("{keep}{suffix}") + }) + .find(|n| free(n)) + .unwrap_or(base) +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// The layer flags art inherits from the layers around it. +#[derive(Clone, Copy)] +struct LayerFlags { + off: bool, + locked: bool, + plot: bool, +} + +pub(crate) struct Scene<'a> { + doc: &'a Document, + o: &'a DxfOptions, + /// Document space → drawing units (y up, origin at the region's bottom-left). + to_dxf: Affine, + /// Drawing units per point. + k: f64, + /// The entities written so far. + e: Codes, + next: u32, + layers: Vec, + /// The DXF layer art goes on, and its flags. + layer: String, + flags: LayerFlags, + linetypes: Vec, + /// (style name, font family). + styles: Vec<(String, String)>, + images: Vec, + files: Vec, + /// Image keys → index into `images` (`None`: left out). + by_key: HashMap>, + warnings: Vec, + brushes: Option>, + extents: Option<[f64; 4]>, + /// The opacity of the containers around the current object. + opacity: f32, + /// Symbols and brush art being written inside one another. + nest: u32, +} + +impl<'a> Scene<'a> { + pub fn new(doc: &'a Document, o: &'a DxfOptions) -> Self { + let k = o.scale / o.unit.points(); + let r = o.region; + Self { + doc, + o, + to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]), + k, + e: Codes::new(o.version), + next: fixed::FIRST_FREE, + layers: vec![], + layer: "0".into(), + flags: LayerFlags { off: false, locked: false, plot: true }, + linetypes: vec![], + styles: vec![], + images: vec![], + files: vec![], + by_key: HashMap::new(), + warnings: vec![], + brushes: None, + extents: None, + opacity: 1.0, + nest: 0, + } + } + + pub fn run(mut self) -> DxfOutput { + if self.o.colors == ColorDepth::True && !self.o.version.true_color() { + self.warn(NO_TRUE_COLOR); + } + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + let r = self.o.region; + let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k]; + let one = (self.o.scale - 1.0).abs() < 1e-12; + let insunits = match self.o.unit { + Unit::Inches if one => 1, + Unit::Feet | Unit::FeetInches if one => 2, + Unit::Millimeters if one => 4, + Unit::Centimeters if one => 5, + Unit::Meters if one => 6, + Unit::Yards if one => 10, + _ => 0, + }; + let drawing = Drawing { + version: self.o.version, + layers: self.layers, + linetypes: self.linetypes, + styles: self.styles, + images: self.images, + entities: self.e.into_string(), + extents: self.extents.unwrap_or(limits), + limits, + insunits, + metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), + next: self.next, + }; + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn handle(&mut self) -> Handle { + let h = Handle(self.next); + self.next = self.next.saturating_add(1); + h + } + + /// A document point in drawing units, counted in the extents. + fn pt(&mut self, p: Point) -> Point { + let q = self.to_dxf * p; + let e = self.extents.get_or_insert([q.x, q.y, q.x, q.y]); + *e = [e[0].min(q.x), e[1].min(q.y), e[2].max(q.x), e[3].max(q.y)]; + q + } + + fn node(&mut self, n: &Node, force: bool) { + if let NodeKind::Layer { template: true, .. } = n.kind { + return; + } + // Hidden layers are written, switched off. + let layer = n.is_layer(); + if !force && !n.visible && !layer { + return; + } + if self.o.crop && !layer { + match n.visual_bounds() { + Some(b) if !overlaps(b, self.o.region) => return, + None if !n.is_container() => return, + _ => {} + } + } + self.note_losses(n); + let opacity = self.opacity; + self.opacity *= n.opacity.clamp(0.0, 1.0); + match &n.kind { + NodeKind::Layer { children, printable, clip, .. } => { + let outer = (self.layer.clone(), self.flags); + self.flags = + LayerFlags { off: self.flags.off || !n.visible, locked: self.flags.locked || n.locked, plot: self.flags.plot && *printable }; + self.layer = self.add_layer(n); + self.children(children, *clip); + (self.layer, self.flags) = outer; + } + NodeKind::Group { children, clip } => self.children(children, *clip), + NodeKind::Path { path, rule, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath(), *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp, *rule); + } + NodeKind::Text(t) => self.text(n, t), + NodeKind::Image(im) => self.image(im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + } + // Live blends, envelopes, meshes and repeats are written as their evaluated art. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + let g = vectorcraft_doc::live::expand_deep(n, None); + for c in g.children().into_iter().flatten() { + self.node(c, false); + } + } + } + self.opacity = opacity; + } + + /// A container's children; a clipping container's first child is its clipping path, which + /// paints its fills below the others and its strokes over them. + fn children(&mut self, children: &[std::sync::Arc], clip: bool) { + let Some((first, rest)) = children.split_first().filter(|_| clip) else { + children.iter().for_each(|c| self.node(c, false)); + return; + }; + self.warn(CLIPPING); + let paint = first.clip_paint(); + if let Some(f) = &paint.fill { + self.node(f, false); + } + rest.iter().for_each(|c| self.node(c, false)); + if let Some(s) = &paint.stroke { + self.node(s, false); + } + } + + /// Warn about what DXF can't hold in `n`'s look. + fn note_losses(&mut self, n: &Node) { + if n.blend != vectorcraft_color::BlendMode::Normal { + self.warn("blending modes are left out"); + } + if n.mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn("opacity masks are left out"); + } + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn("raster effects (shadows, glows, blurs, feathering) are left out"); + } + } + + /// A DXF layer for document layer `n`, with a name no other layer has. + fn add_layer(&mut self, n: &Node) -> String { + let long = self.o.version.r2000(); + let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long); + let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect(); + taken.extend(["0", "Defpoints"]); + let name = unique(&base, &taken, if long { 255 } else { 31 }); + let aci = match n.kind { + NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256), + _ => 7, + }; + let f = self.flags; + self.layers.push(Layer { name: name.clone(), aci, off: f.off, locked: f.locked, plot: f.plot }); + name + } + + /// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's + /// first solid fill. + fn paint_rgb(&mut self, p: &Paint) -> Option<[u8; 3]> { + let rgb = |c: &vectorcraft_color::Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }; + match p { + Paint::None => None, + Paint::Solid { color, .. } => Some(rgb(color)), + Paint::Gradient(g) => { + self.warn("gradients are written as one colour, the average of their stops"); + let stops = &g.gradient.stops; + let n = stops.len().max(1) as f64; + let sum = stops.iter().map(|s| rgb(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i]))); + Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8)) + } + Paint::Pattern { pattern, .. } => { + self.warn("pattern fills are written as one colour"); + let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a))); + Some(first.map_or([128; 3], |c| rgb(&c))) + } + } + } + + /// The entity style of colour `rgb` at `opacity` (times the containers'), with an optional + /// stroke weight (points) and linetype. + fn style(&mut self, rgb: [u8; 3], opacity: f32, weight: Option, linetype: Option) -> EntityStyle { + let v = self.o.version; + let opacity = (self.opacity * opacity).clamp(0.0, 1.0); + let alpha = if opacity >= 0.998 { + None + } else if v.true_color() { + Some((opacity * 255.0).round() as u8) + } else { + self.warn(NO_TRANSPARENCY); + None + }; + EntityStyle { + aci: nearest_aci(rgb, self.o.colors), + rgb: (self.o.colors == ColorDepth::True).then(|| u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]])), + lineweight: weight.filter(|_| v.r2000()).map(|w| self.lineweight(w)), + linetype, + alpha, + } + } + + /// The DXF lineweight nearest to `width` points (scaled with the drawing when asked). + fn lineweight(&mut self, width: f64) -> i32 { + let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; + let hundredths = width * 2540.0 / 72.0 * scale; + let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1]; + if hundredths > f64::from(max) + 5.0 { + self.warn(WIDE_LINES); + } + LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max) + } + + /// A linetype for dash pattern `d` (one per distinct pattern). + fn linetype(&mut self, d: &Dash) -> String { + let mut lengths: Vec = d.pattern.iter().map(|v| v * self.k).collect(); + if lengths.len() % 2 == 1 { + lengths.extend(lengths.clone()); + } + let pattern: Vec = lengths.iter().enumerate().map(|(i, v)| if i % 2 == 0 { *v } else { -v }).collect(); + let same = |l: &LineType| l.pattern.len() == pattern.len() && l.pattern.iter().zip(&pattern).all(|(a, b)| (a - b).abs() < 1e-9); + if let Some(l) = self.linetypes.iter().find(|l| same(l)) { + return l.name.clone(); + } + let name = format!("DASHED{}", self.linetypes.len() + 1); + self.linetypes.push(LineType { name: name.clone(), pattern }); + name + } + + /// A path's fills and strokes, in paint order. + fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) { + if bp.elements().is_empty() { + return; + } + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(f) if f.visible => { + if let Some(rgb) = self.paint_rgb(&f.paint) { + let style = self.style(rgb, f.opacity, None, None); + self.area(bp, rule, &style, false); + } + } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st), + _ => {} + } + } + } + + fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer) { + let appearance = self.o.preserve == Preserve::Appearance; + if appearance + && st.brush.is_some() + && self.nest < MAX_NEST + && let Some(art) = self.brush_art(bp, st) + { + let opacity = self.opacity; + self.opacity *= st.opacity.clamp(0.0, 1.0); + self.nest += 1; + art.iter().for_each(|piece| self.node(piece, true)); + self.nest -= 1; + self.opacity = opacity; + return; + } + let Some(rgb) = self.paint_rgb(&st.paint) else { return }; + let plain = stroke::is_plain(st); + if self.o.alter_paths || (appearance && !plain) { + let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st); + let style = self.style(rgb, st.opacity, None, None); + self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true); + return; + } + if !plain { + self.warn(STROKE_EXTRAS); + } + let linetype = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| self.linetype(d)); + let style = self.style(rgb, st.opacity, Some(st.width), linetype); + self.lines(bp, &style, st.width); + } + + fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option> { + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; + Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + } + + /// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so + /// the hatch's even-odd rule reads it as it is. + fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { + if !self.o.version.handles() { + self.warn(NO_FILLS); + self.lines(bp, style, 0.0); + return; + } + let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1; + let normalized = (rule == FillRule::NonZero && several && !clean) + .then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok()) + .flatten() + .map(|p| p.to_bezpath()); + let loops: Vec> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect(); + if loops.is_empty() { + return; + } + let h = self.handle(); + let layer = self.layer.clone(); + self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch"); + self.e.xyz(10, 0.0, 0.0); + self.e.normal(); + self.e.raw(2, "SOLID"); + self.e.int(70, 1); + self.e.int(71, 0); + self.e.int(91, loops.len() as i64); + for l in &loops { + // An external polyline boundary, closed, without bulges. + self.e.int(92, 3); + self.e.int(72, 0); + self.e.int(73, 1); + self.e.int(93, l.len() as i64); + for p in l { + let q = self.pt(*p); + self.e.xy(10, q.x, q.y); + } + self.e.int(97, 0); + } + self.e.int(75, 0); + self.e.int(76, 1); + self.e.int(98, 0); + } + + /// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened), + /// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives + /// polylines as a width. + fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { + let v = self.o.version; + let width = (!v.r2000() && width > 0.0).then_some(width * self.k); + for sub in subpaths(bp) { + if sub.segs.is_empty() { + continue; + } + if sub.segs.iter().all(|s| matches!(s, Seg::Line(_))) { + let mut pts: Vec = std::iter::once(sub.start).chain(sub.segs.iter().map(|s| s.end())).collect(); + if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { + pts.pop(); + } + self.polyline(&pts, sub.closed, style, width); + } else if v.handles() { + self.spline(&sub, style); + } else { + let mut path = BezPath::new(); + path.move_to(sub.start); + for s in &sub.segs { + match *s { + Seg::Line(p) => path.line_to(p), + Seg::Cubic(a, b, p) => path.curve_to(a, b, p), + } + } + for pts in polygons(&path) { + self.polyline(&pts, sub.closed, style, width); + } + } + } + } + + /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a + /// vertex entity per point. + fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { + if pts.len() < 2 { + return; + } + let pts: Vec = pts.iter().map(|p| self.pt(*p)).collect(); + let layer = self.layer.clone(); + let h = self.handle(); + let flags = i64::from(closed); + if self.o.version.lwpolyline() { + self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline"); + self.e.int(90, pts.len() as i64); + self.e.int(70, flags); + if let Some(w) = width { + self.e.num(43, w); + } + for p in &pts { + self.e.xy(10, p.x, p.y); + } + return; + } + self.e.entity("POLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDb2dPolyline"); + self.e.int(66, 1); + self.e.xyz(10, 0.0, 0.0); + self.e.int(70, flags); + if let Some(w) = width { + self.e.num(40, w); + self.e.num(41, w); + } + for p in &pts { + let vh = self.handle(); + self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex"); + if self.o.version.handles() { + self.e.raw(100, "AcDb2dVertex"); + } + self.e.xyz(10, p.x, p.y); + } + let end = self.handle(); + self.e.entity("SEQEND", end, h, &layer, style, ""); + } + + /// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it + /// passes through every anchor (straight segments become straight cubics). + fn spline(&mut self, sub: &Sub, style: &EntityStyle) { + let end = sub.segs.last().map_or(sub.start, |s| s.end()); + let closing = (sub.closed && end.distance(sub.start) > 1e-9).then_some(Seg::Line(sub.start)); + let mut ctrl = vec![sub.start]; + let mut last = sub.start; + for seg in sub.segs.iter().copied().chain(closing) { + match seg { + Seg::Line(p) => ctrl.extend([last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p]), + Seg::Cubic(a, b, p) => ctrl.extend([a, b, p]), + } + last = seg.end(); + } + let n = (ctrl.len() - 1) / 3; + let mut knots = vec![0.0; 4]; + for i in 1..n { + knots.extend([i as f64; 3]); + } + knots.extend([n as f64; 4]); + let ctrl: Vec = ctrl.iter().map(|p| self.pt(*p)).collect(); + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline"); + self.e.normal(); + // Planar. + self.e.int(70, 8); + self.e.int(71, 3); + self.e.int(72, knots.len() as i64); + self.e.int(73, ctrl.len() as i64); + self.e.int(74, 0); + for k in knots { + self.e.num(40, k); + } + for p in ctrl { + self.e.xyz(10, p.x, p.y); + } + } + + /// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text + /// entity per run of each line. + fn text(&mut self, n: &Node, t: &TextObject) { + let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. }); + if outline { + if let Some(o) = vectorcraft_effects::outline_text(n) { + self.node(&o, true); + } + return; + } + if !n.appearance.items.is_empty() { + self.warn(TYPE_ITEMS); + } + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let plain = t.plain_text(); + let xf = self.to_dxf * t.xf; + let [a, b, c, d, _, _] = xf.as_coeffs(); + // Along and across the baseline, in drawing units per text-space unit. + let along = a.hypot(b); + if along <= 1e-12 { + return; + } + let across = (a * d - b * c).abs() / along; + let angle = b.atan2(a).to_degrees(); + for line in &layout.lines { + let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue }; + for run in glyphs.chunk_by(|x, y| x.run == y.run) { + let Some(first) = run.first() else { continue }; + let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); + let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); + let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue }; + let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue }; + let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill }; + let Some(rgb) = self.paint_rgb(paint) else { continue }; + let style = self.style(rgb, 1.0, None, None); + let text_style = self.text_style(&cs.font_family); + let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across; + let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 }; + let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin); + let p = self.pt(t.xf * origin); + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("TEXT", h, fixed::MODEL_RECORD, &layer, &style, "AcDbText"); + self.e.xyz(10, p.x, p.y); + self.e.num(40, height); + let s: String = s.chars().take(MAX_TEXT).collect(); + self.e.str(1, &s); + if angle.abs() > 1e-9 { + self.e.num(50, angle); + } + if (width - 1.0).abs() > 1e-9 { + self.e.num(41, width); + } + self.e.str(7, &text_style); + if self.o.version.handles() { + self.e.raw(100, "AcDbText"); + } + } + } + } + + /// The text style for font `family` (one per family). + fn text_style(&mut self, family: &str) -> String { + if let Some((name, _)) = self.styles.iter().find(|(_, f)| f == family) { + return name.clone(); + } + let long = self.o.version.r2000(); + let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect(); + taken.push(crate::writer::STANDARD_STYLE); + let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 }); + self.styles.push((name.clone(), family.to_string())); + name + } + + /// A placed image, linked to its file (written once per image). + fn image(&mut self, im: &ImageObject) { + if !self.o.version.handles() { + self.warn(NO_IMAGES); + return; + } + let Some(i) = self.image_def(&im.key) else { return }; + let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; + let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); + let map = self.to_dxf * im.xf; + let lin = |v: kurbo::Vec2| (map * v.to_point()) - (map * Point::ZERO); + // Insertion at the bottom-left corner; u along one file pixel, v one pixel up. + let u = lin(kurbo::Vec2::new(w / dw, 0.0)); + let v = lin(kurbo::Vec2::new(0.0, -h / dh)); + for corner in [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)] { + self.pt(im.xf * corner); + } + let at = map * Point::new(0.0, h); + let handle = self.handle(); + let reactor = self.handle(); + let Some(def) = self.images.get_mut(i) else { return }; + def.reactors.push((reactor, handle)); + let def = def.handle; + let layer = self.layer.clone(); + let style = EntityStyle { aci: 7, ..EntityStyle::default() }; + self.e.entity("IMAGE", handle, fixed::MODEL_RECORD, &layer, &style, "AcDbRasterImage"); + self.e.int(90, 0); + self.e.xyz(10, at.x, at.y); + self.e.xyz(11, u.x, u.y); + self.e.xyz(12, v.x, v.y); + self.e.xy(13, dw, dh); + self.e.handle(340, def); + // Shown, also when not aligned with the screen; PNG with its transparency. + self.e.int(70, if self.o.raster == RasterFormat::Png { 11 } else { 3 }); + self.e.int(280, 0); + self.e.int(281, 50); + self.e.int(282, 50); + self.e.int(283, 0); + self.e.handle(360, reactor); + self.e.int(71, 1); + self.e.int(91, 2); + self.e.xy(14, -0.5, -0.5); + self.e.xy(14, dw - 0.5, dh - 0.5); + if self.o.version >= crate::DxfVersion::R2010 { + self.e.int(290, 0); + } + } + + /// The image file for image `key`, encoded once in the chosen format (`None`: left out). + fn image_def(&mut self, key: &str) -> Option { + if let Some(i) = self.by_key.get(key) { + return *i; + } + let made = self.encode_image(key); + let i = match made { + Ok((file, size)) => { + let handle = self.handle(); + self.images.push(ImageDef { name: file.name.clone(), size, handle, reactors: vec![] }); + self.files.push(file); + Some(self.images.len() - 1) + } + Err(e) => { + self.warn(&e); + None + } + }; + self.by_key.insert(key.to_string(), i); + i + } + + /// Image `key` as a file in the chosen format, and its size in pixels. + fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> { + let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?; + let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?; + let size = (img.width(), img.height()); + let mut bytes = vec![]; + let written = match self.o.raster { + RasterFormat::Png => img.to_rgba8().write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png), + RasterFormat::Jpeg => { + // No alpha in JPEG: transparent pixels over white. + let mut rgba = img.to_rgba8(); + for p in rgba.pixels_mut() { + let a = u32::from(p[3]); + for c in 0..3 { + p[c] = ((u32::from(p[c]) * a + 255 * (255 - a)) / 255) as u8; + } + } + let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90)) + } + }; + written.map_err(|e| format!("image '{key}' could not be written: {e}"))?; + Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size)) + } +} + +/// The first visible solid fill colour in `n` (pattern art). +fn first_solid(n: &Node) -> Option { + let own = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible => f.paint.color(), + _ => None, + }); + own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) +} diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs new file mode 100644 index 00000000..879fb187 --- /dev/null +++ b/crates/cad/src/tests.rs @@ -0,0 +1,364 @@ +//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; + +use crate::*; + +type Pairs = Vec<(i32, String)>; + +/// The `(code, value)` pairs of a DXF file. +fn pairs(dxf: &[u8]) -> Pairs { + let text = std::str::from_utf8(dxf).expect("UTF-8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs"); + lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect() +} + +/// The objects of section `name`: each the pairs from its `0` code on. +fn section(p: &[(i32, String)], name: &str) -> Vec { + let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())); + let Some(start) = start else { return vec![] }; + let mut out: Vec = vec![]; + for pair in &p[start + 2..] { + if *pair == (0, "ENDSEC".into()) { + break; + } + if pair.0 == 0 { + out.push(vec![]); + } + if let Some(o) = out.last_mut() { + o.push(pair.clone()); + } + } + out +} + +fn kind(o: &[(i32, String)]) -> &str { + o.first().map_or("", |p| p.1.as_str()) +} + +fn get(o: &[(i32, String)], code: i32) -> Option<&str> { + o.iter().find(|p| p.0 == code).map(|p| p.1.as_str()) +} + +fn all(o: &[(i32, String)], code: i32) -> Vec { + o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect() +} + +fn header(p: &[(i32, String)], var: &str) -> Option { + let i = p.iter().position(|x| *x == (9, var.into()))?; + p.get(i + 1).map(|x| x.1.clone()) +} + +fn entities(out: &DxfOutput) -> Vec { + section(&pairs(&out.bytes), "ENTITIES") +} + +fn kinds(out: &DxfOutput) -> Vec { + entities(out).iter().map(|e| kind(e).to_string()).collect() +} + +fn table(out: &DxfOutput, kind_: &str) -> Vec { + section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect() +} + +/// A 200 × 100 pt document whose first layer holds what `nodes` makes. +fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 100.0); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width)) +} + +/// The options with the first artboard as the region, in points. +fn opts(d: &Document) -> DxfOptions { + DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() } +} + +/// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse. +fn shapes_doc() -> Document { + doc_with(|d| { + vec![ + path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0), + path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0), + ] + }) +} + +#[test] +fn every_version_declares_its_acadver_and_is_well_formed() { + let d = shapes_doc(); + for v in DxfVersion::ALL { + let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}"); + assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}"); + let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count(); + assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections"); + assert!(!entities(&out).is_empty(), "{v:?}"); + if v >= DxfVersion::R13 { + // Every handle is unique and below $HANDSEED. + let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..]; + let handles: Vec = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect(); + let unique: std::collections::HashSet<_> = handles.iter().collect(); + assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles"); + let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap(); + assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed"); + assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary"); + } else { + assert!(header(&p, "$HANDSEED").is_none()); + } + } + assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013)); + assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12)); + assert_eq!(DxfVersion::from_id("2019"), None); +} + +#[test] +fn lines_are_lwpolylines_curves_splines_and_fills_hatches() { + let d = shapes_doc(); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke"); + let e = entities(&out); + // The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90. + let poly = &e[1]; + assert_eq!(get(poly, 70), Some("1"), "closed"); + let mut xs = all(poly, 10); + xs.sort_by(f64::total_cmp); + assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]); + let mut ys = all(poly, 20); + ys.sort_by(f64::total_cmp); + assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]); + let hatch = &e[0]; + assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4"))); + // The ellipse: four cubics through clamped knots. + let spline = &e[2]; + assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13"))); + assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]); + // R14 has lightweight polylines but no lineweights: the stroke is a polyline width. + let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap(); + assert_eq!(get(&entities(&r14)[1], 43), Some("1")); + assert_eq!(get(&entities(&r14)[1], 370), None); + // R13 has splines and hatches, and polylines of vertices. + let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]); + // R12: polylines only; the fill is its outline, the ellipse flattened. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); + let k = kinds(&r12); + assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}"); + assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3); + assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened"); + assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings); +} + +#[test] +fn colours_map_to_the_index_palette_or_true_colour() { + let d = shapes_doc(); + let style = |o: &DxfOutput, i: usize| { + let e = &entities(o)[i]; + (get(e, 62).map(str::to_string), get(e, 420).map(str::to_string)) + }; + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000"); + assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7"); + assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into()))); + for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] { + let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap(); + assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}"); + } + let orange = doc_with(|d| { + vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)] + }); + let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string(); + assert_eq!( + (aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)), + ("30".into(), "30".into(), "2".into()), + "8 colours: yellow is nearest" + ); + // True colour needs 2004: earlier versions fall back to indices, and say so. + let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap(); + assert_eq!(style(&old, 0), (Some("1".into()), None)); + assert!(old.warnings.iter().any(|w| w.contains("true colour"))); +} + +#[test] +fn units_scale_coordinates_and_set_insunits() { + let d = shapes_doc(); + let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS"); + let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max); + let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap(); + assert_eq!(insunits(&mm).as_deref(), Some("4")); + assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5); + assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1")); + let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap(); + assert_eq!(insunits(&inches).as_deref(), Some("1")); + // 1 in = 10 units: unitless, coordinates ten times larger. + let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap(); + assert_eq!(insunits(&scaled).as_deref(), Some("0")); + assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5); + // Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked. + assert_eq!(get(&entities(&scaled)[1], 370), Some("35")); + let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap(); + assert_eq!(get(&entities(&heavy)[1], 370), Some("70")); + assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err()); + assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); +} + +#[test] +fn layers_become_dxf_layers() { + let mut d = shapes_doc(); + let second = d.add_layer(Some("Dims: ")); + let hidden = d.add_layer(Some("Notes")); + let template = d.add_layer(Some("Tracing")); + for l in [second, hidden, template] { + let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0); + d.insert(Some(l), 0, n).unwrap(); + } + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) { + *template = true; + } + let out = export(&d, &opts(&d)).unwrap(); + let layers: Vec<(String, String)> = + table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect(); + let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect(); + assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out"); + assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off"); + let on: Vec = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect(); + assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]); + // Before 2000: capitals, letters, digits and $-_ only. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); + let names: Vec = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect(); + assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]); +} + +#[test] +fn dashes_become_linetypes_and_opacity_transparency() { + let d = doc_with(|d| { + let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }); + } + n.opacity = 0.5; + vec![n] + }); + let out = export(&d, &opts(&d)).unwrap(); + let e = &entities(&out)[0]; + let alpha = (0x0200_0000 | 128).to_string(); + assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str()))); + let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap(); + assert_eq!(all(<, 49), [6.0, -3.0]); + let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap(); + assert!(get(&entities(&old)[0], 440).is_none()); + assert!(old.warnings.iter().any(|w| w.contains("transparency"))); +} + +#[test] +fn alter_paths_writes_strokes_as_filled_outlines() { + let d = shapes_doc(); + let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]); + // The rectangle's stroke: an outer and an inner boundary. + assert_eq!(get(&entities(&out)[1], 91), Some("2")); + // Preserve Appearance outlines only the strokes a line can't draw. + let mut d = shapes_doc(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) { + st.align = vectorcraft_doc::StrokeAlign::Outside; + } + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]); + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]); + assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment"))); +} + +fn text_doc(text: &str, size: f64) -> Document { + doc_with(|d| { + let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() }; + let t = TextObject::point(Point::new(10.0, 50.0), text, style); + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))] + }) +} + +#[test] +fn type_is_text_or_outlines() { + let d = text_doc("Plan A", 20.0); + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&editable); + assert_eq!(e.len(), 1); + assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A"))); + assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type"); + assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up"); + let family = CharStyle::default().font_family; + let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap(); + assert_eq!(get(&e[0], 7), get(&style, 2)); + // Outline Text, and Preserve Appearance: the glyphs as filled outlines. + for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] { + let k = kinds(&export(&d, &o).unwrap()); + assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}"); + } +} + +#[test] +fn text_outside_ascii_is_escaped_before_2007() { + let d = text_doc("Größe", 12.0); + let text = |v| { + let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + get(&entities(&out)[0], 1).unwrap().to_string() + }; + assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe"); + assert_eq!(text(DxfVersion::R2018), "Größe"); +} + +#[test] +fn images_link_png_or_jpeg_files() { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = doc_with(|d| { + let im = ImageObject { key: "raster-1".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None }; + vec![Node::new(d.alloc_id(), NodeKind::Image(im))] + }); + d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + let out = export(&d, &opts(&d)).unwrap(); + let e = entities(&out); + assert_eq!(kind(&e[0]), "IMAGE"); + // The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units. + assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0)); + assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0)); + assert_eq!(out.images.len(), 1); + assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG")); + let p = pairs(&out.bytes); + let objects = section(&p, "OBJECTS"); + let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap(); + assert_eq!(get(def, 1), Some(out.images[0].name.as_str())); + assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition"); + assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR")); + assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); + let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap(); + assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8])); + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); + assert!(entities(&r12).is_empty() && r12.images.is_empty()); + assert!(r12.warnings.iter().any(|w| w.contains("no images"))); +} + +#[test] +fn crop_leaves_out_art_outside_the_region() { + let d = shapes_doc(); + // Only the ellipse (120..180) reaches into 115..200. + let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) }; + assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]); + assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3); +} diff --git a/crates/cad/src/writer.rs b/crates/cad/src/writer.rs new file mode 100644 index 00000000..2fe4ec39 --- /dev/null +++ b/crates/cad/src/writer.rs @@ -0,0 +1,661 @@ +//! The ASCII DXF file: group codes, handles, and the sections, tables and objects around the +//! entities. DXF R13 and later give every object a handle and subclass markers; R2000 and later +//! add layouts and plot styles, which CAD apps expect to find. + +use std::fmt::Write as _; + +use crate::DxfVersion; + +/// An object handle (written in hex). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Handle(pub u32); + +/// The handles of the fixed objects every drawing has. Entities and table entries count up from +/// [`FIRST_FREE`]. +pub(crate) mod fixed { + use super::Handle; + pub const LAYER_TABLE: Handle = Handle(0x1); + pub const LTYPE_TABLE: Handle = Handle(0x2); + pub const APPID_TABLE: Handle = Handle(0x3); + pub const DIMSTYLE_TABLE: Handle = Handle(0x4); + pub const STYLE_TABLE: Handle = Handle(0x5); + pub const UCS_TABLE: Handle = Handle(0x6); + pub const VIEW_TABLE: Handle = Handle(0x7); + pub const VPORT_TABLE: Handle = Handle(0x8); + pub const BLOCK_RECORD_TABLE: Handle = Handle(0x9); + pub const ROOT: Handle = Handle(0xA); + pub const GROUPS: Handle = Handle(0xB); + pub const LAYOUTS: Handle = Handle(0xC); + pub const MODEL_RECORD: Handle = Handle(0xD); + pub const PAPER_RECORD: Handle = Handle(0xE); + pub const MODEL_BLOCK: Handle = Handle(0xF); + pub const MODEL_END: Handle = Handle(0x10); + pub const PAPER_BLOCK: Handle = Handle(0x11); + pub const PAPER_END: Handle = Handle(0x12); + pub const MODEL_LAYOUT: Handle = Handle(0x13); + pub const PAPER_LAYOUT: Handle = Handle(0x14); + pub const ACTIVE_VPORT: Handle = Handle(0x15); + pub const ACAD_APPID: Handle = Handle(0x16); + pub const STANDARD_DIMSTYLE: Handle = Handle(0x17); + pub const IMAGE_DICT: Handle = Handle(0x18); + pub const IMAGE_VARS: Handle = Handle(0x19); + pub const PLOT_STYLES: Handle = Handle(0x1A); + pub const PLOT_NORMAL: Handle = Handle(0x1B); + pub const FIRST_FREE: u32 = 0x30; +} + +/// A number as DXF writes it: up to 6 decimals, no trailing zeros, 0 for anything not finite. +pub(crate) fn num(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.into() } +} + +/// Text as a DXF string value: one line, `^` escaped, and before DXF 2007 (which reads UTF-8) +/// every character outside ASCII as `\U+XXXX`. +pub(crate) fn text(s: &str, utf8: bool) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\r' | '\n' | '\t' => out.push(' '), + c if c.is_control() => {} + '^' => out.push_str("^ "), + c if !utf8 && !c.is_ascii() => match u16::try_from(u32::from(c)) { + Ok(u) => _ = write!(out, "\\U+{u:04X}"), + Err(_) => out.push('?'), + }, + c => out.push(c), + } + } + out +} + +/// Group-code/value pairs in the order they are written. +pub(crate) struct Codes { + pub v: DxfVersion, + out: String, +} + +impl Codes { + pub fn new(v: DxfVersion) -> Self { + Self { v, out: String::new() } + } + + pub fn into_string(self) -> String { + self.out + } + + pub fn append(&mut self, other: &str) { + self.out.push_str(other); + } + + /// A value written as it is (names and keywords the writer itself chose). + pub fn raw(&mut self, code: u16, value: &str) { + _ = writeln!(self.out, "{code:>3}\n{value}"); + } + + /// Text from the document, encoded for the version (see [`text`]). + pub fn str(&mut self, code: u16, value: &str) { + let t = text(value, self.v.utf8()); + self.raw(code, &t); + } + + pub fn int(&mut self, code: u16, value: i64) { + _ = writeln!(self.out, "{code:>3}\n{value}"); + } + + pub fn num(&mut self, code: u16, value: f64) { + _ = writeln!(self.out, "{code:>3}\n{}", num(value)); + } + + pub fn handle(&mut self, code: u16, h: Handle) { + _ = writeln!(self.out, "{code:>3}\n{:X}", h.0); + } + + /// A 2D point at `code` (x), `code + 10` (y). + pub fn xy(&mut self, code: u16, x: f64, y: f64) { + self.num(code, x); + self.num(code + 10, y); + } + + /// A 3D point at `code`, `code + 10`, `code + 20` (z = 0). + pub fn xyz(&mut self, code: u16, x: f64, y: f64) { + self.xy(code, x, y); + self.num(code + 20, 0.0); + } + + /// The extrusion direction of planar entities: the z axis. + pub fn normal(&mut self) { + self.num(210, 0.0); + self.num(220, 0.0); + self.num(230, 1.0); + } + + /// The start of an object: its type, and from R13 its handle, owner and first subclass. + fn object(&mut self, kind: &str, h: Handle, owner: Handle, subclass: &str) { + self.raw(0, kind); + if self.v.handles() { + self.handle(if kind == "DIMSTYLE" { 105 } else { 5 }, h); + self.handle(330, owner); + self.raw(100, subclass); + } + } + + /// A table entry: the shared record subclass, then the table's own (from R13). + fn record(&mut self, kind: &str, h: Handle, table: Handle, subclass: &str) { + self.object(kind, h, table, "AcDbSymbolTableRecord"); + if self.v.handles() { + self.raw(100, subclass); + } + } + + fn section(&mut self, name: &str) { + self.raw(0, "SECTION"); + self.raw(2, name); + } + + fn end_section(&mut self) { + self.raw(0, "ENDSEC"); + } + + fn table(&mut self, name: &str, h: Handle, count: usize) { + self.raw(0, "TABLE"); + self.raw(2, name); + if self.v.handles() { + self.handle(5, h); + self.raw(330, "0"); + self.raw(100, "AcDbSymbolTable"); + } + self.int(70, count as i64); + if name == "DIMSTYLE" && self.v.r2000() { + self.raw(100, "AcDbDimStyleTable"); + } + } + + fn end_table(&mut self) { + self.raw(0, "ENDTAB"); + } +} + +/// How an entity is drawn: its colour index, true colour, lineweight, linetype and transparency. +#[derive(Clone, Debug, Default)] +pub(crate) struct EntityStyle { + pub aci: u8, + /// `0xRRGGBB` (DXF 2004 and later). + pub rgb: Option, + /// Hundredths of a millimetre (DXF 2000 and later). + pub lineweight: Option, + pub linetype: Option, + /// Opacity 0–255 (DXF 2004 and later; opaque is left out). + pub alpha: Option, +} + +impl Codes { + /// The start of an entity of `kind` owned by `owner` (model space, or a polyline for its + /// vertices) on `layer` with its style, ending with `subclass` (none when empty). + pub fn entity(&mut self, kind: &str, h: Handle, owner: Handle, layer: &str, style: &EntityStyle, subclass: &str) { + self.object(kind, h, owner, "AcDbEntity"); + self.str(8, layer); + if let Some(lt) = &style.linetype { + self.str(6, lt); + } + self.int(62, i64::from(style.aci.max(1))); + if self.v.r2000() + && let Some(lw) = style.lineweight + { + self.int(370, i64::from(lw)); + } + if self.v.true_color() { + if let Some(rgb) = style.rgb { + self.int(420, i64::from(rgb)); + } + if let Some(a) = style.alpha { + self.int(440, 0x0200_0000 | i64::from(a)); + } + } + if self.v.handles() && !subclass.is_empty() { + self.raw(100, subclass); + } + } +} + +/// A DXF layer. +#[derive(Clone, Debug)] +pub(crate) struct Layer { + pub name: String, + pub aci: u8, + /// Switched off (a hidden layer). + pub off: bool, + pub locked: bool, + /// Plotted (printable). + pub plot: bool, +} + +/// A dash pattern: dash, gap, dash… lengths in drawing units. +#[derive(Clone, Debug)] +pub(crate) struct LineType { + pub name: String, + pub pattern: Vec, +} + +/// A placed image file and the image entities that show it. +#[derive(Clone, Debug)] +pub(crate) struct ImageDef { + pub name: String, + pub size: (u32, u32), + pub handle: Handle, + /// `(reactor, image entity)` per image entity. + pub reactors: Vec<(Handle, Handle)>, +} + +/// Everything around the entities, collected while they were written. +pub(crate) struct Drawing { + pub version: DxfVersion, + pub layers: Vec, + pub linetypes: Vec, + /// Text styles: (style name, font family). + pub styles: Vec<(String, String)>, + pub images: Vec, + /// The entities' group codes. + pub entities: String, + /// `[x0, y0, x1, y1]` of everything written (drawing units). + pub extents: [f64; 4], + /// The export region (drawing units). + pub limits: [f64; 4], + /// `$INSUNITS`. + pub insunits: u8, + pub metric: bool, + /// The next free handle. + pub next: u32, +} + +/// The text style every drawing has. +pub(crate) const STANDARD_STYLE: &str = "Standard"; + +impl Drawing { + /// The whole file. + pub fn write(self) -> String { + let v = self.version; + let mut c = Codes::new(v); + // Table entries get the handles after the entities'. + let mut next = self.next; + let mut take = |n: usize| { + let start = next; + next = next.saturating_add(u32::try_from(n).unwrap_or(u32::MAX)); + start + }; + let ltype_base = take(3 + self.linetypes.len()); + let layer_base = take(1 + self.layers.len()); + let style_base = take(1 + self.styles.len()); + self.header(&mut c, next); + if v.handles() { + self.classes(&mut c); + } + c.section("TABLES"); + self.vport(&mut c); + self.ltypes(&mut c, ltype_base); + self.layer_table(&mut c, layer_base); + self.style_table(&mut c, style_base); + if v.handles() { + for (name, h) in [("VIEW", fixed::VIEW_TABLE), ("UCS", fixed::UCS_TABLE)] { + c.table(name, h, 0); + c.end_table(); + } + c.table("APPID", fixed::APPID_TABLE, 1); + c.record("APPID", fixed::ACAD_APPID, fixed::APPID_TABLE, "AcDbRegAppTableRecord"); + c.raw(2, "ACAD"); + c.int(70, 0); + c.end_table(); + c.table("DIMSTYLE", fixed::DIMSTYLE_TABLE, 1); + c.record("DIMSTYLE", fixed::STANDARD_DIMSTYLE, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord"); + c.raw(2, "Standard"); + c.int(70, 0); + c.end_table(); + c.table("BLOCK_RECORD", fixed::BLOCK_RECORD_TABLE, 2); + for (name, h, layout) in + [("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_LAYOUT), ("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_LAYOUT)] + { + c.record("BLOCK_RECORD", h, fixed::BLOCK_RECORD_TABLE, "AcDbBlockTableRecord"); + c.raw(2, name); + if v.r2000() { + c.handle(340, layout); + c.int(70, 0); + c.int(280, 1); + c.int(281, 0); + } + } + c.end_table(); + } + c.end_section(); + if v.handles() { + c.section("BLOCKS"); + for (name, record, begin, end) in [ + ("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_BLOCK, fixed::MODEL_END), + ("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_BLOCK, fixed::PAPER_END), + ] { + c.object("BLOCK", begin, record, "AcDbEntity"); + c.raw(8, "0"); + c.raw(100, "AcDbBlockBegin"); + c.raw(2, name); + c.int(70, 0); + c.xyz(10, 0.0, 0.0); + c.raw(3, name); + c.raw(1, ""); + c.object("ENDBLK", end, record, "AcDbEntity"); + c.raw(8, "0"); + c.raw(100, "AcDbBlockEnd"); + } + c.end_section(); + } + c.section("ENTITIES"); + c.append(&self.entities); + c.end_section(); + if v.handles() { + self.objects(&mut c); + } + c.raw(0, "EOF"); + c.into_string() + } + + fn header(&self, c: &mut Codes, handseed: u32) { + let v = self.version; + let var = |c: &mut Codes, name: &str| c.raw(9, name); + c.section("HEADER"); + var(c, "$ACADVER"); + c.raw(1, v.acadver()); + var(c, "$DWGCODEPAGE"); + c.raw(3, "ANSI_1252"); + var(c, "$INSBASE"); + c.xyz(10, 0.0, 0.0); + let [x0, y0, x1, y1] = self.extents; + var(c, "$EXTMIN"); + c.xyz(10, x0, y0); + var(c, "$EXTMAX"); + c.xyz(10, x1, y1); + let [lx0, ly0, lx1, ly1] = self.limits; + var(c, "$LIMMIN"); + c.xy(10, lx0, ly0); + var(c, "$LIMMAX"); + c.xy(10, lx1, ly1); + if v.r2000() { + var(c, "$LWDISPLAY"); + c.int(290, 1); + var(c, "$INSUNITS"); + c.int(70, i64::from(self.insunits)); + var(c, "$MEASUREMENT"); + c.int(70, i64::from(self.metric)); + } + if v.handles() { + var(c, "$HANDSEED"); + c.handle(5, Handle(handseed)); + } + c.end_section(); + } + + fn classes(&self, c: &mut Codes) { + let mut classes: Vec<(&str, &str, &str, i64, bool)> = vec![]; + if self.version.r2000() { + classes.extend([ + ("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", "ObjectDBX Classes", 0, false), + ("ACDBPLACEHOLDER", "AcDbPlaceHolder", "ObjectDBX Classes", 0, false), + ("LAYOUT", "AcDbLayout", "ObjectDBX Classes", 0, false), + ]); + } + if !self.images.is_empty() { + classes.extend([ + ("RASTERVARIABLES", "AcDbRasterVariables", "ISM", 0, false), + ("IMAGE", "AcDbRasterImage", "ISM", 2175, true), + ("IMAGEDEF", "AcDbRasterImageDef", "ISM", 0, false), + ("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", "ISM", 1, false), + ]); + } + c.section("CLASSES"); + for (name, cpp, app, flags, entity) in classes { + c.raw(0, "CLASS"); + c.raw(1, name); + c.raw(2, cpp); + c.raw(3, app); + c.int(90, flags); + if self.version.true_color() { + c.int(91, 0); + } + c.int(280, 0); + c.int(281, i64::from(entity)); + } + c.end_section(); + } + + /// The active viewport, zoomed to the drawing's extents. + fn vport(&self, c: &mut Codes) { + let [x0, y0, x1, y1] = self.extents; + c.table("VPORT", fixed::VPORT_TABLE, 1); + c.record("VPORT", fixed::ACTIVE_VPORT, fixed::VPORT_TABLE, "AcDbViewportTableRecord"); + c.raw(2, if self.version.handles() { "*Active" } else { "*ACTIVE" }); + c.int(70, 0); + c.xy(10, 0.0, 0.0); + c.xy(11, 1.0, 1.0); + c.xy(12, (x0 + x1) / 2.0, (y0 + y1) / 2.0); + c.xy(13, 0.0, 0.0); + c.xy(14, 1.0, 1.0); + c.xy(15, 10.0, 10.0); + c.xyz(16, 0.0, 0.0); + c.num(36, 1.0); + c.xyz(17, 0.0, 0.0); + let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); + // The view's height, with a margin; its width follows from the aspect ratio. + c.num(40, (h.max(w / 1.5) * 1.1).max(1.0)); + c.num(41, 1.5); + c.num(42, 50.0); + c.num(43, 0.0); + c.num(44, 0.0); + c.num(50, 0.0); + c.num(51, 0.0); + for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] { + c.int(code, value); + } + if self.version.r2000() { + c.int(281, 0); + c.int(65, 0); + c.num(146, 0.0); + } + c.end_table(); + } + + fn ltypes(&self, c: &mut Codes, base: u32) { + c.table("LTYPE", fixed::LTYPE_TABLE, 3 + self.linetypes.len()); + let solid = [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")]; + let rows = solid.iter().map(|(n, d)| (*n, *d, &[][..])).chain(self.linetypes.iter().map(|l| (l.name.as_str(), "", l.pattern.as_slice()))); + for (i, (name, desc, pattern)) in rows.enumerate() { + c.record("LTYPE", Handle(base.saturating_add(i as u32)), fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord"); + c.str(2, name); + c.int(70, 0); + c.str(3, if pattern.is_empty() { desc } else { "Dashed" }); + c.int(72, 65); + c.int(73, pattern.len() as i64); + c.num(40, pattern.iter().map(|v| v.abs()).sum()); + for v in pattern { + c.num(49, *v); + if self.version.handles() { + c.int(74, 0); + } + } + } + c.end_table(); + } + + fn layer_table(&self, c: &mut Codes, base: u32) { + let zero = Layer { name: "0".into(), aci: 7, off: false, locked: false, plot: true }; + c.table("LAYER", fixed::LAYER_TABLE, 1 + self.layers.len()); + for (i, l) in std::iter::once(&zero).chain(&self.layers).enumerate() { + c.record("LAYER", Handle(base.saturating_add(i as u32)), fixed::LAYER_TABLE, "AcDbLayerTableRecord"); + c.str(2, &l.name); + c.int(70, if l.locked { 4 } else { 0 }); + // A negative colour switches the layer off. + let aci = i64::from(l.aci.max(1)); + c.int(62, if l.off { -aci } else { aci }); + c.raw(6, "Continuous"); + if self.version.r2000() { + c.int(290, i64::from(l.plot)); + c.int(370, -3); + c.handle(390, fixed::PLOT_NORMAL); + } + } + c.end_table(); + } + + fn style_table(&self, c: &mut Codes, base: u32) { + c.table("STYLE", fixed::STYLE_TABLE, 1 + self.styles.len()); + let standard = (STANDARD_STYLE.to_string(), String::new()); + for (i, (name, family)) in std::iter::once(&standard).chain(&self.styles).enumerate() { + c.record("STYLE", Handle(base.saturating_add(i as u32)), fixed::STYLE_TABLE, "AcDbTextStyleTableRecord"); + c.str(2, name); + c.int(70, 0); + c.num(40, 0.0); + c.num(41, 1.0); + c.num(50, 0.0); + c.int(71, 0); + c.num(42, 2.5); + // The font file; from 2000 the family name too, which is how CAD apps find a TrueType font. + if family.is_empty() { + c.raw(3, "txt"); + } else { + c.str(3, &format!("{family}.ttf")); + } + c.raw(4, ""); + if self.version.r2000() && !family.is_empty() { + c.raw(1001, "ACAD"); + c.str(1000, family); + c.int(1071, 0); + } + } + c.end_table(); + } + + fn objects(&self, c: &mut Codes) { + let v = self.version; + let images = !self.images.is_empty(); + c.section("OBJECTS"); + let dict = |c: &mut Codes, h: Handle, owner: Handle, entries: &[(&str, Handle)]| { + c.object("DICTIONARY", h, owner, "AcDbDictionary"); + c.int(281, 1); + for (name, e) in entries { + c.str(3, name); + c.handle(350, *e); + } + }; + let mut root = vec![("ACAD_GROUP", fixed::GROUPS)]; + if images { + root.extend([("ACAD_IMAGE_DICT", fixed::IMAGE_DICT), ("ACAD_IMAGE_VARS", fixed::IMAGE_VARS)]); + } + if v.r2000() { + root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]); + } + dict(c, fixed::ROOT, Handle(0), &root); + dict(c, fixed::GROUPS, fixed::ROOT, &[]); + if v.r2000() { + dict(c, fixed::LAYOUTS, fixed::ROOT, &[("Layout1", fixed::PAPER_LAYOUT), ("Model", fixed::MODEL_LAYOUT)]); + c.object("ACDBDICTIONARYWDFLT", fixed::PLOT_STYLES, fixed::ROOT, "AcDbDictionary"); + c.int(281, 1); + c.raw(3, "Normal"); + c.handle(350, fixed::PLOT_NORMAL); + c.raw(100, "AcDbDictionaryWithDefault"); + c.handle(340, fixed::PLOT_NORMAL); + c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder"); + self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); + self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); + } + if images { + c.object("RASTERVARIABLES", fixed::IMAGE_VARS, fixed::ROOT, "AcDbRasterVariables"); + c.int(90, 0); + c.int(70, 0); + c.int(71, 1); + c.int(72, 0); + let entries: Vec<(&str, Handle)> = self.images.iter().map(|i| (i.name.as_str(), i.handle)).collect(); + dict(c, fixed::IMAGE_DICT, fixed::ROOT, &entries); + for img in &self.images { + c.raw(0, "IMAGEDEF"); + c.handle(5, img.handle); + c.raw(102, "{ACAD_REACTORS"); + c.handle(330, fixed::IMAGE_DICT); + for (reactor, _) in &img.reactors { + c.handle(330, *reactor); + } + c.raw(102, "}"); + c.handle(330, fixed::IMAGE_DICT); + c.raw(100, "AcDbRasterImageDef"); + c.int(90, 0); + c.str(1, &img.name); + c.xy(10, f64::from(img.size.0), f64::from(img.size.1)); + c.xy(11, 1.0, 1.0); + c.int(280, 1); + c.int(281, 0); + for (reactor, image) in &img.reactors { + c.object("IMAGEDEF_REACTOR", *reactor, *image, "AcDbRasterImageDefReactor"); + c.int(90, 2); + c.handle(330, *image); + } + } + } + c.end_section(); + } + + /// A layout (DXF 2000 and later): its plot settings, then the layout itself. + fn layout(&self, c: &mut Codes, h: Handle, name: &str, tab: i64, record: Handle) { + let [x0, y0, x1, y1] = self.extents; + c.object("LAYOUT", h, fixed::LAYOUTS, "AcDbPlotSettings"); + c.raw(1, ""); + c.raw(2, "none_device"); + c.raw(4, ""); + c.raw(6, ""); + for code in 40..=49 { + c.num(code, 0.0); + } + c.num(140, 0.0); + c.num(141, 0.0); + c.num(142, 1.0); + c.num(143, 1.0); + c.int(70, 688); + c.int(72, 1); + c.int(73, 0); + c.int(74, 5); + c.raw(7, ""); + c.int(75, 16); + c.num(147, 1.0); + c.xy(148, 0.0, 0.0); + c.raw(100, "AcDbLayout"); + c.raw(1, name); + c.int(70, 1); + c.int(71, tab); + let [lx0, ly0, lx1, ly1] = self.limits; + c.xy(10, lx0, ly0); + c.xy(11, lx1, ly1); + c.xyz(12, 0.0, 0.0); + c.xyz(14, x0, y0); + c.xyz(15, x1, y1); + c.num(146, 0.0); + c.xyz(13, 0.0, 0.0); + c.xyz(16, 1.0, 0.0); + c.xyz(17, 0.0, 1.0); + c.int(76, 1); + c.handle(330, record); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_and_text_are_written_plainly() { + assert_eq!(num(1.5), "1.5"); + assert_eq!(num(-0.0000001), "0"); + assert_eq!(num(2.0), "2"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(text("a^b\nc", true), "a^ b c"); + assert_eq!(text("Größe 😀", false), "Gr\\U+00F6\\U+00DFe ?"); + assert_eq!(text("Größe", true), "Größe"); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 06901e21..98cdb135 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,6 +21,7 @@ vectorcraft-trace = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } +vectorcraft-cad = { workspace = true } image = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs new file mode 100644 index 00000000..f8d84007 --- /dev/null +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -0,0 +1,225 @@ +//! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As +//! → DXF Options): the options parsed from the params, one drawing per artboard with Use +//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_doc::{Document, Unit}; +use vectorcraft_geom::Rect; + +use super::super::*; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +pub fn specs() -> Vec { + vec![cmd!( + "document.exportDxf", + "Export DXF", + [], + None, + "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills solid hatches. Same as document.export {format: dxf}", + has_doc, + export_dxf + )] +} + +/// The DXF options `document.formats` lists. +pub(super) const OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::RANGE, + super::USE_ARTBOARDS, + FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, + FormatOption { + name: "unit", + ty: "string", + default: "\"mm\"", + description: "the art unit that `scale` drawing units stand for (pt, in, mm, cm, m, ft…)", + }, + FormatOption { name: "scale", ty: "number", default: "1", description: "drawing units per unit (1 mm = 1 unit)" }, + FormatOption { name: "scaleLineweights", ty: "boolean", default: "false", description: "lineweights grow with the scale" }, + FormatOption { + name: "colors", + ty: "string", + default: "\"true\"", + description: "8, 16 or 256 indexed colours, or true colour (DXF 2004 and later)", + }, + FormatOption { + name: "rasterFormat", + ty: "string", + default: "\"png\"", + description: "png or jpeg: the files placed images are written to, next to the drawing", + }, + FormatOption { + name: "preserve", + ty: "string", + default: "\"appearance\"", + description: "appearance (type as outlines, strokes a CAD line can't draw as filled outlines) or editability (type as text, strokes as lines)", + }, + FormatOption { + name: "alterPaths", + ty: "boolean", + default: "false", + description: "Alter Paths for Appearance: every stroke as its filled outline", + }, + FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "type as glyph outlines (always with preserve: appearance)" }, + FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" }, +]; + +/// A format that can be neither opened nor written, and what to use instead. +#[derive(Clone, Copy, Debug)] +pub struct Unsupported { + pub id: &'static str, + pub label: &'static str, + /// Lower-case extensions without the dot. + pub extensions: &'static [&'static str], + pub hint: &'static str, +} + +/// Formats people ask for that VectorCraft doesn't read or write. Append-only. +pub const UNSUPPORTED: &[Unsupported] = &[ + Unsupported { + id: "dwg", + label: "DWG", + extensions: &["dwg"], + hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open", + }, + Unsupported { + id: "pict", + label: "PICT", + extensions: &["pict", "pct", "pic"], + hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first", + }, +]; + +/// The unsupported format an id or extension (any case, leading dot allowed) names. +pub fn unsupported(id_or_ext: &str) -> Option<&'static Unsupported> { + let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); + UNSUPPORTED.iter().find(|u| u.id == k || u.extensions.contains(&k.as_str())) +} + +impl Unsupported { + pub fn to_json(&self) -> Value { + json!({ "id": self.id, "label": self.label, "extensions": self.extensions, "hint": self.hint }) + } +} + +/// The DXF params, as they come (each checked when read). +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct Params { + #[serde(flatten)] + boards: ArtboardPick, + version: Option, + unit: Option, + scale: Option, + scale_lineweights: Option, + colors: Option, + raster_format: Option, + preserve: Option, + alter_paths: Option, + outline_text: Option, +} + +/// A string or number param as text (`2018` and `"2018"` alike). +fn text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// `field` read by `read`, or an error listing what it may be. +fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, default: T, allowed: &str) -> Result { + match v { + None => Ok(default), + Some(s) => read(&s).ok_or_else(|| bad(C, format!("DXF {field} `{s}`: {allowed}"))), + } +} + +/// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. +fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { + let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; + let d = DxfOptions::default(); + let scale = q.scale.unwrap_or(d.scale); + if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { + return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); + } + let opts = DxfOptions { + version: choice( + "version", + q.version.as_ref().map(text), + DxfVersion::from_id, + d.version, + "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018", + )?, + unit: choice("unit", q.unit, Unit::named, d.unit, "a unit such as mm, cm, in or pt")?, + scale, + scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), + colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, + raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, + alter_paths: q.alter_paths.unwrap_or(d.alter_paths), + outline_text: q.outline_text.unwrap_or(d.outline_text), + ..d + }; + Ok((opts, q.boards)) +} + +/// Check the DXF options in `p` without exporting (dialogs keep themselves open on an error). +pub fn check(p: &Value) -> std::result::Result<(), String> { + parse(p).map(|_| ()).map_err(|e| e.to_string()) +} + +/// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner +/// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) +/// holding the art over it. +pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> Result { + let (opts, pick) = parse(p)?; + let n = doc.artboards.len(); + let rect = |b: usize| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))); + let drawings: Vec<(Option, Rect, bool)> = match use_artboards { + Some(true) => { + let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); + boards.into_iter().map(|b| rect(b).map(|r| (Some(b), r, true))).collect::>()? + } + _ if n == 0 => vec![(None, doc.art_bounds().unwrap_or(Rect::ZERO), false)], + _ => { + let b = match pick.resolve(n).map_err(|e| bad(C, e))?.as_deref() { + None => 0, + Some([b]) => *b, + Some(_) => return Err(bad(C, "a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard")), + }; + vec![(Some(b), rect(b)?, false)] + } + }; + let mut enc = Encoded::default(); + for (board, region, crop) in drawings { + let out = vectorcraft_cad::export(doc, &DxfOptions { region, crop, ..opts.clone() }).map_err(|e| bad(C, e))?; + enc.files.push((board, out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + for img in out.images { + if !enc.linked.iter().any(|l| l.name == img.name) { + enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) }); + } + } + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + Ok(enc) +} + +fn export_dxf(s: &mut Session, p: &Value) -> Result { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["format"] = json!("dxf"); + super::export::export(s, &q) +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 4848b37d..311c6062 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -224,9 +224,9 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result { AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type")) } -/// The `useArtboards` param of a PDF or raster export (SVG has its own): `true` writes every +/// The `useArtboards` param of a PDF, DXF or raster export (SVG has its own): `true` writes every /// chosen artboard (default all), `false` the bounds of the visible art instead, absent one -/// artboard (raster) or the chosen pages (PDF). +/// artboard (raster, DXF) or the chosen pages (PDF). fn use_artboards(p: &Value) -> Result> { match p.get("useArtboards") { None | Some(Value::Null) => Ok(None), @@ -293,7 +293,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || f.id == "pdf" { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None }; if use_artboards == Some(false) { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); @@ -312,6 +312,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { } "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), + "dxf" => return super::dxf::encode(doc, p, use_artboards), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 7f8c91bc..924fbeff 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; + +use crate::DocState; use super::super::*; use super::encode::single_artboard; @@ -48,6 +50,40 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } +/// The document an export of `st` writes: the whole document, or with `selectedOnly` just the +/// selected objects, each in its layers and groups (a clipping group keeps its clipping path). +pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result> { + if !bool_or(p, "selectedOnly", false) { + return Ok(Cow::Borrowed(&*st.doc)); + } + let keep: HashSet = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)).into_iter().collect(); + if keep.is_empty() { + return Err(bad("document.export", "select something to export (or turn off selectedOnly)")); + } + let mut d = Document::clone(&st.doc); + d.layers = st.doc.layers.iter().filter_map(|l| selected_part(l, &keep)).map(Arc::new).collect(); + Ok(Cow::Owned(d)) +} + +/// `n` with only the parts in `keep` (whole, with everything inside them), `None` when none is. +fn selected_part(n: &Node, keep: &HashSet) -> Option { + if keep.contains(&n.id) { + return Some(n.clone()); + } + let children = n.children()?; + let mut kept: Vec> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect(); + if kept.is_empty() { + return None; + } + let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) { + kept.insert(0, first.clone()); + } + let mut out = n.clone(); + *out.children_mut()? = kept; + Some(out) +} + /// `p` without its artboard choice, also inside its SVG options (for documents made of one /// synthetic artboard, and for callers that pick the artboard themselves). pub(super) fn without_artboards(p: &Value) -> Value { @@ -62,13 +98,13 @@ pub(super) fn without_artboards(p: &Value) -> Value { q } -/// The document an export of `f` writes: the active one, or for text with `selectionOnly` the -/// selected objects alone. +/// The document an export of `f` writes: the active one (see [`export_source`]), or for text +/// with `selectionOnly` the selected objects alone. fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result> { if f.id == "txt" && bool_or(p, "selectionOnly", false) { return Ok(Cow::Owned(selection(s, cmd)?.0)); } - Ok(Cow::Borrowed(&s.doc()?.doc)) + export_source(s.doc()?, p) } /// The selected objects (not on template layers) alone on one layer, with one artboard: their @@ -117,7 +153,7 @@ fn screen_format(row: &Value) -> Result { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template") { + if matches!(format.id, "vectorcraft" | "template" | "dxf") { return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf")); } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 0a73afaf..cdba10f5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -114,7 +114,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if vectorcraft_pdf::is_postscript(bytes) { return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript))); } - let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; + let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) { + Some(u) => err(format!("can't open `{}`: {}", file_name(name), u.hint)), + None => err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")), + })?; let title = file_name(name); let mut converted = false; let (mut doc, warnings, restored) = match format.id { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 1ec7fcbf..06439831 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -9,11 +9,13 @@ //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. +//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. mod batch; +pub mod dxf; mod encode; mod export; mod imagemap; @@ -28,8 +30,10 @@ mod text; use serde_json::{Value, json}; +pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; +pub use export::export_source; use load::err; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; @@ -56,7 +60,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -65,7 +69,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -93,7 +97,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…]}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", always, formats ), @@ -391,6 +395,7 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS }, Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS }, + Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: false, write: true, raster: false, options: dxf::OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -447,9 +452,11 @@ pub fn format_for_name(name: &str) -> Option<&'static Format> { /// The format to write: `format` (an id or extension), else the path's extension, else PNG. pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { + // Formats that can't be written say what to use instead. + let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string()); let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) { - (Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?, - (None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?, + (Some(f), _) => format(f).ok_or_else(|| unknown(format!("unknown format `{f}` (see document.formats)"), f))?, + (None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?, (None, None) => format("png").ok_or("no PNG encoder")?, }; if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) } @@ -467,6 +474,7 @@ fn formats(_: &mut Session, _: &Value) -> Result { "readable": ids(|f| f.read), "writable": ids(|f| f.write), "openExtensions": OPEN_EXTS, + "unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::>(), })) } @@ -645,3 +653,6 @@ mod tests_palette; #[cfg(test)] mod tests_text; + +#[cfg(test)] +mod tests_dxf; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 53f9cf15..5f35f34c 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs new file mode 100644 index 00000000..87a415ab --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -0,0 +1,193 @@ +//! DXF export through the commands: options, artboards, selection, images, and the formats that +//! can't be written (DWG, PICT). + +use serde_json::{Value, json}; + +use super::*; + +fn session(width: f64, height: f64, artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn text(v: &Value) -> String { + String::from_utf8(b64(v)).unwrap() +} + +/// The value after header variable `var`. +fn header(dxf: &str, var: &str) -> Option { + let lines: Vec<&str> = dxf.lines().collect(); + let i = lines.iter().position(|l| *l == var)?; + lines.get(i + 2).map(|s| s.to_string()) +} + +/// The entity types of a DXF, in order. +fn entities(dxf: &str) -> Vec { + let lines: Vec<&str> = dxf.lines().collect(); + let start = lines.windows(3).position(|w| w[1] == "ENTITIES" && w[0].trim() == "2").unwrap(); + let pairs: Vec<(&str, &str)> = lines[start + 2..].chunks(2).map(|p| (p[0].trim(), p[1])).collect(); + pairs.iter().take_while(|p| p.1 != "ENDSEC").filter(|p| p.0 == "0").map(|p| p.1.to_string()).collect() +} + +/// Two artboards side by side, a rectangle on each. +fn two_boards() -> Session { + let mut s = session(100.0, 100.0, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + s +} + +#[test] +fn export_writes_dxf_of_the_chosen_version() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(r["format"], "dxf"); + let dxf = text(&r); + assert_eq!(header(&dxf, "$ACADVER").as_deref(), Some("AC1032")); + assert_eq!(header(&dxf, "$INSUNITS").as_deref(), Some("4"), "millimetres by default"); + assert_eq!(entities(&dxf), ["HATCH", "HATCH"], "the whole document, origin at the first artboard"); + for (v, acadver) in [("R12", "AC1009"), ("R14", "AC1014"), ("2000", "AC1015"), ("2004", "AC1018"), ("2013", "AC1027")] { + let r = s.execute("document.exportDxf", &json!({"version": v})).unwrap(); + assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some(acadver), "{v}"); + } + // A number reads as a version too; serialize gives the same bytes. + assert_eq!(header(&text(&s.execute("document.exportDxf", &json!({"version": 2007})).unwrap()), "$ACADVER").as_deref(), Some("AC1021")); + let ser = s.execute("document.serialize", &json!({"format": "dxf", "unit": "in"})).unwrap(); + assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1")); +} + +#[test] +fn use_artboards_writes_one_drawing_per_artboard() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Untitled-1-Artboard-1.dxf", "Untitled-1-Artboard-2.dxf"]); + for f in files { + assert_eq!(entities(&text(f)), ["HATCH"], "each holds the art over its artboard"); + } + let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": true, "range": "2"})).unwrap(); + assert!(r.get("files").is_none()); + assert_eq!(entities(&text(&r)), ["HATCH"]); + // Without artboards: the origin at the art's bounds. + let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": false, "unit": "pt"})).unwrap(); + let dxf = text(&r); + assert_eq!(header(&dxf, "$LIMMIN").as_deref(), Some("0")); + assert!(s.execute("document.export", &json!({"format": "dxf", "artboard": 5})).is_err()); + let e = s.execute("document.export", &json!({"format": "dxf", "range": "1-2"})).unwrap_err().to_string(); + assert!(e.contains("useArtboards"), "{e}"); +} + +#[test] +fn selected_only_exports_the_selection_in_its_layers() { + let mut s = two_boards(); + s.execute("layer.new", &json!({"name": "Top"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let line = s.execute("shape.line", &json!({"x1": 0, "y1": 90, "x2": 90, "y2": 90})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + let dxf = text(&s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap()); + assert_eq!(entities(&dxf), ["LWPOLYLINE"]); + assert!(dxf.contains("\n 2\nTop\n") && !dxf.contains("\n 2\nLayer 1\n"), "only the selection's layer"); + s.execute("select.none", &json!({})).unwrap(); + let e = s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap_err().to_string(); + assert!(e.contains("select something"), "{e}"); + // Other formats take it too. + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + let svg = s.execute("document.serialize", &json!({"format": "svg", "selectedOnly": true})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!((svg.matches(" = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect(); + assert_eq!(unsupported, ["dwg", "pict"]); +} + +#[test] +fn dwg_and_pict_say_what_to_use_instead() { + let mut s = two_boards(); + for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] { + let e = s.execute("document.export", &p).unwrap_err().to_string(); + assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}"); + } + let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string(); + assert!(e.contains("PICT is a retired format"), "{e}"); + let e = s + .execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])})) + .unwrap_err() + .to_string(); + assert!(e.contains("old.pct") && e.contains("PICT"), "{e}"); + let e = s + .execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")})) + .unwrap_err() + .to_string(); + assert!(e.contains("DWG is not available"), "{e}"); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2d2a8ca1..8d5c8c5b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -222,6 +222,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(place::specs()); v.extend(links::specs()); v.extend(pdfcmds::specs()); + v.extend(fileio::dxf::specs()); v }) } diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 0512d3ce..4933da66 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"])); + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 96cd7e92..3f650568 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,6 +19,7 @@ vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-pdf = { workspace = true } +vectorcraft-cad = { workspace = true } vectorcraft-format = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs new file mode 100644 index 00000000..edf89692 --- /dev/null +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -0,0 +1,152 @@ +//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and +//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize +//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text. +//! +//! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF +//! options of `document.exportDxf`. OK remembers the options for next time and writes the file +//! (asking where when there is no path); bad options keep the dialog open. + +use serde_json::{Map, Value, json}; +use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::fileio; + +use super::DialogSpec; +use super::document_setup::{LABEL, check, choice}; +use crate::state::Dialog; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const KIND: &str = "dxfOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "DXF Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM }; + +/// Fields that are not remembered between exports (they belong to one export). +const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; + +/// The options a first DXF Options dialog starts from (the export's defaults). +fn defaults() -> Map { + let mut m = Map::new(); + for (k, v) in [ + ("version", json!(DxfVersion::default().id())), + ("unit", json!(Unit::Millimeters.label())), + ("scale", json!(1)), + ("scaleLineweights", json!(false)), + ("colors", json!(ColorDepth::default().id())), + ("rasterFormat", json!(RasterFormat::default().id())), + ("preserve", json!(Preserve::default().id())), + ("alterPaths", json!(false)), + ("outlineText", json!(false)), + ("selectedOnly", json!(false)), + ] { + m.insert(k.into(), v); + } + m +} + +/// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are +/// `params`, over the options used last. +pub fn open(app: &mut VectorcraftApp, params: &Value) { + let mut fields = defaults(); + for given in [&app.ui.dxf_options, params] { + if let Some(o) = given.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } + fields.insert("format".into(), json!("dxf")); + app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect(); + choice(ui, d, "version", "Version:", &versions); + ui.add_space(4.0); + scale_row(ui, d); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + check(ui, d, "scaleLineweights", "Scale Lineweights"); + }); + ui.add_space(4.0); + let depths: Vec<(&str, &str)> = ColorDepth::ALL.iter().map(|c| (c.id(), c.label())).collect(); + choice(ui, d, "colors", "Number of Colors:", &depths); + let version = DxfVersion::from_id(&d.str("version")).unwrap_or_default(); + if ColorDepth::from_id(&d.str("colors")) == Some(ColorDepth::True) && version < DxfVersion::R2004 { + note(ui, "True colour needs version 2004 or later: the nearest of 256 colours is written."); + } + let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect(); + choice(ui, d, "rasterFormat", "Raster File Format:", &rasters); + ui.add_space(10.0); + widgets::subheader(ui, "Export Option"); + let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default(); + ui.horizontal(|ui| { + for p in Preserve::ALL { + if widgets::radio(ui, p.label(), p == preserve, true) { + d.fields.insert("preserve".into(), json!(p.id())); + } + ui.add_space(12.0); + } + }); + ui.add_space(4.0); + let selection = app.session.active().is_some_and(|st| !st.selection.is_empty()); + if widgets::check(ui, "Export Selected Art Only", d.bool("selectedOnly") && selection, selection) { + d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly"))); + } + check(ui, d, "alterPaths", "Alter Paths for Appearance"); + // Preserve Appearance always outlines type. + let appearance = preserve == Preserve::Appearance; + if widgets::check(ui, "Outline Text", appearance || d.bool("outlineText"), !appearance) { + d.fields.insert("outlineText".into(), json!(!d.bool("outlineText"))); + } + ui.add_space(8.0); + super::form::caption( + ui, + match preserve { + Preserve::Appearance => "Type becomes outlines, and strokes a CAD line can't draw become filled shapes.", + Preserve::Editability => "Type stays text, and every stroke is a line with its lineweight and dashes.", + }, + ); + false +} + +/// Scale: 1 [unit] = [scale] Units. +fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) { + let t = crate::theme::Tokens::get(ui.ctx()); + let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect(); + let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters); + widgets::label_row(ui, "Scale:", LABEL, |ui| { + ui.label(egui::RichText::new("1").color(t.text)); + if let Some(u) = widgets::dropdown(ui, "dxf-unit", unit.label(), &units, 120.0).and_then(|i| units.get(i)) { + d.fields.insert("unit".into(), json!(u)); + } + ui.label(egui::RichText::new("=").color(t.text)); + let scale = d.f64("scale", 1.0); + if let Some(v) = widgets::plain_field(ui, "dxf-scale", scale, "", 3, 64.0) { + let (lo, hi) = (*vectorcraft_cad::SCALE_RANGE.start(), *vectorcraft_cad::SCALE_RANGE.end()); + d.fields.insert("scale".into(), json!(v.clamp(lo, hi))); + } + ui.label(egui::RichText::new("Units").color(t.text)); + }); +} + +/// A dim line under a field. +fn note(ui: &mut egui::Ui, text: &str) { + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + super::form::caption(ui, text); + }); +} + +/// Check the options, remember them, and write the file(s). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let params = super::form::params(d); + fileio::dxf::check(¶ms)?; + // Nothing selected keeps the dialog open too. + if let Some(st) = app.session.active() { + fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; + } + app.ui.dxf_options = + Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + app.ui.dialog = None; + let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); + io::export(app, Some("dxf"), path, ¶ms) +} diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 2b452b01..14aff069 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -1,14 +1,15 @@ //! File → Export → Export As…: the format and Use Artboards (All or a range: one file per //! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG -//! Options, Text Export Options, and Save PDF for artboards as pages), else the file is written. +//! Options, Text Export Options, DXF Options, and Save PDF for artboards as pages), else the file +//! is written. DWG is listed, greyed out, with what to use instead. use std::sync::LazyLock; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; -use super::{DialogSpec, form, png_options, save_pdf, svg_options, text_export}; +use super::{DialogSpec, dxf_options, form, png_options, save_pdf, svg_options, text_export}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -18,13 +19,26 @@ pub(super) const SPEC: DialogSpec = /// The formats Export As writes (the native format is Save's). static FORMATS: LazyLock> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect()); -static LABELS: LazyLock> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).collect()); +/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out. +const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")]; +static LABELS: LazyLock> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect()); + +/// What to use instead when the field names a format that can't be written (DWG). +fn unavailable(d: &Dialog) -> Option<(&'static str, &'static str)> { + let id = d.str("format"); + let (_, label) = UNAVAILABLE.iter().find(|u| u.0 == id)?; + Some((label, fileio::unsupported(&id)?.hint)) +} /// Open the dialog on `format` (an id or extension, default PNG); `p` may preset `useArtboards` /// and `range`. pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) { let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); - let format = format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id); + let missing = format.and_then(|f| UNAVAILABLE.iter().find(|u| u.0.eq_ignore_ascii_case(f.trim_start_matches('.')))); + let format = match missing { + Some((id, _)) => id, + None => format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id), + }; let range = p.get("range").and_then(Value::as_str); app.ui.dialog = Some(Dialog::new( "exportAs", @@ -47,13 +61,19 @@ fn chosen(d: &Dialog) -> Option<&'static Format> { fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let Some(f) = chosen(d) else { return false }; + let missing = unavailable(d); egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { ui.label(egui::RichText::new("Format:").color(t.text_dim)); - if let Some(i) = widgets::dropdown(ui, "export-as-format", f.label, &LABELS, 160.0) { - d.fields.insert("format".into(), json!(FORMATS[i].id)); + let shown = missing.map_or(f.label, |(label, _)| label); + if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) { + d.fields.insert("format".into(), json!(g.id)); } ui.end_row(); }); + if let Some((_, hint)) = missing { + ui.add_space(6.0); + form::caption(ui, hint); + } ui.add_space(10.0); form::check(ui, d, "useArtboards", "Use Artboards"); let use_artboards = d.bool("useArtboards"); @@ -83,6 +103,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// Check the artboard range, pick the file, then show the format's options or write the file. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if let Some((_, hint)) = unavailable(d) { + return Err(hint.into()); + } let f = chosen(d).ok_or("no format to export to")?; let use_artboards = d.bool("useArtboards"); let all = d.bool("all"); @@ -122,6 +145,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { png_options::open(app, f, params); return Ok(Value::Null); } + if f.id == "dxf" { + dxf_options::open(app, ¶ms); + return Ok(Value::Null); + } io::export(app, Some(f.id), Some(path), ¶ms) } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index b6c64ac7..1662a834 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -15,6 +15,7 @@ mod color_picker; mod command; pub mod confirm; mod document_setup; +pub mod dxf_options; mod effect; pub mod expand; mod export_as; @@ -207,6 +208,7 @@ registry! { SaveOptions: [save_options::KIND] => save_options::SPEC, TextExport: [text_export::KIND] => text_export::SPEC, OfficeExport: [office_export::KIND] => office_export::SPEC, + DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -314,3 +316,6 @@ mod tests_export; mod tests_import_pdf; #[cfg(test)] mod tests_raster_formats; + +#[cfg(test)] +mod tests_dxf; diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs new file mode 100644 index 00000000..252c67f7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -0,0 +1,83 @@ +//! Export As → DXF Options, and DWG listed as unavailable, drawn headlessly. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +fn text(bytes: &[u8]) -> String { + String::from_utf8(bytes.to_vec()).unwrap() +} + +#[test] +fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "dxf"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + frame(&mut app); + set(&mut app, "useArtboards", json!(true)); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("dxfOptions"), "DXF Options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.str("version"), d.str("preserve")), ("/out/Untitled-1.dxf".into(), "2018".into(), "appearance".into())); + frame(&mut app); + // Every control draws: R12 with true colour shows its note, Maximize Editability frees Outline Text. + set(&mut app, "version", json!("R12")); + set(&mut app, "preserve", json!("editability")); + set(&mut app, "unit", json!("Inches")); + frame(&mut app); + set(&mut app, "colors", json!("256")); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(r["path"], "/out/Untitled-1.dxf"); + let dxf = text(&written.borrow()[0].1); + assert!(dxf.contains("$ACADVER\n 1\nAC1009"), "{dxf}"); + // The next DXF Options start from these choices, but not from this export's path. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("version"), d.str("unit"), d.str("colors")), ("R12".into(), "Inches".into(), "256".into())); + assert!(!d.fields.contains_key("path")); + assert!(app.ui.dxf_options.get("useArtboards").is_none()); +} + +#[test] +fn bad_dxf_options_keep_the_dialog_open() { + let (mut app, written) = app(1); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + set(&mut app, "scale", json!(-1)); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some("dxfOptions")); + set(&mut app, "scale", json!(10)); + // Export Selected Art Only with nothing selected. + app.run("select.none", json!({})).unwrap(); + set(&mut app, "selectedOnly", json!(true)); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("select something"), "{e}"); + assert!(written.borrow().is_empty()); + set(&mut app, "selectedOnly", json!(false)); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/a.dxf"]); +} + +#[test] +fn dwg_is_listed_but_points_to_dxf() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "dwg"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "dwg"); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("DWG") && e.contains("DXF"), "{e}"); + assert_eq!(kind(&app), Some("exportAs"), "the dialog stays open to pick DXF"); + set(&mut app, "format", json!("dxf")); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("dxfOptions")); + assert!(written.borrow().is_empty()); +} + +#[test] +fn the_dxf_options_dialog_needs_a_document() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(app.run("ui.dxfOptionsDialog", json!({})).is_err()); + assert!(!crate::menus::enabled(&app, "ui.dxfOptionsDialog")); +} diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 8d5b5429..e2dd471d 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -48,7 +48,7 @@ pub(super) fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Valu app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value); } -fn names(w: &Written) -> Vec { +pub(super) fn names(w: &Written) -> Vec { w.borrow().iter().map(|(p, _)| p.clone()).collect() } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 83fcd946..6e758280 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -317,12 +317,17 @@ pub fn recent_files(app: &VectorcraftApp) -> &[String] { } /// Export the active document in `format` (default: the path's extension, else PNG) with the -/// `document.export` options in `params` (artboard, range, useArtboards, ppi, SVG options…): -/// every file it writes (one per artboard, linked images) goes next to `path`. The document keeps -/// its path. → `{path, warnings, files?}` (`files`: every file when there are several). +/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG +/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The +/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are +/// several). pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { let f = fileio::writable_format(format, path.as_deref())?; - let doc = app.session.active().ok_or("no document")?.doc.clone(); + let st = app.session.active().ok_or("no document")?; + let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + }; let path = target_path(app, path, f.extensions[0])?; // An SVG given a .svgz name is written compressed. let f = match fileio::format_for_name(&path) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b29976dd..903df7b4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -425,6 +425,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.openRecent29", "Open Recent File 29", "", "{}"), ("file.openRecent30", "Open Recent File 30", "", "{}"), ("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"), + ( + "ui.dxfOptionsDialog", + "DXF Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -793,6 +799,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::office_export::open(app), "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), + "ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.dxfOptionsDialog" => { + crate::dialogs::dxf_options::open(app, p); + Ok(Value::Null) + } _ => return None, }; Some(r) @@ -1019,6 +1030,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.spotColors" => app.session.active().is_some(), "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.dxfOptionsDialog" => app.session.active().is_some(), _ => true, } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 993414b8..691f04bb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -285,6 +285,9 @@ pub struct UiState { /// dialog is open. #[serde(skip)] pub dialog_file: Option>, + /// The DXF Options chosen last (`document.exportDxf` options; null: never used). + #[serde(default)] + pub dxf_options: Value, } impl UiState { @@ -343,6 +346,7 @@ impl Default for UiState { svg_options: Value::Null, place_link: true, dialog_file: None, + dxf_options: Value::Null, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f71ea1cb..c88aed29 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -295,3 +295,12 @@ system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` in the Templates folder (preference `templatesFolder`). Older native files open as " [Converted]" (preference `appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved with. + +DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or +`ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are +`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors` +(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`, +`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or +Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and +writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to +`dwg` shows, and on confirm answers, the hint to export DXF instead. diff --git a/docs/mcp.md b/docs/mcp.md index 0a801504..af93f92e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1029,3 +1029,30 @@ without its formatting, taking the style at the caret. {"name":"run_command","arguments":{"command":"clipboard.importImage","params":{"dataBase64":"iVBORw0KGgo…","center":[300,200]}}} {"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200]}}} ``` + +## DXF export + +`document.exportDxf` (also `document.export` / `export` with format `dxf`) writes a CAD drawing, ASCII DXF R12 to 2018 +(`version`: `R12`, `R13`, `R14`, `2000`, `2004`, `2007`, `2010`, `2013`, `2018`; default `2018`). Each layer becomes a +DXF layer (hidden layers switched off, locked ones locked, non-printing ones not plotted; template layers left out), +straight paths become polylines, curved ones cubic splines through every anchor, fills solid hatches (R12 has none: +their outlines), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities +linked to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up +from the bottom-left corner of the first artboard (or `artboard`), in drawing units: `scale` units per `unit` (default +1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or +`256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve: +"appearance"` (default) writes type as glyph outlines and the strokes a CAD line can't draw (inside or outside, +width profiles, arrowheads) as filled outlines; `"editability"` keeps type as text and every stroke a line. +`alterPaths` writes every stroke as its filled outline, `outlineText` outlines type, `selectedOnly` writes only the +selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen +artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, +raster effects, clipping) comes back in `warnings`. + +DWG can't be written (no openly licensed writer exists): `document.formats` lists it under `unsupported` with that +hint, and exporting to it answers with the hint to export DXF. PICT files are not supported either and say so when +opened. + +```json +{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} +{"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 5dc66dde..a9c2abb9 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -55,6 +55,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("cli", Class::Exempt), ("web", Class::Exempt), ("xtask", Class::Exempt), + // Crates added later (append-only). + ("cad", Class::Layer(3)), ]; /// Explicit orderings *within* a layer (earlier may be used by later).