//! Base-point reproduction. Tests only; no production code changes. //! //! Convention under test: //! world = insert_point + Rot(rotation) * Scale * (P - block.base_point) //! //! Block "BP_TEST": base (10,5,0); LINE (10,5,0)-(30,5,0); //! CIRCLE center (20,15,0) r=3; ATTDEF tag TAG1 at (12,8,0). //! INSERT at (100,100,0), rotation 30 degrees, uniform scale 2. //! //! Hand derivation (c = cos30 = 0.8660254038, s = sin30 = 0.5, k = scale 2): //! Rot(30)*Scale maps (x,y) to (k*(c*x - s*y), k*(s*x + c*y)). //! LINE start (10,5)-base = (0,0) -> (100, 100) //! LINE end (30,5)-base = (20,0) -> 100 + (2*17.320508, 2*10) //! = (134.641016, 120) //! CIRCLE center (20,15)-base = (10,10) -> 100 + (2*(8.660254-5), 2*(5+8.660254)) //! = (107.320508, 127.320508) //! CIRCLE radius 3 * scale 2 -> 6 //! ATTDEF (12,8)-base = (2,3) -> 100 + (2*(1.732051-1.5), 2*(1+2.598076)) //! = (100.464102, 107.196152) //! //! Offset between the codec's `Insert::get_transform` (no base subtraction) //! and the app's `insert_transform` (base-subtracting), measured at the block's //! base point, is exactly Rot(30)*Scale*base(10,5,0): //! (2*(c*10 - s*5), 2*(s*10 + c*5)) = (12.320508, 18.660254, 0) //! //! Naming: `locked_*` = behaviour to keep; `documenting_*` = behaviour expected //! to change. Tests that fail today carry //! `#[ignore = "base-point repro: fails today"]`. use crate::command::{CadCommand, CmdResult}; use crate::modules::insert::insert_block::InsertBlockCommand; use crate::scene::render_graph::{ block_base_point, insert_transform, BlockRoot, BlockRootRole, RenderSceneGraph, SceneRoot, }; use crate::scene::{CreateBlockOptions, Scene}; use crate::ui::window::block_definition::{BlockDefinitionState, BlockObjectMode}; use codec::entities::{AttributeDefinition, AttributeEntity, Circle, Insert, Line}; use codec::types::{Handle, Matrix4, Transform, Vector3}; use codec::{CadDocument, EntityType}; use glam::DVec3; const BP_TEST: &str = "BP_TEST"; const DEG30: f64 = std::f64::consts::PI / 6.0; /// Expected world positions, hand-derived from the convention above. pub const EXPECTED_LINE_START: [f64; 3] = [100.0, 100.0, 0.0]; pub const EXPECTED_LINE_END: [f64; 3] = [134.641016, 120.0, 0.0]; pub const EXPECTED_CIRCLE_CENTER: [f64; 3] = [107.320508, 127.320508, 0.0]; pub const EXPECTED_ATTR_POINT: [f64; 3] = [100.464102, 107.196152, 0.0]; /// `Rot(30)*Scale(2)*base(10,5,0)` — the gap between the two transform paths. pub const GET_TO_INSERT_OFFSET: [f64; 3] = [12.320508, 18.660254, 0.0]; /// Step-1 geometry pushed through the codec transform (no base subtraction) — /// the pre-fix position, kept for the picking test's negative case. pub const CODEC_LINE_END: [f64; 3] = [146.961524, 138.660254, 0.0]; fn v(x: f64, y: f64, z: f64) -> Vector3 { Vector3::new(x, y, z) } fn assert_close(actual: Vector3, expected: [f64; 3], tol: f64, label: &str) { let delta = [ (actual.x - expected[0]).abs(), (actual.y - expected[1]).abs(), (actual.z - expected[2]).abs(), ]; assert!( delta.iter().all(|d| *d <= tol), "{label}: actual=({:.6},{:.6},{:.6}) expected=({:.6},{:.6},{:.6})", actual.x, actual.y, actual.z, expected[0], expected[1], expected[2] ); } /// Two points must agree — used where both sides are computed by the app. fn assert_same(actual: Vector3, expected: Vector3, tol: f64, label: &str) { assert!( (actual - expected).length() <= tol, "{label}: actual=({:.9},{:.9},{:.9}) expected=({:.9},{:.9},{:.9})", actual.x, actual.y, actual.z, expected.x, expected.y, expected.z ); } fn attdef(tag: &str) -> AttributeDefinition { AttributeDefinition { insertion_point: v(12.0, 8.0, 0.0), height: 1.0, ..AttributeDefinition::new(tag.to_string(), "Tag one".to_string(), String::new()) } } fn block_entities() -> Vec { vec![ EntityType::Line(Line::from_points(v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0))), EntityType::Circle(Circle::from_coords(20.0, 15.0, 0.0, 3.0)), EntityType::AttributeDefinition(attdef("TAG1")), ] } /// A scene holding "BP_TEST": base (10,5,0) plus the LINE/CIRCLE/ATTDEF above. fn scene_with_bp_test() -> Scene { let mut scene = Scene::new(); scene.define_block_raw(BP_TEST, v(10.0, 5.0, 0.0), block_entities()); scene } /// The LINE stored in `block`, in block-local coordinates. fn block_line(document: &CadDocument, block: &str) -> Line { document .block_records .get(block) .unwrap() .entity_handles .iter() .find_map(|h| match document.get_entity(*h) { Some(EntityType::Line(line)) => Some(line.clone()), _ => None, }) .expect("the block's LINE") } fn block_attdefs(document: &CadDocument, block: &str) -> Vec { let Some(br) = document.block_records.get(block) else { return Vec::new(); }; br.entity_handles .iter() .filter_map(|h| match document.get_entity(*h) { Some(EntityType::AttributeDefinition(ad)) => Some(ad.clone()), _ => None, }) .collect() } /// The model-space INSERT referencing `block`. fn instance_of(scene: &Scene, block: &str) -> Insert { scene .document .entities() .find_map(|e| match e { EntityType::Insert(ins) if ins.block_name == block => Some(ins.clone()), _ => None, }) .expect("the placed INSERT") } /// World positions of the instance's LINE, as the scene computes them. fn instance_line_world(scene: &Scene, block: &str) -> (Vector3, Vector3) { let line = block_line(&scene.document, block); let insert = instance_of(scene, block); let transform = insert_transform(&scene.document, &insert); (transform.apply(line.start), transform.apply(line.end)) } /// Drive the real `-INSERT` command through its prompt sequence: insertion /// point, X scale, Y scale, rotation, then the attribute fill — the same code /// path the INSERT command uses. The host injects the definition's base point /// when it resolves the block's ATTDEFs (`handle_attreq_needed` in /// app/command_driver/entity_commit.rs), which is what makes attribute /// placement follow the render path's convention. fn insert_via_command(scene: &mut Scene) -> Insert { let mut cmd = InsertBlockCommand::classic_for_block(BP_TEST.to_string(), Vec::new(), String::new()); let _ = cmd.on_point(DVec3::new(100.0, 100.0, 0.0)); assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); assert!(matches!( cmd.on_text_input("30"), Some(CmdResult::AttreqNeeded { .. }) )); let attdefs = block_attdefs(&scene.document, BP_TEST); assert_eq!(attdefs.len(), 1, "one ATTDEF is expected in the block"); cmd.set_block_base(block_base_point(&scene.document, BP_TEST)); assert!(cmd.attreq_set_attdefs(attdefs).is_none()); match cmd.on_text_input("VALUE1") { Some(CmdResult::CommitAndExit(EntityType::Insert(insert))) | Some(CmdResult::CommitEntity(EntityType::Insert(insert))) => { scene.add_entity(EntityType::Insert(insert.clone())); insert } _ => panic!("the command did not commit an INSERT"), } } fn placed_instance(scene: &mut Scene) -> Insert { insert_via_command(scene) } /// Walk the render graph from model space, collecting (entity, instance /// transform) pairs — the traversal the renderer itself uses. fn render_graph_leaves(scene: &Scene) -> Vec<(EntityType, Transform)> { let model_space = scene .document .block_records .iter() .find(|record| record.is_model_space()) .map(|record| record.handle) .expect("a model space block record"); let depths = rustc_hash::FxHashMap::default(); let graph = RenderSceneGraph::new(&scene.document, None, None, true, &depths); let mut leaves = Vec::new(); graph.walk_root( SceneRoot::Block(BlockRoot { record: model_space, role: BlockRootRole::ModelSpace, }), |_, _| true, |entity, context| leaves.push((entity.clone(), context.transform)), ); leaves } /// A view where world coordinates equal screen coordinates, so a click at a /// world point can be named directly. struct WorldAsScreen { view_rot: glam::Mat4, eye: DVec3, bounds: iced::Rectangle, } impl WorldAsScreen { fn new(extent: f32) -> Self { Self { view_rot: glam::Mat4::from_scale(glam::Vec3::new(2.0 / extent, -2.0 / extent, 1.0)), eye: DVec3::new(extent as f64 / 2.0, extent as f64 / 2.0, 0.0), bounds: iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(extent, extent)), } } fn at(&self, point: [f64; 3]) -> iced::Point { iced::Point::new(point[0] as f32, point[1] as f32) } } fn click_at( point: [f64; 3], view: &WorldAsScreen, wires: &[crate::scene::model::wire_model::WireModel], depths: &rustc_hash::FxHashMap, ) -> Option { crate::scene::pick::hit_test::click_hit( view.at(point), wires, view.view_rot, view.eye, view.bounds, false, 8.0, depths, ) .and_then(Scene::handle_from_wire_name) } #[test] fn locked_block_base_point_is_stored_on_the_definition() { let scene = scene_with_bp_test(); assert_close( block_base_point(&scene.document, BP_TEST), [10.0, 5.0, 0.0], 1e-9, "block base point", ); } #[test] fn locked_insert_command_builds_the_step1_reference() { let mut scene = scene_with_bp_test(); let insert = insert_via_command(&mut scene); assert_close( insert.insert_point, [100.0, 100.0, 0.0], 1e-9, "insert point", ); assert_eq!( (insert.x_scale(), insert.y_scale(), insert.z_scale()), (2.0, 2.0, 2.0) ); assert!((insert.rotation.to_degrees() - 30.0).abs() < 1e-9); let attribute = insert.attributes.first().expect("TAG1 is filled"); assert_eq!(attribute.tag, "TAG1"); } #[test] fn locked_render_path_subtracts_the_block_base_point() { let mut scene = scene_with_bp_test(); let insert = placed_instance(&mut scene); let transform = insert_transform(&scene.document, &insert); assert_close( transform.apply(v(10.0, 5.0, 0.0)), EXPECTED_LINE_START, 1e-6, "line start", ); assert_close( transform.apply(v(30.0, 5.0, 0.0)), EXPECTED_LINE_END, 1e-6, "line end", ); assert_close( transform.apply(v(20.0, 15.0, 0.0)), EXPECTED_CIRCLE_CENTER, 1e-6, "circle center", ); } #[test] fn locked_render_graph_instances_at_convention_positions() { let mut scene = scene_with_bp_test(); let _ = placed_instance(&mut scene); let leaves = render_graph_leaves(&scene); let line = leaves .iter() .find_map(|(entity, _)| match entity { EntityType::Line(line) => Some(line.clone()), _ => None, }) .expect("the rendered LINE instance"); let transform = leaves .iter() .find(|(entity, _)| matches!(entity, EntityType::Line(_))) .map(|(_, transform)| *transform) .unwrap(); assert_close( transform.apply(line.start), EXPECTED_LINE_START, 1e-6, "render graph: line start", ); assert_close( transform.apply(line.end), EXPECTED_LINE_END, 1e-6, "render graph: line end", ); } #[test] fn locked_hit_test_hits_the_base_point_adjusted_line() { let mut scene = scene_with_bp_test(); let _ = placed_instance(&mut scene); let wires = scene.hit_test_wires(); let depth_map = scene.draw_depth_map(); let view = WorldAsScreen::new(2000.0); let insert_handle = instance_of(&scene, BP_TEST).common.handle; // Mid-point of the LINE under the convention. let convention_mid = [ (EXPECTED_LINE_START[0] + EXPECTED_LINE_END[0]) / 2.0, (EXPECTED_LINE_START[1] + EXPECTED_LINE_END[1]) / 2.0, 0.0, ]; let hit = click_at(convention_mid, &view, wires.as_slice(), depth_map.as_ref()); assert_eq!( hit, Some(insert_handle), "clicking the convention position must select the INSERT" ); // Mid-point of the LINE under the codec transform (base not subtracted). let codec_mid = [ (EXPECTED_LINE_END[0] + CODEC_LINE_END[0]) / 2.0, (EXPECTED_LINE_END[1] + CODEC_LINE_END[1]) / 2.0, 0.0, ]; let hit = click_at(codec_mid, &view, wires.as_slice(), depth_map.as_ref()); assert_eq!( hit, None, "clicking the codec position must not hit anything" ); } #[test] fn locked_attribute_placement_matches_the_convention() { // base-point: the host now injects the definition's base point into the INSERT // command, so `finish_insert` places the attribute through // `insert_transform_with_base` — the same convention as the render path. // Hand derivation (see the module header): ATTDEF (12,8) - base (10,5) = // (2,3); Rot(30)*Scale(2)*(2,3) = (2*(1.732051-1.5), 2*(1+2.598076)) = // (0.464102, 7.196152); + insert point (100,100) = (100.464102, 107.196152). let mut scene = scene_with_bp_test(); let insert = placed_instance(&mut scene); let attribute = insert.attributes.first().expect("TAG1"); assert_close( attribute.insertion_point, EXPECTED_ATTR_POINT, 1e-6, "attribute insertion point", ); // And it is exactly the render path's placement of the ATTDEF. let attdef = block_attdefs(&scene.document, BP_TEST) .into_iter() .next() .expect("TAG1"); let render = insert_transform(&scene.document, &insert).apply(attdef.insertion_point); assert_close( attribute.insertion_point, [render.x, render.y, render.z], 1e-9, "attribute vs render path", ); } #[test] fn documenting_get_transform_offset_from_insert_transform() { // Ran and observed: the two paths differ by exactly Rot(30)*Scale(2)*base // for the step-1 case, measured at the block's own base point. let mut scene = scene_with_bp_test(); let insert = placed_instance(&mut scene); let p = v(10.0, 5.0, 0.0); let render = insert_transform(&scene.document, &insert).apply(p); let codec = insert.get_transform().apply(p); let delta = render - codec; for (actual, expected) in [delta.x, delta.y, delta.z].iter().zip(GET_TO_INSERT_OFFSET) { assert!((actual.abs() - expected).abs() < 1e-6, "offset {delta:?}"); } } #[test] fn locked_creation_with_a_picked_base_bakes_the_origin_into_the_geometry() { // base-point fix (B): the caller bakes the picked base into `world_to_block`, so // the contents are stored relative to it and the definition's own base // point stays ZERO — the picked base becomes the block origin. Storing the // base in the definition as well would subtract it twice. The geometry of // existing / imported definitions is never touched. const NAME: &str = "BP_CREATE_BASE"; let mut scene = Scene::new(); let line = scene.add_entity(EntityType::Line(Line::from_points( v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0), ))); let options = CreateBlockOptions { name: NAME.to_string(), handles: vec![line], base_point: DVec3::new(10.0, 5.0, 0.0), world_to_block: Transform::from_translation(v(-10.0, -5.0, 0.0)), block_to_world: Transform::from_translation(v(10.0, 5.0, 0.0)), mode: BlockObjectMode::Retain, annotative: false, match_orientation: false, scale_uniformly: false, allow_exploding: true, unit: 0, description: String::new(), hyperlink_url: String::new(), hyperlink_desc: String::new(), redefine: false, }; assert!(scene.create_block_with_options(options).is_ok()); // The stored LINE is the drawn LINE translated by -(10,5): (0,0)-(20,0). let stored = block_line(&scene.document, NAME); assert_close(stored.start, [0.0, 0.0, 0.0], 1e-9, "stored line start"); assert_close(stored.end, [20.0, 0.0, 0.0], 1e-9, "stored line end"); // The definition's base point stays ZERO, so the picked base — now the // block origin — is what an insertion point lands on. assert_close( block_base_point(&scene.document, NAME), [0.0, 0.0, 0.0], 1e-9, "stored base point after create_block_with_options", ); // Inserted at (100,100) with no rotation or scale the LINE therefore lands // at (100,100)-(120,100): the picked base was honoured on creation. let _ = scene.add_entity(EntityType::Insert(Insert::new( NAME.to_string(), v(100.0, 100.0, 0.0), ))); let (start, end) = instance_line_world(&scene, NAME); assert_close(start, [100.0, 100.0, 0.0], 1e-6, "instance line start"); assert_close(end, [120.0, 100.0, 0.0], 1e-6, "instance line end"); } #[test] fn locked_redefine_with_a_changed_base_moves_the_instance() { // base-point fix (C). Step 3: create through the dialog's options with a // non-zero base, insert it, then redefine it through the // `redefine_block_raw` call dialog.rs makes, with a different base. A // changed base is allowed to move instances — the contents stay put in // block coordinates while the base they are measured from moves. const NAME: &str = "BP_REDEF_MOVED"; let mut scene = Scene::new(); let line = scene.add_entity(EntityType::Line(Line::from_points( v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0), ))); let options = CreateBlockOptions { name: NAME.to_string(), handles: vec![line], base_point: DVec3::new(10.0, 5.0, 0.0), world_to_block: Transform::from_translation(v(-10.0, -5.0, 0.0)), block_to_world: Transform::from_translation(v(10.0, 5.0, 0.0)), mode: BlockObjectMode::Retain, annotative: false, match_orientation: false, scale_uniformly: false, allow_exploding: true, unit: 0, description: String::new(), hyperlink_url: String::new(), hyperlink_desc: String::new(), redefine: false, }; assert!(scene.create_block_with_options(options).is_ok()); let mut insert = Insert::new(NAME.to_string(), v(100.0, 100.0, 0.0)); insert.set_x_scale(2.0); insert.set_y_scale(2.0); insert.set_z_scale(2.0); insert.rotation = DEG30; let instance = scene.add_entity(EntityType::Insert(insert)); // Block-local LINE is world (10,5)-(30,5) shifted by -(10,5) = (0,0)-(20,0), // and the stored base point is (0,0,0), so the instance renders at // (100,100) and (134.641016, 120). let (start, end) = instance_line_world(&scene, NAME); assert_close( start, [100.0, 100.0, 0.0], 1e-6, "line start before redefine", ); assert_close( end, [134.641016, 120.0, 0.0], 1e-6, "line end before redefine", ); // The redefine path: the same contents, but a new base point (0,20,0). assert!(scene .redefine_block_raw( NAME, v(0.0, 20.0, 0.0), vec![EntityType::Line(Line::from_points( v(0.0, 0.0, 0.0), v(20.0, 0.0, 0.0), ))], ) .is_ok()); assert_close( block_base_point(&scene.document, NAME), [0.0, 20.0, 0.0], 1e-9, "stored base point after redefine", ); // The contents did not move, so every instance moves by exactly // Rot(30)*Scale(2)*(newBase - oldBase) = Rot(30)*2*((0,20) - (0,0)) // = 2*(0.866025*0 - 0.5*20, 0.5*0 + 0.866025*20) = (20, -34.641016). let (start_after, end_after) = instance_line_world(&scene, NAME); // start: (100,100) + Rot(30)*2*((0,0) - (0,20)) = (120, 65.358984) assert_close( start_after, [120.0, 65.358984, 0.0], 1e-6, "line start after redefine", ); // end: (100,100) + Rot(30)*2*((20,0) - (0,20)) // = (100,100) + 2*(17.320508 + 10, 10 - 17.320508) = (154.641016, 85.358984) assert_close( end_after, [154.641016, 85.358984, 0.0], 1e-6, "line end after redefine", ); let jump = Vector3::new( start_after.x - start.x, start_after.y - start.y, start_after.z - start.z, ); assert_close(jump, [20.0, -34.641016, 0.0], 1e-6, "instance movement"); assert!( (jump.x.abs() + jump.y.abs()) > 1.0, "the instance moved by {jump:?}" ); let _ = instance; } #[test] fn locked_noop_redefine_leaves_the_instance_where_it_is() { // base-point fix (C): redefining a block with the same contents and the same // base point must not move any instance — the definition is replaced // verbatim. const NAME: &str = "BP_REDEF_NOOP"; let mut scene = Scene::new(); let base = v(6.0, 4.0, 0.0); scene.define_block_raw( NAME, base, vec![EntityType::Line(Line::from_points( v(6.0, 4.0, 0.0), v(16.0, 4.0, 0.0), ))], ); let mut insert = Insert::new(NAME.to_string(), v(50.0, 20.0, 0.0)); insert.set_x_scale(1.5); insert.set_y_scale(1.5); insert.set_z_scale(1.5); insert.rotation = DEG30; let _ = scene.add_entity(EntityType::Insert(insert)); // Render path before: (50,20) + Rot(30)*1.5*((6,4)-(6,4)) = (50,20) and // (50,20) + Rot(30)*1.5*((16,4)-(6,4)) = (50,20) + 1.5*Rot(30)*(10,0) // = (50,20) + 1.5*(8.660254, 5) = (62.990381, 27.5). let (start, end) = instance_line_world(&scene, NAME); assert_close(start, [50.0, 20.0, 0.0], 1e-6, "line start before"); assert_close(end, [62.990381, 27.5, 0.0], 1e-6, "line end before"); // No-op redefine: identical contents, same base. assert!(scene .redefine_block_raw( NAME, base, vec![EntityType::Line(Line::from_points( v(6.0, 4.0, 0.0), v(16.0, 4.0, 0.0), ))], ) .is_ok()); assert_close( block_base_point(&scene.document, NAME), [6.0, 4.0, 0.0], 1e-9, "base point after no-op redefine", ); let (start_after, end_after) = instance_line_world(&scene, NAME); assert_same(start_after, start, 1e-12, "line start after no-op redefine"); assert_same(end_after, end, 1e-12, "line end after no-op redefine"); } #[test] fn locked_block_definition_dialog_prefills_the_existing_definition_base_point() { // base-point fix (D): choosing an existing block in the Block Definition dialog // prefills the base point from that definition, so a redefine starts from // the base it already has. A new block starts at 0. let scene = scene_with_bp_test(); assert_close( block_base_point(&scene.document, BP_TEST), [10.0, 5.0, 0.0], 1e-9, "the drawing's block base point", ); // A new block: the fields start at 0. let fresh = BlockDefinitionState::new(vec![BP_TEST.to_string()], Vec::new(), 0); assert_eq!(fresh.base_point_x, "0"); assert_eq!(fresh.base_point_y, "0"); assert_eq!(fresh.base_point_z, "0"); assert_eq!(fresh.parse_base_point(), DVec3::ZERO); // Picking the existing block (what `Message::BlockDefNameSelect` does). let mut state = fresh; let base = block_base_point(&scene.document, BP_TEST); state.set_base_point(DVec3::new(base.x, base.y, base.z)); assert_eq!(state.base_point_x, "10.0000"); assert_eq!(state.base_point_y, "5.0000"); assert_eq!(state.base_point_z, "0.0000"); assert_eq!(state.parse_base_point(), DVec3::new(10.0, 5.0, 0.0)); } #[test] fn locked_explode_places_geometry_at_convention_positions() { // The document-aware codec explosion uses the definition's base point, // so the pieces land where the render path puts them. let mut scene = scene_with_bp_test(); let insert = placed_instance(&mut scene); let pieces = crate::modules::draw::modify::explode::explode_entity( &EntityType::Insert(insert), &scene.document, ); let mut line_start = None; let mut line_end = None; let mut circle_center = None; for piece in &pieces { match piece { EntityType::Line(line) => { if line_end.is_none() { line_start = Some(line.start); line_end = Some(line.end); } } EntityType::Circle(circle) => circle_center = Some(circle.center), EntityType::Ellipse(ellipse) => circle_center = Some(ellipse.center), _ => {} } } assert_close( line_start.expect("the exploded LINE"), EXPECTED_LINE_START, 1e-6, "exploded line start", ); assert_close( line_end.expect("the exploded LINE"), EXPECTED_LINE_END, 1e-6, "exploded line end", ); assert_close( circle_center.expect("the exploded CIRCLE"), EXPECTED_CIRCLE_CENTER, 1e-6, "exploded circle center", ); // Exploding an INSERT drops attribute values and the // attribute definitions (tags) reappear as ATTDEF entities. let attdef_piece = pieces.iter().find_map(|p| match p { EntityType::AttributeDefinition(ad) => Some(ad.clone()), _ => None, }); let ad = attdef_piece.expect("the exploded ATTDEF"); assert_eq!(ad.tag, "TAG1"); assert_close( ad.insertion_point, EXPECTED_ATTR_POINT, 1e-6, "exploded attdef insertion point", ); // Same numbers as the render path: EXPLODE and the renderer agree. let (render_start, render_end) = instance_line_world(&scene, BP_TEST); assert_close(render_end, EXPECTED_LINE_END, 1e-6, "render path: line end"); assert_close( render_start, EXPECTED_LINE_START, 1e-6, "render path: line start", ); } #[test] fn locked_minsert_explode_restores_attdefs_in_each_rotated_cell() { let scene = scene_with_bp_test(); let mut insert = Insert::new(BP_TEST, v(100.0, 100.0, 0.0)) .with_uniform_scale(2.0) .with_rotation(std::f64::consts::FRAC_PI_2) .with_array(2, 2, 10.0, 20.0); insert .attributes .push(AttributeEntity::new("TAG1".into(), "Instance value".into())); let pieces = crate::modules::draw::modify::explode::explode_entity( &EntityType::Insert(insert), &scene.document, ); let definitions: Vec<_> = pieces .iter() .filter_map(|entity| match entity { EntityType::AttributeDefinition(definition) => Some(definition), _ => None, }) .collect(); assert_eq!(definitions.len(), 4); for (definition, expected) in definitions.iter().zip([ [94.0, 104.0, 0.0], [94.0, 114.0, 0.0], [74.0, 104.0, 0.0], [74.0, 114.0, 0.0], ]) { assert_close(definition.insertion_point, expected, 1e-6, "array ATTDEF"); assert_eq!(definition.tag, "TAG1"); assert_eq!(definition.default_value, ""); } let lines: Vec<_> = pieces .iter() .filter_map(|entity| match entity { EntityType::Line(line) => Some(line), _ => None, }) .collect(); assert_eq!(lines.len(), 4); for (line, expected) in lines.iter().zip([ [100.0, 100.0, 0.0], [100.0, 110.0, 0.0], [80.0, 100.0, 0.0], [80.0, 110.0, 0.0], ]) { assert_close(line.start, expected, 1e-6, "array LINE"); } assert_close( block_attdefs(&scene.document, BP_TEST)[0].insertion_point, [12.0, 8.0, 0.0], 1e-6, "source ATTDEF stays in definition coordinates", ); } #[test] fn locked_xclip_boundary_round_trips_through_the_base_point_convention() { // base-point follow-up fix: the XCLIP ring is stored as WCS points plus the // inverse of the transform every consumer composes it back with. That // consumer is the base-point-aware instance transform, so the stored // inverse has to be the base-aware one; a base-ignoring inverse displaces // the ring by exactly Rot(30)*Scale(2)*base = (12.320508, 18.660254). const NAME: &str = "BP_XCLIP"; let mut scene = Scene::new(); // Same fixture as BP_TEST: base (10,5,0), LINE (10,5)-(30,5) local // (0,0)-(20,0), INSERT at (100,100) with 30° and scale 2, so the rendered // LINE runs (100,100)-(134.641016,120). scene.define_block_raw( NAME, v(10.0, 5.0, 0.0), vec![EntityType::Line(Line::from_points( v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0), ))], ); let mut insert = Insert::new(NAME.to_string(), v(100.0, 100.0, 0.0)); insert.set_x_scale(2.0); insert.set_y_scale(2.0); insert.set_z_scale(2.0); insert.rotation = DEG30; let handle = scene.add_entity(EntityType::Insert(insert)); let insert = match scene.document.get_entity(handle) { Some(EntityType::Insert(ins)) => ins.clone(), _ => panic!("the INSERT was stored"), }; // A rectangle drawn in WCS around the rendered LINE. let boundary = [[98.0, 98.0], [136.0, 122.0]]; assert!(crate::scene::pick::xclip::set_insert_clip( &mut scene.document, handle, &boundary, false, ([100.0, 100.0], [134.641016, 120.0]), )); let filter = crate::scene::pick::xclip::insert_spatial_filter(&scene.document, &insert) .expect("the stored spatial filter"); let world = crate::scene::pick::xclip::world_clip_polygon_for_transform( filter, &insert_transform(&scene.document, &insert), ); // Two stored points expand to the rectangle in drawn order. let expected = [[98.0, 98.0], [136.0, 98.0], [136.0, 122.0], [98.0, 122.0]]; assert_eq!(world.len(), expected.len()); for (got, want) in world.iter().zip(expected) { assert!( (got[0] - want[0]).abs() < 1e-9 && (got[1] - want[1]).abs() < 1e-9, "ring corner ({:.9},{:.9}), expected ({:.9},{:.9})", got[0], got[1], want[0], want[1] ); } // The same stored filter composed with the codec's base-ignoring transform // displaces the ring by exactly Rot(30)*Scale(2)*base — the error the // base-aware storage removes. let base_ignoring = crate::scene::pick::xclip::world_clip_polygon_for_transform( filter, &insert.get_transform(), ); for (got, want) in base_ignoring.iter().zip(expected) { assert!( ((got[0] - want[0]) - GET_TO_INSERT_OFFSET[0]).abs() < 1e-6 && ((got[1] - want[1]) - GET_TO_INSERT_OFFSET[1]).abs() < 1e-6, "base-ignoring corner ({:.9},{:.9}) is not the drawn corner plus {:?}", got[0], got[1], GET_TO_INSERT_OFFSET ); } } #[test] fn locked_reframe_conjugates_attributes_through_the_convention() { // base-point follow-up fix: `reframe_block_definition` (BEDIT UCS bake) // conjugates the block-local reframe through the INSERT transform for the // ATTRIBs living on external instances. That transform is the // base-point-aware one, because it is the map the attribute positions on // the INSERT were placed through. const NAME: &str = "BP_REFRAME"; let mut scene = Scene::new(); scene.define_block_raw( NAME, v(10.0, 5.0, 0.0), vec![ EntityType::Line(Line::from_points(v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0))), EntityType::AttributeDefinition(attdef("TAG1")), ], ); let mut insert = Insert::new(NAME.to_string(), v(100.0, 100.0, 0.0)); insert.set_x_scale(2.0); insert.set_y_scale(2.0); insert.set_z_scale(2.0); insert.rotation = DEG30; // The attribute sits where the convention puts the ATTDEF at (12,8). let mut attribute = AttributeEntity::simple("TAG1", "VALUE1"); attribute.insertion_point = insert_transform(&scene.document, &insert).apply(v(12.0, 8.0, 0.0)); insert.attributes.push(attribute); let insert_handle = scene.add_entity(EntityType::Insert(insert)); // Reframe: a 90° turn of the block-local frame, the kind of UCS change // `commit_active_ucs_change` bakes in. local_from_old maps (x,y) to (-y,x). let local = Transform::from_matrix(Matrix4::rotation_z(std::f64::consts::FRAC_PI_2)); let record = scene .document .block_records .get(NAME) .expect("the block record") .handle; let changed = scene.reframe_block_definition(record, &local); assert!( changed >= 2, "the LINE and the ATTDEF were reframed: {changed}" ); // The block-local contents turned 90°: (10,5)→(-5,10), (30,5)→(-5,30), // ATTDEF (12,8)→(-8,12). let stored = block_line(&scene.document, NAME); assert_close(stored.start, [-5.0, 10.0, 0.0], 1e-9, "reframed line start"); assert_close(stored.end, [-5.0, 30.0, 0.0], 1e-9, "reframed line end"); let attdefs = block_attdefs(&scene.document, NAME); assert_close( attdefs[0].insertion_point, [-8.0, 12.0, 0.0], 1e-9, "reframed attdef", ); // The attribute on the instance must now sit where the convention puts the // reframed ATTDEF: (100,100) + Rot(30)*2*((-8,12) - (10,5)) // = (100,100) + 2*Rot(30)*(-18,7) // Rot(30)*(-18,7) = (-18*0.8660254 - 7*0.5, -18*0.5 + 7*0.8660254) // = (-19.088457, -2.937822) // -> (100,100) + (-38.176914, -5.875644) = (61.823086, 94.124356). // Conjugating through the base-ignoring transform instead lands on // (92.803848, 100.464102): get_transform⁻¹ of the old position is the // base-relative (2,3), which the 90° turn maps to (-3,2). let attribute = match scene.document.get_entity(insert_handle) { Some(EntityType::Insert(ins)) => ins.attributes.first().cloned(), _ => None, } .expect("the attribute on the instance"); assert_close( attribute.insertion_point, [61.823086, 94.124356, 0.0], 1e-6, "reframed attribute position", ); } /// Back-to-back nested reference: block `OUTER` (base (10,5,0)) holds a LINE /// and an INSERT of block `INNER` (base (4,2,0)) at OUTER-local (5,3) with /// 60° rotation and scale 1; `INNER` holds the LINE (4,2)-(10,2). fn nested_scene() -> (Scene, Insert) { const INNER: &str = "BP_NESTED_INNER"; const OUTER: &str = "BP_NESTED_OUTER"; let mut scene = Scene::new(); // INNER: base (4,2,0), so its LINE is local (0,0)-(6,0). scene.define_block_raw( INNER, v(4.0, 2.0, 0.0), vec![EntityType::Line(Line::from_points( v(4.0, 2.0, 0.0), v(10.0, 2.0, 0.0), ))], ); // OUTER: base (10,5,0), LINE (10,5)-(30,5) is local (0,0)-(20,0), plus the // nested INSERT at OUTER-local (5,3), 60°, scale 1. let mut nested = Insert::new(INNER.to_string(), v(5.0, 3.0, 0.0)); nested.set_x_scale(1.0); nested.set_y_scale(1.0); nested.set_z_scale(1.0); nested.rotation = std::f64::consts::PI / 3.0; scene.define_block_raw( OUTER, v(10.0, 5.0, 0.0), vec![ EntityType::Line(Line::from_points(v(10.0, 5.0, 0.0), v(30.0, 5.0, 0.0))), EntityType::Insert(nested), ], ); // Model space: INSERT of OUTER at (100,100), 30°, scale 2. let mut insert = Insert::new(OUTER.to_string(), v(100.0, 100.0, 0.0)); insert.set_x_scale(2.0); insert.set_y_scale(2.0); insert.set_z_scale(2.0); insert.rotation = DEG30; let handle = scene.add_entity(EntityType::Insert(insert.clone())); let placed = scene .document .get_entity(handle) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.clone()), _ => None, }) .expect("the OUTER INSERT"); (scene, placed) } #[test] fn locked_nested_reference_explode_matches_the_render_path() { // base-point: exploding the outer reference one level yields the nested // reference, and placing the inner block through that piece lands on // exactly the position the render graph computes for it. let (scene, outer) = nested_scene(); // Hand derivation (c30 = 0.8660254, s30 = 0.5; c60 = 0.5, s60 = 0.8660254): // INNER-local (0,0) [the base point] -> OUTER-local // (5,3) + Rot(60)*(0,0) = (5,3) // OUTER-local (5,3) -> world // (100,100) + 2*Rot(30)*((5,3) - (10,5)) = (100,100) + 2*Rot(30)*(-5,-2) // Rot(30)*(-5,-2) = (-5*0.8660254 + 2*0.5, -5*0.5 - 2*0.8660254) // = (-3.330127, -4.232051) // -> (100,100) + (-6.660254, -8.464102) = (93.339746, 91.535898) let mut render_start = None; for (entity, transform) in render_graph_leaves(&scene) { let EntityType::Line(line) = entity else { continue; }; if (line.start.x - 4.0).abs() < 1e-9 && (line.start.y - 2.0).abs() < 1e-9 { let world = transform.apply(line.start); render_start = Some(world); } } let render_start = render_start.expect("the INNER LINE on the render path"); assert_close( render_start, [93.339746, 91.535898, 0.0], 1e-6, "render path: inner line start", ); // Explode path: one level down gives back the nested reference. let pieces = crate::modules::draw::modify::explode::explode_entity( &EntityType::Insert(outer.clone()), &scene.document, ); let nested_piece = pieces .iter() .find_map(|piece| match piece { EntityType::Insert(ins) if ins.block_name == "BP_NESTED_INNER" => Some(ins.clone()), _ => None, }) .expect("the nested reference among the exploded pieces"); // The nested reference itself lands where the render path puts it: the // codec applies Rot(30+60)=90° and scale 2*1=2 to it. assert_close( nested_piece.insert_point, [93.339746, 91.535898, 0.0], 1e-6, "exploded nested reference insert point", ); assert!( (nested_piece.rotation.to_degrees() - 90.0).abs() < 1e-6, "nested rotation = 90, got {}", nested_piece.rotation.to_degrees() ); assert!((nested_piece.x_scale() - 2.0).abs() < 1e-9); // And placing the inner block through that piece reproduces the render // path: (93.339746, 91.535898) + Rot(90)*2*((10,2) - (4,2)) // = (93.339746, 91.535898) + 2*Rot(90)*(6,0) = ... + (0,12) // = (93.339746, 103.535898) let inner_line = scene .document .block_records .get("BP_NESTED_INNER") .and_then(|br| { br.entity_handles .iter() .find_map(|h| match scene.document.get_entity(*h) { Some(EntityType::Line(line)) => Some(line.clone()), _ => None, }) }) .expect("the INNER LINE"); let piece_transform = insert_transform(&scene.document, &nested_piece); assert_close( piece_transform.apply(inner_line.start), [93.339746, 91.535898, 0.0], 1e-6, "explode path: inner line start", ); assert_close( piece_transform.apply(inner_line.end), [93.339746, 103.535898, 0.0], 1e-6, "explode path: inner line end", ); let mut render_end = None; for (entity, transform) in render_graph_leaves(&scene) { let EntityType::Line(line) = entity else { continue; }; if (line.start.x - 4.0).abs() < 1e-9 && (line.start.y - 2.0).abs() < 1e-9 { let world = transform.apply(line.end); render_end = Some(world); } } assert_close( render_end.expect("the INNER LINE end on the render path"), [93.339746, 103.535898, 0.0], 1e-6, "render path: inner line end", ); } #[test] fn locked_nested_insert_with_attributes_exploded_one_level() { // When an outer block containing an inner INSERT and attributes is exploded one level, // the outer block's attributes drop their values and its ATTDEFs reappear, while // the inner INSERT remains an intact INSERT with its attributes preserved. const INNER: &str = "BP_ATTR_INNER"; const OUTER: &str = "BP_ATTR_OUTER"; let mut scene = Scene::new(); // INNER block: base (0,0,0), has a LINE and an ATTDEF let inner_ad = AttributeDefinition { insertion_point: v(1.0, 1.0, 0.0), height: 1.0, ..AttributeDefinition::new( "INNER_TAG".to_string(), "Inner prompt".to_string(), "Inner val".to_string(), ) }; scene.define_block_raw( INNER, v(0.0, 0.0, 0.0), vec![ EntityType::Line(Line::from_points(v(0.0, 0.0, 0.0), v(5.0, 0.0, 0.0))), EntityType::AttributeDefinition(inner_ad), ], ); // OUTER block: base (10.0, 5.0, 0.0), has a nested INSERT of INNER (with an attribute instance) and an ATTDEF let mut inner_ins = Insert::new(INNER.to_string(), v(5.0, 3.0, 0.0)); let mut inner_attr = AttributeEntity::new("INNER_TAG".to_string(), "Actual inner value".to_string()); inner_attr.insertion_point = v(6.0, 4.0, 0.0); inner_ins.attributes.push(inner_attr); let outer_ad = AttributeDefinition { insertion_point: v(12.0, 8.0, 0.0), height: 1.0, ..AttributeDefinition::new( "OUTER_TAG".to_string(), "Outer prompt".to_string(), "Outer val".to_string(), ) }; scene.define_block_raw( OUTER, v(10.0, 5.0, 0.0), vec![ EntityType::Insert(inner_ins), EntityType::AttributeDefinition(outer_ad), ], ); // Model space: instance of OUTER at (100, 100), 30°, scale 2 let mut outer_instance = Insert::new(OUTER.to_string(), v(100.0, 100.0, 0.0)); outer_instance.set_x_scale(2.0); outer_instance.set_y_scale(2.0); outer_instance.set_z_scale(2.0); outer_instance.rotation = DEG30; let mut outer_attr = AttributeEntity::new("OUTER_TAG".to_string(), "Actual outer value".to_string()); outer_attr.insertion_point = v(100.464102, 107.196152, 0.0); outer_instance.attributes.push(outer_attr); // Explode outer instance one level let pieces = crate::modules::draw::modify::explode::explode_entity( &EntityType::Insert(outer_instance), &scene.document, ); // Expected pieces: the nested inner INSERT (with its attribute still intact) and the outer ATTDEF let nested_insert = pieces .iter() .find_map(|p| match p { EntityType::Insert(ins) if ins.block_name == INNER => Some(ins.clone()), _ => None, }) .expect("nested inner INSERT among pieces"); assert_eq!(nested_insert.attributes.len(), 1); assert_eq!(nested_insert.attributes[0].tag, "INNER_TAG"); assert_eq!( nested_insert.attributes[0].get_value(), "Actual inner value" ); let outer_attdef = pieces .iter() .find_map(|p| match p { EntityType::AttributeDefinition(ad) if ad.tag == "OUTER_TAG" => Some(ad.clone()), _ => None, }) .expect("reappeared outer ATTDEF among pieces"); assert_close( outer_attdef.insertion_point, EXPECTED_ATTR_POINT, 1e-6, "outer attdef insertion point", ); } #[test] fn locked_redefine_block_raw_refuses_cycle() { let mut scene = Scene::new(); let blk_a = "REDEF_A"; let blk_b = "REDEF_B"; scene.define_block_raw( blk_a, Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0), ))], ); // B contains A scene.define_block_raw( blk_b, Vector3::ZERO, vec![EntityType::Insert(Insert::new( blk_a.to_string(), Vector3::ZERO, ))], ); // Redefining A to contain B would create cycle A -> B -> A; must refuse let res = scene.redefine_block_raw( blk_a, Vector3::ZERO, vec![EntityType::Insert(Insert::new( blk_b.to_string(), Vector3::ZERO, ))], ); assert!(res.is_err()); let err = res.unwrap_err(); assert!( err.contains("circular dependency detected"), "expected cycle error, got: {err}" ); } #[test] fn locked_redefine_block_raw_refuses_open_block() { let mut scene = Scene::new(); let open_name = "REDEF_OPEN"; let other_name = "REDEF_CLOSED"; scene.define_block_raw( open_name, Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0), ))], ); scene.define_block_raw( other_name, Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(2.0, 0.0, 0.0), ))], ); let open_record = scene.document.block_records.get(open_name).unwrap().handle; let members_before = scene .document .block_records .get(open_name) .unwrap() .entity_handles .clone(); scene.block_edit_block = Some(open_record); let res = scene.redefine_block_raw( open_name, Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(9.0, 9.0, 0.0), ))], ); let err = res.expect_err("redefining the open block must be refused"); assert!( err.contains(open_name) && err.contains("Block Editor"), "expected refusal naming the block and the editor, got: {err}" ); assert_eq!( scene .document .block_records .get(open_name) .unwrap() .entity_handles, members_before, "the open definition must be untouched" ); let res = scene.redefine_block_raw( other_name, Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(8.0, 0.0, 0.0), ))], ); assert!(res.is_ok(), "a closed block must still redefine: {res:?}"); }