From 2e6585d7ea6a9c23c7ceb669aa81486fa2b98ca9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:01:39 +0700 Subject: [PATCH] M4.93: DXF drawings open and place with layout, scale or fit, centring and merged layers ASCII DXF of any version opens (document.open) and places (file.place) through a new reader in vectorcraft-cad: lines, LW/2D/3D polylines with bulge arcs, circles, arcs, ellipses, NURBS splines (exact Beziers up to cubic, fitted otherwise), solids, solid and pattern hatches, TEXT/MTEXT/ATTRIB as point type, named blocks as symbols (one per look for by-block art), anonymous blocks and dimensions as groups, MINSERT arrays, OCS extrusion; layers keep off/frozen/locked/plot state; ACI and true colour, lineweights, linetypes and transparency resolve through layers and blocks. The `dxf` option object picks the layout, ratio or fit, lineweight scaling, centring and merged layers; document.dxfInfo lists layouts, layers and units. Binary DXF and DWG answer a clear error. The DXF Import Options dialog (dxfImport) asks on open and place with a layout preview. Fuzzed in import_fuzz. Co-Authored-By: Claude Opus 5.5 --- crates/cad/src/import.rs | 218 ++++++ crates/cad/src/import/build.rs | 594 +++++++++++++++ crates/cad/src/import/curve.rs | 381 ++++++++++ crates/cad/src/import/entity.rs | 659 ++++++++++++++++ crates/cad/src/import/reader.rs | 444 +++++++++++ crates/cad/src/import/tests.rs | 708 ++++++++++++++++++ crates/cad/src/import/text.rs | 157 ++++ crates/cad/src/lib.rs | 21 +- crates/cad/src/scene.rs | 16 +- crates/engine/src/cmd/fileio/dxfimport.rs | 92 +++ crates/engine/src/cmd/fileio/load.rs | 9 +- crates/engine/src/cmd/fileio/mod.rs | 35 +- crates/engine/src/cmd/fileio/pdfimport.rs | 19 +- crates/engine/src/cmd/fileio/tests_dxf.rs | 3 +- .../engine/src/cmd/fileio/tests_dxfimport.rs | 243 ++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/place/mod.rs | 54 +- crates/engine/tests/import_fuzz.rs | 109 ++- crates/ui-egui/src/dialogs/dxf_import.rs | 295 ++++++++ crates/ui-egui/src/dialogs/dxf_options.rs | 4 +- crates/ui-egui/src/dialogs/import_pdf.rs | 136 ++-- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/io.rs | 7 +- crates/ui-egui/src/place.rs | 4 + crates/ui-egui/src/state.rs | 5 + docs/control-protocol.md | 9 + docs/mcp.md | 24 + 27 files changed, 4152 insertions(+), 97 deletions(-) create mode 100644 crates/cad/src/import.rs create mode 100644 crates/cad/src/import/build.rs create mode 100644 crates/cad/src/import/curve.rs create mode 100644 crates/cad/src/import/entity.rs create mode 100644 crates/cad/src/import/reader.rs create mode 100644 crates/cad/src/import/tests.rs create mode 100644 crates/cad/src/import/text.rs create mode 100644 crates/engine/src/cmd/fileio/dxfimport.rs create mode 100644 crates/engine/src/cmd/fileio/tests_dxfimport.rs create mode 100644 crates/ui-egui/src/dialogs/dxf_import.rs diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs new file mode 100644 index 00000000..721586e5 --- /dev/null +++ b/crates/cad/src/import.rs @@ -0,0 +1,218 @@ +//! DXF import: an ASCII DXF drawing of any version as a document. +//! +//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N +//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred +//! or with the drawing's origin on the artboard's bottom-left corner. Each DXF layer holding art +//! becomes a layer (hidden, frozen, locked and non-plotting layers keep that), or all art goes on +//! one. Lines, polylines with arc segments, circles, arcs, ellipses, splines (NURBS), solid +//! hatches and solids become paths; text and multiline text point type; named blocks symbols +//! (anonymous ones, such as dimensions, groups of their art). Colours (indexed and true colour), +//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve +//! them. What can't come in is listed in the warnings. +//! +//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts. +//! - `entity`: entities to items (geometry and properties). +//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths. +//! - `text`: text values to plain text. +//! - `build`: items to the document. + +mod build; +mod curve; +mod entity; +mod reader; +mod text; + +use vectorcraft_doc::{Document, Unit}; + +pub use reader::BINARY_SENTINEL; + +use crate::{DxfVersion, INSUNITS, SCALE_RANGE}; + +/// Letter, the box Fit to Artboard fills unless told otherwise (points). +pub const FIT_TO: (f64, f64) = (612.0, 792.0); + +/// Why a binary DXF can't be opened. +pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that"; + +/// How a drawing comes in. +#[derive(Clone, Debug, PartialEq)] +pub struct ImportOptions { + /// The layout (any case): `Model` (`None`, the default) or a paper layout's name. + pub layout: Option, + /// Scale the art to fit `fit_to` (its larger side along the art's), instead of the ratio. + pub fit: bool, + /// The artboard Fit fills (points, `width × height`). + pub fit_to: (f64, f64), + /// The ratio: 1 `unit` of the art is `scale` drawing units. `None`: the drawing's own unit + /// at 1:1 (millimetres for unitless metric drawings, inches for imperial ones). + pub unit: Option, + pub scale: Option, + /// Lineweights scale with the art (else they keep their printed widths). + pub scale_lineweights: bool, + /// The art's centre on the artboard's (else the drawing's origin, or fitted art's + /// bottom-left corner, on the artboard's bottom-left corner). + pub center: bool, + /// All art on one layer. + pub merge_layers: bool, +} + +impl Default for ImportOptions { + fn default() -> Self { + Self { layout: None, fit: false, fit_to: FIT_TO, unit: None, scale: None, scale_lineweights: false, center: true, merge_layers: false } + } +} + +/// An imported drawing. +#[derive(Clone, Debug)] +pub struct Imported { + pub document: Document, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// What a drawing holds, for the import options. +#[derive(Clone, Debug, PartialEq)] +pub struct DxfInfo { + /// The DXF version (`2018`, `R12`…; the `$ACADVER` code when it's none of the known ones). + pub version: String, + /// The drawing unit's name (`Millimeters`, `Unitless`…). + pub units: String, + /// `Model`, then the paper layouts. + pub layouts: Vec, + /// The layer table's layers. + pub layers: Vec, + /// The default ratio: 1 `unit` = `scale` drawing units. + pub unit: Unit, + pub scale: f64, +} + +/// Does `bytes` look like a DXF file (ASCII or binary)? +pub fn is_dxf(bytes: &[u8]) -> bool { + if bytes.starts_with(reader::BINARY_SENTINEL) { + return true; + } + let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(1024)).unwrap_or(bytes)); + let mut lines = head.trim_start_matches('\u{feff}').lines().map(str::trim).skip_while(|l| l.is_empty()); + // Comments (999) may come first; then the first section. + while let (Some(code), Some(value)) = (lines.next(), lines.next()) { + match code { + "999" => continue, + "0" => return value == "SECTION", + _ => return false, + } + } + false +} + +/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused). +fn text(bytes: &[u8]) -> Result, String> { + if bytes.starts_with(reader::BINARY_SENTINEL) { + return Err(BINARY.into()); + } + if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") { + return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into()); + } + Ok(reader::decode(bytes)) +} + +/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it. +fn default_ratio(h: &reader::Header) -> (Unit, f64) { + if let Some((_, u)) = INSUNITS.iter().find(|(c, _)| i64::from(*c) == h.insunits) { + return (*u, 1.0); + } + match build::insunits_points(h.insunits) { + Some(pt) => (Unit::Millimeters, Unit::Millimeters.points() / pt), + None if h.measurement == Some(0) => (Unit::Inches, 1.0), + None => (Unit::Millimeters, 1.0), + } +} + +/// The name of `$INSUNITS` code `code`. +fn units_name(code: i64) -> &'static str { + const NAMES: [&str; 22] = [ + "Unitless", + "Inches", + "Feet", + "Miles", + "Millimeters", + "Centimeters", + "Meters", + "Kilometers", + "Microinches", + "Mils", + "Yards", + "Angstroms", + "Nanometers", + "Microns", + "Decimeters", + "Decameters", + "Hectometers", + "Gigameters", + "Astronomical units", + "Light years", + "Parsecs", + "US survey feet", + ]; + usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless") +} + +/// Read what `bytes` holds: its version, units, layouts and layers. +pub fn info(bytes: &[u8]) -> Result { + let text = text(bytes)?; + let (pairs, _) = reader::pairs(&text)?; + let d = reader::Drawing::read(&pairs); + if d.sections == 0 { + return Err("not a DXF drawing".into()); + } + let h = &d.header; + let (unit, scale) = default_ratio(h); + let version = DxfVersion::ALL.into_iter().find(|v| v.acadver() == h.acadver).map_or_else(|| h.acadver.clone(), |v| v.id().to_string()); + Ok(DxfInfo { + version, + units: units_name(h.insunits).into(), + layouts: d.layouts.iter().map(|l| l.name.clone()).collect(), + layers: d.layers.iter().map(|l| l.name.clone()).collect(), + unit, + scale, + }) +} + +/// Import an ASCII DXF drawing with `o`. +pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { + let (w, h) = o.fit_to; + if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) { + return Err(format!("the artboard to fit must be a positive size up to {} pt", build::MAX_EXTENT)); + } + if let Some(s) = o.scale + && !(s.is_finite() && SCALE_RANGE.contains(&s)) + { + return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end())); + } + let text = text(bytes)?; + let (pairs, damaged) = reader::pairs(&text)?; + let d = reader::Drawing::read(&pairs); + if d.sections == 0 { + return Err("not a DXF drawing".into()); + } + let layout = match &o.layout { + None => d.layouts.first(), + Some(name) => d.layout(name), + }; + let Some(layout) = layout else { + let names: Vec<&str> = d.layouts.iter().map(|l| l.name.as_str()).collect(); + return Err(format!("the drawing has no layout `{}` (it has {})", o.layout.as_deref().unwrap_or_default(), names.join(", "))); + }; + let (unit, scale) = default_ratio(&d.header); + let natural = unit.points() / scale; + let ratio = match (o.unit, o.scale) { + (None, None) => natural, + (u, s) => u.unwrap_or(unit).points() / s.unwrap_or(1.0), + }; + let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?; + document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) }; + warnings.extend(damaged); + Ok(Imported { document, warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs new file mode 100644 index 00000000..dd608545 --- /dev/null +++ b/crates/cad/src/import/build.rs @@ -0,0 +1,594 @@ +//! Drawing items to a document: the scale and position the options ask for, one layer per DXF +//! layer that holds art (or one in all), named blocks as symbols (anonymous ones, such as +//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through +//! layers and blocks. + +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use kurbo::BezPath; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAYER_COLORS, LayerColor, LineCap, Node, NodeKind, Symbol, TextObject}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; + +use super::ImportOptions; +use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem}; +use super::reader::{Drawing, LayoutDef}; +use crate::{CAP_HEIGHT, MAX_NEST}; + +/// Points per millimetre. +const PT_PER_MM: f64 = 72.0 / 25.4; +/// The stroke weight of lineweight 0 (CAD apps draw it one device pixel wide), in points. +const THINNEST: f64 = 0.25; +/// The most objects an import makes. +const MAX_NODES: usize = 2_000_000; +/// How far from the origin art may lie (points): the canvas. +pub(crate) const MAX_EXTENT: f64 = 4.0e6; + +const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out"; +const XREFS: &str = "blocks referencing other drawings (external references) are left out"; +const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; + +/// What a block's art takes from the insert that places it: its colour, lineweight, linetype +/// and opacity where the art says "by block", and its layer for art on layer 0. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct Inherit { + color: Option<[u8; 3]>, + lineweight: Option, + linetype: Option, + alpha: Option, + layer: Option, +} + +pub(crate) struct Build<'d, 'a> { + d: &'d Drawing<'a>, + o: &'d ImportOptions, + conv: Converter, + doc: Document, + /// Block items by upper-case name. + blocks: HashMap>>, + bounds: HashMap>, + sensitive: HashMap, + /// Symbol names by block and what it inherits (`None`: the block has no art). + symbols: HashMap<(String, Inherit), Option>, + /// Blocks being drawn, outermost first. + stack: Vec, + /// Document space per drawing unit, and its symbols' (no translation). + map: Affine, + symbol_map: Affine, + /// Lineweights are multiplied by this. + lw_scale: f64, + nodes: usize, + warnings: Vec, +} + +/// The unit of `$INSUNITS` code `code` in points (0, unitless: none). +pub(crate) fn insunits_points(code: i64) -> Option { + let m = 72.0 / 0.0254; + Some(match code { + 1 => 72.0, + 2 => 864.0, + 3 => 72.0 * 63_360.0, + 4 => PT_PER_MM, + 5 => PT_PER_MM * 10.0, + 6 => m, + 7 => m * 1e3, + 8 => 72e-6, + 9 => 72e-3, + 10 => 2592.0, + 11 => m * 1e-10, + 12 => m * 1e-9, + 13 => m * 1e-6, + 14 => m * 0.1, + 15 => m * 10.0, + 16 => m * 100.0, + 17 => m * 1e9, + 18 => m * 1.495_978_707e11, + 19 => m * 9.460_730_472_580_8e15, + 20 => m * 3.085_677_581e16, + 21 => m * 1200.0 / 3937.0, + _ => return None, + }) +} + +impl<'d, 'a> Build<'d, 'a> { + pub fn new(d: &'d Drawing<'a>, o: &'d ImportOptions) -> Self { + Self { + d, + o, + conv: Converter::default(), + doc: Document::new(1.0, 1.0), + blocks: HashMap::new(), + bounds: HashMap::new(), + sensitive: HashMap::new(), + symbols: HashMap::new(), + stack: vec![], + map: Affine::IDENTITY, + symbol_map: Affine::IDENTITY, + lw_scale: 1.0, + nodes: 0, + warnings: vec![], + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + /// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own + /// unit and at the scale asked for (fitting computes its own). + pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec), String> { + let items = self.conv.items(&self.d.layout_records(layout)); + let bounds = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, 0))); + let (k, size, offset) = self.placement(bounds, natural, ratio)?; + self.map = Affine::new([k, 0.0, 0.0, -k, offset.x, offset.y]); + self.symbol_map = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]); + self.lw_scale = if self.o.scale_lineweights { k / natural } else { 1.0 }; + let layers = self.layers(&items); + let Self { mut doc, conv, warnings: notes, .. } = self; + if !layers.is_empty() { + doc.layers = layers; + } + if let Some(a) = doc.artboards.first_mut() { + a.rect = Rect::from_origin_size(Point::ZERO, size); + } + doc.fix_next_id(); + let mut warnings = conv.warnings; + warnings.extend(notes); + if !conv.skipped.is_empty() { + let list: Vec = conv.skipped.iter().map(|(k, n)| format!("{n} {k}")).collect(); + warnings.push(format!("entities of these types are left out: {}", list.join(", "))); + } + if bounds.is_none() { + warnings.push(format!("the {} layout has no art", layout.name)); + } + Ok((doc, warnings)) + } + + /// Points per drawing unit, the artboard's size, and where the drawing's origin goes. + fn placement(&self, bounds: Option, natural: f64, ratio: f64) -> Result<(f64, vectorcraft_geom::Size, Point), String> { + let b = bounds.unwrap_or(Rect::ZERO); + let k = if self.o.fit { + let (w, h) = self.fit_box(b); + let ratio = |a: f64, b: f64| (b > 1e-12).then(|| a / b); + [ratio(w, b.width()), ratio(h, b.height())].into_iter().flatten().reduce(f64::min).unwrap_or(natural) + } else { + ratio + }; + if !(k.is_finite() && k > 0.0) { + return Err("the scale is not a usable number".into()); + } + let size = if self.o.fit { + let (w, h) = self.fit_box(b); + vectorcraft_geom::Size::new(w, h) + } else { + vectorcraft_geom::Size::new((b.width() * k).max(1.0), (b.height() * k).max(1.0)) + }; + // Centred: the art's centre on the artboard's; else the drawing's origin (fitted art: its + // bottom-left corner) on the artboard's bottom-left corner. + let offset = if self.o.center { + Point::new(size.width / 2.0 - b.center().x * k, size.height / 2.0 + b.center().y * k) + } else if self.o.fit { + Point::new(-b.x0 * k, size.height + b.y0 * k) + } else { + Point::new(0.0, size.height) + }; + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)].map(|p| Point::new(p.x * k + offset.x, -p.y * k + offset.y)); + let fits = corners.iter().all(|p| p.x.abs() <= MAX_EXTENT && p.y.abs() <= MAX_EXTENT) && size.width.max(size.height) <= MAX_EXTENT; + if !fits { + let (w, h) = (b.width() * k, b.height() * k); + return Err(format!( + "at this scale the drawing is {w:.0} × {h:.0} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard" + )); + } + Ok((k, size, offset)) + } + + /// The box Fit to Artboard fills, turned to the art's orientation. + fn fit_box(&self, b: Rect) -> (f64, f64) { + let (w, h) = self.o.fit_to; + if (b.width() > b.height()) == (w > h) { (w, h) } else { (h, w) } + } + + // ---------- blocks ---------- + + /// A block's items (read once). + fn block(&mut self, name: &str) -> Option>> { + if let Some(b) = self.blocks.get(name) { + return Some(b.clone()); + } + let def = self.d.blocks.get(name)?; + if def.xref { + self.warn(XREFS); + return None; + } + let items = Rc::new(self.conv.items(&def.records)); + self.blocks.insert(name.to_string(), items.clone()); + Some(items) + } + + /// Enter block `name` `depth` deep; false (with a warning) when too deep or inside itself. + fn enter(&mut self, name: &str, depth: u32) -> bool { + if depth >= MAX_NEST || self.stack.iter().any(|s| s == name) { + self.warn(NESTED); + return false; + } + self.stack.push(name.to_string()); + true + } + + /// A block's bounds in its coordinates less its base point. + fn block_bounds(&mut self, name: &str, depth: u32) -> Option { + if let Some(b) = self.bounds.get(name) { + return *b; + } + let items = self.block(name)?; + if !self.enter(name, depth) { + return None; + } + let b = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, depth + 1))); + self.stack.pop(); + let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base); + let b = b.map(|r| r - base.to_vec2()); + self.bounds.insert(name.to_string(), b); + b + } + + /// An item's bounds in world coordinates (type: an estimate from its height and length). + fn item_bounds(&mut self, it: &Item, depth: u32) -> Option { + match &it.geom { + Geom::Path { bp, .. } => super::curve::bounds(bp), + Geom::Text(t) => { + let lines = t.text.lines().count().max(1) as f64; + let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64; + let w = longest * t.height * 0.9 * t.width; + let x0 = match t.justify { + vectorcraft_doc::Justify::Center => -w / 2.0, + vectorcraft_doc::Justify::Right => -w, + _ => 0.0, + }; + let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3; + Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height))) + } + Geom::Insert(ins) => { + let b = self.block_bounds(&ins.block, depth); + let cells = b.into_iter().flat_map(|b| ins.cells.iter().map(move |m| m.transform_rect_bbox(b))); + let attribs: Vec> = ins.attribs.iter().map(|a| self.item_bounds(a, depth)).collect(); + cells.map(Some).chain(attribs).fold(None, vectorcraft_geom::union_opt) + } + } + .filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) + } + + /// Does a block's look depend on its insert (art "by block", or on layer 0 "by layer")? + fn is_sensitive(&mut self, name: &str, depth: u32) -> bool { + if let Some(s) = self.sensitive.get(name) { + return *s; + } + let Some(items) = self.block(name) else { return false }; + if !self.enter(name, depth) { + return false; + } + let mut s = false; + for it in items.iter() { + let p = &it.props; + let on_zero = p.layer == "0"; + s |= p.color == Col::ByBlock || p.lineweight == Lw::ByBlock || p.linetype == "BYBLOCK" || p.alpha == Alpha::ByBlock; + s |= on_zero && (p.color == Col::ByLayer || p.lineweight == Lw::ByLayer || p.linetype == "BYLAYER"); + if let Geom::Insert(ins) = &it.geom { + s |= !ins.attribs.is_empty() || self.is_sensitive(&ins.block, depth + 1); + } + if s { + break; + } + } + self.stack.pop(); + self.sensitive.insert(name.to_string(), s); + s + } + + // ---------- properties ---------- + + /// The layer art on layer 0 takes from its block, else its own. + fn layer_name<'p>(&self, p: &'p Props, inh: &'p Inherit) -> &'p str { + match &inh.layer { + Some(l) if p.layer == "0" => l, + _ => &p.layer, + } + } + + fn rgb(&self, p: &Props, inh: &Inherit) -> [u8; 3] { + match p.color { + Col::Rgb(c) => c, + Col::ByBlock => inh.color.unwrap_or([0; 3]), + Col::ByLayer => self.d.layer(self.layer_name(p, inh)).map_or([0; 3], |l| l.rgb), + } + } + + /// The lineweight in hundredths of a millimetre. + fn lineweight(&self, p: &Props, inh: &Inherit) -> i32 { + let default = self.d.header.lwdefault; + match p.lineweight { + Lw::Hundredths(v) => v, + Lw::Default => default, + Lw::ByBlock => inh.lineweight.unwrap_or(default), + Lw::ByLayer => self.d.layer(self.layer_name(p, inh)).map(|l| l.lineweight).filter(|v| *v >= 0).unwrap_or(default), + } + } + + /// The linetype name (upper case) after "by layer" and "by block". + fn linetype(&self, p: &Props, inh: &Inherit) -> String { + match p.linetype.as_str() { + "BYLAYER" => self.d.layer(self.layer_name(p, inh)).map_or_else(|| "CONTINUOUS".into(), |l| l.linetype.clone()), + "BYBLOCK" => inh.linetype.clone().unwrap_or_else(|| "CONTINUOUS".into()), + other => other.to_string(), + } + } + + fn opacity(&self, p: &Props, inh: &Inherit) -> f32 { + match p.alpha { + Alpha::Opacity(a) => a, + Alpha::ByBlock => inh.alpha.map_or(1.0, |a| f32::from(a) / 255.0), + Alpha::ByLayer => 1.0, + } + } + + /// What a block placed by an insert with `p` inherits. + fn inherit(&self, p: &Props, inh: &Inherit) -> Inherit { + Inherit { + color: Some(self.rgb(p, inh)), + lineweight: Some(self.lineweight(p, inh)), + linetype: Some(self.linetype(p, inh)), + alpha: Some((self.opacity(p, inh) * 255.0).round() as u8), + layer: Some(self.layer_name(p, inh).to_string()), + } + } + + /// A dash pattern in points for `linetype` drawn `scale` points per drawing unit. + fn dash(&self, linetype: &str, p: &Props, scale: f64) -> Option<(Dash, bool)> { + let pattern = self.d.linetypes.get(linetype)?; + let k = self.d.header.ltscale * p.ltscale * scale; + // Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap… + let mut out: Vec = vec![]; + for v in pattern { + let dash = *v >= 0.0; + let len = v.abs() * k; + match out.len().is_multiple_of(2) { + expects_dash if expects_dash == dash => out.push(len), + _ if out.is_empty() => out.extend([0.0, len]), + _ => { + if let Some(last) = out.last_mut() { + *last += len; + } + } + } + } + if !out.len().is_multiple_of(2) { + out.push(0.0); + } + let dots = out.iter().step_by(2).any(|d| *d <= 1e-9); + let d = Dash { pattern: out, offset: 0.0, align_corners: false }; + (d.is_dashed() && d.pattern.iter().skip(1).step_by(2).any(|g| *g > 1e-9) && d.pattern.iter().all(|v| v.is_finite())).then_some((d, dots)) + } + + // ---------- art ---------- + + /// One doc layer per DXF layer holding art (in the order art first uses them), or one for all. + fn layers(&mut self, items: &[Item]) -> Vec> { + let mut layers: Vec<(String, Vec>)> = vec![]; + let mut index: HashMap = HashMap::new(); + let top = Inherit::default(); + let map = self.map; + for it in items { + let Some(node) = self.item(it, map, &top, 0) else { continue }; + let name = if self.o.merge_layers { "" } else { it.props.layer.as_str() }; + let i = *index.entry(name.to_uppercase()).or_insert_with(|| { + layers.push((name.to_string(), vec![])); + layers.len() - 1 + }); + if let Some((_, children)) = layers.get_mut(i) { + children.push(Arc::new(node)); + } + } + layers + .into_iter() + .enumerate() + .map(|(i, (name, children))| { + let id = self.doc.alloc_id(); + let color = LayerColor::Preset((i % LAYER_COLORS.len()) as u8); + let label = if name.is_empty() { "Layer 1" } else { &name }; + let mut l = Node::layer(id, label, color); + if let Some(def) = self.d.layer(&name) { + l.visible = !def.hidden; + l.locked = def.locked; + if let NodeKind::Layer { printable, .. } = &mut l.kind { + *printable = def.plot; + } + } + if let Some(c) = l.children_mut() { + *c = children; + } + Arc::new(l) + }) + .collect() + } + + /// One item as an object, drawn by `map` (world coordinates to its parent's space). + fn item(&mut self, it: &Item, map: Affine, inh: &Inherit, depth: u32) -> Option { + if self.nodes >= MAX_NODES { + self.warn(TOO_MANY); + return None; + } + let mut node = match &it.geom { + Geom::Path { bp, fill, width } => self.path(bp, *fill, *width, &it.props, map, inh)?, + Geom::Text(t) => self.text(t, &it.props, map, inh)?, + Geom::Insert(ins) => return self.insert(ins, &it.props, map, inh, depth), + }; + node.opacity = self.opacity(&it.props, inh); + self.nodes += 1; + Some(node) + } + + fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option { + let mut bp = bp.clone(); + bp.apply_affine(map); + super::curve::bounds(&bp)?; + let paint = Paint::solid(rgb_color(self.rgb(p, inh))); + let scale = map.determinant().abs().sqrt(); + let appearance = if fill { + Appearance::basic(paint, Paint::None, 0.0) + } else { + let lw = self.lineweight(p, inh); + let w = if width > 0.0 { + width * scale + } else if lw == 0 { + THINNEST + } else { + f64::from(lw) / 100.0 * PT_PER_MM * self.lw_scale + }; + let mut ap = Appearance::basic(Paint::None, paint, w); + if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut() + && let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale) + { + s.dash = Some(dash); + if dots { + s.cap = LineCap::Round; + } + } + ap + }; + let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero }; + let path = PathData::from_bezpath(&bp); + let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false }; + let mut n = if path.subpaths.len() > 1 { + // Several loops (a hatch with islands) paint as one compound path. + let children = path + .subpaths + .into_iter() + .map(|sp| { + let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp))); + c.appearance = appearance.clone(); + Arc::new(c) + }) + .collect(); + Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule }) + } else { + Node::new(self.doc.alloc_id(), kind(path)) + }; + n.appearance = appearance; + Some(n) + } + + fn text(&mut self, t: &TextItem, p: &Props, map: Affine, inh: &Inherit) -> Option { + // Text space is y-down points; the item's is y-up drawing units. + let m = map * t.xf * Affine::FLIP_Y; + let s = m.determinant().abs().sqrt(); + if !(s > 1e-12 && s.is_finite()) { + return None; + } + let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned()); + let default = CharStyle::default(); + let style = CharStyle { + font_family: family.unwrap_or(default.font_family.clone()), + size: t.height / CAP_HEIGHT * s, + h_scale: t.width * 100.0, + leading: t.leading.map(|l| l * s), + fill: Paint::solid(rgb_color(self.rgb(p, inh))), + ..default + }; + let mut obj = TextObject::point(Point::ZERO, &t.text, style); + obj.xf = m * Affine::scale(1.0 / s); + obj.para.justify = t.justify; + Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj)))) + } + + /// An insert: a symbol instance per copy (anonymous blocks: a group of their art), grouped + /// with its attributes. + fn insert(&mut self, ins: &InsertItem, p: &Props, map: Affine, inh: &Inherit, depth: u32) -> Option { + let child = self.inherit(p, inh); + let base = self.d.blocks.get(&ins.block).map_or(Point::ZERO, |b| b.base); + let unbase = Affine::translate(-base.to_vec2()); + let anonymous = ins.block.starts_with('*'); + let symbol = if anonymous { None } else { self.symbol(&ins.block, &child, depth) }; + let mut parts = vec![]; + for cell in &ins.cells { + let node = match &symbol { + Some(name) => { + let xf = map * *cell * self.symbol_map.inverse(); + self.nodes += 1; + Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf })) + } + None if anonymous => self.art(&ins.block, map * *cell * unbase, &child, depth), + None => None, + }; + parts.extend(node); + } + for a in &ins.attribs { + parts.extend(self.item(a, map, &child, depth)); + } + let mut node = match parts.len() { + 0 => return None, + 1 => parts.pop()?, + _ => Node::group(self.doc.alloc_id(), parts.into_iter().map(Arc::new).collect()), + }; + node.opacity = self.opacity(p, inh); + Some(node) + } + + /// The art of block `name` drawn by `map`, as one object. + fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { + let items = self.block(name)?; + if !self.enter(name, depth) { + return None; + } + let mut nodes = vec![]; + for it in items.iter() { + // Art on a hidden layer stays hidden inside blocks. + if self.d.layer(self.layer_name(&it.props, inh)).is_some_and(|l| l.hidden) && it.props.layer != "0" { + continue; + } + nodes.extend(self.item(it, map, inh, depth + 1)); + } + self.stack.pop(); + match nodes.len() { + 0 => None, + 1 => nodes.pop(), + _ => Some(Node::group(self.doc.alloc_id(), nodes.into_iter().map(Arc::new).collect())), + } + } + + /// The symbol for block `name` placed with `inh` (made once per look). + fn symbol(&mut self, name: &str, inh: &Inherit, depth: u32) -> Option { + let inh = if self.is_sensitive(name, depth) { inh.clone() } else { Inherit::default() }; + let key = (name.to_string(), inh); + if let Some(s) = self.symbols.get(&key) { + return s.clone(); + } + let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base); + let map = self.symbol_map * Affine::translate(-base.to_vec2()); + let art = self.art(name, map, &key.1, depth); + let made = art.map(|art| { + let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); + let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n)); + self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) }); + symbol + }); + self.symbols.insert(key, made.clone()); + made + } +} + +fn rgb_color([r, g, b]: [u8; 3]) -> Color { + Color::rgb8(r, g, b) +} + +/// `base`, or `base 2`, `base 3`… when taken. +fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} diff --git a/crates/cad/src/import/curve.rs b/crates/cad/src/import/curve.rs new file mode 100644 index 00000000..c913a8bd --- /dev/null +++ b/crates/cad/src/import/curve.rs @@ -0,0 +1,381 @@ +//! CAD curves as Bézier paths: polylines with bulges (arc segments), circular and elliptical +//! arcs, and splines (NURBS of any degree, rational or not, or a curve through fit points). +//! +//! Curves are built in drawing units with tolerances relative to their own size, so they keep +//! their accuracy at any scale the drawing is imported at. + +use kurbo::{Arc, BezPath, Shape, Vec2}; +use vectorcraft_geom::{Affine, Point}; + +/// Arcs are approximated to within this fraction of their radius. +const ARC_TOLERANCE: f64 = 1e-6; +/// Splines are approximated to within this fraction of their control polygon's size. +const SPLINE_TOLERANCE: f64 = 1e-6; +/// The most Bézier pieces one spline span may become. +const MAX_DEPTH: u32 = 12; +/// The most path elements one spline may become (beyond, its pieces aren't split further). +const MAX_ELEMENTS: usize = 200_000; +/// The highest spline degree read. +const MAX_DEGREE: usize = 11; + +/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a +/// quarter of the arc's angle; positive turns counter-clockwise). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Vertex { + pub p: Point, + pub bulge: f64, +} + +/// A polyline: straight segments, and arcs where a vertex has a bulge. +pub(crate) fn polyline(verts: &[Vertex], closed: bool) -> Option { + let first = verts.first()?; + let mut bp = BezPath::new(); + bp.move_to(first.p); + let n = verts.len(); + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { break }; + bulge_to(&mut bp, a.p, b.p, a.bulge); + } + if closed { + bp.close_path(); + } + Some(bp) +} + +/// A segment from `a` (the current point) to `b`, an arc when `bulge` isn't 0. +fn bulge_to(bp: &mut BezPath, a: Point, b: Point, bulge: f64) { + let chord = b - a; + let len = chord.hypot(); + if bulge.abs() < 1e-12 || len < 1e-12 || !bulge.is_finite() { + bp.line_to(b); + return; + } + // The included angle is 4·atan(bulge); the centre lies off the chord's middle. + let sweep = 4.0 * bulge.atan(); + let radius = len / (2.0 * (sweep / 2.0).sin()).abs(); + let normal = Vec2::new(-chord.y, chord.x) / len; + let offset = (radius * radius - len * len / 4.0).max(0.0).sqrt(); + // A counter-clockwise arc under a half turn has its centre left of the chord. + let side = if (bulge > 0.0) == (bulge.abs() < 1.0) { 1.0 } else { -1.0 }; + let centre = a.midpoint(b) + normal * offset * side; + let start = (a - centre).atan2(); + arc_to(bp, centre, radius, start, sweep); +} + +/// An arc of a circle from angle `start` (radians) through `sweep` (positive: counter-clockwise), +/// from the current point. +fn arc_to(bp: &mut BezPath, centre: Point, radius: f64, start: f64, sweep: f64) { + let arc = Arc::new(centre, (radius, radius), start, sweep, 0.0); + for el in arc.append_iter((radius * ARC_TOLERANCE).max(1e-12)) { + bp.push(el); + } +} + +/// A circular arc from `start` through `sweep` radians as its own subpath; a whole circle closes. +pub(crate) fn arc(centre: Point, radius: f64, start: f64, sweep: f64) -> Option { + if !(radius > 0.0 && radius.is_finite() && sweep.is_finite()) { + return None; + } + let full = sweep.abs() >= std::f64::consts::TAU - 1e-9; + let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU); + let mut bp = BezPath::new(); + bp.move_to(centre + Vec2::from_angle(start) * radius); + arc_to(&mut bp, centre, radius, start, sweep); + if full { + bp.close_path(); + } + Some(bp) +} + +/// An elliptical arc: the unit circle's arc from `start` through `sweep` radians mapped by `frame` +/// (its columns are the major and minor semi-axes, its translation the centre). +pub(crate) fn ellipse(frame: Affine, start: f64, sweep: f64) -> Option { + let [a, b, c, d, _, _] = frame.as_coeffs(); + let size = a.hypot(b).max(c.hypot(d)); + if !(size > 1e-12 && size.is_finite()) { + return None; + } + let full = sweep.abs() >= std::f64::consts::TAU - 1e-9; + let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU); + let unit = Arc::new(Point::ZERO, (1.0, 1.0), start, sweep, 0.0); + let mut bp = BezPath::new(); + bp.move_to(Point::ZERO + Vec2::from_angle(start)); + for el in unit.append_iter(ARC_TOLERANCE) { + bp.push(el); + } + if full { + bp.close_path(); + } + bp.apply_affine(frame); + Some(bp) +} + +/// A point with a weight, in homogeneous form (`x·w, y·w, w`). +#[derive(Clone, Copy, Debug)] +struct H { + x: f64, + y: f64, + w: f64, +} + +impl H { + fn lerp(self, o: Self, t: f64) -> Self { + Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t } + } + fn point(self) -> Option { + (self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite()) + } +} + +/// A NURBS curve of `degree` with `knots`, control points `ctrl` and their `weights` (none: +/// all 1) as Bézier pieces: exact for polynomial curves up to degree 3, else within +/// [`SPLINE_TOLERANCE`] of the control polygon's size. +pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Option<&[f64]>) -> Option { + let n = ctrl.len(); + if !(1..=MAX_DEGREE).contains(°ree) || n <= degree || knots.len() != n + degree + 1 { + return None; + } + if knots.windows(2).any(|w| w[1] < w[0]) { + return None; + } + let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0); + let hs: Vec = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect(); + let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12); + let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| { + (Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y))) + }); + let tol = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12); + let mut bp = BezPath::new(); + // Each knot span of the domain is one polynomial piece: its Bézier points are blossoms. + for span in degree..n { + let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue }; + if t1 - t0 <= 1e-12 * (1.0 + t0.abs()) { + continue; + } + let bez: Vec = (0..=degree) + .map(|j| { + let args: Vec = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect(); + blossom(degree, knots, &hs, span, &args) + }) + .collect::>()?; + let start = bez.first().and_then(|h| h.point())?; + if bp.elements().is_empty() { + bp.move_to(start); + } + let pts: Option> = if rational { None } else { bez.iter().map(|h| h.point()).collect() }; + match pts.as_deref() { + Some([_, p]) => bp.line_to(*p), + Some([_, q, p]) => bp.quad_to(*q, *p), + Some([_, a, b, p]) => bp.curve_to(*a, *b, *p), + _ => fit(&mut bp, &bez, 0.0, 1.0, tol, 0)?, + } + } + (bp.elements().len() > 1).then_some(bp) +} + +/// The blossom of span `span` at `args` (de Boor's algorithm with one parameter per level). +fn blossom(degree: usize, knots: &[f64], ctrl: &[H], span: usize, args: &[f64]) -> Option { + let mut d: Vec = ctrl.get(span - degree..=span)?.to_vec(); + for r in 1..=degree { + let u = *args.get(r - 1)?; + for j in (r..=degree).rev() { + let i = span - degree + j; + let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?); + let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 }; + let prev = *d.get(j - 1)?; + let cur = d.get_mut(j)?; + *cur = prev.lerp(*cur, alpha); + } + } + d.get(degree).copied() +} + +/// A (rational) Bézier of any degree at `t`, with its derivative. +fn eval(bez: &[H], t: f64) -> Option<(Point, Vec2)> { + let mut pts = bez.to_vec(); + let n = pts.len().checked_sub(1)?; + // Down to the two points whose difference gives the tangent. + for level in (1..=n).rev() { + if level == 1 { + let (a, b) = (*pts.first()?, *pts.get(1)?); + let p = a.lerp(b, t); + let pt = p.point()?; + // d/dt (X/W) = (X'·W − X·W') / W², with X' = n·(b − a). + let k = n as f64; + let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w)); + let w2 = p.w * p.w; + let tangent = Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2); + return Some((pt, tangent)); + } + for i in 0..level { + let next = *pts.get(i + 1)?; + let cur = pts.get_mut(i)?; + *cur = cur.lerp(next, t); + } + } + // Degree 0: a point. + Some((pts.first()?.point()?, Vec2::ZERO)) +} + +/// Cubic pieces through the curve `bez` from `t0` to `t1`, each matching its ends and their +/// tangents, split until within `tol` of the curve. +fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> Option<()> { + let (p0, d0) = eval(bez, t0)?; + let (p3, d3) = eval(bez, t1)?; + let h = (t1 - t0) / 3.0; + let (c1, c2) = (p0 + d0 * h, p3 - d3 * h); + let cubic = kurbo::CubicBez::new(p0, c1, c2, p3); + let close = (1..8).all(|i| { + let s = f64::from(i) / 8.0; + eval(bez, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tol) + }); + if close || depth >= MAX_DEPTH || bp.elements().len() >= MAX_ELEMENTS { + if [c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) { + bp.curve_to(c1, c2, p3); + } else { + bp.line_to(p3); + } + return Some(()); + } + let mid = (t0 + t1) / 2.0; + fit(bp, bez, t0, mid, tol, depth + 1)?; + fit(bp, bez, mid, t1, tol, depth + 1) +} + +/// A smooth curve through `pts` (a spline given by fit points only): Catmull-Rom tangents. +pub(crate) fn through(pts: &[Point], closed: bool) -> Option { + let n = pts.len(); + let first = *pts.first()?; + if n < 2 { + return None; + } + let at = |i: isize| -> Point { + let len = n as isize; + let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) }; + pts.get(j as usize).copied().unwrap_or(first) + }; + let mut bp = BezPath::new(); + bp.move_to(first); + let segs = if closed { n } else { n - 1 }; + for i in 0..segs as isize { + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2); + } + if closed { + bp.close_path(); + } + Some(bp) +} + +/// `bp`'s bounds, when it has any finite extent. +pub(crate) fn bounds(bp: &BezPath) -> Option { + if bp.elements().is_empty() { + return None; + } + let r = bp.bounding_box(); + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r) +} + +#[cfg(test)] +mod tests { + use kurbo::PathEl; + + use super::*; + + /// The largest distance from `centre` minus `r` over points along `bp`. + fn radial_error(bp: &BezPath, centre: Point, r: f64) -> f64 { + let mut worst: f64 = 0.0; + for seg in bp.segments() { + for i in 0..=32 { + let p = kurbo::ParamCurve::eval(&seg, f64::from(i) / 32.0); + worst = worst.max((p.distance(centre) - r).abs()); + } + } + worst + } + + #[test] + fn a_bulge_of_one_is_a_half_circle_on_the_right_of_its_chord() { + let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: 1.0 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }]; + let bp = polyline(&verts, false).unwrap(); + assert!(radial_error(&bp, Point::new(5.0, 0.0), 5.0) < 1e-4); + // Counter-clockwise from (0, 0) to (10, 0) passes below the chord. + let b = bp.bounding_box(); + assert!((b.y0 + 5.0).abs() < 1e-3 && b.y1.abs() < 1e-9, "{b:?}"); + // A negative bulge turns the other way; a small one is a shallow arc. + let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: -0.25 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }]; + let bp = polyline(&verts, false).unwrap(); + // Sagitta = bulge · chord / 2; radius = chord (1 + bulge²) / (4 |bulge|). + let b = bp.bounding_box(); + assert!((b.y1 - 1.25).abs() < 1e-4 && b.y0.abs() < 1e-9, "{b:?}"); + let r = 10.0 * (1.0 + 0.0625) / 1.0; + assert!(radial_error(&bp, Point::new(5.0, 1.25 - r), r) < 1e-4); + } + + #[test] + fn a_bulge_over_one_is_the_major_arc() { + let b = (3.0 * std::f64::consts::PI / 8.0).tan(); // a 270° arc + let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: b }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }]; + let bp = polyline(&verts, false).unwrap(); + let r = 10.0 / (2.0 * (135f64).to_radians().sin()); + // Counter-clockwise, so the centre and most of the circle lie right of the chord. + let centre = Point::new(5.0, -5.0); + assert!(radial_error(&bp, centre, r) < 1e-4); + assert!(bp.bounding_box().y0 < -12.0, "most of the circle lies below the chord"); + } + + #[test] + fn a_rational_quadratic_circle_stays_on_the_circle() { + // A whole circle of radius 100 as nine weighted control points (four quarter arcs). + let w = std::f64::consts::FRAC_1_SQRT_2; + let ctrl = [(1., 0.), (1., 1.), (0., 1.), (-1., 1.), (-1., 0.), (-1., -1.), (0., -1.), (1., -1.), (1., 0.)] + .map(|(x, y)| Point::new(x * 100.0, y * 100.0)); + let weights = [1.0, w, 1.0, w, 1.0, w, 1.0, w, 1.0]; + let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0]; + let bp = nurbs(2, &knots, &ctrl, Some(&weights)).unwrap(); + let err = radial_error(&bp, Point::ZERO, 100.0); + assert!(err < 0.01, "error {err}"); + } + + #[test] + fn polynomial_splines_become_exact_beziers() { + // A clamped cubic with one interior knot: two cubic pieces meeting at its blossom. + let ctrl = [(0., 0.), (10., 20.), (30., 20.), (40., 0.), (50., -10.)].map(|(x, y)| Point::new(x, y)); + let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0]; + let bp = nurbs(3, &knots, &ctrl, None).unwrap(); + assert_eq!(bp.elements().iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2); + assert!(matches!(bp.elements().first(), Some(PathEl::MoveTo(p)) if *p == Point::ZERO)); + let end = match bp.elements().last() { + Some(PathEl::CurveTo(_, _, p)) => *p, + other => panic!("{other:?}"), + }; + assert_eq!(end, Point::new(50.0, -10.0)); + // Bad knot vectors are refused rather than read. + assert!(nurbs(3, &knots[1..], &ctrl, None).is_none()); + assert!(nurbs(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 2.0, 2.0, 2.0], &ctrl, None).is_none()); + assert!(nurbs(0, &knots, &ctrl, None).is_none()); + } + + #[test] + fn a_high_degree_spline_is_fitted_within_tolerance() { + // Degree 5 Bézier (one span) of a known polynomial: compare with direct evaluation. + let ctrl: Vec = (0..6).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect(); + let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + let bp = nurbs(5, &knots, &ctrl, None).unwrap(); + let hs: Vec = ctrl.iter().map(|p| H { x: p.x, y: p.y, w: 1.0 }).collect(); + for i in 0..=20 { + let (p, _) = eval(&hs, f64::from(i) / 20.0).unwrap(); + let near = bp.segments().map(|s| kurbo::ParamCurveNearest::nearest(&s, p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min); + assert!(near < 0.01, "error {near}"); + } + } + + #[test] + fn fit_points_make_a_smooth_curve_through_them() { + let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)]; + let bp = through(&pts, false).unwrap(); + let ends: Vec = bp.elements().iter().filter_map(|e| if let PathEl::CurveTo(_, _, p) = e { Some(*p) } else { None }).collect(); + assert_eq!(ends, [Point::new(10.0, 10.0), Point::new(20.0, 0.0)]); + } +} diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs new file mode 100644 index 00000000..124d196a --- /dev/null +++ b/crates/cad/src/import/entity.rs @@ -0,0 +1,659 @@ +//! Entities to drawing items: their geometry in world coordinates (drawing units, y up) and the +//! properties they take from their layer or block (resolved when the items become art). + +use std::collections::BTreeMap; + +use kurbo::{BezPath, PathEl}; +use vectorcraft_doc::Justify; +use vectorcraft_geom::{Affine, Point, Vec2}; + +use super::curve::{self, Vertex}; +use super::reader::{Groups, Pair, Record, groups, number}; +use crate::aci::aci_rgb; + +/// How far below the baseline the lowest descender reaches, as a fraction of the cap height. +const DESCENT: f64 = 0.3; +/// Baseline-to-baseline distance of multiline text at line spacing 1, in cap heights. +const LINE_SPACING: f64 = 5.0 / 3.0; +/// The most copies one array insert (MINSERT) makes. +const MAX_CELLS: i64 = 10_000; +/// Art farther than this from the origin (drawing units) is damaged data, left out. +const MAX_COORD: f64 = 1e12; + +/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. +pub(crate) fn aci_paper(i: u64) -> [u8; 3] { + match u8::try_from(i) { + Ok(7) | Err(_) => [0; 3], + Ok(i) => aci_rgb(i), + } +} + +/// The true colour (group 420, `0xRRGGBB`) of an entity or layer. +pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> { + let v = g.int(420)?; + let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes(); + Some([r, gr, b]) +} + +/// An entity's colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Col { + ByLayer, + ByBlock, + Rgb([u8; 3]), +} + +/// An entity's lineweight. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Lw { + ByLayer, + ByBlock, + Default, + /// Hundredths of a millimetre. + Hundredths(i32), +} + +/// An entity's opacity. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Alpha { + ByLayer, + ByBlock, + Opacity(f32), +} + +/// What an entity takes from its layer or block unless it sets it itself. +#[derive(Clone, Debug)] +pub(crate) struct Props { + pub layer: String, + pub color: Col, + pub lineweight: Lw, + /// The linetype name in upper case (`BYLAYER`, `BYBLOCK`, `CONTINUOUS` or a table entry). + pub linetype: String, + pub ltscale: f64, + pub alpha: Alpha, +} + +impl Props { + fn read(g: &[Pair<'_>]) -> Self { + let color = match (true_color(g), g.int(62)) { + (Some(rgb), _) => Col::Rgb(rgb), + (None, Some(0)) => Col::ByBlock, + (None, None | Some(256..)) => Col::ByLayer, + (None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())), + }; + let lineweight = match g.int(370) { + Some(-2) => Lw::ByBlock, + Some(-3) => Lw::Default, + Some(v @ 0..=211) => Lw::Hundredths(v as i32), + _ => Lw::ByLayer, + }; + let alpha = match g.int(440) { + Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0), + Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock, + _ => Alpha::ByLayer, + }; + Self { + layer: g.name(8).unwrap_or("0").to_string(), + color, + lineweight, + linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(), + ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0), + alpha, + } + } +} + +/// A line of text. +#[derive(Clone, Debug)] +pub(crate) struct TextItem { + pub text: String, + /// Cap height, in drawing units. + pub height: f64, + /// Text space (the first baseline's start at the origin, x along it, y up; drawing units) to + /// world coordinates. + pub xf: Affine, + /// Width factor (1: as designed). + pub width: f64, + pub justify: Justify, + /// The text style (upper case). + pub style: String, + /// A font family the text names itself (multiline text's font changes). + pub family: Option, + /// Baseline-to-baseline distance (drawing units), for several lines. + pub leading: Option, +} + +/// A block reference: one transform per copy (an array insert makes several), each from the +/// block's coordinates (less its base point) to world coordinates. +#[derive(Clone, Debug)] +pub(crate) struct InsertItem { + /// The block (upper case). + pub block: String, + pub cells: Vec, + /// Its attributes' text. + pub attribs: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) enum Geom { + /// A path in world coordinates; `fill`: a filled area (hatch, solid), else a line of + /// `width` drawing units (0: the lineweight). + Path { + bp: BezPath, + fill: bool, + width: f64, + }, + Text(TextItem), + Insert(InsertItem), +} + +/// An entity as art: its geometry and its properties. +#[derive(Clone, Debug)] +pub(crate) struct Item { + pub props: Props, + pub geom: Geom, +} + +/// Turns entity records into items, noting what it leaves out. +#[derive(Default)] +pub(crate) struct Converter { + /// Entity types left out, with how many. + pub skipped: BTreeMap, + pub warnings: Vec, +} + +const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries"; +const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour"; +const MESHES: &str = "3D polygon meshes and polyface meshes are left out"; +const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each"; +const FAR_AWAY: &str = "entities with coordinates out of range are left out"; + +impl Converter { + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn skip(&mut self, kind: &str) { + *self.skipped.entry(kind.to_string()).or_default() += 1; + } + + /// The items of `records` (an entity section or a block), in drawing order. + pub fn items(&mut self, records: &[Record<'_>]) -> Vec { + let mut out = Vec::new(); + for group in groups(records) { + let Some(head) = group.first() else { continue }; + // Invisible entities. + if head.g.int(60) == Some(1) { + continue; + } + match self.geom(head, group) { + Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY), + Some(geom) => out.push(Item { props: Props::read(head.g), geom }), + None if is_known(head.kind) => {} + None => self.skip(head.kind), + } + } + out + } + + fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option { + let g = head.g; + let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 }); + match head.kind { + "LINE" => { + let mut bp = BezPath::new(); + bp.move_to(g.point(10)); + bp.line_to(g.point(11)); + path(bp) + } + "CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))), + "ARC" => { + let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0)); + let sweep = ccw_sweep(start, end); + path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g))) + } + "ELLIPSE" => path(ellipse(g)?), + "LWPOLYLINE" => lwpolyline(g), + "POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])), + "SPLINE" => path(spline(g)?), + "SOLID" | "TRACE" => { + let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; + // The corners zigzag: the outline runs 1, 2, 4, 3. + Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 }) + } + "3DFACE" => { + let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; + path(polygon(&[a, b, c, d])) + } + "HATCH" => self.hatch(g), + "TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text), + "MTEXT" => mtext(g).map(Geom::Text), + "INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])), + // A dimension draws the anonymous block of its lines, arrows and text. + "DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })), + _ => None, + } + } + + fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { + let flags = g.int_or(70, 0); + if flags & (16 | 64) != 0 { + self.warn(MESHES); + return None; + } + // Spline frame control points and polyface faces aren't on the line. + let verts: Vec = rest + .iter() + .filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0) + .map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) }) + .collect(); + let bp = curve::polyline(&verts, flags & 1 != 0)?; + // 3D polylines are in world coordinates; 2D ones in their plane's. + let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) }; + Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) }) + } + + fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { + let block = g.name(2)?.to_uppercase(); + let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0)); + if sx.abs() < 1e-12 || sy.abs() < 1e-12 { + return None; + } + let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians()); + let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1)); + let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0)); + let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS { + self.warn(BIG_ARRAY); + (cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows)) + } else { + (cols, rows) + }; + let cells = (0..rows) + .flat_map(|r| (0..cols).map(move |c| (r, c))) + .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) + .collect(); + let attribs = self.items(rest); + Some(Geom::Insert(InsertItem { block, cells, attribs })) + } + + fn hatch(&mut self, g: &[Pair<'_>]) -> Option { + let start = g.iter().position(|p| p.code == 91)?; + let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize; + let mut c = Cursor { g, i: start + 1 }; + let mut bp = BezPath::new(); + for _ in 0..count { + let Some(path) = boundary(&mut c) else { break }; + bp.extend(path); + } + if bp.elements().is_empty() { + return None; + } + let bp = transformed(bp, ocs(g)); + let solid = g.int_or(70, 0) == 1; + if g.int_or(450, 0) == 1 { + self.warn(GRADIENT_HATCH); + } else if !solid { + self.warn(PATTERN_HATCH); + return Some(Geom::Path { bp, fill: false, width: 0.0 }); + } + Some(Geom::Path { bp, fill: true, width: 0.0 }) + } +} + +/// Is the geometry within [`MAX_COORD`] of the origin? +fn in_range(g: &Geom) -> bool { + let near = |p: Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD; + let origin = |m: &Affine| near(m.translation().to_point()); + match g { + Geom::Path { bp, width, .. } => { + curve::bounds(bp).is_some_and(|b| near(Point::new(b.x0, b.y0)) && near(Point::new(b.x1, b.y1))) && *width <= MAX_COORD + } + Geom::Text(t) => origin(&t.xf) && t.height <= MAX_COORD, + Geom::Insert(i) => i.cells.iter().all(|m| origin(m) && m.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)), + } +} + +/// Entity types that are read but draw nothing themselves (or were reported when read). +fn is_known(kind: &str) -> bool { + matches!( + kind, + "LINE" + | "CIRCLE" + | "ARC" + | "ELLIPSE" + | "LWPOLYLINE" + | "POLYLINE" + | "SPLINE" + | "SOLID" + | "TRACE" + | "3DFACE" + | "HATCH" + | "TEXT" + | "ATTRIB" + | "MTEXT" + | "INSERT" + | "DIMENSION" + | "ATTDEF" + | "SEQEND" + | "VERTEX" + ) +} + +/// The sweep from `start` to `end` degrees counter-clockwise, in (0, 360]. +fn ccw_sweep(start: f64, end: f64) -> f64 { + let s = (end - start).rem_euclid(360.0); + if s < 1e-9 { 360.0 } else { s } +} + +fn transformed(mut bp: BezPath, m: Affine) -> BezPath { + if m != Affine::IDENTITY { + bp.apply_affine(m); + } + bp +} + +fn polygon(pts: &[Point]) -> BezPath { + let mut bp = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + bp.close_path(); + bp +} + +/// The extrusion direction (210, 220, 230) of a planar entity, normalised. +fn extrusion(g: &[Pair<'_>]) -> [f64; 3] { + let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] } +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +fn unit(v: [f64; 3]) -> [f64; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if len > 1e-12 { v.map(|x| x / len) } else { v } +} + +/// A planar entity's coordinate system seen from above: its x and y axes in world space by the +/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it +/// mirrors x). +fn ocs(g: &[Pair<'_>]) -> Affine { + let n = extrusion(g); + if n[2] > 1.0 - 1e-12 { + return Affine::IDENTITY; + } + let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) }); + let ay = unit(cross(n, ax)); + Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0]) +} + +fn ellipse(g: &[Pair<'_>]) -> Option { + let centre = g.point(10); + let major = g.point(11).to_vec2(); + let ratio = g.num_or(40, 1.0); + if ratio <= 0.0 || major.hypot() < 1e-12 { + return None; + } + // The minor axis is the major one turned a quarter about the extrusion direction. + let n = extrusion(g); + let m = cross(n, [major.x, major.y, 0.0]); + let minor = Vec2::new(m[0], m[1]) * ratio; + let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU)); + let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians(); + curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep) +} + +fn lwpolyline(g: &[Pair<'_>]) -> Option { + let mut verts: Vec = Vec::new(); + let (mut widths, mut uniform) = (None, true); + for p in g { + match p.code { + 10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }), + 20 => verts.last_mut()?.p.y = number(p.value)?, + 42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0), + 40 | 41 => { + let w = number(p.value).unwrap_or(0.0); + uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12); + widths = Some(w); + } + _ => {} + } + } + let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g)); + let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0); + Some(Geom::Path { bp, fill: false, width }) +} + +/// The points at `code` (x) and `code + 10` (y), in order. +fn points(g: &[Pair<'_>], code: i32) -> Option> { + let mut out: Vec = Vec::new(); + for p in g { + if p.code == code { + out.push(Point::new(number(p.value)?, 0.0)); + } else if p.code == code + 10 { + out.last_mut()?.y = number(p.value)?; + } + } + Some(out) +} + +fn spline(g: &[Pair<'_>]) -> Option { + let flags = g.int_or(70, 0); + let ctrl = points(g, 10)?; + let degree = usize::try_from(g.int_or(71, 3)).ok()?; + let knots: Vec = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::>()?; + let weights: Vec = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect(); + let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice()); + curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0)) +} + +/// Reads a hatch's boundary groups in order. +struct Cursor<'g, 'a> { + g: &'g [Pair<'a>], + i: usize, +} + +impl Cursor<'_, '_> { + /// The next group's value when it has `code` (consumed). + fn opt(&mut self, code: i32) -> Option<&str> { + let p = self.g.get(self.i).filter(|p| p.code == code)?; + self.i += 1; + Some(p.value) + } + fn num(&mut self, code: i32) -> Option { + self.opt(code).and_then(number) + } + fn count(&mut self, code: i32) -> Option { + self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize) + } + fn point(&mut self, code: i32) -> Option { + Some(Point::new(self.num(code)?, self.num(code + 10)?)) + } +} + +/// One hatch boundary loop, closed. +fn boundary(c: &mut Cursor<'_, '_>) -> Option { + let flags = c.num(92)? as i64; + let mut bp = BezPath::new(); + if flags & 2 != 0 { + let bulges = c.num(72).unwrap_or(0.0) != 0.0; + let _closed = c.opt(73); + let n = c.count(93)?; + let mut verts = Vec::with_capacity(n.min(4096)); + for _ in 0..n { + let p = c.point(10)?; + let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 }; + verts.push(Vertex { p, bulge }); + } + bp = curve::polyline(&verts, true)?; + } else { + let n = c.count(93)?; + for _ in 0..n { + let edge = edge(c)?; + append(&mut bp, &edge); + } + if bp.elements().is_empty() { + return None; + } + bp.close_path(); + } + // The boundary objects it was made from. + if let Some(n) = c.count(97) { + for _ in 0..n { + c.opt(330); + } + } + Some(bp) +} + +/// One edge of a hatch boundary. +fn edge(c: &mut Cursor<'_, '_>) -> Option { + match c.num(72)? as i64 { + 1 => { + let mut bp = BezPath::new(); + bp.move_to(c.point(10)?); + bp.line_to(c.point(11)?); + Some(bp) + } + 2 => { + let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?); + let ccw = c.num(73).unwrap_or(1.0) != 0.0; + let sweep = ccw_sweep(start, end); + // Clockwise arcs have their angles measured clockwise. + let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) }; + curve::arc(centre, r, start.to_radians(), sweep.to_radians()) + } + 3 => { + let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?); + let ccw = c.num(73).unwrap_or(1.0) != 0.0; + let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 }; + let sweep = ccw_sweep(start, end).to_radians(); + curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep) + } + 4 => { + let degree = c.count(94)?; + let rational = c.num(73).unwrap_or(0.0) != 0.0; + let _periodic = c.opt(74); + let (nk, nc) = (c.count(95)?, c.count(96)?); + let knots: Vec = (0..nk).map(|_| c.num(40)).collect::>()?; + let mut ctrl = Vec::with_capacity(nc.min(4096)); + let mut weights = Vec::with_capacity(nc.min(4096)); + for _ in 0..nc { + ctrl.push(c.point(10)?); + weights.push(c.num(42).unwrap_or(1.0)); + } + let fit: Vec = match c.count(97) { + Some(n) => (0..n).map(|_| c.point(11)).collect::>()?, + None => vec![], + }; + // Start and end tangents. + for code in [12, 13] { + c.point(code); + } + curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false)) + } + _ => None, + } +} + +/// `seg` continuing `bp`: joined by a line when it starts elsewhere. +fn append(bp: &mut BezPath, seg: &BezPath) { + for el in seg.elements() { + match *el { + PathEl::MoveTo(p) => { + let here = bp.elements().last().and_then(|e| e.end_point()); + match here { + None => bp.move_to(p), + Some(q) if q.distance(p) > 1e-9 => bp.line_to(p), + Some(_) => {} + } + } + PathEl::ClosePath => {} + other => bp.push(other), + } + } +} + +/// The baseline offset of vertical alignment `v` (0 baseline, 1 bottom, 2 middle, 3 top). +fn baseline(v: i64, height: f64) -> f64 { + match v { + 1 => height * DESCENT, + 2 => -height / 2.0, + 3 => -height, + _ => 0.0, + } +} + +fn text(g: &[Pair<'_>], attrib: bool) -> Option { + if attrib && g.int_or(70, 0) & 1 != 0 { + return None; + } + let s = super::text::plain(g.raw(1)?); + if s.trim().is_empty() { + return None; + } + let height = g.num(40).filter(|h| *h > 0.0)?; + let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); + let (p1, p2) = (g.point(10), g.point_opt(11)); + let mut angle = g.num_or(50, 0.0).to_radians(); + // Aligned and fit text run from the first point to the second. + let anchor = match (h, p2) { + (3 | 5, Some(p)) => { + angle = (p - p1).atan2(); + p1 + } + (0, _) if v == 0 => p1, + (_, Some(p)) => p, + _ => p1, + }; + let justify = match h { + 1 | 4 => Justify::Center, + 2 => Justify::Right, + _ => Justify::Left, + }; + let v = if h == 4 { 2 } else { v }; + let flags = g.int_or(71, 0); + let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 }); + let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); + Some(TextItem { + text: s, + height, + xf, + width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0), + justify, + style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + family: None, + leading: None, + }) +} + +fn mtext(g: &[Pair<'_>]) -> Option { + // The text comes in 250-character chunks (3) before the last (1). + let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect(); + let (s, family) = super::text::mtext(&raw); + if s.trim().is_empty() { + return None; + } + let height = g.num(40).filter(|h| *h > 0.0)?; + let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0); + let lines = s.lines().count().max(1) as f64; + let attach = g.int_or(71, 1).clamp(1, 9) - 1; + let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default(); + // The first baseline below the attachment point: under the top, around the middle, above + // the bottom of the lines. + let total = height + (lines - 1.0) * leading; + let first = match attach / 3 { + 0 => -height, + 1 => total / 2.0 - height, + _ => (lines - 1.0) * leading, + }; + let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); + let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); + let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); + Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) }) +} diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs new file mode 100644 index 00000000..1d83475c --- /dev/null +++ b/crates/cad/src/import/reader.rs @@ -0,0 +1,444 @@ +//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its +//! sections (header variables; the layer, linetype, text style and block record tables; the +//! blocks; the entities; the layouts). + +use std::borrow::Cow; +use std::collections::HashMap; + +use vectorcraft_geom::Point; + +/// The most group-code pairs a drawing may hold (about 300 MB of DXF). +const MAX_PAIRS: usize = 20_000_000; + +/// The first bytes of a binary DXF file (its format's signature). +pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00"; + +/// One group: its code and its value as written. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Pair<'a> { + pub code: i32, + pub value: &'a str, +} + +/// Values of a run of pairs by group code. +pub(crate) trait Groups<'a> { + /// The value of the first `code`, as written. + fn raw(&self, code: i32) -> Option<&'a str>; + /// The first `code` as a name (trimmed, never empty). + fn name(&self, code: i32) -> Option<&'a str> { + self.raw(code).map(str::trim).filter(|s| !s.is_empty()) + } + /// The first `code` as a finite number. + fn num(&self, code: i32) -> Option { + self.raw(code).and_then(number) + } + fn num_or(&self, code: i32, default: f64) -> f64 { + self.num(code).unwrap_or(default) + } + /// The first `code` as a whole number. + fn int(&self, code: i32) -> Option { + self.raw(code).and_then(integer) + } + fn int_or(&self, code: i32, default: i64) -> i64 { + self.int(code).unwrap_or(default) + } + /// The point at `code` (x) and `code + 10` (y); 0 for what is missing. + fn point(&self, code: i32) -> Point { + Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0)) + } + /// [`Groups::point`] when its x is given. + fn point_opt(&self, code: i32) -> Option { + self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0))) + } +} + +impl<'a> Groups<'a> for [Pair<'a>] { + fn raw(&self, code: i32) -> Option<&'a str> { + self.iter().find(|p| p.code == code).map(|p| p.value) + } +} + +/// A finite number (`1e999`, `NaN` and junk are none). +pub(crate) fn number(s: &str) -> Option { + s.trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// A whole number; some writers put integers as `1.0`. +pub(crate) fn integer(s: &str) -> Option { + let s = s.trim(); + s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64)) +} + +/// One object: its type (the value of its `0` group) and the groups up to the next object. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Record<'a> { + pub kind: &'a str, + pub g: &'a [Pair<'a>], +} + +/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions +/// write (a byte order mark is dropped). +pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> { + let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + match std::str::from_utf8(bytes) { + Ok(s) => Cow::Borrowed(s), + Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()), + } +} + +/// A byte of the Windows Western code page. +fn windows_1252(b: u8) -> char { + const HIGH: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', + '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ', + ]; + match b { + 0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'), + _ => char::from(b), + } +} + +/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the +/// reading: what came before is kept and the second value says where the file is damaged. +pub(crate) fn pairs(text: &str) -> Result<(Vec>, Option), String> { + let mut out = Vec::new(); + let mut lines = text.lines().enumerate(); + let mut damaged = None; + while let Some((n, code)) = lines.next() { + let c = code.trim(); + if c.is_empty() && out.is_empty() { + continue; + } + let Ok(code) = c.parse::() else { + damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1)); + break; + }; + let value = lines.next().map_or("", |(_, v)| v); + if code == 0 && value.trim() == "EOF" { + break; + } + if out.len() >= MAX_PAIRS { + return Err("the drawing is too large to open".into()); + } + out.push(Pair { code, value }); + } + if out.is_empty() { + return Err("not a DXF drawing".into()); + } + Ok((out, damaged)) +} + +/// The objects in `pairs`: each from a `0` group up to the next. +fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec> { + let mut out = Vec::new(); + let mut start: Option = None; + for (i, p) in pairs.iter().enumerate() { + if p.code == 0 { + if let Some(s) = start { + out.extend(record(pairs, s, i)); + } + start = Some(i); + } + } + if let Some(s) = start { + out.extend(record(pairs, s, pairs.len())); + } + out +} + +fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option> { + let kind = pairs.get(start)?.value.trim(); + Some(Record { kind, g: pairs.get(start + 1..end)? }) +} + +/// The header variables the import reads. +#[derive(Clone, Debug, Default)] +pub(crate) struct Header { + /// `$ACADVER` (`AC1032`…). + pub acadver: String, + /// `$INSUNITS`: the drawing unit (0: unitless). + pub insunits: i64, + /// `$MEASUREMENT`: 0 imperial, 1 metric. + pub measurement: Option, + /// `$LTSCALE`: the global linetype scale. + pub ltscale: f64, + /// `$LWDEFAULT`: the default lineweight (hundredths of a millimetre). + pub lwdefault: i32, +} + +/// A layer of the LAYER table. +#[derive(Clone, Debug)] +pub(crate) struct LayerDef { + pub name: String, + pub rgb: [u8; 3], + /// Switched off (a negative colour) or frozen. + pub hidden: bool, + pub locked: bool, + pub plot: bool, + /// Its linetype (upper case). + pub linetype: String, + /// Hundredths of a millimetre; negative: the default. + pub lineweight: i32, +} + +/// A block definition. +#[derive(Clone, Debug)] +pub(crate) struct BlockDef<'a> { + pub name: String, + pub base: Point, + pub records: Vec>, + /// A reference to another drawing (its art isn't in this file). + pub xref: bool, +} + +/// A layout: model space, or a paper layout with the block holding its art. +#[derive(Clone, Debug)] +pub(crate) struct LayoutDef { + pub name: String, + /// The block of a paper layout (upper case); `None` for model space. + pub block: Option, +} + +/// The name of model space. +pub(crate) const MODEL: &str = "Model"; +const MODEL_BLOCK: &str = "*MODEL_SPACE"; +const PAPER_BLOCK: &str = "*PAPER_SPACE"; + +/// A drawing as the import reads it. +#[derive(Debug, Default)] +pub(crate) struct Drawing<'a> { + pub header: Header, + pub layers: Vec, + /// Index into `layers` by upper-case name. + layer_index: HashMap, + /// Linetype patterns by upper-case name: dash (positive), gap (negative), dot (0) lengths. + pub linetypes: HashMap>, + /// The font family of each text style (upper-case name; empty: a CAD font). + pub styles: HashMap, + /// Blocks by upper-case name. + pub blocks: HashMap>, + /// The ENTITIES section. + pub entities: Vec>, + /// Model space first, then the paper layouts in tab order. + pub layouts: Vec, + /// How many sections the file has (none: not a drawing). + pub sections: usize, +} + +impl<'a> Drawing<'a> { + /// Read the sections of `pairs`. + pub fn read(pairs: &'a [Pair<'a>]) -> Self { + let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() }; + let all = records(pairs); + let mut section: Option<&str> = None; + let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]); + for r in all { + match (r.kind, section) { + ("SECTION", _) => { + d.sections += 1; + section = r.g.name(2); + if section == Some("HEADER") { + d.header = header(r.g); + } + } + ("ENDSEC", _) => section = None, + (_, Some("TABLES")) => tables.push(r), + (_, Some("BLOCKS")) => blocks.push(r), + (_, Some("ENTITIES")) => d.entities.push(r), + (_, Some("OBJECTS")) => objects.push(r), + _ => {} + } + } + let mut block_records = HashMap::new(); + let mut table = ""; + for r in &tables { + match r.kind { + "TABLE" => table = r.g.name(2).unwrap_or(""), + "LAYER" if table == "LAYER" => { + if let Some(l) = layer(r.g) { + d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); + d.layers.push(l); + } + } + "LTYPE" if table == "LTYPE" => { + if let Some(name) = r.g.name(2) { + let pattern: Vec = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect(); + d.linetypes.insert(name.to_uppercase(), pattern); + } + } + "STYLE" if table == "STYLE" => { + if let Some(name) = r.g.name(2) { + d.styles.insert(name.to_uppercase(), font_family(r.g)); + } + } + "BLOCK_RECORD" if table == "BLOCK_RECORD" => { + if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) { + block_records.insert(h.to_uppercase(), name.to_uppercase()); + } + } + _ => {} + } + } + d.read_blocks(&blocks, &mut block_records); + d.layouts = layouts(&objects, &block_records, &d.entities); + d + } + + fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap) { + let mut current: Option> = None; + for r in records { + match r.kind { + "BLOCK" => { + let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string(); + // R13+ blocks name their block record: layouts find their block through it. + if let Some(owner) = r.g.name(330) { + block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase()); + } + let xref = r.g.int_or(70, 0) & 4 != 0; + current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref }); + } + "ENDBLK" => { + if let Some(b) = current.take().filter(|b| !b.name.is_empty()) { + self.blocks.insert(b.name.to_uppercase(), b); + } + } + _ => { + if let Some(b) = &mut current { + b.records.push(*r); + } + } + } + } + } + + /// The layout named `name` (any case; `model` is model space). + pub fn layout(&self, name: &str) -> Option<&LayoutDef> { + self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name.trim())) + } + + /// The entity records of `layout`. + pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { + let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default(); + // The active paper layout keeps its art in the ENTITIES section, flagged paper space. + let (block_name, paper) = match &layout.block { + None => (MODEL_BLOCK, false), + Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true), + Some(b) => return block(b), + }; + let mut out: Vec> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect(); + out.extend(block(block_name)); + out + } + + /// The layer named `name` (any case). + pub fn layer(&self, name: &str) -> Option<&LayerDef> { + self.layer_index.get(&name.to_uppercase()).and_then(|i| self.layers.get(*i)) + } +} + +/// Is an entity (its first record) in paper space? +fn in_paper_space(group: &[Record<'_>]) -> bool { + group.first().is_some_and(|r| r.g.int(67) == Some(1)) +} + +/// The entities of `records`, each with the records that belong to it (a polyline's vertices, +/// an insert's attributes, and the SEQEND closing them). +pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator]> { + let mut i = 0; + std::iter::from_fn(move || { + let start = i; + let head = records.get(start)?; + i += 1; + if matches!(head.kind, "POLYLINE" | "INSERT") { + while let Some(r) = records.get(i) { + match r.kind { + "VERTEX" | "ATTRIB" => i += 1, + "SEQEND" => { + i += 1; + break; + } + _ => break, + } + } + } + records.get(start..i) + }) +} + +fn header(g: &[Pair<'_>]) -> Header { + let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }; + let mut var = ""; + for p in g { + if p.code == 9 { + var = p.value.trim(); + continue; + } + match (var, p.code) { + ("$ACADVER", 1) => h.acadver = p.value.trim().to_string(), + ("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0), + ("$MEASUREMENT", 70) => h.measurement = integer(p.value), + ("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0), + ("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25), + _ => {} + } + } + h +} + +fn layer(g: &[Pair<'_>]) -> Option { + let name = g.name(2)?.to_string(); + let aci = g.int_or(62, 7); + let flags = g.int_or(70, 0); + let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs())); + Some(LayerDef { + // CAD apps never plot this layer of dimension points. + plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"), + name, + rgb, + hidden: aci < 0 || flags & 1 != 0, + locked: flags & 4 != 0, + linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(), + lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3), + }) +} + +/// A text style's font family: the one 2000-and-later files name, else the font file's name +/// (TrueType); CAD fonts (`.shx`) have none. +fn font_family(g: &[Pair<'_>]) -> String { + if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD") + && let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000)) + { + return f.to_string(); + } + let file = g.name(3).unwrap_or(""); + let path = std::path::Path::new(file); + let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), + _ => String::new(), + } +} + +/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout +/// objects but with paper-space art has one paper layout. +fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { + let mut paper: Vec<(i64, LayoutDef)> = vec![]; + for r in objects.iter().filter(|r| r.kind == "LAYOUT") { + // The layout's own groups follow its subclass marker. + let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); + let Some(name) = own.name(1) else { continue }; + let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned(); + if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) { + continue; + } + let Some(block) = block else { continue }; + if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) { + paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) })); + } + } + paper.sort_by_key(|(order, _)| *order); + if paper.is_empty() && groups(entities).any(in_paper_space) { + paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) })); + } + std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect() +} diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs new file mode 100644 index 00000000..5a4b1ec6 --- /dev/null +++ b/crates/cad/src/import/tests.rs @@ -0,0 +1,708 @@ +//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers, +//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export +//! wrote read back. + +use std::sync::Arc; + +use kurbo::ParamCurve; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit}; +use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes}; + +use super::*; + +/// A DXF file from `(code, value)` pairs written one per line. +fn file(pairs: &[(i32, &str)]) -> Vec { + let mut s = String::new(); + for (c, v) in pairs { + s.push_str(&format!("{c:>3}\n{v}\n")); + } + s.push_str(" 0\nEOF\n"); + s.into_bytes() +} + +/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs). +fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec { + let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")]; + p.extend_from_slice(header); + p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]); + p.extend_from_slice(tables); + p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]); + p.extend_from_slice(blocks); + p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]); + p.extend_from_slice(entities); + p.push((0, "ENDSEC")); + file(&p) +} + +fn entities(e: &[(i32, &str)]) -> Vec { + drawing(&[], &[], &[], e) +} + +/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y). +fn points() -> ImportOptions { + ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() } +} + +fn open(bytes: &[u8], o: &ImportOptions) -> Imported { + import(bytes, o).unwrap() +} + +/// Every leaf object, in paint order. +fn leaves(doc: &Document) -> Vec { + let mut out = vec![]; + doc.walk(|n| { + if n.children().is_none() { + out.push(n.clone()); + } + }); + out +} + +/// The artboard's height (document y = height − drawing y). +fn height(doc: &Document) -> f64 { + doc.artboards[0].rect.height() +} + +fn path_points(n: &Node) -> Vec { + let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) }; + path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect() +} + +fn bounds(n: &Node) -> Rect { + n.geometric_bounds().unwrap() +} + +fn close(a: Rect, b: Rect, tol: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol) +} + +fn rgba(p: Option<&Paint>) -> [u8; 4] { + match p { + Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0), + other => panic!("not a solid paint: {other:?}"), + } +} + +fn stroke_rgb(n: &Node) -> [u8; 4] { + rgba(n.appearance.stroke().map(|s| &s.paint)) +} + +fn fill_rgb(n: &Node) -> [u8; 4] { + rgba(n.appearance.fill().map(|f| &f.paint)) +} + +fn text_of(n: &Node) -> &TextObject { + let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) }; + t +} + +const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")]; + +#[test] +fn a_line_comes_in_at_the_drawings_unit_and_origin() { + let doc = open(&entities(LINE), &points()).document; + let [line] = &leaves(&doc)[..] else { panic!() }; + let board = doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size"); + // Not centred: the origin sits on the artboard's bottom-left corner. + let pts = path_points(line); + assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}"); + assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9); + assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper"); + let w = line.appearance.stroke().unwrap().width; + assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}"); + assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none())); +} + +#[test] +fn units_choose_the_default_scale() { + // Millimetres at 1:1: 100 units are 100 mm. + let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE); + let doc = open(&mm, &ImportOptions::default()).document; + assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6); + assert_eq!(doc.units, Unit::Millimeters); + // Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches. + let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document; + assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6); + let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap(); + assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns")); + assert!((um.scale - 1000.0).abs() < 1e-6); + let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap(); + assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0)); + // A ratio: 1 cm = 10 units is 1:1 in millimetres again. + let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() }; + let cm = open(&mm, &o).document; + assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6); + assert_eq!(cm.units, Unit::Centimeters); +} + +#[test] +fn circles_arcs_and_ellipses() { + let e = [ + (0, "CIRCLE"), + (10, "50"), + (20, "50"), + (40, "25"), + (0, "ARC"), + (10, "200"), + (20, "50"), + (40, "20"), + (50, "0"), + (51, "90"), + (0, "ELLIPSE"), + (10, "400"), + (20, "50"), + (11, "40"), + (21, "0"), + (40, "0.5"), + (41, "0"), + (42, "6.283185307179586"), + ]; + let doc = open(&entities(&e), &points()).document; + let h = height(&doc); + let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0); + let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() }; + assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle)); + for p in path_points(circle) { + assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4); + } + // A quarter arc counter-clockwise from 0° to 90°. + assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc)); + assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse)); + let NodeKind::Path { path, .. } = &circle.kind else { panic!() }; + assert!(path.is_closed()); +} + +#[test] +fn polyline_bulges_are_exact_arcs() { + // A slot: two straight sides and two half circles (bulge 1) of radius 10. + let e = [ + (0, "LWPOLYLINE"), + (90, "4"), + (70, "1"), + (10, "0"), + (20, "0"), + (10, "100"), + (20, "0"), + (42, "1"), + (10, "100"), + (20, "20"), + (10, "0"), + (20, "20"), + (42, "1"), + ]; + let doc = open(&entities(&e), &points()).document; + let [slot] = &leaves(&doc)[..] else { panic!() }; + let h = height(&doc); + // The arcs bulge outward, so the art is 120 wide. + assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot)); + for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) { + let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs(); + assert!(err < 1e-4, "bulge error {err}"); + } + // Old-style polylines read their vertices and bulges too. + let e = [ + (0, "POLYLINE"), + (66, "1"), + (70, "0"), + (0, "VERTEX"), + (10, "0"), + (20, "0"), + (42, "-1"), + (0, "VERTEX"), + (10, "20"), + (20, "0"), + (0, "SEQEND"), + (0, "LINE"), + (10, "0"), + (20, "0"), + (11, "0"), + (21, "-30"), + ]; + let doc = open(&entities(&e), &points()).document; + let [arc, _] = &leaves(&doc)[..] else { panic!() }; + // Clockwise from (0, 0) to (20, 0): over the chord, 10 high. + let b = bounds(arc); + assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}"); + assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}"); +} + +#[test] +fn nurbs_splines_stay_within_a_hundredth() { + // A rational quadratic circle of radius 100: nine weighted control points. + let w = std::f64::consts::FRAC_1_SQRT_2.to_string(); + let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())]; + for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] { + e.push((40, k.into())); + } + for i in 0..9 { + e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() })); + } + for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] { + e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]); + } + let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect(); + let doc = open(&entities(&e), &points()).document; + let [circle] = &leaves(&doc)[..] else { panic!() }; + let h = height(&doc); + let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max); + assert!(worst < 0.01, "NURBS error {worst}"); + // A spline given by fit points alone passes through them. + let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")]; + let doc = open(&entities(&e), &points()).document; + let [s] = &leaves(&doc)[..] else { panic!() }; + let h = height(&doc); + assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9)); +} + +/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc). +fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> { + vec![ + (0, "HATCH"), + (62, "1"), + (10, "0"), + (20, "0"), + (30, "0"), + (2, if solid == "1" { "SOLID" } else { "ANSI31" }), + (70, solid), + (71, "0"), + (91, "2"), + (92, "3"), + (72, "0"), + (73, "1"), + (93, "4"), + (10, "0"), + (20, "0"), + (10, "100"), + (20, "0"), + (10, "100"), + (20, "100"), + (10, "0"), + (20, "100"), + (97, "0"), + (92, "0"), + (93, "1"), + (72, "2"), + (10, "50"), + (20, "50"), + (40, "20"), + (50, "0"), + (51, "360"), + (73, "1"), + (97, "0"), + (75, "0"), + (76, "1"), + (98, "0"), + ] +} + +#[test] +fn solid_hatches_fill_and_pattern_hatches_outline() { + let r = open(&entities(&hatch("1")), &points()); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let compound = r.document.layers[0].children().unwrap()[0].clone(); + let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) }; + assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop"); + assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]); + assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none())); + assert_eq!(bounds(&compound).area(), 10_000.0); + let r = open(&entities(&hatch("0")), &points()); + assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings); + let outline = r.document.layers[0].children().unwrap()[0].clone(); + assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]); + // Solids fill their corners in zigzag order. + let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")]; + let doc = open(&entities(&e), &points()).document; + let [square] = &leaves(&doc)[..] else { panic!() }; + assert_eq!(bounds(square).area(), 100.0); + assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none())); +} + +#[test] +fn text_and_multiline_text() { + let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")]; + let e = [ + (0, "TEXT"), + (10, "10"), + (20, "10"), + (40, "7"), + (1, "Room %%c20"), + (50, "90"), + (7, "Notes"), + (0, "MTEXT"), + (10, "100"), + (20, "100"), + (40, "3.5"), + (71, "2"), + (3, "{\\fArial|b0;First}"), + (1, "\\PSecond"), + ]; + let doc = open(&drawing(&[], &tables, &[], &e), &points()).document; + let [single, multi] = &leaves(&doc)[..] else { panic!() }; + let h = height(&doc); + let t = text_of(single); + assert_eq!(t.plain_text(), "Room ⌀20"); + let st = t.first_style(); + assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size); + assert_eq!(st.font_family, "DejaVuSans"); + // Turned a quarter counter-clockwise, its baseline starting at the insertion point. + let origin = t.xf * Point::ZERO; + assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}"); + let along = t.xf * Point::new(1.0, 0.0) - origin; + assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}"); + let m = text_of(multi); + assert_eq!(m.plain_text(), "First\nSecond"); + assert_eq!(m.para.justify, Justify::Center, "attached top centre"); + assert_eq!(m.first_style().font_family, "Arial"); + // Top attachment: the first baseline one cap height below the insertion point. + let o = m.xf * Point::ZERO; + assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}"); + let leading = m.first_style().leading.unwrap(); + assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}"); +} + +/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`. +fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec { + let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")]; + blocks.extend_from_slice(block_art); + blocks.push((0, "ENDBLK")); + let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")]; + drawing(&[], &tables, &blocks, inserts) +} + +/// A red line 10 units along x from the base point, on layer 0. +const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")]; + +#[test] +fn inserts_become_symbol_instances_with_their_transform() { + let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")]; + let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document; + assert_eq!(doc.symbols.len(), 1); + assert_eq!(doc.symbols[0].name, "Door"); + let [inst] = &leaves(&doc)[..] else { panic!() }; + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) }; + assert_eq!(symbol, "Door"); + assert_eq!(doc.layers[0].name.as_deref(), Some("Doors")); + // Scaled 2 and turned a quarter: from the insertion point 20 units up. + let art = &doc.symbols[0].art; + let pts: Vec = path_points(art).iter().map(|p| *xf * *p).collect(); + let h = height(&doc); + let (a, b) = (pts.first().unwrap(), pts.last().unwrap()); + assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}"); + assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}"); + assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour"); +} + +#[test] +fn by_block_colours_make_a_symbol_per_look() { + // The block's line takes the insert's colour: red and green make two symbols; a third red + // insert reuses the first. + let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")]; + let inserts = [ + (0, "INSERT"), + (2, "Door"), + (62, "1"), + (10, "0"), + (20, "0"), + (0, "INSERT"), + (2, "Door"), + (62, "3"), + (10, "50"), + (20, "0"), + (0, "INSERT"), + (2, "Door"), + (62, "1"), + (10, "100"), + (20, "0"), + ]; + let doc = open(&with_door(by_block, &inserts), &points()).document; + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Door", "Door 2"]); + assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]); + assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]); + assert_eq!(leaves(&doc).len(), 3); + // Layer 0 art takes the insert's layer's colour. + let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")]; + let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")]; + let doc = open(&with_door(on_zero, &inserts), &points()).document; + assert_eq!(doc.symbols.len(), 1); + assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue"); + // Art with its own colour shares one symbol, whatever the inserts' colours. + let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")]; + let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document; + assert_eq!(doc.symbols.len(), 1); +} + +#[test] +fn array_inserts_attributes_and_dimensions() { + let inserts = [ + (0, "INSERT"), + (66, "1"), + (2, "Door"), + (10, "0"), + (20, "0"), + (70, "3"), + (71, "2"), + (44, "20"), + (45, "30"), + (0, "ATTRIB"), + (10, "0"), + (20, "-10"), + (40, "2"), + (1, "D-01"), + (2, "TAG"), + (70, "0"), + (0, "ATTRIB"), + (10, "0"), + (20, "-20"), + (40, "2"), + (1, "hidden"), + (2, "NOTE"), + (70, "1"), + (0, "SEQEND"), + ]; + let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document; + let all = leaves(&doc); + let instances: Vec = + all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect(); + assert_eq!(instances.len(), 6, "3 columns × 2 rows"); + let xs: std::collections::BTreeSet = instances.iter().map(|p| p.x.round() as i64).collect(); + assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}"); + let texts: Vec = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); + assert_eq!(texts, ["D-01"], "invisible attributes stay out"); + // A dimension draws its anonymous block as art, not a symbol. + let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")]; + let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")]; + let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document; + assert!(doc.symbols.is_empty()); + assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0); +} + +#[test] +fn blocks_inside_themselves_are_left_out_without_hanging() { + let blocks = [ + (0, "BLOCK"), + (2, "Loop"), + (10, "0"), + (20, "0"), + (0, "LINE"), + (10, "0"), + (20, "0"), + (11, "10"), + (21, "0"), + (0, "INSERT"), + (2, "Loop"), + (10, "20"), + (20, "0"), + (0, "ENDBLK"), + ]; + let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points()); + assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings); + assert_eq!(r.document.symbols.len(), 1); +} + +#[test] +fn layers_keep_their_state_colour_lineweight_and_linetype() { + let tables = [ + (0, "TABLE"), + (2, "LTYPE"), + (0, "LTYPE"), + (2, "DASHED"), + (73, "2"), + (40, "15"), + (49, "10"), + (49, "-5"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "LAYER"), + (0, "LAYER"), + (2, "Hidden"), + (70, "0"), + (62, "-3"), + (6, "DASHED"), + (370, "50"), + (0, "LAYER"), + (2, "Frozen"), + (70, "1"), + (62, "2"), + (0, "LAYER"), + (2, "Locked"), + (70, "4"), + (62, "1"), + (420, "16744448"), + (290, "0"), + (0, "ENDTAB"), + ]; + let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")]; + let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat(); + let doc = open(&drawing(&[], &tables, &[], &e), &points()).document; + let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect(); + assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]); + let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() }; + assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible); + assert!(locked.locked && !hidden.locked); + assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. })); + let first = |l: &Arc| l.children().unwrap()[0].clone(); + // Colour 3 (green) and a 0.5 mm dashed line from the layer. + let h = first(hidden); + assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]); + let st = h.appearance.stroke().unwrap(); + assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9); + assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]); + assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]); + assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index"); + // Merge Layers: one layer. + let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document; + assert_eq!(merged.layers.len(), 1); + assert_eq!(merged.layers[0].children().unwrap().len(), 4); +} + +#[test] +fn entity_colours_lineweights_and_transparency() { + let e = [ + (0, "LINE"), + (62, "30"), + (370, "100"), + (440, "33554559"), + (10, "0"), + (20, "0"), + (11, "10"), + (21, "0"), + (0, "LINE"), + (62, "5"), + (420, "65280"), + (370, "0"), + (10, "0"), + (20, "10"), + (11, "10"), + (21, "10"), + ]; + let doc = open(&entities(&e), &points()).document; + let [a, b] = &leaves(&doc)[..] else { panic!() }; + assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange"); + assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm"); + assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6); + assert_eq!(stroke_rgb(b), [0, 255, 0, 255]); + assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line"); + // Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too. + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() }; + let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document; + let w = leaves(&doc)[0].appearance.stroke().unwrap().width; + let k = 0.1 / (72.0 / 25.4); + assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}"); +} + +#[test] +fn fit_and_centre_place_the_art_on_the_artboard() { + let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document; + let board = doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art"); + let b = bounds(&leaves(&doc)[0]); + assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}"); + let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() }; + let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]); + assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}"); + // Centred at a ratio, the artboard is the art's bounds. + let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document; + assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9)); + // Art too large for the canvas at the scale asked for says so. + let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() }; + let e = import(&entities(LINE), &o).unwrap_err(); + assert!(e.contains("canvas"), "{e}"); + let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err(); + assert!(e.contains("scale"), "{e}"); +} + +#[test] +fn paper_layouts() { + let mut e = LINE.to_vec(); + e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]); + let bytes = entities(&e); + assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]); + let model = leaves(&open(&bytes, &points()).document); + assert_eq!(model.len(), 1); + assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line"); + let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document); + assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle"); + let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err(); + assert!(e.contains("Model, Layout1"), "{e}"); +} + +#[test] +fn binary_dwg_damaged_and_foreign_files() { + let mut binary = BINARY_SENTINEL.to_vec(); + binary.extend([0u8; 40]); + assert!(is_dxf(&binary)); + let e = import(&binary, &ImportOptions::default()).unwrap_err(); + assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}"); + let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err(); + assert!(e.contains("DWG"), "{e}"); + for junk in [&b""[..], b"hello", b"", b"999\nnote\n"] { + assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}"); + assert!(!is_dxf(junk), "{junk:?}"); + } + assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n")); + // A file cut off in the middle keeps what came before. + let mut cut = entities(LINE); + cut.truncate(cut.len() - 30); + cut.extend(b"\nnot a code\n"); + let r = open(&cut, &points()); + assert_eq!(leaves(&r.document).len(), 1); + assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings); + // Hostile numbers are dropped; unknown entities are listed. + let e = + [(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")]; + let r = open(&entities(&e), &points()); + assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings); +} + +#[test] +fn drawings_the_export_writes_read_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let id = doc.alloc_id(); + let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)); + let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0); + doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap(); + let id = doc.alloc_id(); + let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0)); + doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap(); + for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] { + let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() }; + let out = crate::export(&doc, &opts).unwrap(); + let back = open(&out.bytes, &points()); + let art = leaves(&back.document); + // Same place on the artboard (the export's origin is the artboard's bottom-left). + let shift = Affine::translate((0.0, 100.0 - height(&back.document))); + let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap(); + assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}"); + let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap(); + assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}"); + if version >= crate::DxfVersion::R2000 { + assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch"); + let w = stroked.appearance.stroke().unwrap().width; + assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}"); + } + } +} + +#[test] +fn info_lists_layers_layouts_and_version() { + let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")]; + let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap(); + assert_eq!(i.version, "2000"); + assert_eq!(i.units, "Meters"); + assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0)); + assert_eq!(i.layers, ["0", "Walls"]); + assert_eq!(i.layouts, ["Model"]); + assert!(info(b"not a drawing").is_err()); +} + +#[test] +fn entities_extruded_along_minus_z_are_mirrored() { + let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")]; + let doc = open(&entities(&e), &points()).document; + let b = bounds(&leaves(&doc)[0]); + assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}"); +} diff --git a/crates/cad/src/import/text.rs b/crates/cad/src/import/text.rs new file mode 100644 index 00000000..caf5f9be --- /dev/null +++ b/crates/cad/src/import/text.rs @@ -0,0 +1,157 @@ +//! CAD text values as plain text: control codes (`%%d`), Unicode escapes (`\U+00B0`), caret +//! escapes (`^I`) and, in multiline text, the formatting codes (`\P` paragraph breaks, `{\fArial;…}` +//! font changes, `\S1/2;` stacked fractions…). + +/// A single-line text value (TEXT, ATTRIB) as plain text. +pub(crate) fn plain(s: &str) -> String { + percent_codes(&escapes(s)) +} + +/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family +/// its first font change names. +pub(crate) fn mtext(s: &str) -> (String, Option) { + let s = escapes(s); + let mut out = String::with_capacity(s.len()); + let mut family = None; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' | '}' => {} + '\\' => match chars.next() { + Some('P' | 'X' | 'N') => out.push('\n'), + Some('~') => out.push('\u{A0}'), + Some(c @ ('\\' | '{' | '}')) => out.push(c), + Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {} + Some('S') => { + // A stacked fraction `\Snum^den;` (or / or #): written as num/den. + let arg = until_semicolon(&mut chars); + out.push_str(&arg.replace(['^', '#'], "/")); + } + Some(f @ ('f' | 'F')) => { + let arg = until_semicolon(&mut chars); + if family.is_none() && f == 'f' { + family = arg.split('|').next().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + } + } + // Height, colour, tracking, oblique, width, alignment, paragraph: settings with + // an argument. + Some('H' | 'C' | 'c' | 'T' | 'Q' | 'W' | 'A' | 'p') => { + until_semicolon(&mut chars); + } + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + }, + c => out.push(c), + } + } + (percent_codes(&out), family) +} + +/// The characters up to the next `;` (dropped). +fn until_semicolon(chars: &mut std::iter::Peekable>) -> String { + let mut arg = String::new(); + for c in chars.by_ref() { + if c == ';' { + break; + } + arg.push(c); + } + arg +} + +/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page) +/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. +fn escapes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(c) = rest.chars().next() { + let after = rest.get(c.len_utf8()..).unwrap_or(""); + if c == '\\' + && let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4)) + && let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + { + out.push(ch); + rest = after.get(6..).unwrap_or(""); + continue; + } + if c == '^' + && let Some(next) = after.chars().next() + { + match next { + ' ' => out.push('^'), + 'I' => out.push('\t'), + _ => {} + } + rest = after.get(next.len_utf8()..).unwrap_or(""); + continue; + } + if !c.is_control() || c == '\t' { + out.push(c); + } + rest = after; + } + out +} + +/// `%%` control codes: `%%d` degree, `%%p` plus-minus, `%%c` diameter, `%%%` percent, `%%nnn` a +/// character code; the underline, overline and strike-through toggles (`%%u`, `%%o`, `%%k`) are +/// dropped. +fn percent_codes(s: &str) -> String { + if !s.contains("%%") { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("%%") { + out.push_str(rest.get(..i).unwrap_or("")); + let code = rest.get(i + 2..).unwrap_or(""); + let mut skip = 1; + match code.chars().next().map(|c| c.to_ascii_lowercase()) { + Some('d') => out.push('°'), + Some('p') => out.push('±'), + Some('c') => out.push('⌀'), + Some('%') => out.push('%'), + Some('u' | 'o' | 'k') => {} + Some(c) if c.is_ascii_digit() => { + let digits: String = code.chars().take(3).take_while(char::is_ascii_digit).collect(); + skip = digits.len(); + out.extend(digits.parse::().ok().and_then(char::from_u32).filter(|c| !c.is_control())); + } + _ => { + out.push_str("%%"); + skip = 0; + } + } + rest = code.get(skip..).unwrap_or(""); + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_codes_and_escapes() { + assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under"); + assert_eq!(plain("caf\\U+00E9 ^ x^I^Jy"), "café ^x\ty", "`^ ` is a caret"); + assert_eq!(plain("%%065%%066"), "AB"); + assert_eq!(plain("50%% off"), "50%% off", "an unknown code stays"); + assert_eq!(plain("\\U+"), "\\U+"); + } + + #[test] + fn mtext_formatting_is_dropped() { + let (t, family) = mtext("{\\fArial|b1|i0|c0|p34;Title}\\P\\H2.5x;Line \\C1;two\\~\\S1/2;\\Pa\\\\b {\\Lunder}"); + assert_eq!(t, "Title\nLine two\u{A0}1/2\na\\b under"); + assert_eq!(family.as_deref(), Some("Arial")); + // Carets are written "^ " (a caret before another character is a control character). + assert_eq!(mtext("x\\S3^ 4;").0, "x3/4"); + assert_eq!(mtext("x\\S3#4;").0, "x3/4"); + assert_eq!(mtext("trailing\\").0, "trailing\\"); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index fd805d6e..1d2e3c3e 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,4 +1,4 @@ -//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader. //! //! [`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 @@ -10,11 +10,15 @@ //! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns, //! clipping) is approximated or left out, and each such loss comes back as a warning. //! +//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! //! - `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. +//! - `import`: the reader, entities to art, blocks to symbols. mod aci; +pub mod import; mod scene; mod writer; @@ -22,6 +26,21 @@ use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; pub use aci::{aci_rgb, nearest_aci}; +pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf}; + +/// How deep symbols, blocks 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; + +/// The `$INSUNITS` codes of the units documents use (feet and inches write feet). +const INSUNITS: [(u8, Unit); 7] = + [(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)]; + +/// The `$INSUNITS` code of `unit` (none for points, picas and pixels). +fn insunits_code(unit: Unit) -> Option { + INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c) +} /// The DXF version a file is written for (the `$ACADVER` it declares). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index e3a2667c..11d15e43 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -13,15 +13,11 @@ 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}; +use crate::{CAP_HEIGHT, ColorDepth, DxfImage, DxfOptions, DxfOutput, MAX_NEST, 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). @@ -227,15 +223,7 @@ impl<'a> Scene<'a> { 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 insunits = if one { crate::insunits_code(self.o.unit).unwrap_or(0) } else { 0 }; let drawing = Drawing { version: self.o.version, layers: self.layers, diff --git a/crates/engine/src/cmd/fileio/dxfimport.rs b/crates/engine/src/cmd/fileio/dxfimport.rs new file mode 100644 index 00000000..3d2b6664 --- /dev/null +++ b/crates/engine/src/cmd/fileio/dxfimport.rs @@ -0,0 +1,92 @@ +//! Opening and placing DXF drawings: the `dxf` options `document.open` and `file.place` read +//! (layout, scale or fit, lineweights, centring, merged layers) and `document.dxfInfo` (layouts, +//! layers, units) for the DXF Import Options dialog. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_cad::ImportOptions; +use vectorcraft_doc::{Document, Unit}; + +use super::super::*; +use super::{err, source}; + +pub fn specs() -> Vec { + vec![cmd!( + query "document.dxfInfo", + "DXF Info", + [], + None, + "{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF", + always, + dxf_info + )] +} + +/// The `dxf` params, as they come. +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct Params { + layout: Option, + fit: Option, + fit_to: Option<[f64; 2]>, + unit: Option, + scale: Option, + scale_lineweights: Option, + center: Option, + merge_layers: Option, +} + +/// The DXF import options in `p` (the `dxf` object of `document.open` and `file.place`). +pub(super) fn options(cmd: &str, p: Option<&Value>) -> Result { + let d = ImportOptions::default(); + let Some(p) = p.filter(|v| !v.is_null()) else { return Ok(d) }; + if !p.is_object() { + return Err(bad(cmd, "dxf must be an object of DXF import options")); + } + let q = Params::deserialize(p).map_err(|e| bad(cmd, format!("dxf: {e}")))?; + let unit = match q.unit.as_deref() { + None => None, + Some(u) => Some(Unit::named(u).ok_or_else(|| bad(cmd, format!("dxf unit `{u}`: a unit such as mm, cm, in or pt")))?), + }; + if let Some(s) = q.scale + && !(s.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&s)) + { + return Err(bad(cmd, format!("dxf scale must be a positive number up to {}, not {s}", vectorcraft_cad::SCALE_RANGE.end()))); + } + let fit_to = match q.fit_to { + None => d.fit_to, + Some([w, h]) if w > 0.0 && h > 0.0 && w.max(h) <= crate::MAX_COORD => (w, h), + Some(_) => return Err(bad(cmd, "dxf fitTo must be [width, height], positive")), + }; + Ok(ImportOptions { + layout: q.layout.filter(|l| !l.trim().is_empty()), + fit: q.fit.unwrap_or(d.fit), + fit_to, + unit, + scale: q.scale, + scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), + center: q.center.unwrap_or(d.center), + merge_layers: q.merge_layers.unwrap_or(d.merge_layers), + }) +} + +/// Import a DXF drawing → the document and import notes. +pub(super) fn import(bytes: &[u8], o: &ImportOptions) -> Result<(Document, Vec)> { + let r = vectorcraft_cad::import(bytes, o).map_err(err)?; + Ok((r.document, r.warnings)) +} + +const INFO: &str = "document.dxfInfo"; + +fn dxf_info(_: &mut Session, p: &Value) -> Result { + let bytes = source(p, INFO)?.bytes; + let i = vectorcraft_cad::info(&bytes).map_err(err)?; + Ok(json!({ + "version": i.version, + "units": i.units, + "layouts": i.layouts, + "layers": i.layers, + "unit": i.unit.label(), + "scale": i.scale, + })) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index dbef8d83..de624ab4 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -59,6 +59,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { // .ai and .ait files are PDF inside; the extension keeps the template meaning. return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf")); } + if vectorcraft_cad::is_dxf(bytes) { + return format("dxf"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -116,7 +119,7 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { load_with(name, bytes, &LoadOptions::default()) } -/// [`load`] with `document.open` options (the PDF pages, box and password). +/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { // PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by // any name. @@ -152,6 +155,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result .map(|e| (e.intact, move || Some(e.data))); restore_or_import(editing, || super::pdfimport::import(bytes, opts))? } + "dxf" => { + let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?; + (doc, warnings, false) + } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 4fdf8083..35fe3e5d 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -1,7 +1,7 @@ //! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open, //! save, export and serialize by path or bytes (base64), the format table, and `command.batch`. //! -//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images). +//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. @@ -13,12 +13,14 @@ //! - `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). //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. +//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. mod batch; pub mod dxf; +pub mod dxfimport; mod encode; pub mod eps; mod export; @@ -70,7 +72,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -433,7 +435,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 }, + Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: true, write: true, raster: false, options: dxf::OPTIONS }, Format { id: "eps", label: "EPS", @@ -448,14 +450,32 @@ pub const FORMATS: &[Format] = &[ /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). pub const OPEN_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"]; + &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "dxf"]; /// Text files: File → Place sets them as area type (Text Import Options). pub const TEXT_EXTS: &[&str] = &["txt"]; /// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`]. -pub const PLACE_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"]; +pub const PLACE_EXTS: &[&str] = &[ + "vectorcraft", + "drawcraft", + "svg", + "svgz", + "pdf", + "ai", + "ait", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "vctemplate", + "dxf", + "txt", +]; /// One dialog filter per readable format. fn format_filters() -> impl Iterator { @@ -731,3 +751,6 @@ mod tests_svgimport; #[cfg(test)] mod tests_eps; + +#[cfg(test)] +mod tests_dxfimport; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index a1712acc..31eec450 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -10,7 +10,7 @@ use super::super::*; use super::{err, source}; /// The `document.open` options besides the file. The PDF ones (pages, box, password, text, -/// layers) are ignored by other formats. +/// layers) and the DXF ones are ignored by other formats. #[derive(Clone, Debug, PartialEq)] pub struct LoadOptions { /// 1-based pages to import, such as `"2-3, 5"`; `None` = every page. @@ -27,18 +27,28 @@ pub struct LoadOptions { /// A PDF's optional content groups become layers (else one layer per page, without the art /// that is off). pub layers: bool, + /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). + pub dxf: vectorcraft_cad::ImportOptions, } impl Default for LoadOptions { fn default() -> Self { - Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true } + Self { + pages: None, + crop: CropTo::default(), + password: None, + color_mode: None, + text_as: TextAs::default(), + layers: true, + dxf: Default::default(), + } } } impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`), - /// `textAs` (`text` | `outlines`) and `layers` (true | false). + /// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -60,7 +70,8 @@ impl LoadOptions { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, }; - Ok(Self { pages, crop, password, color_mode, text_as, layers }) + let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; + Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 87a415ab..2d0cfbcf 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -150,7 +150,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let dxf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "dxf").unwrap().clone(); - assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(false), Some(true))); + // Read too since DXF import (tests_dxfimport). + assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(true), Some(true))); for o in [ "version", "unit", diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs new file mode 100644 index 00000000..ec7b295c --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs @@ -0,0 +1,243 @@ +//! DXF through the commands: `document.open` with the DXF options, `document.dxfInfo`, +//! File → Place of a drawing (fitted, centred or at its origin), the format table, binary files, +//! and a drawing `document.export` wrote opened again. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Rect; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// A millimetre drawing: a 200 × 100 rectangle on layer Walls, a circle on layer Notes, a block +/// "Mark" (a short line) inserted twice, and a paper layout holding a line. +fn plan() -> Vec { + let pairs: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "HEADER"), + (9, "$ACADVER"), + (1, "AC1015"), + (9, "$INSUNITS"), + (70, "4"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "LAYER"), + (0, "LAYER"), + (2, "Walls"), + (70, "0"), + (62, "1"), + (0, "LAYER"), + (2, "Notes"), + (70, "4"), + (62, "5"), + (0, "ENDTAB"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "BLOCKS"), + (0, "BLOCK"), + (2, "Mark"), + (10, "0"), + (20, "0"), + (0, "LINE"), + (8, "0"), + (10, "0"), + (20, "0"), + (11, "10"), + (21, "0"), + (0, "ENDBLK"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "ENTITIES"), + (0, "LWPOLYLINE"), + (8, "Walls"), + (90, "4"), + (70, "1"), + (10, "0"), + (20, "0"), + (10, "200"), + (20, "0"), + (10, "200"), + (20, "100"), + (10, "0"), + (20, "100"), + (0, "CIRCLE"), + (8, "Notes"), + (10, "100"), + (20, "50"), + (40, "20"), + (0, "INSERT"), + (8, "Walls"), + (2, "Mark"), + (10, "20"), + (20, "20"), + (0, "INSERT"), + (8, "Walls"), + (2, "Mark"), + (10, "150"), + (20, "20"), + (0, "LINE"), + (67, "1"), + (10, "0"), + (20, "0"), + (11, "50"), + (21, "0"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect::().into_bytes() +} + +fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result { + let mut p = json!({"name": "plan.dxf", "dataBase64": b64(bytes)}); + if let (Some(p), Value::Object(e)) = (p.as_object_mut(), extra) { + p.extend(e); + } + s.execute("document.open", &p) +} + +fn layer_names(s: &Session) -> Vec { + s.doc().unwrap().doc.layers.iter().filter_map(|l| l.name.clone()).collect() +} + +fn leaves(s: &Session) -> Vec { + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + if n.children().is_none() { + out.push(n.clone()); + } + }); + out +} + +const MM: f64 = 72.0 / 25.4; + +#[test] +fn document_open_reads_a_dxf_drawing() { + let mut s = Session::new(); + let r = open(&mut s, &plan(), json!({})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf"))); + assert_eq!(layer_names(&s), ["Walls", "Notes"]); + let st = s.doc().unwrap(); + // Millimetres at 1:1, the artboard the art's size. + let board = st.doc.artboards[0].rect; + assert!((board.width() - 200.0 * MM).abs() < 1e-6 && (board.height() - 100.0 * MM).abs() < 1e-6, "{board:?}"); + assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters); + assert_eq!(st.doc.symbols.len(), 1, "the block is a symbol"); + assert_eq!(leaves(&s).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2); + assert!(st.doc.layers[1].locked, "Notes is locked"); + // Saving asks for a name: DXF opens as artwork, it isn't written back. + assert!(st.path.is_none()); +} + +#[test] +fn document_open_takes_the_dxf_options() { + let mut s = Session::new(); + open(&mut s, &plan(), json!({"dxf": {"fit": true, "mergeLayers": true}})).unwrap(); + assert_eq!(layer_names(&s), ["Layer 1"]); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (792.0, 612.0), "fitted to a landscape letter page"); + open(&mut s, &plan(), json!({"dxf": {"unit": "cm", "scale": 1}})).unwrap(); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert!((board.width() - 200.0 * MM * 10.0).abs() < 1e-6, "1 cm = 1 unit: {board:?}"); + open(&mut s, &plan(), json!({"dxf": {"layout": "Layout1"}})).unwrap(); + assert_eq!(leaves(&s).len(), 1, "the paper layout's line"); + for bad in [json!({"dxf": {"unit": "furlong"}}), json!({"dxf": {"scale": 0}}), json!({"dxf": {"layout": "Sheet 9"}}), json!({"dxf": 3})] { + assert!(open(&mut s, &plan(), bad.clone()).is_err(), "{bad}"); + } +} + +#[test] +fn dxf_info_lists_layouts_layers_and_units() { + let mut s = Session::new(); + let r = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&plan())})).unwrap(); + assert_eq!(r["version"], "2000"); + assert_eq!(r["units"], "Millimeters"); + assert_eq!(r["layouts"], json!(["Model", "Layout1"])); + assert_eq!(r["layers"], json!(["Walls", "Notes"])); + assert_eq!((r["unit"].as_str(), r["scale"].as_f64()), (Some("Millimeters"), Some(1.0))); +} + +#[test] +fn binary_dxf_says_to_save_it_as_ascii() { + let mut s = Session::new(); + let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec(); + bytes.extend([0u8; 64]); + let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string(); + assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}"); + let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string(); + assert!(e.contains("binary DXF"), "{e}"); +} + +#[test] +fn dxf_is_readable_and_detected_by_content() { + let f = format("dxf").unwrap(); + assert!(f.read && f.write); + assert!(OPEN_EXTS.contains(&"dxf") && PLACE_EXTS.contains(&"dxf")); + assert_eq!(detect("drawing.txt", &plan()).map(|f| f.id), Some("dxf"), "the content tells"); + let mut s = Session::new(); + let r = s.execute("document.formats", &json!({})).unwrap(); + assert!(r["readable"].as_array().unwrap().contains(&json!("dxf"))); +} + +/// A document of one 600 × 400 artboard. +fn page() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn placed(s: &mut Session, dxf: Value) -> (Value, Rect) { + let mut p = json!({"name": "plan.dxf", "dataBase64": b64(&plan())}); + if !dxf.is_null() { + p["dxf"] = dxf; + } + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap()); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (r, b) +} + +#[test] +fn file_place_puts_a_drawing_in_as_one_group() { + let mut s = page(); + let (r, b) = placed(&mut s, Value::Null); + assert_eq!(r["format"], "dxf"); + let st = s.doc().unwrap(); + let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap()); + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Group { clip: false, .. })); + assert_eq!(st.doc.symbols.len(), 1, "its symbol joins the document"); + // Centred on the artboard at 1:1. + assert!((b.center().x - 300.0).abs() < 0.5 && (b.center().y - 200.0).abs() < 0.5, "{b:?}"); + assert!((b.width() - 200.0 * MM).abs() < 1.0, "{b:?}"); + // Fit: the artboard's size. + let (_, b) = placed(&mut s, json!({"fit": true})); + assert!((b.width() - 600.0).abs() < 1.0 && (b.center().y - 200.0).abs() < 0.5, "{b:?}"); + // Not centred: the drawing's origin on the artboard's bottom-left corner. + let (_, b) = placed(&mut s, json!({"center": false, "unit": "pt", "scale": 1})); + assert!((b.x0 - 0.0).abs() < 0.5 && (b.y1 - 400.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn a_drawing_export_writes_opens_again() { + let mut s = page(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 350, "y": 100, "width": 100, "height": 100})).unwrap(); + let r = s.execute("document.export", &json!({"format": "dxf", "unit": "pt"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = open(&mut s, &bytes, json!({"dxf": {"unit": "pt", "scale": 1, "center": false}})).unwrap(); + assert_eq!(r["format"], "dxf"); + let all: Vec = leaves(&s).iter().filter_map(|n| n.geometric_bounds()).collect(); + assert_eq!(all.len(), 2, "{all:?}"); + let h = s.doc().unwrap().doc.artboards[0].rect.height(); + // The export's origin is the artboard's bottom-left: the art keeps its place above it. + let union = all.iter().copied().reduce(|a, b| a.union(b)).unwrap(); + assert!((union.width() - 350.0).abs() < 2.0 && (union.y1 - h + 200.0).abs() < 2.0, "{union:?} on {h}"); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a56a048e..9004c3a4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -232,6 +232,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(slices::specs()); v.extend(print::specs()); v.extend(recovery::specs()); + v.extend(fileio::dxfimport::specs()); v }) } diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 02b6ac43..13d8ec07 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -4,7 +4,8 @@ //! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). -//! - An SVG becomes one group of its art. +//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options +//! (`dxf`, [`fileio::dxfimport`]). //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "bounding"`). @@ -47,7 +48,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -74,7 +75,7 @@ pub fn specs() -> Vec { "Load Place Cursor", [], None, - "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", + "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", has_doc, queue ), @@ -107,6 +108,9 @@ struct Loaded { warnings: Vec, /// The link to a raster image's file (for Link), when read from a path. link: Option, + /// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands + /// on the target artboard the same way. + board: Option, } /// Points per pixel of a raster image at the resolution it declares (72 ppi when none). @@ -115,8 +119,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { (72.0 / x, 72.0 / y) } -/// The file `p` names, read into placeable art. -fn load(p: &Value, cmd: &str) -> Result { +/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board` +/// (unless the params name their own box). +fn load(p: &Value, cmd: &str, board: Option) -> Result { let src = fileio::source(p, cmd)?; let name = fileio::file_name(src.name); if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) { @@ -124,7 +129,7 @@ fn load(p: &Value, cmd: &str) -> Result { if text.trim().is_empty() { return Err(bad(cmd, format!("`{name}` has no text to place"))); } - return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None }); + return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None, board: None }); } let format = fileio::detect(src.name, &src.bytes) .ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?; @@ -132,7 +137,12 @@ fn load(p: &Value, cmd: &str) -> Result { None => 1, Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize, }; - let opts = fileio::LoadOptions::from_params(cmd, p)?; + let mut opts = fileio::LoadOptions::from_params(cmd, p)?; + if let Some(b) = board + && p.get("dxf").and_then(|d| d.get("fitTo")).is_none() + { + opts.dxf.fit_to = (b.width(), b.height()); + } let pdf = matches!(format.id, "pdf" | "ai" | "ait"); if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) { return Err(bad( @@ -146,8 +156,9 @@ fn load(p: &Value, cmd: &str) -> Result { let (sx, sy) = pt_per_px(img.ppi); let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy); let link = src.path.map(|path| super::links::link_info(path, &src.bytes)); - return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link }); + return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link, board: None }); } + let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center); let (mut doc, warnings) = match format.id { // Only the page placed is read, its artboard the box asked for (Bounding Box: the art's // bounds, below). @@ -156,15 +167,18 @@ fn load(p: &Value, cmd: &str) -> Result { fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))? } _ => { - let mut l = fileio::load(src.name, &src.bytes)?; + // Of the open options, only a DXF drawing's apply (the colour mode stays the file's). + let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() }; + let mut l = fileio::load_with(src.name, &src.bytes, &o)?; // Linked images (a native document's) show their files, found from its folder. super::links::resolve(&mut l.doc, src.path, false); (l.doc, l.warnings) } }; doc.drop_edit_modes(); + let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored); let layers = std::mem::take(&mut doc.layers); - let (nodes, clip) = if matches!(format.id, "svg" | "svgz") { + let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") { (art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None) } else { // The page placed is a PDF import's only one. @@ -184,7 +198,7 @@ fn load(p: &Value, cmd: &str) -> Result { }; let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))); let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; - Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None }) + Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board }) } /// A visible, non-template layer. @@ -301,7 +315,8 @@ fn place(s: &mut Session, p: &Value) -> Result { (true, [id]) => Some(*id), (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), }; - let mut loaded = load(p, PLACE)?; + let target = board_at(&st.doc, at); + let mut loaded = load(p, PLACE, target)?; let old_node = old.and_then(|id| st.doc.node(id)); // Type reflows rather than scales: its frame takes the size it is placed at. let is_text = matches!(loaded.art, Art::Text(_)); @@ -317,6 +332,8 @@ fn place(s: &mut Session, p: &Value) -> Result { let m = match (old_node, rect) { (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text), (None, Some(r)) => fit(natural, r), + // Its artboard's bottom-left corner on the target's. + (None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)), (None, None) => { let c = at.or_else(|| st.doc.artboards.first().map(|a| a.rect.center())).unwrap_or_default(); Affine::translate(c - natural.center()) @@ -353,10 +370,15 @@ fn place(s: &mut Session, p: &Value) -> Result { ) } +/// The artboard under `at`, else the first. +fn board_at(d: &Document, at: Option) -> Option { + at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map(|a| a.rect) +} + /// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a /// 36 pt margin. fn text_frame(d: &Document, at: Option) -> Rect { - let board = at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map_or(TEXT_FRAME, |a| a.rect); + let board = board_at(d, at).unwrap_or(TEXT_FRAME); Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0))) } @@ -402,12 +424,12 @@ fn queue(s: &mut Session, p: &Value) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); for mut q in queued_files(p)? { - for k in ["link", "template", "page", "crop", "text"] { + for k in ["link", "template", "page", "crop", "text", "dxf"] { if let Some(v) = p.get(k) { q[k] = v.clone(); } } - match load(&q, QUEUE) { + match load(&q, QUEUE, None) { Ok(l) => { let (w, h) = (l.natural.width(), l.natural.height()); let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h }); @@ -465,7 +487,7 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result { } fn info(_: &mut Session, p: &Value) -> Result { - let mut l = load(p, "file.place.info")?; + let mut l = load(p, "file.place.info", None)?; let natural = l.natural; let mut out = json!({ "name": l.name, diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ed0a8e21..768342c6 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,4 +1,4 @@ -//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input, +//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, //! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may @@ -884,3 +884,110 @@ proptest! { r.map_err(|msg| TestCaseError::fail(format!("{password}: panicked: {msg}")))?; } } + +// ---------- DXF ---------- + +/// The rich document exported as DXF (2018: hatches, splines, text, images; R12: polylines). +fn rich_dxf(version: &str) -> Vec { + vectorcraft_engine::cmd::fileio::encode(&rich_doc(), "dxf", &json!({"version": version, "preserve": "editability"})).unwrap() +} + +/// Group-code pairs as DXF text. +fn dxf_text(pairs: &[(i32, String)]) -> String { + pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect() +} + +/// One entity: a type and a run of groups with hostile numbers and names. +fn arb_dxf_entity() -> impl Strategy> { + let kind = prop::sample::select(vec![ + "LINE", + "CIRCLE", + "ARC", + "ELLIPSE", + "LWPOLYLINE", + "POLYLINE", + "VERTEX", + "SEQEND", + "SPLINE", + "SOLID", + "3DFACE", + "HATCH", + "TEXT", + "MTEXT", + "INSERT", + "ATTRIB", + "DIMENSION", + "VIEWPORT", + ]); + let code = prop::sample::select(vec![ + 2, 6, 7, 8, 10, 11, 12, 13, 20, 21, 22, 23, 40, 41, 42, 43, 44, 45, 48, 50, 51, 60, 62, 66, 67, 70, 71, 72, 73, 74, 75, 76, 90, 91, 92, 93, + 94, 95, 96, 97, 210, 220, 230, 370, 420, 440, 450, + ]); + let value = prop_oneof![arb_num(), Just("B".to_string()), Just("*U1".to_string()), Just(r"{\fA;x}\P%%c^".to_string())]; + (kind, prop::collection::vec((code, value), 0..24)).prop_map(|(k, g)| std::iter::once((0, k.to_string())).chain(g).collect()) +} + +/// A drawing whose block "B" holds `block` (it may insert itself) and whose entities are `body`. +fn hostile_dxf(block: &[Vec<(i32, String)>], body: &[Vec<(i32, String)>]) -> Vec { + let head: Vec<(i32, String)> = + [(0, "SECTION"), (2, "BLOCKS"), (0, "BLOCK"), (2, "B"), (10, "0"), (20, "0")].iter().map(|(c, v)| (*c, v.to_string())).collect(); + let mid: Vec<(i32, String)> = [(0, "ENDBLK"), (0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")].iter().map(|(c, v)| (*c, v.to_string())).collect(); + let tail = vec![(0, "ENDSEC".to_string()), (0, "EOF".to_string())]; + let all: Vec<(i32, String)> = head.into_iter().chain(block.concat()).chain(mid).chain(body.concat()).chain(tail).collect(); + dxf_text(&all).into_bytes() +} + +/// Import with every option at once, and place it; whatever comes back must render and export. +fn survive_dxf(what: &str, bytes: &[u8], fit: bool) -> Result<(), TestCaseError> { + let o = vectorcraft_cad::ImportOptions { fit, center: !fit, merge_layers: fit, ..Default::default() }; + survive(what, || vectorcraft_cad::import(bytes, &o).ok().map(|r| r.document))?; + let _ = vectorcraft_cad::info(bytes); + let _ = vectorcraft_cad::is_dxf(bytes); + survive(what, || { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).ok()?; + let p = json!({"name": "x.dxf", "dataBase64": vectorcraft_format::base64_encode(bytes), "dxf": {"fit": fit}, "thumbnail": 8}); + let _ = s.execute("file.place.info", &p); + let _ = s.execute("file.place", &p); + let _ = s.execute("document.dxfInfo", &p); + Some((*s.doc().ok()?.doc).clone()) + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn dxf_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..400), section in any::()) { + let mut b = if section { b" 0\nSECTION\n 2\nENTITIES\n".to_vec() } else { vec![] }; + b.extend(bytes); + survive_dxf("dxf garbage", &b, section)?; + } + + #[test] + fn dxf_hostile_entities_never_panic( + block in prop::collection::vec(arb_dxf_entity(), 0..4), + body in prop::collection::vec(arb_dxf_entity(), 0..8), + fit in any::(), + ) { + let bytes = hostile_dxf(&block, &body); + survive_dxf("hostile dxf", &bytes, fit)?; + } + + #[test] + fn dxf_mutated_export_never_panics( + r12 in any::(), + cut in 0usize..60_000, + edits in prop::collection::vec((0usize..60_000, prop::sample::select(vec![b'0', b'1', b'9', b'-', b'.', b'e', b'\n', b' ', b'A', b'^', b'\\', 0xC3])), 0..12), + ) { + static SAMPLES: std::sync::OnceLock<[Vec; 2]> = std::sync::OnceLock::new(); + let samples = SAMPLES.get_or_init(|| [rich_dxf("2018"), rich_dxf("R12")]); + let mut bytes = samples[usize::from(r12)].clone(); + for &(at, b) in &edits { + let n = bytes.len(); + bytes[at % n] = b; + } + bytes.truncate(cut.max(8)); + survive_dxf("mutated dxf", &bytes, cut % 2 == 0)?; + } +} diff --git a/crates/ui-egui/src/dialogs/dxf_import.rs b/crates/ui-egui/src/dialogs/dxf_import.rs new file mode 100644 index 00000000..f5f4c6bd --- /dev/null +++ b/crates/ui-egui/src/dialogs/dxf_import.rs @@ -0,0 +1,295 @@ +//! DXF Import Options: opening or placing a DXF drawing asks which layout to import (model space +//! or a paper layout, previewed), its scale (fitted to the artboard, or 1 unit of the art = N +//! drawing units: by default the drawing at 1:1 in its own unit), whether lineweights scale with +//! it, whether it is centred, and whether its layers merge into one. +//! +//! Fields: `mode` (`open` | `place`), `name`, `path?`, `layouts` (model space first), `units` and +//! `version` (what the drawing declares), and the `dxf` options of `document.open`: `layout`, +//! `fit`, `unit` (a unit name), `scale`, `scaleLineweights`, `center`, `mergeLayers`. `__place` +//! keeps the other `file.place` params. The file's bytes stay in +//! [`crate::state::UiState::dialog_file`]. OK opens or places the drawing and remembers the fit, +//! lineweight, centring and layer choices for the next one; errors keep the dialog open. + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; + +use super::DialogSpec; +use super::document_setup::{LABEL, check, choice}; +use super::import_pdf::{DialogFile, other_place_params, place_source, preview_texture, show_preview, with_file}; +use crate::state::Dialog; +use crate::{VectorcraftApp, io, widgets}; + +pub const KIND: &str = "dxfImport"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| if d.str("mode") == "place" { "Place DXF" } else { "DXF Import Options" }.into(), + body, + confirm, + min_width: 560.0, + ..DialogSpec::FORM +}; + +/// The longest side of the layout preview (pt). +const PREVIEW: f32 = 170.0; +/// The choices the next drawing starts from (the scale belongs to each drawing's units). +const REMEMBERED: [&str; 4] = ["fit", "scaleLineweights", "center", "mergeLayers"]; + +/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when they +/// are a DXF drawing; false when they aren't (or can't be read: the loader then says why). +pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, place: Option<&Value>) -> bool { + if fileio::detect(name, bytes).is_none_or(|f| f.id != "dxf") { + return false; + } + let Ok(info) = vectorcraft_cad::info(bytes) else { return false }; + app.ui.dialog_file = Some(DialogFile::new(bytes.to_vec())); + let mut fields = json!({ + "mode": if place.is_some() { "place" } else { "open" }, + "name": name, + "layouts": info.layouts, + "layout": info.layouts.first().map_or("Model", String::as_str), + "units": info.units, + "version": info.version, + "fit": false, + "unit": info.unit.label(), + "scale": info.scale, + "scaleLineweights": false, + "center": true, + "mergeLayers": false, + }); + if let Some(last) = app.ui.dxf_import.as_object() { + for k in REMEMBERED { + if let Some(v) = last.get(k).filter(|v| v.is_boolean()) { + fields[k] = v.clone(); + } + } + } + if let Some(p) = path { + fields["path"] = json!(p); + } + if let Some(p) = place { + fields["__place"] = other_place_params(p, &["dxf"]); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + true +} + +/// `file.place` with params `p` asks first (true) when they name a `.dxf` drawing and no `dxf` +/// options. +pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool { + let named = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).map(fileio::extension); + if p.get("dxf").is_some() || named.as_deref() != Some("dxf") { + return false; + } + let Some((name, bytes, path)) = place_source(app, p, |ext| ext == "dxf") else { return false }; + offer(app, &name, &bytes, path, Some(p)) +} + +/// The `dxf` options of `document.open` and `file.place` the fields set. +fn options(d: &Dialog) -> Value { + let fit = d.bool("fit"); + let mut o = json!({ + "layout": d.str("layout"), + "fit": fit, + "scaleLineweights": d.bool("scaleLineweights"), + "center": d.bool("center"), + "mergeLayers": d.bool("mergeLayers"), + }); + if !fit { + o["unit"] = json!(d.str("unit")); + o["scale"] = json!(d.f64("scale", 1.0)); + } + o +} + +/// OK: open or place the drawing with the options. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let file = app.ui.dialog_file.clone().ok_or("the drawing is no longer loaded")?; + let dxf = options(d); + app.ui.dxf_import = Value::Object(REMEMBERED.iter().map(|k| (k.to_string(), dxf[*k].clone())).collect()); + let r = if d.str("mode") == "place" { + let mut p = with_file(d.fields.get("__place"), d, &file); + p["dxf"] = dxf; + crate::place::run(app, &p).map(|_| ()) + } else { + let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); + io::open_document(app, &d.str("name"), &file.bytes, path, &json!({ "dxf": dxf })) + }; + match r { + Ok(()) => { + // Placing may have opened another dialog; only this one closes. + if app.ui.dialog.as_ref().is_some_and(|x| x.kind == KIND) { + app.ui.dialog = None; + } + Ok(Value::Null) + } + Err(e) => { + app.ui.dialog = Some(d.clone()); + Err(e) + } + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + show_preview(ui, preview(app, ui.ctx(), d).as_ref(), PREVIEW); + ui.add_space(6.0); + widgets::dim_label(ui, &format!("DXF {} · {}", d.str("version"), d.str("units"))); + }); + ui.add_space(14.0); + ui.vertical(|ui| { + let layouts: Vec = + d.fields.get("layouts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let rows: Vec<(&str, &str)> = layouts.iter().map(|l| (l.as_str(), l.as_str())).collect(); + choice(ui, d, "layout", "Layout:", &rows); + ui.add_space(10.0); + widgets::subheader(ui, "Scaling"); + check(ui, d, "fit", "Scale to Fit Artboard"); + ui.add_enabled_ui(!d.bool("fit"), |ui| super::dxf_options::scale_row(ui, d)); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + check(ui, d, "scaleLineweights", "Scale Lineweights"); + }); + ui.add_space(10.0); + widgets::subheader(ui, "Options"); + check(ui, d, "center", "Center Artwork"); + check(ui, d, "mergeLayers", "Merge Layers"); + }); + }); + false +} + +/// The chosen layout fitted to the preview, rendered once per drawing and layout. +fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog) -> Option { + let file = app.ui.dialog_file.clone()?; + let layout = d.str("layout"); + let key = egui::Id::new(("dxf-preview", file.token, layout.as_str())); + let o = vectorcraft_cad::ImportOptions { layout: Some(layout.clone()), fit: true, ..Default::default() }; + // A layout that can't be read shows an empty preview (and isn't read again). + preview_texture(app, ctx, key, "dxf-preview", PREVIEW, || vectorcraft_cad::import(&file.bytes, &o).ok().map(|r| r.document)) +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::NodeKind; + + use super::*; + + /// A millimetre drawing: a line in model space and a circle on a paper layout. + fn drawing() -> Vec { + let pairs: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "HEADER"), + (9, "$INSUNITS"), + (70, "4"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "ENTITIES"), + (0, "LINE"), + (8, "Walls"), + (10, "0"), + (20, "0"), + (11, "100"), + (21, "50"), + (0, "CIRCLE"), + (67, "1"), + (10, "0"), + (20, "0"), + (40, "5"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect::().into_bytes() + } + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + #[test] + fn opening_a_drawing_asks_for_the_import_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("mode"), d.str("layout"), d.str("unit")), (KIND, "open".into(), "Model".into(), "Millimeters".into())); + assert_eq!(d.fields["layouts"], json!(["Model", "Layout1"])); + let text = frame(&mut app); + for label in [ + "DXF Import Options", + "Layout:", + "Scaling", + "Scale to Fit Artboard", + "Scale:", + "Scale Lineweights", + "Center Artwork", + "Merge Layers", + "Millimeters", + "OK", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + // Fit greys out the ratio; a paper layout previews and opens. + set(&mut app, "fit", json!(true)); + set(&mut app, "layout", json!("Layout1")); + set(&mut app, "mergeLayers", json!(true)); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + let mut circles = 0; + st.doc.walk(|n| circles += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(circles, 1, "the paper layout's circle"); + assert_eq!(st.doc.layers.len(), 1); + // The next drawing starts from these choices, its own scale. + io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("fit") && d.bool("mergeLayers")); + assert_eq!(d.str("layout"), "Model"); + } + + #[test] + fn bad_options_keep_the_dialog_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap(); + set(&mut app, "layout", json!("Sheet 9")); + assert!(super::super::confirm(&mut app).is_err()); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + set(&mut app, "layout", json!("Model")); + set(&mut app, "unit", json!("Points")); + set(&mut app, "scale", json!(1)); + super::super::confirm(&mut app).unwrap(); + let w = app.session.doc().unwrap().doc.artboards[0].rect.width(); + assert_eq!(w, 100.0, "1 pt = 1 unit"); + } + + #[test] + fn placing_a_drawing_asks_then_places_one_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let p = json!({"name": "plan.dxf", "dataBase64": vectorcraft_format::base64_encode(&drawing()), "template": false}); + crate::place::run(&mut app, &p).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("mode")), (KIND, "place".into())); + assert_eq!(d.fields["__place"], json!({"template": false})); + assert!(frame(&mut app).contains("Place DXF")); + set(&mut app, "fit", json!(true)); + let undo = app.session.doc().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let id = st.selection.objects[0]; + let b = st.doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!((b.width() - 600.0).abs() < 1.0, "fitted to the artboard: {b:?}"); + // Params with dxf options place without asking. + let mut p = p.clone(); + p["dxf"] = json!({}); + crate::place::run(&mut app, &p).unwrap(); + assert!(app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index edf89692..d3240ca8 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -108,8 +108,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// Scale: 1 [unit] = [scale] Units. -fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) { +/// Scale: 1 [unit] = [scale] Units (the `unit` and `scale` fields; DXF import reads them too). +pub(super) 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); diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index acb40410..e5cc74d4 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -28,12 +28,88 @@ pub(super) const SPEC: DialogSpec = const PREVIEW: f32 = 200.0; const LABEL_WIDTH: f32 = 70.0; -/// The file the dialog reads. +/// The file an import dialog reads (Import PDF, DXF Import Options). #[derive(Debug)] pub struct DialogFile { pub bytes: Vec, /// Unique per file the dialog opened (keys its preview cache). - token: u64, + pub(super) token: u64, +} + +impl DialogFile { + pub fn new(bytes: Vec) -> Arc { + static TOKEN: AtomicU64 = AtomicU64::new(0); + Arc::new(Self { bytes, token: TOKEN.fetch_add(1, Ordering::Relaxed) }) + } +} + +/// The file `file.place` params `p` name when it is one `wanted` takes by its extension (a path, +/// read through the app's services) or given as `dataBase64` → (name, bytes, path). +pub(super) fn place_source(app: &VectorcraftApp, p: &Value, wanted: impl Fn(&str) -> bool) -> Option<(String, Vec, Option)> { + let s = |k: &str| p.get(k).and_then(Value::as_str); + match (s("path"), s("dataBase64")) { + (Some(path), _) if wanted(&fileio::extension(path)) => { + let bytes = app.services.read.as_ref().and_then(|r| r(path).ok())?; + Some((path.to_string(), bytes, Some(path.to_string()))) + } + (None, Some(b64)) => Some((s("name").unwrap_or("Untitled").to_string(), vectorcraft_format::base64_decode(b64)?, None)), + _ => None, + } +} + +/// The `file.place` params besides the file and `taken` (kept by an import dialog for its OK). +pub(super) fn other_place_params(p: &Value, taken: &[&str]) -> Value { + let rest = + p.as_object().into_iter().flatten().filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64") && !taken.contains(&k.as_str())); + Value::Object(rest.map(|(k, v)| (k.clone(), v.clone())).collect()) +} + +/// `p` naming the dialog's file again: its path, or its name and bytes. +pub(super) fn with_file(p: Option<&Value>, d: &Dialog, file: &DialogFile) -> Value { + let mut p = p.filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})); + match d.fields.get("path").and_then(Value::as_str) { + Some(path) => p["path"] = json!(path), + None => { + p["name"] = json!(d.str("name")); + p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes)); + } + } + p +} + +/// A preview of `doc`'s first artboard, at most `size` points on its longer side, rendered once +/// per `key` (`None`: there is nothing to show). +pub(super) fn preview_texture( + app: &mut VectorcraftApp, + ctx: &egui::Context, + key: egui::Id, + name: &str, + size: f32, + doc: impl FnOnce() -> Option, +) -> Option { + if let Some(t) = ctx.data(|m| m.get_temp::>(key)) { + return t; + } + let tex = doc().and_then(|doc| { + let r = doc.artboards.first()?.rect; + let px = size as f64 * ctx.pixels_per_point() as f64; + Some(widgets::region_texture(ctx, &mut app.canvas.renderer, name, &doc, r, px)) + }); + ctx.data_mut(|m| m.insert_temp(key, tex.clone())); + tex +} + +/// `tex` fitted in a `size` square on the pasteboard, framed. +pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, size: f32) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + ui.painter().rect_filled(r, 2.0, t.pasteboard); + if let Some(tex) = tex { + let sz = tex.size_vec2(); + let ir = egui::Rect::from_center_size(r.center(), sz * (size / sz.x.max(sz.y))); + ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside); + } } /// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the @@ -48,8 +124,7 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option (0, true), _ => return false, }; - static TOKEN: AtomicU64 = AtomicU64::new(0); - app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) })); + app.ui.dialog_file = Some(DialogFile::new(bytes.to_vec())); let mut fields = json!({ "mode": if place.is_some() { "place" } else { "open" }, "name": name, @@ -64,13 +139,8 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option = p - .iter() - .filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password")) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - fields["__place"] = Value::Object(rest); + if let Some(p) = place { + fields["__place"] = other_place_params(p, &["page", "crop", "password"]); } app.ui.dialog = Some(Dialog::new(KIND, fields)); true @@ -82,18 +152,7 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool { if p.get("page").is_some() { return false; } - let s = |k: &str| p.get(k).and_then(Value::as_str); - let (name, bytes, path) = match (s("path"), s("dataBase64")) { - (Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => { - let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false }; - (path.to_string(), bytes, Some(path.to_string())) - } - (None, Some(b64)) => match vectorcraft_format::base64_decode(b64) { - Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None), - None => return false, - }, - _ => return false, - }; + let Some((name, bytes, path)) = place_source(app, p, |ext| matches!(ext, "pdf" | "ai" | "ait")) else { return false }; offer(app, &name, &bytes, path, Some(p)) } @@ -131,14 +190,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let name = d.str("name"); let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); let r = if d.str("mode") == "place" { - let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({})); - match &path { - Some(path) => p["path"] = json!(path), - None => { - p["name"] = json!(name); - p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes)); - } - } + let mut p = with_file(d.fields.get("__place"), &d, &file); p["page"] = json!(page(&d)); p["crop"] = json!(crop(&d).id()); if let Some(pw) = password(&d) { @@ -180,14 +232,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let mut current = page(d); ui.horizontal_top(|ui| { ui.vertical(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover()); - ui.painter().rect_filled(r, 2.0, t.pasteboard); - if let Some(tex) = preview(app, ui.ctx(), d, current) { - let sz = tex.size_vec2(); - let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y))); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); - ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside); - } + show_preview(ui, preview(app, ui.ctx(), d, current).as_ref(), PREVIEW); ui.add_space(6.0); // Page navigation: first, previous, the page number, next, last. ui.horizontal(|ui| { @@ -261,16 +306,9 @@ fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usiz let file = app.ui.dialog_file.clone()?; let crop = crop(d); let key = egui::Id::new(("pdf-preview", file.token, page, crop.id())); - if let Some(t) = ctx.data(|m| m.get_temp::>(key)) { - return t; - } let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() }; // A page that can't be read shows an empty preview (and isn't read again). - let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| { - let r = doc.artboards.first()?.rect; - let px = PREVIEW as f64 * ctx.pixels_per_point() as f64; - Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px)) - }); - ctx.data_mut(|m| m.insert_temp(key, tex.clone())); - tex + preview_texture(app, ctx, key, &format!("pdf-preview-{page}"), PREVIEW, || { + fileio::page_document(&file.bytes, page - 1, &opts).ok().map(|(doc, _)| doc) + }) } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 512b7329..9639e5fd 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_import; pub mod dxf_options; mod effect; pub mod eps_options; @@ -225,6 +226,7 @@ registry! { DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, Recovery: [crate::recovery::KIND] => recovery::SPEC, + DxfImport: [dxf_import::KIND] => dxf_import::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 62373fff..fdd51ee5 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -38,8 +38,11 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; return load(app, p).map(|_| ()); } - // A PDF with several pages or a password asks first (the Import PDF dialog). - if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) { + // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a + // DXF drawing (DXF Import Options). + if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) + || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) + { return Ok(()); } open_document(app, name, bytes, path, &Value::Null) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 503544a5..68cf5d71 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -89,6 +89,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { if crate::dialogs::import_pdf::offer_place(app, p) { return Ok(Value::Null); } + // A DXF drawing asks for its import options. + if crate::dialogs::dxf_import::offer_place(app, p) { + return Ok(json!({ "dialog": crate::dialogs::dxf_import::KIND })); + } let mut p = p.clone(); if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none()) && let Some(v) = app.view() diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 29dcd665..e0c33b9d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -292,6 +292,10 @@ pub struct UiState { /// The EPS Options chosen last (`document.exportEps` options; null: never used). #[serde(default)] pub eps_options: Value, + /// The DXF Import Options chosen last (fit, scaleLineweights, center, mergeLayers; null: + /// never used). + #[serde(default)] + pub dxf_import: Value, } impl UiState { @@ -352,6 +356,7 @@ impl Default for UiState { dialog_file: None, dxf_options: Value::Null, eps_options: Value::Null, + dxf_import: Value::Null, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4b0e15a4..477a7020 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -307,6 +307,15 @@ Export Selected Art Only with nothing selected, keeps the dialog open), remember 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. +DXF Import Options: opening a DXF drawing through the app (`app.open`, `file.open`, a drop) or placing one +(`file.place` with a `.dxf` file and no `dxf` options, which answers `{"dialog": "dxfImport"}`) opens the `dxfImport` +dialog (`mode`: `open` or `place`; `layouts`, `units` and `version` describe the drawing). Its fields are the +`document.open` `dxf` options: `layout` (one of `layouts`), `fit`, `unit` (a unit name such as `Millimeters`) and +`scale` (1 unit = `scale` drawing units; ignored with `fit`), `scaleLineweights`, `center`, `mergeLayers`. +`ui.dialog.confirm` opens or places the drawing (placing keeps the other `file.place` params in `__place`) and +remembers `fit`, `scaleLineweights`, `center` and `mergeLayers` for the next drawing; an error (an unknown layout, +art too large at that scale) keeps the dialog open. + Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the `placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as diff --git a/docs/mcp.md b/docs/mcp.md index f6f40d91..90e04dd6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1112,6 +1112,30 @@ opened. {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} ``` +## DXF import + +`document.open` reads ASCII DXF drawings of any version (by content or the `.dxf` extension), and `file.place` +places one as a group. Both take `dxf: {layout, unit, scale, fit, fitTo, scaleLineweights, center, mergeLayers}` +(all optional). `document.dxfInfo` reads a drawing without opening it: its `version`, drawing `units`, `layouts` +(`Model` first, then the paper layouts), `layers`, and the default ratio (`unit`, `scale`: the drawing at 1:1 in its +own unit; unitless metric drawings read millimetres, imperial ones inches). A ratio is 1 `unit` of the art = `scale` +drawing units; `fit: true` instead scales the art to fit `fitTo` (a letter page when opening; when placing, the +artboard under `at`). `center: false` puts the drawing's origin (fitted art: its bottom-left corner) on the +artboard's bottom-left corner; `mergeLayers` puts all art on one layer. Each DXF layer holding art becomes a layer +(off and frozen layers hidden, locked ones locked, non-plotting ones non-printing). Lines, polylines (arc segments from +bulges), circles, arcs, ellipses, splines (NURBS, within a millionth of their size), solids and hatches become paths (solid +hatches filled, pattern hatches as their boundaries), text and multiline text point type, named blocks symbols +(one per look when their art takes colour "by block"), anonymous blocks such as dimensions groups. Indexed colours +(7 is black on paper), true colour, lineweights (`scaleLineweights` scales them with the art), linetypes and +transparency resolve through layers and blocks. What is left out (viewports, images, meshes, unknown entities) is +listed in `warnings`. Binary DXF and DWG files answer an error saying to save them as ASCII DXF. + +```json +{"name":"run_command","arguments":{"command":"document.dxfInfo","params":{"path":"/tmp/plan.dxf"}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plan.dxf","dxf":{"layout":"Layout1","fit":true,"mergeLayers":true}}}} +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/detail.dxf","dxf":{"unit":"mm","scale":10,"center":false}}}} +``` + ## Links panel `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top