feat(blocks): implement Block Definition dialog (BLOCK/BMAKE) with on-screen sequencing

Implement the complete Block Definition dialog window launched via BLOCK
and BMAKE commands, featuring standard CAD layout, workflows, and entity
creation pipelines.

- Dialog UI & Layout:
  - 2-column compact CAD grid with synchronized vertical heights (580 × 390px).
  - Editable combobox with existing block names, invalid character validation,
    and inline redefinition badge.
  - Multi-line text editor widget for block descriptions (replacing single-line input).
  - Quick Select (QSELECT) button integration restoring dialog state upon completion.
  - Confirmation guard when attempting to redefine an existing block definition.
  - Centered radio buttons, clean coordinate inputs, and accessible keyboard shortcuts.

- Base Point & On-Screen Workflow:
  - BlockOnScreenCommand strictly enforces prompt sequencing: Base point is
    prompted before object selection.
  - Dynamically recalculates world_to_block and block_to_world affine transforms
    when base point is specified on-screen, preventing stale (0,0,0) normalization.
  - Supports Retain, Convert to block (INSERT placement), and Delete modes.

- Explode Enforcement:
  - Guarded EXPLODE command to prevent decomposing blocks flagged as non-explodable.

- Dynamic Themed Icons:
  - Added pick_point.svg, select_objects.svg, quick_select.svg, and warning_triangle.svg.
  - Routed all icons through the semantic theming engine so line work (#B4B6B9)
    and accents (#6DB7ED) adapt automatically to the active theme.

- Tests:
  - Added unit tests for retain/convert/delete modes, redefinition, explodable flag,
    on-screen base point transform recalculation, and command sequencing.
This commit is contained in:
Karim Jerbi committed 2026-09-22 17:05:15 +01:00
1 parent 889a19c362
commit 652f4d6bbc
19 files changed
+2316 -318

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Pick point">
<line x1="7" y1="2" x2="7" y2="12" stroke="#6DB7ED" stroke-width="1.75" stroke-linecap="round"/>
<line x1="2" y1="7" x2="12" y2="7" stroke="#6DB7ED" stroke-width="1.75" stroke-linecap="round"/>
<circle cx="7" cy="7" r="2" fill="#6DB7ED" stroke="none"/>
<path d="M 8.5 8.5 L 8.5 20.5 L 12.2 16.8 L 15.5 21.5 L 18 20 L 14.8 15.2 L 20.5 15.2 Z" fill="#1A1A1A" stroke="#B4B6B9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Quick Select">
<rect x="12" y="7" width="9.5" height="13.5" rx="1.5" stroke="#B4B6B9" stroke-width="1.5" fill="none"/>
<line x1="14.5" y1="10.5" x2="19" y2="10.5" stroke="#B4B6B9" stroke-width="1.2" stroke-linecap="round"/>
<line x1="14.5" y1="13.5" x2="19" y2="13.5" stroke="#B4B6B9" stroke-width="1.2" stroke-linecap="round"/>
<line x1="14.5" y1="16.5" x2="17.5" y2="16.5" stroke="#B4B6B9" stroke-width="1.2" stroke-linecap="round"/>
<polygon points="10.5,2 5.5,9.5 9.5,9.5 8.5,15.5 14.5,8 10.5,8" fill="#6DB7ED" stroke="#1A1A1A" stroke-width="0.8" stroke-linejoin="round"/>
<path d="M 2.5 7 L 2.5 17.5 L 5.5 14.5 L 8 18.5 L 10 17.5 L 7.5 13.5 L 11 13.5 Z" fill="#1A1A1A" stroke="#B4B6B9" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 861 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Select objects">
<rect x="3" y="3" width="12" height="12" rx="1.5" stroke="#6DB7ED" stroke-width="1.6" stroke-dasharray="2.5 2" fill="#6DB7ED" fill-opacity="0.15"/>
<path d="M 9.5 9.5 L 9.5 21 L 13 17.5 L 16 22 L 18.5 20.5 L 15.5 16 L 21 16 Z" fill="#1A1A1A" stroke="#B4B6B9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 2.5 L22.5 20.5 H1.5 Z" fill="#F0C040" stroke="#F9A825" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="12" y1="8.5" x2="12" y2="14" stroke="#1A1A1A" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="17.5" r="1.2" fill="#1A1A1A"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+30
View File
@@ -3654,6 +3654,36 @@ impl OpenCADStudio {
}
}
}
CmdResult::CreateBlockWithOptions { mut options } => {
options.handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if options.handles.is_empty() {
self.command_line
.push_info(crate::t!("No editable objects selected.").as_ref());
return Task::none();
}
self.push_undo_snapshot(i, "BLOCK");
let name = options.name.clone();
match self.tabs[i].scene.create_block_with_options(*options) {
Ok(insert_handle) => {
self.tabs[i].dirty = true;
self.tabs[i].scene.deselect_all();
if !insert_handle.is_null() {
self.tabs[i].scene.select_entity(insert_handle, false);
}
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.command_line
.push_output(crate::tf!("Block \"{name}\" created.").as_ref());
self.refresh_properties();
self.refresh_block_palette();
}
Err(err) => {
self.discard_last_undo_entry(i);
self.command_line.push_error(&err);
}
}
}
CmdResult::CommitHatch(hatch) => {
let label = self.history_label_from_active_cmd(i, "HATCH");
let pending = self.begin_undo(i, label, 1, true);
+472 -232
View File
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -495,8 +495,8 @@ impl OpenCADStudio {
return Some(Task::none());
}
let insert = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.clone(),
let block_name = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.block_name.clone(),
_ => {
self.command_line
.push_error(crate::t!("BEDIT: selected object is not a block reference.").as_ref());
@@ -509,13 +509,13 @@ impl OpenCADStudio {
.scene
.document
.block_records
.get(&insert.block_name)
.get(&block_name)
{
Some(br) => (br.handle, br.flags.is_xref),
None => {
self.command_line.push_error(crate::tf!(
"BEDIT: block \"{}\" not found.",
insert.block_name
block_name
).as_ref());
return Some(Task::none());
}
@@ -532,7 +532,7 @@ impl OpenCADStudio {
{
self.tabs[i].active_cmd = None;
return Some(Task::done(Message::BlockEditSwitch(
insert.block_name.clone(),
block_name.clone(),
)));
}
@@ -543,7 +543,7 @@ impl OpenCADStudio {
.scene
.document
.block_records
.get(&insert.block_name)
.get(&block_name)
.unwrap();
br.entity_handles
.iter()
@@ -564,7 +564,7 @@ impl OpenCADStudio {
.entities()
.filter_map(|entity| match entity {
acadrust::EntityType::Insert(reference)
if reference.block_name.eq_ignore_ascii_case(&insert.block_name)
if reference.block_name.eq_ignore_ascii_case(&block_name)
&& !reference.attributes.is_empty() =>
{
Some((
@@ -607,7 +607,7 @@ impl OpenCADStudio {
}
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].block_edits.push(BlockEditSession {
block_name: insert.block_name.clone(),
block_name: block_name.clone(),
br_handle,
return_layout,
return_block,
@@ -651,7 +651,7 @@ impl OpenCADStudio {
self.tabs[i].dirty = true;
self.command_line.push_info(crate::tf!(
"BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.",
insert.block_name
block_name
).as_ref());
}
+49 -29
View File
@@ -81,11 +81,26 @@ mod coordinate_parsing_tests {
#[test]
fn parses_all_coordinate_forms() {
let close = |a: glam::DVec3, b: glam::DVec3| (a - b).length() < 1e-9;
assert!(close(parse_coord("1,2").unwrap().0, glam::dvec3(1.0, 2.0, 0.0)));
assert!(close(parse_coord("1,2,3").unwrap().0, glam::dvec3(1.0, 2.0, 3.0)));
assert!(close(parse_coord("10<90").unwrap().0, glam::dvec3(0.0, 10.0, 0.0)));
assert!(close(parse_coord("10<90,4").unwrap().0, glam::dvec3(0.0, 10.0, 4.0)));
assert!(close(parse_coord("10<0<30").unwrap().0, glam::dvec3(5.0 * 3.0_f64.sqrt(), 0.0, 5.0)));
assert!(close(
parse_coord("1,2").unwrap().0,
glam::dvec3(1.0, 2.0, 0.0)
));
assert!(close(
parse_coord("1,2,3").unwrap().0,
glam::dvec3(1.0, 2.0, 3.0)
));
assert!(close(
parse_coord("10<90").unwrap().0,
glam::dvec3(0.0, 10.0, 0.0)
));
assert!(close(
parse_coord("10<90,4").unwrap().0,
glam::dvec3(0.0, 10.0, 4.0)
));
assert!(close(
parse_coord("10<0<30").unwrap().0,
glam::dvec3(5.0 * 3.0_f64.sqrt(), 0.0, 5.0)
));
assert_eq!(parse_coord("@10<0").unwrap().1, CoordKind::Relative);
assert_eq!(parse_coord("#1,2").unwrap().1, CoordKind::Absolute);
}
@@ -102,8 +117,8 @@ mod coordinate_parsing_tests {
/// the UCS icon, snap/ortho, the ViewCube — goes through this one type instead
/// of re-deriving the axis math. Axes are orthonormal, so the inverse rotation
/// is just the transpose (the dot products in `to_ucs`); no matrix inversion.
#[derive(Clone, Copy)]
pub(super) struct UcsXform {
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct UcsXform {
origin: glam::DVec3,
x: glam::DVec3,
y: glam::DVec3,
@@ -112,7 +127,7 @@ pub(super) struct UcsXform {
impl UcsXform {
/// Plain WCS — no active UCS.
pub(super) fn identity() -> Self {
pub(crate) fn identity() -> Self {
Self {
origin: glam::DVec3::ZERO,
x: glam::DVec3::X,
@@ -132,7 +147,12 @@ impl UcsXform {
};
let z = x.cross(raw_y).normalize_or(x.cross(fallback_z).normalize());
let y = z.cross(x).normalize();
Self { origin: v(ucs.origin), x, y, z }
Self {
origin: v(ucs.origin),
x,
y,
z,
}
}
pub(super) fn from_active(ucs: Option<&Ucs>) -> Self {
@@ -191,10 +211,7 @@ impl UcsXform {
/// Full UCS-local → WCS transform, using `origin` as local zero while
/// retaining this UCS's orthonormal axes.
pub(super) fn to_wcs_transform_at(
&self,
origin: glam::DVec3,
) -> acadrust::types::Transform {
pub(crate) fn to_wcs_transform_at(&self, origin: glam::DVec3) -> acadrust::types::Transform {
use acadrust::types::{Matrix4, Transform};
Transform::from_matrix(Matrix4 {
m: [
@@ -207,10 +224,7 @@ impl UcsXform {
}
/// Full WCS → UCS-local transform, using `origin` as the local zero.
pub(super) fn to_ucs_transform_at(
&self,
origin: glam::DVec3,
) -> acadrust::types::Transform {
pub(crate) fn to_ucs_transform_at(&self, origin: glam::DVec3) -> acadrust::types::Transform {
use acadrust::types::{Matrix4, Transform};
Transform::from_matrix(Matrix4 {
m: [
@@ -326,7 +340,11 @@ pub(super) fn drafting_constrain(
let radians = degrees.to_radians();
glam::DVec2::new(radians.cos(), radians.sin())
});
let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { a } else { c };
let direction = if delta.dot(a).abs() >= delta.dot(c).abs() {
a
} else {
c
};
let projected = direction * delta.dot(direction);
let c = glam::DVec3::new(b.x + projected.x, b.y + projected.y, p.z);
xf.to_wcs(c)
@@ -393,8 +411,7 @@ pub(super) fn polar_constrain_near(
tol_px: f32,
xf: &UcsXform,
) -> glam::DVec3 {
polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf)
.unwrap_or(pt)
polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf).unwrap_or(pt)
}
/// Hard axis lock (#312): the locked ray's direction — the nearest polar
@@ -427,8 +444,16 @@ pub(super) fn axis_lock_capture(
let radians = degrees.to_radians();
glam::DVec2::new(radians.cos(), radians.sin())
});
let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { a } else { b };
let direction = if delta.dot(direction) < 0.0 { -direction } else { direction };
let direction = if delta.dot(a).abs() >= delta.dot(b).abs() {
a
} else {
b
};
let direction = if delta.dot(direction) < 0.0 {
-direction
} else {
direction
};
direction.y.atan2(direction.x)
};
let dir_ucs = glam::DVec3::new(ang.cos(), ang.sin(), 0.0);
@@ -439,11 +464,7 @@ pub(super) fn axis_lock_capture(
/// Project `pt` onto the locked ray through `base` — the hard lock applies to
/// EVERYTHING, including an osnap hit, so a snap far off-axis contributes only
/// its along-axis component (#312).
pub(super) fn axis_lock_apply(
pt: glam::DVec3,
base: glam::DVec3,
dir: glam::DVec3,
) -> glam::DVec3 {
pub(super) fn axis_lock_apply(pt: glam::DVec3, base: glam::DVec3, dir: glam::DVec3) -> glam::DVec3 {
base + dir * (pt - base).dot(dir)
}
@@ -481,8 +502,7 @@ pub(super) fn entities_lower_left_by_bbox(
/// Generate the next available auto group name ("*A1", "*A2", …).
pub(super) fn next_group_auto_name(scene: &crate::scene::Scene) -> String {
let existing: rustc_hash::FxHashSet<String> =
scene.groups().map(|g| g.name.clone()).collect();
let existing: rustc_hash::FxHashSet<String> = scene.groups().map(|g| g.name.clone()).collect();
for n in 1..=9999 {
let name = format!("*A{n}");
if !existing.contains(&name) {
+28
View File
@@ -487,6 +487,8 @@ pub(super) struct OpenCADStudio {
layer_translator: Option<crate::ui::window::layer_translator::State>,
/// Working copy of the Drawing Units dialog; `None` while it is closed.
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>,
/// Working copy of the structured feature-control-frame editor.
geometric_tolerance: Option<crate::ui::window::geometric_tolerance::State>,
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
@@ -1857,6 +1859,7 @@ pub enum ModalKind {
LayerStateManager,
LayerTranslator,
DrawingUnits,
BlockDefinition,
GeometricTolerance,
DraftingSettings,
AutoConstrainSettings,
@@ -2830,6 +2833,30 @@ pub enum Message {
DrawingUnitsField(crate::ui::window::drawing_units::Field),
/// Drawing Units OK — write the working copy into the drawing.
DrawingUnitsApply,
/// Block Definition dialog field updates
BlockDefName(String),
BlockDefNameSelect(String),
BlockDefBaseOnScreen(bool),
BlockDefPickPoint,
BlockDefBaseX(String),
BlockDefBaseY(String),
BlockDefBaseZ(String),
BlockDefObjectsOnScreen(bool),
BlockDefSelectObjects,
BlockDefQuickSelect,
BlockDefObjectMode(crate::ui::window::block_definition::BlockObjectMode),
BlockDefAnnotative(bool),
BlockDefMatchOrientation(bool),
BlockDefScaleUniformly(bool),
BlockDefAllowExploding(bool),
BlockDefUnit(i16),
BlockDefDescription(String),
BlockDefDescriptionAction(iced::widget::text_editor::Action),
BlockDefHyperlink,
BlockDefApply,
BlockDefConfirmRedefine(bool),
BlockDefDismissError,
BlockDefHelp,
/// One structured feature-control-frame field changed.
ToleranceDialogField(crate::ui::window::geometric_tolerance::Field),
/// One structured feature-control-frame option changed.
@@ -3902,6 +3929,7 @@ impl OpenCADStudio {
last_layer_translation: None,
layer_translator: None,
drawing_units: None,
block_definition: None,
geometric_tolerance: None,
pick_drag_rect: false,
perf_hud: false,
+3
View File
@@ -1117,6 +1117,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some(state) = self.qselect.take() {
self.qselect_settings = Some((&state).into());
self.reset_modal_geometry();
if self.block_definition.is_some() {
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
}
return Task::none();
}
{
+240
View File
@@ -1,4 +1,5 @@
use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode};
use crate::command::CadCommand;
use crate::scene::VIEWCUBE_DRAW_PX;
use crate::ui::PropertiesPanel;
use iced::time::Instant;
@@ -206,6 +207,7 @@ impl OpenCADStudio {
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
Some(BlockDefinition) => self.block_definition = None,
Some(Hyperlink) => {
self.hyperlink_editor_handles.clear();
self.hyperlink_editor_url.clear();
@@ -313,8 +315,34 @@ impl OpenCADStudio {
{
return self.update(Message::AliasEditorDraftCancel);
}
if self.active_modal == Some(super::ModalKind::BlockDefinition) {
if let Some(ref mut state) = self.block_definition {
if state.confirm_redefine.is_some() {
state.confirm_redefine = None;
return Task::none();
}
if state.error_message.is_some() {
state.error_message = None;
return Task::none();
}
}
}
return self.update(Message::CloseModal);
}
if self.active_modal == Some(super::ModalKind::BlockDefinition) {
if matches!(msg, Message::CommandFinalize)
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN"))
{
if let Some(ref state) = self.block_definition {
if state.confirm_redefine.is_some() {
// Default to "No" so an accidental Enter doesn't nuke a definition.
return self.update(Message::BlockDefConfirmRedefine(false));
} else {
return self.update(Message::BlockDefApply);
}
}
}
}
if is_modal_blocked_key_msg(&msg) {
return Task::none();
}
@@ -4213,6 +4241,12 @@ impl OpenCADStudio {
return Task::none();
}
let description = self.hyperlink_editor_description.trim().to_owned();
if let Some(block_def) = self.block_definition.as_mut() {
block_def.hyperlink_url = url;
block_def.hyperlink_desc = description;
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
return Task::none();
}
let values = if url.is_empty() {
None
} else {
@@ -4236,6 +4270,12 @@ impl OpenCADStudio {
Task::none()
}
Message::HyperlinkRemove => {
if let Some(block_def) = self.block_definition.as_mut() {
block_def.hyperlink_url.clear();
block_def.hyperlink_desc.clear();
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
return Task::none();
}
let i = self.active_tab;
let handles = self.hyperlink_editor_handles.clone();
self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| {
@@ -4250,6 +4290,10 @@ impl OpenCADStudio {
Task::none()
}
Message::HyperlinkCancel => {
if self.block_definition.is_some() {
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
return Task::none();
}
self.close_active_modal();
Task::none()
}
@@ -4606,6 +4650,189 @@ impl OpenCADStudio {
);
Task::none()
}
Message::BlockDefName(name) => {
if let Some(state) = self.block_definition.as_mut() {
state.name = name;
state.error_message = None;
state.confirm_redefine = None;
}
Task::none()
}
Message::BlockDefNameSelect(chosen) => {
if let Some(state) = self.block_definition.as_mut() {
state.name = chosen;
state.error_message = None;
state.confirm_redefine = None;
}
Task::none()
}
Message::BlockDefBaseOnScreen(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.base_point_specify_onscreen = val;
}
Task::none()
}
Message::BlockDefPickPoint => {
self.active_modal = None;
let cmd = crate::modules::insert::create_block::BlockPickBasePointCommand;
self.command_line.push_info(&cmd.prompt());
self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd));
Task::none()
}
Message::BlockDefBaseX(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.base_point_x = val;
}
Task::none()
}
Message::BlockDefBaseY(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.base_point_y = val;
}
Task::none()
}
Message::BlockDefBaseZ(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.base_point_z = val;
}
Task::none()
}
Message::BlockDefObjectsOnScreen(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.objects_specify_onscreen = val;
}
Task::none()
}
Message::BlockDefSelectObjects => {
self.active_modal = None;
use crate::modules::draw::select::SelectObjectsCommand;
let cmd = SelectObjectsCommand::plain("BLOCK", "BLOCK_OBJECTS_GATHERED");
self.command_line.push_info(&cmd.prompt());
self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd));
Task::none()
}
Message::BlockDefQuickSelect => {
self.active_modal = None;
self.on_qselect_open()
}
Message::BlockDefObjectMode(mode) => {
if let Some(state) = self.block_definition.as_mut() {
state.object_mode = mode;
}
Task::none()
}
Message::BlockDefAnnotative(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.annotative = val;
if !val {
state.match_orientation = false;
}
}
Task::none()
}
Message::BlockDefMatchOrientation(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.match_orientation = val;
}
Task::none()
}
Message::BlockDefScaleUniformly(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.scale_uniformly = val;
}
Task::none()
}
Message::BlockDefAllowExploding(val) => {
if let Some(state) = self.block_definition.as_mut() {
state.allow_exploding = val;
}
Task::none()
}
Message::BlockDefUnit(unit) => {
if let Some(state) = self.block_definition.as_mut() {
state.unit = unit;
}
Task::none()
}
Message::BlockDefDescription(desc) => {
if let Some(state) = self.block_definition.as_mut() {
state.description = desc;
}
Task::none()
}
Message::BlockDefDescriptionAction(action) => {
if let Some(state) = self.block_definition.as_mut() {
state.description_content.perform(action);
state.description = state.description_content.text();
}
Task::none()
}
Message::BlockDefHyperlink => {
if let Some(state) = self.block_definition.as_ref() {
self.hyperlink_editor_url = state.hyperlink_url.clone();
self.hyperlink_editor_description = state.hyperlink_desc.clone();
self.hyperlink_editor_mixed = false;
self.hyperlink_editor_dirty = false;
self.hyperlink_editor_handles.clear();
self.active_modal = Some(crate::app::ModalKind::Hyperlink);
}
Task::none()
}
Message::BlockDefDismissError => {
if let Some(state) = self.block_definition.as_mut() {
state.error_message = None;
}
Task::none()
}
Message::BlockDefHelp => {
self.command_line.push_info(
crate::t!("BLOCK creates a block definition from objects you select.").as_ref(),
);
Task::none()
}
Message::BlockDefConfirmRedefine(confirmed) => {
if !confirmed {
if let Some(state) = self.block_definition.as_mut() {
state.confirm_redefine = None;
}
return Task::none();
}
self.commit_block_definition(true)
}
Message::BlockDefApply => {
let Some(state) = self.block_definition.as_mut() else {
return Task::none();
};
let name = state.name.trim().to_string();
if name.is_empty() {
state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned());
return Task::none();
}
if let Some(_ch) = state.invalid_name_char() {
state.error_message = Some(
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(),
);
return Task::none();
}
if name.starts_with('*') {
state.error_message =
Some(crate::t!("Block name cannot start with '*'.").into_owned());
return Task::none();
}
if !state.objects_specify_onscreen && state.selected_handles.is_empty() {
state.error_message = Some(
crate::t!("No objects selected. You must select objects to define a block.")
.into_owned(),
);
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(&name).is_some() {
state.confirm_redefine = Some(name);
return Task::none();
}
self.commit_block_definition(false)
}
Message::ToleranceDialogField(field) => {
if let Some(state) = self.geometric_tolerance.as_mut() {
state.apply_field(field);
@@ -5558,6 +5785,9 @@ impl OpenCADStudio {
self.qselect_settings = Some((&state).into());
}
self.reset_modal_geometry();
if self.block_definition.is_some() {
self.active_modal = Some(super::ModalKind::BlockDefinition);
}
Task::none()
}
@@ -5754,6 +5984,16 @@ impl OpenCADStudio {
self.command_line
.push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref());
self.refresh_properties();
if let Some(ref mut block_def) = self.block_definition {
block_def.selected_handles = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
block_def.error_message = None;
self.active_modal = Some(super::ModalKind::BlockDefinition);
}
Task::none()
}
+7
View File
@@ -23,6 +23,7 @@ impl OpenCADStudio {
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::BlockDefinition) => crate::t!("Block Definition").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(),
@@ -499,6 +500,12 @@ impl OpenCADStudio {
crate::ui::window::drawing_units::view_window(state, flow)
})
}
super::super::ModalKind::BlockDefinition => {
let state = self.block_definition.as_ref()?;
sized_flow(ex, 580, 390, |flow| {
crate::ui::window::block_definition::view_window(state, flow)
})
}
super::super::ModalKind::GeometricTolerance => {
let state = self.geometric_tolerance.as_ref()?;
sized_flow(ex, 670, 530, |flow| {
+4
View File
@@ -1520,6 +1520,10 @@ pub enum CmdResult {
name: String,
base: DVec3,
},
/// Create a block definition with full dialog options (Retain/Convert/Delete, units, annotative, etc.).
CreateBlockWithOptions {
options: Box<crate::scene::CreateBlockOptions>,
},
/// Apply a transform to selected entities and end the command.
TransformSelected(Vec<Handle>, EntityTransform),
/// Copy selected entities with a transform; command stays active for more copies.
+26 -5
View File
@@ -98,11 +98,20 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec<Entity
{
vec![]
}
EntityType::Insert(ins) => ins
.explode_from_document(document)
.into_iter()
.map(normalize_insert_entity)
.collect(),
EntityType::Insert(ins) => {
let explodable = document
.block_records
.get(&ins.block_name)
.map_or(true, |br| br.explodable);
if !explodable {
vec![]
} else {
ins.explode_from_document(document)
.into_iter()
.map(normalize_insert_entity)
.collect()
}
}
EntityType::MLine(ml) => explode_mline(ml),
EntityType::Dimension(dim) => explode_dimension(dim, document),
_ => vec![],
@@ -1500,6 +1509,18 @@ mod tests {
assert!(explode_entity(&EntityType::Insert(insert), &doc).is_empty());
}
#[test]
fn unexplodable_block_is_not_exploded() {
let mut doc = CadDocument::new();
let mut block = acadrust::tables::BlockRecord::new("NO_EXPLODE");
block.handle = doc.allocate_handle();
block.explodable = false;
doc.block_records.add(block).unwrap();
let insert = acadrust::entities::Insert::new("NO_EXPLODE", Vector3::new(0.0, 0.0, 0.0));
assert!(explode_entity(&EntityType::Insert(insert), &doc).is_empty());
}
/// A dimension created without a geometry block gets a real `*D` block on
/// bake, its `block_name` resolves to that block, and a second bake is a
/// no-op (already has a valid block).
+558 -1
View File
@@ -1,6 +1,6 @@
use crate::t;
use acadrust::Handle;
use glam::DVec3;
use crate::t;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
@@ -92,3 +92,560 @@ impl CadCommand for CreateBlockCommand {
vec![]
}
}
/// One-shot point picker for the Block Definition dialog's "Pick point" button.
pub struct BlockPickBasePointCommand;
impl CadCommand for BlockPickBasePointCommand {
fn name(&self) -> &'static str {
"BLOCK"
}
fn prompt(&self) -> String {
crate::t!("BLOCK Specify insertion base point:").into_owned()
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
CmdResult::Dispatch(format!("BLOCK_POINT_PICKED {} {} {}", pt.x, pt.y, pt.z))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch("BLOCK_POINT_CANCELLED".to_string())
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Dispatch("BLOCK_POINT_CANCELLED".to_string())
}
}
/// Command handling the on-screen prompting sequence when "Specify On-screen" is checked in the dialog.
/// Enforces standard command ordering: Base point is ALWAYS prompted before object selection.
pub struct BlockOnScreenCommand {
options: crate::scene::CreateBlockOptions,
ucs: crate::app::helpers::UcsXform,
need_base: bool,
need_objects: bool,
}
impl BlockOnScreenCommand {
pub fn new(
options: crate::scene::CreateBlockOptions,
ucs: crate::app::helpers::UcsXform,
need_base: bool,
need_objects: bool,
) -> Self {
Self {
options,
ucs,
need_base,
need_objects,
}
}
}
impl CadCommand for BlockOnScreenCommand {
fn name(&self) -> &'static str {
"BLOCK"
}
fn prompt(&self) -> String {
if self.need_base {
crate::t!("BLOCK Specify insertion base point:").into_owned()
} else {
crate::t!("BLOCK Select objects:").into_owned()
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if self.need_base {
self.options.base_point = pt;
self.options.world_to_block = self.ucs.to_ucs_transform_at(pt);
self.options.block_to_world = self.ucs.to_wcs_transform_at(pt);
self.need_base = false;
if self.need_objects {
CmdResult::NeedPoint
} else {
CmdResult::CreateBlockWithOptions {
options: Box::new(self.options.clone()),
}
}
} else {
CmdResult::NeedPoint
}
}
fn is_selection_gathering(&self) -> bool {
!self.need_base && self.need_objects
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
self.options.handles = handles;
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
if self.need_base {
return CmdResult::Cancel;
}
if self.options.handles.is_empty() {
return CmdResult::Cancel;
}
CmdResult::CreateBlockWithOptions {
options: Box::new(self.options.clone()),
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{CreateBlockOptions, Scene};
use crate::ui::window::block_definition::BlockObjectMode;
use acadrust::entities::Line;
use acadrust::types::{Transform, Vector3};
fn make_test_scene() -> (Scene, Handle) {
let mut scene = Scene::new();
let mut line = Line::new();
line.start = Vector3::new(0.0, 0.0, 0.0);
line.end = Vector3::new(10.0, 10.0, 0.0);
let h = scene.add_entity(acadrust::EntityType::Line(line));
(scene, h)
}
#[test]
fn test_create_block_retain_mode() {
let (mut scene, line_h) = make_test_scene();
let options = CreateBlockOptions {
name: "BLK_RETAIN".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "Retain test".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let result = scene.create_block_with_options(options);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Handle::NULL); // No insert created in Retain mode
// Original line must still exist in document
assert!(scene.document.get_entity(line_h).is_some());
// Block definition must exist
assert!(scene.document.block_records.get("BLK_RETAIN").is_some());
}
#[test]
fn test_create_block_convert_mode() {
let (mut scene, line_h) = make_test_scene();
let options = CreateBlockOptions {
name: "BLK_CONVERT".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: true,
allow_exploding: true,
unit: 4, // mm
description: "Convert test".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let result = scene.create_block_with_options(options);
assert!(result.is_ok());
let insert_h = result.unwrap();
assert_ne!(insert_h, Handle::NULL);
// Original line was erased from model space
assert!(scene.document.get_entity(line_h).is_none());
// Insert entity exists in drawing
let insert_entity = scene.document.get_entity(insert_h);
assert!(insert_entity.is_some());
if let Some(acadrust::EntityType::Insert(ins)) = insert_entity {
assert_eq!(ins.block_name, "BLK_CONVERT");
} else {
panic!("Expected Insert entity");
}
// Check BlockRecord properties
let br = scene.document.block_records.get("BLK_CONVERT").unwrap();
assert_eq!(br.units, 4);
assert!(br.scale_uniformly);
assert!(br.explodable);
assert_eq!(br.description, "Convert test");
}
#[test]
fn test_create_block_delete_mode() {
let (mut scene, line_h) = make_test_scene();
let options = CreateBlockOptions {
name: "BLK_DELETE".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Delete,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let result = scene.create_block_with_options(options);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Handle::NULL);
// Original line erased
assert!(scene.document.get_entity(line_h).is_none());
// Block definition exists
assert!(scene.document.block_records.get("BLK_DELETE").is_some());
}
#[test]
fn test_create_block_redefinition() {
let (mut scene, line1_h) = make_test_scene();
let options1 = CreateBlockOptions {
name: "REDEF_BLOCK".to_string(),
handles: vec![line1_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "Initial".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
assert!(scene.create_block_with_options(options1).is_ok());
// Attempting to create duplicate without redefine flag fails
let line2_h = {
let mut line2 = Line::new();
line2.start = Vector3::new(5.0, 5.0, 0.0);
line2.end = Vector3::new(15.0, 15.0, 0.0);
scene.add_entity(acadrust::EntityType::Line(line2))
};
let options2 = CreateBlockOptions {
name: "REDEF_BLOCK".to_string(),
handles: vec![line2_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 6, // meters
description: "Redefined".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let err_res = scene.create_block_with_options(options2.clone());
assert!(err_res.is_err());
assert!(err_res.unwrap_err().contains("already exists"));
// Redefine with redefine: true succeeds and updates fields
let mut options3 = options2;
options3.redefine = true;
let redef_res = scene.create_block_with_options(options3);
assert!(redef_res.is_ok());
let br = scene.document.block_records.get("REDEF_BLOCK").unwrap();
assert_eq!(br.description, "Redefined");
assert_eq!(br.units, 6);
}
#[test]
fn test_create_block_validation() {
let (mut scene, line_h) = make_test_scene();
// Empty name
let opt_empty_name = CreateBlockOptions {
name: " ".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
assert!(scene.create_block_with_options(opt_empty_name).is_err());
// Anonymous '*' prefix blocked
let opt_star = CreateBlockOptions {
name: "*ANON".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
assert!(scene.create_block_with_options(opt_star).is_err());
// Empty handles
let opt_no_handles = CreateBlockOptions {
name: "EMPTY_HANDLES".to_string(),
handles: vec![],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Retain,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
assert!(scene.create_block_with_options(opt_no_handles).is_err());
}
#[test]
fn test_block_on_screen_command_ordering() {
let dummy_options = CreateBlockOptions {
name: "ON_SCREEN_TEST".to_string(),
handles: vec![],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
// When both base point and objects are to be specified on-screen:
let ucs = crate::app::helpers::UcsXform::identity();
let mut cmd = BlockOnScreenCommand::new(dummy_options, ucs, true, true);
// 1. Initial step must be insertion base point
assert!(cmd.prompt().contains("Specify insertion base point"));
// 2. Pick base point -> prompt switches to object selection
let picked_pt = DVec3::new(10.0, 20.0, 0.0);
let res1 = cmd.on_point(picked_pt);
assert!(matches!(res1, CmdResult::NeedPoint));
assert!(cmd.prompt().contains("Select objects"));
assert!(cmd.is_selection_gathering());
// 3. User attempts enter before selecting objects -> cancelled
assert!(matches!(cmd.on_enter(), CmdResult::Cancel));
// 4. Complete selection with handles
let fake_handle = Handle::new(42);
cmd.on_selection_complete(vec![fake_handle]);
// 5. User presses enter to finish selection
let finish = cmd.on_enter();
match finish {
CmdResult::CreateBlockWithOptions { options } => {
assert_eq!(options.name, "ON_SCREEN_TEST");
assert_eq!(options.base_point, picked_pt);
assert_eq!(options.handles, vec![fake_handle]);
assert_eq!(options.world_to_block, ucs.to_ucs_transform_at(picked_pt));
assert_eq!(options.block_to_world, ucs.to_wcs_transform_at(picked_pt));
}
_ => panic!("Expected CreateBlockWithOptions"),
}
}
#[test]
fn test_block_on_screen_base_point_sets_correct_transforms() {
let (mut scene, line_h) = make_test_scene();
let initial_options = CreateBlockOptions {
name: "ON_SCREEN_BASE_TEST".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
// User picked objects upfront in dialog, but checked "Specify base point on screen"
let ucs = crate::app::helpers::UcsXform::identity();
let mut cmd = BlockOnScreenCommand::new(initial_options, ucs, true, false);
// Pick base point at (10, 10, 0) (end of the line in make_test_scene)
let pick_pt = DVec3::new(10.0, 10.0, 0.0);
let res = cmd.on_point(pick_pt);
let created_options = match res {
CmdResult::CreateBlockWithOptions { options } => *options,
_ => {
panic!("Expected CreateBlockWithOptions immediately when objects already selected")
}
};
assert_eq!(created_options.base_point, pick_pt);
assert_eq!(
created_options.world_to_block,
ucs.to_ucs_transform_at(pick_pt)
);
assert_eq!(
created_options.block_to_world,
ucs.to_wcs_transform_at(pick_pt)
);
// Create the block with these options and verify insert position and definition entities
let insert_h = scene.create_block_with_options(created_options).unwrap();
assert_ne!(insert_h, Handle::NULL);
// In Convert mode, the insert entity must be placed at the picked base point
let insert_entity = scene.document.get_entity(insert_h).unwrap();
if let acadrust::EntityType::Insert(ins) = insert_entity {
assert_eq!(ins.block_name, "ON_SCREEN_BASE_TEST");
assert!((ins.insert_point.x - 10.0).abs() < 1e-6);
assert!((ins.insert_point.y - 10.0).abs() < 1e-6);
assert!((ins.insert_point.z - 0.0).abs() < 1e-6);
} else {
panic!("Expected Insert entity");
}
// Inside the block table record, the line entity must have been transformed relative to (10, 10, 0)
// Original line was (0,0,0) to (10,10,0), so inside the block it must be (-10,-10,0) to (0,0,0)
let br = scene
.document
.block_records
.get("ON_SCREEN_BASE_TEST")
.unwrap();
let block_line = scene
.document
.entities()
.find(|e| {
e.common().owner_handle == br.handle && matches!(e, acadrust::EntityType::Line(_))
})
.unwrap();
if let acadrust::EntityType::Line(l) = block_line {
assert!((l.start.x - (-10.0)).abs() < 1e-6);
assert!((l.start.y - (-10.0)).abs() < 1e-6);
assert!((l.end.x - 0.0).abs() < 1e-6);
assert!((l.end.y - 0.0).abs() < 1e-6);
} else {
panic!("Expected Line entity inside block");
}
}
#[test]
fn test_explodable_flag_prevents_explode() {
let (mut scene, line_h) = make_test_scene();
let options = CreateBlockOptions {
name: "BLK_NO_EXPLODE".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: false,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let insert_h = scene.create_block_with_options(options).unwrap();
let insert = scene.document.get_entity(insert_h).unwrap();
let pieces = crate::modules::draw::modify::explode::explode_entity(insert, &scene.document);
assert!(pieces.is_empty(), "Unexplodable block must not explode");
}
#[test]
fn test_explodable_flag_allows_explode() {
let (mut scene, line_h) = make_test_scene();
let options = CreateBlockOptions {
name: "BLK_CAN_EXPLODE".to_string(),
handles: vec![line_h],
base_point: DVec3::ZERO,
world_to_block: Transform::identity(),
block_to_world: Transform::identity(),
mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: "".to_string(),
hyperlink_url: "".to_string(),
hyperlink_desc: "".to_string(),
redefine: false,
};
let insert_h = scene.create_block_with_options(options).unwrap();
let insert = scene.document.get_entity(insert_h).unwrap();
let pieces = crate::modules::draw::modify::explode::explode_entity(insert, &scene.document);
assert_eq!(
pieces.len(),
1,
"Explodable block should explode into 1 line"
);
}
}
+169 -42
View File
@@ -1,6 +1,24 @@
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
#[derive(Debug, Clone)]
pub struct CreateBlockOptions {
pub name: String,
pub handles: Vec<Handle>,
pub base_point: glam::DVec3,
pub world_to_block: acadrust::types::Transform,
pub block_to_world: acadrust::types::Transform,
pub mode: crate::ui::window::block_definition::BlockObjectMode,
pub annotative: bool,
pub match_orientation: bool,
pub scale_uniformly: bool,
pub allow_exploding: bool,
pub unit: i16,
pub description: String,
pub hyperlink_url: String,
pub hyperlink_desc: String,
pub redefine: bool,
}
/// Order sampled boundary edges into one tip-to-tail loop.
pub(super) fn chain_path_edges(polys: Vec<Vec<[f64; 2]>>) -> Vec<[f64; 2]> {
chain_path_edges_with_directions(polys).0
@@ -845,18 +863,151 @@ impl Scene {
world_to_block: &acadrust::types::Transform,
block_to_world: &acadrust::types::Transform,
) -> Result<Handle, String> {
let name = name.trim();
self.create_block_with_options(CreateBlockOptions {
name: name.to_string(),
handles: handles.to_vec(),
base_point: glam::DVec3::ZERO,
world_to_block: *world_to_block,
block_to_world: *block_to_world,
mode: crate::ui::window::block_definition::BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: 0,
description: String::new(),
hyperlink_url: String::new(),
hyperlink_desc: String::new(),
redefine: false,
})
}
pub fn create_block_with_options(
&mut self,
options: CreateBlockOptions,
) -> Result<Handle, String> {
let name = options.name.trim();
if name.is_empty() {
return Err("Block name cannot be empty.".into());
}
if name.starts_with('*') {
return Err("Block name cannot start with '*'.".into());
}
if self.document.block_records.get(name).is_some() {
return Err(format!("Block \"{name}\" already exists."));
}
let source_entities: Vec<_> = handles
let existing = self
.document
.block_records
.get(name)
.map(|br| (br.handle, br.block_entity_handle));
let (br_handle, _block_handle) = if let Some((existing_br_h, existing_blk_h)) = existing {
if !options.redefine {
return Err(format!("Block \"{name}\" already exists."));
}
// Redefining: remove all existing entities owned by this block record (except Block/BlockEnd)
let owned: Vec<Handle> = self
.document
.entities()
.filter(|e| {
e.common().owner_handle == existing_br_h
&& !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))
})
.map(|e| e.common().handle)
.collect();
for h in owned {
self.document.remove_entity(h);
}
// Update existing BlockRecord fields
if let Some(br) = self.document.block_records.get_mut(name) {
br.units = options.unit;
br.description = options.description.clone();
br.explodable = options.allow_exploding;
br.scale_uniformly = options.scale_uniformly;
}
// Update existing Block entity fields
if let Some(EntityType::Block(b)) = self.document.get_entity_mut(existing_blk_h) {
b.description = options.description.clone();
}
if options.annotative {
let annotative_vals = vec![acadrust::xdata::XDataValue::Integer16(1)];
view::dispatch::set_entity_xdata(
&mut self.document,
existing_blk_h,
"AcadAnnotative",
Some(annotative_vals),
);
}
if !options.hyperlink_url.is_empty() {
let link_vals = vec![acadrust::xdata::XDataValue::String(
options.hyperlink_url.clone(),
)];
view::dispatch::set_entity_xdata(
&mut self.document,
existing_blk_h,
"PE_URL",
Some(link_vals),
);
}
(existing_br_h, existing_blk_h)
} else {
let next = self.document.next_handle();
let br_handle = Handle::new(next);
let block_handle = Handle::new(next + 1);
let end_handle = Handle::new(next + 2);
let mut block_record = acadrust::tables::BlockRecord::new(name);
block_record.handle = br_handle;
block_record.block_entity_handle = block_handle;
block_record.block_end_handle = end_handle;
block_record.units = options.unit;
block_record.description = options.description.clone();
block_record.explodable = options.allow_exploding;
block_record.scale_uniformly = options.scale_uniformly;
self.document
.block_records
.add(block_record)
.map_err(|e| e.to_string())?;
let mut block = Block::new(name, acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = br_handle;
block.description = options.description.clone();
self.document
.add_entity(EntityType::Block(block))
.map_err(|e| e.to_string())?;
let mut block_end = BlockEnd::new();
block_end.common.handle = end_handle;
block_end.common.owner_handle = br_handle;
self.document
.add_entity(EntityType::BlockEnd(block_end))
.map_err(|e| e.to_string())?;
if options.annotative {
let annotative_vals = vec![acadrust::xdata::XDataValue::Integer16(1)];
view::dispatch::set_entity_xdata(
&mut self.document,
block_handle,
"AcadAnnotative",
Some(annotative_vals),
);
}
if !options.hyperlink_url.is_empty() {
let link_vals = vec![acadrust::xdata::XDataValue::String(
options.hyperlink_url.clone(),
)];
view::dispatch::set_entity_xdata(
&mut self.document,
block_handle,
"PE_URL",
Some(link_vals),
);
}
(br_handle, block_handle)
};
let source_entities: Vec<_> = options
.handles
.iter()
.filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e)))
.collect();
@@ -864,35 +1015,7 @@ impl Scene {
return Err("No valid entities selected for block creation.".into());
}
let next = self.document.next_handle();
let br_handle = Handle::new(next);
let block_handle = Handle::new(next + 1);
let end_handle = Handle::new(next + 2);
let mut block_record = acadrust::tables::BlockRecord::new(name);
block_record.handle = br_handle;
block_record.block_entity_handle = block_handle;
block_record.block_end_handle = end_handle;
self.document
.block_records
.add(block_record)
.map_err(|e| e.to_string())?;
let mut block = Block::new(name, acadrust::types::Vector3::ZERO);
block.common.handle = block_handle;
block.common.owner_handle = br_handle;
self.document
.add_entity(EntityType::Block(block))
.map_err(|e| e.to_string())?;
let mut block_end = BlockEnd::new();
block_end.common.handle = end_handle;
block_end.common.owner_handle = br_handle;
self.document
.add_entity(EntityType::BlockEnd(block_end))
.map_err(|e| e.to_string())?;
let local = EntityTransform::Affine(*world_to_block);
let local = EntityTransform::Affine(options.world_to_block);
for (old_handle, mut entity) in source_entities {
view::dispatch::apply_transform(&mut entity, &local);
entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity);
@@ -901,15 +1024,19 @@ impl Scene {
self.document
.add_entity(entity)
.map_err(|e| e.to_string())?;
self.erase_entities(&[old_handle]);
if options.mode != crate::ui::window::block_definition::BlockObjectMode::Retain {
self.erase_entities(&[old_handle]);
}
}
let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO);
acadrust::Entity::apply_transform(&mut insert, block_to_world);
let insert_handle = self.add_entity(EntityType::Insert(insert));
// A new block definition landed in the document; advance the block
// epoch so consumers (the block palette stale check) notice it even
// when the panel stays open. Mirrors `define_block_from_owned_entities`.
let mut insert_handle = Handle::NULL;
if options.mode == crate::ui::window::block_definition::BlockObjectMode::Convert {
let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO);
acadrust::Entity::apply_transform(&mut insert, &options.block_to_world);
insert_handle = self.add_entity(EntityType::Insert(insert));
}
// Advance the block epoch so all consumers and viewport renderers re-tessellate
self.bump_geometry();
Ok(insert_handle)
}
+1
View File
@@ -36,6 +36,7 @@ pub use dimension_assoc::{ReferenceStatus, ResolvedReference};
pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins};
mod dwg_native_constraints;
mod entity;
pub use entity::CreateBlockOptions;
#[cfg(test)]
mod hatch_boundary;
mod group_layer;
+696
View File
@@ -0,0 +1,696 @@
//! Block Definition dialog window — create or redefine a block definition.
//! Prompted when the user runs the `BLOCK` or `BMAKE` command.
use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text,
text_editor, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::modules::draw::units;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg");
static ICON_SELECT_OBJECTS: &[u8] =
include_bytes!("../../../assets/icons/blocks/select_objects.svg");
static ICON_QUICK_SELECT: &[u8] = include_bytes!("../../../assets/icons/blocks/quick_select.svg");
static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg");
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum BlockObjectMode {
Retain,
Convert,
Delete,
}
impl Default for BlockObjectMode {
fn default() -> Self {
BlockObjectMode::Convert
}
}
/// Dropdown entry for the Block Unit picker.
#[derive(Clone, PartialEq, Eq)]
pub struct UnitChoice {
pub code: i16,
pub label: &'static str,
}
impl fmt::Display for UnitChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = crate::i18n::translate(self.label);
f.write_str(name.as_ref())
}
}
/// The Block Definition dialog's working copy.
pub struct BlockDefinitionState {
pub name: String,
pub existing_names: Vec<String>,
pub name_combo: combo_box::State<String>,
pub base_point_specify_onscreen: bool,
pub base_point_x: String,
pub base_point_y: String,
pub base_point_z: String,
pub objects_specify_onscreen: bool,
pub selected_handles: Vec<Handle>,
pub object_mode: BlockObjectMode,
pub annotative: bool,
pub match_orientation: bool,
pub scale_uniformly: bool,
pub allow_exploding: bool,
pub unit: i16,
pub description: String,
pub description_content: text_editor::Content,
pub hyperlink_url: String,
pub hyperlink_desc: String,
pub error_message: Option<String>,
pub confirm_redefine: Option<String>,
}
impl Clone for BlockDefinitionState {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
existing_names: self.existing_names.clone(),
name_combo: self.name_combo.clone(),
base_point_specify_onscreen: self.base_point_specify_onscreen,
base_point_x: self.base_point_x.clone(),
base_point_y: self.base_point_y.clone(),
base_point_z: self.base_point_z.clone(),
objects_specify_onscreen: self.objects_specify_onscreen,
selected_handles: self.selected_handles.clone(),
object_mode: self.object_mode,
annotative: self.annotative,
match_orientation: self.match_orientation,
scale_uniformly: self.scale_uniformly,
allow_exploding: self.allow_exploding,
unit: self.unit,
description: self.description.clone(),
description_content: text_editor::Content::with_text(&self.description),
hyperlink_url: self.hyperlink_url.clone(),
hyperlink_desc: self.hyperlink_desc.clone(),
error_message: self.error_message.clone(),
confirm_redefine: self.confirm_redefine.clone(),
}
}
}
impl BlockDefinitionState {
pub fn new(existing_names: Vec<String>, pre_selected: Vec<Handle>, default_unit: i16) -> Self {
Self {
name: String::new(),
name_combo: combo_box::State::new(existing_names.clone()),
existing_names,
base_point_specify_onscreen: false,
base_point_x: "0.0000".to_string(),
base_point_y: "0.0000".to_string(),
base_point_z: "0.0000".to_string(),
objects_specify_onscreen: false,
selected_handles: pre_selected,
object_mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: default_unit,
description: String::new(),
description_content: text_editor::Content::new(),
hyperlink_url: String::new(),
hyperlink_desc: String::new(),
error_message: None,
confirm_redefine: None,
}
}
pub fn parse_base_point(&self) -> glam::DVec3 {
let x = self.base_point_x.trim().parse::<f64>().unwrap_or(0.0);
let y = self.base_point_y.trim().parse::<f64>().unwrap_or(0.0);
let z = self.base_point_z.trim().parse::<f64>().unwrap_or(0.0);
glam::DVec3::new(x, y, z)
}
pub fn is_existing_name(&self) -> bool {
let trimmed = self.name.trim();
!trimmed.is_empty()
&& self
.existing_names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
}
pub fn invalid_name_char(&self) -> Option<char> {
self.name.chars().find(|&c| {
matches!(
c,
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '=' | '`'
)
})
}
}
fn group<'a>(
title: impl Into<String>,
body: impl Into<Element<'a, Message>>,
height: impl Into<Length>,
) -> Element<'a, Message> {
container(
column![
text(title.into())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
body.into(),
]
.spacing(4),
)
.padding([5, 8])
.width(Fill)
.height(height)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
fn labeled_checkbox<'a>(
label: impl Into<String>,
is_checked: bool,
on_toggle: impl Fn(bool) -> Message + 'a,
) -> Element<'a, Message> {
row![
checkbox(is_checked).on_toggle(on_toggle).size(14),
text(label.into()).size(11),
]
.spacing(5)
.align_y(iced::Center)
.into()
}
pub fn view_window<'a>(
state: &'a BlockDefinitionState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Name Row ─────────────────────────────────────────────────────────────
let name_combo = combo_box(
&state.name_combo,
t!("Block name").as_ref(),
if state.name.is_empty() {
None
} else {
Some(&state.name)
},
|chosen: String| Message::BlockDefNameSelect(chosen),
)
.on_input(Message::BlockDefName)
.size(11)
.padding([3, 6])
.width(Fill);
let name_hint: Option<Element<'a, Message>> = if let Some(invalid_char) =
state.invalid_name_char()
{
Some(
text(crate::tf!("Invalid character '{invalid_char}' in block name (\\ / : * ? \" < > | = ` are not allowed)"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().danger.base.color),
})
.into(),
)
} else if state.is_existing_name() {
Some(
text(crate::tf!(
"Block \"{}\" already exists — will redefine upon OK",
state.name.trim()
))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().primary.base.color),
})
.into(),
)
} else {
None
};
let mut name_column = column![text(t!("Name:")).size(11).style(muted_style), name_combo,]
.spacing(3)
.width(Fill);
if let Some(hint) = name_hint {
name_column = name_column.push(hint);
}
let name_section = name_column;
// ── Group: Base Point ─────────────────────────────────────────────────────
let base_onscreen = labeled_checkbox(
t!("Specify On-screen"),
state.base_point_specify_onscreen,
Message::BlockDefBaseOnScreen,
);
let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 15.0);
let mut pick_pt_btn = button(
row![pick_pt_icon, text(t!("Pick point")).size(11)]
.spacing(5)
.align_y(iced::Center),
)
.padding([3, 8]);
if !state.base_point_specify_onscreen {
pick_pt_btn = pick_pt_btn
.on_press(Message::BlockDefPickPoint)
.style(button_style(false));
}
let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| {
let is_enabled = !state.base_point_specify_onscreen;
let input: Element<'a, Message> = if is_enabled {
text_input("", val)
.on_input(on_input)
.on_submit(Message::BlockDefApply)
.style(field_style)
.size(11)
.padding([2, 5])
.width(Fill)
.into()
} else {
crate::ui::read_only::field(val, 11.0, Fill)
};
row![
text(axis)
.size(11)
.style(muted_style)
.width(Length::Fixed(16.0)),
input,
]
.spacing(4)
.align_y(iced::Center)
};
let base_point_group = group(
t!("Base point").into_owned(),
column![
base_onscreen,
Space::new().height(1),
pick_pt_btn,
Space::new().height(2),
coord_row("X:", &state.base_point_x, Message::BlockDefBaseX),
coord_row("Y:", &state.base_point_y, Message::BlockDefBaseY),
coord_row("Z:", &state.base_point_z, Message::BlockDefBaseZ),
]
.spacing(3),
Length::Fixed(152.0),
);
// ── Group: Objects ────────────────────────────────────────────────────────
let objects_onscreen = labeled_checkbox(
t!("Specify On-screen"),
state.objects_specify_onscreen,
Message::BlockDefObjectsOnScreen,
);
let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 15.0);
let mut sel_obj_btn = button(
row![sel_obj_icon, text(t!("Select objects")).size(11)]
.spacing(5)
.align_y(iced::Center),
)
.padding([3, 8]);
if !state.objects_specify_onscreen {
sel_obj_btn = sel_obj_btn
.on_press(Message::BlockDefSelectObjects)
.style(button_style(false));
}
let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 15.0);
let mut qsel_btn = button(qsel_icon).padding([3, 5]).style(button_style(false));
if !state.objects_specify_onscreen {
qsel_btn = qsel_btn.on_press(Message::BlockDefQuickSelect);
}
let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center);
let radios = column![
radio(
t!("Retain"),
BlockObjectMode::Retain,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
t!("Convert to block"),
BlockObjectMode::Convert,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
t!("Delete"),
BlockObjectMode::Delete,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
]
.spacing(2);
let count = state.selected_handles.len();
let status_row: Element<'a, Message> = if count == 0 {
row![
crate::ui::icons::semantic(ICON_WARN, 13.0),
text(t!("No objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.75)),
}),
]
.spacing(5)
.align_y(iced::Center)
.into()
} else {
row![text(crate::tf!("{count} objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().primary.base.color),
}),]
.spacing(5)
.align_y(iced::Center)
.into()
};
let objects_group = group(
t!("Objects").into_owned(),
column![
objects_onscreen,
Space::new().height(1),
btns_row,
Space::new().height(2),
radios,
Space::new().height(2),
status_row,
]
.spacing(2),
Length::Fixed(152.0),
);
// ── Group: Behavior ───────────────────────────────────────────────────────
let annotative_cb = labeled_checkbox(
t!("Annotative"),
state.annotative,
Message::BlockDefAnnotative,
);
let match_orient_cb: Element<'a, Message> = if state.annotative {
row![
Space::new().width(16),
checkbox(state.match_orientation)
.on_toggle(Message::BlockDefMatchOrientation)
.size(13),
column![
text(t!("Match block orientation")).size(10),
text(t!("to layout")).size(10),
]
.spacing(1),
]
.spacing(5)
.align_y(iced::Center)
.into()
} else {
row![
Space::new().width(16),
checkbox(false).size(13),
column![
text(t!("Match block orientation"))
.size(10)
.style(muted_style),
text(t!("to layout")).size(10).style(muted_style),
]
.spacing(1),
]
.spacing(5)
.align_y(iced::Center)
.into()
};
let scale_uniform_cb = labeled_checkbox(
t!("Scale uniformly"),
state.scale_uniformly,
Message::BlockDefScaleUniformly,
);
let allow_explode_cb = labeled_checkbox(
t!("Allow exploding"),
state.allow_exploding,
Message::BlockDefAllowExploding,
);
let behavior_group = group(
t!("Behavior").into_owned(),
column![
annotative_cb,
Space::new().height(2),
match_orient_cb,
Space::new().height(4),
scale_uniform_cb,
Space::new().height(4),
allow_explode_cb,
]
.spacing(3),
Length::Fixed(152.0),
);
// ── Group: Settings (Column 1 Bottom) ─────────────────────────────────────
let unit_options: Vec<UnitChoice> = units::all()
.map(|(code, label)| UnitChoice { code, label })
.collect();
let selected_unit = unit_options
.iter()
.find(|choice| choice.code == state.unit)
.cloned();
let unit_picker = pick_list(selected_unit, unit_options, |choice| choice.to_string())
.on_select(|choice| Message::BlockDefUnit(choice.code))
.text_size(11)
.padding([2, 5])
.width(Fill);
let hyperlink_btn = button(
text(if state.hyperlink_url.is_empty() {
t!("Hyperlink...")
} else {
t!("Hyperlink*")
})
.size(11),
)
.on_press(Message::BlockDefHyperlink)
.style(button_style(false))
.padding([3, 14]);
let settings_group = group(
t!("Settings").into_owned(),
column![
text(t!("Block unit:")).size(10).style(muted_style),
unit_picker,
Space::new().height(6),
row![
Space::new().width(Fill),
hyperlink_btn,
Space::new().width(Fill)
],
]
.spacing(3),
Length::Fixed(102.0),
);
// ── Group: Description (Column 2 Bottom) ──────────────────────────────────
let desc_editor = text_editor(&state.description_content)
.on_action(Message::BlockDefDescriptionAction)
.placeholder("Enter block description...")
.size(11)
.padding([4, 6])
.height(Fill)
.style(|theme: &Theme, status| {
let palette = theme.palette();
let border_color = match status {
text_editor::Status::Focused { .. } => palette.primary.base.color,
_ => palette.background.neutral.color,
};
text_editor::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: border_color,
width: 1.0,
radius: 3.0.into(),
},
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
});
let description_group = group(
t!("Description").into_owned(),
desc_editor,
Length::Fixed(102.0),
);
// ── Layout Assembly: Left Column & Right Column ───────────────────────────
let left_column = column![base_point_group, settings_group,]
.spacing(6)
.width(Length::FillPortion(1));
let top_right_row = row![objects_group, behavior_group,].spacing(6).width(Fill);
let right_column = column![top_right_row, description_group,]
.spacing(6)
.width(Length::FillPortion(2));
let main_body = row![left_column, right_column].spacing(6).width(Fill);
// ── Footer Bar ────────────────────────────────────────────────────────────
let actions = row![
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(Message::BlockDefApply)
.style(button_style(true))
.padding([4, 16]),
button(text(t!("Cancel")).size(11))
.on_press(Message::CloseModal)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Help")).size(11))
.on_press(Message::BlockDefHelp)
.style(button_style(false))
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
let mut main_column = column![name_section, main_body, actions,]
.spacing(6)
.padding([8, 10])
.width(sizing.width);
if let Some(err) = &state.error_message {
let err_banner = container(
row![
crate::ui::icons::semantic(ICON_WARN, 16.0),
text(err.clone())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text),
}),
Space::new().width(Fill),
button(text("×").size(12))
.on_press(Message::BlockDefDismissError)
.style(button_style(false))
.padding([1, 6]),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([6, 10])
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().danger.base.color.scale_alpha(0.18),
)),
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().danger.base.color,
},
..Default::default()
});
main_column = column![err_banner, main_column]
.spacing(8)
.width(sizing.width)
.padding(12);
}
let main_content = main_column.into();
// ── Confirm Redefinition Guard ────────────────────────────────────────────
if let Some(name) = &state.confirm_redefine {
let question = crate::tf!("Block \"{name}\" already exists.\nDo you want to redefine it?");
let shield = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.strongest.color.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(Message::BlockDefConfirmRedefine(false));
let panel = container(
column![
text(question)
.size(13)
.align_x(iced::alignment::Horizontal::Center),
Space::new().height(16),
row![
button(text(t!("Yes")).size(12))
.on_press(Message::BlockDefConfirmRedefine(true))
.padding([6, 20])
.style(button_style(false)),
// Default / safe focus on "No"
button(text(t!("No")).size(12))
.on_press(Message::BlockDefConfirmRedefine(false))
.padding([6, 22])
.style(button_style(true)),
]
.spacing(12)
.align_y(iced::Center),
]
.spacing(0)
.align_x(iced::alignment::Horizontal::Center),
)
.padding([20, 24])
.style(container::rounded_box);
return stack![
main_content,
shield,
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Fill)
.center_y(Fill),
]
.into();
}
main_content
}
+1
View File
@@ -1,4 +1,5 @@
pub mod about;
pub mod block_definition;
pub mod block_palette;
pub mod browser;
pub mod layout_manager;