//! # cad_integration — integration tests for the CAD module. //! //! These cross file boundaries: scene conversion, exporter output, GLB //! validity, PDF dimensions, mesh-cache behaviour under realistic //! workloads. Per-module unit tests stay in their own files next to the //! code they test; this is the integration layer, and it exercises the //! crate from outside, as a consumer would. //! //! Run with: `cargo test --package nigig-build --test cad_integration` use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{ self, CadNode, CadScene, CadSolid, CadTransform, DofConstraint, Exporter, IdAllocator, LayerId, MaterialId, MeshCache, NodeId, NodeMetadata, PartKind, SceneBuilder, SceneVisitor, walk_scene, }; use makepad_widgets::{DVec2, Vec3f, Vec4f}; // =========================================================================== // Test fixtures // =========================================================================== /// Build a representative architectural scene: 4 walls forming a /// rectangle, one door, two windows, four columns at the corners. /// 11 nodes total — enough to exercise the visitor + cache without /// being noisy. fn build_house_scene() -> CadScene { let mut alloc = IdAllocator::new(); SceneBuilder::new(&mut alloc) .material("concrete", Vec4f { x: 0.78, y: 0.78, z: 0.78, w: 1.0 }) .material("glass", Vec4f { x: 0.40, y: 0.60, z: 0.85, w: 1.0 }) .material("wood", Vec4f { x: 0.55, y: 0.35, z: 0.20, w: 1.0 }) // 4 walls (north, south, east, west) .wall() .name("Wall-N") .length(8.0) .at_xyz(0.0, 1.4, -4.0) .finish() .wall() .name("Wall-S") .length(8.0) .at_xyz(0.0, 1.4, 4.0) .finish() .wall() .name("Wall-E") .length(8.0) .at_xyz(4.0, 1.4, 0.0) .yaw(1.5708) .finish() .wall() .name("Wall-W") .length(8.0) .at_xyz(-4.0, 1.4, 0.0) .yaw(1.5708) .finish() // Door in the south wall .door() .name("Door-S") .at_xyz(0.0, 1.05, 4.0) .finish() // Two windows in east + west walls .window() .name("Window-E") .at_xyz(4.0, 1.4, 0.0) .yaw(1.5708) .finish() .window() .name("Window-W") .at_xyz(-4.0, 1.4, 0.0) .yaw(1.5708) .finish() // 4 columns at the corners .column() .name("Col-NE") .at_xyz(3.8, 1.5, -3.8) .finish() .column() .name("Col-NW") .at_xyz(-3.8, 1.5, -3.8) .finish() .column() .name("Col-SE") .at_xyz(3.8, 1.5, 3.8) .finish() .column() .name("Col-SW") .at_xyz(-3.8, 1.5, 3.8) .finish() .build() } // =========================================================================== // Scene conversion tests (legacy <-> new) // =========================================================================== mod scene_conversion { use super::*; #[test] fn round_trip_through_legacy_scene_part() { // Build a small scene, extract nodes, convert // back to CadScene, verify the geometry survived. let original = build_house_scene(); let parts = nigig_build::construction_frame::pages::workspace::cad::cad_scene::nodes_from_scene(&original); assert_eq!(parts.len(), 11); // Convert back via the legacy adapter. let roundtripped = parts_to_scene_via_legacy(&parts); assert_eq!(roundtripped.node_count(), 11); // Node names won't match (the legacy adapter generates // "Part-{id}" names), but geometry kinds + positions should. for (orig, round) in original.nodes().iter().zip(roundtripped.nodes().iter()) { assert_eq!(orig.id, round.id, "node id should round-trip"); // Translation should match exactly. assert!( (orig.transform.translation.x - round.transform.translation.x).abs() < 1e-5, "node {} translation.x mismatch", orig.id ); // Material color should match (the legacy adapter regroups // by color, so material *ids* may differ but colors match). let orig_mat = original.material(orig.material).expect("orig material"); let round_mat = roundtripped.material(round.material).expect("round material"); assert!((orig_mat.color.x - round_mat.color.x).abs() < 1e-5); } } fn parts_to_scene_via_legacy(parts: &[CadNode]) -> CadScene { // Use the same path the export entry points use. let mut alloc = IdAllocator::new(); let mut builder = SceneBuilder::new(&mut alloc); for part in parts { let mat_id = builder.register_material_for_color(part.color); let mut node = part.clone(); node.material = mat_id; builder.push_node(node); } builder.build() } #[test] fn legacy_part_kind_conversions_round_trip() { for kind in [ PartKind::Cube, PartKind::Cylinder, PartKind::Sphere, PartKind::Rect2D, PartKind::Circle2D, PartKind::Polygon2D, PartKind::Wall, PartKind::Slab, PartKind::Door, PartKind::Window, PartKind::Column, PartKind::Beam, PartKind::Arc, ] { // The From impls in mod.rs handle this. let cad_kind: nigig_build::construction_frame::pages::workspace::cad::cad_scene::PartKind = kind.into(); let back: PartKind = cad_kind.into(); assert_eq!(kind, back, "PartKind round-trip failed for {:?}", kind); } } #[test] fn empty_scene_converts_cleanly() { let empty = CadScene::default(); let parts = nigig_build::construction_frame::pages::workspace::cad::cad_scene::nodes_from_scene(&empty); assert!(parts.is_empty()); } } // =========================================================================== // Visitor tests // =========================================================================== mod visitor { use super::*; use std::collections::HashSet; #[derive(Default)] struct CountingVisitor { boxes: usize, cylinders: usize, spheres: usize, polygons: usize, extruded: usize, csgs: usize, groups: usize, visited_ids: HashSet, } impl SceneVisitor for CountingVisitor { fn visit_node(&mut self, node: &CadNode) { self.visited_ids.insert(node.id); } fn visit_box(&mut self, _node: &CadNode, _size: Vec3f) { self.boxes += 1; } fn visit_cylinder(&mut self, _node: &CadNode, _r: f32, _h: f32, _s: u32) { self.cylinders += 1; } fn visit_sphere(&mut self, _node: &CadNode, _r: f32, _su: u32, _sv: u32) { self.spheres += 1; } fn visit_polygon_2d(&mut self, _node: &CadNode, _verts: &[DVec2]) { self.polygons += 1; } fn visit_extruded_polygon(&mut self, _node: &CadNode, _verts: &[DVec2], _h: f32) { self.extruded += 1; } fn visit_csg(&mut self, _node: &CadNode, _solid: &nigig_build::makepad_csg::Solid) { self.csgs += 1; } fn visit_group(&mut self, _node: &CadNode) { self.groups += 1; } } #[test] fn visitor_counts_house_scene_correctly() { let scene = build_house_scene(); let mut v = CountingVisitor::default(); walk_scene(&scene, &mut v); // 4 walls + 1 door + 2 windows = 7 boxes assert_eq!(v.boxes, 7); // 4 columns assert_eq!(v.cylinders, 4); // No spheres, polygons, extruded, csg, or groups in our fixture assert_eq!(v.spheres, 0); assert_eq!(v.polygons, 0); assert_eq!(v.extruded, 0); assert_eq!(v.csgs, 0); assert_eq!(v.groups, 0); // Should have visited every node exactly once. assert_eq!(v.visited_ids.len(), 11); } #[test] fn visitor_visits_zero_nodes_on_empty_scene() { let scene = CadScene::default(); let mut v = CountingVisitor::default(); walk_scene(&scene, &mut v); assert_eq!(v.boxes, 0); assert_eq!(v.visited_ids.len(), 0); } } // =========================================================================== // Mesh cache tests — under realistic load // =========================================================================== mod mesh_cache { use super::*; use std::sync::Arc; #[test] fn cache_serves_all_house_nodes() { let scene = build_house_scene(); let cache = MeshCache::new(); // First pass: every node is a cache miss, builds a fresh mesh. let meshes_v1: Vec> = scene .nodes() .iter() .map(|n| cache.get_or_build(n)) .collect(); assert_eq!(meshes_v1.len(), 11); assert_eq!(cache.len(), 11, "cache should have one entry per node"); // Second pass: every node should be a cache hit (same Arc). let meshes_v2: Vec> = scene .nodes() .iter() .map(|n| cache.get_or_build(n)) .collect(); for (a, b) in meshes_v1.iter().zip(meshes_v2.iter()) { assert!( Arc::ptr_eq(a, b), "second pass should return same Arc (cache hit)" ); } } #[test] fn cache_survives_partial_invalidation() { let scene = build_house_scene(); let cache = MeshCache::new(); // Build all. let first_pass: Vec<_> = scene.nodes().iter().map(|n| cache.get_or_build(n)).collect(); // Invalidate one node. let target_id = scene.nodes()[3].id; cache.invalidate(target_id); // Rebuild all. The invalidated node should have a new Arc; // all others should still match. let second_pass: Vec<_> = scene.nodes().iter().map(|n| cache.get_or_build(n)).collect(); for (i, (a, b)) in first_pass.iter().zip(second_pass.iter()).enumerate() { if scene.nodes()[i].id == target_id { assert!( !Arc::ptr_eq(a, b), "invalidated node should get a fresh Arc" ); } else { assert!( Arc::ptr_eq(a, b), "non-invalidated node should still be cached" ); } } } /// A transform edit must HIT the cache, not invalidate it. /// /// INVERTED from `cache_detects_transform_edits_via_param_hash`, /// which asserted the opposite. That test described what the code /// did rather than what it needed to do: `ParamHash` hashed the /// transform, so moving a part discarded its mesh and rebuilt /// triangles identical to the ones just thrown away. /// /// The cached `TriMesh` is in the node's LOCAL space -- /// `CadSolid::build_mesh` cannot see the transform, and every /// consumer (the draw loop, `pick_part`, the STL and glTF /// exporters) applies the model matrix itself. So a move cannot /// change the triangles, and invalidating on one is pure waste: it /// hit on every frame of every drag, for every selected part. #[test] fn a_transform_edit_reuses_the_cached_local_space_mesh() { let scene = build_house_scene(); let cache = MeshCache::new(); let node = &scene.nodes()[0]; let m1 = cache.get_or_build(node); let mut edited = node.clone(); edited.transform.translation.x += 1.0; edited.transform.rotation_euler_xyz.y += 30.0; let m2 = cache.get_or_build(&edited); assert!( Arc::ptr_eq(&m1, &m2), "a move/rotate must reuse the mesh: it is local-space, so the \ transform cannot change a single triangle" ); } #[test] fn cache_detects_geometry_edits_via_param_hash() { let mut alloc = IdAllocator::new(); let scene = SceneBuilder::new(&mut alloc) .wall().length(6.0).finish() .build(); let cache = MeshCache::new(); let node = &scene.nodes()[0]; let m1 = cache.get_or_build(node); // Edit the wall's length (size.x). let mut edited = node.clone(); if let Some(CadSolid::Box { size }) = &mut edited.solid { size.x = 8.0; } let m2 = cache.get_or_build(&edited); assert!( !Arc::ptr_eq(&m1, &m2), "size change should invalidate via ParamHash" ); } #[test] fn cache_clear_drops_all_entries() { let scene = build_house_scene(); let cache = MeshCache::new(); for n in scene.nodes() { let _ = cache.get_or_build(n); } assert_eq!(cache.len(), 11); cache.clear(); assert_eq!(cache.len(), 0); assert!(cache.is_empty()); } } // =========================================================================== // GLB validity tests // =========================================================================== mod glb_validity { use super::*; use nigig_build::construction_frame::pages::workspace::cad::arch_gltf::{GltfExporter, GltfExportOptions}; /// GLB 2.0 binary format: /// bytes 0..4 = "glTF" magic /// bytes 4..8 = version (u32 LE = 2) /// bytes 8..12 = total file length (u32 LE) /// bytes 12..16 = JSON chunk length /// bytes 16..20 = JSON chunk type ("JSON") /// ... /// bytes N..N+8 = BIN chunk length + type ("BIN\0") #[derive(Debug)] struct GlbHeader { magic: [u8; 4], version: u32, total_length: u32, } fn parse_glb_header(bytes: &[u8]) -> GlbHeader { assert!(bytes.len() >= 12, "GLB too short for header"); let mut magic = [0u8; 4]; magic.copy_from_slice(&bytes[0..4]); let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); let total_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]); GlbHeader { magic, version, total_length } } #[test] fn house_scene_produces_valid_glb_header() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = GltfExporter::default(); let bytes = exporter.build_glb(&scene, &cache).expect("build_glb"); let header = parse_glb_header(&bytes); assert_eq!(&header.magic, b"glTF", "magic should be 'glTF'"); assert_eq!(header.version, 2, "version should be 2"); assert_eq!( header.total_length as usize, bytes.len(), "header total_length should match actual byte count" ); } #[test] fn glb_has_json_and_bin_chunks() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = GltfExporter::default(); let bytes = exporter.build_glb(&scene, &cache).expect("build_glb"); // Parse JSON chunk header. let json_chunk_len = u32::from_le_bytes([ bytes[12], bytes[13], bytes[14], bytes[15], ]) as usize; let json_chunk_type = &bytes[16..20]; assert_eq!(json_chunk_type, b"JSON", "JSON chunk type"); // JSON chunk starts at byte 20, runs for json_chunk_len. let json_end = 20 + json_chunk_len; let json_bytes = &bytes[20..json_end]; // Parse the JSON manifest. let manifest: serde_json::Value = serde_json::from_slice(json_bytes).expect("parse JSON"); // Should have one mesh per geometric node (11 in our house). let meshes = manifest["meshes"].as_array().expect("meshes array"); assert_eq!(meshes.len(), 11, "one mesh per geometric node"); // Should have materials. let materials = manifest["materials"].as_array().expect("materials array"); assert!(!materials.is_empty(), "should have at least one material"); // Should have accessors and bufferViews. assert!(manifest["accessors"].is_array(), "should have accessors"); assert!(manifest["bufferViews"].is_array(), "should have bufferViews"); // Should have exactly one buffer. let buffers = manifest["buffers"].as_array().expect("buffers array"); assert_eq!(buffers.len(), 1, "should have one buffer"); // BIN chunk should follow the JSON chunk. let bin_offset = json_end; let bin_chunk_len = u32::from_le_bytes([ bytes[bin_offset], bytes[bin_offset + 1], bytes[bin_offset + 2], bytes[bin_offset + 3], ]) as usize; let bin_chunk_type = &bytes[bin_offset + 4..bin_offset + 8]; assert_eq!(bin_chunk_type, b"BIN\0", "BIN chunk type"); // BIN chunk length should match the buffer byteLength in JSON. let buffer_byte_length = buffers[0]["byteLength"].as_u64().expect("byteLength"); assert_eq!( bin_chunk_len as u64, buffer_byte_length, "BIN chunk length should match buffer.byteLength" ); // Total file length should be: header(12) + json_chunk_header(8) // + json_chunk_len + bin_chunk_header(8) + bin_chunk_len. let expected_total = 12 + 8 + json_chunk_len + 8 + bin_chunk_len; assert_eq!(bytes.len(), expected_total, "file length should match chunk sum"); } #[test] fn glb_node_count_matches_scene() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = GltfExporter::default(); let bytes = exporter.build_glb(&scene, &cache).expect("build_glb"); // Extract JSON. let json_chunk_len = u32::from_le_bytes([ bytes[12], bytes[13], bytes[14], bytes[15], ]) as usize; let json_bytes = &bytes[20..20 + json_chunk_len]; let manifest: serde_json::Value = serde_json::from_slice(json_bytes).expect("parse JSON"); let nodes = manifest["nodes"].as_array().expect("nodes array"); assert_eq!(nodes.len(), 11, "one glTF node per scene node"); } #[test] fn empty_scene_errors_cleanly() { let scene = CadScene::default(); let cache = MeshCache::new(); let exporter = GltfExporter::default(); let err = exporter.build_glb(&scene, &cache).expect_err("should error"); assert!(err.message.contains("No parts"), "{}", err.message); } } // =========================================================================== // PDF dimension tests // =========================================================================== mod pdf_dimensions { use super::*; use nigig_build::construction_frame::pages::workspace::cad::arch_pdf::{ Orientation, PaperSize, PdfExporter, PdfExportOptions, }; fn default_options() -> PdfExportOptions { PdfExportOptions { paper: PaperSize::A3, orientation: Orientation::Landscape, scale_denominator: 50.0, margin_mm: 15.0, include_title_block: true, include_scale_bar: true, include_north_arrow: true, include_grid: true, grid_spacing_m: 1.0, project_name: "Test Project".into(), author: "tests".into(), } } #[test] fn house_scene_produces_valid_pdf_magic() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = PdfExporter::new(default_options()); let bytes = exporter.build_pdf(&scene, &cache).expect("build_pdf"); // PDF files start with "%PDF-" (5 bytes). assert!(bytes.len() > 5, "PDF should be longer than 5 bytes"); assert_eq!(&bytes[0..5], b"%PDF-", "PDF magic should be '%PDF-'"); } #[test] fn pdf_contains_eof_marker() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = PdfExporter::new(default_options()); let bytes = exporter.build_pdf(&scene, &cache).expect("build_pdf"); // PDF files end with "%%EOF" (possibly followed by a newline). let tail = &bytes[bytes.len().saturating_sub(10)..]; assert!( tail.windows(5).any(|w| w == b"%%EOF"), "PDF should end with %%EOF marker, got tail: {:?}", String::from_utf8_lossy(tail) ); } #[test] fn pdf_empty_scene_errors_cleanly() { let scene = CadScene::default(); let cache = MeshCache::new(); let exporter = PdfExporter::new(default_options()); let err = exporter.build_pdf(&scene, &cache).expect_err("should error"); assert!(err.message.contains("No parts"), "{}", err.message); } #[test] fn exporter_trait_methods_return_expected_format_info() { let pdf = PdfExporter::default(); assert_eq!(pdf.format_name(), "PDF 1.7"); assert_eq!(pdf.file_extension(), "pdf"); let glb = nigig_build::construction_frame::pages::workspace::cad::arch_gltf::GltfExporter::default(); assert_eq!(glb.format_name(), "GLB 2.0"); assert_eq!(glb.file_extension(), "glb"); } } // =========================================================================== // Exporter trait polymorphism test // =========================================================================== mod exporter_trait { use super::*; use nigig_build::construction_frame::pages::workspace::cad::arch_gltf::GltfExporter; use nigig_build::construction_frame::pages::workspace::cad::arch_pdf::PdfExporter; #[test] fn both_exporters_can_be_used_through_trait_object() { let scene = build_house_scene(); let cache = MeshCache::new(); // We can't make a `dyn Exporter` because the trait has an // associated type. But we can write a generic function that // takes any `Exporter` and exercises the same code path for // both. This is what the editor's "Export..." menu would do. fn run_export(exporter: &E, scene: &CadScene, cache: &MeshCache) -> Vec { exporter.export_to_vec(scene, cache).expect("export") } let pdf_bytes = run_export(&PdfExporter::default(), &scene, &cache); let glb_bytes = run_export(&GltfExporter::default(), &scene, &cache); assert!(pdf_bytes.starts_with(b"%PDF-")); assert!(glb_bytes.starts_with(b"glTF")); } } // =========================================================================== // Round-trip: builder → scene → export → parse // =========================================================================== mod round_trip { use super::*; use nigig_build::construction_frame::pages::workspace::cad::arch_gltf::GltfExporter; #[test] fn build_export_reparse_preserves_node_count() { // Build a scene. let scene = build_house_scene(); assert_eq!(scene.node_count(), 11); // Export to GLB. let cache = MeshCache::new(); let exporter = GltfExporter::default(); let bytes = exporter.build_glb(&scene, &cache).expect("build_glb"); // Parse the JSON manifest back out. let json_chunk_len = u32::from_le_bytes([ bytes[12], bytes[13], bytes[14], bytes[15], ]) as usize; let json_bytes = &bytes[20..20 + json_chunk_len]; let manifest: serde_json::Value = serde_json::from_slice(json_bytes).expect("parse JSON"); // Verify node count survived the round trip. let nodes = manifest["nodes"].as_array().expect("nodes array"); assert_eq!(nodes.len(), 11); // Every node should have a translation. for (i, node) in nodes.iter().enumerate() { let translation = node["translation"].as_array().expect("translation"); assert_eq!(translation.len(), 3, "node {} translation should be VEC3", i); } } #[test] fn build_export_with_cache_reuses_meshes() { let scene = build_house_scene(); let cache = MeshCache::new(); let exporter = GltfExporter::default(); // First export populates the cache. let bytes1 = exporter.build_glb(&scene, &cache).expect("build_glb 1"); assert_eq!(cache.len(), 11, "cache should have one entry per node after first export"); // Second export should produce identical bytes (cache hits, // same triangulation). let bytes2 = exporter.build_glb(&scene, &cache).expect("build_glb 2"); assert_eq!( bytes1, bytes2, "second export should be byte-identical to the first" ); } } // =========================================================================== // SceneCache integration tests // =========================================================================== mod scene_cache_integration { use super::*; use nigig_build::construction_frame::pages::workspace::cad::scene_holder::SceneCache; use std::sync::Arc; /// Build a Vec with N walls of different colors. use nigig_build::construction_frame::pages::workspace::cad::scene_holder::PartsStore; fn make_colored_store(n: usize) -> PartsStore { let mut store = PartsStore::new(); for node in make_colored_parts(n) { store.push(node); } store } fn make_colored_parts(n: usize) -> Vec { let mut alloc = IdAllocator::new(); let mut builder = SceneBuilder::new(&mut alloc); for i in 0..n { builder = builder .wall() .length(6.0) .at(Vec3f { x: i as f32 * 8.0, y: 0.0, z: 0.0 }) .finish(); } let scene = builder.build(); nigig_build::construction_frame::pages::workspace::cad::cad_scene::nodes_from_scene(&scene) } #[test] fn scene_cache_returns_same_arc_on_consecutive_calls() { let cache = SceneCache::new(); let parts = make_colored_parts(5); let store = make_colored_store(parts.len()); let s1 = cache.scene_for(&store); let s2 = cache.scene_for(&store); let s3 = cache.scene_for(&store); assert!(Arc::ptr_eq(&s1, &s2), "second call must return same Arc"); assert!(Arc::ptr_eq(&s2, &s3), "third call must return same Arc"); } #[test] fn scene_cache_rebuilds_after_mark_dirty() { let cache = SceneCache::new(); let parts = make_colored_parts(5); let store = make_colored_store(parts.len()); let s1 = cache.scene_for(&store); assert!(cache.is_clean(), "cache should be clean after scene() call"); cache.mark_dirty(); assert!(!cache.is_clean(), "cache should be dirty after mark_dirty()"); let s2 = cache.scene_for(&store); assert!( !Arc::ptr_eq(&s1, &s2), "scene_for() must rebuild after mark_dirty()" ); assert!(cache.is_clean(), "cache should be clean after rebuild"); } #[test] fn scene_cache_generation_catches_add() { // Previously this exercised a "safety net": the cache compared // `node_count()` against `parts.len()` to catch a missing // `mark_dirty()`. That guess could not see a same-length change. // The store's generation counter now makes the check exact, and // no manual `mark_dirty()` is involved. let cache = SceneCache::new(); let mut store = make_colored_store(5); let s1 = cache.scene_for(&store); store.push(make_colored_parts(6).pop().expect("6th part")); let s2 = cache.scene_for(&store); assert!( !Arc::ptr_eq(&s1, &s2), "adding a node must invalidate the cached snapshot" ); assert_eq!(s2.node_count(), 6); } #[test] fn scene_cache_safety_net_catches_delete() { let cache = SceneCache::new(); let parts_5 = make_colored_parts(5); let parts_4 = make_colored_parts(4); let s1 = cache.scene(&parts_5); let s2 = cache.scene(&parts_4); assert!(!Arc::ptr_eq(&s1, &s2), "safety net must catch delete"); } #[test] fn mesh_cache_arc_persists_across_scene_rebuilds() { let cache = SceneCache::new(); let parts = make_colored_parts(3); let m1 = cache.mesh_cache(); cache.mark_dirty(); let _s = cache.scene(&parts); // triggers rebuild let m2 = cache.mesh_cache(); assert!( Arc::ptr_eq(&m1, &m2), "mesh cache Arc must persist across scene rebuilds" ); } #[test] fn mesh_cache_can_be_held_after_scene_cache_dropped() { // Simulate the export pattern: get Arc from the // SceneCache, then drop the SceneCache. The mesh cache must // stay alive (it's Arc-shared). let mesh_arc; { let cache = SceneCache::new(); let parts = make_colored_parts(3); let _scene = cache.scene(&parts); mesh_arc = cache.mesh_cache(); } // cache dropped here // mesh_arc is still valid. assert_eq!(mesh_arc.len(), 0, "no meshes built yet"); } #[test] fn scene_arc_can_be_held_after_scene_cache_dropped() { // Simulate the export pattern: get Arc from the // SceneCache, then drop the SceneCache. The scene must stay // alive (it's Arc-shared). let scene_arc; { let cache = SceneCache::new(); let parts = make_colored_parts(3); scene_arc = cache.scene(&parts); } // cache dropped here // scene_arc is still valid. assert_eq!(scene_arc.node_count(), 3); } #[test] fn exporter_uses_shared_mesh_cache() { // Simulate the full export pattern: build a scene, populate // the mesh cache by exporting once, then export again and // verify the cache has entries (proving the second export // reused the shared cache). use nigig_build::construction_frame::pages::workspace::cad::arch_gltf::GltfExporter; let cache = SceneCache::new(); let parts = make_colored_parts(5); let scene = cache.scene(&parts); let mesh_cache = cache.mesh_cache(); let exporter = GltfExporter::default(); // First export populates the mesh cache. let bytes1 = exporter .build_glb(&scene, &mesh_cache) .expect("first export"); assert_eq!( mesh_cache.len(), 5, "mesh cache should have one entry per node after first export" ); // Second export reuses the cache. let bytes2 = exporter .build_glb(&scene, &mesh_cache) .expect("second export"); assert_eq!( bytes1, bytes2, "second export should be byte-identical (cache hits)" ); assert_eq!(mesh_cache.len(), 5, "cache size unchanged after second export"); } #[test] fn parameter_edit_invalidates_only_one_node() { // Build a scene with 5 nodes, populate the cache, then // simulate a parameter edit on one node. The mesh cache // should rebuild only that node. let cache = SceneCache::new(); let parts = make_colored_parts(5); let scene = cache.scene(&parts); let mesh_cache = cache.mesh_cache(); // Populate the cache by building all meshes. for node in scene.nodes() { let _ = mesh_cache.get_or_build(node); } assert_eq!(mesh_cache.len(), 5); // Invalidate node 2. cache.invalidate_node(2); // The cache entry is gone, but the others survive. // (Note: with ParamHash, the entry isn't removed — it's just // marked stale. But invalidate_node() does remove the entry.) // The next get_or_build for node 2 will rebuild. // Rebuild node 2. let node2 = scene.node(NodeId(2)).unwrap(); let _ = mesh_cache.get_or_build(node2); assert_eq!(mesh_cache.len(), 5, "cache should have all 5 entries again"); } #[test] fn mark_dirty_plus_rebuild_preserves_node_ids() { // Critical for cache hits: after mark_dirty() + scene() // rebuild, the same part.id must map to the same NodeId. // Otherwise the mesh cache would miss every time. let cache = SceneCache::new(); let parts = make_colored_parts(5); let s1 = cache.scene(&parts); cache.mark_dirty(); let s2 = cache.scene(&parts); // Node ids must match across rebuilds. for (n1, n2) in s1.nodes().iter().zip(s2.nodes().iter()) { assert_eq!( n1.id, n2.id, "node ids must be stable across rebuilds for cache hits to work" ); } } } // =========================================================================== // Editing tool tests — 2D and 3D coverage for Add/Delete/Move/Resize/Rotate // =========================================================================== // DDE integration tests — parse + compute position (data flow without UI) // =========================================================================== // Editing-tool helpers: polygon hit-testing, CadNode accessors, extrude // =========================================================================== mod editing_tools_tests { use super::*; use nigig_build::construction_frame::pages::workspace::cad::commands::{ }; use nigig_build::construction_frame::pages::workspace::cad::scene_holder::SceneCache; use nigig_build::construction_frame::pages::workspace::cad::math::point_in_polygon; /// Build a test part at the given position with a given size. fn make_part_at(id: u64, pos: Vec3f, size: Vec3f) -> CadNode { CadNode { id: NodeId(id), name: format!("Part-{}", id), solid: Some(CadSolid::Box { size }), transform: CadTransform { translation: pos, rotation_euler_xyz: Vec3f { x: 0.0, y: 0.0, z: 0.0 }, scale: 1.0, }, material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.7, y: 0.7, z: 0.7, w: 1.0 }, kind_hint: None, } } /// Convenience: a 1x1x1 cube at the origin. fn make_unit_cube(id: u64) -> CadNode { make_part_at(id, Vec3f { x: 0.0, y: 0.0, z: 0.0 }, Vec3f { x: 1.0, y: 1.0, z: 1.0 }) } /// Convenience: a 6m wall (the default Wall size) at the origin. #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] #[test] fn point_in_polygon_unit_square_center_inside() { let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; assert!(point_in_polygon(5.0, 5.0, &square)); } #[test] fn point_in_polygon_unit_square_corner_outside() { let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; assert!(!point_in_polygon(-1.0, -1.0, &square)); assert!(!point_in_polygon(11.0, 11.0, &square)); } #[test] fn point_in_polygon_unit_square_edge_on_boundary() { // Points exactly on an edge are tricky for ray-casting. The // algorithm treats them inconsistently depending on edge // orientation. We don't assert either way — just verify it // doesn't panic. let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; let _ = point_in_polygon(5.0, 0.0, &square); let _ = point_in_polygon(0.0, 5.0, &square); } #[test] fn point_in_polygon_concave_shape() { // An L-shaped polygon (concave). let l_shape = [ (0.0, 0.0), (10.0, 0.0), (10.0, 4.0), (4.0, 4.0), (4.0, 10.0), (0.0, 10.0), ]; // Inside the lower-right arm. assert!(point_in_polygon(7.0, 2.0, &l_shape)); // Inside the upper-left arm. assert!(point_in_polygon(2.0, 7.0, &l_shape)); // In the concave notch (outside the L). assert!(!point_in_polygon(7.0, 7.0, &l_shape)); } #[test] fn point_in_polygon_hexagon_like_projected_bbox() { // A hexagon — typical shape of a projected 3D bounding box // (8 corners, but 2 project to the same point as 2 others // in most viewing angles, leaving 6 distinct vertices). let hex = [ (100.0, 50.0), (150.0, 30.0), (200.0, 50.0), (200.0, 150.0), (150.0, 170.0), (100.0, 150.0), ]; // Center should be inside. assert!(point_in_polygon(150.0, 100.0, &hex)); // Outside. assert!(!point_in_polygon(50.0, 100.0, &hex)); assert!(!point_in_polygon(250.0, 100.0, &hex)); } #[test] fn point_in_polygon_degenerate_less_than_3_vertices() { assert!(!point_in_polygon(0.0, 0.0, &[])); assert!(!point_in_polygon(0.0, 0.0, &[(0.0, 0.0)])); assert!(!point_in_polygon(0.0, 0.0, &[(0.0, 0.0), (1.0, 1.0)])); } // ----------------------------------------------------------------------- // CadNode accessor round-trips — verify the v18b migration didn't // break pos/size/rot setters and getters. // ----------------------------------------------------------------------- #[test] fn cadnode_pos_setter_round_trips() { let mut node = make_unit_cube(1); let new_pos = Vec3f { x: 5.0, y: 6.0, z: 7.0 }; node.set_pos(new_pos); assert_eq!(node.pos(), new_pos); } #[test] fn cadnode_size_setter_round_trips_for_box() { let mut node = make_unit_cube(1); let new_size = Vec3f { x: 2.0, y: 3.0, z: 4.0 }; node.set_size(new_size); assert_eq!(node.size(), new_size); } #[test] fn cadnode_rot_setter_round_trips() { let mut node = make_unit_cube(1); let new_rot = Vec3f { x: 45.0, y: 90.0, z: 135.0 }; node.set_rot(new_rot); assert_eq!(node.rot(), new_rot); } #[test] fn cadnode_part_kind_matches_solid() { let cube = make_unit_cube(1); assert_eq!(cube.part_kind(), PartKind::Cube); let cylinder = CadNode { id: NodeId(2), name: "Col".into(), solid: Some(CadSolid::Cylinder { radius: 0.5, height: 3.0, segments: 24, }), transform: CadTransform::default(), material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.7, y: 0.7, z: 0.7, w: 1.0 }, kind_hint: None, }; assert_eq!(cylinder.part_kind(), PartKind::Cylinder); let sphere = CadNode { id: NodeId(3), name: "Ball".into(), solid: Some(CadSolid::Sphere { radius: 1.0, segments_u: 24, segments_v: 16, }), transform: CadTransform::default(), material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.7, y: 0.7, z: 0.7, w: 1.0 }, kind_hint: None, }; assert_eq!(sphere.part_kind(), PartKind::Sphere); } #[test] fn cadnode_group_id_round_trips() { let mut node = make_unit_cube(1); assert_eq!(node.group_id(), None); node.parent = Some(NodeId(5)); assert_eq!(node.group_id(), Some(5)); } /// Build a 2D rectangle (truly flat, no height) at the origin. /// v18b rev2: uses CadSolid::Rect2D, not a thin Box. fn make_rect_2d(id: u64, width: f32, depth: f32) -> CadNode { CadNode { id: NodeId(id), name: format!("Rect2D-{}", id), solid: Some(CadSolid::Rect2D { width, height: depth }), transform: CadTransform::default(), material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.4, y: 0.8, z: 0.9, w: 1.0 }, kind_hint: Some(PartKind::Rect2D), } } /// Build a 2D circle (truly flat disc, no height). /// v18b rev2: uses CadSolid::Circle2D, not a thin Cylinder. fn make_circle_2d(id: u64, diameter: f32) -> CadNode { CadNode { id: NodeId(id), name: format!("Circle2D-{}", id), solid: Some(CadSolid::Circle2D { radius: diameter * 0.5, segments: 32, }), transform: CadTransform::default(), material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.9, y: 0.6, z: 0.4, w: 1.0 }, kind_hint: Some(PartKind::Circle2D), } } /// Build a 2D polygon (triangle) with 3 vertices. fn make_polygon_2d(id: u64) -> CadNode { use makepad_widgets::DVec2; CadNode { id: NodeId(id), name: format!("Poly-{}", id), solid: Some(CadSolid::Polygon2D { verts: vec![ DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 2.0, y: 0.0 }, DVec2 { x: 1.0, y: 2.0 }, ], }), transform: CadTransform::default(), material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.7, y: 0.7, z: 0.7, w: 1.0 }, kind_hint: Some(PartKind::Polygon2D), } } /// Simulate the extrude operation for a Rect2D. /// v18b rev2: converts CadSolid::Rect2D → CadSolid::Box. fn extrude_rect2d(old_part: &CadNode, height: f32) -> CadNode { let mut new_node = old_part.clone(); new_node.set_pos(Vec3f { x: old_part.pos().x, y: height * 0.5, z: old_part.pos().z, }); let (w, h) = match &old_part.solid { Some(CadSolid::Rect2D { width, height }) => (*width, *height), _ => (old_part.size().x, old_part.size().z), }; new_node.solid = Some(CadSolid::Box { size: Vec3f { x: w, y: height, z: h }, }); new_node.kind_hint = Some(PartKind::Wall); new_node } /// Simulate the extrude operation for a Circle2D. /// v18b rev2: converts CadSolid::Circle2D → CadSolid::Cylinder. fn extrude_circle2d(old_part: &CadNode, height: f32) -> CadNode { let mut new_node = old_part.clone(); new_node.set_pos(Vec3f { x: old_part.pos().x, y: height * 0.5, z: old_part.pos().z, }); let radius = match &old_part.solid { Some(CadSolid::Circle2D { radius, .. }) => *radius, _ => old_part.size().x * 0.5, }; new_node.solid = Some(CadSolid::Cylinder { radius, height, segments: 24, }); new_node.kind_hint = Some(PartKind::Column); new_node } /// Simulate the extrude operation for a Polygon2D. fn extrude_polygon2d(old_part: &CadNode, height: f32) -> CadNode { let mut new_node = old_part.clone(); new_node.set_pos(Vec3f { x: old_part.pos().x, y: height * 0.5, z: old_part.pos().z, }); let verts_opt = match &old_part.solid { Some(CadSolid::Polygon2D { verts }) => Some(verts.clone()), Some(CadSolid::ExtrudedPolygon { verts, .. }) => Some(verts.clone()), _ => None, }; if let Some(verts) = verts_opt { new_node.solid = Some(CadSolid::ExtrudedPolygon { verts, height }); } else { // Fallback: Box let mut new_size = old_part.size(); new_size.y = height; new_node.set_size(new_size); } new_node } #[test] fn extrude_rect2d_produces_taller_box() { let original = make_rect_2d(1, 4.0, 3.0); assert_eq!(original.size().z, 0.0, "original should be flat (z=0)"); let extruded = extrude_rect2d(&original, 2.8); assert_eq!(extruded.size().x, 4.0, "width preserved"); assert_eq!(extruded.size().z, 3.0, "depth preserved"); assert_eq!(extruded.size().y, 2.8, "height = extrude height"); assert_eq!(extruded.pos().y, 1.4, "centered at height/2"); // Solid should still be a Box (Rect2D was a Box, extruded Wall is a Box). assert!(matches!( extruded.solid, Some(CadSolid::Box { .. }) )); } #[test] fn extrude_circle2d_produces_cylinder() { let original = make_circle_2d(1, 2.0); assert!(matches!(original.solid, Some(CadSolid::Circle2D { .. }))); let extruded = extrude_circle2d(&original, 3.0); // After extrude, the solid should be a Cylinder. match &extruded.solid { Some(CadSolid::Cylinder { radius, height, segments }) => { assert_eq!(*radius, 1.0, "radius = diameter/2"); assert_eq!(*height, 3.0, "height = extrude height"); assert_eq!(*segments, 24, "default segment count"); } other => panic!("expected Cylinder, got {:?}", other), } assert_eq!(extruded.pos().y, 1.5, "centered at height/2"); } #[test] fn extrude_polygon2d_produces_extruded_polygon() { let original = make_polygon_2d(1); // Precondition: original is a Polygon2D with 3 verts. match &original.solid { Some(CadSolid::Polygon2D { verts }) => { assert_eq!(verts.len(), 3, "triangle has 3 verts"); } other => panic!("expected Polygon2D, got {:?}", other), } let extruded = extrude_polygon2d(&original, 2.5); match &extruded.solid { Some(CadSolid::ExtrudedPolygon { verts, height }) => { assert_eq!(verts.len(), 3, "verts preserved"); assert_eq!(*height, 2.5, "height = extrude height"); } other => panic!("expected ExtrudedPolygon, got {:?}", other), } assert_eq!(extruded.pos().y, 1.25, "centered at height/2"); } #[test] #[test] #[test] #[test] fn extrude_preserves_horizontal_position() { // The extrude should not change x/z position — only y (height). let original = make_part_at( 1, Vec3f { x: 5.0, y: 0.0, z: -3.0 }, Vec3f { x: 4.0, y: 0.1, z: 3.0 }, ); let extruded = extrude_rect2d(&original, 2.8); assert_eq!(extruded.pos().x, 5.0, "x preserved"); assert_eq!(extruded.pos().z, -3.0, "z preserved"); assert_eq!(extruded.pos().y, 1.4, "y = height/2"); } #[test] fn extrude_height_zero_is_no_op() { // Edge case: extruding to height 0 should produce a degenerate // but non-crashing result. The UI filters h > 0, but the // command layer should still handle it. let original = make_rect_2d(1, 4.0, 3.0); let extruded = extrude_rect2d(&original, 0.0); assert_eq!(extruded.size().y, 0.0); assert_eq!(extruded.pos().y, 0.0); } fn make_wall(id: u64) -> CadNode { make_part_at( id, Vec3f { x: 0.0, y: 0.0, z: 0.0 }, Vec3f { x: 6.0, y: 2.8, z: 0.2 }, ) } // ----------------------------------------------------------------------- // Add Part — tests the v18b fix where CreateNode stores a snapshot // so that redo can re-push the part after undo removed it. // ----------------------------------------------------------------------- #[test] fn delete_part_from_empty_selection_is_no_op() { // When the user clicks Delete with no selection, delete_selected // drains the empty selection vec and the loop body never runs. // We simulate that here. let mut parts = vec![make_wall(1)]; let selection: Vec = Vec::new(); let original_len = parts.len(); for id in &selection { if let Some(index) = parts.iter().position(|p| p.id.raw() == *id) { parts.remove(index); } } assert_eq!(parts.len(), original_len, "no parts should be deleted"); } // ----------------------------------------------------------------------- // Move Part — exercises MoveNode. The command itself is // view-mode-agnostic (it just sets pos); the view-mode-specific // math (yaw rotation for 3D) happens in the caller before // constructing the command. // ----------------------------------------------------------------------- #[test] fn cadnode_dof_constraint_round_trips() { let mut node = make_unit_cube(1); assert_eq!(node.dof_constraint(), None); let dof = DofConstraint { fix_tx: true, fix_ty: true, fix_tz: false, fix_rx: false, fix_ry: false, fix_rz: false, }; node.metadata.dof_constraint = Some(dof); let retrieved = node.dof_constraint().expect("dof should be present"); assert!(retrieved.fix_tx); assert!(retrieved.fix_ty); assert!(!retrieved.fix_tz); } // ----------------------------------------------------------------------- // Extrude — verifies that extruding a 2D part produces the correct // 3D solid type, and that the change is reversible via // ModifyNode (which is what the UI records). // ----------------------------------------------------------------------- } // =========================================================================== #[cfg(test)] mod dde_integration_tests { use nigig_build::construction_frame::pages::workspace::cad::construction_geometry::{ CoordInput, parse_coord_input, }; use makepad_widgets::DVec2; /// Simulate the coordinate computation from `dde_live_preview`. /// Mirrors the math in viewport.rs `dde_live_preview()` without /// requiring a full `CadViewport`. fn compute_dde_position( start_world: DVec2, dde_buffer: &str, cursor_dir_deg: f64, ) -> Option { let coord = parse_coord_input(dde_buffer)?; Some(match coord { CoordInput::DirectDistance(dist) => { let a = cursor_dir_deg.to_radians(); DVec2 { x: start_world.x + dist * a.cos(), y: start_world.y + dist * a.sin(), } } CoordInput::Polar(dist, angle_deg) => { let a = angle_deg.to_radians(); DVec2 { x: start_world.x + dist * a.cos(), y: start_world.y + dist * a.sin(), } } CoordInput::RelativeCartesian(dx, dy, _z) => DVec2 { x: start_world.x + dx, y: start_world.y + dy, }, CoordInput::Spherical(..) => return None, }) } #[test] fn dde_direct_distance_horizontal() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "5", 0.0).unwrap(); assert!((pos.x - 5.0).abs() < 1e-9); assert!((pos.y - 0.0).abs() < 1e-9); } #[test] fn dde_direct_distance_45_deg() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "10", 45.0).unwrap(); let expected_x = 10.0 * 45.0_f64.to_radians().cos(); let expected_y = 10.0 * 45.0_f64.to_radians().sin(); assert!((pos.x - expected_x).abs() < 1e-9); assert!((pos.y - expected_y).abs() < 1e-9); } #[test] fn dde_direct_distance_from_offset() { let start = DVec2 { x: 100.0, y: 50.0 }; let pos = compute_dde_position(start, "7.5", 0.0).unwrap(); assert!((pos.x - 107.5).abs() < 1e-9); assert!((pos.y - 50.0).abs() < 1e-9); } #[test] fn dde_polar_0_deg() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "5<0", 999.0).unwrap(); assert!((pos.x - 5.0).abs() < 1e-9); assert!((pos.y - 0.0).abs() < 1e-9); } #[test] fn dde_polar_90_deg() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "5<90", 999.0).unwrap(); assert!((pos.x - 0.0).abs() < 1e-9); assert!((pos.y - 5.0).abs() < 1e-9); } #[test] fn dde_polar_negative_angle() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "5<-90", 999.0).unwrap(); assert!((pos.x - 0.0).abs() < 1e-9); assert!((pos.y - (-5.0)).abs() < 1e-9); } #[test] fn dde_relative_cartesian() { let start = DVec2 { x: 10.0, y: 20.0 }; let pos = compute_dde_position(start, "@5,3", 0.0).unwrap(); assert!((pos.x - 15.0).abs() < 1e-9); assert!((pos.y - 23.0).abs() < 1e-9); } #[test] fn dde_relative_cartesian_negative() { let start = DVec2 { x: 10.0, y: 20.0 }; let pos = compute_dde_position(start, "@-5,-3", 0.0).unwrap(); assert!((pos.x - 5.0).abs() < 1e-9); assert!((pos.y - 17.0).abs() < 1e-9); } #[test] fn dde_empty_buffer_returns_none() { let start = DVec2 { x: 0.0, y: 0.0 }; assert!(compute_dde_position(start, "", 0.0).is_none()); } #[test] fn dde_invalid_buffer_returns_none() { let start = DVec2 { x: 0.0, y: 0.0 }; assert!(compute_dde_position(start, "abc", 0.0).is_none()); } #[test] fn dde_buffer_incremental_parse() { // Simulate typing "5<4" then "5<45". // // NOTE: this test used to assert that "5<4" is "partial" and must // parse to `None`. That premise is wrong and contradicted the rest // of the suite -- `dde_polar_0_deg` asserts "5<0" IS valid, and a // single-digit angle is indistinguishable from it. A polar entry is // complete as soon as both operands parse; there is no way to know // the user intended to type another digit. Each prefix is therefore // a valid intermediate position, which is exactly what a live // preview should show. let start = DVec2 { x: 0.0, y: 0.0 }; let partial = compute_dde_position(start, "5<4", 0.0).expect("5<4 is a valid polar entry"); assert!((partial.x - 5.0 * 4.0_f64.to_radians().cos()).abs() < 1e-9); let pos = compute_dde_position(start, "5<45", 0.0).unwrap(); assert!((pos.x - 5.0 * 45.0_f64.to_radians().cos()).abs() < 1e-9); } /// Genuinely incomplete numeric input must not commit a position. /// Rust parses "3." as 3.0, so without a guard the live preview would /// snap to 3.0 while the user is still typing "3.5". #[test] fn dde_trailing_separator_is_incomplete() { let start = DVec2 { x: 0.0, y: 0.0 }; for buf in ["3.", "1e", "1E", "5-", "5+"] { assert!( compute_dde_position(start, buf, 0.0).is_none(), "{buf:?} should be treated as still-being-typed" ); } } /// "nan"/"inf" parse as f64 but are not usable coordinates. #[test] fn dde_non_finite_words_rejected() { let start = DVec2 { x: 0.0, y: 0.0 }; for buf in ["nan", "inf", "-inf", "NaN"] { assert!( compute_dde_position(start, buf, 0.0).is_none(), "{buf:?} must not be accepted as a distance" ); } } #[test] fn dde_buffer_at_prefix() { // Typing "@" should still be incomplete assert!(compute_dde_position(DVec2::default(), "@", 0.0).is_none()); // "@5" alone — not relative (no comma), not spherical (< missing) assert!(compute_dde_position(DVec2::default(), "@5", 0.0).is_none()); // "@5,3" works assert!(compute_dde_position(DVec2::default(), "@5,3", 0.0).is_some()); } #[test] fn dde_spherical_unsupported_in_2d() { assert!(compute_dde_position(DVec2::default(), "@5<45<30", 0.0).is_none()); } #[test] fn dde_polar_with_spaces() { let pos = compute_dde_position(DVec2::default(), " 10 < 90 ", 0.0).unwrap(); assert!((pos.x - 0.0).abs() < 1e-9); assert!((pos.y - 10.0).abs() < 1e-9); } #[test] fn dde_full_workflow_typing_digits_then_enter() { // Simulate typing "3" then "3.5" then "3.5" with Enter let start = DVec2 { x: 1.0, y: 2.0 }; // After typing "3": let pos3 = compute_dde_position(start, "3", 0.0).unwrap(); assert!((pos3.x - 4.0).abs() < 1e-9); assert!((pos3.y - 2.0).abs() < 1e-9); // After typing ".": assert!(compute_dde_position(start, "3.", 0.0).is_none()); // After typing "5" → "3.5": let pos35 = compute_dde_position(start, "3.5", 0.0).unwrap(); assert!((pos35.x - 4.5).abs() < 1e-9); assert!((pos35.y - 2.0).abs() < 1e-9); } #[test] fn dde_polar_negative_distance() { let start = DVec2 { x: 0.0, y: 0.0 }; let pos = compute_dde_position(start, "-5<0", 0.0).unwrap(); assert!((pos.x - (-5.0)).abs() < 1e-9); assert!((pos.y - 0.0).abs() < 1e-9); } } // =========================================================================== // Window/Crossing selection integration tests // =========================================================================== #[cfg(test)] mod window_crossing_tests { use nigig_build::construction_frame::pages::workspace::cad::SelectionMode; use makepad_widgets::DVec2; /// A minimal 2D part representation for testing marquee selection logic. struct TestPart { id: u64, center: DVec2, half_size: DVec2, } /// Simulate the marquee selection logic from viewport.rs MouseUp handler. /// Returns the IDs of selected parts. fn marquee_select( parts: &[TestPart], start: DVec2, current: DVec2, mode: SelectionMode, shift: bool, mut existing: Vec, ) -> Vec { let min_x = start.x.min(current.x); let min_y = start.y.min(current.y); let max_x = start.x.max(current.x); let max_y = start.y.max(current.y); if !shift { existing.clear(); } for part in parts { if mode == SelectionMode::Window { // Window: all 4 corners of AABB must be inside marquee let corners = [ (part.center.x - part.half_size.x, part.center.y - part.half_size.y), (part.center.x + part.half_size.x, part.center.y - part.half_size.y), (part.center.x - part.half_size.x, part.center.y + part.half_size.y), (part.center.x + part.half_size.x, part.center.y + part.half_size.y), ]; if corners.iter().all(|&(cx, cy)| { cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y }) { if !existing.contains(&part.id) { existing.push(part.id); } } } else { // Crossing: center point inside is enough if part.center.x >= min_x && part.center.x <= max_x && part.center.y >= min_y && part.center.y <= max_y { if !existing.contains(&part.id) { existing.push(part.id); } } } } existing } fn part(id: u64, cx: f64, cy: f64, hw: f64, hh: f64) -> TestPart { TestPart { id, center: DVec2 { x: cx, y: cy }, half_size: DVec2 { x: hw, y: hh }, } } // ── Crossing selection tests ── #[test] fn crossing_selects_center_inside() { let parts = vec![part(1, 5.0, 5.0, 1.0, 1.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Crossing, false, vec![], ); assert_eq!(sel, vec![1]); } #[test] fn crossing_selects_partially_inside() { // Part extends from (4,4) to (8,8), marquee covers (0,0)-(6,6) // Center at (6,6) is inside marquee, so crossing selects it let parts = vec![part(1, 6.0, 6.0, 2.0, 2.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 6.0, y: 6.0 }, SelectionMode::Crossing, false, vec![], ); assert_eq!(sel, vec![1]); } #[test] fn crossing_skips_outside() { let parts = vec![part(1, 15.0, 15.0, 1.0, 1.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Crossing, false, vec![], ); assert!(sel.is_empty()); } // ── Window selection tests ── #[test] fn window_selects_fully_enclosed() { // Part from (4,4) to (6,6) fully inside marquee (0,0)-(10,10) let parts = vec![part(1, 5.0, 5.0, 1.0, 1.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Window, false, vec![], ); assert_eq!(sel, vec![1]); } #[test] fn window_skips_partially_inside() { // Part from (3,3) to (7,7), marquee (0,0)-(5,5) // Center at (5,5) is inside, but corners at (7,7) extend outside let parts = vec![part(1, 5.0, 5.0, 2.0, 2.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 5.0, y: 5.0 }, SelectionMode::Window, false, vec![], ); assert!(sel.is_empty()); } #[test] fn window_skips_outside() { let parts = vec![part(1, 15.0, 15.0, 1.0, 1.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Window, false, vec![], ); assert!(sel.is_empty()); } #[test] fn window_selects_multiple() { let parts = vec![ part(1, 2.0, 2.0, 0.5, 0.5), part(2, 3.0, 3.0, 0.5, 0.5), part(3, 8.0, 8.0, 0.5, 0.5), // outside ]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 5.0, y: 5.0 }, SelectionMode::Window, false, vec![], ); assert_eq!(sel, vec![1, 2]); } // ── Edge cases ── #[test] fn crossing_selects_on_boundary() { // Part center exactly on marquee edge let parts = vec![part(1, 5.0, 5.0, 0.1, 0.1)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 5.0, y: 5.0 }, SelectionMode::Crossing, false, vec![], ); assert_eq!(sel, vec![1]); } #[test] fn window_selects_exactly_fit() { // Part exactly fits inside marquee (edges touch) let parts = vec![part(1, 5.0, 5.0, 5.0, 5.0)]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Window, false, vec![], ); assert_eq!(sel, vec![1]); } #[test] fn shift_additive_selection() { let parts = vec![ part(1, 2.0, 2.0, 0.5, 0.5), part(2, 8.0, 8.0, 0.5, 0.5), ]; // First: select part 1 let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 3.0, y: 3.0 }, SelectionMode::Crossing, false, vec![], ); assert_eq!(sel, vec![1]); // Second: shift-select part 2 (additive) let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Crossing, true, sel, ); assert_eq!(sel, vec![1, 2]); } #[test] fn non_shift_replaces_selection() { let parts = vec![ part(1, 2.0, 2.0, 0.5, 0.5), part(2, 8.0, 8.0, 0.5, 0.5), ]; // First: select part 1 let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 3.0, y: 3.0 }, SelectionMode::Crossing, false, vec![1], ); // Non-shift should replace assert_eq!(sel, vec![1]); // Now non-shift select around both: clears old, selects both let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Crossing, false, sel, ); assert_eq!(sel, vec![1, 2]); } #[test] fn empty_parts_list() { let parts = vec![]; let sel = marquee_select( &parts, DVec2 { x: 0.0, y: 0.0 }, DVec2 { x: 10.0, y: 10.0 }, SelectionMode::Crossing, false, vec![], ); assert!(sel.is_empty()); } #[test] fn direction_detection_left_to_right_is_window() { let start = DVec2 { x: 100.0, y: 100.0 }; let current = DVec2 { x: 200.0, y: 150.0 }; let mode = if current.x >= start.x { SelectionMode::Window } else { SelectionMode::Crossing }; assert_eq!(mode, SelectionMode::Window); } #[test] fn direction_detection_right_to_left_is_crossing() { let start = DVec2 { x: 200.0, y: 100.0 }; let current = DVec2 { x: 100.0, y: 150.0 }; let mode = if current.x >= start.x { SelectionMode::Window } else { SelectionMode::Crossing }; assert_eq!(mode, SelectionMode::Crossing); } } // =========================================================================== // Polar tracking tests // =========================================================================== #[cfg(test)] mod polar_tracking_tests { use nigig_build::construction_frame::pages::workspace::cad::construction_geometry::{ snap_to_polar_angle, next_polar_increment, }; use std::f64::consts::PI; #[test] fn snap_angle_45deg() { let a = PI / 4.0; // 45° let snapped = snap_to_polar_angle(a, 45.0); assert!((snapped - a).abs() < 1e-10); } #[test] fn snap_angle_15deg_to_30() { let a = 28.0_f64.to_radians(); let snapped = snap_to_polar_angle(a, 15.0); assert!((snapped - 30.0_f64.to_radians()).abs() < 1e-10); } #[test] fn snap_angle_15deg_to_0() { let a = 2.0_f64.to_radians(); let snapped = snap_to_polar_angle(a, 15.0); assert!(snapped.abs() < 1e-10); } #[test] fn snap_angle_10deg_increments() { let a = 35.0_f64.to_radians(); let snapped = snap_to_polar_angle(a, 10.0); assert!((snapped - 40.0_f64.to_radians()).abs() < 1e-10); } #[test] fn snap_angle_5deg_increments() { let a = 12.0_f64.to_radians(); let snapped = snap_to_polar_angle(a, 5.0); assert!((snapped - 10.0_f64.to_radians()).abs() < 1e-10); } #[test] fn snap_angle_exact_90() { let a = PI / 2.0; // 90° let snapped = snap_to_polar_angle(a, 15.0); assert!((snapped - a).abs() < 1e-10); } #[test] fn snap_angle_negative() { let a = -PI / 6.0; // -30° let snapped = snap_to_polar_angle(a, 45.0); assert!((snapped - (-45.0_f64.to_radians())).abs() < 1e-10); } #[test] fn snap_angle_beyond_360() { let a = 370.0_f64.to_radians(); // equivalent to 10° let snapped = snap_to_polar_angle(a, 45.0); assert!((snapped - 0.0_f64.to_radians()).abs() < 1e-10); } #[test] fn cycle_5_to_10() { assert!((next_polar_increment(5.0) - 10.0).abs() < 0.1); } #[test] fn cycle_90_wraps_to_5() { assert!((next_polar_increment(90.0) - 5.0).abs() < 0.1); } #[test] fn cycle_full_round() { let mut v = 5.0; for _ in 0..6 { v = next_polar_increment(v); } assert!((v - 5.0).abs() < 0.1); } #[test] fn cycle_unknown_value_wraps_to_5() { assert!((next_polar_increment(22.0) - 5.0).abs() < 0.1); } } // =========================================================================== // Section shape integration tests // =========================================================================== #[cfg(test)] mod section_shape_tests { use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{CadNode, CadSolid, PartKind}; use nigig_build::construction_frame::pages::workspace::cad::section_shape::{ SectionShape, IBeamParams, HSSParams, section_vertices, section_bounding_box, section_area, rect_vertices, ibeam_vertices, hss_vertices, }; use makepad_widgets::DVec2; // ── CadSolid mesh generation ── #[test] fn extruded_ibeam_mesh_has_triangles() { let solid = CadSolid::ExtrudedIBeam { depth: 0.2, flange_width: 0.15, flange_thickness: 0.012, web_thickness: 0.008, length: 1.0, }; let mesh = solid.build_mesh(); assert!(!mesh.triangles.is_empty(), "I-beam mesh should have triangles"); assert!(!mesh.vertices.is_empty(), "I-beam mesh should have vertices"); } #[test] fn extruded_hss_mesh_has_triangles() { let solid = CadSolid::ExtrudedHSS { width: 0.15, depth: 0.15, wall_thickness: 0.01, length: 0.8, }; let mesh = solid.build_mesh(); assert!(!mesh.triangles.is_empty(), "HSS mesh should have triangles"); assert!(!mesh.vertices.is_empty(), "HSS mesh should have vertices"); } #[test] fn extruded_ibeam_longer_than_wide() { let solid = CadSolid::ExtrudedIBeam { depth: 0.1, flange_width: 0.1, flange_thickness: 0.01, web_thickness: 0.006, length: 2.0, }; let mesh = solid.build_mesh(); // Should have at least 12 side faces (4 outer + 4 inner * 2 tris) + end caps assert!(mesh.triangles.len() >= 16, "Expected >=16 triangles for I-beam, got {}", mesh.triangles.len()); } #[test] fn extruded_hss_has_hollow_interior() { // The HSS mesh should have more triangles than a solid box of the same size // because it has inner walls let hss = CadSolid::ExtrudedHSS { width: 0.2, depth: 0.2, wall_thickness: 0.01, length: 0.5, }; let box_solid = CadSolid::Box { size: makepad_widgets::Vec3f { x: 0.2, y: 0.5, z: 0.2 } }; let hss_mesh = hss.build_mesh(); let box_mesh = box_solid.build_mesh(); // HSS has inner walls + end caps, so more triangles than a simple box assert!(hss_mesh.triangles.len() > box_mesh.triangles.len(), "HSS ({}) should have more triangles than Box ({})", hss_mesh.triangles.len(), box_mesh.triangles.len()); } // ── Section shape as CadNode ── #[test] fn beam_node_with_ibeam_solid() { let solid = CadSolid::ExtrudedIBeam { depth: 0.25, flange_width: 0.20, flange_thickness: 0.015, web_thickness: 0.010, length: 1.5, }; let node = CadNode { solid: Some(solid), kind_hint: Some(PartKind::Beam), ..Default::default() }; assert_eq!(node.kind_hint, Some(PartKind::Beam)); let solid = node.build_solid(); let mesh = solid.mesh(); assert!(!mesh.triangles.is_empty()); } #[test] fn beam_node_with_hss_solid() { let solid = CadSolid::ExtrudedHSS { width: 0.20, depth: 0.20, wall_thickness: 0.012, length: 1.0, }; let node = CadNode { solid: Some(solid), kind_hint: Some(PartKind::Beam), ..Default::default() }; assert_eq!(node.kind_hint, Some(PartKind::Beam)); let solid = node.build_solid(); let mesh = solid.mesh(); assert!(!mesh.triangles.is_empty()); } // ── Section area calculations ── #[test] fn ibeam_area_less_than_bounding_rect() { let area = section_area(SectionShape::IBeam, (1.0, 1.0)); let (w, h) = section_bounding_box(SectionShape::IBeam, (1.0, 1.0)); assert!(area < w * h, "IBeam area {} should be less than bbox {}×{}", area, w, h); } #[test] fn hss_area_less_than_bounding_rect() { let area = section_area(SectionShape::HSS, (1.0, 1.0)); let (w, h) = section_bounding_box(SectionShape::HSS, (1.0, 1.0)); assert!(area < w * h, "HSS area {} should be less than bbox {}×{}", area, w, h); } #[test] fn hss_area_positive() { let area = section_area(SectionShape::HSS, (1.0, 1.0)); assert!(area > 0.0, "HSS area should be positive, got {}", area); } // ── Section shape cycling ── #[test] fn section_shape_full_cycle() { let s = SectionShape::Rect; let s = s.next(); // IBeam assert_eq!(s, SectionShape::IBeam); let s = s.next(); // HSS assert_eq!(s, SectionShape::HSS); let s = s.next(); // Rect assert_eq!(s, SectionShape::Rect); } #[test] fn section_shape_label_matches() { assert_eq!(SectionShape::Rect.label(), "Rect"); assert_eq!(SectionShape::IBeam.label(), "I-Beam"); assert_eq!(SectionShape::HSS.label(), "HSS"); } // ── Cross-section vertex generation ── #[test] fn section_vertices_rect() { let v = section_vertices(SectionShape::Rect, (0.2, 0.3)); assert_eq!(v.len(), 4); } #[test] fn section_vertices_ibeam() { let v = section_vertices(SectionShape::IBeam, (0.2, 0.3)); assert_eq!(v.len(), 12); } #[test] fn section_vertices_hss() { let v = section_vertices(SectionShape::HSS, (0.2, 0.3)); assert_eq!(v.len(), 8); } } // =========================================================================== // Session 6 — Subdivide, DOF assignment, Selection actions // =========================================================================== #[cfg(test)] mod selection_action_tests { use super::*; use makepad_widgets::Vec3f; fn make_box_part(id: u64, pos: Vec3f) -> CadNode { CadNode { id: NodeId(id), name: format!("part_{}", id), solid: Some(CadSolid::Box { size: Vec3f { x: 1.0, y: 1.0, z: 1.0 } }), transform: CadTransform { translation: pos, rotation_euler_xyz: Vec3f { x: 0.0, y: 0.0, z: 0.0 }, scale: 1.0, }, material: MaterialId(0), layer: LayerId(0), parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.5, y: 0.5, z: 0.5, w: 1.0 }, kind_hint: None, } } // ── subdivide_mesh unit tests (integration layer) ── #[test] fn subdivide_mesh_increases_triangle_count() { let mesh = CadSolid::Box { size: Vec3f { x: 1.0, y: 1.0, z: 1.0 } }.build_mesh(); let original_tris = mesh.triangles.len(); let subdivided = cad_scene::subdivide_mesh(&mesh); assert_eq!(subdivided.triangles.len(), original_tris * 4); } #[test] fn subdivide_mesh_vertex_count_grows() { let mesh = CadSolid::Box { size: Vec3f { x: 1.0, y: 1.0, z: 1.0 } }.build_mesh(); let original_verts = mesh.vertices.len(); let subdivided = cad_scene::subdivide_mesh(&mesh); assert!(subdivided.vertices.len() > original_verts); } // ── DofConstraint tests ── #[test] fn dof_constraint_default_is_all_free() { let dof = DofConstraint::default(); assert!(!dof.fix_tx); assert!(!dof.fix_ty); assert!(!dof.fix_tz); assert!(!dof.fix_rx); assert!(!dof.fix_ry); assert!(!dof.fix_rz); } #[test] fn dof_constraint_assign_individual_axis() { let dof = DofConstraint { fix_tx: true, fix_ty: false, fix_tz: false, fix_rx: false, fix_ry: false, fix_rz: false }; assert!(dof.fix_tx); assert!(!dof.fix_ty); assert!(!dof.fix_rz); } #[test] fn dof_constraint_all_fixed() { let dof = DofConstraint { fix_tx: true, fix_ty: true, fix_tz: true, fix_rx: true, fix_ry: true, fix_rz: true }; assert!(dof.fix_tx && dof.fix_ty && dof.fix_tz && dof.fix_rx && dof.fix_ry && dof.fix_rz); } #[test] fn dof_node_metadata_roundtrip() { let dof = DofConstraint { fix_tx: true, fix_ty: false, fix_tz: true, fix_rx: false, fix_ry: true, fix_rz: false }; let mut meta = NodeMetadata::default(); meta.dof_constraint = Some(dof); let retrieved = meta.dof_constraint.unwrap(); assert_eq!(retrieved.fix_tx, true); assert_eq!(retrieved.fix_ty, false); assert_eq!(retrieved.fix_tz, true); assert_eq!(retrieved.fix_rx, false); assert_eq!(retrieved.fix_ry, true); assert_eq!(retrieved.fix_rz, false); } // ── Selection actions on CadNode level ── #[test] fn isolate_hides_non_selected_via_name_prefix() { let mut parts: Vec = vec![ make_box_part(1, Vec3f { x: 0.0, y: 0.0, z: 0.0 }), make_box_part(2, Vec3f { x: 2.0, y: 0.0, z: 0.0 }), make_box_part(3, Vec3f { x: 4.0, y: 0.0, z: 0.0 }), ]; let selection = vec![1u64]; // Simulate isolate: prefix non-selected with __hidden__ for p in &mut parts { if !selection.contains(&p.id.raw()) && !p.name.starts_with("__hidden__") { p.name = format!("__hidden__{}", p.name); } } assert_eq!(parts[0].name, "part_1"); assert_eq!(parts[1].name, "__hidden__part_2"); assert_eq!(parts[2].name, "__hidden__part_3"); } #[test] fn isolate_restore_removes_prefix() { let mut parts: Vec = vec![ make_box_part(1, Vec3f { x: 0.0, y: 0.0, z: 0.0 }), make_box_part(2, Vec3f { x: 2.0, y: 0.0, z: 0.0 }), ]; // Already isolated parts[1].name = "__hidden__part_2".to_string(); // Restore for p in &mut parts { if p.name.starts_with("__hidden__") { p.name = p.name.strip_prefix("__hidden__").unwrap_or(&p.name).to_string(); } } assert_eq!(parts[0].name, "part_1"); assert_eq!(parts[1].name, "part_2"); } #[test] fn select_all_gives_all_ids() { let parts: Vec = vec![ make_box_part(1, Vec3f { x: 0.0, y: 0.0, z: 0.0 }), make_box_part(2, Vec3f { x: 2.0, y: 0.0, z: 0.0 }), make_box_part(3, Vec3f { x: 4.0, y: 0.0, z: 0.0 }), ]; let selection: Vec = parts.iter().map(|p| p.id.raw()).collect(); assert_eq!(selection, vec![1, 2, 3]); } #[test] fn subdivide_mesh_preserves_bounding_box() { let mesh = CadSolid::Box { size: Vec3f { x: 2.0, y: 3.0, z: 4.0 } }.build_mesh(); let subdivided = cad_scene::subdivide_mesh(&mesh); // All vertices should be within the original bounding box for v in &subdivided.vertices { assert!(v.x >= -1.0 - 0.001 && v.x <= 1.0 + 0.001, "x out of range: {}", v.x); assert!(v.y >= -1.5 - 0.001 && v.y <= 1.5 + 0.001, "y out of range: {}", v.y); assert!(v.z >= -2.0 - 0.001 && v.z <= 2.0 + 0.001, "z out of range: {}", v.z); } } } // =========================================================================== // Export integration tests (STL + SVG + PdfPreview) // =========================================================================== #[cfg(test)] mod export_tests { use super::*; use nigig_build::construction_frame::pages::workspace::cad::arch_stl::{StlExporter, StlExportOptions}; use nigig_build::construction_frame::pages::workspace::cad::arch_svg::{SvgExporter, SvgExportOptions}; fn make_box_node(id: u64, pos: Vec3f) -> CadNode { CadNode { id: NodeId(id), name: format!("box_{}", id), solid: Some(CadSolid::Box { size: Vec3f { x: 1.0, y: 1.0, z: 1.0 }, }), transform: CadTransform { translation: pos, rotation_euler_xyz: Vec3f { x: 0.0, y: 0.0, z: 0.0 }, scale: 1.0, }, material: MaterialId(0), layer: LayerId(0), parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 0.5, y: 0.5, z: 0.5, w: 1.0 }, kind_hint: None, } } fn build_simple_scene() -> CadScene { let mut alloc = IdAllocator::new(); let mut builder = SceneBuilder::new(&mut alloc); builder.push_node(make_box_node(1, Vec3f { x: 0.0, y: 0.0, z: 0.0 })); builder.push_node(make_box_node(2, Vec3f { x: 5.0, y: 0.0, z: 0.0 })); builder.build() } // ── STL export (refactored to use TriMesh) ── #[test] fn stl_refactored_header_80_bytes() { let scene = build_simple_scene(); let exporter = StlExporter::default(); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); assert!(bytes.len() >= 84); } #[test] fn stl_refactored_triangle_count() { let scene = build_simple_scene(); let exporter = StlExporter::default(); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); let count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]); // 2 boxes × 12 triangles each = 24 assert_eq!(count, 24); assert_eq!(bytes.len(), 84 + 24 * 50); } #[test] fn stl_refactored_normals_are_unit_length() { let scene = build_simple_scene(); let exporter = StlExporter::default(); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); let count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]); for i in 0..count { let offset = 84 + (i as usize) * 50; let nx = f32::from_le_bytes(bytes[offset..offset+4].try_into().unwrap()); let ny = f32::from_le_bytes(bytes[offset+4..offset+8].try_into().unwrap()); let nz = f32::from_le_bytes(bytes[offset+8..offset+12].try_into().unwrap()); let len = (nx * nx + ny * ny + nz * nz).sqrt(); assert!((len - 1.0).abs() < 0.01 || len < 1e-5, "tri {} normal len {}", i, len); } } #[test] fn stl_refactored_uses_trimesh_intermediate() { let scene = build_simple_scene(); let exporter = StlExporter::default(); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); // Verify binary structure: header(80) + count(4) + tris(24×50=1200) assert_eq!(bytes.len(), 1284); // Header should match default let header = std::str::from_utf8(&bytes[..80]).unwrap(); assert!(header.starts_with("nigig-build arch_stl")); } #[test] fn stl_refactored_transformed_box() { let scene = { let mut alloc = IdAllocator::new(); let mut builder = SceneBuilder::new(&mut alloc); builder.push_node(make_box_node(1, Vec3f { x: 10.0, y: 20.0, z: 30.0 })); builder.build() }; let exporter = StlExporter::default(); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); // Check first triangle's first vertex let offset = 84 + 12; // skip header + count + normal let x = f32::from_le_bytes(bytes[offset..offset+4].try_into().unwrap()); let y = f32::from_le_bytes(bytes[offset+4..offset+8].try_into().unwrap()); let z = f32::from_le_bytes(bytes[offset+8..offset+12].try_into().unwrap()); assert!((x - 10.0).abs() < 1.0, "x={}", x); assert!((y - 20.0).abs() < 1.0, "y={}", y); assert!((z - 30.0).abs() < 1.0, "z={}", z); } #[test] fn stl_refactored_empty_scene_errors() { let scene = { let mut alloc = IdAllocator::new(); SceneBuilder::new(&mut alloc).build() }; let exporter = StlExporter::default(); assert!(exporter.build_stl(&scene, &MeshCache::new()).is_err()); } #[test] fn stl_refactored_trait_export_matches_build() { let scene = build_simple_scene(); let exporter = StlExporter::default(); let bytes_build = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); let bytes_trait = exporter.export_to_vec(&scene, &MeshCache::new()).unwrap(); assert_eq!(bytes_build, bytes_trait); } // ── SVG export with Vec3f field access ── #[test] fn svg_refactored_header_present() { let scene = build_simple_scene(); let exporter = SvgExporter::default(); let bytes = exporter.build_svg(&scene, &MeshCache::new()).unwrap(); let text = std::str::from_utf8(&bytes).unwrap(); assert!(text.contains("")); } #[test] fn svg_refactored_format_metadata() { let exporter = SvgExporter::default(); assert_eq!(exporter.format_name(), "SVG"); assert_eq!(exporter.file_extension(), "svg"); } // ── Cross-exporter consistency ── #[test] fn stl_and_svg_both_export_same_scene() { let scene = build_simple_scene(); let stl = StlExporter::default(); let svg = SvgExporter::default(); let stl_bytes = stl.build_stl(&scene, &MeshCache::new()).unwrap(); let svg_bytes = svg.build_svg(&scene, &MeshCache::new()).unwrap(); // Both should succeed on the same scene assert!(!stl_bytes.is_empty()); assert!(!svg_bytes.is_empty()); } #[test] fn stl_exporter_format_and_extension() { let exporter = StlExporter::default(); assert_eq!(exporter.format_name(), "STL (binary)"); assert_eq!(exporter.file_extension(), "stl"); } #[test] fn stl_custom_header() { let scene = build_simple_scene(); let exporter = StlExporter::new(StlExportOptions { header: "custom header test".into(), }); let bytes = exporter.build_stl(&scene, &MeshCache::new()).unwrap(); let header = std::str::from_utf8(&bytes[..80]).unwrap(); assert!(header.starts_with("custom header test")); } } // =========================================================================== // Phase 3: hover-pick throttling + AABB bounds source // =========================================================================== #[cfg(test)] mod hover_throttle_tests { use nigig_build::construction_frame::pages::workspace::cad::constants::HOVER_PICK_MIN_MOVE_PX; use makepad_widgets::DVec2; /// Mirrors the predicate in `CadViewport::handle_event`. fn moved_enough(prev: Option, now: DVec2) -> bool { prev.map_or(true, |p| { let d = now - p; (d.x * d.x + d.y * d.y).sqrt() >= HOVER_PICK_MIN_MOVE_PX }) } /// The first move after entering the viewport must always pick. #[test] fn first_move_always_picks() { assert!(moved_enough(None, DVec2 { x: 10.0, y: 10.0 })); } /// Sub-threshold jitter must not re-run the ray cast. #[test] fn jitter_is_suppressed() { let prev = Some(DVec2 { x: 100.0, y: 100.0 }); assert!(!moved_enough(prev, DVec2 { x: 100.0, y: 100.0 })); assert!(!moved_enough(prev, DVec2 { x: 101.0, y: 100.0 })); assert!(!moved_enough(prev, DVec2 { x: 100.0, y: 102.0 })); } /// A real move must pick. #[test] fn real_movement_picks() { let prev = Some(DVec2 { x: 100.0, y: 100.0 }); assert!(moved_enough(prev, DVec2 { x: 104.0, y: 100.0 })); assert!(moved_enough(prev, DVec2 { x: 100.0, y: 90.0 })); } /// The threshold must stay well below the pick radius, or the highlight /// would visibly lag the cursor. #[test] fn threshold_is_smaller_than_pick_radius() { use nigig_build::construction_frame::pages::workspace::cad::constants::PART_PICK_RADIUS; assert!( HOVER_PICK_MIN_MOVE_PX < PART_PICK_RADIUS / 4.0, "hover threshold {HOVER_PICK_MIN_MOVE_PX} is too coarse for pick radius {PART_PICK_RADIUS}" ); } } #[cfg(test)] mod pick_bounds_tests { use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{ CadNode, CadSolid, CadTransform, LayerId, MaterialId, MeshCache, NodeId, NodeMetadata, }; use makepad_widgets::{vec3, Vec4f}; fn node(solid: CadSolid) -> CadNode { CadNode { id: NodeId(1), name: "n".into(), solid: Some(solid), transform: CadTransform::IDENTITY, material: MaterialId::ROOT, layer: LayerId::ROOT, parent: None, metadata: NodeMetadata::default(), color: Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 }, kind_hint: None, } } /// For a centred box the mesh bounds and `size()` agree, so switching /// the broad phase to mesh bounds cannot change which parts are hit. #[test] fn mesh_bounds_match_size_for_centred_box() { let n = node(CadSolid::Box { size: vec3(2.0, 4.0, 6.0) }); let cache = MeshCache::new(); let mesh = cache.get_or_build(&n); let bbox = mesh.bounding_box(); let s = n.size(); assert!((bbox.size().x - s.x as f64).abs() < 1e-6); assert!((bbox.size().y - s.y as f64).abs() < 1e-6); assert!((bbox.size().z - s.z as f64).abs() < 1e-6); } /// An extruded polygon grows along +Y from the base plane rather than /// straddling the origin. `size()` reports a symmetric extent, so the /// old broad phase used a box in the wrong place; the mesh bounds are /// the truth. #[test] fn mesh_bounds_are_asymmetric_where_size_is_not() { use makepad_widgets::DVec2; let verts = vec![ DVec2 { x: -1.0, y: -1.0 }, DVec2 { x: 1.0, y: -1.0 }, DVec2 { x: 1.0, y: 1.0 }, DVec2 { x: -1.0, y: 1.0 }, ]; let n = node(CadSolid::ExtrudedPolygon { verts, height: 5.0 }); let cache = MeshCache::new(); let bbox = cache.get_or_build(&n).bounding_box(); assert!((bbox.min.y - 0.0).abs() < 1e-6, "base should sit at y=0, got {}", bbox.min.y); assert!((bbox.max.y - 5.0).abs() < 1e-6, "top should sit at y=5, got {}", bbox.max.y); // A symmetric box of the same height would have spanned -2.5..2.5. assert!(bbox.min.y > -1.0, "mesh bounds must not be centred on the origin"); } }