feat(block-editor): wire Visibility parameter placement

Milestone 1: enable Parameters > Visibility and add interactive
BPARAMETER VISIBILITY placement using the existing native visibility
parameter, grip and state engine. Bare BPARAMETER currently offers only
Visibility; other parameter, action and set entries remain unavailable.

Show the placed parameter in the editor and support cancel, local undo/
redo, save/discard and saved-edit undo. Bind placement to its document
and definition, reject duplicates and invalid points, and protect existing
authoring graphs from replacement.

Keep visibility parameters and their grips aligned with geometry when a
BEDIT UCS change reframes the definition, including parameter-only blocks.

Validation: 9 targeted tests, 272 block checks and 58 dock checks passed
(overlapping groups). All 17 local DWG fixtures passed in-memory DWG/DXF
placement roundtrips and graph-preservation checks; originals unchanged.
This commit is contained in:
Karim Jerbi committed 2026-10-15 15:08:21 +01:00
1 parent c8750db7c9
commit 308990e672
14 files changed
+778 -7

No files matched your search

+285
View File
@@ -1355,3 +1355,288 @@ fn locked_bedit_discard_preserves_block_epoch_and_render_caches() {
let _ = app.update(Message::BlockEditChangesDiscard);
assert_eq!(app.tabs[tab].scene.block_epoch, epoch_before);
}
fn placed_visibility(app: &OpenCADStudio) -> &codec::objects::BlockVisibilityParameter {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_visibility::parameter(&tab.scene.document, definition)
.expect("visibility parameter")
}
#[test]
fn visibility_parameter_placement_can_cancel_without_document_or_history_changes() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let history = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, "Visibility");
assert_eq!(
app.tabs[i].active_cmd.as_ref().unwrap().name(),
"BPARAMETER"
);
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.apply_cmd_result(crate::command::CmdResult::Cancel);
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), history);
assert!(!app.block_edit_dirty(i));
}
#[test]
fn visibility_parameter_placement_has_local_undo_redo_save_and_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(placed_visibility(&app).states[0].name, "VisibilityState0");
assert_eq!(
members(&app),
before.block_records.get("EDIT_ME").unwrap().entity_handles
);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
assert!(app.block_edit_dirty(i));
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document.objects, before.objects);
assert!(!app.block_edit_dirty(i));
app.redo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
let _ = app.dispatch_command("BSAVE");
assert!(!app.block_edit_dirty(i));
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
app.undo_steps(1);
let definition = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
assert!(
crate::scene::block_visibility::parameter(&app.tabs[i].scene.document, definition)
.is_none()
);
}
#[test]
fn visibility_parameter_placement_discard_removes_the_unsaved_graph() {
let (mut app, reference) = fixture();
let before = app.tabs[app.active_tab].scene.document.clone();
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "7,8,0");
let _ = app.dispatch_command("BEDIT_DISCARD");
// Closing the editor synchronizes the restored camera to the active VPORT.
let mut after = app.tabs[app.active_tab].scene.document.clone();
after.vports = before.vports.clone();
assert_eq!(after, before);
}
#[test]
fn visibility_parameter_placement_rejects_duplicates_stale_context_and_nonfinite_locations() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let document_id = app.tabs[i].id;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let before = app.tabs[i].scene.document.clone();
for (document_id, location) in [
(document_id + 1, glam::DVec3::ZERO),
(document_id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_visibility_parameter(document_id, definition, location);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.dispatch_command("BTESTBLOCK");
let test = app.active_tab;
let before = app.tabs[test].scene.document.clone();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
assert_eq!(app.tabs[test].active_cmd.as_ref().unwrap().name(), "LINE");
let _ = app.handle_place_block_visibility_parameter(document_id, definition, glam::DVec3::ZERO);
assert_eq!(app.tabs[test].scene.document, before);
}
#[test]
fn visibility_parameter_placement_preserves_an_existing_authoring_graph() {
use codec::objects::{DynamicBlockObject, DynamicBlockData, BlockEvaluationGraph, ObjectType};
let (mut app, reference) = fixture();
let i = app.active_tab;
let doc = &mut app.tabs[i].scene.document;
let definition = doc.block_records.get("EDIT_ME").unwrap().handle;
let dictionary = doc.ensure_extension_dictionary(definition);
let graph_handle = doc.allocate_handle();
let mut graph = DynamicBlockObject::new("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph");
graph.handle = graph_handle;
graph.owner = dictionary;
graph.data = DynamicBlockData::EvaluationGraph(BlockEvaluationGraph::default());
doc.objects
.insert(graph_handle, ObjectType::DynamicBlock(graph));
if let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get_mut(&dictionary) {
dictionary.add_entry("ACAD_ENHANCEDBLOCK", graph_handle);
}
begin(&mut app, reference);
let before = app.tabs[i].scene.document.clone();
for command in [
"BPARAMETER VISIBILITY",
"BVSTATE",
"_BVSTATE\u{1f}NEW\u{1f}Other",
] {
let _ = app.dispatch_command(command);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
assert!(crate::scene::block_visibility::create(
&mut app.tabs[i].scene.document,
definition,
Vector3::ZERO
)
.is_none());
assert_eq!(app.tabs[i].scene.document, before);
}
#[test]
fn visibility_parameter_placement_roundtrips_its_location_and_reference_grip_in_dwg_and_dxf() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
let doc = &app.tabs[app.active_tab].scene.document;
for extension in ["dwg", "dxf"] {
let bytes = crate::io::save_to_bytes(doc, extension, doc.version).unwrap();
let read = crate::io::load_bytes(&format!("visibility.{extension}"), bytes).unwrap();
let (definition, parameter) = read
.dynamic_visibility_for_insert(reference)
.expect(extension);
assert_eq!(
read.block_records.get("EDIT_ME").unwrap().handle,
definition
);
assert_eq!(parameter.def_point, Vector3::new(2.0, 2.0, 0.0));
assert_eq!(parameter.states[0].name, "VisibilityState0");
let grips = read
.objects
.values()
.filter_map(|object| match object {
codec::objects::ObjectType::DynamicBlock(object)
if object.owner == parameter.owner =>
{
match &object.data {
codec::objects::DynamicBlockData::VisibilityGrip(grip) => {
Some(grip.location)
}
_ => None,
}
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(grips, [Vector3::new(2.0, 2.0, 0.0)], "{extension}");
}
}
#[test]
fn visibility_parameter_placement_follows_the_editor_coordinate_frame() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#)["ok"],
true
);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#)["ok"],
true
);
// BEDIT bakes UCS changes into the definition and returns to local WCS.
assert!(app.tabs[app.active_tab].active_ucs.is_none());
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
}
#[test]
fn visibility_parameter_placement_reframes_an_empty_definition_and_undoes_it() {
let (mut app, reference) = fixture();
let i = app.active_tab;
let handles = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.entity_handles
.clone();
for handle in handles {
app.tabs[i].scene.document.remove_entity(handle);
}
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
let before = app.tabs[i].scene.document.objects.clone();
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
app.undo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(app.tabs[i].scene.document.objects, before);
app.redo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
}
+11
View File
@@ -575,6 +575,17 @@ impl OpenCADStudio {
return task;
}
}
CmdResult::PlaceBlockVisibilityParameter {
document_id,
definition,
location,
} => {
return self.handle_place_block_visibility_parameter(
document_id,
definition,
location,
);
}
CmdResult::CreateBlockWithOptions { options } => {
if let Some(task) = self.handle_create_block_with_options(options) {
return task;
+92
View File
@@ -7,6 +7,60 @@ use codec::{CadDocument, EntityType, Handle};
use iced::Task;
impl OpenCADStudio {
pub(in crate::app) fn visibility_placement_error(&self) -> Option<&'static str> {
let tab = &self.tabs[self.active_tab];
let Some(session) = tab.active_block_edit_session() else {
return Some("Open a block in the Block Editor before placing a parameter.");
};
visibility::placement_error(&tab.scene.document, session.br_handle)
}
pub(in crate::app) fn handle_place_block_visibility_parameter(
&mut self,
document_id: u64,
definition: Handle,
location: glam::DVec3,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].id != document_id
|| self.tabs[i]
.active_block_edit_session()
.map(|session| session.br_handle)
!= Some(definition)
{
self.command_line.push_error(
crate::t!("The parameter's Block Editor session is no longer active.").as_ref(),
);
return Task::none();
}
if let Some(error) = self.visibility_placement_error() {
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
if !location.is_finite() {
self.command_line
.push_error(crate::t!("Specify a finite parameter location.").as_ref());
return Task::none();
}
self.push_undo_snapshot(i, "Visibility parameter");
let document = &mut self.tabs[i].scene.document;
visibility::create(
document,
definition,
codec::types::Vector3::new(location.x, location.y, location.z),
)
.expect("validated visibility placement");
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.finish_pending_history(i);
self.refresh_properties();
self.command_line.push_output(
crate::t!("Visibility parameter placed. Use BVSTATE to manage its states.").as_ref(),
);
Task::none()
}
/// The block open in the Block Editor, or an error naming `command`.
fn visibility_block(&mut self, i: usize, command: &str) -> Option<Handle> {
let block = self.tabs[i]
@@ -72,7 +126,45 @@ impl OpenCADStudio {
pub(super) fn dispatch_bvisibility(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
use crate::command::CadCommand;
use crate::modules::draw::modify::bvstate::{BvStateCommand, SEP};
if matches!(cmd, "BVSTATE" | "BVHIDE" | "BVSHOW" | "_BVHIDE" | "_BVSHOW")
|| cmd.starts_with("_BVSTATE")
{
if let Some(block) = self.tabs[i]
.active_block_edit_session()
.map(|session| session.br_handle)
{
if visibility::parameter(&self.tabs[i].scene.document, block).is_none() {
if let Some(error) =
visibility::placement_error(&self.tabs[i].scene.document, block)
{
self.command_line.push_error(crate::t!(error).as_ref());
return Some(Task::none());
}
}
}
}
match cmd {
cmd if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") => {
let Some(block) = self.visibility_block(i, "BPARAMETER") else {
return Some(Task::none());
};
let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim();
if !parameter_type.is_empty()
&& !parameter_type.eq_ignore_ascii_case("VISIBILITY")
&& !parameter_type.eq_ignore_ascii_case("V")
{
self.command_line.push_error(
crate::t!("Only Visibility parameter placement is currently supported.")
.as_ref(),
);
return Some(Task::none());
}
let command = crate::modules::block_editor::visibility_parameter::VisibilityParameterCommand::new(self.tabs[i].id, block, parameter_type.is_empty());
self.command_line.push_info(&command.prompt());
self.command_line.set_step_options(command.options());
self.tabs[i].active_cmd = Some(Box::new(command));
Some(Task::none())
}
"BVSTATE" => {
let block = self.visibility_block(i, "BVSTATE")?;
let document = &self.tabs[i].scene.document;
+6
View File
@@ -173,6 +173,12 @@ impl OpenCADStudio {
};
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") {
if let Some(error) = self.visibility_placement_error() {
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
}
if self.tabs[i].block_test.is_some() {
let name = cmd.split([' ', ':']).next().unwrap_or(cmd);
if matches!(
+32
View File
@@ -988,3 +988,35 @@ fn authoring_snapshot(
.matches_image(path)
.expect("write palette evidence"));
}
#[test]
fn visibility_parameter_placement_starts_from_the_actual_palette_row() {
with_stack(|| {
let mut app = authoring_app();
let _ = app.dispatch_command("BAUTHORPALETTE");
let before = app.tabs[app.active_tab].scene.document.clone();
let point = authoring_control(&app, "authoring-tool-visibility");
authoring_click(&mut app, point);
assert_eq!(
app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
"BPARAMETER"
);
assert_eq!(app.tabs[app.active_tab].scene.document, before);
let _ = app.update(Message::CommandInput("0.25,0.25,0".into()));
let _ = app.update(Message::CommandSubmit);
assert!(app
.visibility_placement_error()
.unwrap()
.contains("already"));
let before = app.tabs[app.active_tab].scene.document.clone();
let _ = app.dispatch_command("LINE");
let point = authoring_control(&app, "authoring-tool-visibility");
authoring_click(&mut app, point);
assert_eq!(
app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
"LINE"
);
assert_eq!(app.tabs[app.active_tab].scene.document, before);
authoring_capture(&app, "visibility-parameter-placed");
});
}
+1
View File
@@ -406,6 +406,7 @@ impl OpenCADStudio {
self.authoring_palette_tab,
width,
chrome,
self.visibility_placement_error(),
)
}
crate::ui::dock::PanelId::BlockPalette => {
+16
View File
@@ -1032,6 +1032,21 @@ bg={bg_ms:.1}ms n={view_count}"
.iter()
.map(|entry| tab.scene.selected_constraint == Some(entry.id))
.collect();
let authoring_visibility = tab.active_block_edit_session().and_then(|session| {
let parameter = crate::scene::block_visibility::parameter(
&tab.scene.document,
session.br_handle,
)?;
let location = glam::DVec3::new(
parameter.def_point.x,
parameter.def_point.y,
parameter.def_point.z,
);
let point = tab.scene.camera.borrow().project(location, vp_bounds)?;
let point = iced::Point::new(vp_bounds.x + point.x, vp_bounds.y + point.y);
(point.x.is_finite() && point.y.is_finite())
.then(|| (point, parameter.name.clone(), vp_bounds))
});
crate::ui::overlay::selection_overlay(
std::sync::Arc::clone(&tab.scene.selection),
snap_info,
@@ -1096,6 +1111,7 @@ bg={bg_ms:.1}ms n={view_count}"
self.constraint_glyph_tooltip
.map(|kind| crate::t!(kind.label()).into_owned()),
constraint_cursor_badge,
authoring_visibility,
)
};