Mechanical `cargo fmt -p nigig-build`. Nothing but formatting is in
this commit, deliberately: it is 89 files and would bury any real
change made alongside it.
The cad-module job has failed on every run since a runner was first
registered. It is one step -- `cargo fmt -p nigig-build -- --check` --
and it reported 1,559 diffs.
Note the scope. The step is named "Formatting (CAD module)" but
`-p nigig-build` covers the whole crate: the largest offenders are
doc/widgets/doc_widget.rs (166 hunks), doc/tests.rs (149) and
project_management/mod.rs (128); CAD proper is a minority. The name is
misleading and the fix is crate-wide.
The changes are what rustfmt does: wrapping long signatures and call
chains, exploding single-line struct literals, adding trailing commas,
and `use makepad_widgets::{Vec4f}` -> `use makepad_widgets::Vec4f`.
Verified inert, since a reformat that changes behaviour is the whole
risk here:
cargo test -p nigig-build --lib
before 794 passed; 0 failed; 19 ignored
after 794 passed; 0 failed; 19 ignored
cargo test --locked -p nigig-build --test cad_integration
after 154 passed; 0 failed
All 12 source-scanning gates in the supply-chain job still pass.
That check matters more than it looks: several are regex-based and
match on line shape, so moving code across line boundaries could
have silently defeated them. It did not.
`cargo fmt -p nigig-build -- --check` now exits 0.
3062 lines
98 KiB
Rust
3062 lines
98 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 makepad_widgets::{DVec2, Vec3f, Vec4f};
|
||
use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{
|
||
self, walk_scene, CadNode, CadScene, CadSolid, CadTransform, DofConstraint, Exporter,
|
||
IdAllocator, LayerId, MaterialId, MeshCache, NodeId, NodeMetadata, PartKind, SceneBuilder,
|
||
SceneVisitor,
|
||
};
|
||
|
||
// ===========================================================================
|
||
// 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::{
|
||
GltfExportOptions, GltfExporter,
|
||
};
|
||
|
||
/// 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, PdfExportOptions, PdfExporter,
|
||
};
|
||
|
||
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::math::point_in_polygon;
|
||
use nigig_build::construction_frame::pages::workspace::cad::scene_holder::SceneCache;
|
||
|
||
/// 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 makepad_widgets::DVec2;
|
||
use nigig_build::construction_frame::pages::workspace::cad::construction_geometry::{
|
||
parse_coord_input, CoordInput,
|
||
};
|
||
|
||
/// 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 makepad_widgets::DVec2;
|
||
use nigig_build::construction_frame::pages::workspace::cad::SelectionMode;
|
||
|
||
/// 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::{
|
||
next_polar_increment, snap_to_polar_angle,
|
||
};
|
||
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 makepad_widgets::DVec2;
|
||
use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{
|
||
CadNode, CadSolid, PartKind,
|
||
};
|
||
use nigig_build::construction_frame::pages::workspace::cad::section_shape::{
|
||
hss_vertices, ibeam_vertices, rect_vertices, section_area, section_bounding_box,
|
||
section_vertices, HSSParams, IBeamParams, SectionShape,
|
||
};
|
||
|
||
// ── 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::{
|
||
StlExportOptions, StlExporter,
|
||
};
|
||
use nigig_build::construction_frame::pages::workspace::cad::arch_svg::{
|
||
SvgExportOptions, SvgExporter,
|
||
};
|
||
|
||
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 makepad_widgets::DVec2;
|
||
use nigig_build::construction_frame::pages::workspace::cad::constants::HOVER_PICK_MIN_MOVE_PX;
|
||
|
||
/// 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 makepad_widgets::{vec3, Vec4f};
|
||
use nigig_build::construction_frame::pages::workspace::cad::cad_scene::{
|
||
CadNode, CadSolid, CadTransform, LayerId, MaterialId, MeshCache, NodeId, NodeMetadata,
|
||
};
|
||
|
||
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"
|
||
);
|
||
}
|
||
}
|