From b2fb8b9193b3e6ac84772ec51778905a588c0898 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:47:10 -0400 Subject: [PATCH] test(exporters): delete the exporters' and geometry inline mirror suites The inline #[cfg(test)] modules of svg, gltf, dxf, dxf_sections, dxf_convert, geometry, entity_geometry, curve, color, graph, block_expansion and python_stubs build synthetic documents and curves and pin the text, bytes or numbers the exporters give. They are unit tests, which this repository bans, and no job runs them since the Test job moved to tools/ci/run_test_targets.sh. Git history is the archive. dxf_convert's and graph's module docs no longer cite those tests. --- src/block_expansion.rs | 129 ------ src/color.rs | 66 --- src/curve.rs | 122 ------ src/dxf.rs | 173 -------- src/dxf_convert.rs | 26 +- src/dxf_sections.rs | 408 ----------------- src/entity_geometry.rs | 971 ----------------------------------------- src/geometry.rs | 386 ---------------- src/gltf.rs | 537 ----------------------- src/graph.rs | 583 +------------------------ src/python_stubs.rs | 40 -- src/svg.rs | 713 ------------------------------ 12 files changed, 8 insertions(+), 4146 deletions(-) diff --git a/src/block_expansion.rs b/src/block_expansion.rs index 8f0262ff..98e0c8e4 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 85a938d4..b8e419b1 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 9ccda9f6..1a9f8890 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 a87cec36..b22fc0ef 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 f388a290..81f4aa21 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 fcf79787..01d82f2a 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 f7de5418..7a334b01 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 62286803..020d61cf 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 a4c2c483..7b48a921 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 7248c6cc..f3328691 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 f41058c9..35fad5a0 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 7c985e14..8ef470b4 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("