feat(block): BEDIT block editor as a dedicated space tab (#261)

Selecting a block and running BEDIT (command, ribbon button, or a
double-click on the block) opens a block-editor "space" tab, named after
the block, alongside Model/Layout in the status bar. The tab draws ONLY
the block record's own entities in block-local coordinates; edits are
live on the definition, so every INSERT updates. Save Block / Discard
buttons (side toolbar) finish the session; Discard restores the on-entry
snapshot. Space switching is locked while a block is open, and BEDIT and
REFEDIT are mutually exclusive.

Implementation: a Scene.block_edit_block override at the top of
current_layout_block_handle scopes render/pick/new-entity ownership to the
edited block while current_layout stays "Model" (so the pervasive
Model-vs-paper code keeps model behaviour). The model render/pick paths
that hardcoded model_space_block_handle (wire tile set, hatch render +
pick, block-instance meshes) are made binding-aware so the block isolation
actually takes effect and model content does not leak in. Entering fits
the camera to the block (fit_all already scopes via the binding). Renaming
the block tab renames the block itself — new Scene::rename_block re-keys
the record, updates the Block marker and repoints every INSERT reference.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-14 13:03:04 +03:00
1 parent a4fc34c6a2
commit 3548de332c
8 files changed
+435 -18

No files matched your search

+190
View File
@@ -289,6 +289,196 @@ impl OpenCADStudio {
}
}
"BEDIT" => {
use crate::modules::draw::modify::block_edit::BlockEditPickCommand;
if self.tabs[i].block_edit.is_some() {
self.command_line.push_error(
"BEDIT: a block editor is already open. Save or discard it first.",
);
} else if self.tabs[i].refedit_session.is_some() {
self.command_line
.push_error("BEDIT: finish the active REFEDIT (REFCLOSE) first.");
} else {
// Jump straight to begin when a single INSERT is preselected.
let selected: Vec<_> =
self.tabs[i].scene.selected_entities().into_iter().collect();
if selected.len() == 1 {
if let Some(acadrust::EntityType::Insert(_)) =
selected.first().map(|(_, e)| e)
{
let handle = selected[0].0;
let _ =
self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value()));
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));
}
}
cmd if cmd.starts_with("BEDIT_BEGIN:") => {
use crate::modules::draw::modify::block_edit::BlockEditSession;
use acadrust::Handle;
let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0);
let insert_handle = Handle::new(handle_u64);
let insert = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.clone(),
_ => {
self.command_line
.push_error("BEDIT: selected object is not a block reference.");
return Some(Task::none());
}
};
// Resolve the block record; reject external references (xrefs).
let (br_handle, is_xref) = match self.tabs[i]
.scene
.document
.block_records
.get(&insert.block_name)
{
Some(br) => (br.handle, br.flags.is_xref),
None => {
self.command_line.push_error(&format!(
"BEDIT: block \"{}\" not found.",
insert.block_name
));
return Some(Task::none());
}
};
if is_xref {
self.command_line
.push_error("BEDIT: cannot edit an external reference (xref).");
return Some(Task::none());
}
// Snapshot the block's block-local entities so Discard can restore
// them (skip structural Block/BlockEnd/AttDef, mirroring REFEDIT).
let snapshot: Vec<_> = {
let br = self.tabs[i]
.scene
.document
.block_records
.get(&insert.block_name)
.unwrap();
br.entity_handles
.iter()
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
.filter(|e| {
!matches!(
e,
acadrust::EntityType::Block(_)
| acadrust::EntityType::BlockEnd(_)
| acadrust::EntityType::AttributeDefinition(_)
)
})
.collect()
};
self.push_undo_snapshot(i, "BEDIT");
let return_layout = self.tabs[i].scene.current_layout.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 return_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_edit = Some(BlockEditSession {
block_name: insert.block_name.clone(),
br_handle,
return_layout,
snapshot,
});
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.bump_geometry();
// 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();
self.tabs[i].active_cmd = None;
self.tabs[i].dirty = true;
self.command_line.push_info(&format!(
"BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.",
insert.block_name
));
}
"BEDIT_SAVE" => {
let session = match self.tabs[i].block_edit.take() {
Some(s) => s,
None => {
self.command_line
.push_error("BEDIT_SAVE: no block editor is open.");
return Some(Task::none());
}
};
// Edits are live on the block record — just leave the block space.
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.set_current_layout(session.return_layout.clone());
self.tabs[i].scene.rebuild_derived_caches();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"BEDIT: Block \"{}\" saved. All references updated.",
session.block_name
));
}
"BEDIT_DISCARD" => {
let session = match self.tabs[i].block_edit.take() {
Some(s) => s,
None => {
self.command_line
.push_error("BEDIT_DISCARD: no block editor is open.");
return Some(Task::none());
}
};
// Restore the block definition to its on-entry snapshot: remove the
// block's current entities, then re-add the snapshot ones (mirrors
// the REFCLOSE_SAVE write-back).
let old_handles: Vec<_> = match self.tabs[i]
.scene
.document
.block_records
.get(&session.block_name)
{
Some(br) => br.entity_handles.clone(),
None => vec![],
};
for h in &old_handles {
self.tabs[i].scene.document.remove_entity(*h);
}
if let Some(br) = self.tabs[i]
.scene
.document
.block_records
.get_mut(&session.block_name)
{
br.entity_handles.clear();
}
for mut entity in session.snapshot {
entity.common_mut().handle = acadrust::Handle::NULL;
entity.common_mut().owner_handle = session.br_handle;
let _ = self.tabs[i].scene.document.add_entity(entity);
}
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.set_current_layout(session.return_layout.clone());
self.tabs[i].scene.rebuild_derived_caches();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"BEDIT: Block \"{}\" edit discarded.",
session.block_name
));
}
cmd if cmd.starts_with("REFEDIT_BEGIN:") => {
use crate::modules::draw::modify::refedit::{
apply_insert_transform, RefEditSession,
+4
View File
@@ -1,5 +1,6 @@
use crate::command::CadCommand;
use crate::io::linetypes;
use crate::modules::draw::modify::block_edit::BlockEditSession;
use crate::modules::draw::modify::refedit::RefEditSession;
use crate::scene::pick::grip::GripEdit;
use crate::scene::GripDef;
@@ -205,6 +206,8 @@ pub(super) struct DocumentTab {
pub(super) paper_bg_color: Option<[f32; 4]>,
/// Active REFEDIT session, if any.
pub(super) refedit_session: Option<RefEditSession>,
/// Active BEDIT block-editor space session, if any (issue #261).
pub(super) block_edit: Option<BlockEditSession>,
/// Currently active MLeader style name.
pub(super) active_mleader_style: String,
/// Last camera_generation value written back to the document.
@@ -437,6 +440,7 @@ impl DocumentTab {
bg_color: None,
paper_bg_color: None,
refedit_session: None,
block_edit: None,
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
is_start: false,
+43 -5
View File
@@ -2592,9 +2592,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
}
// Double-clicking a block with attributes opens the
// attribute editor (edit its values); a block with
// no attributes enters in-place block edit (REFEDIT),
// so its geometry can be edited and the change
// reflects in every instance. (#136, #192)
// no attributes opens the block editor (BEDIT) — a
// space tab scoped to the block's own geometry, so
// edits reflect in every instance. (#136, #192, #261)
let insert_has_attrs = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty()
@@ -2606,9 +2606,12 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(_))
);
if is_insert && self.tabs[i].refedit_session.is_none() {
if is_insert
&& self.tabs[i].refedit_session.is_none()
&& self.tabs[i].block_edit.is_none()
{
return Task::done(Message::Command(format!(
"REFEDIT_BEGIN:{}",
"BEDIT_BEGIN:{}",
handle.value()
)));
}
@@ -2989,6 +2992,14 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
pub(super) fn on_layout_switch(&mut self, name: String) -> Task<Message> {
let i = self.active_tab;
// A BEDIT block editor locks the active space; finish it with
// Save Block or Discard before switching spaces. (#261)
if self.tabs[i].block_edit.is_some() {
self.command_line.push_info(
"Finish the block editor (Save Block or Discard) before switching spaces.",
);
return Task::none();
}
let going_to_paper = name != "Model";
// Persist the camera of the layout we're leaving BEFORE switching
// so returning to it restores where the user left off (the
@@ -3073,6 +3084,33 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let new_name = new_name.trim().to_string();
if !new_name.is_empty() && new_name != orig {
let i = self.active_tab;
// BEDIT block tab: renaming it renames the BLOCK itself —
// its record, marker and every INSERT reference. (#261)
let is_block_tab = self.tabs[i]
.block_edit
.as_ref()
.map(|be| be.block_name == orig)
.unwrap_or(false);
if is_block_tab {
if self.tabs[i].scene.document.block_records.get(&new_name).is_some() {
self.command_line
.push_error(&format!("\"{}\" name already in use", new_name));
} else {
self.push_undo_snapshot(i, "BLOCK RENAME");
if self.tabs[i].scene.rename_block(&orig, &new_name) {
if let Some(be) = self.tabs[i].block_edit.as_mut() {
be.block_name = new_name.clone();
}
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Block \"{orig}\" → \"{new_name}\""));
} else {
self.command_line
.push_error(&format!("Could not rename block \"{orig}\""));
}
}
return Task::none();
}
let exists = self.tabs[i]
.scene
.layout_names()
+22 -2
View File
@@ -1097,6 +1097,15 @@ impl OpenCADStudio {
}
}
// BEDIT block editor: right-edge Save Block / Discard toolbar (#261).
if tab.block_edit.is_some() && !tab.is_start {
if let Some(tb) = crate::ui::side_toolbar::view(
&crate::modules::draw::modify::block_edit::block_edit_tools(),
) {
viewport_stack = viewport_stack.push(tb);
}
}
// Quick Properties: compact floating property panel on selection,
// anchored at the canvas top-left so it doesn't track the cursor.
if self.quick_properties && !tab.is_start {
@@ -1328,8 +1337,19 @@ impl OpenCADStudio {
self.polar_popup_open,
self.dyn_input,
self.snapper.otrack_enabled,
tab.scene.layout_names(),
tab.scene.current_layout.clone(),
{
// In a BEDIT block editor, show the block as an extra
// active space tab alongside Model/layouts. (#261)
let mut names = tab.scene.layout_names();
if let Some(be) = &tab.block_edit {
names.push(be.block_name.clone());
}
names
},
tab.block_edit
.as_ref()
.map(|be| be.block_name.clone())
.unwrap_or_else(|| tab.scene.current_layout.clone()),
self.layout_rename_state.as_ref(),
tab.scene.first_viewport_scale(),
tab.scene.viewport_count(),