From 970e6bb9d4f6fef9f097fac95aede7f294b47f06 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:29:05 +0000 Subject: [PATCH] refactor(io): move dxf_read.rs items to dxf_read/ --- crates/io/src/dxf_read.rs | 2396 +--------------------------- crates/io/src/dxf_read/common.rs | 82 + crates/io/src/dxf_read/dimassoc.rs | 156 ++ crates/io/src/dxf_read/document.rs | 326 ++++ crates/io/src/dxf_read/entities.rs | 651 ++++++++ crates/io/src/dxf_read/hatch.rs | 356 +++++ crates/io/src/dxf_read/objects.rs | 285 ++++ crates/io/src/dxf_read/tables.rs | 370 +++++ crates/io/src/dxf_read/text.rs | 175 ++ 9 files changed, 2419 insertions(+), 2378 deletions(-) create mode 100644 crates/io/src/dxf_read/common.rs create mode 100644 crates/io/src/dxf_read/dimassoc.rs create mode 100644 crates/io/src/dxf_read/document.rs create mode 100644 crates/io/src/dxf_read/entities.rs create mode 100644 crates/io/src/dxf_read/hatch.rs create mode 100644 crates/io/src/dxf_read/objects.rs create mode 100644 crates/io/src/dxf_read/tables.rs create mode 100644 crates/io/src/dxf_read/text.rs diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 4c19a1184..88d1801c3 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -9,968 +9,26 @@ use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; use crate::{IoError, Result}; +mod common; +mod dimassoc; +mod document; +mod entities; +mod hatch; +mod objects; +mod tables; +mod text; + +use document::{ + apply_sortents, count_skipped, outside_sections, parse_entities, place_blocks, place_entities, read_blocks, read_objects, setup_layouts, +}; +#[cfg(test)] +use entities::acad_table; +use entities::{entity, plot_style_names}; +use objects::Objects; +use tables::{header, tables}; + pub(crate) mod mtext_dxf; -/// Value lookup helpers over an entity's tags. -struct T<'a>(&'a [Tag]); - -impl T<'_> { - fn f(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::f64) - } - fn fd(&self, code: i32, d: f64) -> f64 { - self.f(code).unwrap_or(d) - } - fn i(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::i64) - } - fn s(&self, code: i32) -> Option { - self.0.iter().find(|t| t.code == code).map(Tag::str) - } - fn p(&self, code: i32) -> Vec3 { - Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) - } - fn all_f(&self, code: i32) -> Vec { - self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() - } - fn pts(&self, code: i32) -> Vec { - let xs = self.all_f(code); - let ys = self.all_f(code + 10); - xs.into_iter().zip(ys).map(|(x, y)| Vec2::new(x, y)).collect() - } -} - -/// An entity's group 440: `0x01` in the top byte is ByBlock, `0x02` a fixed alpha (mapped to a -/// percentage as for layers), anything else ByLayer. -fn transparency_440(v: i64) -> Transparency { - match (v >> 24) & 0xff { - 1 => Transparency::ByBlock, - 2 => { - let alpha = u32::try_from(v & 0xff).unwrap_or(255); - let opaque = (alpha * 100 + 127) / 255; - Transparency::Percent(u8::try_from(100u32.saturating_sub(opaque).min(90)).unwrap_or(90)) - } - _ => Transparency::ByLayer, - } -} - -fn common(t: &T) -> Common { - let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; - if let Some(v) = t.i(62) { - c.color = Color::from_aci(v as i16); - if v < 0 { - c.color = Color::ByLayer; - c.visible = true; - } - } - if let Some(tc) = t.i(420) { - c.color = Color::True(Rgb::from_u32(tc as u32)); - } - if let Some(lw) = t.i(370) { - c.lineweight = Lineweight::from_dxf(lw as i16); - } - if let Some(s) = t.f(48) { - c.ltscale = if s > 0.0 { s } else { 1.0 }; - } - if t.i(60) == Some(1) { - c.visible = false; - } - if let Some(v) = t.i(440) { - c.transparency = transparency_440(v); - } - c.thickness = t.fd(39, 0.0); - c.extrusion = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); - c -} - -/// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. -fn ocs(c: &Common, p: Vec3) -> Vec3 { - ocs_n(c.extrusion, p) -} - -fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { - if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } -} - -/// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when -/// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's -/// layer, as it is drawn anyway). -fn attrib_props(t: &T, insert_layer: &str) -> AttribProps { - let c = common(t); - AttribProps { - layer: t.s(8).filter(|l| !l.is_empty() && !l.eq_ignore_ascii_case(insert_layer)), - color: (t.i(62).is_some() || t.i(420).is_some()).then_some(c.color), - linetype: t.s(6).filter(|l| !l.is_empty()), - lineweight: t.i(370).is_some().then_some(c.lineweight), - } -} - -/// An attribute's modes from its own groups: flags (70) 4 verify and 8 preset, and Lock -/// position, the 280 after its tag in its subclass (a 280 before the tag is the subclass version). -fn attrib_modes(own: &[Tag]) -> AttribModes { - let flags = own.iter().find(|t| t.code == 70).map(Tag::i64).unwrap_or(0); - let sub = own.iter().position(|t| t.code == 100 && t.str().trim().starts_with("AcDbAttribute")).unwrap_or(0); - let rest = own.get(sub..).unwrap_or_default(); - let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0)); - AttribModes { verify: flags & 4 != 0, preset: flags & 8 != 0, lock_position } -} - -/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`): -/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker. -fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) { - match tags.iter().position(|t| t.code == 101) { - Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)), - None => (tags, None), - } -} - -/// A multiline attribute's text, from the MTEXT groups embedded in it. -fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option { - let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c); - let read_as = cadcraft_fonts::plain_mtext(&mtext.contents); - Some(MultilineAttrib { mtext, read_as }) -} - -/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones). -fn mtext_from(tags: &[Tag], c: &Common) -> MText { - let t = T(tags); - // Contents: code 3 chunks followed by code 1. - let mut contents = String::new(); - for tg in tags.iter().filter(|x| x.code == 3) { - contents.push_str(&tg.str()); - } - contents.push_str(&t.s(1).unwrap_or_default()); - let contents = mtext_dxf::decode_contents(&contents); - let columns = mtext_dxf::read_columns(tags); - // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object - // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). - let own = T(mtext_dxf::before_columns(tags)); - let mut rotation = match (own.f(11), own.f(21)) { - (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), - _ => own.fd(50, 0.0).to_radians(), - }; - let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; - if c.extrusion.z < 0.0 { - // Insertion point and direction are WCS, but the line height runs along extrusion × - // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with - // the same footprint, the text turns round and left/right attachment swap. - rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); - attach = match attach { - 1 | 4 | 7 => attach + 2, - 3 | 6 | 9 => attach - 2, - a => a, - }; - } - MText { - insert: t.p(10), - height: t.fd(40, 0.2).max(1e-9), - width: match &columns { - Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, - _ => t.fd(41, 0.0).max(0.0), - }, - attach, - rotation, - style: t.s(7).unwrap_or_else(|| "Standard".into()), - contents, - line_spacing: t.fd(44, 1.0), - line_spacing_exact: t.i(73) == Some(2), - columns: columns.map(|(c, _)| c), - } -} - -fn text_from(t: &T) -> Text { - let h = match t.i(72).unwrap_or(0) { - 1 => HAlign::Center, - 2 => HAlign::Right, - 3 => HAlign::Aligned, - 4 => HAlign::Middle, - 5 => HAlign::Fit, - _ => HAlign::Left, - }; - let v = match t.i(73).or_else(|| t.i(74)).unwrap_or(0) { - 1 => VAlign::Bottom, - 2 => VAlign::Middle, - 3 => VAlign::Top, - _ => VAlign::Baseline, - }; - let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; - let mut text = Text { - insert: t.p(10), - align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, - height: t.fd(40, 0.2).max(1e-9), - value: t.s(1).unwrap_or_default(), - rotation: t.fd(50, 0.0).to_radians(), - width_factor: t.fd(41, 1.0), - oblique: t.fd(51, 0.0).to_radians(), - style: t.s(7).unwrap_or_else(|| "Standard".into()), - halign: h, - valign: v, - }; - let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); - if n.z < 0.0 { - mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); - } - text -} - -/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its -/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable -/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and -/// left/right justification swap ends (start ↔ end point). -fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { - t.rotation = cadcraft_geom::norm_angle(-t.rotation); - let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; - match t.halign { - HAlign::Left => { - // TL/ML/BL are placed by their alignment point, baseline-left text by its start point. - t.halign = HAlign::Right; - t.insert = insert; - t.align_pt = Some(if t.valign == VAlign::Baseline { insert } else { align.unwrap_or(insert) }); - } - HAlign::Right => { - t.halign = HAlign::Left; - t.insert = align.unwrap_or(insert); - t.align_pt = baseline_left(t, t.insert); - } - HAlign::Aligned | HAlign::Fit => { - t.insert = align.unwrap_or(insert); - t.align_pt = Some(insert); - } - HAlign::Center | HAlign::Middle => { - t.insert = insert; - t.align_pt = align; - } - } -} - -/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. -fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { - entity_with_table_budget(kind, tags, &mut 4_000_000) -} - -/// Parse one entity while charging materialized table cells to the caller's shared import budget. -/// An ARC, with its angles run the other way in a mirrored OCS. -fn arc(c: &Common, t: &T) -> Arc { - let mut a = Arc { center: ocs(c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; - if c.extrusion.z < 0.0 { - // Mirrored OCS: angles run the other way. - let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); - a.start = cadcraft_geom::norm_angle(s); - a.end = cadcraft_geom::norm_angle(e); - } - a -} - -fn ellipse(c: &Common, t: &T) -> Ellipse { - let mut e = Ellipse { - center: t.p(10), - major: t.p(11), - ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), - start: t.fd(41, 0.0), - end: t.fd(42, std::f64::consts::TAU), - }; - if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { - // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) - // it points the other way, so parameter t lands where -t does in the XY plane. A full - // ellipse is symmetric and stays as it is. - let (s, e2) = (-e.end, -e.start); - e.start = cadcraft_geom::norm_angle(s); - e.end = cadcraft_geom::norm_angle(e2); - } - e -} - -/// A LWPOLYLINE, or the region CADCraft wrote as one. -fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option { - let mut verts = Vec::new(); - let mut cur: Option = None; - for tg in tags { - match tg.code { - 10 => { - if let Some(v) = cur.take() { - verts.push(v); - } - cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); - } - 20 => { - if let Some(v) = cur.as_mut() { - v.p.y = tg.f64(); - } - } - 40 => { - if let Some(v) = cur.as_mut() { - v.start_width = tg.f64(); - } - } - 41 => { - if let Some(v) = cur.as_mut() { - v.end_width = tg.f64(); - } - } - 42 => { - if let Some(v) = cur.as_mut() { - v.bulge = tg.f64(); - } - } - _ => {} - } - } - verts.extend(cur); - let flags = t.i(70).unwrap_or(0); - if c.extrusion.z < 0.0 { - for v in &mut verts { - v.p.x = -v.p.x; - v.bulge = -v.bulge; - } - } - let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 }; - // A region CADCraft wrote as polylines (`dxf_region`). - crate::dxf_region::from_polyline(tags, pl) -} - -fn spline(t: &T) -> Spline { - let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; - let knots = t.all_f(40); - let control = t.pts(10); - let weights = t.all_f(41); - let fit = t.pts(11); - let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); - let fit_opts = - cadcraft_geom::FitOptions { knots: Default::default(), start_tangent: tangent(12), end_tangent: tangent(13), tolerance: t.fd(44, 0.0) } - .sanitized(); - let closed = t.i(70).unwrap_or(0) & 1 != 0; - let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; - if !sp.is_valid() && fit.len() >= 2 { - sp = Spline::fit_with(&fit, sp.closed, fit_opts); - } else if !sp.is_valid() && sp.control.len() >= 2 { - sp = Spline::from_control(sp.control.clone(), degree); - } else if fit.len() >= 3 { - // DXF keeps the knots, not how they were spaced: recover it for later refits. - sp.fit_opts.knots = sp.infer_knot_param(); - } - sp -} - -/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups. -fn attdef(tags: &[Tag], c: &Common) -> Attrib { - // A multiline definition has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(tags); - let multiline = multiline_attrib(embedded, c); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: own.i(70).unwrap_or(0) & 2 != 0, - prompt: own.s(3).unwrap_or_default(), - props: Default::default(), - multiline, - modes: attrib_modes(own.0), - } -} - -fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { - // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. - if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { - return None; - } - let t = T(tags); - let c = common(&t); - let k = match kind { - "LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }), - "POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }), - "CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }), - "ARC" => EntityKind::Arc(arc(&c, &t)), - "ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)), - "LWPOLYLINE" => lwpolyline(tags, &c, &t)?, - "SPLINE" => EntityKind::Spline(spline(&t)), - "HELIX" => EntityKind::Helix(helix(tags)), - "RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }), - "XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }), - "TEXT" => EntityKind::Text(text_from(&t)), - "MTEXT" => EntityKind::MText(mtext_from(tags, &c)), - "ATTDEF" => EntityKind::AttDef(attdef(tags, &c)), - "INSERT" => { - // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, - // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that - // is the mirrored point, a negative x scale, the opposite rotation and a negative column - // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the - // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel - // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. - let n = c.extrusion; - let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); - let mut p = t.p(10); - let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); - let mut rotation = t.fd(50, 0.0).to_radians(); - let mut col_spacing = t.fd(44, 0.0); - if mirrored { - p = ocs(&c, p); - scale.x = -scale.x; - rotation = -rotation; - col_spacing = -col_spacing; - } - EntityKind::Insert(Insert { - block: t.s(2).unwrap_or_default(), - insert: p, - scale, - rotation, - attribs: Vec::new(), - cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, - rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, - col_spacing, - row_spacing: t.fd(45, 0.0), - }) - } - "DIMENSION" => { - let ty = t.i(70).unwrap_or(0) & 0x0f; - let kind = match ty { - 0 => DimKind::Linear { rotation: t.fd(50, 0.0).to_radians() }, - 1 => DimKind::Aligned, - 2 => DimKind::Angular, - 3 => DimKind::Diameter, - 4 => DimKind::Radius, - 5 => DimKind::Angular3P, - 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, - 8 => DimKind::ArcLength, - _ => DimKind::Aligned, - }; - EntityKind::Dimension(Dimension { - kind, - defpt: t.p(10), - // The text midpoint is the one OCS point of a DIMENSION. - text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, - p13: t.p(13), - p14: t.p(14), - p15: t.p(15), - p16: t.p(16), - text: t.s(1).unwrap_or_default(), - style: t.s(3).unwrap_or_else(|| "Standard".into()), - measurement: t.fd(42, 0.0), - text_rotation: t.fd(53, 0.0).to_radians(), - user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, - block: t.s(2).filter(|s| !s.is_empty()), - overrides: Default::default(), - assoc: Vec::new(), - marks: Default::default(), - }) - } - "LEADER" => EntityKind::Leader(Leader { - vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), - arrow: t.i(71).unwrap_or(1) != 0, - spline: t.i(72).unwrap_or(0) != 0, - style: t.s(3).unwrap_or_else(|| "Standard".into()), - }), - // Names behind handles follow in `entity_extras` and `Objects::apply`. - "MULTILEADER" | "MLEADER" => EntityKind::MLeader(crate::dxf_mleader::read(tags)), - "SOLID" | "TRACE" => { - let corners = [t.p(10), t.p(11), t.p(12), if t.f(13).is_some() { t.p(13) } else { t.p(12) }]; - let corners = corners.map(|p| ocs(&c, p)); - if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } - } - "MLINE" => EntityKind::MLine(crate::dxf_mline::read_entity(tags)), - "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), - "HATCH" => { - let mut k = hatch(tags, &t)?; - if c.extrusion.z < 0.0 - && let EntityKind::Hatch(h) = &mut k - { - // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. - for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { - v.p.x = -v.p.x; - v.bulge = -v.bulge; - } - h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); - } - k - } - "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), - "VIEWPORT" => EntityKind::Viewport(Viewport { - center: t.p(10), - width: t.fd(40, 1.0), - height: t.fd(41, 1.0), - view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), - view_height: t.fd(45, 1.0), - id: t.i(69).unwrap_or(0) as u32, - locked: t.i(90).unwrap_or(0) & 16384 != 0, - frozen_layers: crate::dxf_ext::read_frozen(tags), - layer_colors: crate::dxf_ext::read_layer_colors(tags), - // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. - clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), - on: t.i(90).unwrap_or(0) & 131072 == 0, - }), - "WIPEOUT" => { - let o = t.p(10); - let u = t.p(11); - let v = t.p(12); - // Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down. - let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); - EntityKind::Wipeout(Wipeout { boundary: pts }) - } - // The file path comes from the IMAGEDEF object (`dxf_image::resolve`). - "IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)), - "TOLERANCE" => { - let dir = t.p(11); - let dir = if dir.xy().is_finite() && dir.xy().len() > 1e-12 { dir } else { Vec3::new(1.0, 0.0, 0.0) }; - EntityKind::Tolerance(Tolerance { - insert: t.p(10), - dir, - style: t.s(3).unwrap_or_else(|| "Standard".into()), - text: t.s(1).unwrap_or_default().chars().take(MAX_FRAME_CHARS).collect(), - }) - } - other => EntityKind::Unknown(Unknown { - dxf_type: other.to_string(), - tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(), - }), - }; - Some((c, k)) -} - -/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's -/// title/header flags, text height and style name from xdata when present. -fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table { - const MAX_CELLS: usize = 1_000_000; - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0); - let tail = tags.get(start..).unwrap_or(&[]); - let first_cell = tail.iter().position(|x| x.code == 171).unwrap_or(tail.len()); - let head = T(tail.get(..first_cell).unwrap_or(&[])); - let mut rows = usize::try_from(head.i(91).unwrap_or(0)).unwrap_or(0).min(10_000); - let mut cols = usize::try_from(head.i(92).unwrap_or(0)).unwrap_or(0).min(10_000); - // Large declared sizes must be backed by cell records (no allocation from a bare count). - let n_cells = tail.iter().filter(|x| x.code == 171).count(); - let declared = rows.saturating_mul(cols); - if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) { - rows = 0; - cols = 0; - } - // Charge before any cell vector is materialized, including discarded tables. - let count = rows.saturating_mul(cols); - match table_cells_left.checked_sub(count) { - Some(left) => *table_cells_left = left, - None => return Table { insert: T(tags).p(10), ..Table::default() }, - } - let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 }; - let mut row_heights: Vec = head.all_f(141).into_iter().take(rows).map(size).collect(); - row_heights.resize(rows, 0.27); - let mut col_widths: Vec = head.all_f(142).into_iter().take(cols).map(size).collect(); - col_widths.resize(cols, 2.5); - // Cells, row-major, each starting at a 171 group; xdata ends the last one. - let body = tail.get(first_cell..).unwrap_or(&[]); - let body = body.get(..body.iter().position(|x| x.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); - let mut flat: Vec = Vec::new(); - let mut i = 0; - while let Some(t0) = body.get(i) { - if flat.len() >= rows * cols { - break; - } - i += 1; - if t0.code != 171 { - continue; - } - let end = body.get(i..).and_then(|r| r.iter().position(|x| x.code == 171)).map(|p| i + p).unwrap_or(body.len()); - let cell = body.get(i..end).unwrap_or(&[]); - i = end; - let c = T(cell); - let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect(); - text.push_str(&c.s(1).unwrap_or_default()); - let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok()); - // A merge starts at the cell whose span (rows 176, columns 175) is more than one cell. - // Its own merged flag (173) varies by writer: CADCraft sets it, while AutoCAD-style - // writers (acadrust 0.6.4) set it only on the cells the merge covers, whose span is 0. - let merged = match (span(176), span(175)) { - (Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)), - _ => None, - }; - // The cell's own text height: 140 outside the value block (301 … 304), where it is a number. - let mut in_value = false; - let mut text_height = None; - for x in cell { - match x.code { - 301 => in_value = true, - 304 => in_value = false, - 140 if !in_value => text_height = Some(x.f64()).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e9), - _ => {} - } - } - flat.push(TableCell { text, merged, text_height, ..TableCell::default() }); - } - flat.resize(rows * cols, TableCell::default()); - let cells: Vec> = if cols == 0 { vec![Vec::new(); rows] } else { flat.chunks(cols).map(<[TableCell]>::to_vec).collect() }; - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let ours = x.iter().position(|t| t.code == 1000 && t.str() == "TABLE").map(|p| x.get(p + 1..).unwrap_or(&[])); - let (title, header, text_height, style) = match ours { - Some(o) => { - let flags: Vec = o.iter().filter(|t| t.code == 1070).map(Tag::i64).collect(); - let h = o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(0.18); - let style = o.iter().find(|t| t.code == 1000).map(Tag::str).unwrap_or_else(|| "Standard".into()); - (flags.first().is_some_and(|v| *v != 0), flags.get(1).is_some_and(|v| *v != 0), h, style) - } - // Other writers: a first row merged across every column reads as a title. - // Their text height is resolved to the table style's once the styles are read (`apply`). - None => (cols > 1 && cells.first().and_then(|r| r.first()).and_then(|c| c.merged) == Some((1, cols as u32)), false, 0.0, "Standard".into()), - }; - let mut t = Table { insert: T(tags).p(10), col_widths, row_heights, cells, style, text_height, title, header, ..Table::default() }; - crate::dxf_table::read_table_xdata(tags, &mut t); - t -} - -fn hatch(tags: &[Tag], t: &T) -> Option { - let (origin, background) = hatch_xdata(tags); - let pattern = t.s(2).unwrap_or_else(|| "SOLID".into()); - let solid = t.i(70).unwrap_or(0) == 1; - let n_loops = t.i(91).unwrap_or(0).clamp(0, 100_000) as usize; - let mut loops = Vec::new(); - // Walk the boundary data after the 91 tag. - let start = tags.iter().position(|x| x.code == 91).map(|i| i + 1)?; - let mut i = start; - let get = |i: usize| tags.get(i); - for _ in 0..n_loops { - // 92: loop type flags. - while get(i).is_some_and(|x| x.code != 92) { - i += 1; - } - let flags = get(i)?.i64(); - i += 1; - let outer = flags & 1 != 0; - let mut verts: Vec = Vec::new(); - if flags & 2 != 0 { - // Polyline boundary: 72 has-bulge, 73 closed, 93 count, then 10/20/42. - let has_bulge = get(i).filter(|x| x.code == 72).map(Tag::i64).unwrap_or(0) != 0; - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - let x = get(i).filter(|t| t.code == 10)?.f64(); - let y = get(i + 1).filter(|t| t.code == 20)?.f64(); - i += 2; - let mut b = 0.0; - if has_bulge && get(i).is_some_and(|t| t.code == 42) { - b = get(i)?.f64(); - i += 1; - } - verts.push(PolyVertex::with_bulge(Vec2::new(x, y), b)); - } - } else { - // Edge boundary: 93 edge count, each 72 edge type. - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - while get(i).is_some_and(|x| x.code != 72) { - i += 1; - } - let ty = get(i)?.i64(); - i += 1; - let f = |i: usize| get(i).map(Tag::f64).unwrap_or(0.0); - match ty { - 1 => { - let a = Vec2::new(f(i), f(i + 1)); - let b = Vec2::new(f(i + 2), f(i + 3)); - i += 4; - if verts.last().is_none_or(|l| !l.p.near(a, 1e-9)) { - verts.push(PolyVertex::new(a)); - } - verts.push(PolyVertex::new(b)); - } - 2 => { - let c = Vec2::new(f(i), f(i + 1)); - let r = f(i + 2); - let sa = f(i + 3).to_radians(); - let ea = f(i + 4).to_radians(); - let ccw = get(i + 5).map(Tag::i64).unwrap_or(1) != 0; - i += 6; - let (s, e) = if ccw { (sa, ea) } else { (-sa, -ea) }; - let p0 = Vec2::polar(c, r, s); - let p1 = Vec2::polar(c, r, e); - let sweep = if ccw { cadcraft_geom::ccw_sweep(s, e) } else { -cadcraft_geom::ccw_sweep(e, s) }; - if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { - verts.push(PolyVertex::new(p0)); - } - // A full circle (0 to 360) starts and ends on the same point, which one - // bulge can't span: it goes in as two half circles. - let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; - let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); - if let Some(l) = verts.last_mut() { - l.bulge = bulge; - } - if full { - verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); - } - verts.push(PolyVertex::new(p1)); - } - 3 => { - // Elliptic arc: approximate with points. - let c = Vec2::new(f(i), f(i + 1)); - let m = Vec2::new(f(i + 2), f(i + 3)); - let ratio = f(i + 4); - let sa = f(i + 5).to_radians(); - let ea = f(i + 6).to_radians(); - let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; - i += 8; - // A clockwise edge stores its parameters mirrored, as an arc edge does: it - // runs from -start clockwise to -end, the ccw sweep from -end to -start - // walked backwards. - let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; - let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; - let mut pts = Vec::new(); - e.tessellate(m.len() * 1e-3, &mut pts); - if !ccw { - pts.reverse(); - } - verts.extend(pts.into_iter().map(PolyVertex::new)); - } - 4 => { - // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 - // control point count, then the 40 knots, the 10/20 control points (each - // with a 42 weight when rational), and 97 fit point count with 11/21 fit - // points and 12/22, 13/23 end tangents (R2010+). - let (mut degree, mut nk, mut nc) = (3, 0, 0); - while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) { - let n = x.i64().clamp(0, 100_000) as usize; - match x.code { - 94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader - 95 => nk = n, - 96 => nc = n, - _ => {} - } - i += 1; - } - let mut knots = Vec::new(); - while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { - knots.push(f(i)); - i += 1; - } - let (mut control, mut weights) = (Vec::new(), Vec::new()); - while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { - control.push(Vec2::new(f(i), f(i + 1))); - i += 2; - if get(i).is_some_and(|t| t.code == 42) { - weights.push(f(i)); - i += 1; - } - } - let mut fit = Vec::new(); - if get(i).is_some_and(|x| x.code == 97) { - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; - i += 1; - while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { - fit.push(Vec2::new(f(i), f(i + 1))); - i += 2; - } - } - if weights.len() != control.len() { - weights.clear(); - } - let sp = Spline { degree, knots, control, weights, ..Spline::default() }; - let sp = if sp.is_valid() { - sp - } else if sp.control.is_empty() && fit.len() > 1 { - Spline::from_fit_points(&fit) - } else { - Spline::from_control(sp.control, degree) - }; - let pts = sp.tessellate(1e-3); - let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); - verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); - } - _ => {} - } - } - } - if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { - verts.pop(); - } - // 97: number of source boundary objects, then their handles (330). A spline edge's fit - // data count is a 97 too, followed by its fit points instead. - let mut sources = Vec::new(); - for _ in 0..2 { - while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { - i += 1; - } - if get(i).is_none_or(|x| x.code != 97) { - break; - } - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; - i += 1; - if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { - continue; - } - while sources.len() < n - && let Some(x) = get(i).filter(|x| x.code == 330) - { - sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); - i += 1; - } - break; - } - loops.push(HatchLoop { vertices: verts, outer, sources }); - } - let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); - let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); - // Keep the file's definition lines even for a name the library knows: the library's versions - // of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale - // could be hundreds of times denser than the hatch the author saw. - let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; - Some(EntityKind::Hatch(Hatch { - pattern: pattern.to_ascii_uppercase(), - solid, - loops, - scale, - angle, - associative: t.i(71).unwrap_or(0) != 0, - style: t.i(75).unwrap_or(0) as u8, - // The first 30 is the elevation point's z (boundary points are 2D). - elevation: t.f(30).filter(|z| z.is_finite()).unwrap_or(0.0), - gradient: gradient(tags), - origin, - background, - pattern_lines, - })) -} - -/// Upper bound on the source boundary objects of one HATCH loop. -const MAX_HATCH_SOURCES: usize = 4096; - -/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. -const MAX_PATTERN_LINES: usize = 1024; -const MAX_PATTERN_DASHES: usize = 256; - -/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base -/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, -/// rotated with the hatch and the offset as a world vector. They are kept the library's way -/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift -/// along the line and spacing), which the writer and the renderer turn back. Lines with -/// non-finite values are skipped. -fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { - let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; - let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; - let angle = if angle.is_finite() { angle } else { 0.0 }; - // Raw lines: angle (degrees), base, offset, dashes. - let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); - for x in tags.iter().skip(at + 1) { - let cur = raw.last_mut(); - match (x.code, cur) { - (53, _) => { - if raw.len() >= MAX_PATTERN_LINES { - break; - } - raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); - } - (43, Some(l)) => l.1.x = x.f64(), - (44, Some(l)) => l.1.y = x.f64(), - (45, Some(l)) => l.2.x = x.f64(), - (46, Some(l)) => l.2.y = x.f64(), - (79, Some(_)) => {} - (49, Some(l)) => { - if l.3.len() < MAX_PATTERN_DASHES { - l.3.push(x.f64()); - } - } - _ => break, - } - } - raw.into_iter() - .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) - .map(|(a, base, offset, dashes)| { - let local = (base - origin).rotate(-angle) * (1.0 / scale); - let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); - cadcraft_doc::library::PatternLine { - angle: a - angle.to_degrees(), - origin: (local.x, local.y), - delta: (delta.x, delta.y), - dashes: dashes.into_iter().map(|d| d / scale).collect(), - } - }) - .collect() -} - -/// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, -/// `1000` background colour name (empty for none). Defaults when absent. -fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let Some(at) = x.windows(2).position(|w| matches!(w, [m, o] if m.code == 1000 && m.str() == "HATCH" && o.code == 1011)) else { - return (Vec2::ZERO, None); - }; - let val = |k: usize, code: i32| x.get(at + k).filter(|t| t.code == code); - let coord = |k: usize, code: i32| val(k, code).map(Tag::f64).filter(|v| v.is_finite()).unwrap_or(0.0); - let origin = Vec2::new(coord(1, 1011), coord(2, 1021)); - (origin, val(4, 1000).and_then(|t| Color::parse(&t.str()))) -} - -/// Gradient values missing from a file fall back to the GRADIENT command's defaults. -fn default_gradient() -> Gradient { - Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } -} - -/// A hatch's gradient fill: the native groups when present (another program may have edited -/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). -fn gradient(tags: &[Tag]) -> Option { - if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } -} - -/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. -fn xdata_gradient(tags: &[Tag]) -> Option { - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; - let o = x.get(at + 1..).unwrap_or(&[]); - let d = default_gradient(); - let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); - let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); - Some(Gradient { - name: strs.first().cloned().unwrap_or(d.name), - color1: color(1, d.color1), - color2: color(2, d.color2), - angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), - centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), - }) -} - -/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record -/// whose 63 (ACI) and 421 (true colour) groups follow. -fn native_gradient(tags: &[Tag]) -> Option { - let start = tags.iter().position(|x| x.code == 450)?; - if tags.get(start)?.i64() != 1 { - return None; - } - let mut g = default_gradient(); - // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. - let mut colors: [(Option, Option); 2] = [(None, None); 2]; - let mut slot: Option = None; - for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { - let rec = slot.and_then(|s| colors.get_mut(s)); - match x.code { - 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), - 461 => g.centered = x.f64().abs() < 1e-9, - 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), - 63 => { - if let Some(r) = rec { - r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); - } - } - 421 => { - if let Some(r) = rec { - r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); - } - } - 470 => g.name = x.str(), - _ => {} - } - } - let [c1, c2] = colors; - let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); - g.color1 = pick(c1, g.color1); - g.color2 = pick(c2, g.color2); - Some(g) -} - /// Handle maps and references gathered while reading. #[derive(Default)] struct Rx { @@ -1000,701 +58,6 @@ struct Rx { layers: HashMap, } -/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the -/// named object dictionary (the first object), by placeholder handle. -fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap { - let mut out = HashMap::new(); - let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out }; - let recs = records(&objects.tags); - let entries = |tags: &[Tag]| { - let mut v: Vec<(String, String)> = Vec::new(); - let mut name: Option = None; - for t in tags { - match t.code { - 3 => name = Some(t.str()), - 350 | 360 => { - if let Some(n) = name.take() { - v.push((n, t.str().trim().to_ascii_uppercase())); - } - } - _ => {} - } - } - v - }; - let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out }; - let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else { - return out; - }; - let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT"; - let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else { - return out; - }; - for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) { - if !n.trim().is_empty() { - out.insert(h, n.chars().take(255).collect()); - } - } - out -} - -/// The named plot style an entity record points at (group 390 among its AcDbEntity groups). -fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option { - let mut in_entity = false; - for t in tags { - if t.code == 100 { - if in_entity { - return None; - } - in_entity = t.str() == "AcDbEntity"; - } else if t.code == 390 && in_entity { - return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); - } - } - None -} - -/// Dimension overrides and associativity, table references: data of an entity record that -/// needs the file's handle maps. -fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &mut Rx) { - match (kind, k) { - ("DIMENSION", EntityKind::Dimension(dm)) => { - dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs, &rx.ltypes); - dm.assoc = crate::dxf_ext::read_assoc(tags); - if let Some(center) = crate::dxf_ext::read_arclen(tags) { - dm.kind = DimKind::ArcLength; - dm.p15 = center; - } - dm.marks = crate::dxf_ext::read_marks(tags); - // Group 52: the extension lines' angle relative to the dimension line (DIMEDIT Oblique). - let base = match dm.kind { - DimKind::Linear { rotation } => Some(rotation), - DimKind::Aligned => Some((dm.p14.xy() - dm.p13.xy()).angle()), - _ => None, - }; - if let (Some(base), Some(o)) = (base, T(tags).f(52).filter(|o| o.is_finite() && o.rem_euclid(180.0) != 0.0)) { - dm.marks.oblique = Some(cadcraft_geom::norm_angle(base + o.to_radians())); - } - } - ("ACAD_TABLE", EntityKind::Table(_)) => { - let t = T(tags); - if let Some(b) = t.s(2) { - rx.table_blocks.insert(b.to_ascii_uppercase()); - } - let ours = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP).iter().any(|t| t.code == 1000 && t.str() == "TABLE"); - if !ours { - rx.table_style_fix.push((h, t.s(342).map(|sh| sh.trim().to_ascii_uppercase()).unwrap_or_default())); - } - } - ("INSERT", EntityKind::Insert(ins)) => { - rx.inserted.insert(ins.block.to_ascii_uppercase()); - } - ("MULTILEADER" | "MLEADER", EntityKind::MLeader(m)) => { - let pending = crate::dxf_mleader::resolve(tags, m, &rx.styles, &rx.brs); - if let Some(b) = &m.block { - rx.inserted.insert(b.block.to_ascii_uppercase()); - } - rx.mleader_fix.push((h, pending)); - } - // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. - ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { - v.frozen_layers = tags - .iter() - .take_while(|t| t.code < 1000) - .filter(|t| t.code == 341) - .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) - .collect(); - } - _ => {} - } -} - -/// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that -/// can't be placed are counted in `skipped`. -/// The entity of a POLYLINE, with its mesh faces (advancing `i` past its VERTEX records). `None` -/// for a mesh without faces. -fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) -> Option<(Common, EntityKind, Vec)> { - let flags = t.i(70).unwrap_or(0); - let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); - // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. - let mut faces: Vec<[i64; 4]> = Vec::new(); - while let Some((k2, t2)) = recs.get(*i) { - if k2 == "VERTEX" { - let tt = T(t2); - let vflags = tt.i(70).unwrap_or(0); - if vflags & 128 != 0 && vflags & 64 == 0 { - faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); - } else if vflags & 16 == 0 { - pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); - } - *i += 1; - } else { - if k2 == "SEQEND" { - *i += 1; - } - break; - } - } - let c = common(t); - let mut extra: Vec = Vec::new(); - let k = if flags & (16 | 64) != 0 { - // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the - // polyline's place. - let verts: Vec = pts.iter().map(|p| p.0).collect(); - let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); - let first = fs.next()?; - extra.extend(fs); - first - } else if flags & 8 != 0 { - EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) - } else { - // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. - let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; - EntityKind::LwPolyline(LwPolyline { - vertices: pts.iter().map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }).collect(), - closed: flags & 1 != 0, - const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), - elevation: t.p(10).z, - plinegen: flags & 128 != 0, - }) - }; - Some((c, k, extra)) -} - -/// The ATTRIB records after an INSERT, advancing `i` past them and their SEQEND. -fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_layer: &str) -> Vec { - let mut attribs = Vec::new(); - while let Some((k2, t2)) = recs.get(*i) { - if k2 == "ATTRIB" { - let tt = T(t2); - // A multiline attribute has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(t2); - let multiline = multiline_attrib(embedded, &common(&tt)); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - attribs.push(Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: false, - prompt: String::new(), - props: attrib_props(&tt, ins_layer), - multiline, - modes: attrib_modes(own.0), - }); - *i += 1; - } else { - if k2 == "SEQEND" { - *i += 1; - } - break; - } - } - attribs -} - -fn parse_entities( - recs: &[(String, Vec)], - d: &mut Drawing, - rx: &mut Rx, - skipped: &mut usize, - table_cells_left: &mut usize, -) -> Vec<(Option, bool, Entity)> { - let mut out = Vec::new(); - let mut i = 0; - while let Some((kind, tags)) = recs.get(i) { - let t = T(tags); - let owner = crate::dxf_ext::owner(tags); - let paper = t.i(67) == Some(1); - let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); - let mut kindent = entity_with_table_budget(kind, tags, table_cells_left); - if let Some((c, _)) = kindent.as_mut() - && let Some(n) = entity_plot_style(tags, rx) - { - c.plot_style = n; - } - // More entities from this record (the faces of a polyface or polygon mesh). - let mut extra: Vec = Vec::new(); - i += 1; - match kind.as_str() { - "POLYLINE" => { - let Some((c, k, more)) = polyline_entity(recs, &mut i, &t) else { - *skipped = skipped.saturating_add(1); - continue; - }; - extra = more; - kindent = Some((c, k)); - } - "INSERT" => { - if t.i(66) == Some(1) { - let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); - let attribs = insert_attribs(recs, &mut i, &ins_layer); - if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { - ins.attribs = attribs; - } - } - } - // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing. - "VERTEX" | "ATTRIB" => { - *skipped = skipped.saturating_add(1); - continue; - } - "SEQEND" => continue, - _ => {} - } - if let Some((c, mut k)) = kindent { - let h = match handle { - Some(h) => { - d.bump_handseed(h); - h - } - None => d.new_handle(), - }; - entity_extras(kind, tags, h, &mut k, rx); - let more: Vec<_> = - extra.into_iter().map(|f| (owner.clone(), paper, Entity { handle: d.new_handle(), common: c.clone(), kind: f })).collect(); - out.push((owner, paper, Entity { handle: h, common: c, kind: k })); - out.extend(more); - } else if kind == "HATCH" { - // Without boundary data. The other records `entity` returns nothing for are read - // elsewhere (the writer's paper viewport, the loops of a region). - *skipped = skipped.saturating_add(1); - } - } - // Assign fresh handles to duplicates (hostile files). - let mut seen = std::collections::HashSet::new(); - for (_, _, e) in &mut out { - if !seen.insert(e.handle) { - e.handle = d.new_handle(); - seen.insert(e.handle); - } - } - out -} - -/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; -/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or -/// an index outside the vertex list are skipped. -fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { - let mut out = Vec::new(); - for f in faces { - let mut corners: Vec = Vec::with_capacity(4); - let mut hidden = 0u8; - let mut ok = true; - for &ix in f.iter().filter(|&&ix| ix != 0) { - let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); - let Some(p) = at.and_then(|n| verts.get(n)) else { - ok = false; - break; - }; - if ix < 0 { - hidden |= 1 << corners.len(); - } - corners.push(*p); - } - match (ok, corners.as_slice()) { - // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). - (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), - (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), - _ => {} - } - } - out -} - -/// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches -/// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. -fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { - let dims = |a: i32, b: i32| Some((usize::try_from(t.i(a)?).ok()?, usize::try_from(t.i(b)?).ok()?)); - let fits = |(m, n): (usize, usize)| m >= 2 && n >= 2 && m.checked_mul(n).is_some_and(|mn| mn <= verts.len()); - let exact = |(m, n): (usize, usize)| fits((m, n)) && m * n == verts.len(); - let Some((m, n)) = [dims(71, 72), dims(73, 74)].into_iter().flatten().find(|d| exact(*d)).or_else(|| dims(71, 72).filter(|d| fits(*d))) else { - return Vec::new(); - }; - let at = |i: usize, j: usize| verts.get((i % m) * n + j % n).copied(); - let rows = if flags & 1 != 0 { m } else { m - 1 }; - let cols = if flags & 32 != 0 { n } else { n - 1 }; - let mut out = Vec::new(); - for i in 0..rows { - for j in 0..cols { - if let (Some(a), Some(b), Some(c), Some(d)) = (at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j)) { - out.push(Face3d { corners: [a, b, c, d], hidden_edges: 0 }); - } - } - } - out -} - -fn header(tags: &[Tag], d: &mut Drawing) { - let mut i = 0; - while let Some(t) = tags.get(i) { - if t.code == 9 { - let name = t.str().trim_start_matches('$').to_ascii_uppercase(); - i += 1; - let mut vals: Vec<&Tag> = Vec::new(); - while let Some(v) = tags.get(i) { - if v.code == 9 { - break; - } - vals.push(v); - i += 1; - } - // Legacy files keep spacing in HEADER; modern *ACTIVE VPORT entries override it. - if matches!(name.as_str(), "SNAPUNIT" | "GRIDUNIT") { - let (Some(x), Some(y)) = (vals.iter().find(|t| t.code == 10), vals.iter().find(|t| t.code == 20)) else { continue }; - let p = Vec2::new(x.f64(), y.f64()); - if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { - d.header.set(&name, HVal::Point(p.to3(0.0))); - } - continue; - } - let hv = match vals.as_slice() { - [x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())), - [x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)), - [v] => match &v.value { - cadcraft_dxf::Value::Real(r) => HVal::Real(*r), - cadcraft_dxf::Value::Int(n) => HVal::Int(*n), - cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)), - other => HVal::Str(other.as_str()), - }, - _ => continue, - }; - // $DIMDSEP holds a character code; the dimension style holds the character. - let hv = match hv { - HVal::Int(n) if name == "DIMDSEP" => match u32::try_from(n).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { - Some(c) => HVal::Str(c.to_string()), - None => continue, - }, - // $DIMJOGANG is in radians; the variable (like the style) holds degrees. - HVal::Real(r) if name == "DIMJOGANG" => HVal::Real((r.to_degrees() * 1e9).round() / 1e9), - // $ANGBASE is in degrees (a drawing measured from north has 90.0); the variable - // holds radians. - HVal::Real(r) if name == "ANGBASE" => HVal::Real(r.to_radians()), - hv => hv, - }; - d.header.set(&name, hv); - } else { - i += 1; - } - } -} - -/// True when a style record carries `AcadAnnotative` xdata with the annotative flag set. -fn annotative(tags: &[Tag]) -> bool { - let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, "AcadAnnotative"), "AnnotativeData"); - list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0) -} - -/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) -/// while the font file is still the `txt` written for other readers; the TrueType family of the -/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType -/// styles); otherwise the file. -fn style_font(tags: &[Tag], file: String) -> String { - if file.trim().is_empty() - && let Some(face) = - crate::dxf_ext::xdata(tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty()) - { - return face; - } - let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); - let own = x.windows(2).find_map(|w| match w { - [m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()), - _ => None, - }); - let stem = file.trim().to_ascii_lowercase(); - match own { - Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, - _ => file, - } -} - -/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates. -fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { - let recs = records(tags); - // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). - let ltype_styles: HashMap = recs - .iter() - .filter(|(k, _)| k == "STYLE") - .filter_map(|(_, tg)| { - let t = T(tg); - let name = t.s(2).filter(|n| !n.trim().is_empty()); - Some((t.s(5)?.trim().to_ascii_uppercase(), name.or_else(|| t.s(3))?)) - }) - .collect(); - // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. - for (kind, tg) in &recs { - let t = T(tg); - let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue }; - match kind.as_str() { - "STYLE" => { - rx.styles.insert(h.trim().to_ascii_uppercase(), n); - } - "BLOCK_RECORD" => { - let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); - rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); - if let Some(a) = crate::dxf_ext::read_array(tg) { - rx.block_arrays.insert(n.to_ascii_uppercase(), a); - } - rx.brs.insert(h.trim().to_ascii_uppercase(), n); - } - "LTYPE" => { - rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); - } - "LAYER" => { - rx.layers.insert(h.trim().to_ascii_uppercase(), n); - } - _ => {} - } - } - let mut active_vport = false; - // Index of layers already appended, by lower-cased name, so each LAYER record updates or - // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer - // names make import quadratic (CWE-407). - let mut layer_index: HashMap = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect(); - for (kind, tg) in recs { - let t = T(&tg); - match kind.as_str() { - "LAYER" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.layers.len() >= MAX_OBJECTS { - continue; - } - let aci = t.i(62).unwrap_or(7); - let flags = t.i(70).unwrap_or(0); - let mut l = Layer { - name: name.clone(), - color: Color::Index(aci.unsigned_abs().clamp(1, 255) as u8), - linetype: t.s(6).unwrap_or_else(|| "Continuous".into()), - lineweight: t.i(370).map(|v| Lineweight::from_dxf(v as i16)).unwrap_or(Lineweight::Default), - on: aci >= 0, - frozen: flags & 1 != 0, - locked: flags & 4 != 0, - vp_freeze_new: flags & 2 != 0, - plot: t.i(290).unwrap_or(1) != 0, - ..Layer::default() - }; - if let Some(tc) = t.i(420) { - l.color = Color::True(Rgb::from_u32(tc as u32)); - } - if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) { - l.plot_style = n.clone(); - } - let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071); - if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) { - l.transparency = pct; - } - if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) { - l.description = desc.str(); - } - match layer_index.get(&name.to_ascii_lowercase()) { - Some(&i) => { - if let Some(x) = d.layers.get_mut(i) { - *x = l; - } - } - None => { - layer_index.insert(name.to_ascii_lowercase(), d.layers.len()); - d.layers.push(l); - } - } - } - "LTYPE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS { - continue; - } - let pattern = ltype_pattern(&tg, <ype_styles); - d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern }); - } - "STYLE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS { - continue; - } - // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. - let gen_flags = t.i(71).unwrap_or(0); - let st = TextStyle { - name: name.clone(), - font: style_font(&tg, t.s(3).unwrap_or_default()), - big_font: t.s(4).unwrap_or_default(), - height: t.fd(40, 0.0), - width_factor: t.fd(41, 1.0), - oblique: t.fd(50, 0.0).to_radians(), - backwards: gen_flags & 2 != 0, - upside_down: gen_flags & 4 != 0, - vertical: t.i(70).unwrap_or(0) & 4 != 0, - annotative: annotative(&tg), - }; - match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = st, - None => d.text_styles.push(st), - } - } - // The first *ACTIVE viewport holds the current snap/grid settings. - "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { - active_vport = true; - vport_record(d, &t); - for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] { - if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) { - let p = Vec2::new(x, y); - if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { - d.header.set(name, HVal::Point(p.to3(0.0))); - } - } - } - if let (Some(x), Some(y)) = (t.f(13), t.f(23)) - && x.is_finite() - && y.is_finite() - { - d.header.set("SNAPBASE", HVal::Point(Vec3::new(x, y, 0.0))); - } - if let Some(a) = t.f(50).filter(|a| a.is_finite()) { - d.header.set_f64("SNAPANG", a); - } - } - "VIEW" => { - let name = t.s(2).unwrap_or_default(); - let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); - if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { - continue; - } - let center = t.p(10).xy(); - if !(center.x.is_finite() && center.y.is_finite()) { - continue; - } - let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP); - let field = |key: &str| { - x.iter().position(|t| t.code == 1000 && t.str() == key).and_then(|i| x.get(i + 1)).filter(|t| t.code == 1000).map(Tag::str) - }; - // 70 bit 1: a paper space view; its layout is CADCraft xdata. - let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default()); - let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians()); - let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout }; - match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = v, - None => d.views.push(v), - } - } - "UCS" => { - let name = t.s(2).unwrap_or_default(); - let (origin, x_axis, y_axis) = (t.p(10), t.p(11), t.p(12)); - let finite = [origin, x_axis, y_axis].iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()); - if name.is_empty() || !finite || d.ucss.len() >= MAX_OBJECTS { - continue; - } - let u = Ucs { name: name.clone(), origin, x_axis, y_axis }; - match d.ucss.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = u, - None => d.ucss.push(u), - } - } - "DIMSTYLE" => { - let name = t.s(2).unwrap_or_default(); - if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS { - continue; - } - // Every variable with a DIMSTYLE group code; values the style can't take are - // left at their defaults. - let mut fields = serde_json::Map::new(); - // The first 70 is the record's flags; a later one is DIMTFILLCLR. - let flags = tg.iter().position(|x| x.code == 70); - for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) { - if Some(i) == flags { - continue; - } - let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue }; - if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) { - fields.insert(field.to_string(), v); - } - } - fields.extend(crate::dxf_ext::read_newer_dimvars(&tg, &rx.styles, &rx.brs, &rx.ltypes)); - let mut st = DimStyle { name: name.clone(), annotative: annotative(&tg), ..DimStyle::default() }; - st.apply_fields(&fields); - match d.dim_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = st, - None => d.dim_styles.push(st), - } - } - "VPORT" => vport_record(d, &t), - _ => {} - } - } - // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). - d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); -} - -/// Most tiles a viewport configuration keeps. -const MAX_VPORT_TILES: usize = 64; - -/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners -/// are fractions of the drawing area; unusable tiles are skipped. -fn vport_record(d: &mut Drawing, t: &T) { - let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); - let height = t.fd(40, 0.0); - let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); - let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) - && max.x > min.x + 1e-6 - && max.y > min.y + 1e-6 - && center.is_finite() - && height.is_finite() - && height > 0.0; - if name.is_empty() || !ok { - return; - } - let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); - let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); - let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; - let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; - let room = d.vports.len() < MAX_OBJECTS; - match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { - Some(c) if c.tiles.len() < MAX_VPORT_TILES => c.tiles.push(tile), - Some(_) => {} - None if room => d.vports.push(VportConfig { name, tiles: vec![tile] }), - None => {} - } -} - -/// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, -/// LTYPE): each `49` length starts an element; `74` type flags (1 absolute rotation, 2 text, -/// 4 shape), `75` shape number, `340` STYLE, `46` scale, `50` rotation (radians), `44`/`45` -/// offset and `9` text follow it. -fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec { - let mut out: Vec = Vec::new(); - for t in tags.iter().take_while(|t| t.code < 1000) { - if t.code == 49 { - out.push(DashElement::dash(t.f64())); - continue; - } - let Some(el) = out.last_mut() else { continue }; - let complex = el.text.is_some() || el.shape.is_some(); - let v = t.f64(); - match t.code { - 74 => { - let flags = t.i64(); - el.absolute = flags & 1 != 0; - if flags & 2 != 0 { - el.text = Some(String::new()); - } else if flags & 4 != 0 { - el.shape = Some(0); - } - } - 75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)), - 340 if complex => el.style = styles.get(&t.str().trim().to_ascii_uppercase()).filter(|n| !n.trim().is_empty()).cloned(), - 9 if el.text.is_some() => el.text = Some(t.str()), - 46 if complex && v.is_finite() => el.scale = v, - 50 if complex && v.is_finite() => el.rotation = v, - 44 if complex && v.is_finite() => el.offset.x = v, - 45 if complex && v.is_finite() => el.offset.y = v, - _ => {} - } - } - out -} - /// Read a DXF file into a drawing. pub fn read(bytes: &[u8]) -> Result { read_budgeted(bytes, 4_000_000) @@ -1774,729 +137,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { Ok(d) } -/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities. -fn read_blocks( - tags: &[Tag], - d: &mut Drawing, - rx: &mut Rx, - table_cells_left: &mut usize, - skipped: &mut std::collections::BTreeMap, -) -> Vec { - let recs = records(tags); - for (k, tg) in &recs { - let t = T(tg); - if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { - rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); - } - } - let mut blocks = Vec::new(); - let mut n = 0; - let mut i = 0; - while let Some((k, tg)) = recs.get(i) { - if k == "BLOCK" { - let t = T(tg); - let name = t.s(2).unwrap_or_default(); - let base = t.p(10); - let description = t.s(4).unwrap_or_default(); - // Block flags: 4 xref, 8 overlay. - let xref = t.i(70).unwrap_or(0) & 12; - let mut j = i + 1; - while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { - j += 1; - } - let body = recs.get(i + 1..j).unwrap_or(&[]); - let ents = parse_entities(body, d, rx, &mut n, table_cells_left); - blocks.push((name, base, description, ents, xref)); - i = j + 1; - } else { - // A record outside any BLOCK ... ENDBLK pair. - if k != "ENDBLK" { - n = n.saturating_add(1); - } - i += 1; - } - } - count_skipped(skipped, "BLOCKS", n); - blocks -} - -/// Read the OBJECTS section into `objs`, and each LAYOUT into `layout_objs`. -fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut Vec) { - for (k, tg) in records(tags) { - objs.add(&k, &tg); - if k == "LAYOUT" { - // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). - let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); - let tail = tg.get(start..).unwrap_or(&[]); - let t = T(tail); - // LAYOUT has two 330s: owner dict and the block record; take the last. - let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); - let plot = T(tg.get(..start).unwrap_or(&[])); - layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); - } - } -} - -/// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the -/// layout that takes paper-space entities without an owner. -fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { - let mut paper_block_for: HashMap = HashMap::new(); - layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in layout_objs.iter() { - if name.eq_ignore_ascii_case("Model") { - continue; - } - d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); - if let Some(bname) = rx.brs.get(br) { - paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); - } - } - if d.layouts.is_empty() { - d.layouts.push(Layout::new("Layout1", 1)); - paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into()); - } - // The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first - // is active by default); paper-space entities without an owner go there. - let active = paper_block_for.get("*PAPER_SPACE").cloned(); - let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone()); - d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref()); - let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into()); - (paper_block_for, first_layout) -} - -/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become -/// block definitions. -fn place_blocks(d: &mut Drawing, blocks: Vec, rx: &mut Rx, paper_block_for: &HashMap, first_layout: &str) { - for (name, base, description, ents, xref) in blocks { - let up = name.to_ascii_uppercase(); - if up == "*MODEL_SPACE" { - for (_, _, e) in ents { - d.ensure_layer(&e.common.layer); - d.model.push(e); - } - continue; - } - if up.starts_with("*PAPER_SPACE") { - let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - for (_, _, e) in ents { - l.entities.push(e); - } - } - continue; - } - // Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks - // nothing inserts whose content is the one CADCraft writes. An arrowhead block with other - // content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it. - let generated_arrow = || { - let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect(); - crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own) - }; - if rx.table_blocks.contains(&up) || generated_arrow() { - continue; - } - let mut b = Block::new(&name); - b.base = base; - b.description = description; - if let Some(&(units, explodable)) = rx.block_props.get(&up) { - (b.units, b.explodable) = (units, explodable); - } - if xref & 4 != 0 { - b.xref_path = Some(String::new()); - b.xref_overlay = xref & 8 != 0; - } else { - for (_, _, e) in ents { - b.entities.push(e); - } - } - // Array parameters, with the block names the items use (other programs may rename - // anonymous blocks); dropped when the items no longer match them. - b.array = rx.block_arrays.get(&up).cloned().and_then(|mut a| a.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(a)); - if let Some(path) = rx.xref_paths.remove(&up) { - b.xref_path = Some(path); - } - d.blocks.insert(name, std::sync::Arc::new(b)); - } -} - -/// Place the ENTITIES section: model space, or paper space when flagged (67=1). -fn place_entities( - d: &mut Drawing, - entities: Vec<(Option, bool, Entity)>, - rx: &Rx, - paper_block_for: &HashMap, - first_layout: &str, -) { - for (owner, paper, e) in entities { - d.ensure_layer(&e.common.layer); - let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase()); - let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE")); - if to_paper { - let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string()); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - l.entities.push(e); - } - } else { - d.model.push(e); - } - } -} - -fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { - if n > 0 { - let c = skipped.entry(section.to_string()).or_insert(0); - *c = c.saturating_add(n); - } -} - -/// Records between sections, which [`sections`] passes over: everything after an ENDSEC up to the -/// next SECTION. Stray ENDSECs and the EOF hold nothing and don't count. -fn outside_sections(tags: &[Tag]) -> usize { - let mut inside = false; - let mut n = 0usize; - for t in tags.iter().filter(|t| t.code == 0) { - match t.str().as_str() { - "SECTION" => inside = true, - "ENDSEC" => inside = false, - "EOF" => {} - _ if !inside => n = n.saturating_add(1), - _ => {} - } - } - n -} - -/// A SORTENTSTABLE (DXF Reference, OBJECTS section): after its AcDbSortentsTable marker, the -/// owning block record (330), then pairs of an entity handle (331) and the sort handle (5) it is -/// drawn by. -fn sortents_table(tags: &[Tag]) -> Option<(String, HashMap)> { - let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSortentsTable")?; - let tail = tags.get(start + 1..)?; - let owner = tail.iter().find(|t| t.code == 330)?.str().trim().to_ascii_uppercase(); - let mut map = HashMap::new(); - let mut ent: Option = None; - for t in tail { - match t.code { - 331 => ent = Handle::parse_hex(&t.str()), - 5 => { - if let (Some(e), Some(k)) = (ent.take(), Handle::parse_hex(&t.str())) - && map.len() < MAX_OBJECTS - { - map.insert(e, k.0); - } - } - _ => {} - } - } - (!map.is_empty()).then_some((owner, map)) -} - -/// Draw each space with a SORTENTSTABLE in sort-handle order: an entity listed in the table by -/// its sort handle, any other by its own handle. -fn apply_sortents( - d: &mut Drawing, - tables: &[(String, HashMap)], - brs: &HashMap, - paper_for: &HashMap, - first_layout: &str, -) { - for (owner, map) in tables { - let Some(name) = brs.get(owner) else { continue }; - let up = name.to_ascii_uppercase(); - let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); - if up == "*MODEL_SPACE" { - d.model.sort_by_key(key); - } else if up.starts_with("*PAPER_SPACE") { - let lname = paper_for.get(&up).map(String::as_str).unwrap_or(first_layout); - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { - l.entities.sort_by_key(key); - } - } else if let Some(b) = d.blocks.get_mut(name.as_str()) { - std::sync::Arc::make_mut(b).entities.sort_by_key(key); - } - } -} - -/// Non-graphical objects applied once every entity is placed. -#[derive(Default)] -struct Objects { - /// Dictionary entry handle (upper case) → entry name. - names: HashMap, - /// SCALE objects (the annotation scale list), in file order. - scales: Vec, - /// XRECORD handle (upper case) → its groups. - xrecords: HashMap>, - /// TABLESTYLE objects: (handle, groups). - table_styles: Vec<(String, Vec)>, - /// DIMASSOC objects. - dimassocs: Vec>, - /// MLEADERSTYLE objects: (handle, groups). - mleader_styles: Vec<(String, Vec)>, - /// GROUP objects: (handle, groups). - groups: Vec<(String, Vec)>, - /// Saved paper-space views of layouts: (layout name, view). - layout_views: Vec<(String, (Vec2, f64))>, - /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). - sortents: Vec<(String, HashMap)>, - /// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown). - wipeout_frame: Option, -} - -const MAX_OBJECTS: usize = 1_000_000; - -impl Objects { - fn add(&mut self, kind: &str, tags: &[Tag]) { - let h = T(tags).s(5).map(|h| h.trim().to_ascii_uppercase()).unwrap_or_default(); - match kind { - "SCALE" if self.scales.len() < cadcraft_doc::MAX_SCALES => { - if let Some(s) = crate::dxf_ext::read_scale(tags) - && !self.scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) - { - self.scales.push(s); - } - } - "DICTIONARY" => { - let mut name: Option = None; - for t in tags { - match t.code { - 3 => name = Some(t.str()), - 350 | 360 => { - if let Some(n) = name.take() - && self.names.len() < MAX_OBJECTS - { - self.names.insert(t.str().trim().to_ascii_uppercase(), n); - } - } - _ => {} - } - } - } - "XRECORD" if self.xrecords.len() < MAX_OBJECTS => { - self.xrecords.insert(h, tags.to_vec()); - } - "TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())), - "DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()), - "MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())), - "GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())), - "WIPEOUTVARIABLES" => self.wipeout_frame = T(tags).i(70).filter(|v| (0..=2).contains(v)), - "SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => { - if let Some(t) = sortents_table(tags) { - self.sortents.push(t); - } - } - "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); - if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { - self.layout_views.push((name, view)); - } - } - _ => {} - } - } - - /// The text of the CADCraft XRECORD stored under `key` in a dictionary (its 1/3 strings). - fn xrecord_text(&self, key: &str) -> Option { - let h = self.names.iter().find(|(_, n)| n.as_str() == key).map(|(h, _)| h)?; - let tags = self.xrecords.get(h)?; - let mut text = String::new(); - for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { - if text.len() > crate::dxf_ext::MAX_PAYLOAD { - break; - } - text.push_str(&t.str()); - } - Some(text) - } - - fn apply_table_styles(&self, d: &mut Drawing) { - // Table styles (named by their ACAD_TABLESTYLE dictionary entries). - for (h, tags) in &self.table_styles { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let st = crate::dxf_table::read_table_style(name, tags); - match d.table_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = st, - None => d.table_styles.push(st), - } - } - } - - fn apply_table_style_fixes(&self, d: &mut Drawing, rx: &Rx) { - for (h, sh) in &rx.table_style_fix { - let name = self.names.get(sh).cloned(); - // A table from another writer has no scale of its own: its text height is its - // style's, so every cell style height applies as it is. - let height = { - let style = name.as_deref().unwrap_or("Standard"); - d.table_styles - .iter() - .find(|s| s.name.eq_ignore_ascii_case(style)) - .map_or(cadcraft_doc::TableStyle::default().text_height, |s| s.text_height) - }; - let _ = d.modify_entity(*h, |e| { - if let EntityKind::Table(t) = &mut e.kind { - if let Some(name) = name { - t.style = name; - } - t.text_height = height; - } - }); - } - } - - fn apply_mleader_styles(&self, d: &mut Drawing, rx: &Rx) { - // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). - for (h, tags) in &self.mleader_styles { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let def = MLeaderStyle::default(); - let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); - let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); - let st = MLeaderStyle { - name: name.clone(), - arrow_size: num(44, def.arrow_size, 0.0), - text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), - landing_gap: num(42, def.landing_gap, 0.0), - dogleg: num(43, def.dogleg, 0.0), - text_style: text_style.unwrap_or(def.text_style), - annotative: t.i(296).unwrap_or(0) != 0, - }; - match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = st, - None => d.mleader_styles.push(st), - } - } - } - - fn apply_groups(&self, d: &mut Drawing) { - // Groups (named by their ACAD_GROUP dictionary entries); members that exist. - for (h, tags) in &self.groups { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let members: Vec = tags - .iter() - .filter(|x| x.code == 340) - .filter_map(|x| Handle::parse_hex(&x.str())) - .filter(|m| d.entity(*m).is_some()) - .take(MAX_OBJECTS) - .collect(); - let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; - match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = g, - None => d.groups.push(g), - } - } - } - - fn apply(self, d: &mut Drawing, rx: &Rx) { - if let Some(v) = self.wipeout_frame { - d.header.set_i64("WIPEOUTFRAME", v); - } - if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { - d.header.set_str("CMLEADERSTYLE", &style); - } - self.apply_table_styles(d); - self.apply_table_style_fixes(d, rx); - // Parametric constraints and parameters. - if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) { - d.constraints = c; - d.parametric = p; - } - // The annotation scale list (a metric drawing without one gets the metric defaults), - // then CADCraft's annotative scaling record. - if !self.scales.is_empty() { - d.scales = self.scales.clone(); - } else if d.header.i64("MEASUREMENT", 0) == 1 { - d.scales = cadcraft_doc::metric_scales(); - } - if let Some(p) = self.xrecord_text(crate::dxf_ext::ANNOTATION_KEY).and_then(|t| crate::dxf_ext::parse_anno(&t)) { - crate::dxf_ext::apply_anno(d, p); - } - // Saved layer states. - if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { - d.layer_states = states; - } - // The drawing's plot style tables. - if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) { - d.plot_style_tables = tables; - } - self.apply_mleader_styles(d, rx); - crate::dxf_mleader::apply(d, &rx.mleader_fix, &self.names); - for (name, view) in &self.layout_views { - if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) { - l.view = Some(*view); - } - } - self.apply_groups(d); - // Standard associativity, for dimensions without CADCraft's exact links. - for tags in &self.dimassocs { - dimassoc(d, tags); - } - } -} - -/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides -/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` -/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for -/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate -/// dimensions, and for radial or diametric dimensions the single circle reference -/// ([`radial_links`]). CADCraft's own xdata links, when present, win. -fn dimassoc(d: &mut Drawing, tags: &[Tag]) { - let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0); - let tail = tags.get(start..).unwrap_or(&[]); - let Some(dim_h) = tail.iter().find(|x| x.code == 330).and_then(|x| Handle::parse_hex(&x.str())) else { return }; - let Some(EntityKind::Dimension(dm)) = d.entity(dim_h).map(|e| &e.kind) else { return }; - if !dm.assoc.is_empty() { - return; - } - // A radial or diametric dimension names its circle once, by its centre. - if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { - let links = radial_links(d, dm, tail); - if !links.is_empty() { - let _ = d.modify_entity(dim_h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.assoc = links; - } - }); - } - return; - } - let slots: &[&str] = match dm.kind { - DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], - DimKind::Angular => &["p13", "p14", "p15", "p16"], - DimKind::Angular3P => &["p13", "p14", "p15"], - DimKind::ArcLength => &["p13", "p14"], - DimKind::Ordinate { .. } => &["p13"], - DimKind::Radius | DimKind::Diameter => return, - }; - let flags = T(tail).i(90).unwrap_or(0); - let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect(); - // Point references start at each `1 AcDbOsnapPointRef`. - let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1).map(|(i, _)| i).take(8).collect(); - let mut links = Vec::new(); - for (k, s) in starts.iter().enumerate() { - let Some(point) = used.get(k) else { break }; - let end = starts.get(k + 1).copied().unwrap_or(tail.len()); - let r = T(tail.get(s + 1..end).unwrap_or(&[])); - let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; - let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); - let at = r.p(10).xy(); - // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). - let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); - if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { - links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); - } - } - if !links.is_empty() { - let _ = d.modify_entity(dim_h, |e| { - if let EntityKind::Dimension(dm) = &mut e.kind { - dm.assoc = links; - } - }); - } -} - -/// Associations of a radial or diametric dimension from its one standard reference: the circle -/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle, -/// so the dimension follows its radius or diameter. -fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec { - let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect(); - for (k, s) in starts.iter().enumerate() { - let end = starts.get(k + 1).copied().unwrap_or(tail.len()); - let r = T(tail.get(s + 1..end).unwrap_or(&[])); - if !matches!(r.i(72), Some(3 | 4)) { - continue; - } - let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; - let Some(centre) = circle_centre(d, handle) else { continue }; - let on = |point: &str, p: Vec3| { - let angle = centre.angle_to(p.xy()); - angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } }) - }; - let links = match dm.kind { - DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]), - DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::>>(), - _ => None, - }; - if let Some(links) = links { - return links; - } - } - Vec::new() -} - -/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or -/// diametric dimension's single DIMASSOC reference names. -fn circle_centre(d: &Drawing, h: Handle) -> Option { - match &d.entity(h)?.kind { - EntityKind::Circle(c) => Some(c.center.xy()), - EntityKind::Arc(a) => Some(a.center.xy()), - _ => None, - } -} - -/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment -/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment -/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone -/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is -/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has -/// only one position, so both codes mean it. -fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { - let e = d.entity(main)?; - if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { - let n = p.vertices.len(); - let segments = if p.closed { n } else { n.saturating_sub(1) }; - let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; - let index = if osnap == 13 { k - 1 } else { k % n }; - return Some(AssocSnap::Vertex { index }); - } - Some(match osnap { - 1 | 13 => match &e.kind { - // AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0). - // Other writers use 1 for either end of a line with the point stored: take the nearer. - EntityKind::Line(l) => { - if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) { - AssocSnap::Start - } else { - AssocSnap::End - } - } - EntityKind::Arc(_) => { - if osnap == 13 { - AssocSnap::Start - } else { - AssocSnap::End - } - } - EntityKind::LwPolyline(p) => { - let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?; - AssocSnap::Vertex { index } - } - EntityKind::Point(_) => AssocSnap::Start, - _ => return None, - }, - 2 => AssocSnap::Mid, - 3 | 4 => AssocSnap::Center, - 5 | 10 => match &e.kind { - EntityKind::Circle(Circle { center, .. }) | EntityKind::Arc(Arc { center, .. }) => { - let v = at - center.xy(); - AssocSnap::OnCircle { angle: v.y.atan2(v.x) } - } - _ => return None, - }, - 6 => AssocSnap::Intersection { other: other.filter(|o| d.entity(*o).is_some())? }, - _ => return None, - }) -} - -/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm). -fn page_setup(t: &T) -> PageSetup { - let mut p = PageSetup::default(); - let mm = |code: i32, d: f64| { - let v = t.fd(code, d); - if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d } - }; - let (w, h) = (mm(44, 0.0), mm(45, 0.0)); - if w > 0.0 && h > 0.0 { - p.width_mm = w.min(h); - p.height_mm = w.max(h); - } - p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])]; - // Plot paper units 1: millimetres. 0 (inches) is left to MEASUREMENT: earlier CADCraft - // versions wrote 0 for every layout. - p.paper_mm = t.i(72) == Some(1); - // Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is - // already landscape. - let rotated = matches!(t.i(73), Some(1 | 3)); - if w > 0.0 && h > 0.0 { - p.landscape = rotated != (w > h); - } else if t.i(73).is_some() { - p.landscape = rotated; - } - if let Some(name) = t.s(4).filter(|n| !n.is_empty()) { - p.paper = name.replace('_', " "); - } - if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { - p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; - } - // Plot layout flags: 2 display plot styles, 4 centred, 16 standard scale (75 = 0: scaled - // to fit), 128 lineweights. - if let Some(flags) = t.i(70) { - p.show_plot_styles = flags & 2 != 0; - p.center = flags & 4 != 0; - p.lineweights = flags & 128 != 0; - p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); - } - // Custom scale: 142 paper units per 143 drawing units; 147 is the scale factor. - let pos = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0); - let ratio = pos(t.f(142)).zip(pos(t.f(143))).map(|(n, d)| n / d); - if let Some(s) = pos(ratio).or_else(|| pos(t.f(147))) { - p.scale = s; - } - p.plot_area = match t.i(74) { - Some(0) => "display", - Some(1) => "extents", - Some(2) => "limits", - Some(4) => "window", - _ => "layout", - } - .into(); - p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); - p.plot_style_table = t.s(7).unwrap_or_default(); - p -} - -/// A layout's saved paper-space view (centre, height) from CADCraft xdata. -fn layout_view(tags: &[Tag]) -> Option<(Vec2, f64)> { - let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, crate::dxf_ext::APP), "PSVIEW"); - let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); - Some((Vec2::new(g(1010)?, g(1020)?), g(1040)?)) -} - -/// A HELIX (DXF Reference): the axis base point, start point, axis, radius, turns, turn height, -/// handedness and constraint after the AcDbHelix marker; the top radius is how far the end of its -/// spline (AcDbSpline: the last control point, else fit point) lies from the axis. -fn helix(tags: &[Tag]) -> Helix { - let hs = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbHelix").unwrap_or(tags.len()); - let ss = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSpline").unwrap_or(0).min(hs); - let spline = tags.get(ss..hs).unwrap_or(&[]); - let t = T(tags.get(hs..).unwrap_or(&[])); - // The last point of the spline (3D) with group `code`. - let last = |code: i32| { - let i = spline.iter().rposition(|g| g.code == code)?; - let f = |c: i32| spline.get(i..).and_then(|r| r.iter().find(|g| g.code == c)).map(Tag::f64).unwrap_or(0.0); - Some(Vec3::new(f(code), f(code + 10), f(code + 20))) - }; - let center = t.p(10); - let start = t.p(11); - let axis = t.p(12); - let axis = if axis.is_finite() && axis.len() > 1e-12 { axis.normalized() } else { Vec3::Z }; - // Distance from the axis. - let radius = |p: Vec3| { - let v = Vec3::new(p.x - center.x, p.y - center.y, p.z - center.z); - let along = v.dot(axis); - Vec3::new(v.x - axis.x * along, v.y - axis.y * along, v.z - axis.z * along).len() - }; - let base_radius = t.f(40).filter(|r| r.is_finite() && *r >= 0.0).unwrap_or_else(|| radius(start)); - let turns = t.fd(41, 1.0); - let end = last(10).or_else(|| last(11)); - let height = match (t.f(42), end) { - (Some(th), _) if th.is_finite() => th * turns, - (_, Some(e)) => e.z - center.z, - _ => 0.0, - }; - Helix { - center, - base_radius, - top_radius: end.map(radius).unwrap_or(base_radius), - height, - turns, - start_angle: center.xy().angle_to(start.xy()), - ccw: t.i(290).unwrap_or(1) != 0, - constrain: HelixConstrain::from_dxf(t.i(280).unwrap_or(2)), - } - .sanitized() -} - /// An entity from the groups of a DXF record of type `kind` (without its `0` group), as the /// reader makes it. AutoLISP's `entmake` and `entmod` are built on it. pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs new file mode 100644 index 000000000..18e8edd8e --- /dev/null +++ b/crates/io/src/dxf_read/common.rs @@ -0,0 +1,82 @@ +use super::*; + +/// Value lookup helpers over an entity's tags. +pub(super) struct T<'a>(pub(super) &'a [Tag]); + +impl T<'_> { + pub(super) fn f(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::f64) + } + pub(super) fn fd(&self, code: i32, d: f64) -> f64 { + self.f(code).unwrap_or(d) + } + pub(super) fn i(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::i64) + } + pub(super) fn s(&self, code: i32) -> Option { + self.0.iter().find(|t| t.code == code).map(Tag::str) + } + pub(super) fn p(&self, code: i32) -> Vec3 { + Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) + } + pub(super) fn all_f(&self, code: i32) -> Vec { + self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() + } + pub(super) fn pts(&self, code: i32) -> Vec { + let xs = self.all_f(code); + let ys = self.all_f(code + 10); + xs.into_iter().zip(ys).map(|(x, y)| Vec2::new(x, y)).collect() + } +} + +/// An entity's group 440: `0x01` in the top byte is ByBlock, `0x02` a fixed alpha (mapped to a +/// percentage as for layers), anything else ByLayer. +fn transparency_440(v: i64) -> Transparency { + match (v >> 24) & 0xff { + 1 => Transparency::ByBlock, + 2 => { + let alpha = u32::try_from(v & 0xff).unwrap_or(255); + let opaque = (alpha * 100 + 127) / 255; + Transparency::Percent(u8::try_from(100u32.saturating_sub(opaque).min(90)).unwrap_or(90)) + } + _ => Transparency::ByLayer, + } +} + +pub(super) fn common(t: &T) -> Common { + let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; + if let Some(v) = t.i(62) { + c.color = Color::from_aci(v as i16); + if v < 0 { + c.color = Color::ByLayer; + c.visible = true; + } + } + if let Some(tc) = t.i(420) { + c.color = Color::True(Rgb::from_u32(tc as u32)); + } + if let Some(lw) = t.i(370) { + c.lineweight = Lineweight::from_dxf(lw as i16); + } + if let Some(s) = t.f(48) { + c.ltscale = if s > 0.0 { s } else { 1.0 }; + } + if t.i(60) == Some(1) { + c.visible = false; + } + if let Some(v) = t.i(440) { + c.transparency = transparency_440(v); + } + c.thickness = t.fd(39, 0.0); + c.extrusion = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + c +} + +/// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. +pub(super) fn ocs(c: &Common, p: Vec3) -> Vec3 { + ocs_n(c.extrusion, p) +} + +pub(super) fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { + if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } +} diff --git a/crates/io/src/dxf_read/dimassoc.rs b/crates/io/src/dxf_read/dimassoc.rs new file mode 100644 index 000000000..a6219c1d4 --- /dev/null +++ b/crates/io/src/dxf_read/dimassoc.rs @@ -0,0 +1,156 @@ +use super::common::T; +use super::*; + +/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides +/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` +/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for +/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate +/// dimensions, and for radial or diametric dimensions the single circle reference +/// ([`radial_links`]). CADCraft's own xdata links, when present, win. +pub(super) fn dimassoc(d: &mut Drawing, tags: &[Tag]) { + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0); + let tail = tags.get(start..).unwrap_or(&[]); + let Some(dim_h) = tail.iter().find(|x| x.code == 330).and_then(|x| Handle::parse_hex(&x.str())) else { return }; + let Some(EntityKind::Dimension(dm)) = d.entity(dim_h).map(|e| &e.kind) else { return }; + if !dm.assoc.is_empty() { + return; + } + // A radial or diametric dimension names its circle once, by its centre. + if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { + let links = radial_links(d, dm, tail); + if !links.is_empty() { + let _ = d.modify_entity(dim_h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = links; + } + }); + } + return; + } + let slots: &[&str] = match dm.kind { + DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], + DimKind::Angular => &["p13", "p14", "p15", "p16"], + DimKind::Angular3P => &["p13", "p14", "p15"], + DimKind::ArcLength => &["p13", "p14"], + DimKind::Ordinate { .. } => &["p13"], + DimKind::Radius | DimKind::Diameter => return, + }; + let flags = T(tail).i(90).unwrap_or(0); + let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect(); + // Point references start at each `1 AcDbOsnapPointRef`. + let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1).map(|(i, _)| i).take(8).collect(); + let mut links = Vec::new(); + for (k, s) in starts.iter().enumerate() { + let Some(point) = used.get(k) else { break }; + let end = starts.get(k + 1).copied().unwrap_or(tail.len()); + let r = T(tail.get(s + 1..end).unwrap_or(&[])); + let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; + let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); + let at = r.p(10).xy(); + // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). + let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); + if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { + links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); + } + } + if !links.is_empty() { + let _ = d.modify_entity(dim_h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = links; + } + }); + } +} + +/// Associations of a radial or diametric dimension from its one standard reference: the circle +/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle, +/// so the dimension follows its radius or diameter. +fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec { + let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect(); + for (k, s) in starts.iter().enumerate() { + let end = starts.get(k + 1).copied().unwrap_or(tail.len()); + let r = T(tail.get(s + 1..end).unwrap_or(&[])); + if !matches!(r.i(72), Some(3 | 4)) { + continue; + } + let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; + let Some(centre) = circle_centre(d, handle) else { continue }; + let on = |point: &str, p: Vec3| { + let angle = centre.angle_to(p.xy()); + angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } }) + }; + let links = match dm.kind { + DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]), + DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::>>(), + _ => None, + }; + if let Some(links) = links { + return links; + } + } + Vec::new() +} + +/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or +/// diametric dimension's single DIMASSOC reference names. +fn circle_centre(d: &Drawing, h: Handle) -> Option { + match &d.entity(h)?.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Arc(a) => Some(a.center.xy()), + _ => None, + } +} + +/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment +/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment +/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone +/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is +/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has +/// only one position, so both codes mean it. +fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { + let e = d.entity(main)?; + if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { + let n = p.vertices.len(); + let segments = if p.closed { n } else { n.saturating_sub(1) }; + let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; + let index = if osnap == 13 { k - 1 } else { k % n }; + return Some(AssocSnap::Vertex { index }); + } + Some(match osnap { + 1 | 13 => match &e.kind { + // AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0). + // Other writers use 1 for either end of a line with the point stored: take the nearer. + EntityKind::Line(l) => { + if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) { + AssocSnap::Start + } else { + AssocSnap::End + } + } + EntityKind::Arc(_) => { + if osnap == 13 { + AssocSnap::Start + } else { + AssocSnap::End + } + } + EntityKind::LwPolyline(p) => { + let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?; + AssocSnap::Vertex { index } + } + EntityKind::Point(_) => AssocSnap::Start, + _ => return None, + }, + 2 => AssocSnap::Mid, + 3 | 4 => AssocSnap::Center, + 5 | 10 => match &e.kind { + EntityKind::Circle(Circle { center, .. }) | EntityKind::Arc(Arc { center, .. }) => { + let v = at - center.xy(); + AssocSnap::OnCircle { angle: v.y.atan2(v.x) } + } + _ => return None, + }, + 6 => AssocSnap::Intersection { other: other.filter(|o| d.entity(*o).is_some())? }, + _ => return None, + }) +} diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs new file mode 100644 index 000000000..245bda331 --- /dev/null +++ b/crates/io/src/dxf_read/document.rs @@ -0,0 +1,326 @@ +use super::common::T; +use super::entities::{entity_extras, entity_plot_style, entity_with_table_budget, insert_attribs, polyline_entity}; +use super::objects::{MAX_OBJECTS, Objects, page_setup}; +use super::*; + +pub(super) fn parse_entities( + recs: &[(String, Vec)], + d: &mut Drawing, + rx: &mut Rx, + skipped: &mut usize, + table_cells_left: &mut usize, +) -> Vec<(Option, bool, Entity)> { + let mut out = Vec::new(); + let mut i = 0; + while let Some((kind, tags)) = recs.get(i) { + let t = T(tags); + let owner = crate::dxf_ext::owner(tags); + let paper = t.i(67) == Some(1); + let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); + let mut kindent = entity_with_table_budget(kind, tags, table_cells_left); + if let Some((c, _)) = kindent.as_mut() + && let Some(n) = entity_plot_style(tags, rx) + { + c.plot_style = n; + } + // More entities from this record (the faces of a polyface or polygon mesh). + let mut extra: Vec = Vec::new(); + i += 1; + match kind.as_str() { + "POLYLINE" => { + let Some((c, k, more)) = polyline_entity(recs, &mut i, &t) else { + *skipped = skipped.saturating_add(1); + continue; + }; + extra = more; + kindent = Some((c, k)); + } + "INSERT" => { + if t.i(66) == Some(1) { + let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); + let attribs = insert_attribs(recs, &mut i, &ins_layer); + if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { + ins.attribs = attribs; + } + } + } + // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing. + "VERTEX" | "ATTRIB" => { + *skipped = skipped.saturating_add(1); + continue; + } + "SEQEND" => continue, + _ => {} + } + if let Some((c, mut k)) = kindent { + let h = match handle { + Some(h) => { + d.bump_handseed(h); + h + } + None => d.new_handle(), + }; + entity_extras(kind, tags, h, &mut k, rx); + let more: Vec<_> = + extra.into_iter().map(|f| (owner.clone(), paper, Entity { handle: d.new_handle(), common: c.clone(), kind: f })).collect(); + out.push((owner, paper, Entity { handle: h, common: c, kind: k })); + out.extend(more); + } else if kind == "HATCH" { + // Without boundary data. The other records `entity` returns nothing for are read + // elsewhere (the writer's paper viewport, the loops of a region). + *skipped = skipped.saturating_add(1); + } + } + // Assign fresh handles to duplicates (hostile files). + let mut seen = std::collections::HashSet::new(); + for (_, _, e) in &mut out { + if !seen.insert(e.handle) { + e.handle = d.new_handle(); + seen.insert(e.handle); + } + } + out +} + +/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities. +pub(super) fn read_blocks( + tags: &[Tag], + d: &mut Drawing, + rx: &mut Rx, + table_cells_left: &mut usize, + skipped: &mut std::collections::BTreeMap, +) -> Vec { + let recs = records(tags); + for (k, tg) in &recs { + let t = T(tg); + if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { + rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); + } + } + let mut blocks = Vec::new(); + let mut n = 0; + let mut i = 0; + while let Some((k, tg)) = recs.get(i) { + if k == "BLOCK" { + let t = T(tg); + let name = t.s(2).unwrap_or_default(); + let base = t.p(10); + let description = t.s(4).unwrap_or_default(); + // Block flags: 4 xref, 8 overlay. + let xref = t.i(70).unwrap_or(0) & 12; + let mut j = i + 1; + while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { + j += 1; + } + let body = recs.get(i + 1..j).unwrap_or(&[]); + let ents = parse_entities(body, d, rx, &mut n, table_cells_left); + blocks.push((name, base, description, ents, xref)); + i = j + 1; + } else { + // A record outside any BLOCK ... ENDBLK pair. + if k != "ENDBLK" { + n = n.saturating_add(1); + } + i += 1; + } + } + count_skipped(skipped, "BLOCKS", n); + blocks +} + +/// Read the OBJECTS section into `objs`, and each LAYOUT into `layout_objs`. +pub(super) fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut Vec) { + for (k, tg) in records(tags) { + objs.add(&k, &tg); + if k == "LAYOUT" { + // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). + let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + let tail = tg.get(start..).unwrap_or(&[]); + let t = T(tail); + // LAYOUT has two 330s: owner dict and the block record; take the last. + let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); + let plot = T(tg.get(..start).unwrap_or(&[])); + layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); + } + } +} + +/// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the +/// layout that takes paper-space entities without an owner. +pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { + let mut paper_block_for: HashMap = HashMap::new(); + layout_objs.sort_by_key(|l| l.1); + for (name, order, br, page) in layout_objs.iter() { + if name.eq_ignore_ascii_case("Model") { + continue; + } + d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); + if let Some(bname) = rx.brs.get(br) { + paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); + } + } + if d.layouts.is_empty() { + d.layouts.push(Layout::new("Layout1", 1)); + paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into()); + } + // The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first + // is active by default); paper-space entities without an owner go there. + let active = paper_block_for.get("*PAPER_SPACE").cloned(); + let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone()); + d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref()); + let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into()); + (paper_block_for, first_layout) +} + +/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become +/// block definitions. +pub(super) fn place_blocks(d: &mut Drawing, blocks: Vec, rx: &mut Rx, paper_block_for: &HashMap, first_layout: &str) { + for (name, base, description, ents, xref) in blocks { + let up = name.to_ascii_uppercase(); + if up == "*MODEL_SPACE" { + for (_, _, e) in ents { + d.ensure_layer(&e.common.layer); + d.model.push(e); + } + continue; + } + if up.starts_with("*PAPER_SPACE") { + let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + for (_, _, e) in ents { + l.entities.push(e); + } + } + continue; + } + // Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks + // nothing inserts whose content is the one CADCraft writes. An arrowhead block with other + // content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it. + let generated_arrow = || { + let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect(); + crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own) + }; + if rx.table_blocks.contains(&up) || generated_arrow() { + continue; + } + let mut b = Block::new(&name); + b.base = base; + b.description = description; + if let Some(&(units, explodable)) = rx.block_props.get(&up) { + (b.units, b.explodable) = (units, explodable); + } + if xref & 4 != 0 { + b.xref_path = Some(String::new()); + b.xref_overlay = xref & 8 != 0; + } else { + for (_, _, e) in ents { + b.entities.push(e); + } + } + // Array parameters, with the block names the items use (other programs may rename + // anonymous blocks); dropped when the items no longer match them. + b.array = rx.block_arrays.get(&up).cloned().and_then(|mut a| a.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(a)); + if let Some(path) = rx.xref_paths.remove(&up) { + b.xref_path = Some(path); + } + d.blocks.insert(name, std::sync::Arc::new(b)); + } +} + +/// Place the ENTITIES section: model space, or paper space when flagged (67=1). +pub(super) fn place_entities( + d: &mut Drawing, + entities: Vec<(Option, bool, Entity)>, + rx: &Rx, + paper_block_for: &HashMap, + first_layout: &str, +) { + for (owner, paper, e) in entities { + d.ensure_layer(&e.common.layer); + let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase()); + let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE")); + if to_paper { + let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string()); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + l.entities.push(e); + } + } else { + d.model.push(e); + } + } +} + +pub(super) fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { + if n > 0 { + let c = skipped.entry(section.to_string()).or_insert(0); + *c = c.saturating_add(n); + } +} + +/// Records between sections, which [`sections`] passes over: everything after an ENDSEC up to the +/// next SECTION. Stray ENDSECs and the EOF hold nothing and don't count. +pub(super) fn outside_sections(tags: &[Tag]) -> usize { + let mut inside = false; + let mut n = 0usize; + for t in tags.iter().filter(|t| t.code == 0) { + match t.str().as_str() { + "SECTION" => inside = true, + "ENDSEC" => inside = false, + "EOF" => {} + _ if !inside => n = n.saturating_add(1), + _ => {} + } + } + n +} + +/// A SORTENTSTABLE (DXF Reference, OBJECTS section): after its AcDbSortentsTable marker, the +/// owning block record (330), then pairs of an entity handle (331) and the sort handle (5) it is +/// drawn by. +pub(super) fn sortents_table(tags: &[Tag]) -> Option<(String, HashMap)> { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSortentsTable")?; + let tail = tags.get(start + 1..)?; + let owner = tail.iter().find(|t| t.code == 330)?.str().trim().to_ascii_uppercase(); + let mut map = HashMap::new(); + let mut ent: Option = None; + for t in tail { + match t.code { + 331 => ent = Handle::parse_hex(&t.str()), + 5 => { + if let (Some(e), Some(k)) = (ent.take(), Handle::parse_hex(&t.str())) + && map.len() < MAX_OBJECTS + { + map.insert(e, k.0); + } + } + _ => {} + } + } + (!map.is_empty()).then_some((owner, map)) +} + +/// Draw each space with a SORTENTSTABLE in sort-handle order: an entity listed in the table by +/// its sort handle, any other by its own handle. +pub(super) fn apply_sortents( + d: &mut Drawing, + tables: &[(String, HashMap)], + brs: &HashMap, + paper_for: &HashMap, + first_layout: &str, +) { + for (owner, map) in tables { + let Some(name) = brs.get(owner) else { continue }; + let up = name.to_ascii_uppercase(); + let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); + if up == "*MODEL_SPACE" { + d.model.sort_by_key(key); + } else if up.starts_with("*PAPER_SPACE") { + let lname = paper_for.get(&up).map(String::as_str).unwrap_or(first_layout); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + l.entities.sort_by_key(key); + } + } else if let Some(b) = d.blocks.get_mut(name.as_str()) { + std::sync::Arc::make_mut(b).entities.sort_by_key(key); + } + } +} diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs new file mode 100644 index 000000000..9f0816d2a --- /dev/null +++ b/crates/io/src/dxf_read/entities.rs @@ -0,0 +1,651 @@ +use super::common::{T, common, ocs}; +use super::hatch::hatch; +use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; +use super::*; + +/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. +pub(super) fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { + entity_with_table_budget(kind, tags, &mut 4_000_000) +} + +/// Parse one entity while charging materialized table cells to the caller's shared import budget. +/// An ARC, with its angles run the other way in a mirrored OCS. +fn arc(c: &Common, t: &T) -> Arc { + let mut a = Arc { center: ocs(c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; + if c.extrusion.z < 0.0 { + // Mirrored OCS: angles run the other way. + let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); + a.start = cadcraft_geom::norm_angle(s); + a.end = cadcraft_geom::norm_angle(e); + } + a +} + +fn ellipse(c: &Common, t: &T) -> Ellipse { + let mut e = Ellipse { + center: t.p(10), + major: t.p(11), + ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), + start: t.fd(41, 0.0), + end: t.fd(42, std::f64::consts::TAU), + }; + if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { + // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) + // it points the other way, so parameter t lands where -t does in the XY plane. A full + // ellipse is symmetric and stays as it is. + let (s, e2) = (-e.end, -e.start); + e.start = cadcraft_geom::norm_angle(s); + e.end = cadcraft_geom::norm_angle(e2); + } + e +} + +/// A LWPOLYLINE, or the region CADCraft wrote as one. +fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option { + let mut verts = Vec::new(); + let mut cur: Option = None; + for tg in tags { + match tg.code { + 10 => { + if let Some(v) = cur.take() { + verts.push(v); + } + cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); + } + 20 => { + if let Some(v) = cur.as_mut() { + v.p.y = tg.f64(); + } + } + 40 => { + if let Some(v) = cur.as_mut() { + v.start_width = tg.f64(); + } + } + 41 => { + if let Some(v) = cur.as_mut() { + v.end_width = tg.f64(); + } + } + 42 => { + if let Some(v) = cur.as_mut() { + v.bulge = tg.f64(); + } + } + _ => {} + } + } + verts.extend(cur); + let flags = t.i(70).unwrap_or(0); + if c.extrusion.z < 0.0 { + for v in &mut verts { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + } + let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 }; + // A region CADCraft wrote as polylines (`dxf_region`). + crate::dxf_region::from_polyline(tags, pl) +} + +fn spline(t: &T) -> Spline { + let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; + let knots = t.all_f(40); + let control = t.pts(10); + let weights = t.all_f(41); + let fit = t.pts(11); + let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); + let fit_opts = + cadcraft_geom::FitOptions { knots: Default::default(), start_tangent: tangent(12), end_tangent: tangent(13), tolerance: t.fd(44, 0.0) } + .sanitized(); + let closed = t.i(70).unwrap_or(0) & 1 != 0; + let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; + if !sp.is_valid() && fit.len() >= 2 { + sp = Spline::fit_with(&fit, sp.closed, fit_opts); + } else if !sp.is_valid() && sp.control.len() >= 2 { + sp = Spline::from_control(sp.control.clone(), degree); + } else if fit.len() >= 3 { + // DXF keeps the knots, not how they were spaced: recover it for later refits. + sp.fit_opts.knots = sp.infer_knot_param(); + } + sp +} + +pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { + // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. + if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { + return None; + } + let t = T(tags); + let c = common(&t); + let k = match kind { + "LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }), + "POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }), + "CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }), + "ARC" => EntityKind::Arc(arc(&c, &t)), + "ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)), + "LWPOLYLINE" => lwpolyline(tags, &c, &t)?, + "SPLINE" => EntityKind::Spline(spline(&t)), + "HELIX" => EntityKind::Helix(helix(tags)), + "RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }), + "XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }), + "TEXT" => EntityKind::Text(text_from(&t)), + "MTEXT" => EntityKind::MText(mtext_from(tags, &c)), + "ATTDEF" => EntityKind::AttDef(attdef(tags, &c)), + "INSERT" => { + // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, + // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that + // is the mirrored point, a negative x scale, the opposite rotation and a negative column + // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the + // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel + // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. + let n = c.extrusion; + let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); + let mut p = t.p(10); + let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); + let mut rotation = t.fd(50, 0.0).to_radians(); + let mut col_spacing = t.fd(44, 0.0); + if mirrored { + p = ocs(&c, p); + scale.x = -scale.x; + rotation = -rotation; + col_spacing = -col_spacing; + } + EntityKind::Insert(Insert { + block: t.s(2).unwrap_or_default(), + insert: p, + scale, + rotation, + attribs: Vec::new(), + cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, + rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, + col_spacing, + row_spacing: t.fd(45, 0.0), + }) + } + "DIMENSION" => { + let ty = t.i(70).unwrap_or(0) & 0x0f; + let kind = match ty { + 0 => DimKind::Linear { rotation: t.fd(50, 0.0).to_radians() }, + 1 => DimKind::Aligned, + 2 => DimKind::Angular, + 3 => DimKind::Diameter, + 4 => DimKind::Radius, + 5 => DimKind::Angular3P, + 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, + 8 => DimKind::ArcLength, + _ => DimKind::Aligned, + }; + EntityKind::Dimension(Dimension { + kind, + defpt: t.p(10), + // The text midpoint is the one OCS point of a DIMENSION. + text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, + p13: t.p(13), + p14: t.p(14), + p15: t.p(15), + p16: t.p(16), + text: t.s(1).unwrap_or_default(), + style: t.s(3).unwrap_or_else(|| "Standard".into()), + measurement: t.fd(42, 0.0), + text_rotation: t.fd(53, 0.0).to_radians(), + user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, + block: t.s(2).filter(|s| !s.is_empty()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }) + } + "LEADER" => EntityKind::Leader(Leader { + vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), + arrow: t.i(71).unwrap_or(1) != 0, + spline: t.i(72).unwrap_or(0) != 0, + style: t.s(3).unwrap_or_else(|| "Standard".into()), + }), + // Names behind handles follow in `entity_extras` and `Objects::apply`. + "MULTILEADER" | "MLEADER" => EntityKind::MLeader(crate::dxf_mleader::read(tags)), + "SOLID" | "TRACE" => { + let corners = [t.p(10), t.p(11), t.p(12), if t.f(13).is_some() { t.p(13) } else { t.p(12) }]; + let corners = corners.map(|p| ocs(&c, p)); + if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } + } + "MLINE" => EntityKind::MLine(crate::dxf_mline::read_entity(tags)), + "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), + "HATCH" => { + let mut k = hatch(tags, &t)?; + if c.extrusion.z < 0.0 + && let EntityKind::Hatch(h) = &mut k + { + // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. + for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); + } + k + } + "ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)), + "VIEWPORT" => EntityKind::Viewport(Viewport { + center: t.p(10), + width: t.fd(40, 1.0), + height: t.fd(41, 1.0), + view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), + view_height: t.fd(45, 1.0), + id: t.i(69).unwrap_or(0) as u32, + locked: t.i(90).unwrap_or(0) & 16384 != 0, + frozen_layers: crate::dxf_ext::read_frozen(tags), + layer_colors: crate::dxf_ext::read_layer_colors(tags), + // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off. + clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0), + on: t.i(90).unwrap_or(0) & 131072 == 0, + }), + "WIPEOUT" => { + let o = t.p(10); + let u = t.p(11); + let v = t.p(12); + // Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down. + let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); + EntityKind::Wipeout(Wipeout { boundary: pts }) + } + // The file path comes from the IMAGEDEF object (`dxf_image::resolve`). + "IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)), + "TOLERANCE" => { + let dir = t.p(11); + let dir = if dir.xy().is_finite() && dir.xy().len() > 1e-12 { dir } else { Vec3::new(1.0, 0.0, 0.0) }; + EntityKind::Tolerance(Tolerance { + insert: t.p(10), + dir, + style: t.s(3).unwrap_or_else(|| "Standard".into()), + text: t.s(1).unwrap_or_default().chars().take(MAX_FRAME_CHARS).collect(), + }) + } + other => EntityKind::Unknown(Unknown { + dxf_type: other.to_string(), + tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(), + }), + }; + Some((c, k)) +} + +/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's +/// title/header flags, text height and style name from xdata when present. +pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table { + const MAX_CELLS: usize = 1_000_000; + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0); + let tail = tags.get(start..).unwrap_or(&[]); + let first_cell = tail.iter().position(|x| x.code == 171).unwrap_or(tail.len()); + let head = T(tail.get(..first_cell).unwrap_or(&[])); + let mut rows = usize::try_from(head.i(91).unwrap_or(0)).unwrap_or(0).min(10_000); + let mut cols = usize::try_from(head.i(92).unwrap_or(0)).unwrap_or(0).min(10_000); + // Large declared sizes must be backed by cell records (no allocation from a bare count). + let n_cells = tail.iter().filter(|x| x.code == 171).count(); + let declared = rows.saturating_mul(cols); + if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) { + rows = 0; + cols = 0; + } + // Charge before any cell vector is materialized, including discarded tables. + let count = rows.saturating_mul(cols); + match table_cells_left.checked_sub(count) { + Some(left) => *table_cells_left = left, + None => return Table { insert: T(tags).p(10), ..Table::default() }, + } + let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 }; + let mut row_heights: Vec = head.all_f(141).into_iter().take(rows).map(size).collect(); + row_heights.resize(rows, 0.27); + let mut col_widths: Vec = head.all_f(142).into_iter().take(cols).map(size).collect(); + col_widths.resize(cols, 2.5); + // Cells, row-major, each starting at a 171 group; xdata ends the last one. + let body = tail.get(first_cell..).unwrap_or(&[]); + let body = body.get(..body.iter().position(|x| x.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); + let mut flat: Vec = Vec::new(); + let mut i = 0; + while let Some(t0) = body.get(i) { + if flat.len() >= rows * cols { + break; + } + i += 1; + if t0.code != 171 { + continue; + } + let end = body.get(i..).and_then(|r| r.iter().position(|x| x.code == 171)).map(|p| i + p).unwrap_or(body.len()); + let cell = body.get(i..end).unwrap_or(&[]); + i = end; + let c = T(cell); + let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect(); + text.push_str(&c.s(1).unwrap_or_default()); + let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok()); + // A merge starts at the cell whose span (rows 176, columns 175) is more than one cell. + // Its own merged flag (173) varies by writer: CADCraft sets it, while AutoCAD-style + // writers (acadrust 0.6.4) set it only on the cells the merge covers, whose span is 0. + let merged = match (span(176), span(175)) { + (Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)), + _ => None, + }; + // The cell's own text height: 140 outside the value block (301 … 304), where it is a number. + let mut in_value = false; + let mut text_height = None; + for x in cell { + match x.code { + 301 => in_value = true, + 304 => in_value = false, + 140 if !in_value => text_height = Some(x.f64()).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e9), + _ => {} + } + } + flat.push(TableCell { text, merged, text_height, ..TableCell::default() }); + } + flat.resize(rows * cols, TableCell::default()); + let cells: Vec> = if cols == 0 { vec![Vec::new(); rows] } else { flat.chunks(cols).map(<[TableCell]>::to_vec).collect() }; + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let ours = x.iter().position(|t| t.code == 1000 && t.str() == "TABLE").map(|p| x.get(p + 1..).unwrap_or(&[])); + let (title, header, text_height, style) = match ours { + Some(o) => { + let flags: Vec = o.iter().filter(|t| t.code == 1070).map(Tag::i64).collect(); + let h = o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(0.18); + let style = o.iter().find(|t| t.code == 1000).map(Tag::str).unwrap_or_else(|| "Standard".into()); + (flags.first().is_some_and(|v| *v != 0), flags.get(1).is_some_and(|v| *v != 0), h, style) + } + // Other writers: a first row merged across every column reads as a title. + // Their text height is resolved to the table style's once the styles are read (`apply`). + None => (cols > 1 && cells.first().and_then(|r| r.first()).and_then(|c| c.merged) == Some((1, cols as u32)), false, 0.0, "Standard".into()), + }; + let mut t = Table { insert: T(tags).p(10), col_widths, row_heights, cells, style, text_height, title, header, ..Table::default() }; + crate::dxf_table::read_table_xdata(tags, &mut t); + t +} + +/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the +/// named object dictionary (the first object), by placeholder handle. +pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap { + let mut out = HashMap::new(); + let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out }; + let recs = records(&objects.tags); + let entries = |tags: &[Tag]| { + let mut v: Vec<(String, String)> = Vec::new(); + let mut name: Option = None; + for t in tags { + match t.code { + 3 => name = Some(t.str()), + 350 | 360 => { + if let Some(n) = name.take() { + v.push((n, t.str().trim().to_ascii_uppercase())); + } + } + _ => {} + } + } + v + }; + let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out }; + let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else { + return out; + }; + let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT"; + let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else { + return out; + }; + for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) { + if !n.trim().is_empty() { + out.insert(h, n.chars().take(255).collect()); + } + } + out +} + +/// The named plot style an entity record points at (group 390 among its AcDbEntity groups). +pub(super) fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option { + let mut in_entity = false; + for t in tags { + if t.code == 100 { + if in_entity { + return None; + } + in_entity = t.str() == "AcDbEntity"; + } else if t.code == 390 && in_entity { + return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); + } + } + None +} + +/// Dimension overrides and associativity, table references: data of an entity record that +/// needs the file's handle maps. +pub(super) fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &mut Rx) { + match (kind, k) { + ("DIMENSION", EntityKind::Dimension(dm)) => { + dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs, &rx.ltypes); + dm.assoc = crate::dxf_ext::read_assoc(tags); + if let Some(center) = crate::dxf_ext::read_arclen(tags) { + dm.kind = DimKind::ArcLength; + dm.p15 = center; + } + dm.marks = crate::dxf_ext::read_marks(tags); + // Group 52: the extension lines' angle relative to the dimension line (DIMEDIT Oblique). + let base = match dm.kind { + DimKind::Linear { rotation } => Some(rotation), + DimKind::Aligned => Some((dm.p14.xy() - dm.p13.xy()).angle()), + _ => None, + }; + if let (Some(base), Some(o)) = (base, T(tags).f(52).filter(|o| o.is_finite() && o.rem_euclid(180.0) != 0.0)) { + dm.marks.oblique = Some(cadcraft_geom::norm_angle(base + o.to_radians())); + } + } + ("ACAD_TABLE", EntityKind::Table(_)) => { + let t = T(tags); + if let Some(b) = t.s(2) { + rx.table_blocks.insert(b.to_ascii_uppercase()); + } + let ours = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP).iter().any(|t| t.code == 1000 && t.str() == "TABLE"); + if !ours { + rx.table_style_fix.push((h, t.s(342).map(|sh| sh.trim().to_ascii_uppercase()).unwrap_or_default())); + } + } + ("INSERT", EntityKind::Insert(ins)) => { + rx.inserted.insert(ins.block.to_ascii_uppercase()); + } + ("MULTILEADER" | "MLEADER", EntityKind::MLeader(m)) => { + let pending = crate::dxf_mleader::resolve(tags, m, &rx.styles, &rx.brs); + if let Some(b) = &m.block { + rx.inserted.insert(b.block.to_ascii_uppercase()); + } + rx.mleader_fix.push((h, pending)); + } + // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. + ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { + v.frozen_layers = tags + .iter() + .take_while(|t| t.code < 1000) + .filter(|t| t.code == 341) + .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) + .collect(); + } + _ => {} + } +} + +/// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that +/// can't be placed are counted in `skipped`. +/// The entity of a POLYLINE, with its mesh faces (advancing `i` past its VERTEX records). `None` +/// for a mesh without faces. +pub(super) fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) -> Option<(Common, EntityKind, Vec)> { + let flags = t.i(70).unwrap_or(0); + let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); + // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. + let mut faces: Vec<[i64; 4]> = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "VERTEX" { + let tt = T(t2); + let vflags = tt.i(70).unwrap_or(0); + if vflags & 128 != 0 && vflags & 64 == 0 { + faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); + } else if vflags & 16 == 0 { + pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); + } + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + let c = common(t); + let mut extra: Vec = Vec::new(); + let k = if flags & (16 | 64) != 0 { + // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the + // polyline's place. + let verts: Vec = pts.iter().map(|p| p.0).collect(); + let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); + let first = fs.next()?; + extra.extend(fs); + first + } else if flags & 8 != 0 { + EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) + } else { + // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. + let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; + EntityKind::LwPolyline(LwPolyline { + vertices: pts.iter().map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }).collect(), + closed: flags & 1 != 0, + const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), + elevation: t.p(10).z, + plinegen: flags & 128 != 0, + }) + }; + Some((c, k, extra)) +} + +/// The ATTRIB records after an INSERT, advancing `i` past them and their SEQEND. +pub(super) fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_layer: &str) -> Vec { + let mut attribs = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "ATTRIB" { + let tt = T(t2); + // A multiline attribute has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(t2); + let multiline = multiline_attrib(embedded, &common(&tt)); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + attribs.push(Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: false, + prompt: String::new(), + props: attrib_props(&tt, ins_layer), + multiline, + modes: attrib_modes(own.0), + }); + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + attribs +} + +/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; +/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or +/// an index outside the vertex list are skipped. +fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { + let mut out = Vec::new(); + for f in faces { + let mut corners: Vec = Vec::with_capacity(4); + let mut hidden = 0u8; + let mut ok = true; + for &ix in f.iter().filter(|&&ix| ix != 0) { + let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); + let Some(p) = at.and_then(|n| verts.get(n)) else { + ok = false; + break; + }; + if ix < 0 { + hidden |= 1 << corners.len(); + } + corners.push(*p); + } + match (ok, corners.as_slice()) { + // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). + (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), + (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), + _ => {} + } + } + out +} + +/// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches +/// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. +fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { + let dims = |a: i32, b: i32| Some((usize::try_from(t.i(a)?).ok()?, usize::try_from(t.i(b)?).ok()?)); + let fits = |(m, n): (usize, usize)| m >= 2 && n >= 2 && m.checked_mul(n).is_some_and(|mn| mn <= verts.len()); + let exact = |(m, n): (usize, usize)| fits((m, n)) && m * n == verts.len(); + let Some((m, n)) = [dims(71, 72), dims(73, 74)].into_iter().flatten().find(|d| exact(*d)).or_else(|| dims(71, 72).filter(|d| fits(*d))) else { + return Vec::new(); + }; + let at = |i: usize, j: usize| verts.get((i % m) * n + j % n).copied(); + let rows = if flags & 1 != 0 { m } else { m - 1 }; + let cols = if flags & 32 != 0 { n } else { n - 1 }; + let mut out = Vec::new(); + for i in 0..rows { + for j in 0..cols { + if let (Some(a), Some(b), Some(c), Some(d)) = (at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j)) { + out.push(Face3d { corners: [a, b, c, d], hidden_edges: 0 }); + } + } + } + out +} + +/// A HELIX (DXF Reference): the axis base point, start point, axis, radius, turns, turn height, +/// handedness and constraint after the AcDbHelix marker; the top radius is how far the end of its +/// spline (AcDbSpline: the last control point, else fit point) lies from the axis. +fn helix(tags: &[Tag]) -> Helix { + let hs = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbHelix").unwrap_or(tags.len()); + let ss = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSpline").unwrap_or(0).min(hs); + let spline = tags.get(ss..hs).unwrap_or(&[]); + let t = T(tags.get(hs..).unwrap_or(&[])); + // The last point of the spline (3D) with group `code`. + let last = |code: i32| { + let i = spline.iter().rposition(|g| g.code == code)?; + let f = |c: i32| spline.get(i..).and_then(|r| r.iter().find(|g| g.code == c)).map(Tag::f64).unwrap_or(0.0); + Some(Vec3::new(f(code), f(code + 10), f(code + 20))) + }; + let center = t.p(10); + let start = t.p(11); + let axis = t.p(12); + let axis = if axis.is_finite() && axis.len() > 1e-12 { axis.normalized() } else { Vec3::Z }; + // Distance from the axis. + let radius = |p: Vec3| { + let v = Vec3::new(p.x - center.x, p.y - center.y, p.z - center.z); + let along = v.dot(axis); + Vec3::new(v.x - axis.x * along, v.y - axis.y * along, v.z - axis.z * along).len() + }; + let base_radius = t.f(40).filter(|r| r.is_finite() && *r >= 0.0).unwrap_or_else(|| radius(start)); + let turns = t.fd(41, 1.0); + let end = last(10).or_else(|| last(11)); + let height = match (t.f(42), end) { + (Some(th), _) if th.is_finite() => th * turns, + (_, Some(e)) => e.z - center.z, + _ => 0.0, + }; + Helix { + center, + base_radius, + top_radius: end.map(radius).unwrap_or(base_radius), + height, + turns, + start_angle: center.xy().angle_to(start.xy()), + ccw: t.i(290).unwrap_or(1) != 0, + constrain: HelixConstrain::from_dxf(t.i(280).unwrap_or(2)), + } + .sanitized() +} diff --git a/crates/io/src/dxf_read/hatch.rs b/crates/io/src/dxf_read/hatch.rs new file mode 100644 index 000000000..18fe81531 --- /dev/null +++ b/crates/io/src/dxf_read/hatch.rs @@ -0,0 +1,356 @@ +use super::common::T; +use super::*; + +pub(super) fn hatch(tags: &[Tag], t: &T) -> Option { + let (origin, background) = hatch_xdata(tags); + let pattern = t.s(2).unwrap_or_else(|| "SOLID".into()); + let solid = t.i(70).unwrap_or(0) == 1; + let n_loops = t.i(91).unwrap_or(0).clamp(0, 100_000) as usize; + let mut loops = Vec::new(); + // Walk the boundary data after the 91 tag. + let start = tags.iter().position(|x| x.code == 91).map(|i| i + 1)?; + let mut i = start; + let get = |i: usize| tags.get(i); + for _ in 0..n_loops { + // 92: loop type flags. + while get(i).is_some_and(|x| x.code != 92) { + i += 1; + } + let flags = get(i)?.i64(); + i += 1; + let outer = flags & 1 != 0; + let mut verts: Vec = Vec::new(); + if flags & 2 != 0 { + // Polyline boundary: 72 has-bulge, 73 closed, 93 count, then 10/20/42. + let has_bulge = get(i).filter(|x| x.code == 72).map(Tag::i64).unwrap_or(0) != 0; + while get(i).is_some_and(|x| x.code != 93) { + i += 1; + } + let n = get(i)?.i64().clamp(0, 1_000_000) as usize; + i += 1; + for _ in 0..n { + let x = get(i).filter(|t| t.code == 10)?.f64(); + let y = get(i + 1).filter(|t| t.code == 20)?.f64(); + i += 2; + let mut b = 0.0; + if has_bulge && get(i).is_some_and(|t| t.code == 42) { + b = get(i)?.f64(); + i += 1; + } + verts.push(PolyVertex::with_bulge(Vec2::new(x, y), b)); + } + } else { + // Edge boundary: 93 edge count, each 72 edge type. + while get(i).is_some_and(|x| x.code != 93) { + i += 1; + } + let n = get(i)?.i64().clamp(0, 1_000_000) as usize; + i += 1; + for _ in 0..n { + while get(i).is_some_and(|x| x.code != 72) { + i += 1; + } + let ty = get(i)?.i64(); + i += 1; + let f = |i: usize| get(i).map(Tag::f64).unwrap_or(0.0); + match ty { + 1 => { + let a = Vec2::new(f(i), f(i + 1)); + let b = Vec2::new(f(i + 2), f(i + 3)); + i += 4; + if verts.last().is_none_or(|l| !l.p.near(a, 1e-9)) { + verts.push(PolyVertex::new(a)); + } + verts.push(PolyVertex::new(b)); + } + 2 => { + let c = Vec2::new(f(i), f(i + 1)); + let r = f(i + 2); + let sa = f(i + 3).to_radians(); + let ea = f(i + 4).to_radians(); + let ccw = get(i + 5).map(Tag::i64).unwrap_or(1) != 0; + i += 6; + let (s, e) = if ccw { (sa, ea) } else { (-sa, -ea) }; + let p0 = Vec2::polar(c, r, s); + let p1 = Vec2::polar(c, r, e); + let sweep = if ccw { cadcraft_geom::ccw_sweep(s, e) } else { -cadcraft_geom::ccw_sweep(e, s) }; + if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { + verts.push(PolyVertex::new(p0)); + } + // A full circle (0 to 360) starts and ends on the same point, which one + // bulge can't span: it goes in as two half circles. + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; + let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); + if let Some(l) = verts.last_mut() { + l.bulge = bulge; + } + if full { + verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); + } + verts.push(PolyVertex::new(p1)); + } + 3 => { + // Elliptic arc: approximate with points. + let c = Vec2::new(f(i), f(i + 1)); + let m = Vec2::new(f(i + 2), f(i + 3)); + let ratio = f(i + 4); + let sa = f(i + 5).to_radians(); + let ea = f(i + 6).to_radians(); + let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; + i += 8; + // A clockwise edge stores its parameters mirrored, as an arc edge does: it + // runs from -start clockwise to -end, the ccw sweep from -end to -start + // walked backwards. + let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; + let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; + let mut pts = Vec::new(); + e.tessellate(m.len() * 1e-3, &mut pts); + if !ccw { + pts.reverse(); + } + verts.extend(pts.into_iter().map(PolyVertex::new)); + } + 4 => { + // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 + // control point count, then the 40 knots, the 10/20 control points (each + // with a 42 weight when rational), and 97 fit point count with 11/21 fit + // points and 12/22, 13/23 end tangents (R2010+). + let (mut degree, mut nk, mut nc) = (3, 0, 0); + while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) { + let n = x.i64().clamp(0, 100_000) as usize; + match x.code { + 94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader + 95 => nk = n, + 96 => nc = n, + _ => {} + } + i += 1; + } + let mut knots = Vec::new(); + while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { + knots.push(f(i)); + i += 1; + } + let (mut control, mut weights) = (Vec::new(), Vec::new()); + while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { + control.push(Vec2::new(f(i), f(i + 1))); + i += 2; + if get(i).is_some_and(|t| t.code == 42) { + weights.push(f(i)); + i += 1; + } + } + let mut fit = Vec::new(); + if get(i).is_some_and(|x| x.code == 97) { + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; + i += 1; + while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { + fit.push(Vec2::new(f(i), f(i + 1))); + i += 2; + } + } + if weights.len() != control.len() { + weights.clear(); + } + let sp = Spline { degree, knots, control, weights, ..Spline::default() }; + let sp = if sp.is_valid() { + sp + } else if sp.control.is_empty() && fit.len() > 1 { + Spline::from_fit_points(&fit) + } else { + Spline::from_control(sp.control, degree) + }; + let pts = sp.tessellate(1e-3); + let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); + verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); + } + _ => {} + } + } + } + if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { + verts.pop(); + } + // 97: number of source boundary objects, then their handles (330). A spline edge's fit + // data count is a 97 too, followed by its fit points instead. + let mut sources = Vec::new(); + for _ in 0..2 { + while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { + i += 1; + } + if get(i).is_none_or(|x| x.code != 97) { + break; + } + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; + i += 1; + if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { + continue; + } + while sources.len() < n + && let Some(x) = get(i).filter(|x| x.code == 330) + { + sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); + i += 1; + } + break; + } + loops.push(HatchLoop { vertices: verts, outer, sources }); + } + let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); + let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); + // Keep the file's definition lines even for a name the library knows: the library's versions + // of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale + // could be hundreds of times denser than the hatch the author saw. + let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; + Some(EntityKind::Hatch(Hatch { + pattern: pattern.to_ascii_uppercase(), + solid, + loops, + scale, + angle, + associative: t.i(71).unwrap_or(0) != 0, + style: t.i(75).unwrap_or(0) as u8, + // The first 30 is the elevation point's z (boundary points are 2D). + elevation: t.f(30).filter(|z| z.is_finite()).unwrap_or(0.0), + gradient: gradient(tags), + origin, + background, + pattern_lines, + })) +} + +/// Upper bound on the source boundary objects of one HATCH loop. +const MAX_HATCH_SOURCES: usize = 4096; + +/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. +const MAX_PATTERN_LINES: usize = 1024; + +const MAX_PATTERN_DASHES: usize = 256; + +/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base +/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, +/// rotated with the hatch and the offset as a world vector. They are kept the library's way +/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift +/// along the line and spacing), which the writer and the renderer turn back. Lines with +/// non-finite values are skipped. +fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { + let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; + let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + // Raw lines: angle (degrees), base, offset, dashes. + let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); + for x in tags.iter().skip(at + 1) { + let cur = raw.last_mut(); + match (x.code, cur) { + (53, _) => { + if raw.len() >= MAX_PATTERN_LINES { + break; + } + raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); + } + (43, Some(l)) => l.1.x = x.f64(), + (44, Some(l)) => l.1.y = x.f64(), + (45, Some(l)) => l.2.x = x.f64(), + (46, Some(l)) => l.2.y = x.f64(), + (79, Some(_)) => {} + (49, Some(l)) => { + if l.3.len() < MAX_PATTERN_DASHES { + l.3.push(x.f64()); + } + } + _ => break, + } + } + raw.into_iter() + .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) + .map(|(a, base, offset, dashes)| { + let local = (base - origin).rotate(-angle) * (1.0 / scale); + let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); + cadcraft_doc::library::PatternLine { + angle: a - angle.to_degrees(), + origin: (local.x, local.y), + delta: (delta.x, delta.y), + dashes: dashes.into_iter().map(|d| d / scale).collect(), + } + }) + .collect() +} + +/// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, +/// `1000` background colour name (empty for none). Defaults when absent. +fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let Some(at) = x.windows(2).position(|w| matches!(w, [m, o] if m.code == 1000 && m.str() == "HATCH" && o.code == 1011)) else { + return (Vec2::ZERO, None); + }; + let val = |k: usize, code: i32| x.get(at + k).filter(|t| t.code == code); + let coord = |k: usize, code: i32| val(k, code).map(Tag::f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let origin = Vec2::new(coord(1, 1011), coord(2, 1021)); + (origin, val(4, 1000).and_then(|t| Color::parse(&t.str()))) +} + +/// Gradient values missing from a file fall back to the GRADIENT command's defaults. +fn default_gradient() -> Gradient { + Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } +} + +/// A hatch's gradient fill: the native groups when present (another program may have edited +/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). +fn gradient(tags: &[Tag]) -> Option { + if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } +} + +/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. +fn xdata_gradient(tags: &[Tag]) -> Option { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; + let o = x.get(at + 1..).unwrap_or(&[]); + let d = default_gradient(); + let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); + let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); + Some(Gradient { + name: strs.first().cloned().unwrap_or(d.name), + color1: color(1, d.color1), + color2: color(2, d.color2), + angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), + centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), + }) +} + +/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record +/// whose 63 (ACI) and 421 (true colour) groups follow. +fn native_gradient(tags: &[Tag]) -> Option { + let start = tags.iter().position(|x| x.code == 450)?; + if tags.get(start)?.i64() != 1 { + return None; + } + let mut g = default_gradient(); + // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. + let mut colors: [(Option, Option); 2] = [(None, None); 2]; + let mut slot: Option = None; + for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { + let rec = slot.and_then(|s| colors.get_mut(s)); + match x.code { + 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), + 461 => g.centered = x.f64().abs() < 1e-9, + 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), + 63 => { + if let Some(r) = rec { + r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); + } + } + 421 => { + if let Some(r) = rec { + r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); + } + } + 470 => g.name = x.str(), + _ => {} + } + } + let [c1, c2] = colors; + let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); + g.color1 = pick(c1, g.color1); + g.color2 = pick(c2, g.color2); + Some(g) +} diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs new file mode 100644 index 000000000..97b30c786 --- /dev/null +++ b/crates/io/src/dxf_read/objects.rs @@ -0,0 +1,285 @@ +use super::common::T; +use super::dimassoc::dimassoc; +use super::document::sortents_table; +use super::*; + +/// Non-graphical objects applied once every entity is placed. +#[derive(Default)] +pub(super) struct Objects { + /// Dictionary entry handle (upper case) → entry name. + names: HashMap, + /// SCALE objects (the annotation scale list), in file order. + scales: Vec, + /// XRECORD handle (upper case) → its groups. + xrecords: HashMap>, + /// TABLESTYLE objects: (handle, groups). + table_styles: Vec<(String, Vec)>, + /// DIMASSOC objects. + dimassocs: Vec>, + /// MLEADERSTYLE objects: (handle, groups). + mleader_styles: Vec<(String, Vec)>, + /// GROUP objects: (handle, groups). + groups: Vec<(String, Vec)>, + /// Saved paper-space views of layouts: (layout name, view). + layout_views: Vec<(String, (Vec2, f64))>, + /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). + pub(super) sortents: Vec<(String, HashMap)>, + /// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown). + wipeout_frame: Option, +} + +pub(super) const MAX_OBJECTS: usize = 1_000_000; + +impl Objects { + pub(super) fn add(&mut self, kind: &str, tags: &[Tag]) { + let h = T(tags).s(5).map(|h| h.trim().to_ascii_uppercase()).unwrap_or_default(); + match kind { + "SCALE" if self.scales.len() < cadcraft_doc::MAX_SCALES => { + if let Some(s) = crate::dxf_ext::read_scale(tags) + && !self.scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) + { + self.scales.push(s); + } + } + "DICTIONARY" => { + let mut name: Option = None; + for t in tags { + match t.code { + 3 => name = Some(t.str()), + 350 | 360 => { + if let Some(n) = name.take() + && self.names.len() < MAX_OBJECTS + { + self.names.insert(t.str().trim().to_ascii_uppercase(), n); + } + } + _ => {} + } + } + } + "XRECORD" if self.xrecords.len() < MAX_OBJECTS => { + self.xrecords.insert(h, tags.to_vec()); + } + "TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())), + "DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()), + "MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())), + "GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())), + "WIPEOUTVARIABLES" => self.wipeout_frame = T(tags).i(70).filter(|v| (0..=2).contains(v)), + "SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => { + if let Some(t) = sortents_table(tags) { + self.sortents.push(t); + } + } + "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { + self.layout_views.push((name, view)); + } + } + _ => {} + } + } + + /// The text of the CADCraft XRECORD stored under `key` in a dictionary (its 1/3 strings). + pub(super) fn xrecord_text(&self, key: &str) -> Option { + let h = self.names.iter().find(|(_, n)| n.as_str() == key).map(|(h, _)| h)?; + let tags = self.xrecords.get(h)?; + let mut text = String::new(); + for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { + if text.len() > crate::dxf_ext::MAX_PAYLOAD { + break; + } + text.push_str(&t.str()); + } + Some(text) + } + + fn apply_table_styles(&self, d: &mut Drawing) { + // Table styles (named by their ACAD_TABLESTYLE dictionary entries). + for (h, tags) in &self.table_styles { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let st = crate::dxf_table::read_table_style(name, tags); + match d.table_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + None => d.table_styles.push(st), + } + } + } + + fn apply_table_style_fixes(&self, d: &mut Drawing, rx: &Rx) { + for (h, sh) in &rx.table_style_fix { + let name = self.names.get(sh).cloned(); + // A table from another writer has no scale of its own: its text height is its + // style's, so every cell style height applies as it is. + let height = { + let style = name.as_deref().unwrap_or("Standard"); + d.table_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(style)) + .map_or(cadcraft_doc::TableStyle::default().text_height, |s| s.text_height) + }; + let _ = d.modify_entity(*h, |e| { + if let EntityKind::Table(t) = &mut e.kind { + if let Some(name) = name { + t.style = name; + } + t.text_height = height; + } + }); + } + } + + fn apply_mleader_styles(&self, d: &mut Drawing, rx: &Rx) { + // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). + for (h, tags) in &self.mleader_styles { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let def = MLeaderStyle::default(); + let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); + let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); + let st = MLeaderStyle { + name: name.clone(), + arrow_size: num(44, def.arrow_size, 0.0), + text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), + landing_gap: num(42, def.landing_gap, 0.0), + dogleg: num(43, def.dogleg, 0.0), + text_style: text_style.unwrap_or(def.text_style), + annotative: t.i(296).unwrap_or(0) != 0, + }; + match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + None => d.mleader_styles.push(st), + } + } + } + + fn apply_groups(&self, d: &mut Drawing) { + // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + for (h, tags) in &self.groups { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let members: Vec = tags + .iter() + .filter(|x| x.code == 340) + .filter_map(|x| Handle::parse_hex(&x.str())) + .filter(|m| d.entity(*m).is_some()) + .take(MAX_OBJECTS) + .collect(); + let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; + match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = g, + None => d.groups.push(g), + } + } + } + + pub(super) fn apply(self, d: &mut Drawing, rx: &Rx) { + if let Some(v) = self.wipeout_frame { + d.header.set_i64("WIPEOUTFRAME", v); + } + if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { + d.header.set_str("CMLEADERSTYLE", &style); + } + self.apply_table_styles(d); + self.apply_table_style_fixes(d, rx); + // Parametric constraints and parameters. + if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) { + d.constraints = c; + d.parametric = p; + } + // The annotation scale list (a metric drawing without one gets the metric defaults), + // then CADCraft's annotative scaling record. + if !self.scales.is_empty() { + d.scales = self.scales.clone(); + } else if d.header.i64("MEASUREMENT", 0) == 1 { + d.scales = cadcraft_doc::metric_scales(); + } + if let Some(p) = self.xrecord_text(crate::dxf_ext::ANNOTATION_KEY).and_then(|t| crate::dxf_ext::parse_anno(&t)) { + crate::dxf_ext::apply_anno(d, p); + } + // Saved layer states. + if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { + d.layer_states = states; + } + // The drawing's plot style tables. + if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) { + d.plot_style_tables = tables; + } + self.apply_mleader_styles(d, rx); + crate::dxf_mleader::apply(d, &rx.mleader_fix, &self.names); + for (name, view) in &self.layout_views { + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) { + l.view = Some(*view); + } + } + self.apply_groups(d); + // Standard associativity, for dimensions without CADCraft's exact links. + for tags in &self.dimassocs { + dimassoc(d, tags); + } + } +} + +/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm). +pub(super) fn page_setup(t: &T) -> PageSetup { + let mut p = PageSetup::default(); + let mm = |code: i32, d: f64| { + let v = t.fd(code, d); + if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d } + }; + let (w, h) = (mm(44, 0.0), mm(45, 0.0)); + if w > 0.0 && h > 0.0 { + p.width_mm = w.min(h); + p.height_mm = w.max(h); + } + p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])]; + // Plot paper units 1: millimetres. 0 (inches) is left to MEASUREMENT: earlier CADCraft + // versions wrote 0 for every layout. + p.paper_mm = t.i(72) == Some(1); + // Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is + // already landscape. + let rotated = matches!(t.i(73), Some(1 | 3)); + if w > 0.0 && h > 0.0 { + p.landscape = rotated != (w > h); + } else if t.i(73).is_some() { + p.landscape = rotated; + } + if let Some(name) = t.s(4).filter(|n| !n.is_empty()) { + p.paper = name.replace('_', " "); + } + if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { + p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; + } + // Plot layout flags: 2 display plot styles, 4 centred, 16 standard scale (75 = 0: scaled + // to fit), 128 lineweights. + if let Some(flags) = t.i(70) { + p.show_plot_styles = flags & 2 != 0; + p.center = flags & 4 != 0; + p.lineweights = flags & 128 != 0; + p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); + } + // Custom scale: 142 paper units per 143 drawing units; 147 is the scale factor. + let pos = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0); + let ratio = pos(t.f(142)).zip(pos(t.f(143))).map(|(n, d)| n / d); + if let Some(s) = pos(ratio).or_else(|| pos(t.f(147))) { + p.scale = s; + } + p.plot_area = match t.i(74) { + Some(0) => "display", + Some(1) => "extents", + Some(2) => "limits", + Some(4) => "window", + _ => "layout", + } + .into(); + p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); + p.plot_style_table = t.s(7).unwrap_or_default(); + p +} + +/// A layout's saved paper-space view (centre, height) from CADCraft xdata. +fn layout_view(tags: &[Tag]) -> Option<(Vec2, f64)> { + let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, crate::dxf_ext::APP), "PSVIEW"); + let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); + Some((Vec2::new(g(1010)?, g(1020)?), g(1040)?)) +} diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs new file mode 100644 index 000000000..9419a081e --- /dev/null +++ b/crates/io/src/dxf_read/tables.rs @@ -0,0 +1,370 @@ +use super::common::T; +use super::objects::MAX_OBJECTS; +use super::*; + +pub(super) fn header(tags: &[Tag], d: &mut Drawing) { + let mut i = 0; + while let Some(t) = tags.get(i) { + if t.code == 9 { + let name = t.str().trim_start_matches('$').to_ascii_uppercase(); + i += 1; + let mut vals: Vec<&Tag> = Vec::new(); + while let Some(v) = tags.get(i) { + if v.code == 9 { + break; + } + vals.push(v); + i += 1; + } + // Legacy files keep spacing in HEADER; modern *ACTIVE VPORT entries override it. + if matches!(name.as_str(), "SNAPUNIT" | "GRIDUNIT") { + let (Some(x), Some(y)) = (vals.iter().find(|t| t.code == 10), vals.iter().find(|t| t.code == 20)) else { continue }; + let p = Vec2::new(x.f64(), y.f64()); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(&name, HVal::Point(p.to3(0.0))); + } + continue; + } + let hv = match vals.as_slice() { + [x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())), + [x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)), + [v] => match &v.value { + cadcraft_dxf::Value::Real(r) => HVal::Real(*r), + cadcraft_dxf::Value::Int(n) => HVal::Int(*n), + cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)), + other => HVal::Str(other.as_str()), + }, + _ => continue, + }; + // $DIMDSEP holds a character code; the dimension style holds the character. + let hv = match hv { + HVal::Int(n) if name == "DIMDSEP" => match u32::try_from(n).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { + Some(c) => HVal::Str(c.to_string()), + None => continue, + }, + // $DIMJOGANG is in radians; the variable (like the style) holds degrees. + HVal::Real(r) if name == "DIMJOGANG" => HVal::Real((r.to_degrees() * 1e9).round() / 1e9), + // $ANGBASE is in degrees (a drawing measured from north has 90.0); the variable + // holds radians. + HVal::Real(r) if name == "ANGBASE" => HVal::Real(r.to_radians()), + hv => hv, + }; + d.header.set(&name, hv); + } else { + i += 1; + } + } +} + +/// True when a style record carries `AcadAnnotative` xdata with the annotative flag set. +fn annotative(tags: &[Tag]) -> bool { + let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, "AcadAnnotative"), "AnnotativeData"); + list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0) +} + +/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) +/// while the font file is still the `txt` written for other readers; the TrueType family of the +/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType +/// styles); otherwise the file. +fn style_font(tags: &[Tag], file: String) -> String { + if file.trim().is_empty() + && let Some(face) = + crate::dxf_ext::xdata(tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty()) + { + return face; + } + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let own = x.windows(2).find_map(|w| match w { + [m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()), + _ => None, + }); + let stem = file.trim().to_ascii_lowercase(); + match own { + Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, + _ => file, + } +} + +/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates. +pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { + let recs = records(tags); + // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). + let ltype_styles: HashMap = recs + .iter() + .filter(|(k, _)| k == "STYLE") + .filter_map(|(_, tg)| { + let t = T(tg); + let name = t.s(2).filter(|n| !n.trim().is_empty()); + Some((t.s(5)?.trim().to_ascii_uppercase(), name.or_else(|| t.s(3))?)) + }) + .collect(); + // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. + for (kind, tg) in &recs { + let t = T(tg); + let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue }; + match kind.as_str() { + "STYLE" => { + rx.styles.insert(h.trim().to_ascii_uppercase(), n); + } + "BLOCK_RECORD" => { + let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); + rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); + if let Some(a) = crate::dxf_ext::read_array(tg) { + rx.block_arrays.insert(n.to_ascii_uppercase(), a); + } + rx.brs.insert(h.trim().to_ascii_uppercase(), n); + } + "LTYPE" => { + rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); + } + "LAYER" => { + rx.layers.insert(h.trim().to_ascii_uppercase(), n); + } + _ => {} + } + } + let mut active_vport = false; + // Index of layers already appended, by lower-cased name, so each LAYER record updates or + // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer + // names make import quadratic (CWE-407). + let mut layer_index: HashMap = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect(); + for (kind, tg) in recs { + let t = T(&tg); + match kind.as_str() { + "LAYER" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.layers.len() >= MAX_OBJECTS { + continue; + } + let aci = t.i(62).unwrap_or(7); + let flags = t.i(70).unwrap_or(0); + let mut l = Layer { + name: name.clone(), + color: Color::Index(aci.unsigned_abs().clamp(1, 255) as u8), + linetype: t.s(6).unwrap_or_else(|| "Continuous".into()), + lineweight: t.i(370).map(|v| Lineweight::from_dxf(v as i16)).unwrap_or(Lineweight::Default), + on: aci >= 0, + frozen: flags & 1 != 0, + locked: flags & 4 != 0, + vp_freeze_new: flags & 2 != 0, + plot: t.i(290).unwrap_or(1) != 0, + ..Layer::default() + }; + if let Some(tc) = t.i(420) { + l.color = Color::True(Rgb::from_u32(tc as u32)); + } + if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) { + l.plot_style = n.clone(); + } + let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071); + if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) { + l.transparency = pct; + } + if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) { + l.description = desc.str(); + } + match layer_index.get(&name.to_ascii_lowercase()) { + Some(&i) => { + if let Some(x) = d.layers.get_mut(i) { + *x = l; + } + } + None => { + layer_index.insert(name.to_ascii_lowercase(), d.layers.len()); + d.layers.push(l); + } + } + } + "LTYPE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS { + continue; + } + let pattern = ltype_pattern(&tg, <ype_styles); + d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern }); + } + "STYLE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS { + continue; + } + // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. + let gen_flags = t.i(71).unwrap_or(0); + let st = TextStyle { + name: name.clone(), + font: style_font(&tg, t.s(3).unwrap_or_default()), + big_font: t.s(4).unwrap_or_default(), + height: t.fd(40, 0.0), + width_factor: t.fd(41, 1.0), + oblique: t.fd(50, 0.0).to_radians(), + backwards: gen_flags & 2 != 0, + upside_down: gen_flags & 4 != 0, + vertical: t.i(70).unwrap_or(0) & 4 != 0, + annotative: annotative(&tg), + }; + match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = st, + None => d.text_styles.push(st), + } + } + // The first *ACTIVE viewport holds the current snap/grid settings. + "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { + active_vport = true; + vport_record(d, &t); + for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] { + if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) { + let p = Vec2::new(x, y); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(name, HVal::Point(p.to3(0.0))); + } + } + } + if let (Some(x), Some(y)) = (t.f(13), t.f(23)) + && x.is_finite() + && y.is_finite() + { + d.header.set("SNAPBASE", HVal::Point(Vec3::new(x, y, 0.0))); + } + if let Some(a) = t.f(50).filter(|a| a.is_finite()) { + d.header.set_f64("SNAPANG", a); + } + } + "VIEW" => { + let name = t.s(2).unwrap_or_default(); + let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); + if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { + continue; + } + let center = t.p(10).xy(); + if !(center.x.is_finite() && center.y.is_finite()) { + continue; + } + let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP); + let field = |key: &str| { + x.iter().position(|t| t.code == 1000 && t.str() == key).and_then(|i| x.get(i + 1)).filter(|t| t.code == 1000).map(Tag::str) + }; + // 70 bit 1: a paper space view; its layout is CADCraft xdata. + let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default()); + let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians()); + let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout }; + match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = v, + None => d.views.push(v), + } + } + "UCS" => { + let name = t.s(2).unwrap_or_default(); + let (origin, x_axis, y_axis) = (t.p(10), t.p(11), t.p(12)); + let finite = [origin, x_axis, y_axis].iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()); + if name.is_empty() || !finite || d.ucss.len() >= MAX_OBJECTS { + continue; + } + let u = Ucs { name: name.clone(), origin, x_axis, y_axis }; + match d.ucss.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = u, + None => d.ucss.push(u), + } + } + "DIMSTYLE" => { + let name = t.s(2).unwrap_or_default(); + if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS { + continue; + } + // Every variable with a DIMSTYLE group code; values the style can't take are + // left at their defaults. + let mut fields = serde_json::Map::new(); + // The first 70 is the record's flags; a later one is DIMTFILLCLR. + let flags = tg.iter().position(|x| x.code == 70); + for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) { + if Some(i) == flags { + continue; + } + let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue }; + if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) { + fields.insert(field.to_string(), v); + } + } + fields.extend(crate::dxf_ext::read_newer_dimvars(&tg, &rx.styles, &rx.brs, &rx.ltypes)); + let mut st = DimStyle { name: name.clone(), annotative: annotative(&tg), ..DimStyle::default() }; + st.apply_fields(&fields); + match d.dim_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = st, + None => d.dim_styles.push(st), + } + } + "VPORT" => vport_record(d, &t), + _ => {} + } + } + // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). + d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); +} + +/// Most tiles a viewport configuration keeps. +const MAX_VPORT_TILES: usize = 64; + +/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners +/// are fractions of the drawing area; unusable tiles are skipped. +fn vport_record(d: &mut Drawing, t: &T) { + let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + let height = t.fd(40, 0.0); + let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); + let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) + && max.x > min.x + 1e-6 + && max.y > min.y + 1e-6 + && center.is_finite() + && height.is_finite() + && height > 0.0; + if name.is_empty() || !ok { + return; + } + let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); + let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; + let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; + let room = d.vports.len() < MAX_OBJECTS; + match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { + Some(c) if c.tiles.len() < MAX_VPORT_TILES => c.tiles.push(tile), + Some(_) => {} + None if room => d.vports.push(VportConfig { name, tiles: vec![tile] }), + None => {} + } +} + +/// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, +/// LTYPE): each `49` length starts an element; `74` type flags (1 absolute rotation, 2 text, +/// 4 shape), `75` shape number, `340` STYLE, `46` scale, `50` rotation (radians), `44`/`45` +/// offset and `9` text follow it. +fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec { + let mut out: Vec = Vec::new(); + for t in tags.iter().take_while(|t| t.code < 1000) { + if t.code == 49 { + out.push(DashElement::dash(t.f64())); + continue; + } + let Some(el) = out.last_mut() else { continue }; + let complex = el.text.is_some() || el.shape.is_some(); + let v = t.f64(); + match t.code { + 74 => { + let flags = t.i64(); + el.absolute = flags & 1 != 0; + if flags & 2 != 0 { + el.text = Some(String::new()); + } else if flags & 4 != 0 { + el.shape = Some(0); + } + } + 75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)), + 340 if complex => el.style = styles.get(&t.str().trim().to_ascii_uppercase()).filter(|n| !n.trim().is_empty()).cloned(), + 9 if el.text.is_some() => el.text = Some(t.str()), + 46 if complex && v.is_finite() => el.scale = v, + 50 if complex && v.is_finite() => el.rotation = v, + 44 if complex && v.is_finite() => el.offset.x = v, + 45 if complex && v.is_finite() => el.offset.y = v, + _ => {} + } + } + out +} diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs new file mode 100644 index 000000000..45ee2f003 --- /dev/null +++ b/crates/io/src/dxf_read/text.rs @@ -0,0 +1,175 @@ +use super::common::{T, common, ocs_n}; +use super::*; + +/// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when +/// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's +/// layer, as it is drawn anyway). +pub(super) fn attrib_props(t: &T, insert_layer: &str) -> AttribProps { + let c = common(t); + AttribProps { + layer: t.s(8).filter(|l| !l.is_empty() && !l.eq_ignore_ascii_case(insert_layer)), + color: (t.i(62).is_some() || t.i(420).is_some()).then_some(c.color), + linetype: t.s(6).filter(|l| !l.is_empty()), + lineweight: t.i(370).is_some().then_some(c.lineweight), + } +} + +/// An attribute's modes from its own groups: flags (70) 4 verify and 8 preset, and Lock +/// position, the 280 after its tag in its subclass (a 280 before the tag is the subclass version). +pub(super) fn attrib_modes(own: &[Tag]) -> AttribModes { + let flags = own.iter().find(|t| t.code == 70).map(Tag::i64).unwrap_or(0); + let sub = own.iter().position(|t| t.code == 100 && t.str().trim().starts_with("AcDbAttribute")).unwrap_or(0); + let rest = own.get(sub..).unwrap_or_default(); + let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0)); + AttribModes { verify: flags & 4 != 0, preset: flags & 8 != 0, lock_position } +} + +/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`): +/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker. +pub(super) fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) { + match tags.iter().position(|t| t.code == 101) { + Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)), + None => (tags, None), + } +} + +/// A multiline attribute's text, from the MTEXT groups embedded in it. +pub(super) fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option { + let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c); + let read_as = cadcraft_fonts::plain_mtext(&mtext.contents); + Some(MultilineAttrib { mtext, read_as }) +} + +/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones). +pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { + let t = T(tags); + // Contents: code 3 chunks followed by code 1. + let mut contents = String::new(); + for tg in tags.iter().filter(|x| x.code == 3) { + contents.push_str(&tg.str()); + } + contents.push_str(&t.s(1).unwrap_or_default()); + let contents = mtext_dxf::decode_contents(&contents); + let columns = mtext_dxf::read_columns(tags); + // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object + // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). + let own = T(mtext_dxf::before_columns(tags)); + let mut rotation = match (own.f(11), own.f(21)) { + (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), + _ => own.fd(50, 0.0).to_radians(), + }; + let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; + if c.extrusion.z < 0.0 { + // Insertion point and direction are WCS, but the line height runs along extrusion × + // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with + // the same footprint, the text turns round and left/right attachment swap. + rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); + attach = match attach { + 1 | 4 | 7 => attach + 2, + 3 | 6 | 9 => attach - 2, + a => a, + }; + } + MText { + insert: t.p(10), + height: t.fd(40, 0.2).max(1e-9), + width: match &columns { + Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, + _ => t.fd(41, 0.0).max(0.0), + }, + attach, + rotation, + style: t.s(7).unwrap_or_else(|| "Standard".into()), + contents, + line_spacing: t.fd(44, 1.0), + line_spacing_exact: t.i(73) == Some(2), + columns: columns.map(|(c, _)| c), + } +} + +pub(super) fn text_from(t: &T) -> Text { + let h = match t.i(72).unwrap_or(0) { + 1 => HAlign::Center, + 2 => HAlign::Right, + 3 => HAlign::Aligned, + 4 => HAlign::Middle, + 5 => HAlign::Fit, + _ => HAlign::Left, + }; + let v = match t.i(73).or_else(|| t.i(74)).unwrap_or(0) { + 1 => VAlign::Bottom, + 2 => VAlign::Middle, + 3 => VAlign::Top, + _ => VAlign::Baseline, + }; + let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; + let mut text = Text { + insert: t.p(10), + align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, + height: t.fd(40, 0.2).max(1e-9), + value: t.s(1).unwrap_or_default(), + rotation: t.fd(50, 0.0).to_radians(), + width_factor: t.fd(41, 1.0), + oblique: t.fd(51, 0.0).to_radians(), + style: t.s(7).unwrap_or_else(|| "Standard".into()), + halign: h, + valign: v, + }; + let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + if n.z < 0.0 { + mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); + } + text +} + +/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its +/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable +/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and +/// left/right justification swap ends (start ↔ end point). +fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { + t.rotation = cadcraft_geom::norm_angle(-t.rotation); + let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; + match t.halign { + HAlign::Left => { + // TL/ML/BL are placed by their alignment point, baseline-left text by its start point. + t.halign = HAlign::Right; + t.insert = insert; + t.align_pt = Some(if t.valign == VAlign::Baseline { insert } else { align.unwrap_or(insert) }); + } + HAlign::Right => { + t.halign = HAlign::Left; + t.insert = align.unwrap_or(insert); + t.align_pt = baseline_left(t, t.insert); + } + HAlign::Aligned | HAlign::Fit => { + t.insert = align.unwrap_or(insert); + t.align_pt = Some(insert); + } + HAlign::Center | HAlign::Middle => { + t.insert = insert; + t.align_pt = align; + } + } +} + +/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups. +pub(super) fn attdef(tags: &[Tag], c: &Common) -> Attrib { + // A multiline definition has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(tags); + let multiline = multiline_attrib(embedded, c); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: own.i(70).unwrap_or(0) & 2 != 0, + prompt: own.s(3).unwrap_or_default(), + props: Default::default(), + multiline, + modes: attrib_modes(own.0), + } +}