diff --git a/README.md b/README.md index be5e2936e..3830d0560 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", + /// Group 330: the block record (or other object) that owns the entity. + pub owner: Option, + /// Group 8 (`0` when absent). + pub layer: String, + /// Group 6; `None` is ByLayer. + pub linetype: Option, + pub color: Color, + pub lineweight: Lineweight, + /// Group 48 (1 when absent or not positive). + pub ltscale: f64, + /// Group 60 is not 1. + pub visible: bool, + /// Group 67 is 1. + pub paper_space: bool, + pub transparency: Transparency, + /// Group 39. + pub thickness: f64, + /// Groups 210/220/230 (the world Z axis when absent). + pub extrusion: Point, + /// Group 390: the plot style name object. + pub plot_style: Option, +} + +/// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all +/// of it in files without markers, and in records with no AcDbEntity marker, whose layer and +/// colour may follow the entity's marker), or up to an embedded object (101). Later groups reuse +/// the codes: an MTEXT's column width is 48. Markers inside an application-defined group do not +/// count. +fn entity_groups(tags: &[Tag]) -> &[Tag] { + let is_entity = |t: &Tag| t.str_ref().is_some_and(|s| s == "AcDbEntity"); + let marked = outside_app(tags).any(|(_, t)| t.code == 100 && is_entity(t)); + let end = outside_app(tags).find(|(_, t)| (marked && t.code == 100 && !is_entity(t)) || t.code == 101).map_or(tags.len(), |(i, _)| i); + tags.get(..end).unwrap_or(tags) +} + +impl Common { + pub fn read(tags: &[Tag]) -> Common { + let (all, e) = (Groups(tags), Groups(entity_groups(tags))); + Common { + handle: e.handle(5), + owner: outside_app(tags).find(|(_, t)| t.code == 330).and_then(|(_, t)| parse_handle(&t.str())), + layer: e.name(8).unwrap_or_else(|| "0".into()), + linetype: e.name(6).filter(|l| !l.eq_ignore_ascii_case("ByLayer")), + color: Color::of(&e), + lineweight: e.i(370).map_or(Lineweight::ByLayer, Lineweight::from_dxf), + ltscale: e.f(48).filter(|s| *s > 0.0).unwrap_or(1.0), + visible: e.i(60) != Some(1), + paper_space: e.i(67) == Some(1), + transparency: e.i(440).map_or(Transparency::ByLayer, Transparency::from_dxf), + thickness: all.f_or(39, 0.0), + extrusion: [all.f_or(210, 0.0), all.f_or(220, 0.0), all.f_or(230, 1.0)], + plot_style: e.handle(390).filter(|h| *h != 0), + } + } +} + +/// A vertex of an LWPOLYLINE. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub struct LwVertex { + pub x: f64, + pub y: f64, + /// Group 42: the tangent of a quarter of the arc to the next vertex (0: straight). + pub bulge: f64, + pub start_width: f64, + pub end_width: f64, +} + +/// An LWPOLYLINE. +#[derive(Clone, Debug, PartialEq)] +pub struct LwPolyline { + pub vertices: Vec, + /// Flags (70) bit 1. + pub closed: bool, + /// Flags bit 128: the linetype runs around the vertices. + pub plinegen: bool, + /// Group 43. + pub const_width: f64, + /// Group 38. + pub elevation: f64, +} + +/// A VERTEX of a POLYLINE. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vertex { + pub location: Point, + pub bulge: f64, + pub start_width: f64, + pub end_width: f64, + /// Group 70: 1 added by curve fitting, 8 added by spline fitting, 16 a spline frame control + /// point, 32 a 3D polyline vertex, 64 a mesh vertex, 128 a polyface vertex (128 without 64: + /// a face record). + pub flags: i64, + /// Groups 71–74: the vertex numbers of a polyface face record (1-based; negative: the edge + /// from it is invisible; 0: none). + pub face: [i64; 4], +} + +impl Vertex { + /// A face record of a polyface mesh, which holds vertex numbers and no point. + pub fn is_face(&self) -> bool { + self.flags & 128 != 0 && self.flags & 64 == 0 + } + /// A control point of the frame a spline-fit polyline was made from: not on the curve. + pub fn is_spline_frame(&self) -> bool { + self.flags & 16 != 0 + } +} + +/// A POLYLINE with its VERTEX records: a 2D or 3D polyline, a polygon mesh or a polyface mesh. +#[derive(Clone, Debug, PartialEq)] +pub struct Polyline { + /// Group 70: 1 closed (a mesh: closed in M), 2 curve-fit, 4 spline-fit, 8 a 3D polyline, 16 a + /// polygon mesh, 32 a mesh closed in N, 64 a polyface mesh, 128 the linetype runs around the + /// vertices. + pub flags: i64, + /// The z of the POLYLINE's own point: a 2D polyline's elevation. + pub elevation: f64, + /// Groups 40 and 41: the default widths. + pub start_width: f64, + pub end_width: f64, + /// Groups 71 and 72: a mesh's M and N vertex counts (a polyface mesh: vertices and faces). + pub m: i64, + pub n: i64, + /// Group 75: the smooth surface type (5 quadratic, 6 cubic, 8 Bezier). + pub smooth: i64, + pub vertices: Vec, +} + +impl Polyline { + pub fn closed(&self) -> bool { + self.flags & 1 != 0 + } + pub fn is_3d(&self) -> bool { + self.flags & 8 != 0 + } + pub fn is_mesh(&self) -> bool { + self.flags & 16 != 0 + } + pub fn is_polyface(&self) -> bool { + self.flags & 64 != 0 + } +} + +/// A SPLINE (NURBS), in world coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Spline { + /// Group 71 (3 when absent). + pub degree: i64, + /// Group 70: 1 closed, 2 periodic, 4 rational, 8 planar, 16 linear. + pub flags: i64, + pub knots: Vec, + pub control: Vec, + /// Group 41: one per control point when rational. + pub weights: Vec, + pub fit: Vec, + /// Groups 12/22/32 and 13/23/33. + pub start_tangent: Option, + pub end_tangent: Option, + /// Group 44. + pub fit_tolerance: f64, +} + +/// TEXT, and the text of an ATTRIB or ATTDEF. +#[derive(Clone, Debug, PartialEq)] +pub struct Text { + /// Group 10: the first alignment point. + pub insert: Point, + /// Group 11: the second alignment point, which places text that is not left-baseline. + pub align: Option, + pub height: f64, + /// The string, decoded ([`decode_line`]); `%%` codes are left as written. + pub value: String, + /// Group 50, in degrees. + pub rotation: f64, + /// Group 41 (1 when absent). + pub width_factor: f64, + /// Group 51, in degrees. + pub oblique: f64, + /// Group 7 (`Standard` when absent). + pub style: String, + /// Group 71: 2 backwards, 4 upside down. + pub generation: i64, + /// Group 72: 0 left, 1 centre, 2 right, 3 aligned, 4 middle, 5 fit. + pub halign: i64, + /// Group 73 (74 in an ATTRIB or ATTDEF): 0 baseline, 1 bottom, 2 middle, 3 top. + pub valign: i64, +} + +impl Text { + pub fn read(g: &Groups<'_>) -> Text { + Text { + insert: g.point(10), + align: g.point_opt(11), + height: g.f_or(40, 0.0), + value: decode_line(&g.s(1).unwrap_or_default()), + rotation: g.f_or(50, 0.0), + width_factor: g.f_or(41, 1.0), + oblique: g.f_or(51, 0.0), + style: g.name(7).unwrap_or_else(|| "Standard".into()), + generation: g.i_or(71, 0), + halign: g.i_or(72, 0), + valign: g.i(73).or_else(|| g.i(74)).unwrap_or(0), + } + } +} + +/// An ATTRIB (an attribute of a block reference) or ATTDEF (its definition in the block). +#[derive(Clone, Debug, PartialEq)] +pub struct Attrib { + /// Group 2. + pub tag: String, + pub text: Text, + /// Group 3 of an ATTDEF, decoded; empty in an ATTRIB. + pub prompt: String, + /// Group 70: 1 invisible, 2 constant, 4 verify, 8 preset. + pub flags: i64, + /// The 280 after the tag in the attribute's subclass (one before the tag is the subclass + /// version). + pub lock_position: bool, + /// The embedded MTEXT of a multiline attribute. + pub multiline: Option, +} + +impl Attrib { + pub fn read(kind: &str, tags: &[Tag]) -> Attrib { + // A multiline attribute has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(tags); + let g = Groups(own); + let sub = own.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.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)); + Attrib { + tag: g.s(2).unwrap_or_default(), + text: Text::read(&g), + prompt: if kind == "ATTDEF" { decode_line(&g.s(3).unwrap_or_default()) } else { String::new() }, + flags: g.i_or(70, 0), + lock_position, + multiline: embedded.filter(|e| !e.is_empty()).map(MText::read), + } + } +} + +/// An INSERT: a block reference, with its attributes. +#[derive(Clone, Debug, PartialEq)] +pub struct Insert { + /// Group 2. + pub block: String, + pub insert: Point, + /// Groups 41, 42 and 43 (1 when absent). + pub scale: Point, + /// Group 50, in degrees. + pub rotation: f64, + /// Groups 70 and 71 (1 when absent or less). + pub columns: i64, + pub rows: i64, + /// Groups 44 and 45. + pub column_spacing: f64, + pub row_spacing: f64, + /// The ATTRIB records that follow, each with its own properties. + pub attribs: Vec<(Common, Attrib)>, +} + +/// A DIMENSION. The geometry it is drawn with is in its block ([`Dimension::block`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Dimension { + /// Group 70: the type in the low bits (0 rotated, 1 aligned, 2 angular, 3 diameter, 4 radius, + /// 5 angular 3-point, 6 ordinate), 32 the block is this dimension's alone, 64 an ordinate's + /// X type, 128 the text is at a user position. + pub flags: i64, + /// Group 2: the block that holds the dimension's picture. + pub block: Option, + /// Group 3 (`Standard` when absent). + pub style: String, + /// Group 10: the definition point. + pub defpt: Point, + /// Group 11: the middle of the text, in the object coordinate system. + pub text_mid: Point, + /// Group 12: the insertion point for clones (baseline and continue). + pub insertion: Point, + /// Groups 13 to 16: the definition points of the type. + pub p13: Point, + pub p14: Point, + pub p15: Point, + pub p16: Point, + /// Group 1, decoded: empty or `<>` for the measurement, a space for none. + pub text: String, + /// Group 42: the measurement, when the file states it. + pub measurement: Option, + /// Group 50: the angle of a rotated dimension, in degrees. + pub rotation: f64, + /// Group 52: the oblique angle of the extension lines, in degrees. + pub oblique: f64, + /// Group 53: the text's rotation, in degrees. + pub text_rotation: f64, + /// Group 51: the horizontal direction, in degrees. + pub horizontal: f64, + /// Group 40: the leader length of a radius or diameter dimension. + pub leader_length: f64, +} + +impl Dimension { + /// The type: the low four bits of the flags. + pub fn kind(&self) -> i64 { + self.flags & 0x0f + } +} + +/// A LEADER. +#[derive(Clone, Debug, PartialEq)] +pub struct Leader { + pub vertices: Vec, + /// Group 71 is not 0. + pub arrow: bool, + /// Group 72 is not 0: a spline path. + pub spline: bool, + /// Group 3 (`Standard` when absent). + pub style: String, + /// Group 73: what the leader was made with (0 text, 1 a tolerance, 2 a block, 3 nothing). + pub annotation_type: i64, + /// Group 340: the annotation. + pub annotation: Option, +} + +/// A paper space VIEWPORT. +#[derive(Clone, Debug, PartialEq)] +pub struct Viewport { + /// Group 10: the centre on the sheet. + pub center: Point, + /// Groups 40 and 41: the size on the sheet. + pub width: f64, + pub height: f64, + /// Group 69: the id (1 is the sheet's own view). Other programs write -1 for unknown. + pub id: i64, + /// Group 90: the status flags (16384 locked, 65536 clipped by the entity of group 340, + /// 131072 off). + pub status: i64, + /// Groups 12/22: the view centre in display coordinates, whose origin is the target. + pub view_center: [f64; 2], + /// Groups 16/26/36 and 17/27/37. + pub view_direction: Point, + pub view_target: Point, + /// Group 45. + pub view_height: f64, + /// Group 51: the twist, in degrees. + pub twist: f64, + /// Group 340: the entity that clips the viewport. + pub clip: Option, + /// Groups 331 and 341: the layers frozen in the viewport. + pub frozen_layers: Vec, +} + +/// An IMAGE or WIPEOUT: a raster's place and clip. The file is named by the IMAGEDEF object. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// Group 10: the lower-left corner. + pub insert: Point, + /// Groups 11 and 12: the vectors of one pixel along the image's width and height. + pub u: Point, + pub v: Point, + /// Groups 13/23: the size in pixels. + pub size: [f64; 2], + /// Group 340: the IMAGEDEF. + pub image_def: Option, + /// Group 70: 1 show, 2 show when not aligned with the screen, 4 use the clip, 8 transparency. + pub display: i64, + /// Group 280 is not 0. + pub clipping: bool, + /// Groups 281, 282 and 283 (50, 50 and 0 when absent). + pub brightness: i64, + pub contrast: i64, + pub fade: i64, + /// Group 71: 1 a rectangle (two corners), 2 a polygon. + pub clip_type: i64, + /// Groups 14/24: the clip boundary in pixels, with the origin at the image's top-left corner + /// and y down: a point `q` is at `insert + u * (q.x + 0.5) + v * (0.5 - q.y)`. + pub clip: Vec<[f64; 2]>, + /// Group 290 is not 0: the clip keeps the outside. + pub clip_inverted: bool, +} + +/// The geometry of an entity. +#[derive(Clone, Debug, PartialEq)] +pub enum EntityKind { + Line { + start: Point, + end: Point, + }, + /// `angle` is group 50: the X axis of the point's symbol, in degrees. + Point { + location: Point, + angle: f64, + }, + Circle { + center: Point, + radius: f64, + }, + Arc { + center: Point, + radius: f64, + start_angle: f64, + end_angle: f64, + }, + /// `major` is the end of the major axis from the centre; `start` and `end` are parameters in + /// radians (0 and 2π for a full ellipse). + Ellipse { + center: Point, + major: Point, + ratio: f64, + start: f64, + end: f64, + }, + LwPolyline(LwPolyline), + Polyline(Polyline), + Spline(Spline), + Ray { + base: Point, + direction: Point, + }, + XLine { + base: Point, + direction: Point, + }, + Text(Text), + MText(MText), + AttDef(Attrib), + Insert(Insert), + Dimension(Dimension), + Leader(Leader), + /// The fourth corner repeats the third when the file has three. + Solid { + corners: [Point; 4], + }, + Trace { + corners: [Point; 4], + }, + /// `hidden_edges` is group 70: bit n hides edge n. + Face3d { + corners: [Point; 4], + hidden_edges: i64, + }, + Hatch(Hatch), + Viewport(Viewport), + Image(Image), + Wipeout(Image), + /// `text` is the feature control frame's string, as written. + Tolerance { + insert: Point, + direction: Point, + style: String, + text: String, + }, + /// A type this module does not model (or a HATCH with no boundary data): read it from the + /// record's tags. + Other, +} + +/// An entity: its properties and geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct Entity { + pub common: Common, + pub kind: EntityKind, +} + +fn lwpolyline(tags: &[Tag], g: &Groups<'_>) -> LwPolyline { + let mut vertices: Vec = Vec::new(); + for t in tags { + if t.code == 10 { + vertices.push(LwVertex { x: t.f64(), ..LwVertex::default() }); + } else if let Some(v) = vertices.last_mut() { + match t.code { + 20 => v.y = t.f64(), + 40 => v.start_width = t.f64(), + 41 => v.end_width = t.f64(), + 42 => v.bulge = t.f64(), + _ => {} + } + } + } + let flags = g.i_or(70, 0); + LwPolyline { vertices, closed: flags & 1 != 0, plinegen: flags & 128 != 0, const_width: g.f_or(43, 0.0), elevation: g.f_or(38, 0.0) } +} + +fn polyline(g: &Groups<'_>, followers: &[Record<'_>]) -> Polyline { + let vertices = followers + .iter() + .map(|r| { + let v = r.groups(); + Vertex { + location: v.point(10), + bulge: v.f_or(42, 0.0), + start_width: v.f_or(40, 0.0), + end_width: v.f_or(41, 0.0), + flags: v.i_or(70, 0), + face: [71, 72, 73, 74].map(|c| v.i_or(c, 0)), + } + }) + .collect(); + Polyline { + flags: g.i_or(70, 0), + elevation: g.f_or(30, 0.0), + start_width: g.f_or(40, 0.0), + end_width: g.f_or(41, 0.0), + m: g.i_or(71, 0), + n: g.i_or(72, 0), + smooth: g.i_or(75, 0), + vertices, + } +} + +fn spline(g: &Groups<'_>) -> Spline { + Spline { + degree: g.i_or(71, 3), + flags: g.i_or(70, 0), + knots: g.all_f(40), + control: g.points(10), + weights: g.all_f(41), + fit: g.points(11), + start_tangent: g.point_opt(12), + end_tangent: g.point_opt(13), + fit_tolerance: g.f_or(44, 0.0), + } +} + +fn insert(g: &Groups<'_>, followers: &[Record<'_>]) -> Insert { + Insert { + block: g.s(2).unwrap_or_default(), + insert: g.point(10), + scale: [g.f_or(41, 1.0), g.f_or(42, 1.0), g.f_or(43, 1.0)], + rotation: g.f_or(50, 0.0), + columns: g.i_or(70, 1).max(1), + rows: g.i_or(71, 1).max(1), + column_spacing: g.f_or(44, 0.0), + row_spacing: g.f_or(45, 0.0), + attribs: followers.iter().map(|r| (Common::read(r.tags), Attrib::read("ATTRIB", r.tags))).collect(), + } +} + +fn dimension(g: &Groups<'_>) -> Dimension { + Dimension { + flags: g.i_or(70, 0), + block: g.name(2), + style: g.name(3).unwrap_or_else(|| "Standard".into()), + defpt: g.point(10), + text_mid: g.point(11), + insertion: g.point(12), + p13: g.point(13), + p14: g.point(14), + p15: g.point(15), + p16: g.point(16), + text: decode_line(&g.s(1).unwrap_or_default()), + measurement: g.f(42), + rotation: g.f_or(50, 0.0), + oblique: g.f_or(52, 0.0), + text_rotation: g.f_or(53, 0.0), + horizontal: g.f_or(51, 0.0), + leader_length: g.f_or(40, 0.0), + } +} + +fn viewport(tags: &[Tag], g: &Groups<'_>) -> Viewport { + Viewport { + center: g.point(10), + width: g.f_or(40, 0.0), + height: g.f_or(41, 0.0), + id: g.i_or(69, 0), + status: g.i_or(90, 0), + view_center: [g.f_or(12, 0.0), g.f_or(22, 0.0)], + view_direction: g.point_opt(16).unwrap_or([0.0, 0.0, 1.0]), + view_target: g.point(17), + view_height: g.f_or(45, 0.0), + twist: g.f_or(51, 0.0), + clip: g.handle(340).filter(|h| *h != 0), + frozen_layers: tags.iter().filter(|t| matches!(t.code, 331 | 341)).filter_map(|t| parse_handle(&t.str())).collect(), + } +} + +fn image(g: &Groups<'_>) -> Image { + Image { + insert: g.point(10), + u: g.point(11), + v: g.point(12), + size: [g.f_or(13, 0.0), g.f_or(23, 0.0)], + image_def: g.handle(340).filter(|h| *h != 0), + display: g.i_or(70, 0), + clipping: g.i_or(280, 0) != 0, + brightness: g.i_or(281, 50), + contrast: g.i_or(282, 50), + fade: g.i_or(283, 0), + clip_type: g.i_or(71, 1), + clip: g.points(14).into_iter().map(|p| [p[0], p[1]]).collect(), + clip_inverted: g.i_or(290, 0) != 0, + } +} + +fn corners(g: &Groups<'_>) -> [Point; 4] { + [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or_else(|| g.point(12))] +} + +impl Entity { + /// The typed form of an entity record. + pub fn read(e: &EntityRecord<'_>) -> Entity { + let g = e.groups(); + let line = |g: &Groups<'_>| (g.point(10), g.point(11)); + let kind = match e.kind { + "LINE" => EntityKind::Line { start: line(&g).0, end: line(&g).1 }, + "POINT" => EntityKind::Point { location: g.point(10), angle: g.f_or(50, 0.0) }, + "CIRCLE" => EntityKind::Circle { center: g.point(10), radius: g.f_or(40, 0.0) }, + "ARC" => EntityKind::Arc { center: g.point(10), radius: g.f_or(40, 0.0), start_angle: g.f_or(50, 0.0), end_angle: g.f_or(51, 360.0) }, + "ELLIPSE" => EntityKind::Ellipse { + center: g.point(10), + major: g.point(11), + ratio: g.f_or(40, 1.0), + start: g.f_or(41, 0.0), + end: g.f_or(42, std::f64::consts::TAU), + }, + "LWPOLYLINE" => EntityKind::LwPolyline(lwpolyline(e.tags, &g)), + "POLYLINE" => EntityKind::Polyline(polyline(&g, &e.followers)), + "SPLINE" => EntityKind::Spline(spline(&g)), + "RAY" => EntityKind::Ray { base: line(&g).0, direction: line(&g).1 }, + "XLINE" => EntityKind::XLine { base: line(&g).0, direction: line(&g).1 }, + "TEXT" => EntityKind::Text(Text::read(&g)), + "MTEXT" => EntityKind::MText(MText::read(e.tags)), + "ATTDEF" => EntityKind::AttDef(Attrib::read("ATTDEF", e.tags)), + "INSERT" => EntityKind::Insert(insert(&g, &e.followers)), + "DIMENSION" => EntityKind::Dimension(dimension(&g)), + "LEADER" => EntityKind::Leader(Leader { + vertices: g.points(10), + arrow: g.i_or(71, 1) != 0, + spline: g.i_or(72, 0) != 0, + style: g.name(3).unwrap_or_else(|| "Standard".into()), + annotation_type: g.i_or(73, 3), + annotation: g.handle(340).filter(|h| *h != 0), + }), + "SOLID" => EntityKind::Solid { corners: corners(&g) }, + "TRACE" => EntityKind::Trace { corners: corners(&g) }, + "3DFACE" => EntityKind::Face3d { corners: corners(&g), hidden_edges: g.i_or(70, 0) }, + "HATCH" => Hatch::read(e.tags).map_or(EntityKind::Other, EntityKind::Hatch), + "VIEWPORT" => EntityKind::Viewport(viewport(e.tags, &g)), + "IMAGE" => EntityKind::Image(image(&g)), + "WIPEOUT" => EntityKind::Wipeout(image(&g)), + "TOLERANCE" => EntityKind::Tolerance { + insert: g.point(10), + direction: g.point_opt(11).unwrap_or([1.0, 0.0, 0.0]), + style: g.name(3).unwrap_or_else(|| "Standard".into()), + text: g.s(1).unwrap_or_default(), + }, + _ => EntityKind::Other, + }; + Entity { common: Common::read(e.tags), kind } + } +} diff --git a/crates/dxf/src/model/hatch.rs b/crates/dxf/src/model/hatch.rs new file mode 100644 index 000000000..ac3425622 --- /dev/null +++ b/crates/dxf/src/model/hatch.rs @@ -0,0 +1,355 @@ +//! HATCH: the boundary loops (polylines or edges), the pattern definition, the seed points and +//! the gradient, as the file holds them. Boundary points are 2D, in the object coordinate system +//! of the hatch at its elevation. + +use super::{Color, Groups, parse_handle}; +use crate::Tag; + +/// One edge of a boundary loop made of edges. +#[derive(Clone, Debug, PartialEq)] +pub enum HatchEdge { + Line { + start: [f64; 2], + end: [f64; 2], + }, + /// Angles in degrees; a clockwise arc (`ccw` false) has them measured clockwise. + Arc { + center: [f64; 2], + radius: f64, + start_angle: f64, + end_angle: f64, + ccw: bool, + }, + /// `major` is the end of the major axis from the centre, `ratio` the minor axis's length + /// over the major's; angles in degrees, as for an arc. + Ellipse { + center: [f64; 2], + major: [f64; 2], + ratio: f64, + start_angle: f64, + end_angle: f64, + ccw: bool, + }, + Spline { + degree: i64, + rational: bool, + periodic: bool, + knots: Vec, + control: Vec<[f64; 2]>, + /// One per control point when rational; else empty. + weights: Vec, + fit: Vec<[f64; 2]>, + start_tangent: Option<[f64; 2]>, + end_tangent: Option<[f64; 2]>, + }, +} + +/// The path of a boundary loop. +#[derive(Clone, Debug, PartialEq)] +pub enum HatchPath { + /// Vertices as x, y and bulge. + Polyline { + closed: bool, + vertices: Vec<[f64; 3]>, + }, + Edges(Vec), +} + +/// A boundary loop. +#[derive(Clone, Debug, PartialEq)] +pub struct HatchLoop { + /// Group 92: 1 external, 2 a polyline, 4 derived, 8 a text box, 16 outermost. + pub flags: i64, + pub path: HatchPath, + /// Groups 330 after the loop's 97: the entities an associative hatch follows. + pub sources: Vec, +} + +/// One line family of a pattern definition, as drawn: scaled and rotated with the hatch. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct PatternLine { + /// Group 53, in degrees. + pub angle: f64, + /// Groups 43/44: a point the first line passes through. + pub base: [f64; 2], + /// Groups 45/46: the step from one line to the next, as a vector. + pub offset: [f64; 2], + /// Groups 49: dash (positive), gap (negative) and dot (0) lengths; empty for a solid line. + pub dashes: Vec, +} + +/// A gradient fill (groups 450–470). +#[derive(Clone, Debug, PartialEq)] +pub struct Gradient { + /// Group 470 (`LINEAR`, `CYLINDER`, `SPHERICAL`...). + pub name: String, + /// Group 460, in radians. + pub angle: f64, + /// Group 461: 0 centred, 1 shifted up and left. + pub shift: f64, + /// Group 452 is 1: one colour, shaded by `tint` (group 462). + pub single: bool, + pub tint: f64, + /// The colour of each 463 record: its true colour (421), else its index (63). + pub colors: Vec, +} + +/// A HATCH. +#[derive(Clone, Debug, PartialEq)] +pub struct Hatch { + /// Group 2, as written (`SOLID` when absent). + pub pattern: String, + /// Group 70 is 1. + pub solid: bool, + /// Group 71 is not 0. + pub associative: bool, + /// Group 75: 0 normal (odd parity), 1 outer, 2 ignore islands. + pub style: i64, + /// Group 76: 0 user-defined, 1 predefined, 2 custom. + pub pattern_type: i64, + /// Group 52, in degrees. + pub angle: f64, + /// Group 41 (1 when absent). + pub scale: f64, + /// Group 77 is not 0: a user-defined pattern is doubled (crossed). + pub double: bool, + /// The z of the elevation point (the first group 30). + pub elevation: f64, + pub loops: Vec, + pub pattern_lines: Vec, + /// Groups 10/20 after the 98 count. + pub seeds: Vec<[f64; 2]>, + pub gradient: Option, +} + +/// A cursor over a record's tags, for the boundary data, which is read in order. +struct Cur<'a> { + tags: &'a [Tag], + at: usize, +} + +impl<'a> Cur<'a> { + fn peek(&self) -> Option<&'a Tag> { + self.tags.get(self.at) + } + /// The next tag when its code is `code`, consumed. + fn take(&mut self, code: i32) -> Option<&'a Tag> { + let t = self.peek().filter(|t| t.code == code)?; + self.at += 1; + Some(t) + } + fn f(&mut self, code: i32) -> f64 { + self.take(code).map_or(0.0, Tag::f64) + } + fn xy(&mut self, code: i32) -> [f64; 2] { + [self.f(code), self.f(code + 10)] + } + /// Move to the next `code`; false when there is none. + fn seek(&mut self, code: i32) -> bool { + while self.peek().is_some_and(|t| t.code != code) { + self.at += 1; + } + self.peek().is_some() + } +} + +/// A declared count: never trusted for an allocation, only as the most items to read. +fn count(t: &Tag) -> usize { + usize::try_from(t.i64()).unwrap_or(0) +} + +fn polyline_path(c: &mut Cur<'_>) -> Option { + // 72 has bulges, 73 closed, 93 the vertex count. + let mut closed = false; + while let Some(t) = c.peek().filter(|t| t.code != 93) { + if t.code == 73 { + closed = t.i64() != 0; + } else if t.code != 72 { + return None; + } + c.at += 1; + } + let n = count(c.take(93)?); + let mut vertices = Vec::new(); + while vertices.len() < n && c.peek().is_some_and(|t| t.code == 10) { + let [x, y] = c.xy(10); + vertices.push([x, y, c.f(42)]); + } + Some(HatchPath::Polyline { closed, vertices }) +} + +fn spline_edge(c: &mut Cur<'_>) -> HatchEdge { + let (mut degree, mut rational, mut periodic, mut knot_count, mut control_count) = (3, false, false, 0, 0); + while let Some(t) = c.peek().filter(|t| matches!(t.code, 94 | 73 | 74 | 95 | 96)) { + match t.code { + 94 => degree = t.i64(), + 73 => rational = t.i64() != 0, + 74 => periodic = t.i64() != 0, + 95 => knot_count = count(t), + _ => control_count = count(t), + } + c.at += 1; + } + let mut knots = Vec::new(); + while knots.len() < knot_count && c.peek().is_some_and(|t| t.code == 40) { + knots.push(c.f(40)); + } + let (mut control, mut weights) = (Vec::new(), Vec::new()); + while control.len() < control_count && c.peek().is_some_and(|t| t.code == 10) { + control.push(c.xy(10)); + weights.extend(c.take(42).map(Tag::f64)); + } + if weights.len() != control.len() { + weights.clear(); + } + // A 97 here is the edge's fit point count (R2010 and later) when fit points follow it, or + // when it is 0 and the loop's own 97 comes next; otherwise it is the loop's source count. + let mut fit = Vec::new(); + let next = c.tags.get(c.at + 1).map(|t| t.code); + let fit_count = c.peek().filter(|t| t.code == 97 && (next == Some(11) || (t.i64() == 0 && next == Some(97)))); + if let Some(n) = fit_count.map(count) { + c.at += 1; + while fit.len() < n && c.peek().is_some_and(|t| t.code == 11) { + fit.push(c.xy(11)); + } + } + let tangent = |c: &mut Cur<'_>, code: i32| c.peek().is_some_and(|t| t.code == code).then(|| c.xy(code)); + let start_tangent = tangent(c, 12); + let end_tangent = tangent(c, 13); + HatchEdge::Spline { degree, rational, periodic, knots, control, weights, fit, start_tangent, end_tangent } +} + +fn edge_path(c: &mut Cur<'_>) -> Option { + if !c.seek(93) { + return None; + } + let n = count(c.take(93)?); + let mut edges = Vec::new(); + while edges.len() < n { + // Groups this reader does not know may sit between edges; the loop's own end may not. + while c.peek().is_some_and(|t| !matches!(t.code, 72 | 97 | 92 | 75)) { + c.at += 1; + } + if c.peek().is_none_or(|t| t.code != 72) { + break; + } + let kind = c.take(72).map_or(0, Tag::i64); + // 73 is counter-clockwise unless it says 0. + let ccw = |c: &mut Cur<'_>| c.take(73).is_none_or(|t| t.i64() != 0); + edges.push(match kind { + 1 => HatchEdge::Line { start: c.xy(10), end: c.xy(11) }, + 2 => HatchEdge::Arc { center: c.xy(10), radius: c.f(40), start_angle: c.f(50), end_angle: c.f(51), ccw: ccw(c) }, + 3 => HatchEdge::Ellipse { center: c.xy(10), major: c.xy(11), ratio: c.f(40), start_angle: c.f(50), end_angle: c.f(51), ccw: ccw(c) }, + 4 => spline_edge(c), + // An edge type the format does not have: its groups are passed over above. + _ => continue, + }); + } + Some(HatchPath::Edges(edges)) +} + +fn pattern_lines(tags: &[Tag]) -> Vec { + let Some(at) = tags.iter().position(|t| t.code == 78) else { return Vec::new() }; + let mut lines: Vec = Vec::new(); + for t in tags.iter().skip(at + 1) { + if t.code == 53 { + lines.push(PatternLine { angle: t.f64(), ..PatternLine::default() }); + continue; + } + let Some(l) = lines.last_mut() else { break }; + match t.code { + 43 => l.base[0] = t.f64(), + 44 => l.base[1] = t.f64(), + 45 => l.offset[0] = t.f64(), + 46 => l.offset[1] = t.f64(), + 79 => {} + 49 => l.dashes.push(t.f64()), + _ => break, + } + } + lines +} + +/// Groups 450–470, when 450 says gradient. Each 463 starts a colour record whose 63 (index) and +/// 421 (true colour) follow. +fn gradient(tags: &[Tag]) -> Option { + let start = tags.iter().position(|t| t.code == 450)?; + if tags.get(start)?.i64() != 1 { + return None; + } + let mut g = Gradient { name: String::new(), angle: 0.0, shift: 0.0, single: false, tint: 0.0, colors: Vec::new() }; + for t in tags.iter().skip(start + 1).take_while(|t| t.code != 1001) { + match t.code { + 452 => g.single = t.i64() == 1, + 460 => g.angle = t.f64(), + 461 => g.shift = t.f64(), + 462 => g.tint = t.f64(), + 463 => g.colors.push(Color::ByLayer), + 63 => { + // A true colour already read for this record wins over its index. + if let Some(c) = g.colors.last_mut().filter(|c| !matches!(c, Color::Rgb(_))) { + *c = Color::from_index(t.i64()); + } + } + 421 => { + if let Some(c) = g.colors.last_mut() { + *c = Color::from_true(t.i64()); + } + } + 470 => g.name = t.str(), + _ => {} + } + } + Some(g) +} + +impl Hatch { + /// The typed form of a HATCH record; `None` when it has no boundary data (no group 91) or + /// the data ends inside a loop. + pub fn read(tags: &[Tag]) -> Option { + let g = Groups(tags); + let n_loops = count(tags.iter().find(|t| t.code == 91)?); + let mut c = Cur { tags, at: tags.iter().position(|t| t.code == 91)? + 1 }; + let mut loops = Vec::new(); + while loops.len() < n_loops { + if !c.seek(92) { + return None; + } + let flags = c.take(92)?.i64(); + let path = if flags & 2 != 0 { polyline_path(&mut c)? } else { edge_path(&mut c)? }; + // 97 counts the source entities (330) that follow. + let mut sources = Vec::new(); + if let Some(n) = c.take(97).map(count) { + while sources.len() < n + && let Some(t) = c.take(330) + { + sources.extend(parse_handle(&t.str()).filter(|h| *h != 0)); + } + } + loops.push(HatchLoop { flags, path, sources }); + } + let mut seeds = Vec::new(); + if c.seek(98) { + let n = c.take(98).map_or(0, count); + while seeds.len() < n && c.peek().is_some_and(|t| t.code == 10) { + seeds.push(c.xy(10)); + } + } + let pattern = g.name(2).unwrap_or_else(|| "SOLID".into()); + Some(Hatch { + solid: g.i_or(70, 0) == 1, + associative: g.i_or(71, 0) != 0, + style: g.i_or(75, 0), + pattern_type: g.i_or(76, 1), + angle: g.f_or(52, 0.0), + scale: g.f_or(41, 1.0), + double: g.i_or(77, 0) != 0, + elevation: g.f_or(30, 0.0), + loops, + pattern_lines: pattern_lines(tags), + seeds, + gradient: gradient(tags), + pattern, + }) + } +} diff --git a/crates/dxf/src/model/text.rs b/crates/dxf/src/model/text.rs new file mode 100644 index 000000000..ffafae96d --- /dev/null +++ b/crates/dxf/src/model/text.rs @@ -0,0 +1,280 @@ +//! Text in DXF: the caret encoding of control characters in strings, and MTEXT with its columns. +//! +//! Control characters are written as a caret and a letter (`^I` a tab, `^J` a line feed, `^ ` a +//! caret). TEXT, ATTDEF, ATTRIB and DIMENSION strings use the same encoding as MTEXT contents, +//! without line feeds: AutoCAD reads a bare `^2` in `10^2` as a control character. +//! +//! MTEXT columns are groups 75 (type), 76 (count), 78 (flow reversed), 79 (auto height), 48 +//! (width), 49 (gutter) and 50 (column heights, after the column type; before it, 50 is the +//! rotation) before R2018, and an embedded object (group 101) from R2018 on. + +use super::{Groups, Point, outside_app}; +use crate::Tag; + +/// Decode MTEXT contents: `^I` (tab), `^J` (line feed) and `^ ` (caret). Other carets stay as +/// they are (older files write stacked tolerances `\S+0.1^-0.1;` with a bare caret). +pub fn decode_contents(s: &str) -> String { + decode(s, true) +} + +/// A single-line string as read: [`decode_contents`] without line feeds (`^J` stays as it is). +pub fn decode_line(s: &str) -> String { + decode(s, false) +} + +fn decode(s: &str, line_feeds: bool) -> String { + if !s.contains('^') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.peek().copied() { + Some(' ') => out.push('^'), + Some('I') => out.push('\t'), + Some('J') if line_feeds => out.push('\n'), + _ => { + out.push('^'); + continue; + } + } + it.next(); + } + out +} + +/// A single-line string (TEXT, ATTDEF/ATTRIB, DIMENSION) for a DXF file: a caret as `^ ` and a +/// tab as `^I`, read back by [`decode_line`]. It must hold no line break. +pub fn encode_line(s: &str) -> String { + s.replace('^', "^ ").replace('\t', "^I") +} + +/// Encode MTEXT contents: a tab as `^I`, a line feed as `^J`, a caret as `^ `; other control +/// characters (which draw nothing) are dropped. +pub fn encode_contents(s: &str) -> String { + if !s.chars().any(|c| c == '^' || c.is_control()) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + 8); + for c in s.chars() { + match c { + '^' => out.push_str("^ "), + '\t' => out.push_str("^I"), + '\n' => out.push_str("^J"), + c if c.is_control() => {} + c => out.push(c), + } + } + out +} + +/// Apply `f` ([`encode_line`] or [`decode_line`]) to the groups of a `kind` record that hold an +/// encoded string: the text (1), and an ATTDEF's prompt (3). An embedded MTEXT (from group 101) +/// has its own and is left alone. +pub fn map_text_groups(kind: &str, tags: &mut [Tag], f: fn(&str) -> String) { + let kind = kind.to_ascii_uppercase(); + if !matches!(kind.as_str(), "TEXT" | "ATTRIB" | "ATTDEF" | "DIMENSION") { + return; + } + for t in tags.iter_mut().take_while(|t| t.code != 101) { + if t.code == 1 || (t.code == 3 && kind == "ATTDEF") { + *t = Tag::s(t.code, f(&t.str())); + } + } +} + +/// A record's groups split at its embedded object (`101 Embedded Object`): its own groups and, +/// when it has one, the object's. +pub 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), + } +} + +/// Upper bound on the column heights read from one MTEXT. +pub const MAX_COLUMNS: usize = 1_000; + +/// The columns of an MTEXT. +#[derive(Clone, Debug, PartialEq)] +pub struct MTextColumns { + /// Dynamic columns (type 2): the text flows into as many as it needs. Otherwise static + /// (type 1). + pub dynamic: bool, + pub count: u16, + /// One column's width (the MTEXT's own width is the total in some writers' files). + pub width: f64, + pub gutter: f64, + /// The column height: the tallest for manual heights. + pub height: f64, + pub auto_height: bool, + /// Each column's height, for dynamic columns with manual heights; else empty. + pub heights: Vec, + pub flow_reversed: bool, +} + +fn pos(v: f64) -> f64 { + v.max(0.0) +} + +impl MTextColumns { + /// The columns of an MTEXT record, from the legacy groups or the embedded object. + pub fn read(tags: &[Tag]) -> Option { + let (own, embedded) = split_embedded(tags); + // An application-defined group in the record may hold any code, 50 included. + let own_tags: Vec<&Tag> = outside_app(own).map(|(_, t)| t).collect(); + let Some(start) = own_tags.iter().position(|t| t.code == 75) else { return Self::read_embedded(own, embedded?) }; + let dynamic = match own_tags.get(start).map(|t| t.i64()) { + Some(1) => false, + Some(2) => true, + _ => return None, + }; + let cols = own_tags.get(start..).unwrap_or_default(); + let first = |code: i32| cols.iter().find(|t| t.code == code).copied(); + // The first 50 is the number of heights that follow, unless the file has the heights + // of group 76 alone. + let fifties = cols.iter().filter(|t| t.code == 50); + let counted = first(76).is_some_and(|t| i64::try_from(fifties.clone().count()).is_ok_and(|n| n - 1 == t.i64())); + let heights: Vec = fifties.skip(usize::from(counted)).take(MAX_COLUMNS).map(|t| pos(t.f64())).collect(); + let count = first(76).map_or(heights.len().max(1), |t| usize::try_from(t.i64()).unwrap_or(1)).clamp(1, MAX_COLUMNS); + let auto_height = first(79).is_some_and(|t| t.i64() != 0); + let defined = own_tags.iter().find(|t| t.code == 46).map_or(0.0, |t| pos(t.f64())); + let manual = dynamic && !auto_height; + let height = + if manual { heights.iter().copied().fold(0.0, f64::max) } else { heights.first().copied().filter(|h| *h > 0.0).unwrap_or(defined) }; + Some(MTextColumns { + dynamic, + count: u16::try_from(count).unwrap_or(1), + width: first(48).map_or(0.0, |t| pos(t.f64())), + gutter: first(49).map_or(0.0, |t| pos(t.f64())), + height, + auto_height, + heights: if manual { heights } else { Vec::new() }, + flow_reversed: first(78).is_some_and(|t| t.i64() != 0), + }) + } + + /// The columns of an R2018 MTEXT, from its embedded object, whose groups reuse MTEXT's + /// codes: 71 type, 72 count, 44 column width, 45 gutter, 73 auto height, 74 flow reversed, + /// 46 heights. + #[expect(clippy::cast_possible_truncation, reason = "the rounded count is clamped to 1..=MAX_COLUMNS before use")] + fn read_embedded(own: &[Tag], obj: &[Tag]) -> Option { + let first = |code: i32| obj.iter().find(|t| t.code == code); + let dynamic = match first(71).map(Tag::i64) { + Some(1) => false, + Some(2) => true, + _ => return None, + }; + let heights: Vec = obj.iter().filter(|t| t.code == 46).take(MAX_COLUMNS).map(|t| pos(t.f64())).collect(); + let auto_height = first(73).is_some_and(|t| t.i64() != 0); + let manual = dynamic && !auto_height; + // The column height: the entity's own 46 (defined height), else the object's 41 or first 46. + let own_46 = own.iter().find(|t| t.code == 46).map(|t| pos(t.f64())); + let defined = [own_46, first(41).map(Tag::f64), heights.first().copied()].into_iter().flatten().find(|h| *h > 0.0).unwrap_or(0.0); + // A dynamic auto-height object stores 0 columns in 72: the count follows from the total + // width (42 = n * 44 + (n - 1) * 45). + let [width, gutter, total] = [44, 45, 42].map(|g| first(g).map_or(0.0, Tag::f64)); + let count = match first(72).map(Tag::i64) { + Some(n) if n > 0 => n, + Some(_) if width + gutter > 0.0 && total > 0.0 => ((total + gutter) / (width + gutter)).round().clamp(1.0, MAX_COLUMNS as f64) as i64, + _ => 1, + }; + Some(MTextColumns { + dynamic, + count: u16::try_from(count.clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), + width: pos(width), + gutter: pos(gutter), + height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { defined }, + auto_height, + heights: if manual { heights } else { Vec::new() }, + flow_reversed: first(74).is_some_and(|t| t.i64() != 0), + }) + } +} + +/// Multiline text: an MTEXT entity, or the one embedded in a multiline attribute. +#[derive(Clone, Debug, PartialEq)] +pub struct MText { + /// Group 10. + pub insert: Point, + /// Group 40: the text height. + pub height: f64, + /// Group 41: the reference rectangle's width (0: none). With columns, see + /// [`MTextColumns::width`]. + pub width: f64, + /// Group 71: 1–9, top left to bottom right. + pub attach: u8, + /// Group 72: 1 left to right, 3 top to bottom, 5 by style. + pub drawing_direction: i64, + /// Groups 11/21/31: the X axis direction, which wins over the rotation when given and not + /// zero. + pub direction: Option, + /// Group 50, in degrees. + pub rotation: f64, + /// Group 7 (`Standard` when absent). + pub style: String, + /// The contents (the group 3 chunks, then group 1), decoded ([`decode_contents`]); the + /// formatting codes (`\P`, `\f...;`) are left as written. + pub contents: String, + /// Group 44: the line spacing factor (1 when absent). + pub line_spacing: f64, + /// Group 73 is 2: the spacing is exact, not "at least". + pub line_spacing_exact: bool, + /// Group 90: the background fill flags (1 a fill colour, 2 the window's colour, 16 a + /// frame), with the fill colour (63, 421) and the border offset factor (45). + pub background: Option<(i64, super::Color, f64)>, + pub columns: Option, +} + +impl MText { + /// The typed form of MTEXT groups. + pub fn read(tags: &[Tag]) -> MText { + let columns = MTextColumns::read(tags); + // The MTEXT's own groups: an embedded object reuses their codes (44 is its column + // width, not the line spacing factor). + let (own, _) = split_embedded(tags); + let g = Groups(own); + let mut contents = String::new(); + for t in own.iter().filter(|t| t.code == 3) { + contents.push_str(&t.str()); + } + contents.push_str(&g.s(1).unwrap_or_default()); + // The direction and rotation come from before the column groups, where 50 is a height. + let head = Groups(own.get(..own.iter().position(|t| t.code == 75).unwrap_or(own.len())).unwrap_or(own)); + let background = g.i(90).filter(|f| *f != 0).map(|f| { + let color = match (g.i(421), g.i(63)) { + (Some(tc), _) => super::Color::from_true(tc), + (None, Some(v)) => super::Color::from_index(v), + (None, None) => super::Color::ByLayer, + }; + (f, color, g.f_or(45, 1.5)) + }); + MText { + insert: g.point(10), + height: g.f_or(40, 0.0), + width: pos(g.f_or(41, 0.0)), + attach: u8::try_from(g.i_or(71, 1).clamp(1, 9)).unwrap_or(1), + drawing_direction: g.i_or(72, 1), + direction: head.point_opt(11).filter(|d| d[0] != 0.0 || d[1] != 0.0 || d[2] != 0.0), + rotation: head.f_or(50, 0.0), + style: g.name(7).unwrap_or_else(|| "Standard".into()), + contents: decode_contents(&contents), + line_spacing: g.f_or(44, 1.0), + line_spacing_exact: g.i(73) == Some(2), + background, + columns, + } + } + /// The angle of the text's X axis in radians: of the direction when the record has one, + /// else the rotation. + pub fn angle(&self) -> f64 { + match self.direction { + Some(d) if d[0] != 0.0 || d[1] != 0.0 => d[1].atan2(d[0]), + _ => self.rotation.to_radians(), + } + } +} diff --git a/crates/dxf/tests/model_entities.rs b/crates/dxf/tests/model_entities.rs new file mode 100644 index 000000000..5de59761b --- /dev/null +++ b/crates/dxf/tests/model_entities.rs @@ -0,0 +1,393 @@ +//! The drawing model's entities: common properties, geometry, text, hatches, dimensions. + +use craft_dxf::model::text::{MTextColumns, decode_contents, decode_line, encode_contents, encode_line, map_text_groups}; +use craft_dxf::model::{Color, Document, Entity, EntityKind, HatchEdge, HatchPath, Lineweight, Transparency, entity_records, records}; +use craft_dxf::{Tag, parse}; + +fn tags(text: &str) -> Vec { + let lines: Vec<&str> = text.split(['\n', '|']).map(str::trim).filter(|l| !l.is_empty()).map(|l| if l == "~" { "" } else { l }).collect(); + parse((lines.join("\n") + "\n").as_bytes()).expect("the test drawing parses") +} + +/// The entities of `text`, the records of an ENTITIES section. +fn entities(text: &str) -> Vec { + let t = tags(text); + let (recs, strays) = entity_records(&records(&t)); + assert_eq!(strays, 0); + recs.iter().map(Entity::read).collect() +} + +fn one(text: &str) -> Entity { + let mut e = entities(text); + assert_eq!(e.len(), 1); + e.remove(0) +} + +#[test] +fn common_properties() { + let c = one("0|LINE|5|2A|330|1F|100|AcDbEntity|8|Walls|6|DASHED|62|1|420|65280|370|50|48|2.5|60|1|67|1|440|33554687|390|B|100|AcDbLine|39|1.5|210|0.0|220|0.0|230|-1.0").common; + assert_eq!((c.handle, c.owner, c.layer.as_str(), c.linetype.as_deref()), (Some(0x2A), Some(0x1F), "Walls", Some("DASHED"))); + assert_eq!((c.color, c.lineweight, c.ltscale), (Color::Rgb([0, 255, 0]), Lineweight::Hundredths(50), 2.5)); + assert!(!c.visible && c.paper_space); + assert_eq!((c.transparency, c.thickness, c.extrusion, c.plot_style), (Transparency::Alpha(255), 1.5, [0.0, 0.0, -1.0], Some(0xB))); + let d = one("0|LINE").common; + assert_eq!((d.handle, d.owner, d.layer.as_str(), d.linetype.as_deref(), d.color), (None, None, "0", None, Color::ByLayer)); + assert_eq!((d.lineweight, d.ltscale, d.visible, d.paper_space, d.extrusion), (Lineweight::ByLayer, 1.0, true, false, [0.0, 0.0, 1.0])); + assert_eq!(one("0|LINE|6|BYLAYER|48|-1.0|62|-2").common, d, "ByLayer by name, a negative scale and a negative colour are the defaults"); +} + +#[test] +fn entity_properties_are_not_read_from_the_entitys_own_subclass() { + // An MTEXT's 48 is its column width, not the linetype scale, and a marker in an + // application's braces is the application's: it does not end the entity's groups. A record with no AcDbEntity marker has its + // layer wherever it is. + let c = one("0|MTEXT|102|{APP|100|AcDbMText|102|}|100|AcDbEntity|8|A|100|AcDbMText|48|7.0|62|3").common; + assert_eq!((c.ltscale, c.color, c.layer.as_str()), (1.0, Color::ByLayer, "A")); + let c = one("0|LINE|100|AcDbLine|8|B|62|3").common; + assert_eq!((c.color, c.layer.as_str()), (Color::Index(3), "B")); + // An embedded object ends the entity's groups. + assert_eq!(one("0|ATTDEF|8|C|101|Embedded Object|62|5").common.color, Color::ByLayer); +} + +#[test] +fn curves() { + let k = |text: &str| one(text).kind; + assert_eq!(k("0|LINE|10|1.0|20|2.0|30|3.0|11|4.0|21|5.0"), EntityKind::Line { start: [1.0, 2.0, 3.0], end: [4.0, 5.0, 0.0] }); + assert_eq!(k("0|POINT|10|1.0|50|30.0"), EntityKind::Point { location: [1.0, 0.0, 0.0], angle: 30.0 }); + assert_eq!(k("0|CIRCLE|10|1.0|20|2.0|40|3.0"), EntityKind::Circle { center: [1.0, 2.0, 0.0], radius: 3.0 }); + assert_eq!(k("0|ARC|10|1.0|40|3.0|50|10.0"), EntityKind::Arc { center: [1.0, 0.0, 0.0], radius: 3.0, start_angle: 10.0, end_angle: 360.0 }); + assert_eq!( + k("0|ELLIPSE|10|1.0|11|2.0|21|0.0|40|0.5|41|0.0|42|3.0"), + EntityKind::Ellipse { center: [1.0, 0.0, 0.0], major: [2.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: 3.0 } + ); + assert_eq!(k("0|RAY|10|1.0|11|0.0|21|1.0"), EntityKind::Ray { base: [1.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] }); + assert_eq!(k("0|XLINE|10|1.0|11|0.0|21|1.0"), EntityKind::XLine { base: [1.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] }); + let EntityKind::Spline(s) = k( + "0|SPLINE|70|8|71|2|40|0.0|40|0.0|40|1.0|10|0.0|20|0.0|10|1.0|20|1.0|30|5.0|10|2.0|41|1.0|41|2.0|41|1.0|11|9.0|21|9.0|12|1.0|22|0.0|44|0.01", + ) else { + panic!("a spline") + }; + assert_eq!((s.degree, s.flags, s.knots.len(), s.weights.as_slice(), s.fit_tolerance), (2, 8, 3, [1.0, 2.0, 1.0].as_slice(), 0.01)); + assert_eq!(s.control, [[0.0, 0.0, 0.0], [1.0, 1.0, 5.0], [2.0, 0.0, 0.0]], "the third point has no y or z of its own"); + assert_eq!((s.fit.as_slice(), s.start_tangent, s.end_tangent), ([[9.0, 9.0, 0.0]].as_slice(), Some([1.0, 0.0, 0.0]), None)); +} + +#[test] +fn polylines() { + let EntityKind::LwPolyline(p) = one("0|LWPOLYLINE|90|3|70|129|43|0.5|38|2.0|10|0.0|20|0.0|42|1.0|10|4.0|20|0.0|40|0.1|41|0.2|10|4.0|20|3.0").kind + else { + panic!("a lightweight polyline") + }; + assert!(p.closed && p.plinegen); + assert_eq!((p.const_width, p.elevation, p.vertices.len()), (0.5, 2.0, 3)); + assert_eq!((p.vertices[0].bulge, p.vertices[1].start_width, p.vertices[1].end_width, p.vertices[2].y), (1.0, 0.1, 0.2, 3.0)); + // A polyface mesh: three vertices and a face record that names them. + let e = one( + "0|POLYLINE|66|1|70|64|71|3|72|1|10|0.0|20|0.0|30|7.0|0|VERTEX|10|0.0|20|0.0|30|0.0|70|192|0|VERTEX|10|1.0|70|192|0|VERTEX|10|1.0|20|1.0|70|192|0|VERTEX|70|128|71|1|72|2|73|-3|0|SEQEND", + ); + let EntityKind::Polyline(m) = e.kind else { panic!("a polyline") }; + assert!(m.is_polyface() && !m.is_mesh() && !m.is_3d() && !m.closed()); + assert_eq!((m.m, m.n, m.elevation, m.vertices.len()), (3, 1, 7.0, 4)); + assert!(m.vertices[3].is_face() && !m.vertices[0].is_face()); + assert_eq!(m.vertices[3].face, [1, 2, -3, 0]); + let EntityKind::Polyline(h) = one("0|POLYLINE|70|4|40|0.5|41|0.25|0|VERTEX|10|1.0|42|0.5|40|0.1|41|0.2|70|16|0|SEQEND").kind else { + panic!("a polyline") + }; + assert_eq!((h.start_width, h.end_width), (0.5, 0.25)); + let v = h.vertices[0]; + assert!(v.is_spline_frame()); + assert_eq!((v.bulge, v.start_width, v.end_width), (0.5, 0.1, 0.2)); +} + +#[test] +fn text_and_attributes() { + let EntityKind::Text(t) = one("0|TEXT|10|1.0|20|2.0|11|3.0|21|4.0|40|2.5|1|10^ 2^Im|50|90.0|41|0.8|51|15.0|7|Notes|71|2|72|1|73|2").kind else { + panic!("text") + }; + assert_eq!((t.insert, t.align, t.height, t.value.as_str()), ([1.0, 2.0, 0.0], Some([3.0, 4.0, 0.0]), 2.5, "10^2\tm")); + assert_eq!((t.rotation, t.width_factor, t.oblique, t.style.as_str(), t.generation, t.halign, t.valign), (90.0, 0.8, 15.0, "Notes", 2, 1, 2)); + let EntityKind::Text(d) = one("0|TEXT|1|x").kind else { panic!("text") }; + assert_eq!((d.align, d.width_factor, d.style.as_str(), d.halign, d.valign), (None, 1.0, "Standard", 0, 0)); + + let EntityKind::AttDef(a) = + one("0|ATTDEF|100|AcDbText|10|1.0|1|default|100|AcDbAttributeDefinition|280|0|3|Enter^Ia|2|TAG|70|12|74|3|280|1").kind + else { + panic!("an attribute definition") + }; + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.flags, a.text.valign, a.lock_position), ("TAG", "Enter\ta", 12, 3, true)); + assert!(a.multiline.is_none()); + + // A block reference with two attributes; the second is multiline. + let EntityKind::Insert(i) = one("0|INSERT|66|1|2|DOOR|10|1.0|20|2.0|41|2.0|43|-1.0|50|45.0|70|3|71|0|44|5.0|45|6.0 + 0|ATTRIB|8|Tags|62|2|1|one|2|A|70|1|280|0 + 0|ATTRIB|1|~|2|B|101|Embedded Object|10|7.0|40|2.0|71|5|3|line one^J|1|line two + 0|SEQEND") + .kind + else { + panic!("an insert") + }; + assert_eq!((i.block.as_str(), i.insert, i.scale, i.rotation), ("DOOR", [1.0, 2.0, 0.0], [2.0, 1.0, -1.0], 45.0)); + assert_eq!((i.columns, i.rows, i.column_spacing, i.row_spacing), (3, 1, 5.0, 6.0)); + assert_eq!(i.attribs.len(), 2); + let (c, a) = &i.attribs[0]; + assert_eq!( + (c.layer.as_str(), c.color, a.tag.as_str(), a.text.value.as_str(), a.flags, a.lock_position), + ("Tags", Color::Index(2), "A", "one", 1, false) + ); + assert!(a.prompt.is_empty(), "an ATTRIB has no prompt"); + let m = i.attribs[1].1.multiline.as_ref().expect("embedded MTEXT"); + assert_eq!((m.contents.as_str(), m.insert, m.height, m.attach), ("line one\nline two", [7.0, 0.0, 0.0], 2.0, 5)); +} + +#[test] +fn caret_encoding() { + assert_eq!(decode_contents("a^Ib^Jc^ d^2^"), "a\tb\nc^d^2^"); + assert_eq!(decode_line("a^Ib^Jc^ d"), "a\tb^Jc^d", "a single line has no line feed"); + assert_eq!(encode_contents("a\tb\nc^d\u{7}"), "a^Ib^Jc^ d", "other control characters are dropped"); + assert_eq!(encode_line("10^2\t"), "10^ 2^I"); + for s in ["", "plain", "10^2", "tab\there", "^^ ^I"] { + assert_eq!(decode_line(&encode_line(s)), s); + assert_eq!(decode_contents(&encode_contents(s)), s); + } + let mut t = vec![Tag::s(1, "a^ b"), Tag::s(3, "p^ q"), Tag::i(101, 0), Tag::s(1, "e^ f")]; + map_text_groups("attdef", &mut t, decode_line); + assert_eq!(t.iter().map(Tag::str).collect::>(), ["a^b", "p^q", "0", "e^ f"], "the embedded object's text is left alone"); + let mut t = vec![Tag::s(1, "a^ b"), Tag::s(3, "p^ q")]; + map_text_groups("TEXT", &mut t, decode_line); + assert_eq!(t[1].str(), "p^ q", "only an ATTDEF's 3 is a prompt"); + map_text_groups("LINE", &mut t, |_| String::new()); + assert_eq!(t[0].str(), "a^b"); +} + +#[test] +fn mtext() { + let EntityKind::MText(m) = one("0|MTEXT|10|1.0|20|2.0|40|2.5|41|40.0|71|5|72|1|3|first ^Ipart, |3|second, |1|last\\Pline|7|Notes|11|0.0|21|1.0|50|30.0|44|1.2|73|2|90|1|63|4|45|1.25").kind else { + panic!("multiline text") + }; + assert_eq!((m.insert, m.height, m.width, m.attach, m.style.as_str()), ([1.0, 2.0, 0.0], 2.5, 40.0, 5, "Notes")); + assert_eq!(m.contents, "first \tpart,second,last\\Pline"); + assert_eq!((m.direction, m.rotation, m.line_spacing, m.line_spacing_exact), (Some([0.0, 1.0, 0.0]), 30.0, 1.2, true)); + assert!((m.angle() - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "the direction wins over the rotation"); + assert_eq!(m.background, Some((1, Color::Index(4), 1.25))); + assert!(m.columns.is_none()); + let EntityKind::MText(r) = one("0|MTEXT|1|x|50|90.0|11|0.0|21|0.0|71|99").kind else { panic!("multiline text") }; + assert!((r.angle() - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "a zero direction is none"); + assert_eq!((r.attach, r.background, r.line_spacing), (9, None, 1.0)); +} + +#[test] +fn mtext_columns() { + // Legacy groups: static, 2 columns, the first 50 after 75 counts the heights; the 50 before + // 75 is the rotation. + let t = tags("0|MTEXT|10|0.0|50|15.0|1|x|75|1|76|2|78|1|79|0|48|30.0|49|5.0|50|2|50|80.0|50|80.0"); + let c = MTextColumns::read(&t[1..]).expect("columns"); + assert_eq!((c.dynamic, c.count, c.width, c.gutter, c.height, c.auto_height, c.flow_reversed), (false, 2, 30.0, 5.0, 80.0, false, true)); + assert!(c.heights.is_empty(), "only manual dynamic columns keep each height"); + let EntityKind::MText(m) = one("0|MTEXT|10|0.0|50|15.0|1|x|75|1|76|2|48|30.0|49|5.0|50|2|50|80.0|50|80.0").kind else { panic!("multiline text") }; + assert_eq!(m.rotation, 15.0); + // Dynamic with manual heights, and no count before them. + let t = tags("0|MTEXT|1|x|75|2|76|3|79|0|48|30.0|49|5.0|50|10.0|50|40.0|50|20.0"); + let c = MTextColumns::read(&t[1..]).expect("columns"); + assert_eq!((c.dynamic, c.count, c.height, c.heights.as_slice()), (true, 3, 40.0, [10.0, 40.0, 20.0].as_slice())); + // R2018: the embedded object; 72 is 0 for dynamic auto-height, the count follows the widths. + let t = tags("0|MTEXT|1|x|46|50.0|101|Embedded Object|70|1|10|1.0|11|0.0|40|0.0|41|0.0|42|65.0|43|0.0|71|2|72|0|44|30.0|45|5.0|73|1|74|0"); + let c = MTextColumns::read(&t[1..]).expect("columns"); + assert_eq!((c.dynamic, c.count, c.width, c.gutter, c.height, c.auto_height), (true, 2, 30.0, 5.0, 50.0, true)); + // No columns: type 0, or no column groups at all. + assert!(MTextColumns::read(&tags("0|MTEXT|1|x|75|0|76|0")[1..]).is_none()); + assert!(MTextColumns::read(&tags("0|MTEXT|1|x|101|Embedded Object|71|0")[1..]).is_none()); +} + +#[test] +fn the_column_height_limit() { + let mut text = String::from("0|MTEXT|1|x|75|2|76|5000|79|0"); + for _ in 0..5000 { + text.push_str("|50|1.0"); + } + let t = tags(&text); + let c = MTextColumns::read(&t[1..]).expect("columns"); + assert_eq!((c.heights.len(), c.count), (1000, 1000)); + let mut text = String::from("0|MTEXT|1|x|101|Embedded Object|71|2|72|40000|73|0"); + for _ in 0..5000 { + text.push_str("|46|1.0"); + } + let t = tags(&text); + let c = MTextColumns::read(&t[1..]).expect("columns"); + assert_eq!((c.heights.len(), c.count), (1000, 1000)); + // A total width that asks for a count beyond any number. + let t = tags("0|MTEXT|1|x|101|Embedded Object|71|2|72|0|73|1|42|1e300|44|1e-300|45|0.0"); + assert_eq!(MTextColumns::read(&t[1..]).expect("columns").count, 1000); +} + +#[test] +fn dimensions_and_leaders() { + let EntityKind::Dimension(d) = + one("0|DIMENSION|2|*D1|3|ISO-25|10|1.0|20|2.0|11|3.0|21|4.0|12|0.5|13|5.0|14|6.0|15|7.0|16|8.0|70|161|1|<> ^Im|42|12.5|50|30.0|52|60.0|53|45.0|40|2.0").kind + else { + panic!("a dimension") + }; + assert_eq!((d.kind(), d.flags & 128, d.flags & 32), (1, 128, 32)); + assert_eq!((d.block.as_deref(), d.style.as_str(), d.text.as_str(), d.measurement), (Some("*D1"), "ISO-25", "<> \tm", Some(12.5))); + assert_eq!((d.defpt, d.text_mid, d.insertion[0]), ([1.0, 2.0, 0.0], [3.0, 4.0, 0.0], 0.5)); + assert_eq!((d.p13[0], d.p14[0], d.p15[0], d.p16[0]), (5.0, 6.0, 7.0, 8.0)); + assert_eq!((d.rotation, d.oblique, d.text_rotation, d.leader_length), (30.0, 60.0, 45.0, 2.0)); + let EntityKind::Dimension(n) = one("0|DIMENSION|70|6").kind else { panic!("a dimension") }; + assert_eq!((n.block.as_deref(), n.style.as_str(), n.measurement, n.kind()), (None, "Standard", None, 6)); + + // The second vertex has no z: it does not take the third's. + let EntityKind::Leader(l) = one("0|LEADER|3|Arrows|71|0|72|1|73|0|340|4F|10|1.0|20|2.0|30|3.0|10|4.0|20|5.0|10|6.0|20|7.0|30|8.0").kind else { + panic!("a leader") + }; + assert_eq!(l.vertices, [[1.0, 2.0, 3.0], [4.0, 5.0, 0.0], [6.0, 7.0, 8.0]]); + assert!(!l.arrow && l.spline); + assert_eq!((l.style.as_str(), l.annotation_type, l.annotation), ("Arrows", 0, Some(0x4F))); +} + +#[test] +fn faces_viewports_images_and_tolerances() { + let k = |text: &str| one(text).kind; + let three = "|10|0.0|11|1.0|12|1.0|22|1.0"; + assert_eq!(k(&format!("0|SOLID{three}")), EntityKind::Solid { corners: [[0.0; 3], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [1.0, 1.0, 0.0]] }); + assert!(matches!(k(&format!("0|TRACE{three}|13|9.0")), EntityKind::Trace { corners } if corners[3] == [9.0, 0.0, 0.0])); + assert!(matches!(k(&format!("0|3DFACE{three}|70|5")), EntityKind::Face3d { hidden_edges: 5, .. })); + let EntityKind::Viewport(v) = + k("0|VIEWPORT|10|5.0|20|4.0|40|8.0|41|6.0|69|2|90|81920|12|1.0|22|2.0|17|9.0|45|30.0|51|90.0|340|A1|331|B1|331|B2|341|B3") + else { + panic!("a viewport") + }; + assert_eq!((v.center, v.width, v.height, v.id, v.status & 16384, v.status & 131_072), ([5.0, 4.0, 0.0], 8.0, 6.0, 2, 16384, 0)); + assert_eq!((v.view_center, v.view_target, v.view_direction, v.view_height, v.twist), ([1.0, 2.0], [9.0, 0.0, 0.0], [0.0, 0.0, 1.0], 30.0, 90.0)); + assert_eq!((v.clip, v.frozen_layers.as_slice()), (Some(0xA1), [0xB1, 0xB2, 0xB3].as_slice())); + let EntityKind::Image(i) = + k("0|IMAGE|10|1.0|11|0.5|12|0.0|22|0.5|13|640.0|23|480.0|340|C1|70|7|280|1|281|60|71|2|14|0.0|24|0.0|14|10.0|24|0.0|14|10.0|24|10.0|290|1") + else { + panic!("an image") + }; + assert_eq!((i.insert[0], i.u[0], i.v[1], i.size, i.image_def), (1.0, 0.5, 0.5, [640.0, 480.0], Some(0xC1))); + assert_eq!((i.display, i.clipping, i.brightness, i.contrast, i.fade, i.clip_type, i.clip_inverted), (7, true, 60, 50, 0, 2, true)); + assert_eq!(i.clip, [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]); + assert!(matches!(k("0|WIPEOUT|10|1.0|14|-0.5|24|-0.5|14|0.5|24|0.5"), EntityKind::Wipeout(w) if w.clip.len() == 2 && w.clip_type == 1)); + assert_eq!( + k("0|TOLERANCE|3|Tol|10|1.0|1|{\\Fgdt;j}%%v0.1"), + EntityKind::Tolerance { insert: [1.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0], style: "Tol".into(), text: "{\\Fgdt;j}%%v0.1".into() } + ); + assert_eq!(k("0|MULTILEADER|10|1.0"), EntityKind::Other, "a type the model does not have is read from its tags"); +} + +#[test] +fn hatch_with_a_polyline_loop_and_a_pattern() { + let EntityKind::Hatch(h) = one("0|HATCH|10|0.0|20|0.0|30|2.5|2|ANSI31|70|0|71|1|91|1 + 92|7|72|1|73|1|93|3|10|0.0|20|0.0|42|0.5|10|4.0|20|0.0|42|0.0|10|4.0|20|3.0|42|0.0|97|2|330|A1|330|A2 + 75|1|76|1|52|45.0|41|2.0|77|0|78|2 + 53|45.0|43|0.0|44|0.0|45|-1.4|46|1.4|79|0 + 53|135.0|43|1.0|44|2.0|45|1.0|46|1.0|79|2|49|3.0|49|-1.5 + 98|2|10|1.0|20|1.0|10|2.0|20|2.0") + .kind + else { + panic!("a hatch") + }; + assert_eq!((h.pattern.as_str(), h.solid, h.associative, h.style, h.pattern_type), ("ANSI31", false, true, 1, 1)); + assert_eq!((h.angle, h.scale, h.double, h.elevation), (45.0, 2.0, false, 2.5)); + assert_eq!(h.loops.len(), 1); + let l = &h.loops[0]; + assert_eq!((l.flags, l.sources.as_slice()), (7, [0xA1, 0xA2].as_slice())); + assert_eq!(l.path, HatchPath::Polyline { closed: true, vertices: vec![[0.0, 0.0, 0.5], [4.0, 0.0, 0.0], [4.0, 3.0, 0.0]] }); + assert_eq!(h.pattern_lines.len(), 2); + assert_eq!((h.pattern_lines[0].angle, h.pattern_lines[0].offset, h.pattern_lines[0].dashes.len()), (45.0, [-1.4, 1.4], 0)); + assert_eq!((h.pattern_lines[1].base, h.pattern_lines[1].dashes.as_slice()), ([1.0, 2.0], [3.0, -1.5].as_slice())); + assert_eq!(h.seeds, [[1.0, 1.0], [2.0, 2.0]]); + assert!(h.gradient.is_none()); +} + +#[test] +fn hatch_with_edge_loops_and_a_gradient() { + let EntityKind::Hatch(h) = one( + "0|HATCH|2|SOLID|70|1|71|0|91|2 + 92|1|93|4 + 72|1|10|0.0|20|0.0|11|4.0|21|0.0 + 72|2|10|4.0|20|1.0|40|1.0|50|270.0|51|360.0|73|1 + 72|3|10|2.0|20|2.0|11|2.0|21|0.0|40|0.5|50|0.0|51|180.0|73|0 + 72|4|94|3|73|1|74|0|95|8|96|4|40|0.0|40|0.0|40|0.0|40|0.0|40|1.0|40|1.0|40|1.0|40|1.0|10|0.0|20|2.0|42|1.0|10|0.0|20|1.5|42|2.0|10|0.0|20|0.5|42|2.0|10|0.0|20|0.0|42|1.0|97|2|11|0.0|21|2.0|11|0.0|21|0.0|12|0.0|22|-1.0|13|0.0|23|-1.0 + 97|0 + 92|16|93|1 + 72|2|10|2.0|20|1.0|40|0.25|50|0.0|51|360.0|73|1 + 97|1|330|B1 + 75|0|76|1|98|0 + 450|1|451|0|460|0.785|461|0.0|452|0|462|0.0|453|2|463|0.0|63|5|421|255|463|1.0|63|2|470|LINEAR + 1001|ACAD|1010|0.0", + ) + .kind + else { + panic!("a hatch") + }; + assert!(h.solid && !h.associative); + assert_eq!(h.loops.len(), 2); + let HatchPath::Edges(e) = &h.loops[0].path else { panic!("edges") }; + assert_eq!(e.len(), 4); + assert_eq!(e[0], HatchEdge::Line { start: [0.0, 0.0], end: [4.0, 0.0] }); + assert_eq!(e[1], HatchEdge::Arc { center: [4.0, 1.0], radius: 1.0, start_angle: 270.0, end_angle: 360.0, ccw: true }); + assert_eq!(e[2], HatchEdge::Ellipse { center: [2.0, 2.0], major: [2.0, 0.0], ratio: 0.5, start_angle: 0.0, end_angle: 180.0, ccw: false }); + let HatchEdge::Spline { degree, rational, periodic, knots, control, weights, fit, start_tangent, end_tangent } = &e[3] else { + panic!("a spline edge") + }; + assert_eq!((*degree, *rational, *periodic, knots.len(), control.len()), (3, true, false, 8, 4)); + assert_eq!( + (weights.as_slice(), fit.len(), *start_tangent, *end_tangent), + ([1.0, 2.0, 2.0, 1.0].as_slice(), 2, Some([0.0, -1.0]), Some([0.0, -1.0])) + ); + // The spline's own 97 (fit points) is not the loop's source count. + assert!(h.loops[0].sources.is_empty()); + assert_eq!((h.loops[1].flags, h.loops[1].sources.as_slice()), (16, [0xB1].as_slice())); + let g = h.gradient.expect("a gradient"); + assert_eq!((g.name.as_str(), g.angle, g.shift, g.single), ("LINEAR", 0.785, 0.0, false)); + assert_eq!(g.colors, [Color::Rgb([0, 0, 255]), Color::Index(2)], "a true colour wins over its index"); +} + +#[test] +fn damaged_hatches() { + let k = |text: &str| one(text).kind; + assert_eq!(k("0|HATCH|2|SOLID"), EntityKind::Other, "no boundary data"); + assert_eq!(k("0|HATCH|91|2|92|2|93|1|10|0.0|20|0.0"), EntityKind::Other, "the second loop is missing"); + assert_eq!(k("0|HATCH|91|1|92|2|8|x|93|1"), EntityKind::Other, "a foreign group in the loop header"); + // Counts larger than the data: what is there is read, nothing is reserved for the rest. + let EntityKind::Hatch(h) = k("0|HATCH|91|1|92|2|93|2000000000|10|1.0|20|2.0|97|2000000000|330|A|98|2000000000|10|5.0|20|6.0") else { + panic!("a hatch") + }; + assert_eq!(h.loops[0].path, HatchPath::Polyline { closed: false, vertices: vec![[1.0, 2.0, 0.0]] }); + assert_eq!((h.loops[0].sources.as_slice(), h.seeds.as_slice()), ([0xA].as_slice(), [[5.0, 6.0]].as_slice())); + let EntityKind::Hatch(h) = k("0|HATCH|91|1|92|0|93|9|72|9|10|1.0|72|1|10|1.0|20|1.0|11|2.0|21|2.0|72|4|95|-5|96|2000000000|10|1.0|20|1.0") else { + panic!("a hatch") + }; + let HatchPath::Edges(e) = &h.loops[0].path else { panic!("edges") }; + assert_eq!(e.len(), 2, "an edge type the format does not have is passed over"); + assert!(matches!(&e[1], HatchEdge::Spline { knots, control, .. } if knots.is_empty() && control.len() == 1)); + assert_eq!(k("0|HATCH|91|-4").clone(), k("0|HATCH|91|0"), "a negative loop count is none"); + // A 450 that says no gradient. + assert!(matches!(k("0|HATCH|91|0|450|0|463|0.0|63|1"), EntityKind::Hatch(h) if h.gradient.is_none())); +} + +#[test] +fn entities_of_a_whole_drawing() { + let t = tags( + "0|SECTION|2|BLOCKS|0|BLOCK|2|B|0|CIRCLE|40|2.0|0|ENDBLK|0|ENDSEC|0|SECTION|2|ENTITIES|0|INSERT|2|B|0|POLYLINE|0|VERTEX|10|1.0|0|SEQEND|0|ENDSEC|0|EOF", + ); + let d = Document::read(&t); + let kinds: Vec = d.entities.iter().chain(&d.blocks[0].entities).map(|e| Entity::read(e).kind).collect(); + assert!(matches!(kinds.as_slice(), [EntityKind::Insert(_), EntityKind::Polyline(p), EntityKind::Circle { .. }] if p.vertices.len() == 1)); +} + +#[test] +fn a_spline_edge_without_fit_data_leaves_the_loops_source_count() { + // Before R2010 a spline edge has no 97 of its own: the one after it counts the loop's sources. + let EntityKind::Hatch(h) = one( + "0|HATCH|91|1|92|1|93|1|72|4|94|1|73|0|74|0|95|4|96|2|40|0.0|40|0.0|40|1.0|40|1.0|10|0.0|20|0.0|10|1.0|20|1.0|97|2|330|A1|330|A2|75|0|98|0", + ) + .kind + else { + panic!("a hatch") + }; + assert_eq!(h.loops[0].sources, [0xA1, 0xA2]); + assert!( + matches!(&h.loops[0].path, HatchPath::Edges(e) if matches!(&e[0], HatchEdge::Spline { fit, control, .. } if fit.is_empty() && control.len() == 2)) + ); +}