#[cfg(test)] mod command_replacement_tests { use super::super::*; use codec::entities::{Circle, Line}; use codec::types::Vector3; #[test] fn one_to_one_edits_keep_identity_but_type_changes_do_not() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; let handle = app.tabs[tab] .scene .add_entity(codec::EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0), ))); let owner = app.tabs[tab] .scene .document .get_entity(handle) .unwrap() .common() .owner_handle; let kept = app.replace_command_entity( tab, handle, vec![codec::EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0), ))], ); assert_eq!(kept, vec![handle]); let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); assert_eq!(edited.common().owner_handle, owner); assert!(matches!(edited, codec::EntityType::Line(line) if line.end.x == 3.0)); let allocated = app.replace_command_entity( tab, handle, vec![codec::EntityType::Circle(Circle::from_coords( 0.0, 0.0, 0.0, 2.0, ))], ); assert_eq!(allocated.len(), 1); assert_ne!(allocated[0], handle); assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); } #[test] fn live_geometry_updates_keep_document_identity_and_display_properties() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; let handle = app.tabs[tab] .scene .add_entity(codec::EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0), ))); let expected = { let entity = app.tabs[tab].scene.document.get_entity_mut(handle).unwrap(); entity.common_mut().layer = "LIVE".to_string(); entity.common_mut().invisible = true; entity.common_mut().linetype_scale = 2.5; entity.common().clone() }; let _ = app.apply_cmd_result(CmdResult::UpdateLiveEntity { handle, entity: codec::EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(4.0, 0.0, 0.0), )), finish: false, }); let updated = app.tabs[tab].scene.document.get_entity(handle).unwrap(); assert_eq!(updated.common().handle, expected.handle); assert_eq!(updated.common().owner_handle, expected.owner_handle); assert_eq!(updated.common().layer, expected.layer); assert_eq!(updated.common().invisible, expected.invisible); assert_eq!(updated.common().linetype_scale, expected.linetype_scale); assert!(matches!(updated, codec::EntityType::Line(line) if line.end.x == 4.0)); } } #[cfg(test)] mod parametric_constraint_undo_tests { use super::super::*; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { app.tabs[app.active_tab] .scene .add_entity(codec::EntityType::Line(codec::entities::Line::from_points( codec::types::Vector3::new(x1, y1, 0.0), codec::types::Vector3::new(x2, y2, 0.0), ))) } fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(l)) => (l.end.y - l.start.y) .atan2(l.end.x - l.start.x) .to_degrees(), other => panic!("expected a Line, got {other:?}"), } } /// Constraint state and solved geometry share one undo/redo transaction. #[test] fn adding_a_constraint_is_undoable_and_redoable_atomically() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); let original_angle = line_angle_deg(&app, line); assert!((original_angle - 16.699244).abs() < 1e-3); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); assert!( line_angle_deg(&app, line).abs() < 1e-6, "constraint should have leveled the line" ); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), 1 ); app.undo_steps(1); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, "undo should remove the constraint record" ); assert!( (line_angle_deg(&app, line) - original_angle).abs() < 1e-3, "undo should restore the pre-constraint angle" ); app.redo_steps(1); assert!( line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line" ); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), 1, "redo should restore the constraint record" ); } #[test] fn perpendicular_initial_solve_rotates_parallel_second_line_in_kernel() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let first = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); let second = add_line(&mut app, 20.0, 0.0, 30.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddPerpendicularConstraint { first: ParametricRef::whole(first), second: ParametricRef::whole(second), first_fixed: ParametricRef::whole(first), second_start: ParametricRef::point(second, 0), label: "Perpendicular constraint", }); let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => line.clone(), other => panic!("expected a Line, got {other:?}"), }; let fixed = line(first); let moving = line(second); assert_eq!(fixed.start, codec::types::Vector3::new(0.0, 0.0, 0.0)); assert_eq!(fixed.end, codec::types::Vector3::new(10.0, 0.0, 0.0)); assert!((moving.start - codec::types::Vector3::new(20.0, 0.0, 0.0)).length() < 1.0e-9); assert!((moving.length() - 10.0).abs() < 1.0e-7); assert!( (fixed.end - fixed.start) .dot(&(moving.end - moving.start)) .abs() < 1.0e-7, "fixed={fixed:?}, moving={moving:?}" ); } #[test] fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); let (original_start, original_end, original_length) = match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => (line.start, line.end, line.length()), other => panic!("expected a Line, got {other:?}"), }; let direction = codec::types::Vector3::new(3.0, 4.0, 0.0).normalize(); let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Reference( ParametricRef::whole(handle), ), direction, label: "Horizontal constraint", }); let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => line, other => panic!("expected a Line, got {other:?}"), }; let solved = (line.end - line.start).normalize(); assert!(solved.cross(&direction).length() < 1.0e-7); assert!((line.length() - original_length).abs() < 1.0e-7); let constraint = &app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints[0]; assert_eq!(constraint.axis_direction, Some(direction)); app.undo_steps(1); let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => line, other => panic!("expected a Line after undo, got {other:?}"), }; assert_eq!((line.start, line.end), (original_start, original_end)); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .map(|set| set.constraints.len()) .unwrap_or(0), 0 ); app.redo_steps(1); let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => line, other => panic!("expected a Line after redo, got {other:?}"), }; assert!( (line.end - line.start) .normalize() .cross(&direction) .length() < 1.0e-7 ); } #[test] fn horizontal_two_point_solve_keeps_the_first_point_fixed() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0); let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0); let pick = |handle, point| crate::command::CoincidentPick { handle: Some(handle), point, whole_curve: false, }; let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Points( pick(first, glam::DVec3::ZERO), pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), ), direction: codec::types::Vector3::UNIT_X, label: "Horizontal constraint", }); let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Line(line)) => line, other => panic!("expected a Line, got {other:?}"), }; assert_eq!(line(first).start, codec::types::Vector3::ZERO); assert!(line(second).start.y.abs() < 1.0e-8); } #[test] fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let mut ellipse = codec::entities::Ellipse::default(); ellipse.center = codec::types::Vector3::new(3.0, 7.0, 0.0); ellipse.major_axis = codec::types::Vector3::new(3.0, 4.0, 0.0); ellipse.minor_axis_ratio = 0.4; let handle = app.tabs[app.active_tab] .scene .add_entity(codec::EntityType::Ellipse(ellipse)); let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Reference( ParametricRef::ellipse_minor_axis(handle), ), direction: codec::types::Vector3::UNIT_X, label: "Horizontal constraint", }); let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) { Some(codec::EntityType::Ellipse(ellipse)) => ellipse, other => panic!("expected an Ellipse, got {other:?}"), }; assert!((ellipse.center - codec::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9); assert!(ellipse.major_axis.x.abs() < 1.0e-7); assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7); assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9); } /// Drawing commands must not create parametric constraints implicitly. #[test] fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let new_line = codec::EntityType::Line(codec::entities::Line::from_points( codec::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point codec::types::Vector3::new(10.0, 0.0, 0.0), )); app.tabs[app.active_tab].active_cmd = Some(Box::new( crate::modules::draw::draw::line::LineCommand::new(), )); let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, "a drawing command must not add an implicit constraint" ); } /// Conflict resolution removes one flagged constraint and is undoable. #[test] fn resolve_one_parametric_conflict_removes_a_flagged_constraint_and_is_undoable() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); // exact duplicate — flagged redundant once bumped app.tabs[app.active_tab] .scene .bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); let set = app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!(set.constraints.len(), 2); assert_eq!( set.conflicts.len(), 1, "the duplicate should have been flagged" ); app.resolve_one_parametric_conflict(); let set = app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!( set.constraints.len(), 1, "one of the two duplicates should have been removed" ); app.undo_steps(1); let set = app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!( set.constraints.len(), 2, "undo should restore the removed constraint" ); } #[test] fn deleting_one_parametric_constraint_is_undoable() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); let id = app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints[0] .id; app.tabs[app.active_tab].scene.selected_constraint = Some(id); app.delete_parametric_constraint(id); assert!(app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .is_empty()); assert_eq!(app.tabs[app.active_tab].scene.selected_constraint, None); app.undo_steps(1); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), 1 ); } /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's /// working buffer both defines the parameter and re-solves whatever /// constraint already references it by name — the entire point of a /// *named* parameter, not just data plumbing. #[test] fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); // The constraint is added *before* the parameter exists -- allowed, // same as `ParameterTable::set`'s own forward-reference tolerance; // it just doesn't resolve to anything until the parameter is defined. let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( "target_len".to_string(), )), label: "Distance constraint", }); app.named_parameter_editor_rows = vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string(), }]; app.apply_named_parameter_editor_rows(); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("target_len"), Ok(8.0) ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(codec::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); assert!( (len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}" ); // Redefining the parameter and re-applying should ripple again. app.named_parameter_editor_rows[0].formula = "3".to_string(); app.apply_named_parameter_editor_rows(); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(codec::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); assert!( (len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}" ); app.undo_steps(1); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("target_len"), Ok(8.0) ); let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(codec::EntityType::Line(line)) => line.length(), other => panic!("expected a Line, got {other:?}"), }; assert!((length_after_undo - 8.0).abs() < 1e-6); app.redo_steps(1); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("target_len"), Ok(3.0) ); } /// The no-selection page must expose the active drawing's named parameters. #[test] fn no_selection_properties_exposes_named_parameters() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); app.tabs[app.active_tab] .scene .named_parameters_mut() .set("width", "12") .unwrap(); app.refresh_properties(); assert!(app.tabs[app.active_tab] .properties .sections .iter() .flat_map(|section| §ion.props) .any(|property| matches!( &property.value, crate::scene::model::object::PropValue::ParamRow { name, .. } if name == "width" ))); assert!(app.tabs[app.active_tab] .properties .sections .iter() .flat_map(|section| §ion.props) .any(|property| matches!( property.value, crate::scene::model::object::PropValue::ParamsVisibilityToggle(_) ))); } /// End-to-end through the actual `Message` handlers a Properties-panel /// click/keystroke would fire — not `apply_named_parameter_editor_rows` /// directly — covering the whole Parameters-section lifecycle: add, /// rename, redefine, and confirm a referencing constraint re-solves /// after each commit (not just after a whole-table Apply, since this /// path commits per field). #[test] fn properties_panel_parameter_row_add_rename_redefine_and_delete() { use crate::ui::window::named_parameters::ParamField; let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( "len".to_string(), )), label: "Distance constraint", }); // Add: the first row is auto-named "param1". let _ = app.update(Message::PropParamAddNew); assert!(app.tabs[app.active_tab] .scene .named_parameters() .contains("param1")); let index = app.tabs[app.active_tab] .scene .named_parameters() .iter() .position(|p| p.name == "param1") .unwrap(); // Rename param1 -> len (commit-on-submit, not per keystroke: input // alone must not touch the table yet). let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string(), }); assert!( app.tabs[app.active_tab] .scene .named_parameters() .contains("param1"), "typing alone must not commit" ); let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name, }); assert!(!app.tabs[app.active_tab] .scene .named_parameters() .contains("param1")); assert!(app.tabs[app.active_tab] .scene .named_parameters() .contains("len")); // Redefine its formula to 8 and confirm the Distance constraint // (already referencing "len" by name, defined before this) re-solves. let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string(), }); let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula, }); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("len"), Ok(8.0) ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(codec::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); assert!( (len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}" ); // Delete: the row is gone from the table. let _ = app.update(Message::PropParamDelete(index)); assert!(!app.tabs[app.active_tab] .scene .named_parameters() .contains("len")); } /// A Constraints-section row click selects every entity the constraint /// references, replacing whatever was selected before. #[test] fn properties_panel_constraint_link_click_selects_its_entities() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Parallel, refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], driving_param: None, label: "Parallel constraint", }); app.tabs[app.active_tab] .scene .select_entity(unrelated, true); let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); let selected: std::collections::HashSet = app.tabs[app.active_tab] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .collect(); assert_eq!(selected, std::collections::HashSet::from([a, b])); } /// A row that fails to validate (here, a formula that doesn't parse) /// must not silently vanish or corrupt the table: it's reported as an /// error and simply isn't added, while a valid row alongside it still /// commits — the same isolate-the-failure treatment /// `ParameterTable::resolve_all` already gives a bad reference. #[test] fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string(), }, crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string(), }, ]; app.apply_named_parameter_editor_rows(); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("good"), Ok(10.0) ); assert!( !app.tabs[app.active_tab] .scene .named_parameters() .contains("bad"), "a malformed row must not be added" ); assert!( app.command_line .history .iter() .any(|e| e.kind == crate::ui::command_line::EntryKind::Error), "a malformed row must be reported as an error, not silently dropped" ); } /// Two rows typed with the same name must not silently collapse into /// "whichever `set` call happens last wins" — both are refused, and an /// unrelated row still commits normally. #[test] fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string(), }, crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string(), }, crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string(), }, ]; app.apply_named_parameter_editor_rows(); assert!( !app.tabs[app.active_tab] .scene .named_parameters() .contains("x"), "a duplicated name must not be added at all" ); assert_eq!( app.tabs[app.active_tab] .scene .named_parameters() .resolve("y"), Ok(3.0) ); assert!(app .command_line .history .iter() .any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); } /// Two endpoint picks resolve to a Coincident constraint. #[test] fn coincident_command_resolves_two_picked_points_into_a_constraint() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { first: crate::command::CoincidentPick { handle: Some(a), point: glam::DVec3::new(5.0, 0.0, 0.0), whole_curve: false, }, second: crate::command::CoincidentPick { handle: Some(b), point: glam::DVec3::new(20.0, 0.0, 0.0), whole_curve: false, }, multiple: false, label: "Coincident constraint", }); let set = app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .expect("a constraint set should exist"); assert_eq!(set.constraints.len(), 1); let c = &set.constraints[0]; assert_eq!(c.kind, ConstraintKind::Coincident); let entities: Vec = c.refs.iter().map(|r| r.entity).collect(); assert!( entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines" ); let Some(codec::EntityType::Line(first)) = app.tabs[app.active_tab].scene.document.get_entity(a) else { panic!("expected first line"); }; let Some(codec::EntityType::Line(second)) = app.tabs[app.active_tab].scene.document.get_entity(b) else { panic!("expected second line"); }; assert!((first.end - codec::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7); assert!((second.start - first.end).length() < 1.0e-7); assert!((second.length() - 5.0).abs() < 1.0e-7); } /// A pick that doesn't land near any real point (no object snap match) /// must not add a constraint — it should report an error and leave the /// scope untouched rather than silently constraining the wrong thing. #[test] fn coincident_command_reports_an_error_when_a_pick_misses() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { first: crate::command::CoincidentPick { handle: Some(_a), point: glam::DVec3::new(5.0, 0.0, 0.0), whole_curve: false, }, second: crate::command::CoincidentPick { handle: None, point: glam::DVec3::new(500.0, 500.0, 0.0), whole_curve: false, }, multiple: false, label: "Coincident constraint", }); assert_eq!( app.tabs[app.active_tab] .scene .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, "a missed pick must not add a constraint" ); } } #[cfg(all(test, not(target_arch = "wasm32")))] mod delobj_tests { use super::super::*; use codec::types::Vector3; fn sweep_source_presence(value: i16, mode: crate::command::ExtrudeMode) -> (bool, bool) { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; let mut circle = codec::Circle::new(); circle.radius = 2.0; let profile = app.tabs[i] .scene .add_entity(codec::EntityType::Circle(circle)); let path = app.tabs[i] .scene .add_entity(codec::EntityType::Line(codec::Line::from_points( Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 5.0), ))); app.delete_objects = value; let _ = app.apply_cmd_result(CmdResult::SweepEntities { handles: vec![profile], path_handle: path, mode, options: crate::command::SweepOptions::default(), color: [1.0; 4], expressions: [None, None], }); let created = app.tabs[i] .scene .document .entities() .any(|entity| match mode { crate::command::ExtrudeMode::Solid => { matches!(entity, codec::EntityType::Solid3D(_)) } crate::command::ExtrudeMode::Surface => { matches!(entity, codec::EntityType::Surface(_)) } }); assert!(created, "SWEEP must produce the requested result"); ( app.tabs[i].scene.document.get_entity(profile).is_some(), app.tabs[i].scene.document.get_entity(path).is_some(), ) } #[test] fn sweep_applies_all_four_source_deletion_modes() { let solid_expectations = [(true, true), (false, true), (false, false), (false, false)]; for (value, expected) in solid_expectations.into_iter().enumerate() { assert_eq!( sweep_source_presence(value as i16, crate::command::ExtrudeMode::Solid), expected, ); } // A swept surface is associative by default (SURFACEASSOCIATIVITY 1) // and keeps the profile and path it follows, whatever DELOBJ says; // DELOBJ reaches surfaces only with associativity off, a profile-wide // switch this parallel test leaves alone. assert!(crate::scene::model::sweep_model::surface_associativity()); for value in [2, 3] { assert_eq!( sweep_source_presence(value, crate::command::ExtrudeMode::Surface), (true, true), ); } } } #[cfg(all(test, not(target_arch = "wasm32")))] mod thicken_tests { use super::super::*; use codec::entities::{Surface, SurfaceKind}; use kernel::geom2d::{Circle, Curve, NurbsCurve}; use kernel::space::{Parameterization, Plane}; #[test] fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let body = kernel::brep::planar_region( Plane::XY, &[vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0, })]], ) .unwrap(); let source = app.add_surface_model( codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Plane))), body, ); assert!(!source.is_null()); let i = app.active_tab; let invalid = app.tabs[i] .scene .add_entity(codec::EntityType::Surface(Box::new(Surface::new( SurfaceKind::Generic, )))); let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); let before = app.tabs[i].history.undo_stack.len(); let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0, }); assert_eq!(app.tabs[i].history.undo_stack.len(), before); let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0, }); assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); let result = app.tabs[i] .scene .document .entities() .find_map(|entity| { matches!(entity, codec::EntityType::Solid3D(_)).then_some(entity.common().handle) }) .unwrap(); assert!(app.tabs[i] .scene .document .solid_history_operation(result) .is_some()); assert_eq!( serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original ); let expected = kernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]) .unwrap() .volume; let bytes = crate::io::save_to_bytes( &app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version, ) .unwrap(); let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); let mut restored = crate::scene::Scene::new(); restored.document = document; restored.restore_solid_models(&[result]); let actual = kernel::brep::analytic_mass_properties(&restored.solid_models[&result]) .unwrap() .volume; assert!((expected - actual).abs() < 1e-8); app.undo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_none()); assert!(app.tabs[i] .scene .document .solid_history_operation(result) .is_none()); assert!(app.tabs[i].scene.document.get_entity(source).is_some()); assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); app.redo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_some()); assert!(app.tabs[i] .scene .document .solid_history_operation(result) .is_some()); assert_eq!( serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original ); } #[test] fn thicken_creates_a_solid_from_a_free_form_surface() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let profile = NurbsCurve::interpolate( &[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]], None, None, Parameterization::Chord, ) .unwrap(); let body = kernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) .unwrap(); let source = app.add_surface_model( codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Generic))), body, ); let i = app.active_tab; let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.15, }); let solids = app.tabs[i] .scene .document .entities() .filter_map(|entity| { matches!(entity, codec::EntityType::Solid3D(_)).then_some(entity.common().handle) }) .collect::>(); assert_eq!(solids.len(), 1); let solid = &app.tabs[i].scene.solid_models[&solids[0]]; assert!(solid.validate().is_empty()); assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2)); assert!(app.tabs[i] .scene .document .solid_history_operation(solids[0]) .is_some()); assert!(app.tabs[i].scene.document.get_entity(source).is_some()); } } #[cfg(test)] mod dispatched_command_task_tests { use super::super::*; use crate::command::CmdResult; use codec::entities::ViewportRenderMode as Mode; /// `CmdResult::Dispatch` and `CmdResult::Relaunch` run the assembled line /// through `dispatch_command`, and the Task it returns must reach the /// runtime: the render mode a VSCURRENT keyword picker chooses is applied /// by a message that Task carries. #[test] fn dispatch_and_relaunch_keep_the_task_the_command_returns() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; app.tabs[i].render_mode = Mode::Wireframe2D; let task = app.apply_cmd_result(CmdResult::Dispatch("VSCURRENT GOURAUDSHADED".into())); app.drive_headless_task(task).unwrap(); assert_eq!( app.tabs[i].render_mode, Mode::GouraudShaded, "Dispatch dropped the dispatched command's task" ); app.tabs[i].render_mode = Mode::Wireframe2D; let task = app.apply_cmd_result(CmdResult::Relaunch( "VSCURRENT GOURAUDSHADED".into(), Vec::new(), )); app.drive_headless_task(task).unwrap(); assert_eq!( app.tabs[i].render_mode, Mode::GouraudShaded, "Relaunch dropped the dispatched command's task" ); } } /// PW-1 BEDIT characterization tests. /// /// Two groups: /// - `locked_*`: behavior Phase 1 must preserve (save keeps edits, discard /// restores the definition, refusals stay refused). /// - `documenting_*`: today's questionable behavior, pinned so the refactor /// cannot change it silently. Each one is expected to flip in Phase 1. #[cfg(test)] mod bedit_characterization_tests { use super::super::*; use codec::entities::{Circle, Insert, Line}; use codec::types::Vector3; use codec::EntityType; /// Fresh document with one block definition `BLK` (one line) and one /// model-space INSERT referencing it. Returns the INSERT handle and the /// definition's member handles. fn setup_block_and_insert(app: &mut OpenCADStudio) -> (Handle, Vec) { let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; app.tabs[tab].scene.define_block_raw( "BLK", Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0), ))], ); let insert = app.tabs[tab] .scene .document .add_entity(EntityType::Insert(Insert::new( "BLK", Vector3::new(100.0, 0.0, 0.0), ))) .expect("insert"); let members = block_members(app, "BLK"); (insert, members) } fn block_members(app: &OpenCADStudio, name: &str) -> Vec { app.tabs[app.active_tab] .scene .document .block_records .get(name) .map(|br| br.entity_handles.clone()) .unwrap_or_default() } /// Definition state as (handle, Debug form) in member order. The Debug /// form stands in for PartialEq, which EntityType does not provide. fn definition_state(app: &OpenCADStudio, name: &str) -> Vec<(Handle, String)> { let tab = app.active_tab; block_members(app, name) .into_iter() .map(|h| { let e = app.tabs[tab].scene.document.get_entity(h); (h, format!("{e:?}")) }) .collect() } fn undo_depth(app: &OpenCADStudio) -> usize { app.tabs[app.active_tab].history.undo_stack.len() } fn begin_bedit(app: &mut OpenCADStudio, insert: Handle) { let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!( app.tabs[app.active_tab].active_block_edit.is_some(), "setup: BEDIT_BEGIN should open a block-edit session" ); } fn add_circle_in_bedit(app: &mut OpenCADStudio) -> Handle { let tab = app.active_tab; app.tabs[tab] .scene .add_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))) } // ---- locked: must survive Phase 1 ---- /// Locked: save keeps the entity added inside the session. #[test] fn locked_save_keeps_entity_added_in_session() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); begin_bedit(&mut app, insert); add_circle_in_bedit(&mut app); let _ = app.dispatch_command("BEDIT_SAVE"); assert!(app.tabs[app.active_tab].active_block_edit.is_none()); assert_eq!( block_members(&app, "BLK").len(), 2, "save keeps the new entity" ); } /// Locked: discard restores the definition to its entry state exactly /// (same handles, same geometry, same order). #[test] fn locked_discard_restores_definition_to_entry_state() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let entry = definition_state(&app, "BLK"); begin_bedit(&mut app, insert); add_circle_in_bedit(&mut app); let _ = app.dispatch_command("BEDIT_DISCARD"); assert!(app.tabs[app.active_tab].active_block_edit.is_none()); assert_eq!(definition_state(&app, "BLK"), entry); } /// Locked: refusals. A non-INSERT is rejected and no session opens. #[test] fn locked_bedit_refuses_non_insert_object() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; let line = app.tabs[tab] .scene .document .add_entity(EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0), ))) .expect("line"); let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", line.value())); assert!(app.tabs[tab].active_block_edit.is_none()); assert!(app.tabs[tab].block_edits.is_empty()); } /// Locked: refusal. The `BEDIT` picker command is rejected while a /// REFEDIT session is active and no BEDIT session opens. #[test] fn locked_bedit_picker_refused_while_refedit_active() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", insert.value())); assert!( app.tabs[tab].refedit_session.is_some(), "setup: REFEDIT_BEGIN should open a REFEDIT session" ); let _ = app.dispatch_command("BEDIT"); assert!(app.tabs[tab].active_block_edit.is_none()); assert!(app.tabs[tab].block_edits.is_empty()); } /// Every entry point must refuse while REFEDIT is active. #[test] fn locked_bedit_begin_refuses_active_refedit() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", insert.value())); assert!(app.tabs[tab].refedit_session.is_some()); let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!( app.tabs[tab].active_block_edit.is_none(), "begin must refuse while REFEDIT is active" ); } /// Locked: refusal. An xref definition cannot be edited. #[test] fn locked_bedit_refuses_xref_definition() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; app.tabs[tab] .scene .document .block_records .get_mut("BLK") .expect("block record") .flags .is_xref = true; let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!(app.tabs[tab].active_block_edit.is_none()); assert!(app.tabs[tab].block_edits.is_empty()); } /// V7 (reference check): while a block editor session is open this /// command set is refused outright with "not allowed in block editor"; /// the refusal pushes no undo entry and starts no command. #[test] fn locked_deny_list_refuses_while_session_open() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!( app.tabs[tab].active_block_edit.is_some(), "setup: BEDIT_BEGIN should open a session" ); let undo_depth = app.tabs[tab].history.undo_stack.len(); for verb in [ "LAYOUT", "VPORTS", "MVIEW", "WBLOCK", "PURGE", "RENAME", "REFEDIT", "REFEDIT_BEGIN", "PLOT", ] { let revision = app.command_line.error_revision; let _ = app.dispatch_command(verb); assert_eq!( app.command_line.error_revision, revision.wrapping_add(1), "{verb} must refuse with a command-line error" ); assert!( app.command_line .last_error .as_deref() .unwrap_or_default() .contains("command not allowed in block editor"), "{verb}: got {:?}", app.command_line.last_error ); assert!( app.tabs[tab].active_block_edit.is_some(), "{verb} must keep the session open" ); assert!( app.tabs[tab].active_cmd.is_none(), "{verb} must not install a command" ); assert_eq!( app.tabs[tab].history.undo_stack.len(), undo_depth, "{verb} must push no undo entry" ); } } /// Spec 8: a rejected command must not cancel a running command — the /// deny hook sits before the running-command teardown. #[test] fn locked_deny_list_does_not_cancel_a_running_command() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!(app.tabs[tab].active_block_edit.is_some()); let _ = app.dispatch_command("LINE"); assert_eq!( app.tabs[tab].active_cmd.as_deref().map(|cmd| cmd.name()), Some("LINE"), "setup: LINE should be running inside the session" ); let _ = app.dispatch_command("LAYOUT"); assert!(app .command_line .last_error .as_deref() .unwrap_or_default() .contains("command not allowed in block editor")); assert_eq!( app.tabs[tab].active_cmd.as_deref().map(|cmd| cmd.name()), Some("LINE"), "the refused command must leave the running LINE untouched" ); } /// V7: the reference keeps XREF, NEW, OPEN, SAVEAS and BEDIT available /// inside the editor — the deny list must not touch them. #[test] fn locked_deny_list_leaves_v7_allowed_commands_alone() { for verb in ["XREF", "NEW", "OPEN", "SAVEAS", "BEDIT"] { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value())); assert!(app.tabs[tab].active_block_edit.is_some()); let _ = app.dispatch_command(verb); assert!( !app.command_line .last_error .as_deref() .unwrap_or_default() .contains("not allowed in block editor"), "{verb} is available in the reference session and must not be denied: {:?}", app.command_line.last_error ); } } // ---- documenting: today's behavior, expected to flip in Phase 1 ---- /// Discard removes local before-images before drawing undo resumes. #[test] fn locked_modify_discard_then_undo_does_not_restore_local_edits() { let mut app = OpenCADStudio::new_for_test(); let (insert, members) = setup_block_and_insert(&mut app); let line = members[0]; let entry = definition_state(&app, "BLK"); begin_bedit(&mut app, insert); let tab = app.active_tab; // Record the undo step before the mutation so the recorder captures it. app.push_undo_snapshot(tab, "modify inside BEDIT"); app.tabs[tab] .scene .document .get_entity_mut(line) .map(|e| { if let EntityType::Line(l) = e { l.end = Vector3::new(99.0, 0.0, 0.0); } }) .expect("line in definition"); app.finish_pending_history(tab); let _ = app.dispatch_command("BEDIT_DISCARD"); app.finish_pending_history(tab); assert_eq!( definition_state(&app, "BLK"), entry, "discard restores entry" ); app.undo_steps(1); // Pinned: the state after one undo, observed on current code. assert_eq!( definition_state(&app, "BLK"), entry, "documenting: undo after discard restores the entry state (ISS-02 not reproduced here)" ); } /// Local undo restores both the entity store and block membership. #[test] fn locked_session_undo_restores_membership_without_crossing_entry() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); begin_bedit(&mut app, insert); let tab = app.active_tab; app.push_undo_snapshot(tab, "add circle in BEDIT"); add_circle_in_bedit(&mut app); app.finish_pending_history(tab); assert_eq!(block_members(&app, "BLK").len(), 2); app.undo_steps(1); let members = block_members(&app, "BLK"); assert_eq!( members.len(), 1, "block membership must match the undone entity store" ); let dangling = members .iter() .filter(|h| app.tabs[tab].scene.document.get_entity(**h).is_none()) .count(); assert_eq!(dangling, 0, "every member must have an entity"); assert!( app.tabs[tab].active_block_edit.is_some(), "documenting: undo does not close the session" ); } /// Opening starts an empty local history; undo cannot cross entry. #[test] fn locked_session_entry_has_no_undo_step() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; begin_bedit(&mut app, insert); app.finish_pending_history(tab); let depth_after_begin = undo_depth(&app); assert_eq!(depth_after_begin, 0, "opening must not record an undo step"); app.undo_steps(1); assert!( app.tabs[tab].active_block_edit.is_some(), "documenting: undoing BEDIT_BEGIN does not close the open session" ); } /// Switching a clean block replaces the single session. #[test] fn locked_clean_switch_replaces_the_session() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); let tab = app.active_tab; app.tabs[tab].scene.define_block_raw( "OTHER", Vector3::ZERO, vec![EntityType::Line(Line::from_points( Vector3::ZERO, Vector3::new(5.0, 0.0, 0.0), ))], ); let other_insert = app.tabs[tab] .scene .document .add_entity(EntityType::Insert(Insert::new( "OTHER", Vector3::new(200.0, 0.0, 0.0), ))) .expect("insert"); begin_bedit(&mut app, insert); begin_bedit(&mut app, other_insert); assert_eq!( app.tabs[tab].block_edits.len(), 1, "a drawing has only one block-editor session" ); } /// No-change Save and Discard leave the drawing history untouched. #[test] fn locked_clean_save_and_discard_leave_history_unchanged() { let mut app = OpenCADStudio::new_for_test(); let (insert, _) = setup_block_and_insert(&mut app); begin_bedit(&mut app, insert); app.finish_pending_history(app.active_tab); let after_begin = undo_depth(&app); let _ = app.dispatch_command("BEDIT_SAVE"); app.finish_pending_history(app.active_tab); assert_eq!(undo_depth(&app), after_begin, "save pushes no undo entry"); begin_bedit(&mut app, insert); app.finish_pending_history(app.active_tab); let before_discard = undo_depth(&app); let _ = app.dispatch_command("BEDIT_DISCARD"); app.finish_pending_history(app.active_tab); assert_eq!( undo_depth(&app), before_discard, "discard pushes no undo entry (not undoable)" ); } } #[cfg(test)] mod block_base_point_tests { use super::super::*; use crate::command::{CadCommand, CmdResult}; use crate::modules::insert::insert_block::InsertBlockCommand; use codec::entities::AttributeDefinition; use codec::types::Vector3; use codec::EntityType; /// base-point fix (A), host side: `AttreqNeeded` is where the host resolves the /// block's ATTDEFs, so it also injects the definition's base point. Without /// that the committed attribute lands on the codec transform's position. #[test] fn locked_attreq_injects_the_block_base_point_for_attribute_placement() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; // Block "ATTREQ_BP": base (10,5,0); LINE (10,5)-(30,5); ATTDEF TAG1 at // (12,8). Rendering convention (see scene::block_base_point_tests): the // ATTDEF lands at (100,100) + Rot(30)*Scale(2)*((12,8) - (10,5)) // = (100,100) + 2*(1.732051 - 1.5, 1 + 2.598076) = (100.464102, 107.196152). app.tabs[i].scene.define_block_raw( "ATTREQ_BP", Vector3::new(10.0, 5.0, 0.0), vec![ EntityType::Line(codec::entities::Line::from_points( Vector3::new(10.0, 5.0, 0.0), Vector3::new(30.0, 5.0, 0.0), )), EntityType::AttributeDefinition(AttributeDefinition { insertion_point: Vector3::new(12.0, 8.0, 0.0), height: 1.0, ..AttributeDefinition::new( "TAG1".to_string(), "Tag one".to_string(), String::new(), ) }), ], ); let mut cmd = InsertBlockCommand::classic_for_block( "ATTREQ_BP".to_string(), Vec::new(), String::new(), ); let _ = cmd.on_point(glam::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 { .. }) )); app.tabs[i].active_cmd = Some(Box::new(cmd)); let _ = app.apply_cmd_result(CmdResult::AttreqNeeded { block_name: "ATTREQ_BP".to_string(), }); let result = app.tabs[i] .active_cmd .as_mut() .expect("the INSERT command waits for the attribute value") .on_text_input("VALUE1") .expect("the value finishes the INSERT"); let _ = app.apply_cmd_result(result); let attribute = app.tabs[i] .scene .document .entities() .find_map(|entity| match entity { EntityType::Insert(ins) if ins.block_name == "ATTREQ_BP" => { ins.attributes.first().cloned() } _ => None, }) .expect("the committed INSERT's attribute"); assert_eq!(attribute.tag, "TAG1"); let point = attribute.insertion_point; assert!( (point.x - 100.464102).abs() < 1e-6 && (point.y - 107.196152).abs() < 1e-6 && point.z.abs() < 1e-9, "attribute at ({:.6},{:.6},{:.6}), expected (100.464102,107.196152,0)", point.x, point.y, point.z ); } /// base-point fix (D), host side: picking an existing block in the Block /// Definition dialog prefills the base point from that definition. #[test] fn locked_block_def_name_select_prefills_the_definition_base_point() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; app.tabs[i].scene.define_block_raw( "DEF_BASE", Vector3::new(10.0, 5.0, 0.0), vec![EntityType::Line(codec::entities::Line::from_points( Vector3::new(10.0, 5.0, 0.0), Vector3::new(30.0, 5.0, 0.0), ))], ); app.block_definition = Some( crate::ui::window::block_definition::BlockDefinitionState::new( vec!["DEF_BASE".to_string()], Vec::new(), 0, ), ); assert_eq!( app.block_definition .as_ref() .map(|s| s.base_point_y.as_str()), Some("0"), "a new block starts at 0" ); let _ = app.update(Message::BlockDefNameSelect("DEF_BASE".to_string())); let state = app.block_definition.as_ref().expect("the dialog is open"); assert_eq!(state.name, "DEF_BASE"); 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(), glam::DVec3::new(10.0, 5.0, 0.0)); } /// A block with base (10,5,0) and LINE (10,5)-(30,5), referenced by an /// INSERT at (100,100) with 30° rotation and uniform scale 2. Under the /// convention the LINE renders at (100,100)-(134.641016,120). fn block_and_instance_with_base(app: &mut OpenCADStudio, name: &str) -> codec::Handle { let _ = app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; app.tabs[i].scene.define_block_raw( name, Vector3::new(10.0, 5.0, 0.0), vec![EntityType::Line(codec::entities::Line::from_points( Vector3::new(10.0, 5.0, 0.0), Vector3::new(30.0, 5.0, 0.0), ))], ); let mut insert = codec::entities::Insert::new(name.to_string(), Vector3::new(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 = std::f64::consts::PI / 6.0; app.tabs[i].scene.add_entity(EntityType::Insert(insert)) } /// base-point follow-up fix: the REFEDIT working copy is placed through the /// base-point-aware INSERT transform, so it sits exactly on the rendered /// reference, and saving folds it back to the same block-local geometry. #[test] fn locked_refedit_working_copy_sits_on_the_rendered_reference() { let mut app = OpenCADStudio::new_for_test(); let handle = block_and_instance_with_base(&mut app, "REFEDIT_BP"); let i = app.active_tab; let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value())); assert!( app.tabs[i].refedit_session.is_some(), "setup: REFEDIT_BEGIN opens a session" ); // The working copy is the LINE the session moved into model space. let temp: Vec = app.tabs[i] .refedit_session .as_ref() .expect("the session") .temp_handles .clone(); let line = temp .iter() .find_map(|h| match app.tabs[i].scene.document.get_entity(*h) { Some(EntityType::Line(line)) => Some(line.clone()), _ => None, }) .expect("the working-copy LINE"); // (100,100) + Rot(30)*2*((10,5)-(10,5)) and ...*((30,5)-(10,5)). assert!( (line.start.x - 100.0).abs() < 1e-6 && (line.start.y - 100.0).abs() < 1e-9 && (line.end.x - 134.641016).abs() < 1e-6 && (line.end.y - 120.0).abs() < 1e-6, "working copy at ({:.6},{:.6})-({:.6},{:.6}), expected (100,100)-(134.641016,120)", line.start.x, line.start.y, line.end.x, line.end.y ); // Saving without editing restores the definition verbatim: the // inverse is the exact inverse of the forward transform. let _ = app.dispatch_command("REFCLOSE_SAVE"); assert!(app.tabs[i].refedit_session.is_none()); let stored = app.tabs[i] .scene .document .block_records .get("REFEDIT_BP") .expect("the block record") .entity_handles .iter() .find_map(|h| match app.tabs[i].scene.document.get_entity(*h) { Some(EntityType::Line(line)) => Some(line.clone()), _ => None, }) .expect("the stored LINE"); assert!( (stored.start.x - 10.0).abs() < 1e-9 && (stored.start.y - 5.0).abs() < 1e-9 && (stored.end.x - 30.0).abs() < 1e-9 && (stored.end.y - 5.0).abs() < 1e-9, "stored LINE at ({:.9},{:.9})-({:.9},{:.9}), expected (10,5)-(30,5)", stored.start.x, stored.start.y, stored.end.x, stored.end.y ); assert_eq!( crate::scene::render_graph::block_base_point(&app.tabs[i].scene.document, "REFEDIT_BP"), Vector3::new(10.0, 5.0, 0.0), "the base point survives the round trip" ); } /// base-point follow-up fix: the dynamic-block visibility grip is a /// block-definition coordinate, so it follows the base-point convention /// too: (100,100) + Rot(30)*2*((12,3) - (10,5)) = (105.464102, 98.535898). #[test] fn locked_visibility_grip_follows_the_base_point_convention() { let mut app = OpenCADStudio::new_for_test(); let handle = block_and_instance_with_base(&mut app, "VIS_BP"); let i = app.active_tab; let def_block = app.tabs[i] .scene .document .block_records .get("VIS_BP") .expect("the block record") .handle; let mut param = codec::objects::BlockVisibilityParameter::default(); param.owner = def_block; param.def_point = Vector3::new(12.0, 3.0, 0.0); app.tabs[i] .scene .document .block_visibility_params .insert(codec::Handle::new(0x70), param); app.tabs[i].selected_handle = Some(handle); app.refresh_visibility_grip([0.0, 0.0, 0.0]); let grip = app.tabs[i] .selected_grips .last() .expect("the visibility lookup grip"); assert!( (grip.world.x - 105.464102).abs() < 1e-6 && (grip.world.y - 98.535898).abs() < 1e-6, "grip at ({:.6},{:.6}), expected (105.464102,98.535898)", grip.world.x, grip.world.y ); assert!( app.tabs[i].visibility_grip.is_some(), "the selection is a dynamic-block reference" ); } }