forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/block-definition-command-line
# Conflicts: # src/app/commands/inquiry.rs # src/modules/draw/modify/block_edit.rs
This commit is contained in:
commit
99e30f1548
33 files changed
+1660
-199
No files matched your search
@@ -700,6 +700,9 @@ impl OpenCADStudio {
|
||||
};
|
||||
match req["op"].as_str().unwrap_or("") {
|
||||
"new" => {
|
||||
if self.tabs[self.active_tab].block_test.is_some() {
|
||||
return err("Close Test Block before replacing the drawing.");
|
||||
}
|
||||
if self.tabs[self.active_tab].block_edit_history.is_some() {
|
||||
return err("Close the block editor before replacing the drawing.");
|
||||
}
|
||||
@@ -726,6 +729,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"open" => {
|
||||
if self.tabs[self.active_tab].block_test.is_some() {
|
||||
return err("Close Test Block before replacing the drawing.");
|
||||
}
|
||||
if self.tabs[self.active_tab].block_edit_history.is_some() {
|
||||
return err("Close the block editor before replacing the drawing.");
|
||||
}
|
||||
@@ -792,6 +798,7 @@ impl OpenCADStudio {
|
||||
if self.command_line.error_revision != error_revision {
|
||||
return err(self.command_line.last_error.clone().unwrap_or_default());
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let after = self.tabs[i].scene.document.entities().count();
|
||||
// A command can finish with an interactive surface still open:
|
||||
// the in-place text editor (the `TEXT` content step), the MTEXT
|
||||
@@ -996,6 +1003,9 @@ impl OpenCADStudio {
|
||||
json!({ "ok": true, "selected": self.tabs[i].scene.selected_entities().len() })
|
||||
}
|
||||
"save" => {
|
||||
if self.tabs[self.active_tab].block_test.is_some() {
|
||||
return err("Test Block is temporary and cannot be saved.");
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let path = req["path"]
|
||||
.as_str()
|
||||
@@ -1533,6 +1543,11 @@ impl OpenCADStudio {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn save_verified_request(&mut self, req: &Value) -> Result<Value, Value> {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].block_test.is_some() {
|
||||
return Err(
|
||||
json!({"ok":false,"code":"temporary_document","error":"Test Block is temporary and cannot be saved."}),
|
||||
);
|
||||
}
|
||||
let Some(raw_path) = req["path"].as_str() else {
|
||||
return Err(
|
||||
json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}),
|
||||
|
||||
+120
-13
@@ -27,6 +27,7 @@ pub(super) struct BlockEditHistory {
|
||||
pub(super) enum BlockEditContinuation {
|
||||
Close,
|
||||
Switch(String),
|
||||
Create(String),
|
||||
Layout(String),
|
||||
Tab(u64),
|
||||
Quit,
|
||||
@@ -98,7 +99,7 @@ impl OpenCADStudio {
|
||||
});
|
||||
}
|
||||
|
||||
fn block_edit_delta(&mut self, i: usize) -> Option<DeltaSnapshot> {
|
||||
fn block_edit_delta(&mut self, i: usize, for_save: bool) -> Option<DeltaSnapshot> {
|
||||
self.reconcile_block_edit_membership(i);
|
||||
self.finish_pending_history(i);
|
||||
let current_structure = self.tabs[i].scene.document.snapshot_structure();
|
||||
@@ -106,12 +107,19 @@ impl OpenCADStudio {
|
||||
let history = tab.block_edit_history.as_ref()?;
|
||||
let checkpoint = &history.checkpoint;
|
||||
let session = tab.active_block_edit_session()?;
|
||||
let before: FxHashMap<_, _> = checkpoint
|
||||
.document
|
||||
let before_doc = if for_save {
|
||||
session
|
||||
.unpersisted_pre_document
|
||||
.as_ref()
|
||||
.unwrap_or(&checkpoint.document)
|
||||
} else {
|
||||
&checkpoint.document
|
||||
};
|
||||
let before: FxHashMap<_, _> = before_doc
|
||||
.entities()
|
||||
.map(|e| {
|
||||
let h = e.common().handle;
|
||||
(h, checkpoint.document.get_entity_arc(h).unwrap())
|
||||
(h, before_doc.get_entity_arc(h).unwrap())
|
||||
})
|
||||
.collect();
|
||||
let after: FxHashMap<_, _> = tab
|
||||
@@ -134,7 +142,7 @@ impl OpenCADStudio {
|
||||
})
|
||||
.collect();
|
||||
entities.sort_by_key(|(handle, _, _)| handle.value());
|
||||
let mut before_structure = checkpoint.document.clone();
|
||||
let mut before_structure = before_doc.clone();
|
||||
before_structure = before_structure.snapshot_structure();
|
||||
let mut after_structure = current_structure;
|
||||
// The editor temporarily uses Model rendering. Drawing history must
|
||||
@@ -221,7 +229,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn block_edit_dirty(&mut self, i: usize) -> bool {
|
||||
self.block_edit_delta(i).is_some()
|
||||
self.block_edit_delta(i, false).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn validate_block_save_as_name(
|
||||
@@ -245,6 +253,88 @@ impl OpenCADStudio {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub(super) fn open_bedit_dialog(&mut self, i: usize) {
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
self.command_line.push_error(
|
||||
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_names =
|
||||
crate::scene::block_service::bedit_candidate_block_names(&self.tabs[i].scene.document);
|
||||
|
||||
let initial_name = if let Some(session) = self.tabs[i].active_block_edit_session() {
|
||||
session.block_name.clone()
|
||||
} else if let Some(first) = existing_names.first() {
|
||||
first.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let preview = if !initial_name.is_empty() {
|
||||
self.tabs[i].scene.block_preview_wires(&initial_name)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let description = if !initial_name.is_empty() {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&initial_name)
|
||||
.map(|b| b.description.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let error = if initial_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
crate::scene::block_service::validate_block_name(&initial_name)
|
||||
.err()
|
||||
.map(|e| e.to_string())
|
||||
};
|
||||
|
||||
self.block_edit_picker = Some(crate::ui::window::block_edit_picker::State {
|
||||
document_id: self.tabs[i].id,
|
||||
name: initial_name,
|
||||
existing_names,
|
||||
preview,
|
||||
description,
|
||||
error,
|
||||
});
|
||||
self.active_modal = Some(ModalKind::BlockEditPicker);
|
||||
self.reset_modal_geometry();
|
||||
}
|
||||
|
||||
pub(super) fn apply_bedit_dialog(&mut self) -> Task<Message> {
|
||||
let Some(state) = self.block_edit_picker.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
let id = state.document_id;
|
||||
let name = state.name.trim().to_string();
|
||||
if name.is_empty() || state.error.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(i) = self.tabs.iter().position(|tab| tab.id == id) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.active_tab = i;
|
||||
self.block_edit_picker = None;
|
||||
self.active_modal = None;
|
||||
self.reset_modal_geometry();
|
||||
|
||||
if let Some(record) = self.tabs[i].scene.document.block_records.get(&name) {
|
||||
let real_name = record.name.clone();
|
||||
self.dispatch_command(&format!("BEDIT_OPEN:{real_name}"))
|
||||
} else {
|
||||
self.dispatch_command(&format!("BEDIT_NEW:{name}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_block_save_as_dialog(&mut self, i: usize) {
|
||||
let Some(session) = self.tabs[i].active_block_edit_session() else {
|
||||
self.command_line
|
||||
@@ -385,7 +475,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.named_parameters = parameters;
|
||||
self.tabs[i].active_layer = active_layer;
|
||||
let mut delta = self
|
||||
.block_edit_delta(i)
|
||||
.block_edit_delta(i, true)
|
||||
.expect("a new definition changes the document");
|
||||
delta.label = format!("Save block as {name}");
|
||||
let mut history = self.tabs[i].block_edit_history.take().unwrap();
|
||||
@@ -398,6 +488,7 @@ impl OpenCADStudio {
|
||||
debug_assert_eq!(session.br_handle, source_handle);
|
||||
session.br_handle = copied.record;
|
||||
session.block_name = name.clone();
|
||||
session.unpersisted_pre_document = None;
|
||||
self.tabs[i].scene.block_edit_block = Some(copied.record);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.tabs[i].active_grip = None;
|
||||
@@ -460,7 +551,7 @@ impl OpenCADStudio {
|
||||
definition,
|
||||
);
|
||||
self.tabs[i].scene.visibility_states.remove(&definition);
|
||||
if let Some(delta) = self.block_edit_delta(i) {
|
||||
if let Some(delta) = self.block_edit_delta(i, true) {
|
||||
let mut history = self.tabs[i].block_edit_history.take().unwrap();
|
||||
std::mem::swap(&mut self.tabs[i].history, &mut history.main);
|
||||
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
|
||||
@@ -470,6 +561,9 @@ impl OpenCADStudio {
|
||||
self.tabs[i].block_edit_history = Some(history);
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
}
|
||||
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
|
||||
session.unpersisted_pre_document = None;
|
||||
}
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Block {} saved.", name).as_ref());
|
||||
true
|
||||
@@ -482,7 +576,11 @@ impl OpenCADStudio {
|
||||
};
|
||||
if let Some(history) = self.tabs[i].block_edit_history.take() {
|
||||
if discard {
|
||||
self.tabs[i].scene.document = history.checkpoint.document;
|
||||
if let Some(pre_doc) = session.unpersisted_pre_document {
|
||||
self.tabs[i].scene.document = pre_doc;
|
||||
} else {
|
||||
self.tabs[i].scene.document = history.checkpoint.document;
|
||||
}
|
||||
self.tabs[i].scene.parametric_constraints = history.checkpoint.constraints;
|
||||
self.tabs[i].scene.named_parameters = history.checkpoint.parameters;
|
||||
self.tabs[i].active_layer = history.checkpoint.active_layer;
|
||||
@@ -491,9 +589,6 @@ impl OpenCADStudio {
|
||||
self.tabs[i].history = history.main;
|
||||
self.tabs[i].scene.object_isolation = history.return_isolation;
|
||||
}
|
||||
if discard && session.created_for_edit {
|
||||
crate::scene::block_service::erase(&mut self.tabs[i].scene, &session.block_name);
|
||||
}
|
||||
self.tabs[i].active_block_edit = None;
|
||||
self.restore_after_block_edit_close(
|
||||
i,
|
||||
@@ -502,6 +597,7 @@ impl OpenCADStudio {
|
||||
&session.return_camera,
|
||||
);
|
||||
self.cancel_attr_editor();
|
||||
self.discard_block_tests(self.tabs[i].id);
|
||||
}
|
||||
|
||||
pub(super) fn request_block_edit_close(
|
||||
@@ -509,6 +605,7 @@ impl OpenCADStudio {
|
||||
i: usize,
|
||||
continuation: BlockEditContinuation,
|
||||
) -> Task<Message> {
|
||||
let id = self.tabs[i].id;
|
||||
if self.tabs[i].active_block_edit_session().is_some() {
|
||||
if self.block_edit_dirty(i) {
|
||||
self.active_tab = i;
|
||||
@@ -518,6 +615,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.close_block_edit(i, true);
|
||||
}
|
||||
let i = self.tabs.iter().position(|tab| tab.id == id).unwrap();
|
||||
self.continue_after_block_edit(continuation, i)
|
||||
}
|
||||
|
||||
@@ -532,6 +630,10 @@ impl OpenCADStudio {
|
||||
self.active_tab = i;
|
||||
self.dispatch_command(&format!("BEDIT_OPEN:{name}"))
|
||||
}
|
||||
BlockEditContinuation::Create(name) => {
|
||||
self.active_tab = i;
|
||||
self.dispatch_command(&format!("BEDIT_NEW:{name}"))
|
||||
}
|
||||
BlockEditContinuation::Layout(name) => {
|
||||
self.active_tab = i;
|
||||
self.on_layout_switch(name)
|
||||
@@ -567,6 +669,7 @@ impl OpenCADStudio {
|
||||
self.pending_block_close = None;
|
||||
self.active_modal = None;
|
||||
self.close_block_edit(i, !save);
|
||||
let i = self.tabs.iter().position(|tab| tab.id == id).unwrap();
|
||||
self.continue_after_block_edit(continuation, i)
|
||||
}
|
||||
|
||||
@@ -586,7 +689,11 @@ impl OpenCADStudio {
|
||||
{
|
||||
return self.request_block_edit_close(i, BlockEditContinuation::Quit);
|
||||
}
|
||||
if self.tabs.iter().any(|tab| tab.dirty) {
|
||||
if self
|
||||
.tabs
|
||||
.iter()
|
||||
.any(|tab| tab.dirty && tab.block_test.is_none())
|
||||
{
|
||||
self.pending_close = Some(super::PendingClose::Quit);
|
||||
self.open_unsaved_dialog_window()
|
||||
} else {
|
||||
|
||||
@@ -56,7 +56,9 @@ fn main_depth(app: &OpenCADStudio) -> usize {
|
||||
|
||||
#[test]
|
||||
fn local_undo_preserves_imported_native_networks_when_constraints_are_unchanged() {
|
||||
use codec::objects::{AssocNetwork, AssociativeData, AssociativeObject, Dictionary, ObjectType};
|
||||
use codec::objects::{
|
||||
AssocNetwork, AssociativeData, AssociativeObject, Dictionary, ObjectType,
|
||||
};
|
||||
let (mut app, reference) = fixture();
|
||||
let i = app.active_tab;
|
||||
let doc = &mut app.tabs[i].scene.document;
|
||||
@@ -919,3 +921,234 @@ fn close_all_resumes_after_discarding_a_dirty_block() {
|
||||
.any(|tab| tab.id == first || tab.id == second));
|
||||
assert!(app.pending_tab_closes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_no_selection_opens_edit_block_definition_dialog() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.define_block_raw(
|
||||
"DOOR",
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::UNIT_X,
|
||||
))],
|
||||
);
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get_mut("DOOR")
|
||||
.unwrap()
|
||||
.description = "Wooden door".to_string();
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
|
||||
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
|
||||
let picker = app.block_edit_picker.as_ref().expect("picker state set");
|
||||
assert_eq!(picker.existing_names, vec!["DOOR", "WINDOW"]);
|
||||
assert_eq!(picker.name, "DOOR");
|
||||
assert_eq!(picker.description, "Wooden door");
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_dialog_select_existing_block() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.define_block_raw("DOOR", Vector3::ZERO, vec![]);
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
|
||||
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerSelect("WINDOW".to_string()));
|
||||
let picker = app.block_edit_picker.as_ref().unwrap();
|
||||
assert_eq!(picker.name, "WINDOW");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert_eq!(app.active_modal, None);
|
||||
assert!(app.tabs[tab].active_block_edit.is_some());
|
||||
assert_eq!(
|
||||
app.tabs[tab]
|
||||
.active_block_edit_session()
|
||||
.unwrap()
|
||||
.block_name,
|
||||
"WINDOW"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_dialog_name_validation() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerName("INVALID/NAME".to_string()));
|
||||
assert!(app.block_edit_picker.as_ref().unwrap().error.is_some());
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerName("".to_string()));
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
|
||||
let _ = app.update(Message::CloseModal);
|
||||
assert_eq!(app.active_modal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_dialog_create_new_block_closes_without_saving() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW".to_string()));
|
||||
assert!(app.block_edit_picker.as_ref().unwrap().error.is_none());
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert_eq!(app.active_modal, None);
|
||||
assert!(app.tabs[tab].active_block_edit.is_some());
|
||||
assert_eq!(
|
||||
app.tabs[tab]
|
||||
.active_block_edit_session()
|
||||
.unwrap()
|
||||
.block_name,
|
||||
"BRAND_NEW"
|
||||
);
|
||||
assert!(!app.block_edit_dirty(tab));
|
||||
|
||||
let _ = app.dispatch_command("BCLOSE");
|
||||
assert_eq!(app.active_modal, None);
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("BRAND_NEW")
|
||||
.is_none(),
|
||||
"unpersisted new block closed without saving must not remain in drawing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_dialog_create_new_block_discard_dirty_leaves_no_block() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW2".to_string()));
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert!(app.tabs[tab].active_block_edit.is_some());
|
||||
|
||||
circle(&mut app, 1.0);
|
||||
assert!(app.block_edit_dirty(tab));
|
||||
|
||||
let _ = app.dispatch_command("BCLOSE");
|
||||
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
|
||||
|
||||
let _ = app.update(Message::BlockEditChangesDiscard);
|
||||
assert_eq!(app.active_modal, None);
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("BRAND_NEW2")
|
||||
.is_none(),
|
||||
"discarded new block must not remain in drawing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_dialog_create_new_block_save_persists_and_undoes() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
|
||||
let _ = app.update(Message::BlockEditPickerName("SAVED_BLOCK".to_string()));
|
||||
let _ = app.update(Message::BlockEditPickerApply);
|
||||
assert!(app.tabs[tab].active_block_edit.is_some());
|
||||
|
||||
circle(&mut app, 1.0);
|
||||
let _ = app.dispatch_command("BSAVE");
|
||||
assert!(app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("SAVED_BLOCK")
|
||||
.is_some());
|
||||
|
||||
let _ = app.dispatch_command("BCLOSE");
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("SAVED_BLOCK")
|
||||
.is_some());
|
||||
|
||||
app.undo_steps(1);
|
||||
assert!(
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("SAVED_BLOCK")
|
||||
.is_none(),
|
||||
"undo must revert block creation"
|
||||
);
|
||||
|
||||
app.redo_steps(1);
|
||||
assert!(
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("SAVED_BLOCK")
|
||||
.is_some(),
|
||||
"redo must restore block creation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_with_preselected_insert_opens_directly() {
|
||||
let (mut app, reference) = fixture();
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.deselect_all();
|
||||
app.tabs[tab].scene.select_entity(reference, true);
|
||||
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
assert_eq!(app.active_modal, None);
|
||||
assert!(app.tabs[tab].active_block_edit.is_some());
|
||||
assert_eq!(
|
||||
app.tabs[tab]
|
||||
.active_block_edit_session()
|
||||
.unwrap()
|
||||
.block_name,
|
||||
"EDIT_ME"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::{document::DocumentTab, Message, OpenCADStudio};
|
||||
use codec::entities::{AttributeEntity, Entity, Insert};
|
||||
use codec::EntityType;
|
||||
use iced::Task;
|
||||
|
||||
pub(super) struct BlockTestSession {
|
||||
pub source_id: u64,
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn refuse_block_test_save(&mut self, i: usize) -> bool {
|
||||
if self.tabs[i].block_test.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.command_line.push_error(
|
||||
crate::t!("Test Block is temporary. Close it and save from the Block Editor.").as_ref(),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn open_block_test(&mut self, i: usize) -> Task<Message> {
|
||||
let source_id = self.tabs[i]
|
||||
.block_test
|
||||
.as_ref()
|
||||
.map_or(self.tabs[i].id, |test| test.source_id);
|
||||
let Some(i) = self.tabs.iter().position(|tab| tab.id == source_id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(session) = self.tabs[i].active_block_edit_session() else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("No block editor is open.").as_ref());
|
||||
return Task::none();
|
||||
};
|
||||
let name = session.block_name.clone();
|
||||
let definition = session.br_handle;
|
||||
self.finish_pending_history(i);
|
||||
let mut document = self.tabs[i].scene.document.clone();
|
||||
// Keep definitions and their native relationships in the clone. Only
|
||||
// drawing-space contents are replaced by the temporary reference.
|
||||
let spaces: Vec<_> = document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|record| record.is_model_space() || record.is_paper_space())
|
||||
.map(|record| record.handle)
|
||||
.collect();
|
||||
let removed: Vec<_> = document
|
||||
.entities()
|
||||
.filter(|entity| {
|
||||
spaces.contains(&entity.common().owner_handle)
|
||||
&& !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_))
|
||||
})
|
||||
.map(|entity| entity.common().handle)
|
||||
.collect();
|
||||
for handle in removed {
|
||||
document.remove_entity(handle);
|
||||
}
|
||||
for record in document
|
||||
.block_records
|
||||
.iter_mut()
|
||||
.filter(|record| spaces.contains(&record.handle))
|
||||
{
|
||||
record.entity_handles.retain(|handle| {
|
||||
*handle == record.block_entity_handle || *handle == record.block_end_handle
|
||||
});
|
||||
record.insert_handles.clear();
|
||||
record.insert_count_bytes.clear();
|
||||
}
|
||||
document.header.show_model_space = true;
|
||||
crate::scene::block_visibility::finish_editing(&mut document, definition);
|
||||
let record = document.block_records.get(&name).unwrap();
|
||||
let base = record.base_point;
|
||||
let mut insert = Insert::new(&name, codec::types::Vector3::ZERO);
|
||||
insert.common.layer = self.tabs[i].active_layer.clone();
|
||||
insert.common.handle = document.allocate_handle();
|
||||
insert.common.owner_handle = document.header.model_space_block_handle;
|
||||
let transform = crate::scene::render_graph::insert_transform_with_base(&insert, base);
|
||||
for entity in document.entities_in_block(&name) {
|
||||
if let EntityType::AttributeDefinition(definition) = entity {
|
||||
if !definition.flags.constant {
|
||||
let mut attribute = AttributeEntity::from_definition(definition, None);
|
||||
attribute.attdef_handle = definition.common.handle;
|
||||
attribute.apply_transform(&transform);
|
||||
insert.attributes.push(attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
for attribute in &mut insert.attributes {
|
||||
attribute.common.handle = document.allocate_handle();
|
||||
attribute.common.owner_handle = insert.common.handle;
|
||||
if let Some(text) = &mut attribute.embedded_mtext {
|
||||
text.common.handle = document.allocate_handle();
|
||||
text.common.owner_handle = attribute.common.handle;
|
||||
text.common.raw_record = None;
|
||||
text.common.xdictionary_handle = None;
|
||||
text.common.reactors.clear();
|
||||
}
|
||||
}
|
||||
if !insert.attributes.is_empty() {
|
||||
insert.seqend_handle = Some(document.allocate_handle());
|
||||
}
|
||||
if let Err(error) = document.add_entity(EntityType::Insert(insert)) {
|
||||
self.command_line.push_error(&error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
let mut tab = DocumentTab::new_drawing(0);
|
||||
tab.tab_title = crate::tf!("Test Block Window - {}", name).into_owned();
|
||||
tab.block_test = Some(BlockTestSession { source_id });
|
||||
tab.active_layer = self.tabs[i].active_layer.clone();
|
||||
tab.scene.document = document;
|
||||
tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone();
|
||||
tab.scene.parametric_constraints = self.tabs[i]
|
||||
.scene
|
||||
.parametric_constraints
|
||||
.iter()
|
||||
.filter(|set| {
|
||||
matches!(
|
||||
set.scope,
|
||||
crate::scene::parametric_constraints::ParametricScope::Block(_)
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
tab.scene.material_base_dir = self.tabs[i].scene.material_base_dir.clone();
|
||||
tab.scene.sync_annotation_scale_from_header();
|
||||
tab.scene.rebuild_derived_caches();
|
||||
tab.scene.set_current_layout("Model".into());
|
||||
self.discard_block_tests(source_id);
|
||||
self.visibility_popup = None;
|
||||
self.grip_popup = None;
|
||||
self.grip_hover = None;
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_styles();
|
||||
self.sync_ribbon_from_selection();
|
||||
self.refresh_block_palette();
|
||||
self.refresh_properties();
|
||||
self.ribbon.set_block_editing(false);
|
||||
self.ribbon.set_block_testing(true);
|
||||
self.dispatch_command("ZOOM EXTENTS")
|
||||
}
|
||||
|
||||
pub(super) fn close_block_test(&mut self, i: usize) -> Task<Message> {
|
||||
if self.tabs[i].block_test.is_none() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("No Test Block is open.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
self.on_tab_close(i)
|
||||
}
|
||||
|
||||
pub(super) fn discard_block_tests(&mut self, source_id: u64) {
|
||||
for index in (0..self.tabs.len()).rev() {
|
||||
if self.tabs[index]
|
||||
.block_test
|
||||
.as_ref()
|
||||
.is_some_and(|test| test.source_id == source_id)
|
||||
{
|
||||
let _ = self.on_tab_close(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use super::{Message, ModalKind, OpenCADStudio};
|
||||
use codec::entities::{Insert, Line};
|
||||
use codec::types::Vector3;
|
||||
use codec::{EntityType, Handle};
|
||||
|
||||
fn fixture() -> (OpenCADStudio, usize, Handle) {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.define_block_raw(
|
||||
"SAMPLE",
|
||||
Vector3::new(10.0, 20.0, 0.0),
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
Vector3::new(10.0, 20.0, 0.0),
|
||||
Vector3::new(15.0, 20.0, 0.0),
|
||||
))],
|
||||
);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get_mut("SAMPLE")
|
||||
.unwrap()
|
||||
.base_point = Vector3::new(10.0, 20.0, 0.0);
|
||||
let reference = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Insert(Insert::new(
|
||||
"SAMPLE",
|
||||
Vector3::new(100.0, 200.0, 0.0),
|
||||
)))
|
||||
.unwrap();
|
||||
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value()));
|
||||
(app, i, reference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_uses_unsaved_definition_and_preserves_editor_history_and_view() {
|
||||
let (mut app, source, _) = fixture();
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
app.finish_pending_history(source);
|
||||
let document = app.tabs[source].scene.document.clone();
|
||||
let depth = app.tabs[source].history.undo_stack.len();
|
||||
let camera = app.tabs[source].scene.camera.borrow().clone();
|
||||
let source_id = app.tabs[source].id;
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"BTESTBLOCK"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
let test = app.active_tab;
|
||||
assert_ne!(app.tabs[test].id, source_id);
|
||||
assert!(app.tabs[test].active_block_edit.is_none());
|
||||
assert_eq!(
|
||||
app.tabs[test].scene.document.block_records.get("SAMPLE"),
|
||||
document.block_records.get("SAMPLE")
|
||||
);
|
||||
let model = app.tabs[test]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.model_space_block_handle;
|
||||
let top: Vec<_> = app.tabs[test]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| {
|
||||
e.common().owner_handle == model
|
||||
&& !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(top.len(), 1);
|
||||
let EntityType::Insert(reference) = top[0] else {
|
||||
panic!("missing test reference")
|
||||
};
|
||||
assert_eq!(reference.insert_point, Vector3::ZERO);
|
||||
assert_eq!(reference.block_name, "SAMPLE");
|
||||
assert!(app.tabs[test].history.undo_stack.is_empty());
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1000,1000 8"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"BCLOSETESTBLOCK"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
assert_eq!(app.active_tab, source);
|
||||
assert_eq!(app.tabs[source].scene.document, document);
|
||||
assert_eq!(app.tabs[source].history.undo_stack.len(), depth);
|
||||
assert_eq!(app.tabs[source].scene.camera.borrow().target, camera.target);
|
||||
assert!(app.active_modal.is_none());
|
||||
app.undo_steps(1);
|
||||
assert_eq!(
|
||||
app.tabs[source]
|
||||
.scene
|
||||
.document
|
||||
.entities_in_block("SAMPLE")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_keytips_follow_context_and_offer_close() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let tab_tip = |app: &OpenCADStudio, target: &str| {
|
||||
app.ribbon.keytips_json(false)["tips"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tip| tip["target"] == target)
|
||||
};
|
||||
app.ribbon.show_keytips(false);
|
||||
assert!(!tab_tip(&app, "block_test"));
|
||||
let _ = app.ribbon.keytip_type(Some("H"));
|
||||
assert!(tab_tip(&app, "LINE"));
|
||||
assert!(!tab_tip(&app, "BCLOSETESTBLOCK"));
|
||||
let _ = app.ribbon.hide_keytips();
|
||||
let _ = app.open_block_test(source);
|
||||
app.ribbon.show_keytips(false);
|
||||
assert_eq!(app.ribbon.keytips_json(false)["tab"], "draw");
|
||||
assert!(!tab_tip(&app, "block_test"));
|
||||
assert!(!tab_tip(&app, "block_editor"));
|
||||
let _ = app.ribbon.keytip_type(Some("H"));
|
||||
assert!(tab_tip(&app, "LINE"));
|
||||
assert!(tab_tip(&app, "Close"));
|
||||
assert!(tab_tip(&app, "BCLOSETESTBLOCK"));
|
||||
let _ = app.ribbon.keytip_type(Some("C"));
|
||||
let crate::ui::ribbon::KeyTipStep::Run(close) = app.ribbon.keytip_type(Some("T")) else {
|
||||
panic!("close key tip did not dispatch a ribbon action");
|
||||
};
|
||||
assert!(matches!(
|
||||
&close,
|
||||
Message::RibbonToolClick { event: crate::modules::ModuleEvent::Command(command), .. }
|
||||
if command == "BCLOSETESTBLOCK"
|
||||
));
|
||||
let _ = app.ribbon.hide_keytips();
|
||||
let _ = app.update(close);
|
||||
app.ribbon.show_keytips(false);
|
||||
assert!(!tab_tip(&app, "block_test"));
|
||||
assert!(tab_tip(&app, "block_editor"));
|
||||
assert_eq!(app.ribbon.keytips_json(false)["tab"], "block_editor");
|
||||
let _ = app.ribbon.keytip_type(Some("H"));
|
||||
assert!(!tab_tip(&app, "Close"));
|
||||
assert!(!tab_tip(&app, "BCLOSETESTBLOCK"));
|
||||
let _ = app.ribbon.hide_keytips();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_close_x_discards_dirty_edits_and_repeated_test_refreshes() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let _ = app.open_block_test(source);
|
||||
let first = app.tabs[app.active_tab].id;
|
||||
let _ = app.open_block_test(app.active_tab);
|
||||
assert!(!app.tabs.iter().any(|tab| tab.id == first));
|
||||
assert_eq!(
|
||||
app.tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.block_test.is_some())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].dirty = true;
|
||||
let _ = app.on_tab_close(i);
|
||||
assert_eq!(app.active_tab, source);
|
||||
assert!(app.pending_close.is_none());
|
||||
assert!(app.active_modal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_refuses_persistence_and_replacing_the_scratch_drawing() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let _ = app.open_block_test(source);
|
||||
let test = app.active_tab;
|
||||
for command in [
|
||||
"SAVE",
|
||||
"QSAVE",
|
||||
"SAVEAS",
|
||||
"SAVEALL",
|
||||
"BSAVE",
|
||||
"BSAVEAS:OTHER",
|
||||
"BEDIT_OPEN:SAMPLE",
|
||||
"REFEDIT",
|
||||
] {
|
||||
let request = serde_json::json!({"op":"run", "cmd":command});
|
||||
assert_eq!(
|
||||
app.automation_op(&request.to_string())["ok"],
|
||||
false,
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"save","path":"blocked-test.dwg"}"#)["ok"],
|
||||
false
|
||||
);
|
||||
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], false);
|
||||
let _ = app.update(Message::SaveFile);
|
||||
let _ = app.update(Message::SaveAs);
|
||||
assert!(app.active_modal.is_none());
|
||||
assert_eq!(app.active_tab, test);
|
||||
assert!(app.tabs[test].current_path.is_none());
|
||||
assert!(app.active_save_jobs.is_empty());
|
||||
app.tabs[test].dirty = true;
|
||||
let _ = app.update(Message::AutoSave);
|
||||
assert!(app.active_save_jobs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_parent_close_cancel_keeps_test_and_discard_removes_it() {
|
||||
let (mut app, source, _) = fixture();
|
||||
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#);
|
||||
let _ = app.open_block_test(source);
|
||||
let test_id = app.tabs[app.active_tab].id;
|
||||
let _ = app.on_tab_close(source);
|
||||
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
|
||||
let _ = app.update(Message::BlockEditChangesCancel);
|
||||
assert!(app.tabs.iter().any(|tab| tab.id == test_id));
|
||||
let _ = app.on_tab_close(source);
|
||||
let _ = app.update(Message::BlockEditChangesDiscard);
|
||||
assert!(!app.tabs.iter().any(|tab| tab.id == test_id));
|
||||
assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_no_editor_is_refused() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let count = app.tabs.len();
|
||||
let _ = app.open_block_test(app.active_tab);
|
||||
assert_eq!(app.tabs.len(), count);
|
||||
assert!(app.command_line.last_error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_reordering_before_its_source_keeps_close_targets_stable() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let source_id = app.tabs[source].id;
|
||||
let _ = app.open_block_test(source);
|
||||
let test = app.active_tab;
|
||||
app.tabs.swap(source, test);
|
||||
app.active_tab = test;
|
||||
let _ = app.dispatch_command("BEDIT_DISCARD");
|
||||
assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some()));
|
||||
assert_eq!(app.tabs[app.active_tab].id, source_id);
|
||||
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
|
||||
assert_eq!(app.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_unsaved_visibility_states_work_without_changing_the_editor() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let doc = &mut app.tabs[source].scene.document;
|
||||
let owner = doc.block_records.get("SAMPLE").unwrap().handle;
|
||||
let first = doc.block_records.get("SAMPLE").unwrap().entity_handles[0];
|
||||
let mut line = Line::from_points(Vector3::new(10.0, 23.0, 0.0), Vector3::new(15.0, 23.0, 0.0));
|
||||
line.common.owner_handle = owner;
|
||||
let second = doc.add_entity(EntityType::Line(line)).unwrap();
|
||||
let parameter = crate::scene::block_visibility::create(doc, owner, Vector3::ZERO).unwrap();
|
||||
crate::scene::block_visibility::add_state(doc, parameter, "Alternative", 0).unwrap();
|
||||
crate::scene::block_visibility::set_visible(doc, parameter, 0, &[second], false);
|
||||
crate::scene::block_visibility::set_visible(doc, parameter, 1, &[first], false);
|
||||
let original = app.tabs[source].scene.document.clone();
|
||||
let _ = app.open_block_test(source);
|
||||
let test = app.active_tab;
|
||||
let doc = &app.tabs[test].scene.document;
|
||||
let model = doc.header.model_space_block_handle;
|
||||
let reference = doc
|
||||
.entities()
|
||||
.find(|e| e.common().owner_handle == model && matches!(e, EntityType::Insert(_)))
|
||||
.unwrap()
|
||||
.common()
|
||||
.handle;
|
||||
assert!(doc.dynamic_visibility_for_insert(reference).is_some());
|
||||
app.apply_visibility_state(reference, 1);
|
||||
app.finish_pending_history(test);
|
||||
let doc = &app.tabs[test].scene.document;
|
||||
let EntityType::Insert(insert) = doc.get_entity(reference).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_ne!(insert.block_name, "SAMPLE");
|
||||
let visible: Vec<_> = doc
|
||||
.entities_in_block(&insert.block_name)
|
||||
.filter(|e| !e.common().invisible)
|
||||
.collect();
|
||||
assert_eq!(visible.len(), 1);
|
||||
let EntityType::Line(line) = visible[0] else {
|
||||
panic!("expected the alternative line")
|
||||
};
|
||||
assert_eq!(line.start.y, 23.0);
|
||||
assert_eq!(app.tabs[source].scene.document, original);
|
||||
app.visibility_popup = Some(super::visibility::VisibilityPopup {
|
||||
insert_handle: reference,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
items: vec!["Alternative".into()],
|
||||
current: Some(1),
|
||||
});
|
||||
let _ = app.close_block_test(test);
|
||||
assert!(app.visibility_popup.is_none());
|
||||
assert_eq!(app.tabs[source].scene.document, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_attributes_use_defaults_and_the_stored_base_point() {
|
||||
use codec::entities::AttributeDefinition;
|
||||
let (mut app, source, _) = fixture();
|
||||
let doc = &mut app.tabs[source].scene.document;
|
||||
let owner = doc.block_records.get("SAMPLE").unwrap().handle;
|
||||
let mut attribute = AttributeDefinition::new("TAG".into(), "Value".into(), "default".into());
|
||||
attribute.insertion_point = Vector3::new(12.0, 23.0, 0.0);
|
||||
attribute.common.owner_handle = owner;
|
||||
let definition = doc
|
||||
.add_entity(EntityType::AttributeDefinition(attribute))
|
||||
.unwrap();
|
||||
let _ = app.open_block_test(source);
|
||||
let doc = &app.tabs[app.active_tab].scene.document;
|
||||
let model = doc.header.model_space_block_handle;
|
||||
let insert = doc
|
||||
.entities()
|
||||
.find_map(|entity| match entity {
|
||||
EntityType::Insert(insert) if insert.common.owner_handle == model => Some(insert),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(insert.attributes.len(), 1);
|
||||
let attribute = &insert.attributes[0];
|
||||
assert_eq!(attribute.value, "default");
|
||||
assert_eq!(attribute.insertion_point, Vector3::new(2.0, 3.0, 0.0));
|
||||
assert_eq!(attribute.attdef_handle, definition);
|
||||
assert!(!attribute.common.handle.is_null());
|
||||
assert_eq!(attribute.common.owner_handle, insert.common.handle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_test_close_others_discards_the_test_without_prompting() {
|
||||
let (mut app, source, _) = fixture();
|
||||
let _ = app.open_block_test(source);
|
||||
app.tabs[app.active_tab].dirty = true;
|
||||
let test = app.tabs[app.active_tab].id;
|
||||
let _ = app.update(Message::DocTabCloseOthers(source));
|
||||
assert!(!app.tabs.iter().any(|tab| tab.id == test));
|
||||
assert!(app.active_modal.is_none());
|
||||
assert!(app.tabs[source].active_block_edit.is_some());
|
||||
}
|
||||
@@ -38,7 +38,7 @@ impl OpenCADStudio {
|
||||
let mut saved = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for t in 0..self.tabs.len() {
|
||||
if self.tabs[t].is_start {
|
||||
if self.tabs[t].is_start || self.tabs[t].block_test.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = self.tabs[t].current_path.clone() {
|
||||
|
||||
+121
-106
@@ -458,7 +458,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
"BEDIT" => {
|
||||
use crate::modules::draw::modify::block_edit::BlockEditPickCommand;
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
self.command_line.push_error(
|
||||
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
|
||||
@@ -476,12 +475,46 @@ impl OpenCADStudio {
|
||||
return Some(Task::none());
|
||||
}
|
||||
}
|
||||
let cmd_obj = BlockEditPickCommand::new();
|
||||
self.command_line.push_info(&cmd_obj.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||
self.open_bedit_dialog(i);
|
||||
}
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("BEDIT_NEW:") => {
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
self.command_line.push_error(
|
||||
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
let raw_name = &cmd["BEDIT_NEW:".len()..];
|
||||
let block_name = match crate::scene::block_service::validate_block_name(raw_name) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
self.command_line.push_error(&e.to_string());
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
if let Some(br) = self.tabs[i].scene.document.block_records.get(&block_name) {
|
||||
let real_name = br.name.clone();
|
||||
return Some(self.dispatch_command(&format!("BEDIT_OPEN:{real_name}")));
|
||||
}
|
||||
if self.tabs[i].active_block_edit_session().is_some() {
|
||||
return Some(self.request_block_edit_close(
|
||||
i,
|
||||
crate::app::block_editor::BlockEditContinuation::Create(block_name),
|
||||
));
|
||||
}
|
||||
let pre_doc = self.tabs[i].scene.document.clone();
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.define_block_raw(&block_name, codec::types::Vector3::ZERO, vec![]);
|
||||
let Some(record) = self.tabs[i].scene.document.block_records.get(&block_name) else {
|
||||
return Some(Task::none());
|
||||
};
|
||||
let br_handle = record.handle;
|
||||
self.start_block_edit_session(i, block_name, br_handle, Some(pre_doc));
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("BEDIT_BEGIN:") || cmd.starts_with("BEDIT_OPEN:") => {
|
||||
if self.tabs[i].refedit_session.is_some() {
|
||||
self.command_line.push_error(
|
||||
@@ -489,7 +522,6 @@ impl OpenCADStudio {
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
use crate::modules::draw::modify::block_edit::BlockEditSession;
|
||||
use codec::Handle;
|
||||
|
||||
let block_name = if let Some(name) = cmd.strip_prefix("BEDIT_OPEN:") {
|
||||
@@ -512,36 +544,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
};
|
||||
|
||||
// A name that is not a block yet opens an empty definition,
|
||||
// dropped again when the editor closes without saving.
|
||||
let mut created_for_edit = false;
|
||||
if self.tabs[i].scene.document.block_records.get(&block_name).is_none()
|
||||
&& cmd.starts_with("BEDIT_OPEN:")
|
||||
&& crate::ui::window::block_definition::invalid_block_name_char(&block_name)
|
||||
.is_none()
|
||||
&& !block_name.trim().is_empty()
|
||||
&& !block_name.starts_with('*')
|
||||
{
|
||||
let options = crate::scene::CreateBlockOptions {
|
||||
name: block_name.clone(),
|
||||
handles: Vec::new(),
|
||||
base_point: glam::DVec3::ZERO,
|
||||
world_to_block: codec::types::Transform::identity(),
|
||||
block_to_world: codec::types::Transform::identity(),
|
||||
mode: crate::ui::window::block_definition::BlockObjectMode::Retain,
|
||||
annotative: false,
|
||||
match_orientation: false,
|
||||
scale_uniformly: false,
|
||||
allow_exploding: true,
|
||||
unit: self.tabs[i].scene.document.header.insertion_units,
|
||||
description: String::new(),
|
||||
hyperlink_url: String::new(),
|
||||
hyperlink_desc: String::new(),
|
||||
redefine: false,
|
||||
};
|
||||
created_for_edit =
|
||||
self.tabs[i].scene.create_block_with_options(options).is_ok();
|
||||
}
|
||||
// Resolve the block record; reject external references (xrefs).
|
||||
let (br_handle, is_xref) =
|
||||
match self.tabs[i].scene.document.block_records.get(&block_name) {
|
||||
@@ -569,82 +571,14 @@ impl OpenCADStudio {
|
||||
));
|
||||
}
|
||||
|
||||
// Capture the camera before the editor reframes it, so leaving
|
||||
// the block editor returns the view exactly where it was (#425).
|
||||
let current_camera = self.tabs[i].scene.camera.borrow().clone();
|
||||
let current_ucs = self.tabs[i].active_ucs.clone();
|
||||
let (return_layout, return_block, return_camera) =
|
||||
if let Some(parent_index) = self.tabs[i].active_block_edit {
|
||||
let parent = &mut self.tabs[i].block_edits[parent_index];
|
||||
parent.editor_camera = current_camera.clone();
|
||||
parent.editor_ucs = current_ucs;
|
||||
(
|
||||
parent.return_layout.clone(),
|
||||
Some(parent.block_name.clone()),
|
||||
parent.return_camera.clone(),
|
||||
)
|
||||
} else {
|
||||
self.tabs[i].scene.sync_camera_to_document();
|
||||
(
|
||||
self.tabs[i].scene.current_layout.clone(),
|
||||
None,
|
||||
current_camera.clone(),
|
||||
)
|
||||
};
|
||||
// A block editor renders in model style; switch to Model first so
|
||||
// the paper-space code paths stay off, then scope to the block.
|
||||
if self.tabs[i].scene.current_layout != "Model" {
|
||||
self.tabs[i].scene.set_current_layout("Model".to_string());
|
||||
}
|
||||
self.tabs[i].scene.block_edit_block = Some(br_handle);
|
||||
self.tabs[i].block_edits.push(BlockEditSession {
|
||||
block_name: block_name.clone(),
|
||||
br_handle,
|
||||
return_layout,
|
||||
return_block,
|
||||
return_camera,
|
||||
editor_camera: current_camera,
|
||||
editor_ucs: None,
|
||||
created_for_edit,
|
||||
});
|
||||
self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1);
|
||||
// Every BEDIT tab owns an isolated local UCS. Never inherit or
|
||||
// overwrite the drawing's model-space UCS.
|
||||
self.tabs[i].refresh_active_ucs();
|
||||
|
||||
self.tabs[i].scene.deselect_all();
|
||||
// The first click of a double-click selects the model-space
|
||||
// INSERT and populates the cached grip overlay. Clearing only
|
||||
// Scene::selected leaves that INSERT's grips active inside the
|
||||
// block editor, where dragging one moves the outer reference
|
||||
// instead of block-local geometry (#517).
|
||||
self.tabs[i].active_grip = None;
|
||||
self.grip_hover = None;
|
||||
self.grip_popup = None;
|
||||
self.visibility_popup = None;
|
||||
// Entering BEDIT changes which block is assembled, not the
|
||||
// cached geometry of that block's entities.
|
||||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||||
// Frame the camera on the block's own geometry (block-local, near
|
||||
// origin) — fit_all() goes through current_layout_block_handle so
|
||||
// it already scopes to the edited block. Without this the view
|
||||
// stays wherever model/paper space was. (#261)
|
||||
self.tabs[i].scene.fit_all();
|
||||
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
|
||||
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
|
||||
session.editor_camera = editor_camera;
|
||||
}
|
||||
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
|
||||
self.refresh_properties();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.begin_block_edit_history(i);
|
||||
self.command_line
|
||||
.push_info(crate::tf!("Block Editor: editing {}", block_name).as_ref());
|
||||
self.start_block_edit_session(i, block_name, br_handle, None);
|
||||
}
|
||||
|
||||
"BSAVE" => {
|
||||
self.save_block_edit(i);
|
||||
}
|
||||
"BTESTBLOCK" => return Some(self.open_block_test(i)),
|
||||
"BCLOSETESTBLOCK" => return Some(self.close_block_test(i)),
|
||||
"BSAVEAS" => {
|
||||
self.open_block_save_as_dialog(i);
|
||||
}
|
||||
@@ -1056,6 +990,87 @@ impl OpenCADStudio {
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
|
||||
pub(in crate::app) fn start_block_edit_session(
|
||||
&mut self,
|
||||
i: usize,
|
||||
block_name: String,
|
||||
br_handle: codec::Handle,
|
||||
unpersisted_pre_document: Option<codec::CadDocument>,
|
||||
) {
|
||||
use crate::modules::draw::modify::block_edit::BlockEditSession;
|
||||
// Capture the camera before the editor reframes it, so leaving
|
||||
// the block editor returns the view exactly where it was (#425).
|
||||
let current_camera = self.tabs[i].scene.camera.borrow().clone();
|
||||
let current_ucs = self.tabs[i].active_ucs.clone();
|
||||
let (return_layout, return_block, return_camera) =
|
||||
if let Some(parent_index) = self.tabs[i].active_block_edit {
|
||||
let parent = &mut self.tabs[i].block_edits[parent_index];
|
||||
parent.editor_camera = current_camera.clone();
|
||||
parent.editor_ucs = current_ucs;
|
||||
(
|
||||
parent.return_layout.clone(),
|
||||
Some(parent.block_name.clone()),
|
||||
parent.return_camera.clone(),
|
||||
)
|
||||
} else {
|
||||
self.tabs[i].scene.sync_camera_to_document();
|
||||
(
|
||||
self.tabs[i].scene.current_layout.clone(),
|
||||
None,
|
||||
current_camera.clone(),
|
||||
)
|
||||
};
|
||||
// A block editor renders in model style; switch to Model first so
|
||||
// the paper-space code paths stay off, then scope to the block.
|
||||
if self.tabs[i].scene.current_layout != "Model" {
|
||||
self.tabs[i].scene.set_current_layout("Model".to_string());
|
||||
}
|
||||
self.tabs[i].scene.block_edit_block = Some(br_handle);
|
||||
self.tabs[i].block_edits.push(BlockEditSession {
|
||||
block_name: block_name.clone(),
|
||||
br_handle,
|
||||
return_layout,
|
||||
return_block,
|
||||
return_camera,
|
||||
editor_camera: current_camera,
|
||||
editor_ucs: None,
|
||||
unpersisted_pre_document,
|
||||
});
|
||||
self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1);
|
||||
// Every BEDIT tab owns an isolated local UCS. Never inherit or
|
||||
// overwrite the drawing's model-space UCS.
|
||||
self.tabs[i].refresh_active_ucs();
|
||||
|
||||
self.tabs[i].scene.deselect_all();
|
||||
// The first click of a double-click selects the model-space
|
||||
// INSERT and populates the cached grip overlay. Clearing only
|
||||
// Scene::selected leaves that INSERT's grips active inside the
|
||||
// block editor, where dragging one moves the outer reference
|
||||
// instead of block-local geometry (#517).
|
||||
self.tabs[i].active_grip = None;
|
||||
self.grip_hover = None;
|
||||
self.grip_popup = None;
|
||||
self.visibility_popup = None;
|
||||
// Entering BEDIT changes which block is assembled, not the
|
||||
// cached geometry of that block's entities.
|
||||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||||
// Frame the camera on the block's own geometry (block-local, near
|
||||
// origin) — fit_all() goes through current_layout_block_handle so
|
||||
// it already scopes to the edited block. Without this the view
|
||||
// stays wherever model/paper space was. (#261)
|
||||
self.tabs[i].scene.fit_all();
|
||||
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
|
||||
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
|
||||
session.editor_camera = editor_camera;
|
||||
}
|
||||
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
|
||||
self.refresh_properties();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.begin_block_edit_history(i);
|
||||
self.command_line
|
||||
.push_info(crate::tf!("Block Editor: editing {}", block_name).as_ref());
|
||||
}
|
||||
|
||||
pub(in crate::app) fn restore_after_block_edit_close(
|
||||
&mut self,
|
||||
i: usize,
|
||||
|
||||
@@ -150,6 +150,33 @@ impl OpenCADStudio {
|
||||
};
|
||||
let resolved = self.resolve_alias(cmd);
|
||||
let cmd = resolved.as_deref().unwrap_or(cmd);
|
||||
if self.tabs[i].block_test.is_some() {
|
||||
let name = cmd.split([' ', ':']).next().unwrap_or(cmd);
|
||||
if matches!(
|
||||
name,
|
||||
"BEDIT"
|
||||
| "BEDIT_BEGIN"
|
||||
| "BEDIT_OPEN"
|
||||
| "REFEDIT"
|
||||
| "REFEDIT_BEGIN"
|
||||
| "BSAVE"
|
||||
| "BSAVEAS"
|
||||
| "SAVE"
|
||||
| "QSAVE"
|
||||
| "SAVEAS"
|
||||
| "SAVEALL"
|
||||
| "WBLOCK"
|
||||
| "EXPORT"
|
||||
| "ETRANSMIT"
|
||||
| "ARCHIVE"
|
||||
) {
|
||||
self.command_line.push_error(
|
||||
crate::t!("Close Test Block before saving or opening another block editor.")
|
||||
.as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if is_spacemouse_command(cmd) {
|
||||
return self.run_action(cmd);
|
||||
}
|
||||
|
||||
@@ -410,6 +410,12 @@ impl OpenCADStudio {
|
||||
/// this needs no undo snapshot. Format follows the path extension.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn control_wblock(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
||||
if self.tabs[self.active_tab].block_test.is_some() {
|
||||
return Err(failure(
|
||||
"temporary_document",
|
||||
"Test Block is temporary and cannot be saved",
|
||||
));
|
||||
}
|
||||
let path = string(req, "path")?;
|
||||
let document = &self.tabs[self.active_tab].scene.document;
|
||||
// A template base keeps its tables/styles in the exported file
|
||||
|
||||
@@ -1148,6 +1148,12 @@ impl OpenCADStudio {
|
||||
"view_focus" => self.control_view_focus(req)?,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"save" => {
|
||||
if self.tabs[i].block_test.is_some() {
|
||||
return Err(failure(
|
||||
"temporary_document",
|
||||
"Test Block is temporary and cannot be saved",
|
||||
));
|
||||
}
|
||||
let path = req["path"]
|
||||
.as_str()
|
||||
.map(std::path::PathBuf::from)
|
||||
@@ -1198,6 +1204,12 @@ impl OpenCADStudio {
|
||||
// Web: no filesystem path; the drawing comes back in the reply.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
"save" => {
|
||||
if self.tabs[i].block_test.is_some() {
|
||||
return Err(failure(
|
||||
"temporary_document",
|
||||
"Test Block is temporary and cannot be saved",
|
||||
));
|
||||
}
|
||||
use base64::Engine as _;
|
||||
let default_is_dxf = crate::io::source_is_dxf(None, &self.tabs[i].scene.document);
|
||||
let (version, is_dxf) = super::automation::requested_save_target(
|
||||
|
||||
@@ -20,7 +20,10 @@ impl OpenCADStudio {
|
||||
self.set_control_result(json!({ "closed": false, "reason": "start_page" }));
|
||||
return Ok(Task::none());
|
||||
}
|
||||
if self.tabs[index].dirty && !req["discard"].as_bool().unwrap_or(false) {
|
||||
if self.tabs[index].dirty
|
||||
&& self.tabs[index].block_test.is_none()
|
||||
&& !req["discard"].as_bool().unwrap_or(false)
|
||||
{
|
||||
return Err(failure(
|
||||
"document_dirty",
|
||||
"Save the document or pass discard:true",
|
||||
|
||||
@@ -207,6 +207,7 @@ pub(super) struct DocumentTab {
|
||||
pub(super) history: HistoryState,
|
||||
/// Drawing history is suspended while `history` belongs to BEDIT.
|
||||
pub(super) block_edit_history: Option<super::block_editor::BlockEditHistory>,
|
||||
pub(super) block_test: Option<super::block_test::BlockTestSession>,
|
||||
pub(super) active_layer: String,
|
||||
/// Block references whose properties panel shows per-axis Scale X/Y/Z even
|
||||
/// though the three factors are currently equal — the user unchecked the
|
||||
@@ -660,6 +661,7 @@ impl DocumentTab {
|
||||
dyn_active: 0,
|
||||
history: HistoryState::default(),
|
||||
block_edit_history: None,
|
||||
block_test: None,
|
||||
active_layer: "0".to_string(),
|
||||
props_asym_scale: std::collections::HashSet::new(),
|
||||
graph: Default::default(),
|
||||
|
||||
@@ -32,6 +32,7 @@ impl DraftingSettingsState {
|
||||
ortho_on: app.ortho_mode,
|
||||
polar_increment_deg: app.polar_increment_deg,
|
||||
osnap_on: app.snapper.snap_enabled,
|
||||
osnap_aperture_input: format!("{}", app.snapper.osnap_radius_px),
|
||||
otrack_on: app.snapper.otrack_enabled,
|
||||
snap_modes: app.snapper.enabled.clone(),
|
||||
osnap3d_on: app.snapper.snap3d_enabled,
|
||||
@@ -107,6 +108,13 @@ impl OpenCADStudio {
|
||||
self.polar_mode = state.polar_on;
|
||||
self.ortho_mode = state.ortho_on;
|
||||
self.polar_increment_deg = state.polar_increment_deg;
|
||||
let aperture = state.osnap_aperture_input.trim().parse::<f32>().ok()
|
||||
.filter(|v| v.is_finite() && (4.0..=40.0).contains(v));
|
||||
let Some(aperture) = aperture else {
|
||||
self.command_line.push_error("OSNAP capture radius must be between 4 and 40 pixels.");
|
||||
return false;
|
||||
};
|
||||
self.snapper.osnap_radius_px = aperture;
|
||||
self.snapper.snap_enabled = state.osnap_on;
|
||||
self.snapper.otrack_enabled = state.otrack_on;
|
||||
self.snapper.enabled = state.snap_modes.clone();
|
||||
@@ -145,6 +153,7 @@ mod tests {
|
||||
ortho_on: false,
|
||||
polar_increment_deg: 15.0,
|
||||
osnap_on: true,
|
||||
osnap_aperture_input: "15".to_string(),
|
||||
otrack_on: false,
|
||||
snap_modes: rustc_hash::FxHashSet::default(),
|
||||
osnap3d_on: false,
|
||||
@@ -186,6 +195,7 @@ mod tests {
|
||||
ortho_on: false,
|
||||
polar_increment_deg: 15.0,
|
||||
osnap_on: true,
|
||||
osnap_aperture_input: "15".to_string(),
|
||||
otrack_on: false,
|
||||
snap_modes: rustc_hash::FxHashSet::default(),
|
||||
osnap3d_on: false,
|
||||
|
||||
@@ -9,6 +9,9 @@ pub(crate) mod config;
|
||||
pub use automation::{export_headless, serve};
|
||||
mod annotation_data;
|
||||
mod block_editor;
|
||||
mod block_test;
|
||||
#[cfg(test)]
|
||||
mod block_test_tests;
|
||||
mod block_save_as;
|
||||
#[cfg(test)]
|
||||
mod block_editor_tests;
|
||||
@@ -571,6 +574,7 @@ pub(super) struct OpenCADStudio {
|
||||
drawing_units: Option<crate::ui::window::drawing_units::State>,
|
||||
/// Working copy of the Block Definition dialog; `None` while it is closed.
|
||||
block_definition: Option<crate::ui::window::block_definition::BlockDefinitionState>,
|
||||
block_edit_picker: Option<crate::ui::window::block_edit_picker::State>,
|
||||
block_save_as: Option<crate::ui::window::block_save_as::State>,
|
||||
/// PDF dialogs' working copies; `None` while closed.
|
||||
pdf_attach: Option<crate::ui::window::pdf_dialogs::PdfAttachState>,
|
||||
@@ -2187,6 +2191,7 @@ pub enum ModalKind {
|
||||
DimStyle,
|
||||
Unsaved,
|
||||
BlockEditChanges,
|
||||
BlockEditPicker,
|
||||
BlockSaveAs,
|
||||
SaveDialog,
|
||||
Recovery,
|
||||
@@ -2824,6 +2829,9 @@ pub enum Message {
|
||||
BlockEditChangesCancel,
|
||||
BlockSaveAsName(String),
|
||||
BlockSaveAsApply,
|
||||
BlockEditPickerName(String),
|
||||
BlockEditPickerSelect(String),
|
||||
BlockEditPickerApply,
|
||||
// ── AEC / unsupported-object drop warning (lossy Save-As) ──────────────
|
||||
/// Save as the source DWG version so the unsupported objects survive.
|
||||
AecDropSameVersion,
|
||||
@@ -3181,6 +3189,8 @@ pub enum Message {
|
||||
/// never fall through and rotate a different camera.
|
||||
ViewportClick(Option<codec::Handle>),
|
||||
ViewportMove(Point),
|
||||
/// Position recorded by the pointer event itself, not the last hover.
|
||||
ViewportLeftPressAt(Point),
|
||||
ViewportLeftPress,
|
||||
ViewportLeftRelease,
|
||||
ViewportRightPress,
|
||||
@@ -3482,6 +3492,7 @@ pub enum Message {
|
||||
DraftingSettingsTogglePolar,
|
||||
DraftingSettingsToggleOrtho,
|
||||
DraftingSettingsToggleOsnap,
|
||||
DraftingSettingsOsnapApertureChanged(String),
|
||||
DraftingSettingsToggleOtrack,
|
||||
DraftingSettingsToggleSnapMode(crate::snap::SnapType),
|
||||
DraftingSettingsToggleSnapMode3d(crate::snap::SnapType),
|
||||
@@ -4574,6 +4585,7 @@ impl OpenCADStudio {
|
||||
layer_translator: None,
|
||||
drawing_units: None,
|
||||
block_definition: None,
|
||||
block_edit_picker: None,
|
||||
block_save_as: None,
|
||||
pdf_attach: None,
|
||||
point_cloud_attach: None,
|
||||
|
||||
@@ -160,7 +160,7 @@ impl OpenCADStudio {
|
||||
if self.tabs[idx].is_start {
|
||||
continue;
|
||||
}
|
||||
if self.tabs[idx].active_block_edit.is_some() {
|
||||
if self.tabs[idx].active_block_edit.is_some() || self.tabs[idx].block_test.is_some() {
|
||||
tasks.push(self.on_tab_close(idx));
|
||||
if self.pending_block_close.is_some() || self.pending_close.is_some() {
|
||||
break;
|
||||
@@ -178,6 +178,27 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(in crate::app) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
let test_return_id = self
|
||||
.tabs
|
||||
.get(idx)
|
||||
.and_then(|tab| tab.block_test.as_ref())
|
||||
.map(|test| {
|
||||
if idx == self.active_tab {
|
||||
test.source_id
|
||||
} else {
|
||||
self.tabs[self.active_tab].id
|
||||
}
|
||||
});
|
||||
if test_return_id.is_some() {
|
||||
if idx == self.active_tab {
|
||||
self.cancel_active_grip_edit();
|
||||
self.grip_pending = None;
|
||||
self.visibility_popup = None;
|
||||
self.grip_popup = None;
|
||||
self.grip_hover = None;
|
||||
}
|
||||
self.tabs[idx].dirty = false;
|
||||
}
|
||||
// Start tab is fixed — close requests on it are no-ops.
|
||||
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
|
||||
return Task::none();
|
||||
@@ -249,6 +270,16 @@ impl OpenCADStudio {
|
||||
// The active tab is now either a brand-new blank or a
|
||||
// different existing tab; in both cases the ribbon needs
|
||||
// to track that doc's defaults / selection. #21.
|
||||
if let Some(index) =
|
||||
test_return_id.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
|
||||
{
|
||||
self.active_tab = index;
|
||||
self.adopt_header_sysvars(index);
|
||||
}
|
||||
self.ribbon
|
||||
.set_block_testing(self.tabs[self.active_tab].block_test.is_some());
|
||||
self.ribbon
|
||||
.set_block_editing(self.tabs[self.active_tab].active_block_edit.is_some());
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_styles();
|
||||
self.sync_ribbon_from_selection();
|
||||
|
||||
@@ -22,6 +22,9 @@ use iced::{mouse, Point, Task};
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
if self.refuse_block_test_save(tab_idx) {
|
||||
return Task::none();
|
||||
}
|
||||
// Every entry here is Save-As semantics (the SAVEAS command, the
|
||||
// recovery save-as, the native failure redirects): the user is
|
||||
// choosing a destination, so on the web the FS Access picker must
|
||||
|
||||
+21
-4
@@ -1779,6 +1779,11 @@ impl OpenCADStudio {
|
||||
version: codec::DxfVersion,
|
||||
set_current_path: bool,
|
||||
) -> Result<(), crate::io::SaveFailure> {
|
||||
if self.tabs[i].block_test.is_some() {
|
||||
return Err(crate::io::SaveFailure::other(
|
||||
"Test Block is temporary and cannot be saved",
|
||||
));
|
||||
}
|
||||
let previous_autosave = self.autosave_target(i);
|
||||
if self.tabs[i].recovery_save_as_required
|
||||
&& self.tabs[i]
|
||||
@@ -2539,7 +2544,7 @@ impl OpenCADStudio {
|
||||
let mut skipped = 0usize;
|
||||
let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new();
|
||||
for t in 0..self.tabs.len() {
|
||||
if self.tabs[t].is_start {
|
||||
if self.tabs[t].is_start || self.tabs[t].block_test.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = self.tabs[t].current_path.clone() else {
|
||||
@@ -3199,6 +3204,9 @@ impl OpenCADStudio {
|
||||
set_current_path: bool,
|
||||
check_external_change: bool,
|
||||
) -> Task<Message> {
|
||||
if self.refuse_block_test_save(i) {
|
||||
return Task::none();
|
||||
}
|
||||
let tab_id = self.tabs[i].id;
|
||||
if self
|
||||
.pending_native_thumbnail_save
|
||||
@@ -3867,6 +3875,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn on_save_file(&mut self) -> Task<Message> {
|
||||
if self.refuse_block_test_save(self.active_tab) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.read_only {
|
||||
self.command_line.push_error(
|
||||
crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(),
|
||||
@@ -3923,6 +3934,9 @@ impl OpenCADStudio {
|
||||
/// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the
|
||||
/// save-before-close flow — the version picker is reserved for Save As.
|
||||
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
if self.refuse_block_test_save(tab_idx) {
|
||||
return Task::none();
|
||||
}
|
||||
self.active_tab = tab_idx;
|
||||
// Every caller of this entry (Save/QSAVE, the save-before-close
|
||||
// flow) is plain-save semantics: on the web, the tab's retained
|
||||
@@ -4315,7 +4329,10 @@ impl OpenCADStudio {
|
||||
pub(super) fn on_autosave(&mut self) -> Task<Message> {
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..self.tabs.len() {
|
||||
if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) {
|
||||
if self.tabs[i].block_test.is_some()
|
||||
|| !self.tabs[i].dirty
|
||||
|| self.active_save_jobs.contains_key(&self.tabs[i].id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.prepare_snapshot_save(i);
|
||||
@@ -4350,7 +4367,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
let mut pending = Vec::new();
|
||||
for i in 0..self.tabs.len() {
|
||||
if !self.tabs[i].dirty {
|
||||
if self.tabs[i].block_test.is_some() || !self.tabs[i].dirty {
|
||||
continue;
|
||||
}
|
||||
// Same pre-serialize state sync the web Save flow performs
|
||||
@@ -4677,7 +4694,7 @@ impl OpenCADStudio {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut active = 0;
|
||||
for (index, tab) in self.tabs.iter().enumerate() {
|
||||
if tab.is_start {
|
||||
if tab.is_start || tab.block_test.is_some() {
|
||||
continue;
|
||||
}
|
||||
if index == self.active_tab {
|
||||
|
||||
+98
-3
@@ -50,7 +50,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
|
||||
|
||||
fn perf_message_label(msg: &Message) -> &'static str {
|
||||
match msg {
|
||||
Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down",
|
||||
Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down",
|
||||
Message::ViewportLeftRelease | Message::PaneRelease(_) => "pointer-up",
|
||||
Message::ViewportMove(_) | Message::PaneMove(_, _) => "pointer-move",
|
||||
Message::CommandFinalize => "command-finalize",
|
||||
@@ -230,6 +230,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
Some(GeometricTolerance) => self.geometric_tolerance = None,
|
||||
Some(BlockDefinition) => self.block_definition = None,
|
||||
Some(BlockEditPicker) => self.block_edit_picker = None,
|
||||
Some(BlockSaveAs) => self.block_save_as = None,
|
||||
Some(PdfAttach) => self.pdf_attach = None,
|
||||
Some(UnderlayLayers) => self.underlay_layers = None,
|
||||
@@ -346,7 +347,17 @@ impl OpenCADStudio {
|
||||
.tabs
|
||||
.get(self.active_tab)
|
||||
.is_some_and(|tab| tab.active_block_edit.is_some());
|
||||
self.ribbon.set_block_editing(block_editing);
|
||||
let testing = self
|
||||
.tabs
|
||||
.get(self.active_tab)
|
||||
.is_some_and(|tab| tab.block_test.is_some());
|
||||
if testing {
|
||||
self.ribbon.set_block_editing(false);
|
||||
self.ribbon.set_block_testing(true);
|
||||
} else {
|
||||
self.ribbon.set_block_testing(false);
|
||||
self.ribbon.set_block_editing(block_editing);
|
||||
}
|
||||
task
|
||||
}
|
||||
|
||||
@@ -445,6 +456,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.active_modal == Some(super::ModalKind::BlockEditPicker)
|
||||
&& (matches!(msg, Message::CommandFinalize)
|
||||
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")))
|
||||
{
|
||||
return self.apply_bedit_dialog();
|
||||
}
|
||||
if self.active_modal == Some(super::ModalKind::BlockSaveAs)
|
||||
&& (matches!(msg, Message::CommandFinalize)
|
||||
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")))
|
||||
@@ -595,7 +612,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::sys::set_unsaved_changes_warning(self.tabs.iter().any(|tab| tab.dirty));
|
||||
crate::sys::set_unsaved_changes_warning(
|
||||
self.tabs
|
||||
.iter()
|
||||
.any(|tab| tab.dirty && tab.block_test.is_none()),
|
||||
);
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
self.block_palette.placing = None;
|
||||
}
|
||||
@@ -4390,6 +4411,7 @@ impl OpenCADStudio {
|
||||
|
||||
// A multi-finger gesture owns the pointer while it runs.
|
||||
Message::ViewportMove(_)
|
||||
| Message::ViewportLeftPressAt(_)
|
||||
| Message::ViewportLeftPress
|
||||
| Message::ViewportLeftRelease
|
||||
| Message::PaneMove(..)
|
||||
@@ -4474,6 +4496,17 @@ impl OpenCADStudio {
|
||||
self.update(Message::ViewportScroll(d))
|
||||
}
|
||||
|
||||
Message::ViewportLeftPressAt(position) => {
|
||||
// Use the pointer location captured by the actual button
|
||||
// event. The last hover can lag behind fast cursor motion,
|
||||
// particularly while expensive OSNAP/OTRACK previews run.
|
||||
// Refresh the snap and world point before accepting the pick.
|
||||
let moved = self.on_viewport_move(position);
|
||||
self.graphic_attributes.open_menu = None;
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
Task::batch(vec![moved, sweep, self.on_viewport_left_press()])
|
||||
}
|
||||
|
||||
Message::ViewportLeftPress => {
|
||||
self.graphic_attributes.open_menu = None;
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
@@ -6255,6 +6288,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::DraftingSettingsOsnapApertureChanged(value) => {
|
||||
if let Some(state) = &mut self.drafting_settings_state {
|
||||
state.osnap_aperture_input = value;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::DraftingSettingsToggleOsnap => {
|
||||
if let Some(state) = &mut self.drafting_settings_state {
|
||||
state.osnap_on = !state.osnap_on;
|
||||
@@ -10445,6 +10484,62 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::BlockSaveAsApply => self.apply_block_save_as_dialog(),
|
||||
Message::BlockEditPickerName(name) => {
|
||||
if let Some(state) = self.block_edit_picker.as_mut() {
|
||||
state.name = name.clone();
|
||||
let trimmed = name.trim();
|
||||
state.error = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
crate::scene::block_service::validate_block_name(trimmed)
|
||||
.err()
|
||||
.map(|e| e.to_string())
|
||||
};
|
||||
if let Some(tab_idx) =
|
||||
self.tabs.iter().position(|t| t.id == state.document_id)
|
||||
{
|
||||
if let Some(matched) = state
|
||||
.existing_names
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(trimmed))
|
||||
{
|
||||
state.preview =
|
||||
self.tabs[tab_idx].scene.block_preview_wires(matched);
|
||||
state.description = self.tabs[tab_idx]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(matched)
|
||||
.map(|b| b.description.clone())
|
||||
.unwrap_or_default();
|
||||
} else {
|
||||
state.preview.clear();
|
||||
state.description.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::BlockEditPickerSelect(name) => {
|
||||
if let Some(state) = self.block_edit_picker.as_mut() {
|
||||
state.name = name.clone();
|
||||
state.error = None;
|
||||
if let Some(tab_idx) =
|
||||
self.tabs.iter().position(|t| t.id == state.document_id)
|
||||
{
|
||||
state.preview = self.tabs[tab_idx].scene.block_preview_wires(&name);
|
||||
state.description = self.tabs[tab_idx]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&name)
|
||||
.map(|b| b.description.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::BlockEditPickerApply => self.apply_bedit_dialog(),
|
||||
Message::BlockEditChangesDiscard => self.resolve_block_edit_close(false),
|
||||
Message::BlockEditChangesCancel => {
|
||||
self.cancel_block_edit_close();
|
||||
|
||||
@@ -2663,7 +2663,15 @@ impl OpenCADStudio {
|
||||
.map(|snap| snap.world)
|
||||
.unwrap_or(cursor_world);
|
||||
axis_lock_apply(point, base, dir)
|
||||
} else if let Some(h) = otrack_hit {
|
||||
} else if let Some(h) = otrack_hit.filter(|_| {
|
||||
// A real object snap must win over tracking alignment.
|
||||
// Otherwise the endpoint glyph can appear at the
|
||||
// corner while the preview/accepted point follows the
|
||||
// tracking line instead.
|
||||
!self.tabs[i].snap_result.is_some_and(|s| {
|
||||
s.snap_type != crate::snap::SnapType::Grid
|
||||
})
|
||||
}) {
|
||||
h.aligned
|
||||
} else {
|
||||
// Snap runs in model space (viewport camera or the
|
||||
@@ -4498,7 +4506,11 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(h) = otrack {
|
||||
if let Some(h) = otrack.filter(|_| {
|
||||
// Match the hover path: never override a genuine OSNAP
|
||||
// endpoint/intersection with a tracking projection.
|
||||
!snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid)
|
||||
}) {
|
||||
pt = h.aligned;
|
||||
if clamp_world_xy {
|
||||
pt.z = 0.0;
|
||||
|
||||
+81
-5
@@ -15,6 +15,46 @@ use iced::widget::{
|
||||
use iced::window;
|
||||
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
|
||||
use iced_aw::ContextMenu;
|
||||
|
||||
/// Invisible event surface for accurate point picking. MouseArea emits a
|
||||
/// positionless press message; capturing the cursor in the actual button
|
||||
/// event avoids using the previous hover location when clicking quickly.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ViewportPickSurface;
|
||||
|
||||
impl iced::widget::canvas::Program<Message> for ViewportPickSurface {
|
||||
type State = ();
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
_state: &mut Self::State,
|
||||
event: &iced::Event,
|
||||
bounds: iced::Rectangle,
|
||||
cursor: iced::mouse::Cursor,
|
||||
) -> Option<iced::widget::canvas::Action<Message>> {
|
||||
if let iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) =
|
||||
event
|
||||
{
|
||||
return cursor.position_in(bounds).map(|position| {
|
||||
iced::widget::canvas::Action::publish(Message::ViewportLeftPressAt(position))
|
||||
.and_capture()
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &Self::State,
|
||||
_renderer: &iced::Renderer,
|
||||
_theme: &iced::Theme,
|
||||
_bounds: iced::Rectangle,
|
||||
_cursor: iced::mouse::Cursor,
|
||||
) -> Vec<iced::widget::canvas::Geometry> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
mod controls;
|
||||
@@ -876,10 +916,16 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
.hover_highlight
|
||||
.map(|h| tab.scene.is_layer_locked(h))
|
||||
.unwrap_or(false);
|
||||
let point_cursor = tab
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
|
||||
// Keep the hollow pickbox at rest. Use the standard LINE/RECTANG
|
||||
// dot crosshair only after the first window/crossing corner.
|
||||
// No OSNAP or selection behavior changes.
|
||||
let selecting_window = tab.active_cmd.is_none()
|
||||
&& tab.scene.selection.borrow().gesture.box_anchor.is_some();
|
||||
let point_cursor = selecting_window
|
||||
|| tab
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
|
||||
let constraint_cursor_badge = if is_paper {
|
||||
None
|
||||
} else {
|
||||
@@ -986,9 +1032,15 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
|
||||
mark("viewport_mouse");
|
||||
let viewport_mouse = mouse_area(container(
|
||||
iced::widget::Space::new().width(Fill).height(Fill),
|
||||
iced::widget::canvas(ViewportPickSurface)
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
))
|
||||
.on_move(Message::ViewportMove)
|
||||
// Left mouse presses are captured by the canvas with their exact
|
||||
// event-time position. MouseArea::on_press has no coordinate, so
|
||||
// queued cursor moves could otherwise make quick clicks use a stale
|
||||
// snap position. Touch presses still use this fallback.
|
||||
.on_press(Message::ViewportLeftPress)
|
||||
.on_release(Message::ViewportLeftRelease)
|
||||
.on_right_press(Message::ViewportRightPress)
|
||||
@@ -1036,6 +1088,20 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
{
|
||||
let w = tab.last_cursor_world;
|
||||
let base = tab.dyn_anchor.or(self.last_point);
|
||||
// A single distance field on an acquired tracking ray is measured
|
||||
// from the acquired OSNAP/OTRACK point, not from the command's
|
||||
// previous point (or from WCS zero before the first pick).
|
||||
// Keep typed fields and ordinary polar distance inputs unchanged.
|
||||
let tracking_distance = if tab.dyn_fields.len() == 1
|
||||
&& tab.dyn_fields[0].component == DynComponent::Distance
|
||||
{
|
||||
self.active_distance_ray(self.active_tab).and_then(|(origin, direction)| {
|
||||
let axis = direction.try_normalize()?;
|
||||
Some((w - origin).dot(axis).abs())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let label_screen = tab
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
@@ -1185,6 +1251,16 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
format!("{lv:.4}")
|
||||
}
|
||||
}
|
||||
// The displayed live distance must use the same
|
||||
// acquired tracking origin as the guide and typed
|
||||
// distance placement, not the last picked point.
|
||||
(None, _) if f.component == DynComponent::Distance
|
||||
&& tracking_distance.is_some() =>
|
||||
{
|
||||
crate::entities::common::format_length(
|
||||
tracking_distance.unwrap() * f.role.value_scale() as f64,
|
||||
)
|
||||
}
|
||||
_ => dyn_component_value(
|
||||
f,
|
||||
w,
|
||||
|
||||
@@ -79,6 +79,7 @@ impl OpenCADStudio {
|
||||
Some(K::LayerDeleteWarning) => crate::tr!("modal", "delete-layer"),
|
||||
Some(K::Unsaved) => crate::tr!("modal", "unsaved-changes"),
|
||||
Some(K::BlockEditChanges) => crate::t!("Block Editor").into_owned(),
|
||||
Some(K::BlockEditPicker) => crate::t!("Edit Block Definition").into_owned(),
|
||||
Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(),
|
||||
Some(K::PointStyle) => crate::tr!("modal", "point-style"),
|
||||
Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"),
|
||||
@@ -501,6 +502,12 @@ impl OpenCADStudio {
|
||||
crate::ui::window::block_definition::view_window(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::BlockEditPicker => {
|
||||
let state = self.block_edit_picker.as_ref()?;
|
||||
sized_flow(ex, 540, 380, |flow| {
|
||||
crate::ui::window::block_edit_picker::view(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::BlockSaveAs => {
|
||||
let state = self.block_save_as.as_ref()?;
|
||||
sized_flow(ex, 430, 370, |flow| {
|
||||
|
||||
@@ -63,6 +63,11 @@ impl CadModule for BlockEditorModule {
|
||||
"Close Block Editor",
|
||||
include_bytes!("../../../assets/icons/mt_cancel.svg"),
|
||||
),
|
||||
command(
|
||||
"BTESTBLOCK",
|
||||
"Test Block",
|
||||
include_bytes!("../../../assets/icons/edit_block.svg"),
|
||||
),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
|
||||
+10
-1
@@ -15,7 +15,7 @@ mod report;
|
||||
pub mod select;
|
||||
pub mod units;
|
||||
|
||||
use crate::modules::{CadModule, RibbonGroup, RibbonItem};
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
pub struct DrawModule;
|
||||
|
||||
@@ -249,6 +249,15 @@ impl CadModule for DrawModule {
|
||||
default: "DIST",
|
||||
}],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Close",
|
||||
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||
id: "BCLOSETESTBLOCK",
|
||||
label: "Close Test Block",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/mt_ok.svg")),
|
||||
event: ModuleEvent::Command("BCLOSETESTBLOCK".into()),
|
||||
})],
|
||||
},
|
||||
// Support group lives on the Start tab now (see view.rs:
|
||||
// start_page_view). Removed from the Draw ribbon to declutter.
|
||||
]
|
||||
|
||||
@@ -70,9 +70,10 @@ pub struct BlockEditSession {
|
||||
/// Transient UCS being manipulated in this block tab. On commit its full
|
||||
/// frame is baked into the definition and this returns to `None`.
|
||||
pub editor_ucs: Option<codec::tables::Ucs>,
|
||||
/// The definition was created empty to be edited (a new name at BEDIT /
|
||||
/// -BEDIT); closing without saving removes it again.
|
||||
pub created_for_edit: bool,
|
||||
/// Snapshot of the document prior to creating an empty new block for editing.
|
||||
/// If the session is closed without saving, this snapshot is restored so
|
||||
/// no block record remains in the block table.
|
||||
pub unpersisted_pre_document: Option<codec::CadDocument>,
|
||||
}
|
||||
|
||||
// ── BEDIT pick command ─────────────────────────────────────────────────────
|
||||
@@ -122,3 +123,6 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["BSAVE", "BSAVEAS", "BCLOSE"]
|
||||
});
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["BTESTBLOCK", "BCLOSETESTBLOCK"]
|
||||
});
|
||||
@@ -763,7 +763,7 @@ enum BeditStep {
|
||||
}
|
||||
|
||||
/// -BEDIT: `Enter block name or [?]:` opens the block editor on that
|
||||
/// definition, creating an empty one for a new name.
|
||||
/// definition, or on a new empty one for a new name.
|
||||
pub struct DashBeditCommand {
|
||||
step: BeditStep,
|
||||
table: BlockTable,
|
||||
@@ -845,7 +845,7 @@ impl CadCommand for DashBeditCommand {
|
||||
return Some(CmdResult::ReportError("Invalid block name.".to_string()));
|
||||
}
|
||||
let name = self.table.find_user(t).unwrap_or_else(|| t.to_string());
|
||||
CmdResult::Dispatch(format!("BEDIT_OPEN:{name}"))
|
||||
CmdResult::Dispatch(format!("BEDIT_NEW:{name}"))
|
||||
}
|
||||
BeditStep::ListPattern => {
|
||||
self.step = BeditStep::Name;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use codec::objects::ObjectType;
|
||||
use codec::tables::BlockRecord;
|
||||
use codec::types::Handle;
|
||||
use codec::{CadDocument, EntityType};
|
||||
use codec::{CadDocument, EntityType, TableEntry};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
@@ -285,6 +285,29 @@ fn is_layout_record(document: &CadDocument, record: &BlockRecord) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Candidate block names for the Edit Block Definition (BEDIT) dialog:
|
||||
/// excludes standard layouts (*MODEL_SPACE, *PAPER_SPACE), layout records,
|
||||
/// anonymous blocks (*U, *D, *T, etc.), and externally referenced blocks.
|
||||
/// Sorted alphabetically, case-insensitive.
|
||||
pub fn bedit_candidate_block_names(document: &CadDocument) -> Vec<String> {
|
||||
let mut names: Vec<String> = document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
!record.is_standard()
|
||||
&& !is_layout_record(document, record)
|
||||
&& !record.is_anonymous()
|
||||
&& !record.name.starts_with('*')
|
||||
&& !record.flags.is_xref
|
||||
&& !record.flags.is_xref_overlay
|
||||
&& !record.name.contains('|')
|
||||
})
|
||||
.map(|record| record.name.clone())
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
/// The extension-dictionary key on a dynamic block's record.
|
||||
const ENHANCED_BLOCK_KEY: &str = "ACAD_ENHANCEDBLOCK";
|
||||
|
||||
@@ -1238,4 +1261,32 @@ mod tests {
|
||||
assert!(would_create_cycle(&scene.document, "blockb", "BLOCKA"));
|
||||
assert!(!would_create_cycle(&scene.document, "blocka", "BLOCKB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_candidate_block_names_filters_and_sorts() {
|
||||
let mut scene = scene();
|
||||
define(&mut scene, "zebra");
|
||||
define(&mut scene, "Alpha");
|
||||
define(&mut scene, "Beta");
|
||||
|
||||
// Anonymous block
|
||||
let mut anon = BlockRecord::new("*U1");
|
||||
anon.handle = Handle::new(9901);
|
||||
anon.flags.anonymous = true;
|
||||
scene.document.block_records.add(anon).unwrap();
|
||||
|
||||
// Xref block
|
||||
let mut xref = BlockRecord::new("XrefBlock");
|
||||
xref.handle = Handle::new(9902);
|
||||
xref.flags.is_xref = true;
|
||||
scene.document.block_records.add(xref).unwrap();
|
||||
|
||||
// Xref dependent symbol
|
||||
let mut xref_dep = BlockRecord::new("HOST|CHILD");
|
||||
xref_dep.handle = Handle::new(9903);
|
||||
scene.document.block_records.add(xref_dep).unwrap();
|
||||
|
||||
let candidates = bedit_candidate_block_names(&scene.document);
|
||||
assert_eq!(candidates, vec!["Alpha", "Beta", "zebra"]);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ use kernel::geom2d::Curve;
|
||||
use crate::command::{DimensionAssociationSource, TangentObject};
|
||||
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
||||
use crate::scene::pick::interaction_index::WireSource;
|
||||
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
|
||||
const DEFAULT_OSNAP_RADIUS_PX: f32 = 25.0;
|
||||
|
||||
// ── Snap type ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -434,6 +434,9 @@ const CODES: &[(&str, &str, &str)] = &[
|
||||
("block_editor", "Open and Save", "BO"),
|
||||
("block_editor", "BEDIT", "BE"),
|
||||
("block_editor", "BSAVE", "BS"),
|
||||
("block_editor", "BTESTBLOCK", "TB"),
|
||||
("draw", "Close", "TC"),
|
||||
("draw", "BCLOSETESTBLOCK", "CT"),
|
||||
("block_editor", "ATTDEF", "AD"),
|
||||
("block_editor", "AUTOCONSTRAIN", "AC"),
|
||||
("block_editor", "CCONSTRAINT", "CI"),
|
||||
@@ -631,7 +634,7 @@ impl Ribbon {
|
||||
};
|
||||
let scope = module.id();
|
||||
let mut tips = Vec::new();
|
||||
for group in module.ribbon_groups() {
|
||||
for group in self.groups_for(index) {
|
||||
for item in &group.tools {
|
||||
match item {
|
||||
RibbonItem::Tool(tool)
|
||||
@@ -709,11 +712,16 @@ impl Ribbon {
|
||||
fn item_tips(&self, index: usize, open: &str) -> Vec<Tip> {
|
||||
let mut items = draw_panel::keytip_items(open);
|
||||
if items.is_empty() {
|
||||
let groups = self.modules.get(index).map(|m| m.ribbon_groups()).unwrap_or(&[]);
|
||||
for item in groups.iter().flat_map(|group| &group.tools) {
|
||||
if let RibbonItem::Dropdown { id, items: list, .. }
|
||||
| RibbonItem::LabeledDropdown { id, items: list, .. }
|
||||
| RibbonItem::LargeDropdown { id, items: list, .. } = item
|
||||
for item in self.groups_for(index).flat_map(|group| &group.tools) {
|
||||
if let RibbonItem::Dropdown {
|
||||
id, items: list, ..
|
||||
}
|
||||
| RibbonItem::LabeledDropdown {
|
||||
id, items: list, ..
|
||||
}
|
||||
| RibbonItem::LargeDropdown {
|
||||
id, items: list, ..
|
||||
} = item
|
||||
{
|
||||
if *id == open {
|
||||
items = list
|
||||
|
||||
+6
-15
@@ -68,15 +68,11 @@ impl Ribbon {
|
||||
fn entries(&self) -> impl Iterator<Item = (usize, Entry<'_>)> {
|
||||
let order = std::iter::once(self.active)
|
||||
.chain((0..self.modules.len()).filter(move |i| *i != self.active));
|
||||
order
|
||||
.filter_map(|i| self.modules.get(i).map(|m| (i, m)))
|
||||
.flat_map(|(i, module)| {
|
||||
module
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.flat_map(group_entries)
|
||||
.map(move |entry| (i, entry))
|
||||
})
|
||||
order.flat_map(|i| {
|
||||
self.groups_for(i)
|
||||
.flat_map(group_entries)
|
||||
.map(move |entry| (i, entry))
|
||||
})
|
||||
}
|
||||
|
||||
/// The button id that runs the command line `cmd`: an exact match first,
|
||||
@@ -111,12 +107,7 @@ impl Ribbon {
|
||||
/// module has no tab (its tools are on the side toolbar).
|
||||
pub(super) fn tab_holding_active_tool(&self) -> Option<usize> {
|
||||
let id = self.active_tool.as_deref()?;
|
||||
let holds = |i: usize| {
|
||||
self.modules[i]
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.any(|group| group_holds(group, id))
|
||||
};
|
||||
let holds = |i: usize| self.groups_for(i).any(|group| group_holds(group, id));
|
||||
if self
|
||||
.modules
|
||||
.get(self.active)
|
||||
|
||||
+101
-33
@@ -126,6 +126,7 @@ pub struct Ribbon {
|
||||
/// A block editor session is open: the tab that was active before the
|
||||
/// Block Editor tab took over.
|
||||
block_editing: Option<usize>,
|
||||
block_testing: Option<usize>,
|
||||
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
|
||||
pub xref_fade: i32,
|
||||
/// Open key tips (Alt).
|
||||
@@ -231,6 +232,7 @@ impl Ribbon {
|
||||
xref_ctx: false,
|
||||
point_cloud_ctx: None,
|
||||
block_editing: None,
|
||||
block_testing: None,
|
||||
xref_fade: 50,
|
||||
keytips: None,
|
||||
}
|
||||
@@ -328,6 +330,40 @@ impl Ribbon {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_block_testing(&mut self, testing: bool) {
|
||||
if testing != self.block_testing.is_some() {
|
||||
self.close_dropdown();
|
||||
let _ = self.hide_keytips();
|
||||
}
|
||||
let Some(index) = self.modules.iter().position(|module| module.id() == "draw") else {
|
||||
return;
|
||||
};
|
||||
match (testing, self.block_testing) {
|
||||
(true, None) => {
|
||||
self.block_testing = Some(self.active);
|
||||
self.active = index;
|
||||
}
|
||||
(false, Some(previous)) => {
|
||||
self.block_testing = None;
|
||||
if self.active == index {
|
||||
self.active = previous.min(self.modules.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contextual panels share their normal tab with the drawing tools.
|
||||
fn groups_for(&self, index: usize) -> impl Iterator<Item = &RibbonGroup> {
|
||||
let testing = self.block_testing.is_some();
|
||||
self.modules.get(index).into_iter().flat_map(move |module| {
|
||||
module
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.filter(move |group| module.id() != "draw" || group.title != "Close" || testing)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: usize) {
|
||||
if index < self.modules.len() {
|
||||
self.active = index;
|
||||
@@ -733,38 +769,36 @@ impl Ribbon {
|
||||
} else {
|
||||
self.active
|
||||
};
|
||||
let tool_area: Element<'_, Message> =
|
||||
if let Some(module) = self.modules.get(effective_active) {
|
||||
let groups = module.ribbon_groups();
|
||||
let style_ctx = StyleContext {
|
||||
text_style_names: &self.text_style_names,
|
||||
active_text_style: &self.active_text_style,
|
||||
dim_style_names: &self.dim_style_names,
|
||||
active_dim_style: &self.active_dim_style,
|
||||
mleader_style_names: &self.mleader_style_names,
|
||||
active_mleader_style: &self.active_mleader_style,
|
||||
table_style_names: &self.table_style_names,
|
||||
active_table_style: &self.active_table_style,
|
||||
};
|
||||
|
||||
// Adaptive tool area: panels sit on one row; when they don't all
|
||||
// fit they degrade from the right — a panel's large buttons first
|
||||
// shrink to compact icon columns, then it collapses to a ▾ flyout
|
||||
// button. See `CollapsePanels`.
|
||||
let ts = self.toggle_state(show_block_palette);
|
||||
let panels: Vec<Panel<'_>> = groups
|
||||
.iter()
|
||||
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
|
||||
.collect();
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
|
||||
.report_height(self.tool_bar_h.clone())
|
||||
.report_tight(self.collapse_tight.clone())
|
||||
.mode(self.collapse_mode)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() {
|
||||
let groups = self.groups_for(effective_active);
|
||||
let style_ctx = StyleContext {
|
||||
text_style_names: &self.text_style_names,
|
||||
active_text_style: &self.active_text_style,
|
||||
dim_style_names: &self.dim_style_names,
|
||||
active_dim_style: &self.active_dim_style,
|
||||
mleader_style_names: &self.mleader_style_names,
|
||||
active_mleader_style: &self.active_mleader_style,
|
||||
table_style_names: &self.table_style_names,
|
||||
active_table_style: &self.active_table_style,
|
||||
};
|
||||
|
||||
// Adaptive tool area: panels sit on one row; when they don't all
|
||||
// fit they degrade from the right — a panel's large buttons first
|
||||
// shrink to compact icon columns, then it collapses to a ▾ flyout
|
||||
// button. See `CollapsePanels`.
|
||||
let ts = self.toggle_state(show_block_palette);
|
||||
let panels: Vec<Panel<'_>> = groups
|
||||
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
|
||||
.collect();
|
||||
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
|
||||
.report_height(self.tool_bar_h.clone())
|
||||
.report_tight(self.collapse_tight.clone())
|
||||
.mode(self.collapse_mode)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let tool_bar: Element<'_, Message> = container(tool_area)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weakest.color)),
|
||||
@@ -931,8 +965,7 @@ impl Ribbon {
|
||||
return Some(ov);
|
||||
}
|
||||
|
||||
let module = self.modules.get(self.active)?;
|
||||
let groups = module.ribbon_groups();
|
||||
let groups = self.groups_for(self.active);
|
||||
let mut items_list: Option<Vec<(&'static str, &'static str, IconKind)>> = None;
|
||||
let mut dd_default = "";
|
||||
let mut dd_id: &'static str = "";
|
||||
@@ -1161,7 +1194,7 @@ impl Ribbon {
|
||||
/// Draw-tab dropdowns — so the list grows to fit its entries instead of
|
||||
/// being clipped to the ribbon-row height. (#153)
|
||||
fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option<Element<'_, Message>> {
|
||||
let groups = self.modules.get(self.active)?.ribbon_groups();
|
||||
let groups = self.groups_for(self.active);
|
||||
|
||||
// Locate the open style combo; capture its style key + manager command.
|
||||
let mut found: Option<(crate::modules::StyleKey, Option<&'static str>)> = None;
|
||||
@@ -1677,6 +1710,41 @@ mod tests {
|
||||
use super::widgets::is_active_tool;
|
||||
use super::Ribbon;
|
||||
|
||||
#[test]
|
||||
fn block_test_close_panel_extends_draw_only_while_testing() {
|
||||
let mut ribbon = Ribbon::default();
|
||||
let draw = ribbon
|
||||
.modules
|
||||
.iter()
|
||||
.position(|module| module.id() == "draw")
|
||||
.unwrap();
|
||||
assert!(!ribbon
|
||||
.modules
|
||||
.iter()
|
||||
.any(|module| module.id() == "block_test"));
|
||||
let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
|
||||
assert!(!ordinary.contains(&"Close"));
|
||||
ribbon.set_block_editing(true);
|
||||
ribbon.set_block_editing(false);
|
||||
ribbon.set_block_testing(true);
|
||||
assert_eq!(ribbon.active, draw);
|
||||
let testing: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
|
||||
assert_eq!(&testing[..ordinary.len()], ordinary.as_slice());
|
||||
assert_eq!(testing.last(), Some(&"Close"));
|
||||
ribbon.toggle_collapsed_panel("Close");
|
||||
ribbon.set_block_testing(false);
|
||||
assert!(ribbon.collapsed_open.is_none());
|
||||
assert_eq!(
|
||||
ribbon
|
||||
.groups_for(draw)
|
||||
.map(|group| group.title)
|
||||
.collect::<Vec<_>>(),
|
||||
ordinary
|
||||
);
|
||||
ribbon.set_block_editing(true);
|
||||
assert_eq!(ribbon.modules[ribbon.active].id(), "block_editor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_palette_highlight_follows_the_app_state_passed_to_the_view() {
|
||||
// The Block Palette button highlight is derived from the app's
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::app::Message;
|
||||
use crate::scene::WireModel;
|
||||
use crate::ui::style::common::muted_style;
|
||||
use crate::ui::style::form::{hdivider, vsep};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::{Element, Fill, Length, Theme};
|
||||
|
||||
pub(crate) struct State {
|
||||
pub document_id: u64,
|
||||
pub name: String,
|
||||
pub existing_names: Vec<String>,
|
||||
pub preview: Vec<WireModel>,
|
||||
pub description: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn list_item_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |theme: &Theme, status| {
|
||||
if selected {
|
||||
button::primary(theme, status)
|
||||
} else {
|
||||
button::subtle(theme, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
|
||||
let can_apply = state.error.is_none() && !state.name.trim().is_empty();
|
||||
|
||||
// ── Left: Block name field and list of existing blocks ─────────────────────
|
||||
let name_input = text_input(crate::t!("Block name").as_ref(), &state.name)
|
||||
.on_input(Message::BlockEditPickerName)
|
||||
.on_submit_maybe(can_apply.then_some(Message::BlockEditPickerApply))
|
||||
.style(crate::ui::style::form::field_style);
|
||||
|
||||
let list_items: Vec<Element<'_, Message>> = state
|
||||
.existing_names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let is_sel = name.eq_ignore_ascii_case(state.name.trim());
|
||||
button(text(name.clone()).size(12))
|
||||
.on_press(Message::BlockEditPickerSelect(name.clone()))
|
||||
.style(list_item_style(is_sel))
|
||||
.padding([4, 8])
|
||||
.width(Fill)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list_scrollable = container(scrollable(column(list_items).spacing(2)).height(Fill))
|
||||
.style(container::bordered_box)
|
||||
.padding(2)
|
||||
.height(Fill);
|
||||
|
||||
let left_col = column![
|
||||
text(crate::t!("Block to create or edit").into_owned()).size(12),
|
||||
name_input,
|
||||
list_scrollable,
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fixed(240.0))
|
||||
.height(Fill);
|
||||
|
||||
// ── Right: Preview and Description ─────────────────────────────────────────
|
||||
let preview_box = column![
|
||||
text(crate::t!("Preview").into_owned()).size(12),
|
||||
container(super::block_palette::preview_canvas(&state.preview, 150.0))
|
||||
.style(container::bordered_box)
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(150.0)),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
let desc_text = if state.description.is_empty() {
|
||||
crate::t!("(No description)").into_owned()
|
||||
} else {
|
||||
state.description.clone()
|
||||
};
|
||||
|
||||
let desc_box = column![
|
||||
text(crate::t!("Description").into_owned()).size(12),
|
||||
container(scrollable(text(desc_text).size(11).style(muted_style)).height(Fill))
|
||||
.style(container::bordered_box)
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.height(Fill);
|
||||
|
||||
let right_col = column![preview_box, desc_box]
|
||||
.spacing(8)
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
|
||||
// ── Body ───────────────────────────────────────────────────────────────────
|
||||
let body = row![left_col, vsep(sizing.height), right_col]
|
||||
.spacing(12)
|
||||
.height(Fill);
|
||||
|
||||
// ── Bottom: Error message and OK / Cancel buttons ───────────────────────────
|
||||
let error_text = text(state.error.clone().unwrap_or_default())
|
||||
.size(11)
|
||||
.style(text::danger);
|
||||
|
||||
let buttons = row![
|
||||
error_text,
|
||||
Space::new().width(Fill),
|
||||
button(text(crate::t!("OK").into_owned()))
|
||||
.on_press_maybe(can_apply.then_some(Message::BlockEditPickerApply))
|
||||
.style(button::primary),
|
||||
button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center);
|
||||
|
||||
container(column![body, hdivider(sizing.width), buttons].spacing(10))
|
||||
.padding(16)
|
||||
.width(sizing.width)
|
||||
.height(sizing.height)
|
||||
.into()
|
||||
}
|
||||
@@ -44,6 +44,7 @@ pub struct DraftingSettingsState {
|
||||
pub polar_increment_deg: f32,
|
||||
// Object Snap
|
||||
pub osnap_on: bool,
|
||||
pub osnap_aperture_input: String,
|
||||
pub otrack_on: bool,
|
||||
pub snap_modes: rustc_hash::FxHashSet<SnapType>,
|
||||
// 3D Object Snap
|
||||
@@ -78,6 +79,7 @@ impl DraftingSettingsState {
|
||||
|| self.ortho_on != saved.ortho_on
|
||||
|| (self.polar_increment_deg - saved.polar_increment_deg).abs() > 0.001
|
||||
|| self.osnap_on != saved.osnap_on
|
||||
|| self.osnap_aperture_input != saved.osnap_aperture_input
|
||||
|| self.otrack_on != saved.otrack_on
|
||||
|| self.snap_modes != saved.snap_modes
|
||||
|| self.osnap3d_on != saved.osnap3d_on
|
||||
@@ -508,6 +510,14 @@ pub fn view_window<'a>(
|
||||
|
||||
column![
|
||||
row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle].align_y(iced::Center),
|
||||
row![
|
||||
text("OSNAP capture radius (pixels)").size(11),
|
||||
text_input("25", &state.osnap_aperture_input)
|
||||
.on_input(Message::DraftingSettingsOsnapApertureChanged)
|
||||
.width(70),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
action_buttons,
|
||||
Space::new().height(4),
|
||||
snap_modes_group,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod attdef_dialog;
|
||||
pub mod attribute_editor;
|
||||
pub mod auto_constrain_settings;
|
||||
pub mod block_definition;
|
||||
pub mod block_edit_picker;
|
||||
pub mod block_save_as;
|
||||
pub mod block_palette;
|
||||
pub mod browser;
|
||||
|
||||
Reference in new issue
Block a user