diff --git a/assets/icons/graphic_attributes.svg b/assets/icons/graphic_attributes.svg
new file mode 100644
index 000000000..aed1edd32
--- /dev/null
+++ b/assets/icons/graphic_attributes.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs
index 4dd6b2d5b..c3c40b3b0 100644
--- a/src/app/commands/display.rs
+++ b/src/app/commands/display.rs
@@ -472,6 +472,11 @@ impl OpenCADStudio {
return Some(Task::done(Message::ToggleProperties));
}
+ // ── GRAPHICATTRIBUTES — toggle Graphic Attributes palette ───────────
+ "GRAPHICATTRIBUTES" => {
+ return Some(Task::done(Message::ToggleGraphicAttributes));
+ }
+
// ── FILETAB — toggle file/document tabs ──────────────────────────────
"FILETAB" => {
return Some(Task::done(Message::ToggleFileTabs));
diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs
index 6fb283b99..a27263c5b 100644
--- a/src/app/commands/mod.rs
+++ b/src/app/commands/mod.rs
@@ -386,6 +386,7 @@ inventory::submit!(crate::command::CommandRegistration {
"DSETTINGS",
"PARAMETERS",
"GRID",
+ "GRAPHICATTRIBUTES",
"ISODRAFT",
"ISOPLANE",
"OSNAP",
diff --git a/src/app/config.rs b/src/app/config.rs
index f8d2f26d3..68e329c44 100644
--- a/src/app/config.rs
+++ b/src/app/config.rs
@@ -30,6 +30,8 @@ pub struct AppConfig {
/// General edge-stack dock layout (which panels are docked, side, order,
/// width and auto-collapse) for the Properties panel and block palette.
pub dock: crate::ui::dock::DockState,
+ /// Whether the optional Graphic Attributes palette is visible.
+ pub show_graphic_attributes: bool,
/// Add a newly selected annotation scale to existing annotative objects.
pub annotation_auto_scale: i8,
/// Ribbon collapse density.
@@ -52,6 +54,7 @@ impl Default for AppConfig {
start: StartConfig::default(),
statusbar: StatusBarConfig::default(),
dock: crate::ui::dock::DockState::default(),
+ show_graphic_attributes: false,
annotation_auto_scale: -4,
ribbon: RibbonConfig::default(),
plot: PlotDialogState::default(),
@@ -601,6 +604,18 @@ mod tests {
assert_eq!(deserialized.model_space.selection_window_color, 5);
}
+ #[test]
+ fn graphic_attributes_visibility_defaults_off_and_round_trips() {
+ let missing: AppConfig = serde_json::from_str("{}").expect("deserialize old config");
+ assert!(!missing.show_graphic_attributes);
+
+ let mut original = AppConfig::default();
+ original.show_graphic_attributes = true;
+ let serialized = serde_json::to_string(&original).expect("serialize config");
+ let restored: AppConfig = serde_json::from_str(&serialized).expect("deserialize config");
+ assert!(restored.show_graphic_attributes);
+ }
+
#[test]
fn test_parse_theme_name() {
assert_eq!(parse_theme_name("light"), Some(iced::Theme::Light));
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 7b26e0aec..713ec9d67 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2807,6 +2807,8 @@ pub enum Message {
ToggleViewCube,
/// Toggle the Properties panel visibility (PROPERTIES).
ToggleProperties,
+ /// Toggle the Graphic Attributes panel visibility (GRAPHICATTRIBUTES).
+ ToggleGraphicAttributes,
/// Toggle the document file tabs at the top (FILETAB).
ToggleFileTabs,
/// Toggle the layout tabs at the bottom (LAYOUTTAB).
@@ -4167,7 +4169,7 @@ impl OpenCADStudio {
render_mode_menu_open: false,
render_mode_preview: None,
show_properties: true,
- show_graphic_attributes: true,
+ show_graphic_attributes: false,
graphic_attributes_header_menu_open: false,
graphic_attribute_menu_open: false,
line_color_menu_open: false,
diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs
index 455ab5d99..542370d3e 100644
--- a/src/app/update/dialog.rs
+++ b/src/app/update/dialog.rs
@@ -130,7 +130,6 @@ fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool {
}
}
-#[derive(Clone)]
struct ConcreteGraphicAttributes {
color: acadrust::types::Color,
linetype: String,
@@ -212,10 +211,11 @@ fn valid_layer_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= 255
&& !name.chars().any(|character| {
- matches!(
- character,
- '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`'
- )
+ character.is_control()
+ || matches!(
+ character,
+ '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`'
+ )
})
}
@@ -918,7 +918,10 @@ impl OpenCADStudio {
}
pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task {
- let scale = ((scale * 10.0).round() / 10.0).clamp(0.1, 1000.0);
+ let Some(scale) = crate::ui::window::graphic_attributes::normalized_linetype_scale(scale)
+ else {
+ return iced::Task::none();
+ };
let i = self.active_tab;
let selected = self.tabs[i].scene.selected_handles_in_order();
if selected.is_empty() {
@@ -1595,8 +1598,12 @@ impl OpenCADStudio {
}
PanelId::GraphicAttributes => {
self.show_graphic_attributes = false;
+ self.ribbon.set_graphic_attributes(false);
self.graphic_attributes_header_menu_open = false;
self.graphic_attribute_menu_open = false;
+ self.gradient_editor = None;
+ self.hatch_editor_handles = None;
+ self.save_config();
}
}
if self.dock_expanded == Some(id) {
@@ -2588,6 +2595,17 @@ mod tests {
.abs()
< f64::EPSILON
);
+ let _ = app.update(Message::LineLinetypeScaleChanged(f64::NAN));
+ assert_eq!(
+ app.tabs[i]
+ .scene
+ .document
+ .get_entity(line)
+ .expect("line")
+ .common()
+ .linetype_scale,
+ 2.5
+ );
let _ = app.update(Message::LineLinetypeScaleChanged(1.2000000000000002));
assert_eq!(
app.tabs[i]
@@ -2817,6 +2835,14 @@ mod tests {
let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35));
let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3));
+ let _ = app.create_graphic_attributes_layer("Invalid\nLayer");
+ assert!(app.tabs[i]
+ .scene
+ .document
+ .layers
+ .get("Invalid\nLayer")
+ .is_none());
+
let _ = app.update(Message::GraphicAttributesCreateLayer);
assert_eq!(
app.tabs[i]
diff --git a/src/app/update/file.rs b/src/app/update/file.rs
index bca1aa378..ab8db72ea 100644
--- a/src/app/update/file.rs
+++ b/src/app/update/file.rs
@@ -1086,6 +1086,7 @@ impl OpenCADStudio {
dock.ensure_settings();
dock
},
+ show_graphic_attributes: self.show_graphic_attributes,
annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(),
@@ -1144,6 +1145,9 @@ impl OpenCADStudio {
let mut dock = cfg.dock;
dock.ensure_settings();
self.dock = dock;
+ self.show_graphic_attributes = cfg.show_graphic_attributes;
+ self.ribbon
+ .set_graphic_attributes(self.show_graphic_attributes);
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.plot_dialog = cfg.plot;
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 2047d0067..a88e44a38 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -4229,6 +4229,19 @@ impl OpenCADStudio {
self.ribbon.set_properties(self.show_properties);
Task::none()
}
+ Message::ToggleGraphicAttributes => {
+ self.show_graphic_attributes ^= true;
+ self.ribbon
+ .set_graphic_attributes(self.show_graphic_attributes);
+ if !self.show_graphic_attributes {
+ self.graphic_attributes_header_menu_open = false;
+ self.graphic_attribute_menu_open = false;
+ self.gradient_editor = None;
+ self.hatch_editor_handles = None;
+ }
+ self.save_config();
+ Task::none()
+ }
Message::ToggleFileTabs => {
self.show_file_tabs ^= true;
self.ribbon.set_file_tabs(self.show_file_tabs);
@@ -11220,6 +11233,30 @@ mod prop_pointer_tests {
}
}
+#[cfg(test)]
+mod graphic_attributes_visibility_tests {
+ use super::Message;
+ use crate::app::OpenCADStudio;
+
+ #[test]
+ fn graphic_attributes_starts_hidden_and_toggle_is_persisted() {
+ let mut app = OpenCADStudio::new_for_test();
+ app.apply_config(crate::app::config::AppConfig::default());
+ assert!(!app.show_graphic_attributes);
+ assert!(!app.ribbon.show_graphic_attributes);
+
+ let _ = app.update(Message::ToggleGraphicAttributes);
+ assert!(app.show_graphic_attributes);
+ assert!(app.ribbon.show_graphic_attributes);
+ assert!(app.current_config().show_graphic_attributes);
+
+ let _ = app.update(Message::ToggleGraphicAttributes);
+ assert!(!app.show_graphic_attributes);
+ assert!(!app.ribbon.show_graphic_attributes);
+ assert!(!app.current_config().show_graphic_attributes);
+ }
+}
+
#[cfg(test)]
mod free_text_entry_tests {
//! The free-form text prompts (table cell content, TEXT / MTEXT bodies)
diff --git a/src/command.rs b/src/command.rs
index bfa4c19eb..5fb96016c 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -308,6 +308,10 @@ pub struct ValuePromptCommand {
prompt: &'static str,
}
+fn dispatch_prompt_value(name: &str, text: &str) -> CmdResult {
+ CmdResult::Dispatch(format!("{name} {}", text.trim()))
+}
+
impl ValuePromptCommand {
pub fn new(name: &'static str, prompt: &'static str) -> Self {
Self { name, prompt }
@@ -328,12 +332,7 @@ impl CadCommand for ValuePromptCommand {
}
fn on_text_input(&mut self, text: &str) -> Option {
- let t = text.trim();
- if t.is_empty() {
- Some(CmdResult::Dispatch(format!("{} ", self.name)))
- } else {
- Some(CmdResult::Dispatch(format!("{} {t}", self.name)))
- }
+ Some(dispatch_prompt_value(self.name, text))
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
@@ -343,7 +342,7 @@ impl CadCommand for ValuePromptCommand {
fn on_enter(&mut self) -> CmdResult {
// Bare Enter → report the current value via the inline handler.
- CmdResult::Dispatch(format!("{} ", self.name))
+ dispatch_prompt_value(self.name, "")
}
}
@@ -375,7 +374,7 @@ impl CadCommand for FreeTextValuePromptCommand {
}
fn on_text_input(&mut self, text: &str) -> Option {
- Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim())))
+ Some(dispatch_prompt_value(self.name, text))
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
@@ -383,7 +382,7 @@ impl CadCommand for FreeTextValuePromptCommand {
}
fn on_enter(&mut self) -> CmdResult {
- CmdResult::Dispatch(format!("{} ", self.name))
+ dispatch_prompt_value(self.name, "")
}
}
@@ -3079,6 +3078,20 @@ mod constraint_registry_tests {
mod tests {
use super::*;
+ #[test]
+ fn prompt_dispatch_trims_values_and_preserves_empty_argument() {
+ let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " value with spaces ")
+ else {
+ panic!("prompt input should dispatch a command");
+ };
+ assert_eq!(value, "TEST value with spaces");
+
+ let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " ") else {
+ panic!("empty prompt input should dispatch a command");
+ };
+ assert_eq!(value, "TEST ");
+ }
+
#[test]
fn test_mid2point_command() {
let mut cmd = Mid2PointCommand::new();
diff --git a/src/modules/view/graphic_attributes_palette.rs b/src/modules/view/graphic_attributes_palette.rs
new file mode 100644
index 000000000..03896aa11
--- /dev/null
+++ b/src/modules/view/graphic_attributes_palette.rs
@@ -0,0 +1,14 @@
+use crate::modules::{IconKind, ModuleEvent, ToolDef};
+
+pub const ICON: IconKind = IconKind::Svg(include_bytes!(
+ "../../../assets/icons/graphic_attributes.svg"
+));
+
+pub fn tool() -> ToolDef {
+ ToolDef {
+ id: "GRAPHICATTRIBUTES",
+ label: "Graphic Attributes",
+ icon: ICON,
+ event: ModuleEvent::Command("GRAPHICATTRIBUTES".to_string()),
+ }
+}
diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs
index d29f1826d..b9b987f4c 100644
--- a/src/modules/view/mod.rs
+++ b/src/modules/view/mod.rs
@@ -2,6 +2,7 @@
mod cascade;
mod file_tabs;
+mod graphic_attributes_palette;
mod layout_tabs;
pub mod limits;
mod orbit;
@@ -120,6 +121,7 @@ impl CadModule for ViewModule {
tools: vec![
RibbonItem::LargeTool(tool_palettes::tool()),
RibbonItem::LargeTool(properties_palette::tool()),
+ RibbonItem::LargeTool(graphic_attributes_palette::tool()),
RibbonItem::LargeTool(sheetset::tool()),
],
},
diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs
index c1c2852f3..d8004c769 100644
--- a/src/ui/ribbon/mod.rs
+++ b/src/ui/ribbon/mod.rs
@@ -54,6 +54,8 @@ pub struct Ribbon {
pub show_ucs_icon: bool,
/// Properties panel (PROPERTIES) visibility — drives the Properties button highlight.
pub show_properties: bool,
+ /// Graphic Attributes panel visibility — drives its ribbon button highlight.
+ pub show_graphic_attributes: bool,
/// File tabs (FILETAB) visibility — drives the File Tabs button highlight.
pub show_file_tabs: bool,
/// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight.
@@ -162,6 +164,7 @@ impl Ribbon {
show_viewcube: true,
show_ucs_icon: true,
show_properties: true,
+ show_graphic_attributes: false,
show_file_tabs: true,
show_layout_tabs: true,
open_dropdown: None,
@@ -315,6 +318,9 @@ impl Ribbon {
pub fn set_properties(&mut self, on: bool) {
self.show_properties = on;
}
+ pub fn set_graphic_attributes(&mut self, on: bool) {
+ self.show_graphic_attributes = on;
+ }
pub fn set_file_tabs(&mut self, on: bool) {
self.show_file_tabs = on;
}
@@ -332,6 +338,7 @@ impl Ribbon {
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
show_properties: self.show_properties,
+ show_graphic_attributes: self.show_graphic_attributes,
show_block_palette,
show_file_tabs: self.show_file_tabs,
show_layout_tabs: self.show_layout_tabs,
diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs
index 72b765fa2..6adf4b07a 100644
--- a/src/ui/ribbon/widgets.rs
+++ b/src/ui/ribbon/widgets.rs
@@ -37,6 +37,7 @@ pub(super) struct ToggleState {
pub show_viewcube: bool,
pub show_ucs_icon: bool,
pub show_properties: bool,
+ pub show_graphic_attributes: bool,
pub show_block_palette: bool,
pub show_file_tabs: bool,
pub show_layout_tabs: bool,
@@ -374,6 +375,7 @@ pub(super) fn is_active_tool(
"NAVVCUBE" => state.show_viewcube,
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
+ "GRAPHICATTRIBUTES" => state.show_graphic_attributes,
"BLOCKPALETTE" => state.show_block_palette,
"FILETAB" => state.show_file_tabs,
"LAYOUTTAB" => state.show_layout_tabs,
diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs
index 8758985f1..1a44ca701 100644
--- a/src/ui/window/graphic_attributes.rs
+++ b/src/ui/window/graphic_attributes.rs
@@ -57,10 +57,19 @@ struct LinetypeScaleInfo {
const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0;
const COMPACT_BUTTON_HEIGHT: f32 = 22.0;
+const LINETYPE_SCALE_MIN: f64 = 0.1;
+const LINETYPE_SCALE_MAX: f64 = 1000.0;
+const LINETYPE_SCALE_STEP: f64 = 0.1;
const TRANSPARENCY_ICON: &[u8] =
include_bytes!("../../../assets/icons/attributes/transparency.svg");
const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg");
+pub(crate) fn normalized_linetype_scale(value: f64) -> Option {
+ value
+ .is_finite()
+ .then(|| ((value * 10.0).round() / 10.0).clamp(LINETYPE_SCALE_MIN, LINETYPE_SCALE_MAX))
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphicAttribute {
Varies,
@@ -737,6 +746,60 @@ fn transparency_control<'a>(
row![control, more].spacing(4).align_y(iced::Center).into()
}
+fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'static, Message> {
+ let menu_button = button(crate::ui::icons::themed_secondary(
+ crate::ui::icons::MENU,
+ 12.0,
+ ))
+ .on_press(Message::ToggleGraphicAttributesHeaderMenu)
+ .style(move |theme: &Theme, status| {
+ if open {
+ button::primary(theme, status)
+ } else {
+ button::subtle(theme, status)
+ }
+ })
+ .padding([3, 5]);
+ if !open {
+ return menu_button.into();
+ }
+
+ let item = |label: &'static str, message| {
+ button(text(label).size(11))
+ .on_press(message)
+ .style(crate::ui::color_select::list_row_style)
+ .width(Fill)
+ .padding([4, 8])
+ };
+ let popup = container(column![
+ item(
+ "Set all attributes ByLayer",
+ Message::GraphicAttributesSetAllByLayer,
+ ),
+ item(
+ "Set all attributes ByBlock",
+ Message::GraphicAttributesSetAllByBlock,
+ ),
+ item(
+ "Remove ByLayer / ByBlock",
+ Message::GraphicAttributesRemoveReferences,
+ ),
+ item(
+ "Create Layer with active settings",
+ Message::GraphicAttributesCreateLayer,
+ ),
+ ])
+ .style(crate::ui::color_select::popup_panel_style)
+ .padding(2);
+ iced_aw::DropDown::new(menu_button, popup, true)
+ .width(Length::Fixed(220.0))
+ .height(Length::Shrink)
+ .alignment(popup_alignment(side))
+ .offset(2.0)
+ .on_dismiss(Message::CloseGraphicAttributesHeaderMenu)
+ .into()
+}
+
pub fn view<'a>(
document: &'a CadDocument,
properties: &'a crate::ui::properties::PropertiesPanel,
@@ -759,57 +822,7 @@ pub fn view<'a>(
gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>,
gradient_color_picker_open: bool,
) -> Element<'a, Message> {
- let menu_button = button(crate::ui::icons::themed_secondary(
- crate::ui::icons::MENU,
- 12.0,
- ))
- .on_press(Message::ToggleGraphicAttributesHeaderMenu)
- .style(move |theme: &Theme, status| {
- if header_menu_open {
- button::primary(theme, status)
- } else {
- button::subtle(theme, status)
- }
- })
- .padding([3, 5]);
- let menu: Element<'a, Message> = if header_menu_open {
- let item = |label: &'static str, message| {
- button(text(label).size(11))
- .on_press(message)
- .style(crate::ui::color_select::list_row_style)
- .width(Fill)
- .padding([4, 8])
- };
- let popup = container(column![
- item(
- "Set all attributes ByLayer",
- Message::GraphicAttributesSetAllByLayer,
- ),
- item(
- "Set all attributes ByBlock",
- Message::GraphicAttributesSetAllByBlock,
- ),
- item(
- "Remove ByLayer / ByBlock",
- Message::GraphicAttributesRemoveReferences,
- ),
- item(
- "Create Layer with active settings",
- Message::GraphicAttributesCreateLayer,
- ),
- ])
- .style(crate::ui::color_select::popup_panel_style)
- .padding(2);
- iced_aw::DropDown::new(menu_button, popup, true)
- .width(Length::Fixed(220.0))
- .height(Length::Shrink)
- .alignment(popup_alignment(side))
- .offset(2.0)
- .on_dismiss(Message::CloseGraphicAttributesHeaderMenu)
- .into()
- } else {
- menu_button.into()
- };
+ let menu = header_menu(side, header_menu_open);
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
@@ -1040,15 +1053,17 @@ pub fn view<'a>(
.width(Fill)
.into()
} else {
- let scale_value = ((scale.value * 10.0).round() / 10.0).clamp(0.1, 1000.0);
+ let scale_value = normalized_linetype_scale(scale.value).unwrap_or(1.0);
let mut input = iced_aw::number_input(
&scale_value,
- 0.1..=1000.0,
+ LINETYPE_SCALE_MIN..=LINETYPE_SCALE_MAX,
|value: f64| {
- Message::LineLinetypeScaleChanged((value * 10.0).round() / 10.0)
+ Message::LineLinetypeScaleChanged(
+ normalized_linetype_scale(value).unwrap_or(1.0),
+ )
},
)
- .step(0.1)
+ .step(LINETYPE_SCALE_STEP)
.set_size(crate::ui::ROW_H * 0.42)
.padding([5, 5])
.input_style(move |theme: &Theme, status| {