forked from 1b-agent/opencadstudio-upstream
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:
1 parent
889a19c362
commit
652f4d6bbc
19 files changed
+2316
-318
No files matched your search
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod about;
|
||||
pub mod block_definition;
|
||||
pub mod block_palette;
|
||||
pub mod browser;
|
||||
pub mod layout_manager;
|
||||
|
||||
Reference in new issue
Block a user