forked from 1b-agent/opencadstudio-upstream
Refuse LAYOUT, VPORTS, MVIEW, WBLOCK, PURGE, RENAME, REFEDIT, REFEDIT_BEGIN and PLOT from the command line while a block editor session is open, matching the reference product's "<CMD> command not allowed in block editor" behaviour (spec section 8, based on the V7 hands-on check). XREF, NEW, OPEN and SAVEAS stay allowed. The service-level RENAME guard stays as defense in depth.
1852 lines
69 KiB
Rust
1852 lines
69 KiB
Rust
#[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<Handle> = 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<Handle> = 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::<Vec<_>>();
|
|
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<Handle>) {
|
|
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<Handle> {
|
|
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<codec::Handle> = 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"
|
|
);
|
|
}
|
|
}
|