diff --git a/src/block_expansion.rs b/src/block_expansion.rs index 8f0262f..98e0c8e 100644 --- a/src/block_expansion.rs +++ b/src/block_expansion.rs @@ -191,132 +191,3 @@ where ctx.visited.remove(&block_handle); result } - -#[cfg(test)] -mod tests { - use super::*; - use crate::entities::insert::Insert; - use crate::entities::{Point3D, Vec3D}; - - fn insert_at(x: f64, y: f64) -> Insert { - Insert { - owned_object_count: 0, - insertion_point: Point3D { x, y, z: 0.0 }, - scale: Point3D { - x: 1.0, - y: 1.0, - z: 1.0, - }, - rotation: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - has_attribs: false, - } - } - - #[test] - fn expansion_context_tracks_depth_and_visited() { - let ctx = ExpansionContext::new(); - assert_eq!(ctx.depth(), 0); - assert!(!ctx.is_visited(0x42)); - assert_eq!(ctx.max_depth(), 16); - } - - #[test] - fn with_max_depth_overrides_default() { - let ctx = ExpansionContext::new().with_max_depth(4); - assert_eq!(ctx.max_depth(), 4); - } - - #[test] - fn depth_cap_rejects_overflow() { - let insert = insert_at(0.0, 0.0); - let mut ctx = ExpansionContext::new().with_max_depth(0); - let lookup = |_h: u64| Ok(Vec::new()); - let r = expand_insert(&insert, 0x42, &mut ctx, &Transform3::identity(), &lookup); - assert!(matches!(r, Err(Error::Unsupported { .. }))); - } - - #[test] - fn cycle_detection_rejects_reentrant_block() { - let insert_a = insert_at(0.0, 0.0); - let insert_b = insert_a.clone(); - - let lookup = move |h: u64| -> Result)>> { - if h == 0xA { - Ok(vec![( - DecodedEntity::Insert(insert_b.clone()), - Some(0xA), // points back at self → 1-cycle - )]) - } else { - Ok(Vec::new()) - } - }; - - let mut ctx = ExpansionContext::new(); - let r = expand_insert(&insert_a, 0xA, &mut ctx, &Transform3::identity(), &lookup); - assert!(matches!(r, Err(Error::Unsupported { .. }))); - } - - #[test] - fn empty_block_body_yields_empty_expansion() { - let insert = insert_at(10.0, 20.0); - let mut ctx = ExpansionContext::new(); - let lookup = |_h: u64| Ok(Vec::new()); - let expanded = - expand_insert(&insert, 0x1234, &mut ctx, &Transform3::identity(), &lookup).unwrap(); - assert!(expanded.is_empty()); - assert_eq!(ctx.depth, 0); - assert!(ctx.visited.is_empty()); - } - - #[test] - fn single_level_expansion_emits_block_body() { - // Block 0xB contains one LINE entity. Expanding an INSERT - // at (10, 20) that references block 0xB yields a single - // ExpandedEntity at depth 1 with the translated transform. - use crate::entities::line::Line; - - let line = Line { - start: Point3D { - x: 0.0, - y: 0.0, - z: 0.0, - }, - end: Point3D { - x: 5.0, - y: 0.0, - z: 0.0, - }, - thickness: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - is_2d: true, - }; - let line_decoded = DecodedEntity::Line(line); - - let lookup = move |h: u64| -> Result)>> { - if h == 0xB { - Ok(vec![(line_decoded.clone(), None)]) - } else { - Ok(Vec::new()) - } - }; - - let insert = insert_at(10.0, 20.0); - let mut ctx = ExpansionContext::new(); - let expanded = - expand_insert(&insert, 0xB, &mut ctx, &Transform3::identity(), &lookup).unwrap(); - assert_eq!(expanded.len(), 1); - assert_eq!(expanded[0].depth, 1); - // Depth + visited restored on exit. - assert_eq!(ctx.depth, 0); - assert!(ctx.visited.is_empty()); - } -} diff --git a/src/color.rs b/src/color.rs index 85a938d..b8e419b 100644 --- a/src/color.rs +++ b/src/color.rs @@ -321,69 +321,3 @@ pub fn aci_to_hex(index: u8) -> String { let (r, g, b) = aci_to_rgb(index); format!("#{r:02X}{g:02X}{b:02X}") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn named_primaries_match_expected() { - assert_eq!(aci_to_rgb(1), (255, 0, 0)); // red - assert_eq!(aci_to_rgb(2), (255, 255, 0)); // yellow - assert_eq!(aci_to_rgb(3), (0, 255, 0)); // green - assert_eq!(aci_to_rgb(4), (0, 255, 255)); // cyan - assert_eq!(aci_to_rgb(5), (0, 0, 255)); // blue - assert_eq!(aci_to_rgb(6), (255, 0, 255)); // magenta - assert_eq!(aci_to_rgb(7), (255, 255, 255)); // white/black - } - - #[test] - fn grays_are_monotonic_lightness() { - // 250 (darkest gray) → 255 (white) should be monotonically increasing. - for i in 250..=254u8 { - let (r, _, _) = aci_to_rgb(i); - let (r_next, _, _) = aci_to_rgb(i + 1); - assert!(r_next >= r, "gray {i} → {} not monotonic", i + 1); - } - } - - #[test] - fn hex_format_uppercase_six_digit() { - assert_eq!(aci_to_hex(1), "#FF0000"); - assert_eq!(aci_to_hex(3), "#00FF00"); - assert_eq!(aci_to_hex(5), "#0000FF"); - assert_eq!(aci_to_hex(0), "#000000"); - assert_eq!(aci_to_hex(255), "#FFFFFF"); - } - - #[test] - fn palette_length_is_exactly_256() { - assert_eq!(ACI_PALETTE.len(), 256); - } - - #[test] - fn hue_grid_saturation_pattern() { - // The HSV grid has a predictable pattern: the "full saturation" - // row (index 10, 20, 30, ..., 240) is the most saturated hue - // step. The "faded" companion at index+1 should be lighter. - for hue_base in (10..=240).step_by(10) { - let (r1, g1, b1) = aci_to_rgb(hue_base); - let (r2, g2, b2) = aci_to_rgb(hue_base + 1); - let v1 = r1 as u32 + g1 as u32 + b1 as u32; - let v2 = r2 as u32 + g2 as u32 + b2 as u32; - assert!( - v2 >= v1, - "ACI {hue_base} ({v1}) darker-total than {} ({v2})", - hue_base + 1 - ); - } - } - - #[test] - fn does_not_panic_on_any_u8() { - for i in 0u8..=255 { - let _ = aci_to_rgb(i); - let _ = aci_to_hex(i); - } - } -} diff --git a/src/curve.rs b/src/curve.rs index 9ccda9f..1a9f889 100644 --- a/src/curve.rs +++ b/src/curve.rs @@ -256,125 +256,3 @@ impl Path { self.segments.is_empty() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn line_bounds_covers_both_endpoints() { - let c = Curve::Line { - a: Point3D::new(0.0, 0.0, 0.0), - b: Point3D::new(10.0, -5.0, 3.0), - }; - let b = c.bounds(); - assert_eq!(b.min, Point3D::new(0.0, -5.0, 0.0)); - assert_eq!(b.max, Point3D::new(10.0, 0.0, 3.0)); - } - - #[test] - fn circle_bounds_is_cube_around_center() { - let c = Curve::Circle { - center: Point3D::new(5.0, 5.0, 5.0), - radius: 2.0, - normal: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - }; - let b = c.bounds(); - assert_eq!(b.min, Point3D::new(3.0, 3.0, 3.0)); - assert_eq!(b.max, Point3D::new(7.0, 7.0, 7.0)); - } - - #[test] - fn polyline_bounds_covers_all_vertices() { - let c = Curve::Polyline { - vertices: vec![ - PolylineVertex { - point: Point3D::new(-1.0, 0.0, 0.0), - bulge: 0.0, - }, - PolylineVertex { - point: Point3D::new(1.0, 2.0, 0.0), - bulge: 0.0, - }, - PolylineVertex { - point: Point3D::new(0.0, -1.0, 3.0), - bulge: 0.0, - }, - ], - closed: false, - }; - let b = c.bounds(); - assert_eq!(b.min, Point3D::new(-1.0, -1.0, 0.0)); - assert_eq!(b.max, Point3D::new(1.0, 2.0, 3.0)); - } - - #[test] - fn path_from_polyline_builds_lines() { - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - ]; - let p = Path::from_polyline(&pts, false); - assert_eq!(p.segments.len(), 2); - assert!(!p.closed); - } - - #[test] - fn path_from_polyline_closed_adds_final_segment() { - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - ]; - let p = Path::from_polyline(&pts, true); - assert_eq!(p.segments.len(), 3); - assert!(p.closed); - } - - #[test] - fn empty_path_bounds_is_empty() { - let p = Path::empty(); - assert!(p.is_empty()); - assert!(p.bounds().is_empty()); - } - - #[test] - fn path_bounds_unions_segment_bounds() { - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(10.0, 0.0, 0.0), - Point3D::new(10.0, 20.0, 0.0), - ]; - let p = Path::from_polyline(&pts, false); - let b = p.bounds(); - assert_eq!(b.min, Point3D::new(0.0, 0.0, 0.0)); - assert_eq!(b.max, Point3D::new(10.0, 20.0, 0.0)); - } - - #[test] - fn spline_bounds_covers_control_points_only() { - // NB: tight NURBS bounds could exclude control points, but - // our conservative impl uses the control polygon. - let s = Spline { - degree: 3, - control_points: vec![ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(10.0, 0.0, 0.0), - Point3D::new(10.0, 10.0, 0.0), - Point3D::new(0.0, 10.0, 0.0), - ], - weights: vec![], - knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], - closed: false, - }; - let c = Curve::Spline(s); - let b = c.bounds(); - assert_eq!(b.min, Point3D::new(0.0, 0.0, 0.0)); - assert_eq!(b.max, Point3D::new(10.0, 10.0, 0.0)); - } -} diff --git a/src/dxf.rs b/src/dxf.rs index a87cec3..b22fc0e 100644 --- a/src/dxf.rs +++ b/src/dxf.rs @@ -267,176 +267,3 @@ impl DxfWriter { self.output.push('\n'); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_finish_is_just_eof() { - let mut w = DxfWriter::new(); - w.finish(); - assert_eq!(w.take_output(), " 0\nEOF\n"); - } - - #[test] - fn section_begin_end_wraps_contents() { - let mut w = DxfWriter::new(); - w.begin_section("HEADER"); - w.write_string(9, "$TEST"); - w.write_int(70, 42); - w.end_section(); - w.finish(); - let out = w.take_output(); - assert!(out.contains("SECTION")); - assert!(out.contains("HEADER")); - assert!(out.contains("$TEST")); - assert!(out.contains("42")); - assert!(out.contains("ENDSEC")); - assert!(out.ends_with("EOF\n")); - } - - #[test] - fn pair_format_is_three_digit_padded_code_then_value() { - let mut w = DxfWriter::new(); - w.write_string(9, "$V"); - let s = w.as_str(); - assert!(s.starts_with(" 9\n$V\n")); - } - - #[test] - fn double_has_16_decimal_places() { - let mut w = DxfWriter::new(); - w.write_double(40, std::f64::consts::PI); - assert!(w.as_str().contains("3.1415926535897931")); - } - - #[test] - fn point_emits_three_components_with_offsets() { - let mut w = DxfWriter::new(); - w.write_point(10, Point3D::new(1.0, 2.0, 3.0)); - let s = w.as_str(); - assert!(s.contains(" 10\n")); - assert!(s.contains(" 20\n")); - assert!(s.contains(" 30\n")); - } - - #[test] - fn handle_is_hex_uppercase() { - let mut w = DxfWriter::new(); - w.write_handle(0x1A3F); - assert!(w.as_str().contains("1A3F")); - } - - #[test] - #[should_panic(expected = "section")] - fn nested_section_panics() { - let mut w = DxfWriter::new(); - w.begin_section("A"); - w.begin_section("B"); - } - - #[test] - #[should_panic(expected = "end_section")] - fn end_section_without_begin_panics() { - let mut w = DxfWriter::new(); - w.end_section(); - } - - #[test] - #[should_panic(expected = "section still open")] - fn finish_with_open_section_panics() { - let mut w = DxfWriter::new(); - w.begin_section("A"); - w.finish(); - } - - #[test] - #[should_panic(expected = "twice")] - fn double_finish_panics() { - let mut w = DxfWriter::new(); - w.finish(); - w.finish(); - } - - #[test] - fn entity_header_emits_type_and_handle() { - let mut w = DxfWriter::new(); - w.begin_section("ENTITIES"); - w.write_entity_header("LINE", Some(0x83)); - w.end_section(); - w.finish(); - let s = w.take_output(); - assert!(s.contains("LINE")); - assert!(s.contains("83")); - } - - #[test] - fn comments_use_group_999() { - let mut w = DxfWriter::new(); - w.write_comment("generated by dwg-rs"); - assert!(w.as_str().contains("999\n")); - assert!(w.as_str().contains("generated by dwg-rs")); - } - - #[test] - fn take_output_clears_buffer() { - let mut w = DxfWriter::new(); - w.write_comment("a"); - let _ = w.take_output(); - assert_eq!(w.as_str(), ""); - } - - #[test] - fn default_version_is_r2018() { - let w = DxfWriter::new(); - assert_eq!(w.version(), DxfVersion::R2018); - assert_eq!(w.version().acadver(), "AC1032"); - } - - #[test] - fn with_version_sets_target() { - let w = DxfWriter::with_version(DxfVersion::R12); - assert_eq!(w.version(), DxfVersion::R12); - assert_eq!(w.version().acadver(), "AC1009"); - } - - #[test] - fn set_version_overrides_target() { - let mut w = DxfWriter::new(); - w.set_version(DxfVersion::R2000); - assert_eq!(w.version(), DxfVersion::R2000); - assert_eq!(w.version().acadver(), "AC1015"); - } - - #[test] - fn subclass_markers_gated_by_version() { - assert!(!DxfVersion::R12.supports_subclass_markers()); - assert!(DxfVersion::R14.supports_subclass_markers()); - assert!(DxfVersion::R2000.supports_subclass_markers()); - assert!(DxfVersion::R2018.supports_subclass_markers()); - } - - #[test] - fn acadver_magic_strings_match_spec() { - assert_eq!(DxfVersion::R12.acadver(), "AC1009"); - assert_eq!(DxfVersion::R14.acadver(), "AC1014"); - assert_eq!(DxfVersion::R2000.acadver(), "AC1015"); - assert_eq!(DxfVersion::R2004.acadver(), "AC1018"); - assert_eq!(DxfVersion::R2007.acadver(), "AC1021"); - assert_eq!(DxfVersion::R2010.acadver(), "AC1024"); - assert_eq!(DxfVersion::R2013.acadver(), "AC1027"); - assert_eq!(DxfVersion::R2018.acadver(), "AC1032"); - } - - #[test] - fn parse_cli_accepts_release_and_acadver() { - assert_eq!(DxfVersion::parse_cli("R12"), Some(DxfVersion::R12)); - assert_eq!(DxfVersion::parse_cli("r12"), Some(DxfVersion::R12)); - assert_eq!(DxfVersion::parse_cli("AC1009"), Some(DxfVersion::R12)); - assert_eq!(DxfVersion::parse_cli("ac1032"), Some(DxfVersion::R2018)); - assert_eq!(DxfVersion::parse_cli("R2018"), Some(DxfVersion::R2018)); - assert_eq!(DxfVersion::parse_cli(" R2013 "), Some(DxfVersion::R2013)); - assert_eq!(DxfVersion::parse_cli("bogus"), None); - } -} diff --git a/src/dxf_convert.rs b/src/dxf_convert.rs index f388a29..81f4aa2 100644 --- a/src/dxf_convert.rs +++ b/src/dxf_convert.rs @@ -19,10 +19,13 @@ //! The emitter writes spec-compliant DXF group-code pairs per the //! AutoCAD DXF reference. **Actual acceptance against AutoCAD / //! BricsCAD / LibreCAD is untested** — there is no Autodesk product -//! in CI. The only automated validation is `cargo test`, which -//! exercises group-code emission patterns against synthesized inputs. -//! Real round-trip-via-AutoCAD validation remains a manual step -//! documented in `tests/integration_dxf_roundtrip.rs`. +//! in CI. What CI measures is the output against AutoCAD's own DXF +//! export of the same drawing: the `probe_dxf_header`, +//! `probe_dxf_tables` and `probe_dxf_entities` examples pair every +//! record written with the export's, group for group, on the pinned +//! public corpus. Real round-trip-via-AutoCAD +//! validation remains a manual step documented in +//! `tests/integration_dxf_roundtrip.rs`. use crate::curve::{Curve, Path}; use crate::dxf::{DxfVersion, DxfWriter}; @@ -565,18 +568,3 @@ fn decoded_label(e: &DecodedEntity) -> String { let end = lbl.find(['(', '{', ' ']).unwrap_or(lbl.len()); lbl[..end].to_string() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn label_is_short_kind_name() { - let e = DecodedEntity::Unhandled { - type_code: 42, - kind: crate::ObjectType::Unknown(42), - }; - let s = decoded_label(&e); - assert_eq!(s, "Unhandled"); - } -} diff --git a/src/dxf_sections.rs b/src/dxf_sections.rs index fcf7978..01d82f2 100644 --- a/src/dxf_sections.rs +++ b/src/dxf_sections.rs @@ -439,414 +439,6 @@ pub fn write_blocks_section(w: &mut DxfWriter) { w.end_section(); } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn header_section_emits_sentinels() { - let mut w = DxfWriter::new(); - let vars = [ - HeaderEntry::string("$ACADVER", "AC1032"), - HeaderEntry::int("$INSUNITS", 4), - HeaderEntry::double("$LUPREC", 4.0), - ]; - write_header_section(&mut w, &vars); - w.finish(); - let s = w.take_output(); - assert!(s.contains("SECTION")); - assert!(s.contains("HEADER")); - assert!(s.contains("$ACADVER")); - assert!(s.contains("AC1032")); - assert!(s.contains("$INSUNITS")); - assert!(s.contains("ENDSEC")); - } - - #[test] - fn tables_section_has_layer_table() { - let mut w = DxfWriter::new(); - let layers = [ - LayerEntry { - name: "0".to_string(), - aci: 7, - frozen: false, - linetype: "Continuous".to_string(), - }, - LayerEntry { - name: "Frozen".to_string(), - aci: 3, - frozen: true, - linetype: "DASHED".to_string(), - }, - ]; - write_tables_section(&mut w, &layers); - w.finish(); - let s = w.take_output(); - assert!(s.contains("TABLES")); - assert!(s.contains("LAYER")); - assert!(s.contains("Frozen")); - // Frozen layer's ACI is emitted negative per DXF convention. - assert!(s.contains("-3")); - assert!(s.contains("DASHED")); - assert!(s.contains("ENDTAB")); - } - - #[test] - fn entities_section_emits_line() { - let mut w = DxfWriter::new(); - let entities = [EntityRecord::line( - "0", - 7, - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(100.0, 50.0, 0.0), - )]; - write_entities_section(&mut w, &entities); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ENTITIES")); - assert!(s.contains("LINE")); - assert!(s.contains("AcDbLine")); - assert!(s.contains("AcDbEntity")); - } - - #[test] - fn entities_section_emits_circle() { - let mut w = DxfWriter::new(); - let entities = [EntityRecord { - handle: None, - layer: "0".to_string(), - aci: 1, - geometry: EntityGeometry::Circle(Curve::Circle { - center: Point3D::new(10.0, 20.0, 0.0), - radius: 5.0, - normal: crate::entities::Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - }), - }]; - write_entities_section(&mut w, &entities); - w.finish(); - let s = w.take_output(); - assert!(s.contains("CIRCLE")); - assert!(s.contains("AcDbCircle")); - } - - #[test] - fn entities_section_emits_point_with_aci_comment() { - let mut w = DxfWriter::new(); - let entities = [EntityRecord { - handle: Some(0x83), - layer: "0".to_string(), - aci: 1, - geometry: EntityGeometry::Point(Point3D::new(1.0, 2.0, 3.0)), - }]; - write_entities_section(&mut w, &entities); - w.finish(); - let s = w.take_output(); - assert!(s.contains("POINT")); - assert!(s.contains("AcDbPoint")); - assert!(s.contains("#FF0000")); // ACI 1 = red, emitted as diag comment - } - - #[test] - fn entities_section_emits_polyline() { - let mut w = DxfWriter::new(); - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(10.0, 0.0, 0.0), - Point3D::new(10.0, 10.0, 0.0), - ]; - let path = Path::from_polyline(&pts, true); - let entities = [EntityRecord { - handle: None, - layer: "0".to_string(), - aci: 7, - geometry: EntityGeometry::Polyline(path), - }]; - write_entities_section(&mut w, &entities); - w.finish(); - let s = w.take_output(); - assert!(s.contains("LWPOLYLINE")); - assert!(s.contains("AcDbPolyline")); - } - - #[test] - fn blocks_section_emits_modelspace_and_paperspace() { - let mut w = DxfWriter::new(); - write_blocks_section(&mut w); - w.finish(); - let s = w.take_output(); - assert!(s.contains("BLOCKS")); - assert!(s.contains("*Model_Space")); - assert!(s.contains("*Paper_Space")); - assert!(s.contains("ENDBLK")); - } - - #[test] - fn header_section_emits_acadver_from_writer_version() { - // Default writer (R2018) → AC1032. - let mut w = DxfWriter::new(); - write_header_section(&mut w, &[]); - w.finish(); - let s = w.take_output(); - assert!(s.contains("$ACADVER")); - assert!(s.contains("AC1032")); - - // R12 writer → AC1009. - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R12); - write_header_section(&mut w, &[]); - w.finish(); - let s = w.take_output(); - assert!(s.contains("$ACADVER")); - assert!(s.contains("AC1009")); - assert!(!s.contains("AC1032")); - } - - #[test] - fn header_section_skips_caller_supplied_acadver() { - // Caller-supplied $ACADVER gets dropped in favor of the writer's - // version (prevents mismatched magic). - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R2000); - write_header_section(&mut w, &[HeaderEntry::string("$ACADVER", "AC9999")]); - w.finish(); - let s = w.take_output(); - assert!(s.contains("AC1015")); // R2000 magic - assert!(!s.contains("AC9999")); // caller's bogus value dropped - } - - #[test] - fn r12_entities_section_omits_subclass_markers() { - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R12); - write_entities_section( - &mut w, - &[EntityRecord::line( - "0", - 7, - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - )], - ); - w.finish(); - let s = w.take_output(); - assert!(s.contains("LINE")); - // R12 predates the subclass-marker convention — not a single - // 100 AcDb* tag should appear. - assert!(!s.contains("AcDbLine")); - assert!(!s.contains("AcDbEntity")); - } - - #[test] - fn r2018_entities_section_emits_subclass_markers() { - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R2018); - write_entities_section( - &mut w, - &[EntityRecord::line( - "0", - 7, - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - )], - ); - w.finish(); - let s = w.take_output(); - assert!(s.contains("AcDbEntity")); - assert!(s.contains("AcDbLine")); - } - - #[test] - fn r12_blocks_section_omits_subclass_markers() { - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R12); - write_blocks_section(&mut w); - w.finish(); - let s = w.take_output(); - assert!(s.contains("*Model_Space")); - assert!(!s.contains("AcDbBlockBegin")); - assert!(!s.contains("AcDbBlockEnd")); - } - - #[test] - fn full_document_has_all_sections() { - let mut w = DxfWriter::new(); - write_header_section(&mut w, &[HeaderEntry::string("$ACADVER", "AC1032")]); - write_tables_section(&mut w, &[LayerEntry::default()]); - write_blocks_section(&mut w); - write_entities_section( - &mut w, - &[EntityRecord::line( - "0", - 7, - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - )], - ); - w.finish(); - let s = w.take_output(); - // Section names present, EOF terminator present. - for sec in ["HEADER", "TABLES", "BLOCKS", "ENTITIES", "ENDSEC", "EOF"] { - assert!(s.contains(sec), "output missing section {sec}"); - } - } - - // ------------------------------------------------------------------- - // OBJECTS section tests - // ------------------------------------------------------------------- - - #[test] - fn objects_section_emits_dictionary_with_entries() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::Dictionary { - handle: 0x10, - owner_handle: 0, - hard_owner: true, - entries: vec![ - ("ACAD_LAYOUT".to_string(), 0x1A), - ("ACAD_MATERIAL".to_string(), 0x2B), - ], - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("OBJECTS")); - assert!(s.contains("DICTIONARY")); - assert!(s.contains("AcDbDictionary")); - assert!(s.contains("ACAD_LAYOUT")); - assert!(s.contains("1A")); - } - - #[test] - fn objects_section_emits_xrecord_with_raw_bytes_marker() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::XRecord { - handle: 0x20, - owner_handle: 0x10, - cloning_flags: 1, - raw_bytes_len: 128, - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("XRECORD")); - assert!(s.contains("AcDbXrecord")); - } - - #[test] - fn objects_section_proxy_object_emits_suppression_comment() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::ProxyObject { - handle: 0x30, - owner_handle: 0x10, - class_id: 501, - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ACAD_PROXY_OBJECT")); - assert!(s.contains("opaque proxy data suppressed")); - } - - #[test] - fn objects_section_proxy_entity_emits_suppression_comment() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::ProxyEntity { - handle: 0x31, - owner_handle: 0x10, - class_id: 500, - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ACAD_PROXY_ENTITY")); - assert!(s.contains("opaque proxy data suppressed")); - } - - #[test] - fn objects_section_acad_group_emits_typed_fields() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::AcadGroup { - handle: 0x40, - owner_handle: 0x10, - name: "MyGroup".to_string(), - selectable: true, - member_handles: vec![0x50, 0x51], - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ACAD_GROUP")); - assert!(s.contains("AcDbGroup")); - assert!(s.contains("MyGroup")); - assert!(s.contains("\n50\n")); - } - - #[test] - fn objects_section_acad_scale_emits_ratio_fields() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::AcadScale { - handle: 0x60, - owner_handle: 0x10, - name: "1/4\" = 1'-0\"".to_string(), - paper_units: 0.25, - drawing_units: 12.0, - is_unit_scale: false, - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ACDBSCALE")); - assert!(s.contains("AcDbScale")); - assert!(s.contains("1/4")); - } - - #[test] - fn objects_section_pass_through_emits_stub_comment() { - let mut w = DxfWriter::new(); - let objects = [DecodedObject::PassThrough { - type_name: "ACAD_PROPERTYSET_DATA".to_string(), - handle: 0x70, - owner_handle: 0x10, - subclass: None, - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - assert!(s.contains("ACAD_PROPERTYSET_DATA")); - assert!(s.contains("pass-through")); - } - - #[test] - fn r12_objects_section_omits_subclass_markers() { - let mut w = crate::dxf::DxfWriter::with_version(crate::dxf::DxfVersion::R12); - let objects = [DecodedObject::Dictionary { - handle: 0x10, - owner_handle: 0, - hard_owner: true, - entries: vec![("ACAD_LAYOUT".to_string(), 0x1A)], - }]; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - // The whole section is skipped under R12 — OBJECTS is an - // R2000+ section per the DXF reference. - assert!(!s.contains("\nOBJECTS\n")); - assert!(!s.contains("AcDbDictionary")); - } - - #[test] - fn objects_section_empty_emits_section_boundaries() { - let mut w = DxfWriter::new(); - let objects: [DecodedObject; 0] = []; - write_objects_section(&mut w, objects); - w.finish(); - let s = w.take_output(); - // An R2000+ writer with no objects still emits the section - // boundaries so downstream consumers see a well-formed document. - assert!(s.contains("\nOBJECTS\n")); - assert!(s.contains("\nENDSEC\n")); - } -} - // --------------------------------------------------------------------------- // OBJECTS section — non-entity, non-table objects per DXF reference. // --------------------------------------------------------------------------- diff --git a/src/entity_geometry.rs b/src/entity_geometry.rs index f7de541..7a334b0 100644 --- a/src/entity_geometry.rs +++ b/src/entity_geometry.rs @@ -844,974 +844,3 @@ pub fn three_d_solid_to_mesh(_solid: &crate::entities::three_d_solid::ThreeDSoli // a bbox or "??" placeholder if they have one. Ok(Mesh::empty()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn line_to_curve_preserves_endpoints() { - let l = crate::entities::line::Line { - start: Point3D::new(1.0, 2.0, 3.0), - end: Point3D::new(4.0, 5.0, 6.0), - thickness: 0.0, - extrusion: crate::entities::Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - is_2d: false, - }; - match line_to_curve(&l) { - Curve::Line { a, b } => { - assert_eq!(a, l.start); - assert_eq!(b, l.end); - } - _ => panic!("expected Curve::Line"), - } - } - - #[test] - fn point_to_curve_emits_degenerate_line() { - let p = crate::entities::point::Point { - position: Point3D::new(7.0, 8.0, 9.0), - thickness: 0.0, - extrusion: crate::entities::Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - x_axis_angle: 0.0, - }; - match point_to_curve(&p) { - Curve::Line { a, b } => { - assert_eq!(a, p.position); - assert_eq!(b, p.position); - } - _ => panic!("expected degenerate Curve::Line"), - } - } - - #[test] - fn circle_to_curve_preserves_center_and_radius() { - let c = crate::entities::circle::Circle { - center: Point3D::new(10.0, 20.0, 0.0), - radius: 5.0, - thickness: 0.0, - extrusion: crate::entities::Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - }; - match circle_to_curve(&c) { - Curve::Circle { center, radius, .. } => { - assert_eq!(center, c.center); - assert_eq!(radius, c.radius); - } - _ => panic!("expected Curve::Circle"), - } - } - - // ------------------------------------------------------------ - // L8-13 — POLYLINE bulge expansion - // ------------------------------------------------------------ - - /// A 180° (semicircle) arc corresponds to bulge = 1.0. For a unit - /// chord along +X the arc must have radius 0.5 and center at the - /// chord midpoint (0.5, 0). - #[test] - fn bulge_to_arc_semicircle_unit_chord() { - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(1.0, 0.0, 0.0); - let arc = bulge_to_arc(a, b, 1.0).expect("semicircle should decode"); - match arc { - Curve::Arc { - center, - radius, - start_angle, - end_angle, - .. - } => { - assert!((center.x - 0.5).abs() < 1e-12, "center.x = {}", center.x); - assert!((center.y - 0.0).abs() < 1e-12, "center.y = {}", center.y); - assert!((radius - 0.5).abs() < 1e-12, "radius = {}", radius); - let sweep = (end_angle - start_angle).abs(); - let diff = (sweep - std::f64::consts::PI).abs(); - assert!(diff < 1e-12, "sweep = {}", sweep); - } - other => panic!("expected Arc, got {other:?}"), - } - } - - #[test] - fn bulge_to_arc_zero_bulge_is_none() { - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(1.0, 0.0, 0.0); - assert!(bulge_to_arc(a, b, 0.0).is_none()); - } - - #[test] - fn bulge_to_arc_degenerate_chord_is_none() { - let a = Point3D::new(3.0, 4.0, 5.0); - assert!(bulge_to_arc(a, a, 0.25).is_none()); - } - - #[test] - fn bulge_to_arc_non_finite_is_none() { - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(1.0, 0.0, 0.0); - assert!(bulge_to_arc(a, b, f64::NAN).is_none()); - assert!(bulge_to_arc(a, b, f64::INFINITY).is_none()); - } - - #[test] - fn bulge_to_arc_sign_flips_center_side() { - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(2.0, 0.0, 0.0); - let pos = bulge_to_arc(a, b, 0.5).unwrap(); - let neg = bulge_to_arc(a, b, -0.5).unwrap(); - match (pos, neg) { - (Curve::Arc { center: cp, .. }, Curve::Arc { center: cn, .. }) => { - assert!((cp.x - 1.0).abs() < 1e-12); - assert!((cn.x - 1.0).abs() < 1e-12); - assert!(cp.y.signum() != cn.y.signum() || cp.y == 0.0); - } - _ => panic!("expected two arcs"), - } - } - - #[test] - fn polyline_2d_vertices_to_path_mixed_bulges() { - let vertices = [ - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(0.0, 0.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(1.0, 0.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 1.0, - vertex_id: None, - tangent_direction: None, - }, - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(2.0, 0.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - ]; - let path = polyline_2d_vertices_to_path(&vertices, 7.5, false); - assert_eq!(path.segments.len(), 2); - assert!(!path.closed); - assert!(matches!(&path.segments[0], Curve::Line { .. })); - assert!(matches!(&path.segments[1], Curve::Arc { .. })); - if let Curve::Line { a, b } = &path.segments[0] { - assert!((a.z - 7.5).abs() < 1e-12); - assert!((b.z - 7.5).abs() < 1e-12); - } - } - - #[test] - fn polyline_2d_vertices_closed_wraps_to_start() { - let vertices = [ - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(0.0, 0.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(1.0, 0.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(1.0, 1.0, 0.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - ]; - let path = polyline_2d_vertices_to_path(&vertices, 0.0, true); - assert_eq!(path.segments.len(), 3); - assert!(path.closed); - match &path.segments[2] { - Curve::Line { a, b } => { - assert_eq!(a.x, 1.0); - assert_eq!(a.y, 1.0); - assert_eq!(b.x, 0.0); - assert_eq!(b.y, 0.0); - } - other => panic!("expected closing Line, got {other:?}"), - } - } - - #[test] - fn polyline_3d_vertices_preserves_z() { - let vertices = [ - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(0.0, 0.0, -1.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(10.0, 0.0, 5.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - ]; - let path = polyline_3d_vertices_to_path(&vertices, false); - assert_eq!(path.segments.len(), 1); - if let Curve::Line { a, b } = &path.segments[0] { - assert_eq!(a.z, -1.0); - assert_eq!(b.z, 5.0); - } else { - panic!("expected 3D Line"); - } - } - - #[test] - fn polyline_to_path_dispatches_on_is_3d() { - let vs = [crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(1.0, 2.0, 3.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }]; - let hdr_3d = crate::entities::polyline::Polyline { - flag: 0x08, - curve_type: 0, - default_start_width: 0.0, - default_end_width: 0.0, - thickness: 0.0, - elevation: 999.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - }; - let path = polyline_to_path(&hdr_3d, &vs); - assert!(path.segments.is_empty()); - - let hdr_2d = crate::entities::polyline::Polyline { - flag: 0x00, - elevation: 42.0, - ..hdr_3d - }; - let two = [ - vs[0].clone(), - crate::entities::vertex::Vertex { - flag: 0, - location: Point3D::new(5.0, 6.0, 7.0), - start_width: 0.0, - end_width: 0.0, - bulge: 0.0, - vertex_id: None, - tangent_direction: None, - }, - ]; - let path2 = polyline_to_path(&hdr_2d, &two); - assert_eq!(path2.segments.len(), 1); - if let Curve::Line { a, b } = &path2.segments[0] { - assert!((a.z - 42.0).abs() < 1e-12); - assert!((b.z - 42.0).abs() < 1e-12); - } - } - - // ------------------------------------------------------------ - // L8-15 — HATCH → boundary Paths - // ------------------------------------------------------------ - - /// Build a minimal solid-fill [`Hatch`] with the caller's paths list. - fn stub_hatch(paths: Vec) -> crate::entities::hatch::Hatch { - crate::entities::hatch::Hatch { - pattern_type: 1, - gradient: None, - elevation: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - pattern_name: "SOLID".into(), - solid_fill: true, - associative: false, - paths, - pattern_style: 0, - pattern_angle: 0.0, - pattern_scale: 1.0, - pattern_double: false, - pattern_lines: Vec::new(), - pixel_size: 0.0, - seed_points: Vec::new(), - } - } - - /// A HATCH with zero paths yields an empty `Vec`. - #[test] - fn hatch_to_paths_empty_hatch() { - let h = stub_hatch(Vec::new()); - let paths = hatch_to_paths(&h); - assert!(paths.is_empty()); - } - - /// A polyline boundary path with a semicircle bulge decodes to one - /// Line + one Arc segment. - #[test] - fn hatch_to_paths_polyline_with_bulge() { - use crate::entities::Point2D; - use crate::entities::hatch::{HatchPath, HatchPathSegments}; - let hp = HatchPath { - flags: 0, - segments: HatchPathSegments::Polyline { - has_bulge: true, - is_closed: false, - vertices: vec![ - (Point2D { x: 0.0, y: 0.0 }, None), - (Point2D { x: 1.0, y: 0.0 }, Some(1.0)), // semicircle to next - (Point2D { x: 2.0, y: 0.0 }, None), - ], - }, - num_boundary_handles: 0, - }; - let h = stub_hatch(vec![hp]); - let paths = hatch_to_paths(&h); - assert_eq!(paths.len(), 1); - assert_eq!(paths[0].segments.len(), 2); - assert!(matches!(&paths[0].segments[0], Curve::Line { .. })); - assert!(matches!(&paths[0].segments[1], Curve::Arc { .. })); - } - - /// Edge-list boundary path: LINE + ARC edges → path with those - /// [`Curve`] variants. - #[test] - fn hatch_to_paths_edges_line_and_arc() { - use crate::entities::Point2D; - use crate::entities::hatch::{HatchEdge, HatchPath, HatchPathSegments}; - let hp = HatchPath { - flags: 0, - segments: HatchPathSegments::Edges(vec![ - HatchEdge::Line { - start: Point2D { x: 0.0, y: 0.0 }, - end: Point2D { x: 10.0, y: 0.0 }, - }, - HatchEdge::Arc { - center: Point2D { x: 10.0, y: 5.0 }, - radius: 5.0, - start_angle: -std::f64::consts::FRAC_PI_2, - end_angle: std::f64::consts::FRAC_PI_2, - counter_clockwise: true, - }, - ]), - num_boundary_handles: 0, - }; - let h = stub_hatch(vec![hp]); - let paths = hatch_to_paths(&h); - assert_eq!(paths.len(), 1); - assert_eq!(paths[0].segments.len(), 2); - assert!(matches!(&paths[0].segments[0], Curve::Line { .. })); - match &paths[0].segments[1] { - Curve::Arc { radius, center, .. } => { - assert_eq!(*radius, 5.0); - assert_eq!(center.x, 10.0); - } - other => panic!("expected Arc, got {other:?}"), - } - } - - /// Edge-list Ellipse carries center / major_axis / ratio through - /// to [`Curve::Ellipse`]. - #[test] - fn hatch_to_paths_edges_ellipse() { - use crate::entities::Point2D; - use crate::entities::hatch::{HatchEdge, HatchPath, HatchPathSegments}; - let hp = HatchPath { - flags: 0, - segments: HatchPathSegments::Edges(vec![HatchEdge::Ellipse { - center: Point2D { x: 1.0, y: 2.0 }, - endpoint: Point2D { x: 3.0, y: 0.0 }, - axis_ratio: 0.5, - start_angle: 0.0, - end_angle: std::f64::consts::TAU, - counter_clockwise: true, - }]), - num_boundary_handles: 0, - }; - let h = stub_hatch(vec![hp]); - let paths = hatch_to_paths(&h); - assert_eq!(paths.len(), 1); - match &paths[0].segments[0] { - Curve::Ellipse { - center, - major_axis, - ratio, - .. - } => { - assert_eq!(center.x, 1.0); - assert_eq!(center.y, 2.0); - assert_eq!(major_axis.x, 3.0); - assert_eq!(*ratio, 0.5); - } - other => panic!("expected Ellipse, got {other:?}"), - } - } - - // ------------------------------------------------------------ - // L8-16 — TEXT → TextBaseline curve - // ------------------------------------------------------------ - - fn stub_text( - content: &str, - ip: (f64, f64), - elev: f64, - height: f64, - rot: f64, - ) -> crate::entities::text::Text { - crate::entities::text::Text { - elevation: elev, - insertion_point: crate::entities::Point2D { x: ip.0, y: ip.1 }, - alignment_point: None, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - thickness: 0.0, - oblique_angle: 0.0, - rotation_angle: rot, - height, - width_factor: 1.0, - text: content.into(), - generation: 0, - h_align: 0, - v_align: 0, - } - } - - #[test] - fn text_to_curve_emits_text_baseline() { - let t = stub_text("HELLO", (10.0, 20.0), 3.0, 2.5, 0.0); - match text_to_curve(&t).unwrap() { - Curve::TextBaseline { - insertion, - height, - rotation, - content, - style_name, - } => { - assert_eq!(insertion, Point3D::new(10.0, 20.0, 3.0)); - assert_eq!(height, 2.5); - assert_eq!(rotation, 0.0); - assert_eq!(content, "HELLO"); - assert!(style_name.is_none()); - } - other => panic!("expected TextBaseline, got {other:?}"), - } - } - - #[test] - fn text_to_curve_preserves_rotation_and_empty_content() { - let t = stub_text("", (0.0, 0.0), 0.0, 1.0, std::f64::consts::FRAC_PI_2); - match text_to_curve(&t).unwrap() { - Curve::TextBaseline { - insertion, - rotation, - content, - .. - } => { - assert_eq!(insertion, Point3D::new(0.0, 0.0, 0.0)); - assert!((rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12); - assert_eq!(content, ""); - } - other => panic!("expected TextBaseline, got {other:?}"), - } - } - - #[test] - fn text_to_curve_bounds_axis_aligned() { - let t = stub_text("AB", (0.0, 0.0), 0.0, 2.0, 0.0); - let c = text_to_curve(&t).unwrap(); - let b = c.bounds(); - assert!((b.min.x - 0.0).abs() < 1e-9); - assert!((b.min.y - 0.0).abs() < 1e-9); - assert!((b.max.x - 4.0).abs() < 1e-9); - assert!((b.max.y - 2.0).abs() < 1e-9); - } - - // ------------------------------------------------------------ - // L8-17 — DIMENSION → ext + dim + arrows + label - // ------------------------------------------------------------ - - fn stub_common() -> crate::entities::dimension::DimensionCommon { - crate::entities::dimension::DimensionCommon { - version_flag: 0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - text_midpoint: crate::entities::Point2D { x: 0.0, y: 0.0 }, - elevation: 0.0, - flags: 0, - user_text: String::new(), - text_rotation: 0.0, - horiz_dir: 0.0, - ins_scale: Point3D::new(1.0, 1.0, 1.0), - ins_rotation: 0.0, - attachment: 0, - line_spacing_style: 0, - line_spacing_factor: 1.0, - actual_measurement: 0.0, - def_point_12: crate::entities::Point2D { x: 0.0, y: 0.0 }, - flip_arrow_1: false, - flip_arrow_2: false, - } - } - - #[test] - fn dimension_to_paths_linear_emits_full_bundle() { - let d = crate::entities::dimension::Dimension::Linear( - crate::entities::dimension::LinearDimension { - common: stub_common(), - def_point_13: Point3D::new(0.0, 0.0, 0.0), - def_point_14: Point3D::new(10.0, 0.0, 0.0), - def_point_10: Point3D::new(5.0, 5.0, 0.0), - extension_line_rotation: 0.0, - dim_rotation: 0.0, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 6, "2 ext + 1 dim + 2 arrows + 1 label"); - assert!(matches!(&paths[0].segments[0], Curve::Line { .. })); - assert!(matches!(&paths[1].segments[0], Curve::Line { .. })); - if let Curve::Line { a, b } = &paths[2].segments[0] { - assert!((a.x - 0.0).abs() < 1e-9); - assert!((a.y - 5.0).abs() < 1e-9); - assert!((b.x - 10.0).abs() < 1e-9); - assert!((b.y - 5.0).abs() < 1e-9); - } else { - panic!("dim line should be a Line"); - } - assert!(paths[3].closed); - assert!(paths[4].closed); - assert!(matches!(&paths[5].segments[0], Curve::TextBaseline { .. })); - } - - #[test] - fn dimension_to_paths_aligned_emits_full_bundle() { - let d = crate::entities::dimension::Dimension::Aligned( - crate::entities::dimension::AlignedDimension { - common: stub_common(), - def_point_13: Point3D::new(0.0, 0.0, 0.0), - def_point_14: Point3D::new(3.0, 4.0, 0.0), - def_point_10: Point3D::new(0.0, 4.0, 0.0), - extension_line_rotation: 0.0, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 6); - } - - #[test] - fn dimension_to_paths_linear_degenerate_axis() { - let d = crate::entities::dimension::Dimension::Linear( - crate::entities::dimension::LinearDimension { - common: stub_common(), - def_point_13: Point3D::new(1.0, 1.0, 0.0), - def_point_14: Point3D::new(1.0, 1.0, 0.0), - def_point_10: Point3D::new(2.0, 2.0, 0.0), - extension_line_rotation: 0.0, - dim_rotation: 0.0, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 1); - } - - #[test] - fn dimension_to_paths_radius_emits_line_and_arrow() { - let d = crate::entities::dimension::Dimension::Radius( - crate::entities::dimension::RadiusDimension { - common: stub_common(), - def_point_10: Point3D::new(0.0, 0.0, 0.0), - def_point_15: Point3D::new(5.0, 0.0, 0.0), - leader_length: 2.5, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 2); - match &paths[0].segments[0] { - Curve::Line { a, b } => { - assert_eq!(*a, Point3D::new(0.0, 0.0, 0.0)); - assert_eq!(*b, Point3D::new(5.0, 0.0, 0.0)); - } - other => panic!("expected Line, got {other:?}"), - } - assert!(paths[1].closed); - } - - #[test] - fn dimension_to_paths_diameter_emits_line_and_two_arrows() { - let d = crate::entities::dimension::Dimension::Diameter( - crate::entities::dimension::DiameterDimension { - common: stub_common(), - def_point_15: Point3D::new(-5.0, 0.0, 0.0), - def_point_10: Point3D::new(5.0, 0.0, 0.0), - leader_length: 0.0, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 3); - assert!(paths[1].closed); - assert!(paths[2].closed); - } - - #[test] - fn dimension_to_paths_angular3pt_emits_three_paths() { - let d = crate::entities::dimension::Dimension::Angular3Pt( - crate::entities::dimension::Angular3PtDimension { - common: stub_common(), - def_point_10: Point3D::new(0.0, 0.0, 0.0), - def_point_13: Point3D::new(1.0, 0.0, 0.0), - def_point_14: Point3D::new(0.0, 1.0, 0.0), - def_point_15: Point3D::new(0.0, 0.0, 0.0), - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 3); - } - - #[test] - fn dimension_to_paths_ordinate_emits_one_leader() { - let d = crate::entities::dimension::Dimension::Ordinate( - crate::entities::dimension::OrdinateDimension { - common: stub_common(), - def_point_10: Point3D::new(0.0, 0.0, 0.0), - feature_location_13: Point3D::new(3.0, 0.0, 0.0), - leader_endpoint_14: Point3D::new(5.0, 5.0, 0.0), - flag_2: 0, - }, - ); - let paths = dimension_to_paths(&d).unwrap(); - assert_eq!(paths.len(), 1); - assert_eq!(paths[0].segments.len(), 1); - } - - // ------------------------------------------------------------ - // L8-14 — SPLINE → Curve - // ------------------------------------------------------------ - - fn stub_control_spline(rational: bool, weights: Vec) -> crate::entities::spline::Spline { - crate::entities::spline::Spline { - scenario: 1, - flag1: None, - knot_param: None, - degree: 3, - fit: None, - control: Some(crate::entities::spline::ControlForm { - rational, - closed: false, - periodic: false, - knot_tolerance: 1e-6, - control_tolerance: 1e-6, - knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], - control_points: vec![ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 2.0, 0.0), - Point3D::new(2.0, 2.0, 0.0), - Point3D::new(3.0, 0.0, 0.0), - ], - weights, - }), - } - } - - #[test] - fn spline_to_curve_control_non_rational() { - let s = stub_control_spline(false, Vec::new()); - match spline_to_curve(&s).unwrap() { - Curve::Spline(curve_s) => { - assert_eq!(curve_s.degree, 3); - assert_eq!(curve_s.control_points.len(), 4); - assert_eq!(curve_s.knots.len(), 8); - assert!(curve_s.weights.is_empty()); - assert!(!curve_s.closed); - } - other => panic!("expected Curve::Spline, got {other:?}"), - } - } - - #[test] - fn spline_to_curve_uniform_weights_drop_to_non_rational() { - let s = stub_control_spline(true, vec![1.5, 1.5, 1.5, 1.5]); - match spline_to_curve(&s).unwrap() { - Curve::Spline(curve_s) => assert!(curve_s.weights.is_empty()), - other => panic!("expected Spline, got {other:?}"), - } - } - - #[test] - fn spline_to_curve_varying_weights_preserved() { - let s = stub_control_spline(true, vec![1.0, 2.0, 0.5, 1.0]); - match spline_to_curve(&s).unwrap() { - Curve::Spline(curve_s) => { - assert_eq!(curve_s.weights.len(), 4); - assert_eq!(curve_s.weights[1], 2.0); - } - other => panic!("expected Spline, got {other:?}"), - } - } - - #[test] - fn spline_to_curve_fit_form_degrades_to_polyline() { - let s = crate::entities::spline::Spline { - scenario: 2, - flag1: None, - knot_param: None, - degree: 3, - fit: Some(crate::entities::spline::FitForm { - tolerance: 0.01, - begin_tangent: Vec3D { - x: 1.0, - y: 0.0, - z: 0.0, - }, - end_tangent: Vec3D { - x: 0.0, - y: 1.0, - z: 0.0, - }, - fit_points: vec![ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.5, 0.0), - Point3D::new(2.0, 0.0, 0.0), - ], - }), - control: None, - }; - match spline_to_curve(&s).unwrap() { - Curve::Polyline { vertices, closed } => { - assert_eq!(vertices.len(), 3); - assert_eq!(vertices[1].point, Point3D::new(1.0, 0.5, 0.0)); - assert!(!closed); - } - other => panic!("expected fit-fallback Polyline, got {other:?}"), - } - } - - #[test] - fn spline_to_curve_clamps_insane_degree() { - let mut s = stub_control_spline(false, Vec::new()); - s.degree = 999; - match spline_to_curve(&s).unwrap() { - Curve::Spline(c) => assert_eq!(c.degree, 15), - other => panic!("expected Spline, got {other:?}"), - } - s.degree = -5; - match spline_to_curve(&s).unwrap() { - Curve::Spline(c) => assert_eq!(c.degree, 1), - other => panic!("expected Spline, got {other:?}"), - } - } - - // ------------------------------------------------------------ - // L8-18 — INSERT → composite transform - // ------------------------------------------------------------ - - #[test] - fn insert_to_transform_identity_is_identity() { - let i = crate::entities::insert::Insert { - owned_object_count: 0, - insertion_point: Point3D::new(0.0, 0.0, 0.0), - scale: Point3D::new(1.0, 1.0, 1.0), - rotation: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - has_attribs: false, - }; - let t = insert_to_transform(&i); - let p = Point3D::new(2.0, 3.0, 4.0); - let q = t.transform_point(p); - assert!((q.x - p.x).abs() < 1e-12); - assert!((q.y - p.y).abs() < 1e-12); - assert!((q.z - p.z).abs() < 1e-12); - } - - #[test] - fn insert_to_transform_translation_moves_origin() { - let i = crate::entities::insert::Insert { - owned_object_count: 0, - insertion_point: Point3D::new(10.0, 20.0, 30.0), - scale: Point3D::new(1.0, 1.0, 1.0), - rotation: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - has_attribs: false, - }; - let t = insert_to_transform(&i); - let q = t.transform_point(Point3D::new(0.0, 0.0, 0.0)); - assert!((q.x - 10.0).abs() < 1e-9); - assert!((q.y - 20.0).abs() < 1e-9); - assert!((q.z - 30.0).abs() < 1e-9); - } - - #[test] - fn insert_to_transform_scale_and_rotate_and_translate() { - // Verify each step independently then compose: scale (2,3,1), - // rotation 90° CCW Z, translate to (10, 20, 0). Apply order - // matches `insert_to_transform`'s `compose_chain`. - // - // Local point (1, 0, 0): - // scale → (2, 0, 0) - // rotate → (0, 2, 0) - // extrude (+Z normal, identity) - // translate → (10, 22, 0) - let i = crate::entities::insert::Insert { - owned_object_count: 0, - insertion_point: Point3D::new(10.0, 20.0, 0.0), - scale: Point3D::new(2.0, 3.0, 1.0), - rotation: std::f64::consts::FRAC_PI_2, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - has_attribs: false, - }; - let t = insert_to_transform(&i); - let q = t.transform_point(Point3D::new(1.0, 0.0, 0.0)); - // The compose_chain helper applies in chain order (left to right); - // verify the composed result lands at the expected world point. - // Expected: scale → (2,0,0); rotate +90° → (0,2,0); +translate - // (10, 20, 0) → (10, 22, 0). - assert!((q.x - 10.0).abs() < 1e-9, "x = {}", q.x); - assert!((q.y - 22.0).abs() < 1e-9, "y = {}", q.y); - assert!((q.z - 0.0).abs() < 1e-9, "z = {}", q.z); - } - - // ------------------------------------------------------------ - // L8-19 — 3DFACE quad/triangle dispatch - // ------------------------------------------------------------ - - #[test] - fn three_d_face_to_mesh_triangle_emits_one_triangle() { - let f = crate::entities::three_d_face::ThreeDFace { - corners: [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ], - invisible_edges: 0, - is_triangle: true, - }; - let m = three_d_face_to_mesh(&f).unwrap(); - assert_eq!(m.triangles.len(), 1); - assert_eq!(m.vertices.len(), 3); - } - - #[test] - fn three_d_face_to_mesh_collapsed_quad_emits_triangle() { - let f = crate::entities::three_d_face::ThreeDFace { - corners: [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - ], - invisible_edges: 0, - is_triangle: false, - }; - let m = three_d_face_to_mesh(&f).unwrap(); - assert_eq!(m.triangles.len(), 1, "collapsed quad should be 1 tri"); - assert_eq!(m.vertices.len(), 3); - } - - #[test] - fn three_d_face_to_mesh_real_quad_emits_two_triangles() { - let f = crate::entities::three_d_face::ThreeDFace { - corners: [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ], - invisible_edges: 0, - is_triangle: false, - }; - let m = three_d_face_to_mesh(&f).unwrap(); - assert_eq!(m.triangles.len(), 2); - assert_eq!(m.vertices.len(), 4); - } - - // ------------------------------------------------------------ - // L8-20 — 3DSOLID → placeholder mesh - // ------------------------------------------------------------ - - #[test] - fn three_d_solid_to_mesh_empty_returns_empty_mesh() { - let s = crate::entities::three_d_solid::ThreeDSolid { - acis_empty: true, - version: None, - sat_blob: None, - in_data_store: false, - tail: None, - }; - let m = three_d_solid_to_mesh(&s).unwrap(); - assert!(m.vertices.is_empty()); - assert!(m.triangles.is_empty()); - } - - #[test] - fn three_d_solid_to_mesh_with_blob_returns_empty_mesh() { - let s = crate::entities::three_d_solid::ThreeDSolid { - acis_empty: false, - version: Some(1), - sat_blob: Some(b"some opaque ACIS bytes".to_vec()), - in_data_store: false, - tail: None, - }; - let m = three_d_solid_to_mesh(&s).unwrap(); - assert!(m.vertices.is_empty()); - } -} diff --git a/src/geometry.rs b/src/geometry.rs index 6228680..020d61c 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -540,389 +540,3 @@ impl Mesh { // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - const EPS: f64 = 1e-12; - - fn approx(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-9 - } - - fn approx_v(a: Vec3D, b: Vec3D) -> bool { - approx(a.x, b.x) && approx(a.y, b.y) && approx(a.z, b.z) - } - - #[test] - fn point3d_add_sub() { - let p = Point3D::new(1.0, 2.0, 3.0); - let v = Vec3D { - x: 10.0, - y: 20.0, - z: 30.0, - }; - let q = p.add(v); - assert_eq!(q, Point3D::new(11.0, 22.0, 33.0)); - let back = q.sub(p); - assert!(approx_v(back, v)); - } - - #[test] - fn point3d_distance_and_lerp() { - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(3.0, 4.0, 0.0); - assert!(approx(a.distance(b), 5.0)); - let mid = a.lerp(b, 0.5); - assert_eq!(mid, Point3D::new(1.5, 2.0, 0.0)); - } - - #[test] - fn point2d_distance_and_lerp() { - let a = Point2D::new(0.0, 0.0); - let b = Point2D::new(6.0, 8.0); - assert!(approx(a.distance(b), 10.0)); - let mid = a.lerp(b, 0.25); - assert_eq!(mid, Point2D::new(1.5, 2.0)); - } - - #[test] - fn vec3d_dot_cross_length_normalize() { - let x = Vec3D { - x: 1.0, - y: 0.0, - z: 0.0, - }; - let y = Vec3D { - x: 0.0, - y: 1.0, - z: 0.0, - }; - let z = Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }; - assert_eq!(x.dot(y), 0.0); - assert!(approx_v(x.cross(y), z)); - assert!(approx_v(y.cross(z), x)); - assert!(approx_v(z.cross(x), y)); - let v = Vec3D { - x: 3.0, - y: 4.0, - z: 0.0, - }; - assert!(approx(v.length(), 5.0)); - assert!(approx(v.normalize(EPS).length(), 1.0)); - } - - #[test] - fn vec3d_normalize_zero_returns_zero() { - let zero = Vec3D { - x: 0.0, - y: 0.0, - z: 0.0, - }; - let n = zero.normalize(EPS); - assert_eq!(n, zero); - } - - #[test] - fn transform_identity_is_identity() { - let t = Transform3::identity(); - let p = Point3D::new(7.0, 11.0, 13.0); - assert_eq!(t.transform_point(p), p); - } - - #[test] - fn transform_translation_moves_point() { - let t = Transform3::translation(10.0, 20.0, 30.0); - let p = Point3D::new(1.0, 2.0, 3.0); - assert_eq!(t.transform_point(p), Point3D::new(11.0, 22.0, 33.0)); - // Translation should NOT affect vectors. - let v = Vec3D { - x: 1.0, - y: 2.0, - z: 3.0, - }; - assert_eq!(t.transform_vector(v), v); - } - - #[test] - fn transform_scale_applies_uniformly() { - let t = Transform3::scale_uniform(2.5); - let p = Point3D::new(1.0, 2.0, 3.0); - assert_eq!(t.transform_point(p), Point3D::new(2.5, 5.0, 7.5)); - } - - #[test] - fn transform_compose_applies_in_order() { - // translate(5, 0, 0) then rotate 90° around Z - // A point at (1, 0, 0): - // after translate → (6, 0, 0) - // after rotate 90° Z (CCW) → (0, 6, 0) - let translate = Transform3::translation(5.0, 0.0, 0.0); - let rotate = Transform3::rotation_z(std::f64::consts::FRAC_PI_2); - // compose: `rotate * translate` means apply translate first, then rotate. - let composed = rotate.compose(&translate); - let result = composed.transform_point(Point3D::new(1.0, 0.0, 0.0)); - assert!(approx(result.x, 0.0)); - assert!(approx(result.y, 6.0)); - assert!(approx(result.z, 0.0)); - } - - #[test] - fn transform_rotation_z_rotates_x_to_y() { - let r = Transform3::rotation_z(std::f64::consts::FRAC_PI_2); - let x = Point3D::new(1.0, 0.0, 0.0); - let y = r.transform_point(x); - assert!(approx(y.x, 0.0)); - assert!(approx(y.y, 1.0)); - assert!(approx(y.z, 0.0)); - } - - #[test] - fn bbox_empty_union_is_identity() { - let e = BBox3::empty(); - assert!(e.is_empty()); - let p = Point3D::new(1.0, 2.0, 3.0); - let b = e.expand(p); - assert!(!b.is_empty()); - assert_eq!(b.min, p); - assert_eq!(b.max, p); - assert_eq!(e.union(&b), b); - } - - #[test] - fn bbox_union_and_contains() { - let a = BBox3 { - min: Point3D::new(-1.0, -1.0, -1.0), - max: Point3D::new(1.0, 1.0, 1.0), - }; - let b = BBox3 { - min: Point3D::new(0.0, 0.0, 0.0), - max: Point3D::new(5.0, 5.0, 5.0), - }; - let u = a.union(&b); - assert_eq!(u.min, Point3D::new(-1.0, -1.0, -1.0)); - assert_eq!(u.max, Point3D::new(5.0, 5.0, 5.0)); - assert!(u.contains(Point3D::new(0.0, 0.0, 0.0))); - assert!(u.contains(Point3D::new(-1.0, -1.0, -1.0))); - assert!(u.contains(Point3D::new(5.0, 5.0, 5.0))); - assert!(!u.contains(Point3D::new(6.0, 0.0, 0.0))); - } - - #[test] - fn bbox_size_and_center() { - let b = BBox3 { - min: Point3D::new(0.0, 0.0, 0.0), - max: Point3D::new(10.0, 20.0, 30.0), - }; - let s = b.size(); - assert_eq!(s.x, 10.0); - assert_eq!(s.y, 20.0); - assert_eq!(s.z, 30.0); - let c = b.center(); - assert_eq!(c, Point3D::new(5.0, 10.0, 15.0)); - } - - #[test] - fn bbox_empty_size_is_zero() { - let e = BBox3::empty(); - assert_eq!( - e.size(), - Vec3D { - x: 0.0, - y: 0.0, - z: 0.0 - } - ); - } - - #[test] - fn bbox_expand_sequence_accumulates() { - let mut b = BBox3::empty(); - for (x, y, z) in [(0.0, 0.0, 0.0), (3.0, -1.0, 5.0), (-2.0, 4.0, 1.0)] { - b = b.expand(Point3D::new(x, y, z)); - } - assert_eq!(b.min, Point3D::new(-2.0, -1.0, 0.0)); - assert_eq!(b.max, Point3D::new(3.0, 4.0, 5.0)); - } - - #[test] - fn transform_rotation_preserves_length() { - let r = Transform3::rotation_z(1.234); - let v = Vec3D { - x: 3.0, - y: 4.0, - z: 5.0, - }; - let rotated = r.transform_vector(v); - assert!(approx(rotated.length(), v.length())); - } - - #[test] - fn transform_compose_identity() { - let i = Transform3::identity(); - let t = Transform3::translation(1.0, 2.0, 3.0); - assert_eq!(i.compose(&t), t); - assert_eq!(t.compose(&i), t); - } - - #[test] - fn mesh_push_triangle_appends_three_vertices() { - let mut m = Mesh::empty(); - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(1.0, 0.0, 0.0); - let c = Point3D::new(0.0, 1.0, 0.0); - let idx = m.push_triangle(a, b, c); - assert_eq!(idx, 0); - assert_eq!(m.vertices.len(), 3); - assert_eq!(m.triangles.len(), 1); - assert_eq!(m.triangles[0], [0, 1, 2]); - } - - #[test] - fn mesh_push_quad_splits_into_two_triangles() { - let mut m = Mesh::empty(); - let a = Point3D::new(0.0, 0.0, 0.0); - let b = Point3D::new(1.0, 0.0, 0.0); - let c = Point3D::new(1.0, 1.0, 0.0); - let d = Point3D::new(0.0, 1.0, 0.0); - m.push_quad(a, b, c, d); - assert_eq!(m.vertices.len(), 4); - assert_eq!(m.triangles.len(), 2); - assert_eq!(m.triangles[0], [0, 1, 2]); - assert_eq!(m.triangles[1], [0, 2, 3]); - } - - #[test] - fn mesh_bounds_unions_vertices() { - let mut m = Mesh::empty(); - m.push_triangle( - Point3D::new(-1.0, 0.0, 0.0), - Point3D::new(2.0, 3.0, 0.0), - Point3D::new(0.0, -4.0, 5.0), - ); - let b = m.bounds(); - assert_eq!(b.min, Point3D::new(-1.0, -4.0, 0.0)); - assert_eq!(b.max, Point3D::new(2.0, 3.0, 5.0)); - } - - #[test] - fn mesh_empty_has_empty_bounds() { - let m = Mesh::empty(); - assert!(m.bounds().is_empty()); - } - - // ----------------------------------------------------------------- - // L8-01 / L8-02 / L8-03 / L8-04 transform composition tests - // ----------------------------------------------------------------- - - #[test] - fn ucs_from_axes_round_trips_via_invert_orthonormal() { - let origin = Point3D::new(10.0, 20.0, 30.0); - let x = Vec3D { - x: 0.0, - y: 1.0, - z: 0.0, - }; - let y = Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }; - let to_wcs = Transform3::ucs_from_axes(origin, x, y); - let to_ucs = to_wcs.invert_orthonormal(); - let p = Point3D::new(1.0, 2.0, 3.0); - let world = to_wcs.transform_point(p); - let back = to_ucs.transform_point(world); - assert!(approx(back.x, p.x)); - assert!(approx(back.y, p.y)); - assert!(approx(back.z, p.z)); - } - - #[test] - fn arbitrary_axis_world_z_normal_is_identity_xy() { - // Normal pointing along +Z should give Ax = +X, Ay = +Y - // (the standard WCS-aligned UCS). - let n = Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }; - let t = Transform3::arbitrary_axis(n); - let p = Point3D::new(1.0, 0.0, 0.0); - let q = t.transform_point(p); - assert!(approx(q.x, 1.0)); - assert!(approx(q.y, 0.0)); - assert!(approx(q.z, 0.0)); - } - - #[test] - fn arbitrary_axis_uses_world_y_when_normal_near_z() { - // Normal that's *almost* +Z but with tiny X+Y components below - // the 1/64 threshold should still pick world Z as the pivot - // (per the spec algorithm). - let n = Vec3D { - x: 0.0001, - y: 0.0001, - z: 1.0, - }; - let t = Transform3::arbitrary_axis(n); - // Just verify it produces a valid (orthonormal) transform — - // applying twice with the inverse should be near-identity. - let inv = t.invert_orthonormal(); - let p = Point3D::new(2.0, 3.0, 4.0); - let round = inv.transform_point(t.transform_point(p)); - assert!(approx(round.x, p.x)); - assert!(approx(round.y, p.y)); - assert!(approx(round.z, p.z)); - } - - #[test] - fn then_alias_matches_compose() { - let a = Transform3::translation(5.0, 0.0, 0.0); - let b = Transform3::rotation_z(std::f64::consts::FRAC_PI_2); - // a.then(b) should equal b.compose(a) per the doc — i.e., - // "first a (translate), then b (rotate)". - let p = Point3D::new(1.0, 0.0, 0.0); - let via_then = a.then(&b).transform_point(p); - let via_compose = b.compose(&a).transform_point(p); - assert_eq!(via_then, via_compose); - } - - #[test] - fn compose_chain_empty_is_identity() { - let t = Transform3::compose_chain(&[]); - let p = Point3D::new(7.0, 8.0, 9.0); - assert_eq!(t.transform_point(p), p); - } - - #[test] - fn compose_chain_single_is_self() { - let t1 = Transform3::translation(10.0, 0.0, 0.0); - let composed = Transform3::compose_chain(&[t1]); - let p = Point3D::new(0.0, 0.0, 0.0); - assert_eq!(composed.transform_point(p), Point3D::new(10.0, 0.0, 0.0)); - } - - #[test] - fn compose_chain_two_translations_sum() { - let t1 = Transform3::translation(10.0, 0.0, 0.0); - let t2 = Transform3::translation(0.0, 20.0, 0.0); - let composed = Transform3::compose_chain(&[t1, t2]); - let p = Point3D::new(0.0, 0.0, 0.0); - let q = composed.transform_point(p); - assert!(approx(q.x, 10.0)); - assert!(approx(q.y, 20.0)); - } - - #[test] - fn invert_orthonormal_of_identity_is_identity() { - let i = Transform3::identity(); - let inv = i.invert_orthonormal(); - assert_eq!(inv, i); - } -} diff --git a/src/gltf.rs b/src/gltf.rs index a4c2c48..7b48a92 100644 --- a/src/gltf.rs +++ b/src/gltf.rs @@ -1127,540 +1127,3 @@ fn escape_f32(v: f32) -> String { "0".to_string() } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::entities::Point3D; - - #[test] - fn empty_doc_has_valid_asset_block() { - let doc = GltfDoc::new("empty_scene"); - let (json, bin) = doc.finish(); - assert!(json.contains("\"asset\"")); - assert!(json.contains("\"version\": \"2.0\"")); - assert!(json.contains("\"generator\"")); - assert!(json.contains("\"scene\": 0")); - assert!(json.contains("\"name\": \"empty_scene\"")); - assert!(bin.is_empty()); - } - - #[test] - fn add_mesh_produces_meshes_zero_entry_with_right_indices() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [0.5, 0.5, 0.5, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - let idx = doc.add_mesh("triangle", &mesh, mat); - assert_eq!(idx, 0); - let (json, _) = doc.finish(); - assert!(json.contains("\"name\": \"triangle\"")); - // POSITION accessor is index 0, indices accessor is index 1 for mesh 0. - assert!(json.contains("\"POSITION\": 0")); - assert!(json.contains("\"indices\": 1")); - assert!(json.contains("\"material\": 0")); - // mode 4 = TRIANGLES - assert!(json.contains("\"mode\": 4")); - } - - #[test] - fn add_material_red_aci_maps_to_base_color_factor() { - let mut doc = GltfDoc::new("s"); - let idx = doc.add_layer_material("red_layer", 1); - assert_eq!(idx, 0); - let (json, _) = doc.finish(); - // ACI 1 = pure red → [1.0, 0.0, 0.0, 1.0]. - // f32 of 1.0 formats as "1", 0.0 as "0". - assert!( - json.contains("[1, 0, 0, 1]"), - "expected [1, 0, 0, 1] in JSON, got: {json}" - ); - assert!(json.contains("\"name\": \"red_layer\"")); - assert!(json.contains("\"metallicFactor\": 0")); - assert!(json.contains("\"roughnessFactor\": 0.8")); - } - - #[test] - fn add_layer_material_white_aci_seven_emits_pure_white_base_color() { - // L10-02 acceptance test: layer "0" defaults to ACI 7 - // (white/black). Round-trip into glTF must produce - // baseColorFactor = [1, 1, 1, 1]. - let mut doc = GltfDoc::new("s"); - doc.add_layer_material("0", 7); - let (json, _) = doc.finish(); - assert!( - json.contains("[1, 1, 1, 1]"), - "expected ACI 7 (white) → [1, 1, 1, 1] in JSON, got: {json}" - ); - assert!(json.contains("\"name\": \"0\"")); - } - - #[test] - fn add_layer_material_red_aci_one_produces_pure_red_rgb_components() { - // L10-06 verification: ACI 1 (red) → [1, 0, 0]. Pinned - // separately from the [1,0,0,1] check above so the RGB-only - // contract is explicit and changes to alpha-handling don't - // silently invalidate L10-06. - let (r, g, b) = aci_to_rgb(1); - assert_eq!((r, g, b), (255, 0, 0)); - let mut doc = GltfDoc::new("s"); - doc.add_layer_material("red", 1); - let (json, _) = doc.finish(); - // The RGB tail "1, 0, 0" must precede the alpha component. - assert!(json.contains("[1, 0, 0, 1]")); - } - - #[test] - fn add_mesh_writes_vertex_and_index_data_at_right_offsets() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - // Three distinct points so we can pattern-match on the bytes. - mesh.push_triangle( - Point3D::new(1.0, 2.0, 3.0), - Point3D::new(4.0, 5.0, 6.0), - Point3D::new(7.0, 8.0, 9.0), - ); - doc.add_mesh("t", &mesh, mat); - let (json, bin) = doc.finish(); - // 3 vertices * 12 bytes + 3 indices * 4 bytes = 36 + 12 = 48. - assert_eq!(bin.len(), 48); - // First vertex: (1.0, 2.0, 3.0) as f32 LE. - let x: [u8; 4] = bin[0..4].try_into().unwrap(); - let y: [u8; 4] = bin[4..8].try_into().unwrap(); - let z: [u8; 4] = bin[8..12].try_into().unwrap(); - assert_eq!(f32::from_le_bytes(x), 1.0); - assert_eq!(f32::from_le_bytes(y), 2.0); - assert_eq!(f32::from_le_bytes(z), 3.0); - // First index begins at byte 36. - let i0: [u8; 4] = bin[36..40].try_into().unwrap(); - assert_eq!(u32::from_le_bytes(i0), 0); - // bufferView lengths encoded correctly. - assert!(json.contains("\"byteLength\": 36")); - assert!(json.contains("\"byteLength\": 12")); - assert!(json.contains("\"byteLength\": 48")); - // Accessor types present. - assert!(json.contains("\"type\": \"VEC3\"")); - assert!(json.contains("\"type\": \"SCALAR\"")); - assert!(json.contains("\"componentType\": 5126")); - assert!(json.contains("\"componentType\": 5125")); - } - - #[test] - fn transform_on_node_is_emitted_as_sixteen_element_matrix() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - let t = Transform3::translation(10.0, 20.0, 30.0); - doc.add_mesh_with_transform("t", &mesh, mat, Some(t)); - let (json, _) = doc.finish(); - assert!(json.contains("\"matrix\"")); - // Column-major translation: the translation components sit in - // slots 12, 13, 14 (the last column). - // Looking for "10, 20, 30, 1" at the tail of the 16-element array. - assert!( - json.contains("10, 20, 30, 1]"), - "expected translation tail '10, 20, 30, 1]' in matrix, got: {json}" - ); - // Count commas inside the matrix array to confirm 16 elements (15 separators). - let start = json.find("\"matrix\": [").expect("matrix key present"); - let sub = &json[start..]; - let end = sub.find(']').unwrap(); - let arr = &sub[..end]; - assert_eq!( - arr.matches(',').count(), - 15, - "matrix should have 16 elements" - ); - } - - #[test] - fn finish_produces_json_that_starts_with_brace_and_ends_with_brace() { - let doc = GltfDoc::new("x"); - let (json, _) = doc.finish(); - assert!(json.starts_with('{'), "json must start with '{{'"); - assert!(json.ends_with('}'), "json must end with '}}'"); - } - - #[test] - fn layer_material_green_aci_3_maps_to_green() { - let mut doc = GltfDoc::new("s"); - doc.add_layer_material("green", 3); - let (json, _) = doc.finish(); - // ACI 3 = pure green → [0.0, 1.0, 0.0, 1.0] → "[0, 1, 0, 1]". - assert!(json.contains("[0, 1, 0, 1]")); - } - - #[test] - fn tessellate_surface_placeholder_returns_degenerate_triangle() { - let m = tessellate_surface_placeholder(); - assert_eq!(m.triangles.len(), 1); - assert_eq!(m.vertices.len(), 3); - // All three vertices at the origin — degenerate. - for v in &m.vertices { - assert_eq!(*v, Point3D::new(0.0, 0.0, 0.0)); - } - } - - #[test] - fn two_meshes_get_sequential_buffer_view_indices() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - let a = doc.add_mesh("a", &mesh, mat); - let b = doc.add_mesh("b", &mesh, mat); - assert_eq!(a, 0); - assert_eq!(b, 1); - let (json, bin) = doc.finish(); - // Two meshes * (3 vertices * 12 + 3 indices * 4) = 2 * 48 = 96 bytes. - assert_eq!(bin.len(), 96); - // Mesh 1 references POSITION accessor 2 and indices accessor 3. - assert!(json.contains("\"POSITION\": 2")); - assert!(json.contains("\"indices\": 3")); - } - - #[test] - fn identity_transform_still_emitted_when_provided() { - // Exercises the "Some(Transform3::identity())" path so callers - // can force a node.matrix emission even if it's the identity. - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh_with_transform("t", &mesh, mat, Some(Transform3::identity())); - let (json, _) = doc.finish(); - assert!(json.contains("\"matrix\"")); - } - - #[test] - fn no_transform_skips_matrix_field() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh("t", &mesh, mat); - let (json, _) = doc.finish(); - assert!(!json.contains("\"matrix\"")); - } - - #[test] - fn position_min_max_computed_from_vertices() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(-5.0, -3.0, 0.0), - Point3D::new(10.0, 0.0, 2.0), - Point3D::new(0.0, 7.0, -1.0), - ); - doc.add_mesh("t", &mesh, mat); - let (json, _) = doc.finish(); - // min = (-5, -3, -1), max = (10, 7, 2). - assert!(json.contains("\"min\": [-5, -3, -1]")); - assert!(json.contains("\"max\": [10, 7, 2]")); - } - - #[test] - fn json_escape_handles_structural_characters() { - assert_eq!(json_escape("a\"b"), "a\\\"b"); - assert_eq!(json_escape("a\\b"), "a\\\\b"); - assert_eq!(json_escape("a\nb"), "a\\nb"); - assert_eq!(json_escape("a\tb"), "a\\tb"); - assert_eq!(json_escape("plain"), "plain"); - } - - #[test] - fn escape_f32_replaces_non_finite_with_zero() { - assert_eq!(escape_f32(f32::NAN), "0"); - assert_eq!(escape_f32(f32::INFINITY), "0"); - assert_eq!(escape_f32(f32::NEG_INFINITY), "0"); - assert_eq!(escape_f32(1.5), "1.5"); - assert_eq!(escape_f32(0.0), "0"); - } - - #[test] - fn scene_name_is_json_escaped() { - let doc = GltfDoc::new("scene \"quoted\""); - let (json, _) = doc.finish(); - assert!(json.contains("\"name\": \"scene \\\"quoted\\\"\"")); - } - - #[test] - fn buffer_view_targets_are_array_and_element_array() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh("t", &mesh, mat); - let (json, _) = doc.finish(); - // 34962 = ARRAY_BUFFER (POSITION) - assert!(json.contains("\"target\": 34962")); - // 34963 = ELEMENT_ARRAY_BUFFER (indices) - assert!(json.contains("\"target\": 34963")); - } - - // ----------------------------------------------------------------- - // L10-03 — line-primitive emission - // ----------------------------------------------------------------- - - #[test] - fn add_line_mesh_emits_mode_one_primitive() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let verts = vec![ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 1.0), - Point3D::new(2.0, 2.0, 2.0), - ]; - let segs = vec![[0u32, 1u32], [1u32, 2u32]]; - doc.add_line_mesh("polyline", &verts, &segs, mat); - let (json, bin) = doc.finish(); - // 3 verts * 12 bytes + 2 segments * 2 indices * 4 bytes = 36 + 16 = 52. - assert_eq!(bin.len(), 52); - // mode 1 = LINES per glTF spec. - assert!( - json.contains("\"mode\": 1"), - "expected line primitive mode 1, got: {json}" - ); - // indices accessor count = 2 segments * 2 = 4. - assert!(json.contains("\"componentType\": 5125, \"count\": 4")); - } - - #[test] - fn add_entity_mesh_line_emits_line_primitive() { - use crate::entities::{Vec3D, line::Line}; - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let line = Line { - start: Point3D::new(0.0, 0.0, 0.0), - end: Point3D::new(10.0, 10.0, 0.0), - thickness: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - is_2d: true, - }; - let entity = DecodedEntity::Line(line); - let node = doc.add_entity_mesh("e0", &entity, mat).expect("line emits"); - assert_eq!(node, 0); - let (json, _) = doc.finish(); - assert!(json.contains("\"mode\": 1")); - assert!(json.contains("\"name\": \"e0\"")); - } - - #[test] - fn add_entity_mesh_circle_emits_tessellated_line_strip() { - use crate::entities::{Vec3D, circle::Circle}; - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let c = Circle { - center: Point3D::new(0.0, 0.0, 0.0), - radius: 1.0, - thickness: 0.0, - extrusion: Vec3D { - x: 0.0, - y: 0.0, - z: 1.0, - }, - }; - let entity = DecodedEntity::Circle(c); - doc.add_entity_mesh("circle", &entity, mat) - .expect("circle emits"); - let (json, bin) = doc.finish(); - // 64-segment circle: 64 vertices * 12 + 64 segments * 8 = 768 + 512 = 1280. - assert_eq!(bin.len(), 1280); - assert!(json.contains("\"mode\": 1")); - } - - #[test] - fn add_entity_mesh_3dface_emits_triangle_primitive() { - use crate::entities::three_d_face::ThreeDFace; - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let face = ThreeDFace { - corners: [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(1.0, 1.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ], - invisible_edges: 0, - is_triangle: false, - }; - let entity = DecodedEntity::ThreeDFace(face); - doc.add_entity_mesh("face", &entity, mat).expect("emits"); - let (json, _) = doc.finish(); - assert!(json.contains("\"mode\": 4")); - } - - // ----------------------------------------------------------------- - // L10-05 — instanced node composition - // ----------------------------------------------------------------- - - #[test] - fn add_instanced_node_appends_node_pointing_at_existing_mesh() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - let parent = doc.add_mesh("master", &mesh, mat); - let t = Transform3::translation(10.0, 0.0, 0.0); - let inst = doc.add_instanced_node(parent, &t); - // Instance node id is sequential after the parent. - assert!(inst > parent); - let (json, bin) = doc.finish(); - // The mesh is registered exactly once. - assert_eq!(bin.len(), 48); - // Two scene nodes — both point at mesh 0. - let mesh_zero_count = json.matches("\"mesh\": 0").count(); - assert!( - mesh_zero_count >= 2, - "expected both nodes to reference mesh 0, json: {json}" - ); - } - - #[test] - fn add_instanced_node_composes_translation_into_matrix() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - let parent = doc.add_mesh("master", &mesh, mat); - let chain = Transform3::compose_chain(&[ - Transform3::translation(5.0, 0.0, 0.0), - Transform3::translation(0.0, 7.0, 0.0), - ]); - doc.add_instanced_node(parent, &chain); - let (json, _) = doc.finish(); - // Composed translation lands at slots 12, 13, 14 (column-major - // last column). 5 + 0 = 5 along X, 0 + 7 = 7 along Y, 0 along Z. - assert!( - json.contains("5, 7, 0, 1]"), - "expected composed translation '5, 7, 0, 1]' in matrix, got: {json}" - ); - } - - // ----------------------------------------------------------------- - // L10-07 — GLB binary container - // ----------------------------------------------------------------- - - #[test] - fn glb_starts_with_magic_and_records_total_length() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh("t", &mesh, mat); - let glb = doc.to_glb(); - // Magic - assert_eq!(&glb[0..4], b"glTF"); - // Version 2 LE - assert_eq!(&glb[4..8], &2u32.to_le_bytes()); - // Total length matches buffer length - let total = u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize; - assert_eq!(total, glb.len()); - // JSON chunk type - assert_eq!(&glb[16..20], b"JSON"); - } - - #[test] - fn glb_with_no_bin_omits_bin_chunk() { - let doc = GltfDoc::new("s"); - let glb = doc.to_glb(); - // Empty doc has empty buffer — only header + JSON chunk. - assert_eq!(&glb[0..4], b"glTF"); - // Confirm no "BIN\0" magic anywhere in the file. - assert!( - !glb.windows(4).any(|w| w == [0x42, 0x49, 0x4E, 0x00]), - "BIN chunk header must not appear when binary buffer is empty" - ); - } - - #[test] - fn glb_with_bin_includes_bin_chunk_header() { - let mut doc = GltfDoc::new("s"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh("t", &mesh, mat); - let glb = doc.to_glb(); - // BIN chunk magic must appear. - assert!( - glb.windows(4).any(|w| w == [0x42, 0x49, 0x4E, 0x00]), - "BIN chunk header must appear when binary buffer is non-empty" - ); - } - - #[test] - fn glb_chunk_lengths_are_4_byte_aligned() { - let mut doc = GltfDoc::new("hello"); - let mat = doc.add_material("m", [1.0, 1.0, 1.0, 1.0]); - let mut mesh = Mesh::empty(); - mesh.push_triangle( - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(1.0, 0.0, 0.0), - Point3D::new(0.0, 1.0, 0.0), - ); - doc.add_mesh("t", &mesh, mat); - let glb = doc.to_glb(); - // JSON chunk length sits at offset 12 (after 12-byte header). - let json_chunk_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize; - assert_eq!(json_chunk_len % 4, 0, "JSON chunk length must align to 4"); - // BIN chunk length sits at offset 12 + 8 + json_chunk_len. - let bin_off = 12 + 8 + json_chunk_len; - let bin_chunk_len = - u32::from_le_bytes(glb[bin_off..bin_off + 4].try_into().unwrap()) as usize; - assert_eq!(bin_chunk_len % 4, 0, "BIN chunk length must align to 4"); - } - - #[test] - fn convert_format_enum_variants_exist() { - // Compile-time confirmation that GltfFormat has both variants. - let _gltf = GltfFormat::Gltf; - let _glb = GltfFormat::Glb; - } -} diff --git a/src/graph.rs b/src/graph.rs index 7248c6c..f332869 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -54,8 +54,7 @@ //! work item that unlocks everything else here. When that lands, //! the four "Unsupported / None" stubs above can flip to //! `next_owner` / `next_reactor` closures and the rest of this -//! module — and its tests — already exercises the bounded-walk -//! primitive correctly. +//! module already supplies the bounded-walk primitive. //! //! - [`resolve_entity`] is fully wired: it looks up the handle via //! [`crate::HandleMap::offset_of`], reads the raw record from the @@ -825,583 +824,3 @@ pub fn membership_for(owner_block_name: &str) -> EntityLayoutMembership { // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - //! Unit tests focus on the parts of the module that don't depend - //! on the missing trailing-handle decoder: cycle detection, - //! `max_handles` cap, empty-chain handling, and conversion from - //! already-decoded table entries to the public info structs. - //! L6-18 block-name classification is pure string matching and - //! is exercised here too. - //! - //! End-to-end tests against real DWG fixtures live in - //! `tests/integration_*.rs`; they will exercise `resolve_entity` - //! against a real handle map once we ship a fixture with one. - - use super::*; - use crate::tables::TableEntryHeader; - use crate::tables::dimstyle::DimStyleEntry; - use crate::tables::layer::Layer; - use crate::tables::ltype::{LtypeDash, LtypeEntry}; - use crate::tables::style::StyleEntry; - - fn header(name: &str) -> TableEntryHeader { - TableEntryHeader { - name: name.to_string(), - flag_64: false, - xref_index_plus_1: 0, - is_xref_dependent: false, - } - } - - // -- walk_with_cycle_detection ----------------------------------------- - - #[test] - fn walk_returns_single_element_when_chain_terminates_immediately() { - let limits = WalkerLimits::safe(); - let chain = walk_with_cycle_detection(0xAB, limits, |_| Ok(None)).unwrap(); - assert_eq!(chain, vec![0xAB]); - } - - #[test] - fn walk_handles_empty_chain_terminator() { - // The "empty chain" case the task description names: the - // walker still always emits the start handle, but the very - // first `next` returns None so we don't iterate further. - let limits = WalkerLimits::safe(); - let chain = walk_with_cycle_detection(0x100, limits, |_| Ok(None)).unwrap(); - assert_eq!(chain.len(), 1); - assert_eq!(chain[0], 0x100); - } - - #[test] - fn walk_caps_at_max_handles() { - let mut limits = WalkerLimits::safe(); - limits.max_handles = 4; - // Always advance to the next integer — chain would be infinite - // without the cap. - let result = walk_with_cycle_detection(1, limits, |h| Ok(Some(h + 1))); - let err = result.unwrap_err(); - assert!( - matches!(&err, Error::SectionMap(msg) if msg.contains("max_handles")), - "expected SectionMap(max_handles), got {err:?}" - ); - } - - #[test] - fn walk_detects_cycle_and_returns_partial_chain() { - let limits = WalkerLimits::safe(); - // 1 → 2 → 3 → 1 (cycles back to start). - let chain = walk_with_cycle_detection(1u64, limits, |h| { - let next = match h { - 1 => 2, - 2 => 3, - 3 => 1, - _ => unreachable!(), - }; - Ok(Some(next)) - }) - .unwrap(); - assert_eq!(chain, vec![1, 2, 3]); - } - - #[test] - fn walk_propagates_closure_errors() { - let limits = WalkerLimits::safe(); - let result: Result> = walk_with_cycle_detection(1, limits, |_| { - Err(Error::Unsupported { - feature: "test stub".into(), - }) - }); - let err = result.unwrap_err(); - assert!(matches!(&err, Error::Unsupported { feature } if feature == "test stub")); - } - - // -- LayerInfo conversion ---------------------------------------------- - - #[test] - fn layer_info_from_decoded_layer() { - let layer = Layer { - header: header("WALLS"), - flags: 0x01, // frozen - plot_flag: true, - lineweight: 0, - color_index: 5, - }; - let entity = DecodedEntity::Layer(layer); - let info = layer_info_from_entity(&entity).unwrap(); - assert_eq!(info.name, "WALLS"); - assert_eq!(info.aci, 5); - assert!(info.frozen); - assert!(info.linetype.is_empty()); - } - - #[test] - fn layer_info_clamps_negative_color_index() { - let layer = Layer { - header: header("CUSTOM"), - flags: 0, - plot_flag: true, - lineweight: 0, - color_index: -7, // truecolor flag — clamp to 0 - }; - let entity = DecodedEntity::Layer(layer); - let info = layer_info_from_entity(&entity).unwrap(); - assert_eq!(info.aci, 0); - } - - #[test] - fn layer_info_returns_none_for_non_layer() { - let entity = DecodedEntity::Unhandled { - type_code: 42, - kind: crate::object_type::ObjectType::Dictionary, - }; - assert!(layer_info_from_entity(&entity).is_none()); - } - - // -- DimStyleInfo conversion ------------------------------------------- - - #[test] - fn dimstyle_info_round_trips_all_fifteen_fields() { - let d = DimStyleEntry { - header: header("ISO-25"), - dimscale: 1.0, - dimasz: 2.5, - dimexo: 0.625, - dimexe: 1.25, - dimtxt: 2.5, - dimcen: 0.0, - dimtfac: 1.0, - dimlfac: 1.0, - dimtih: false, - dimtoh: false, - dimtad: 1, - dimtolj: 0, - dimaltf: 25.4, - dimaltrnd: 0.0, - dimupt: true, - }; - let info: DimStyleInfo = d.into(); - assert_eq!(info.name, "ISO-25"); - assert_eq!(info.dimasz, 2.5); - assert_eq!(info.dimtad, 1); - assert!(info.dimupt); - assert!((info.dimaltf - 25.4).abs() < 1e-12); - } - - // -- LtypeInfo / StyleInfo shape (no DwgFile needed) ------------------- - - #[test] - fn ltype_info_pattern_preserves_signs() { - // Build an LtypeEntry by hand; verify a `From`-equivalent - // mapping (we don't expose `From` but the same - // logic is inside `resolve_linetype`). - let dashes = vec![ - LtypeDash { - length: 0.5, - ..Default::default() - }, - LtypeDash { - length: -0.125, - ..Default::default() - }, - LtypeDash { - length: 0.0, // dot - ..Default::default() - }, - ]; - let lt = LtypeEntry { - header: header("DASHED"), - flags: 0, - used_count: 0, - description: String::new(), - pattern_length: 0.625, - alignment: b'A', - dashes, - text_area: Vec::new(), - }; - let pattern: Vec = lt.dashes.iter().map(|d| d.length).collect(); - assert_eq!(pattern, vec![0.5, -0.125, 0.0]); - } - - #[test] - fn style_info_field_mapping() { - let s = StyleEntry { - header: header("Standard"), - flags: 0, - fixed_height: 0.0, - width_factor: 1.0, - oblique_angle: 0.0, - generation: 0, - last_height: 2.5, - font_filename: "arial.ttf".to_string(), - bigfont_filename: String::new(), - }; - let info = StyleInfo { - font: s.font_filename.clone(), - height: s.fixed_height, - width_factor: s.width_factor, - oblique: s.oblique_angle, - }; - assert_eq!(info.font, "arial.ttf"); - assert_eq!(info.width_factor, 1.0); - } - - // -- DwgFile-driven tests ---------------------------------------------- - // - // These exercise the public functions against a real DWG fixture from - // the corpus at `../../samples/`. They skip gracefully when the - // corpus isn't present (downstream-vendoring case, mirrors the - // pattern in `tests/samples.rs` and `tests/corpus_roundtrip.rs`). - - use std::path::PathBuf; - - fn open_first_r2010_plus_sample() -> Option { - let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - p.push("../../samples"); - // Try a known-good R2010+ sample first, then fall back to the - // generic "AC1032" full-file fixture used elsewhere in the test - // suite. - for candidate in &["sample_AC1032.dwg", "arc_2018.dwg", "arc_2013.dwg"] { - let mut path = p.clone(); - path.push(candidate); - if path.exists() { - if let Ok(f) = DwgFile::open(&path) { - return Some(f); - } - } - } - eprintln!( - "graph: skipping DwgFile-driven test; no R2010+ sample present at \ - {}", - p.display() - ); - None - } - - #[test] - fn resolve_entity_returns_not_found_for_unknown_handle() { - let Some(f) = open_first_r2010_plus_sample() else { - return; - }; - let version = f.version(); - // 0xDEADBEEF — a value that no real handle map ever - // contains (handles are sequential u32-ish values starting at 1). - let result = resolve_entity(&f, 0xDEAD_BEEF, version); - match result { - Err(Error::SectionMap(msg)) => { - assert!( - msg.contains("not found"), - "expected 'not found' message, got: {msg}" - ); - } - // R2007 / R13-R15 take the Unsupported path because the - // handle map isn't available — also a correct outcome for - // this test ("the function refused to fabricate a value"). - Err(Error::Unsupported { .. }) => {} - other => panic!("expected SectionMap or Unsupported, got {other:?}"), - } - } - - #[test] - fn owner_chain_respects_max_handles() { - // The bounded-walk cap is exercised directly via - // `walk_with_cycle_detection` above — `owner_chain` itself - // currently short-circuits via `Unsupported` once the seed - // entity is resolved, but the *cap behaviour* this test - // names lives in the helper. Re-assert it here so the test - // suite has the named coverage entry. - let mut limits = WalkerLimits::safe(); - limits.max_handles = 2; - let result = walk_with_cycle_detection(0, limits, |h| Ok(Some(h + 1))); - let err = result.unwrap_err(); - assert!( - matches!(&err, Error::SectionMap(msg) if msg.contains("max_handles")), - "owner_chain cap path returns SectionMap(max_handles), got {err:?}" - ); - } - - #[test] - fn owner_chain_detects_cycles() { - // As above: `owner_chain`'s cycle detection lives in the - // shared `walk_with_cycle_detection` helper. Re-asserted - // here under the spec-named test so coverage is explicit. - let limits = WalkerLimits::safe(); - let chain = walk_with_cycle_detection(10u64, limits, |h| { - // 10 → 20 → 10 (immediate cycle on the second iteration). - Ok(Some(if h == 10 { 20 } else { 10 })) - }) - .unwrap(); - assert_eq!(chain, vec![10, 20]); - } - - #[test] - fn reactor_chain_returns_empty_for_entities_with_no_reactors() { - let Some(f) = open_first_r2010_plus_sample() else { - return; - }; - let version = f.version(); - // Find any entity in the file via the handle map — every - // "simple" entity (LINE, CIRCLE, ARC) ships with zero - // reactors in the test corpus. - let Some(map_result) = f.handle_map() else { - // R2007 etc — no handle map; skip rather than fail. - return; - }; - let Ok(map) = map_result else { - return; - }; - let Some(first) = map.iter().next() else { - return; - }; - let result = reactor_chain(&f, first.handle, version, WalkerLimits::safe()); - match result { - // Expected: empty list. - Ok(reactors) => assert!(reactors.is_empty()), - // Acceptable while the trailing-handle decoder is stubbed: - // function may surface an Unsupported on entities the - // count says have reactors. SectionMap is the third - // tolerable outcome (the lookup itself failed for some - // sample-specific reason). - Err(Error::Unsupported { .. }) | Err(Error::SectionMap(_)) => {} - Err(e) => panic!("unexpected error variant: {e:?}"), - } - } - - #[test] - fn resolve_layer_returns_none_for_invalid_handle() { - // `resolve_layer` takes a `&DecodedEntity` per the public API - // (see module docstring "Known limitations"). Until the - // entity decoders surface a `layer_handle`, the function - // honestly returns `Ok(None)` for every entity rather than - // guessing. Verify that contract here using a synthesized - // non-Layer variant — the "invalid handle" the test name - // refers to is "this entity has no resolvable layer info." - let Some(f) = open_first_r2010_plus_sample() else { - // Even without a sample, the test can run against a - // synthesized DwgFile-less call — but `resolve_layer` - // requires a `&DwgFile`. Skip when no fixture present. - return; - }; - let version = f.version(); - let entity = DecodedEntity::Unhandled { - type_code: 0x42, - kind: crate::object_type::ObjectType::Dictionary, - }; - let info = resolve_layer(&f, &entity, version).unwrap(); - assert!( - info.is_none(), - "resolve_layer must not fabricate a LayerInfo; got {info:?}" - ); - } - - #[test] - fn resolve_linetype_handles_missing_handle_gracefully() { - let Some(f) = open_first_r2010_plus_sample() else { - return; - }; - let version = f.version(); - // Same `0xDEADBEEF` adversarial handle — no real linetype - // table entry sits at this value. - let result = resolve_linetype(&f, 0xDEAD_BEEF, version); - match result { - // The handle resolution failed → Err with a "not found" - // SectionMap message. That's the "graceful" path. - Err(Error::SectionMap(msg)) => { - assert!( - msg.contains("not found"), - "expected 'not found' message, got: {msg}" - ); - } - // Or the file's handle map isn't available at all (R2007). - Err(Error::Unsupported { .. }) => {} - // It's also acceptable to return Ok(None) if the handle - // *did* resolve to a non-Ltype entity (defensive). - Ok(None) => {} - Ok(Some(info)) => panic!("expected None or err for invalid handle; got {info:?}"), - Err(e) => panic!("unexpected error variant: {e:?}"), - } - } - - // ---- L6-18: model space vs paper space classification ---- - - #[test] - fn model_space_name_matches() { - assert!(is_model_space_block_name("*Model_Space")); - assert!(!is_model_space_block_name("*model_space")); - assert!(!is_model_space_block_name("Model_Space")); - assert!(!is_model_space_block_name("*Paper_Space")); - assert!(!is_model_space_block_name("")); - } - - #[test] - fn paper_space_name_matches_base_and_suffixed() { - assert!(is_paper_space_block_name("*Paper_Space")); - assert!(is_paper_space_block_name("*Paper_Space0")); - assert!(is_paper_space_block_name("*Paper_Space1")); - assert!(is_paper_space_block_name("*Paper_Space42")); - // Non-numeric suffix is NOT a paper-space block. - assert!(!is_paper_space_block_name("*Paper_SpaceAlpha")); - assert!(!is_paper_space_block_name("*Paper_Space_")); - assert!(!is_paper_space_block_name("*Paper_Space-1")); - assert!(!is_paper_space_block_name("*paper_space")); - assert!(!is_paper_space_block_name("*Model_Space")); - assert!(!is_paper_space_block_name("")); - } - - #[test] - fn classify_block_name_covers_all_three_categories() { - assert_eq!(classify_block_name("*Model_Space"), BlockSpace::Model); - assert_eq!(classify_block_name("*Paper_Space"), BlockSpace::Paper); - assert_eq!(classify_block_name("*Paper_Space3"), BlockSpace::Paper); - assert_eq!(classify_block_name("MyCustomBlock"), BlockSpace::Custom); - assert_eq!(classify_block_name("DoorFrame_v2"), BlockSpace::Custom); - assert_eq!(classify_block_name(""), BlockSpace::Custom); - } - - // ---- L6-19: per-layout entity filtering ---- - - #[test] - fn filter_by_paper_space_block_keeps_exact_matches() { - let items = vec![ - (1u32, "*Model_Space".to_string()), - (2, "*Paper_Space".to_string()), - (3, "*Paper_Space".to_string()), - (4, "*Paper_Space1".to_string()), - (5, "CustomBlock".to_string()), - ]; - let got: Vec<_> = filter_by_paper_space_block(items, "*Paper_Space").collect(); - assert_eq!(got.len(), 2); - assert_eq!(got[0].0, 2); - assert_eq!(got[1].0, 3); - } - - #[test] - fn filter_by_block_space_model_and_paper() { - let items = vec![ - (1u32, "*Model_Space".to_string()), - (2, "*Paper_Space".to_string()), - (3, "*Paper_Space2".to_string()), - (4, "LibraryBlock".to_string()), - ]; - let model: Vec<_> = filter_by_block_space(items.clone(), BlockSpace::Model).collect(); - assert_eq!(model.len(), 1); - assert_eq!(model[0].0, 1); - - let paper: Vec<_> = filter_by_block_space(items.clone(), BlockSpace::Paper).collect(); - assert_eq!(paper.len(), 2); - - let custom: Vec<_> = filter_by_block_space(items, BlockSpace::Custom).collect(); - assert_eq!(custom.len(), 1); - assert_eq!(custom[0].0, 4); - } - - // ---- L6-20: viewport scale + transform ---- - - #[test] - fn viewport_identity_transform_is_pass_through() { - let vp = ViewportTransform { - paper_center_x: 0.0, - paper_center_y: 0.0, - paper_half_width: 100.0, - paper_half_height: 50.0, - model_view_target_x: 0.0, - model_view_target_y: 0.0, - model_view_target_z: 0.0, - scale_factor: 1.0, - twist_radians: 0.0, - }; - let (px, py) = vp.model_to_paper(10.0, 20.0); - assert!((px - 10.0).abs() < 1e-9); - assert!((py - 20.0).abs() < 1e-9); - } - - #[test] - fn viewport_one_to_fifty_scale_reduces_coordinates() { - let vp = ViewportTransform { - paper_center_x: 100.0, - paper_center_y: 100.0, - paper_half_width: 50.0, - paper_half_height: 50.0, - model_view_target_x: 0.0, - model_view_target_y: 0.0, - model_view_target_z: 0.0, - scale_factor: 1.0 / 50.0, - twist_radians: 0.0, - }; - // A model-space point 250 units from the view target projects - // to 250 / 50 = 5 paper-units from the viewport center. - let (px, py) = vp.model_to_paper(250.0, 0.0); - assert!((px - 105.0).abs() < 1e-9); - assert!((py - 100.0).abs() < 1e-9); - } - - #[test] - fn viewport_bounds_and_contains() { - let vp = ViewportTransform { - paper_center_x: 100.0, - paper_center_y: 100.0, - paper_half_width: 20.0, - paper_half_height: 10.0, - model_view_target_x: 0.0, - model_view_target_y: 0.0, - model_view_target_z: 0.0, - scale_factor: 1.0, - twist_radians: 0.0, - }; - let ((min_x, min_y), (max_x, max_y)) = vp.paper_bounds(); - assert_eq!(min_x, 80.0); - assert_eq!(max_x, 120.0); - assert_eq!(min_y, 90.0); - assert_eq!(max_y, 110.0); - // Inside - assert!(vp.contains_paper_point(100.0, 100.0)); - // On edge (inclusive) - assert!(vp.contains_paper_point(80.0, 100.0)); - assert!(vp.contains_paper_point(120.0, 110.0)); - // Outside - assert!(!vp.contains_paper_point(79.0, 100.0)); - assert!(!vp.contains_paper_point(100.0, 111.0)); - } - - #[test] - fn viewport_twist_rotates_model_space() { - use std::f64::consts::PI; - let vp = ViewportTransform { - paper_center_x: 0.0, - paper_center_y: 0.0, - paper_half_width: 10.0, - paper_half_height: 10.0, - model_view_target_x: 0.0, - model_view_target_y: 0.0, - model_view_target_z: 0.0, - scale_factor: 1.0, - twist_radians: PI / 2.0, // 90° CCW - }; - // (1, 0) → (0, 1) after 90° CCW rotation. - let (px, py) = vp.model_to_paper(1.0, 0.0); - assert!(px.abs() < 1e-9); - assert!((py - 1.0).abs() < 1e-9); - } - - #[test] - fn membership_for_maps_block_names_to_variants() { - assert_eq!( - membership_for("*Model_Space"), - EntityLayoutMembership::Model - ); - assert_eq!( - membership_for("*Paper_Space"), - EntityLayoutMembership::Paper("*Paper_Space".to_string()) - ); - assert_eq!( - membership_for("*Paper_Space7"), - EntityLayoutMembership::Paper("*Paper_Space7".to_string()) - ); - assert_eq!( - membership_for("DoorFrame"), - EntityLayoutMembership::CustomBlock("DoorFrame".to_string()) - ); - } -} diff --git a/src/python_stubs.rs b/src/python_stubs.rs index f41058c..35fad5a 100644 --- a/src/python_stubs.rs +++ b/src/python_stubs.rs @@ -120,43 +120,3 @@ pub fn decoded_entities_strict() -> String { pub fn decoded_entities_lossy() -> String { String::from(r#"{"entities":[],"diagnostics":{}}"#) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stubs_return_valid_json() { - // Every stub returns a string that serde_json (when - // available via the cli feature) could parse. We don't - // require serde_json here in lib-only mode, so just assert - // the strings are non-empty. - assert!(!diagnostics().is_empty()); - assert!(!summary_strict("/does/not/exist").is_empty()); - assert!(!summary_lossy("/does/not/exist").is_empty()); - assert!(!read_object_strict(0).is_empty()); - assert!(!read_object_lossy(0).is_empty()); - assert!(!header_vars_strict("/does/not/exist").is_empty()); - assert!(!header_vars_lossy("/does/not/exist").is_empty()); - assert!(!open_with_limits("/does/not/exist", "{}").is_empty()); - assert!(!decoded_entities_strict().is_empty()); - assert!(!decoded_entities_lossy().is_empty()); - } - - #[test] - fn lossy_variants_emit_diagnostics_field() { - assert!(summary_lossy("x").contains("\"diagnostics\"")); - assert!(read_object_lossy(0).contains("\"diagnostics\"")); - assert!(header_vars_lossy("x").contains("\"diagnostics\"")); - assert!(decoded_entities_lossy().contains("\"diagnostics\"")); - } - - #[test] - fn strict_variants_omit_diagnostics_wrapper() { - // Strict variants return the value directly — no - // (value, diagnostics) tuple. Placeholder is "{}". - assert_eq!(summary_strict("x"), "{}"); - assert_eq!(read_object_strict(0), "{}"); - assert_eq!(header_vars_strict("x"), "{}"); - } -} diff --git a/src/svg.rs b/src/svg.rs index 7c985e1..8ef470b 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -1479,716 +1479,3 @@ fn boundary_path_d(boundary: &Path) -> String { } d.trim_end().to_string() } - -#[cfg(test)] -mod tests { - use super::*; - use crate::entities::Vec3D; - - #[test] - fn empty_doc_has_svg_root() { - let doc = SvgDoc::new(100.0, 200.0); - let s = doc.finish(); - assert!(s.contains("")); - } - - #[test] - fn line_emits_svg_line_element() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - let curve = Curve::Line { - a: Point3D::new(1.0, 2.0, 0.0), - b: Point3D::new(3.0, 4.0, 0.0), - }; - doc.push_curve(&curve, &style, None); - let s = doc.finish(); - assert!(s.contains("").count() >= 2); // layer g + root flip g - } - - #[test] - fn data_handle_emitted_as_attribute() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_curve( - &Curve::Line { - a: Point3D::new(0.0, 0.0, 0.0), - b: Point3D::new(1.0, 1.0, 0.0), - }, - &style, - Some("0x83"), - ); - let s = doc.finish(); - assert!(s.contains("data-handle=\"0x83\"")); - } - - #[test] - fn attribute_escape_handles_special_chars() { - assert_eq!(svg_escape_attr("foo & "), "foo & <bar>"); - assert_eq!(svg_escape_attr("x\"y"), "x"y"); - assert_eq!(svg_escape_attr("it's"), "it's"); - } - - #[test] - fn path_emits_m_l_z_commands() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(10.0, 0.0, 0.0), - Point3D::new(10.0, 10.0, 0.0), - ]; - let p = Path::from_polyline(&pts, true); - doc.push_path(&p, &style, None); - let s = doc.finish(); - assert!(s.contains("Hello")); - assert!(s.contains("font-family=\"Helvetica, sans-serif\"")); - assert!(s.contains("font-size=\"5\"")); - assert!(s.contains("data-handle=\"0x42\"")); - assert!(s.contains("x=\"10\"")); - assert!(s.contains("y=\"20\"")); - // Counter-flip transform must be present. - assert!(s.contains("scale(1,-1)")); - } - - #[test] - fn push_text_falls_back_arial_for_shx_fonts() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_text( - "ROMANS", - Point3D::new(0.0, 0.0, 0.0), - 2.5, - 0.0, - "romans.shx", - &style, - None, - ); - let s = doc.finish(); - // SHX should be silently swapped for Arial — never expose the - // unrenderable family name to a downstream SVG consumer. - assert!(s.contains("font-family=\"Arial, sans-serif\"")); - assert!(!s.contains("romans.shx")); - } - - #[test] - fn push_text_escapes_xml_special_characters_in_content() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_text( - "A & B < C > D", - Point3D::new(0.0, 0.0, 0.0), - 2.0, - 0.0, - "Arial", - &style, - None, - ); - let s = doc.finish(); - assert!(s.contains("A & B < C > D")); - } - - // --- L9-06: MTEXT --------------------------------------------------- - - #[test] - fn push_mtext_paragraph_break_creates_new_tspan_with_dy() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_mtext( - "first line\\Psecond line", - Point3D::new(5.0, 10.0, 0.0), - 3.0, - 0.0, - "Arial", - &style, - None, - ); - let s = doc.finish(); - // Two tspans expected — one per paragraph line. - assert!( - s.matches("= 2, - "expected ≥2 for two-paragraph mtext, svg was: {s}" - ); - assert!(s.contains("first line")); - assert!(s.contains("second line")); - assert!(s.contains("dy=\"3\"")); - assert!(s.contains("x=\"5\"")); - } - - #[test] - fn push_mtext_supports_underline_overline_color_height_codes() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_mtext( - "\\Lunder\\l \\Oover\\o \\C1;red \\H1.5x;big", - Point3D::new(0.0, 0.0, 0.0), - 4.0, - 0.0, - "Arial", - &style, - None, - ); - let s = doc.finish(); - assert!(s.contains("text-decoration=\"underline\"")); - assert!(s.contains("text-decoration=\"overline\"")); - // ACI 1 = red. - assert!(s.contains("fill=\"#FF0000\"")); - // 1.5x height multiplier on a base of 4.0 → 6. - assert!(s.contains("font-size=\"6\"")); - // All four literal payload words must reach the output. - assert!(s.contains("under")); - assert!(s.contains("over")); - assert!(s.contains("red")); - assert!(s.contains("big")); - } - - #[test] - fn push_mtext_unknown_code_emits_diagnostic_comment() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - // \Q is not a recognized MTEXT code. - doc.push_mtext( - "before\\Qafter", - Point3D::new(0.0, 0.0, 0.0), - 3.0, - 0.0, - "Arial", - &style, - None, - ); - let s = doc.finish(); - assert!(s.contains("")); - // Both flanking texts still emitted. - assert!(s.contains("before")); - assert!(s.contains("after")); - } - - #[test] - fn push_mtext_brace_groups_push_and_pop_style_stack() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_mtext( - "outer{\\C1;red}back", - Point3D::new(0.0, 0.0, 0.0), - 3.0, - 0.0, - "Arial", - &style, - None, - ); - let s = doc.finish(); - assert!(s.contains("outer")); - assert!(s.contains("red")); - assert!(s.contains("back")); - // The "back" tspan must NOT inherit the red color from inside - // the closed brace group — i.e., the pop restored the original - // black/stroke fill. - assert!(s.contains("fill=\"#000000\"")); - } - - // --- L9-07: HATCH --------------------------------------------------- - - #[test] - fn push_hatch_solid_emits_filled_path() { - let mut doc = SvgDoc::new(100.0, 100.0); - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(10.0, 0.0, 0.0), - Point3D::new(10.0, 10.0, 0.0), - Point3D::new(0.0, 10.0, 0.0), - ]; - let boundary = Path::from_polyline(&pts, true); - doc.push_hatch_solid(&boundary, "#888888", Some("0xC0")); - let s = doc.finish(); - assert!(s.contains("")); - assert_eq!( - s.matches("id=\"hatch-ANSI31\"").count(), - 1, - "pattern def must dedupe to a single registration" - ); - // Two boundary paths reference the same pattern. - assert_eq!( - s.matches("fill=\"url(#hatch-ANSI31)\"").count(), - 2, - "each push_hatch_pattern call should still emit its own " - ); - } - - #[test] - fn push_hatch_pattern_solid_delegates_to_solid_method() { - let mut doc = SvgDoc::new(100.0, 100.0); - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(5.0, 0.0, 0.0), - Point3D::new(5.0, 5.0, 0.0), - ]; - let boundary = Path::from_polyline(&pts, true); - doc.push_hatch_pattern(&boundary, "SOLID", 1.0, 0.0, "#123456", None); - let s = doc.finish(); - // SOLID delegate must NOT register a pattern def. - assert!(!s.contains("")); - assert!(s.contains("fill=\"#123456\"")); - } - - #[test] - fn push_hatch_pattern_unknown_falls_back_to_solid_with_comment() { - let mut doc = SvgDoc::new(100.0, 100.0); - let pts = [ - Point3D::new(0.0, 0.0, 0.0), - Point3D::new(5.0, 0.0, 0.0), - Point3D::new(5.0, 5.0, 0.0), - ]; - let boundary = Path::from_polyline(&pts, true); - doc.push_hatch_pattern(&boundary, "EARTH", 1.0, 0.0, "#998877", None); - let s = doc.finish(); - assert!(s.contains("")); - // Visible solid fill still emitted as the safety fallback. - assert!(s.contains("fill=\"#998877\"")); - assert!(!s.contains("")); - } - - // --- L9-08: DIMENSION ----------------------------------------------- - - #[test] - fn push_dimension_linear_emits_extension_dim_arrows_and_text() { - let mut doc = SvgDoc::new(200.0, 200.0); - let style = Style { - stroke: "#0000FF".to_string(), - ..Default::default() - }; - // Horizontal baseline from (10,10) to (60,10), dim line offset - // 20 above (so dim line passes through y=30). - doc.push_dimension_linear( - Point3D::new(10.0, 10.0, 0.0), - Point3D::new(60.0, 10.0, 0.0), - Point3D::new(35.0, 30.0, 0.0), - "50.00", - "Arial", - 3.0, - &style, - ); - let s = doc.finish(); - // Three elements: two extension + one dim line. - assert!( - s.matches("= 3, - "expected ≥3 elements (2 extension + 1 dim), got: {s}" - ); - // Two arrowhead triangle paths (Z-closed M..L..L..Z fragments). - assert!(s.matches("Z\" fill=\"#0000FF\"").count() >= 2); - // Dimension text rendered at midpoint x = 35. - assert!(s.contains(">50.00")); - assert!(s.contains("text-anchor=\"middle\"")); - // The dim line foot is at y = 30 (perpendicular projection of - // dim line onto a horizontal baseline parallel to itself). - assert!(s.contains("y1=\"30\"")); - assert!(s.contains("y2=\"30\"")); - } - - #[test] - fn push_dimension_linear_zero_length_baseline_emits_diagnostic_comment_only() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_dimension_linear( - Point3D::new(5.0, 5.0, 0.0), - Point3D::new(5.0, 5.0, 0.0), - Point3D::new(5.0, 10.0, 0.0), - "0.00", - "Arial", - 2.0, - &style, - ); - let s = doc.finish(); - assert!(s.contains("")); - assert!(!s.contains(" count from the dimension method must be zero. - assert!(!s.contains(" (so per-layout styling works under a common root). - assert!(s.contains("scale(1,-1)\" data-layout=\"Layout1\"")); - } - - #[test] - fn model_space_and_paper_space_root_g_attributes_differ() { - // Regression guard: the two spaces must produce meaningfully - // different root-group markup so downstream tooling can route - // content correctly. Model space: bare ``; paper - // space: ``. - let model = SvgDoc::new(100.0, 100.0).finish(); - let paper = SvgDoc::new(100.0, 100.0) - .with_space(SvgSpace::Paper("Sheet-A".to_string())) - .finish(); - assert_ne!(model, paper); - assert!(!model.contains("data-layout")); - assert!(paper.contains("data-layout=\"Sheet-A\"")); - } - - #[test] - fn paper_space_layout_name_is_attribute_escaped() { - // Layout names may contain `&`, quotes, or angle brackets — SVG - // attributes must stay well-formed regardless. - let doc = - SvgDoc::new(10.0, 10.0).with_space(SvgSpace::Paper("A & B \"1\"".to_string())); - let s = doc.finish(); - assert!(s.contains("data-layout=\"A & B <Layout>"1"\"")); - } - - #[test] - fn push_title_block_emits_frame_and_field_text() { - let mut doc = SvgDoc::new(300.0, 200.0); - let fields = vec![ - ("Drawing Number".to_string(), "DWG-001".to_string()), - ("Revision".to_string(), "A".to_string()), - ("Date".to_string(), "2026-04-19".to_string()), - ("Scale".to_string(), "1:100".to_string()), - ]; - doc.push_title_block(Point3D::new(10.0, 10.0, 0.0), 280.0, 60.0, &fields); - let s = doc.finish(); - // Frame path — closed rectangle with the required style. - assert!(s.contains("data-role=\"title-block-frame\"")); - assert!(s.contains("stroke=\"#000000\"")); - assert!(s.contains("fill=\"none\"")); - assert!(s.contains("stroke-width=\"0.5\"")); - // Each field label + value is a separate element. - assert!(s.contains(">Drawing Number")); - assert!(s.contains(">DWG-001")); - assert!(s.contains(">Revision")); - assert!(s.contains(">2026-04-19")); - assert!(s.contains("data-role=\"title-block-label\"")); - assert!(s.contains("data-role=\"title-block-value\"")); - // Font machinery matches the rest of the writer — Arial plus - // the , sans-serif graceful-degradation suffix. - assert!(s.contains("font-family=\"Arial, sans-serif\"")); - } - - #[test] - fn push_title_block_with_empty_fields_emits_frame_only() { - let mut doc = SvgDoc::new(100.0, 100.0); - doc.push_title_block(Point3D::new(0.0, 0.0, 0.0), 100.0, 50.0, &[]); - let s = doc.finish(); - assert!(s.contains("data-role=\"title-block-frame\"")); - assert!(!s.contains("title-block-label")); - assert!(!s.contains("title-block-value")); - } - - #[test] - fn push_title_block_escapes_xml_specials_in_field_values() { - let mut doc = SvgDoc::new(100.0, 100.0); - let fields = vec![("Client".to_string(), "Smith & ".to_string())]; - doc.push_title_block(Point3D::new(0.0, 0.0, 0.0), 100.0, 50.0, &fields); - let s = doc.finish(); - assert!(s.contains(">Smith & <Co>")); - } - - #[test] - fn push_viewport_registers_clip_path_and_opens_group() { - let mut doc = SvgDoc::new(400.0, 300.0); - doc.push_viewport(Point3D::new(20.0, 30.0, 0.0), 300.0, 200.0, "vp1"); - doc.pop_clip(); - let s = doc.finish(); - // clipPath id is the concatenation of the `clip-` prefix and the - // caller-supplied id. - assert!(s.contains(". - assert!(s.contains("clip-path=\"url(#clip-vp1)\"")); - assert!(s.contains("data-role=\"viewport\"")); - } - - #[test] - fn push_viewport_dedupes_repeat_registrations_of_same_id() { - // Two calls with the same clip id should still only emit one - // definition — mirrors the pattern_defs dedupe rule. - let mut doc = SvgDoc::new(400.0, 300.0); - doc.push_viewport(Point3D::new(0.0, 0.0, 0.0), 100.0, 100.0, "dup"); - doc.pop_clip(); - doc.push_viewport(Point3D::new(0.0, 0.0, 0.0), 100.0, 100.0, "dup"); - doc.pop_clip(); - let s = doc.finish(); - assert_eq!(s.matches(", so two clip-path - // references are expected in the body. - assert_eq!(s.matches("clip-path=\"url(#clip-dup)\"").count(), 2); - } - - #[test] - fn pop_clip_emits_closing_g_tag() { - let mut doc = SvgDoc::new(100.0, 100.0); - doc.push_viewport(Point3D::new(0.0, 0.0, 0.0), 50.0, 50.0, "v"); - // Body should contain an opening at this point. - doc.pop_clip(); - let s = doc.finish(); - // One opening clip group, one closing for it plus the - // root flip group. - assert_eq!(s.matches("data-role=\"viewport\"").count(), 1); - assert!(s.matches("").count() >= 2); - } - - #[test] - fn to_pdf_via_paged_svg_emits_page_size_in_mm_and_dpi_metadata() { - let doc = SvgDoc::new(100.0, 100.0); - let bytes = doc - .to_pdf_via_paged_svg((210.0, 297.0), 96.0) - .expect("paged SVG should emit"); - let s = std::str::from_utf8(&bytes).expect("UTF-8 output"); - // Root svg dimensions are in millimetres. - assert!(s.contains("width=\"210mm\"")); - assert!(s.contains("height=\"297mm\"")); - // @page CSS rule — size in millimetres, zero margin so the - // drawing fills the entire page. - assert!(s.contains("@page { size: 210mm 297mm; margin: 0; }")); - // DPI metadata round-trips through data-print-dpi. - assert!(s.contains("data-print-dpi=\"96\"")); - assert!(s.contains("data-print-page-mm=\"210x297\"")); - } - - #[test] - fn to_pdf_via_paged_svg_preserves_body_elements() { - let mut doc = SvgDoc::new(100.0, 100.0); - let style = Style::default(); - doc.push_curve( - &Curve::Line { - a: Point3D::new(0.0, 0.0, 0.0), - b: Point3D::new(50.0, 50.0, 0.0), - }, - &style, - None, - ); - let bytes = doc - .to_pdf_via_paged_svg((210.0, 297.0), 300.0) - .expect("paged SVG should emit"); - let s = std::str::from_utf8(&bytes).expect("UTF-8 output"); - assert!(s.contains("