Phase 5.4. Two defects with one root cause: part_geoms was keyed on the
raw node id, so staleness was invisible to the type and correctness rested
on seventeen scattered `part_geoms.remove(&id)` calls at the edit sites.
All seventeen are deleted; the map is now keyed on (id, ParamHash), the
same key MeshCache uses, and an entry whose hash no longer matches its
node is simply never read.
1. subdivide_selected drew the wrong geometry. It replaced each selected
part's solid with a fresh Csg and invalidated the MESH cache, but never
removed the part's part_geoms entry -- so the stale uploaded buffer
stayed a hit and the viewport kept drawing the un-subdivided shape.
An audit of every mutation path found this was the only edit site
missing its manual eviction, which is exactly the failure mode a manual
protocol produces.
2. ParamHash covered the transform and the material, so a pure move
invalidated the mesh. It should not: build_mesh reads neither. The
cached TriMesh is in the node's LOCAL space, and all four consumers --
the draw loop, pick_part, the STL and glTF exporters -- apply the model
matrix themselves. Dragging a part therefore re-triangulated it on
every frame to produce byte-identical triangles, at up to 886 ns per
extruded part per frame, per selected part. The hash now covers the
solid parameters and nothing else.
Two existing tests asserted the old behaviour and were INVERTED, not
deleted -- they described what the code did rather than what it needed to
do:
cache_detects_transform_edits_via_param_hash
-> a_transform_edit_reuses_the_cached_local_space_mesh
mesh_cache_self_invalidates_on_parameter_and_transform_edits
-> mesh_cache_self_invalidates_on_solid_parameter_edits
New: build_mesh_output_does_not_depend_on_the_transform guards the
assumption the key now rests on -- if anyone makes build_mesh bake the
transform in, it fails and the key must grow it back. Plus
mesh_cache_hits_on_a_pure_transform_edit, mesh_cache_hits_on_a_material_edit
and an_uploaded_buffer_is_not_reused_after_the_solid_changes. Negative
test: restoring the transform to the hash makes the first two fail;
restored and green.
Deletion is still explicit (`retain` on the live id set) because it is the
one case a content hash cannot express -- there is no node left to hash.
ParamHash is pub(crate), not pub: it is how the caches agree on staleness,
not a consumer contract.
Also recorded in BENCH_BASELINE.md, not fixed here: bench_command_execute
_overhead is 227 us/cmd against a stale recorded 0.4 us. Verified
pre-existing -- 254 us on the commit before this branch, so this work
slightly improves it. CadCommandCtx::new eagerly builds a scene snapshot
that most commands never read, and every command bumps the generation, so
it is O(commands x nodes). The fix is to make that snapshot lazy; it is a
separate change and gets its own commit.
753 lib + 154 integration tests pass. Test-name list diffed, not just the
count. All 12 CI gates pass.
2659 lines
92 KiB
Rust
2659 lines
92 KiB
Rust
//! # 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<NodeId>,
|
||
}
|
||
|
||
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<Arc<nigig_build::makepad_csg::TriMesh>> = 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<Arc<nigig_build::makepad_csg::TriMesh>> = 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<E: Exporter>(exporter: &E, scene: &CadScene, cache: &MeshCache) -> Vec<u8> {
|
||
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<CadNode> 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<CadNode> {
|
||
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<MeshCache> 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<CadScene> 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<u64> = 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<DVec2> {
|
||
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<u64>,
|
||
) -> Vec<u64> {
|
||
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<CadNode> = 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<CadNode> = 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<CadNode> = 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<u64> = 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("<svg"));
|
||
assert!(text.contains("xmlns"));
|
||
}
|
||
|
||
#[test]
|
||
fn svg_refactored_polygon_for_two_boxes() {
|
||
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();
|
||
let poly_count = text.matches("<polygon").count();
|
||
assert_eq!(poly_count, 2, "expected 2 polygons for 2 boxes");
|
||
}
|
||
|
||
#[test]
|
||
fn svg_refactored_empty_scene_errors() {
|
||
let scene = {
|
||
let mut alloc = IdAllocator::new();
|
||
SceneBuilder::new(&mut alloc).build()
|
||
};
|
||
let exporter = SvgExporter::default();
|
||
assert!(exporter.build_svg(&scene, &MeshCache::new()).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn svg_refactored_valid_utf8() {
|
||
let scene = build_simple_scene();
|
||
let exporter = SvgExporter::default();
|
||
let bytes = exporter.build_svg(&scene, &MeshCache::new()).unwrap();
|
||
assert!(std::str::from_utf8(&bytes).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn svg_refactored_closing_tag() {
|
||
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.trim_end().ends_with("</svg>"));
|
||
}
|
||
|
||
#[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<DVec2>, 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");
|
||
}
|
||
}
|