forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c8750db7c9
commit
308990e672
14 files changed
+778
-7
No files matched your search
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -406,6 +406,7 @@ impl OpenCADStudio {
|
||||
self.authoring_palette_tab,
|
||||
width,
|
||||
chrome,
|
||||
self.visibility_placement_error(),
|
||||
)
|
||||
}
|
||||
crate::ui::dock::PanelId::BlockPalette => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -1514,6 +1514,12 @@ impl DimensionPreview {
|
||||
/// Returned by every `CadCommand` method to tell main.rs what to do.
|
||||
#[allow(dead_code)]
|
||||
pub enum CmdResult {
|
||||
/// Place the visibility parameter in the definition that requested it.
|
||||
PlaceBlockVisibilityParameter {
|
||||
document_id: u64,
|
||||
definition: Handle,
|
||||
location: DVec3,
|
||||
},
|
||||
/// Return the suspended filter after a read-only sample pick or cancellation.
|
||||
QSelectSample {
|
||||
state: Box<crate::app::QSelectState>,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use crate::modules::parametric::ParametricModule;
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
pub mod visibility_parameter;
|
||||
|
||||
pub struct BlockEditorModule;
|
||||
|
||||
impl CadModule for BlockEditorModule {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Interactive placement of the supported Visibility action parameter.
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult};
|
||||
use codec::Handle;
|
||||
use glam::DVec3;
|
||||
|
||||
pub struct VisibilityParameterCommand {
|
||||
document_id: u64,
|
||||
definition: Handle,
|
||||
choosing_type: bool,
|
||||
}
|
||||
|
||||
impl VisibilityParameterCommand {
|
||||
pub fn new(document_id: u64, definition: Handle, choosing_type: bool) -> Self {
|
||||
Self {
|
||||
document_id,
|
||||
definition,
|
||||
choosing_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for VisibilityParameterCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"BPARAMETER"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.choosing_type {
|
||||
crate::t!("BPARAMETER Enter parameter type [Visibility] <Visibility>:").into_owned()
|
||||
} else {
|
||||
crate::t!("BPARAMETER Specify visibility parameter location:").into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if self.choosing_type {
|
||||
vec![CmdOption::new(crate::t!("Visibility").as_ref(), "V")]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.choosing_type
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if !self.choosing_type {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
match text
|
||||
.trim()
|
||||
.trim_start_matches('_')
|
||||
.to_ascii_uppercase()
|
||||
.as_str()
|
||||
{
|
||||
"" | "V" | "VISIBILITY" => {
|
||||
self.choosing_type = false;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError(
|
||||
crate::t!("Only Visibility parameter placement is currently supported.")
|
||||
.into_owned(),
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, location: DVec3) -> CmdResult {
|
||||
if self.choosing_type {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if !location.is_finite() {
|
||||
return CmdResult::ReportError(
|
||||
crate::t!("Specify a finite parameter location.").into_owned(),
|
||||
);
|
||||
}
|
||||
CmdResult::PlaceBlockVisibilityParameter {
|
||||
document_id: self.document_id,
|
||||
definition: self.definition,
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.choosing_type {
|
||||
self.on_text_input("").unwrap()
|
||||
} else {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["BPARAMETER"]
|
||||
});
|
||||
@@ -234,6 +234,34 @@ fn new_guid() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Why another visibility parameter cannot be placed in this definition.
|
||||
/// Existing evaluation graphs must be composed, never replaced by a new graph.
|
||||
pub fn placement_error(document: &CadDocument, definition: Handle) -> Option<&'static str> {
|
||||
if parameter(document, definition).is_some() {
|
||||
return Some("This block already has a visibility parameter.");
|
||||
}
|
||||
if !document
|
||||
.block_records
|
||||
.iter()
|
||||
.any(|record| record.handle == definition)
|
||||
{
|
||||
return Some("The block definition is no longer available.");
|
||||
}
|
||||
if let Some(handle) = document.extension_dictionary_handle(definition) {
|
||||
match document.objects.get(&handle) {
|
||||
Some(ObjectType::Dictionary(dictionary)) => {
|
||||
if dictionary.get("ACAD_ENHANCEDBLOCK").is_some() {
|
||||
return Some(
|
||||
"Adding Visibility to an existing authoring graph is not available yet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => return Some("The block extension dictionary is unavailable."),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Make block definition `definition` dynamic with a visibility parameter
|
||||
/// whose grip sits at `location`, one state showing every object. Returns
|
||||
/// the parameter, or the one the block has already.
|
||||
@@ -241,6 +269,13 @@ pub fn create(document: &mut CadDocument, definition: Handle, location: Vector3)
|
||||
if let Some(existing) = parameter(document, definition) {
|
||||
return Some(existing.handle);
|
||||
}
|
||||
if placement_error(document, definition).is_some()
|
||||
|| !location.x.is_finite()
|
||||
|| !location.y.is_finite()
|
||||
|| !location.z.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = document
|
||||
.block_records
|
||||
.iter()
|
||||
@@ -400,6 +435,46 @@ pub fn create(document: &mut CadDocument, definition: Handle, location: Vector3)
|
||||
Some(parameter)
|
||||
}
|
||||
|
||||
/// Carry the parameter and its grip through a change of definition coordinates.
|
||||
/// Expressions read UpdatedX/UpdatedY from the parameter; their cached scalar
|
||||
/// values are not geometry and must not receive the point transform.
|
||||
pub fn reframe(
|
||||
document: &mut CadDocument,
|
||||
definition: Handle,
|
||||
transform: &codec::types::Transform,
|
||||
) -> usize {
|
||||
let Some(mut parameter) = parameter(document, definition).cloned() else {
|
||||
return 0;
|
||||
};
|
||||
parameter.def_point = transform.apply(parameter.def_point);
|
||||
let graph = parameter.owner;
|
||||
let handle = parameter.handle;
|
||||
match document.objects.get_mut(&handle) {
|
||||
Some(ObjectType::BlockVisibilityParameter(stored)) => {
|
||||
stored.def_point = parameter.def_point
|
||||
}
|
||||
Some(ObjectType::DynamicBlock(object)) => {
|
||||
if let DynamicBlockData::VisibilityParameter(stored) = &mut object.data {
|
||||
stored.def_point = parameter.def_point;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
document.block_visibility_params.insert(handle, parameter);
|
||||
let mut changed = 1;
|
||||
for object in document.objects.values_mut() {
|
||||
if let ObjectType::DynamicBlock(object) = object {
|
||||
if object.owner == graph {
|
||||
if let DynamicBlockData::VisibilityGrip(grip) = &mut object.data {
|
||||
grip.location = transform.apply(grip.location);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The names of the states of `parameter`, in list order.
|
||||
pub fn state_names(document: &CadDocument, parameter: Handle) -> Vec<String> {
|
||||
match document.objects.get(¶meter) {
|
||||
@@ -765,3 +840,97 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placement_fixtures {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires OCS_DYNAMIC_BLOCK_FIXTURES with local DWG fixtures"]
|
||||
fn visibility_parameter_placement_fixture_roundtrips() {
|
||||
let directory = std::env::var_os("OCS_DYNAMIC_BLOCK_FIXTURES").expect("fixture directory");
|
||||
let mut paths = std::fs::read_dir(directory)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("dwg"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
assert!(!paths.is_empty(), "no DWG fixtures found");
|
||||
for path in paths {
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let digest = Sha256::digest(&original);
|
||||
let filename = path.file_name().unwrap().to_string_lossy();
|
||||
let document = crate::io::load_bytes(&filename, original).expect(&filename);
|
||||
let before = document.clone();
|
||||
let mut scene = Scene::new();
|
||||
scene.document = document;
|
||||
let protected = scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.map(|record| record.handle)
|
||||
.filter(|definition| {
|
||||
parameter(&scene.document, *definition).is_none()
|
||||
&& placement_error(&scene.document, *definition).is_some()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for definition in &protected {
|
||||
assert!(create(&mut scene.document, *definition, Vector3::ZERO).is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
scene.document, before,
|
||||
"{filename}: existing graphs remain untouched"
|
||||
);
|
||||
scene.define_block_raw(
|
||||
"PALETTE_VISIBILITY_TEST",
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Line(codec::entities::Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::UNIT_X,
|
||||
))],
|
||||
);
|
||||
let definition = scene
|
||||
.document
|
||||
.block_records
|
||||
.get("PALETTE_VISIBILITY_TEST")
|
||||
.unwrap()
|
||||
.handle;
|
||||
let location = Vector3::new(3.25, -4.5, 0.0);
|
||||
create(&mut scene.document, definition, location).expect(&filename);
|
||||
for (handle, object) in &before.objects {
|
||||
assert_eq!(
|
||||
scene.document.objects.get(handle),
|
||||
Some(object),
|
||||
"{filename}: preserved object {handle}"
|
||||
);
|
||||
}
|
||||
for extension in ["dwg", "dxf"] {
|
||||
let bytes =
|
||||
crate::io::save_to_bytes(&scene.document, extension, scene.document.version)
|
||||
.expect(&filename);
|
||||
let read =
|
||||
crate::io::load_bytes(&format!("placed.{extension}"), bytes).expect(&filename);
|
||||
let owner = read
|
||||
.block_records
|
||||
.get("PALETTE_VISIBILITY_TEST")
|
||||
.unwrap()
|
||||
.handle;
|
||||
assert_eq!(
|
||||
parameter(&read, owner).expect(&filename).def_point,
|
||||
location,
|
||||
"{filename}: {extension}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Sha256::digest(std::fs::read(&path).unwrap()),
|
||||
digest,
|
||||
"{filename}: source checksum"
|
||||
);
|
||||
eprintln!("{filename}: DWG/DXF placement roundtrips; {} existing graphs protected; source unchanged", protected.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -552,10 +552,12 @@ impl Scene {
|
||||
for handle in changed.iter().copied() {
|
||||
let _ = self.sync_displayed_annotation_context(handle);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
let parameter_changes =
|
||||
super::block_visibility::reframe(&mut self.document, block_record, local_from_old);
|
||||
if !changed.is_empty() || parameter_changes > 0 {
|
||||
self.rebuild_derived_caches();
|
||||
}
|
||||
changed.len()
|
||||
changed.len() + parameter_changes
|
||||
}
|
||||
|
||||
/// Give a freshly-cloned entity brand-new handles for every *inline*
|
||||
|
||||
@@ -977,6 +977,7 @@ pub fn selection_overlay<'a>(
|
||||
constraint_glyph_selected: Arc<[bool]>,
|
||||
constraint_glyph_tooltip: Option<String>,
|
||||
constraint_cursor_badge: Option<String>,
|
||||
authoring_visibility: Option<(Point, String, iced::Rectangle)>,
|
||||
) -> Element<'a, Message> {
|
||||
canvas(SelectionCanvas {
|
||||
selection,
|
||||
@@ -1005,6 +1006,7 @@ pub fn selection_overlay<'a>(
|
||||
constraint_glyph_selected,
|
||||
constraint_glyph_tooltip,
|
||||
constraint_cursor_badge,
|
||||
authoring_visibility,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -1081,6 +1083,7 @@ struct SelectionCanvas {
|
||||
/// Symbol shown beside the cursor while it targets an entity that already
|
||||
/// participates in an enabled geometric constraint.
|
||||
constraint_cursor_badge: Option<String>,
|
||||
authoring_visibility: Option<(Point, String, iced::Rectangle)>,
|
||||
}
|
||||
|
||||
/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the
|
||||
@@ -2303,6 +2306,25 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
frame.stroke(&b1, stroke.clone());
|
||||
frame.stroke(&b2, stroke);
|
||||
}
|
||||
if let Some((point, label, clip)) = &self.authoring_visibility {
|
||||
frame.with_clip(*clip, |frame| {
|
||||
let color = theme.palette().primary.base.color;
|
||||
let triangle = canvas::Path::new(|builder| {
|
||||
builder.move_to(Point::new(point.x - 5.0, point.y - 3.0));
|
||||
builder.line_to(Point::new(point.x + 5.0, point.y - 3.0));
|
||||
builder.line_to(Point::new(point.x, point.y + 5.0));
|
||||
builder.close();
|
||||
});
|
||||
frame.fill(&triangle, color);
|
||||
frame.fill_text(canvas::Text {
|
||||
content: label.clone(),
|
||||
position: Point::new(point.x + 10.0, point.y - 9.0),
|
||||
color,
|
||||
size: iced::Pixels(13.0),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
}
|
||||
// Draw + hover + tooltip all read the memoised entries' precomputed
|
||||
// `top_left`/`size` (one shared `hovered` lookup below) — no offset
|
||||
// recompute, no label clones on this path.
|
||||
|
||||
@@ -188,7 +188,7 @@ const PARAMETER_SETS: &[(&str, &[u8])] = &[
|
||||
];
|
||||
|
||||
/// Catalog rows remain inert until their authoring commands exist.
|
||||
fn catalog(tab: Tab) -> Element<'static, Message> {
|
||||
fn catalog(tab: Tab, visibility_error: Option<&'static str>) -> Element<'static, Message> {
|
||||
let (entries, unavailable) = match tab {
|
||||
Tab::Parameters => (
|
||||
PARAMETERS,
|
||||
@@ -202,7 +202,13 @@ fn catalog(tab: Tab) -> Element<'static, Message> {
|
||||
Tab::Constraints => return constraints(),
|
||||
};
|
||||
column(entries.iter().map(|(label, icon)| {
|
||||
let base = icons::semantic_disabled(icon, 28.0);
|
||||
let visibility = tab == Tab::Parameters && *label == "Visibility";
|
||||
let enabled = visibility && visibility_error.is_none();
|
||||
let base = if enabled {
|
||||
icons::semantic(icon, 28.0)
|
||||
} else {
|
||||
icons::semantic_disabled(icon, 28.0)
|
||||
};
|
||||
let icon = if tab != Tab::Parameters && *label != "Block Properties Table" {
|
||||
stack![
|
||||
base,
|
||||
@@ -219,7 +225,23 @@ fn catalog(tab: Tab) -> Element<'static, Message> {
|
||||
} else {
|
||||
base
|
||||
};
|
||||
tool_row(label, icon, None, &unavailable)
|
||||
let message = enabled.then(|| Message::RibbonToolClick {
|
||||
tool_id: "BPARAMETER_VISIBILITY".into(),
|
||||
event: crate::modules::ModuleEvent::Command("BPARAMETER VISIBILITY".into()),
|
||||
});
|
||||
let description = if visibility {
|
||||
crate::t!(visibility_error
|
||||
.unwrap_or("Place a visibility parameter, then use BVSTATE to manage its states."))
|
||||
.into_owned()
|
||||
} else {
|
||||
unavailable.to_string()
|
||||
};
|
||||
let entry = tool_row(label, icon, message, &description);
|
||||
if visibility {
|
||||
container(entry).id("authoring-tool-visibility").into()
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}))
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -323,7 +345,12 @@ fn constraints() -> Element<'static, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view(selected_tab: Tab, width: f32, chrome: Chrome) -> Element<'static, Message> {
|
||||
pub fn view(
|
||||
selected_tab: Tab,
|
||||
width: f32,
|
||||
chrome: Chrome,
|
||||
visibility_error: Option<&'static str>,
|
||||
) -> Element<'static, Message> {
|
||||
let tabs = column(Tab::ALL.into_iter().map(|category| {
|
||||
let label = category.label();
|
||||
let selected = category == selected_tab;
|
||||
@@ -372,7 +399,9 @@ pub fn view(selected_tab: Tab, width: f32, chrome: Chrome) -> Element<'static, M
|
||||
chrome,
|
||||
),
|
||||
row![
|
||||
scrollable(catalog(selected_tab)).width(Fill).height(Fill),
|
||||
scrollable(catalog(selected_tab, visibility_error))
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
tabs
|
||||
]
|
||||
.spacing(4)
|
||||
|
||||
Reference in new issue
Block a user