diff --git a/Cargo.lock b/Cargo.lock index dc08d4542e..b4feae9991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -983,6 +983,16 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +[[package]] +name = "craft-dxf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" +dependencies = [ + "encoding_rs", + "log", + "thiserror 2.0.21", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -6393,6 +6403,7 @@ dependencies = [ name = "vectorcraft-cad" version = "0.24.1" dependencies = [ + "craft-dxf", "image", "kurbo 0.13.1", "serde_json", diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 34f77ec929..e529e1d4e2 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 workspace = true [dependencies] +craft-dxf = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } diff --git a/crates/cad/src/aci.rs b/crates/cad/src/aci.rs index 63fbb180f9..39818a78ef 100644 --- a/crates/cad/src/aci.rs +++ b/crates/cad/src/aci.rs @@ -1,50 +1,11 @@ -//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are -//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a -//! half-saturated tone, and 250–255 are greys. +//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth +//! of the writer may use. use crate::ColorDepth; -/// The nine standard colours (index 7 shows white on a dark background and black on paper). -const STANDARD: [[u8; 3]; 9] = - [[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]]; - -/// The brightness of the five shades of each hue. -const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15]; - /// The RGB of colour index `i` (1–255; anything else is index 7's white). pub fn aci_rgb(i: u8) -> [u8; 3] { - match i { - 1..=9 => STANDARD[usize::from(i - 1)], - 10..=249 => { - let hue = f64::from(i / 10 - 1) * 15.0; - let k = i % 10; - let v = SHADES[usize::from(k / 2)]; - let pastel = k % 2 == 1; - hue_rgb(hue).map(|f| { - let f = if pastel { f + (1.0 - f) / 2.0 } else { f }; - (255.0 * v * f).floor() as u8 - }) - } - 250..=255 => { - let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8; - [g; 3] - } - _ => STANDARD[6], - } -} - -/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels. -fn hue_rgb(hue: f64) -> [f64; 3] { - let h = hue.rem_euclid(360.0) / 60.0; - let x = 1.0 - (h % 2.0 - 1.0).abs(); - match h as u8 { - 0 => [1.0, x, 0.0], - 1 => [x, 1.0, 0.0], - 2 => [0.0, 1.0, x], - 3 => [0.0, x, 1.0], - 4 => [x, 0.0, 1.0], - _ => [1.0, 0.0, x], - } + craft_dxf::model::aci::rgb(i) } /// The indices a colour depth may use (true colour: all of them, for the fallback index). @@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box> { /// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index). /// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast. pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 { - let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::(); - candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7) + craft_dxf::model::aci::nearest(rgb, candidates(depth)) } #[cfg(test)] mod tests { use super::*; - #[test] - fn the_palette_matches_its_known_entries() { - let known: [(u8, [u8; 3]); 14] = [ - (1, [255, 0, 0]), - (10, [255, 0, 0]), - (11, [255, 127, 127]), - (12, [165, 0, 0]), - (13, [165, 82, 82]), - (17, [76, 38, 38]), - (19, [38, 19, 19]), - (20, [255, 63, 0]), - (21, [255, 159, 127]), - (30, [255, 127, 0]), - (140, [0, 191, 255]), - (250, [51, 51, 51]), - (252, [132, 132, 132]), - (255, [255, 255, 255]), - ]; - for (i, rgb) in known { - assert_eq!(aci_rgb(i), rgb, "index {i}"); - } - } - #[test] fn nearest_index_by_depth() { assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1); diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs index f1f4104863..9216c57c29 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -1,4 +1,4 @@ -//! DXF import: an ASCII DXF drawing of any version as a document. +//! DXF import: a DXF drawing (ASCII or binary) of any version as a document. //! //! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N //! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred @@ -10,7 +10,8 @@ //! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve //! them. What can't come in is listed in the warnings. //! -//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts. +//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables, +//! blocks and layouts. //! - `entity`: entities to items (geometry and properties). //! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths. //! - `text`: text values to plain text. @@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE}; /// Letter, the box Fit to Artboard fills unless told otherwise (points). pub const FIT_TO: (f64, f64) = (612.0, 792.0); -/// Why a binary DXF can't be opened. -pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that"; - /// How a drawing comes in. #[derive(Clone, Debug, PartialEq)] pub struct ImportOptions { @@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool { false } -/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused). -fn text(bytes: &[u8]) -> Result, String> { - if bytes.starts_with(reader::BINARY_SENTINEL) { - return Err(BINARY.into()); - } +/// The tags of a DXF file and where it is damaged (DWG files are refused). +fn tags(bytes: &[u8]) -> Result<(Vec, Option), String> { if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") { - return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into()); + return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into()); } - Ok(reader::decode(bytes)) + let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e { + craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(), + _ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(), + _ => "not a DXF drawing".to_string(), + })?; + if tags.is_empty() { + return Err("not a DXF drawing".into()); + } + Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out")))) } /// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it. @@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str { /// Read what `bytes` holds: its version, units, layouts and layers. pub fn info(bytes: &[u8]) -> Result { - let text = text(bytes)?; - let (pairs, _) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, _) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } @@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result { }) } -/// Import an ASCII DXF drawing with `o`. +/// Import a DXF drawing with `o`. pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { let (w, h) = o.fit_to; if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) { @@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { { return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end())); } - let text = text(bytes)?; - let (pairs, damaged) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, damaged) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index 59e1283cce..309a4ea228 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl}; use vectorcraft_doc::Justify; use vectorcraft_geom::{Affine, Point, Vec2}; +use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath}; + use super::curve::{self, Vertex}; -use super::reader::{Groups, Pair, Record, groups, number}; -use crate::aci::aci_rgb; +use super::reader::{aci_paper, point}; /// How far below the baseline the lowest descender reaches, as a fraction of the cap height. const DESCENT: f64 = 0.3; @@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12; /// factor 1, the 1 to 10,000% horizontal scale the Character panel allows. const SPAN_SCALE: (f64, f64) = (0.01, 100.0); -/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. -pub(crate) fn aci_paper(i: u64) -> [u8; 3] { - match u8::try_from(i) { - Ok(7) | Err(_) => [0; 3], - Ok(i) => aci_rgb(i), - } -} - -/// The true colour (group 420, `0xRRGGBB`) of an entity or layer. -pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> { - let v = g.int(420)?; - let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes(); - Some([r, gr, b]) -} - /// An entity's colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum Col { @@ -79,30 +65,30 @@ pub(crate) struct Props { } impl Props { - fn read(g: &[Pair<'_>]) -> Self { - let color = match (true_color(g), g.int(62)) { - (Some(rgb), _) => Col::Rgb(rgb), - (None, Some(0)) => Col::ByBlock, - (None, None | Some(256..)) => Col::ByLayer, - (None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())), + fn read(c: &model::Common) -> Self { + let color = match c.color { + model::Color::Rgb(rgb) => Col::Rgb(rgb), + model::Color::ByBlock => Col::ByBlock, + model::Color::ByLayer => Col::ByLayer, + model::Color::Index(i) => Col::Rgb(aci_paper(i)), }; - let lineweight = match g.int(370) { - Some(-2) => Lw::ByBlock, - Some(-3) => Lw::Default, - Some(v @ 0..=211) => Lw::Hundredths(v as i32), + let lineweight = match c.lineweight { + model::Lineweight::ByBlock => Lw::ByBlock, + model::Lineweight::Default => Lw::Default, + model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)), _ => Lw::ByLayer, }; - let alpha = match g.int(440) { - Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0), - Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock, - _ => Alpha::ByLayer, + let alpha = match c.transparency { + model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0), + model::Transparency::ByBlock => Alpha::ByBlock, + model::Transparency::ByLayer => Alpha::ByLayer, }; Self { - layer: g.name(8).unwrap_or("0").to_string(), + layer: c.layer.clone(), color, lineweight, - linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(), - ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0), + linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(), + ltscale: c.ltscale, alpha, } } @@ -209,90 +195,87 @@ impl Converter { } /// The items of `records` (an entity section or a block), in drawing order. - pub fn items(&mut self, records: &[Record<'_>]) -> Vec { + pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec { let mut out = Vec::new(); - for group in groups(records) { - let Some(head) = group.first() else { continue }; + for e in records { + let m = model::Entity::read(e); // Invisible entities. - if head.g.int(60) == Some(1) { + if !m.common.visible { continue; } - match self.geom(head, group) { + match self.geom(e, &m) { Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY), - Some(geom) => out.push(Item { props: Props::read(head.g), geom }), - None if is_known(head.kind) => {} - None => self.skip(head.kind), + Some(geom) => out.push(Item { props: Props::read(&m.common), geom }), + None if is_known(e.kind) => {} + None => self.skip(e.kind), } } out } - fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option { - let g = head.g; + fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option { + let g = e.groups(); let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 }); - match head.kind { - "LINE" => { + let ocs = ocs(m.common.extrusion); + match &m.kind { + model::EntityKind::Line { start, end } => { let mut bp = BezPath::new(); - bp.move_to(g.point(10)); - bp.line_to(g.point(11)); + bp.move_to(point(*start)); + bp.line_to(point(*end)); path(bp) } - "CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))), - "ARC" => { - let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0)); - let sweep = ccw_sweep(start, end); - path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g))) + model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)), + model::EntityKind::Arc { center, radius, start_angle, end_angle } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs)) } - "ELLIPSE" => path(ellipse(g)?), - "LWPOLYLINE" => lwpolyline(g), - "POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])), - "SPLINE" => path(spline(g)?), - "SOLID" | "TRACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; + model::EntityKind::Ellipse { center, major, ratio, start, end } => { + path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?) + } + model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs), + model::EntityKind::Polyline(p) => self.polyline(p, ocs), + model::EntityKind::Spline(s) => path(spline(s)?), + model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => { + let [a, b, c, d] = corners.map(point); // The corners zigzag: the outline runs 1, 2, 4, 3. - Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 }) + Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 }) } - "3DFACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; - path(polygon(&[a, b, c, d])) - } - "HATCH" => self.hatch(g), - "TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text), - "MTEXT" => mtext(g).map(Geom::Text), - "INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])), + model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))), + model::EntityKind::Hatch(h) => self.hatch(h, ocs), + model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text), + model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text), + model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs), // A dimension draws the anonymous block of its lines, arrows and text. - "DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })), + model::EntityKind::Dimension(d) => { + Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })) + } _ => None, } } - fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let flags = g.int_or(70, 0); - if flags & (16 | 64) != 0 { + fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option { + if p.is_mesh() || p.is_polyface() { self.warn(MESHES); return None; } // Spline frame control points and polyface faces aren't on the line. - let verts: Vec = rest - .iter() - .filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0) - .map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) }) - .collect(); - let bp = curve::polyline(&verts, flags & 1 != 0)?; + let verts: Vec = + p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect(); + let bp = curve::polyline(&verts, p.closed())?; // 3D polylines are in world coordinates; 2D ones in their plane's. - let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) }; - Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) }) + let bp = if p.is_3d() { bp } else { transformed(bp, ocs) }; + Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) }) } - fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let block = g.name(2)?.to_uppercase(); - let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0)); + fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option { + let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase(); + let [sx, sy, _] = ins.scale; if sx.abs() < 1e-12 || sy.abs() < 1e-12 { return None; } - let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians()); - let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1)); - let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0)); + let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians()); + let (cols, rows) = (ins.columns, ins.rows); + let (cs, rs) = (ins.column_spacing, ins.row_spacing); let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS { self.warn(BIG_ARRAY); (cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows)) @@ -312,27 +295,41 @@ impl Converter { .flat_map(|r| (0..cols).map(move |c| (r, c))) .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) .collect(); - let attribs = self.items(rest); + let attribs = self.attribs(followers); Some(Geom::Insert(InsertItem { block, cells, attribs })) } - fn hatch(&mut self, g: &[Pair<'_>]) -> Option { - let start = g.iter().position(|p| p.code == 91)?; - let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize; - let mut c = Cursor { g, i: start + 1 }; + /// An insert's attributes as text. + fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec { + let mut out = Vec::new(); + for r in followers { + let c = model::Common::read(r.tags); + if !c.visible { + continue; + } + let a = model::Attrib::read("ATTRIB", r.tags); + match text(&a.text, &r.groups(), true, ocs(c.extrusion)) { + Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY), + Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }), + None => {} + } + } + out + } + + fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option { let mut bp = BezPath::new(); - for _ in 0..count { - let Some(path) = boundary(&mut c) else { break }; + for l in &h.loops { + let Some(path) = boundary(&l.path) else { break }; bp.extend(path); } if bp.elements().is_empty() { return None; } - let bp = transformed(bp, ocs(g)); - let solid = g.int_or(70, 0) == 1; - if g.int_or(450, 0) == 1 { + let bp = transformed(bp, ocs); + if h.gradient.is_some() { self.warn(GRADIENT_HATCH); - } else if !solid { + } else if !h.solid { self.warn(PATTERN_HATCH); return Some(Geom::Path { bp, fill: false, width: 0.0 }); } @@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath { bp } -/// The extrusion direction (210, 220, 230) of a planar entity, normalised. -fn extrusion(g: &[Pair<'_>]) -> [f64; 3] { - let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)]; - let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] } -} - fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } -fn unit(v: [f64; 3]) -> [f64; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if len > 1e-12 { v.map(|x| x / len) } else { v } -} - /// A planar entity's coordinate system seen from above: its x and y axes in world space by the /// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it /// mirrors x). -fn ocs(g: &[Pair<'_>]) -> Affine { - let n = extrusion(g); - if n[2] > 1.0 - 1e-12 { +fn ocs(extrusion: model::Point) -> Affine { + let [ax, ay, _] = model::ocs_axes(extrusion); + if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] { return Affine::IDENTITY; } - let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) }); - let ay = unit(cross(n, ax)); Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0]) } -fn ellipse(g: &[Pair<'_>]) -> Option { - let centre = g.point(10); - let major = g.point(11).to_vec2(); - let ratio = g.num_or(40, 1.0); +fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option { + let centre = point(centre); + let major = point(major).to_vec2(); if ratio <= 0.0 || major.hypot() < 1e-12 { return None; } // The minor axis is the major one turned a quarter about the extrusion direction. - let n = extrusion(g); + let n = model::ocs_axes(extrusion)[2]; let m = cross(n, [major.x, major.y, 0.0]); let minor = Vec2::new(m[0], m[1]) * ratio; - let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU)); let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians(); curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep) } -fn lwpolyline(g: &[Pair<'_>]) -> Option { - let mut verts: Vec = Vec::new(); - let (mut widths, mut uniform) = (None, true); - for p in g { - match p.code { - 10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }), - 20 => verts.last_mut()?.p.y = number(p.value)?, - 42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0), - 40 | 41 => { - let w = number(p.value).unwrap_or(0.0); - uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12); - widths = Some(w); - } - _ => {} - } - } - let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g)); - let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0); +fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option { + let verts: Vec = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect(); + let bp = transformed(curve::polyline(&verts, p.closed)?, ocs); + // One width for every vertex is the line's width. + let widths: Vec = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect(); + let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied(); + let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0); Some(Geom::Path { bp, fill: false, width }) } -/// The points at `code` (x) and `code + 10` (y), in order. -fn points(g: &[Pair<'_>], code: i32) -> Option> { - let mut out: Vec = Vec::new(); - for p in g { - if p.code == code { - out.push(Point::new(number(p.value)?, 0.0)); - } else if p.code == code + 10 { - out.last_mut()?.y = number(p.value)?; - } - } - Some(out) -} - -fn spline(g: &[Pair<'_>]) -> Option { - let flags = g.int_or(70, 0); - let ctrl = points(g, 10)?; - let degree = usize::try_from(g.int_or(71, 3)).ok()?; - let knots: Vec = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::>()?; - let weights: Vec = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect(); - let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice()); - curve::nurbs(degree, &knots, &ctrl, weights) +fn spline(s: &model::Spline) -> Option { + let ctrl: Vec = s.control.iter().map(|p| point(*p)).collect(); + let degree = usize::try_from(s.degree).ok()?; + let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice()); + curve::nurbs(degree, &s.knots, &ctrl, weights) .map(|mut path| { - if flags & 1 != 0 { + if s.flags & 1 != 0 { path.close_path(); } path }) - .or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0)) -} - -/// Reads a hatch's boundary groups in order. -struct Cursor<'g, 'a> { - g: &'g [Pair<'a>], - i: usize, -} - -impl Cursor<'_, '_> { - /// The next group's value when it has `code` (consumed). - fn opt(&mut self, code: i32) -> Option<&str> { - let p = self.g.get(self.i).filter(|p| p.code == code)?; - self.i += 1; - Some(p.value) - } - fn num(&mut self, code: i32) -> Option { - self.opt(code).and_then(number) - } - fn count(&mut self, code: i32) -> Option { - self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize) - } - fn point(&mut self, code: i32) -> Option { - Some(Point::new(self.num(code)?, self.num(code + 10)?)) - } + .or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::>(), s.flags & 1 != 0)) } /// One hatch boundary loop, closed. -fn boundary(c: &mut Cursor<'_, '_>) -> Option { - let flags = c.num(92)? as i64; - let mut bp = BezPath::new(); - if flags & 2 != 0 { - let bulges = c.num(72).unwrap_or(0.0) != 0.0; - let _closed = c.opt(73); - let n = c.count(93)?; - let mut verts = Vec::with_capacity(n.min(4096)); - for _ in 0..n { - let p = c.point(10)?; - let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 }; - verts.push(Vertex { p, bulge }); +fn boundary(path: &HatchPath) -> Option { + let xy = |p: &[f64; 2]| Point::new(p[0], p[1]); + match path { + HatchPath::Polyline { vertices, .. } => { + let verts: Vec = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect(); + curve::polyline(&verts, true) } - bp = curve::polyline(&verts, true)?; - } else { - let n = c.count(93)?; - for _ in 0..n { - let edge = edge(c)?; - append(&mut bp, &edge); - } - if bp.elements().is_empty() { - return None; - } - bp.close_path(); - } - // The boundary objects it was made from. - if let Some(n) = c.count(97) { - for _ in 0..n { - if c.opt(330).is_none() { - break; - } - } - } - Some(bp) -} - -/// One edge of a hatch boundary. -fn edge(c: &mut Cursor<'_, '_>) -> Option { - match c.num(72)? as i64 { - 1 => { + HatchPath::Edges(edges) => { let mut bp = BezPath::new(); - bp.move_to(c.point(10)?); - bp.line_to(c.point(11)?); + for e in edges { + let seg = match e { + HatchEdge::Line { start, end } => { + let mut bp = BezPath::new(); + bp.move_to(xy(start)); + bp.line_to(xy(end)); + Some(bp) + } + HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + // Clockwise arcs have their angles measured clockwise. + let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) }; + curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians()) + } + HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => { + let (centre, major) = (xy(center), xy(major).to_vec2()); + let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 }; + let sweep = ccw_sweep(*start_angle, *end_angle).to_radians(); + curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep) + } + HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => { + let ctrl: Vec = control.iter().map(xy).collect(); + let degree = usize::try_from(*degree).ok()?; + let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice()); + curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::>(), false)) + } + }; + append(&mut bp, &seg?); + } + if bp.elements().is_empty() { + return None; + } + bp.close_path(); Some(bp) } - 2 => { - let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let sweep = ccw_sweep(start, end); - // Clockwise arcs have their angles measured clockwise. - let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) }; - curve::arc(centre, r, start.to_radians(), sweep.to_radians()) - } - 3 => { - let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 }; - let sweep = ccw_sweep(start, end).to_radians(); - curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep) - } - 4 => { - let degree = c.count(94)?; - let rational = c.num(73).unwrap_or(0.0) != 0.0; - let _periodic = c.opt(74); - let (nk, nc) = (c.count(95)?, c.count(96)?); - let knots: Vec = (0..nk).map(|_| c.num(40)).collect::>()?; - let mut ctrl = Vec::with_capacity(nc.min(4096)); - let mut weights = Vec::with_capacity(nc.min(4096)); - for _ in 0..nc { - ctrl.push(c.point(10)?); - weights.push(c.num(42).unwrap_or(1.0)); - } - // Fit data (DXF 2010 and later): a count, then that many points. Older files have none, - // and the 97 that may follow is the boundary's count of source objects (then their - // handles), so it is only the fit count when it is 0 or points follow. - let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) { - (Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11), - _ => false, - }; - let fit: Vec = match fit_count.then(|| c.count(97)).flatten() { - Some(n) => (0..n).map(|_| c.point(11)).collect::>()?, - None => vec![], - }; - // Start and end tangents. - for code in [12, 13] { - c.point(code); - } - curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false)) - } - _ => None, } } @@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 { } } -fn text(g: &[Pair<'_>], attrib: bool) -> Option { - if attrib && g.int_or(70, 0) & 1 != 0 { +/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and +/// percent codes are turned into text once, by `super::text`. +fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option { + if attrib && g.i_or(70, 0) & 1 != 0 { return None; } - let s = super::text::plain(g.raw(1)?); + let s = super::text::plain(&g.s(1)?); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); - let (p1, p2) = (g.point(10), g.point_opt(11)); - let mut angle = g.num_or(50, 0.0).to_radians(); + let height = Some(t.height).filter(|h| *h > 0.0)?; + // An ATTRIB's 73 is its field length: its vertical alignment is 74. + let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign }); + let (p1, p2) = (point(t.insert), t.align.map(point)); + let mut angle = t.rotation.to_radians(); let mut span = None; // Aligned and fit text run from the first point to the second. let anchor = match (h, p2) { @@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { _ => Justify::Left, }; let v = if h == 4 { 2 } else { v }; - let flags = g.int_or(71, 0); + let flags = t.generation; let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 }); - let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); + let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); Some(TextItem { text: s, height, xf, - width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0), + width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0), justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + style: t.style.to_uppercase(), family: None, leading: None, span, }) } -fn mtext(g: &[Pair<'_>]) -> Option { - // The text comes in 250-character chunks (3) before the last (1). - let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect(); +/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`]. +fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option { + // The text comes in 250-character chunks (3) before the last (1), up to an embedded object. + let (own, _) = model::text::split_embedded(tags); + let g = Groups(own); + let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect(); let (s, family) = super::text::mtext(&raw); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0); + let height = Some(t.height).filter(|h| *h > 0.0)?; + let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0); let lines = s.lines().count().max(1) as f64; - let attach = g.int_or(71, 1).clamp(1, 9) - 1; + let attach = i64::from(t.attach) - 1; let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default(); // The first baseline below the attachment point: under the top, around the middle, above // the bottom of the lines. @@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option { 1 => total / 2.0 - height, _ => (lines - 1.0) * leading, }; - let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); - let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); - let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); - Some(TextItem { - text: s, - height, - xf, - width: 1.0, - justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), - family, - leading: Some(leading), - span: None, - }) + let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first)); + Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None }) } diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 0da950ead1..762cfdb721 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -1,156 +1,14 @@ -//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its -//! sections (header variables; the layer, linetype, text style and block record tables; the -//! blocks; the entities; the layouts). +//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer, +//! linetype and text style tables; the blocks; the entities; the layouts. -use std::borrow::Cow; use std::collections::HashMap; +use craft_dxf::model::{self, EntityRecord}; use vectorcraft_geom::Point; -/// The most group-code pairs a drawing may hold (about 300 MB of DXF). -const MAX_PAIRS: usize = 20_000_000; - /// The first bytes of a binary DXF file (its format's signature). pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00"; -/// One group: its code and its value as written. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Pair<'a> { - pub code: i32, - pub value: &'a str, -} - -/// Values of a run of pairs by group code. -pub(crate) trait Groups<'a> { - /// The value of the first `code`, as written. - fn raw(&self, code: i32) -> Option<&'a str>; - /// The first `code` as a name (trimmed, never empty). - fn name(&self, code: i32) -> Option<&'a str> { - self.raw(code).map(str::trim).filter(|s| !s.is_empty()) - } - /// The first `code` as a finite number. - fn num(&self, code: i32) -> Option { - self.raw(code).and_then(number) - } - fn num_or(&self, code: i32, default: f64) -> f64 { - self.num(code).unwrap_or(default) - } - /// The first `code` as a whole number. - fn int(&self, code: i32) -> Option { - self.raw(code).and_then(integer) - } - fn int_or(&self, code: i32, default: i64) -> i64 { - self.int(code).unwrap_or(default) - } - /// The point at `code` (x) and `code + 10` (y); 0 for what is missing. - fn point(&self, code: i32) -> Point { - Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0)) - } - /// [`Groups::point`] when its x is given. - fn point_opt(&self, code: i32) -> Option { - self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0))) - } -} - -impl<'a> Groups<'a> for [Pair<'a>] { - fn raw(&self, code: i32) -> Option<&'a str> { - self.iter().find(|p| p.code == code).map(|p| p.value) - } -} - -/// A finite number (`1e999`, `NaN` and junk are none). -pub(crate) fn number(s: &str) -> Option { - s.trim().parse::().ok().filter(|v| v.is_finite()) -} - -/// A whole number; some writers put integers as `1.0`. -pub(crate) fn integer(s: &str) -> Option { - let s = s.trim(); - s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64)) -} - -/// One object: its type (the value of its `0` group) and the groups up to the next object. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Record<'a> { - pub kind: &'a str, - pub g: &'a [Pair<'a>], -} - -/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions -/// write (a byte order mark is dropped). -pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> { - let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); - match std::str::from_utf8(bytes) { - Ok(s) => Cow::Borrowed(s), - Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()), - } -} - -/// A byte of the Windows Western code page. -fn windows_1252(b: u8) -> char { - const HIGH: [char; 32] = [ - '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', - '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ', - ]; - match b { - 0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'), - _ => char::from(b), - } -} - -/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the -/// reading: what came before is kept and the second value says where the file is damaged. -pub(crate) fn pairs(text: &str) -> Result<(Vec>, Option), String> { - let mut out = Vec::new(); - let mut lines = text.lines().enumerate(); - let mut damaged = None; - while let Some((n, code)) = lines.next() { - let c = code.trim(); - if c.is_empty() && out.is_empty() { - continue; - } - let Ok(code) = c.parse::() else { - damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1)); - break; - }; - let value = lines.next().map_or("", |(_, v)| v); - if code == 0 && value.trim() == "EOF" { - break; - } - if out.len() >= MAX_PAIRS { - return Err("the drawing is too large to open".into()); - } - out.push(Pair { code, value }); - } - if out.is_empty() { - return Err("not a DXF drawing".into()); - } - Ok((out, damaged)) -} - -/// The objects in `pairs`: each from a `0` group up to the next. -fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec> { - let mut out = Vec::new(); - let mut start: Option = None; - for (i, p) in pairs.iter().enumerate() { - if p.code == 0 { - if let Some(s) = start { - out.extend(record(pairs, s, i)); - } - start = Some(i); - } - } - if let Some(s) = start { - out.extend(record(pairs, s, pairs.len())); - } - out -} - -fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option> { - let kind = pairs.get(start)?.value.trim(); - Some(Record { kind, g: pairs.get(start + 1..end)? }) -} - /// The header variables the import reads. #[derive(Clone, Debug, Default)] pub(crate) struct Header { @@ -186,7 +44,7 @@ pub(crate) struct LayerDef { pub(crate) struct BlockDef<'a> { pub name: String, pub base: Point, - pub records: Vec>, + pub records: Vec>, /// A reference to another drawing (its art isn't in this file). pub xref: bool, } @@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> { /// Blocks by upper-case name. pub blocks: HashMap>, /// The ENTITIES section. - pub entities: Vec>, + pub entities: Vec>, /// Model space first, then the paper layouts in tab order. pub layouts: Vec, /// How many sections the file has (none: not a drawing). pub sections: usize, } -impl<'a> Drawing<'a> { - /// Read the sections of `pairs`. - pub fn read(pairs: &'a [Pair<'a>]) -> Self { - let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() }; - let all = records(pairs); - let mut section: Option<&str> = None; - let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]); - for r in all { - match (r.kind, section) { - ("SECTION", _) => { - d.sections += 1; - section = r.g.name(2); - if section == Some("HEADER") { - d.header = header(r.g); - } - } - ("ENDSEC", _) => section = None, - (_, Some("TABLES")) => tables.push(r), - (_, Some("BLOCKS")) => blocks.push(r), - (_, Some("ENTITIES")) => d.entities.push(r), - (_, Some("OBJECTS")) => objects.push(r), - _ => {} - } - } - let mut block_records = HashMap::new(); - let mut table = ""; - for r in &tables { - match r.kind { - "TABLE" => table = r.g.name(2).unwrap_or(""), - "LAYER" if table == "LAYER" => { - if let Some(l) = layer(r.g) { - d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); - d.layers.push(l); - } - } - "LTYPE" if table == "LTYPE" => { - if let Some(name) = r.g.name(2) { - let pattern: Vec = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect(); - d.linetypes.insert(name.to_uppercase(), pattern); - } - } - "STYLE" if table == "STYLE" => { - if let Some(name) = r.g.name(2) { - d.styles.insert(name.to_uppercase(), font_family(r.g)); - } - } - "BLOCK_RECORD" if table == "BLOCK_RECORD" => { - if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) { - block_records.insert(h.to_uppercase(), name.to_uppercase()); - } - } - _ => {} - } - } - d.read_blocks(&blocks, &mut block_records); - d.layouts = layouts(&objects, &block_records, &d.entities); - d - } +/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. +pub(crate) fn aci_paper(i: u8) -> [u8; 3] { + if i == 7 { [0; 3] } else { model::aci::rgb(i) } +} - fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap) { - let mut current: Option> = None; - for r in records { - match r.kind { - "BLOCK" => { - let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string(); - // R13+ blocks name their block record: layouts find their block through it. - if let Some(owner) = r.g.name(330) { - block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase()); - } - let xref = r.g.int_or(70, 0) & 4 != 0; - current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref }); - } - "ENDBLK" => { - if let Some(b) = current.take().filter(|b| !b.name.is_empty()) { - self.blocks.insert(b.name.to_uppercase(), b); - } - } - _ => { - if let Some(b) = &mut current { - b.records.push(*r); - } - } - } +impl<'a> Drawing<'a> { + /// What the import uses of `doc`. + pub fn read(doc: model::Document<'a>) -> Self { + let h = &doc.header; + let header = Header { + acadver: h.acadver().unwrap_or_default(), + insunits: h.insunits(), + measurement: h.measurement(), + ltscale: h.ltscale(), + lwdefault: i32::from(h.lwdefault()), + }; + let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() }; + for l in doc.layers() { + d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); + d.layers.push(layer(l)); } + for lt in doc.linetypes() { + d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect()); + } + for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) { + d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default()); + } + let block_names = doc.block_names(); + d.layouts = layouts(&doc, &block_names); + for b in doc.blocks { + if b.name.is_empty() { + continue; + } + let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities }; + d.blocks.insert(b.name.to_uppercase(), def); + } + d.entities = doc.entities; + d } /// The layout named `name` (any case; `model` is model space). @@ -317,7 +129,7 @@ impl<'a> Drawing<'a> { } /// The entity records of `layout`. - pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { + pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default(); // The active paper layout keeps its art in the ENTITIES section, flagged paper space. let (block_name, paper) = match &layout.block { @@ -325,7 +137,7 @@ impl<'a> Drawing<'a> { Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true), Some(b) => return block(b), }; - let mut out: Vec> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect(); + let mut out: Vec> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect(); out.extend(block(block_name)); out } @@ -336,111 +148,47 @@ impl<'a> Drawing<'a> { } } -/// Is an entity (its first record) in paper space? -fn in_paper_space(group: &[Record<'_>]) -> bool { - group.first().is_some_and(|r| r.g.int(67) == Some(1)) +pub(crate) fn point(p: model::Point) -> Point { + Point::new(p[0], p[1]) } -/// The entities of `records`, each with the records that belong to it (a polyline's vertices, -/// an insert's attributes, and the SEQEND closing them). -pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator]> { - let mut i = 0; - std::iter::from_fn(move || { - let start = i; - let head = records.get(start)?; - i += 1; - if matches!(head.kind, "POLYLINE" | "INSERT") { - while let Some(r) = records.get(i) { - match r.kind { - "VERTEX" | "ATTRIB" => i += 1, - "SEQEND" => { - i += 1; - break; - } - _ => break, - } - } - } - records.get(start..i) - }) -} - -fn header(g: &[Pair<'_>]) -> Header { - let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }; - let mut var = ""; - for p in g { - if p.code == 9 { - var = p.value.trim(); - continue; - } - match (var, p.code) { - ("$ACADVER", 1) => h.acadver = p.value.trim().to_string(), - ("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0), - ("$MEASUREMENT", 70) => h.measurement = integer(p.value), - ("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0), - ("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25), - _ => {} - } - } - h -} - -fn layer(g: &[Pair<'_>]) -> Option { - let name = g.name(2)?.to_string(); - let aci = g.int_or(62, 7); - let flags = g.int_or(70, 0); - let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs())); - Some(LayerDef { +fn layer(l: model::Layer) -> LayerDef { + let rgb = match l.color { + model::Color::Rgb(rgb) => rgb, + model::Color::Index(i) => aci_paper(i), + model::Color::ByLayer | model::Color::ByBlock => aci_paper(7), + }; + LayerDef { // CAD apps never plot this layer of dimension points. - plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"), - name, + plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"), rgb, - hidden: aci < 0 || flags & 1 != 0, - locked: flags & 4 != 0, - linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(), - lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3), - }) -} - -/// A text style's font family: the one 2000-and-later files name, else the font file's name -/// (TrueType); CAD fonts (`.shx`) have none. -fn font_family(g: &[Pair<'_>]) -> String { - if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD") - && let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000)) - { - return f.to_string(); - } - let file = g.name(3).unwrap_or(""); - let path = std::path::Path::new(file); - let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - match ext.as_str() { - "ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), - _ => String::new(), + hidden: !l.on || l.frozen, + locked: l.locked, + linetype: l.linetype.to_uppercase(), + lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3), + name: l.name, } } -/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout -/// objects but with paper-space art has one paper layout. /// The most bytes of layout and block names the layouts keep together. Each layout keeps its own /// copy of its block's name, so many layouts naming one long block name multiplied it; real /// drawings have a few dozen layouts with short names. const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20; -fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { +/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout +/// objects but with paper-space art has one paper layout. +fn layouts(doc: &model::Document<'_>, block_names: &HashMap) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; let mut seen = std::collections::HashSet::new(); let mut name_bytes = 0usize; - for r in objects.iter().filter(|r| r.kind == "LAYOUT") { - // The layout's own groups follow its subclass marker. - let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); - let Some(name) = own.name(1) else { continue }; - let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())); - if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) { + for l in doc.layouts() { + let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase()); + if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) { continue; } let Some(block) = block else { continue }; - let key = name.to_ascii_lowercase(); - let cost = name.len() + block.len(); + let key = l.name.to_ascii_lowercase(); + let cost = l.name.len() + block.len(); // A layout past the allowance is skipped alone: a later short one is still offered, and a // skipped one reserves neither its name nor its bytes. if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES { @@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti } seen.insert(key); name_bytes += cost; - paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) })); + paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) })); } paper.sort_by_key(|(order, _)| *order); - if paper.is_empty() && groups(entities).any(in_paper_space) { + if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) { paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) })); } std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect() } - -#[cfg(test)] -mod tests { - use super::integer; - - #[test] - fn integer_rejects_fractional_float_values() { - assert_eq!(integer("1.0"), Some(1)); - assert_eq!(integer("1.9"), None); - } -} diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d28a48553d..67ceef03a0 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() { binary.extend([0u8; 40]); assert!(is_dxf(&binary)); let e = import(&binary, &ImportOptions::default()).unwrap_err(); - assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}"); + assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}"); + // A binary DXF opens as its ASCII form does. + let ascii = entities(LINE); + let tags = craft_dxf::parse(&ascii).unwrap(); + let r = open(&craft_dxf::write_binary(&tags, true), &points()); + assert_eq!(leaves(&r.document).len(), 1); let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err(); assert!(e.contains("DWG"), "{e}"); for junk in [&b""[..], b"hello", b"", b"999\nnote\n"] { @@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")]; let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect(); let bytes = entities(&e); - let text = reader::decode(&bytes); - let (pairs, _) = reader::pairs(&text).unwrap(); - let d = reader::Drawing::read(&pairs); + let tags = craft_dxf::parse(&bytes).unwrap(); + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); let model = d.layout("Model").or(d.layouts.first()).unwrap(); let mut conv = entity::Converter::default(); let items = conv.items(&d.layout_records(model)); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 1443829806..66a6a36baa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader and writer. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on +//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,7 +11,7 @@ //! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns, //! clipping) is approximated or left out, and each such loss comes back as a warning. //! -//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`]. //! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. diff --git a/crates/engine/src/cmd/fileio/dxfimport.rs b/crates/engine/src/cmd/fileio/dxfimport.rs index 96fb715106..eef7aca4cd 100644 --- a/crates/engine/src/cmd/fileio/dxfimport.rs +++ b/crates/engine/src/cmd/fileio/dxfimport.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "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", + "{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 DWG answers an error saying to save it as DXF", always, dxf_info )] diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs index ec7b295c23..d3dcdcb86c 100644 --- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs @@ -163,12 +163,12 @@ fn dxf_info_lists_layouts_layers_and_units() { } #[test] -fn binary_dxf_says_to_save_it_as_ascii() { +fn damaged_binary_dxf_says_so() { 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}"); + assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}"); let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string(); assert!(e.contains("binary DXF"), "{e}"); } diff --git a/docs/mcp.md b/docs/mcp.md index ef3ff788a5..ac076bcbe7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7200,7 +7200,7 @@ hatches filled, pattern hatches as their boundaries), text and multiline text po (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. +listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an error saying to save them as DXF. ```json {"name":"run_command","arguments":{"command":"document.dxfInfo","params":{"path":"/tmp/plan.dxf"}}}