From 65bd8e1b4c6f09b84146aa53901df96630a288da Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 27 Sep 2026 21:13:24 +0200 Subject: [PATCH 01/10] Improve CAD workspace functionality and user experience --- assets/icons/gradient/gradient_curved.svg | 135 +++ .../gradient/gradient_curved_inverted.svg | 155 +++ .../icons/gradient/gradient_cylindrical.svg | 97 ++ .../gradient_cylindrical_inverted.svg | 97 ++ .../icons/gradient/gradient_hemispherical.svg | 90 ++ .../gradient_hemispherical_inverted.svg | 100 ++ assets/icons/gradient/gradient_linear.svg | 89 ++ assets/icons/gradient/gradient_spherical.svg | 90 ++ .../gradient/gradient_spherical_inverted.svg | 90 ++ assets/icons/hatch/hatch_gradient.svg | 91 +- assets/icons/hatch/hatch_lines.svg | 51 +- assets/icons/hatch/hatch_none.svg | 50 + assets/icons/hatch/hatch_solid.svg | 46 +- src/app/mod.rs | 47 +- src/app/update/dialog.rs | 1076 ++++++++++++++--- src/app/update/mod.rs | 278 ++++- src/app/update/style.rs | 22 +- src/app/view/mod.rs | 56 +- src/modules/draw/draw/hatch.rs | 7 + src/scene/entity.rs | 27 +- src/scene/mod.rs | 1 + src/ui/color_select.rs | 28 +- src/ui/dock.rs | 19 +- src/ui/properties.rs | 34 +- src/ui/window/gradient_editor.rs | 424 +++++++ src/ui/window/graphic_attributes.rs | 668 ++++++++++ src/ui/window/hatch_editor.rs | 36 + src/ui/window/mod.rs | 3 + 28 files changed, 3628 insertions(+), 279 deletions(-) create mode 100644 assets/icons/gradient/gradient_curved.svg create mode 100644 assets/icons/gradient/gradient_curved_inverted.svg create mode 100644 assets/icons/gradient/gradient_cylindrical.svg create mode 100644 assets/icons/gradient/gradient_cylindrical_inverted.svg create mode 100644 assets/icons/gradient/gradient_hemispherical.svg create mode 100644 assets/icons/gradient/gradient_hemispherical_inverted.svg create mode 100644 assets/icons/gradient/gradient_linear.svg create mode 100644 assets/icons/gradient/gradient_spherical.svg create mode 100644 assets/icons/gradient/gradient_spherical_inverted.svg create mode 100644 assets/icons/hatch/hatch_none.svg create mode 100644 src/ui/window/gradient_editor.rs create mode 100644 src/ui/window/graphic_attributes.rs create mode 100644 src/ui/window/hatch_editor.rs diff --git a/assets/icons/gradient/gradient_curved.svg b/assets/icons/gradient/gradient_curved.svg new file mode 100644 index 000000000..62e2dcada --- /dev/null +++ b/assets/icons/gradient/gradient_curved.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_curved_inverted.svg b/assets/icons/gradient/gradient_curved_inverted.svg new file mode 100644 index 000000000..75177eb43 --- /dev/null +++ b/assets/icons/gradient/gradient_curved_inverted.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_cylindrical.svg b/assets/icons/gradient/gradient_cylindrical.svg new file mode 100644 index 000000000..5ae41e792 --- /dev/null +++ b/assets/icons/gradient/gradient_cylindrical.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_cylindrical_inverted.svg b/assets/icons/gradient/gradient_cylindrical_inverted.svg new file mode 100644 index 000000000..015caca73 --- /dev/null +++ b/assets/icons/gradient/gradient_cylindrical_inverted.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_hemispherical.svg b/assets/icons/gradient/gradient_hemispherical.svg new file mode 100644 index 000000000..675bc94a8 --- /dev/null +++ b/assets/icons/gradient/gradient_hemispherical.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_hemispherical_inverted.svg b/assets/icons/gradient/gradient_hemispherical_inverted.svg new file mode 100644 index 000000000..e2a254153 --- /dev/null +++ b/assets/icons/gradient/gradient_hemispherical_inverted.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_linear.svg b/assets/icons/gradient/gradient_linear.svg new file mode 100644 index 000000000..f1f3c2736 --- /dev/null +++ b/assets/icons/gradient/gradient_linear.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_spherical.svg b/assets/icons/gradient/gradient_spherical.svg new file mode 100644 index 000000000..b95f3b4f7 --- /dev/null +++ b/assets/icons/gradient/gradient_spherical.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_spherical_inverted.svg b/assets/icons/gradient/gradient_spherical_inverted.svg new file mode 100644 index 000000000..2e6816449 --- /dev/null +++ b/assets/icons/gradient/gradient_spherical_inverted.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/hatch/hatch_gradient.svg b/assets/icons/hatch/hatch_gradient.svg index 199d8a941..7faf6ee0a 100644 --- a/assets/icons/hatch/hatch_gradient.svg +++ b/assets/icons/hatch/hatch_gradient.svg @@ -1,10 +1,87 @@ - - - - - + + + + + + + + + + + + - - + diff --git a/assets/icons/hatch/hatch_lines.svg b/assets/icons/hatch/hatch_lines.svg index 1b72bb148..0ec5b480c 100644 --- a/assets/icons/hatch/hatch_lines.svg +++ b/assets/icons/hatch/hatch_lines.svg @@ -6,7 +6,7 @@ version="1.1" id="svg6" sodipodi:docname="hatch_lines.svg" - inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" + inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -20,15 +20,15 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="6.3152247" - inkscape:cx="34.044711" - inkscape:cy="32.698757" - inkscape:window-width="1920" - inkscape:window-height="939" + inkscape:zoom="8.9310764" + inkscape:cx="26.032696" + inkscape:cy="34.878215" + inkscape:window-width="2560" + inkscape:window-height="1368" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" - inkscape:current-layer="svg6" /> + inkscape:current-layer="g6" /> + id="line1" + style="stroke-width:1.25222463;stroke-dasharray:none" /> + id="line2" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="line3" + inkscape:transform-center-x="1.4695877" + inkscape:transform-center-y="-1.4695877" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="line5" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="rect6" + style="stroke-width:1.875;stroke-dasharray:none" /> diff --git a/assets/icons/hatch/hatch_none.svg b/assets/icons/hatch/hatch_none.svg new file mode 100644 index 000000000..fe8936ab9 --- /dev/null +++ b/assets/icons/hatch/hatch_none.svg @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/assets/icons/hatch/hatch_solid.svg b/assets/icons/hatch/hatch_solid.svg index 5cccf8100..846d024a7 100644 --- a/assets/icons/hatch/hatch_solid.svg +++ b/assets/icons/hatch/hatch_solid.svg @@ -1,5 +1,45 @@ - + + + + - - + diff --git a/src/app/mod.rs b/src/app/mod.rs index 9f5e455c2..79dd787e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,20 +12,18 @@ mod command_driver; pub(crate) mod commands; pub(crate) mod dim_viewport; #[cfg(test)] -mod viewport_dimension_tests; -#[cfg(test)] mod dimension_preview_tests; mod document; mod drafting_settings; pub(crate) mod expr_eval; -mod options_session; mod find_replace; pub(crate) mod helpers; mod history; mod layers; mod model_ops; -mod navigation; mod mtext_editor; +mod navigation; +mod options_session; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; mod presspull_ops; @@ -40,6 +38,8 @@ mod text_inline; mod tolerance_dialog; mod update; mod view; +#[cfg(test)] +mod viewport_dimension_tests; mod visibility; pub use style_ops::StyleKind; @@ -768,6 +768,12 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + show_graphic_attributes: bool, + graphic_attribute_menu_open: bool, + solid_fill_color_menu_open: bool, + fill_transparency_menu_open: bool, + gradient_editor: Option, + hatch_editor_handles: Option>, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Docked External References panel visibility (EXTERNALREFERENCES). @@ -1524,6 +1530,8 @@ pub enum ColorPickerTab { /// Where a colour chosen in the standalone palette window should be applied. #[derive(Debug, Clone)] pub enum ColorPickTarget { + Gradient(u8), + GraphicAttributesSolid, DimStyle(DsField), MLeader(&'static str), Table(u8, &'static str), @@ -2046,6 +2054,31 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { + ToggleGraphicAttributeDropdown, + CloseGraphicAttributeDropdown, + GraphicAttributeChanged(crate::ui::window::graphic_attributes::GraphicAttribute), + ToggleSolidFillColorDropdown, + CloseSolidFillColorDropdown, + SolidFillColorChanged(AcadColor), + ToggleFillTransparencyDropdown, + CloseFillTransparencyDropdown, + FillTransparencyChanged(u8), + FillTransparencyByLayer, + FillTransparencyByBlock, + HatchEditorOpen, + HatchEditorClose, + GradientEditorOpen, + GradientColorModeChanged(crate::ui::window::gradient_editor::GradientColorMode), + GradientColorToggle(u8), + GradientColorChanged(u8, AcadColor), + GradientShadeTintChanged(f32), + GradientTypeChanged(crate::scene::model::hatch_model::GradientKind), + GradientInvertToggled, + GradientAngleChanged(String), + GradientAngleReset, + GradientCenteredChanged(bool), + GradientApply, + GradientCancel, SpaceMouseWake, SpaceMouseFrame(iced::time::Instant), SpaceMouseFocus(iced::window::Id, bool), @@ -4095,6 +4128,12 @@ impl OpenCADStudio { render_mode_menu_open: false, render_mode_preview: None, show_properties: true, + show_graphic_attributes: true, + graphic_attribute_menu_open: false, + solid_fill_color_menu_open: false, + fill_transparency_menu_open: false, + gradient_editor: None, + hatch_editor_handles: None, show_block_palette: false, show_external_references: false, show_browser: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 7c20cb776..2c214f62e 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,27 +2,445 @@ #![allow(unused_imports)] use super::util::*; -use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; +use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +fn set_hatch_graphic_attribute( + hatch: &mut acadrust::entities::Hatch, + value: crate::ui::window::graphic_attributes::GraphicAttribute, + visible_color: [f32; 4], +) { + use crate::ui::window::graphic_attributes::GraphicAttribute; + match value { + GraphicAttribute::Solid => { + hatch.pattern = acadrust::entities::HatchPattern::new("SOLID"); + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = true; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Hatch => { + let Some(entry) = crate::scene::model::hatch_patterns::catalog().iter().find(|entry| { + matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Pattern(_) + ) + }) else { + return; + }; + let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + hatch.pattern_scale.max(1.0e-6), + ); + crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + origin.x, + origin.y, + ); + hatch.pattern = pattern; + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = false; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Gradient => { + let to_color = |rgba: [f32; 4]| acadrust::types::Color::Rgb { + r: (rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8, + g: (rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8, + b: (rgba[2] * 255.0).round().clamp(0.0, 255.0) as u8, + }; + hatch.is_solid = true; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.angle = 0.0; + hatch.pattern_angle = 0.0; + hatch.gradient_color.shift = 0.0; + hatch.gradient_color.is_single_color = false; + hatch.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: to_color(visible_color), + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: acadrust::types::Color::Rgb { + r: 46, + g: 46, + b: 46, + }, + }, + ]; + } + GraphicAttribute::None | GraphicAttribute::Varies => {} + } +} impl OpenCADStudio { + pub(super) fn open_gradient_editor(&mut self) { + use crate::ui::window::gradient_editor::GradientEditorState; + let i = self.active_tab; + let selected: rustc_hash::FxHashSet<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let Some(hatch) = self.tabs[i].scene.document.entities().find_map(|entity| { + let acadrust::EntityType::Hatch(hatch) = entity else { + return None; + }; + if !hatch.gradient_color.enabled { + return None; + } + (selected.contains(&entity.common().handle) + || hatch + .paths + .iter() + .flat_map(|p| p.boundary_handles.iter()) + .any(|handle| selected.contains(handle))) + .then_some(hatch) + }) else { + return; + }; + self.gradient_editor = Some(GradientEditorState::from_hatch( + selected.iter().copied().collect(), + hatch, + )); + } + + pub(super) fn apply_gradient_editor(&mut self) -> iced::Task { + use crate::ui::window::gradient_editor::GradientColorMode; + let Some(state) = self.gradient_editor.take() else { + return iced::Task::none(); + }; + let i = self.active_tab; + let sources: rustc_hash::FxHashSet<_> = state.handles.iter().copied().collect(); + let targets: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let acadrust::EntityType::Hatch(h) = entity else { + return None; + }; + (h.gradient_color.enabled + && (sources.contains(&entity.common().handle) + || h.paths + .iter() + .any(|p| p.boundary_handles.iter().any(|x| sources.contains(x))))) + .then_some(entity.common().handle) + }) + .collect(); + if targets.is_empty() { + return iced::Task::none(); + } + if targets + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Gradient Fill: the fill is on a locked layer."); + self.gradient_editor = Some(state); + return iced::Task::none(); + } + let angle = state.angle_radians(); + let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); + for handle in &targets { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if let acadrust::EntityType::Hatch(h) = &mut entity { + h.gradient_color.name = state.kind.dxf_name(state.inverted).into(); + h.gradient_color.angle = angle; + h.pattern_angle = angle; + h.gradient_color.shift = if state.centered { 0.0 } else { 1.0 }; + h.gradient_color.is_single_color = state.color_mode == GradientColorMode::One; + h.gradient_color.color_tint = state.shade_tint as f64; + h.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: state.color_1.clone(), + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: state.color_2.clone(), + }, + ]; + } + self.tabs[i].scene.update_entity(entity); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(p) = pending { + self.commit_undo_delta(i, p) + } + iced::Task::none() + } + + pub(super) fn on_graphic_attribute( + &mut self, + value: crate::ui::window::graphic_attributes::GraphicAttribute, + ) -> iced::Task { + use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; + use crate::ui::window::graphic_attributes::GraphicAttribute; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + self.gradient_editor = None; + self.hatch_editor_handles = None; + if value == GraphicAttribute::Varies { + return iced::Task::none(); + } + let i = self.active_tab; + let selected: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if selected.is_empty() { + self.command_line + .push_info(crate::t!("Select one or more closed objects first.").as_ref()); + return iced::Task::none(); + } + let selected_handles: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); + let (associated, new_hatches, updated_hatches, skipped) = + if value == GraphicAttribute::None { + let associated: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let acadrust::EntityType::Hatch(hatch) = entity else { + return None; + }; + let directly_selected = selected_handles.contains(&entity.common().handle); + let selected_boundary = hatch.is_associative + && hatch.paths.iter().any(|path| { + path.boundary_handles + .iter() + .any(|handle| selected_handles.contains(handle)) + }); + (directly_selected || selected_boundary).then_some(entity.common().handle) + }) + .collect(); + (associated, Vec::new(), Vec::new(), 0) + } else { + let working = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let normal = working.z.normalize_or(glam::DVec3::Z); + let storage = crate::entities::curve::ocs_plane( + acadrust::types::Vector3::new(normal.x, normal.y, normal.z), + working.origin.dot(normal), + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); + let (name, pattern) = match value { + GraphicAttribute::Solid => ("SOLID".into(), HatchPattern::Solid), + GraphicAttribute::Hatch => { + let Some(entry) = crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) + else { + self.command_line + .push_error("Graphic Attributes: no hatch pattern is available."); + return iced::Task::none(); + }; + (entry.name.clone(), entry.gpu.clone()) + } + GraphicAttribute::Gradient => ( + "LINEAR".into(), + HatchPattern::Gradient { + angle_deg: 0.0, + color2: [0.18, 0.18, 0.18, 0.0], + kind: GradientKind::Linear, + invert: false, + shift: 0.0, + }, + ), + GraphicAttribute::None | GraphicAttribute::Varies => unreachable!(), + }; + let origin = self.tabs[i].scene.document.hatch_origin(); + let mut hatches = Vec::new(); + let mut updated_hatches = Vec::new(); + let mut directly_updated = rustc_hash::FxHashSet::default(); + let mut changed_boundaries = rustc_hash::FxHashSet::default(); + let mut skipped = 0; + for handle in &selected { + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity(*handle) + { + let mut hatch = hatch.clone(); + let visible_color = crate::scene::view::render::render_style_for_common_viewport( + &self.tabs[i].scene.document, + &hatch.common, + None, + ) + .0; + set_hatch_graphic_attribute(&mut hatch, value, visible_color); + updated_hatches.push(acadrust::EntityType::Hatch(hatch)); + directly_updated.insert(*handle); + continue; + } + // Object mode only needs this object's source. Including all + // scene sources can associate coincident, unselected objects. + let object_sources = sources + .get(handle) + .map(|source| (*handle, source.clone())) + .into_iter() + .collect(); + let command = crate::modules::draw::draw::hatch::HatchCommand::new( + Vec::new(), + object_sources, + vec![*handle], + None, + plane, + ) + .with_origin(origin) + .with_pattern(name.clone(), pattern.clone()); + if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { + hatches.push(hatch); + changed_boundaries.insert(*handle); + } else { + skipped += 1; + } + } + // Replace only fills belonging to boundaries that successfully + // produced a new hatch. Existing fills on skipped objects remain. + let associated: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let acadrust::EntityType::Hatch(hatch) = entity else { + return None; + }; + (hatch.is_associative + && !directly_updated.contains(&entity.common().handle) + && hatch.paths.iter().any(|path| { + path.boundary_handles + .iter() + .any(|handle| changed_boundaries.contains(handle)) + })) + .then_some(entity.common().handle) + }) + .collect(); + (associated, hatches, updated_hatches, skipped) + }; + if associated + .iter() + .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Graphic Attributes: an associated fill is on a locked layer."); + return iced::Task::none(); + } + if skipped > 0 { + let message = if skipped == 1 { + "Graphic Attributes: 1 object was not changed because it is not closed or supported." + .to_owned() + } else { + format!( + "Graphic Attributes: {skipped} objects were not changed because they are not closed or supported." + ) + }; + self.command_line.push_info(&message); + } + if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { + return iced::Task::none(); + } + let pending = self.begin_undo( + i, + "Graphic Attributes", + associated.len() + new_hatches.len() + updated_hatches.len(), + true, + ); + self.tabs[i].scene.erase_entities(&associated); + for entity in updated_hatches { + self.tabs[i].scene.update_entity(entity); + } + let layer = self.tabs[i].active_layer.clone(); + for hatch in new_hatches { + self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + iced::Task::none() + } + + pub(super) fn on_solid_fill_color( + &mut self, + color: acadrust::types::Color, + ) -> iced::Task { + self.solid_fill_color_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let handles = crate::ui::window::graphic_attributes::solid_fill_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Solid fill color", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_color(entity, color); + } + }); + iced::Task::none() + } + + pub(super) fn on_fill_transparency( + &mut self, + transparency: acadrust::types::Transparency, + ) -> iced::Task { + self.fill_transparency_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let handles = crate::ui::window::graphic_attributes::fill_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Fill transparency", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + entity.common_mut().transparency = transparency; + } + }); + iced::Task::none() + } + pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { // Default the format dropdown to the loaded file's own format — its // DWG-vs-DXF kind (from the extension) and its version (from the parsed @@ -65,7 +483,6 @@ impl OpenCADStudio { Task::none() } - pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task { self.aec_drop_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { @@ -75,7 +492,6 @@ impl OpenCADStudio { Task::none() } - pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task { self.active_modal = Some(crate::app::ModalKind::Unsaved); // The unsaved-changes prompt renders inside the main window, so bring @@ -96,7 +512,6 @@ impl OpenCADStudio { } } - pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task { if self.active_modal == Some(crate::app::ModalKind::Unsaved) { self.active_modal = None; @@ -249,16 +664,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven return Task::none(); }; let (idx, continuation) = match pending { - crate::app::PendingClose::Tab(idx) => { - (idx, crate::app::SaveContinuation::CloseTab) - } + crate::app::PendingClose::Tab(idx) => (idx, crate::app::SaveContinuation::CloseTab), crate::app::PendingClose::Quit => { let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else { self.pending_close = None; - return Task::batch([ - self.close_unsaved_dialog_window(), - self.exit_app(), - ]); + return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]); }; (idx, crate::app::SaveContinuation::Quit) } @@ -314,10 +724,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let save = self.save_with_default_format(idx); Task::batch([close, save]) } else { - Task::batch([ - self.close_unsaved_dialog_window(), - self.exit_app(), - ]) + Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]) } } None => Task::none(), @@ -392,7 +799,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .iter() .filter_map(|h| doc.get_entity(*h)) .filter(|e| { - !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) }) .cloned() .collect() @@ -405,7 +815,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven o == model_br || o.is_null() }) .filter(|e| { - !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) }) .cloned() .collect() @@ -499,7 +912,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Ok(name) } - pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { + pub(super) fn on_block_palette( + &mut self, + m: crate::ui::window::block_palette::BlockPaletteMsg, + ) -> iced::Task { use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; match m { BlockPaletteMsg::Search(s) => { @@ -521,7 +937,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven async { let handle = rfd::AsyncFileDialog::new() .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) + .add_filter( + crate::t!("DWG/DXF Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) .pick_file() .await; match handle { @@ -540,7 +959,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.start_block_placement(&name); } Err(e) if e != "Cancelled" => { - self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); + self.command_line + .push_error(&crate::tf!("INSERT FILE: {e}")); } Err(_) => {} } @@ -609,6 +1029,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.show_properties = false; self.ribbon.set_properties(false); } + PanelId::GraphicAttributes => { + self.show_graphic_attributes = false; + self.graphic_attribute_menu_open = false; + } } if self.dock_expanded == Some(id) { self.dock_expanded = None; @@ -647,7 +1071,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // produced a one-time jump plus a stuck drag. Column moves // arrive via Message::XrefColMove only. if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; + let avail = self.tabs[self.active_tab] + .scene + .selection + .borrow() + .vp_size + .1; let side = if point.x < self.win_size.0 * 0.5 { DockSide::Left } else { @@ -705,6 +1134,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } match id { PanelId::Properties => self.show_properties, + PanelId::GraphicAttributes => self.show_graphic_attributes, PanelId::BlockPalette => self.show_block_palette, PanelId::ExternalReferences => self.show_external_references, PanelId::Browser => self.show_browser, @@ -858,13 +1288,19 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .entities() .filter_map(|entity| match entity { acadrust::EntityType::RasterImage(image) - if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => + if image + .definition_handle + .is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => { Some(entity.common().handle) } acadrust::EntityType::Underlay(underlay) - if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf) - && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => + if matches!( + underlay.underlay_type, + acadrust::entities::UnderlayType::Pdf + ) && self.tabs[i] + .xref_unloaded + .is_unloaded(underlay.definition_handle.value()) => { Some(entity.common().handle) } @@ -883,12 +1319,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// selection. Same engine fns as the CLI arms, batched with per-item /// report lines in the CLI wording; finishes with [`post_ref_op`] plus /// a palette rescan (mirror of the CLI post-op sequence). - pub(crate) fn xref_manager_op( - &mut self, - op: crate::ui::window::xref_manager::XrefPaletteOp, - ) { + pub(crate) fn xref_manager_op(&mut self, op: crate::ui::window::xref_manager::XrefPaletteOp) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + self.command_line.push_error( + crate::t!( + "Reference changes are not available on web — the reference list is read-only." + ) + .as_ref(), + ); return; } use crate::ui::window::xref_manager::XrefPaletteOp; @@ -901,14 +1339,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven idx.sort_unstable(); idx.iter() .filter_map(|idx| { - self.xref_manager.entries.get(*idx).map(|e| { - ( - e.key, - e.name.clone(), - e.kind, - e.parent_key.is_some(), - ) - }) + self.xref_manager + .entries + .get(*idx) + .map(|e| (e.key, e.name.clone(), e.kind, e.parent_key.is_some())) }) .collect() }; @@ -923,8 +1357,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ); let host: Option = self.tabs[i].current_path.clone(); if needs_host && host.is_none() { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), + ); return; } let label = match op { @@ -949,15 +1384,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ).as_ref()); continue; } - match crate::io::xref::detach_reference( - &mut self.tabs[i].scene.document, - *key, - ) { + match crate::io::xref::detach_reference(&mut self.tabs[i].scene.document, *key) + { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: detached \"{}\".", - name - ).as_ref()); + self.command_line + .push_output(crate::tf!("XREF: detached \"{}\".", name).as_ref()); self.tabs[i].xref_unloaded.remove(key); self.tabs[i].xref_stat_cache.remove(key); done += 1; @@ -975,15 +1406,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ).as_ref()); continue; } - match crate::io::xref::unload_reference( - &mut self.tabs[i].scene.document, - *key, - ) { + match crate::io::xref::unload_reference(&mut self.tabs[i].scene.document, *key) + { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: unloaded \"{}\".", - name - ).as_ref()); + self.command_line + .push_output(crate::tf!("XREF: unloaded \"{}\".", name).as_ref()); self.tabs[i].xref_unloaded.add(*key); done += 1; } @@ -1017,10 +1444,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven name ).as_ref()); } else if *kind != crate::io::xref_model::RefKind::DwgXref { - self.command_line.push_error(crate::tf!( - "{}: reload applies to drawing references only.", - name - ).as_ref()); + self.command_line.push_error( + crate::tf!("{}: reload applies to drawing references only.", name) + .as_ref(), + ); } } for (key, _) in &dwg_picked { @@ -1060,18 +1487,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } for (_, name) in &dwg_picked { - match infos - .iter() - .find(|n| n.name.eq_ignore_ascii_case(name)) - { + match infos.iter().find(|n| n.name.eq_ignore_ascii_case(name)) { Some(info) => { self.report_xref_status(info); done += 1; } - None => self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - name - ).as_ref()), + None => self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", name).as_ref(), + ), } } } @@ -1090,10 +1513,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven crate::io::xref_model::RefType::Overlay, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: \"{}\" set to Overlay.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: \"{}\" set to Overlay.", name).as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1115,10 +1537,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven crate::io::xref_model::RefType::Attach, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: \"{}\" set to Attach.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: \"{}\" set to Attach.", name).as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1136,10 +1557,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .unwrap_or_else(|| std::path::PathBuf::from(".")); for (key, name, _, is_nested) in &picked { if *is_nested { - self.command_line.push_error(crate::tf!( + self.command_line.push_error( + crate::tf!( "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", name - ).as_ref()); + ) + .as_ref(), + ); continue; } match crate::io::xref::bind_reference( @@ -1150,10 +1574,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ) { Ok(outcome) => { if outcome.unremapped == 0 { - self.command_line.push_output(crate::tf!( - "XREF: bound \"{}\".", - outcome.name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: bound \"{}\".", outcome.name).as_ref(), + ); } else { self.command_line.push_output(crate::tf!( "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", @@ -1185,10 +1608,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven &host, ) { Ok(_) => { - self.command_line.push_output(crate::tf!( - "XREF: Path set for \"{}\" — Reload to apply.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) + .as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1210,18 +1633,25 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// full resolve, per-ref report, stat baselines refreshed. pub(crate) fn xref_manager_reload_all(&mut self) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + self.command_line.push_error( + crate::t!( + "Reference changes are not available on web — the reference list is read-only." + ) + .as_ref(), + ); return; } let i = self.active_tab; let Some(path) = self.tabs[i].current_path.clone() else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), + ); return; }; let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), + ); return; }; let reload_keys: Vec = crate::io::xref::collect_entries_with_prev( @@ -1276,6 +1706,248 @@ mod tests { app } + #[test] + fn graphic_attributes_creates_one_associative_fill_per_object() { + use crate::ui::window::graphic_attributes::GraphicAttribute; + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + + let mut app = fresh(); + let i = app.active_tab; + let mut boundaries = Vec::new(); + // Coincident boundaries must still own independent associations. + for offset in [0.0, 0.0] { + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x + offset, y)); + } + polyline.close(); + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + boundaries.push(handle); + } + + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && hatch.is_associative)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .count() + == 1)); + + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(boundaries[0], false); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.gradient_color.enabled) + .count(), + 1 + ); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) + .count(), + 1 + ); + + // An invalid boundary is skipped while valid selected boundaries are + // still updated. + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .all(|handle| *handle != line))); + + // Editing a common gradient updates each object's independent hatch + // and records the whole operation as one undo step. + app.tabs[i].scene.deselect_all(); + for handle in &boundaries { + app.tabs[i].scene.select_entity(*handle, false); + } + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + app.open_gradient_editor(); + let editor = app.gradient_editor.as_mut().expect("gradient editor"); + assert_eq!(editor.handles.len(), 2); + editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; + editor.inverted = true; + editor.angle = "405°".into(); + editor.centered = false; + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_gradient_editor(); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let gradients: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + hatch.gradient_color.enabled.then_some(hatch) + }) + .collect(); + assert_eq!(gradients.len(), 2); + assert!(gradients.iter().all(|hatch| { + hatch.gradient_color.name == "INVCURVED" + && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 + && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 + })); + + // A selection change closes the flyout and it remains closed until + // the user explicitly opens it again. + app.open_gradient_editor(); + assert!(app.gradient_editor.is_some()); + app.tabs[i].scene.deselect_all(); + let _ = app.update(Message::CloseGraphicAttributeDropdown); + assert!(app.gradient_editor.is_none()); + + let selected_hatch = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) + }) + .expect("gradient hatch"); + assert_eq!( + crate::ui::window::graphic_attributes::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + + // A directly selected hatch is edited in place instead of being + // rejected as an unsupported boundary object. + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + assert_eq!( + crate::ui::window::graphic_attributes::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Solid, + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); + assert!((app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("transparent solid hatch") + .common() + .transparency + .as_percent() + - 0.35) + .abs() + < 0.01); + let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("ByLayer solid hatch") + .common() + .transparency + .is_by_layer()); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert_eq!( + crate::ui::window::graphic_attributes::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Hatch, + ); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert_eq!( + crate::ui::window::graphic_attributes::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.open_gradient_editor(); + assert!(app.gradient_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert!(app.gradient_editor.is_none()); + let _ = app.update(Message::HatchEditorOpen); + assert!(app.hatch_editor_handles.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert!(app.hatch_editor_handles.is_none()); + assert!(app.gradient_editor.is_none()); + + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.on_graphic_attribute(GraphicAttribute::None); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + } + /// A foreign document: the fresh scene's document plus one model-space LINE. fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { let mut doc = app.tabs[app.active_tab].scene.document.clone(); @@ -1303,7 +1975,9 @@ mod tests { fn import_document_as_block_defines_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Fixture".to_string()) + .unwrap(); assert_eq!(name, "Fixture"); assert!(app.tabs[app.active_tab] .scene @@ -1483,7 +2157,9 @@ mod tests { ) .unwrap(); - let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); + let _ = app + .import_document_as_block(doc, "Imported".to_string()) + .unwrap(); let doc = &app.tabs[i].scene.document; // The file's own "Door (2)" is kept intact and targets the file's // renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source). @@ -1530,7 +2206,9 @@ mod tests { ) .unwrap(); - let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Imported".to_string()) + .unwrap(); assert_eq!(name, "Imported"); let doc = &app.tabs[i].scene.document; @@ -1561,11 +2239,16 @@ mod tests { fn blockpalette_refresh_lists_and_places_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Fixture".to_string()) + .unwrap(); app.refresh_block_palette(); assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); - let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); + let cmd = app.tabs[app.active_tab] + .active_cmd + .as_ref() + .expect("INSERT running"); assert_eq!(cmd.name(), "INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } @@ -1621,7 +2304,10 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); assert!(app.dock.auto_collapse(id), "pin enables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); + assert!( + !app.dock.auto_collapse(id), + "second pin disables auto-collapse" + ); let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } @@ -1639,7 +2325,9 @@ mod tests { ); let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 100.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -1662,22 +2350,13 @@ mod tests { // Left: block palette hidden, properties shown -> 1 visible panel. app.show_block_palette = false; app.show_properties = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); // Reveal the block palette -> both count. app.show_block_palette = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 2 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2); // A hidden (closed) panel counts for nothing even when stacked. app.show_block_palette = false; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); } #[test] @@ -1701,8 +2380,9 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); // Pointer near the bottom of the left edge: one visible panel means a // single slot, so every y maps to index 0 (no top/bottom split). - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 850.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -1726,8 +2406,9 @@ mod tests { app.win_size = (1600.0, 900.0).into(); let id = crate::ui::dock::PanelId::Properties; let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 850.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 2)) @@ -1752,7 +2433,11 @@ mod tests { line.end = Vector3::new(1.0, 0.0, 0.0); app.tabs[i] .scene - .define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO) + .define_block_from_owned_entities( + vec![EntityType::Line(line)], + "Chair", + glam::DVec3::ZERO, + ) .unwrap(); assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); @@ -1781,12 +2466,7 @@ mod tests { br.flags.is_xref = true; br.xref_path = "refs/plan.dwg".to_string(); br.handle = app.tabs[i].scene.document.allocate_handle(); - app.tabs[i] - .scene - .document - .block_records - .add(br) - .unwrap(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(old_dir.join("host.dwg")); let tab_id = app.tabs[i].id; let job_id = 4242u64; @@ -1847,9 +2527,9 @@ mod tests { // Behavioral matrix: every row op on a NESTED row via the GUI // message path. Nested rows must report per-entry errors (CLI // wording), never silently skip. - use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; + use acadrust::tables::BlockRecord; let dir = palette_tmpdir("nestedmatrix"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -1867,7 +2547,12 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.name == "INNER") + .expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); for op in [ XrefPaletteOp::Detach, @@ -1879,9 +2564,17 @@ mod tests { let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, op)); let out = palette_output(&app, start); - assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}"); assert!( - app.tabs[i].scene.document.block_records.get("HOST").is_some(), + out.contains("INNER"), + "op {op:?} on nested row gave: {out:?}" + ); + assert!( + app.tabs[i] + .scene + .document + .block_records + .get("HOST") + .is_some(), "op {op:?} must not mutate the direct reference" ); } @@ -1892,9 +2585,9 @@ mod tests { fn palette_detach_unload_rowop_gui_path() { // Reproduction for "Detach/Unload from the palette do nothing": // drive the exact GUI message path (row right-click menu item). - use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; + use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; for name in ["PLAN", "SITE"] { @@ -1906,19 +2599,42 @@ mod tests { } app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg")); app.refresh_xref_manager(); - let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.name == "PLAN") + .expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "detach output missing, got: {out:?}"); - assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone"); - let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed"); + assert!( + app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .is_none(), + "PLAN definition must be gone" + ); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.name == "SITE") + .expect("SITE listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload)); let out = palette_output(&app, start); assert!(out.contains("SITE"), "unload output missing, got: {out:?}"); assert_eq!( - app.xref_manager.entries.iter().find(|e| e.name == "SITE").unwrap().status, + app.xref_manager + .entries + .iter() + .find(|e| e.name == "SITE") + .unwrap() + .status, crate::io::xref_model::RefStatus::Unloaded ); } @@ -1927,10 +2643,10 @@ mod tests { fn palette_overlay_and_pathtype_rowop_gui_path() { // Row-menu Overlay + Change-Path row ops on a direct row via the // GUI message path: type flag flips, saved path clears. - use acadrust::tables::BlockRecord; use crate::app::Message; use crate::io::xref_model::{Pathtype, RefType}; use crate::ui::window::xref_manager::XrefPaletteOp; + use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; let mut br = BlockRecord::new("PLAN"); @@ -1940,20 +2656,49 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg")); app.refresh_xref_manager(); - let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.name == "PLAN") + .expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); + let br = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap(); assert!(br.flags.is_xref_overlay && !br.flags.is_xref); - assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay); + assert_eq!( + app.xref_manager + .entries + .iter() + .find(|e| e.name == "PLAN") + .unwrap() + .ref_type, + RefType::Overlay + ); let start = app.command_line.history.len(); - let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None))); + let _ = app.update(Message::XrefRowOp( + idx, + XrefPaletteOp::Pathtype(Pathtype::None), + )); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); - assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename"); + let br = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap(); + assert_eq!( + br.xref_path, "plan.dwg", + "Remove Path strips to the bare filename" + ); } #[test] @@ -1976,9 +2721,21 @@ mod tests { let start = app.command_line.history.len(); app.xref_manager_reload_all(); let out = palette_output(&app, start); - assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}"); - let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed"); - assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded"); + assert!( + out.contains("PLAN"), + "reload-all must report the reference, got: {out:?}" + ); + let entry = app + .xref_manager + .entries + .iter() + .find(|e| e.name == "PLAN") + .expect("PLAN listed"); + assert_eq!( + entry.status, + crate::io::xref_model::RefStatus::Loaded, + "PLAN must resolve Loaded" + ); std::fs::remove_dir_all(&dir).ok(); } @@ -1996,8 +2753,8 @@ mod tests { fn palette_unload_nested_reports_per_entry() { // F6: palette Unload on a nested row reports the per-entry nested // error (CLI wording) instead of the confusing 'no loaded reference'. - use acadrust::tables::BlockRecord; use crate::ui::window::xref_manager::XrefPaletteOp; + use acadrust::tables::BlockRecord; let dir = palette_tmpdir("nested"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -2015,7 +2772,12 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.name == "INNER") + .expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); app.xref_manager.selected.insert(idx); let start = app.command_line.history.len(); @@ -2031,16 +2793,20 @@ mod tests { // F7: palette Reload on an image/PDF row reports the drawing-only // error and leaves its unloaded flag untouched (no spurious // no-match after a flag clear). - use acadrust::objects::{ImageDefinition, ObjectType}; use crate::io::xref_model::RefKind; use crate::ui::window::xref_manager::XrefPaletteOp; + use acadrust::objects::{ImageDefinition, ObjectType}; let dir = palette_tmpdir("imgreload"); let mut app = fresh(); let i = app.active_tab; let h = app.tabs[i].scene.document.allocate_handle(); let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); def.handle = h; - app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + app.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::ImageDefinition(def)); let mut img = acadrust::entities::RasterImage::new( "img.png", acadrust::types::Vector3::ZERO, @@ -2048,10 +2814,19 @@ mod tests { 8.0, ); img.definition_handle = Some(h); - app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i] + .scene + .document + .add_entity(acadrust::EntityType::RasterImage(img)) + .unwrap(); app.tabs[i].current_path = Some(dir.join("host.dwg")); app.refresh_xref_manager(); - let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed"); + let idx = app + .xref_manager + .entries + .iter() + .position(|e| e.kind == RefKind::Image) + .expect("image listed"); let key = app.xref_manager.entries[idx].key; app.xref_manager.selected.insert(idx); app.tabs[i].xref_unloaded.add(key); @@ -2060,7 +2835,10 @@ mod tests { let out = palette_output(&app, start); assert!(out.contains("img.png"), "got: {out:?}"); assert_eq!(app.command_line.history.len(), start + 1); - assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched"); + assert!( + app.tabs[i].xref_unloaded.is_unloaded(key), + "image flag must stay untouched" + ); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9e3e52580..4ab4e54eb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -331,8 +331,7 @@ impl OpenCADStudio { { // Esc backs out of a pending ALIASEDIT draft first, mirroring // the shortcut editor's capture cancel; the next Esc closes. - if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add - { + if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add { return self.update(Message::AliasEditorDraftCancel); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { @@ -394,9 +393,12 @@ impl OpenCADStudio { // a mouse move included — without allocating. #[cfg(not(target_arch = "wasm32"))] let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> { - app.tabs - .get(app.active_tab) - .map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name()))) + app.tabs.get(app.active_tab).map(|tab| { + ( + tab.id, + tab.active_cmd.as_ref().map(|command| command.name()), + ) + }) }; #[cfg(not(target_arch = "wasm32"))] let command_before = active_command(self); @@ -414,6 +416,22 @@ impl OpenCADStudio { } } self.refresh_gpu_status(); + if self.gradient_editor.as_ref().is_some_and(|editor| { + let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); + selected.len() != editor.handles.len() + || selected + .iter() + .any(|handle| !editor.handles.contains(handle)) + }) { + self.gradient_editor = None; + } + if self.hatch_editor_handles.as_ref().is_some_and(|handles| { + let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); + selected.len() != handles.len() + || selected.iter().any(|handle| !handles.contains(handle)) + }) { + self.hatch_editor_handles = None; + } self.show_next_startup_modal(); self.sync_open_command_history(); // Close the document-level first-touch transaction started by @@ -507,9 +525,8 @@ impl OpenCADStudio { Message::SpaceMouseWake => self.on_spacemouse_wake(), Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification), Message::SpaceMouseFrame(time) => { - self.spacemouse.frame( - time.saturating_duration_since(self.start).as_secs_f64() * 1000., - ); + self.spacemouse + .frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.); Task::none() } Message::SpaceMouseFocus(id, focused) => { @@ -561,9 +578,8 @@ impl OpenCADStudio { if response["ok"] == false { let code = response["code"].as_str().unwrap_or("failed"); let detail = response["error"].as_str().unwrap_or(""); - self.command_line.push_error(&format!( - "automation {op}: {code} — {detail}" - )); + self.command_line + .push_error(&format!("automation {op}: {code} — {detail}")); } else if matches!( response["status"].as_str(), Some("completed" | "cancelled" | "waiting_input") @@ -1282,14 +1298,16 @@ impl OpenCADStudio { if let Some(path) = path { return Task::done(Message::OpenExternal(path)); } - self.command_line.push_info(crate::t!( - "Save and reopen the drawing to apply the new fonts." - ).as_ref()); + self.command_line.push_info( + crate::t!("Save and reopen the drawing to apply the new fonts.") + .as_ref(), + ); } Err(e) => { // Keep the prompt open: the user can correct a custom // source or retry a transient network failure. - self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("Font download failed: {e}").as_ref()); } } Task::none() @@ -2477,7 +2495,8 @@ impl OpenCADStudio { use crate::app::config::DockSide; use crate::ui::dock::PanelId; if self.dock.location(PanelId::ExternalReferences).is_none() { - self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); + self.dock + .dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); } self.dock_expanded = Some(PanelId::ExternalReferences); self.refresh_xref_manager(); @@ -2615,16 +2634,13 @@ impl OpenCADStudio { }; let new_raw = path.to_string_lossy().into_owned(); self.push_undo_snapshot(i, "XREF-PATH"); - match crate::io::xref::set_ref_path( - &mut self.tabs[i].scene.document, - key, - &new_raw, - ) { + match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw) + { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: Path set for \"{}\" — Reload to apply.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) + .as_ref(), + ); self.post_ref_op(i); } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2634,7 +2650,8 @@ impl OpenCADStudio { } Message::XrefPathPickResult(Err(e)) => { if e != "Cancelled" { - self.command_line.push_error(crate::tf!("XREF: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("XREF: {e}").as_ref()); } Task::none() } @@ -2659,9 +2676,11 @@ impl OpenCADStudio { if let Some(found) = entry.found_at.clone() { let is_dwg = found.to_ascii_lowercase().ends_with(".dwg") || found.to_ascii_lowercase().ends_with(".dxf"); - self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); + self.command_line + .push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); if is_dwg { - return self.update(Message::OpenRecent(std::path::PathBuf::from(found))); + return self + .update(Message::OpenRecent(std::path::PathBuf::from(found))); } else { #[cfg(not(target_arch = "wasm32"))] let _ = open::that_detached(&found); @@ -2697,7 +2716,10 @@ impl OpenCADStudio { self.xref_manager.row_change_path_open = false; let prefill = if let Some(entry) = self.xref_manager.entries.get(index) { let saved = &entry.saved_path; - if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) { + if let Some(parent) = std::path::Path::new(saved) + .parent() + .and_then(|p| p.to_str()) + { if !parent.is_empty() { format!("XREF Path Find \"{}\" ", parent) } else { @@ -3697,6 +3719,9 @@ impl OpenCADStudio { } Message::ViewportLeftPress => { + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_viewport_left_press()]) } @@ -3704,6 +3729,9 @@ impl OpenCADStudio { Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportRightPress => { + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; let i = self.active_tab; self.ribbon.close_dropdown(); // Shift+RMB: the one-shot snap override menu at the cursor — @@ -3805,7 +3833,12 @@ impl OpenCADStudio { self.unfocus_widgets() } - Message::ViewportMiddlePress => self.on_viewport_middle_press(), + Message::ViewportMiddlePress => { + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + self.on_viewport_middle_press() + } Message::ViewportMiddleRelease => { let i = self.active_tab; @@ -4327,7 +4360,9 @@ impl OpenCADStudio { continue; }; let current = ( - crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(), + crate::scene::pe_url_of(entity) + .unwrap_or_default() + .to_owned(), crate::scene::pe_url_description_of(entity) .unwrap_or_default() .to_owned(), @@ -4779,7 +4814,9 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), + crate::entities::common::UnitContext::from_header( + &self.tabs[i].scene.document.header, + ), ); Task::none() } @@ -4938,12 +4975,14 @@ impl OpenCADStudio { }; let name = state.name.trim().to_string(); if name.is_empty() { - state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned()); + 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(), + crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `") + .into_owned(), ); return Task::none(); } @@ -4986,12 +5025,16 @@ impl OpenCADStudio { let current_path = std::path::Path::new(&state.file_path); let file_stem = current_path.file_stem().and_then(|s| s.to_str()); let is_default_or_block = file_stem == Some("new_block") - || state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem); + || state + .existing_blocks + .iter() + .any(|b| Some(b.as_str()) == file_stem); if is_default_or_block { let new_file_name = format!("{}.dwg", trimmed); if let Some(parent) = current_path.parent() { if !parent.as_os_str().is_empty() { - state.file_path = parent.join(new_file_name).to_string_lossy().to_string(); + state.file_path = + parent.join(new_file_name).to_string_lossy().to_string(); } else { state.file_path = new_file_name; } @@ -5112,12 +5155,19 @@ impl OpenCADStudio { WblockSourceMode::Block => { let name = state.block_name.trim(); if name.is_empty() { - state.error_message = - Some(crate::t!("Please select or enter a block name.").into_owned()); + state.error_message = Some( + crate::t!("Please select or enter a block name.").into_owned(), + ); return Task::none(); } let i = self.active_tab; - if self.tabs[i].scene.document.block_records.get(name).is_none() { + if self.tabs[i] + .scene + .document + .block_records + .get(name) + .is_none() + { state.error_message = Some( crate::tf!("Block \"{}\" does not exist in drawing.", name) .into_owned(), @@ -5241,10 +5291,7 @@ impl OpenCADStudio { } WblockSourceMode::Objects => { crate::modules::insert::wblock::extract_entities_to_doc_with_base( - &document, - &handles, - base_point, - unit, + &document, &handles, base_point, unit, ) .map_err(|e| e.to_string())? } @@ -5303,7 +5350,9 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), + crate::entities::common::UnitContext::from_header( + &self.tabs[i].scene.document.header, + ), ); let label = crate::modules::draw::units::linear_format_label(code); self.command_line @@ -5633,8 +5682,7 @@ impl OpenCADStudio { Task::none() } Message::AutoConstrainReset => { - self.auto_constrain_settings = - super::settings::AutoConstrainSettings::default(); + self.auto_constrain_settings = super::settings::AutoConstrainSettings::default(); self.auto_constrain_selected_row = 0; self.auto_constrain_distance_input = format!("{}", self.auto_constrain_settings.distance_tolerance); @@ -5673,8 +5721,7 @@ impl OpenCADStudio { self.auto_constrain_settings.distance_tolerance = distance; self.auto_constrain_settings.angle_tolerance_deg = angle; self.auto_constrain_settings.sanitize(); - self.auto_constrain_saved = - Some(self.auto_constrain_settings.clone()); + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); self.persist_settings_if_changed(); if matches!(action, Message::AutoConstrainOk) { self.close_active_modal(); @@ -9462,6 +9509,138 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), + Message::ToggleGraphicAttributeDropdown => { + self.graphic_attribute_menu_open ^= true; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseGraphicAttributeDropdown => { + self.graphic_attribute_menu_open = false; + Task::none() + } + Message::GraphicAttributeChanged(value) => self.on_graphic_attribute(value), + Message::ToggleSolidFillColorDropdown => { + self.solid_fill_color_menu_open ^= true; + self.graphic_attribute_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseSolidFillColorDropdown => { + self.solid_fill_color_menu_open = false; + Task::none() + } + Message::SolidFillColorChanged(color) => self.on_solid_fill_color(color), + Message::ToggleFillTransparencyDropdown => { + self.fill_transparency_menu_open ^= true; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + Task::none() + } + Message::CloseFillTransparencyDropdown => { + self.fill_transparency_menu_open = false; + Task::none() + } + Message::FillTransparencyChanged(value) => self.on_fill_transparency( + acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), + ), + Message::FillTransparencyByLayer => { + self.on_fill_transparency(acadrust::types::Transparency::BY_LAYER) + } + Message::FillTransparencyByBlock => { + self.on_fill_transparency(acadrust::types::Transparency::BY_BLOCK) + } + Message::HatchEditorOpen => { + self.gradient_editor = None; + self.hatch_editor_handles = Some( + self.tabs[self.active_tab] + .scene + .selected_handles_in_order(), + ); + Task::none() + } + Message::HatchEditorClose => { + self.hatch_editor_handles = None; + Task::none() + } + Message::GradientEditorOpen => { + self.hatch_editor_handles = None; + self.open_gradient_editor(); + Task::none() + } + Message::GradientColorModeChanged(mode) => { + if let Some(s) = &mut self.gradient_editor { + s.color_mode = mode; + } + Task::none() + } + Message::GradientColorToggle(index) => { + if let Some(s) = &mut self.gradient_editor { + s.color_open = if s.color_open == Some(index) { + None + } else { + Some(index) + }; + } + Task::none() + } + Message::GradientColorChanged(index, color) => { + if let Some(s) = &mut self.gradient_editor { + if index == 1 { + s.color_1 = color + } else { + s.color_2 = color + }; + s.color_open = None; + } + Task::none() + } + Message::GradientShadeTintChanged(value) => { + if let Some(s) = &mut self.gradient_editor { + s.shade_tint = value.clamp(0.0, 1.0); + } + Task::none() + } + Message::GradientTypeChanged(kind) => { + if let Some(s) = &mut self.gradient_editor { + s.kind = kind; + if kind == crate::scene::model::hatch_model::GradientKind::Linear { + s.inverted = false; + } + } + Task::none() + } + Message::GradientInvertToggled => { + if let Some(s) = &mut self.gradient_editor { + if s.kind != crate::scene::model::hatch_model::GradientKind::Linear { + s.inverted ^= true; + } + } + Task::none() + } + Message::GradientAngleChanged(value) => { + if let Some(s) = &mut self.gradient_editor { + s.angle = value; + } + Task::none() + } + Message::GradientAngleReset => { + if let Some(s) = &mut self.gradient_editor { + s.angle = "0.0°".into(); + } + Task::none() + } + Message::GradientCenteredChanged(value) => { + if let Some(s) = &mut self.gradient_editor { + s.centered = value; + } + Task::none() + } + Message::GradientApply => self.apply_gradient_editor(), + Message::GradientCancel => { + self.gradient_editor = None; + Task::none() + } Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { @@ -10598,6 +10777,9 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { + if matches!(target, crate::app::ColorPickTarget::GraphicAttributesSolid) { + self.solid_fill_color_menu_open = false; + } self.color_pick_target = Some((target, color)); // Always open the shared CAD colour picker on the indexed ACI page. diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 3574338df..9d5adf177 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -4,13 +4,12 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -20,7 +19,6 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; - impl OpenCADStudio { pub(in crate::app) fn mlstyle_mut( &mut self, @@ -107,7 +105,10 @@ impl OpenCADStudio { } } - pub(in crate::app) fn tablestyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::TableStyle> { + pub(in crate::app) fn tablestyle_mut( + &mut self, + tab: usize, + ) -> Option<&mut acadrust::objects::TableStyle> { use acadrust::objects::ObjectType; let name = self.tablestyle_selected.clone(); self.tabs[tab] @@ -211,7 +212,10 @@ impl OpenCADStudio { /// Mutable access to the currently selected multileader style. - pub(in crate::app) fn mleaderstyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::MultiLeaderStyle> { + pub(in crate::app) fn mleaderstyle_mut( + &mut self, + tab: usize, + ) -> Option<&mut acadrust::objects::MultiLeaderStyle> { use acadrust::objects::ObjectType; let name = self.mleaderstyle_selected.clone(); self.tabs[tab] @@ -1129,6 +1133,12 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { } let s = crate::ui::color_select::color_to_aci_string(color); let edit = match self.color_pick_target.take().map(|(target, _)| target) { + Some(crate::app::ColorPickTarget::Gradient(index)) => { + Some(Message::GradientColorChanged(index, color)) + } + Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { + Some(Message::SolidFillColorChanged(color)) + } Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0d98f523e..ae9944d23 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -613,7 +613,10 @@ bg={bg_ms:.1}ms n={view_count}" let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { Some(acadrust::EntityType::Spline(spline)) - if crate::entities::spline::shows_control_vertices(spline) => spline, + if crate::entities::spline::shows_control_vertices(spline) => + { + spline + } _ => return None, }; if tab @@ -1109,8 +1112,12 @@ bg={bg_ms:.1}ms n={view_count}" (None, _) if rectangle_values.is_some() => { let (width, height) = rectangle_values.unwrap(); match f.role { - crate::command::DynRole::Width => crate::entities::common::format_length(width), - crate::command::DynRole::Height => crate::entities::common::format_length(height), + crate::command::DynRole::Width => { + crate::entities::common::format_length(width) + } + crate::command::DynRole::Height => { + crate::entities::common::format_length(height) + } _ => String::new(), } } @@ -1839,6 +1846,7 @@ bg={bg_ms:.1}ms n={view_count}" } match id { crate::ui::dock::PanelId::Properties => self.show_properties, + crate::ui::dock::PanelId::GraphicAttributes => self.show_graphic_attributes, crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, crate::ui::dock::PanelId::Browser => self.show_browser, @@ -2742,7 +2750,9 @@ impl OpenCADStudio { } else { Subscription::none() }, - self.spacemouse.subscription().map(|_| Message::SpaceMouseWake), + self.spacemouse + .subscription() + .map(|_| Message::SpaceMouseWake), crate::input::trackpad::subscription().map(Message::TrackpadPinch), event::listen_with(|event, _, id| match event { iced::Event::Window(window::Event::Focused) => { @@ -2929,18 +2939,42 @@ impl OpenCADStudio { let auto_collapse = self.dock.auto_collapse(id); let panel: Element<'_, Message> = match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), + crate::ui::dock::PanelId::GraphicAttributes => { + let draw_depth = tab.scene.draw_depth_map(); + crate::ui::window::graphic_attributes::view( + &tab.scene.document, + &tab.scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect::>(), + draw_depth.as_ref(), + width, + auto_collapse, + side, + self.graphic_attribute_menu_open, + self.solid_fill_color_menu_open, + self.fill_transparency_menu_open, + self.hatch_editor_handles.is_some(), + self.gradient_editor.as_ref(), + matches!( + self.color_pick_target.as_ref().map(|(target, _)| target), + Some(crate::app::ColorPickTarget::Gradient(_)) + ), + ) + } crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } - crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( - width, - auto_collapse, - tab.xref_missing, - &tab.scene.document, - ), + crate::ui::dock::PanelId::ExternalReferences => { + self.xref_manager + .view(width, auto_collapse, tab.xref_missing, &tab.scene.document) + } crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( &tab.scene.document, - tab.sketch_session.as_ref().map(|session| session.name.as_str()), + tab.sketch_session + .as_ref() + .map(|session| session.name.as_str()), width, auto_collapse, ), diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index e4d4ec7b2..0c05a9330 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -261,6 +261,13 @@ impl HatchCommand { pub fn with_origin(mut self, origin: [f64; 2]) -> Self { self.default_origin = origin; self } + pub fn with_pattern(mut self, name: impl Into, pattern: HatchPattern) -> Self { + self.pattern_override = Some((name.into(), pattern)); + self + } + + pub fn finish_selected(mut self) -> CmdResult { self.on_enter() } + fn set_object_selection(&mut self, handles: Vec) { let mut segments = Vec::new(); for handle in &handles { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index d804d6d44..a43d3909c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -103,7 +103,7 @@ fn family_from_stored_line( /// Preserve the selected hue while moving its HSL lightness towards the /// persisted one-colour tint/shade target (0 = black, 1 = white). -fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { +pub(crate) fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { let max = base[0].max(base[1]).max(base[2]); let min = base[0].min(base[1]).min(base[2]); let lightness = (max + min) * 0.5; @@ -2002,9 +2002,11 @@ impl Scene { let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); - if path.edges.iter().any(|edge| { - matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) - }) { + if path + .edges + .iter() + .any(|edge| matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))) + { spline_paths.push((path, local_ring, range)); } boundary_exterior.push(depth == 0); @@ -2210,13 +2212,21 @@ impl Scene { } let project = |point: [f64; 2]| { let [x, y] = to_xy(point[0], point[1]); - [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + [ + (x - world_origin[0]) as f32 as f64, + (y - world_origin[1]) as f32 as f64, + ] }; for (path, ring, range) in spline_paths.into_iter().rev() { if range.end > boundary.len() { continue; } - let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + let curves = || { + path.edges + .iter() + .filter_map(crate::entities::hatch::edge_curve) + .collect() + }; if let Some(refined) = cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project) { @@ -2224,7 +2234,10 @@ impl Scene { continue; } boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); - local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + local_boundary.splice( + range, + refined.into_iter().map(|[x, y]| [x as f32, y as f32]), + ); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d620e553f..ed8b858ca 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -37,6 +37,7 @@ pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_mar mod dwg_native_constraints; mod entity; pub use entity::CreateBlockOptions; +pub(crate) use entity::gradient_tint_color; #[cfg(test)] mod hatch_boundary; mod group_layer; diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index bd05c1db7..75da4751f 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -232,18 +232,7 @@ fn color_selector_with_indicator<'a>( } let popup = container(color_list(extras, on_select, on_more)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - } - }) + .style(popup_panel_style) .padding(2); // `DropDown` keeps the popup outside the surrounding form layout and @@ -257,7 +246,20 @@ fn color_selector_with_indicator<'a>( .into() } -fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { +pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } +} + +pub(crate) fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { let palette = theme.palette(); let hovered = matches!(status, button::Status::Hovered); let text_color = if hovered { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 2be4829a2..2991d9e51 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -40,6 +40,7 @@ pub enum DockMsg { #[serde(rename_all = "snake_case")] pub enum PanelId { Properties, + GraphicAttributes, BlockPalette, ExternalReferences, /// Outline of the drawing's origin planes, open sketch and solid bodies. @@ -51,6 +52,7 @@ impl PanelId { pub fn title(self) -> &'static str { match self { PanelId::Properties => "Properties", + PanelId::GraphicAttributes => "Graphic Attributes", PanelId::BlockPalette => "Block Palette", PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", @@ -61,6 +63,7 @@ impl PanelId { fn default_width(self) -> f32 { match self { PanelId::Properties => 250.0, + PanelId::GraphicAttributes => 250.0, PanelId::BlockPalette => 260.0, PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, @@ -120,7 +123,7 @@ pub struct DockState { impl Default for DockState { fn default() -> Self { Self { - left: vec![PanelId::Properties], + left: vec![PanelId::Properties, PanelId::GraphicAttributes], right: vec![PanelId::BlockPalette], panels: BTreeMap::new(), } @@ -149,12 +152,20 @@ impl DockState { pub fn ensure_settings(&mut self) { for id in [ PanelId::Properties, + PanelId::GraphicAttributes, PanelId::BlockPalette, PanelId::ExternalReferences, PanelId::Browser, ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } + if self.location(PanelId::GraphicAttributes).is_none() { + if let Some((side, index)) = self.location(PanelId::Properties) { + self.stack_mut(side).insert(index + 1, PanelId::GraphicAttributes); + } else { + self.left.push(PanelId::GraphicAttributes); + } + } } /// Where (if anywhere) a panel is currently docked. @@ -262,6 +273,7 @@ mod tests { fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); + assert_eq!(state.location(PanelId::GraphicAttributes), Some((DockSide::Left, 1))); assert_eq!( state.location(PanelId::BlockPalette), Some((DockSide::Right, 0)) @@ -273,6 +285,7 @@ mod tests { let mut state = DockState::default(); state.ensure_settings(); assert_eq!(state.width(PanelId::Properties, 1600.0), 250.0); + assert_eq!(state.width(PanelId::GraphicAttributes, 1600.0), 250.0); assert_eq!(state.width(PanelId::BlockPalette, 1600.0), 260.0); assert!(!state.auto_collapse(PanelId::Properties)); } @@ -286,7 +299,7 @@ mod tests { Some((DockSide::Right, 0)) ); // It no longer occupies the left edge. - assert!(state.left.is_empty()); + assert_eq!(state.left, vec![PanelId::GraphicAttributes]); assert_eq!(state.right.len(), 2); } @@ -360,4 +373,4 @@ mod tests { assert_eq!(drop_index(10.0, 2, 900.0), 0); assert_eq!(drop_index(890.0, 2, 900.0), 2); } -} \ No newline at end of file +} diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 5f41b1847..20a5cec9e 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -95,6 +95,7 @@ impl fmt::Display for SelectionGroup { #[derive(Clone)] struct HatchPatternPreview { pattern: crate::scene::model::hatch_model::HatchPattern, + padding: f32, } impl canvas::Program for HatchPatternPreview { @@ -112,7 +113,7 @@ impl canvas::Program for HatchPatternPreview { let mut frame = canvas::Frame::new(renderer, bounds.size()); let palette = theme.palette(); - let pad = 4.0; + let pad = self.padding; let sample = canvas::Path::rectangle( Point::new(pad, pad), Size::new( @@ -201,6 +202,31 @@ fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) } } +pub(crate) fn hatch_pattern_preview( + pattern: crate::scene::model::hatch_model::HatchPattern, + height: f32, +) -> Element<'static, Message> { + canvas(HatchPatternPreview { + pattern, + padding: 4.0, + }) + .width(Length::Fill) + .height(height) + .into() +} + +pub(crate) fn compact_hatch_pattern_preview( + pattern: crate::scene::model::hatch_model::HatchPattern, +) -> Element<'static, Message> { + canvas(HatchPatternPreview { + pattern, + padding: 0.5, + }) + .width(Length::Fill) + .height(ROW_H) + .into() +} + fn hatch_pattern_matches( entry: &crate::scene::model::hatch_patterns::PatternEntry, search: &str, @@ -1553,11 +1579,7 @@ impl PropertiesPanel { let selected = current.eq_ignore_ascii_case(&entry.name); let focused = self.hatch_pattern_focus == index; let name = entry.name.clone(); - let preview = canvas(HatchPatternPreview { - pattern: entry.gpu.clone(), - }) - .width(Length::Fill) - .height(PATTERN_PREVIEW_H); + let preview = hatch_pattern_preview(entry.gpu.clone(), PATTERN_PREVIEW_H); let card = button( column![ preview, diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs new file mode 100644 index 000000000..058afc52e --- /dev/null +++ b/src/ui/window/gradient_editor.rs @@ -0,0 +1,424 @@ +use crate::app::Message; +use crate::scene::model::hatch_model::GradientKind; +use acadrust::types::Color as AcadColor; +use iced::widget::{ + button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip, + Space, +}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientColorMode { + One, + Two, +} + +#[derive(Debug, Clone)] +pub struct GradientEditorState { + pub handles: Vec, + pub color_mode: GradientColorMode, + pub color_1: AcadColor, + pub color_2: AcadColor, + pub shade_tint: f32, + pub kind: GradientKind, + pub inverted: bool, + pub angle: String, + pub centered: bool, + pub color_open: Option, +} + +impl GradientEditorState { + pub fn from_hatch( + handles: Vec, + hatch: &acadrust::entities::Hatch, + ) -> Self { + let color = |index: usize| { + hatch + .gradient_color + .colors + .get(index) + .map(|entry| entry.color.clone()) + .unwrap_or(AcadColor::Index(7)) + }; + let (kind, inverted) = GradientKind::from_name(&hatch.gradient_color.name); + Self { + handles, + color_mode: if hatch.gradient_color.is_single_color { + GradientColorMode::One + } else { + GradientColorMode::Two + }, + color_1: color(0), + color_2: color(1), + shade_tint: hatch.gradient_color.color_tint as f32, + kind, + inverted, + angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()), + centered: hatch.gradient_color.shift.abs() < 1e-9, + color_open: None, + } + } + + pub fn angle_radians(&self) -> f64 { + self.angle + .trim() + .trim_end_matches('°') + .replace(',', ".") + .parse::() + .unwrap_or(0.0) + .rem_euclid(360.0) + .to_radians() + } +} + +fn rgb(color: &AcadColor) -> Color { + let (r, g, b) = match color { + AcadColor::Rgb { r, g, b } => (*r, *g, *b), + AcadColor::Index(i) => { + acadrust::types::aci_table::aci_to_rgb(*i).unwrap_or((128, 128, 128)) + } + _ => (255, 255, 255), + }; + Color::from_rgb8(r, g, b) +} + +fn rgb_array(color: &AcadColor) -> [f32; 4] { + let c = rgb(color); + [c.r, c.g, c.b, 1.0] +} + +fn mix_rgba(a: Color, b: Color, t: f32) -> [u8; 4] { + let channel = |a: f32, b: f32| ((a + (b - a) * t) * 255.0).round() as u8; + [channel(a.r, b.r), channel(a.g, b.g), channel(a.b, b.b), 255] +} + +fn preview_sized( + state: &GradientEditorState, + width: u32, + height: u32, +) -> iced::widget::image::Handle { + let c1 = rgb(&state.color_1); + let c2 = if state.color_mode == GradientColorMode::One { + let c = crate::scene::gradient_tint_color(rgb_array(&state.color_1), state.shade_tint); + Color::from_rgb(c[0], c[1], c[2]) + } else { + rgb(&state.color_2) + }; + let angle = state.angle_radians(); + let frame = cadkernel::geom2d::gradient_frame( + &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + angle, + if state.centered { 0.0 } else { 1.0 }, + cadkernel::geom2d::Tolerance::default(), + ) + .expect("the preview rectangle always defines a gradient frame"); + let (sin, cos) = angle.sin_cos(); + let radial = state.kind.radial(); + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + let py = y as f64 / (height - 1) as f64; + for x in 0..width { + let px = x as f64 / (width - 1) as f64; + let mut t = if radial { + ((px - frame.center[0]).hypot(py - frame.center[1]) / frame.radius).clamp(0.0, 1.0) + as f32 + } else { + (((px * cos + py * sin) - frame.projection_min) / frame.projection_span) + .clamp(0.0, 1.0) as f32 + }; + if state.kind == GradientKind::Cylinder { + t = 1.0 - (2.0 * t - 1.0).abs(); + } else if state.kind == GradientKind::Curved { + t *= t; + } else if state.kind == GradientKind::Hemispherical { + t = t.sqrt(); + } + if state.inverted { + t = 1.0 - t; + } + pixels.extend_from_slice(&if radial { + mix_rgba(c2, c1, t) + } else { + mix_rgba(c1, c2, t) + }); + } + } + iced::widget::image::Handle::from_rgba(width, height, pixels) +} + +fn preview(state: &GradientEditorState) -> iced::widget::image::Handle { + preview_sized(state, 370, 80) +} + +pub(crate) fn compact_preview( + hatch: &acadrust::entities::Hatch, +) -> iced::widget::image::Handle { + preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26) +} + +fn icon(kind: GradientKind, inverted: bool) -> &'static [u8] { + match (kind, inverted) { + (GradientKind::Linear, _) => { + include_bytes!("../../../assets/icons/gradient/gradient_linear.svg") + } + (GradientKind::Cylinder, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_cylindrical.svg") + } + (GradientKind::Cylinder, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_cylindrical_inverted.svg") + } + (GradientKind::Spherical, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_spherical.svg") + } + (GradientKind::Spherical, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_spherical_inverted.svg") + } + (GradientKind::Hemispherical, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_hemispherical.svg") + } + (GradientKind::Hemispherical, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_hemispherical_inverted.svg") + } + (GradientKind::Curved, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_curved.svg") + } + (GradientKind::Curved, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_curved_inverted.svg") + } + } +} + +pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { + let segment = |label, mode| { + let selected = state.color_mode == mode; + button(text(label).size(11)) + .on_press(Message::GradientColorModeChanged(mode)) + .width(Fill) + .style(move |theme: &Theme, status| { + if selected { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + }; + let color_field = |label: &'static str, index, color: AcadColor| { + let (background, _) = crate::ui::properties::acad_color_display(color.clone()); + let value = crate::ui::color_select::color_display_name(color.clone()); + let picker = button( + row![ + crate::ui::color_select::swatch(background), + text(value).size(10), + Space::new().width(Fill), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::Gradient(index), + color, + )) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color( + if matches!(status, button::Status::Hovered) { + palette.background.weak.color + } else { + palette.background.base.color + }, + )), + text_color: palette.background.base.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + }) + .height(crate::ui::ROW_H) + .padding([3, 6]) + .width(Fill); + row![text(label).size(11).width(43), picker] + .spacing(4) + .align_y(iced::Center) + .width(Fill) + }; + let kinds = [ + (GradientKind::Linear, "Linear"), + (GradientKind::Cylinder, "Cyl."), + (GradientKind::Spherical, "Spher."), + (GradientKind::Hemispherical, "Hemi."), + (GradientKind::Curved, "Curved"), + ]; + let mut type_row = row![].spacing(3); + for (kind, label) in kinds { + let selected = state.kind == kind; + let b = button( + column![ + crate::ui::icons::semantic( + icon(kind, state.inverted && kind != GradientKind::Linear), + 25.0 + ), + text(label).size(9) + ] + .align_x(iced::Center), + ) + .on_press(Message::GradientTypeChanged(kind)) + .width(48) + .height(48) + .style(move |theme: &Theme, status| { + let mut s = button::subtle(theme, status); + if selected { + s.border.color = theme.palette().primary.base.color; + s.border.width = 1.0; + } + s + }); + type_row = type_row.push(tooltip( + b, + text(kind.choice_label(false)).size(10), + tooltip::Position::Bottom, + )); + } + let invert_selected = state.inverted && state.kind != GradientKind::Linear; + let invert = button(column![text("⇄").size(18), text("Invert").size(9)].align_x(iced::Center)) + .on_press_maybe( + (state.kind != GradientKind::Linear).then_some(Message::GradientInvertToggled), + ) + .width(42) + .height(48) + .style(move |theme: &Theme, status| { + let mut style = button::secondary(theme, status); + if invert_selected { + style.border.color = theme.palette().primary.base.color; + style.border.width = 1.0; + } + style + }); + let separator = + container(Space::new().width(1).height(36)).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + type_row = type_row.push(separator).push(tooltip( + invert, + text("Invert gradient").size(10), + tooltip::Position::Bottom, + )); + let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two { + row![ + color_field("Color 1", 1, state.color_1.clone()), + color_field("Color 2", 2, state.color_2.clone()) + ] + .spacing(6) + .into() + } else { + row![ + color_field("Color 1", 1, state.color_1.clone()), + row![ + text("Tone").size(11).width(43), + slider(0..=100, (state.shade_tint * 100.0) as i32, |v| { + Message::GradientShadeTintChanged(v as f32 / 100.0) + }) + .width(Fill) + ] + .spacing(4) + .align_y(iced::Center) + .height(crate::ui::ROW_H) + .width(Fill) + ] + .spacing(6) + .align_y(iced::Center) + .into() + }; + let angle = row![ + text("Angle").size(11).width(43), + text_input("0.0°", &state.angle) + .on_input(Message::GradientAngleChanged) + .size(11) + .width(Length::Fixed(92.0)), + tooltip( + button(crate::ui::icons::themed_undo(11.0, true)) + .on_press(Message::GradientAngleReset) + .style(button::subtle) + .padding(3) + .width(24) + .height(crate::ui::ROW_H), + text("Reset angle").size(10), + tooltip::Position::Bottom, + ), + Space::new().width(Fill), + checkbox(state.centered) + .label("Centered") + .on_toggle(Message::GradientCenteredChanged) + .size(14) + .text_size(11) + ] + .spacing(6) + .align_y(iced::Center); + let types = row![text("Type").size(11).width(43), type_row] + .spacing(4) + .align_y(iced::Center); + let actions = container( + row![ + button(text(crate::t!("Cancel")).size(11)) + .on_press(Message::GradientCancel) + .style(button::secondary) + .padding([5, 14]), + button(text(crate::t!("OK")).size(11)) + .on_press(Message::GradientApply) + .style(button::primary) + .padding([5, 18]), + ] + .spacing(8), + ) + .width(Fill) + .align_x(iced::alignment::Horizontal::Right); + let content = column![ + image(preview(state)).width(Fill).height(80), + row![ + segment("One Color", GradientColorMode::One), + segment("Two Colors", GradientColorMode::Two) + ] + .spacing(2), + colors, + types, + angle, + actions + ] + .spacing(8) + .padding(10); + let panel: Element<'_, Message> = container(content) + .width(Length::Fixed(390.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .into(); + let panel: Element<'_, Message> = if dimmed { + let shield = container(Space::new()) + .width(Fill) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.strongest.color.scale_alpha(0.55), + )), + ..Default::default() + }); + iced::widget::stack![panel, iced::widget::opaque(shield)] + .width(Length::Fixed(390.0)) + .into() + } else { + panel + }; + mouse_area(panel) + .interaction(iced::mouse::Interaction::Pointer) + .into() +} diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs new file mode 100644 index 000000000..beced95ae --- /dev/null +++ b/src/ui/window/graphic_attributes.rs @@ -0,0 +1,668 @@ +//! Graphic Attributes palette: apply or remove an associative object fill. + +use crate::app::Message; +use crate::ui::dock::{DockMsg, PanelId}; +use acadrust::{CadDocument, EntityType, Handle}; +use iced::widget::{ + button, column, container, image, mouse_area, row, slider, text, tooltip, Space, +}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +pub(crate) struct SolidFillColorInfo { + pub color: acadrust::types::Color, + pub display: iced::Color, + pub varies: bool, +} + +struct FillTransparencyInfo { + value: u8, + varies: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicAttribute { + Varies, + None, + Solid, + Hatch, + Gradient, +} + +impl GraphicAttribute { + pub const ALL: [Self; 4] = [Self::None, Self::Solid, Self::Hatch, Self::Gradient]; + + fn icon(self) -> &'static [u8] { + match self { + Self::Varies | Self::None => { + include_bytes!("../../../assets/icons/hatch/hatch_none.svg") + } + Self::Solid => include_bytes!("../../../assets/icons/hatch/hatch_solid.svg"), + Self::Hatch => include_bytes!("../../../assets/icons/hatch/hatch_lines.svg"), + Self::Gradient => include_bytes!("../../../assets/icons/hatch/hatch_gradient.svg"), + } + } +} + +impl std::fmt::Display for GraphicAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Varies => "*VARIES*", + Self::None => "None", + Self::Solid => "Solid", + Self::Hatch => "Hatch", + Self::Gradient => "Gradient", + }) + } +} + +pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { + // Scan the document once, rather than once per selected object on every frame. + let mut fills: rustc_hash::FxHashMap> = + selected.iter().map(|handle| (*handle, None)).collect(); + for entity in document.entities() { + let EntityType::Hatch(hatch) = entity else { + continue; + }; + let value = if hatch.gradient_color.enabled { + GraphicAttribute::Gradient + } else if hatch.is_solid { + GraphicAttribute::Solid + } else { + GraphicAttribute::Hatch + }; + if let Some(fill) = fills.get_mut(&entity.common().handle) { + fill.get_or_insert(value); + } + if !hatch.is_associative { + continue; + } + for handle in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { + if let Some(fill) = fills.get_mut(handle) { + fill.get_or_insert(value); + } + } + } + let Some((&first, rest)) = selected.split_first() else { + return GraphicAttribute::None; + }; + let first = fills[&first].unwrap_or(GraphicAttribute::None); + if rest + .iter() + .any(|handle| fills[handle].unwrap_or(GraphicAttribute::None) != first) + { + GraphicAttribute::Varies + } else { + first + } +} + +pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { + let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); + document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + let directly_selected = selected.contains(&entity.common().handle); + let selected_boundary = hatch.is_associative + && hatch.paths.iter().any(|path| { + path.boundary_handles + .iter() + .any(|handle| selected.contains(handle)) + }); + (directly_selected || selected_boundary).then_some(entity.common().handle) + }) + .collect() +} + +pub(crate) fn solid_fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { + fill_handles(document, selected) + .into_iter() + .filter(|handle| { + matches!( + document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) + if hatch.is_solid && !hatch.gradient_color.enabled + ) + }) + .collect() +} + +fn fill_transparency_info( + document: &CadDocument, + selected: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let handles = fill_handles(document, selected); + let top = handles.iter().copied().max_by(|left, right| { + let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); + let right = draw_depth + .get(&right.value()) + .map_or(0.0, |depth| depth[0]); + left.total_cmp(&right) + })?; + let top_entity = document.get_entity(top)?; + let stored = top_entity.common().transparency; + let effective = if stored.is_by_layer() { + document + .layers + .get(&top_entity.common().layer) + .map_or(stored, |layer| layer.transparency) + } else if stored.is_by_block() { + acadrust::types::Transparency::from_percent(0.0) + } else { + stored + }; + Some(FillTransparencyInfo { + value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, + varies: handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| entity.common().transparency != stored) + }), + }) +} + +fn solid_fill_color_info( + document: &CadDocument, + selected: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let handles = solid_fill_handles(document, selected); + let top = handles.iter().copied().max_by(|left, right| { + let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); + let right = draw_depth + .get(&right.value()) + .map_or(0.0, |depth| depth[0]); + left.total_cmp(&right) + })?; + let top_entity = document.get_entity(top)?; + let color = top_entity.common().color; + let varies = handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| entity.common().color != color) + }); + let rgba = crate::scene::view::render::render_style_for_common_viewport( + document, + top_entity.common(), + None, + ) + .0; + Some(SolidFillColorInfo { + color, + display: iced::Color::from_rgba(rgba[0], rgba[1], rgba[2], 1.0), + varies, + }) +} + +fn top_fill_hatch<'a>( + document: &'a CadDocument, + selected: &[Handle], + draw_depth: &rustc_hash::FxHashMap, + expected: GraphicAttribute, +) -> Option<&'a acadrust::entities::Hatch> { + let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); + document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + let value = if hatch.gradient_color.enabled { + GraphicAttribute::Gradient + } else if hatch.is_solid { + GraphicAttribute::Solid + } else { + GraphicAttribute::Hatch + }; + if value != expected { + return None; + } + let directly_selected = selected.contains(&entity.common().handle); + let selected_boundary = hatch.is_associative + && hatch.paths.iter().any(|path| { + path.boundary_handles + .iter() + .any(|handle| selected.contains(handle)) + }); + (directly_selected || selected_boundary).then_some((entity.common().handle, hatch)) + }) + .max_by(|(left, _), (right, _)| { + let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); + let right = draw_depth + .get(&right.value()) + .map_or(0.0, |depth| depth[0]); + left.total_cmp(&right) + }) + .map(|(_, hatch)| hatch) +} + +pub fn view<'a>( + document: &'a CadDocument, + selected: &[Handle], + draw_depth: &rustc_hash::FxHashMap, + width: f32, + auto_collapse: bool, + side: crate::app::config::DockSide, + menu_open: bool, + solid_color_menu_open: bool, + transparency_menu_open: bool, + hatch_editor_open: bool, + gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>, + gradient_color_picker_open: bool, +) -> Element<'a, Message> { + let pin_icon = if auto_collapse { + crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + }; + let pin = button(pin_icon) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle( + PanelId::GraphicAttributes, + ))) + .style(button::subtle) + .padding([3, 5]); + let pin = tooltip( + pin, + text(crate::t!("Auto")).size(10), + tooltip::Position::Bottom, + ) + .gap(4); + let close = button(crate::ui::icons::themed_secondary( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::Dock(DockMsg::Close(PanelId::GraphicAttributes))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip( + close, + text(crate::t!("Close")).size(10), + tooltip::Position::Bottom, + ) + .gap(4); + let header = mouse_area( + container( + row![ + text(crate::t!("Graphic Attributes")).size(12), + Space::new().width(Fill), + pin, + close + ] + .spacing(3) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Fill) + .padding([3, 6]), + ) + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::GraphicAttributes))) + .interaction(iced::mouse::Interaction::Grab); + + let current = current(document, selected); + let picker = button( + row![ + crate::ui::icons::semantic(current.icon(), 14.0), + text(current.to_string()).size(11), + Space::new().width(Fill), + crate::ui::icons::themed_arrow_toggle(menu_open, 9.0), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(Message::ToggleGraphicAttributeDropdown) + .style(move |theme: &Theme, _| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: palette.background.base.text, + border: Border { + color: if menu_open { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + }) + .height(crate::ui::ROW_H) + .width(Fill) + .padding([3, 6]); + let picker: Element<'_, Message> = if menu_open { + let mut rows = column![].spacing(0); + for value in GraphicAttribute::ALL { + rows = rows.push( + button( + row![ + crate::ui::icons::semantic(value.icon(), 14.0), + text(value.to_string()).size(11), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(Message::GraphicAttributeChanged(value)) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]), + ); + } + let popup = container(rows) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + crate::ui::color_select::drop_down_below( + picker.into(), + popup.into(), + None, + Length::Shrink, + Message::CloseGraphicAttributeDropdown, + ) + } else { + picker.into() + }; + let mut body = column![text(crate::t!("Fill")).size(11), picker].spacing(6); + if current == GraphicAttribute::Solid { + if let Some(info) = solid_fill_color_info(document, selected, draw_depth) { + let label = info.varies.then(|| { + container(text("* VARIES *").size(10)) + .width(Fill) + .align_x(iced::Center) + .align_y(iced::Center) + }); + let color = button(label.unwrap_or_else(|| { + container(Space::new()).width(Fill).height(Fill) + })) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::GraphicAttributesSolid, + info.color, + )) + .width(Fill) + .height(crate::ui::ROW_H) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(info.display)), + text_color: if info.display.r * 0.299 + + info.display.g * 0.587 + + info.display.b * 0.114 + > 0.55 + { + iced::Color::BLACK + } else { + iced::Color::WHITE + }, + border: Border { + color: if matches!(status, button::Status::Hovered) { + theme.palette().primary.base.color + } else { + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let more = button(text("...").size(crate::ui::ROW_H * 0.42)) + .on_press(Message::ToggleSolidFillColorDropdown) + .style(move |theme: &Theme, status| { + if solid_color_menu_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let more: Element<'_, Message> = if solid_color_menu_open { + let choice = |label, color| { + button(text(label).size(11)) + .on_press(Message::SolidFillColorChanged(color)) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + }; + let popup = container(column![ + choice("ByLayer", acadrust::types::Color::ByLayer), + choice("ByBlock", acadrust::types::Color::ByBlock), + button(text("Custom...").size(11)) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::GraphicAttributesSolid, + info.color, + )) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + let alignment = match side { + crate::app::config::DockSide::Left => { + iced_aw::drop_down::Alignment::BottomEnd + } + crate::app::config::DockSide::Right => { + iced_aw::drop_down::Alignment::BottomStart + } + }; + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(alignment) + .offset(2.0) + .on_dismiss(Message::CloseSolidFillColorDropdown) + .into() + } else { + more.into() + }; + body = body.push(row![color, more].spacing(4).align_y(iced::Center)); + } + } else if current == GraphicAttribute::Gradient { + if let Some(hatch) = top_fill_hatch( + document, + selected, + draw_depth, + GraphicAttribute::Gradient, + ) { + let preview = container( + image(crate::ui::window::gradient_editor::compact_preview(hatch)) + .width(Fill) + .height(crate::ui::ROW_H), + ) + .width(Fill) + .height(crate::ui::ROW_H) + .style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) + .on_press(if gradient_editor.is_some() { + Message::GradientCancel + } else { + Message::GradientEditorOpen + }) + .style(move |theme: &Theme, status| { + if gradient_editor.is_some() { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let edit = tooltip( + edit, + text("Edit gradient").size(10), + tooltip::Position::Bottom, + ); + let edit: Element<'_, Message> = if let Some(editor) = gradient_editor { + let alignment = match side { + crate::app::config::DockSide::Left => { + iced_aw::drop_down::Alignment::BottomEnd + } + crate::app::config::DockSide::Right => { + iced_aw::drop_down::Alignment::BottomStart + } + }; + iced_aw::DropDown::new( + edit, + crate::ui::window::gradient_editor::view( + editor, + gradient_color_picker_open, + ), + true, + ) + .width(Length::Fixed(390.0)) + .alignment(alignment) + .offset(4.0) + .into() + } else { + edit.into() + }; + body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); + } + } else if current == GraphicAttribute::Hatch { + let preview: Element<'_, Message> = top_fill_hatch( + document, + selected, + draw_depth, + GraphicAttribute::Hatch, + ) + .and_then(|hatch| { + crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) + .map(|model| model.pattern) + }) + .map(crate::ui::properties::compact_hatch_pattern_preview) + .unwrap_or_else(|| { + container(Space::new()) + .width(Fill) + .height(crate::ui::ROW_H) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .into() + }); + let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) + .on_press(if hatch_editor_open { + Message::HatchEditorClose + } else { + Message::HatchEditorOpen + }) + .style(move |theme: &Theme, status| { + if hatch_editor_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let edit = tooltip(edit, text("Edit hatch").size(10), tooltip::Position::Bottom); + let edit: Element<'_, Message> = if hatch_editor_open { + let alignment = match side { + crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + crate::app::config::DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, + }; + iced_aw::DropDown::new(edit, crate::ui::window::hatch_editor::view(), true) + .width(Length::Fixed(390.0)) + .alignment(alignment) + .offset(4.0) + .into() + } else { + edit.into() + }; + body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); + } + if current != GraphicAttribute::None { + if let Some(info) = fill_transparency_info(document, selected, draw_depth) { + let value_label = if info.varies { + "*VARIES*".to_string() + } else { + format!("{}%", info.value) + }; + let control = row![ + text(crate::t!("Transparency")).size(11).width(75), + slider(0..=90, i32::from(info.value), |value| { + Message::FillTransparencyChanged(value as u8) + }) + .width(Fill), + text(value_label).size(10).width(45), + ] + .spacing(5) + .align_y(iced::Center) + .height(crate::ui::ROW_H); + let more = button(text("...").size(crate::ui::ROW_H * 0.42)) + .on_press(Message::ToggleFillTransparencyDropdown) + .style(move |theme: &Theme, status| { + if transparency_menu_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let more: Element<'_, Message> = if transparency_menu_open { + let row_button = |label, message| { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + }; + let popup = container(column![ + row_button("ByLayer", Message::FillTransparencyByLayer), + row_button("ByBlock", Message::FillTransparencyByBlock), + row_button( + "Custom", + Message::FillTransparencyChanged(info.value), + ), + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + let alignment = match side { + crate::app::config::DockSide::Left => { + iced_aw::drop_down::Alignment::BottomEnd + } + crate::app::config::DockSide::Right => { + iced_aw::drop_down::Alignment::BottomStart + } + }; + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(alignment) + .offset(2.0) + .on_dismiss(Message::CloseFillTransparencyDropdown) + .into() + } else { + more.into() + }; + body = body.push(row![control, more].spacing(4).align_y(iced::Center)); + } + } + let body = body.padding(8); + + container(column![header, body]) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .into() +} diff --git a/src/ui/window/hatch_editor.rs b/src/ui/window/hatch_editor.rs new file mode 100644 index 000000000..677bff862 --- /dev/null +++ b/src/ui/window/hatch_editor.rs @@ -0,0 +1,36 @@ +use crate::app::Message; +use iced::widget::{button, column, container, mouse_area, row, text, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +pub fn view() -> Element<'static, Message> { + let content = column![ + text("Hatch settings").size(12), + text("Additional hatch settings will be available here.").size(11), + Space::new().height(12), + container( + row![button(text(crate::t!("Close")).size(11)) + .on_press(Message::HatchEditorClose) + .style(button::secondary) + .padding([5, 14])] + .width(Fill), + ) + .width(Fill) + .align_x(iced::alignment::Horizontal::Right), + ] + .spacing(8) + .padding(10); + let panel = container(content) + .width(Length::Fixed(390.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + mouse_area(panel) + .interaction(iced::mouse::Interaction::Pointer) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 5adbfda83..6f2de35a1 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -2,6 +2,9 @@ pub mod about; pub mod block_definition; pub mod block_palette; pub mod browser; +pub mod graphic_attributes; +pub mod gradient_editor; +pub mod hatch_editor; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; From abef285cc60b31afe8617cece3c68758ef7a1cac Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 29 Sep 2026 10:10:05 +0200 Subject: [PATCH 02/10] Add Graphic Attributes fill and line controls --- assets/icons/attributes/lineweight.svg | 57 ++ assets/icons/attributes/transparency.svg | 50 ++ src/app/mod.rs | 33 + src/app/update/command.rs | 677 +++++++-------- src/app/update/dialog.rs | 402 ++++++++- src/app/update/mod.rs | 214 ++++- src/app/update/style.rs | 44 +- src/app/view/mod.rs | 7 + src/scene/entity.rs | 19 +- src/scene/hatch_boundary/tests.rs | 40 +- src/scene/model/hatch_model.rs | 17 +- src/scene/paper.rs | 50 +- src/ui/properties.rs | 305 ++++--- src/ui/window/graphic_attributes.rs | 1007 +++++++++++++++++----- src/ui/window/hatch_editor.rs | 36 - src/ui/window/mod.rs | 45 +- 16 files changed, 2112 insertions(+), 891 deletions(-) create mode 100644 assets/icons/attributes/lineweight.svg create mode 100644 assets/icons/attributes/transparency.svg delete mode 100644 src/ui/window/hatch_editor.rs diff --git a/assets/icons/attributes/lineweight.svg b/assets/icons/attributes/lineweight.svg new file mode 100644 index 000000000..1cd7f45e2 --- /dev/null +++ b/assets/icons/attributes/lineweight.svg @@ -0,0 +1,57 @@ + + + + + + + + + diff --git a/assets/icons/attributes/transparency.svg b/assets/icons/attributes/transparency.svg new file mode 100644 index 000000000..451288a65 --- /dev/null +++ b/assets/icons/attributes/transparency.svg @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/app/mod.rs b/src/app/mod.rs index 79dd787e8..6468edd00 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -770,6 +770,12 @@ pub(super) struct OpenCADStudio { show_properties: bool, show_graphic_attributes: bool, graphic_attribute_menu_open: bool, + line_color_menu_open: bool, + line_linetype_menu_open: bool, + line_lineweight_menu_open: bool, + line_transparency_menu_open: bool, + graphic_hatch_pattern_search: String, + graphic_hatch_pattern_focus: usize, solid_fill_color_menu_open: bool, fill_transparency_menu_open: bool, gradient_editor: Option, @@ -1531,6 +1537,7 @@ pub enum ColorPickerTab { #[derive(Debug, Clone)] pub enum ColorPickTarget { Gradient(u8), + GraphicAttributesLine, GraphicAttributesSolid, DimStyle(DsField), MLeader(&'static str), @@ -2057,6 +2064,22 @@ pub enum Message { ToggleGraphicAttributeDropdown, CloseGraphicAttributeDropdown, GraphicAttributeChanged(crate::ui::window::graphic_attributes::GraphicAttribute), + ToggleLineColorDropdown, + CloseLineColorDropdown, + LineColorChanged(AcadColor), + ToggleLineLinetypeMenu, + CloseLineLinetypeMenu, + LineLinetypeChanged(String), + ToggleLineLineweightMenu, + CloseLineLineweightMenu, + LineLineweightChanged(LineWeight), + LineLinetypeScaleChanged(f64), + LineLinetypeScaleInput(String), + ToggleLineTransparencyDropdown, + CloseLineTransparencyDropdown, + LineTransparencyChanged(u8), + LineTransparencyByLayer, + LineTransparencyByBlock, ToggleSolidFillColorDropdown, CloseSolidFillColorDropdown, SolidFillColorChanged(AcadColor), @@ -2067,6 +2090,10 @@ pub enum Message { FillTransparencyByBlock, HatchEditorOpen, HatchEditorClose, + GraphicHatchPatternSearchChanged(String), + GraphicHatchPatternFocus(usize), + GraphicHatchPatternChanged(String), + GraphicHatchPatternConfirm, GradientEditorOpen, GradientColorModeChanged(crate::ui::window::gradient_editor::GradientColorMode), GradientColorToggle(u8), @@ -4130,6 +4157,12 @@ impl OpenCADStudio { show_properties: true, show_graphic_attributes: true, graphic_attribute_menu_open: false, + line_color_menu_open: false, + line_linetype_menu_open: false, + line_lineweight_menu_open: false, + line_transparency_menu_open: false, + graphic_hatch_pattern_search: String::new(), + graphic_hatch_pattern_focus: 0, solid_fill_color_menu_open: false, fill_transparency_menu_open: false, gradient_editor: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 78b9be13c..27a1090f0 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -4,23 +4,22 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; -use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::app::TextEntryMode; +use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{ - find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, -}; #[cfg(not(target_arch = "wasm32"))] use crate::plugin::v4_support; use crate::scene::model::object::{GripApply, PropValue}; +use crate::scene::pick::grip::{ + find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, +}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; -use crate::ui::PropertiesPanel; use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; +use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; @@ -243,12 +242,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Live incremental search for INSERT/MINSERT (see CommandInput) let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } - } else { (false, Vec::new(), String::new()) }; + let (should_update, opts, prompt) = + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } + } else { + (false, Vec::new(), String::new()) + }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + if let Some(last) = self.command_line.history.last_mut() { + if last.pinned { + last.text = prompt; + } + } } } } @@ -290,12 +300,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } - } else { (false, Vec::new(), String::new()) }; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() + { + if cmd.on_live_input(&live) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } + } else { + (false, Vec::new(), String::new()) + }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + if let Some(last) = self.command_line.history.last_mut() { + if last.pinned { + last.text = prompt; + } + } } } self.focus_cmd_input() @@ -324,9 +345,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::Lengthen => { Some(GripEditMode::Lengthen) } - crate::scene::model::object::GripMenuAction::Radius => { - Some(GripEditMode::Radius) - } + crate::scene::model::object::GripMenuAction::Radius => Some(GripEditMode::Radius), crate::scene::model::object::GripMenuAction::ArcLength => { Some(GripEditMode::ArcLength) } @@ -342,7 +361,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { _ => None, }; expected_mode.is_some_and(|mode| { - !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { + !self.tabs[self.active_tab] + .active_grip + .as_ref() + .is_some_and(|grip| { grip.mode == mode && grip.handle == pending.handle && grip.grip_id == pending.grip_id @@ -369,17 +391,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { - self.command_line.push_error(crate::tf!( - "{}: expected a number, got \"{raw}\"", - pending.label - ).as_ref()); + self.command_line.push_error( + crate::tf!("{}: expected a number, got \"{raw}\"", pending.label).as_ref(), + ); self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_value_grip = self.tabs[i] - .active_grip - .as_ref() - .is_some_and(|grip| { + let interactive_value_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { matches!( (grip.mode, pending.action), ( @@ -401,8 +419,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripEditMode::MoveParallel, crate::scene::model::object::GripMenuAction::MoveParallel, ) - ) - && grip.handle == pending.handle + ) && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); if interactive_value_grip { @@ -410,8 +427,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } use crate::entities::traits::EntityTypeOps; self.push_undo_snapshot(i, pending.label); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) { entity.apply_grip_menu_value(pending.grip_id, pending.action, v); } // A typed grip-menu value reshapes dimensions too — drop a @@ -419,10 +435,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .scene .invalidate_dim_block_recorded(pending.handle); - self.tabs[i].scene.bump_entities(&[( - pending.handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(pending.handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); @@ -439,22 +454,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; if let Some(grip) = self.tabs[i].active_grip.clone() { - if matches!(grip.mode, GripEditMode::Stretch | GripEditMode::RectangleResize) { + if matches!( + grip.mode, + GripEditMode::Stretch | GripEditMode::RectangleResize + ) { let dyn_locked = self.tabs[i] .dyn_fields .iter() .any(|field| field.buffer.is_some()); let target = if matches!(grip.mode, GripEditMode::RectangleResize) { - grip.rectangle_frame.map(|(opposite, width_axis, height_axis)| { + grip.rectangle_frame + .map(|(opposite, width_axis, height_axis)| { let cursor_delta = self.tabs[i].last_cursor_world - opposite; let mut width = cursor_delta.dot(width_axis); let mut height = cursor_delta.dot(height_axis); for field in &self.tabs[i].dyn_fields { - let value = field - .buffer - .as_ref() - .and_then(|buffer| crate::app::expr_eval::eval_number(buffer)); + let value = field.buffer.as_ref().and_then(|buffer| { + crate::app::expr_eval::eval_number(buffer) + }); if let Some(value) = value { match field.role { crate::command::DynRole::Width => { @@ -479,9 +497,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_resolve_point() } else { // Legacy command-line direct-distance entry. - let text = crate::app::expr_eval::eval_to_string( - self.command_line.input.trim(), - ); + let text = + crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); crate::app::expr_eval::eval_number(text.trim()).map(|dist| { let cursor = self.tabs[i].last_cursor_world; @@ -546,7 +563,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } let delta = target - grip.last_world; - let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame { + let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = + grip.rectangle_frame + { let opposite_id = (grip.grip_id + 2) % 4; let mut edits = Vec::with_capacity(3); for adjacent_id in [(opposite_id + 1) % 4, (opposite_id + 3) % 4] { @@ -554,44 +573,46 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, candidate)| **owner == grip.handle && candidate.id == adjacent_id) + .find(|(owner, candidate)| { + **owner == grip.handle && candidate.id == adjacent_id + }) .map(|(_, candidate)| candidate.world) { let d = original - opposite; - let axis = if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { + let axis = + if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { width_axis } else { height_axis }; - edits.push((grip.handle, adjacent_id, GripApply::Absolute( + edits.push(( + grip.handle, + adjacent_id, + GripApply::Absolute( opposite + axis * (target - opposite).dot(axis), - ))); + ), + )); } } edits.push((grip.handle, grip.grip_id, GripApply::Absolute(target))); edits } else { - grip.targets.iter().map(|target_grip| { + grip.targets + .iter() + .map(|target_grip| { let apply = if target_grip.is_translate { GripApply::Translate(delta) } else { - GripApply::Absolute( - target_grip.last_world + delta, - ) + GripApply::Absolute(target_grip.last_world + delta) }; - ( - target_grip.handle, - target_grip.grip_id, - apply, - ) - }).collect() + (target_grip.handle, target_grip.grip_id, apply) + }) + .collect() }; for (handle, grip_id, apply) in actions { - self.tabs[i] - .scene - .apply_grip(handle, grip_id, apply); + self.tabs[i].scene.apply_grip(handle, grip_id, apply); } self.solve_grip_constraints(i, &grip); @@ -901,7 +922,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { /// has an established anchor, and `text` parses as a scalar distance, /// project along the active reference ray (if any) or the current cursor /// direction in the active UCS plane. - pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option> { + pub(in crate::app) fn try_direct_distance_entry( + &mut self, + text: &str, + ) -> Option> { let i = self.active_tab; if self.tabs[i] .active_cmd @@ -1013,10 +1037,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; let grip_dyn_locked = self.tabs[i].active_grip.is_some() && self.dyn_input - && self.tabs[i] - .dyn_fields - .iter() - .any(|field| field.locked()); + && self.tabs[i].dyn_fields.iter().any(|field| field.locked()); if grip_dyn_locked { return self.update(Message::CommandSubmit); @@ -1126,7 +1147,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { sel.box_crossing_locked = false; } if was_pan { - self.command_line.push_output(crate::t!("PAN ended.").as_ref()); + self.command_line + .push_output(crate::t!("PAN ended.").as_ref()); } self.ribbon.deactivate_tool(); return Task::none(); @@ -1212,7 +1234,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_layer_toggle_vp_freeze(&mut self, layer_idx: usize, vp_col_idx: usize) -> Task { + pub(super) fn on_layer_toggle_vp_freeze( + &mut self, + layer_idx: usize, + vp_col_idx: usize, + ) -> Task { let i = self.active_tab; let vp_handle = self.tabs[i] .layers @@ -1305,9 +1331,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // added row is measured. match new_idx { Some(idx) => iced::widget::operation::scroll_to( - iced::advanced::widget::Id::new( - crate::ui::window::layers::LAYER_TABLE_SCROLL_ID, - ), + iced::advanced::widget::Id::new(crate::ui::window::layers::LAYER_TABLE_SCROLL_ID), iced::widget::scrollable::AbsoluteOffset { x: 0.0, y: idx as f32 * crate::ui::ROW_H, @@ -1326,12 +1350,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Layer "0" and the current layer can't be deleted — drop them // from the batch and note it. - let current = self.tabs[i].scene.document.header.current_layer_name.clone(); + let current = self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(); let before = names.len(); names.retain(|n| n != "0" && *n != current); if names.len() < before { - self.command_line - .push_info(crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref()); + self.command_line.push_info( + crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref(), + ); } if names.is_empty() { return Task::none(); @@ -1424,7 +1454,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { fn sync_layer_panel(&mut self, i: usize) { let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); self.tabs[i].layers.selected = None; self.tabs[i].layers.selected_multi.clear(); self.sync_ribbon_layers(); @@ -1525,10 +1557,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { movement, ); self.tabs[i].dirty = true; - self.tabs[i].scene.bump_entities(&[( - popup.handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); } } // Stretch / Move = grab this grip. Engage it so the next @@ -1547,9 +1578,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(acadrust::EntityType::MultiLeader(_)) ); let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) - && is_multileader - { + if matches!(item.action, GripMenuAction::MoveWithLeader) && is_multileader { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { ( @@ -1569,14 +1598,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let (frame, plane) = crate::entities::lwpolyline::rectangle_frame(polyline)?; - let opposite = polyline - .vertices - .get((popup.grip_id + 2) % 4)? - .location; + let opposite = polyline.vertices.get((popup.grip_id + 2) % 4)?.location; Some(( - glam::DVec3::from_array( - plane.point_at([opposite.x, opposite.y]), - ), + glam::DVec3::from_array(plane.point_at([opposite.x, opposite.y])), glam::DVec3::from_array(plane.vector_at(frame.width_axis)) .try_normalize()?, glam::DVec3::from_array(plane.vector_at(frame.height_axis)) @@ -1642,9 +1666,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| { - **owner == popup.handle && grip.id == popup.grip_id - }) + .find(|(owner, grip)| **owner == popup.handle && grip.id == popup.grip_id) { if self.grip_originals.is_empty() { self.grip_originals = self.tabs[i] @@ -1656,36 +1678,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .unwrap_or_default(); } self.tabs[i].active_grip = Some(match item.action { - GripMenuAction::Radius => GripEdit::radius( - popup.handle, - popup.grip_id, - grip.world, - ), - GripMenuAction::ArcLength => GripEdit::arc_length( - popup.handle, - popup.grip_id, - grip.world, - ), - GripMenuAction::RectangleWidth => GripEdit::rectangle_width( - popup.handle, - popup.grip_id, - grip.world, - ), - GripMenuAction::RectangleHeight => GripEdit::rectangle_height( - popup.handle, - popup.grip_id, - grip.world, - ), - GripMenuAction::MoveParallel => GripEdit::move_parallel( - popup.handle, - popup.grip_id, - grip.world, - ), - _ => GripEdit::lengthen( - popup.handle, - popup.grip_id, - grip.world, - ), + GripMenuAction::Radius => { + GripEdit::radius(popup.handle, popup.grip_id, grip.world) + } + GripMenuAction::ArcLength => { + GripEdit::arc_length(popup.handle, popup.grip_id, grip.world) + } + GripMenuAction::RectangleWidth => { + GripEdit::rectangle_width(popup.handle, popup.grip_id, grip.world) + } + GripMenuAction::RectangleHeight => { + GripEdit::rectangle_height(popup.handle, popup.grip_id, grip.world) + } + GripMenuAction::MoveParallel => { + GripEdit::move_parallel(popup.handle, popup.grip_id, grip.world) + } + _ => GripEdit::lengthen(popup.handle, popup.grip_id, grip.world), }); } // Popup actions do not pass through the normal grip @@ -1695,23 +1703,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // move (and so keyboard input has a field to target). self.sync_dyn_fields(); if matches!(item.action, GripMenuAction::Radius) { - self.command_line.push_info( - crate::t!("Specify point or enter radius:").as_ref(), - ); + self.command_line + .push_info(crate::t!("Specify point or enter radius:").as_ref()); } else if matches!(item.action, GripMenuAction::ArcLength) { - self.command_line.push_info( - crate::t!("Specify point or enter arc length:").as_ref(), - ); + self.command_line + .push_info(crate::t!("Specify point or enter arc length:").as_ref()); } else if matches!(item.action, GripMenuAction::RectangleWidth) { self.command_line.push_info("Specify point or enter width:"); } else if matches!(item.action, GripMenuAction::RectangleHeight) { - self.command_line.push_info("Specify point or enter height:"); + self.command_line + .push_info("Specify point or enter height:"); } else if matches!(item.action, GripMenuAction::MoveParallel) { - self.command_line.push_info("Specify point or enter parallel offset:"); + self.command_line + .push_info("Specify point or enter parallel offset:"); } else { - self.command_line.push_info( - crate::t!("Specify point or enter distance:").as_ref(), - ); + self.command_line + .push_info(crate::t!("Specify point or enter distance:").as_ref()); } } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); @@ -1737,7 +1744,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line.push_output(crate::t!("Polyline broken at vertex.").as_ref()); + self.command_line + .push_output(crate::t!("Polyline broken at vertex.").as_ref()); } None => self .command_line @@ -1763,9 +1771,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }); if let Some((original, new_gid, old_len)) = placement { let dirty_before = self.tabs[i].dirty; - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(popup.handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { entity.apply_grip_menu(popup.grip_id, item.action); } let inserted = self.tabs[i] @@ -1775,18 +1781,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .and_then(polyline_vertex_count) .is_some_and(|len| len == old_len + 1); if !inserted { - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(popup.handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { *entity = original; } - self.tabs[i].scene.bump_entities(&[ - (popup.handle, crate::scene::ChangeKind::Modified), - ]); + self.tabs[i] + .scene + .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line.push_error(crate::t!("Cannot add a vertex here.").as_ref()); + self.command_line + .push_error(crate::t!("Cannot add a vertex here.").as_ref()); return Task::none(); } self.tabs[i] @@ -1799,30 +1804,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| { - **owner == popup.handle && grip.id == new_gid - }) + .find(|(owner, grip)| **owner == popup.handle && grip.id == new_gid) .map(|(_, grip)| grip.world); if let Some(grip_world) = grip_world { self.grip_originals = vec![(popup.handle, original)]; self.grip_dirty_before = Some(dirty_before); - self.tabs[i].active_grip = Some(GripEdit::single( - popup.handle, - new_gid, - false, - grip_world, - )); + self.tabs[i].active_grip = + Some(GripEdit::single(popup.handle, new_gid, false, grip_world)); self.command_line .push_info(crate::t!("Specify new vertex location:").as_ref()); } else { - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(popup.handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { *entity = original; } - self.tabs[i].scene.bump_entities(&[ - (popup.handle, crate::scene::ChangeKind::Modified), - ]); + self.tabs[i] + .scene + .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); @@ -1842,10 +1839,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { crate::entities::spline::shows_fit_points(spline) } - ( - GripMenuAction::ShowControlVertices, - acadrust::EntityType::Spline(spline), - ) => { + (GripMenuAction::ShowControlVertices, acadrust::EntityType::Spline(spline)) => { crate::entities::spline::shows_control_vertices(spline) } _ => false, @@ -1898,12 +1892,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .zip(self.tabs[i].selected_grips.iter()) .find(|(owner, g)| **owner == popup.handle && g.id == new_gid) { - self.tabs[i].active_grip = Some(GripEdit::single( - popup.handle, - new_gid, - false, - g.world, - )); + self.tabs[i].active_grip = + Some(GripEdit::single(popup.handle, new_gid, false, g.world)); self.grip_add_provisional = Some((popup.handle, new_gid)); } } @@ -1934,14 +1924,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // sync clipboard read returns nothing there); native uses // iced's clipboard. #[cfg(target_arch = "wasm32")] - return Task::perform( - crate::sys::read_clipboard_text(), - Message::MTextPasteClip, - ); + return Task::perform(crate::sys::read_clipboard_text(), Message::MTextPasteClip); #[cfg(not(target_arch = "wasm32"))] - return iced::clipboard::read_text().map(|result| { - Message::MTextPasteClip(result.ok().map(|text| (*text).clone())) - }); + return iced::clipboard::read_text() + .map(|result| Message::MTextPasteClip(result.ok().map(|text| (*text).clone()))); } if self.text_inline.is_some() { // Web: the iced text_input can't reach the async clipboard, @@ -1967,9 +1953,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { Task::perform(crate::sys::read_clipboard_text(), |text| { Message::SystemClipboardPaste(match text { - Some(text) if !text.is_empty() => { - crate::app::SystemClipboardText::Text(text) - } + Some(text) if !text.is_empty() => crate::app::SystemClipboardText::Text(text), _ => crate::app::SystemClipboardText::EmptyOrUnsupported, }) }) @@ -1999,10 +1983,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; let remembered = self.qselect_settings.clone(); - let scope = remembered.as_ref().map_or( - crate::app::QSelectScope::CurrentSpace, - |settings| settings.scope, - ); + let scope = remembered + .as_ref() + .map_or(crate::app::QSelectScope::CurrentSpace, |settings| { + settings.scope + }); let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); let type_filter = if let Some(settings) = remembered.as_ref() { settings @@ -2036,11 +2021,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let mut operator = remembered .as_ref() .map_or(crate::app::QSelectOp::Eq, |settings| settings.operator); - if matches!(operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !property.as_ref().is_some_and(|property| { + if matches!( + operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !property.as_ref().is_some_and(|property| { matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + }) { operator = crate::app::QSelectOp::Eq; } let value = remembered.as_ref().map_or_else(String::new, |settings| { @@ -2103,9 +2089,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // baked into the cached wire geometry — re-tessellate so the // change shows immediately (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); } }); @@ -2132,9 +2116,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.active_color = color; } else { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_color(entity, color); } }); @@ -2171,9 +2153,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // re-tessellate so the dashed/solid look updates immediately // (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); } }); @@ -2182,7 +2162,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_ribbon_style_changed(&mut self, key: crate::modules::StyleKey, name: String) -> Task { + pub(super) fn on_ribbon_style_changed( + &mut self, + key: crate::modules::StyleKey, + name: String, + ) -> Task { use crate::modules::StyleKey; self.ribbon.close_dropdown(); match key { @@ -2239,11 +2223,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task { let i = self.active_tab; let handles = self.property_target_handles(i); + self.apply_hatch_pattern(i, &handles, name) + } + + pub(super) fn apply_hatch_pattern( + &mut self, + i: usize, + handles: &[acadrust::Handle], + name: String, + ) -> Task { if !handles.is_empty() { use crate::scene::model::hatch_patterns; if let Some(entry) = hatch_patterns::find(&name) { self.push_undo_snapshot(i, "HATCHEDIT"); - for &handle in &handles { + for &handle in handles { if let Some(acadrust::EntityType::Hatch(dxf)) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -2261,14 +2254,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { dxf.pattern_angle, ); let origin = dxf.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + origin.x, + origin.y, + ); dxf.pattern = pattern; dxf.is_solid = matches!( entry.gpu, crate::scene::model::hatch_model::HatchPattern::Solid ); - dxf.pattern_type = - acadrust::entities::HatchPatternType::Predefined; + dxf.pattern_type = acadrust::entities::HatchPatternType::Predefined; dxf.gradient_color.enabled = false; } if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) { @@ -2297,8 +2293,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The Annotative Yes/No list drives the per-object annotative toggle // (MTEXT's own flag, the annotation context for the rest). if field == "annotative" { - let wanted = - value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); + let wanted = value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); let Some(handle) = handles.first().copied() else { return Task::none(); }; @@ -2423,14 +2418,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - let Some(mut state) = self.tabs[i] + let Some(mut state) = + self.tabs[i] .scene .document .get_entity(handle) .and_then(|entity| match entity { - acadrust::EntityType::Surface(surface) => Some( - crate::entities::solid3d::surface_property_state(surface), - ), + acadrust::EntityType::Surface(surface) => { + Some(crate::entities::solid3d::surface_property_state(surface)) + } _ => None, }) else { @@ -2452,7 +2448,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &mut self.tabs[i].scene.document, handle, crate::entities::solid3d::SURFACE_PROPERTIES_APP, - Some(crate::entities::solid3d::surface_property_xdata_values(state)), + Some(crate::entities::solid3d::surface_property_xdata_values( + state, + )), ); if field == PROP_SURFACE_WIREFRAME_TYPE { self.tabs[i].scene.reseed_derived_caches(handle); @@ -2643,15 +2641,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .then(|| { doc.objects.values().find_map(|object| match object { acadrust::objects::ObjectType::MultiLeaderStyle(style) - if style.name == value => Some(style.clone()), + if style.name == value => + { + Some(style.clone()) + } _ => None, }) }) .flatten(); let resolved: Option = match field { - "mleader_style" => { - resolved_mleader_style.as_ref().map(|style| style.handle) - } + "mleader_style" => resolved_mleader_style.as_ref().map(|style| style.handle), "text_style_handle" => doc .text_styles .iter() @@ -2788,9 +2787,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .document .block_records .iter() - .find(|b| { - crate::app::properties::arrowhead_label(&b.name) == value - }) + .find(|b| crate::app::properties::arrowhead_label(&b.name) == value) .map(|b| b.handle) } else { None @@ -2838,12 +2835,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(XDataValue::Handle(acadrust::Handle::NULL)), ); } else if let Some(h) = arrow_h { - dov::set( - doc, - handle, - dov::DIMLDRBLK, - Some(XDataValue::Handle(h)), - ); + dov::set(doc, handle, dov::DIMLDRBLK, Some(XDataValue::Handle(h))); } } _ => {} @@ -2876,10 +2868,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { if ins.block_name != canon { ins.block_name = canon.clone(); - changes.push(( - handle, - crate::scene::ChangeKind::Modified, - )); + changes.push((handle, crate::scene::ChangeKind::Modified)); } } } @@ -2888,7 +2877,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else if field == "tbl_style_handle" { - let style_handle = self.tabs[i] + let style_handle = + self.tabs[i] .scene .document .objects @@ -2916,7 +2906,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { continue; } if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); + crate::scene::view::dispatch::apply_common_prop( + entity, + "transparency", + &value, + ); } } } else if field == "plot_style" { @@ -2927,22 +2921,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. - let dict_h = - self.tabs[i].scene.document.header.acad_plotstylename_dict_handle; + let dict_h = self.tabs[i] + .scene + .document + .header + .acad_plotstylename_dict_handle; let ph: Option = crate::scene::annotative::as_dict(&self.tabs[i].scene.document, dict_h) - .and_then(|d| { - d.entries - .iter() - .find(|(n, _)| *n == value) - .map(|(_, h)| *h) - }); + .and_then(|d| d.entries.iter().find(|(n, _)| *n == value).map(|(_, h)| *h)); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -2976,17 +2967,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .iter() .find_map(|(h, o)| match o { - acadrust::objects::ObjectType::Material(m) if m.name == value => { - Some(*h) - } + acadrust::objects::ObjectType::Material(m) if m.name == value => Some(*h), _ => None, }); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -3017,7 +3005,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::command::WorkingPlane::default() }; for &handle in &handles { - let mline_style = self.tabs[i] + let mline_style = + self.tabs[i] .scene .document .get_entity(handle) @@ -3038,19 +3027,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &value, ); } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( - entity, - field, - &value, - plane, + entity, field, &value, plane, ); if matches!(field, "ml_justification" | "ml_scale") { - if let ( - acadrust::EntityType::MLine(mline), - Some(style), - ) = (entity, mline_style.as_ref()) + if let (acadrust::EntityType::MLine(mline), Some(style)) = + (entity, mline_style.as_ref()) { crate::modules::draw::draw::mline::sync_mline_element_parameters( mline, style, @@ -3080,14 +3063,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { match field { "transparency" => { - let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else { - self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90."); + let Some(transparency) = + crate::scene::creation_style::parse_current_transparency(&value) + else { + self.command_line.push_error( + "Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90.", + ); self.refresh_properties(); return Task::none(); }; - if self.tabs[i].scene.document.current_entity_transparency() - != transparency - { + if self.tabs[i].scene.document.current_entity_transparency() != transparency { self.push_undo_snapshot(i, "CETRANSPARENCY"); if self.tabs[i] .scene @@ -3123,8 +3108,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } _ => { if let Some(h) = mat_handle { - self.tabs[i].scene.document.header.current_material_handle = - h; + self.tabs[i].scene.document.header.current_material_handle = h; } } } @@ -3137,12 +3121,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } match value.as_str() { - "ByBlock" => { - self.tabs[i].scene.document.header.current_plotstyle_type = 1 - } - "Normal" => { - self.tabs[i].scene.document.header.current_plotstyle_type = 2 - } + "ByBlock" => self.tabs[i].scene.document.header.current_plotstyle_type = 1, + "Normal" => self.tabs[i].scene.document.header.current_plotstyle_type = 2, _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, } self.tabs[i].dirty = true; @@ -3161,8 +3141,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .values() .find_map(|object| { - let acadrust::objects::ObjectType::Layout(layout) = object - else { + let acadrust::objects::ObjectType::Layout(layout) = object else { return None; }; layout @@ -3170,9 +3149,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .eq_ignore_ascii_case(&layout_name) .then(|| layout.plot_style_sheet.clone()) }) - .unwrap_or_else(|| { - self.tabs[i].scene.document.header.stylesheet.clone() - }); + .unwrap_or_else(|| self.tabs[i].scene.document.header.stylesheet.clone()); if current != next { self.push_undo_snapshot(i, "PLOTSTYLE"); for object in self.tabs[i].scene.document.objects.values_mut() { @@ -3198,7 +3175,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .clone(); if !current.eq_ignore_ascii_case(&value) { self.push_undo_snapshot(i, "CANNOSCALE"); - if self.tabs[i].scene.set_annotation_scale_named(&value).is_some() { + if self.tabs[i] + .scene + .set_annotation_scale_named(&value) + .is_some() + { self.tabs[i].dirty = true; } else { self.discard_last_undo_entry(i); @@ -3375,9 +3356,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "Flat Shaded" => Some(Mode::FlatShaded), "Gouraud Shaded" => Some(Mode::GouraudShaded), "Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges), - "Gouraud Shaded + Edges" => { - Some(Mode::GouraudShadedWithEdges) - } + "Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges), _ => None, }; if let Some(mode) = mode { @@ -3415,8 +3394,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "start_x" | "start_y" | "start_z" => Some(Some(0)), "end_x" | "end_y" | "end_z" => Some(Some(1)), "center_x" | "center_y" | "center_z" => Some(Some(-3)), - "radius" | "diameter" | "circumference" | "area" - | "major_r" | "minor_r" | "ratio" => Some(None), + "radius" | "diameter" | "circumference" | "area" | "major_r" | "minor_r" + | "ratio" => Some(None), _ => None, }; let retain_size = self.constraint_solve_mode @@ -3442,9 +3421,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .iter() .flat_map(|section| section.props.iter()) .find(|property| property.field == field) - .is_some_and(|property| { - matches!(property.value, PropValue::EditText(_)) - }); + .is_some_and(|property| matches!(property.value, PropValue::EditText(_))); if let Some(raw_val) = self.tabs[i] .properties .edit_buf @@ -3464,20 +3441,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .filter_map(|handle| { let entity = self.tabs[i].scene.document.get_entity(*handle)?; match (field, entity) { - ( - "current_fit_point", - acadrust::EntityType::Spline(spline), - ) => Some(spline.fit_points.len()), - ( - "current_control_point", - acadrust::EntityType::Spline(spline), - ) => Some( - crate::entities::spline::control_vertex_count(spline), - ), - ( - "pm_current_vertex", - acadrust::EntityType::PolygonMesh(mesh), - ) => Some(mesh.vertices.len()), + ("current_fit_point", acadrust::EntityType::Spline(spline)) => { + Some(spline.fit_points.len()) + } + ("current_control_point", acadrust::EntityType::Spline(spline)) => { + Some(crate::entities::spline::control_vertex_count(spline)) + } + ("pm_current_vertex", acadrust::EntityType::PolygonMesh(mesh)) => { + Some(mesh.vertices.len()) + } _ => None, } }) @@ -3495,7 +3467,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); if field == "tbl_style_handle" { - let style_handle = self.tabs[i] + let style_handle = + self.tabs[i] .scene .document .objects @@ -3637,13 +3610,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ); if applied && field == "dim_text_inside" { if let Some(acadrust::EntityType::Dimension( - acadrust::entities::Dimension::LargeRadial( - dimension, - ), - )) = self.tabs[i] - .scene - .document - .get_entity_mut(handle) + acadrust::entities::Dimension::LargeRadial(dimension), + )) = self.tabs[i].scene.document.get_entity_mut(handle) { dimension.base.text_user_positioned = false; } @@ -3656,9 +3624,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let vals = if val.trim().is_empty() { None } else { - Some(vec![acadrust::xdata::XDataValue::String( - val.clone(), - )]) + Some(vec![acadrust::xdata::XDataValue::String(val.clone())]) }; crate::scene::view::dispatch::set_entity_xdata( &mut self.tabs[i].scene.document, @@ -3681,12 +3647,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let trimmed = val.trim(); if trimmed.is_empty() { - dov::set( - &mut self.tabs[i].scene.document, - handle, - code, - None, - ); + dov::set(&mut self.tabs[i].scene.document, handle, code, None); } else if let Ok(n) = trimmed.parse::() { dov::set( &mut self.tabs[i].scene.document, @@ -3712,29 +3673,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } _ => { - if crate::scene::model::solid_history::is_primitive_property( - field, - ) { - self.tabs[i].scene.apply_solid_history_property( - handle, - field, - &val, - ); + if crate::scene::model::solid_history::is_primitive_property(field) + { + self.tabs[i] + .scene + .apply_solid_history_property(handle, field, &val); } else if self.tabs[i] .scene - .apply_solid_position_property( - handle, - field, - &val, - plane, - ) + .apply_solid_position_property(handle, field, &val, plane) .is_none() { - let mline_style = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|entity| match entity { + let mline_style = + self.tabs[i].scene.document.get_entity(handle).and_then( + |entity| match entity { acadrust::EntityType::MLine(mline) => { crate::entities::mline::resolved_mline_style( mline, @@ -3743,7 +3694,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .cloned() } _ => None, - }); + }, + ); if matches!(field, "text_x" | "text_y") { crate::entities::dimension::materialize_large_radial_text_position( &mut self.tabs[i].scene.document, @@ -3751,10 +3703,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &val, ); } - if let Some(entity) = self.tabs[i] - .scene - .document - .get_entity_mut(handle) + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( entity, @@ -3774,8 +3724,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - if matches!(field, "srf_u_isolines" | "srf_v_isolines") - { + if matches!(field, "srf_u_isolines" | "srf_v_isolines") { self.tabs[i].scene.reseed_derived_caches(handle); } } @@ -3802,9 +3751,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) } None => { - crate::scene::parametric_constraints::ParametricRef::whole( - handle, - ) + crate::scene::parametric_constraints::ParametricRef::whole(handle) } }) }) @@ -3840,7 +3787,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Existing target → swap the selected references to it. Anonymous, // xref and xref-dependent definitions are not valid targets. - let target = self.tabs[i].scene.document.block_records.get(new).map(|br| { + let target = self.tabs[i] + .scene + .document + .block_records + .get(new) + .map(|br| { ( br.name.clone(), br.is_anonymous() || br.flags.is_xref || br.name.contains('|'), @@ -3943,9 +3895,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .collect::>(); Ok((ins.block_name.clone(), rows)) } - Some(acadrust::EntityType::Insert(_)) => { - Err("ATTEDIT This block has no attributes.") - } + Some(acadrust::EntityType::Insert(_)) => Err("ATTEDIT This block has no attributes."), _ => Err("ATTEDIT Select a block with attributes."), }; match result { @@ -3999,9 +3949,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The block's layer may have been locked while the editor was open — // refuse to write attributes to a locked-layer block. if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - self.command_line.push_info(crate::tf!( + self.command_line.push_info( + crate::tf!( "Object is on locked layer \"{layer}\" — unlock the layer to edit its attributes." - ).as_ref()); + ) + .as_ref(), + ); return Task::none(); } // Snapshot the working copy so the document can be mutated while the @@ -4185,7 +4138,13 @@ mod layer_rename_tests { let i = app.active_tab; let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "TEST"); - let handle = app.tabs[i].scene.document.layers.get("TEST").unwrap().handle; + let handle = app.tabs[i] + .scene + .document + .layers + .get("TEST") + .unwrap() + .handle; rename_layer(&mut app, "TEST", "test"); @@ -4213,7 +4172,10 @@ mod layer_rename_tests { fn current_layer_rename_updates_name_and_allocated_handle() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); + let idx = app.tabs[i] + .layers + .editing + .expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i] .scene @@ -4227,12 +4189,7 @@ mod layer_rename_tests { rename_layer(&mut app, &old_name, "Renamed"); - let layer = app.tabs[i] - .scene - .document - .layers - .get("Renamed") - .unwrap(); + let layer = app.tabs[i].scene.document.layers.get("Renamed").unwrap(); assert!(layer.handle.is_valid()); assert_eq!(app.tabs[i].active_layer, "Renamed"); assert_eq!(app.tabs[i].layers.current_layer, "Renamed"); @@ -4251,7 +4208,10 @@ mod layer_rename_tests { fn layer_rename_undo_redo_restores_active_layer() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); + let idx = app.tabs[i] + .layers + .editing + .expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i].layers.selected = Some(idx); let _ = app.on_layer_set_current(); @@ -4277,7 +4237,10 @@ mod layer_rename_tests { let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "A"); let _ = app.on_layer_new(); - let b_idx = app.tabs[i].layers.editing.expect("new layer is being edited"); + let b_idx = app.tabs[i] + .layers + .editing + .expect("new layer is being edited"); app.tabs[i].layers.selected = Some(b_idx); let _ = app.on_layer_set_current(); let header_name = app.tabs[i].scene.document.header.current_layer_name.clone(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 2c214f62e..76884f72f 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -34,12 +34,15 @@ fn set_hatch_graphic_attribute( hatch.gradient_color.enabled = false; } GraphicAttribute::Hatch => { - let Some(entry) = crate::scene::model::hatch_patterns::catalog().iter().find(|entry| { + let Some(entry) = crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| { matches!( entry.gpu, crate::scene::model::hatch_model::HatchPattern::Pattern(_) ) - }) else { + }) + else { return; }; let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); @@ -49,11 +52,7 @@ fn set_hatch_graphic_attribute( ); crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - origin.x, - origin.y, - ); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); hatch.pattern = pattern; hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; hatch.is_solid = false; @@ -200,10 +199,16 @@ impl OpenCADStudio { use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; use crate::ui::window::graphic_attributes::GraphicAttribute; self.graphic_attribute_menu_open = false; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.solid_fill_color_menu_open = false; self.fill_transparency_menu_open = false; self.gradient_editor = None; self.hatch_editor_handles = None; + self.graphic_hatch_pattern_search.clear(); + self.graphic_hatch_pattern_focus = 0; if value == GraphicAttribute::Varies { return iced::Task::none(); } @@ -220,8 +225,8 @@ impl OpenCADStudio { return iced::Task::none(); } let selected_handles: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); - let (associated, new_hatches, updated_hatches, skipped) = - if value == GraphicAttribute::None { + let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None + { let associated: Vec<_> = self.tabs[i] .scene .document @@ -294,7 +299,8 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity(*handle) { let mut hatch = hatch.clone(); - let visible_color = crate::scene::view::render::render_style_for_common_viewport( + let visible_color = + crate::scene::view::render::render_style_for_common_viewport( &self.tabs[i].scene.document, &hatch.common, None, @@ -402,7 +408,7 @@ impl OpenCADStudio { self.solid_fill_color_menu_open = false; let i = self.active_tab; let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::solid_fill_handles( + let handles = crate::ui::window::graphic_attributes::fill_handles( &self.tabs[i].scene.document, &selected, ); @@ -410,7 +416,7 @@ impl OpenCADStudio { .into_iter() .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); - self.apply_property_op(i, "Solid fill color", &unlocked, |app, handle| { + self.apply_property_op(i, "Fill color", &unlocked, |app, handle| { if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_color(entity, color); } @@ -418,6 +424,146 @@ impl OpenCADStudio { iced::Task::none() } + pub(super) fn on_line_color(&mut self, color: acadrust::types::Color) -> iced::Task { + self.line_color_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + return self.on_ribbon_color_changed(color); + } + let handles = crate::ui::window::graphic_attributes::line_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Line color", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_color(entity, color); + } + }); + iced::Task::none() + } + + pub(super) fn on_line_linetype(&mut self, linetype: String) -> iced::Task { + self.line_linetype_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + return self.on_ribbon_linetype_changed(linetype); + } + let handles = crate::ui::window::graphic_attributes::line_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Line linetype", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); + } + }); + iced::Task::none() + } + + pub(super) fn on_line_lineweight( + &mut self, + lineweight: acadrust::types::LineWeight, + ) -> iced::Task { + self.line_lineweight_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + self.tabs[i].scene.document.header.current_line_weight = lineweight.value(); + self.tabs[i].dirty = true; + self.ribbon.active_lineweight = lineweight; + self.refresh_properties(); + return iced::Task::none(); + } + let handles = crate::ui::window::graphic_attributes::line_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Line weight", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + } + }); + iced::Task::none() + } + + pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task { + let scale = scale.clamp(0.01, 1000.0); + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale = scale; + self.tabs[i].dirty = true; + self.refresh_properties(); + return iced::Task::none(); + } + let handles = crate::ui::window::graphic_attributes::line_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Linetype scale", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + entity.common_mut().linetype_scale = scale; + } + }); + iced::Task::none() + } + + pub(super) fn on_line_transparency( + &mut self, + transparency: acadrust::types::Transparency, + ) -> iced::Task { + self.line_transparency_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + if self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return iced::Task::none(); + } + let handles = crate::ui::window::graphic_attributes::line_handles( + &self.tabs[i].scene.document, + &selected, + ); + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Line transparency", &unlocked, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + entity.common_mut().transparency = transparency; + } + }); + iced::Task::none() + } + pub(super) fn on_fill_transparency( &mut self, transparency: acadrust::types::Transparency, @@ -520,8 +666,11 @@ impl OpenCADStudio { Task::none() } - -pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task { + pub(super) fn on_ribbon_tool_click( + &mut self, + tool_id: String, + event: ModuleEvent, + ) -> Task { // Commands use `start_allowed`; other events need a drawing // and stay blocked on the Start page (#299, #388, #389). if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) { @@ -563,7 +712,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].properties = PropertiesPanel::empty(); - self.command_line.push_output(crate::t!("Scene cleared.").as_ref()); + self.command_line + .push_output(crate::t!("Scene cleared.").as_ref()); } ModuleEvent::SetVisualStyle(name) => { use crate::modules::view::visual_style; @@ -572,9 +722,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven None => { // Name the styles that do exist, from the same // list every other caller reads. - self.command_line.push_error( - crate::tf!("Unknown visual style \"{name}\".").as_ref(), - ); + self.command_line + .push_error(crate::tf!("Unknown visual style \"{name}\".").as_ref()); self.command_line .push_info(crate::t!(visual_style::keyword_prompt()).as_ref()); } @@ -591,8 +740,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } => { return Task::perform( async move { - let exts: Vec<&str> = - extensions.iter().map(|s| s.as_str()).collect(); + let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); let path = crate::sys::file_dialog() .set_title(title) .add_filter(filter_name, &exts) @@ -622,8 +770,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let _ = std::fs::remove_file(self.autosave_target(idx)); if self.tabs.len() == 1 { self.tab_counter += 1; - self.tabs[0] = - crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter); self.active_tab = 0; self.apply_display_defaults(0); } else { @@ -1892,7 +2039,8 @@ mod tests { acadrust::types::Color::Index(3), ); let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); - assert!((app.tabs[i] + assert!( + (app.tabs[i] .scene .document .get_entity(selected_hatch) @@ -1902,7 +2050,8 @@ mod tests { .as_percent() - 0.35) .abs() - < 0.01); + < 0.01 + ); let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); assert!(app.tabs[i] .scene @@ -1912,6 +2061,170 @@ mod tests { .common() .transparency .is_by_layer()); + + // Line controls ignore HATCH entities, while fill controls ignore + // ordinary geometry in the same mixed selection. + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_line_color(acadrust::types::Color::Index(4)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(5), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + let _ = app.on_line_linetype("ByBlock".to_string()); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype, + "ByBlock", + ); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + assert_ne!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + let _ = app.update(Message::LineLinetypeScaleInput("2.5".to_string())); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale + - 2.5) + .abs() + < f64::EPSILON + ); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .linetype_scale + - 1.0) + .abs() + < f64::EPSILON + ); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .transparency + .is_by_layer()); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + let pattern_name = crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| { + matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Pattern(_) + ) + }) + .expect("pattern hatch") + .name + .clone(); + let _ = app.update(Message::GraphicHatchPatternChanged(pattern_name.clone())); + let selected_pattern = match app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("pattern hatch") + { + EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), + _ => panic!("fill target is not a hatch"), + }; + assert_eq!(selected_pattern, pattern_name); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); assert_eq!( crate::ui::window::graphic_attributes::current( @@ -1948,6 +2261,38 @@ mod tests { assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); } + #[test] + fn graphic_line_controls_update_creation_defaults_without_a_selection() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); + let _ = app.on_line_linetype_scale(2.5); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + + let header = &app.tabs[i].scene.document.header; + assert_eq!( + header.current_entity_color, + acadrust::types::Color::Index(2) + ); + assert_eq!(header.current_linetype_name, "Continuous"); + assert_eq!(header.current_line_weight, 35); + assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); + assert!( + (app.tabs[i] + .scene + .document + .current_entity_transparency() + .as_percent() + - 0.3) + .abs() + < 0.01 + ); + } + /// A foreign document: the fresh scene's document plus one model-space LINE. fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { let mut doc = app.tabs[app.active_tab].scene.document.clone(); @@ -2072,12 +2417,11 @@ mod tests { } /// The nested-INSERT's block name inside a defined block record. - fn nested_insert_target( - doc: &acadrust::CadDocument, - block: &str, - ) -> Option { + fn nested_insert_target(doc: &acadrust::CadDocument, block: &str) -> Option { let br = doc.block_records.get(block)?; - br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? { + br.entity_handles + .iter() + .find_map(|h| match doc.get_entity(*h)? { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4ab4e54eb..681cd30f3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1180,19 +1180,23 @@ impl OpenCADStudio { Task::none() } - Message::ImageEmbedPick => { - Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult) - } + Message::ImageEmbedPick => Task::perform( + crate::io::pick_embedded_image_file(), + Message::ImageEmbedPickResult, + ), Message::ImageEmbedPickResult(Ok(image)) => { use crate::command::CadCommand; use crate::modules::draw::draw::raster_image::ImageCommand; - self.command_line.push_output(crate::tf!( + self.command_line.push_output( + crate::tf!( "IMAGEEMBED \"{name}\": {w}×{h} px (embedded)", name = image.name.as_str(), w = image.pixel_width, h = image.pixel_height, - ).as_ref()); + ) + .as_ref(), + ); let cmd = ImageCommand::new_embedded(image); let i = self.active_tab; self.command_line.push_info(&cmd.prompt()); @@ -1279,10 +1283,13 @@ impl OpenCADStudio { .insert(crate::io::font_repo::font_key(name)); } for (name, path) in &pairs { - self.command_line.push_output(crate::tf!( + self.command_line.push_output( + crate::tf!( "FONT Downloaded {name} → {path}", path = path.display() - ).as_ref()); + ) + .as_ref(), + ); } if !unavailable.is_empty() { self.command_line.push_info(crate::tf!( @@ -3720,6 +3727,10 @@ impl OpenCADStudio { Message::ViewportLeftPress => { self.graphic_attribute_menu_open = false; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.solid_fill_color_menu_open = false; self.fill_transparency_menu_open = false; let sweep = self.sync_active_field_if_any(); @@ -3730,6 +3741,10 @@ impl OpenCADStudio { Message::ViewportRightPress => { self.graphic_attribute_menu_open = false; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.solid_fill_color_menu_open = false; self.fill_transparency_menu_open = false; let i = self.active_tab; @@ -3835,6 +3850,10 @@ impl OpenCADStudio { Message::ViewportMiddlePress => { self.graphic_attribute_menu_open = false; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.solid_fill_color_menu_open = false; self.fill_transparency_menu_open = false; self.on_viewport_middle_press() @@ -4993,13 +5012,21 @@ impl OpenCADStudio { } 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.") + 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() { + if self.tabs[i] + .scene + .document + .block_records + .get(&name) + .is_some() + { state.confirm_redefine = Some(name); return Task::none(); } @@ -9511,6 +9538,10 @@ impl OpenCADStudio { Message::BlockPalette(m) => self.on_block_palette(m), Message::ToggleGraphicAttributeDropdown => { self.graphic_attribute_menu_open ^= true; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.solid_fill_color_menu_open = false; self.fill_transparency_menu_open = false; Task::none() @@ -9520,8 +9551,90 @@ impl OpenCADStudio { Task::none() } Message::GraphicAttributeChanged(value) => self.on_graphic_attribute(value), + Message::ToggleLineColorDropdown => { + self.line_color_menu_open ^= true; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseLineColorDropdown => { + self.line_color_menu_open = false; + Task::none() + } + Message::LineColorChanged(color) => self.on_line_color(color), + Message::ToggleLineLinetypeMenu => { + self.line_linetype_menu_open ^= true; + self.line_color_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseLineLinetypeMenu => { + self.line_linetype_menu_open = false; + Task::none() + } + Message::LineLinetypeChanged(value) => self.on_line_linetype(value), + Message::ToggleLineLineweightMenu => { + self.line_lineweight_menu_open ^= true; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_transparency_menu_open = false; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseLineLineweightMenu => { + self.line_lineweight_menu_open = false; + Task::none() + } + Message::LineLineweightChanged(value) => self.on_line_lineweight(value), + Message::LineLinetypeScaleChanged(value) => self.on_line_linetype_scale(value), + Message::LineLinetypeScaleInput(value) => { + let value = value.trim().replace(',', "."); + match value.parse::() { + Ok(value) if value.is_finite() && value > 0.0 => { + self.on_line_linetype_scale(value) + } + _ => Task::none(), + } + } + Message::ToggleLineTransparencyDropdown => { + self.line_transparency_menu_open ^= true; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.graphic_attribute_menu_open = false; + self.solid_fill_color_menu_open = false; + self.fill_transparency_menu_open = false; + Task::none() + } + Message::CloseLineTransparencyDropdown => { + self.line_transparency_menu_open = false; + Task::none() + } + Message::LineTransparencyChanged(value) => self.on_line_transparency( + acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), + ), + Message::LineTransparencyByLayer => { + self.on_line_transparency(acadrust::types::Transparency::BY_LAYER) + } + Message::LineTransparencyByBlock => { + self.on_line_transparency(acadrust::types::Transparency::BY_BLOCK) + } Message::ToggleSolidFillColorDropdown => { self.solid_fill_color_menu_open ^= true; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.graphic_attribute_menu_open = false; self.fill_transparency_menu_open = false; Task::none() @@ -9533,6 +9646,10 @@ impl OpenCADStudio { Message::SolidFillColorChanged(color) => self.on_solid_fill_color(color), Message::ToggleFillTransparencyDropdown => { self.fill_transparency_menu_open ^= true; + self.line_color_menu_open = false; + self.line_linetype_menu_open = false; + self.line_lineweight_menu_open = false; + self.line_transparency_menu_open = false; self.graphic_attribute_menu_open = false; self.solid_fill_color_menu_open = false; Task::none() @@ -9552,17 +9669,78 @@ impl OpenCADStudio { } Message::HatchEditorOpen => { self.gradient_editor = None; - self.hatch_editor_handles = Some( - self.tabs[self.active_tab] - .scene - .selected_handles_in_order(), + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let handles = crate::ui::window::graphic_attributes::fill_handles( + &self.tabs[i].scene.document, + &selected, ); - Task::none() + let current = handles.iter().find_map(|handle| { + let acadrust::EntityType::Hatch(hatch) = + self.tabs[i].scene.document.get_entity(*handle)? + else { + return None; + }; + Some(hatch.pattern.name.as_str()) + }); + self.graphic_hatch_pattern_search.clear(); + self.graphic_hatch_pattern_focus = current + .and_then(|current| { + crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(current)) + }) + .unwrap_or(0); + self.hatch_editor_handles = Some(selected); + iced::widget::operation::focus(iced::widget::Id::new( + "graphic-hatch-pattern-search", + )) } Message::HatchEditorClose => { self.hatch_editor_handles = None; + self.graphic_hatch_pattern_search.clear(); + self.graphic_hatch_pattern_focus = 0; Task::none() } + Message::GraphicHatchPatternSearchChanged(search) => { + self.graphic_hatch_pattern_search = search; + self.graphic_hatch_pattern_focus = 0; + Task::none() + } + Message::GraphicHatchPatternFocus(index) => { + let len = crate::ui::properties::filtered_hatch_patterns( + &self.graphic_hatch_pattern_search, + ) + .len(); + if index < len { + self.graphic_hatch_pattern_focus = index; + } + Task::none() + } + Message::GraphicHatchPatternChanged(name) => { + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let handles = crate::ui::window::graphic_attributes::fill_handles( + &self.tabs[i].scene.document, + &selected, + ); + self.hatch_editor_handles = None; + self.graphic_hatch_pattern_search.clear(); + self.graphic_hatch_pattern_focus = 0; + self.apply_hatch_pattern(i, &handles, name) + } + Message::GraphicHatchPatternConfirm => { + let name = crate::ui::properties::filtered_hatch_patterns( + &self.graphic_hatch_pattern_search, + ) + .get(self.graphic_hatch_pattern_focus) + .map(|entry| entry.name.clone()); + if let Some(name) = name { + self.update(Message::GraphicHatchPatternChanged(name)) + } else { + Task::none() + } + } Message::GradientEditorOpen => { self.hatch_editor_handles = None; self.open_gradient_editor(); @@ -10777,8 +10955,14 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { - if matches!(target, crate::app::ColorPickTarget::GraphicAttributesSolid) { + match target { + crate::app::ColorPickTarget::GraphicAttributesLine => { + self.line_color_menu_open = false; + } + crate::app::ColorPickTarget::GraphicAttributesSolid => { self.solid_fill_color_menu_open = false; + } + _ => {} } self.color_pick_target = Some((target, color)); diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 9d5adf177..94e81be06 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -823,7 +823,8 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_edit(&mut self, field: &'static str, value: String) -> Task { + pub(super) fn on_mleader_style_edit( + &mut self, field: &'static str, value: String,) -> Task { self.mls_color_open = None; match field { "landing_distance" => self.mls_landing_distance = value, @@ -850,7 +851,8 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_enum(&mut self, field: &'static str, value: String) -> Task { + pub(super) fn on_mleader_style_set_enum( + &mut self, field: &'static str, value: String,) -> Task { use acadrust::objects::{ BlockContentConnectionType, LeaderContentType, LeaderDrawOrderType, MultiLeaderDrawOrderType, MultiLeaderPathType, TextAlignmentType, @@ -870,9 +872,8 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { "BottomOfTopLineUnderlineTopLine" => { TextAttachmentType::BottomOfTopLineUnderlineTopLine } - "BottomOfTopLineUnderlineAll" => { - TextAttachmentType::BottomOfTopLineUnderlineAll - } + "BottomOfTopLineUnderlineAll" => + TextAttachmentType::BottomOfTopLineUnderlineAll, "CenterOfText" => TextAttachmentType::CenterOfText, "CenterOfTextOverline" => TextAttachmentType::CenterOfTextOverline, _ => TextAttachmentType::MiddleOfTopLine, @@ -898,9 +899,8 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { } "text_angle_type" => { s.text_angle_type = match value.as_str() { - "ParallelToLastLeaderLine" => { - TextAngleType::ParallelToLastLeaderLine - } + "ParallelToLastLeaderLine" => + TextAngleType::ParallelToLastLeaderLine, "Optimized" => TextAngleType::Optimized, _ => TextAngleType::Horizontal, }; @@ -946,7 +946,8 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_handle(&mut self, field: &'static str, value: String) -> Task { + pub(super) fn on_mleader_style_set_handle( + &mut self, field: &'static str, value: String,) -> Task { let i = self.active_tab; let doc = &self.tabs[i].scene.document; let handle: Option = if value == "None" @@ -1136,35 +1137,34 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Some(crate::app::ColorPickTarget::Gradient(index)) => { Some(Message::GradientColorChanged(index, color)) } - Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { + Some(crate::app::ColorPickTarget::GraphicAttributesLine) => { + Some(Message::LineColorChanged(color)) + } + Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { Some(Message::SolidFillColorChanged(color)) } Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) } - Some(crate::app::ColorPickTarget::Table(r, f)) => { + Some(crate::app::ColorPickTarget::Table(r, f)) => Some(Message::TableStyleCellEdit { row: r, field: f, value: s, - }) - } - Some(crate::app::ColorPickTarget::Properties) => { - Some(Message::PropColorChanged(color)) - } + }), + Some(crate::app::ColorPickTarget::Properties) => + Some(Message::PropColorChanged(color)), Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } Some(crate::app::ColorPickTarget::PropertiesField(field)) => { Some(Message::PropColorFieldChanged { field, color }) } - Some(crate::app::ColorPickTarget::MText) => { - Some(Message::MTextColorChanged(color)) - } - Some(crate::app::ColorPickTarget::Ribbon) => { - Some(Message::RibbonColorChanged(color)) - } + Some(crate::app::ColorPickTarget::MText) => + Some(Message::MTextColorChanged(color)), + Some(crate::app::ColorPickTarget::Ribbon) => + Some(Message::RibbonColorChanged(color)), Some(crate::app::ColorPickTarget::Layer(idx)) => { self.tabs[self.active_tab].layers.selected = Some(idx); Some(Message::LayerColorSet(color)) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index ae9944d23..3cfddacae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2943,6 +2943,7 @@ impl OpenCADStudio { let draw_depth = tab.scene.draw_depth_map(); crate::ui::window::graphic_attributes::view( &tab.scene.document, + &tab.properties, &tab.scene .selected_entities() .into_iter() @@ -2953,9 +2954,15 @@ impl OpenCADStudio { auto_collapse, side, self.graphic_attribute_menu_open, + self.line_color_menu_open, + self.line_linetype_menu_open, + self.line_lineweight_menu_open, + self.line_transparency_menu_open, self.solid_fill_color_menu_open, self.fill_transparency_menu_open, self.hatch_editor_handles.is_some(), + &self.graphic_hatch_pattern_search, + self.graphic_hatch_pattern_focus, self.gradient_editor.as_ref(), matches!( self.color_pick_target.as_ref().map(|(target, _)| target), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a43d3909c..c43ba3cd9 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1269,10 +1269,12 @@ impl Scene { style.0 = crate::scene::view::render::adapt_to_bg(style.0, hatch_bg); m.aci = style.4; m.line_weight_px = style.3; - // A gradient's colour is its first stop (already baked into - // the cached model); only solid / pattern fills take the - // entity's resolved colour. - if !matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + // Gradient RGB comes from its own colour stops, while + // transparency remains an entity property shared by both + // stops. Solid/pattern fills take the complete style. + if matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + m.set_alpha(style.0[3]); + } else { m.color = style.0; } if let EntityType::Hatch(dxf) = e { @@ -2055,7 +2057,14 @@ impl Scene { .colors .get(i) .and_then(|e| e.color.rgb()) - .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) + .map(|(r, g, b)| { + [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + color[3], + ] + }) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs index e6362d232..0210d6489 100644 --- a/src/scene/hatch_boundary/tests.rs +++ b/src/scene/hatch_boundary/tests.rs @@ -4,6 +4,43 @@ use acadrust::types::{Vector2, Vector3}; use cadkernel::geom2d::{refine_spline_boundary, triangulate_rings, Curve, NurbsCurve}; use cadkernel::tessellation::DEFAULT_ANGLE; +#[test] +fn gradient_stops_keep_entity_transparency() { + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch + .paths + .push(rectangle([0.0, 0.0], [10.0, 10.0], [0.0; 2])); + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: acadrust::types::Color::Rgb { r: 255, g: 0, b: 0 }, + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: acadrust::types::Color::Rgb { r: 0, g: 0, b: 255 }, + }, + ]; + + let mut model = Scene::hatch_model_from_dxf(&hatch, [0.2, 0.3, 0.4, 0.35]).unwrap(); + assert!((model.color[3] - 0.35).abs() < f32::EPSILON); + let crate::scene::model::hatch_model::HatchPattern::Gradient { color2, .. } = &model.pattern + else { + panic!("gradient hatch must produce a gradient render pattern"); + }; + assert!((color2[3] - 0.35).abs() < f32::EPSILON); + + model.set_alpha(0.6); + assert!((model.color[3] - 0.6).abs() < f32::EPSILON); + let crate::scene::model::hatch_model::HatchPattern::Gradient { color2, .. } = &model.pattern + else { + unreachable!(); + }; + assert!((color2[3] - 0.6).abs() < f32::EPSILON); +} + fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { let line = |a: [f64; 2], b: [f64; 2]| { [ @@ -184,8 +221,7 @@ fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { |p| p ) .is_none()); - assert!(refine_spline_boundary( - &vec![[0.0; 2]; 513], + assert!(refine_spline_boundary(&vec![[0.0; 2]; 513], || panic!("size cap ignored"), |p| p ) diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 8165bcdbd..56e300ced 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -272,6 +272,18 @@ pub struct HatchModel { pub draw_depth: f32, } +impl HatchModel { + /// Apply an entity's resolved opacity to every colour stop. Gradient stop + /// colours come from the HATCH payload, but transparency remains a common + /// entity property and must affect both ends equally. + pub(crate) fn set_alpha(&mut self, alpha: f32) { + self.color[3] = alpha; + if let HatchPattern::Gradient { color2, .. } = &mut self.pattern { + color2[3] = alpha; + } + } +} + impl HatchModel { pub(crate) fn gradient_frame( &self, @@ -350,9 +362,8 @@ impl HatchModel { self.pattern_segments_with_dot_length(Some(0.0)) } - fn pattern_segments_with_dot_length( - &self, - plot_dot_length: Option, + fn pattern_segments_with_dot_length(&self, + plot_dot_length: Option ) -> Vec<[[f64; 2]; 2]> { let HatchPattern::Pattern(families) = &self.pattern else { return Vec::new(); diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 43f21a996..4cfecd2d5 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -2,8 +2,7 @@ use super::*; impl Scene { - pub(super) fn paper_viewport_handles( - &self, + pub(super) fn paper_viewport_handles(&self ) -> (Handle, Handle, Arc>) { { let cache = self.paper_viewport_cache.borrow(); @@ -12,7 +11,7 @@ impl Scene { return ( cache.layout_block, cache.sheet, - Arc::clone(&cache.content), + Arc::clone(&cache.content) ); } } @@ -123,7 +122,8 @@ impl Scene { ) -> Vec { if self.current_layout == "Model" { let tiles = self.model_tiles.borrow(); - let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); + let active = self.active_model_tile.get() + .min(tiles.len().saturating_sub(1)); return tiles .iter() .enumerate() @@ -298,9 +298,7 @@ impl Scene { hatches.push(sheet); } hatches.extend( - self.paper_canvas_hatches(tint_selected) - .iter() - .cloned(), + self.paper_canvas_hatches(tint_selected).iter().cloned() ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); @@ -377,9 +375,8 @@ impl Scene { /// Physical sheet bounds in canvas pixels. Uses the same forced top-down /// paper transform as the GPU sheet viewport, ignoring stored camera twist. - pub fn paper_sheet_screen_rect( - &self, - canvas_px: (f32, f32), + pub fn paper_sheet_screen_rect(&self, + canvas_px: (f32, f32) ) -> Option { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (canvas_w, canvas_h) = canvas_px; @@ -477,13 +474,17 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color) - .unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - m.color = style.0; + if matches!( + m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + m.set_alpha(style.0[3]); + } else { + m.color = style.0; + } m.aci = style.4; m.line_weight_px = style.3; if let EntityType::Hatch(dxf) = entity { @@ -600,13 +601,19 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color) - .unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - hatch.color = style.0; + if matches!( + hatch.pattern, + model::hatch_model::HatchPattern::Gradient { .. } + ) { + hatch.set_alpha(style.0[3]); + } else { + hatch.color = style.0; + } hatch.aci = style.4; hatch.line_weight_px = style.3; if let EntityType::Hatch(dxf) = entity { @@ -715,9 +722,9 @@ impl Scene { // from a big symbol library) has viewports legitimately aimed at that // second cluster; testing only the dense one wrongly auto-fits them onto // the library. Fall back to the cluster box when no extents are known. - let full_bounds = self.model_space_extents().map(|(mn, mx)| { - (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64) - }); + let full_bounds = self.model_space_extents() + .map(|(mn, mx)| + (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64)); // Absolute drawing centre. Geometry now reaches the scene at absolute // (UTM) coordinates — the old code centred the overlap test and the // auto-fit on the origin, which was right only while world_offset @@ -734,9 +741,8 @@ impl Scene { (self.local_center[0], self.local_center[1]) } else { self.model_space_extents() - .map(|(mn, mx)| { - (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64) - }) + .map(|(mn, mx)| + (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)) .unwrap_or((0.0, 0.0)) }; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 20a5cec9e..7e95a2880 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -10,6 +10,7 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::{fmt, sync::Arc}; +use crate::t; use crate::ui::ROW_H; use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::Handle; @@ -20,7 +21,6 @@ use iced::widget::{ use iced::{ mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme, }; -use crate::t; // ── Row-height-derived constants ───────────────────────────────────────── const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px @@ -234,7 +234,8 @@ fn hatch_pattern_matches( let query = search.trim(); query.is_empty() || entry.name.to_lowercase().contains(&query.to_lowercase()) - || entry.description.to_lowercase().contains(&query.to_lowercase()) + || entry.description.to_lowercase() + .contains(&query.to_lowercase()) } pub(crate) fn filtered_hatch_patterns( @@ -246,6 +247,100 @@ pub(crate) fn filtered_hatch_patterns( .collect() } +pub(crate) fn hatch_pattern_picker_content<'a>( + search_value: &'a str, + focus: usize, + current: &'a str, + search_id: &'static str, + on_search: fn(String) -> Message, + on_confirm: Message, + on_focus: fn(usize) -> Message, + on_changed: fn(String) -> Message, +) -> Element<'a, Message> { + let search = text_input(t!("Search patterns…").as_ref(), search_value) + .id(iced::widget::Id::new(search_id)) + .on_input(on_search) + .on_submit(on_confirm) + .size(FONT_SZ) + .padding([5, 7]) + .width(Length::Fill); + + let mut grid = column![].spacing(6); + let visible = filtered_hatch_patterns(search_value); + for (row_index, pair) in visible.chunks(2).enumerate() { + let mut cards = row![].spacing(6); + for (column_index, entry) in pair.iter().enumerate() { + let index = row_index * 2 + column_index; + let selected = current.eq_ignore_ascii_case(&entry.name); + let focused = focus == index; + let name = entry.name.clone(); + let preview = hatch_pattern_preview(entry.gpu.clone(), PATTERN_PREVIEW_H); + let card = button( + column![ + preview, + container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ)) + .width(Length::Fill) + .align_x(iced::Center), + ] + .spacing(3), + ) + .on_press(on_changed(name)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + let pair = if selected { + palette.primary.weak + } else if hovered || focused { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: if selected || focused { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if selected || focused { 2.0 } else { 1.0 }, + radius: 4.0.into(), + }, + ..Default::default() + } + }) + .padding(5) + .width(PATTERN_CARD_W); + cards = cards.push(mouse_area(card).on_enter(on_focus(index))); + } + grid = grid.push(cards); + } + + let results: Element<'_, Message> = if visible.is_empty() { + container( + text(t!("No matching patterns")) + .size(FONT_SZ) + .style(hint_text_style), + ) + .padding(12) + .width(Length::Fill) + .center_x(Length::Fill) + .into() + } else { + scrollable(grid) + .height(Length::Fill) + .width(Length::Fill) + .into() + }; + container(column![search, results].spacing(7)) + .style(container::bordered_box) + .padding(8) + .width(PATTERN_PICKER_W) + .height(Length::Fixed(PATTERN_PICKER_H)) + .into() +} + /// All standard CAD lineweight options for the combobox. pub fn lw_options() -> Vec { [ @@ -314,7 +409,7 @@ impl FieldKey { } /// Text-input widget id for one column of a named-parameter row. -pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id { +pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField,) -> iced::widget::Id { use crate::ui::window::named_parameters::ParamField; let suffix = match field { ParamField::Name => "name", @@ -363,7 +458,7 @@ pub fn active_key_focused( /// its field key in O(1) instead of re-scanning the sections, enabling /// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( - sections: &[PropSection], + sections: &[PropSection] ) -> HashMap { let mut map = HashMap::default(); for section in sections { @@ -577,7 +672,7 @@ impl PropertiesPanel { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties))) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties,))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { @@ -602,9 +697,7 @@ impl PropertiesPanel { let close = tooltip( close, text(t!("Close")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); + tooltip::Position::Bottom).gap(4); let header = mouse_area( container( @@ -619,7 +712,7 @@ impl PropertiesPanel { ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weak.color, + theme.palette().background.weak.color )), ..Default::default() }) @@ -786,9 +879,7 @@ impl PropertiesPanel { let hdr = container( row![ title, - toggle, - ] - .align_y(iced::Center), + toggle,].align_y(iced::Center) ) .style(|theme: &Theme| { let palette = theme.palette(); @@ -852,9 +943,8 @@ impl PropertiesPanel { label: &'a str, ) -> Element<'a, Message> { match &prop.value { - PropValue::ColorChoice(color) => { - self.render_color_row(label, prop.field, *color, None) - } + PropValue::ColorChoice(color) => + self.render_color_row(label, prop.field, *color, None), PropValue::NamedColorChoice { color, name } => { self.render_color_row(label, prop.field, *color, Some(name)) } @@ -864,9 +954,8 @@ impl PropertiesPanel { PropValue::FieldLwChoice { field, value } => { self.render_field_lw_row(label, field, *value) } - PropValue::FieldLwVaries { field } => { - self.render_field_lw_varies_row(label, field) - } + PropValue::FieldLwVaries { field } => + self.render_field_lw_varies_row(label, field), PropValue::LwVaries => self.render_lw_varies_row(label), PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), PropValue::Choice { selected, options } => { @@ -888,16 +977,13 @@ impl PropertiesPanel { PropValue::ReadOnlyWithTooltip { value, tooltip } => { render_ro_with_tooltip_row(label, value, tooltip) } - PropValue::HatchPatternChoice(current) => { - self.render_hatch_pattern_row(label, current) - } + PropValue::HatchPatternChoice(current) => + self.render_hatch_pattern_row(label, current), PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { id, handles, conflicting } => { - render_entity_link_row(label, *id, handles.clone(), *conflicting) - } - PropValue::ParamRow { index, name, formula, resolved } => { - self.render_param_row(*index, name, formula, resolved) - } + PropValue::EntityLink { id, handles, conflicting, } => + render_entity_link_row(label, *id, handles.clone(), *conflicting), + PropValue::ParamRow { index, name, formula, resolved, } => + self.render_param_row(*index, name, formula, resolved), PropValue::ParamAddRow => render_param_add_row(), PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value), } @@ -958,7 +1044,7 @@ impl PropertiesPanel { // (#415). Message::OpenColorWindow( crate::app::ColorPickTarget::PropertiesBg, - color, + color ), ); return prop_row_widget(label, selector); @@ -1034,7 +1120,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - color, + color ), ); prop_row_widget(label, selector) @@ -1052,7 +1138,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - AcadColor::ByLayer, + AcadColor::ByLayer ), ); prop_row_widget(label, selector) @@ -1442,24 +1528,28 @@ impl PropertiesPanel { use crate::ui::window::named_parameters::ParamField; let name_key = FieldKey::Param(index, ParamField::Name); - let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name); + let name_display = self.edit_buf.get(&name_key) + .map(|s| s.as_str()) + .unwrap_or(name); let name_active = self.active_field.as_ref() == Some(&name_key); let name_input = text_input("name", name_display) .id(name_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Name }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v, }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Name, }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) .width(Length::FillPortion(3)); let formula_key = FieldKey::Param(index, ParamField::Formula); - let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula); + let formula_display = self.edit_buf.get(&formula_key) + .map(|s| s.as_str()) + .unwrap_or(formula); let formula_active = self.active_field.as_ref() == Some(&formula_key); let formula_input = text_input("formula", formula_display) .id(formula_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Formula }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v, }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Formula, }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) @@ -1470,9 +1560,10 @@ impl PropertiesPanel { Err(_) => ("—".to_string(), true), }; let value_label = container( - text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style { + text(value_text).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { color: is_error.then_some(theme.palette().danger.base.color), - }), + } + }) ) .width(Length::FillPortion(2)) .align_x(iced::Right); @@ -1490,13 +1581,15 @@ impl PropertiesPanel { Background::Color(theme.palette().background.base.color) } }; - let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center)) + let content = container(row![name_input, formula_input, value_label, delete_btn] + .spacing(4) + .align_y(iced::Center),) .style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() }) .padding([2, 6]) .width(Length::Fill); if let Err(err) = resolved { - tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top) + tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top,) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -1504,7 +1597,7 @@ impl PropertiesPanel { container::Style { background: Some(Background::Color(palette.danger.weak.color)), text_color: Some(palette.danger.weak.text), - border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() }, + border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() } }) @@ -1562,96 +1655,16 @@ impl PropertiesPanel { return prop_row_widget(label, head.into()); } - let search = text_input(t!("Search patterns…").as_ref(), &self.hatch_pattern_search) - .id(iced::widget::Id::new("hatch-pattern-search")) - .on_input(Message::PropHatchPatternSearchChanged) - .on_submit(Message::PropHatchPatternConfirm) - .size(FONT_SZ) - .padding([5, 7]) - .width(Length::Fill); - - let mut grid = column![].spacing(6); - let visible = filtered_hatch_patterns(&self.hatch_pattern_search); - for (row_index, pair) in visible.chunks(2).enumerate() { - let mut cards = row![].spacing(6); - for (column_index, entry) in pair.iter().enumerate() { - let index = row_index * 2 + column_index; - let selected = current.eq_ignore_ascii_case(&entry.name); - let focused = self.hatch_pattern_focus == index; - let name = entry.name.clone(); - let preview = hatch_pattern_preview(entry.gpu.clone(), PATTERN_PREVIEW_H); - let card = button( - column![ - preview, - container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ)) - .width(Length::Fill) - .align_x(iced::Center), - ] - .spacing(3), - ) - .on_press(Message::PropHatchPatternChanged(name)) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let hovered = - matches!(status, button::Status::Hovered | button::Status::Pressed); - let pair = if selected { - palette.primary.weak - } else if hovered || focused { - palette.background.strong - } else { - palette.background.weak - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: if selected || focused { - palette.primary.base.color - } else { - palette.background.neutral.color - }, - width: if selected || focused { 2.0 } else { 1.0 }, - radius: 4.0.into(), - }, - ..Default::default() - } - }) - .padding(5) - .width(PATTERN_CARD_W); - cards = cards.push( - mouse_area(card).on_enter(Message::PropHatchPatternFocus(index)), - ); - } - grid = grid.push(cards); - } - - let results: Element<'_, Message> = if visible.is_empty() { - container( - text(t!("No matching patterns")) - .size(FONT_SZ) - .style(hint_text_style), - ) - .padding(12) - .width(Length::Fill) - .center_x(Length::Fill) - .into() - } else { - scrollable(grid) - .height(Length::Fill) - .width(Length::Fill) - .into() - }; - let popup = container(column![search, results].spacing(7)) - .style(container::bordered_box) - .padding(8) - .width(PATTERN_PICKER_W) - .height(Length::Fixed(PATTERN_PICKER_H)); + let popup = hatch_pattern_picker_content( &self.hatch_pattern_search, + self.hatch_pattern_focus, + current,"hatch-pattern-search",Message::PropHatchPatternSearchChanged,Message::PropHatchPatternConfirm, + Message::PropHatchPatternFocus, + Message::PropHatchPatternChanged, + ); prop_row_widget( - label, - crate::ui::color_select::drop_down_below( - head.into(), - popup.into(), + label,crate::ui::color_select::drop_down_below( + head.into(), popup, Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), @@ -1707,11 +1720,10 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem }; let btn = button( - text(btn_label) - .size(FONT_SZ) - .style(move |theme: &Theme| iced::widget::text::Style { + text(btn_label).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { color: value.then_some(theme.palette().warning.base.color), - }), + } + }) ) .on_press(Message::PropBoolToggle(field)) .style(move |theme: &Theme, status| { @@ -1854,7 +1866,7 @@ fn render_group_row( let label_col = container(label_btn) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weakest.color, + theme.palette().background.weakest.color )), ..Default::default() }) @@ -1867,7 +1879,7 @@ fn render_group_row( let value_col = container(value_field) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.base.color, + theme.palette().background.base.color )), ..Default::default() }) @@ -1895,7 +1907,7 @@ fn render_group_row( } fn render_annotative_scale_row<'a>( label: &'a str, - value: &'a str, + value: &'a str ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); @@ -1941,7 +1953,7 @@ fn render_ro_with_tooltip_row<'a>( tooltip_text: &'a str, ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); - let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top,) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -1989,7 +2001,7 @@ fn render_entity_link_row<'a>( }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, text_color: pair.text, ..Default::default() } @@ -2012,7 +2024,10 @@ fn render_entity_link_row<'a>( /// — avoids a separate "pending new row" concept, since every row always /// reflects a real committed table entry. fn render_param_add_row<'a>() -> Element<'a, Message> { - let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ) + ] + .spacing(6) + .align_y(iced::Center),) .on_press(Message::PropParamAddNew) .style(button::text) .padding([4, 8]) @@ -2045,14 +2060,18 @@ fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, text_color: pair.text, ..Default::default() } }) .padding([4, 8]) .width(Length::Fill); - container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center)) + container(row![text(t!("Values").into_owned()) + .size(FONT_SZ) + .width(Length::Fill), btn + ] + .align_y(iced::Center),) .width(Length::Fill) .into() } @@ -2202,6 +2221,8 @@ fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::St } } +pub(crate) + fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { text_input_style(theme, status) } @@ -2226,7 +2247,7 @@ mod tests { }; fn sample_sections() -> Vec { - use crate::scene::model::object::{Property, PropValue}; + use crate::scene::model::object::{PropValue,Property}; vec![ crate::scene::model::object::PropSection { title: "Geometry".into(), diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index beced95ae..f4e42a60a 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -4,9 +4,10 @@ use crate::app::Message; use crate::ui::dock::{DockMsg, PanelId}; use acadrust::{CadDocument, EntityType, Handle}; use iced::widget::{ - button, column, container, image, mouse_area, row, slider, text, tooltip, Space, + button, column, combo_box, container, image, mouse_area, row, slider, text, text_input, + tooltip, Space, }; -use iced::{Background, Border, Element, Fill, Length, Theme}; +use iced::{Background, Border, Element, Fill, Length, Padding, Theme}; pub(crate) struct SolidFillColorInfo { pub color: acadrust::types::Color, @@ -14,11 +15,49 @@ pub(crate) struct SolidFillColorInfo { pub varies: bool, } -struct FillTransparencyInfo { +struct TransparencyInfo { value: u8, varies: bool, + mode: PropertyMode, } +#[derive(Clone, Copy)] +enum PropertyMode { + ByLayer, + ByBlock, + Custom, +} + +impl PropertyMode { + fn button_label(self) -> &'static str { + match self { + Self::ByLayer => "[L]", + Self::ByBlock => "[B]", + Self::Custom => "[C]", + } + } +} + +struct LinetypeInfo { + value: String, + varies: bool, +} + +struct LineweightInfo { + value: acadrust::types::LineWeight, + varies: bool, +} + +struct LinetypeScaleInfo { + value: f64, + varies: bool, +} + +const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; +const TRANSPARENCY_ICON: &[u8] = + include_bytes!("../../../assets/icons/attributes/transparency.svg"); +const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg"); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GraphicAttribute { Varies, @@ -116,32 +155,35 @@ pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec Vec { - fill_handles(document, selected) - .into_iter() +pub(crate) fn line_handles(document: &CadDocument, selected: &[Handle]) -> Vec { + selected + .iter() + .copied() .filter(|handle| { - matches!( - document.get_entity(*handle), - Some(EntityType::Hatch(hatch)) - if hatch.is_solid && !hatch.gradient_color.enabled - ) + document + .get_entity(*handle) + .is_some_and(|entity| !matches!(entity, EntityType::Hatch(_))) }) .collect() } -fn fill_transparency_info( - document: &CadDocument, - selected: &[Handle], +fn top_handle( + handles: &[Handle], draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let handles = fill_handles(document, selected); - let top = handles.iter().copied().max_by(|left, right| { +) -> Option { + handles.iter().copied().max_by(|left, right| { let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth - .get(&right.value()) - .map_or(0.0, |depth| depth[0]); + let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); left.total_cmp(&right) - })?; + }) +} + +fn transparency_info( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let top = top_handle(handles, draw_depth)?; let top_entity = document.get_entity(top)?; let stored = top_entity.common().transparency; let effective = if stored.is_by_layer() { @@ -154,29 +196,29 @@ fn fill_transparency_info( } else { stored }; - Some(FillTransparencyInfo { + Some(TransparencyInfo { value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, varies: handles.iter().any(|handle| { document .get_entity(*handle) .is_some_and(|entity| entity.common().transparency != stored) }), + mode: if stored.is_by_layer() { + PropertyMode::ByLayer + } else if stored.is_by_block() { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }, }) } -fn solid_fill_color_info( +fn color_info( document: &CadDocument, - selected: &[Handle], + handles: &[Handle], draw_depth: &rustc_hash::FxHashMap, ) -> Option { - let handles = solid_fill_handles(document, selected); - let top = handles.iter().copied().max_by(|left, right| { - let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth - .get(&right.value()) - .map_or(0.0, |depth| depth[0]); - left.total_cmp(&right) - })?; + let top = top_handle(handles, draw_depth)?; let top_entity = document.get_entity(top)?; let color = top_entity.common().color; let varies = handles.iter().any(|handle| { @@ -197,6 +239,145 @@ fn solid_fill_color_info( }) } +fn linetype_info( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let top = top_handle(handles, draw_depth)?; + let value = document.get_entity(top)?.common().linetype.clone(); + let varies = handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| entity.common().linetype != value) + }); + Some(LinetypeInfo { + value: if value.is_empty() { + "ByLayer".to_string() + } else { + value + }, + varies, + }) +} + +fn lineweight_info( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let top = top_handle(handles, draw_depth)?; + let value = document.get_entity(top)?.common().line_weight; + let varies = handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| entity.common().line_weight != value) + }); + Some(LineweightInfo { value, varies }) +} + +fn linetype_scale_info( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> Option { + let top = top_handle(handles, draw_depth)?; + let value = document.get_entity(top)?.common().linetype_scale; + let varies = handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| (entity.common().linetype_scale - value).abs() > f64::EPSILON) + }); + Some(LinetypeScaleInfo { value, varies }) +} + +fn effective_linetype_is_continuous( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, +) -> bool { + let (name, layer) = top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map(|entity| { + ( + entity.common().linetype.as_str(), + entity.common().layer.as_str(), + ) + }) + .unwrap_or_else(|| { + ( + current_linetype_name(document), + document.header.current_layer_name.as_str(), + ) + }); + let name = if name.is_empty() || name.eq_ignore_ascii_case("ByLayer") { + document + .layers + .get(if layer.is_empty() { "0" } else { layer }) + .map_or("Continuous", |layer| layer.line_type.as_str()) + } else if name.eq_ignore_ascii_case("ByBlock") { + "Continuous" + } else { + name + }; + name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") +} + +fn current_linetype_name(document: &CadDocument) -> &str { + if !document.header.current_linetype_name.is_empty() { + document.header.current_linetype_name.as_str() + } else if !document.header.current_linetype_handle.is_null() { + document + .line_types + .iter() + .find(|line_type| line_type.handle == document.header.current_linetype_handle) + .map_or("ByLayer", |line_type| line_type.name.as_str()) + } else { + "ByLayer" + } +} + +fn linetype_preview_art( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, + logical_name: &str, +) -> String { + let layer_name = top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map(|entity| entity.common().layer.as_str()) + .unwrap_or_else(|| { + if document.header.current_layer_name.is_empty() { + "0" + } else { + document.header.current_layer_name.as_str() + } + }); + let effective_name = if logical_name.is_empty() + || logical_name.eq_ignore_ascii_case("ByLayer") + || logical_name.eq_ignore_ascii_case("ByBlock") + { + document + .layers + .get(layer_name) + .map_or("Continuous", |layer| layer.line_type.as_str()) + } else { + logical_name + }; + document + .line_types + .iter() + .find(|line_type| line_type.name.eq_ignore_ascii_case(effective_name)) + .map(|line_type| { + if line_type.elements.is_empty() { + "____________".to_string() + } else { + crate::io::linetypes::extract_pattern(&line_type.description) + } + }) + .unwrap_or_default() +} + fn top_fill_hatch<'a>( document: &'a CadDocument, selected: &[Handle], @@ -231,25 +412,254 @@ fn top_fill_hatch<'a>( }) .max_by(|(left, _), (right, _)| { let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth - .get(&right.value()) - .map_or(0.0, |depth| depth[0]); + let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); left.total_cmp(&right) }) .map(|(_, hatch)| hatch) } +fn popup_alignment(side: crate::app::config::DockSide) -> iced_aw::drop_down::Alignment { + match side { + crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + crate::app::config::DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, + } +} + +fn by_mode_menu<'a>( + side: crate::app::config::DockSide, + mode: PropertyMode, + open: bool, + toggle: Message, + close: Message, + by_layer: Message, + by_block: Message, +) -> Element<'a, Message> { + let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + .on_press(toggle) + .style(move |theme: &Theme, status| { + if open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + if !open { + return more.into(); + } + let choice = |label, message| { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + }; + let popup = container(column![ + choice("ByLayer", by_layer), + choice("ByBlock", by_block), + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(close) + .into() +} + +fn color_control<'a>( + info: SolidFillColorInfo, + side: crate::app::config::DockSide, + menu_open: bool, + target: crate::app::ColorPickTarget, + toggle: Message, + close: Message, + changed: fn(acadrust::types::Color) -> Message, +) -> Element<'a, Message> { + let label = if info.varies { + Some("*VARIES*") + } else { + match info.color { + acadrust::types::Color::ByLayer => Some("ByLayer"), + acadrust::types::Color::ByBlock => Some("ByBlock"), + _ => None, + } + } + .map(|label| { + container(text(label).size(10)) + .width(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]) + }); + let color = button(label.unwrap_or_else(|| container(Space::new()).width(Fill).height(Fill))) + .on_press(Message::OpenColorWindow(target.clone(), info.color)) + .width(Fill) + .height(crate::ui::ROW_H) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(info.display)), + text_color: if info.display.r * 0.299 + info.display.g * 0.587 + info.display.b * 0.114 + > 0.55 + { + iced::Color::BLACK + } else { + iced::Color::WHITE + }, + border: Border { + color: if matches!(status, button::Status::Hovered) { + theme.palette().primary.base.color + } else { + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let mode = if info.varies { + PropertyMode::Custom + } else { + match info.color { + acadrust::types::Color::ByLayer => PropertyMode::ByLayer, + acadrust::types::Color::ByBlock => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + }; + let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + .on_press(toggle) + .style(move |theme: &Theme, status| { + if menu_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let more: Element<'a, Message> = if menu_open { + let choice = |label, color| { + button(text(label).size(11)) + .on_press(changed(color)) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + }; + let popup = container(column![ + choice("ByLayer", acadrust::types::Color::ByLayer), + choice("ByBlock", acadrust::types::Color::ByBlock), + button(text("Custom...").size(11)) + .on_press(Message::OpenColorWindow(target, info.color)) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(close) + .into() + } else { + more.into() + }; + row![color, more].spacing(4).align_y(iced::Center).into() +} + +fn transparency_control<'a>( + info: TransparencyInfo, + side: crate::app::config::DockSide, + menu_open: bool, + toggle: Message, + close: Message, + changed: fn(u8) -> Message, + by_layer: Message, + by_block: Message, +) -> Element<'a, Message> { + let value_label = if info.varies { + "*VARIES*".to_string() + } else { + format!("{}%", info.value) + }; + let control = row![ + crate::ui::icons::semantic(TRANSPARENCY_ICON, PALETTE_CONTROL_ICON_SIZE), + text(value_label).size(10).width(38), + slider(0..=90, i32::from(info.value), move |value| changed( + value as u8 + )) + .width(Fill), + ] + .spacing(5) + .align_y(iced::Center) + .height(crate::ui::ROW_H); + let mode = if info.varies { + PropertyMode::Custom + } else { + info.mode + }; + let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + .on_press(toggle) + .style(move |theme: &Theme, status| { + if menu_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(crate::ui::ROW_H) + .padding([2, 7]); + let more: Element<'a, Message> = if menu_open { + let row_button = |label, message| { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + }; + let popup = container(column![ + row_button("ByLayer", by_layer), + row_button("ByBlock", by_block), + row_button("Custom", changed(info.value)), + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(close) + .into() + } else { + more.into() + }; + row![control, more].spacing(4).align_y(iced::Center).into() +} + pub fn view<'a>( document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, selected: &[Handle], draw_depth: &rustc_hash::FxHashMap, width: f32, auto_collapse: bool, side: crate::app::config::DockSide, menu_open: bool, + line_color_menu_open: bool, + line_linetype_menu_open: bool, + line_lineweight_menu_open: bool, + line_transparency_menu_open: bool, solid_color_menu_open: bool, transparency_menu_open: bool, hatch_editor_open: bool, + hatch_pattern_search: &'a str, + hatch_pattern_focus: usize, gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>, gradient_color_picker_open: bool, ) -> Element<'a, Message> { @@ -367,38 +777,283 @@ pub fn view<'a>( } else { picker.into() }; - let mut body = column![text(crate::t!("Fill")).size(11), picker].spacing(6); - if current == GraphicAttribute::Solid { - if let Some(info) = solid_fill_color_info(document, selected, draw_depth) { - let label = info.varies.then(|| { - container(text("* VARIES *").size(10)) - .width(Fill) - .align_x(iced::Center) - .align_y(iced::Center) + let line_handles = line_handles(document, selected); + let fill_handles = fill_handles(document, selected); + let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); + let no_selection = selected.is_empty(); + let line_color = color_info(document, &line_handles, draw_depth).or_else(|| { + no_selection.then(|| { + let color = document.header.current_entity_color; + let display = if color == acadrust::types::Color::ByLayer { + document + .layers + .get(if document.header.current_layer_name.is_empty() { + "0" + } else { + &document.header.current_layer_name + }) + .map_or_else( + || crate::ui::properties::acad_color_display(color).0, + |layer| crate::ui::properties::acad_color_display(layer.color).0, + ) + } else { + crate::ui::properties::acad_color_display(color).0 + }; + SolidFillColorInfo { + color, + display, + varies: false, + } + }) }); - let color = button(label.unwrap_or_else(|| { - container(Space::new()).width(Fill).height(Fill) - })) - .on_press(Message::OpenColorWindow( + let linetype = linetype_info(document, &line_handles, draw_depth).or_else(|| { + no_selection.then(|| LinetypeInfo { + value: current_linetype_name(document).to_string(), + varies: false, + }) + }); + let continuous_linetype = linetype.as_ref().is_some_and(|info| { + !info.varies && effective_linetype_is_continuous(document, &line_handles, draw_depth) + }); + let linetype_scale = linetype_scale_info(document, &line_handles, draw_depth).or_else(|| { + no_selection.then(|| LinetypeScaleInfo { + value: document.header.current_entity_linetype_scale, + varies: false, + }) + }); + if let Some(info) = linetype.as_ref() { + let selected = (!info.varies).then(|| crate::ui::properties::LinetypeItem { + name: info.value.clone(), + art: linetype_preview_art(document, &line_handles, draw_depth, &info.value), + }); + let combo = combo_box( + &properties.linetype_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LinetypeItem| Message::LineLinetypeChanged(item.name), + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let more = by_mode_menu( + side, + if info.varies { + PropertyMode::Custom + } else if info.value.eq_ignore_ascii_case("ByLayer") { + PropertyMode::ByLayer + } else if info.value.eq_ignore_ascii_case("ByBlock") { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }, + line_linetype_menu_open, + Message::ToggleLineLinetypeMenu, + Message::CloseLineLinetypeMenu, + Message::LineLinetypeChanged("ByLayer".to_string()), + Message::LineLinetypeChanged("ByBlock".to_string()), + ); + body = body.push( + row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] + .spacing(4) + .align_y(iced::Center), + ); + } + let lineweight = lineweight_info(document, &line_handles, draw_depth).or_else(|| { + no_selection.then(|| LineweightInfo { + value: acadrust::types::LineWeight::from_value(document.header.current_line_weight), + varies: false, + }) + }); + if let Some(color) = line_color { + body = body.push(color_control( + color, + side, + line_color_menu_open, + crate::app::ColorPickTarget::GraphicAttributesLine, + Message::ToggleLineColorDropdown, + Message::CloseLineColorDropdown, + Message::LineColorChanged, + )); + } + if let (Some(scale), Some(lineweight)) = (linetype_scale, lineweight) { + let scale_text = if scale.varies { + String::new() + } else { + format!("{:.2}", scale.value) + }; + let mut scale_input = text_input("*VARIES*", &scale_text) + .size(crate::ui::ROW_H * 0.42) + .padding([3, 5]) + .style(crate::ui::properties::combo_input_style) + .on_submit(Message::Noop) + .width(Fill); + if !continuous_linetype { + scale_input = scale_input.on_input(Message::LineLinetypeScaleInput); + } + let step = |up: bool| { + let icon: Element<'_, Message> = if up { + crate::ui::icons::themed_arrow_up(7.0) + } else { + crate::ui::icons::themed_arrow_down(7.0) + }; + let button = button(icon).style(button::secondary).padding([0, 3]); + if continuous_linetype { + button + } else { + let delta = if up { 0.1 } else { -0.1 }; + button.on_press(Message::LineLinetypeScaleChanged( + (scale.value + delta).clamp(0.01, 1000.0), + )) + } + }; + let scale = row![ + crate::ui::icons::semantic(GraphicAttribute::Solid.icon(), PALETTE_CONTROL_ICON_SIZE), + scale_input, + column![step(true), step(false)].spacing(0), + ] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)); + let selected = + (!lineweight.varies).then_some(crate::ui::properties::LwItem(lineweight.value)); + let lineweight_mode = if lineweight.varies { + PropertyMode::Custom + } else { + match lineweight.value { + acadrust::types::LineWeight::ByLayer => PropertyMode::ByLayer, + acadrust::types::LineWeight::ByBlock => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + }; + let lineweight_picker = combo_box( + &properties.lineweight_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LwItem| Message::LineLineweightChanged(item.0), + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let more = by_mode_menu( + side, + lineweight_mode, + line_lineweight_menu_open, + Message::ToggleLineLineweightMenu, + Message::CloseLineLineweightMenu, + Message::LineLineweightChanged(acadrust::types::LineWeight::ByLayer), + Message::LineLineweightChanged(acadrust::types::LineWeight::ByBlock), + ); + let lineweight = row![ + crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), + lineweight_picker, + more, + ] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)); + body = body.push( + row![scale, lineweight] + .spacing(12) + .align_y(iced::Center) + .height(crate::ui::ROW_H), + ); + } + let line_transparency = transparency_info(document, &line_handles, draw_depth).or_else(|| { + no_selection.then(|| { + let stored = document.current_entity_transparency(); + let effective = if stored.is_by_layer() { + document + .layers + .get(if document.header.current_layer_name.is_empty() { + "0" + } else { + &document.header.current_layer_name + }) + .map_or(stored, |layer| layer.transparency) + } else if stored.is_by_block() { + acadrust::types::Transparency::from_percent(0.0) + } else { + stored + }; + TransparencyInfo { + value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, + varies: false, + mode: if stored.is_by_layer() { + PropertyMode::ByLayer + } else if stored.is_by_block() { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }, + } + }) + }); + if let Some(info) = line_transparency { + body = body.push(transparency_control( + info, + side, + line_transparency_menu_open, + Message::ToggleLineTransparencyDropdown, + Message::CloseLineTransparencyDropdown, + Message::LineTransparencyChanged, + Message::LineTransparencyByLayer, + Message::LineTransparencyByBlock, + )); + } + body = body + .push(Space::new().height(2)) + .push(text(crate::t!("Fill")).size(11)) + .push(picker); + if !matches!(current, GraphicAttribute::None | GraphicAttribute::Gradient) { + if let Some(info) = color_info(document, &fill_handles, draw_depth) { + body = body.push(color_control( + info, + side, + solid_color_menu_open, crate::app::ColorPickTarget::GraphicAttributesSolid, - info.color, - )) + Message::ToggleSolidFillColorDropdown, + Message::CloseSolidFillColorDropdown, + Message::SolidFillColorChanged, + )); + } + } + if current == GraphicAttribute::Gradient { + if let Some(hatch) = + top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Gradient) + { + let toggle = if gradient_editor.is_some() { + Message::GradientCancel + } else { + Message::GradientEditorOpen + }; + let preview = button( + image(crate::ui::window::gradient_editor::compact_preview(hatch)) + .width(Fill) + .height(Fill) + .content_fit(iced::ContentFit::Fill), + ) + .on_press(toggle.clone()) .width(Fill) .height(crate::ui::ROW_H) .padding(0) .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(info.display)), - text_color: if info.display.r * 0.299 - + info.display.g * 0.587 - + info.display.b * 0.114 - > 0.55 - { - iced::Color::BLACK - } else { - iced::Color::WHITE - }, + background: Some(Background::Color(theme.palette().background.base.color)), border: Border { - color: if matches!(status, button::Status::Hovered) { + color: if gradient_editor.is_some() + || matches!(status, button::Status::Hovered | button::Status::Pressed) + { theme.palette().primary.base.color } else { theme.palette().background.neutral.color @@ -408,87 +1063,8 @@ pub fn view<'a>( }, ..Default::default() }); - let more = button(text("...").size(crate::ui::ROW_H * 0.42)) - .on_press(Message::ToggleSolidFillColorDropdown) - .style(move |theme: &Theme, status| { - if solid_color_menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([2, 7]); - let more: Element<'_, Message> = if solid_color_menu_open { - let choice = |label, color| { - button(text(label).size(11)) - .on_press(Message::SolidFillColorChanged(color)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - choice("ByLayer", acadrust::types::Color::ByLayer), - choice("ByBlock", acadrust::types::Color::ByBlock), - button(text("Custom...").size(11)) - .on_press(Message::OpenColorWindow( - crate::app::ColorPickTarget::GraphicAttributesSolid, - info.color, - )) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - let alignment = match side { - crate::app::config::DockSide::Left => { - iced_aw::drop_down::Alignment::BottomEnd - } - crate::app::config::DockSide::Right => { - iced_aw::drop_down::Alignment::BottomStart - } - }; - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(alignment) - .offset(2.0) - .on_dismiss(Message::CloseSolidFillColorDropdown) - .into() - } else { - more.into() - }; - body = body.push(row![color, more].spacing(4).align_y(iced::Center)); - } - } else if current == GraphicAttribute::Gradient { - if let Some(hatch) = top_fill_hatch( - document, - selected, - draw_depth, - GraphicAttribute::Gradient, - ) { - let preview = container( - image(crate::ui::window::gradient_editor::compact_preview(hatch)) - .width(Fill) - .height(crate::ui::ROW_H), - ) - .width(Fill) - .height(crate::ui::ROW_H) - .style(|theme: &Theme| container::Style { - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }); let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) - .on_press(if gradient_editor.is_some() { - Message::GradientCancel - } else { - Message::GradientEditorOpen - }) + .on_press(toggle) .style(move |theme: &Theme, status| { if gradient_editor.is_some() { button::primary(theme, status) @@ -505,19 +1081,14 @@ pub fn view<'a>( ); let edit: Element<'_, Message> = if let Some(editor) = gradient_editor { let alignment = match side { - crate::app::config::DockSide::Left => { - iced_aw::drop_down::Alignment::BottomEnd - } + crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, crate::app::config::DockSide::Right => { iced_aw::drop_down::Alignment::BottomStart } }; iced_aw::DropDown::new( edit, - crate::ui::window::gradient_editor::view( - editor, - gradient_color_picker_open, - ), + crate::ui::window::gradient_editor::view(editor, gradient_color_picker_open), true, ) .width(Length::Fixed(390.0)) @@ -530,12 +1101,9 @@ pub fn view<'a>( body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); } } else if current == GraphicAttribute::Hatch { - let preview: Element<'_, Message> = top_fill_hatch( - document, - selected, - draw_depth, - GraphicAttribute::Hatch, - ) + let hatch = top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Hatch); + let current_pattern = hatch.map_or("", |hatch| hatch.pattern.name.as_str()); + let preview_content: Element<'_, Message> = hatch .and_then(|hatch| { crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) .map(|model| model.pattern) @@ -545,17 +1113,53 @@ pub fn view<'a>( container(Space::new()) .width(Fill) .height(crate::ui::ROW_H) - .style(|theme: &Theme| container::Style { + .into() + }); + let preview = button(preview_content) + .on_press(if hatch_editor_open { + Message::HatchEditorClose + } else { + Message::HatchEditorOpen + }) + .width(Fill) + .height(crate::ui::ROW_H) + .padding(0) + .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border { - color: theme.palette().background.neutral.color, + color: if hatch_editor_open + || matches!(status, button::Status::Hovered | button::Status::Pressed) + { + theme.palette().primary.base.color + } else { + theme.palette().background.neutral.color + }, width: 1.0, radius: 2.0.into(), }, ..Default::default() - }) - .into() }); + let preview: Element<'_, Message> = if hatch_editor_open { + let popup = crate::ui::properties::hatch_pattern_picker_content( + hatch_pattern_search, + hatch_pattern_focus, + current_pattern, + "graphic-hatch-pattern-search", + Message::GraphicHatchPatternSearchChanged, + Message::GraphicHatchPatternConfirm, + Message::GraphicHatchPatternFocus, + Message::GraphicHatchPatternChanged, + ); + iced_aw::DropDown::new(preview, popup, true) + .width(Length::Fixed(348.0)) + .height(Length::Fixed(720.0)) + .alignment(popup_alignment(side)) + .offset(4.0) + .on_dismiss(Message::HatchEditorClose) + .into() + } else { + preview.into() + }; let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) .on_press(if hatch_editor_open { Message::HatchEditorClose @@ -572,87 +1176,20 @@ pub fn view<'a>( .height(crate::ui::ROW_H) .padding([2, 7]); let edit = tooltip(edit, text("Edit hatch").size(10), tooltip::Position::Bottom); - let edit: Element<'_, Message> = if hatch_editor_open { - let alignment = match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, - }; - iced_aw::DropDown::new(edit, crate::ui::window::hatch_editor::view(), true) - .width(Length::Fixed(390.0)) - .alignment(alignment) - .offset(4.0) - .into() - } else { - edit.into() - }; body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); } if current != GraphicAttribute::None { - if let Some(info) = fill_transparency_info(document, selected, draw_depth) { - let value_label = if info.varies { - "*VARIES*".to_string() - } else { - format!("{}%", info.value) - }; - let control = row![ - text(crate::t!("Transparency")).size(11).width(75), - slider(0..=90, i32::from(info.value), |value| { - Message::FillTransparencyChanged(value as u8) - }) - .width(Fill), - text(value_label).size(10).width(45), - ] - .spacing(5) - .align_y(iced::Center) - .height(crate::ui::ROW_H); - let more = button(text("...").size(crate::ui::ROW_H * 0.42)) - .on_press(Message::ToggleFillTransparencyDropdown) - .style(move |theme: &Theme, status| { - if transparency_menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([2, 7]); - let more: Element<'_, Message> = if transparency_menu_open { - let row_button = |label, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - row_button("ByLayer", Message::FillTransparencyByLayer), - row_button("ByBlock", Message::FillTransparencyByBlock), - row_button( - "Custom", - Message::FillTransparencyChanged(info.value), - ), - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - let alignment = match side { - crate::app::config::DockSide::Left => { - iced_aw::drop_down::Alignment::BottomEnd - } - crate::app::config::DockSide::Right => { - iced_aw::drop_down::Alignment::BottomStart - } - }; - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(alignment) - .offset(2.0) - .on_dismiss(Message::CloseFillTransparencyDropdown) - .into() - } else { - more.into() - }; - body = body.push(row![control, more].spacing(4).align_y(iced::Center)); + if let Some(info) = transparency_info(document, &fill_handles, draw_depth) { + body = body.push(transparency_control( + info, + side, + transparency_menu_open, + Message::ToggleFillTransparencyDropdown, + Message::CloseFillTransparencyDropdown, + Message::FillTransparencyChanged, + Message::FillTransparencyByLayer, + Message::FillTransparencyByBlock, + )); } } let body = body.padding(8); diff --git a/src/ui/window/hatch_editor.rs b/src/ui/window/hatch_editor.rs deleted file mode 100644 index 677bff862..000000000 --- a/src/ui/window/hatch_editor.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::app::Message; -use iced::widget::{button, column, container, mouse_area, row, text, Space}; -use iced::{Background, Border, Element, Fill, Length, Theme}; - -pub fn view() -> Element<'static, Message> { - let content = column![ - text("Hatch settings").size(12), - text("Additional hatch settings will be available here.").size(11), - Space::new().height(12), - container( - row![button(text(crate::t!("Close")).size(11)) - .on_press(Message::HatchEditorClose) - .style(button::secondary) - .padding([5, 14])] - .width(Fill), - ) - .width(Fill) - .align_x(iced::alignment::Horizontal::Right), - ] - .spacing(8) - .padding(10); - let panel = container(content) - .width(Length::Fixed(390.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().primary.base.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); - mouse_area(panel) - .interaction(iced::mouse::Interaction::Pointer) - .into() -} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 6f2de35a1..0944cb779 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,33 +1,32 @@ pub mod about; +pub mod alias_editor; +pub mod annotation_data; +pub mod attribute_editor; +pub mod auto_constrain_settings; pub mod block_definition; pub mod block_palette; pub mod browser; -pub mod graphic_attributes; -pub mod gradient_editor; -pub mod hatch_editor; -pub mod layout_manager; -pub mod layer_state_manager; -pub mod drawing_units; -pub mod geometric_tolerance; pub mod drafting_settings; -pub mod auto_constrain_settings; -pub mod layer_translator; -pub mod plot; -pub mod print_all; -pub mod plugin_manager; -pub mod shortcuts; -pub mod layers; -pub mod update_notice; -pub mod open_progress; -pub mod missing_fonts; -pub mod recovery; -pub mod options; -pub mod attribute_editor; -pub mod annotation_data; -pub mod alias_editor; +pub mod drawing_units; pub mod find_replace; +pub mod geometric_tolerance; +pub mod gradient_editor; +pub mod graphic_attributes; +pub mod layer_state_manager; +pub mod layer_translator; +pub mod layers; +pub mod layout_manager; +pub mod missing_fonts; pub mod named_parameters; +pub mod open_progress; +pub mod options; +pub mod plot; +pub mod plugin_manager; +pub mod print_all; +pub mod recovery; +pub mod shortcuts; +pub mod update_notice; +pub mod wblock; pub mod xref_attach; pub mod xref_help; pub mod xref_manager; -pub mod wblock; From 85e2dda6ba0aad3f5bccf1a3ec8b8c3e3abb9f19 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 29 Sep 2026 19:06:29 +0200 Subject: [PATCH 03/10] Improve graphic attribute handling for open boundaries and varied values --- src/app/mod.rs | 6 + src/app/update/dialog.rs | 148 +++++++++++++- src/app/update/mod.rs | 41 ++++ src/ui/properties.rs | 3 + src/ui/window/graphic_attributes.rs | 304 +++++++++++++++++++++------- 5 files changed, 427 insertions(+), 75 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6468edd00..a172d776e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -780,6 +780,11 @@ pub(super) struct OpenCADStudio { fill_transparency_menu_open: bool, gradient_editor: Option, hatch_editor_handles: Option>, + pending_graphic_fill_close: Option<( + crate::ui::window::graphic_attributes::GraphicAttribute, + Vec, + Vec, + )>, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Docked External References panel visibility (EXTERNALREFERENCES). @@ -4167,6 +4172,7 @@ impl OpenCADStudio { fill_transparency_menu_open: false, gradient_editor: None, hatch_editor_handles: None, + pending_graphic_fill_close: None, show_block_palette: false, show_external_references: false, show_browser: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 76884f72f..2e0756498 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -90,6 +90,46 @@ fn set_hatch_graphic_attribute( } } +fn is_open_fill_boundary(entity: &acadrust::EntityType) -> bool { + match entity { + acadrust::EntityType::LwPolyline(polyline) => { + !polyline.is_closed && polyline.vertices.len() >= 3 + } + acadrust::EntityType::Polyline(polyline) => { + !polyline.flags.is_closed() && polyline.vertices.len() >= 3 + } + acadrust::EntityType::Polyline2D(polyline) => { + !polyline.is_closed() && polyline.vertices.len() >= 3 + } + acadrust::EntityType::Polyline3D(polyline) => { + !polyline.flags.closed && polyline.vertices.len() >= 3 + } + _ => false, + } +} + +fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool { + match entity { + acadrust::EntityType::LwPolyline(polyline) if !polyline.is_closed => { + polyline.is_closed = true; + true + } + acadrust::EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { + polyline.flags.set_closed(true); + true + } + acadrust::EntityType::Polyline2D(polyline) if !polyline.is_closed() => { + polyline.flags.set_closed(true); + true + } + acadrust::EntityType::Polyline3D(polyline) if !polyline.flags.closed => { + polyline.flags.closed = true; + true + } + _ => false, + } +} + impl OpenCADStudio { pub(super) fn open_gradient_editor(&mut self) { use crate::ui::window::gradient_editor::GradientEditorState; @@ -195,6 +235,49 @@ impl OpenCADStudio { pub(super) fn on_graphic_attribute( &mut self, value: crate::ui::window::graphic_attributes::GraphicAttribute, + ) -> iced::Task { + self.pending_graphic_fill_close = None; + self.on_graphic_attribute_impl(value, true) + } + + pub(super) fn on_graphic_fill_close_response( + &mut self, + close: bool, + ) -> iced::Task { + let Some((value, handles, _)) = self.pending_graphic_fill_close.take() else { + return iced::Task::none(); + }; + if close { + let i = self.active_tab; + let unlocked: Vec<_> = handles + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if !unlocked.is_empty() { + let pending = self.begin_undo(i, "Close fill boundaries", unlocked.len(), true); + for handle in unlocked { + let Some(mut entity) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + continue; + }; + if close_fill_boundary(&mut entity) { + self.tabs[i].scene.update_entity(entity); + } + } + self.tabs[i].dirty = true; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + } + self.on_graphic_attribute_impl(value, false) + } + + fn on_graphic_attribute_impl( + &mut self, + value: crate::ui::window::graphic_attributes::GraphicAttribute, + ask_to_close: bool, ) -> iced::Task { use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; use crate::ui::window::graphic_attributes::GraphicAttribute; @@ -224,6 +307,22 @@ impl OpenCADStudio { .push_info(crate::t!("Select one or more closed objects first.").as_ref()); return iced::Task::none(); } + let open_boundaries: rustc_hash::FxHashSet<_> = selected + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(is_open_fill_boundary) + }) + .collect(); + if ask_to_close && value != GraphicAttribute::None && !open_boundaries.is_empty() { + self.pending_graphic_fill_close = + Some((value, open_boundaries.iter().copied().collect(), selected.clone())); + return iced::Task::none(); + } let selected_handles: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None { @@ -331,7 +430,9 @@ impl OpenCADStudio { hatches.push(hatch); changed_boundaries.insert(*handle); } else { - skipped += 1; + if !open_boundaries.contains(handle) { + skipped += 1; + } } } // Replace only fills belonging to boundaries that successfully @@ -367,11 +468,10 @@ impl OpenCADStudio { } if skipped > 0 { let message = if skipped == 1 { - "Graphic Attributes: 1 object was not changed because it is not closed or supported." - .to_owned() + "Graphic Attributes: 1 unsupported object was not changed.".to_owned() } else { format!( - "Graphic Attributes: {skipped} objects were not changed because they are not closed or supported." + "Graphic Attributes: {skipped} unsupported objects were not changed." ) }; self.command_line.push_info(&message); @@ -2007,6 +2107,12 @@ mod tests { matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) }) .expect("gradient hatch"); + let associated_lines = crate::ui::window::graphic_attributes::line_handles( + &app.tabs[i].scene.document, + &[selected_hatch], + ); + assert_eq!(associated_lines.len(), 1); + assert!(boundaries.contains(&associated_lines[0])); assert_eq!( crate::ui::window::graphic_attributes::current( &app.tabs[i].scene.document, @@ -2261,6 +2367,40 @@ mod tests { assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); } + #[test] + fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { + use crate::ui::window::graphic_attributes::GraphicAttribute; + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + + let mut app = fresh(); + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let boundary = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(boundary, false); + + let _ = app.update(Message::GraphicAttributeChanged(GraphicAttribute::Solid)); + assert!(app.pending_graphic_fill_close.is_some()); + assert!(!app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(_)) + })); + + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(app.pending_graphic_fill_close.is_none()); + assert!(matches!( + app.tabs[i].scene.document.get_entity(boundary), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + )); + assert!(app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) + })); + } + #[test] fn graphic_line_controls_update_creation_defaults_without_a_selection() { let mut app = fresh(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 681cd30f3..a16a13008 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -432,6 +432,18 @@ impl OpenCADStudio { }) { self.hatch_editor_handles = None; } + if self + .pending_graphic_fill_close + .as_ref() + .is_some_and(|(_, _, handles)| { + let selected = + self.tabs[self.active_tab].scene.selected_handles_in_order(); + selected.len() != handles.len() + || selected.iter().any(|handle| !handles.contains(handle)) + }) + { + self.pending_graphic_fill_close = None; + } self.show_next_startup_modal(); self.sync_open_command_history(); // Close the document-level first-touch transaction started by @@ -445,6 +457,19 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.prompt()) + .or_else(|| { + self.pending_graphic_fill_close.as_ref().map(|(_, handles, _)| { + if handles.len() == 1 { + "The selected object is open. Close it before creating the fill? [Yes / No]" + .to_string() + } else { + format!( + "{} selected objects are open. Close them before creating the fill? [Yes / No]", + handles.len() + ) + } + }) + }) .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). @@ -452,6 +477,14 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.options()) + .or_else(|| { + self.pending_graphic_fill_close.as_ref().map(|_| { + vec![ + crate::command::CmdOption::new("Yes", "Y"), + crate::command::CmdOption::new("No", "N"), + ] + }) + }) .unwrap_or_default(); self.command_line.set_step_options(opts); // Persist UI preferences whenever a toggle changes them (issue #68). @@ -2406,6 +2439,14 @@ impl OpenCADStudio { // finishes the step like Enter. (#304) self.command_line.input.clear(); self.command_line.close_history(); + if self.pending_graphic_fill_close.is_some() { + if kw.eq_ignore_ascii_case("Y") || kw.eq_ignore_ascii_case("YES") { + return self.on_graphic_fill_close_response(true); + } + if kw.eq_ignore_ascii_case("N") || kw.eq_ignore_ascii_case("NO") { + return self.on_graphic_fill_close_response(false); + } + } if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 7e95a2880..0b85eb8c3 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -55,6 +55,9 @@ pub fn linetype_display_name(name: &str) -> String { impl fmt::Display for LinetypeItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.name.is_empty() && !self.art.is_empty() { + return f.write_str(&self.art); + } let name = linetype_display_name(&self.name); if self.art.is_empty() { write!(f, "{name}") diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index f4e42a60a..4ec42d4ea 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -26,14 +26,16 @@ enum PropertyMode { ByLayer, ByBlock, Custom, + Varies, } impl PropertyMode { fn button_label(self) -> &'static str { match self { - Self::ByLayer => "[L]", - Self::ByBlock => "[B]", - Self::Custom => "[C]", + Self::ByLayer => "L", + Self::ByBlock => "B", + Self::Custom => "C", + Self::Varies => "V", } } } @@ -54,6 +56,7 @@ struct LinetypeScaleInfo { } const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; +const COMPACT_BUTTON_HEIGHT: f32 = 22.0; const TRANSPARENCY_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/transparency.svg"); const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg"); @@ -156,15 +159,28 @@ pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec Vec { - selected - .iter() - .copied() - .filter(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| !matches!(entity, EntityType::Hatch(_))) - }) - .collect() + let mut handles = Vec::new(); + let mut seen = rustc_hash::FxHashSet::default(); + for handle in selected { + match document.get_entity(*handle) { + Some(EntityType::Hatch(hatch)) if hatch.is_associative => { + for boundary in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { + if seen.insert(*boundary) + && document + .get_entity(*boundary) + .is_some_and(|entity| !matches!(entity, EntityType::Hatch(_))) + { + handles.push(*boundary); + } + } + } + Some(entity) if !matches!(entity, EntityType::Hatch(_)) && seen.insert(*handle) => { + handles.push(*handle); + } + _ => {} + } + } + handles } fn top_handle( @@ -276,6 +292,33 @@ fn lineweight_info( Some(LineweightInfo { value, varies }) } +fn effective_lineweight( + document: &CadDocument, + handles: &[Handle], + draw_depth: &rustc_hash::FxHashMap, + stored: acadrust::types::LineWeight, +) -> acadrust::types::LineWeight { + if !matches!(stored, acadrust::types::LineWeight::ByLayer) { + return stored; + } + let layer_name = top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map(|entity| entity.common().layer.as_str()) + .unwrap_or_else(|| { + if document.header.current_layer_name.is_empty() { + "0" + } else { + document.header.current_layer_name.as_str() + } + }); + document + .layers + .get(layer_name) + .map_or(acadrust::types::LineWeight::Default, |layer| { + layer.line_weight + }) +} + fn linetype_scale_info( document: &CadDocument, handles: &[Handle], @@ -364,7 +407,12 @@ fn linetype_preview_art( } else { logical_name }; - document + if effective_name.eq_ignore_ascii_case("Continuous") + || effective_name.eq_ignore_ascii_case("Solid") + { + return "_".repeat(80); + } + let art = document .line_types .iter() .find(|line_type| line_type.name.eq_ignore_ascii_case(effective_name)) @@ -375,7 +423,26 @@ fn linetype_preview_art( crate::io::linetypes::extract_pattern(&line_type.description) } }) - .unwrap_or_default() + .unwrap_or_default(); + let pattern_start = art.split_whitespace().find_map(|part| { + (part + .chars() + .filter(|character| matches!(character, '_' | '.' | '-' | '/' | '\\' | '|' | '~')) + .count() + >= 2) + .then(|| art.find(part)) + .flatten() + }); + let pattern = pattern_start.map_or(art.as_str(), |start| &art[start..]); + if pattern.is_empty() { + return String::new(); + } + let mut full_width = pattern.to_string(); + while full_width.chars().count() < 80 { + full_width.push(' '); + full_width.push_str(pattern); + } + full_width } fn top_fill_hatch<'a>( @@ -418,6 +485,21 @@ fn top_fill_hatch<'a>( .map(|(_, hatch)| hatch) } +fn gradients_vary(document: &CadDocument, selected: &[Handle]) -> bool { + let mut gradients = fill_handles(document, selected) + .into_iter() + .filter_map(|handle| match document.get_entity(handle) { + Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled => { + Some(&hatch.gradient_color) + } + _ => None, + }); + let Some(first) = gradients.next() else { + return false; + }; + gradients.any(|gradient| gradient != first) +} + fn popup_alignment(side: crate::app::config::DockSide) -> iced_aw::drop_down::Alignment { match side { crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, @@ -425,6 +507,19 @@ fn popup_alignment(side: crate::app::config::DockSide) -> iced_aw::drop_down::Al } } +fn compact_button_content(label: &'static str) -> Element<'static, Message> { + container(text(label).size(crate::ui::ROW_H * 0.42)) + .width(12) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center) + .into() +} + +fn mode_button_content(mode: PropertyMode) -> Element<'static, Message> { + compact_button_content(mode.button_label()) +} + fn by_mode_menu<'a>( side: crate::app::config::DockSide, mode: PropertyMode, @@ -434,7 +529,7 @@ fn by_mode_menu<'a>( by_layer: Message, by_block: Message, ) -> Element<'a, Message> { - let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + let more = button(mode_button_content(mode)) .on_press(toggle) .style(move |theme: &Theme, status| { if open { @@ -443,8 +538,8 @@ fn by_mode_menu<'a>( button::secondary(theme, status) } }) - .height(crate::ui::ROW_H) - .padding([2, 7]); + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); if !open { return more.into(); } @@ -483,7 +578,7 @@ fn color_control<'a>( Some("*VARIES*") } else { match info.color { - acadrust::types::Color::ByLayer => Some("ByLayer"), + acadrust::types::Color::ByLayer => None, acadrust::types::Color::ByBlock => Some("ByBlock"), _ => None, } @@ -498,7 +593,7 @@ fn color_control<'a>( let color = button(label.unwrap_or_else(|| container(Space::new()).width(Fill).height(Fill))) .on_press(Message::OpenColorWindow(target.clone(), info.color)) .width(Fill) - .height(crate::ui::ROW_H) + .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(info.display)), @@ -521,7 +616,7 @@ fn color_control<'a>( ..Default::default() }); let mode = if info.varies { - PropertyMode::Custom + PropertyMode::Varies } else { match info.color { acadrust::types::Color::ByLayer => PropertyMode::ByLayer, @@ -529,7 +624,7 @@ fn color_control<'a>( _ => PropertyMode::Custom, } }; - let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + let more = button(mode_button_content(mode)) .on_press(toggle) .style(move |theme: &Theme, status| { if menu_open { @@ -538,8 +633,8 @@ fn color_control<'a>( button::secondary(theme, status) } }) - .height(crate::ui::ROW_H) - .padding([2, 7]); + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); let more: Element<'a, Message> = if menu_open { let choice = |label, color| { button(text(label).size(11)) @@ -599,11 +694,11 @@ fn transparency_control<'a>( .align_y(iced::Center) .height(crate::ui::ROW_H); let mode = if info.varies { - PropertyMode::Custom + PropertyMode::Varies } else { info.mode }; - let more = button(text(mode.button_label()).size(crate::ui::ROW_H * 0.42)) + let more = button(mode_button_content(mode)) .on_press(toggle) .style(move |theme: &Theme, status| { if menu_open { @@ -613,7 +708,7 @@ fn transparency_control<'a>( } }) .height(crate::ui::ROW_H) - .padding([2, 7]); + .padding([1, 3]); let more: Element<'a, Message> = if menu_open { let row_button = |label, message| { button(text(label).size(11)) @@ -823,7 +918,9 @@ pub fn view<'a>( }); if let Some(info) = linetype.as_ref() { let selected = (!info.varies).then(|| crate::ui::properties::LinetypeItem { - name: info.value.clone(), + // An empty name makes only the preview art visible in the closed + // Graphic Attributes control. Menu items retain their full names. + name: String::new(), art: linetype_preview_art(document, &line_handles, draw_depth, &info.value), }); let combo = combo_box( @@ -844,7 +941,7 @@ pub fn view<'a>( let more = by_mode_menu( side, if info.varies { - PropertyMode::Custom + PropertyMode::Varies } else if info.value.eq_ignore_ascii_case("ByLayer") { PropertyMode::ByLayer } else if info.value.eq_ignore_ascii_case("ByBlock") { @@ -890,7 +987,15 @@ pub fn view<'a>( let mut scale_input = text_input("*VARIES*", &scale_text) .size(crate::ui::ROW_H * 0.42) .padding([3, 5]) - .style(crate::ui::properties::combo_input_style) + .style(move |theme: &Theme, status| { + let mut style = crate::ui::properties::combo_input_style(theme, status); + if continuous_linetype { + let disabled = theme.palette().background.base.text.scale_alpha(0.42); + style.value = disabled; + style.placeholder = disabled; + } + style + }) .on_submit(Message::Noop) .width(Fill); if !continuous_linetype { @@ -902,7 +1007,10 @@ pub fn view<'a>( } else { crate::ui::icons::themed_arrow_down(7.0) }; - let button = button(icon).style(button::secondary).padding([0, 3]); + let button = button(icon) + .style(button::subtle) + .height(10) + .padding([0, 3]); if continuous_linetype { button } else { @@ -912,18 +1020,48 @@ pub fn view<'a>( )) } }; - let scale = row![ - crate::ui::icons::semantic(GraphicAttribute::Solid.icon(), PALETTE_CONTROL_ICON_SIZE), - scale_input, - column![step(true), step(false)].spacing(0), - ] + let scale_icon: Element<'_, Message> = if continuous_linetype { + crate::ui::icons::themed_disabled( + GraphicAttribute::Solid.icon(), + PALETTE_CONTROL_ICON_SIZE, + ) + } else { + crate::ui::icons::semantic( + GraphicAttribute::Solid.icon(), + PALETTE_CONTROL_ICON_SIZE, + ) + }; + let spinner_divider = container(Space::new()) + .width(Fill) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let spinner = container(column![step(true), spinner_divider, step(false)].spacing(0)) + .height(COMPACT_BUTTON_HEIGHT) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let scale_field = row![scale_input, spinner] + .spacing(0) + .align_y(iced::Center) + .width(Fill); + let scale = row![scale_icon, scale_field] .spacing(3) .align_y(iced::Center) .width(Length::FillPortion(1)); - let selected = - (!lineweight.varies).then_some(crate::ui::properties::LwItem(lineweight.value)); + let selected = (!lineweight.varies).then_some(crate::ui::properties::LwItem( + effective_lineweight(document, &line_handles, draw_depth, lineweight.value), + )); let lineweight_mode = if lineweight.varies { - PropertyMode::Custom + PropertyMode::Varies } else { match lineweight.value { acadrust::types::LineWeight::ByLayer => PropertyMode::ByLayer, @@ -1038,12 +1176,25 @@ pub fn view<'a>( } else { Message::GradientEditorOpen }; - let preview = button( - image(crate::ui::window::gradient_editor::compact_preview(hatch)) - .width(Fill) - .height(Fill) - .content_fit(iced::ContentFit::Fill), - ) + let preview_image = image(crate::ui::window::gradient_editor::compact_preview(hatch)) + .width(Fill) + .height(Fill) + .content_fit(iced::ContentFit::Fill); + let preview_content: Element<'_, Message> = if gradients_vary(document, selected) { + iced::widget::stack![ + preview_image, + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]), + ] + .into() + } else { + preview_image.into() + }; + let preview = button(preview_content) .on_press(toggle.clone()) .width(Fill) .height(crate::ui::ROW_H) @@ -1063,7 +1214,7 @@ pub fn view<'a>( }, ..Default::default() }); - let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) + let edit = button(compact_button_content("⋮")) .on_press(toggle) .style(move |theme: &Theme, status| { if gradient_editor.is_some() { @@ -1072,8 +1223,8 @@ pub fn view<'a>( button::secondary(theme, status) } }) - .height(crate::ui::ROW_H) - .padding([2, 7]); + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); let edit = tooltip( edit, text("Edit gradient").size(10), @@ -1122,7 +1273,7 @@ pub fn view<'a>( Message::HatchEditorOpen }) .width(Fill) - .height(crate::ui::ROW_H) + .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(theme.palette().background.base.color)), @@ -1139,28 +1290,7 @@ pub fn view<'a>( }, ..Default::default() }); - let preview: Element<'_, Message> = if hatch_editor_open { - let popup = crate::ui::properties::hatch_pattern_picker_content( - hatch_pattern_search, - hatch_pattern_focus, - current_pattern, - "graphic-hatch-pattern-search", - Message::GraphicHatchPatternSearchChanged, - Message::GraphicHatchPatternConfirm, - Message::GraphicHatchPatternFocus, - Message::GraphicHatchPatternChanged, - ); - iced_aw::DropDown::new(preview, popup, true) - .width(Length::Fixed(348.0)) - .height(Length::Fixed(720.0)) - .alignment(popup_alignment(side)) - .offset(4.0) - .on_dismiss(Message::HatchEditorClose) - .into() - } else { - preview.into() - }; - let edit = button(text("...").size(crate::ui::ROW_H * 0.42)) + let edit = button(compact_button_content("⋮")) .on_press(if hatch_editor_open { Message::HatchEditorClose } else { @@ -1174,8 +1304,40 @@ pub fn view<'a>( } }) .height(crate::ui::ROW_H) - .padding([2, 7]); + .padding([1, 3]); let edit = tooltip(edit, text("Edit hatch").size(10), tooltip::Position::Bottom); + let edit: Element<'_, Message> = if hatch_editor_open { + let popup = crate::ui::properties::hatch_pattern_picker_content( + hatch_pattern_search, + hatch_pattern_focus, + current_pattern, + "graphic-hatch-pattern-search", + Message::GraphicHatchPatternSearchChanged, + Message::GraphicHatchPatternConfirm, + Message::GraphicHatchPatternFocus, + Message::GraphicHatchPatternChanged, + ); + let popup = container(popup) + .padding(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + iced_aw::DropDown::new(edit, popup, true) + .width(Length::Fixed(352.0)) + .height(Length::Fixed(724.0)) + .alignment(popup_alignment(side)) + .offset(4.0) + .on_dismiss(Message::HatchEditorClose) + .into() + } else { + edit.into() + }; body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); } if current != GraphicAttribute::None { From 3b74c7e7ea4895d7adf550009ee415fe43a210ef Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 29 Sep 2026 20:57:52 +0200 Subject: [PATCH 04/10] Add graphic attributes controls and layer creation --- Cargo.lock | 12 + Cargo.toml | 2 +- src/app/commands/layers.rs | 4 + src/app/mod.rs | 8 + src/app/update/dialog.rs | 409 +++++++++++++++++++++++++++- src/app/update/mod.rs | 23 ++ src/app/view/mod.rs | 1 + src/command.rs | 40 +++ src/ui/window/graphic_attributes.rs | 139 ++++++---- 9 files changed, 586 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c94eff96..19b6a71c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2571,6 +2571,8 @@ dependencies = [ "iced_core", "iced_fonts_macros", "iced_widget", + "num-format", + "num-traits", "web-time", ] @@ -3768,6 +3770,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" diff --git a/Cargo.toml b/Cargo.toml index 8d5dbfc21..1805b6b8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ url = "2" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down"] } +iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down", "number_input"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 70a055ff0..186545e12 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -3,6 +3,10 @@ use super::*; impl OpenCADStudio { pub(super) fn dispatch_layers(&mut self, cmd: &str, i: usize) -> Option> { match cmd { + cmd if cmd.starts_with("GRAPHICATTRIBUTELAYER ") => { + let name = cmd.trim_start_matches("GRAPHICATTRIBUTELAYER").trim(); + return Some(self.create_graphic_attributes_layer(name)); + } // ── Layer object commands ────────────────────────────────────── "LAYOFF" => { let handles: Vec<_> = self.tabs[i] diff --git a/src/app/mod.rs b/src/app/mod.rs index a172d776e..7b26e0aec 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -769,6 +769,7 @@ pub(super) struct OpenCADStudio { /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, show_graphic_attributes: bool, + graphic_attributes_header_menu_open: bool, graphic_attribute_menu_open: bool, line_color_menu_open: bool, line_linetype_menu_open: bool, @@ -2066,6 +2067,12 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { + ToggleGraphicAttributesHeaderMenu, + CloseGraphicAttributesHeaderMenu, + GraphicAttributesSetAllByLayer, + GraphicAttributesSetAllByBlock, + GraphicAttributesRemoveReferences, + GraphicAttributesCreateLayer, ToggleGraphicAttributeDropdown, CloseGraphicAttributeDropdown, GraphicAttributeChanged(crate::ui::window::graphic_attributes::GraphicAttribute), @@ -4161,6 +4168,7 @@ impl OpenCADStudio { render_mode_preview: None, show_properties: true, show_graphic_attributes: true, + graphic_attributes_header_menu_open: false, graphic_attribute_menu_open: false, line_color_menu_open: false, line_linetype_menu_open: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 2e0756498..455ab5d99 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -130,7 +130,324 @@ fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool { } } +#[derive(Clone)] +struct ConcreteGraphicAttributes { + color: acadrust::types::Color, + linetype: String, + lineweight: acadrust::types::LineWeight, + transparency: acadrust::types::Transparency, +} + +fn concrete_graphic_attributes( + color: acadrust::types::Color, + linetype: &str, + lineweight: acadrust::types::LineWeight, + transparency: acadrust::types::Transparency, + layer: Option<&acadrust::tables::Layer>, +) -> ConcreteGraphicAttributes { + let layer_color = layer.map_or(acadrust::types::Color::Index(7), |layer| { + match layer.color { + acadrust::types::Color::ByLayer | acadrust::types::Color::ByBlock => { + acadrust::types::Color::Index(7) + } + color => color, + } + }); + let layer_linetype = layer + .map(|layer| layer.line_type.as_str()) + .filter(|value| { + !value.is_empty() + && !value.eq_ignore_ascii_case("ByLayer") + && !value.eq_ignore_ascii_case("ByBlock") + }) + .unwrap_or("Continuous"); + let layer_lineweight = layer.map_or(acadrust::types::LineWeight::Default, |layer| { + match layer.line_weight { + acadrust::types::LineWeight::ByLayer | acadrust::types::LineWeight::ByBlock => { + acadrust::types::LineWeight::Default + } + value => value, + } + }); + let layer_transparency = layer.map_or_else( + || acadrust::types::Transparency::from_percent(0.0), + |layer| { + if layer.transparency.is_by_layer() || layer.transparency.is_by_block() { + acadrust::types::Transparency::from_percent(0.0) + } else { + layer.transparency + } + }, + ); + + ConcreteGraphicAttributes { + color: match color { + acadrust::types::Color::ByLayer => layer_color, + acadrust::types::Color::ByBlock => acadrust::types::Color::Index(7), + value => value, + }, + linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { + layer_linetype.to_owned() + } else if linetype.eq_ignore_ascii_case("ByBlock") { + "Continuous".to_owned() + } else { + linetype.to_owned() + }, + lineweight: match lineweight { + acadrust::types::LineWeight::ByLayer => layer_lineweight, + acadrust::types::LineWeight::ByBlock => acadrust::types::LineWeight::Default, + value => value, + }, + transparency: if transparency.is_by_layer() { + layer_transparency + } else if transparency.is_by_block() { + acadrust::types::Transparency::from_percent(0.0) + } else { + transparency + }, + } +} + +fn valid_layer_name(name: &str) -> bool { + !name.is_empty() + && name.chars().count() <= 255 + && !name.chars().any(|character| { + matches!( + character, + '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`' + ) + }) +} + impl OpenCADStudio { + fn graphic_attribute_handles(&self, tab: usize) -> Vec { + let selected = self.tabs[tab].scene.selected_handles_in_order(); + let document = &self.tabs[tab].scene.document; + let mut seen = rustc_hash::FxHashSet::default(); + crate::ui::window::graphic_attributes::line_handles(document, &selected) + .into_iter() + .chain(crate::ui::window::graphic_attributes::fill_handles( + document, &selected, + )) + .filter(|handle| seen.insert(*handle)) + .collect() + } + + fn current_graphic_attributes(&self, tab: usize) -> ConcreteGraphicAttributes { + let document = &self.tabs[tab].scene.document; + let handles = self.graphic_attribute_handles(tab); + let draw_depth = self.tabs[tab].scene.draw_depth_map(); + let entity = handles + .iter() + .filter_map(|handle| { + document.get_entity(*handle).map(|entity| { + let depth = draw_depth + .get(&handle.value()) + .map_or(0.0, |value| value[0]); + (depth, entity) + }) + }) + .max_by(|(left, _), (right, _)| left.total_cmp(right)) + .map(|(_, entity)| entity); + + if let Some(entity) = entity { + let common = entity.common(); + return concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ); + } + + let header = &document.header; + let layer_name = if header.current_layer_name.is_empty() { + self.tabs[tab].active_layer.as_str() + } else { + header.current_layer_name.as_str() + }; + concrete_graphic_attributes( + header.current_entity_color, + &header.current_linetype_name, + acadrust::types::LineWeight::from_value(header.current_line_weight), + document.current_entity_transparency(), + document.layers.get(layer_name), + ) + } + + pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> iced::Task { + self.graphic_attributes_header_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let (color, linetype, lineweight, transparency) = if by_layer { + ( + acadrust::types::Color::ByLayer, + "ByLayer", + acadrust::types::LineWeight::ByLayer, + acadrust::types::Transparency::BY_LAYER, + ) + } else { + ( + acadrust::types::Color::ByBlock, + "ByBlock", + acadrust::types::LineWeight::ByBlock, + acadrust::types::Transparency::BY_BLOCK, + ) + }; + if selected.is_empty() { + let header = &mut self.tabs[i].scene.document.header; + header.current_entity_color = color; + header.current_linetype_name = linetype.to_owned(); + header.current_linetype_handle = acadrust::Handle::NULL; + header.current_line_weight = lineweight.value(); + self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency); + self.tabs[i].dirty = true; + self.sync_ribbon_from_selection(); + self.refresh_properties(); + return iced::Task::none(); + } + + let handles: Vec<_> = self + .graphic_attribute_handles(i) + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_color(entity, color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + entity.common_mut().transparency = transparency; + } + }); + iced::Task::none() + } + + pub(super) fn on_graphic_attributes_remove_references(&mut self) -> iced::Task { + self.graphic_attributes_header_menu_open = false; + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + let values = self.current_graphic_attributes(i); + let linetype_handle = self.tabs[i] + .scene + .document + .line_types + .iter() + .find(|linetype| linetype.name.eq_ignore_ascii_case(&values.linetype)) + .map_or(acadrust::Handle::NULL, |linetype| linetype.handle); + let header = &mut self.tabs[i].scene.document.header; + header.current_entity_color = values.color; + header.current_linetype_name = values.linetype; + header.current_linetype_handle = linetype_handle; + header.current_line_weight = values.lineweight.value(); + self.tabs[i] + .scene + .document + .set_current_entity_transparency(values.transparency); + self.tabs[i].dirty = true; + self.sync_ribbon_from_selection(); + self.refresh_properties(); + return iced::Task::none(); + } + + let handles: Vec<_> = self + .graphic_attribute_handles(i) + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + let values: rustc_hash::FxHashMap<_, _> = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + let common = entity.common(); + Some(( + *handle, + concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + self.tabs[i].scene.document.layers.get(&common.layer), + ), + )) + }) + .collect(); + self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { + let Some(values) = values.get(&handle) else { + return; + }; + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_color(entity, values.color); + crate::scene::view::dispatch::apply_common_prop( + entity, + "linetype", + &values.linetype, + ); + crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); + entity.common_mut().transparency = values.transparency; + } + }); + iced::Task::none() + } + + pub(crate) fn create_graphic_attributes_layer( + &mut self, + raw_name: &str, + ) -> iced::Task { + let i = self.active_tab; + let name = raw_name.trim(); + if !valid_layer_name(name) { + self.command_line + .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|=`."); + return iced::Task::none(); + } + if self.tabs[i] + .scene + .document + .layers + .iter() + .any(|layer| layer.name.eq_ignore_ascii_case(name)) + { + self.command_line.push_error( + crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), + ); + return iced::Task::none(); + } + + let values = self.current_graphic_attributes(i); + let layer_name = name.to_owned(); + let undo = self.begin_layer_undo( + i, + "Create layer with active settings", + std::slice::from_ref(&layer_name), + ); + let mut layer = acadrust::tables::Layer::new(name); + layer.handle = self.tabs[i].scene.document.allocate_handle(); + layer.color = values.color; + layer.line_type = values.linetype; + layer.line_weight = values.lineweight; + layer.transparency = values.transparency; + use acadrust::tables::Table; + if self.tabs[i].scene.document.layers.add(layer).is_err() { + self.command_line + .push_error("Graphic Attributes: the layer could not be created."); + return iced::Task::none(); + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + self.refresh_layer_panel(); + self.command_line.push_output( + crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") + .as_ref(), + ); + iced::Task::none() + } + pub(super) fn open_gradient_editor(&mut self) { use crate::ui::window::gradient_editor::GradientEditorState; let i = self.active_tab; @@ -601,7 +918,7 @@ impl OpenCADStudio { } pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task { - let scale = scale.clamp(0.01, 1000.0); + let scale = ((scale * 10.0).round() / 10.0).clamp(0.1, 1000.0); let i = self.active_tab; let selected = self.tabs[i].scene.selected_handles_in_order(); if selected.is_empty() { @@ -1278,6 +1595,7 @@ impl OpenCADStudio { } PanelId::GraphicAttributes => { self.show_graphic_attributes = false; + self.graphic_attributes_header_menu_open = false; self.graphic_attribute_menu_open = false; } } @@ -2270,6 +2588,17 @@ mod tests { .abs() < f64::EPSILON ); + let _ = app.update(Message::LineLinetypeScaleChanged(1.2000000000000002)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 1.2 + ); let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); assert!( (app.tabs[i] @@ -2433,6 +2762,84 @@ mod tests { ); } + #[test] + fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { + let mut app = fresh(); + let i = app.active_tab; + { + let layer = app.tabs[i] + .scene + .document + .layers + .get_mut("0") + .expect("default layer"); + layer.color = acadrust::types::Color::Index(3); + layer.line_type = "Continuous".to_string(); + layer.line_weight = acadrust::types::LineWeight::Value(50); + layer.transparency = acadrust::types::Transparency::from_percent(0.25); + } + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(handle, false); + + let _ = app.on_graphic_attributes_set_all(false); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::ByBlock); + assert_eq!(common.linetype, "ByBlock"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::ByBlock); + assert!(common.transparency.is_by_block()); + + let _ = app.on_graphic_attributes_set_all(true); + let _ = app.on_graphic_attributes_remove_references(); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::Index(3)); + assert_eq!(common.linetype, "Continuous"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::Value(50)); + assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); + } + + #[test] + fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + + let _ = app.update(Message::GraphicAttributesCreateLayer); + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("GRAPHICATTRIBUTELAYER") + ); + let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); + let _ = app.update(Message::CommandFinalize); + + let layer = app.tabs[i] + .scene + .document + .layers + .get("Graphic Settings") + .expect("created layer"); + assert_eq!(layer.color, acadrust::types::Color::Index(2)); + assert_eq!(layer.line_type, "Continuous"); + assert_eq!(layer.line_weight, acadrust::types::LineWeight::Value(35)); + assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); + } + /// A foreign document: the fresh scene's document plus one model-space LINE. fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { let mut doc = app.tabs[app.active_tab].scene.document.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a16a13008..2047d0067 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -9577,6 +9577,29 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), + Message::ToggleGraphicAttributesHeaderMenu => { + self.graphic_attributes_header_menu_open ^= true; + Task::none() + } + Message::CloseGraphicAttributesHeaderMenu => { + self.graphic_attributes_header_menu_open = false; + Task::none() + } + Message::GraphicAttributesSetAllByLayer => self.on_graphic_attributes_set_all(true), + Message::GraphicAttributesSetAllByBlock => self.on_graphic_attributes_set_all(false), + Message::GraphicAttributesRemoveReferences => { + self.on_graphic_attributes_remove_references() + } + Message::GraphicAttributesCreateLayer => { + self.graphic_attributes_header_menu_open = false; + let command = crate::command::FreeTextValuePromptCommand::new( + "GRAPHICATTRIBUTELAYER", + "Enter a name for the new layer:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } Message::ToggleGraphicAttributeDropdown => { self.graphic_attribute_menu_open ^= true; self.line_color_menu_open = false; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3cfddacae..add93fa4d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2953,6 +2953,7 @@ impl OpenCADStudio { width, auto_collapse, side, + self.graphic_attributes_header_menu_open, self.graphic_attribute_menu_open, self.line_color_menu_open, self.line_linetype_menu_open, diff --git a/src/command.rs b/src/command.rs index 8719ce9d7..bfa4c19eb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -347,6 +347,46 @@ impl CadCommand for ValuePromptCommand { } } +/// One-value prompt that preserves spaces and letter casing. This is intended +/// for symbol names entered through the command line, where a space belongs to +/// the value instead of submitting the current token. +pub struct FreeTextValuePromptCommand { + name: &'static str, + prompt: &'static str, +} + +impl FreeTextValuePromptCommand { + pub fn new(name: &'static str, prompt: &'static str) -> Self { + Self { name, prompt } + } +} + +impl CadCommand for FreeTextValuePromptCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + crate::t!(self.prompt).into_owned() + } + + fn input_kind(&self) -> InputKind { + InputKind::FreeText + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch(format!("{} ", self.name)) + } +} + /// Interactive pick for `UCS FACE` and `UCS OBJECT`. /// /// Hands the picked entity to the inline `UCS FACE ` / diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 4ec42d4ea..8758985f1 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -745,6 +745,7 @@ pub fn view<'a>( width: f32, auto_collapse: bool, side: crate::app::config::DockSide, + header_menu_open: bool, menu_open: bool, line_color_menu_open: bool, line_linetype_menu_open: bool, @@ -758,6 +759,57 @@ 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 pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -793,6 +845,7 @@ pub fn view<'a>( row![ text(crate::t!("Graphic Attributes")).size(12), Space::new().width(Fill), + menu, pin, close ] @@ -979,15 +1032,26 @@ pub fn view<'a>( )); } if let (Some(scale), Some(lineweight)) = (linetype_scale, lineweight) { - let scale_text = if scale.varies { - String::new() + let scale_field: Element<'_, Message> = if scale.varies { + text_input("*VARIES*", "") + .size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .style(crate::ui::properties::combo_input_style) + .width(Fill) + .into() } else { - format!("{:.2}", scale.value) - }; - let mut scale_input = text_input("*VARIES*", &scale_text) - .size(crate::ui::ROW_H * 0.42) - .padding([3, 5]) - .style(move |theme: &Theme, status| { + let scale_value = ((scale.value * 10.0).round() / 10.0).clamp(0.1, 1000.0); + let mut input = iced_aw::number_input( + &scale_value, + 0.1..=1000.0, + |value: f64| { + Message::LineLinetypeScaleChanged((value * 10.0).round() / 10.0) + }, + ) + .step(0.1) + .set_size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .input_style(move |theme: &Theme, status| { let mut style = crate::ui::properties::combo_input_style(theme, status); if continuous_linetype { let disabled = theme.palette().background.base.text.scale_alpha(0.42); @@ -996,29 +1060,26 @@ pub fn view<'a>( } style }) - .on_submit(Message::Noop) + .style(move |theme: &Theme, status| { + let text = theme.palette().background.base.text; + iced_aw::number_input::number_input::Style { + button_background: None, + icon_color: if continuous_linetype + || status == iced_aw::style::Status::Disabled + { + text.scale_alpha(0.42) + } else { + text + }, + } + }) .width(Fill); - if !continuous_linetype { - scale_input = scale_input.on_input(Message::LineLinetypeScaleInput); - } - let step = |up: bool| { - let icon: Element<'_, Message> = if up { - crate::ui::icons::themed_arrow_up(7.0) - } else { - crate::ui::icons::themed_arrow_down(7.0) - }; - let button = button(icon) - .style(button::subtle) - .height(10) - .padding([0, 3]); if continuous_linetype { - button + input = input.on_input_maybe(None:: Message>); } else { - let delta = if up { 0.1 } else { -0.1 }; - button.on_press(Message::LineLinetypeScaleChanged( - (scale.value + delta).clamp(0.01, 1000.0), - )) - } + input = input.on_submit(Message::Noop); + } + input.into() }; let scale_icon: Element<'_, Message> = if continuous_linetype { crate::ui::icons::themed_disabled( @@ -1031,28 +1092,6 @@ pub fn view<'a>( PALETTE_CONTROL_ICON_SIZE, ) }; - let spinner_divider = container(Space::new()) - .width(Fill) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); - let spinner = container(column![step(true), spinner_divider, step(false)].spacing(0)) - .height(COMPACT_BUTTON_HEIGHT) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }); - let scale_field = row![scale_input, spinner] - .spacing(0) - .align_y(iced::Center) - .width(Fill); let scale = row![scale_icon, scale_field] .spacing(3) .align_y(iced::Center) From 3e45331de1c19d506e123a60cb6570ad9087d246 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 29 Sep 2026 21:34:31 +0200 Subject: [PATCH 05/10] Add Graphic Attributes palette toggle and persistence --- assets/icons/graphic_attributes.svg | 6 + src/app/commands/display.rs | 5 + src/app/commands/mod.rs | 1 + src/app/config.rs | 15 +++ src/app/mod.rs | 4 +- src/app/update/dialog.rs | 38 +++++- src/app/update/file.rs | 4 + src/app/update/mod.rs | 37 ++++++ src/command.rs | 31 +++-- .../view/graphic_attributes_palette.rs | 14 ++ src/modules/view/mod.rs | 2 + src/ui/ribbon/mod.rs | 7 + src/ui/ribbon/widgets.rs | 2 + src/ui/window/graphic_attributes.rs | 125 ++++++++++-------- 14 files changed, 220 insertions(+), 71 deletions(-) create mode 100644 assets/icons/graphic_attributes.svg create mode 100644 src/modules/view/graphic_attributes_palette.rs 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| { From 3c859c6b0eb403fc18db3ad16355bd20476fb04f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:45:39 +0000 Subject: [PATCH 06/10] Clean up Graphic Attributes palette - Revert formatting, import/module reordering and unrelated rewrites outside the feature (command.rs, style.rs, paper.rs, view/mod.rs, properties.rs, dialog.rs, ValuePromptCommand); the new SVG icons stay. - Move the palette logic out of dialog.rs into update/graphic_attributes.rs. - Replace 14 loose app fields and ~50 Message variants by GraphicAttributesState (one Menu enum for the open drop-down) and a nested GraphicAttributesMsg / GradientMsg, like BlockPaletteMsg. - Share the selection -> line/fill handles -> unlocked edit path, reuse valid_block_name, the ribbon lineweight handler and fill_handles, and drop the unused GradientColorToggle, color_open and LineLinetypeScaleInput. - Linetype scale accepts any positive value (e.g. ISO pen widths 0.13, 0.18, 0.25, 0.35) instead of rounding to 0.1 with a 0.1 minimum. - Closing open boundaries and creating the fill is one undo step. - The close prompt also answers typed Y/N + Enter, a bare Enter (Yes) and Escape (cancel), not only the option buttons. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- src/app/mod.rs | 93 +- src/app/update/command.rs | 680 +++++---- src/app/update/dialog.rs | 2018 +++----------------------- src/app/update/graphic_attributes.rs | 1693 +++++++++++++++++++++ src/app/update/mod.rs | 578 +------- src/app/update/style.rs | 68 +- src/app/view/mod.rs | 57 +- src/command.rs | 31 +- src/scene/entity.rs | 34 +- src/scene/hatch_boundary/tests.rs | 3 +- src/scene/model/hatch_model.rs | 5 +- src/scene/paper.rs | 42 +- src/ui/dock.rs | 7 +- src/ui/properties.rs | 139 +- src/ui/window/gradient_editor.rs | 113 +- src/ui/window/graphic_attributes.rs | 1877 ++++++++++++------------ src/ui/window/mod.rs | 32 +- 17 files changed, 3478 insertions(+), 3992 deletions(-) create mode 100644 src/app/update/graphic_attributes.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 713ec9d67..16a119f87 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,18 +12,20 @@ mod command_driver; pub(crate) mod commands; pub(crate) mod dim_viewport; #[cfg(test)] +mod viewport_dimension_tests; +#[cfg(test)] mod dimension_preview_tests; mod document; mod drafting_settings; pub(crate) mod expr_eval; +mod options_session; mod find_replace; pub(crate) mod helpers; mod history; mod layers; mod model_ops; -mod mtext_editor; mod navigation; -mod options_session; +mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; mod presspull_ops; @@ -38,8 +40,6 @@ mod text_inline; mod tolerance_dialog; mod update; mod view; -#[cfg(test)] -mod viewport_dimension_tests; mod visibility; pub use style_ops::StyleKind; @@ -768,24 +768,9 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + /// Whether the Graphic Attributes palette is shown (GRAPHICATTRIBUTES). show_graphic_attributes: bool, - graphic_attributes_header_menu_open: bool, - graphic_attribute_menu_open: bool, - line_color_menu_open: bool, - line_linetype_menu_open: bool, - line_lineweight_menu_open: bool, - line_transparency_menu_open: bool, - graphic_hatch_pattern_search: String, - graphic_hatch_pattern_focus: usize, - solid_fill_color_menu_open: bool, - fill_transparency_menu_open: bool, - gradient_editor: Option, - hatch_editor_handles: Option>, - pending_graphic_fill_close: Option<( - crate::ui::window::graphic_attributes::GraphicAttribute, - Vec, - Vec, - )>, + graphic_attributes: crate::ui::window::graphic_attributes::GraphicAttributesState, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Docked External References panel visibility (EXTERNALREFERENCES). @@ -2067,57 +2052,7 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { - ToggleGraphicAttributesHeaderMenu, - CloseGraphicAttributesHeaderMenu, - GraphicAttributesSetAllByLayer, - GraphicAttributesSetAllByBlock, - GraphicAttributesRemoveReferences, - GraphicAttributesCreateLayer, - ToggleGraphicAttributeDropdown, - CloseGraphicAttributeDropdown, - GraphicAttributeChanged(crate::ui::window::graphic_attributes::GraphicAttribute), - ToggleLineColorDropdown, - CloseLineColorDropdown, - LineColorChanged(AcadColor), - ToggleLineLinetypeMenu, - CloseLineLinetypeMenu, - LineLinetypeChanged(String), - ToggleLineLineweightMenu, - CloseLineLineweightMenu, - LineLineweightChanged(LineWeight), - LineLinetypeScaleChanged(f64), - LineLinetypeScaleInput(String), - ToggleLineTransparencyDropdown, - CloseLineTransparencyDropdown, - LineTransparencyChanged(u8), - LineTransparencyByLayer, - LineTransparencyByBlock, - ToggleSolidFillColorDropdown, - CloseSolidFillColorDropdown, - SolidFillColorChanged(AcadColor), - ToggleFillTransparencyDropdown, - CloseFillTransparencyDropdown, - FillTransparencyChanged(u8), - FillTransparencyByLayer, - FillTransparencyByBlock, - HatchEditorOpen, - HatchEditorClose, - GraphicHatchPatternSearchChanged(String), - GraphicHatchPatternFocus(usize), - GraphicHatchPatternChanged(String), - GraphicHatchPatternConfirm, - GradientEditorOpen, - GradientColorModeChanged(crate::ui::window::gradient_editor::GradientColorMode), - GradientColorToggle(u8), - GradientColorChanged(u8, AcadColor), - GradientShadeTintChanged(f32), - GradientTypeChanged(crate::scene::model::hatch_model::GradientKind), - GradientInvertToggled, - GradientAngleChanged(String), - GradientAngleReset, - GradientCenteredChanged(bool), - GradientApply, - GradientCancel, + GraphicAttributes(crate::ui::window::graphic_attributes::GraphicAttributesMsg), SpaceMouseWake, SpaceMouseFrame(iced::time::Instant), SpaceMouseFocus(iced::window::Id, bool), @@ -4170,19 +4105,7 @@ impl OpenCADStudio { render_mode_preview: None, show_properties: true, show_graphic_attributes: false, - graphic_attributes_header_menu_open: false, - graphic_attribute_menu_open: false, - line_color_menu_open: false, - line_linetype_menu_open: false, - line_lineweight_menu_open: false, - line_transparency_menu_open: false, - graphic_hatch_pattern_search: String::new(), - graphic_hatch_pattern_focus: 0, - solid_fill_color_menu_open: false, - fill_transparency_menu_open: false, - gradient_editor: None, - hatch_editor_handles: None, - pending_graphic_fill_close: None, + graphic_attributes: Default::default(), show_block_palette: false, show_external_references: false, show_browser: false, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 27a1090f0..6b13e7fa4 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -4,22 +4,23 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; -use crate::app::TextEntryMode; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; +use crate::app::TextEntryMode; use crate::modules::ModuleEvent; -#[cfg(not(target_arch = "wasm32"))] -use crate::plugin::v4_support; -use crate::scene::model::object::{GripApply, PropValue}; use crate::scene::pick::grip::{ find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::plugin::v4_support; +use crate::scene::model::object::{GripApply, PropValue}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; -use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; use crate::ui::PropertiesPanel; +use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; @@ -242,23 +243,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Live incremental search for INSERT/MINSERT (see CommandInput) let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live) { - (true, cmd.options(), cmd.prompt()) - } else { - (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { - if last.pinned { - last.text = prompt; - } - } + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } } } } @@ -300,23 +290,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() - { - if cmd.on_live_input(&live) { - (true, cmd.options(), cmd.prompt()) - } else { - (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { - if last.pinned { - last.text = prompt; - } - } + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } } } self.focus_cmd_input() @@ -327,6 +306,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // dropdown so the dispatched command's new prompt is // immediately visible on the overlay. self.command_line.close_history(); + if self.awaiting_fill_close_answer() { + let input = std::mem::take(&mut self.command_line.input); + return self.on_graphic_fill_close_input(&input); + } // A leading `>` was only a "literal spaces" typing hint (see // CommandSpace) — drop it before the input is interpreted. // Free-form text prompts keep it: there it is content, not a @@ -345,7 +328,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::Lengthen => { Some(GripEditMode::Lengthen) } - crate::scene::model::object::GripMenuAction::Radius => Some(GripEditMode::Radius), + crate::scene::model::object::GripMenuAction::Radius => { + Some(GripEditMode::Radius) + } crate::scene::model::object::GripMenuAction::ArcLength => { Some(GripEditMode::ArcLength) } @@ -361,10 +346,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { _ => None, }; expected_mode.is_some_and(|mode| { - !self.tabs[self.active_tab] - .active_grip - .as_ref() - .is_some_and(|grip| { + !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { grip.mode == mode && grip.handle == pending.handle && grip.grip_id == pending.grip_id @@ -391,13 +373,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { - self.command_line.push_error( - crate::tf!("{}: expected a number, got \"{raw}\"", pending.label).as_ref(), - ); + self.command_line.push_error(crate::tf!( + "{}: expected a number, got \"{raw}\"", + pending.label + ).as_ref()); self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_value_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + let interactive_value_grip = self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| { matches!( (grip.mode, pending.action), ( @@ -419,7 +405,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripEditMode::MoveParallel, crate::scene::model::object::GripMenuAction::MoveParallel, ) - ) && grip.handle == pending.handle + ) + && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); if interactive_value_grip { @@ -427,7 +414,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } use crate::entities::traits::EntityTypeOps; self.push_undo_snapshot(i, pending.label); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) + { entity.apply_grip_menu_value(pending.grip_id, pending.action, v); } // A typed grip-menu value reshapes dimensions too — drop a @@ -435,9 +423,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .scene .invalidate_dim_block_recorded(pending.handle); - self.tabs[i] - .scene - .bump_entities(&[(pending.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[( + pending.handle, + crate::scene::ChangeKind::Modified, + )]); self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); @@ -454,25 +443,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; if let Some(grip) = self.tabs[i].active_grip.clone() { - if matches!( - grip.mode, - GripEditMode::Stretch | GripEditMode::RectangleResize - ) { + if matches!(grip.mode, GripEditMode::Stretch | GripEditMode::RectangleResize) { let dyn_locked = self.tabs[i] .dyn_fields .iter() .any(|field| field.buffer.is_some()); let target = if matches!(grip.mode, GripEditMode::RectangleResize) { - grip.rectangle_frame - .map(|(opposite, width_axis, height_axis)| { + grip.rectangle_frame.map(|(opposite, width_axis, height_axis)| { let cursor_delta = self.tabs[i].last_cursor_world - opposite; let mut width = cursor_delta.dot(width_axis); let mut height = cursor_delta.dot(height_axis); for field in &self.tabs[i].dyn_fields { - let value = field.buffer.as_ref().and_then(|buffer| { - crate::app::expr_eval::eval_number(buffer) - }); + let value = field + .buffer + .as_ref() + .and_then(|buffer| crate::app::expr_eval::eval_number(buffer)); if let Some(value) = value { match field.role { crate::command::DynRole::Width => { @@ -497,8 +483,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_resolve_point() } else { // Legacy command-line direct-distance entry. - let text = - crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + let text = crate::app::expr_eval::eval_to_string( + self.command_line.input.trim(), + ); crate::app::expr_eval::eval_number(text.trim()).map(|dist| { let cursor = self.tabs[i].last_cursor_world; @@ -563,9 +550,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } let delta = target - grip.last_world; - let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = - grip.rectangle_frame - { + let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame { let opposite_id = (grip.grip_id + 2) % 4; let mut edits = Vec::with_capacity(3); for adjacent_id in [(opposite_id + 1) % 4, (opposite_id + 3) % 4] { @@ -573,46 +558,44 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, candidate)| { - **owner == grip.handle && candidate.id == adjacent_id - }) + .find(|(owner, candidate)| **owner == grip.handle && candidate.id == adjacent_id) .map(|(_, candidate)| candidate.world) { let d = original - opposite; - let axis = - if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { + let axis = if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { width_axis } else { height_axis }; - edits.push(( - grip.handle, - adjacent_id, - GripApply::Absolute( + edits.push((grip.handle, adjacent_id, GripApply::Absolute( opposite + axis * (target - opposite).dot(axis), - ), - )); + ))); } } edits.push((grip.handle, grip.grip_id, GripApply::Absolute(target))); edits } else { - grip.targets - .iter() - .map(|target_grip| { + grip.targets.iter().map(|target_grip| { let apply = if target_grip.is_translate { GripApply::Translate(delta) } else { - GripApply::Absolute(target_grip.last_world + delta) + GripApply::Absolute( + target_grip.last_world + delta, + ) }; - (target_grip.handle, target_grip.grip_id, apply) - }) - .collect() + ( + target_grip.handle, + target_grip.grip_id, + apply, + ) + }).collect() }; for (handle, grip_id, apply) in actions { - self.tabs[i].scene.apply_grip(handle, grip_id, apply); + self.tabs[i] + .scene + .apply_grip(handle, grip_id, apply); } self.solve_grip_constraints(i, &grip); @@ -922,10 +905,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { /// has an established anchor, and `text` parses as a scalar distance, /// project along the active reference ray (if any) or the current cursor /// direction in the active UCS plane. - pub(in crate::app) fn try_direct_distance_entry( - &mut self, - text: &str, - ) -> Option> { + pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option> { let i = self.active_tab; if self.tabs[i] .active_cmd @@ -1031,13 +1011,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if !self.command_line.input.trim().is_empty() { return self.update(Message::CommandSubmit); } + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(""); + } // A grip edit is not an active CAD command, but its Dynamic Input // fields use the same keyboard path. Route Enter through CommandSubmit, // whose grip branch resolves Distance / Angle and finalizes the edit. let i = self.active_tab; let grip_dyn_locked = self.tabs[i].active_grip.is_some() && self.dyn_input - && self.tabs[i].dyn_fields.iter().any(|field| field.locked()); + && self.tabs[i] + .dyn_fields + .iter() + .any(|field| field.locked()); if grip_dyn_locked { return self.update(Message::CommandSubmit); @@ -1084,6 +1070,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } if self.ribbon.escape_extension() { return Task::none(); + } + if self.awaiting_fill_close_answer() { + self.graphic_attributes.pending_fill_close = None; + self.command_line.push_info(crate::t!("*Cancel*").as_ref()); + return Task::none(); } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. @@ -1147,8 +1138,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { sel.box_crossing_locked = false; } if was_pan { - self.command_line - .push_output(crate::t!("PAN ended.").as_ref()); + self.command_line.push_output(crate::t!("PAN ended.").as_ref()); } self.ribbon.deactivate_tool(); return Task::none(); @@ -1234,11 +1224,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_layer_toggle_vp_freeze( - &mut self, - layer_idx: usize, - vp_col_idx: usize, - ) -> Task { + pub(super) fn on_layer_toggle_vp_freeze(&mut self, layer_idx: usize, vp_col_idx: usize) -> Task { let i = self.active_tab; let vp_handle = self.tabs[i] .layers @@ -1331,7 +1317,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // added row is measured. match new_idx { Some(idx) => iced::widget::operation::scroll_to( - iced::advanced::widget::Id::new(crate::ui::window::layers::LAYER_TABLE_SCROLL_ID), + iced::advanced::widget::Id::new( + crate::ui::window::layers::LAYER_TABLE_SCROLL_ID, + ), iced::widget::scrollable::AbsoluteOffset { x: 0.0, y: idx as f32 * crate::ui::ROW_H, @@ -1350,18 +1338,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Layer "0" and the current layer can't be deleted — drop them // from the batch and note it. - let current = self.tabs[i] - .scene - .document - .header - .current_layer_name - .clone(); + let current = self.tabs[i].scene.document.header.current_layer_name.clone(); let before = names.len(); names.retain(|n| n != "0" && *n != current); if names.len() < before { - self.command_line.push_info( - crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref(), - ); + self.command_line + .push_info(crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref()); } if names.is_empty() { return Task::none(); @@ -1454,9 +1436,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { fn sync_layer_panel(&mut self, i: usize) { let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); + self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info); self.tabs[i].layers.selected = None; self.tabs[i].layers.selected_multi.clear(); self.sync_ribbon_layers(); @@ -1557,9 +1537,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { movement, ); self.tabs[i].dirty = true; - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[( + popup.handle, + crate::scene::ChangeKind::Modified, + )]); } } // Stretch / Move = grab this grip. Engage it so the next @@ -1578,7 +1559,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(acadrust::EntityType::MultiLeader(_)) ); let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) && is_multileader { + if matches!(item.action, GripMenuAction::MoveWithLeader) + && is_multileader + { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { ( @@ -1598,9 +1581,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let (frame, plane) = crate::entities::lwpolyline::rectangle_frame(polyline)?; - let opposite = polyline.vertices.get((popup.grip_id + 2) % 4)?.location; + let opposite = polyline + .vertices + .get((popup.grip_id + 2) % 4)? + .location; Some(( - glam::DVec3::from_array(plane.point_at([opposite.x, opposite.y])), + glam::DVec3::from_array( + plane.point_at([opposite.x, opposite.y]), + ), glam::DVec3::from_array(plane.vector_at(frame.width_axis)) .try_normalize()?, glam::DVec3::from_array(plane.vector_at(frame.height_axis)) @@ -1666,7 +1654,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| **owner == popup.handle && grip.id == popup.grip_id) + .find(|(owner, grip)| { + **owner == popup.handle && grip.id == popup.grip_id + }) { if self.grip_originals.is_empty() { self.grip_originals = self.tabs[i] @@ -1678,22 +1668,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .unwrap_or_default(); } self.tabs[i].active_grip = Some(match item.action { - GripMenuAction::Radius => { - GripEdit::radius(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::ArcLength => { - GripEdit::arc_length(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::RectangleWidth => { - GripEdit::rectangle_width(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::RectangleHeight => { - GripEdit::rectangle_height(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::MoveParallel => { - GripEdit::move_parallel(popup.handle, popup.grip_id, grip.world) - } - _ => GripEdit::lengthen(popup.handle, popup.grip_id, grip.world), + GripMenuAction::Radius => GripEdit::radius( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::ArcLength => GripEdit::arc_length( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::RectangleWidth => GripEdit::rectangle_width( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::RectangleHeight => GripEdit::rectangle_height( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::MoveParallel => GripEdit::move_parallel( + popup.handle, + popup.grip_id, + grip.world, + ), + _ => GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + ), }); } // Popup actions do not pass through the normal grip @@ -1703,22 +1707,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // move (and so keyboard input has a field to target). self.sync_dyn_fields(); if matches!(item.action, GripMenuAction::Radius) { - self.command_line - .push_info(crate::t!("Specify point or enter radius:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter radius:").as_ref(), + ); } else if matches!(item.action, GripMenuAction::ArcLength) { - self.command_line - .push_info(crate::t!("Specify point or enter arc length:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter arc length:").as_ref(), + ); } else if matches!(item.action, GripMenuAction::RectangleWidth) { self.command_line.push_info("Specify point or enter width:"); } else if matches!(item.action, GripMenuAction::RectangleHeight) { - self.command_line - .push_info("Specify point or enter height:"); + self.command_line.push_info("Specify point or enter height:"); } else if matches!(item.action, GripMenuAction::MoveParallel) { - self.command_line - .push_info("Specify point or enter parallel offset:"); + self.command_line.push_info("Specify point or enter parallel offset:"); } else { - self.command_line - .push_info(crate::t!("Specify point or enter distance:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter distance:").as_ref(), + ); } } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); @@ -1744,8 +1749,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line - .push_output(crate::t!("Polyline broken at vertex.").as_ref()); + self.command_line.push_output(crate::t!("Polyline broken at vertex.").as_ref()); } None => self .command_line @@ -1771,7 +1775,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }); if let Some((original, new_gid, old_len)) = placement { let dirty_before = self.tabs[i].dirty; - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { entity.apply_grip_menu(popup.grip_id, item.action); } let inserted = self.tabs[i] @@ -1781,17 +1787,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .and_then(polyline_vertex_count) .is_some_and(|len| len == old_len + 1); if !inserted { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { *entity = original; } - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[ + (popup.handle, crate::scene::ChangeKind::Modified), + ]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line - .push_error(crate::t!("Cannot add a vertex here.").as_ref()); + self.command_line.push_error(crate::t!("Cannot add a vertex here.").as_ref()); return Task::none(); } self.tabs[i] @@ -1804,22 +1811,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| **owner == popup.handle && grip.id == new_gid) + .find(|(owner, grip)| { + **owner == popup.handle && grip.id == new_gid + }) .map(|(_, grip)| grip.world); if let Some(grip_world) = grip_world { self.grip_originals = vec![(popup.handle, original)]; self.grip_dirty_before = Some(dirty_before); - self.tabs[i].active_grip = - Some(GripEdit::single(popup.handle, new_gid, false, grip_world)); + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + new_gid, + false, + grip_world, + )); self.command_line .push_info(crate::t!("Specify new vertex location:").as_ref()); } else { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { *entity = original; } - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[ + (popup.handle, crate::scene::ChangeKind::Modified), + ]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); @@ -1839,7 +1854,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { crate::entities::spline::shows_fit_points(spline) } - (GripMenuAction::ShowControlVertices, acadrust::EntityType::Spline(spline)) => { + ( + GripMenuAction::ShowControlVertices, + acadrust::EntityType::Spline(spline), + ) => { crate::entities::spline::shows_control_vertices(spline) } _ => false, @@ -1892,8 +1910,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .zip(self.tabs[i].selected_grips.iter()) .find(|(owner, g)| **owner == popup.handle && g.id == new_gid) { - self.tabs[i].active_grip = - Some(GripEdit::single(popup.handle, new_gid, false, g.world)); + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + new_gid, + false, + g.world, + )); self.grip_add_provisional = Some((popup.handle, new_gid)); } } @@ -1924,10 +1946,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // sync clipboard read returns nothing there); native uses // iced's clipboard. #[cfg(target_arch = "wasm32")] - return Task::perform(crate::sys::read_clipboard_text(), Message::MTextPasteClip); + return Task::perform( + crate::sys::read_clipboard_text(), + Message::MTextPasteClip, + ); #[cfg(not(target_arch = "wasm32"))] - return iced::clipboard::read_text() - .map(|result| Message::MTextPasteClip(result.ok().map(|text| (*text).clone()))); + return iced::clipboard::read_text().map(|result| { + Message::MTextPasteClip(result.ok().map(|text| (*text).clone())) + }); } if self.text_inline.is_some() { // Web: the iced text_input can't reach the async clipboard, @@ -1953,7 +1979,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { Task::perform(crate::sys::read_clipboard_text(), |text| { Message::SystemClipboardPaste(match text { - Some(text) if !text.is_empty() => crate::app::SystemClipboardText::Text(text), + Some(text) if !text.is_empty() => { + crate::app::SystemClipboardText::Text(text) + } _ => crate::app::SystemClipboardText::EmptyOrUnsupported, }) }) @@ -1983,11 +2011,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; let remembered = self.qselect_settings.clone(); - let scope = remembered - .as_ref() - .map_or(crate::app::QSelectScope::CurrentSpace, |settings| { - settings.scope - }); + let scope = remembered.as_ref().map_or( + crate::app::QSelectScope::CurrentSpace, + |settings| settings.scope, + ); let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); let type_filter = if let Some(settings) = remembered.as_ref() { settings @@ -2021,12 +2048,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let mut operator = remembered .as_ref() .map_or(crate::app::QSelectOp::Eq, |settings| settings.operator); - if matches!( - operator, - crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt - ) && !property.as_ref().is_some_and(|property| { + if matches!(operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !property.as_ref().is_some_and(|property| { matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) { + }) + { operator = crate::app::QSelectOp::Eq; } let value = remembered.as_ref().map_or_else(String::new, |settings| { @@ -2089,7 +2115,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // baked into the cached wire geometry — re-tessellate so the // change shows immediately (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); } }); @@ -2116,7 +2144,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.active_color = color; } else { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_color(entity, color); } }); @@ -2153,7 +2183,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // re-tessellate so the dashed/solid look updates immediately // (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); } }); @@ -2162,11 +2194,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_ribbon_style_changed( - &mut self, - key: crate::modules::StyleKey, - name: String, - ) -> Task { + pub(super) fn on_ribbon_style_changed(&mut self, key: crate::modules::StyleKey, name: String) -> Task { use crate::modules::StyleKey; self.ribbon.close_dropdown(); match key { @@ -2236,7 +2264,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { use crate::scene::model::hatch_patterns; if let Some(entry) = hatch_patterns::find(&name) { self.push_undo_snapshot(i, "HATCHEDIT"); - for &handle in handles { + for &handle in handles { if let Some(acadrust::EntityType::Hatch(dxf)) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -2254,17 +2282,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { dxf.pattern_angle, ); let origin = dxf.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - origin.x, - origin.y, - ); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); dxf.pattern = pattern; dxf.is_solid = matches!( entry.gpu, crate::scene::model::hatch_model::HatchPattern::Solid ); - dxf.pattern_type = acadrust::entities::HatchPatternType::Predefined; + dxf.pattern_type = + acadrust::entities::HatchPatternType::Predefined; dxf.gradient_color.enabled = false; } if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) { @@ -2293,7 +2318,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The Annotative Yes/No list drives the per-object annotative toggle // (MTEXT's own flag, the annotation context for the rest). if field == "annotative" { - let wanted = value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); + let wanted = + value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); let Some(handle) = handles.first().copied() else { return Task::none(); }; @@ -2418,15 +2444,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - let Some(mut state) = - self.tabs[i] + let Some(mut state) = self.tabs[i] .scene .document .get_entity(handle) .and_then(|entity| match entity { - acadrust::EntityType::Surface(surface) => { - Some(crate::entities::solid3d::surface_property_state(surface)) - } + acadrust::EntityType::Surface(surface) => Some( + crate::entities::solid3d::surface_property_state(surface), + ), _ => None, }) else { @@ -2448,9 +2473,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &mut self.tabs[i].scene.document, handle, crate::entities::solid3d::SURFACE_PROPERTIES_APP, - Some(crate::entities::solid3d::surface_property_xdata_values( - state, - )), + Some(crate::entities::solid3d::surface_property_xdata_values(state)), ); if field == PROP_SURFACE_WIREFRAME_TYPE { self.tabs[i].scene.reseed_derived_caches(handle); @@ -2641,16 +2664,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .then(|| { doc.objects.values().find_map(|object| match object { acadrust::objects::ObjectType::MultiLeaderStyle(style) - if style.name == value => - { - Some(style.clone()) - } + if style.name == value => Some(style.clone()), _ => None, }) }) .flatten(); let resolved: Option = match field { - "mleader_style" => resolved_mleader_style.as_ref().map(|style| style.handle), + "mleader_style" => { + resolved_mleader_style.as_ref().map(|style| style.handle) + } "text_style_handle" => doc .text_styles .iter() @@ -2787,7 +2809,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .document .block_records .iter() - .find(|b| crate::app::properties::arrowhead_label(&b.name) == value) + .find(|b| { + crate::app::properties::arrowhead_label(&b.name) == value + }) .map(|b| b.handle) } else { None @@ -2835,7 +2859,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(XDataValue::Handle(acadrust::Handle::NULL)), ); } else if let Some(h) = arrow_h { - dov::set(doc, handle, dov::DIMLDRBLK, Some(XDataValue::Handle(h))); + dov::set( + doc, + handle, + dov::DIMLDRBLK, + Some(XDataValue::Handle(h)), + ); } } _ => {} @@ -2868,7 +2897,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { if ins.block_name != canon { ins.block_name = canon.clone(); - changes.push((handle, crate::scene::ChangeKind::Modified)); + changes.push(( + handle, + crate::scene::ChangeKind::Modified, + )); } } } @@ -2877,8 +2909,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else if field == "tbl_style_handle" { - let style_handle = - self.tabs[i] + let style_handle = self.tabs[i] .scene .document .objects @@ -2906,11 +2937,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { continue; } if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop( - entity, - "transparency", - &value, - ); + crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); } } } else if field == "plot_style" { @@ -2921,19 +2948,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. - let dict_h = self.tabs[i] - .scene - .document - .header - .acad_plotstylename_dict_handle; + let dict_h = + self.tabs[i].scene.document.header.acad_plotstylename_dict_handle; let ph: Option = crate::scene::annotative::as_dict(&self.tabs[i].scene.document, dict_h) - .and_then(|d| d.entries.iter().find(|(n, _)| *n == value).map(|(_, h)| *h)); + .and_then(|d| { + d.entries + .iter() + .find(|(n, _)| *n == value) + .map(|(_, h)| *h) + }); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -2967,14 +2997,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .iter() .find_map(|(h, o)| match o { - acadrust::objects::ObjectType::Material(m) if m.name == value => Some(*h), + acadrust::objects::ObjectType::Material(m) if m.name == value => { + Some(*h) + } _ => None, }); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -3005,8 +3038,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::command::WorkingPlane::default() }; for &handle in &handles { - let mline_style = - self.tabs[i] + let mline_style = self.tabs[i] .scene .document .get_entity(handle) @@ -3027,13 +3059,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &value, ); } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( - entity, field, &value, plane, + entity, + field, + &value, + plane, ); if matches!(field, "ml_justification" | "ml_scale") { - if let (acadrust::EntityType::MLine(mline), Some(style)) = - (entity, mline_style.as_ref()) + if let ( + acadrust::EntityType::MLine(mline), + Some(style), + ) = (entity, mline_style.as_ref()) { crate::modules::draw::draw::mline::sync_mline_element_parameters( mline, style, @@ -3063,16 +3101,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { match field { "transparency" => { - let Some(transparency) = - crate::scene::creation_style::parse_current_transparency(&value) - else { - self.command_line.push_error( - "Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90.", - ); + let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else { + self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90."); self.refresh_properties(); return Task::none(); }; - if self.tabs[i].scene.document.current_entity_transparency() != transparency { + if self.tabs[i].scene.document.current_entity_transparency() + != transparency + { self.push_undo_snapshot(i, "CETRANSPARENCY"); if self.tabs[i] .scene @@ -3108,7 +3144,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } _ => { if let Some(h) = mat_handle { - self.tabs[i].scene.document.header.current_material_handle = h; + self.tabs[i].scene.document.header.current_material_handle = + h; } } } @@ -3121,8 +3158,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } match value.as_str() { - "ByBlock" => self.tabs[i].scene.document.header.current_plotstyle_type = 1, - "Normal" => self.tabs[i].scene.document.header.current_plotstyle_type = 2, + "ByBlock" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 1 + } + "Normal" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 2 + } _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, } self.tabs[i].dirty = true; @@ -3141,7 +3182,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .values() .find_map(|object| { - let acadrust::objects::ObjectType::Layout(layout) = object else { + let acadrust::objects::ObjectType::Layout(layout) = object + else { return None; }; layout @@ -3149,7 +3191,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .eq_ignore_ascii_case(&layout_name) .then(|| layout.plot_style_sheet.clone()) }) - .unwrap_or_else(|| self.tabs[i].scene.document.header.stylesheet.clone()); + .unwrap_or_else(|| { + self.tabs[i].scene.document.header.stylesheet.clone() + }); if current != next { self.push_undo_snapshot(i, "PLOTSTYLE"); for object in self.tabs[i].scene.document.objects.values_mut() { @@ -3175,11 +3219,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .clone(); if !current.eq_ignore_ascii_case(&value) { self.push_undo_snapshot(i, "CANNOSCALE"); - if self.tabs[i] - .scene - .set_annotation_scale_named(&value) - .is_some() - { + if self.tabs[i].scene.set_annotation_scale_named(&value).is_some() { self.tabs[i].dirty = true; } else { self.discard_last_undo_entry(i); @@ -3356,7 +3396,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "Flat Shaded" => Some(Mode::FlatShaded), "Gouraud Shaded" => Some(Mode::GouraudShaded), "Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges), - "Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges), + "Gouraud Shaded + Edges" => { + Some(Mode::GouraudShadedWithEdges) + } _ => None, }; if let Some(mode) = mode { @@ -3394,8 +3436,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "start_x" | "start_y" | "start_z" => Some(Some(0)), "end_x" | "end_y" | "end_z" => Some(Some(1)), "center_x" | "center_y" | "center_z" => Some(Some(-3)), - "radius" | "diameter" | "circumference" | "area" | "major_r" | "minor_r" - | "ratio" => Some(None), + "radius" | "diameter" | "circumference" | "area" + | "major_r" | "minor_r" | "ratio" => Some(None), _ => None, }; let retain_size = self.constraint_solve_mode @@ -3421,7 +3463,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .iter() .flat_map(|section| section.props.iter()) .find(|property| property.field == field) - .is_some_and(|property| matches!(property.value, PropValue::EditText(_))); + .is_some_and(|property| { + matches!(property.value, PropValue::EditText(_)) + }); if let Some(raw_val) = self.tabs[i] .properties .edit_buf @@ -3441,15 +3485,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .filter_map(|handle| { let entity = self.tabs[i].scene.document.get_entity(*handle)?; match (field, entity) { - ("current_fit_point", acadrust::EntityType::Spline(spline)) => { - Some(spline.fit_points.len()) - } - ("current_control_point", acadrust::EntityType::Spline(spline)) => { - Some(crate::entities::spline::control_vertex_count(spline)) - } - ("pm_current_vertex", acadrust::EntityType::PolygonMesh(mesh)) => { - Some(mesh.vertices.len()) - } + ( + "current_fit_point", + acadrust::EntityType::Spline(spline), + ) => Some(spline.fit_points.len()), + ( + "current_control_point", + acadrust::EntityType::Spline(spline), + ) => Some( + crate::entities::spline::control_vertex_count(spline), + ), + ( + "pm_current_vertex", + acadrust::EntityType::PolygonMesh(mesh), + ) => Some(mesh.vertices.len()), _ => None, } }) @@ -3467,8 +3516,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); if field == "tbl_style_handle" { - let style_handle = - self.tabs[i] + let style_handle = self.tabs[i] .scene .document .objects @@ -3610,8 +3658,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ); if applied && field == "dim_text_inside" { if let Some(acadrust::EntityType::Dimension( - acadrust::entities::Dimension::LargeRadial(dimension), - )) = self.tabs[i].scene.document.get_entity_mut(handle) + acadrust::entities::Dimension::LargeRadial( + dimension, + ), + )) = self.tabs[i] + .scene + .document + .get_entity_mut(handle) { dimension.base.text_user_positioned = false; } @@ -3624,7 +3677,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let vals = if val.trim().is_empty() { None } else { - Some(vec![acadrust::xdata::XDataValue::String(val.clone())]) + Some(vec![acadrust::xdata::XDataValue::String( + val.clone(), + )]) }; crate::scene::view::dispatch::set_entity_xdata( &mut self.tabs[i].scene.document, @@ -3647,7 +3702,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let trimmed = val.trim(); if trimmed.is_empty() { - dov::set(&mut self.tabs[i].scene.document, handle, code, None); + dov::set( + &mut self.tabs[i].scene.document, + handle, + code, + None, + ); } else if let Ok(n) = trimmed.parse::() { dov::set( &mut self.tabs[i].scene.document, @@ -3673,19 +3733,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } _ => { - if crate::scene::model::solid_history::is_primitive_property(field) - { - self.tabs[i] - .scene - .apply_solid_history_property(handle, field, &val); + if crate::scene::model::solid_history::is_primitive_property( + field, + ) { + self.tabs[i].scene.apply_solid_history_property( + handle, + field, + &val, + ); } else if self.tabs[i] .scene - .apply_solid_position_property(handle, field, &val, plane) + .apply_solid_position_property( + handle, + field, + &val, + plane, + ) .is_none() { - let mline_style = - self.tabs[i].scene.document.get_entity(handle).and_then( - |entity| match entity { + let mline_style = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { acadrust::EntityType::MLine(mline) => { crate::entities::mline::resolved_mline_style( mline, @@ -3694,8 +3764,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .cloned() } _ => None, - }, - ); + }); if matches!(field, "text_x" | "text_y") { crate::entities::dimension::materialize_large_radial_text_position( &mut self.tabs[i].scene.document, @@ -3703,8 +3772,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &val, ); } - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) + if let Some(entity) = self.tabs[i] + .scene + .document + .get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( entity, @@ -3724,7 +3795,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - if matches!(field, "srf_u_isolines" | "srf_v_isolines") { + if matches!(field, "srf_u_isolines" | "srf_v_isolines") + { self.tabs[i].scene.reseed_derived_caches(handle); } } @@ -3751,7 +3823,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) } None => { - crate::scene::parametric_constraints::ParametricRef::whole(handle) + crate::scene::parametric_constraints::ParametricRef::whole( + handle, + ) } }) }) @@ -3787,12 +3861,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Existing target → swap the selected references to it. Anonymous, // xref and xref-dependent definitions are not valid targets. - let target = self.tabs[i] - .scene - .document - .block_records - .get(new) - .map(|br| { + let target = self.tabs[i].scene.document.block_records.get(new).map(|br| { ( br.name.clone(), br.is_anonymous() || br.flags.is_xref || br.name.contains('|'), @@ -3895,7 +3964,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .collect::>(); Ok((ins.block_name.clone(), rows)) } - Some(acadrust::EntityType::Insert(_)) => Err("ATTEDIT This block has no attributes."), + Some(acadrust::EntityType::Insert(_)) => { + Err("ATTEDIT This block has no attributes.") + } _ => Err("ATTEDIT Select a block with attributes."), }; match result { @@ -3949,12 +4020,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The block's layer may have been locked while the editor was open — // refuse to write attributes to a locked-layer block. if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - self.command_line.push_info( - crate::tf!( + self.command_line.push_info(crate::tf!( "Object is on locked layer \"{layer}\" — unlock the layer to edit its attributes." - ) - .as_ref(), - ); + ).as_ref()); return Task::none(); } // Snapshot the working copy so the document can be mutated while the @@ -4138,13 +4206,7 @@ mod layer_rename_tests { let i = app.active_tab; let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "TEST"); - let handle = app.tabs[i] - .scene - .document - .layers - .get("TEST") - .unwrap() - .handle; + let handle = app.tabs[i].scene.document.layers.get("TEST").unwrap().handle; rename_layer(&mut app, "TEST", "test"); @@ -4172,10 +4234,7 @@ mod layer_rename_tests { fn current_layer_rename_updates_name_and_allocated_handle() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i] .scene @@ -4189,7 +4248,12 @@ mod layer_rename_tests { rename_layer(&mut app, &old_name, "Renamed"); - let layer = app.tabs[i].scene.document.layers.get("Renamed").unwrap(); + let layer = app.tabs[i] + .scene + .document + .layers + .get("Renamed") + .unwrap(); assert!(layer.handle.is_valid()); assert_eq!(app.tabs[i].active_layer, "Renamed"); assert_eq!(app.tabs[i].layers.current_layer, "Renamed"); @@ -4208,10 +4272,7 @@ mod layer_rename_tests { fn layer_rename_undo_redo_restores_active_layer() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i].layers.selected = Some(idx); let _ = app.on_layer_set_current(); @@ -4237,10 +4298,7 @@ mod layer_rename_tests { let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "A"); let _ = app.on_layer_new(); - let b_idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let b_idx = app.tabs[i].layers.editing.expect("new layer is being edited"); app.tabs[i].layers.selected = Some(b_idx); let _ = app.on_layer_set_current(); let header_name = app.tabs[i].scene.document.header.current_layer_name.clone(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 542370d3e..29d9c111a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,1011 +2,27 @@ #![allow(unused_imports)] use super::util::*; +use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::model::object::GripApply; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; +use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; -use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; -fn set_hatch_graphic_attribute( - hatch: &mut acadrust::entities::Hatch, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - visible_color: [f32; 4], -) { - use crate::ui::window::graphic_attributes::GraphicAttribute; - match value { - GraphicAttribute::Solid => { - hatch.pattern = acadrust::entities::HatchPattern::new("SOLID"); - hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; - hatch.is_solid = true; - hatch.gradient_color.enabled = false; - } - GraphicAttribute::Hatch => { - let Some(entry) = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| { - matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Pattern(_) - ) - }) - else { - return; - }; - let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); - crate::entities::hatch::scale_pattern_geometry( - &mut pattern, - hatch.pattern_scale.max(1.0e-6), - ); - crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); - let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); - hatch.pattern = pattern; - hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; - hatch.is_solid = false; - hatch.gradient_color.enabled = false; - } - GraphicAttribute::Gradient => { - let to_color = |rgba: [f32; 4]| acadrust::types::Color::Rgb { - r: (rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8, - g: (rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8, - b: (rgba[2] * 255.0).round().clamp(0.0, 255.0) as u8, - }; - hatch.is_solid = true; - hatch.gradient_color.enabled = true; - hatch.gradient_color.name = "LINEAR".into(); - hatch.gradient_color.angle = 0.0; - hatch.pattern_angle = 0.0; - hatch.gradient_color.shift = 0.0; - hatch.gradient_color.is_single_color = false; - hatch.gradient_color.colors = vec![ - acadrust::entities::hatch::GradientColorEntry { - value: 0.0, - color: to_color(visible_color), - }, - acadrust::entities::hatch::GradientColorEntry { - value: 1.0, - color: acadrust::types::Color::Rgb { - r: 46, - g: 46, - b: 46, - }, - }, - ]; - } - GraphicAttribute::None | GraphicAttribute::Varies => {} - } -} - -fn is_open_fill_boundary(entity: &acadrust::EntityType) -> bool { - match entity { - acadrust::EntityType::LwPolyline(polyline) => { - !polyline.is_closed && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline(polyline) => { - !polyline.flags.is_closed() && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline2D(polyline) => { - !polyline.is_closed() && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline3D(polyline) => { - !polyline.flags.closed && polyline.vertices.len() >= 3 - } - _ => false, - } -} - -fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool { - match entity { - acadrust::EntityType::LwPolyline(polyline) if !polyline.is_closed => { - polyline.is_closed = true; - true - } - acadrust::EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { - polyline.flags.set_closed(true); - true - } - acadrust::EntityType::Polyline2D(polyline) if !polyline.is_closed() => { - polyline.flags.set_closed(true); - true - } - acadrust::EntityType::Polyline3D(polyline) if !polyline.flags.closed => { - polyline.flags.closed = true; - true - } - _ => false, - } -} - -struct ConcreteGraphicAttributes { - color: acadrust::types::Color, - linetype: String, - lineweight: acadrust::types::LineWeight, - transparency: acadrust::types::Transparency, -} - -fn concrete_graphic_attributes( - color: acadrust::types::Color, - linetype: &str, - lineweight: acadrust::types::LineWeight, - transparency: acadrust::types::Transparency, - layer: Option<&acadrust::tables::Layer>, -) -> ConcreteGraphicAttributes { - let layer_color = layer.map_or(acadrust::types::Color::Index(7), |layer| { - match layer.color { - acadrust::types::Color::ByLayer | acadrust::types::Color::ByBlock => { - acadrust::types::Color::Index(7) - } - color => color, - } - }); - let layer_linetype = layer - .map(|layer| layer.line_type.as_str()) - .filter(|value| { - !value.is_empty() - && !value.eq_ignore_ascii_case("ByLayer") - && !value.eq_ignore_ascii_case("ByBlock") - }) - .unwrap_or("Continuous"); - let layer_lineweight = layer.map_or(acadrust::types::LineWeight::Default, |layer| { - match layer.line_weight { - acadrust::types::LineWeight::ByLayer | acadrust::types::LineWeight::ByBlock => { - acadrust::types::LineWeight::Default - } - value => value, - } - }); - let layer_transparency = layer.map_or_else( - || acadrust::types::Transparency::from_percent(0.0), - |layer| { - if layer.transparency.is_by_layer() || layer.transparency.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - layer.transparency - } - }, - ); - - ConcreteGraphicAttributes { - color: match color { - acadrust::types::Color::ByLayer => layer_color, - acadrust::types::Color::ByBlock => acadrust::types::Color::Index(7), - value => value, - }, - linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { - layer_linetype.to_owned() - } else if linetype.eq_ignore_ascii_case("ByBlock") { - "Continuous".to_owned() - } else { - linetype.to_owned() - }, - lineweight: match lineweight { - acadrust::types::LineWeight::ByLayer => layer_lineweight, - acadrust::types::LineWeight::ByBlock => acadrust::types::LineWeight::Default, - value => value, - }, - transparency: if transparency.is_by_layer() { - layer_transparency - } else if transparency.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - transparency - }, - } -} - -fn valid_layer_name(name: &str) -> bool { - !name.is_empty() - && name.chars().count() <= 255 - && !name.chars().any(|character| { - character.is_control() - || matches!( - character, - '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`' - ) - }) -} impl OpenCADStudio { - fn graphic_attribute_handles(&self, tab: usize) -> Vec { - let selected = self.tabs[tab].scene.selected_handles_in_order(); - let document = &self.tabs[tab].scene.document; - let mut seen = rustc_hash::FxHashSet::default(); - crate::ui::window::graphic_attributes::line_handles(document, &selected) - .into_iter() - .chain(crate::ui::window::graphic_attributes::fill_handles( - document, &selected, - )) - .filter(|handle| seen.insert(*handle)) - .collect() - } - - fn current_graphic_attributes(&self, tab: usize) -> ConcreteGraphicAttributes { - let document = &self.tabs[tab].scene.document; - let handles = self.graphic_attribute_handles(tab); - let draw_depth = self.tabs[tab].scene.draw_depth_map(); - let entity = handles - .iter() - .filter_map(|handle| { - document.get_entity(*handle).map(|entity| { - let depth = draw_depth - .get(&handle.value()) - .map_or(0.0, |value| value[0]); - (depth, entity) - }) - }) - .max_by(|(left, _), (right, _)| left.total_cmp(right)) - .map(|(_, entity)| entity); - - if let Some(entity) = entity { - let common = entity.common(); - return concrete_graphic_attributes( - common.color, - &common.linetype, - common.line_weight, - common.transparency, - document.layers.get(&common.layer), - ); - } - - let header = &document.header; - let layer_name = if header.current_layer_name.is_empty() { - self.tabs[tab].active_layer.as_str() - } else { - header.current_layer_name.as_str() - }; - concrete_graphic_attributes( - header.current_entity_color, - &header.current_linetype_name, - acadrust::types::LineWeight::from_value(header.current_line_weight), - document.current_entity_transparency(), - document.layers.get(layer_name), - ) - } - - pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> iced::Task { - self.graphic_attributes_header_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let (color, linetype, lineweight, transparency) = if by_layer { - ( - acadrust::types::Color::ByLayer, - "ByLayer", - acadrust::types::LineWeight::ByLayer, - acadrust::types::Transparency::BY_LAYER, - ) - } else { - ( - acadrust::types::Color::ByBlock, - "ByBlock", - acadrust::types::LineWeight::ByBlock, - acadrust::types::Transparency::BY_BLOCK, - ) - }; - if selected.is_empty() { - let header = &mut self.tabs[i].scene.document.header; - header.current_entity_color = color; - header.current_linetype_name = linetype.to_owned(); - header.current_linetype_handle = acadrust::Handle::NULL; - header.current_line_weight = lineweight.value(); - self.tabs[i] - .scene - .document - .set_current_entity_transparency(transparency); - self.tabs[i].dirty = true; - self.sync_ribbon_from_selection(); - self.refresh_properties(); - return iced::Task::none(); - } - - let handles: Vec<_> = self - .graphic_attribute_handles(i) - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); - crate::scene::view::dispatch::apply_line_weight(entity, lineweight); - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - - pub(super) fn on_graphic_attributes_remove_references(&mut self) -> iced::Task { - self.graphic_attributes_header_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - let values = self.current_graphic_attributes(i); - let linetype_handle = self.tabs[i] - .scene - .document - .line_types - .iter() - .find(|linetype| linetype.name.eq_ignore_ascii_case(&values.linetype)) - .map_or(acadrust::Handle::NULL, |linetype| linetype.handle); - let header = &mut self.tabs[i].scene.document.header; - header.current_entity_color = values.color; - header.current_linetype_name = values.linetype; - header.current_linetype_handle = linetype_handle; - header.current_line_weight = values.lineweight.value(); - self.tabs[i] - .scene - .document - .set_current_entity_transparency(values.transparency); - self.tabs[i].dirty = true; - self.sync_ribbon_from_selection(); - self.refresh_properties(); - return iced::Task::none(); - } - - let handles: Vec<_> = self - .graphic_attribute_handles(i) - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let values: rustc_hash::FxHashMap<_, _> = handles - .iter() - .filter_map(|handle| { - let entity = self.tabs[i].scene.document.get_entity(*handle)?; - let common = entity.common(); - Some(( - *handle, - concrete_graphic_attributes( - common.color, - &common.linetype, - common.line_weight, - common.transparency, - self.tabs[i].scene.document.layers.get(&common.layer), - ), - )) - }) - .collect(); - self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { - let Some(values) = values.get(&handle) else { - return; - }; - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, values.color); - crate::scene::view::dispatch::apply_common_prop( - entity, - "linetype", - &values.linetype, - ); - crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); - entity.common_mut().transparency = values.transparency; - } - }); - iced::Task::none() - } - - pub(crate) fn create_graphic_attributes_layer( - &mut self, - raw_name: &str, - ) -> iced::Task { - let i = self.active_tab; - let name = raw_name.trim(); - if !valid_layer_name(name) { - self.command_line - .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|=`."); - return iced::Task::none(); - } - if self.tabs[i] - .scene - .document - .layers - .iter() - .any(|layer| layer.name.eq_ignore_ascii_case(name)) - { - self.command_line.push_error( - crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), - ); - return iced::Task::none(); - } - - let values = self.current_graphic_attributes(i); - let layer_name = name.to_owned(); - let undo = self.begin_layer_undo( - i, - "Create layer with active settings", - std::slice::from_ref(&layer_name), - ); - let mut layer = acadrust::tables::Layer::new(name); - layer.handle = self.tabs[i].scene.document.allocate_handle(); - layer.color = values.color; - layer.line_type = values.linetype; - layer.line_weight = values.lineweight; - layer.transparency = values.transparency; - use acadrust::tables::Table; - if self.tabs[i].scene.document.layers.add(layer).is_err() { - self.command_line - .push_error("Graphic Attributes: the layer could not be created."); - return iced::Task::none(); - } - self.tabs[i].dirty = true; - self.commit_layer_undo(i, undo); - self.refresh_layer_panel(); - self.command_line.push_output( - crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") - .as_ref(), - ); - iced::Task::none() - } - - pub(super) fn open_gradient_editor(&mut self) { - use crate::ui::window::gradient_editor::GradientEditorState; - let i = self.active_tab; - let selected: rustc_hash::FxHashSet<_> = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - let Some(hatch) = self.tabs[i].scene.document.entities().find_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - if !hatch.gradient_color.enabled { - return None; - } - (selected.contains(&entity.common().handle) - || hatch - .paths - .iter() - .flat_map(|p| p.boundary_handles.iter()) - .any(|handle| selected.contains(handle))) - .then_some(hatch) - }) else { - return; - }; - self.gradient_editor = Some(GradientEditorState::from_hatch( - selected.iter().copied().collect(), - hatch, - )); - } - - pub(super) fn apply_gradient_editor(&mut self) -> iced::Task { - use crate::ui::window::gradient_editor::GradientColorMode; - let Some(state) = self.gradient_editor.take() else { - return iced::Task::none(); - }; - let i = self.active_tab; - let sources: rustc_hash::FxHashSet<_> = state.handles.iter().copied().collect(); - let targets: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(h) = entity else { - return None; - }; - (h.gradient_color.enabled - && (sources.contains(&entity.common().handle) - || h.paths - .iter() - .any(|p| p.boundary_handles.iter().any(|x| sources.contains(x))))) - .then_some(entity.common().handle) - }) - .collect(); - if targets.is_empty() { - return iced::Task::none(); - } - if targets - .iter() - .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.command_line - .push_error("Gradient Fill: the fill is on a locked layer."); - self.gradient_editor = Some(state); - return iced::Task::none(); - } - let angle = state.angle_radians(); - let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); - for handle in &targets { - let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { - continue; - }; - if let acadrust::EntityType::Hatch(h) = &mut entity { - h.gradient_color.name = state.kind.dxf_name(state.inverted).into(); - h.gradient_color.angle = angle; - h.pattern_angle = angle; - h.gradient_color.shift = if state.centered { 0.0 } else { 1.0 }; - h.gradient_color.is_single_color = state.color_mode == GradientColorMode::One; - h.gradient_color.color_tint = state.shade_tint as f64; - h.gradient_color.colors = vec![ - acadrust::entities::hatch::GradientColorEntry { - value: 0.0, - color: state.color_1.clone(), - }, - acadrust::entities::hatch::GradientColorEntry { - value: 1.0, - color: state.color_2.clone(), - }, - ]; - } - self.tabs[i].scene.update_entity(entity); - } - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(p) = pending { - self.commit_undo_delta(i, p) - } - iced::Task::none() - } - - pub(super) fn on_graphic_attribute( - &mut self, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - ) -> iced::Task { - self.pending_graphic_fill_close = None; - self.on_graphic_attribute_impl(value, true) - } - - pub(super) fn on_graphic_fill_close_response( - &mut self, - close: bool, - ) -> iced::Task { - let Some((value, handles, _)) = self.pending_graphic_fill_close.take() else { - return iced::Task::none(); - }; - if close { - let i = self.active_tab; - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - if !unlocked.is_empty() { - let pending = self.begin_undo(i, "Close fill boundaries", unlocked.len(), true); - for handle in unlocked { - let Some(mut entity) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { - continue; - }; - if close_fill_boundary(&mut entity) { - self.tabs[i].scene.update_entity(entity); - } - } - self.tabs[i].dirty = true; - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - } - self.on_graphic_attribute_impl(value, false) - } - - fn on_graphic_attribute_impl( - &mut self, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - ask_to_close: bool, - ) -> iced::Task { - use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; - use crate::ui::window::graphic_attributes::GraphicAttribute; - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - self.gradient_editor = None; - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - if value == GraphicAttribute::Varies { - return iced::Task::none(); - } - let i = self.active_tab; - let selected: Vec<_> = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - if selected.is_empty() { - self.command_line - .push_info(crate::t!("Select one or more closed objects first.").as_ref()); - return iced::Task::none(); - } - let open_boundaries: rustc_hash::FxHashSet<_> = selected - .iter() - .copied() - .filter(|handle| { - self.tabs[i] - .scene - .document - .get_entity(*handle) - .is_some_and(is_open_fill_boundary) - }) - .collect(); - if ask_to_close && value != GraphicAttribute::None && !open_boundaries.is_empty() { - self.pending_graphic_fill_close = - Some((value, open_boundaries.iter().copied().collect(), selected.clone())); - return iced::Task::none(); - } - let selected_handles: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); - let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None - { - let associated: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - let directly_selected = selected_handles.contains(&entity.common().handle); - let selected_boundary = hatch.is_associative - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| selected_handles.contains(handle)) - }); - (directly_selected || selected_boundary).then_some(entity.common().handle) - }) - .collect(); - (associated, Vec::new(), Vec::new(), 0) - } else { - let working = if self.tabs[i].editing_model_space() { - self.tabs[i].ucs_xform().working_plane() - } else { - crate::command::WorkingPlane::default() - }; - let normal = working.z.normalize_or(glam::DVec3::Z); - let storage = crate::entities::curve::ocs_plane( - acadrust::types::Vector3::new(normal.x, normal.y, normal.z), - working.origin.dot(normal), - ); - let plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(storage.origin), - glam::DVec3::from_array(storage.x_axis), - glam::DVec3::from_array(storage.y_axis), - ); - let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); - let (name, pattern) = match value { - GraphicAttribute::Solid => ("SOLID".into(), HatchPattern::Solid), - GraphicAttribute::Hatch => { - let Some(entry) = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) - else { - self.command_line - .push_error("Graphic Attributes: no hatch pattern is available."); - return iced::Task::none(); - }; - (entry.name.clone(), entry.gpu.clone()) - } - GraphicAttribute::Gradient => ( - "LINEAR".into(), - HatchPattern::Gradient { - angle_deg: 0.0, - color2: [0.18, 0.18, 0.18, 0.0], - kind: GradientKind::Linear, - invert: false, - shift: 0.0, - }, - ), - GraphicAttribute::None | GraphicAttribute::Varies => unreachable!(), - }; - let origin = self.tabs[i].scene.document.hatch_origin(); - let mut hatches = Vec::new(); - let mut updated_hatches = Vec::new(); - let mut directly_updated = rustc_hash::FxHashSet::default(); - let mut changed_boundaries = rustc_hash::FxHashSet::default(); - let mut skipped = 0; - for handle in &selected { - if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity(*handle) - { - let mut hatch = hatch.clone(); - let visible_color = - crate::scene::view::render::render_style_for_common_viewport( - &self.tabs[i].scene.document, - &hatch.common, - None, - ) - .0; - set_hatch_graphic_attribute(&mut hatch, value, visible_color); - updated_hatches.push(acadrust::EntityType::Hatch(hatch)); - directly_updated.insert(*handle); - continue; - } - // Object mode only needs this object's source. Including all - // scene sources can associate coincident, unselected objects. - let object_sources = sources - .get(handle) - .map(|source| (*handle, source.clone())) - .into_iter() - .collect(); - let command = crate::modules::draw::draw::hatch::HatchCommand::new( - Vec::new(), - object_sources, - vec![*handle], - None, - plane, - ) - .with_origin(origin) - .with_pattern(name.clone(), pattern.clone()); - if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { - hatches.push(hatch); - changed_boundaries.insert(*handle); - } else { - if !open_boundaries.contains(handle) { - skipped += 1; - } - } - } - // Replace only fills belonging to boundaries that successfully - // produced a new hatch. Existing fills on skipped objects remain. - let associated: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - (hatch.is_associative - && !directly_updated.contains(&entity.common().handle) - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| changed_boundaries.contains(handle)) - })) - .then_some(entity.common().handle) - }) - .collect(); - (associated, hatches, updated_hatches, skipped) - }; - if associated - .iter() - .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) - .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.command_line - .push_error("Graphic Attributes: an associated fill is on a locked layer."); - return iced::Task::none(); - } - if skipped > 0 { - let message = if skipped == 1 { - "Graphic Attributes: 1 unsupported object was not changed.".to_owned() - } else { - format!( - "Graphic Attributes: {skipped} unsupported objects were not changed." - ) - }; - self.command_line.push_info(&message); - } - if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { - return iced::Task::none(); - } - let pending = self.begin_undo( - i, - "Graphic Attributes", - associated.len() + new_hatches.len() + updated_hatches.len(), - true, - ); - self.tabs[i].scene.erase_entities(&associated); - for entity in updated_hatches { - self.tabs[i].scene.update_entity(entity); - } - let layer = self.tabs[i].active_layer.clone(); - for hatch in new_hatches { - self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); - } - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - iced::Task::none() - } - - pub(super) fn on_solid_fill_color( - &mut self, - color: acadrust::types::Color, - ) -> iced::Task { - self.solid_fill_color_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Fill color", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_color(&mut self, color: acadrust::types::Color) -> iced::Task { - self.line_color_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - return self.on_ribbon_color_changed(color); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line color", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_linetype(&mut self, linetype: String) -> iced::Task { - self.line_linetype_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - return self.on_ribbon_linetype_changed(linetype); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line linetype", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_lineweight( - &mut self, - lineweight: acadrust::types::LineWeight, - ) -> iced::Task { - self.line_lineweight_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - self.tabs[i].scene.document.header.current_line_weight = lineweight.value(); - self.tabs[i].dirty = true; - self.ribbon.active_lineweight = lineweight; - self.refresh_properties(); - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line weight", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_line_weight(entity, lineweight); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task { - 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() { - self.tabs[i] - .scene - .document - .header - .current_entity_linetype_scale = scale; - self.tabs[i].dirty = true; - self.refresh_properties(); - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Linetype scale", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().linetype_scale = scale; - } - }); - iced::Task::none() - } - - pub(super) fn on_line_transparency( - &mut self, - transparency: acadrust::types::Transparency, - ) -> iced::Task { - self.line_transparency_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - if self.tabs[i] - .scene - .document - .set_current_entity_transparency(transparency) - { - self.tabs[i].dirty = true; - self.refresh_properties(); - } - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line transparency", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - - pub(super) fn on_fill_transparency( - &mut self, - transparency: acadrust::types::Transparency, - ) -> iced::Task { - self.fill_transparency_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Fill transparency", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { // Default the format dropdown to the loaded file's own format — its // DWG-vs-DXF kind (from the extension) and its version (from the parsed @@ -1049,6 +65,7 @@ impl OpenCADStudio { Task::none() } + pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task { self.aec_drop_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { @@ -1058,6 +75,7 @@ impl OpenCADStudio { Task::none() } + pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task { self.active_modal = Some(crate::app::ModalKind::Unsaved); // The unsaved-changes prompt renders inside the main window, so bring @@ -1078,6 +96,7 @@ impl OpenCADStudio { } } + pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task { if self.active_modal == Some(crate::app::ModalKind::Unsaved) { self.active_modal = None; @@ -1086,11 +105,8 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_ribbon_tool_click( - &mut self, - tool_id: String, - event: ModuleEvent, - ) -> Task { + +pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task { // Commands use `start_allowed`; other events need a drawing // and stay blocked on the Start page (#299, #388, #389). if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) { @@ -1132,8 +148,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].properties = PropertiesPanel::empty(); - self.command_line - .push_output(crate::t!("Scene cleared.").as_ref()); + self.command_line.push_output(crate::t!("Scene cleared.").as_ref()); } ModuleEvent::SetVisualStyle(name) => { use crate::modules::view::visual_style; @@ -1142,8 +157,9 @@ impl OpenCADStudio { None => { // Name the styles that do exist, from the same // list every other caller reads. - self.command_line - .push_error(crate::tf!("Unknown visual style \"{name}\".").as_ref()); + self.command_line.push_error( + crate::tf!("Unknown visual style \"{name}\".").as_ref(), + ); self.command_line .push_info(crate::t!(visual_style::keyword_prompt()).as_ref()); } @@ -1160,7 +176,8 @@ impl OpenCADStudio { } => { return Task::perform( async move { - let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + let exts: Vec<&str> = + extensions.iter().map(|s| s.as_str()).collect(); let path = crate::sys::file_dialog() .set_title(title) .add_filter(filter_name, &exts) @@ -1190,7 +207,8 @@ impl OpenCADStudio { let _ = std::fs::remove_file(self.autosave_target(idx)); if self.tabs.len() == 1 { self.tab_counter += 1; - self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.tabs[0] = + crate::app::document::DocumentTab::new_drawing(self.tab_counter); self.active_tab = 0; self.apply_display_defaults(0); } else { @@ -1231,11 +249,16 @@ impl OpenCADStudio { return Task::none(); }; let (idx, continuation) = match pending { - crate::app::PendingClose::Tab(idx) => (idx, crate::app::SaveContinuation::CloseTab), + crate::app::PendingClose::Tab(idx) => { + (idx, crate::app::SaveContinuation::CloseTab) + } crate::app::PendingClose::Quit => { let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else { self.pending_close = None; - return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]); + return Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]); }; (idx, crate::app::SaveContinuation::Quit) } @@ -1291,7 +314,10 @@ impl OpenCADStudio { let save = self.save_with_default_format(idx); Task::batch([close, save]) } else { - Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]) + Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]) } } None => Task::none(), @@ -1366,10 +392,7 @@ impl OpenCADStudio { .iter() .filter_map(|h| doc.get_entity(*h)) .filter(|e| { - !matches!( - e, - acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) - ) + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) }) .cloned() .collect() @@ -1382,10 +405,7 @@ impl OpenCADStudio { o == model_br || o.is_null() }) .filter(|e| { - !matches!( - e, - acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) - ) + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) }) .cloned() .collect() @@ -1479,10 +499,7 @@ impl OpenCADStudio { Ok(name) } - pub(super) fn on_block_palette( - &mut self, - m: crate::ui::window::block_palette::BlockPaletteMsg, - ) -> iced::Task { + pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; match m { BlockPaletteMsg::Search(s) => { @@ -1504,10 +521,7 @@ impl OpenCADStudio { async { let handle = rfd::AsyncFileDialog::new() .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter( - crate::t!("DWG/DXF Files").as_ref(), - &["dwg", "dxf", "DWG", "DXF"], - ) + .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) .pick_file() .await; match handle { @@ -1526,8 +540,7 @@ impl OpenCADStudio { self.start_block_placement(&name); } Err(e) if e != "Cancelled" => { - self.command_line - .push_error(&crate::tf!("INSERT FILE: {e}")); + self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); } Err(_) => {} } @@ -1599,10 +612,7 @@ 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.graphic_attributes.close_popups(); self.save_config(); } } @@ -1643,12 +653,7 @@ impl OpenCADStudio { // produced a one-time jump plus a stuck drag. Column moves // arrive via Message::XrefColMove only. if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab] - .scene - .selection - .borrow() - .vp_size - .1; + let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; let side = if point.x < self.win_size.0 * 0.5 { DockSide::Left } else { @@ -1860,19 +865,13 @@ impl OpenCADStudio { .entities() .filter_map(|entity| match entity { acadrust::EntityType::RasterImage(image) - if image - .definition_handle - .is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => + if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => { Some(entity.common().handle) } acadrust::EntityType::Underlay(underlay) - if matches!( - underlay.underlay_type, - acadrust::entities::UnderlayType::Pdf - ) && self.tabs[i] - .xref_unloaded - .is_unloaded(underlay.definition_handle.value()) => + if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf) + && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => { Some(entity.common().handle) } @@ -1891,14 +890,12 @@ impl OpenCADStudio { /// selection. Same engine fns as the CLI arms, batched with per-item /// report lines in the CLI wording; finishes with [`post_ref_op`] plus /// a palette rescan (mirror of the CLI post-op sequence). - pub(crate) fn xref_manager_op(&mut self, op: crate::ui::window::xref_manager::XrefPaletteOp) { + pub(crate) fn xref_manager_op( + &mut self, + op: crate::ui::window::xref_manager::XrefPaletteOp, + ) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error( - crate::t!( - "Reference changes are not available on web — the reference list is read-only." - ) - .as_ref(), - ); + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); return; } use crate::ui::window::xref_manager::XrefPaletteOp; @@ -1911,10 +908,14 @@ impl OpenCADStudio { idx.sort_unstable(); idx.iter() .filter_map(|idx| { - self.xref_manager - .entries - .get(*idx) - .map(|e| (e.key, e.name.clone(), e.kind, e.parent_key.is_some())) + self.xref_manager.entries.get(*idx).map(|e| { + ( + e.key, + e.name.clone(), + e.kind, + e.parent_key.is_some(), + ) + }) }) .collect() }; @@ -1929,9 +930,8 @@ impl OpenCADStudio { ); let host: Option = self.tabs[i].current_path.clone(); if needs_host && host.is_none() { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; } let label = match op { @@ -1956,11 +956,15 @@ impl OpenCADStudio { ).as_ref()); continue; } - match crate::io::xref::detach_reference(&mut self.tabs[i].scene.document, *key) - { + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + *key, + ) { Ok(name) => { - self.command_line - .push_output(crate::tf!("XREF: detached \"{}\".", name).as_ref()); + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); self.tabs[i].xref_unloaded.remove(key); self.tabs[i].xref_stat_cache.remove(key); done += 1; @@ -1978,11 +982,15 @@ impl OpenCADStudio { ).as_ref()); continue; } - match crate::io::xref::unload_reference(&mut self.tabs[i].scene.document, *key) - { + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + *key, + ) { Ok(name) => { - self.command_line - .push_output(crate::tf!("XREF: unloaded \"{}\".", name).as_ref()); + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); self.tabs[i].xref_unloaded.add(*key); done += 1; } @@ -2016,10 +1024,10 @@ impl OpenCADStudio { name ).as_ref()); } else if *kind != crate::io::xref_model::RefKind::DwgXref { - self.command_line.push_error( - crate::tf!("{}: reload applies to drawing references only.", name) - .as_ref(), - ); + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + name + ).as_ref()); } } for (key, _) in &dwg_picked { @@ -2059,14 +1067,18 @@ impl OpenCADStudio { } } for (_, name) in &dwg_picked { - match infos.iter().find(|n| n.name.eq_ignore_ascii_case(name)) { + match infos + .iter() + .find(|n| n.name.eq_ignore_ascii_case(name)) + { Some(info) => { self.report_xref_status(info); done += 1; } - None => self.command_line.push_error( - crate::tf!("XREF: no references match '{}'.", name).as_ref(), - ), + None => self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + name + ).as_ref()), } } } @@ -2085,9 +1097,10 @@ impl OpenCADStudio { crate::io::xref_model::RefType::Overlay, ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: \"{}\" set to Overlay.", name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2109,9 +1122,10 @@ impl OpenCADStudio { crate::io::xref_model::RefType::Attach, ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: \"{}\" set to Attach.", name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Attach.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2129,13 +1143,10 @@ impl OpenCADStudio { .unwrap_or_else(|| std::path::PathBuf::from(".")); for (key, name, _, is_nested) in &picked { if *is_nested { - self.command_line.push_error( - crate::tf!( + self.command_line.push_error(crate::tf!( "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", name - ) - .as_ref(), - ); + ).as_ref()); continue; } match crate::io::xref::bind_reference( @@ -2146,9 +1157,10 @@ impl OpenCADStudio { ) { Ok(outcome) => { if outcome.unremapped == 0 { - self.command_line.push_output( - crate::tf!("XREF: bound \"{}\".", outcome.name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); } else { self.command_line.push_output(crate::tf!( "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", @@ -2180,10 +1192,10 @@ impl OpenCADStudio { &host, ) { Ok(_) => { - self.command_line.push_output( - crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) - .as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2205,25 +1217,18 @@ impl OpenCADStudio { /// full resolve, per-ref report, stat baselines refreshed. pub(crate) fn xref_manager_reload_all(&mut self) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error( - crate::t!( - "Reference changes are not available on web — the reference list is read-only." - ) - .as_ref(), - ); + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); return; } let i = self.active_tab; let Some(path) = self.tabs[i].current_path.clone() else { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; }; let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; }; let reload_keys: Vec = crate::io::xref::collect_entries_with_prev( @@ -2278,594 +1283,6 @@ mod tests { app } - #[test] - fn graphic_attributes_creates_one_associative_fill_per_object() { - use crate::ui::window::graphic_attributes::GraphicAttribute; - use acadrust::entities::LwPolyline; - use acadrust::types::Vector2; - - let mut app = fresh(); - let i = app.active_tab; - let mut boundaries = Vec::new(); - // Coincident boundaries must still own independent associations. - for offset in [0.0, 0.0] { - let mut polyline = LwPolyline::new(); - for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { - polyline.add_point(Vector2::new(x + offset, y)); - } - polyline.close(); - let handle = app.tabs[i] - .scene - .add_entity(EntityType::LwPolyline(polyline)); - app.tabs[i].scene.select_entity(handle, false); - boundaries.push(handle); - } - - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && hatch.is_associative)); - assert!(hatches.iter().all(|hatch| hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .count() - == 1)); - - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(boundaries[0], false); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.gradient_color.enabled) - .count(), - 1 - ); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) - .count(), - 1 - ); - - // An invalid boundary is skipped while valid selected boundaries are - // still updated. - let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); - app.tabs[i].scene.select_entity(line, false); - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); - assert!(hatches.iter().all(|hatch| hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .all(|handle| *handle != line))); - - // Editing a common gradient updates each object's independent hatch - // and records the whole operation as one undo step. - app.tabs[i].scene.deselect_all(); - for handle in &boundaries { - app.tabs[i].scene.select_entity(*handle, false); - } - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - app.open_gradient_editor(); - let editor = app.gradient_editor.as_mut().expect("gradient editor"); - assert_eq!(editor.handles.len(), 2); - editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; - editor.inverted = true; - editor.angle = "405°".into(); - editor.centered = false; - let undo_before = app.tabs[i].history.undo_stack.len(); - let _ = app.apply_gradient_editor(); - assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); - - let gradients: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - hatch.gradient_color.enabled.then_some(hatch) - }) - .collect(); - assert_eq!(gradients.len(), 2); - assert!(gradients.iter().all(|hatch| { - hatch.gradient_color.name == "INVCURVED" - && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 - && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 - })); - - // A selection change closes the flyout and it remains closed until - // the user explicitly opens it again. - app.open_gradient_editor(); - assert!(app.gradient_editor.is_some()); - app.tabs[i].scene.deselect_all(); - let _ = app.update(Message::CloseGraphicAttributeDropdown); - assert!(app.gradient_editor.is_none()); - - let selected_hatch = app.tabs[i] - .scene - .document - .entities() - .find_map(|entity| { - matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) - }) - .expect("gradient hatch"); - let associated_lines = crate::ui::window::graphic_attributes::line_handles( - &app.tabs[i].scene.document, - &[selected_hatch], - ); - assert_eq!(associated_lines.len(), 1); - assert!(boundaries.contains(&associated_lines[0])); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); - - // A directly selected hatch is edited in place instead of being - // rejected as an unsupported boundary object. - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Solid, - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("transparent solid hatch") - .common() - .transparency - .as_percent() - - 0.35) - .abs() - < 0.01 - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("ByLayer solid hatch") - .common() - .transparency - .is_by_layer()); - - // Line controls ignore HATCH entities, while fill controls ignore - // ordinary geometry in the same mixed selection. - app.tabs[i].scene.select_entity(line, false); - let _ = app.on_line_color(acadrust::types::Color::Index(4)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(5), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); - let _ = app.on_line_linetype("ByBlock".to_string()); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype, - "ByBlock", - ); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - assert_ne!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - let _ = app.update(Message::LineLinetypeScaleInput("2.5".to_string())); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale - - 2.5) - .abs() - < f64::EPSILON - ); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .linetype_scale - - 1.0) - .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] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale, - 1.2 - ); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .transparency - .is_by_layer()); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - let pattern_name = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| { - matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Pattern(_) - ) - }) - .expect("pattern hatch") - .name - .clone(); - let _ = app.update(Message::GraphicHatchPatternChanged(pattern_name.clone())); - let selected_pattern = match app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("pattern hatch") - { - EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), - _ => panic!("fill target is not a hatch"), - }; - assert_eq!(selected_pattern, pattern_name); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); - - let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Hatch, - ); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); - app.open_gradient_editor(); - assert!(app.gradient_editor.is_some()); - let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); - assert!(app.gradient_editor.is_none()); - let _ = app.update(Message::HatchEditorOpen); - assert!(app.hatch_editor_handles.is_some()); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - assert!(app.hatch_editor_handles.is_none()); - assert!(app.gradient_editor.is_none()); - - let undo_before = app.tabs[i].history.undo_stack.len(); - let _ = app.on_graphic_attribute(GraphicAttribute::None); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .is_none()); - assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); - } - - #[test] - fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { - use crate::ui::window::graphic_attributes::GraphicAttribute; - use acadrust::entities::LwPolyline; - use acadrust::types::Vector2; - - let mut app = fresh(); - let i = app.active_tab; - let mut polyline = LwPolyline::new(); - for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { - polyline.add_point(Vector2::new(x, y)); - } - let boundary = app.tabs[i] - .scene - .add_entity(EntityType::LwPolyline(polyline)); - app.tabs[i].scene.select_entity(boundary, false); - - let _ = app.update(Message::GraphicAttributeChanged(GraphicAttribute::Solid)); - assert!(app.pending_graphic_fill_close.is_some()); - assert!(!app.tabs[i].scene.document.entities().any(|entity| { - matches!(entity, EntityType::Hatch(_)) - })); - - let _ = app.update(Message::CommandOptionPick("Y".to_string())); - assert!(app.pending_graphic_fill_close.is_none()); - assert!(matches!( - app.tabs[i].scene.document.get_entity(boundary), - Some(EntityType::LwPolyline(polyline)) if polyline.is_closed - )); - assert!(app.tabs[i].scene.document.entities().any(|entity| { - matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) - })); - } - - #[test] - fn graphic_line_controls_update_creation_defaults_without_a_selection() { - let mut app = fresh(); - let i = app.active_tab; - app.tabs[i].scene.deselect_all(); - - let _ = app.on_line_color(acadrust::types::Color::Index(2)); - let _ = app.on_line_linetype("Continuous".to_string()); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); - let _ = app.on_line_linetype_scale(2.5); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); - - let header = &app.tabs[i].scene.document.header; - assert_eq!( - header.current_entity_color, - acadrust::types::Color::Index(2) - ); - assert_eq!(header.current_linetype_name, "Continuous"); - assert_eq!(header.current_line_weight, 35); - assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); - assert!( - (app.tabs[i] - .scene - .document - .current_entity_transparency() - .as_percent() - - 0.3) - .abs() - < 0.01 - ); - } - - #[test] - fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { - let mut app = fresh(); - let i = app.active_tab; - { - let layer = app.tabs[i] - .scene - .document - .layers - .get_mut("0") - .expect("default layer"); - layer.color = acadrust::types::Color::Index(3); - layer.line_type = "Continuous".to_string(); - layer.line_weight = acadrust::types::LineWeight::Value(50); - layer.transparency = acadrust::types::Transparency::from_percent(0.25); - } - let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); - app.tabs[i].scene.select_entity(handle, false); - - let _ = app.on_graphic_attributes_set_all(false); - let common = app.tabs[i] - .scene - .document - .get_entity(handle) - .expect("line") - .common(); - assert_eq!(common.color, acadrust::types::Color::ByBlock); - assert_eq!(common.linetype, "ByBlock"); - assert_eq!(common.line_weight, acadrust::types::LineWeight::ByBlock); - assert!(common.transparency.is_by_block()); - - let _ = app.on_graphic_attributes_set_all(true); - let _ = app.on_graphic_attributes_remove_references(); - let common = app.tabs[i] - .scene - .document - .get_entity(handle) - .expect("line") - .common(); - assert_eq!(common.color, acadrust::types::Color::Index(3)); - assert_eq!(common.linetype, "Continuous"); - assert_eq!(common.line_weight, acadrust::types::LineWeight::Value(50)); - assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); - } - - #[test] - fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { - let mut app = fresh(); - let i = app.active_tab; - app.tabs[i].scene.deselect_all(); - let _ = app.on_line_color(acadrust::types::Color::Index(2)); - let _ = app.on_line_linetype("Continuous".to_string()); - 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] - .active_cmd - .as_ref() - .map(|command| command.name()), - Some("GRAPHICATTRIBUTELAYER") - ); - let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); - let _ = app.update(Message::CommandFinalize); - - let layer = app.tabs[i] - .scene - .document - .layers - .get("Graphic Settings") - .expect("created layer"); - assert_eq!(layer.color, acadrust::types::Color::Index(2)); - assert_eq!(layer.line_type, "Continuous"); - assert_eq!(layer.line_weight, acadrust::types::LineWeight::Value(35)); - assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); - } - /// A foreign document: the fresh scene's document plus one model-space LINE. fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { let mut doc = app.tabs[app.active_tab].scene.document.clone(); @@ -2893,9 +1310,7 @@ mod tests { fn import_document_as_block_defines_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app - .import_document_as_block(doc, "Fixture".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); assert_eq!(name, "Fixture"); assert!(app.tabs[app.active_tab] .scene @@ -2990,11 +1405,12 @@ mod tests { } /// The nested-INSERT's block name inside a defined block record. - fn nested_insert_target(doc: &acadrust::CadDocument, block: &str) -> Option { + fn nested_insert_target( + doc: &acadrust::CadDocument, + block: &str, + ) -> Option { let br = doc.block_records.get(block)?; - br.entity_handles - .iter() - .find_map(|h| match doc.get_entity(*h)? { + br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }) @@ -3074,9 +1490,7 @@ mod tests { ) .unwrap(); - let _ = app - .import_document_as_block(doc, "Imported".to_string()) - .unwrap(); + let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); let doc = &app.tabs[i].scene.document; // The file's own "Door (2)" is kept intact and targets the file's // renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source). @@ -3123,9 +1537,7 @@ mod tests { ) .unwrap(); - let name = app - .import_document_as_block(doc, "Imported".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); assert_eq!(name, "Imported"); let doc = &app.tabs[i].scene.document; @@ -3156,16 +1568,11 @@ mod tests { fn blockpalette_refresh_lists_and_places_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app - .import_document_as_block(doc, "Fixture".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); app.refresh_block_palette(); assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); - let cmd = app.tabs[app.active_tab] - .active_cmd - .as_ref() - .expect("INSERT running"); + let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); assert_eq!(cmd.name(), "INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } @@ -3221,10 +1628,7 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); assert!(app.dock.auto_collapse(id), "pin enables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!( - !app.dock.auto_collapse(id), - "second pin disables auto-collapse" - ); + assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } @@ -3242,9 +1646,7 @@ mod tests { ); let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 100.0, - ))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -3267,13 +1669,22 @@ mod tests { // Left: block palette hidden, properties shown -> 1 visible panel. app.show_block_palette = false; app.show_properties = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 1 + ); // Reveal the block palette -> both count. app.show_block_palette = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 2 + ); // A hidden (closed) panel counts for nothing even when stacked. app.show_block_palette = false; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 1 + ); } #[test] @@ -3297,9 +1708,8 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); // Pointer near the bottom of the left edge: one visible panel means a // single slot, so every y maps to index 0 (no top/bottom split). - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); + let _ = + app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -3323,9 +1733,8 @@ mod tests { app.win_size = (1600.0, 900.0).into(); let id = crate::ui::dock::PanelId::Properties; let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); + let _ = + app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 2)) @@ -3350,11 +1759,7 @@ mod tests { line.end = Vector3::new(1.0, 0.0, 0.0); app.tabs[i] .scene - .define_block_from_owned_entities( - vec![EntityType::Line(line)], - "Chair", - glam::DVec3::ZERO, - ) + .define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO) .unwrap(); assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); @@ -3383,7 +1788,12 @@ mod tests { br.flags.is_xref = true; br.xref_path = "refs/plan.dwg".to_string(); br.handle = app.tabs[i].scene.document.allocate_handle(); - app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i] + .scene + .document + .block_records + .add(br) + .unwrap(); app.tabs[i].current_path = Some(old_dir.join("host.dwg")); let tab_id = app.tabs[i].id; let job_id = 4242u64; @@ -3444,9 +1854,9 @@ mod tests { // Behavioral matrix: every row op on a NESTED row via the GUI // message path. Nested rows must report per-entry errors (CLI // wording), never silently skip. + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let dir = palette_tmpdir("nestedmatrix"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -3464,12 +1874,7 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "INNER") - .expect("nested INNER listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); for op in [ XrefPaletteOp::Detach, @@ -3481,17 +1886,9 @@ mod tests { let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, op)); let out = palette_output(&app, start); + assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}"); assert!( - out.contains("INNER"), - "op {op:?} on nested row gave: {out:?}" - ); - assert!( - app.tabs[i] - .scene - .document - .block_records - .get("HOST") - .is_some(), + app.tabs[i].scene.document.block_records.get("HOST").is_some(), "op {op:?} must not mutate the direct reference" ); } @@ -3502,9 +1899,9 @@ mod tests { fn palette_detach_unload_rowop_gui_path() { // Reproduction for "Detach/Unload from the palette do nothing": // drive the exact GUI message path (row right-click menu item). + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; for name in ["PLAN", "SITE"] { @@ -3516,42 +1913,19 @@ mod tests { } app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "PLAN") - .expect("PLAN listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "detach output missing, got: {out:?}"); - assert!( - app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .is_none(), - "PLAN definition must be gone" - ); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "SITE") - .expect("SITE listed"); + assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload)); let out = palette_output(&app, start); assert!(out.contains("SITE"), "unload output missing, got: {out:?}"); assert_eq!( - app.xref_manager - .entries - .iter() - .find(|e| e.name == "SITE") - .unwrap() - .status, + app.xref_manager.entries.iter().find(|e| e.name == "SITE").unwrap().status, crate::io::xref_model::RefStatus::Unloaded ); } @@ -3560,10 +1934,10 @@ mod tests { fn palette_overlay_and_pathtype_rowop_gui_path() { // Row-menu Overlay + Change-Path row ops on a direct row via the // GUI message path: type flag flips, saved path clears. + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::io::xref_model::{Pathtype, RefType}; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; let mut br = BlockRecord::new("PLAN"); @@ -3573,49 +1947,20 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "PLAN") - .expect("PLAN listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .unwrap(); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); assert!(br.flags.is_xref_overlay && !br.flags.is_xref); - assert_eq!( - app.xref_manager - .entries - .iter() - .find(|e| e.name == "PLAN") - .unwrap() - .ref_type, - RefType::Overlay - ); + assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay); let start = app.command_line.history.len(); - let _ = app.update(Message::XrefRowOp( - idx, - XrefPaletteOp::Pathtype(Pathtype::None), - )); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None))); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .unwrap(); - assert_eq!( - br.xref_path, "plan.dwg", - "Remove Path strips to the bare filename" - ); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); + assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename"); } #[test] @@ -3638,21 +1983,9 @@ mod tests { let start = app.command_line.history.len(); app.xref_manager_reload_all(); let out = palette_output(&app, start); - assert!( - out.contains("PLAN"), - "reload-all must report the reference, got: {out:?}" - ); - let entry = app - .xref_manager - .entries - .iter() - .find(|e| e.name == "PLAN") - .expect("PLAN listed"); - assert_eq!( - entry.status, - crate::io::xref_model::RefStatus::Loaded, - "PLAN must resolve Loaded" - ); + assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}"); + let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed"); + assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded"); std::fs::remove_dir_all(&dir).ok(); } @@ -3670,8 +2003,8 @@ mod tests { fn palette_unload_nested_reports_per_entry() { // F6: palette Unload on a nested row reports the per-entry nested // error (CLI wording) instead of the confusing 'no loaded reference'. - use crate::ui::window::xref_manager::XrefPaletteOp; use acadrust::tables::BlockRecord; + use crate::ui::window::xref_manager::XrefPaletteOp; let dir = palette_tmpdir("nested"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -3689,12 +2022,7 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "INNER") - .expect("nested INNER listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); app.xref_manager.selected.insert(idx); let start = app.command_line.history.len(); @@ -3710,20 +2038,16 @@ mod tests { // F7: palette Reload on an image/PDF row reports the drawing-only // error and leaves its unloaded flag untouched (no spurious // no-match after a flag clear). + use acadrust::objects::{ImageDefinition, ObjectType}; use crate::io::xref_model::RefKind; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::objects::{ImageDefinition, ObjectType}; let dir = palette_tmpdir("imgreload"); let mut app = fresh(); let i = app.active_tab; let h = app.tabs[i].scene.document.allocate_handle(); let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); def.handle = h; - app.tabs[i] - .scene - .document - .objects - .insert(h, ObjectType::ImageDefinition(def)); + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); let mut img = acadrust::entities::RasterImage::new( "img.png", acadrust::types::Vector3::ZERO, @@ -3731,19 +2055,10 @@ mod tests { 8.0, ); img.definition_handle = Some(h); - app.tabs[i] - .scene - .document - .add_entity(acadrust::EntityType::RasterImage(img)) - .unwrap(); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); app.tabs[i].current_path = Some(dir.join("host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.kind == RefKind::Image) - .expect("image listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed"); let key = app.xref_manager.entries[idx].key; app.xref_manager.selected.insert(idx); app.tabs[i].xref_unloaded.add(key); @@ -3752,10 +2067,7 @@ mod tests { let out = palette_output(&app, start); assert!(out.contains("img.png"), "got: {out:?}"); assert_eq!(app.command_line.history.len(), start + 1); - assert!( - app.tabs[i].xref_unloaded.is_unloaded(key), - "image flag must stay untouched" - ); + assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched"); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs new file mode 100644 index 000000000..52fb76ace --- /dev/null +++ b/src/app/update/graphic_attributes.rs @@ -0,0 +1,1693 @@ +//! Graphic Attributes palette: applies line attributes and associative fills +//! to the selection, or to the creation defaults when nothing is selected. + +use crate::app::{Message, OpenCADStudio}; +use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; +use crate::ui::window::gradient_editor::{GradientColorMode, GradientEditorState, GradientMsg}; +use crate::ui::window::graphic_attributes::{ + self as palette, GraphicAttribute, GraphicAttributesMsg, HatchEditorState, PendingFillClose, +}; +use acadrust::types::{Color as AcadColor, LineWeight, Transparency}; +use acadrust::{EntityType, Handle}; +use iced::Task; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Which objects of the selection a palette control edits. +#[derive(Clone, Copy)] +enum Part { + /// Non-HATCH objects and the boundaries of selected associative fills. + Line, + /// Selected HATCH entities and the fills associated with selected objects. + Fill, + Both, +} + +/// The first line pattern of the catalogue, used when a fill becomes a hatch. +fn default_hatch_pattern() -> Option<&'static crate::scene::model::hatch_patterns::PatternEntry> { + crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) +} + +/// Pattern name and render pattern of a new fill; `None` for no fill. +fn fill_pattern(value: GraphicAttribute) -> Option<(String, HatchPattern)> { + match value { + GraphicAttribute::Solid => Some(("SOLID".into(), HatchPattern::Solid)), + GraphicAttribute::Hatch => { + default_hatch_pattern().map(|entry| (entry.name.clone(), entry.gpu.clone())) + } + GraphicAttribute::Gradient => Some(( + "LINEAR".into(), + HatchPattern::Gradient { + angle_deg: 0.0, + color2: [0.18, 0.18, 0.18, 0.0], + kind: GradientKind::Linear, + invert: false, + shift: 0.0, + }, + )), + GraphicAttribute::None | GraphicAttribute::Varies => None, + } +} + +/// Convert a selected HATCH to another fill kind in place. +fn set_hatch_graphic_attribute( + hatch: &mut acadrust::entities::Hatch, + value: GraphicAttribute, + visible_color: [f32; 4], +) { + match value { + GraphicAttribute::Solid => { + hatch.pattern = acadrust::entities::HatchPattern::new("SOLID"); + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = true; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Hatch => { + let Some(entry) = default_hatch_pattern() else { + return; + }; + let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + hatch.pattern_scale.max(1.0e-6), + ); + crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + hatch.pattern = pattern; + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = false; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Gradient => { + let channel = |value: f32| (value * 255.0).round().clamp(0.0, 255.0) as u8; + hatch.is_solid = true; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.angle = 0.0; + hatch.pattern_angle = 0.0; + hatch.gradient_color.shift = 0.0; + hatch.gradient_color.is_single_color = false; + hatch.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: AcadColor::Rgb { + r: channel(visible_color[0]), + g: channel(visible_color[1]), + b: channel(visible_color[2]), + }, + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: AcadColor::Rgb { + r: 46, + g: 46, + b: 46, + }, + }, + ]; + } + GraphicAttribute::None | GraphicAttribute::Varies => {} + } +} + +fn is_open_fill_boundary(entity: &EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) => !polyline.is_closed && polyline.vertices.len() >= 3, + EntityType::Polyline(polyline) => { + !polyline.flags.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline2D(polyline) => { + !polyline.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline3D(polyline) => { + !polyline.flags.closed && polyline.vertices.len() >= 3 + } + _ => false, + } +} + +fn close_fill_boundary(entity: &mut EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) if !polyline.is_closed => { + polyline.is_closed = true; + true + } + EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline2D(polyline) if !polyline.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline3D(polyline) if !polyline.flags.closed => { + polyline.flags.closed = true; + true + } + _ => false, + } +} + +/// Line attributes with ByLayer / ByBlock resolved to the values they show. +struct ConcreteGraphicAttributes { + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, +} + +fn concrete_graphic_attributes( + color: AcadColor, + linetype: &str, + lineweight: LineWeight, + transparency: Transparency, + layer: Option<&acadrust::tables::Layer>, +) -> ConcreteGraphicAttributes { + let layer_color = layer.map_or(AcadColor::Index(7), |layer| match layer.color { + AcadColor::ByLayer | AcadColor::ByBlock => AcadColor::Index(7), + color => color, + }); + let layer_linetype = layer + .map(|layer| layer.line_type.as_str()) + .filter(|value| { + !value.is_empty() + && !value.eq_ignore_ascii_case("ByLayer") + && !value.eq_ignore_ascii_case("ByBlock") + }) + .unwrap_or("Continuous"); + let layer_lineweight = layer.map_or(LineWeight::Default, |layer| match layer.line_weight { + LineWeight::ByLayer | LineWeight::ByBlock => LineWeight::Default, + value => value, + }); + let layer_transparency = layer + .map(|layer| layer.transparency) + .filter(|value| !value.is_by_layer() && !value.is_by_block()) + .unwrap_or_else(|| Transparency::from_percent(0.0)); + + ConcreteGraphicAttributes { + color: match color { + AcadColor::ByLayer => layer_color, + AcadColor::ByBlock => AcadColor::Index(7), + value => value, + }, + linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { + layer_linetype.to_owned() + } else if linetype.eq_ignore_ascii_case("ByBlock") { + "Continuous".to_owned() + } else { + linetype.to_owned() + }, + lineweight: match lineweight { + LineWeight::ByLayer => layer_lineweight, + LineWeight::ByBlock => LineWeight::Default, + value => value, + }, + transparency: if transparency.is_by_layer() { + layer_transparency + } else if transparency.is_by_block() { + Transparency::from_percent(0.0) + } else { + transparency + }, + } +} + +impl OpenCADStudio { + pub(super) fn on_graphic_attributes(&mut self, message: GraphicAttributesMsg) -> Task { + use GraphicAttributesMsg as M; + if !matches!( + message, + M::ToggleMenu(_) | M::HatchPatternSearch(_) | M::HatchPatternFocus(_) | M::Gradient(_) + ) { + self.graphic_attributes.open_menu = None; + } + match message { + M::ToggleMenu(menu) => { + let state = &mut self.graphic_attributes; + state.open_menu = (state.open_menu != Some(menu)).then_some(menu); + Task::none() + } + M::CloseMenu => Task::none(), + M::SetAllByLayer => self.on_graphic_attributes_set_all(true), + M::SetAllByBlock => self.on_graphic_attributes_set_all(false), + M::RemoveReferences => self.on_graphic_attributes_remove_references(), + M::CreateLayer => { + use crate::command::CadCommand; + let command = crate::command::FreeTextValuePromptCommand::new( + "GRAPHICATTRIBUTELAYER", + "Enter a name for the new layer:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } + M::Fill(value) => self.on_graphic_attribute(value), + M::LineColor(color) => self.on_line_color(color), + M::LineLinetype(linetype) => self.on_line_linetype(linetype), + M::LineLineweight(lineweight) => self.on_line_lineweight(lineweight), + M::LineLinetypeScale(scale) => self.on_line_linetype_scale(scale), + M::LineTransparency(transparency) => self.on_line_transparency(transparency), + M::FillColor(color) => self.on_solid_fill_color(color), + M::FillTransparency(transparency) => self.on_fill_transparency(transparency), + M::HatchEditorOpen => self.open_hatch_editor(), + M::HatchEditorClose => { + self.graphic_attributes.hatch_editor = None; + Task::none() + } + M::HatchPatternSearch(search) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + editor.search = search; + editor.focus = 0; + } + Task::none() + } + M::HatchPatternFocus(index) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + if index < crate::ui::properties::filtered_hatch_patterns(&editor.search).len() + { + editor.focus = index; + } + } + Task::none() + } + M::HatchPattern(name) => self.apply_graphic_hatch_pattern(name), + M::HatchPatternConfirm => { + let name = self.graphic_attributes.hatch_editor.as_ref().and_then(|editor| { + crate::ui::properties::filtered_hatch_patterns(&editor.search) + .get(editor.focus) + .map(|entry| entry.name.clone()) + }); + match name { + Some(name) => self.apply_graphic_hatch_pattern(name), + None => Task::none(), + } + } + M::Gradient(message) => self.on_gradient_editor(message), + } + } + + /// Prompt answers belong to the fill question only while no command runs. + pub(super) fn awaiting_fill_close_answer(&self) -> bool { + self.graphic_attributes.pending_fill_close.is_some() + && self.tabs[self.active_tab].active_cmd.is_none() + } + + /// Answer the "close open boundaries first?" prompt; Enter means Yes. + pub(super) fn on_graphic_fill_close_input(&mut self, input: &str) -> Task { + match input.trim().to_ascii_uppercase().as_str() { + "" | "Y" | "YES" => self.on_graphic_fill_close_response(true), + "N" | "NO" => self.on_graphic_fill_close_response(false), + _ => { + self.command_line.push_error("Enter Yes or No."); + Task::none() + } + } + } + + fn graphic_handles(&self, i: usize, part: Part) -> Vec { + let selected = self.tabs[i].scene.selected_handles_in_order(); + let document = &self.tabs[i].scene.document; + match part { + Part::Line => palette::line_handles(document, &selected), + Part::Fill => palette::fill_handles(document, &selected), + Part::Both => { + let mut seen = FxHashSet::default(); + palette::line_handles(document, &selected) + .into_iter() + .chain(palette::fill_handles(document, &selected)) + .filter(|handle| seen.insert(*handle)) + .collect() + } + } + } + + fn unlocked_graphic_handles(&self, i: usize, part: Part) -> Vec { + let mut handles = self.graphic_handles(i, part); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + handles + } + + /// Edit the unlocked `part` of the selection as one undo step. + fn edit_graphic_part(&mut self, part: Part, label: &str, mut edit: impl FnMut(&mut EntityType)) { + let i = self.active_tab; + let handles = self.unlocked_graphic_handles(i, part); + self.apply_property_op(i, label, &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + edit(entity); + } + }); + } + + fn has_selection(&self) -> bool { + !self.tabs[self.active_tab].scene.selected_handles_in_order().is_empty() + } + + fn current_graphic_attributes(&self, i: usize) -> ConcreteGraphicAttributes { + let document = &self.tabs[i].scene.document; + let handles = self.graphic_handles(i, Part::Both); + let draw_depth = self.tabs[i].scene.draw_depth_map(); + if let Some(entity) = + palette::top_handle(&handles, &draw_depth).and_then(|handle| document.get_entity(handle)) + { + let common = entity.common(); + return concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ); + } + + let header = &document.header; + let layer_name = if header.current_layer_name.is_empty() { + self.tabs[i].active_layer.as_str() + } else { + header.current_layer_name.as_str() + }; + concrete_graphic_attributes( + header.current_entity_color, + &header.current_linetype_name, + LineWeight::from_value(header.current_line_weight), + document.current_entity_transparency(), + document.layers.get(layer_name), + ) + } + + /// Store line attributes as the creation defaults (CECOLOR, CELTYPE, + /// CELWEIGHT, CETRANSPARENCY). + fn set_current_graphic_attributes( + &mut self, + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, + ) { + let i = self.active_tab; + let linetype_handle = self.tabs[i] + .scene + .document + .line_types + .iter() + .find(|line_type| line_type.name.eq_ignore_ascii_case(&linetype)) + .map_or(Handle::NULL, |line_type| line_type.handle); + let header = &mut self.tabs[i].scene.document.header; + header.current_entity_color = color; + header.current_linetype_name = linetype; + header.current_linetype_handle = linetype_handle; + header.current_line_weight = lineweight.value(); + self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency); + self.tabs[i].dirty = true; + self.sync_ribbon_from_selection(); + self.refresh_properties(); + } + + pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> Task { + let (color, linetype, lineweight, transparency) = if by_layer { + (AcadColor::ByLayer, "ByLayer", LineWeight::ByLayer, Transparency::BY_LAYER) + } else { + (AcadColor::ByBlock, "ByBlock", LineWeight::ByBlock, Transparency::BY_BLOCK) + }; + if !self.has_selection() { + self.set_current_graphic_attributes(color, linetype.to_owned(), lineweight, transparency); + return Task::none(); + } + self.edit_graphic_part(Part::Both, "Graphic Attributes", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_graphic_attributes_remove_references(&mut self) -> Task { + let i = self.active_tab; + if !self.has_selection() { + let values = self.current_graphic_attributes(i); + self.set_current_graphic_attributes( + values.color, + values.linetype, + values.lineweight, + values.transparency, + ); + return Task::none(); + } + + let document = &self.tabs[i].scene.document; + let values: FxHashMap<_, _> = self + .unlocked_graphic_handles(i, Part::Both) + .into_iter() + .filter_map(|handle| { + let common = document.get_entity(handle)?.common(); + Some(( + handle, + concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ), + )) + }) + .collect(); + let handles: Vec<_> = values.keys().copied().collect(); + self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { + let (Some(values), Some(entity)) = ( + values.get(&handle), + app.tabs[i].scene.document.get_entity_mut(handle), + ) else { + return; + }; + crate::scene::view::dispatch::apply_color(entity, values.color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &values.linetype); + crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); + entity.common_mut().transparency = values.transparency; + }); + Task::none() + } + + pub(crate) fn create_graphic_attributes_layer(&mut self, raw_name: &str) -> Task { + let i = self.active_tab; + let name = raw_name.trim(); + if !crate::scene::valid_block_name(name) { + self.command_line + .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`."); + return Task::none(); + } + if self.tabs[i] + .scene + .document + .layers + .iter() + .any(|layer| layer.name.eq_ignore_ascii_case(name)) + { + self.command_line.push_error( + crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), + ); + return Task::none(); + } + + let values = self.current_graphic_attributes(i); + let layer_name = name.to_owned(); + let undo = self.begin_layer_undo( + i, + "Create layer with active settings", + std::slice::from_ref(&layer_name), + ); + let mut layer = acadrust::tables::Layer::new(name); + layer.handle = self.tabs[i].scene.document.allocate_handle(); + layer.color = values.color; + layer.line_type = values.linetype; + layer.line_weight = values.lineweight; + layer.transparency = values.transparency; + if self.tabs[i].scene.document.layers.add(layer).is_err() { + self.command_line + .push_error("Graphic Attributes: the layer could not be created."); + return Task::none(); + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + self.refresh_layer_panel(); + self.command_line.push_output( + crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") + .as_ref(), + ); + Task::none() + } + + fn open_hatch_editor(&mut self) -> Task { + self.graphic_attributes.gradient_editor = None; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let current = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) => Some(hatch.pattern.name.as_str()), + _ => None, + }); + let focus = current + .and_then(|current| { + crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(current)) + }) + .unwrap_or(0); + self.graphic_attributes.hatch_editor = Some(HatchEditorState { + handles: selected, + search: String::new(), + focus, + }); + iced::widget::operation::focus(iced::widget::Id::new("graphic-hatch-pattern-search")) + } + + fn apply_graphic_hatch_pattern(&mut self, name: String) -> Task { + self.graphic_attributes.hatch_editor = None; + let i = self.active_tab; + let handles = self.graphic_handles(i, Part::Fill); + self.apply_hatch_pattern(i, &handles, name) + } + + fn on_gradient_editor(&mut self, message: GradientMsg) -> Task { + match message { + GradientMsg::Open => { + self.graphic_attributes.hatch_editor = None; + self.open_gradient_editor(); + } + GradientMsg::Cancel => self.graphic_attributes.gradient_editor = None, + GradientMsg::Apply => return self.apply_gradient_editor(), + message => { + if let Some(editor) = &mut self.graphic_attributes.gradient_editor { + editor.update(message); + } + } + } + Task::none() + } + + pub(super) fn open_gradient_editor(&mut self) { + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let gradient = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) if hatch.gradient_color.enabled => Some(hatch), + _ => None, + }); + if let Some(hatch) = gradient { + self.graphic_attributes.gradient_editor = + Some(GradientEditorState::from_hatch(selected, hatch)); + } + } + + pub(super) fn apply_gradient_editor(&mut self) -> Task { + let Some(state) = self.graphic_attributes.gradient_editor.take() else { + return Task::none(); + }; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let targets: Vec<_> = palette::fill_handles(document, &state.handles) + .into_iter() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled) + }) + .collect(); + if targets.is_empty() { + return Task::none(); + } + if targets + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Gradient Fill: the fill is on a locked layer."); + self.graphic_attributes.gradient_editor = Some(state); + return Task::none(); + } + let angle = state.angle_radians(); + let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); + for handle in &targets { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if let EntityType::Hatch(hatch) = &mut entity { + let gradient = &mut hatch.gradient_color; + gradient.name = state.kind.dxf_name(state.inverted).into(); + gradient.angle = angle; + gradient.shift = if state.centered { 0.0 } else { 1.0 }; + gradient.is_single_color = state.color_mode == GradientColorMode::One; + gradient.color_tint = state.shade_tint as f64; + gradient.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: state.color_1, + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: state.color_2, + }, + ]; + hatch.pattern_angle = angle; + } + self.tabs[i].scene.update_entity(entity); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Give every selected object the chosen fill. Open polylines first ask + /// whether they should be closed. + pub(super) fn on_graphic_attribute(&mut self, value: GraphicAttribute) -> Task { + self.graphic_attributes.close_popups(); + self.graphic_attributes.pending_fill_close = None; + if value == GraphicAttribute::Varies { + return Task::none(); + } + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + self.command_line + .push_info(crate::t!("Select one or more closed objects first.").as_ref()); + return Task::none(); + } + if value != GraphicAttribute::None { + let open_boundaries: Vec<_> = selected + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(is_open_fill_boundary) + }) + .collect(); + if !open_boundaries.is_empty() { + self.graphic_attributes.pending_fill_close = Some(PendingFillClose { + fill: value, + open_boundaries, + selection: selected, + }); + return Task::none(); + } + } + self.apply_graphic_fill(i, value, &selected, &[]) + } + + pub(super) fn on_graphic_fill_close_response(&mut self, close: bool) -> Task { + let Some(pending) = self.graphic_attributes.pending_fill_close.take() else { + return Task::none(); + }; + let i = self.active_tab; + let to_close: Vec<_> = if close { + pending + .open_boundaries + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect() + } else { + Vec::new() + }; + self.apply_graphic_fill(i, pending.fill, &pending.selection, &to_close) + } + + /// Close `to_close` and replace the fills of `selected`, as one undo step. + fn apply_graphic_fill( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + to_close: &[Handle], + ) -> Task { + let pending = self.begin_undo(i, "Graphic Attributes", selected.len(), true); + let mut changed = false; + for handle in to_close { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if close_fill_boundary(&mut entity) { + self.tabs[i].scene.update_entity(entity); + changed = true; + } + } + changed |= self.replace_graphic_fills(i, value, selected); + if changed { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Replace the fills of `selected` by one associative fill per object. + /// Returns whether the document changed. + fn replace_graphic_fills( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + ) -> bool { + let document = &self.tabs[i].scene.document; + let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None + { + (palette::fill_handles(document, selected), Vec::new(), Vec::new(), 0) + } else { + let Some((name, pattern)) = fill_pattern(value) else { + self.command_line + .push_error("Graphic Attributes: no hatch pattern is available."); + return false; + }; + let working = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let normal = working.z.normalize_or(glam::DVec3::Z); + let storage = crate::entities::curve::ocs_plane( + acadrust::types::Vector3::new(normal.x, normal.y, normal.z), + working.origin.dot(normal), + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); + let origin = document.hatch_origin(); + let mut new_hatches = Vec::new(); + let mut updated_hatches = Vec::new(); + let mut directly_updated = FxHashSet::default(); + let mut changed_boundaries = FxHashSet::default(); + let mut skipped = 0; + for handle in selected { + let Some(entity) = document.get_entity(*handle) else { + continue; + }; + if let EntityType::Hatch(hatch) = entity { + let mut hatch = hatch.clone(); + let visible_color = + crate::scene::view::render::render_style_for_common_viewport( + document, + &hatch.common, + None, + ) + .0; + set_hatch_graphic_attribute(&mut hatch, value, visible_color); + updated_hatches.push(EntityType::Hatch(hatch)); + directly_updated.insert(*handle); + continue; + } + // Object mode only needs this object's source. Including all + // scene sources can associate coincident, unselected objects. + let object_sources = sources + .get(handle) + .map(|source| (*handle, source.clone())) + .into_iter() + .collect(); + let command = crate::modules::draw::draw::hatch::HatchCommand::new( + Vec::new(), + object_sources, + vec![*handle], + None, + plane, + ) + .with_origin(origin) + .with_pattern(name.clone(), pattern.clone()); + if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { + new_hatches.push(hatch); + changed_boundaries.insert(*handle); + } else if !is_open_fill_boundary(entity) { + // Open boundaries the user chose not to close are expected + // to stay unfilled; anything else is unsupported. + skipped += 1; + } + } + // Replace only fills belonging to boundaries that successfully + // produced a new hatch. Existing fills on skipped objects remain. + let associated: Vec<_> = document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + (hatch.is_associative + && !directly_updated.contains(&entity.common().handle) + && hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .any(|handle| changed_boundaries.contains(handle))) + .then_some(entity.common().handle) + }) + .collect(); + (associated, new_hatches, updated_hatches, skipped) + }; + if associated + .iter() + .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Graphic Attributes: an associated fill is on a locked layer."); + return false; + } + if skipped == 1 { + self.command_line + .push_info("Graphic Attributes: 1 unsupported object was not changed."); + } else if skipped > 1 { + self.command_line.push_info(&format!( + "Graphic Attributes: {skipped} unsupported objects were not changed." + )); + } + if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { + return false; + } + self.tabs[i].scene.erase_entities(&associated); + for entity in updated_hatches { + self.tabs[i].scene.update_entity(entity); + } + let layer = self.tabs[i].active_layer.clone(); + for hatch in new_hatches { + self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + } + true + } + + pub(super) fn on_solid_fill_color(&mut self, color: AcadColor) -> Task { + self.edit_graphic_part(Part::Fill, "Fill color", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_fill_transparency(&mut self, transparency: Transparency) -> Task { + self.edit_graphic_part(Part::Fill, "Fill transparency", |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_line_color(&mut self, color: AcadColor) -> Task { + if !self.has_selection() { + return self.on_ribbon_color_changed(color); + } + self.edit_graphic_part(Part::Line, "Line color", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_line_linetype(&mut self, linetype: String) -> Task { + if !self.has_selection() { + return self.on_ribbon_linetype_changed(linetype); + } + self.edit_graphic_part(Part::Line, "Line linetype", |entity| { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); + }); + Task::none() + } + + pub(super) fn on_line_lineweight(&mut self, lineweight: LineWeight) -> Task { + if !self.has_selection() { + return self.update(Message::RibbonLineweightChanged(lineweight)); + } + self.edit_graphic_part(Part::Line, "Line weight", |entity| { + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + }); + Task::none() + } + + pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> Task { + let Some(scale) = palette::normalized_linetype_scale(scale) else { + return Task::none(); + }; + if !self.has_selection() { + let i = self.active_tab; + self.tabs[i].scene.document.header.current_entity_linetype_scale = scale; + self.tabs[i].dirty = true; + self.refresh_properties(); + return Task::none(); + } + self.edit_graphic_part(Part::Line, "Linetype scale", |entity| { + entity.common_mut().linetype_scale = scale; + }); + Task::none() + } + + pub(super) fn on_line_transparency(&mut self, transparency: Transparency) -> Task { + if !self.has_selection() { + let i = self.active_tab; + if self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } + self.edit_graphic_part(Part::Line, "Line transparency", |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Line, LwPolyline}; + use acadrust::types::Vector2; + + fn ga(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) + } + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn graphic_attributes_creates_one_associative_fill_per_object() { + let mut app = fresh(); + let i = app.active_tab; + let mut boundaries = Vec::new(); + // Coincident boundaries must still own independent associations. + for offset in [0.0, 0.0] { + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x + offset, y)); + } + polyline.close(); + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + boundaries.push(handle); + } + + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && hatch.is_associative)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .count() + == 1)); + + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(boundaries[0], false); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.gradient_color.enabled) + .count(), + 1 + ); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) + .count(), + 1 + ); + + // An invalid boundary is skipped while valid selected boundaries are + // still updated. + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .all(|handle| *handle != line))); + + // Editing a common gradient updates each object's independent hatch + // and records the whole operation as one undo step. + app.tabs[i].scene.deselect_all(); + for handle in &boundaries { + app.tabs[i].scene.select_entity(*handle, false); + } + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + app.open_gradient_editor(); + let editor = app.graphic_attributes.gradient_editor.as_mut().expect("gradient editor"); + assert_eq!(editor.handles.len(), 2); + editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; + editor.inverted = true; + editor.angle = "405°".into(); + editor.centered = false; + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_gradient_editor(); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let gradients: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + hatch.gradient_color.enabled.then_some(hatch) + }) + .collect(); + assert_eq!(gradients.len(), 2); + assert!(gradients.iter().all(|hatch| { + hatch.gradient_color.name == "INVCURVED" + && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 + && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 + })); + + // A selection change closes the flyout and it remains closed until + // the user explicitly opens it again. + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + app.tabs[i].scene.deselect_all(); + let _ = app.update(ga(GraphicAttributesMsg::CloseMenu)); + assert!(app.graphic_attributes.gradient_editor.is_none()); + + let selected_hatch = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) + }) + .expect("gradient hatch"); + let associated_lines = palette::line_handles( + &app.tabs[i].scene.document, + &[selected_hatch], + ); + assert_eq!(associated_lines.len(), 1); + assert!(boundaries.contains(&associated_lines[0])); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + + // A directly selected hatch is edited in place instead of being + // rejected as an unsupported boundary object. + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Solid, + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("transparent solid hatch") + .common() + .transparency + .as_percent() + - 0.35) + .abs() + < 0.01 + ); + let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("ByLayer solid hatch") + .common() + .transparency + .is_by_layer()); + + // Line controls ignore HATCH entities, while fill controls ignore + // ordinary geometry in the same mixed selection. + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_line_color(acadrust::types::Color::Index(4)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(5), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + let _ = app.on_line_linetype("ByBlock".to_string()); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype, + "ByBlock", + ); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + assert_ne!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + let _ = app.on_line_linetype_scale(2.5); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale + - 2.5) + .abs() + < f64::EPSILON + ); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .linetype_scale + - 1.0) + .abs() + < f64::EPSILON + ); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN))); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 2.5 + ); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(1.2000000000000002))); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 1.2 + ); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .transparency + .is_by_layer()); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + let pattern_name = crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| { + matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Pattern(_) + ) + }) + .expect("pattern hatch") + .name + .clone(); + let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern_name.clone()))); + let selected_pattern = match app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("pattern hatch") + { + EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), + _ => panic!("fill target is not a hatch"), + }; + assert_eq!(selected_pattern, pattern_name); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Hatch, + ); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert!(app.graphic_attributes.gradient_editor.is_none()); + let _ = app.update(ga(GraphicAttributesMsg::HatchEditorOpen)); + assert!(app.graphic_attributes.hatch_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert!(app.graphic_attributes.hatch_editor.is_none()); + assert!(app.graphic_attributes.gradient_editor.is_none()); + + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.on_graphic_attribute(GraphicAttribute::None); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + } + + #[test] + fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { + let mut app = fresh(); + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let boundary = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(boundary, false); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(!app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(_)) + })); + + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(matches!( + app.tabs[i].scene.document.get_entity(boundary), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + )); + assert!(app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) + })); + } + + #[test] + fn graphic_line_controls_update_creation_defaults_without_a_selection() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); + let _ = app.on_line_linetype_scale(2.5); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + + let header = &app.tabs[i].scene.document.header; + assert_eq!( + header.current_entity_color, + acadrust::types::Color::Index(2) + ); + assert_eq!(header.current_linetype_name, "Continuous"); + assert_eq!(header.current_line_weight, 35); + assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); + assert!( + (app.tabs[i] + .scene + .document + .current_entity_transparency() + .as_percent() + - 0.3) + .abs() + < 0.01 + ); + } + + #[test] + fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { + let mut app = fresh(); + let i = app.active_tab; + { + let layer = app.tabs[i] + .scene + .document + .layers + .get_mut("0") + .expect("default layer"); + layer.color = acadrust::types::Color::Index(3); + layer.line_type = "Continuous".to_string(); + layer.line_weight = acadrust::types::LineWeight::Value(50); + layer.transparency = acadrust::types::Transparency::from_percent(0.25); + } + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(handle, false); + + let _ = app.on_graphic_attributes_set_all(false); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::ByBlock); + assert_eq!(common.linetype, "ByBlock"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::ByBlock); + assert!(common.transparency.is_by_block()); + + let _ = app.on_graphic_attributes_set_all(true); + let _ = app.on_graphic_attributes_remove_references(); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::Index(3)); + assert_eq!(common.linetype, "Continuous"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::Value(50)); + assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); + } + + #[test] + fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + 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(ga(GraphicAttributesMsg::CreateLayer)); + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("GRAPHICATTRIBUTELAYER") + ); + let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); + let _ = app.update(Message::CommandFinalize); + + let layer = app.tabs[i] + .scene + .document + .layers + .get("Graphic Settings") + .expect("created layer"); + assert_eq!(layer.color, acadrust::types::Color::Index(2)); + assert_eq!(layer.line_type, "Continuous"); + assert_eq!(layer.line_weight, acadrust::types::LineWeight::Value(35)); + assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); + } + + fn open_square(app: &mut OpenCADStudio) -> Handle { + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + handle + } + + fn has_hatch(app: &OpenCADStudio) -> bool { + app.tabs[app.active_tab] + .scene + .document + .entities() + .any(|entity| matches!(entity, EntityType::Hatch(_))) + } + + fn is_closed(app: &OpenCADStudio, handle: Handle) -> bool { + matches!( + app.tabs[app.active_tab].scene.document.get_entity(handle), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + ) + } + + fn type_and_enter(app: &mut OpenCADStudio, input: &str) { + let _ = app.update(Message::CommandInput(input.to_string())); + let _ = app.update(Message::CommandSubmit); + } + + #[test] + fn closing_an_open_boundary_and_filling_it_is_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let boundary = open_square(&mut app); + let undo_before = app.tabs[i].history.undo_stack.len(); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(is_closed(&app, boundary)); + assert!(has_hatch(&app)); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let _ = app.update(Message::Undo); + assert!(!is_closed(&app, boundary)); + assert!(!has_hatch(&app)); + } + + #[test] + fn typed_answers_to_the_close_prompt() { + // Yes, typed and submitted with Enter. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "y"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // No leaves the object open and unfilled. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "No"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + + // Anything else keeps asking; a bare Enter takes the default Yes. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "maybe"); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let _ = app.update(Message::CommandFinalize); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // Escape cancels the request. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandEscape); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + } + + #[test] + fn linetype_scale_keeps_iso_pen_width_values() { + let mut app = fresh(); + let i = app.active_tab; + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + for scale in [0.13, 0.18, 0.25, 0.35, 0.05] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(scale))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, scale); + } + // Spinner steps do not accumulate floating-point noise. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(0.25 + 0.1))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + for invalid in [0.0, -1.0, f64::INFINITY] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(invalid))); + } + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + } + + #[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); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a88e44a38..82adcf37a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -95,6 +95,7 @@ mod context_menu; mod dialog; mod dynamic; mod file; +mod graphic_attributes; mod page_setup_import; mod style; pub(in crate::app) mod util; @@ -331,7 +332,8 @@ impl OpenCADStudio { { // Esc backs out of a pending ALIASEDIT draft first, mirroring // the shortcut editor's capture cancel; the next Esc closes. - if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add { + if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add + { return self.update(Message::AliasEditorDraftCancel); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { @@ -393,12 +395,9 @@ impl OpenCADStudio { // a mouse move included — without allocating. #[cfg(not(target_arch = "wasm32"))] let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> { - app.tabs.get(app.active_tab).map(|tab| { - ( - tab.id, - tab.active_cmd.as_ref().map(|command| command.name()), - ) - }) + app.tabs + .get(app.active_tab) + .map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name()))) }; #[cfg(not(target_arch = "wasm32"))] let command_before = active_command(self); @@ -416,33 +415,9 @@ impl OpenCADStudio { } } self.refresh_gpu_status(); - if self.gradient_editor.as_ref().is_some_and(|editor| { + if self.graphic_attributes.tracks_selection() { let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != editor.handles.len() - || selected - .iter() - .any(|handle| !editor.handles.contains(handle)) - }) { - self.gradient_editor = None; - } - if self.hatch_editor_handles.as_ref().is_some_and(|handles| { - let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != handles.len() - || selected.iter().any(|handle| !handles.contains(handle)) - }) { - self.hatch_editor_handles = None; - } - if self - .pending_graphic_fill_close - .as_ref() - .is_some_and(|(_, _, handles)| { - let selected = - self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != handles.len() - || selected.iter().any(|handle| !handles.contains(handle)) - }) - { - self.pending_graphic_fill_close = None; + self.graphic_attributes.retain_selection(&selected); } self.show_next_startup_modal(); self.sync_open_command_history(); @@ -457,19 +432,7 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.prompt()) - .or_else(|| { - self.pending_graphic_fill_close.as_ref().map(|(_, handles, _)| { - if handles.len() == 1 { - "The selected object is open. Close it before creating the fill? [Yes / No]" - .to_string() - } else { - format!( - "{} selected objects are open. Close them before creating the fill? [Yes / No]", - handles.len() - ) - } - }) - }) + .or_else(|| self.graphic_attributes.fill_close_prompt()) .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). @@ -478,7 +441,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.options()) .or_else(|| { - self.pending_graphic_fill_close.as_ref().map(|_| { + self.graphic_attributes.pending_fill_close.as_ref().map(|_| { vec![ crate::command::CmdOption::new("Yes", "Y"), crate::command::CmdOption::new("No", "N"), @@ -558,8 +521,9 @@ impl OpenCADStudio { Message::SpaceMouseWake => self.on_spacemouse_wake(), Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification), Message::SpaceMouseFrame(time) => { - self.spacemouse - .frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.); + self.spacemouse.frame( + time.saturating_duration_since(self.start).as_secs_f64() * 1000., + ); Task::none() } Message::SpaceMouseFocus(id, focused) => { @@ -611,8 +575,9 @@ impl OpenCADStudio { if response["ok"] == false { let code = response["code"].as_str().unwrap_or("failed"); let detail = response["error"].as_str().unwrap_or(""); - self.command_line - .push_error(&format!("automation {op}: {code} — {detail}")); + self.command_line.push_error(&format!( + "automation {op}: {code} — {detail}" + )); } else if matches!( response["status"].as_str(), Some("completed" | "cancelled" | "waiting_input") @@ -1213,23 +1178,19 @@ impl OpenCADStudio { Task::none() } - Message::ImageEmbedPick => Task::perform( - crate::io::pick_embedded_image_file(), - Message::ImageEmbedPickResult, - ), + Message::ImageEmbedPick => { + Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult) + } Message::ImageEmbedPickResult(Ok(image)) => { use crate::command::CadCommand; use crate::modules::draw::draw::raster_image::ImageCommand; - self.command_line.push_output( - crate::tf!( + self.command_line.push_output(crate::tf!( "IMAGEEMBED \"{name}\": {w}×{h} px (embedded)", name = image.name.as_str(), w = image.pixel_width, h = image.pixel_height, - ) - .as_ref(), - ); + ).as_ref()); let cmd = ImageCommand::new_embedded(image); let i = self.active_tab; self.command_line.push_info(&cmd.prompt()); @@ -1316,13 +1277,10 @@ impl OpenCADStudio { .insert(crate::io::font_repo::font_key(name)); } for (name, path) in &pairs { - self.command_line.push_output( - crate::tf!( + self.command_line.push_output(crate::tf!( "FONT Downloaded {name} → {path}", path = path.display() - ) - .as_ref(), - ); + ).as_ref()); } if !unavailable.is_empty() { self.command_line.push_info(crate::tf!( @@ -1338,16 +1296,14 @@ impl OpenCADStudio { if let Some(path) = path { return Task::done(Message::OpenExternal(path)); } - self.command_line.push_info( - crate::t!("Save and reopen the drawing to apply the new fonts.") - .as_ref(), - ); + self.command_line.push_info(crate::t!( + "Save and reopen the drawing to apply the new fonts." + ).as_ref()); } Err(e) => { // Keep the prompt open: the user can correct a custom // source or retry a transient network failure. - self.command_line - .push_error(crate::tf!("Font download failed: {e}").as_ref()); + self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); } } Task::none() @@ -2439,13 +2395,8 @@ impl OpenCADStudio { // finishes the step like Enter. (#304) self.command_line.input.clear(); self.command_line.close_history(); - if self.pending_graphic_fill_close.is_some() { - if kw.eq_ignore_ascii_case("Y") || kw.eq_ignore_ascii_case("YES") { - return self.on_graphic_fill_close_response(true); - } - if kw.eq_ignore_ascii_case("N") || kw.eq_ignore_ascii_case("NO") { - return self.on_graphic_fill_close_response(false); - } + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(&kw); } if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); @@ -2543,8 +2494,7 @@ impl OpenCADStudio { use crate::app::config::DockSide; use crate::ui::dock::PanelId; if self.dock.location(PanelId::ExternalReferences).is_none() { - self.dock - .dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); + self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); } self.dock_expanded = Some(PanelId::ExternalReferences); self.refresh_xref_manager(); @@ -2682,13 +2632,16 @@ impl OpenCADStudio { }; let new_raw = path.to_string_lossy().into_owned(); self.push_undo_snapshot(i, "XREF-PATH"); - match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw) - { + match crate::io::xref::set_ref_path( + &mut self.tabs[i].scene.document, + key, + &new_raw, + ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) - .as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); self.post_ref_op(i); } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2698,8 +2651,7 @@ impl OpenCADStudio { } Message::XrefPathPickResult(Err(e)) => { if e != "Cancelled" { - self.command_line - .push_error(crate::tf!("XREF: {e}").as_ref()); + self.command_line.push_error(crate::tf!("XREF: {e}").as_ref()); } Task::none() } @@ -2724,11 +2676,9 @@ impl OpenCADStudio { if let Some(found) = entry.found_at.clone() { let is_dwg = found.to_ascii_lowercase().ends_with(".dwg") || found.to_ascii_lowercase().ends_with(".dxf"); - self.command_line - .push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); + self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); if is_dwg { - return self - .update(Message::OpenRecent(std::path::PathBuf::from(found))); + return self.update(Message::OpenRecent(std::path::PathBuf::from(found))); } else { #[cfg(not(target_arch = "wasm32"))] let _ = open::that_detached(&found); @@ -2764,10 +2714,7 @@ impl OpenCADStudio { self.xref_manager.row_change_path_open = false; let prefill = if let Some(entry) = self.xref_manager.entries.get(index) { let saved = &entry.saved_path; - if let Some(parent) = std::path::Path::new(saved) - .parent() - .and_then(|p| p.to_str()) - { + if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) { if !parent.is_empty() { format!("XREF Path Find \"{}\" ", parent) } else { @@ -3767,13 +3714,7 @@ impl OpenCADStudio { } Message::ViewportLeftPress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_viewport_left_press()]) } @@ -3781,13 +3722,7 @@ impl OpenCADStudio { Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportRightPress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; let i = self.active_tab; self.ribbon.close_dropdown(); // Shift+RMB: the one-shot snap override menu at the cursor — @@ -3890,13 +3825,7 @@ impl OpenCADStudio { } Message::ViewportMiddlePress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; self.on_viewport_middle_press() } @@ -4231,13 +4160,9 @@ impl OpenCADStudio { } Message::ToggleGraphicAttributes => { self.show_graphic_attributes ^= true; - self.ribbon - .set_graphic_attributes(self.show_graphic_attributes); + 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.graphic_attributes.close_popups(); } self.save_config(); Task::none() @@ -4433,9 +4358,7 @@ impl OpenCADStudio { continue; }; let current = ( - crate::scene::pe_url_of(entity) - .unwrap_or_default() - .to_owned(), + crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(), crate::scene::pe_url_description_of(entity) .unwrap_or_default() .to_owned(), @@ -4887,9 +4810,7 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header( - &self.tabs[i].scene.document.header, - ), + crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), ); Task::none() } @@ -5048,14 +4969,12 @@ impl OpenCADStudio { }; let name = state.name.trim().to_string(); if name.is_empty() { - state.error_message = - Some(crate::t!("Block name cannot be empty.").into_owned()); + 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(), + crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(), ); return Task::none(); } @@ -5066,21 +4985,13 @@ impl OpenCADStudio { } 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." - ) + 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() - { + if self.tabs[i].scene.document.block_records.get(&name).is_some() { state.confirm_redefine = Some(name); return Task::none(); } @@ -5106,16 +5017,12 @@ impl OpenCADStudio { let current_path = std::path::Path::new(&state.file_path); let file_stem = current_path.file_stem().and_then(|s| s.to_str()); let is_default_or_block = file_stem == Some("new_block") - || state - .existing_blocks - .iter() - .any(|b| Some(b.as_str()) == file_stem); + || state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem); if is_default_or_block { let new_file_name = format!("{}.dwg", trimmed); if let Some(parent) = current_path.parent() { if !parent.as_os_str().is_empty() { - state.file_path = - parent.join(new_file_name).to_string_lossy().to_string(); + state.file_path = parent.join(new_file_name).to_string_lossy().to_string(); } else { state.file_path = new_file_name; } @@ -5236,19 +5143,12 @@ impl OpenCADStudio { WblockSourceMode::Block => { let name = state.block_name.trim(); if name.is_empty() { - state.error_message = Some( - crate::t!("Please select or enter a block name.").into_owned(), - ); + state.error_message = + Some(crate::t!("Please select or enter a block name.").into_owned()); return Task::none(); } let i = self.active_tab; - if self.tabs[i] - .scene - .document - .block_records - .get(name) - .is_none() - { + if self.tabs[i].scene.document.block_records.get(name).is_none() { state.error_message = Some( crate::tf!("Block \"{}\" does not exist in drawing.", name) .into_owned(), @@ -5372,7 +5272,10 @@ impl OpenCADStudio { } WblockSourceMode::Objects => { crate::modules::insert::wblock::extract_entities_to_doc_with_base( - &document, &handles, base_point, unit, + &document, + &handles, + base_point, + unit, ) .map_err(|e| e.to_string())? } @@ -5431,9 +5334,7 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header( - &self.tabs[i].scene.document.header, - ), + crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), ); let label = crate::modules::draw::units::linear_format_label(code); self.command_line @@ -5763,7 +5664,8 @@ impl OpenCADStudio { Task::none() } Message::AutoConstrainReset => { - self.auto_constrain_settings = super::settings::AutoConstrainSettings::default(); + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); self.auto_constrain_selected_row = 0; self.auto_constrain_distance_input = format!("{}", self.auto_constrain_settings.distance_tolerance); @@ -5802,7 +5704,8 @@ impl OpenCADStudio { self.auto_constrain_settings.distance_tolerance = distance; self.auto_constrain_settings.angle_tolerance_deg = angle; self.auto_constrain_settings.sanitize(); - self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); self.persist_settings_if_changed(); if matches!(action, Message::AutoConstrainOk) { self.close_active_modal(); @@ -9590,312 +9493,7 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), - Message::ToggleGraphicAttributesHeaderMenu => { - self.graphic_attributes_header_menu_open ^= true; - Task::none() - } - Message::CloseGraphicAttributesHeaderMenu => { - self.graphic_attributes_header_menu_open = false; - Task::none() - } - Message::GraphicAttributesSetAllByLayer => self.on_graphic_attributes_set_all(true), - Message::GraphicAttributesSetAllByBlock => self.on_graphic_attributes_set_all(false), - Message::GraphicAttributesRemoveReferences => { - self.on_graphic_attributes_remove_references() - } - Message::GraphicAttributesCreateLayer => { - self.graphic_attributes_header_menu_open = false; - let command = crate::command::FreeTextValuePromptCommand::new( - "GRAPHICATTRIBUTELAYER", - "Enter a name for the new layer:", - ); - self.command_line.push_info(&command.prompt()); - self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); - self.focus_cmd_input() - } - Message::ToggleGraphicAttributeDropdown => { - self.graphic_attribute_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseGraphicAttributeDropdown => { - self.graphic_attribute_menu_open = false; - Task::none() - } - Message::GraphicAttributeChanged(value) => self.on_graphic_attribute(value), - Message::ToggleLineColorDropdown => { - self.line_color_menu_open ^= true; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineColorDropdown => { - self.line_color_menu_open = false; - Task::none() - } - Message::LineColorChanged(color) => self.on_line_color(color), - Message::ToggleLineLinetypeMenu => { - self.line_linetype_menu_open ^= true; - self.line_color_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineLinetypeMenu => { - self.line_linetype_menu_open = false; - Task::none() - } - Message::LineLinetypeChanged(value) => self.on_line_linetype(value), - Message::ToggleLineLineweightMenu => { - self.line_lineweight_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineLineweightMenu => { - self.line_lineweight_menu_open = false; - Task::none() - } - Message::LineLineweightChanged(value) => self.on_line_lineweight(value), - Message::LineLinetypeScaleChanged(value) => self.on_line_linetype_scale(value), - Message::LineLinetypeScaleInput(value) => { - let value = value.trim().replace(',', "."); - match value.parse::() { - Ok(value) if value.is_finite() && value > 0.0 => { - self.on_line_linetype_scale(value) - } - _ => Task::none(), - } - } - Message::ToggleLineTransparencyDropdown => { - self.line_transparency_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineTransparencyDropdown => { - self.line_transparency_menu_open = false; - Task::none() - } - Message::LineTransparencyChanged(value) => self.on_line_transparency( - acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), - ), - Message::LineTransparencyByLayer => { - self.on_line_transparency(acadrust::types::Transparency::BY_LAYER) - } - Message::LineTransparencyByBlock => { - self.on_line_transparency(acadrust::types::Transparency::BY_BLOCK) - } - Message::ToggleSolidFillColorDropdown => { - self.solid_fill_color_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseSolidFillColorDropdown => { - self.solid_fill_color_menu_open = false; - Task::none() - } - Message::SolidFillColorChanged(color) => self.on_solid_fill_color(color), - Message::ToggleFillTransparencyDropdown => { - self.fill_transparency_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - Task::none() - } - Message::CloseFillTransparencyDropdown => { - self.fill_transparency_menu_open = false; - Task::none() - } - Message::FillTransparencyChanged(value) => self.on_fill_transparency( - acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), - ), - Message::FillTransparencyByLayer => { - self.on_fill_transparency(acadrust::types::Transparency::BY_LAYER) - } - Message::FillTransparencyByBlock => { - self.on_fill_transparency(acadrust::types::Transparency::BY_BLOCK) - } - Message::HatchEditorOpen => { - self.gradient_editor = None; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let current = handles.iter().find_map(|handle| { - let acadrust::EntityType::Hatch(hatch) = - self.tabs[i].scene.document.get_entity(*handle)? - else { - return None; - }; - Some(hatch.pattern.name.as_str()) - }); - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = current - .and_then(|current| { - crate::ui::properties::filtered_hatch_patterns("") - .iter() - .position(|entry| entry.name.eq_ignore_ascii_case(current)) - }) - .unwrap_or(0); - self.hatch_editor_handles = Some(selected); - iced::widget::operation::focus(iced::widget::Id::new( - "graphic-hatch-pattern-search", - )) - } - Message::HatchEditorClose => { - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - Task::none() - } - Message::GraphicHatchPatternSearchChanged(search) => { - self.graphic_hatch_pattern_search = search; - self.graphic_hatch_pattern_focus = 0; - Task::none() - } - Message::GraphicHatchPatternFocus(index) => { - let len = crate::ui::properties::filtered_hatch_patterns( - &self.graphic_hatch_pattern_search, - ) - .len(); - if index < len { - self.graphic_hatch_pattern_focus = index; - } - Task::none() - } - Message::GraphicHatchPatternChanged(name) => { - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - self.apply_hatch_pattern(i, &handles, name) - } - Message::GraphicHatchPatternConfirm => { - let name = crate::ui::properties::filtered_hatch_patterns( - &self.graphic_hatch_pattern_search, - ) - .get(self.graphic_hatch_pattern_focus) - .map(|entry| entry.name.clone()); - if let Some(name) = name { - self.update(Message::GraphicHatchPatternChanged(name)) - } else { - Task::none() - } - } - Message::GradientEditorOpen => { - self.hatch_editor_handles = None; - self.open_gradient_editor(); - Task::none() - } - Message::GradientColorModeChanged(mode) => { - if let Some(s) = &mut self.gradient_editor { - s.color_mode = mode; - } - Task::none() - } - Message::GradientColorToggle(index) => { - if let Some(s) = &mut self.gradient_editor { - s.color_open = if s.color_open == Some(index) { - None - } else { - Some(index) - }; - } - Task::none() - } - Message::GradientColorChanged(index, color) => { - if let Some(s) = &mut self.gradient_editor { - if index == 1 { - s.color_1 = color - } else { - s.color_2 = color - }; - s.color_open = None; - } - Task::none() - } - Message::GradientShadeTintChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.shade_tint = value.clamp(0.0, 1.0); - } - Task::none() - } - Message::GradientTypeChanged(kind) => { - if let Some(s) = &mut self.gradient_editor { - s.kind = kind; - if kind == crate::scene::model::hatch_model::GradientKind::Linear { - s.inverted = false; - } - } - Task::none() - } - Message::GradientInvertToggled => { - if let Some(s) = &mut self.gradient_editor { - if s.kind != crate::scene::model::hatch_model::GradientKind::Linear { - s.inverted ^= true; - } - } - Task::none() - } - Message::GradientAngleChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.angle = value; - } - Task::none() - } - Message::GradientAngleReset => { - if let Some(s) = &mut self.gradient_editor { - s.angle = "0.0°".into(); - } - Task::none() - } - Message::GradientCenteredChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.centered = value; - } - Task::none() - } - Message::GradientApply => self.apply_gradient_editor(), - Message::GradientCancel => { - self.gradient_editor = None; - Task::none() - } + Message::GraphicAttributes(m) => self.on_graphic_attributes(m), Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { @@ -11032,14 +10630,12 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { - match target { - crate::app::ColorPickTarget::GraphicAttributesLine => { - self.line_color_menu_open = false; - } - crate::app::ColorPickTarget::GraphicAttributesSolid => { - self.solid_fill_color_menu_open = false; - } - _ => {} + if matches!( + target, + crate::app::ColorPickTarget::GraphicAttributesLine + | crate::app::ColorPickTarget::GraphicAttributesSolid + ) { + self.graphic_attributes.open_menu = None; } self.color_pick_target = Some((target, color)); @@ -11233,30 +10829,6 @@ 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/app/update/style.rs b/src/app/update/style.rs index 94e81be06..d9ea9aa33 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -4,12 +4,14 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::model::object::GripApply; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; +use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -19,6 +21,7 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; + impl OpenCADStudio { pub(in crate::app) fn mlstyle_mut( &mut self, @@ -105,10 +108,7 @@ impl OpenCADStudio { } } - pub(in crate::app) fn tablestyle_mut( - &mut self, - tab: usize, - ) -> Option<&mut acadrust::objects::TableStyle> { + pub(in crate::app) fn tablestyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::TableStyle> { use acadrust::objects::ObjectType; let name = self.tablestyle_selected.clone(); self.tabs[tab] @@ -212,10 +212,7 @@ impl OpenCADStudio { /// Mutable access to the currently selected multileader style. - pub(in crate::app) fn mleaderstyle_mut( - &mut self, - tab: usize, - ) -> Option<&mut acadrust::objects::MultiLeaderStyle> { + pub(in crate::app) fn mleaderstyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::MultiLeaderStyle> { use acadrust::objects::ObjectType; let name = self.mleaderstyle_selected.clone(); self.tabs[tab] @@ -823,8 +820,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_edit( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_edit(&mut self, field: &'static str, value: String) -> Task { self.mls_color_open = None; match field { "landing_distance" => self.mls_landing_distance = value, @@ -851,8 +847,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_enum( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_set_enum(&mut self, field: &'static str, value: String) -> Task { use acadrust::objects::{ BlockContentConnectionType, LeaderContentType, LeaderDrawOrderType, MultiLeaderDrawOrderType, MultiLeaderPathType, TextAlignmentType, @@ -872,8 +867,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { "BottomOfTopLineUnderlineTopLine" => { TextAttachmentType::BottomOfTopLineUnderlineTopLine } - "BottomOfTopLineUnderlineAll" => - TextAttachmentType::BottomOfTopLineUnderlineAll, + "BottomOfTopLineUnderlineAll" => { + TextAttachmentType::BottomOfTopLineUnderlineAll + } "CenterOfText" => TextAttachmentType::CenterOfText, "CenterOfTextOverline" => TextAttachmentType::CenterOfTextOverline, _ => TextAttachmentType::MiddleOfTopLine, @@ -899,8 +895,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { } "text_angle_type" => { s.text_angle_type = match value.as_str() { - "ParallelToLastLeaderLine" => - TextAngleType::ParallelToLastLeaderLine, + "ParallelToLastLeaderLine" => { + TextAngleType::ParallelToLastLeaderLine + } "Optimized" => TextAngleType::Optimized, _ => TextAngleType::Horizontal, }; @@ -946,8 +943,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_handle( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_set_handle(&mut self, field: &'static str, value: String) -> Task { let i = self.active_tab; let doc = &self.tabs[i].scene.document; let handle: Option = if value == "None" @@ -1135,36 +1131,42 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { let s = crate::ui::color_select::color_to_aci_string(color); let edit = match self.color_pick_target.take().map(|(target, _)| target) { Some(crate::app::ColorPickTarget::Gradient(index)) => { - Some(Message::GradientColorChanged(index, color)) + Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient( + crate::ui::window::gradient_editor::GradientMsg::Color(index, color), + ))) } Some(crate::app::ColorPickTarget::GraphicAttributesLine) => { - Some(Message::LineColorChanged(color)) - } - Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { - Some(Message::SolidFillColorChanged(color)) + Some(Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color))) + } + Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { + Some(Message::GraphicAttributes(GraphicAttributesMsg::FillColor(color))) } Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) } - Some(crate::app::ColorPickTarget::Table(r, f)) => + Some(crate::app::ColorPickTarget::Table(r, f)) => { Some(Message::TableStyleCellEdit { row: r, field: f, value: s, - }), - Some(crate::app::ColorPickTarget::Properties) => - Some(Message::PropColorChanged(color)), + }) + } + Some(crate::app::ColorPickTarget::Properties) => { + Some(Message::PropColorChanged(color)) + } Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } Some(crate::app::ColorPickTarget::PropertiesField(field)) => { Some(Message::PropColorFieldChanged { field, color }) } - Some(crate::app::ColorPickTarget::MText) => - Some(Message::MTextColorChanged(color)), - Some(crate::app::ColorPickTarget::Ribbon) => - Some(Message::RibbonColorChanged(color)), + Some(crate::app::ColorPickTarget::MText) => { + Some(Message::MTextColorChanged(color)) + } + Some(crate::app::ColorPickTarget::Ribbon) => { + Some(Message::RibbonColorChanged(color)) + } Some(crate::app::ColorPickTarget::Layer(idx)) => { self.tabs[self.active_tab].layers.selected = Some(idx); Some(Message::LayerColorSet(color)) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index add93fa4d..06b8cab97 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -613,10 +613,7 @@ bg={bg_ms:.1}ms n={view_count}" let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { Some(acadrust::EntityType::Spline(spline)) - if crate::entities::spline::shows_control_vertices(spline) => - { - spline - } + if crate::entities::spline::shows_control_vertices(spline) => spline, _ => return None, }; if tab @@ -1112,12 +1109,8 @@ bg={bg_ms:.1}ms n={view_count}" (None, _) if rectangle_values.is_some() => { let (width, height) = rectangle_values.unwrap(); match f.role { - crate::command::DynRole::Width => { - crate::entities::common::format_length(width) - } - crate::command::DynRole::Height => { - crate::entities::common::format_length(height) - } + crate::command::DynRole::Width => crate::entities::common::format_length(width), + crate::command::DynRole::Height => crate::entities::common::format_length(height), _ => String::new(), } } @@ -2750,9 +2743,7 @@ impl OpenCADStudio { } else { Subscription::none() }, - self.spacemouse - .subscription() - .map(|_| Message::SpaceMouseWake), + self.spacemouse.subscription().map(|_| Message::SpaceMouseWake), crate::input::trackpad::subscription().map(Message::TrackpadPinch), event::listen_with(|event, _, id| match event { iced::Event::Window(window::Event::Focused) => { @@ -2940,49 +2931,31 @@ impl OpenCADStudio { let panel: Element<'_, Message> = match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), crate::ui::dock::PanelId::GraphicAttributes => { - let draw_depth = tab.scene.draw_depth_map(); crate::ui::window::graphic_attributes::view( - &tab.scene.document, + &self.graphic_attributes, + &tab.scene, &tab.properties, - &tab.scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect::>(), - draw_depth.as_ref(), width, auto_collapse, side, - self.graphic_attributes_header_menu_open, - self.graphic_attribute_menu_open, - self.line_color_menu_open, - self.line_linetype_menu_open, - self.line_lineweight_menu_open, - self.line_transparency_menu_open, - self.solid_fill_color_menu_open, - self.fill_transparency_menu_open, - self.hatch_editor_handles.is_some(), - &self.graphic_hatch_pattern_search, - self.graphic_hatch_pattern_focus, - self.gradient_editor.as_ref(), matches!( - self.color_pick_target.as_ref().map(|(target, _)| target), - Some(crate::app::ColorPickTarget::Gradient(_)) + self.color_pick_target, + Some((crate::app::ColorPickTarget::Gradient(_), _)) ), ) } crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } - crate::ui::dock::PanelId::ExternalReferences => { - self.xref_manager - .view(width, auto_collapse, tab.xref_missing, &tab.scene.document) - } + crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( + width, + auto_collapse, + tab.xref_missing, + &tab.scene.document, + ), crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( &tab.scene.document, - tab.sketch_session - .as_ref() - .map(|session| session.name.as_str()), + tab.sketch_session.as_ref().map(|session| session.name.as_str()), width, auto_collapse, ), diff --git a/src/command.rs b/src/command.rs index 5fb96016c..bfa4c19eb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -308,10 +308,6 @@ 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 } @@ -332,7 +328,12 @@ impl CadCommand for ValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(dispatch_prompt_value(self.name, text)) + let t = text.trim(); + if t.is_empty() { + Some(CmdResult::Dispatch(format!("{} ", self.name))) + } else { + Some(CmdResult::Dispatch(format!("{} {t}", self.name))) + } } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -342,7 +343,7 @@ impl CadCommand for ValuePromptCommand { fn on_enter(&mut self) -> CmdResult { // Bare Enter → report the current value via the inline handler. - dispatch_prompt_value(self.name, "") + CmdResult::Dispatch(format!("{} ", self.name)) } } @@ -374,7 +375,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(dispatch_prompt_value(self.name, text)) + Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -382,7 +383,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_enter(&mut self) -> CmdResult { - dispatch_prompt_value(self.name, "") + CmdResult::Dispatch(format!("{} ", self.name)) } } @@ -3078,20 +3079,6 @@ 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/scene/entity.rs b/src/scene/entity.rs index c43ba3cd9..ba3cb4af1 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2004,11 +2004,9 @@ impl Scene { let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); - if path - .edges - .iter() - .any(|edge| matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))) - { + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { spline_paths.push((path, local_ring, range)); } boundary_exterior.push(depth == 0); @@ -2057,14 +2055,7 @@ impl Scene { .colors .get(i) .and_then(|e| e.color.rgb()) - .map(|(r, g, b)| { - [ - r as f32 / 255.0, - g as f32 / 255.0, - b as f32 / 255.0, - color[3], - ] - }) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, color[3]]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); @@ -2221,21 +2212,13 @@ impl Scene { } let project = |point: [f64; 2]| { let [x, y] = to_xy(point[0], point[1]); - [ - (x - world_origin[0]) as f32 as f64, - (y - world_origin[1]) as f32 as f64, - ] + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] }; for (path, ring, range) in spline_paths.into_iter().rev() { if range.end > boundary.len() { continue; } - let curves = || { - path.edges - .iter() - .filter_map(crate::entities::hatch::edge_curve) - .collect() - }; + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); if let Some(refined) = cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project) { @@ -2243,10 +2226,7 @@ impl Scene { continue; } boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); - local_boundary.splice( - range, - refined.into_iter().map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); } } diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs index 0210d6489..85fd55f38 100644 --- a/src/scene/hatch_boundary/tests.rs +++ b/src/scene/hatch_boundary/tests.rs @@ -221,7 +221,8 @@ fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { |p| p ) .is_none()); - assert!(refine_spline_boundary(&vec![[0.0; 2]; 513], + assert!(refine_spline_boundary( + &vec![[0.0; 2]; 513], || panic!("size cap ignored"), |p| p ) diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 56e300ced..cfcc3f469 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -362,8 +362,9 @@ impl HatchModel { self.pattern_segments_with_dot_length(Some(0.0)) } - fn pattern_segments_with_dot_length(&self, - plot_dot_length: Option + fn pattern_segments_with_dot_length( + &self, + plot_dot_length: Option, ) -> Vec<[[f64; 2]; 2]> { let HatchPattern::Pattern(families) = &self.pattern else { return Vec::new(); diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 4cfecd2d5..0f60f9223 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -2,7 +2,8 @@ use super::*; impl Scene { - pub(super) fn paper_viewport_handles(&self + pub(super) fn paper_viewport_handles( + &self, ) -> (Handle, Handle, Arc>) { { let cache = self.paper_viewport_cache.borrow(); @@ -11,7 +12,7 @@ impl Scene { return ( cache.layout_block, cache.sheet, - Arc::clone(&cache.content) + Arc::clone(&cache.content), ); } } @@ -122,8 +123,7 @@ impl Scene { ) -> Vec { if self.current_layout == "Model" { let tiles = self.model_tiles.borrow(); - let active = self.active_model_tile.get() - .min(tiles.len().saturating_sub(1)); + let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); return tiles .iter() .enumerate() @@ -298,7 +298,9 @@ impl Scene { hatches.push(sheet); } hatches.extend( - self.paper_canvas_hatches(tint_selected).iter().cloned() + self.paper_canvas_hatches(tint_selected) + .iter() + .cloned(), ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); @@ -375,8 +377,9 @@ impl Scene { /// Physical sheet bounds in canvas pixels. Uses the same forced top-down /// paper transform as the GPU sheet viewport, ignoring stored camera twist. - pub fn paper_sheet_screen_rect(&self, - canvas_px: (f32, f32) + pub fn paper_sheet_screen_rect( + &self, + canvas_px: (f32, f32), ) -> Option { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (canvas_w, canvas_h) = canvas_px; @@ -474,13 +477,13 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color) + .unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - if matches!( - m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + if matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { m.set_alpha(style.0[3]); } else { m.color = style.0; @@ -601,15 +604,13 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color) + .unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - if matches!( - hatch.pattern, - model::hatch_model::HatchPattern::Gradient { .. } - ) { + if matches!(hatch.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { hatch.set_alpha(style.0[3]); } else { hatch.color = style.0; @@ -722,9 +723,9 @@ impl Scene { // from a big symbol library) has viewports legitimately aimed at that // second cluster; testing only the dense one wrongly auto-fits them onto // the library. Fall back to the cluster box when no extents are known. - let full_bounds = self.model_space_extents() - .map(|(mn, mx)| - (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64)); + let full_bounds = self.model_space_extents().map(|(mn, mx)| { + (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64) + }); // Absolute drawing centre. Geometry now reaches the scene at absolute // (UTM) coordinates — the old code centred the overlap test and the // auto-fit on the origin, which was right only while world_offset @@ -741,8 +742,9 @@ impl Scene { (self.local_center[0], self.local_center[1]) } else { self.model_space_extents() - .map(|(mn, mx)| - (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)) + .map(|(mn, mx)| { + (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64) + }) .unwrap_or((0.0, 0.0)) }; diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 2991d9e51..caf96dd62 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -273,7 +273,10 @@ mod tests { fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); - assert_eq!(state.location(PanelId::GraphicAttributes), Some((DockSide::Left, 1))); + assert_eq!( + state.location(PanelId::GraphicAttributes), + Some((DockSide::Left, 1)) + ); assert_eq!( state.location(PanelId::BlockPalette), Some((DockSide::Right, 0)) @@ -373,4 +376,4 @@ mod tests { assert_eq!(drop_index(10.0, 2, 900.0), 0); assert_eq!(drop_index(890.0, 2, 900.0), 2); } -} +} \ No newline at end of file diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 0b85eb8c3..f0a86db38 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -10,7 +10,6 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::{fmt, sync::Arc}; -use crate::t; use crate::ui::ROW_H; use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::Handle; @@ -21,6 +20,7 @@ use iced::widget::{ use iced::{ mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme, }; +use crate::t; // ── Row-height-derived constants ───────────────────────────────────────── const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px @@ -205,7 +205,7 @@ fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) } } -pub(crate) fn hatch_pattern_preview( +fn hatch_pattern_preview( pattern: crate::scene::model::hatch_model::HatchPattern, height: f32, ) -> Element<'static, Message> { @@ -213,9 +213,9 @@ pub(crate) fn hatch_pattern_preview( pattern, padding: 4.0, }) - .width(Length::Fill) - .height(height) - .into() + .width(Length::Fill) + .height(height) + .into() } pub(crate) fn compact_hatch_pattern_preview( @@ -237,8 +237,7 @@ fn hatch_pattern_matches( let query = search.trim(); query.is_empty() || entry.name.to_lowercase().contains(&query.to_lowercase()) - || entry.description.to_lowercase() - .contains(&query.to_lowercase()) + || entry.description.to_lowercase().contains(&query.to_lowercase()) } pub(crate) fn filtered_hatch_patterns( @@ -412,7 +411,7 @@ impl FieldKey { } /// Text-input widget id for one column of a named-parameter row. -pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField,) -> iced::widget::Id { +pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id { use crate::ui::window::named_parameters::ParamField; let suffix = match field { ParamField::Name => "name", @@ -461,7 +460,7 @@ pub fn active_key_focused( /// its field key in O(1) instead of re-scanning the sections, enabling /// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( - sections: &[PropSection] + sections: &[PropSection], ) -> HashMap { let mut map = HashMap::default(); for section in sections { @@ -675,7 +674,7 @@ impl PropertiesPanel { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties,))) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { @@ -700,7 +699,9 @@ impl PropertiesPanel { let close = tooltip( close, text(t!("Close")).size(10), - tooltip::Position::Bottom).gap(4); + tooltip::Position::Bottom, + ) + .gap(4); let header = mouse_area( container( @@ -715,7 +716,7 @@ impl PropertiesPanel { ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weak.color + theme.palette().background.weak.color, )), ..Default::default() }) @@ -882,7 +883,9 @@ impl PropertiesPanel { let hdr = container( row![ title, - toggle,].align_y(iced::Center) + toggle, + ] + .align_y(iced::Center), ) .style(|theme: &Theme| { let palette = theme.palette(); @@ -946,8 +949,9 @@ impl PropertiesPanel { label: &'a str, ) -> Element<'a, Message> { match &prop.value { - PropValue::ColorChoice(color) => - self.render_color_row(label, prop.field, *color, None), + PropValue::ColorChoice(color) => { + self.render_color_row(label, prop.field, *color, None) + } PropValue::NamedColorChoice { color, name } => { self.render_color_row(label, prop.field, *color, Some(name)) } @@ -957,8 +961,9 @@ impl PropertiesPanel { PropValue::FieldLwChoice { field, value } => { self.render_field_lw_row(label, field, *value) } - PropValue::FieldLwVaries { field } => - self.render_field_lw_varies_row(label, field), + PropValue::FieldLwVaries { field } => { + self.render_field_lw_varies_row(label, field) + } PropValue::LwVaries => self.render_lw_varies_row(label), PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), PropValue::Choice { selected, options } => { @@ -980,13 +985,16 @@ impl PropertiesPanel { PropValue::ReadOnlyWithTooltip { value, tooltip } => { render_ro_with_tooltip_row(label, value, tooltip) } - PropValue::HatchPatternChoice(current) => - self.render_hatch_pattern_row(label, current), + PropValue::HatchPatternChoice(current) => { + self.render_hatch_pattern_row(label, current) + } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { id, handles, conflicting, } => - render_entity_link_row(label, *id, handles.clone(), *conflicting), - PropValue::ParamRow { index, name, formula, resolved, } => - self.render_param_row(*index, name, formula, resolved), + PropValue::EntityLink { id, handles, conflicting } => { + render_entity_link_row(label, *id, handles.clone(), *conflicting) + } + PropValue::ParamRow { index, name, formula, resolved } => { + self.render_param_row(*index, name, formula, resolved) + } PropValue::ParamAddRow => render_param_add_row(), PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value), } @@ -1047,7 +1055,7 @@ impl PropertiesPanel { // (#415). Message::OpenColorWindow( crate::app::ColorPickTarget::PropertiesBg, - color + color, ), ); return prop_row_widget(label, selector); @@ -1123,7 +1131,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - color + color, ), ); prop_row_widget(label, selector) @@ -1141,7 +1149,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - AcadColor::ByLayer + AcadColor::ByLayer, ), ); prop_row_widget(label, selector) @@ -1531,28 +1539,24 @@ impl PropertiesPanel { use crate::ui::window::named_parameters::ParamField; let name_key = FieldKey::Param(index, ParamField::Name); - let name_display = self.edit_buf.get(&name_key) - .map(|s| s.as_str()) - .unwrap_or(name); + let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name); let name_active = self.active_field.as_ref() == Some(&name_key); let name_input = text_input("name", name_display) .id(name_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v, }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Name, }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Name }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) .width(Length::FillPortion(3)); let formula_key = FieldKey::Param(index, ParamField::Formula); - let formula_display = self.edit_buf.get(&formula_key) - .map(|s| s.as_str()) - .unwrap_or(formula); + let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula); let formula_active = self.active_field.as_ref() == Some(&formula_key); let formula_input = text_input("formula", formula_display) .id(formula_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v, }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Formula, }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Formula }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) @@ -1563,10 +1567,9 @@ impl PropertiesPanel { Err(_) => ("—".to_string(), true), }; let value_label = container( - text(value_text).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { + text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style { color: is_error.then_some(theme.palette().danger.base.color), - } - }) + }), ) .width(Length::FillPortion(2)) .align_x(iced::Right); @@ -1584,15 +1587,13 @@ impl PropertiesPanel { Background::Color(theme.palette().background.base.color) } }; - let content = container(row![name_input, formula_input, value_label, delete_btn] - .spacing(4) - .align_y(iced::Center),) + let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center)) .style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() }) .padding([2, 6]) .width(Length::Fill); if let Err(err) = resolved { - tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top,) + tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -1600,7 +1601,7 @@ impl PropertiesPanel { container::Style { background: Some(Background::Color(palette.danger.weak.color)), text_color: Some(palette.danger.weak.text), - border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into(), }, + border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() }, ..Default::default() } }) @@ -1658,16 +1659,22 @@ impl PropertiesPanel { return prop_row_widget(label, head.into()); } - let popup = hatch_pattern_picker_content( &self.hatch_pattern_search, + let popup = hatch_pattern_picker_content( + &self.hatch_pattern_search, self.hatch_pattern_focus, - current,"hatch-pattern-search",Message::PropHatchPatternSearchChanged,Message::PropHatchPatternConfirm, + current, + "hatch-pattern-search", + Message::PropHatchPatternSearchChanged, + Message::PropHatchPatternConfirm, Message::PropHatchPatternFocus, Message::PropHatchPatternChanged, ); prop_row_widget( - label,crate::ui::color_select::drop_down_below( - head.into(), popup, + label, + crate::ui::color_select::drop_down_below( + head.into(), + popup, Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), @@ -1723,10 +1730,11 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem }; let btn = button( - text(btn_label).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { + text(btn_label) + .size(FONT_SZ) + .style(move |theme: &Theme| iced::widget::text::Style { color: value.then_some(theme.palette().warning.base.color), - } - }) + }), ) .on_press(Message::PropBoolToggle(field)) .style(move |theme: &Theme, status| { @@ -1869,7 +1877,7 @@ fn render_group_row( let label_col = container(label_btn) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weakest.color + theme.palette().background.weakest.color, )), ..Default::default() }) @@ -1882,7 +1890,7 @@ fn render_group_row( let value_col = container(value_field) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.base.color + theme.palette().background.base.color, )), ..Default::default() }) @@ -1910,7 +1918,7 @@ fn render_group_row( } fn render_annotative_scale_row<'a>( label: &'a str, - value: &'a str + value: &'a str, ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); @@ -1956,7 +1964,7 @@ fn render_ro_with_tooltip_row<'a>( tooltip_text: &'a str, ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); - let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top,) + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -2004,7 +2012,7 @@ fn render_entity_link_row<'a>( }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, text_color: pair.text, ..Default::default() } @@ -2027,10 +2035,7 @@ fn render_entity_link_row<'a>( /// — avoids a separate "pending new row" concept, since every row always /// reflects a real committed table entry. fn render_param_add_row<'a>() -> Element<'a, Message> { - let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ) - ] - .spacing(6) - .align_y(iced::Center),) + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) .on_press(Message::PropParamAddNew) .style(button::text) .padding([4, 8]) @@ -2063,18 +2068,14 @@ fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, text_color: pair.text, ..Default::default() } }) .padding([4, 8]) .width(Length::Fill); - container(row![text(t!("Values").into_owned()) - .size(FONT_SZ) - .width(Length::Fill), btn - ] - .align_y(iced::Center),) + container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center)) .width(Length::Fill) .into() } @@ -2224,9 +2225,7 @@ fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::St } } -pub(crate) - -fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { +pub(crate) fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { text_input_style(theme, status) } @@ -2250,7 +2249,7 @@ mod tests { }; fn sample_sections() -> Vec { - use crate::scene::model::object::{PropValue,Property}; + use crate::scene::model::object::{Property, PropValue}; vec![ crate::scene::model::object::PropSection { title: "Geometry".into(), diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs index 058afc52e..30d6925bc 100644 --- a/src/ui/window/gradient_editor.rs +++ b/src/ui/window/gradient_editor.rs @@ -1,5 +1,8 @@ +//! Gradient flyout of the Graphic Attributes palette. + use crate::app::Message; use crate::scene::model::hatch_model::GradientKind; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; use acadrust::types::Color as AcadColor; use iced::widget::{ button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip, @@ -24,7 +27,26 @@ pub struct GradientEditorState { pub inverted: bool, pub angle: String, pub centered: bool, - pub color_open: Option, +} + +#[derive(Debug, Clone)] +pub enum GradientMsg { + Open, + Cancel, + Apply, + ColorMode(GradientColorMode), + /// Stop colour 1 or 2. + Color(u8, AcadColor), + ShadeTint(f32), + Kind(GradientKind), + InvertToggled, + Angle(String), + AngleReset, + Centered(bool), +} + +fn msg(message: GradientMsg) -> Message { + Message::GraphicAttributes(GraphicAttributesMsg::Gradient(message)) } impl GradientEditorState { @@ -37,7 +59,7 @@ impl GradientEditorState { .gradient_color .colors .get(index) - .map(|entry| entry.color.clone()) + .map(|entry| entry.color) .unwrap_or(AcadColor::Index(7)) }; let (kind, inverted) = GradientKind::from_name(&hatch.gradient_color.name); @@ -55,7 +77,30 @@ impl GradientEditorState { inverted, angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()), centered: hatch.gradient_color.shift.abs() < 1e-9, - color_open: None, + } + } + + pub fn update(&mut self, message: GradientMsg) { + match message { + GradientMsg::ColorMode(mode) => self.color_mode = mode, + GradientMsg::Color(1, color) => self.color_1 = color, + GradientMsg::Color(_, color) => self.color_2 = color, + GradientMsg::ShadeTint(value) => self.shade_tint = value.clamp(0.0, 1.0), + GradientMsg::Kind(kind) => { + self.kind = kind; + if kind == GradientKind::Linear { + self.inverted = false; + } + } + GradientMsg::InvertToggled => { + if self.kind != GradientKind::Linear { + self.inverted ^= true; + } + } + GradientMsg::Angle(value) => self.angle = value, + GradientMsg::AngleReset => self.angle = "0.0°".into(), + GradientMsg::Centered(value) => self.centered = value, + GradientMsg::Open | GradientMsg::Cancel | GradientMsg::Apply => {} } } @@ -96,7 +141,7 @@ fn preview_sized( state: &GradientEditorState, width: u32, height: u32, -) -> iced::widget::image::Handle { +) -> Option { let c1 = rgb(&state.color_1); let c2 = if state.color_mode == GradientColorMode::One { let c = crate::scene::gradient_tint_color(rgb_array(&state.color_1), state.shade_tint); @@ -110,8 +155,7 @@ fn preview_sized( angle, if state.centered { 0.0 } else { 1.0 }, cadkernel::geom2d::Tolerance::default(), - ) - .expect("the preview rectangle always defines a gradient frame"); + )?; let (sin, cos) = angle.sin_cos(); let radial = state.kind.radial(); let mut pixels = Vec::with_capacity((width * height * 4) as usize); @@ -143,17 +187,30 @@ fn preview_sized( }); } } - iced::widget::image::Handle::from_rgba(width, height, pixels) + Some(iced::widget::image::Handle::from_rgba(width, height, pixels)) } -fn preview(state: &GradientEditorState) -> iced::widget::image::Handle { - preview_sized(state, 370, 80) +fn preview(state: &GradientEditorState) -> Element<'static, Message> { + preview_image(preview_sized(state, 370, 80)) } -pub(crate) fn compact_preview( - hatch: &acadrust::entities::Hatch, -) -> iced::widget::image::Handle { - preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26) +pub(crate) fn compact_preview(hatch: &acadrust::entities::Hatch) -> Element<'static, Message> { + preview_image(preview_sized( + &GradientEditorState::from_hatch(Vec::new(), hatch), + 256, + 26, + )) +} + +fn preview_image(handle: Option) -> Element<'static, Message> { + match handle { + Some(handle) => image(handle) + .width(Fill) + .height(Fill) + .content_fit(iced::ContentFit::Fill) + .into(), + None => Space::new().width(Fill).height(Fill).into(), + } } fn icon(kind: GradientKind, inverted: bool) -> &'static [u8] { @@ -192,7 +249,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let segment = |label, mode| { let selected = state.color_mode == mode; button(text(label).size(11)) - .on_press(Message::GradientColorModeChanged(mode)) + .on_press(msg(GradientMsg::ColorMode(mode))) .width(Fill) .style(move |theme: &Theme, status| { if selected { @@ -203,8 +260,8 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { }) }; let color_field = |label: &'static str, index, color: AcadColor| { - let (background, _) = crate::ui::properties::acad_color_display(color.clone()); - let value = crate::ui::color_select::color_display_name(color.clone()); + let (background, _) = crate::ui::properties::acad_color_display(color); + let value = crate::ui::color_select::color_display_name(color); let picker = button( row![ crate::ui::color_select::swatch(background), @@ -265,7 +322,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { ] .align_x(iced::Center), ) - .on_press(Message::GradientTypeChanged(kind)) + .on_press(msg(GradientMsg::Kind(kind))) .width(48) .height(48) .style(move |theme: &Theme, status| { @@ -285,7 +342,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let invert_selected = state.inverted && state.kind != GradientKind::Linear; let invert = button(column![text("⇄").size(18), text("Invert").size(9)].align_x(iced::Center)) .on_press_maybe( - (state.kind != GradientKind::Linear).then_some(Message::GradientInvertToggled), + (state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)), ) .width(42) .height(48) @@ -309,18 +366,18 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { )); let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two { row![ - color_field("Color 1", 1, state.color_1.clone()), - color_field("Color 2", 2, state.color_2.clone()) + color_field("Color 1", 1, state.color_1), + color_field("Color 2", 2, state.color_2) ] .spacing(6) .into() } else { row![ - color_field("Color 1", 1, state.color_1.clone()), + color_field("Color 1", 1, state.color_1), row![ text("Tone").size(11).width(43), slider(0..=100, (state.shade_tint * 100.0) as i32, |v| { - Message::GradientShadeTintChanged(v as f32 / 100.0) + msg(GradientMsg::ShadeTint(v as f32 / 100.0)) }) .width(Fill) ] @@ -336,12 +393,12 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let angle = row![ text("Angle").size(11).width(43), text_input("0.0°", &state.angle) - .on_input(Message::GradientAngleChanged) + .on_input(|value| msg(GradientMsg::Angle(value))) .size(11) .width(Length::Fixed(92.0)), tooltip( button(crate::ui::icons::themed_undo(11.0, true)) - .on_press(Message::GradientAngleReset) + .on_press(msg(GradientMsg::AngleReset)) .style(button::subtle) .padding(3) .width(24) @@ -352,7 +409,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { Space::new().width(Fill), checkbox(state.centered) .label("Centered") - .on_toggle(Message::GradientCenteredChanged) + .on_toggle(|value| msg(GradientMsg::Centered(value))) .size(14) .text_size(11) ] @@ -364,11 +421,11 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let actions = container( row![ button(text(crate::t!("Cancel")).size(11)) - .on_press(Message::GradientCancel) + .on_press(msg(GradientMsg::Cancel)) .style(button::secondary) .padding([5, 14]), button(text(crate::t!("OK")).size(11)) - .on_press(Message::GradientApply) + .on_press(msg(GradientMsg::Apply)) .style(button::primary) .padding([5, 18]), ] @@ -377,7 +434,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { .width(Fill) .align_x(iced::alignment::Horizontal::Right); let content = column![ - image(preview(state)).width(Fill).height(80), + container(preview(state)).width(Fill).height(80), row![ segment("One Color", GradientColorMode::One), segment("Two Colors", GradientColorMode::Two) diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 1a44ca701..e1a50bc55 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -1,73 +1,37 @@ -//! Graphic Attributes palette: apply or remove an associative object fill. +//! Graphic Attributes palette: line attributes and the associative fill of +//! the selected objects. -use crate::app::Message; +use crate::app::config::DockSide; +use crate::app::{ColorPickTarget, Message}; +use crate::scene::Scene; use crate::ui::dock::{DockMsg, PanelId}; +use crate::ui::window::gradient_editor::{GradientEditorState, GradientMsg}; +use acadrust::entities::{EntityCommon, Hatch}; +use acadrust::types::{Color as AcadColor, LineWeight, Transparency}; use acadrust::{CadDocument, EntityType, Handle}; use iced::widget::{ - button, column, combo_box, container, image, mouse_area, row, slider, text, text_input, - tooltip, Space, + button, column, combo_box, container, mouse_area, row, slider, text, text_input, tooltip, + Space, }; use iced::{Background, Border, Element, Fill, Length, Padding, Theme}; +use rustc_hash::{FxHashMap, FxHashSet}; -pub(crate) struct SolidFillColorInfo { - pub color: acadrust::types::Color, - pub display: iced::Color, - pub varies: bool, -} - -struct TransparencyInfo { - value: u8, - varies: bool, - mode: PropertyMode, -} - -#[derive(Clone, Copy)] -enum PropertyMode { - ByLayer, - ByBlock, - Custom, - Varies, -} - -impl PropertyMode { - fn button_label(self) -> &'static str { - match self { - Self::ByLayer => "L", - Self::ByBlock => "B", - Self::Custom => "C", - Self::Varies => "V", - } - } -} - -struct LinetypeInfo { - value: String, - varies: bool, -} - -struct LineweightInfo { - value: acadrust::types::LineWeight, - varies: bool, -} - -struct LinetypeScaleInfo { - value: f64, - varies: bool, -} +type DrawDepth = FxHashMap; 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"); +/// A usable object linetype scale (CELTSCALE): any positive finite value. +/// Spinner steps are rounded to six decimals so repeated `±0.1` steps do not +/// accumulate floating-point noise; typed values such as the ISO pen widths +/// 0.13, 0.18, 0.25 and 0.35 are kept as entered. 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)) + let value = (value * 1.0e6).round() / 1.0e6; + (value.is_finite() && value > 0.0).then_some(value) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -82,6 +46,16 @@ pub enum GraphicAttribute { impl GraphicAttribute { pub const ALL: [Self; 4] = [Self::None, Self::Solid, Self::Hatch, Self::Gradient]; + fn of(hatch: &Hatch) -> Self { + if hatch.gradient_color.enabled { + Self::Gradient + } else if hatch.is_solid { + Self::Solid + } else { + Self::Hatch + } + } + fn icon(self) -> &'static [u8] { match self { Self::Varies | Self::None => { @@ -106,21 +80,127 @@ impl std::fmt::Display for GraphicAttribute { } } +/// The palette's drop-down menus; at most one is open at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Menu { + Header, + Fill, + LineColor, + LineLinetype, + LineLineweight, + LineTransparency, + FillColor, + FillTransparency, +} + +#[derive(Debug, Clone)] +pub enum GraphicAttributesMsg { + ToggleMenu(Menu), + CloseMenu, + SetAllByLayer, + SetAllByBlock, + RemoveReferences, + CreateLayer, + Fill(GraphicAttribute), + LineColor(AcadColor), + LineLinetype(String), + LineLineweight(LineWeight), + LineLinetypeScale(f64), + LineTransparency(Transparency), + FillColor(AcadColor), + FillTransparency(Transparency), + HatchEditorOpen, + HatchEditorClose, + HatchPatternSearch(String), + HatchPatternFocus(usize), + HatchPattern(String), + HatchPatternConfirm, + Gradient(GradientMsg), +} + +fn msg(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) +} + +/// The hatch pattern flyout, opened for one selection. +#[derive(Debug)] +pub struct HatchEditorState { + pub handles: Vec, + pub search: String, + pub focus: usize, +} + +/// A fill request waiting for the user to decide whether its open +/// boundaries are closed first. +#[derive(Debug)] +pub struct PendingFillClose { + pub fill: GraphicAttribute, + pub open_boundaries: Vec, + pub selection: Vec, +} + +#[derive(Debug, Default)] +pub struct GraphicAttributesState { + pub open_menu: Option, + pub hatch_editor: Option, + pub gradient_editor: Option, + pub pending_fill_close: Option, +} + +impl GraphicAttributesState { + pub fn close_popups(&mut self) { + self.open_menu = None; + self.hatch_editor = None; + self.gradient_editor = None; + } + + /// Whether a flyout or question is bound to the selection it was opened for. + pub fn tracks_selection(&self) -> bool { + self.hatch_editor.is_some() + || self.gradient_editor.is_some() + || self.pending_fill_close.is_some() + } + + /// Drop flyouts and questions that were opened for another selection. + pub fn retain_selection(&mut self, selected: &[Handle]) { + let same = |handles: &[Handle]| { + handles.len() == selected.len() && selected.iter().all(|h| handles.contains(h)) + }; + if self.gradient_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.gradient_editor = None; + } + if self.hatch_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.hatch_editor = None; + } + if self.pending_fill_close.as_ref().is_some_and(|p| !same(&p.selection)) { + self.pending_fill_close = None; + } + } + + pub fn fill_close_prompt(&self) -> Option { + let count = self.pending_fill_close.as_ref()?.open_boundaries.len(); + Some(if count == 1 { + "The selected object is open. Close it before creating the fill? [Yes / No] " + .to_string() + } else { + format!( + "{count} selected objects are open. Close them before creating the fill? [Yes / No] " + ) + }) + } +} + +/// The fill kind of each selected object: its own kind for a selected HATCH, +/// otherwise the kind of the associative HATCH it bounds. pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { // Scan the document once, rather than once per selected object on every frame. - let mut fills: rustc_hash::FxHashMap> = + let mut fills: FxHashMap> = selected.iter().map(|handle| (*handle, None)).collect(); for entity in document.entities() { let EntityType::Hatch(hatch) = entity else { continue; }; - let value = if hatch.gradient_color.enabled { - GraphicAttribute::Gradient - } else if hatch.is_solid { - GraphicAttribute::Solid - } else { - GraphicAttribute::Hatch - }; + let value = GraphicAttribute::of(hatch); if let Some(fill) = fills.get_mut(&entity.common().handle) { fill.get_or_insert(value); } @@ -147,8 +227,9 @@ pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAtt } } +/// HATCH entities that are selected or associated with a selected boundary. pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { - let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); + let selected: FxHashSet<_> = selected.iter().copied().collect(); document .entities() .filter_map(|entity| { @@ -157,19 +238,21 @@ pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec Vec { let mut handles = Vec::new(); - let mut seen = rustc_hash::FxHashSet::default(); + let mut seen = FxHashSet::default(); for handle in selected { match document.get_entity(*handle) { Some(EntityType::Hatch(hatch)) if hatch.is_associative => { @@ -192,187 +275,173 @@ pub(crate) fn line_handles(document: &CadDocument, selected: &[Handle]) -> Vec, -) -> Option { +/// The handle drawn on top, which decides the value shown for a selection. +pub(crate) fn top_handle(handles: &[Handle], draw_depth: &DrawDepth) -> Option { handles.iter().copied().max_by(|left, right| { - let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); - left.total_cmp(&right) + let depth = |handle: &Handle| draw_depth.get(&handle.value()).map_or(0.0, |d| d[0]); + depth(left).total_cmp(&depth(right)) }) } -fn transparency_info( - document: &CadDocument, +/// The value of the topmost object and whether any of `handles` differs. +fn shared_value<'a, T: PartialEq>( + document: &'a CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let top_entity = document.get_entity(top)?; - let stored = top_entity.common().transparency; - let effective = if stored.is_by_layer() { - document - .layers - .get(&top_entity.common().layer) - .map_or(stored, |layer| layer.transparency) - } else if stored.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - stored - }; - Some(TransparencyInfo { - value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, - varies: handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().transparency != stored) - }), - mode: if stored.is_by_layer() { - PropertyMode::ByLayer - } else if stored.is_by_block() { - PropertyMode::ByBlock - } else { - PropertyMode::Custom - }, - }) -} - -fn color_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let top_entity = document.get_entity(top)?; - let color = top_entity.common().color; + draw_depth: &DrawDepth, + value: impl Fn(&EntityCommon) -> T, +) -> Option<(&'a EntityCommon, T, bool)> { + let top = document.get_entity(top_handle(handles, draw_depth)?)?.common(); + let shared = value(top); let varies = handles.iter().any(|handle| { document .get_entity(*handle) - .is_some_and(|entity| entity.common().color != color) + .is_some_and(|entity| value(entity.common()) != shared) }); - let rgba = crate::scene::view::render::render_style_for_common_viewport( - document, - top_entity.common(), - None, - ) - .0; - Some(SolidFillColorInfo { + Some((top, shared, varies)) +} + +fn current_layer_name(document: &CadDocument) -> &str { + if document.header.current_layer_name.is_empty() { + "0" + } else { + &document.header.current_layer_name + } +} + +/// Layer of the topmost object, or the current layer without a selection. +fn top_layer<'a>(document: &'a CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> &'a str { + top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map_or_else( + || current_layer_name(document), + |entity| entity.common().layer.as_str(), + ) +} + +#[derive(Clone, Copy)] +enum PropertyMode { + ByLayer, + ByBlock, + Custom, + Varies, +} + +impl PropertyMode { + fn button_label(self) -> &'static str { + match self { + Self::ByLayer => "L", + Self::ByBlock => "B", + Self::Custom => "C", + Self::Varies => "V", + } + } +} + +struct ColorInfo { + color: AcadColor, + display: iced::Color, + varies: bool, +} + +impl ColorInfo { + fn mode(&self) -> PropertyMode { + match (self.varies, self.color) { + (true, _) => PropertyMode::Varies, + (false, AcadColor::ByLayer) => PropertyMode::ByLayer, + (false, AcadColor::ByBlock) => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + } +} + +struct TransparencyInfo { + value: u8, + stored: Transparency, + varies: bool, +} + +impl TransparencyInfo { + fn new(document: &CadDocument, layer: &str, stored: Transparency, varies: bool) -> Self { + let effective = if stored.is_by_layer() { + document + .layers + .get(layer) + .map_or(stored, |layer| layer.transparency) + } else if stored.is_by_block() { + Transparency::from_percent(0.0) + } else { + stored + }; + Self { + value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, + stored, + varies, + } + } + + fn mode(&self) -> PropertyMode { + if self.varies { + PropertyMode::Varies + } else if self.stored.is_by_layer() { + PropertyMode::ByLayer + } else if self.stored.is_by_block() { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + } + } +} + +fn color_info(document: &CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> Option { + let (top, color, varies) = shared_value(document, handles, draw_depth, |c| c.color)?; + let rgba = + crate::scene::view::render::render_style_for_common_viewport(document, top, None).0; + Some(ColorInfo { color, display: iced::Color::from_rgba(rgba[0], rgba[1], rgba[2], 1.0), varies, }) } -fn linetype_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().linetype.clone(); - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().linetype != value) - }); - Some(LinetypeInfo { - value: if value.is_empty() { - "ByLayer".to_string() - } else { - value - }, - varies, - }) +/// Line colour for new objects (CECOLOR) when nothing is selected. +fn current_color_info(document: &CadDocument) -> ColorInfo { + let color = document.header.current_entity_color; + let shown = match color { + AcadColor::ByLayer => document + .layers + .get(current_layer_name(document)) + .map_or(color, |layer| layer.color), + _ => color, + }; + ColorInfo { + color, + display: crate::ui::properties::acad_color_display(shown).0, + varies: false, + } } -fn lineweight_info( +fn transparency_info( document: &CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().line_weight; - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().line_weight != value) - }); - Some(LineweightInfo { value, varies }) + draw_depth: &DrawDepth, +) -> Option { + let (top, stored, varies) = shared_value(document, handles, draw_depth, |c| c.transparency)?; + Some(TransparencyInfo::new(document, &top.layer, stored, varies)) } fn effective_lineweight( document: &CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - stored: acadrust::types::LineWeight, -) -> acadrust::types::LineWeight { - if !matches!(stored, acadrust::types::LineWeight::ByLayer) { + draw_depth: &DrawDepth, + stored: LineWeight, +) -> LineWeight { + if !matches!(stored, LineWeight::ByLayer) { return stored; } - let layer_name = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| entity.common().layer.as_str()) - .unwrap_or_else(|| { - if document.header.current_layer_name.is_empty() { - "0" - } else { - document.header.current_layer_name.as_str() - } - }); document .layers - .get(layer_name) - .map_or(acadrust::types::LineWeight::Default, |layer| { - layer.line_weight - }) -} - -fn linetype_scale_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().linetype_scale; - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| (entity.common().linetype_scale - value).abs() > f64::EPSILON) - }); - Some(LinetypeScaleInfo { value, varies }) -} - -fn effective_linetype_is_continuous( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> bool { - let (name, layer) = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| { - ( - entity.common().linetype.as_str(), - entity.common().layer.as_str(), - ) - }) - .unwrap_or_else(|| { - ( - current_linetype_name(document), - document.header.current_layer_name.as_str(), - ) - }); - let name = if name.is_empty() || name.eq_ignore_ascii_case("ByLayer") { - document - .layers - .get(if layer.is_empty() { "0" } else { layer }) - .map_or("Continuous", |layer| layer.line_type.as_str()) - } else if name.eq_ignore_ascii_case("ByBlock") { - "Continuous" - } else { - name - }; - name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") + .get(top_layer(document, handles, draw_depth)) + .map_or(LineWeight::Default, |layer| layer.line_weight) } fn current_linetype_name(document: &CadDocument) -> &str { @@ -389,42 +458,33 @@ fn current_linetype_name(document: &CadDocument) -> &str { } } -fn linetype_preview_art( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - logical_name: &str, -) -> String { - let layer_name = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| entity.common().layer.as_str()) - .unwrap_or_else(|| { - if document.header.current_layer_name.is_empty() { - "0" - } else { - document.header.current_layer_name.as_str() - } - }); - let effective_name = if logical_name.is_empty() - || logical_name.eq_ignore_ascii_case("ByLayer") - || logical_name.eq_ignore_ascii_case("ByBlock") - { +/// The linetype actually drawn: ByLayer resolves to the layer's linetype, +/// ByBlock to Continuous. +fn effective_linetype<'a>(document: &'a CadDocument, layer: &str, logical: &'a str) -> &'a str { + if logical.is_empty() || logical.eq_ignore_ascii_case("ByLayer") { document .layers - .get(layer_name) + .get(if layer.is_empty() { "0" } else { layer }) .map_or("Continuous", |layer| layer.line_type.as_str()) + } else if logical.eq_ignore_ascii_case("ByBlock") { + "Continuous" } else { - logical_name - }; - if effective_name.eq_ignore_ascii_case("Continuous") - || effective_name.eq_ignore_ascii_case("Solid") - { + logical + } +} + +fn is_continuous(name: &str) -> bool { + name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") +} + +fn linetype_preview_art(document: &CadDocument, linetype: &str) -> String { + if is_continuous(linetype) { return "_".repeat(80); } let art = document .line_types .iter() - .find(|line_type| line_type.name.eq_ignore_ascii_case(effective_name)) + .find(|line_type| line_type.name.eq_ignore_ascii_case(linetype)) .map(|line_type| { if line_type.elements.is_empty() { "____________".to_string() @@ -454,50 +514,31 @@ fn linetype_preview_art( full_width } +/// The topmost selected fill of the given kind. fn top_fill_hatch<'a>( document: &'a CadDocument, - selected: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - expected: GraphicAttribute, -) -> Option<&'a acadrust::entities::Hatch> { - let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); - document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - let value = if hatch.gradient_color.enabled { - GraphicAttribute::Gradient - } else if hatch.is_solid { - GraphicAttribute::Solid - } else { - GraphicAttribute::Hatch - }; - if value != expected { - return None; - } - let directly_selected = selected.contains(&entity.common().handle); - let selected_boundary = hatch.is_associative - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| selected.contains(handle)) - }); - (directly_selected || selected_boundary).then_some((entity.common().handle, hatch)) + fills: &[Handle], + draw_depth: &DrawDepth, + kind: GraphicAttribute, +) -> Option<&'a Hatch> { + let fills: Vec<_> = fills + .iter() + .copied() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if GraphicAttribute::of(hatch) == kind) }) - .max_by(|(left, _), (right, _)| { - let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); - left.total_cmp(&right) - }) - .map(|(_, hatch)| hatch) + .collect(); + match document.get_entity(top_handle(&fills, draw_depth)?)? { + EntityType::Hatch(hatch) => Some(hatch), + _ => None, + } } -fn gradients_vary(document: &CadDocument, selected: &[Handle]) -> bool { - let mut gradients = fill_handles(document, selected) - .into_iter() - .filter_map(|handle| match document.get_entity(handle) { +fn gradients_vary(document: &CadDocument, fills: &[Handle]) -> bool { + let mut gradients = fills + .iter() + .filter_map(|handle| match document.get_entity(*handle) { Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled => { Some(&hatch.gradient_color) } @@ -509,10 +550,10 @@ fn gradients_vary(document: &CadDocument, selected: &[Handle]) -> bool { gradients.any(|gradient| gradient != first) } -fn popup_alignment(side: crate::app::config::DockSide) -> iced_aw::drop_down::Alignment { +fn popup_alignment(side: DockSide) -> iced_aw::drop_down::Alignment { match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, + DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, } } @@ -525,21 +566,27 @@ fn compact_button_content(label: &'static str) -> Element<'static, Message> { .into() } -fn mode_button_content(mode: PropertyMode) -> Element<'static, Message> { - compact_button_content(mode.button_label()) +fn popup_item<'a>(label: &'a str, message: Message) -> Element<'a, Message> { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() } -fn by_mode_menu<'a>( - side: crate::app::config::DockSide, +/// The small L / B / C / V button next to a control, with its drop-down. +fn mode_menu<'a>( + side: DockSide, mode: PropertyMode, - open: bool, - toggle: Message, - close: Message, - by_layer: Message, - by_block: Message, + menu: Menu, + open_menu: Option, + height: f32, + items: Vec<(&'a str, Message)>, ) -> Element<'a, Message> { - let more = button(mode_button_content(mode)) - .on_press(toggle) + let open = open_menu == Some(menu); + let more = button(compact_button_content(mode.button_label())) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(menu))) .style(move |theme: &Theme, status| { if open { button::primary(theme, status) @@ -547,22 +594,16 @@ fn by_mode_menu<'a>( button::secondary(theme, status) } }) - .height(COMPACT_BUTTON_HEIGHT) + .height(height) .padding([1, 3]); if !open { return more.into(); } - let choice = |label, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - choice("ByLayer", by_layer), - choice("ByBlock", by_block), - ]) + let popup = container(column( + items + .into_iter() + .map(|(label, message)| popup_item(label, message)), + )) .style(crate::ui::color_select::popup_panel_style) .padding(2); iced_aw::DropDown::new(more, popup, true) @@ -570,45 +611,43 @@ fn by_mode_menu<'a>( .height(Length::Shrink) .alignment(popup_alignment(side)) .offset(2.0) - .on_dismiss(close) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) .into() } fn color_control<'a>( - info: SolidFillColorInfo, - side: crate::app::config::DockSide, - menu_open: bool, - target: crate::app::ColorPickTarget, - toggle: Message, - close: Message, - changed: fn(acadrust::types::Color) -> Message, + info: ColorInfo, + side: DockSide, + menu: Menu, + open_menu: Option, + target: ColorPickTarget, + changed: fn(AcadColor) -> GraphicAttributesMsg, ) -> Element<'a, Message> { let label = if info.varies { Some("*VARIES*") + } else if info.color == AcadColor::ByBlock { + Some("ByBlock") } else { - match info.color { - acadrust::types::Color::ByLayer => None, - acadrust::types::Color::ByBlock => Some("ByBlock"), - _ => None, - } - } - .map(|label| { - container(text(label).size(10)) + None + }; + let label: Element<'a, Message> = match label { + Some(label) => container(text(label).size(10)) .width(Fill) .align_x(iced::Left) .align_y(iced::Center) .padding([0, 6]) - }); - let color = button(label.unwrap_or_else(|| container(Space::new()).width(Fill).height(Fill))) + .into(), + None => container(Space::new()).width(Fill).height(Fill).into(), + }; + let display = info.display; + let swatch = button(label) .on_press(Message::OpenColorWindow(target.clone(), info.color)) .width(Fill) .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(info.display)), - text_color: if info.display.r * 0.299 + info.display.g * 0.587 + info.display.b * 0.114 - > 0.55 - { + background: Some(Background::Color(display)), + text_color: if display.r * 0.299 + display.g * 0.587 + display.b * 0.114 > 0.55 { iced::Color::BLACK } else { iced::Color::WHITE @@ -624,134 +663,66 @@ fn color_control<'a>( }, ..Default::default() }); - let mode = if info.varies { - PropertyMode::Varies - } else { - match info.color { - acadrust::types::Color::ByLayer => PropertyMode::ByLayer, - acadrust::types::Color::ByBlock => PropertyMode::ByBlock, - _ => PropertyMode::Custom, - } - }; - let more = button(mode_button_content(mode)) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(COMPACT_BUTTON_HEIGHT) - .padding([1, 3]); - let more: Element<'a, Message> = if menu_open { - let choice = |label, color| { - button(text(label).size(11)) - .on_press(changed(color)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - choice("ByLayer", acadrust::types::Color::ByLayer), - choice("ByBlock", acadrust::types::Color::ByBlock), - button(text("Custom...").size(11)) - .on_press(Message::OpenColorWindow(target, info.color)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(popup_alignment(side)) - .offset(2.0) - .on_dismiss(close) - .into() - } else { - more.into() - }; - row![color, more].spacing(4).align_y(iced::Center).into() + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(changed(AcadColor::ByLayer))), + ("ByBlock", msg(changed(AcadColor::ByBlock))), + ("Custom...", Message::OpenColorWindow(target, info.color)), + ], + ); + row![swatch, more].spacing(4).align_y(iced::Center).into() } fn transparency_control<'a>( info: TransparencyInfo, - side: crate::app::config::DockSide, - menu_open: bool, - toggle: Message, - close: Message, - changed: fn(u8) -> Message, - by_layer: Message, - by_block: Message, + side: DockSide, + menu: Menu, + open_menu: Option, + changed: fn(Transparency) -> GraphicAttributesMsg, ) -> Element<'a, Message> { let value_label = if info.varies { "*VARIES*".to_string() } else { format!("{}%", info.value) }; + let percent = |value: u8| Transparency::from_percent(f64::from(value) / 100.0); let control = row![ crate::ui::icons::semantic(TRANSPARENCY_ICON, PALETTE_CONTROL_ICON_SIZE), text(value_label).size(10).width(38), - slider(0..=90, i32::from(info.value), move |value| changed( - value as u8 - )) + slider(0..=90, i32::from(info.value), move |value| { + msg(changed(percent(value as u8))) + }) .width(Fill), ] .spacing(5) .align_y(iced::Center) .height(crate::ui::ROW_H); - let mode = if info.varies { - PropertyMode::Varies - } else { - info.mode - }; - let more = button(mode_button_content(mode)) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([1, 3]); - let more: Element<'a, Message> = if menu_open { - let row_button = |label, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - row_button("ByLayer", by_layer), - row_button("ByBlock", by_block), - row_button("Custom", changed(info.value)), - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(popup_alignment(side)) - .offset(2.0) - .on_dismiss(close) - .into() - } else { - more.into() - }; + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + crate::ui::ROW_H, + vec![ + ("ByLayer", msg(changed(Transparency::BY_LAYER))), + ("ByBlock", msg(changed(Transparency::BY_BLOCK))), + ("Custom", msg(changed(percent(info.value)))), + ], + ); row![control, more].spacing(4).align_y(iced::Center).into() } -fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'static, Message> { +fn header_menu(side: 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) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Header))) .style(move |theme: &Theme, status| { if open { button::primary(theme, status) @@ -766,28 +737,16 @@ fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'stati let item = |label: &'static str, message| { button(text(label).size(11)) - .on_press(message) + .on_press(msg(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, - ), + item("Set all attributes ByLayer", GraphicAttributesMsg::SetAllByLayer), + item("Set all attributes ByBlock", GraphicAttributesMsg::SetAllByBlock), + item("Remove ByLayer / ByBlock", GraphicAttributesMsg::RemoveReferences), + item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), ]) .style(crate::ui::color_select::popup_panel_style) .padding(2); @@ -796,33 +755,11 @@ fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'stati .height(Length::Shrink) .alignment(popup_alignment(side)) .offset(2.0) - .on_dismiss(Message::CloseGraphicAttributesHeaderMenu) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) .into() } -pub fn view<'a>( - document: &'a CadDocument, - properties: &'a crate::ui::properties::PropertiesPanel, - selected: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - width: f32, - auto_collapse: bool, - side: crate::app::config::DockSide, - header_menu_open: bool, - menu_open: bool, - line_color_menu_open: bool, - line_linetype_menu_open: bool, - line_lineweight_menu_open: bool, - line_transparency_menu_open: bool, - solid_color_menu_open: bool, - transparency_menu_open: bool, - hatch_editor_open: bool, - hatch_pattern_search: &'a str, - hatch_pattern_focus: usize, - gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>, - gradient_color_picker_open: bool, -) -> Element<'a, Message> { - let menu = header_menu(side, header_menu_open); +fn header<'a>(side: DockSide, auto_collapse: bool, menu_open: bool) -> Element<'a, Message> { let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -834,12 +771,7 @@ pub fn view<'a>( ))) .style(button::subtle) .padding([3, 5]); - let pin = tooltip( - pin, - text(crate::t!("Auto")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); + let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); let close = button(crate::ui::icons::themed_secondary( crate::ui::icons::CLOSE, 12.0, @@ -847,18 +779,13 @@ pub fn view<'a>( .on_press(Message::Dock(DockMsg::Close(PanelId::GraphicAttributes))) .style(button::subtle) .padding([3, 5]); - let close = tooltip( - close, - text(crate::t!("Close")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); - let header = mouse_area( + let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); + mouse_area( container( row![ text(crate::t!("Graphic Attributes")).size(12), Space::new().width(Fill), - menu, + header_menu(side, menu_open), pin, close ] @@ -873,27 +800,29 @@ pub fn view<'a>( .padding([3, 6]), ) .on_press(Message::Dock(DockMsg::DockGrab(PanelId::GraphicAttributes))) - .interaction(iced::mouse::Interaction::Grab); + .interaction(iced::mouse::Interaction::Grab) + .into() +} - let current = current(document, selected); +fn fill_picker<'a>(current: GraphicAttribute, open: bool) -> Element<'a, Message> { let picker = button( row![ crate::ui::icons::semantic(current.icon(), 14.0), text(current.to_string()).size(11), Space::new().width(Fill), - crate::ui::icons::themed_arrow_toggle(menu_open, 9.0), + crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) .align_y(iced::Center), ) - .on_press(Message::ToggleGraphicAttributeDropdown) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Fill))) .style(move |theme: &Theme, _| { let palette = theme.palette(); button::Style { background: Some(Background::Color(palette.background.base.color)), text_color: palette.background.base.text, border: Border { - color: if menu_open { + color: if open { palette.primary.base.color } else { palette.background.neutral.color @@ -907,515 +836,509 @@ pub fn view<'a>( .height(crate::ui::ROW_H) .width(Fill) .padding([3, 6]); - let picker: Element<'_, Message> = if menu_open { - let mut rows = column![].spacing(0); - for value in GraphicAttribute::ALL { - rows = rows.push( - button( - row![ - crate::ui::icons::semantic(value.icon(), 14.0), - text(value.to_string()).size(11), - ] - .spacing(5) - .align_y(iced::Center), - ) - .on_press(Message::GraphicAttributeChanged(value)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]), - ); - } - let popup = container(rows) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - crate::ui::color_select::drop_down_below( - picker.into(), - popup.into(), - None, - Length::Shrink, - Message::CloseGraphicAttributeDropdown, + if !open { + return picker.into(); + } + let rows = column(GraphicAttribute::ALL.into_iter().map(|value| { + button( + row![ + crate::ui::icons::semantic(value.icon(), 14.0), + text(value.to_string()).size(11), + ] + .spacing(5) + .align_y(iced::Center), ) - } else { - picker.into() - }; - let line_handles = line_handles(document, selected); - let fill_handles = fill_handles(document, selected); - let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); - let no_selection = selected.is_empty(); - let line_color = color_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| { - let color = document.header.current_entity_color; - let display = if color == acadrust::types::Color::ByLayer { - document - .layers - .get(if document.header.current_layer_name.is_empty() { - "0" + .on_press(msg(GraphicAttributesMsg::Fill(value))) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() + })); + let popup = container(rows) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + crate::ui::color_select::drop_down_below( + picker.into(), + popup.into(), + None, + Length::Shrink, + msg(GraphicAttributesMsg::CloseMenu), + ) +} + +/// A preview button plus the `⋮` button that toggles its editor flyout. +fn preview_row<'a>( + preview: Element<'a, Message>, + editor_open: bool, + toggle: GraphicAttributesMsg, + tooltip_label: &'static str, + flyout: Option<(Element<'a, Message>, f32, Option)>, + side: DockSide, +) -> Element<'a, Message> { + let preview = button(preview) + .on_press(msg(toggle.clone())) + .width(Fill) + .height(COMPACT_BUTTON_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: if editor_open + || matches!(status, button::Status::Hovered | button::Status::Pressed) + { + theme.palette().primary.base.color } else { - &document.header.current_layer_name - }) - .map_or_else( - || crate::ui::properties::acad_color_display(color).0, - |layer| crate::ui::properties::acad_color_display(layer.color).0, - ) - } else { - crate::ui::properties::acad_color_display(color).0 - }; - SolidFillColorInfo { - color, - display, - varies: false, - } - }) - }); - let linetype = linetype_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LinetypeInfo { - value: current_linetype_name(document).to_string(), - varies: false, - }) - }); - let continuous_linetype = linetype.as_ref().is_some_and(|info| { - !info.varies && effective_linetype_is_continuous(document, &line_handles, draw_depth) - }); - let linetype_scale = linetype_scale_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LinetypeScaleInfo { - value: document.header.current_entity_linetype_scale, - varies: false, - }) - }); - if let Some(info) = linetype.as_ref() { - let selected = (!info.varies).then(|| crate::ui::properties::LinetypeItem { - // An empty name makes only the preview art visible in the closed - // Graphic Attributes control. Menu items retain their full names. - name: String::new(), - art: linetype_preview_art(document, &line_handles, draw_depth, &info.value), - }); - let combo = combo_box( - &properties.linetype_combo, - "*VARIES*", - selected.as_ref(), - |item: crate::ui::properties::LinetypeItem| Message::LineLinetypeChanged(item.name), - ) - .size(crate::ui::ROW_H * 0.42) - .padding(Padding { - top: 3.0, - bottom: 3.0, - left: 6.0, - right: 6.0, - }) - .input_style(crate::ui::properties::combo_input_style) - .width(Fill); - let more = by_mode_menu( - side, - if info.varies { - PropertyMode::Varies - } else if info.value.eq_ignore_ascii_case("ByLayer") { - PropertyMode::ByLayer - } else if info.value.eq_ignore_ascii_case("ByBlock") { - PropertyMode::ByBlock - } else { - PropertyMode::Custom + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), }, - line_linetype_menu_open, - Message::ToggleLineLinetypeMenu, - Message::CloseLineLinetypeMenu, - Message::LineLinetypeChanged("ByLayer".to_string()), - Message::LineLinetypeChanged("ByBlock".to_string()), - ); - body = body.push( - row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] - .spacing(4) - .align_y(iced::Center), - ); - } - let lineweight = lineweight_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LineweightInfo { - value: acadrust::types::LineWeight::from_value(document.header.current_line_weight), - varies: false, - }) - }); - if let Some(color) = line_color { - body = body.push(color_control( - color, - side, - line_color_menu_open, - crate::app::ColorPickTarget::GraphicAttributesLine, - Message::ToggleLineColorDropdown, - Message::CloseLineColorDropdown, - Message::LineColorChanged, - )); - } - if let (Some(scale), Some(lineweight)) = (linetype_scale, lineweight) { - let scale_field: Element<'_, Message> = if scale.varies { - text_input("*VARIES*", "") - .size(crate::ui::ROW_H * 0.42) - .padding([5, 5]) - .style(crate::ui::properties::combo_input_style) - .width(Fill) - .into() - } else { - let scale_value = normalized_linetype_scale(scale.value).unwrap_or(1.0); - let mut input = iced_aw::number_input( - &scale_value, - LINETYPE_SCALE_MIN..=LINETYPE_SCALE_MAX, - |value: f64| { - Message::LineLinetypeScaleChanged( - normalized_linetype_scale(value).unwrap_or(1.0), - ) - }, - ) - .step(LINETYPE_SCALE_STEP) - .set_size(crate::ui::ROW_H * 0.42) - .padding([5, 5]) - .input_style(move |theme: &Theme, status| { - let mut style = crate::ui::properties::combo_input_style(theme, status); - if continuous_linetype { - let disabled = theme.palette().background.base.text.scale_alpha(0.42); - style.value = disabled; - style.placeholder = disabled; - } - style - }) - .style(move |theme: &Theme, status| { - let text = theme.palette().background.base.text; - iced_aw::number_input::number_input::Style { - button_background: None, - icon_color: if continuous_linetype - || status == iced_aw::style::Status::Disabled - { - text.scale_alpha(0.42) - } else { - text - }, - } - }) - .width(Fill); - if continuous_linetype { - input = input.on_input_maybe(None:: Message>); + ..Default::default() + }); + let edit = button(compact_button_content("⋮")) + .on_press(msg(toggle)) + .style(move |theme: &Theme, status| { + if editor_open { + button::primary(theme, status) } else { - input = input.on_submit(Message::Noop); + button::secondary(theme, status) } - input.into() - }; - let scale_icon: Element<'_, Message> = if continuous_linetype { - crate::ui::icons::themed_disabled( - GraphicAttribute::Solid.icon(), - PALETTE_CONTROL_ICON_SIZE, - ) - } else { - crate::ui::icons::semantic( - GraphicAttribute::Solid.icon(), - PALETTE_CONTROL_ICON_SIZE, - ) - }; - let scale = row![scale_icon, scale_field] - .spacing(3) - .align_y(iced::Center) - .width(Length::FillPortion(1)); - let selected = (!lineweight.varies).then_some(crate::ui::properties::LwItem( - effective_lineweight(document, &line_handles, draw_depth, lineweight.value), - )); - let lineweight_mode = if lineweight.varies { - PropertyMode::Varies - } else { - match lineweight.value { - acadrust::types::LineWeight::ByLayer => PropertyMode::ByLayer, - acadrust::types::LineWeight::ByBlock => PropertyMode::ByBlock, - _ => PropertyMode::Custom, - } - }; - let lineweight_picker = combo_box( - &properties.lineweight_combo, - "*VARIES*", - selected.as_ref(), - |item: crate::ui::properties::LwItem| Message::LineLineweightChanged(item.0), - ) - .size(crate::ui::ROW_H * 0.42) - .padding(Padding { - top: 3.0, - bottom: 3.0, - left: 6.0, - right: 6.0, }) - .input_style(crate::ui::properties::combo_input_style) - .width(Fill); - let more = by_mode_menu( - side, - lineweight_mode, - line_lineweight_menu_open, - Message::ToggleLineLineweightMenu, - Message::CloseLineLineweightMenu, - Message::LineLineweightChanged(acadrust::types::LineWeight::ByLayer), - Message::LineLineweightChanged(acadrust::types::LineWeight::ByBlock), - ); - let lineweight = row![ - crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), - lineweight_picker, - more, - ] - .spacing(3) - .align_y(iced::Center) - .width(Length::FillPortion(1)); - body = body.push( - row![scale, lineweight] - .spacing(12) - .align_y(iced::Center) - .height(crate::ui::ROW_H), - ); - } - let line_transparency = transparency_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| { - let stored = document.current_entity_transparency(); - let effective = if stored.is_by_layer() { - document - .layers - .get(if document.header.current_layer_name.is_empty() { - "0" - } else { - &document.header.current_layer_name - }) - .map_or(stored, |layer| layer.transparency) - } else if stored.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - stored - }; - TransparencyInfo { - value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, - varies: false, - mode: if stored.is_by_layer() { - PropertyMode::ByLayer - } else if stored.is_by_block() { - PropertyMode::ByBlock - } else { - PropertyMode::Custom - }, + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); + let edit = tooltip(edit, text(tooltip_label).size(10), tooltip::Position::Bottom); + let edit: Element<'a, Message> = match flyout { + Some((popup, width, height)) => { + let mut drop_down = iced_aw::DropDown::new(edit, popup, true) + .width(Length::Fixed(width)) + .alignment(popup_alignment(side)) + .offset(4.0); + if let Some(height) = height { + drop_down = drop_down + .height(Length::Fixed(height)) + .on_dismiss(msg(GraphicAttributesMsg::HatchEditorClose)); + } + drop_down.into() } - }) + None => edit.into(), + }; + row![preview, edit].spacing(4).align_y(iced::Center).into() +} + +fn linetype_row<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + linetype: &str, + varies: bool, + layer: &str, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then(|| crate::ui::properties::LinetypeItem { + // An empty name shows only the preview art in the closed control; + // the menu items keep their full names. + name: String::new(), + art: linetype_preview_art(document, effective_linetype(document, layer, linetype)), }); - if let Some(info) = line_transparency { - body = body.push(transparency_control( - info, - side, - line_transparency_menu_open, - Message::ToggleLineTransparencyDropdown, - Message::CloseLineTransparencyDropdown, - Message::LineTransparencyChanged, - Message::LineTransparencyByLayer, - Message::LineTransparencyByBlock, - )); + let combo = combo_box( + &properties.linetype_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LinetypeItem| { + msg(GraphicAttributesMsg::LineLinetype(item.name)) + }, + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let mode = if varies { + PropertyMode::Varies + } else if linetype.eq_ignore_ascii_case("ByLayer") { + PropertyMode::ByLayer + } else if linetype.eq_ignore_ascii_case("ByBlock") { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }; + let more = mode_menu( + side, + mode, + Menu::LineLinetype, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))), + ("ByBlock", msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))), + ], + ); + row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] + .spacing(4) + .align_y(iced::Center) + .into() +} + +fn linetype_scale_field<'a>(scale: f64, varies: bool, continuous: bool) -> Element<'a, Message> { + let field: Element<'a, Message> = if varies { + text_input("*VARIES*", "") + .size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .style(crate::ui::properties::combo_input_style) + .width(Fill) + .into() + } else { + let value = normalized_linetype_scale(scale).unwrap_or(1.0); + let mut input = iced_aw::number_input( + &value, + (std::ops::Bound::Excluded(0.0), std::ops::Bound::Unbounded), + |value: f64| match normalized_linetype_scale(value) { + Some(value) => msg(GraphicAttributesMsg::LineLinetypeScale(value)), + None => Message::Noop, + }, + ) + .step(LINETYPE_SCALE_STEP) + .set_size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .input_style(move |theme: &Theme, status| { + let mut style = crate::ui::properties::combo_input_style(theme, status); + if continuous { + let disabled = theme.palette().background.base.text.scale_alpha(0.42); + style.value = disabled; + style.placeholder = disabled; + } + style + }) + .style(move |theme: &Theme, status| { + let text = theme.palette().background.base.text; + iced_aw::number_input::number_input::Style { + button_background: None, + icon_color: if continuous || status == iced_aw::style::Status::Disabled { + text.scale_alpha(0.42) + } else { + text + }, + } + }) + .width(Fill); + if continuous { + input = input.on_input_maybe(None:: Message>); + } else { + input = input.on_submit(Message::Noop); + } + input.into() + }; + let icon = GraphicAttribute::Solid.icon(); + let icon = if continuous { + crate::ui::icons::themed_disabled(icon, PALETTE_CONTROL_ICON_SIZE) + } else { + crate::ui::icons::semantic(icon, PALETTE_CONTROL_ICON_SIZE) + }; + row![icon, field] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +fn lineweight_field<'a>( + properties: &'a crate::ui::properties::PropertiesPanel, + stored: LineWeight, + effective: LineWeight, + varies: bool, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then_some(crate::ui::properties::LwItem(effective)); + let mode = if varies { + PropertyMode::Varies + } else { + match stored { + LineWeight::ByLayer => PropertyMode::ByLayer, + LineWeight::ByBlock => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + }; + let picker = combo_box( + &properties.lineweight_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LwItem| msg(GraphicAttributesMsg::LineLineweight(item.0)), + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let more = mode_menu( + side, + mode, + Menu::LineLineweight, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))), + ("ByBlock", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))), + ], + ); + row![ + crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), + picker, + more, + ] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +/// The Line section: values of the selection's line objects, or the +/// creation defaults when nothing is selected. +fn line_section<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + handles: &[Handle], + no_selection: bool, + draw_depth: &DrawDepth, + side: DockSide, + open_menu: Option, +) -> Vec> { + let mut rows = Vec::new(); + if handles.is_empty() && !no_selection { + return rows; } + let layer = top_layer(document, handles, draw_depth).to_string(); + let header = &document.header; + let (linetype, linetype_varies) = shared_value(document, handles, draw_depth, |c| { + c.linetype.clone() + }) + .map_or_else( + || (current_linetype_name(document).to_string(), false), + |(_, value, varies)| (if value.is_empty() { "ByLayer".into() } else { value }, varies), + ); + let (scale, scale_varies) = + shared_value(document, handles, draw_depth, |c| c.linetype_scale).map_or( + (header.current_entity_linetype_scale, false), + |(_, value, varies)| (value, varies), + ); + let (lineweight, lineweight_varies) = + shared_value(document, handles, draw_depth, |c| c.line_weight).map_or( + (LineWeight::from_value(header.current_line_weight), false), + |(_, value, varies)| (value, varies), + ); + let color = color_info(document, handles, draw_depth) + .unwrap_or_else(|| current_color_info(document)); + let transparency = transparency_info(document, handles, draw_depth).unwrap_or_else(|| { + TransparencyInfo::new(document, &layer, document.current_entity_transparency(), false) + }); + let continuous = + !linetype_varies && is_continuous(effective_linetype(document, &layer, &linetype)); + + rows.push(linetype_row( + document, + properties, + &linetype, + linetype_varies, + &layer, + side, + open_menu, + )); + rows.push(color_control( + color, + side, + Menu::LineColor, + open_menu, + ColorPickTarget::GraphicAttributesLine, + GraphicAttributesMsg::LineColor, + )); + let effective = effective_lineweight(document, handles, draw_depth, lineweight); + rows.push( + row![ + linetype_scale_field(scale, scale_varies, continuous), + lineweight_field( + properties, + lineweight, + effective, + lineweight_varies, + side, + open_menu + ), + ] + .spacing(12) + .align_y(iced::Center) + .height(crate::ui::ROW_H) + .into(), + ); + rows.push(transparency_control( + transparency, + side, + Menu::LineTransparency, + open_menu, + GraphicAttributesMsg::LineTransparency, + )); + rows +} + +pub fn view<'a>( + state: &'a GraphicAttributesState, + scene: &'a Scene, + properties: &'a crate::ui::properties::PropertiesPanel, + width: f32, + auto_collapse: bool, + side: DockSide, + gradient_color_picking: bool, +) -> Element<'a, Message> { + let document = &scene.document; + let selected = scene.selected_handles_in_order(); + let draw_depth = scene.draw_depth_map(); + let draw_depth = draw_depth.as_ref(); + let open_menu = state.open_menu; + let line_handles = line_handles(document, &selected); + let fill_handles = fill_handles(document, &selected); + + let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); + body = body.extend(line_section( + document, + properties, + &line_handles, + selected.is_empty(), + draw_depth, + side, + open_menu, + )); + + let current = current(document, &selected); body = body .push(Space::new().height(2)) .push(text(crate::t!("Fill")).size(11)) - .push(picker); + .push(fill_picker(current, open_menu == Some(Menu::Fill))); if !matches!(current, GraphicAttribute::None | GraphicAttribute::Gradient) { if let Some(info) = color_info(document, &fill_handles, draw_depth) { body = body.push(color_control( info, side, - solid_color_menu_open, - crate::app::ColorPickTarget::GraphicAttributesSolid, - Message::ToggleSolidFillColorDropdown, - Message::CloseSolidFillColorDropdown, - Message::SolidFillColorChanged, + Menu::FillColor, + open_menu, + ColorPickTarget::GraphicAttributesSolid, + GraphicAttributesMsg::FillColor, )); } } - if current == GraphicAttribute::Gradient { - if let Some(hatch) = - top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Gradient) - { - let toggle = if gradient_editor.is_some() { - Message::GradientCancel - } else { - Message::GradientEditorOpen - }; - let preview_image = image(crate::ui::window::gradient_editor::compact_preview(hatch)) - .width(Fill) - .height(Fill) - .content_fit(iced::ContentFit::Fill); - let preview_content: Element<'_, Message> = if gradients_vary(document, selected) { - iced::widget::stack![ - preview_image, - container(text("*VARIES*").size(10)) - .width(Fill) - .height(Fill) - .align_x(iced::Left) - .align_y(iced::Center) - .padding([0, 6]), - ] - .into() - } else { - preview_image.into() - }; - let preview = button(preview_content) - .on_press(toggle.clone()) - .width(Fill) - .height(crate::ui::ROW_H) - .padding(0) - .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: if gradient_editor.is_some() - || matches!(status, button::Status::Hovered | button::Status::Pressed) - { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + match current { + GraphicAttribute::Hatch => { + let hatch = top_fill_hatch(document, &fill_handles, draw_depth, current); + let preview = hatch + .and_then(|hatch| Scene::hatch_model_from_dxf(hatch, [1.0; 4])) + .map(|model| crate::ui::properties::compact_hatch_pattern_preview(model.pattern)) + .unwrap_or_else(|| Space::new().width(Fill).height(Fill).into()); + let flyout = state.hatch_editor.as_ref().map(|editor| { + let picker = crate::ui::properties::hatch_pattern_picker_content( + &editor.search, + editor.focus, + hatch.map_or("", |hatch| hatch.pattern.name.as_str()), + "graphic-hatch-pattern-search", + |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)), + msg(GraphicAttributesMsg::HatchPatternConfirm), + |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)), + |name| msg(GraphicAttributesMsg::HatchPattern(name)), + ); + let popup = container(picker) + .padding(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + (popup.into(), 352.0, Some(724.0)) }); - let edit = button(compact_button_content("⋮")) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if gradient_editor.is_some() { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(COMPACT_BUTTON_HEIGHT) - .padding([1, 3]); - let edit = tooltip( - edit, - text("Edit gradient").size(10), - tooltip::Position::Bottom, - ); - let edit: Element<'_, Message> = if let Some(editor) = gradient_editor { - let alignment = match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => { - iced_aw::drop_down::Alignment::BottomStart - } - }; - iced_aw::DropDown::new( - edit, - crate::ui::window::gradient_editor::view(editor, gradient_color_picker_open), - true, - ) - .width(Length::Fixed(390.0)) - .alignment(alignment) - .offset(4.0) - .into() - } else { - edit.into() - }; - body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); - } - } else if current == GraphicAttribute::Hatch { - let hatch = top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Hatch); - let current_pattern = hatch.map_or("", |hatch| hatch.pattern.name.as_str()); - let preview_content: Element<'_, Message> = hatch - .and_then(|hatch| { - crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) - .map(|model| model.pattern) - }) - .map(crate::ui::properties::compact_hatch_pattern_preview) - .unwrap_or_else(|| { - container(Space::new()) - .width(Fill) - .height(crate::ui::ROW_H) - .into() - }); - let preview = button(preview_content) - .on_press(if hatch_editor_open { - Message::HatchEditorClose - } else { - Message::HatchEditorOpen - }) - .width(Fill) - .height(COMPACT_BUTTON_HEIGHT) - .padding(0) - .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: if hatch_editor_open - || matches!(status, button::Status::Hovered | button::Status::Pressed) - { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }); - let edit = button(compact_button_content("⋮")) - .on_press(if hatch_editor_open { - Message::HatchEditorClose - } else { - Message::HatchEditorOpen - }) - .style(move |theme: &Theme, status| { - if hatch_editor_open { - button::primary(theme, status) + let open = state.hatch_editor.is_some(); + body = body.push(preview_row( + preview, + open, + if open { + GraphicAttributesMsg::HatchEditorClose } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([1, 3]); - let edit = tooltip(edit, text("Edit hatch").size(10), tooltip::Position::Bottom); - let edit: Element<'_, Message> = if hatch_editor_open { - let popup = crate::ui::properties::hatch_pattern_picker_content( - hatch_pattern_search, - hatch_pattern_focus, - current_pattern, - "graphic-hatch-pattern-search", - Message::GraphicHatchPatternSearchChanged, - Message::GraphicHatchPatternConfirm, - Message::GraphicHatchPatternFocus, - Message::GraphicHatchPatternChanged, - ); - let popup = container(popup) - .padding(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().primary.base.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() + GraphicAttributesMsg::HatchEditorOpen + }, + "Edit hatch", + flyout, + side, + )); + } + GraphicAttribute::Gradient => { + if let Some(hatch) = top_fill_hatch(document, &fill_handles, draw_depth, current) { + let preview = crate::ui::window::gradient_editor::compact_preview(hatch); + let preview = if gradients_vary(document, &fill_handles) { + iced::widget::stack![ + preview, + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]), + ] + .into() + } else { + preview + }; + let flyout = state.gradient_editor.as_ref().map(|editor| { + ( + crate::ui::window::gradient_editor::view(editor, gradient_color_picking), + 390.0, + None, + ) }); - iced_aw::DropDown::new(edit, popup, true) - .width(Length::Fixed(352.0)) - .height(Length::Fixed(724.0)) - .alignment(popup_alignment(side)) - .offset(4.0) - .on_dismiss(Message::HatchEditorClose) - .into() - } else { - edit.into() - }; - body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); + let open = state.gradient_editor.is_some(); + body = body.push(preview_row( + preview, + open, + GraphicAttributesMsg::Gradient(if open { + GradientMsg::Cancel + } else { + GradientMsg::Open + }), + "Edit gradient", + flyout, + side, + )); + } + } + GraphicAttribute::None | GraphicAttribute::Solid | GraphicAttribute::Varies => {} } if current != GraphicAttribute::None { if let Some(info) = transparency_info(document, &fill_handles, draw_depth) { body = body.push(transparency_control( info, side, - transparency_menu_open, - Message::ToggleFillTransparencyDropdown, - Message::CloseFillTransparencyDropdown, - Message::FillTransparencyChanged, - Message::FillTransparencyByLayer, - Message::FillTransparencyByBlock, + Menu::FillTransparency, + open_menu, + GraphicAttributesMsg::FillTransparency, )); } } - let body = body.padding(8); - container(column![header, body]) - .width(Length::Fixed(width)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - ..Default::default() - }) - .into() + container(column![ + header(side, auto_collapse, open_menu == Some(Menu::Header)), + body.padding(8) + ]) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .into() } diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 0944cb779..3b5421bd7 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,32 +1,32 @@ pub mod about; -pub mod alias_editor; -pub mod annotation_data; -pub mod attribute_editor; -pub mod auto_constrain_settings; pub mod block_definition; pub mod block_palette; pub mod browser; -pub mod drafting_settings; +pub mod layout_manager; +pub mod layer_state_manager; pub mod drawing_units; -pub mod find_replace; pub mod geometric_tolerance; pub mod gradient_editor; pub mod graphic_attributes; -pub mod layer_state_manager; +pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; -pub mod layers; -pub mod layout_manager; -pub mod missing_fonts; -pub mod named_parameters; -pub mod open_progress; -pub mod options; pub mod plot; -pub mod plugin_manager; pub mod print_all; -pub mod recovery; +pub mod plugin_manager; pub mod shortcuts; +pub mod layers; pub mod update_notice; -pub mod wblock; +pub mod open_progress; +pub mod missing_fonts; +pub mod recovery; +pub mod options; +pub mod attribute_editor; +pub mod annotation_data; +pub mod alias_editor; +pub mod find_replace; +pub mod named_parameters; pub mod xref_attach; pub mod xref_help; pub mod xref_manager; +pub mod wblock; From 119131ee101b7fe90d4b62fa145bf86fc49c39e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:45:38 +0000 Subject: [PATCH 07/10] Graphic Attributes: faster view, fills on object layer, dock on first show - The palette view looks fills up through an index of associative fills that is rebuilt only when the scene's geometry epoch changes, instead of scanning the drawing twice per frame. The gradient previews are cached and rebuilt only when the gradient changes. - New fills go to the layer of the filled object. A checkbox in the palette menu ("Create fills on the current layer") restores the HATCH behaviour and is saved in the config. Objects on locked layers are skipped with a message. - The palette is no longer part of the default dock layout; it is docked below Properties the first time it is shown and keeps a place the user chose. The original dock test is restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- src/app/config.rs | 4 + src/app/update/file.rs | 5 + src/app/update/graphic_attributes.rs | 142 +++++++++++++++++-- src/app/update/mod.rs | 4 +- src/ui/dock.rs | 25 ++-- src/ui/window/gradient_editor.rs | 33 +++-- src/ui/window/graphic_attributes.rs | 199 +++++++++++++++++++-------- 7 files changed, 326 insertions(+), 86 deletions(-) diff --git a/src/app/config.rs b/src/app/config.rs index 68e329c44..01e6354eb 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -32,6 +32,9 @@ pub struct AppConfig { pub dock: crate::ui::dock::DockState, /// Whether the optional Graphic Attributes palette is visible. pub show_graphic_attributes: bool, + /// Graphic Attributes creates fills on the current layer instead of the + /// layer of the filled object. + pub graphic_fills_on_current_layer: bool, /// Add a newly selected annotation scale to existing annotative objects. pub annotation_auto_scale: i8, /// Ribbon collapse density. @@ -55,6 +58,7 @@ impl Default for AppConfig { statusbar: StatusBarConfig::default(), dock: crate::ui::dock::DockState::default(), show_graphic_attributes: false, + graphic_fills_on_current_layer: false, annotation_auto_scale: -4, ribbon: RibbonConfig::default(), plot: PlotDialogState::default(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ab8db72ea..22171ca59 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1087,6 +1087,7 @@ impl OpenCADStudio { dock }, show_graphic_attributes: self.show_graphic_attributes, + graphic_fills_on_current_layer: self.graphic_attributes.fill_on_current_layer, annotation_auto_scale: self.annotation_auto_scale, ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), @@ -1146,6 +1147,10 @@ impl OpenCADStudio { dock.ensure_settings(); self.dock = dock; self.show_graphic_attributes = cfg.show_graphic_attributes; + self.graphic_attributes.fill_on_current_layer = cfg.graphic_fills_on_current_layer; + if self.show_graphic_attributes { + self.dock.dock_below_properties(crate::ui::dock::PanelId::GraphicAttributes); + } self.ribbon .set_graphic_attributes(self.show_graphic_attributes); self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs index 52fb76ace..36dc86b5a 100644 --- a/src/app/update/graphic_attributes.rs +++ b/src/app/update/graphic_attributes.rs @@ -233,6 +233,11 @@ impl OpenCADStudio { M::SetAllByLayer => self.on_graphic_attributes_set_all(true), M::SetAllByBlock => self.on_graphic_attributes_set_all(false), M::RemoveReferences => self.on_graphic_attributes_remove_references(), + M::ToggleFillOnCurrentLayer => { + self.graphic_attributes.fill_on_current_layer ^= true; + self.save_config(); + Task::none() + } M::CreateLayer => { use crate::command::CadCommand; let command = crate::command::FreeTextValuePromptCommand::new( @@ -668,11 +673,12 @@ impl OpenCADStudio { .iter() .copied() .filter(|handle| { - self.tabs[i] - .scene - .document - .get_entity(*handle) - .is_some_and(is_open_fill_boundary) + !self.tabs[i].scene.is_layer_locked(*handle) + && self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(is_open_fill_boundary) }) .collect(); if !open_boundaries.is_empty() { @@ -712,6 +718,19 @@ impl OpenCADStudio { selected: &[Handle], to_close: &[Handle], ) -> Task { + let (selected, locked): (Vec, Vec) = selected + .iter() + .partition(|handle| !self.tabs[i].scene.is_layer_locked(**handle)); + if !locked.is_empty() { + self.command_line.push_info( + crate::tf!( + "Graphic Attributes: {} object(s) on a locked layer were not changed.", + locked.len() + ) + .as_ref(), + ); + } + let selected = selected.as_slice(); let pending = self.begin_undo(i, "Graphic Attributes", selected.len(), true); let mut changed = false; for handle in to_close { @@ -809,7 +828,12 @@ impl OpenCADStudio { .with_origin(origin) .with_pattern(name.clone(), pattern.clone()); if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { - new_hatches.push(hatch); + let layer = if self.graphic_attributes.fill_on_current_layer { + self.tabs[i].active_layer.clone() + } else { + entity.common().layer.clone() + }; + new_hatches.push((hatch, layer)); changed_boundaries.insert(*handle); } else if !is_open_fill_boundary(entity) { // Open boundaries the user chose not to close are expected @@ -861,8 +885,7 @@ impl OpenCADStudio { for entity in updated_hatches { self.tabs[i].scene.update_entity(entity); } - let layer = self.tabs[i].active_layer.clone(); - for hatch in new_hatches { + for (hatch, layer) in new_hatches { self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); } true @@ -1690,4 +1713,107 @@ mod tests { assert!(!app.ribbon.show_graphic_attributes); assert!(!app.current_config().show_graphic_attributes); } + + fn closed_square_on(app: &mut OpenCADStudio, layer: &str) -> Handle { + let i = app.active_tab; + if app.tabs[i].scene.document.layers.get(layer).is_none() { + let mut new_layer = acadrust::tables::Layer::new(layer); + new_layer.handle = app.tabs[i].scene.document.allocate_handle(); + let _ = app.tabs[i].scene.document.layers.add(new_layer); + } + let handle = open_square(app); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let EntityType::LwPolyline(polyline) = entity { + polyline.is_closed = true; + } + entity.common_mut().layer = layer.to_string(); + } + handle + } + + fn fill_layers(app: &OpenCADStudio) -> Vec { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch.common.layer.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn new_fills_follow_the_object_layer_unless_the_menu_option_is_set() { + let mut app = fresh(); + closed_square_on(&mut app, "Walls"); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec!["Walls".to_string()]); + + let mut app = fresh(); + let current = app.tabs[app.active_tab].active_layer.clone(); + closed_square_on(&mut app, "Walls"); + let _ = app.update(ga(GraphicAttributesMsg::ToggleFillOnCurrentLayer)); + assert!(app.current_config().graphic_fills_on_current_layer); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec![current]); + } + + #[test] + fn objects_on_locked_layers_get_no_fill() { + let mut app = fresh(); + let i = app.active_tab; + closed_square_on(&mut app, "Locked"); + closed_square_on(&mut app, "Open"); + app.tabs[i] + .scene + .document + .layers + .get_mut("Locked") + .expect("locked layer") + .lock(); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec!["Open".to_string()]); + } + + #[test] + fn palette_fill_lookup_follows_document_changes() { + let mut app = fresh(); + let i = app.active_tab; + let boundary = closed_square_on(&mut app, "0"); + let selected = vec![boundary]; + let (fills, kind) = app.graphic_attributes.fills(&app.tabs[i].scene, &selected); + assert!(fills.is_empty()); + assert_eq!(kind, GraphicAttribute::None); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let (fills, kind) = app.graphic_attributes.fills(&app.tabs[i].scene, &selected); + assert_eq!(fills.len(), 1); + assert_eq!(kind, GraphicAttribute::Solid); + } + + #[test] + fn showing_the_palette_docks_it_below_properties_once() { + use crate::app::config::DockSide; + use crate::ui::dock::PanelId; + let mut app = OpenCADStudio::new_for_test(); + app.apply_config(crate::app::config::AppConfig::default()); + assert_eq!(app.dock.location(PanelId::GraphicAttributes), None); + + let _ = app.update(Message::ToggleGraphicAttributes); + let properties = app.dock.location(PanelId::Properties).expect("properties docked"); + assert_eq!( + app.dock.location(PanelId::GraphicAttributes), + Some((properties.0, properties.1 + 1)) + ); + + // A place the user chose is kept when the palette is shown again. + app.dock.dock(PanelId::GraphicAttributes, DockSide::Right, 0); + let _ = app.update(Message::ToggleGraphicAttributes); + let _ = app.update(Message::ToggleGraphicAttributes); + assert_eq!( + app.dock.location(PanelId::GraphicAttributes), + Some((DockSide::Right, 0)) + ); + } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 82adcf37a..2d682e884 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4161,7 +4161,9 @@ impl OpenCADStudio { Message::ToggleGraphicAttributes => { self.show_graphic_attributes ^= true; self.ribbon.set_graphic_attributes(self.show_graphic_attributes); - if !self.show_graphic_attributes { + if self.show_graphic_attributes { + self.dock.dock_below_properties(crate::ui::dock::PanelId::GraphicAttributes); + } else { self.graphic_attributes.close_popups(); } self.save_config(); diff --git a/src/ui/dock.rs b/src/ui/dock.rs index caf96dd62..975bd5e58 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -123,7 +123,7 @@ pub struct DockState { impl Default for DockState { fn default() -> Self { Self { - left: vec![PanelId::Properties, PanelId::GraphicAttributes], + left: vec![PanelId::Properties], right: vec![PanelId::BlockPalette], panels: BTreeMap::new(), } @@ -159,13 +159,18 @@ impl DockState { ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } - if self.location(PanelId::GraphicAttributes).is_none() { - if let Some((side, index)) = self.location(PanelId::Properties) { - self.stack_mut(side).insert(index + 1, PanelId::GraphicAttributes); - } else { - self.left.push(PanelId::GraphicAttributes); - } + } + + /// Dock `id` directly below Properties (or at the end of the left edge) + /// unless it already has a place, which is then kept. + pub fn dock_below_properties(&mut self, id: PanelId) { + if self.location(id).is_some() { + return; } + let (side, index) = self + .location(PanelId::Properties) + .map_or((DockSide::Left, usize::MAX), |(side, index)| (side, index + 1)); + self.dock(id, side, index); } /// Where (if anywhere) a panel is currently docked. @@ -273,10 +278,6 @@ mod tests { fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); - assert_eq!( - state.location(PanelId::GraphicAttributes), - Some((DockSide::Left, 1)) - ); assert_eq!( state.location(PanelId::BlockPalette), Some((DockSide::Right, 0)) @@ -302,7 +303,7 @@ mod tests { Some((DockSide::Right, 0)) ); // It no longer occupies the left edge. - assert_eq!(state.left, vec![PanelId::GraphicAttributes]); + assert!(state.left.is_empty()); assert_eq!(state.right.len(), 2); } diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs index 30d6925bc..a51976f1d 100644 --- a/src/ui/window/gradient_editor.rs +++ b/src/ui/window/gradient_editor.rs @@ -27,8 +27,12 @@ pub struct GradientEditorState { pub inverted: bool, pub angle: String, pub centered: bool, + /// Large preview, rebuilt only when a setting it depends on changes. + preview: std::cell::RefCell)>>, } +type PreviewKey = (GradientColorMode, AcadColor, AcadColor, u32, GradientKind, bool, String, bool); + #[derive(Debug, Clone)] pub enum GradientMsg { Open, @@ -77,6 +81,7 @@ impl GradientEditorState { inverted, angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()), centered: hatch.gradient_color.shift.abs() < 1e-9, + preview: Default::default(), } } @@ -191,18 +196,30 @@ fn preview_sized( } fn preview(state: &GradientEditorState) -> Element<'static, Message> { - preview_image(preview_sized(state, 370, 80)) + let key: PreviewKey = ( + state.color_mode, + state.color_1, + state.color_2, + state.shade_tint.to_bits(), + state.kind, + state.inverted, + state.angle.clone(), + state.centered, + ); + let mut cache = state.preview.borrow_mut(); + if cache.as_ref().is_none_or(|(cached, _)| *cached != key) { + *cache = Some((key, preview_sized(state, 370, 80))); + } + preview_image(cache.as_ref().and_then(|(_, handle)| handle.clone())) } -pub(crate) fn compact_preview(hatch: &acadrust::entities::Hatch) -> Element<'static, Message> { - preview_image(preview_sized( - &GradientEditorState::from_hatch(Vec::new(), hatch), - 256, - 26, - )) +pub(crate) fn compact_preview( + hatch: &acadrust::entities::Hatch, +) -> Option { + preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26) } -fn preview_image(handle: Option) -> Element<'static, Message> { +pub(crate) fn preview_image(handle: Option) -> Element<'static, Message> { match handle { Some(handle) => image(handle) .width(Fill) diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index e1a50bc55..5d327e91b 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -6,15 +6,16 @@ use crate::app::{ColorPickTarget, Message}; use crate::scene::Scene; use crate::ui::dock::{DockMsg, PanelId}; use crate::ui::window::gradient_editor::{GradientEditorState, GradientMsg}; -use acadrust::entities::{EntityCommon, Hatch}; +use acadrust::entities::{EntityCommon, Hatch, HatchGradientPattern}; use acadrust::types::{Color as AcadColor, LineWeight, Transparency}; use acadrust::{CadDocument, EntityType, Handle}; use iced::widget::{ - button, column, combo_box, container, mouse_area, row, slider, text, text_input, tooltip, + button, image, column, combo_box, container, mouse_area, row, slider, text, text_input, tooltip, Space, }; use iced::{Background, Border, Element, Fill, Length, Padding, Theme}; use rustc_hash::{FxHashMap, FxHashSet}; +use std::cell::RefCell; type DrawDepth = FxHashMap; @@ -101,6 +102,8 @@ pub enum GraphicAttributesMsg { SetAllByBlock, RemoveReferences, CreateLayer, + /// Put new fills on the current layer instead of their object's layer. + ToggleFillOnCurrentLayer, Fill(GraphicAttribute), LineColor(AcadColor), LineLinetype(String), @@ -145,6 +148,12 @@ pub struct GraphicAttributesState { pub hatch_editor: Option, pub gradient_editor: Option, pub pending_fill_close: Option, + /// New fills go to the current layer instead of their object's layer. + pub fill_on_current_layer: bool, + /// View-side caches; `view` runs after every message, so neither the + /// fill lookup nor the gradient preview is rebuilt unless its input changed. + fill_index: RefCell>, + gradient_preview: RefCell)>>, } impl GraphicAttributesState { @@ -154,6 +163,36 @@ impl GraphicAttributesState { self.gradient_editor = None; } + /// The fills and fill kind of `selected`, from an index of associative + /// fills that is rebuilt only when the scene's geometry changes. + pub(crate) fn fills(&self, scene: &Scene, selected: &[Handle]) -> (Vec, GraphicAttribute) { + let key = (std::ptr::from_ref(scene) as usize, scene.geometry_epoch); + let mut cache = self.fill_index.borrow_mut(); + if cache.as_ref().is_none_or(|(cached, _)| *cached != key) { + *cache = Some((key, FillIndex::build(&scene.document))); + } + let index = &cache.as_ref().expect("fill index was just built").1; + ( + index.fill_handles(&scene.document, selected), + index.current(&scene.document, selected), + ) + } + + fn gradient_preview(&self, hatch: &Hatch) -> Element<'static, Message> { + let mut cache = self.gradient_preview.borrow_mut(); + if cache + .as_ref() + .is_none_or(|(gradient, _)| *gradient != hatch.gradient_color) + { + *cache = Some(( + hatch.gradient_color.clone(), + crate::ui::window::gradient_editor::compact_preview(hatch), + )); + } + let handle = cache.as_ref().and_then(|(_, handle)| handle.clone()); + crate::ui::window::gradient_editor::preview_image(handle) + } + /// Whether a flyout or question is bound to the selection it was opened for. pub fn tracks_selection(&self) -> bool { self.hatch_editor.is_some() @@ -190,62 +229,91 @@ impl GraphicAttributesState { } } -/// The fill kind of each selected object: its own kind for a selected HATCH, -/// otherwise the kind of the associative HATCH it bounds. -pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { - // Scan the document once, rather than once per selected object on every frame. - let mut fills: FxHashMap> = - selected.iter().map(|handle| (*handle, None)).collect(); - for entity in document.entities() { - let EntityType::Hatch(hatch) = entity else { - continue; - }; - let value = GraphicAttribute::of(hatch); - if let Some(fill) = fills.get_mut(&entity.common().handle) { - fill.get_or_insert(value); - } - if !hatch.is_associative { - continue; - } - for handle in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { - if let Some(fill) = fills.get_mut(handle) { - fill.get_or_insert(value); +/// Associative HATCH entities per boundary object, in document order. +#[derive(Debug, Default)] +pub(crate) struct FillIndex { + by_boundary: FxHashMap>, +} + +impl FillIndex { + pub(crate) fn build(document: &CadDocument) -> Self { + let mut by_boundary: FxHashMap> = FxHashMap::default(); + for entity in document.entities() { + let EntityType::Hatch(hatch) = entity else { + continue; + }; + if !hatch.is_associative { + continue; + } + for boundary in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { + let fills = by_boundary.entry(*boundary).or_default(); + if !fills.contains(&hatch.common.handle) { + fills.push(hatch.common.handle); + } } } + Self { by_boundary } } - let Some((&first, rest)) = selected.split_first() else { - return GraphicAttribute::None; - }; - let first = fills[&first].unwrap_or(GraphicAttribute::None); - if rest - .iter() - .any(|handle| fills[handle].unwrap_or(GraphicAttribute::None) != first) - { - GraphicAttribute::Varies - } else { - first + + /// The fill kind of each selected object: its own kind for a selected + /// HATCH, otherwise the kind of the first associative HATCH it bounds. + pub(crate) fn current(&self, document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { + let fill_of = |handle: &Handle| { + let own = match document.get_entity(*handle) { + Some(EntityType::Hatch(hatch)) => return GraphicAttribute::of(hatch), + _ => None, + }; + own.or_else(|| { + self.by_boundary.get(handle)?.iter().find_map(|fill| { + match document.get_entity(*fill)? { + EntityType::Hatch(hatch) => Some(GraphicAttribute::of(hatch)), + _ => None, + } + }) + }) + .unwrap_or(GraphicAttribute::None) + }; + let Some((first, rest)) = selected.split_first() else { + return GraphicAttribute::None; + }; + let first = fill_of(first); + if rest.iter().any(|handle| fill_of(handle) != first) { + GraphicAttribute::Varies + } else { + first + } + } + + /// HATCH entities that are selected or associated with a selected boundary. + pub(crate) fn fill_handles(&self, document: &CadDocument, selected: &[Handle]) -> Vec { + let mut seen = FxHashSet::default(); + let mut fills = Vec::new(); + for handle in selected { + if matches!(document.get_entity(*handle), Some(EntityType::Hatch(_))) + && seen.insert(*handle) + { + fills.push(*handle); + } + for fill in self.by_boundary.get(handle).into_iter().flatten() { + if document.get_entity(*fill).is_some() && seen.insert(*fill) { + fills.push(*fill); + } + } + } + fills } } +/// The fill kind of the selection; see [`FillIndex::current`]. +#[cfg(test)] +pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { + FillIndex::build(document).current(document, selected) +} + /// HATCH entities that are selected or associated with a selected boundary. +/// Scans the whole drawing; the palette view uses its cached [`FillIndex`]. pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { - let selected: FxHashSet<_> = selected.iter().copied().collect(); - document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - let directly_selected = selected.contains(&entity.common().handle); - let selected_boundary = hatch.is_associative - && hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .any(|handle| selected.contains(handle)); - (directly_selected || selected_boundary).then_some(entity.common().handle) - }) - .collect() + FillIndex::build(document).fill_handles(document, selected) } /// Selected non-HATCH objects plus the boundaries of selected associative @@ -717,7 +785,7 @@ fn transparency_control<'a>( row![control, more].spacing(4).align_y(iced::Center).into() } -fn header_menu(side: DockSide, open: bool) -> Element<'static, Message> { +fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Element<'static, Message> { let menu_button = button(crate::ui::icons::themed_secondary( crate::ui::icons::MENU, 12.0, @@ -747,6 +815,14 @@ fn header_menu(side: DockSide, open: bool) -> Element<'static, Message> { item("Set all attributes ByBlock", GraphicAttributesMsg::SetAllByBlock), item("Remove ByLayer / ByBlock", GraphicAttributesMsg::RemoveReferences), item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), + container( + iced::widget::checkbox(fill_on_current_layer) + .label("Create fills on the current layer") + .on_toggle(|_| msg(GraphicAttributesMsg::ToggleFillOnCurrentLayer)) + .size(12) + .text_size(11), + ) + .padding([4, 8]), ]) .style(crate::ui::color_select::popup_panel_style) .padding(2); @@ -759,7 +835,12 @@ fn header_menu(side: DockSide, open: bool) -> Element<'static, Message> { .into() } -fn header<'a>(side: DockSide, auto_collapse: bool, menu_open: bool) -> Element<'a, Message> { +fn header<'a>( + side: DockSide, + auto_collapse: bool, + menu_open: bool, + fill_on_current_layer: bool, +) -> Element<'a, Message> { let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -785,7 +866,7 @@ fn header<'a>(side: DockSide, auto_collapse: bool, menu_open: bool) -> Element<' row![ text(crate::t!("Graphic Attributes")).size(12), Space::new().width(Fill), - header_menu(side, menu_open), + header_menu(side, menu_open, fill_on_current_layer), pin, close ] @@ -1202,7 +1283,7 @@ pub fn view<'a>( let draw_depth = draw_depth.as_ref(); let open_menu = state.open_menu; let line_handles = line_handles(document, &selected); - let fill_handles = fill_handles(document, &selected); + let (fill_handles, current) = state.fills(scene, &selected); let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); body = body.extend(line_section( @@ -1215,7 +1296,6 @@ pub fn view<'a>( open_menu, )); - let current = current(document, &selected); body = body .push(Space::new().height(2)) .push(text(crate::t!("Fill")).size(11)) @@ -1279,7 +1359,7 @@ pub fn view<'a>( } GraphicAttribute::Gradient => { if let Some(hatch) = top_fill_hatch(document, &fill_handles, draw_depth, current) { - let preview = crate::ui::window::gradient_editor::compact_preview(hatch); + let preview = state.gradient_preview(hatch); let preview = if gradients_vary(document, &fill_handles) { iced::widget::stack![ preview, @@ -1331,7 +1411,12 @@ pub fn view<'a>( } container(column![ - header(side, auto_collapse, open_menu == Some(Menu::Header)), + header( + side, + auto_collapse, + open_menu == Some(Menu::Header), + state.fill_on_current_layer + ), body.padding(8) ]) .width(Length::Fixed(width)) From c21a1e653894c04a2c11b0da3df1d98576da7037 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 18:38:04 +0000 Subject: [PATCH 08/10] Graphic Attributes: tidy helpers, split tests, translate the palette - The hatch pattern picker takes its messages as one PatternPickerMessages struct instead of eight arguments. - The palette's fill index is keyed on the scene's geometry epoch alone; epochs are unique across scenes, so the scene address is not needed. - The ribbon lineweight handler is an on_ribbon_lineweight_changed method, used by the palette instead of a recursive update() call. - The 430-line palette test is split into eight focused tests with shared helpers; no checks were dropped. - Palette, gradient editor, prompts, messages and undo labels go through t!/tf!. The 33 new strings are in a graphic-attributes Fluent group in all 21 locales; "[Yes / No] ", ByLayer and ByBlock stay English. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- locales/ar-SA/opencadstudio.ftl | 35 ++ locales/bg-BG/opencadstudio.ftl | 35 ++ locales/cs-CZ/opencadstudio.ftl | 35 ++ locales/de-DE/opencadstudio.ftl | 35 ++ locales/el-GR/opencadstudio.ftl | 35 ++ locales/en-US/opencadstudio.ftl | 35 ++ locales/es-ES/opencadstudio.ftl | 35 ++ locales/fi-FI/opencadstudio.ftl | 35 ++ locales/fr-FR/opencadstudio.ftl | 35 ++ locales/hi-IN/opencadstudio.ftl | 35 ++ locales/hu-HU/opencadstudio.ftl | 35 ++ locales/it-IT/opencadstudio.ftl | 35 ++ locales/ja-JP/opencadstudio.ftl | 35 ++ locales/ko-KR/opencadstudio.ftl | 35 ++ locales/nl-NL/opencadstudio.ftl | 35 ++ locales/pl-PL/opencadstudio.ftl | 35 ++ locales/pt-BR/opencadstudio.ftl | 35 ++ locales/ru-RU/opencadstudio.ftl | 35 ++ locales/tr-TR/opencadstudio.ftl | 35 ++ locales/zh-CN/opencadstudio.ftl | 35 ++ locales/zh-TW/opencadstudio.ftl | 35 ++ src/app/update/command.rs | 26 ++ src/app/update/graphic_attributes.rs | 580 +++++++++------------------ src/app/update/mod.rs | 26 +- src/locale_catalog.rs | 33 ++ src/ui/properties.rs | 35 +- src/ui/window/gradient_editor.rs | 36 +- src/ui/window/graphic_attributes.rs | 71 ++-- 28 files changed, 1060 insertions(+), 482 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 63a632194..0766b2552 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5627,3 +5627,38 @@ style-manager = .center-of-text-overline = مركز النص مع خط علوي .block-extents = نطاق الكتلة .base-point = نقطة الأساس + +graphic-attributes = + .title = سمات الرسم + .set-all-bylayer = تعيين كل السمات إلى ByLayer + .set-all-byblock = تعيين كل السمات إلى ByBlock + .remove-bylayer-byblock = إزالة ByLayer / ByBlock + .create-layer-active-settings = إنشاء طبقة بالإعدادات النشطة + .fills-on-current-layer = إنشاء التعبئات على الطبقة الحالية + .edit-hatch = تحرير التهشير + .edit-gradient = تحرير التدرج + .close-open-object = الكائن المحدد مفتوح. هل تريد إغلاقه قبل إنشاء التعبئة؟ [Yes / No] + .close-open-objects = __ocs_fmt_0__ من الكائنات المحددة مفتوحة. هل تريد إغلاقها قبل إنشاء التعبئة؟ [Yes / No] + .enter-new-layer-name = أدخل اسمًا للطبقة الجديدة: + .invalid-layer-name = سمات الرسم: أدخل اسم طبقة صالحًا بدون <>/\":;?*|,=`. + .layer-exists = سمات الرسم: الطبقة "__ocs_fmt_0__" موجودة بالفعل. + .layer-not-created = سمات الرسم: تعذر إنشاء الطبقة. + .layer-created = تم إنشاء الطبقة "__ocs_fmt_0__" بإعدادات سمات الرسم النشطة. + .gradient-fill-locked = تعبئة متدرجة: التعبئة على طبقة مقفلة. + .select-closed-objects = حدد كائنًا مغلقًا واحدًا أو أكثر أولاً. + .locked-objects-skipped = سمات الرسم: لم يتم تغيير __ocs_fmt_0__ من الكائنات على طبقة مقفلة. + .no-hatch-pattern = سمات الرسم: لا يتوفر نمط تهشير. + .associated-fill-locked = سمات الرسم: توجد تعبئة مرتبطة على طبقة مقفلة. + .one-unsupported = سمات الرسم: لم يتم تغيير كائن واحد غير مدعوم. + .unsupported-skipped = سمات الرسم: لم يتم تغيير __ocs_fmt_0__ من الكائنات غير المدعومة. + .fill-transparency = شفافية التعبئة + .line-linetype = نوع خط الخطوط + .line-transparency = شفافية الخطوط + .cylindrical-short = أسطواني + .spherical-short = كروي + .hemispherical-short = نصف كروي + .invert-gradient = عكس التدرج + .tone = الدرجة + .reset-angle = إعادة تعيين الزاوية + .one-color = لون واحد + .two-colors = لونان diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 3c2daed2b..bc8e77fe8 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5615,3 +5615,38 @@ style-manager = .center-of-text-overline = Център на надчертан текст .block-extents = Обхват на блок .base-point = Базова точка + +graphic-attributes = + .title = Графични атрибути + .set-all-bylayer = Задаване на всички атрибути на ByLayer + .set-all-byblock = Задаване на всички атрибути на ByBlock + .remove-bylayer-byblock = Премахване на ByLayer / ByBlock + .create-layer-active-settings = Създаване на слой с активните настройки + .fills-on-current-layer = Създаване на запълвания в текущия слой + .edit-hatch = Редактиране на щриховката + .edit-gradient = Редактиране на преливането + .close-open-object = Избраният обект е отворен. Да се затвори ли преди създаване на запълването? [Yes / No] + .close-open-objects = __ocs_fmt_0__ от избраните обекти са отворени. Да се затворят ли преди създаване на запълването? [Yes / No] + .enter-new-layer-name = Въведете име за новия слой: + .invalid-layer-name = Графични атрибути: въведете валидно име на слой без <>/\":;?*|,=`. + .layer-exists = Графични атрибути: слоят "__ocs_fmt_0__" вече съществува. + .layer-not-created = Графични атрибути: слоят не можа да бъде създаден. + .layer-created = Създаден е слой "__ocs_fmt_0__" с активните настройки на графичните атрибути. + .gradient-fill-locked = Преливащо запълване: запълването е в заключен слой. + .select-closed-objects = Първо изберете един или повече затворени обекта. + .locked-objects-skipped = Графични атрибути: __ocs_fmt_0__ обекта в заключен слой не бяха променени. + .no-hatch-pattern = Графични атрибути: няма наличен шаблон за щриховка. + .associated-fill-locked = Графични атрибути: свързано запълване е в заключен слой. + .one-unsupported = Графични атрибути: 1 неподдържан обект не беше променен. + .unsupported-skipped = Графични атрибути: __ocs_fmt_0__ неподдържани обекта не бяха променени. + .fill-transparency = Прозрачност на запълването + .line-linetype = Тип линия на линиите + .line-transparency = Прозрачност на линиите + .cylindrical-short = Цил. + .spherical-short = Сфер. + .hemispherical-short = Полусф. + .invert-gradient = Обръщане на преливането + .tone = Тон + .reset-angle = Нулиране на ъгъла + .one-color = Един цвят + .two-colors = Два цвята diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 8b099c672..bfbd5cec9 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5612,3 +5612,38 @@ style-manager = .center-of-text-overline = Střed textu s přeškrtnutím .block-extents = Blokovat rozsahy .base-point = Základní bod + +graphic-attributes = + .title = Grafické atributy + .set-all-bylayer = Nastavit všechny atributy na ByLayer + .set-all-byblock = Nastavit všechny atributy na ByBlock + .remove-bylayer-byblock = Odebrat ByLayer / ByBlock + .create-layer-active-settings = Vytvořit hladinu s aktivním nastavením + .fills-on-current-layer = Vytvářet výplně v aktuální hladině + .edit-hatch = Upravit šrafu + .edit-gradient = Upravit přechod + .close-open-object = Vybraný objekt je otevřený. Uzavřít jej před vytvořením výplně? [Yes / No] + .close-open-objects = Vybrané objekty jsou otevřené (__ocs_fmt_0__). Uzavřít je před vytvořením výplně? [Yes / No] + .enter-new-layer-name = Zadejte název nové hladiny: + .invalid-layer-name = Grafické atributy: zadejte platný název hladiny bez <>/\":;?*|,=`. + .layer-exists = Grafické atributy: hladina "__ocs_fmt_0__" již existuje. + .layer-not-created = Grafické atributy: hladinu nelze vytvořit. + .layer-created = Hladina "__ocs_fmt_0__" byla vytvořena s aktivním nastavením grafických atributů. + .gradient-fill-locked = Přechodová výplň: výplň je v zamčené hladině. + .select-closed-objects = Nejprve vyberte jeden nebo více uzavřených objektů. + .locked-objects-skipped = Grafické atributy: objekty v zamčené hladině (__ocs_fmt_0__) nebyly změněny. + .no-hatch-pattern = Grafické atributy: není k dispozici žádný vzor šraf. + .associated-fill-locked = Grafické atributy: přidružená výplň je v zamčené hladině. + .one-unsupported = Grafické atributy: 1 nepodporovaný objekt nebyl změněn. + .unsupported-skipped = Grafické atributy: nepodporované objekty (__ocs_fmt_0__) nebyly změněny. + .fill-transparency = Průhlednost výplně + .line-linetype = Typ čáry + .line-transparency = Průhlednost čar + .cylindrical-short = Válc. + .spherical-short = Kul. + .hemispherical-short = Polokul. + .invert-gradient = Obrátit přechod + .tone = Tón + .reset-angle = Obnovit úhel + .one-color = Jedna barva + .two-colors = Dvě barvy diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 225f32ba6..9708790cf 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5613,3 +5613,38 @@ style-manager = .center-of-text-overline = Textzentrum mit Oberstrich .block-extents = Blockgrenzen .base-point = Basispunkt + +graphic-attributes = + .title = Grafikattribute + .set-all-bylayer = Alle Attribute auf ByLayer setzen + .set-all-byblock = Alle Attribute auf ByBlock setzen + .remove-bylayer-byblock = ByLayer / ByBlock entfernen + .create-layer-active-settings = Layer mit aktiven Einstellungen erstellen + .fills-on-current-layer = Füllungen auf dem aktuellen Layer erstellen + .edit-hatch = Schraffur bearbeiten + .edit-gradient = Verlauf bearbeiten + .close-open-object = Das ausgewählte Objekt ist offen. Vor dem Erstellen der Füllung schließen? [Yes / No] + .close-open-objects = __ocs_fmt_0__ ausgewählte Objekte sind offen. Vor dem Erstellen der Füllung schließen? [Yes / No] + .enter-new-layer-name = Namen für den neuen Layer eingeben: + .invalid-layer-name = Grafikattribute: gültigen Layernamen ohne <>/\":;?*|,=` eingeben. + .layer-exists = Grafikattribute: Layer "__ocs_fmt_0__" ist bereits vorhanden. + .layer-not-created = Grafikattribute: Der Layer konnte nicht erstellt werden. + .layer-created = Layer "__ocs_fmt_0__" mit den aktiven Grafikattributen erstellt. + .gradient-fill-locked = Verlaufsfüllung: Die Füllung liegt auf einem gesperrten Layer. + .select-closed-objects = Zuerst ein oder mehrere geschlossene Objekte auswählen. + .locked-objects-skipped = Grafikattribute: __ocs_fmt_0__ Objekt(e) auf einem gesperrten Layer wurden nicht geändert. + .no-hatch-pattern = Grafikattribute: Kein Schraffurmuster verfügbar. + .associated-fill-locked = Grafikattribute: Eine zugehörige Füllung liegt auf einem gesperrten Layer. + .one-unsupported = Grafikattribute: 1 nicht unterstütztes Objekt wurde nicht geändert. + .unsupported-skipped = Grafikattribute: __ocs_fmt_0__ nicht unterstützte Objekte wurden nicht geändert. + .fill-transparency = Füllungstransparenz + .line-linetype = Linientyp der Linien + .line-transparency = Linientransparenz + .cylindrical-short = Zyl. + .spherical-short = Kugel + .hemispherical-short = Halbk. + .invert-gradient = Verlauf umkehren + .tone = Ton + .reset-angle = Winkel zurücksetzen + .one-color = Einfarbig + .two-colors = Zweifarbig diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index ef73bc35e..e9aac1bb9 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5615,3 +5615,38 @@ style-manager = .center-of-text-overline = Κέντρο του κειμένου με επιγράμμιση .block-extents = Όρια μπλοκ .base-point = Σημείο βάσης + +graphic-attributes = + .title = Γραφικά χαρακτηριστικά + .set-all-bylayer = Ορισμός όλων των χαρακτηριστικών σε ByLayer + .set-all-byblock = Ορισμός όλων των χαρακτηριστικών σε ByBlock + .remove-bylayer-byblock = Αφαίρεση ByLayer / ByBlock + .create-layer-active-settings = Δημιουργία επιπέδου με τις ενεργές ρυθμίσεις + .fills-on-current-layer = Δημιουργία γεμισμάτων στο τρέχον επίπεδο + .edit-hatch = Επεξεργασία διαγράμμισης + .edit-gradient = Επεξεργασία διαβάθμισης + .close-open-object = Το επιλεγμένο αντικείμενο είναι ανοιχτό. Να κλείσει πριν από τη δημιουργία του γεμίσματος; [Yes / No] + .close-open-objects = __ocs_fmt_0__ επιλεγμένα αντικείμενα είναι ανοιχτά. Να κλείσουν πριν από τη δημιουργία του γεμίσματος; [Yes / No] + .enter-new-layer-name = Εισαγάγετε όνομα για το νέο επίπεδο: + .invalid-layer-name = Γραφικά χαρακτηριστικά: εισαγάγετε έγκυρο όνομα επιπέδου χωρίς <>/\":;?*|,=`. + .layer-exists = Γραφικά χαρακτηριστικά: το επίπεδο "__ocs_fmt_0__" υπάρχει ήδη. + .layer-not-created = Γραφικά χαρακτηριστικά: δεν ήταν δυνατή η δημιουργία του επιπέδου. + .layer-created = Δημιουργήθηκε το επίπεδο "__ocs_fmt_0__" με τις ενεργές ρυθμίσεις γραφικών χαρακτηριστικών. + .gradient-fill-locked = Γέμισμα διαβάθμισης: το γέμισμα βρίσκεται σε κλειδωμένο επίπεδο. + .select-closed-objects = Επιλέξτε πρώτα ένα ή περισσότερα κλειστά αντικείμενα. + .locked-objects-skipped = Γραφικά χαρακτηριστικά: __ocs_fmt_0__ αντικείμενα σε κλειδωμένο επίπεδο δεν άλλαξαν. + .no-hatch-pattern = Γραφικά χαρακτηριστικά: δεν υπάρχει διαθέσιμο μοτίβο διαγράμμισης. + .associated-fill-locked = Γραφικά χαρακτηριστικά: ένα συσχετισμένο γέμισμα βρίσκεται σε κλειδωμένο επίπεδο. + .one-unsupported = Γραφικά χαρακτηριστικά: 1 μη υποστηριζόμενο αντικείμενο δεν άλλαξε. + .unsupported-skipped = Γραφικά χαρακτηριστικά: __ocs_fmt_0__ μη υποστηριζόμενα αντικείμενα δεν άλλαξαν. + .fill-transparency = Διαφάνεια γεμίσματος + .line-linetype = Τύπος γραμμής + .line-transparency = Διαφάνεια γραμμών + .cylindrical-short = Κυλ. + .spherical-short = Σφαιρ. + .hemispherical-short = Ημισφ. + .invert-gradient = Αντιστροφή διαβάθμισης + .tone = Τόνος + .reset-angle = Επαναφορά γωνίας + .one-color = Ένα χρώμα + .two-colors = Δύο χρώματα diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 109c504f8..456f168f4 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5616,3 +5616,38 @@ style-manager = .center-of-text-overline = Center of text with overline .block-extents = Block extents .base-point = Base point + +graphic-attributes = + .title = Graphic Attributes + .set-all-bylayer = Set all attributes ByLayer + .set-all-byblock = Set all attributes ByBlock + .remove-bylayer-byblock = Remove ByLayer / ByBlock + .create-layer-active-settings = Create Layer with active settings + .fills-on-current-layer = Create fills on the current layer + .edit-hatch = Edit hatch + .edit-gradient = Edit gradient + .close-open-object = The selected object is open. Close it before creating the fill? [Yes / No] + .close-open-objects = __ocs_fmt_0__ selected objects are open. Close them before creating the fill? [Yes / No] + .enter-new-layer-name = Enter a name for the new layer: + .invalid-layer-name = Graphic Attributes: enter a valid layer name without <>/\":;?*|,=`. + .layer-exists = Graphic Attributes: layer "__ocs_fmt_0__" already exists. + .layer-not-created = Graphic Attributes: the layer could not be created. + .layer-created = Created layer "__ocs_fmt_0__" with the active Graphic Attributes settings. + .gradient-fill-locked = Gradient Fill: the fill is on a locked layer. + .select-closed-objects = Select one or more closed objects first. + .locked-objects-skipped = Graphic Attributes: __ocs_fmt_0__ object(s) on a locked layer were not changed. + .no-hatch-pattern = Graphic Attributes: no hatch pattern is available. + .associated-fill-locked = Graphic Attributes: an associated fill is on a locked layer. + .one-unsupported = Graphic Attributes: 1 unsupported object was not changed. + .unsupported-skipped = Graphic Attributes: __ocs_fmt_0__ unsupported objects were not changed. + .fill-transparency = Fill transparency + .line-linetype = Line linetype + .line-transparency = Line transparency + .cylindrical-short = Cyl. + .spherical-short = Spher. + .hemispherical-short = Hemi. + .invert-gradient = Invert gradient + .tone = Tone + .reset-angle = Reset angle + .one-color = One Color + .two-colors = Two Colors diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 7faf157ce..8005d34de 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5615,3 +5615,38 @@ style-manager = .center-of-text-overline = Centro del texto con suprarrayado .block-extents = Extensión del bloque .base-point = Punto base + +graphic-attributes = + .title = Atributos gráficos + .set-all-bylayer = Establecer todos los atributos en ByLayer + .set-all-byblock = Establecer todos los atributos en ByBlock + .remove-bylayer-byblock = Quitar ByLayer / ByBlock + .create-layer-active-settings = Crear capa con la configuración activa + .fills-on-current-layer = Crear rellenos en la capa actual + .edit-hatch = Editar sombreado + .edit-gradient = Editar degradado + .close-open-object = El objeto seleccionado está abierto. ¿Cerrarlo antes de crear el relleno? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objetos seleccionados están abiertos. ¿Cerrarlos antes de crear el relleno? [Yes / No] + .enter-new-layer-name = Introduzca un nombre para la capa nueva: + .invalid-layer-name = Atributos gráficos: introduzca un nombre de capa válido sin <>/\":;?*|,=`. + .layer-exists = Atributos gráficos: la capa "__ocs_fmt_0__" ya existe. + .layer-not-created = Atributos gráficos: no se pudo crear la capa. + .layer-created = Se creó la capa "__ocs_fmt_0__" con la configuración activa de atributos gráficos. + .gradient-fill-locked = Relleno degradado: el relleno está en una capa bloqueada. + .select-closed-objects = Seleccione primero uno o varios objetos cerrados. + .locked-objects-skipped = Atributos gráficos: no se modificaron __ocs_fmt_0__ objeto(s) en una capa bloqueada. + .no-hatch-pattern = Atributos gráficos: no hay ningún patrón de sombreado disponible. + .associated-fill-locked = Atributos gráficos: un relleno asociado está en una capa bloqueada. + .one-unsupported = Atributos gráficos: no se modificó 1 objeto no admitido. + .unsupported-skipped = Atributos gráficos: no se modificaron __ocs_fmt_0__ objetos no admitidos. + .fill-transparency = Transparencia del relleno + .line-linetype = Tipo de línea + .line-transparency = Transparencia de línea + .cylindrical-short = Cil. + .spherical-short = Esfér. + .hemispherical-short = Semiesf. + .invert-gradient = Invertir degradado + .tone = Tono + .reset-angle = Restablecer ángulo + .one-color = Un color + .two-colors = Dos colores diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 917ad12ac..2cbd7647e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5612,3 +5612,38 @@ style-manager = .center-of-text-overline = Tekstin keskikohta yliviivauksella .block-extents = Estä laajuudet .base-point = Peruspiste + +graphic-attributes = + .title = Grafiikkamääritteet + .set-all-bylayer = Aseta kaikki määritteet arvoon ByLayer + .set-all-byblock = Aseta kaikki määritteet arvoon ByBlock + .remove-bylayer-byblock = Poista ByLayer / ByBlock + .create-layer-active-settings = Luo taso aktiivisilla asetuksilla + .fills-on-current-layer = Luo täytöt nykyiselle tasolle + .edit-hatch = Muokkaa viivoitusta + .edit-gradient = Muokkaa liukuväriä + .close-open-object = Valittu objekti on avoin. Suljetaanko se ennen täytön luomista? [Yes / No] + .close-open-objects = __ocs_fmt_0__ valittua objektia on avoimia. Suljetaanko ne ennen täytön luomista? [Yes / No] + .enter-new-layer-name = Anna uuden tason nimi: + .invalid-layer-name = Grafiikkamääritteet: anna kelvollinen tason nimi ilman merkkejä <>/\":;?*|,=`. + .layer-exists = Grafiikkamääritteet: taso "__ocs_fmt_0__" on jo olemassa. + .layer-not-created = Grafiikkamääritteet: tasoa ei voitu luoda. + .layer-created = Taso "__ocs_fmt_0__" luotiin aktiivisilla grafiikkamääritteillä. + .gradient-fill-locked = Liukuväritäyttö: täyttö on lukitulla tasolla. + .select-closed-objects = Valitse ensin yksi tai useampi suljettu objekti. + .locked-objects-skipped = Grafiikkamääritteet: __ocs_fmt_0__ lukitulla tasolla olevaa objektia jätettiin muuttamatta. + .no-hatch-pattern = Grafiikkamääritteet: viivoituskuviota ei ole saatavilla. + .associated-fill-locked = Grafiikkamääritteet: liitetty täyttö on lukitulla tasolla. + .one-unsupported = Grafiikkamääritteet: 1 tukematonta objektia ei muutettu. + .unsupported-skipped = Grafiikkamääritteet: __ocs_fmt_0__ tukematonta objektia jätettiin muuttamatta. + .fill-transparency = Täytön läpinäkyvyys + .line-linetype = Viivojen viivatyyppi + .line-transparency = Viivojen läpinäkyvyys + .cylindrical-short = Lieriö + .spherical-short = Pallo + .hemispherical-short = Puolip. + .invert-gradient = Käännä liukuväri + .tone = Sävy + .reset-angle = Palauta kulma + .one-color = Yksi väri + .two-colors = Kaksi väriä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index cb1d177e9..2d0f667ca 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5614,3 +5614,38 @@ style-manager = .center-of-text-overline = Centre du texte avec surlignement .block-extents = Limites du bloc .base-point = Point de base + +graphic-attributes = + .title = Attributs graphiques + .set-all-bylayer = Définir tous les attributs sur ByLayer + .set-all-byblock = Définir tous les attributs sur ByBlock + .remove-bylayer-byblock = Supprimer ByLayer / ByBlock + .create-layer-active-settings = Créer un calque avec les paramètres actifs + .fills-on-current-layer = Créer les remplissages sur le calque courant + .edit-hatch = Modifier la hachure + .edit-gradient = Modifier le dégradé + .close-open-object = L'objet sélectionné est ouvert. Le fermer avant de créer le remplissage ? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objets sélectionnés sont ouverts. Les fermer avant de créer le remplissage ? [Yes / No] + .enter-new-layer-name = Entrez un nom pour le nouveau calque : + .invalid-layer-name = Attributs graphiques : entrez un nom de calque valide sans <>/\":;?*|,=`. + .layer-exists = Attributs graphiques : le calque "__ocs_fmt_0__" existe déjà. + .layer-not-created = Attributs graphiques : impossible de créer le calque. + .layer-created = Calque "__ocs_fmt_0__" créé avec les attributs graphiques actifs. + .gradient-fill-locked = Remplissage dégradé : le remplissage est sur un calque verrouillé. + .select-closed-objects = Sélectionnez d'abord un ou plusieurs objets fermés. + .locked-objects-skipped = Attributs graphiques : __ocs_fmt_0__ objet(s) sur un calque verrouillé n'ont pas été modifiés. + .no-hatch-pattern = Attributs graphiques : aucun motif de hachure n'est disponible. + .associated-fill-locked = Attributs graphiques : un remplissage associé est sur un calque verrouillé. + .one-unsupported = Attributs graphiques : 1 objet non pris en charge n'a pas été modifié. + .unsupported-skipped = Attributs graphiques : __ocs_fmt_0__ objets non pris en charge n'ont pas été modifiés. + .fill-transparency = Transparence du remplissage + .line-linetype = Type de ligne + .line-transparency = Transparence des lignes + .cylindrical-short = Cyl. + .spherical-short = Sphér. + .hemispherical-short = Hémi. + .invert-gradient = Inverser le dégradé + .tone = Ton + .reset-angle = Réinitialiser l'angle + .one-color = Une couleur + .two-colors = Deux couleurs diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 03319c8b6..27fa7766e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5603,3 +5603,38 @@ style-manager = .center-of-text-overline = ऊपरी रेखा सहित टेक्स्ट का केंद्र .block-extents = ब्लॉक परिमिति .base-point = आधार बिंदु + +graphic-attributes = + .title = ग्राफ़िक गुण + .set-all-bylayer = सभी गुण ByLayer पर सेट करें + .set-all-byblock = सभी गुण ByBlock पर सेट करें + .remove-bylayer-byblock = ByLayer / ByBlock हटाएँ + .create-layer-active-settings = सक्रिय सेटिंग्स के साथ लेयर बनाएँ + .fills-on-current-layer = भराव वर्तमान लेयर पर बनाएँ + .edit-hatch = हैच संपादित करें + .edit-gradient = ग्रेडिएंट संपादित करें + .close-open-object = चयनित ऑब्जेक्ट खुला है। भराव बनाने से पहले इसे बंद करें? [Yes / No] + .close-open-objects = __ocs_fmt_0__ चयनित ऑब्जेक्ट खुले हैं। भराव बनाने से पहले इन्हें बंद करें? [Yes / No] + .enter-new-layer-name = नई लेयर का नाम दर्ज करें: + .invalid-layer-name = ग्राफ़िक गुण: <>/\":;?*|,=` के बिना मान्य लेयर नाम दर्ज करें। + .layer-exists = ग्राफ़िक गुण: लेयर "__ocs_fmt_0__" पहले से मौजूद है। + .layer-not-created = ग्राफ़िक गुण: लेयर नहीं बनाई जा सकी। + .layer-created = सक्रिय ग्राफ़िक गुण सेटिंग्स के साथ लेयर "__ocs_fmt_0__" बनाई गई। + .gradient-fill-locked = ग्रेडिएंट भराव: भराव लॉक की गई लेयर पर है। + .select-closed-objects = पहले एक या अधिक बंद ऑब्जेक्ट चुनें। + .locked-objects-skipped = ग्राफ़िक गुण: लॉक की गई लेयर पर __ocs_fmt_0__ ऑब्जेक्ट नहीं बदले गए। + .no-hatch-pattern = ग्राफ़िक गुण: कोई हैच पैटर्न उपलब्ध नहीं है। + .associated-fill-locked = ग्राफ़िक गुण: एक संबद्ध भराव लॉक की गई लेयर पर है। + .one-unsupported = ग्राफ़िक गुण: 1 असमर्थित ऑब्जेक्ट नहीं बदला गया। + .unsupported-skipped = ग्राफ़िक गुण: __ocs_fmt_0__ असमर्थित ऑब्जेक्ट नहीं बदले गए। + .fill-transparency = भराव पारदर्शिता + .line-linetype = रेखा का लाइनटाइप + .line-transparency = रेखा पारदर्शिता + .cylindrical-short = बेलन. + .spherical-short = गोल. + .hemispherical-short = अर्धगोल. + .invert-gradient = ग्रेडिएंट उलटें + .tone = टोन + .reset-angle = कोण रीसेट करें + .one-color = एक रंग + .two-colors = दो रंग diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 534a1264c..ce71c489d 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5614,3 +5614,38 @@ style-manager = .center-of-text-overline = Szöveg közepe áthúzással .block-extents = Kiterjedések blokkolása .base-point = Alappont + +graphic-attributes = + .title = Grafikus jellemzők + .set-all-bylayer = Minden jellemző ByLayer értékre + .set-all-byblock = Minden jellemző ByBlock értékre + .remove-bylayer-byblock = ByLayer / ByBlock eltávolítása + .create-layer-active-settings = Fólia létrehozása az aktív beállításokkal + .fills-on-current-layer = Kitöltések létrehozása az aktuális fólián + .edit-hatch = Sraffozás szerkesztése + .edit-gradient = Színátmenet szerkesztése + .close-open-object = A kijelölt objektum nyitott. Bezárja a kitöltés létrehozása előtt? [Yes / No] + .close-open-objects = __ocs_fmt_0__ kijelölt objektum nyitott. Bezárja őket a kitöltés létrehozása előtt? [Yes / No] + .enter-new-layer-name = Adja meg az új fólia nevét: + .invalid-layer-name = Grafikus jellemzők: adjon meg érvényes fólianevet <>/\":;?*|,=` nélkül. + .layer-exists = Grafikus jellemzők: a(z) "__ocs_fmt_0__" fólia már létezik. + .layer-not-created = Grafikus jellemzők: a fóliát nem sikerült létrehozni. + .layer-created = A(z) "__ocs_fmt_0__" fólia létrejött az aktív grafikus jellemzőkkel. + .gradient-fill-locked = Színátmenetes kitöltés: a kitöltés zárolt fólián van. + .select-closed-objects = Először jelöljön ki egy vagy több zárt objektumot. + .locked-objects-skipped = Grafikus jellemzők: __ocs_fmt_0__ zárolt fólián lévő objektum nem változott. + .no-hatch-pattern = Grafikus jellemzők: nincs elérhető sraffozási minta. + .associated-fill-locked = Grafikus jellemzők: egy társított kitöltés zárolt fólián van. + .one-unsupported = Grafikus jellemzők: 1 nem támogatott objektum nem változott. + .unsupported-skipped = Grafikus jellemzők: __ocs_fmt_0__ nem támogatott objektum nem változott. + .fill-transparency = Kitöltés átlátszósága + .line-linetype = Vonaltípus + .line-transparency = Vonalak átlátszósága + .cylindrical-short = Henger + .spherical-short = Gömb + .hemispherical-short = Félgömb + .invert-gradient = Színátmenet megfordítása + .tone = Tónus + .reset-angle = Szög visszaállítása + .one-color = Egy szín + .two-colors = Két szín diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 325895258..bac57efe9 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5614,3 +5614,38 @@ style-manager = .center-of-text-overline = Centro del testo con sopralineato .block-extents = Blocca estensioni .base-point = Punto base + +graphic-attributes = + .title = Attributi grafici + .set-all-bylayer = Imposta tutti gli attributi su ByLayer + .set-all-byblock = Imposta tutti gli attributi su ByBlock + .remove-bylayer-byblock = Rimuovi ByLayer / ByBlock + .create-layer-active-settings = Crea layer con le impostazioni attive + .fills-on-current-layer = Crea i riempimenti sul layer corrente + .edit-hatch = Modifica tratteggio + .edit-gradient = Modifica sfumatura + .close-open-object = L'oggetto selezionato è aperto. Chiuderlo prima di creare il riempimento? [Yes / No] + .close-open-objects = __ocs_fmt_0__ oggetti selezionati sono aperti. Chiuderli prima di creare il riempimento? [Yes / No] + .enter-new-layer-name = Immettere un nome per il nuovo layer: + .invalid-layer-name = Attributi grafici: immettere un nome di layer valido senza <>/\":;?*|,=`. + .layer-exists = Attributi grafici: il layer "__ocs_fmt_0__" esiste già. + .layer-not-created = Attributi grafici: impossibile creare il layer. + .layer-created = Layer "__ocs_fmt_0__" creato con le impostazioni attive degli attributi grafici. + .gradient-fill-locked = Riempimento sfumato: il riempimento è su un layer bloccato. + .select-closed-objects = Selezionare prima uno o più oggetti chiusi. + .locked-objects-skipped = Attributi grafici: __ocs_fmt_0__ oggetto/i su un layer bloccato non sono stati modificati. + .no-hatch-pattern = Attributi grafici: nessun modello di tratteggio disponibile. + .associated-fill-locked = Attributi grafici: un riempimento associato è su un layer bloccato. + .one-unsupported = Attributi grafici: 1 oggetto non supportato non è stato modificato. + .unsupported-skipped = Attributi grafici: __ocs_fmt_0__ oggetti non supportati non sono stati modificati. + .fill-transparency = Trasparenza riempimento + .line-linetype = Tipo di linea + .line-transparency = Trasparenza linee + .cylindrical-short = Cil. + .spherical-short = Sfer. + .hemispherical-short = Emisf. + .invert-gradient = Inverti sfumatura + .tone = Tono + .reset-angle = Reimposta angolo + .one-color = Un colore + .two-colors = Due colori diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 03cdaf6dc..fec230084 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5603,3 +5603,38 @@ style-manager = .center-of-text-overline = 上線付きテキストの中心 .block-extents = ブロック範囲 .base-point = 基点 + +graphic-attributes = + .title = グラフィック属性 + .set-all-bylayer = すべての属性を ByLayer に設定 + .set-all-byblock = すべての属性を ByBlock に設定 + .remove-bylayer-byblock = ByLayer / ByBlock を解除 + .create-layer-active-settings = 現在の設定で画層を作成 + .fills-on-current-layer = 塗り潰しを現在の画層に作成 + .edit-hatch = ハッチングを編集 + .edit-gradient = グラデーションを編集 + .close-open-object = 選択したオブジェクトは開いています。塗り潰しを作成する前に閉じますか? [Yes / No] + .close-open-objects = 選択した __ocs_fmt_0__ 個のオブジェクトは開いています。塗り潰しを作成する前に閉じますか? [Yes / No] + .enter-new-layer-name = 新しい画層の名前を入力: + .invalid-layer-name = グラフィック属性: <>/\":;?*|,=` を含まない有効な画層名を入力してください。 + .layer-exists = グラフィック属性: 画層 "__ocs_fmt_0__" は既に存在します。 + .layer-not-created = グラフィック属性: 画層を作成できませんでした。 + .layer-created = 現在のグラフィック属性の設定で画層 "__ocs_fmt_0__" を作成しました。 + .gradient-fill-locked = グラデーション塗り潰し: 塗り潰しはロックされた画層にあります。 + .select-closed-objects = 先に 1 つ以上の閉じたオブジェクトを選択してください。 + .locked-objects-skipped = グラフィック属性: ロックされた画層上の __ocs_fmt_0__ 個のオブジェクトは変更されませんでした。 + .no-hatch-pattern = グラフィック属性: 使用できるハッチング パターンがありません。 + .associated-fill-locked = グラフィック属性: 関連付けられた塗り潰しがロックされた画層にあります。 + .one-unsupported = グラフィック属性: サポートされていないオブジェクト 1 個は変更されませんでした。 + .unsupported-skipped = グラフィック属性: サポートされていないオブジェクト __ocs_fmt_0__ 個は変更されませんでした。 + .fill-transparency = 塗り潰しの透過性 + .line-linetype = 線の線種 + .line-transparency = 線の透過性 + .cylindrical-short = 円柱 + .spherical-short = 球 + .hemispherical-short = 半球 + .invert-gradient = グラデーションを反転 + .tone = 濃淡 + .reset-angle = 角度をリセット + .one-color = 1 色 + .two-colors = 2 色 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d01f1d748..c1fcbc464 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5614,3 +5614,38 @@ style-manager = .center-of-text-overline = 윗줄이 있는 텍스트 중앙 .block-extents = 블록 범위 .base-point = 기준점 + +graphic-attributes = + .title = 그래픽 속성 + .set-all-bylayer = 모든 속성을 ByLayer로 설정 + .set-all-byblock = 모든 속성을 ByBlock으로 설정 + .remove-bylayer-byblock = ByLayer / ByBlock 제거 + .create-layer-active-settings = 현재 설정으로 도면층 작성 + .fills-on-current-layer = 현재 도면층에 채우기 작성 + .edit-hatch = 해치 편집 + .edit-gradient = 그라데이션 편집 + .close-open-object = 선택한 객체가 열려 있습니다. 채우기를 작성하기 전에 닫겠습니까? [Yes / No] + .close-open-objects = 선택한 객체 __ocs_fmt_0__개가 열려 있습니다. 채우기를 작성하기 전에 닫겠습니까? [Yes / No] + .enter-new-layer-name = 새 도면층 이름 입력: + .invalid-layer-name = 그래픽 속성: <>/\":;?*|,=`이(가) 없는 올바른 도면층 이름을 입력하십시오. + .layer-exists = 그래픽 속성: 도면층 "__ocs_fmt_0__"이(가) 이미 있습니다. + .layer-not-created = 그래픽 속성: 도면층을 작성할 수 없습니다. + .layer-created = 현재 그래픽 속성 설정으로 도면층 "__ocs_fmt_0__"을(를) 작성했습니다. + .gradient-fill-locked = 그라데이션 채우기: 채우기가 잠긴 도면층에 있습니다. + .select-closed-objects = 먼저 닫힌 객체를 하나 이상 선택하십시오. + .locked-objects-skipped = 그래픽 속성: 잠긴 도면층의 객체 __ocs_fmt_0__개가 변경되지 않았습니다. + .no-hatch-pattern = 그래픽 속성: 사용 가능한 해치 패턴이 없습니다. + .associated-fill-locked = 그래픽 속성: 연관된 채우기가 잠긴 도면층에 있습니다. + .one-unsupported = 그래픽 속성: 지원되지 않는 객체 1개가 변경되지 않았습니다. + .unsupported-skipped = 그래픽 속성: 지원되지 않는 객체 __ocs_fmt_0__개가 변경되지 않았습니다. + .fill-transparency = 채우기 투명도 + .line-linetype = 선의 선종류 + .line-transparency = 선 투명도 + .cylindrical-short = 원통 + .spherical-short = 구 + .hemispherical-short = 반구 + .invert-gradient = 그라데이션 반전 + .tone = 색조 + .reset-angle = 각도 재설정 + .one-color = 단색 + .two-colors = 두 색 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index bce553628..2c9de8d1a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5613,3 +5613,38 @@ style-manager = .center-of-text-overline = Tekstmiddelpunt met bovenstreep .block-extents = Symboolgrenzen .base-point = Basispunt + +graphic-attributes = + .title = Grafische attributen + .set-all-bylayer = Alle attributen op ByLayer zetten + .set-all-byblock = Alle attributen op ByBlock zetten + .remove-bylayer-byblock = ByLayer / ByBlock verwijderen + .create-layer-active-settings = Laag maken met actieve instellingen + .fills-on-current-layer = Vullingen op de huidige laag maken + .edit-hatch = Arcering bewerken + .edit-gradient = Kleurverloop bewerken + .close-open-object = Het geselecteerde object is open. Sluiten voordat de vulling wordt gemaakt? [Yes / No] + .close-open-objects = __ocs_fmt_0__ geselecteerde objecten zijn open. Sluiten voordat de vulling wordt gemaakt? [Yes / No] + .enter-new-layer-name = Voer een naam in voor de nieuwe laag: + .invalid-layer-name = Grafische attributen: voer een geldige laagnaam in zonder <>/\":;?*|,=`. + .layer-exists = Grafische attributen: laag "__ocs_fmt_0__" bestaat al. + .layer-not-created = Grafische attributen: de laag kon niet worden gemaakt. + .layer-created = Laag "__ocs_fmt_0__" gemaakt met de actieve instellingen van Grafische attributen. + .gradient-fill-locked = Verloopvulling: de vulling staat op een vergrendelde laag. + .select-closed-objects = Selecteer eerst een of meer gesloten objecten. + .locked-objects-skipped = Grafische attributen: __ocs_fmt_0__ object(en) op een vergrendelde laag zijn niet gewijzigd. + .no-hatch-pattern = Grafische attributen: er is geen arceerpatroon beschikbaar. + .associated-fill-locked = Grafische attributen: een gekoppelde vulling staat op een vergrendelde laag. + .one-unsupported = Grafische attributen: 1 niet-ondersteund object is niet gewijzigd. + .unsupported-skipped = Grafische attributen: __ocs_fmt_0__ niet-ondersteunde objecten zijn niet gewijzigd. + .fill-transparency = Transparantie vulling + .line-linetype = Lijntype lijnen + .line-transparency = Transparantie lijnen + .cylindrical-short = Cil. + .spherical-short = Bol + .hemispherical-short = Halfbol + .invert-gradient = Kleurverloop omkeren + .tone = Toon + .reset-angle = Hoek herstellen + .one-color = Eén kleur + .two-colors = Twee kleuren diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 9ac4ad83b..70af7f1fa 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5614,3 +5614,38 @@ style-manager = .center-of-text-overline = Środek tekstu z podkreśleniem .block-extents = Zasięg bloku .base-point = Punkt bazowy + +graphic-attributes = + .title = Atrybuty graficzne + .set-all-bylayer = Ustaw wszystkie atrybuty na ByLayer + .set-all-byblock = Ustaw wszystkie atrybuty na ByBlock + .remove-bylayer-byblock = Usuń ByLayer / ByBlock + .create-layer-active-settings = Utwórz warstwę z aktywnymi ustawieniami + .fills-on-current-layer = Twórz wypełnienia na bieżącej warstwie + .edit-hatch = Edytuj kreskowanie + .edit-gradient = Edytuj gradient + .close-open-object = Wybrany obiekt jest otwarty. Zamknąć go przed utworzeniem wypełnienia? [Yes / No] + .close-open-objects = Wybrane obiekty są otwarte (__ocs_fmt_0__). Zamknąć je przed utworzeniem wypełnienia? [Yes / No] + .enter-new-layer-name = Podaj nazwę nowej warstwy: + .invalid-layer-name = Atrybuty graficzne: podaj prawidłową nazwę warstwy bez <>/\":;?*|,=`. + .layer-exists = Atrybuty graficzne: warstwa "__ocs_fmt_0__" już istnieje. + .layer-not-created = Atrybuty graficzne: nie można utworzyć warstwy. + .layer-created = Utworzono warstwę "__ocs_fmt_0__" z aktywnymi atrybutami graficznymi. + .gradient-fill-locked = Wypełnienie gradientowe: wypełnienie jest na zablokowanej warstwie. + .select-closed-objects = Najpierw wybierz jeden lub więcej zamkniętych obiektów. + .locked-objects-skipped = Atrybuty graficzne: obiekty na zablokowanej warstwie (__ocs_fmt_0__) nie zostały zmienione. + .no-hatch-pattern = Atrybuty graficzne: brak dostępnego wzoru kreskowania. + .associated-fill-locked = Atrybuty graficzne: skojarzone wypełnienie jest na zablokowanej warstwie. + .one-unsupported = Atrybuty graficzne: 1 nieobsługiwany obiekt nie został zmieniony. + .unsupported-skipped = Atrybuty graficzne: nieobsługiwane obiekty (__ocs_fmt_0__) nie zostały zmienione. + .fill-transparency = Przezroczystość wypełnienia + .line-linetype = Rodzaj linii + .line-transparency = Przezroczystość linii + .cylindrical-short = Walc. + .spherical-short = Sfer. + .hemispherical-short = Półsf. + .invert-gradient = Odwróć gradient + .tone = Ton + .reset-angle = Resetuj kąt + .one-color = Jeden kolor + .two-colors = Dwa kolory diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 385759cd2..c007c8458 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5615,3 +5615,38 @@ style-manager = .center-of-text-overline = Centro do texto com sobrelinha .block-extents = Extensões do bloco .base-point = Ponto base + +graphic-attributes = + .title = Atributos gráficos + .set-all-bylayer = Definir todos os atributos como ByLayer + .set-all-byblock = Definir todos os atributos como ByBlock + .remove-bylayer-byblock = Remover ByLayer / ByBlock + .create-layer-active-settings = Criar camada com as configurações ativas + .fills-on-current-layer = Criar preenchimentos na camada atual + .edit-hatch = Editar hachura + .edit-gradient = Editar gradiente + .close-open-object = O objeto selecionado está aberto. Fechá-lo antes de criar o preenchimento? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objetos selecionados estão abertos. Fechá-los antes de criar o preenchimento? [Yes / No] + .enter-new-layer-name = Digite um nome para a nova camada: + .invalid-layer-name = Atributos gráficos: digite um nome de camada válido sem <>/\":;?*|,=`. + .layer-exists = Atributos gráficos: a camada "__ocs_fmt_0__" já existe. + .layer-not-created = Atributos gráficos: não foi possível criar a camada. + .layer-created = Camada "__ocs_fmt_0__" criada com as configurações ativas de atributos gráficos. + .gradient-fill-locked = Preenchimento gradiente: o preenchimento está em uma camada bloqueada. + .select-closed-objects = Selecione primeiro um ou mais objetos fechados. + .locked-objects-skipped = Atributos gráficos: __ocs_fmt_0__ objeto(s) em uma camada bloqueada não foram alterados. + .no-hatch-pattern = Atributos gráficos: nenhum padrão de hachura disponível. + .associated-fill-locked = Atributos gráficos: um preenchimento associado está em uma camada bloqueada. + .one-unsupported = Atributos gráficos: 1 objeto não suportado não foi alterado. + .unsupported-skipped = Atributos gráficos: __ocs_fmt_0__ objetos não suportados não foram alterados. + .fill-transparency = Transparência do preenchimento + .line-linetype = Tipo de linha + .line-transparency = Transparência da linha + .cylindrical-short = Cil. + .spherical-short = Esfér. + .hemispherical-short = Hemisf. + .invert-gradient = Inverter gradiente + .tone = Tom + .reset-angle = Redefinir ângulo + .one-color = Uma cor + .two-colors = Duas cores diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 05ec1405c..3b4d84356 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5616,3 +5616,38 @@ style-manager = .center-of-text-overline = Центр текста с надчёркиванием .block-extents = Габариты блока .base-point = Базовая точка + +graphic-attributes = + .title = Графические атрибуты + .set-all-bylayer = Задать всем атрибутам ByLayer + .set-all-byblock = Задать всем атрибутам ByBlock + .remove-bylayer-byblock = Убрать ByLayer / ByBlock + .create-layer-active-settings = Создать слой с текущими настройками + .fills-on-current-layer = Создавать заливки на текущем слое + .edit-hatch = Редактировать штриховку + .edit-gradient = Редактировать градиент + .close-open-object = Выбранный объект разомкнут. Замкнуть его перед созданием заливки? [Yes / No] + .close-open-objects = Выбранные объекты разомкнуты (__ocs_fmt_0__). Замкнуть их перед созданием заливки? [Yes / No] + .enter-new-layer-name = Введите имя нового слоя: + .invalid-layer-name = Графические атрибуты: введите допустимое имя слоя без <>/\":;?*|,=`. + .layer-exists = Графические атрибуты: слой "__ocs_fmt_0__" уже существует. + .layer-not-created = Графические атрибуты: не удалось создать слой. + .layer-created = Создан слой "__ocs_fmt_0__" с текущими графическими атрибутами. + .gradient-fill-locked = Градиентная заливка: заливка находится на заблокированном слое. + .select-closed-objects = Сначала выберите один или несколько замкнутых объектов. + .locked-objects-skipped = Графические атрибуты: объекты на заблокированном слое (__ocs_fmt_0__) не изменены. + .no-hatch-pattern = Графические атрибуты: нет доступного образца штриховки. + .associated-fill-locked = Графические атрибуты: связанная заливка находится на заблокированном слое. + .one-unsupported = Графические атрибуты: 1 неподдерживаемый объект не изменён. + .unsupported-skipped = Графические атрибуты: неподдерживаемые объекты (__ocs_fmt_0__) не изменены. + .fill-transparency = Прозрачность заливки + .line-linetype = Тип линий + .line-transparency = Прозрачность линий + .cylindrical-short = Цил. + .spherical-short = Сфер. + .hemispherical-short = Полусф. + .invert-gradient = Инвертировать градиент + .tone = Тон + .reset-angle = Сбросить угол + .one-color = Один цвет + .two-colors = Два цвета diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 22abcc5ce..f94360dbd 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5538,3 +5538,38 @@ style-manager = .center-of-text-overline = Üst çizgili metin merkezi .block-extents = Blok sınırları .base-point = Taban noktası + +graphic-attributes = + .title = Grafik öznitelikleri + .set-all-bylayer = Tüm öznitelikleri ByLayer yap + .set-all-byblock = Tüm öznitelikleri ByBlock yap + .remove-bylayer-byblock = ByLayer / ByBlock kaldır + .create-layer-active-settings = Etkin ayarlarla katman oluştur + .fills-on-current-layer = Dolguları geçerli katmanda oluştur + .edit-hatch = Taramayı düzenle + .edit-gradient = Gradyanı düzenle + .close-open-object = Seçili nesne açık. Dolgu oluşturmadan önce kapatılsın mı? [Yes / No] + .close-open-objects = __ocs_fmt_0__ seçili nesne açık. Dolgu oluşturmadan önce kapatılsın mı? [Yes / No] + .enter-new-layer-name = Yeni katman için bir ad girin: + .invalid-layer-name = Grafik öznitelikleri: <>/\":;?*|,=` içermeyen geçerli bir katman adı girin. + .layer-exists = Grafik öznitelikleri: "__ocs_fmt_0__" katmanı zaten var. + .layer-not-created = Grafik öznitelikleri: katman oluşturulamadı. + .layer-created = "__ocs_fmt_0__" katmanı etkin grafik öznitelikleriyle oluşturuldu. + .gradient-fill-locked = Gradyan dolgu: dolgu kilitli bir katmanda. + .select-closed-objects = Önce bir veya daha fazla kapalı nesne seçin. + .locked-objects-skipped = Grafik öznitelikleri: kilitli katmandaki __ocs_fmt_0__ nesne değiştirilmedi. + .no-hatch-pattern = Grafik öznitelikleri: kullanılabilir tarama deseni yok. + .associated-fill-locked = Grafik öznitelikleri: ilişkili bir dolgu kilitli katmanda. + .one-unsupported = Grafik öznitelikleri: desteklenmeyen 1 nesne değiştirilmedi. + .unsupported-skipped = Grafik öznitelikleri: desteklenmeyen __ocs_fmt_0__ nesne değiştirilmedi. + .fill-transparency = Dolgu saydamlığı + .line-linetype = Çizgi tipi + .line-transparency = Çizgi saydamlığı + .cylindrical-short = Sil. + .spherical-short = Küre + .hemispherical-short = Yarıküre + .invert-gradient = Gradyanı ters çevir + .tone = Ton + .reset-angle = Açıyı sıfırla + .one-color = Tek renk + .two-colors = İki renk diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a1fb404ee..bf728d3a4 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5598,3 +5598,38 @@ style-manager = .center-of-text-overline = 带上划线的文字中心 .block-extents = 块范围 .base-point = 基点 + +graphic-attributes = + .title = 图形特性 + .set-all-bylayer = 将所有特性设为 ByLayer + .set-all-byblock = 将所有特性设为 ByBlock + .remove-bylayer-byblock = 移除 ByLayer / ByBlock + .create-layer-active-settings = 用当前设置创建图层 + .fills-on-current-layer = 在当前图层上创建填充 + .edit-hatch = 编辑填充图案 + .edit-gradient = 编辑渐变色 + .close-open-object = 选定对象未闭合。创建填充前是否将其闭合? [Yes / No] + .close-open-objects = __ocs_fmt_0__ 个选定对象未闭合。创建填充前是否将其闭合? [Yes / No] + .enter-new-layer-name = 输入新图层的名称: + .invalid-layer-name = 图形特性: 请输入不含 <>/\":;?*|,=` 的有效图层名称。 + .layer-exists = 图形特性: 图层 "__ocs_fmt_0__" 已存在。 + .layer-not-created = 图形特性: 无法创建图层。 + .layer-created = 已用当前图形特性设置创建图层 "__ocs_fmt_0__"。 + .gradient-fill-locked = 渐变填充: 填充位于锁定的图层上。 + .select-closed-objects = 请先选择一个或多个闭合对象。 + .locked-objects-skipped = 图形特性: 锁定图层上的 __ocs_fmt_0__ 个对象未更改。 + .no-hatch-pattern = 图形特性: 没有可用的填充图案。 + .associated-fill-locked = 图形特性: 关联的填充位于锁定的图层上。 + .one-unsupported = 图形特性: 1 个不支持的对象未更改。 + .unsupported-skipped = 图形特性: __ocs_fmt_0__ 个不支持的对象未更改。 + .fill-transparency = 填充透明度 + .line-linetype = 线的线型 + .line-transparency = 线的透明度 + .cylindrical-short = 圆柱 + .spherical-short = 球形 + .hemispherical-short = 半球 + .invert-gradient = 反转渐变 + .tone = 明暗 + .reset-angle = 重置角度 + .one-color = 单色 + .two-colors = 双色 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index ad0d17c01..e8953c1a2 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5610,3 +5610,38 @@ style-manager = .center-of-text-overline = 帶上划線的文字中心 .block-extents = 圖塊範圍 .base-point = 基點 + +graphic-attributes = + .title = 圖形特性 + .set-all-bylayer = 將所有特性設為 ByLayer + .set-all-byblock = 將所有特性設為 ByBlock + .remove-bylayer-byblock = 移除 ByLayer / ByBlock + .create-layer-active-settings = 以目前設定建立圖層 + .fills-on-current-layer = 在目前圖層上建立填充 + .edit-hatch = 編輯填充線 + .edit-gradient = 編輯漸層 + .close-open-object = 選取的物件未封閉。建立填充前是否將其封閉? [Yes / No] + .close-open-objects = __ocs_fmt_0__ 個選取的物件未封閉。建立填充前是否將其封閉? [Yes / No] + .enter-new-layer-name = 輸入新圖層的名稱: + .invalid-layer-name = 圖形特性: 請輸入不含 <>/\":;?*|,=` 的有效圖層名稱。 + .layer-exists = 圖形特性: 圖層 "__ocs_fmt_0__" 已存在。 + .layer-not-created = 圖形特性: 無法建立圖層。 + .layer-created = 已以目前圖形特性設定建立圖層 "__ocs_fmt_0__"。 + .gradient-fill-locked = 漸層填充: 填充位於鎖定的圖層上。 + .select-closed-objects = 請先選取一個或多個封閉物件。 + .locked-objects-skipped = 圖形特性: 鎖定圖層上的 __ocs_fmt_0__ 個物件未變更。 + .no-hatch-pattern = 圖形特性: 沒有可用的填充線樣式。 + .associated-fill-locked = 圖形特性: 關聯的填充位於鎖定的圖層上。 + .one-unsupported = 圖形特性: 1 個不支援的物件未變更。 + .unsupported-skipped = 圖形特性: __ocs_fmt_0__ 個不支援的物件未變更。 + .fill-transparency = 填充透明度 + .line-linetype = 線的線型 + .line-transparency = 線的透明度 + .cylindrical-short = 圓柱 + .spherical-short = 球形 + .hemispherical-short = 半球 + .invert-gradient = 反轉漸層 + .tone = 明暗 + .reset-angle = 重設角度 + .one-color = 單色 + .two-colors = 雙色 diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 6b13e7fa4..43a20b304 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2155,6 +2155,32 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } + pub(super) fn on_ribbon_lineweight_changed(&mut self, lw: acadrust::types::LineWeight) -> Task { + let i = self.active_tab; + self.ribbon.close_dropdown(); + let handles = self.property_target_handles(i); + if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } + // Persist into the tab's header (CELWEIGHT). #21. + self.tabs[i].scene.document.header.current_line_weight = lw.value(); + self.tabs[i].dirty = true; + self.ribbon.active_lineweight = lw; + } else { + // Lineweight is baked into the cached wire geometry — + // re-tessellate so the change shows immediately (issue #231 + // class). + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_line_weight(entity, lw); + } + }); + self.ribbon.active_lineweight = lw; + } + Task::none() + } + pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task { let i = self.active_tab; self.ribbon.close_dropdown(); diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs index 36dc86b5a..f41e7f147 100644 --- a/src/app/update/graphic_attributes.rs +++ b/src/app/update/graphic_attributes.rs @@ -305,7 +305,7 @@ impl OpenCADStudio { "" | "Y" | "YES" => self.on_graphic_fill_close_response(true), "N" | "NO" => self.on_graphic_fill_close_response(false), _ => { - self.command_line.push_error("Enter Yes or No."); + self.command_line.push_error(crate::t!("Enter Yes or No.").as_ref()); Task::none() } } @@ -422,7 +422,7 @@ impl OpenCADStudio { self.set_current_graphic_attributes(color, linetype.to_owned(), lineweight, transparency); return Task::none(); } - self.edit_graphic_part(Part::Both, "Graphic Attributes", |entity| { + self.edit_graphic_part(Part::Both, &crate::t!("Graphic Attributes"), |entity| { crate::scene::view::dispatch::apply_color(entity, color); crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); crate::scene::view::dispatch::apply_line_weight(entity, lineweight); @@ -463,7 +463,7 @@ impl OpenCADStudio { }) .collect(); let handles: Vec<_> = values.keys().copied().collect(); - self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { + self.apply_property_op(i, crate::t!("Graphic Attributes"), &handles, |app, handle| { let (Some(values), Some(entity)) = ( values.get(&handle), app.tabs[i].scene.document.get_entity_mut(handle), @@ -483,7 +483,7 @@ impl OpenCADStudio { let name = raw_name.trim(); if !crate::scene::valid_block_name(name) { self.command_line - .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`."); + .push_error(crate::t!("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`.").as_ref()); return Task::none(); } if self.tabs[i] @@ -503,7 +503,7 @@ impl OpenCADStudio { let layer_name = name.to_owned(); let undo = self.begin_layer_undo( i, - "Create layer with active settings", + crate::t!("Create Layer with active settings"), std::slice::from_ref(&layer_name), ); let mut layer = acadrust::tables::Layer::new(name); @@ -514,7 +514,7 @@ impl OpenCADStudio { layer.transparency = values.transparency; if self.tabs[i].scene.document.layers.add(layer).is_err() { self.command_line - .push_error("Graphic Attributes: the layer could not be created."); + .push_error(crate::t!("Graphic Attributes: the layer could not be created.").as_ref()); return Task::none(); } self.tabs[i].dirty = true; @@ -614,12 +614,12 @@ impl OpenCADStudio { .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) { self.command_line - .push_error("Gradient Fill: the fill is on a locked layer."); + .push_error(crate::t!("Gradient Fill: the fill is on a locked layer.").as_ref()); self.graphic_attributes.gradient_editor = Some(state); return Task::none(); } let angle = state.angle_radians(); - let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); + let pending = self.begin_undo(i, crate::t!("Edit gradient"), targets.len(), true); for handle in &targets { let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { continue; @@ -731,7 +731,7 @@ impl OpenCADStudio { ); } let selected = selected.as_slice(); - let pending = self.begin_undo(i, "Graphic Attributes", selected.len(), true); + let pending = self.begin_undo(i, crate::t!("Graphic Attributes"), selected.len(), true); let mut changed = false; for handle in to_close { let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { @@ -768,7 +768,7 @@ impl OpenCADStudio { } else { let Some((name, pattern)) = fill_pattern(value) else { self.command_line - .push_error("Graphic Attributes: no hatch pattern is available."); + .push_error(crate::t!("Graphic Attributes: no hatch pattern is available.").as_ref()); return false; }; let working = if self.tabs[i].editing_model_space() { @@ -867,16 +867,17 @@ impl OpenCADStudio { .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) { self.command_line - .push_error("Graphic Attributes: an associated fill is on a locked layer."); + .push_error(crate::t!("Graphic Attributes: an associated fill is on a locked layer.").as_ref()); return false; } if skipped == 1 { self.command_line - .push_info("Graphic Attributes: 1 unsupported object was not changed."); + .push_info(crate::t!("Graphic Attributes: 1 unsupported object was not changed.").as_ref()); } else if skipped > 1 { - self.command_line.push_info(&format!( - "Graphic Attributes: {skipped} unsupported objects were not changed." - )); + self.command_line.push_info( + crate::tf!("Graphic Attributes: {skipped} unsupported objects were not changed.") + .as_ref(), + ); } if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { return false; @@ -892,14 +893,14 @@ impl OpenCADStudio { } pub(super) fn on_solid_fill_color(&mut self, color: AcadColor) -> Task { - self.edit_graphic_part(Part::Fill, "Fill color", |entity| { + self.edit_graphic_part(Part::Fill, &crate::t!("Fill color"), |entity| { crate::scene::view::dispatch::apply_color(entity, color); }); Task::none() } pub(super) fn on_fill_transparency(&mut self, transparency: Transparency) -> Task { - self.edit_graphic_part(Part::Fill, "Fill transparency", |entity| { + self.edit_graphic_part(Part::Fill, &crate::t!("Fill transparency"), |entity| { entity.common_mut().transparency = transparency; }); Task::none() @@ -909,7 +910,7 @@ impl OpenCADStudio { if !self.has_selection() { return self.on_ribbon_color_changed(color); } - self.edit_graphic_part(Part::Line, "Line color", |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Line color"), |entity| { crate::scene::view::dispatch::apply_color(entity, color); }); Task::none() @@ -919,7 +920,7 @@ impl OpenCADStudio { if !self.has_selection() { return self.on_ribbon_linetype_changed(linetype); } - self.edit_graphic_part(Part::Line, "Line linetype", |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Line linetype"), |entity| { crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); }); Task::none() @@ -927,9 +928,9 @@ impl OpenCADStudio { pub(super) fn on_line_lineweight(&mut self, lineweight: LineWeight) -> Task { if !self.has_selection() { - return self.update(Message::RibbonLineweightChanged(lineweight)); + return self.on_ribbon_lineweight_changed(lineweight); } - self.edit_graphic_part(Part::Line, "Line weight", |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Line weight"), |entity| { crate::scene::view::dispatch::apply_line_weight(entity, lineweight); }); Task::none() @@ -946,7 +947,7 @@ impl OpenCADStudio { self.refresh_properties(); return Task::none(); } - self.edit_graphic_part(Part::Line, "Linetype scale", |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Linetype scale"), |entity| { entity.common_mut().linetype_scale = scale; }); Task::none() @@ -965,7 +966,7 @@ impl OpenCADStudio { } return Task::none(); } - self.edit_graphic_part(Part::Line, "Line transparency", |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Line transparency"), |entity| { entity.common_mut().transparency = transparency; }); Task::none() @@ -988,113 +989,114 @@ mod tests { app } - #[test] - fn graphic_attributes_creates_one_associative_fill_per_object() { - let mut app = fresh(); - let i = app.active_tab; - let mut boundaries = Vec::new(); - // Coincident boundaries must still own independent associations. - for offset in [0.0, 0.0] { - let mut polyline = LwPolyline::new(); - for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { - polyline.add_point(Vector2::new(x + offset, y)); - } - polyline.close(); - let handle = app.tabs[i] - .scene - .add_entity(EntityType::LwPolyline(polyline)); - app.tabs[i].scene.select_entity(handle, false); - boundaries.push(handle); + fn add_closed_square(app: &mut OpenCADStudio, x: f64) -> Handle { + let mut polyline = LwPolyline::new(); + for (px, py) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(px + x, py)); } + polyline.close(); + let i = app.active_tab; + app.tabs[i].scene.add_entity(EntityType::LwPolyline(polyline)) + } - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] + fn select(app: &mut OpenCADStudio, handles: &[Handle]) { + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + for handle in handles { + app.tabs[i].scene.select_entity(*handle, false); + } + } + + fn hatches(app: &OpenCADStudio) -> Vec<&acadrust::entities::Hatch> { + app.tabs[app.active_tab] .scene .document .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) + .filter_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch), + _ => None, }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && hatch.is_associative)); - assert!(hatches.iter().all(|hatch| hatch + .collect() + } + + fn common(app: &OpenCADStudio, handle: Handle) -> &acadrust::entities::EntityCommon { + app.tabs[app.active_tab] + .scene + .document + .get_entity(handle) + .expect("entity") + .common() + } + + fn fill_kind(app: &OpenCADStudio, handle: Handle) -> GraphicAttribute { + palette::current(&app.tabs[app.active_tab].scene.document, &[handle]) + } + + /// A closed square with a fill of `kind`; the fill HATCH is selected. + fn filled_square(app: &mut OpenCADStudio, kind: GraphicAttribute) -> (Handle, Handle) { + let boundary = add_closed_square(app, 0.0); + select(app, &[boundary]); + let _ = app.on_graphic_attribute(kind); + let hatch = hatches(app)[0].common.handle; + select(app, &[hatch]); + (boundary, hatch) + } + + #[test] + fn coincident_boundaries_each_get_their_own_associative_fill() { + let mut app = fresh(); + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 0.0)]; + select(&mut app, &boundaries); + + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let fills = hatches(&app); + assert_eq!(fills.len(), 2); + assert!(fills.iter().all(|hatch| hatch.is_solid && hatch.is_associative)); + assert!(fills.iter().all(|hatch| hatch .paths .iter() .flat_map(|path| &path.boundary_handles) .count() == 1)); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(boundaries[0], false); + select(&mut app, &boundaries[..1]); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Solid); + assert_eq!(hatches(&app).len(), 2); + } + + #[test] + fn unsupported_objects_are_skipped_while_the_rest_is_filled() { + let mut app = fresh(); + let i = app.active_tab; + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)]; + select(&mut app, &boundaries); let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.gradient_color.enabled) - .count(), - 1 - ); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) - .count(), - 1 - ); - // An invalid boundary is skipped while valid selected boundaries are - // still updated. let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); - app.tabs[i].scene.select_entity(line, false); + select(&mut app, &[boundaries[0], line]); let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); - assert!(hatches.iter().all(|hatch| hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .all(|handle| *handle != line))); + assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Solid); + assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, line), GraphicAttribute::None); + assert_eq!(hatches(&app).len(), 2); + } - // Editing a common gradient updates each object's independent hatch - // and records the whole operation as one undo step. - app.tabs[i].scene.deselect_all(); - for handle in &boundaries { - app.tabs[i].scene.select_entity(*handle, false); - } + #[test] + fn editing_a_shared_gradient_is_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)]; + select(&mut app, &boundaries); let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + app.open_gradient_editor(); - let editor = app.graphic_attributes.gradient_editor.as_mut().expect("gradient editor"); + let editor = app + .graphic_attributes + .gradient_editor + .as_mut() + .expect("gradient editor"); assert_eq!(editor.handles.len(), 2); editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; editor.inverted = true; @@ -1104,319 +1106,105 @@ mod tests { let _ = app.apply_gradient_editor(); assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); - let gradients: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - hatch.gradient_color.enabled.then_some(hatch) - }) - .collect(); + let gradients = hatches(&app); assert_eq!(gradients.len(), 2); assert!(gradients.iter().all(|hatch| { hatch.gradient_color.name == "INVCURVED" && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 })); + } - // A selection change closes the flyout and it remains closed until - // the user explicitly opens it again. + #[test] + fn gradient_editor_closes_when_the_selection_changes() { + let mut app = fresh(); + filled_square(&mut app, GraphicAttribute::Gradient); app.open_gradient_editor(); assert!(app.graphic_attributes.gradient_editor.is_some()); - app.tabs[i].scene.deselect_all(); + + app.tabs[app.active_tab].scene.deselect_all(); let _ = app.update(ga(GraphicAttributesMsg::CloseMenu)); assert!(app.graphic_attributes.gradient_editor.is_none()); + } - let selected_hatch = app.tabs[i] - .scene - .document - .entities() - .find_map(|entity| { - matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) - }) - .expect("gradient hatch"); - let associated_lines = palette::line_handles( - &app.tabs[i].scene.document, - &[selected_hatch], - ); - assert_eq!(associated_lines.len(), 1); - assert!(boundaries.contains(&associated_lines[0])); - assert_eq!( - palette::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); + #[test] + fn a_selected_hatch_is_converted_in_place() { + let mut app = fresh(); + let (boundary, hatch) = filled_square(&mut app, GraphicAttribute::Gradient); + let document = &app.tabs[app.active_tab].scene.document; + assert_eq!(palette::line_handles(document, &[hatch]), vec![boundary]); + assert_eq!(fill_kind(&app, hatch), GraphicAttribute::Gradient); - // A directly selected hatch is edited in place instead of being - // rejected as an unsupported boundary object. - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - assert_eq!( - palette::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Solid, - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("transparent solid hatch") - .common() - .transparency - .as_percent() - - 0.35) - .abs() - < 0.01 - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("ByLayer solid hatch") - .common() - .transparency - .is_by_layer()); + for kind in [GraphicAttribute::Solid, GraphicAttribute::Hatch, GraphicAttribute::Gradient] { + let _ = app.on_graphic_attribute(kind); + assert_eq!(fill_kind(&app, hatch), kind); + } + assert_eq!(hatches(&app).len(), 1); + + let pattern = default_hatch_pattern().expect("pattern hatch").name.clone(); + let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern.clone()))); + assert_eq!(hatches(&app)[0].pattern.name, pattern); + } + + #[test] + fn line_and_fill_controls_only_edit_their_own_part() { + let mut app = fresh(); + let i = app.active_tab; + let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid); + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + select(&mut app, &[hatch, line]); + + let _ = app.on_solid_fill_color(AcadColor::Index(3)); + let _ = app.on_line_color(AcadColor::Index(4)); + assert_eq!(common(&app, hatch).color, AcadColor::Index(3)); + assert_eq!(common(&app, line).color, AcadColor::Index(4)); - // Line controls ignore HATCH entities, while fill controls ignore - // ordinary geometry in the same mixed selection. - app.tabs[i].scene.select_entity(line, false); - let _ = app.on_line_color(acadrust::types::Color::Index(4)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(5), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); let _ = app.on_line_linetype("ByBlock".to_string()); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype, - "ByBlock", - ); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - assert_ne!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - let _ = app.on_line_linetype_scale(2.5); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale - - 2.5) - .abs() - < f64::EPSILON - ); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .linetype_scale - - 1.0) - .abs() - < f64::EPSILON - ); - let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN))); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale, - 2.5 - ); - let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(1.2000000000000002))); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale, - 1.2 - ); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .transparency - .is_by_layer()); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - let pattern_name = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| { - matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Pattern(_) - ) - }) - .expect("pattern hatch") - .name - .clone(); - let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern_name.clone()))); - let selected_pattern = match app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("pattern hatch") - { - EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), - _ => panic!("fill target is not a hatch"), - }; - assert_eq!(selected_pattern, pattern_name); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); + assert_eq!(common(&app, line).linetype, "ByBlock"); - let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); - assert_eq!( - palette::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Hatch, - ); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - assert_eq!( - palette::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); + let _ = app.on_line_lineweight(LineWeight::Value(50)); + assert_eq!(common(&app, line).line_weight, LineWeight::Value(50)); + assert_ne!(common(&app, hatch).line_weight, LineWeight::Value(50)); + + let _ = app.on_line_linetype_scale(2.5); + assert_eq!(common(&app, line).linetype_scale, 2.5); + assert_eq!(common(&app, hatch).linetype_scale, 1.0); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN))); + assert_eq!(common(&app, line).linetype_scale, 2.5); + + let _ = app.on_line_transparency(Transparency::from_percent(0.2)); + let _ = app.on_fill_transparency(Transparency::from_percent(0.4)); + assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01); + assert!((common(&app, hatch).transparency.as_percent() - 0.4).abs() < 0.01); + let _ = app.on_fill_transparency(Transparency::BY_LAYER); + assert!(common(&app, hatch).transparency.is_by_layer()); + assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01); + } + + #[test] + fn choosing_another_fill_closes_the_editors() { + let mut app = fresh(); + filled_square(&mut app, GraphicAttribute::Gradient); app.open_gradient_editor(); assert!(app.graphic_attributes.gradient_editor.is_some()); let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); assert!(app.graphic_attributes.gradient_editor.is_none()); + let _ = app.update(ga(GraphicAttributesMsg::HatchEditorOpen)); assert!(app.graphic_attributes.hatch_editor.is_some()); let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); assert!(app.graphic_attributes.hatch_editor.is_none()); assert!(app.graphic_attributes.gradient_editor.is_none()); + } + #[test] + fn removing_the_fill_erases_it_in_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid); let undo_before = app.tabs[i].history.undo_stack.len(); let _ = app.on_graphic_attribute(GraphicAttribute::None); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .is_none()); + assert!(app.tabs[i].scene.document.get_entity(hatch).is_none()); assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2d682e884..36097bbff 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6477,31 +6477,7 @@ impl OpenCADStudio { Task::none() } Message::RibbonLinetypeChanged(lt) => self.on_ribbon_linetype_changed(lt), - Message::RibbonLineweightChanged(lw) => { - let i = self.active_tab; - self.ribbon.close_dropdown(); - let handles = self.property_target_handles(i); - if handles.is_empty() { - if self.has_property_selection(i) { - return Task::none(); - } - // Persist into the tab's header (CELWEIGHT). #21. - self.tabs[i].scene.document.header.current_line_weight = lw.value(); - self.tabs[i].dirty = true; - self.ribbon.active_lineweight = lw; - } else { - // Lineweight is baked into the cached wire geometry — - // re-tessellate so the change shows immediately (issue #231 - // class). - self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_line_weight(entity, lw); - } - }); - self.ribbon.active_lineweight = lw; - } - Task::none() - } + Message::RibbonLineweightChanged(lw) => self.on_ribbon_lineweight_changed(lw), Message::RibbonStyleChanged { key, name } => self.on_ribbon_style_changed(key, name), diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index bb32171c4..f9e70e4b4 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5251,6 +5251,39 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "US Survey Feet" => Some(("common", "us-survey-feet")), "to layout" => Some(("common", "to-layout")), "{count} objects selected" => Some(("common", "count-objects-selected")), + "Graphic Attributes" => Some(("graphic-attributes", "title")), + "Set all attributes ByLayer" => Some(("graphic-attributes", "set-all-bylayer")), + "Set all attributes ByBlock" => Some(("graphic-attributes", "set-all-byblock")), + "Remove ByLayer / ByBlock" => Some(("graphic-attributes", "remove-bylayer-byblock")), + "Create Layer with active settings" => Some(("graphic-attributes", "create-layer-active-settings")), + "Create fills on the current layer" => Some(("graphic-attributes", "fills-on-current-layer")), + "Edit hatch" => Some(("graphic-attributes", "edit-hatch")), + "Edit gradient" => Some(("graphic-attributes", "edit-gradient")), + "The selected object is open. Close it before creating the fill? [Yes / No] " => Some(("graphic-attributes", "close-open-object")), + "{count} selected objects are open. Close them before creating the fill? [Yes / No] " => Some(("graphic-attributes", "close-open-objects")), + "Enter a name for the new layer:" => Some(("graphic-attributes", "enter-new-layer-name")), + "Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`." => Some(("graphic-attributes", "invalid-layer-name")), + "Graphic Attributes: layer \"{name}\" already exists." => Some(("graphic-attributes", "layer-exists")), + "Graphic Attributes: the layer could not be created." => Some(("graphic-attributes", "layer-not-created")), + "Created layer \"{name}\" with the active Graphic Attributes settings." => Some(("graphic-attributes", "layer-created")), + "Gradient Fill: the fill is on a locked layer." => Some(("graphic-attributes", "gradient-fill-locked")), + "Select one or more closed objects first." => Some(("graphic-attributes", "select-closed-objects")), + "Graphic Attributes: {} object(s) on a locked layer were not changed." => Some(("graphic-attributes", "locked-objects-skipped")), + "Graphic Attributes: no hatch pattern is available." => Some(("graphic-attributes", "no-hatch-pattern")), + "Graphic Attributes: an associated fill is on a locked layer." => Some(("graphic-attributes", "associated-fill-locked")), + "Graphic Attributes: 1 unsupported object was not changed." => Some(("graphic-attributes", "one-unsupported")), + "Graphic Attributes: {skipped} unsupported objects were not changed." => Some(("graphic-attributes", "unsupported-skipped")), + "Fill transparency" => Some(("graphic-attributes", "fill-transparency")), + "Line linetype" => Some(("graphic-attributes", "line-linetype")), + "Line transparency" => Some(("graphic-attributes", "line-transparency")), + "Cyl." => Some(("graphic-attributes", "cylindrical-short")), + "Spher." => Some(("graphic-attributes", "spherical-short")), + "Hemi." => Some(("graphic-attributes", "hemispherical-short")), + "Invert gradient" => Some(("graphic-attributes", "invert-gradient")), + "Tone" => Some(("graphic-attributes", "tone")), + "Reset angle" => Some(("graphic-attributes", "reset-angle")), + "One Color" => Some(("graphic-attributes", "one-color")), + "Two Colors" => Some(("graphic-attributes", "two-colors")), _ => None, } } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index f0a86db38..ca12bf0e0 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -249,16 +249,29 @@ pub(crate) fn filtered_hatch_patterns( .collect() } +/// Messages of a hatch pattern picker; the Properties panel and the Graphic +/// Attributes palette each route them to their own state. +pub(crate) struct PatternPickerMessages { + pub search_id: &'static str, + pub on_search: fn(String) -> Message, + pub on_confirm: Message, + pub on_focus: fn(usize) -> Message, + pub on_changed: fn(String) -> Message, +} + pub(crate) fn hatch_pattern_picker_content<'a>( search_value: &'a str, focus: usize, current: &'a str, - search_id: &'static str, - on_search: fn(String) -> Message, - on_confirm: Message, - on_focus: fn(usize) -> Message, - on_changed: fn(String) -> Message, + messages: PatternPickerMessages, ) -> Element<'a, Message> { + let PatternPickerMessages { + search_id, + on_search, + on_confirm, + on_focus, + on_changed, + } = messages; let search = text_input(t!("Search patterns…").as_ref(), search_value) .id(iced::widget::Id::new(search_id)) .on_input(on_search) @@ -1663,11 +1676,13 @@ impl PropertiesPanel { &self.hatch_pattern_search, self.hatch_pattern_focus, current, - "hatch-pattern-search", - Message::PropHatchPatternSearchChanged, - Message::PropHatchPatternConfirm, - Message::PropHatchPatternFocus, - Message::PropHatchPatternChanged, + PatternPickerMessages { + search_id: "hatch-pattern-search", + on_search: Message::PropHatchPatternSearchChanged, + on_confirm: Message::PropHatchPatternConfirm, + on_focus: Message::PropHatchPatternFocus, + on_changed: Message::PropHatchPatternChanged, + }, ); prop_row_widget( diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs index a51976f1d..d050b2a83 100644 --- a/src/ui/window/gradient_editor.rs +++ b/src/ui/window/gradient_editor.rs @@ -276,7 +276,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { } }) }; - let color_field = |label: &'static str, index, color: AcadColor| { + let color_field = |label: std::borrow::Cow<'static, str>, index, color: AcadColor| { let (background, _) = crate::ui::properties::acad_color_display(color); let value = crate::ui::color_select::color_display_name(color); let picker = button( @@ -320,11 +320,11 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { .width(Fill) }; let kinds = [ - (GradientKind::Linear, "Linear"), - (GradientKind::Cylinder, "Cyl."), - (GradientKind::Spherical, "Spher."), - (GradientKind::Hemispherical, "Hemi."), - (GradientKind::Curved, "Curved"), + (GradientKind::Linear, crate::t!("Linear")), + (GradientKind::Cylinder, crate::t!("Cyl.")), + (GradientKind::Spherical, crate::t!("Spher.")), + (GradientKind::Hemispherical, crate::t!("Hemi.")), + (GradientKind::Curved, crate::t!("Curved")), ]; let mut type_row = row![].spacing(3); for (kind, label) in kinds { @@ -357,7 +357,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { )); } let invert_selected = state.inverted && state.kind != GradientKind::Linear; - let invert = button(column![text("⇄").size(18), text("Invert").size(9)].align_x(iced::Center)) + let invert = button(column![text("⇄").size(18), text(crate::t!("Invert")).size(9)].align_x(iced::Center)) .on_press_maybe( (state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)), ) @@ -378,21 +378,21 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { }); type_row = type_row.push(separator).push(tooltip( invert, - text("Invert gradient").size(10), + text(crate::t!("Invert gradient")).size(10), tooltip::Position::Bottom, )); let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two { row![ - color_field("Color 1", 1, state.color_1), - color_field("Color 2", 2, state.color_2) + color_field(crate::t!("Color 1"), 1, state.color_1), + color_field(crate::t!("Color 2"), 2, state.color_2) ] .spacing(6) .into() } else { row![ - color_field("Color 1", 1, state.color_1), + color_field(crate::t!("Color 1"), 1, state.color_1), row![ - text("Tone").size(11).width(43), + text(crate::t!("Tone")).size(11).width(43), slider(0..=100, (state.shade_tint * 100.0) as i32, |v| { msg(GradientMsg::ShadeTint(v as f32 / 100.0)) }) @@ -408,7 +408,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { .into() }; let angle = row![ - text("Angle").size(11).width(43), + text(crate::t!("Angle")).size(11).width(43), text_input("0.0°", &state.angle) .on_input(|value| msg(GradientMsg::Angle(value))) .size(11) @@ -420,19 +420,19 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { .padding(3) .width(24) .height(crate::ui::ROW_H), - text("Reset angle").size(10), + text(crate::t!("Reset angle")).size(10), tooltip::Position::Bottom, ), Space::new().width(Fill), checkbox(state.centered) - .label("Centered") + .label(crate::t!("Centered")) .on_toggle(|value| msg(GradientMsg::Centered(value))) .size(14) .text_size(11) ] .spacing(6) .align_y(iced::Center); - let types = row![text("Type").size(11).width(43), type_row] + let types = row![text(crate::t!("Type")).size(11).width(43), type_row] .spacing(4) .align_y(iced::Center); let actions = container( @@ -453,8 +453,8 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let content = column![ container(preview(state)).width(Fill).height(80), row![ - segment("One Color", GradientColorMode::One), - segment("Two Colors", GradientColorMode::Two) + segment(crate::t!("One Color"), GradientColorMode::One), + segment(crate::t!("Two Colors"), GradientColorMode::Two) ] .spacing(2), colors, diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 5d327e91b..9a435c725 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -71,12 +71,12 @@ impl GraphicAttribute { impl std::fmt::Display for GraphicAttribute { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Varies => "*VARIES*", - Self::None => "None", - Self::Solid => "Solid", - Self::Hatch => "Hatch", - Self::Gradient => "Gradient", + f.write_str(&match self { + Self::Varies => "*VARIES*".into(), + Self::None => crate::t!("None"), + Self::Solid => crate::t!("Solid"), + Self::Hatch => crate::t!("Hatch"), + Self::Gradient => crate::t!("Gradient"), }) } } @@ -152,7 +152,7 @@ pub struct GraphicAttributesState { pub fill_on_current_layer: bool, /// View-side caches; `view` runs after every message, so neither the /// fill lookup nor the gradient preview is rebuilt unless its input changed. - fill_index: RefCell>, + fill_index: RefCell>, gradient_preview: RefCell)>>, } @@ -166,7 +166,9 @@ impl GraphicAttributesState { /// The fills and fill kind of `selected`, from an index of associative /// fills that is rebuilt only when the scene's geometry changes. pub(crate) fn fills(&self, scene: &Scene, selected: &[Handle]) -> (Vec, GraphicAttribute) { - let key = (std::ptr::from_ref(scene) as usize, scene.geometry_epoch); + // Geometry epochs are unique across all scenes, so the epoch alone + // identifies both the tab and the state of its drawing. + let key = scene.geometry_epoch; let mut cache = self.fill_index.borrow_mut(); if cache.as_ref().is_none_or(|(cached, _)| *cached != key) { *cache = Some((key, FillIndex::build(&scene.document))); @@ -219,12 +221,13 @@ impl GraphicAttributesState { pub fn fill_close_prompt(&self) -> Option { let count = self.pending_fill_close.as_ref()?.open_boundaries.len(); Some(if count == 1 { - "The selected object is open. Close it before creating the fill? [Yes / No] " - .to_string() + crate::t!("The selected object is open. Close it before creating the fill? [Yes / No] ") + .into_owned() } else { - format!( + crate::tf!( "{count} selected objects are open. Close them before creating the fill? [Yes / No] " ) + .into_owned() }) } } @@ -634,7 +637,7 @@ fn compact_button_content(label: &'static str) -> Element<'static, Message> { .into() } -fn popup_item<'a>(label: &'a str, message: Message) -> Element<'a, Message> { +fn popup_item<'a>(label: std::borrow::Cow<'a, str>, message: Message) -> Element<'a, Message> { button(text(label).size(11)) .on_press(message) .style(crate::ui::color_select::list_row_style) @@ -650,7 +653,7 @@ fn mode_menu<'a>( menu: Menu, open_menu: Option, height: f32, - items: Vec<(&'a str, Message)>, + items: Vec<(std::borrow::Cow<'a, str>, Message)>, ) -> Element<'a, Message> { let open = open_menu == Some(menu); let more = button(compact_button_content(mode.button_label())) @@ -738,9 +741,9 @@ fn color_control<'a>( open_menu, COMPACT_BUTTON_HEIGHT, vec![ - ("ByLayer", msg(changed(AcadColor::ByLayer))), - ("ByBlock", msg(changed(AcadColor::ByBlock))), - ("Custom...", Message::OpenColorWindow(target, info.color)), + ("ByLayer".into(), msg(changed(AcadColor::ByLayer))), + ("ByBlock".into(), msg(changed(AcadColor::ByBlock))), + (crate::t!("Custom…"), Message::OpenColorWindow(target, info.color)), ], ); row![swatch, more].spacing(4).align_y(iced::Center).into() @@ -777,9 +780,9 @@ fn transparency_control<'a>( open_menu, crate::ui::ROW_H, vec![ - ("ByLayer", msg(changed(Transparency::BY_LAYER))), - ("ByBlock", msg(changed(Transparency::BY_BLOCK))), - ("Custom", msg(changed(percent(info.value)))), + ("ByLayer".into(), msg(changed(Transparency::BY_LAYER))), + ("ByBlock".into(), msg(changed(Transparency::BY_BLOCK))), + (crate::t!("Custom"), msg(changed(percent(info.value)))), ], ); row![control, more].spacing(4).align_y(iced::Center).into() @@ -804,7 +807,7 @@ fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Eleme } let item = |label: &'static str, message| { - button(text(label).size(11)) + button(text(crate::t!(label)).size(11)) .on_press(msg(message)) .style(crate::ui::color_select::list_row_style) .width(Fill) @@ -817,7 +820,7 @@ fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Eleme item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), container( iced::widget::checkbox(fill_on_current_layer) - .label("Create fills on the current layer") + .label(crate::t!("Create fills on the current layer")) .on_toggle(|_| msg(GraphicAttributesMsg::ToggleFillOnCurrentLayer)) .size(12) .text_size(11), @@ -952,7 +955,7 @@ fn preview_row<'a>( preview: Element<'a, Message>, editor_open: bool, toggle: GraphicAttributesMsg, - tooltip_label: &'static str, + tooltip_label: std::borrow::Cow<'static, str>, flyout: Option<(Element<'a, Message>, f32, Option)>, side: DockSide, ) -> Element<'a, Message> { @@ -1054,8 +1057,8 @@ fn linetype_row<'a>( open_menu, COMPACT_BUTTON_HEIGHT, vec![ - ("ByLayer", msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))), - ("ByBlock", msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))), + ("ByLayer".into(), msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))), + ("ByBlock".into(), msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))), ], ); row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] @@ -1166,8 +1169,8 @@ fn lineweight_field<'a>( open_menu, COMPACT_BUTTON_HEIGHT, vec![ - ("ByLayer", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))), - ("ByBlock", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))), + ("ByLayer".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))), + ("ByBlock".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))), ], ); row![ @@ -1324,11 +1327,13 @@ pub fn view<'a>( &editor.search, editor.focus, hatch.map_or("", |hatch| hatch.pattern.name.as_str()), - "graphic-hatch-pattern-search", - |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)), - msg(GraphicAttributesMsg::HatchPatternConfirm), - |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)), - |name| msg(GraphicAttributesMsg::HatchPattern(name)), + crate::ui::properties::PatternPickerMessages { + search_id: "graphic-hatch-pattern-search", + on_search: |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)), + on_confirm: msg(GraphicAttributesMsg::HatchPatternConfirm), + on_focus: |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)), + on_changed: |name| msg(GraphicAttributesMsg::HatchPattern(name)), + }, ); let popup = container(picker) .padding(1) @@ -1352,7 +1357,7 @@ pub fn view<'a>( } else { GraphicAttributesMsg::HatchEditorOpen }, - "Edit hatch", + crate::t!("Edit hatch"), flyout, side, )); @@ -1390,7 +1395,7 @@ pub fn view<'a>( } else { GradientMsg::Open }), - "Edit gradient", + crate::t!("Edit gradient"), flyout, side, )); From 33a105a619d2142fb2cf41d1956b90db00000462 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:01:25 +0000 Subject: [PATCH 09/10] Graphic Attributes: quieter swatches, Enter in linetype scale, undo names - Colour swatches carry no *VARIES* or ByBlock text; the L / B / C / V button already shows that. - A separator sits between "Create Layer with active settings" and the "Create fills on the current layer" checkbox. - The linetype scale field is a plain text field like the Properties panel: the first click selects its value, Enter applies it (decimal comma and expressions accepted) instead of repeating the last command. This drops the iced_aw number_input feature again. - Undo/redo name fill changes "Fill change" and linetype changes "Linetype", in all 21 locales. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- Cargo.lock | 12 --- Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 2 +- locales/bg-BG/opencadstudio.ftl | 2 +- locales/cs-CZ/opencadstudio.ftl | 2 +- locales/de-DE/opencadstudio.ftl | 2 +- locales/el-GR/opencadstudio.ftl | 2 +- locales/en-US/opencadstudio.ftl | 2 +- locales/es-ES/opencadstudio.ftl | 2 +- locales/fi-FI/opencadstudio.ftl | 2 +- locales/fr-FR/opencadstudio.ftl | 2 +- locales/hi-IN/opencadstudio.ftl | 2 +- locales/hu-HU/opencadstudio.ftl | 2 +- locales/it-IT/opencadstudio.ftl | 2 +- locales/ja-JP/opencadstudio.ftl | 2 +- locales/ko-KR/opencadstudio.ftl | 2 +- locales/nl-NL/opencadstudio.ftl | 2 +- locales/pl-PL/opencadstudio.ftl | 2 +- locales/pt-BR/opencadstudio.ftl | 2 +- locales/ru-RU/opencadstudio.ftl | 2 +- locales/tr-TR/opencadstudio.ftl | 2 +- locales/zh-CN/opencadstudio.ftl | 2 +- locales/zh-TW/opencadstudio.ftl | 2 +- src/app/update/graphic_attributes.rs | 88 ++++++++++++++++++++- src/app/update/mod.rs | 17 +++- src/locale_catalog.rs | 2 +- src/ui/window/graphic_attributes.rs | 113 +++++++++++---------------- 27 files changed, 168 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19b6a71c0..2c94eff96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2571,8 +2571,6 @@ dependencies = [ "iced_core", "iced_fonts_macros", "iced_widget", - "num-format", - "num-traits", "web-time", ] @@ -3770,16 +3768,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "num-format" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" -dependencies = [ - "arrayvec", - "itoa", -] - [[package]] name = "num-integer" version = "0.1.46" diff --git a/Cargo.toml b/Cargo.toml index 1805b6b8a..8d5dbfc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ url = "2" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down", "number_input"] } +iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 0766b2552..69fc56032 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5652,7 +5652,7 @@ graphic-attributes = .one-unsupported = سمات الرسم: لم يتم تغيير كائن واحد غير مدعوم. .unsupported-skipped = سمات الرسم: لم يتم تغيير __ocs_fmt_0__ من الكائنات غير المدعومة. .fill-transparency = شفافية التعبئة - .line-linetype = نوع خط الخطوط + .fill-change = تغيير التعبئة .line-transparency = شفافية الخطوط .cylindrical-short = أسطواني .spherical-short = كروي diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index bc8e77fe8..ee13f803b 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5640,7 +5640,7 @@ graphic-attributes = .one-unsupported = Графични атрибути: 1 неподдържан обект не беше променен. .unsupported-skipped = Графични атрибути: __ocs_fmt_0__ неподдържани обекта не бяха променени. .fill-transparency = Прозрачност на запълването - .line-linetype = Тип линия на линиите + .fill-change = Промяна на запълването .line-transparency = Прозрачност на линиите .cylindrical-short = Цил. .spherical-short = Сфер. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index bfbd5cec9..3011095ae 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5637,7 +5637,7 @@ graphic-attributes = .one-unsupported = Grafické atributy: 1 nepodporovaný objekt nebyl změněn. .unsupported-skipped = Grafické atributy: nepodporované objekty (__ocs_fmt_0__) nebyly změněny. .fill-transparency = Průhlednost výplně - .line-linetype = Typ čáry + .fill-change = Změna výplně .line-transparency = Průhlednost čar .cylindrical-short = Válc. .spherical-short = Kul. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9708790cf..49a9892c6 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5638,7 +5638,7 @@ graphic-attributes = .one-unsupported = Grafikattribute: 1 nicht unterstütztes Objekt wurde nicht geändert. .unsupported-skipped = Grafikattribute: __ocs_fmt_0__ nicht unterstützte Objekte wurden nicht geändert. .fill-transparency = Füllungstransparenz - .line-linetype = Linientyp der Linien + .fill-change = Füllung ändern .line-transparency = Linientransparenz .cylindrical-short = Zyl. .spherical-short = Kugel diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index e9aac1bb9..3b6bf7812 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5640,7 +5640,7 @@ graphic-attributes = .one-unsupported = Γραφικά χαρακτηριστικά: 1 μη υποστηριζόμενο αντικείμενο δεν άλλαξε. .unsupported-skipped = Γραφικά χαρακτηριστικά: __ocs_fmt_0__ μη υποστηριζόμενα αντικείμενα δεν άλλαξαν. .fill-transparency = Διαφάνεια γεμίσματος - .line-linetype = Τύπος γραμμής + .fill-change = Αλλαγή γεμίσματος .line-transparency = Διαφάνεια γραμμών .cylindrical-short = Κυλ. .spherical-short = Σφαιρ. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 456f168f4..6d263460d 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5641,7 +5641,7 @@ graphic-attributes = .one-unsupported = Graphic Attributes: 1 unsupported object was not changed. .unsupported-skipped = Graphic Attributes: __ocs_fmt_0__ unsupported objects were not changed. .fill-transparency = Fill transparency - .line-linetype = Line linetype + .fill-change = Fill change .line-transparency = Line transparency .cylindrical-short = Cyl. .spherical-short = Spher. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8005d34de..835fe5b55 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5640,7 +5640,7 @@ graphic-attributes = .one-unsupported = Atributos gráficos: no se modificó 1 objeto no admitido. .unsupported-skipped = Atributos gráficos: no se modificaron __ocs_fmt_0__ objetos no admitidos. .fill-transparency = Transparencia del relleno - .line-linetype = Tipo de línea + .fill-change = Cambio de relleno .line-transparency = Transparencia de línea .cylindrical-short = Cil. .spherical-short = Esfér. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2cbd7647e..3fbc0d13f 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5637,7 +5637,7 @@ graphic-attributes = .one-unsupported = Grafiikkamääritteet: 1 tukematonta objektia ei muutettu. .unsupported-skipped = Grafiikkamääritteet: __ocs_fmt_0__ tukematonta objektia jätettiin muuttamatta. .fill-transparency = Täytön läpinäkyvyys - .line-linetype = Viivojen viivatyyppi + .fill-change = Täytön muutos .line-transparency = Viivojen läpinäkyvyys .cylindrical-short = Lieriö .spherical-short = Pallo diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 2d0f667ca..503f011fc 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5639,7 +5639,7 @@ graphic-attributes = .one-unsupported = Attributs graphiques : 1 objet non pris en charge n'a pas été modifié. .unsupported-skipped = Attributs graphiques : __ocs_fmt_0__ objets non pris en charge n'ont pas été modifiés. .fill-transparency = Transparence du remplissage - .line-linetype = Type de ligne + .fill-change = Modification du remplissage .line-transparency = Transparence des lignes .cylindrical-short = Cyl. .spherical-short = Sphér. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 27fa7766e..076582fd6 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5628,7 +5628,7 @@ graphic-attributes = .one-unsupported = ग्राफ़िक गुण: 1 असमर्थित ऑब्जेक्ट नहीं बदला गया। .unsupported-skipped = ग्राफ़िक गुण: __ocs_fmt_0__ असमर्थित ऑब्जेक्ट नहीं बदले गए। .fill-transparency = भराव पारदर्शिता - .line-linetype = रेखा का लाइनटाइप + .fill-change = भराव परिवर्तन .line-transparency = रेखा पारदर्शिता .cylindrical-short = बेलन. .spherical-short = गोल. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ce71c489d..9c2555673 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5639,7 +5639,7 @@ graphic-attributes = .one-unsupported = Grafikus jellemzők: 1 nem támogatott objektum nem változott. .unsupported-skipped = Grafikus jellemzők: __ocs_fmt_0__ nem támogatott objektum nem változott. .fill-transparency = Kitöltés átlátszósága - .line-linetype = Vonaltípus + .fill-change = Kitöltés módosítása .line-transparency = Vonalak átlátszósága .cylindrical-short = Henger .spherical-short = Gömb diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index bac57efe9..89b0a7179 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5639,7 +5639,7 @@ graphic-attributes = .one-unsupported = Attributi grafici: 1 oggetto non supportato non è stato modificato. .unsupported-skipped = Attributi grafici: __ocs_fmt_0__ oggetti non supportati non sono stati modificati. .fill-transparency = Trasparenza riempimento - .line-linetype = Tipo di linea + .fill-change = Modifica riempimento .line-transparency = Trasparenza linee .cylindrical-short = Cil. .spherical-short = Sfer. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index fec230084..1e5cfd68e 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5628,7 +5628,7 @@ graphic-attributes = .one-unsupported = グラフィック属性: サポートされていないオブジェクト 1 個は変更されませんでした。 .unsupported-skipped = グラフィック属性: サポートされていないオブジェクト __ocs_fmt_0__ 個は変更されませんでした。 .fill-transparency = 塗り潰しの透過性 - .line-linetype = 線の線種 + .fill-change = 塗り潰しの変更 .line-transparency = 線の透過性 .cylindrical-short = 円柱 .spherical-short = 球 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c1fcbc464..90172b57e 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5639,7 +5639,7 @@ graphic-attributes = .one-unsupported = 그래픽 속성: 지원되지 않는 객체 1개가 변경되지 않았습니다. .unsupported-skipped = 그래픽 속성: 지원되지 않는 객체 __ocs_fmt_0__개가 변경되지 않았습니다. .fill-transparency = 채우기 투명도 - .line-linetype = 선의 선종류 + .fill-change = 채우기 변경 .line-transparency = 선 투명도 .cylindrical-short = 원통 .spherical-short = 구 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 2c9de8d1a..fdf7c69ea 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5638,7 +5638,7 @@ graphic-attributes = .one-unsupported = Grafische attributen: 1 niet-ondersteund object is niet gewijzigd. .unsupported-skipped = Grafische attributen: __ocs_fmt_0__ niet-ondersteunde objecten zijn niet gewijzigd. .fill-transparency = Transparantie vulling - .line-linetype = Lijntype lijnen + .fill-change = Vulling wijzigen .line-transparency = Transparantie lijnen .cylindrical-short = Cil. .spherical-short = Bol diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 70af7f1fa..f2c1e5c23 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5639,7 +5639,7 @@ graphic-attributes = .one-unsupported = Atrybuty graficzne: 1 nieobsługiwany obiekt nie został zmieniony. .unsupported-skipped = Atrybuty graficzne: nieobsługiwane obiekty (__ocs_fmt_0__) nie zostały zmienione. .fill-transparency = Przezroczystość wypełnienia - .line-linetype = Rodzaj linii + .fill-change = Zmiana wypełnienia .line-transparency = Przezroczystość linii .cylindrical-short = Walc. .spherical-short = Sfer. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index c007c8458..312cc29f8 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5640,7 +5640,7 @@ graphic-attributes = .one-unsupported = Atributos gráficos: 1 objeto não suportado não foi alterado. .unsupported-skipped = Atributos gráficos: __ocs_fmt_0__ objetos não suportados não foram alterados. .fill-transparency = Transparência do preenchimento - .line-linetype = Tipo de linha + .fill-change = Alteração de preenchimento .line-transparency = Transparência da linha .cylindrical-short = Cil. .spherical-short = Esfér. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3b4d84356..c708c089f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5641,7 +5641,7 @@ graphic-attributes = .one-unsupported = Графические атрибуты: 1 неподдерживаемый объект не изменён. .unsupported-skipped = Графические атрибуты: неподдерживаемые объекты (__ocs_fmt_0__) не изменены. .fill-transparency = Прозрачность заливки - .line-linetype = Тип линий + .fill-change = Изменение заливки .line-transparency = Прозрачность линий .cylindrical-short = Цил. .spherical-short = Сфер. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f94360dbd..81e5f8d1d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5563,7 +5563,7 @@ graphic-attributes = .one-unsupported = Grafik öznitelikleri: desteklenmeyen 1 nesne değiştirilmedi. .unsupported-skipped = Grafik öznitelikleri: desteklenmeyen __ocs_fmt_0__ nesne değiştirilmedi. .fill-transparency = Dolgu saydamlığı - .line-linetype = Çizgi tipi + .fill-change = Dolgu değişikliği .line-transparency = Çizgi saydamlığı .cylindrical-short = Sil. .spherical-short = Küre diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index bf728d3a4..28fccc873 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5623,7 +5623,7 @@ graphic-attributes = .one-unsupported = 图形特性: 1 个不支持的对象未更改。 .unsupported-skipped = 图形特性: __ocs_fmt_0__ 个不支持的对象未更改。 .fill-transparency = 填充透明度 - .line-linetype = 线的线型 + .fill-change = 更改填充 .line-transparency = 线的透明度 .cylindrical-short = 圆柱 .spherical-short = 球形 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index e8953c1a2..0ed8cf646 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5635,7 +5635,7 @@ graphic-attributes = .one-unsupported = 圖形特性: 1 個不支援的物件未變更。 .unsupported-skipped = 圖形特性: __ocs_fmt_0__ 個不支援的物件未變更。 .fill-transparency = 填充透明度 - .line-linetype = 線的線型 + .fill-change = 變更填充 .line-transparency = 線的透明度 .cylindrical-short = 圓柱 .spherical-short = 球形 diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs index f41e7f147..3e6a9b3e4 100644 --- a/src/app/update/graphic_attributes.rs +++ b/src/app/update/graphic_attributes.rs @@ -219,7 +219,11 @@ impl OpenCADStudio { use GraphicAttributesMsg as M; if !matches!( message, - M::ToggleMenu(_) | M::HatchPatternSearch(_) | M::HatchPatternFocus(_) | M::Gradient(_) + M::ToggleMenu(_) + | M::HatchPatternSearch(_) + | M::HatchPatternFocus(_) + | M::LineLinetypeScaleInput(_) + | M::Gradient(_) ) { self.graphic_attributes.open_menu = None; } @@ -253,6 +257,19 @@ impl OpenCADStudio { M::LineLinetype(linetype) => self.on_line_linetype(linetype), M::LineLineweight(lineweight) => self.on_line_lineweight(lineweight), M::LineLinetypeScale(scale) => self.on_line_linetype_scale(scale), + M::LineLinetypeScaleInput(value) => { + self.graphic_attributes.linetype_scale_input = Some(value); + Task::none() + } + M::LineLinetypeScaleSubmit => { + match self.graphic_attributes.linetype_scale_input.take() { + Some(typed) => { + let value = crate::app::expr_eval::eval_number(&typed.replace(',', ".")); + value.map_or_else(Task::none, |scale| self.on_line_linetype_scale(scale)) + } + None => Task::none(), + } + } M::LineTransparency(transparency) => self.on_line_transparency(transparency), M::FillColor(color) => self.on_solid_fill_color(color), M::FillTransparency(transparency) => self.on_fill_transparency(transparency), @@ -731,7 +748,7 @@ impl OpenCADStudio { ); } let selected = selected.as_slice(); - let pending = self.begin_undo(i, crate::t!("Graphic Attributes"), selected.len(), true); + let pending = self.begin_undo(i, crate::t!("Fill change"), selected.len(), true); let mut changed = false; for handle in to_close { let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { @@ -920,7 +937,7 @@ impl OpenCADStudio { if !self.has_selection() { return self.on_ribbon_linetype_changed(linetype); } - self.edit_graphic_part(Part::Line, &crate::t!("Line linetype"), |entity| { + self.edit_graphic_part(Part::Line, &crate::t!("Linetype"), |entity| { crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); }); Task::none() @@ -1604,4 +1621,69 @@ mod tests { Some((DockSide::Right, 0)) ); } + + fn output_since(app: &OpenCADStudio, start: usize) -> String { + app.command_line.history[start..] + .iter() + .map(|entry| entry.text.as_str()) + .collect::>() + .join("\n") + } + + #[test] + fn undo_names_fill_and_linetype_changes() { + let mut app = fresh(); + let boundary = add_closed_square(&mut app, 0.0); + select(&mut app, &[boundary]); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let start = app.command_line.history.len(); + let _ = app.update(Message::Undo); + assert!(output_since(&app, start).contains("Undo: Fill change")); + + let _ = app.update(ga(GraphicAttributesMsg::LineLinetype("Continuous".into()))); + let start = app.command_line.history.len(); + let _ = app.update(Message::Undo); + assert!(output_since(&app, start).contains("Undo: Linetype")); + } + + #[test] + fn typed_linetype_scale_is_applied_on_enter() { + let mut app = fresh(); + let i = app.active_tab; + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + select(&mut app, &[line]); + + // Enter without typing changes nothing. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 1.0); + + // Typing alone does not apply; Enter does, with a decimal comma too. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleInput("0,35".into()))); + assert_eq!(common(&app, line).linetype_scale, 1.0); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 0.35); + assert!(app.graphic_attributes.linetype_scale_input.is_none()); + + // Invalid or non-positive input is dropped. + for typed in ["abc", "0", "-2"] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleInput(typed.into()))); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 0.35); + } + } + + #[test] + fn clicking_the_linetype_scale_field_selects_it_once() { + let mut app = fresh(); + let field = iced::widget::Id::new(palette::LINETYPE_SCALE_FIELD); + let _ = app.update(Message::PropSyncActive(Some(field.clone()))); + assert!(app.graphic_attributes.linetype_scale_focused); + + // Focus elsewhere drops unsubmitted text. + app.graphic_attributes.linetype_scale_input = Some("3".into()); + let _ = app.update(Message::PropSyncActive(None)); + assert!(!app.graphic_attributes.linetype_scale_focused); + assert!(app.graphic_attributes.linetype_scale_input.is_none()); + } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 36097bbff..8ab8f8603 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6887,13 +6887,28 @@ impl OpenCADStudio { Message::PropAttrCommit(tag) => self.on_prop_attr_commit(tag), Message::PropPointerPressed => { - if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) { + if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) + && !self.dock_panel_visible(crate::ui::dock::PanelId::GraphicAttributes) + { return Task::none(); } crate::ui::properties::sync_active_field_task() } Message::PropSyncActive(focused) => { + // The Graphic Attributes linetype scale field behaves like a + // Properties field: the first click selects its value. + let scale_field = iced::widget::Id::new( + crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD, + ); + if focused.as_ref() == Some(&scale_field) { + if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) { + return iced::widget::operation::select_all(scale_field); + } + return Task::none(); + } + self.graphic_attributes.linetype_scale_focused = false; + self.graphic_attributes.linetype_scale_input = None; let panel = &mut self.tabs[self.active_tab].properties; if let Some(id) = focused.as_ref() { if let Some(key) = panel.prop_field_key_for_id(id) { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index f9e70e4b4..f634e9e4d 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5274,7 +5274,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Graphic Attributes: 1 unsupported object was not changed." => Some(("graphic-attributes", "one-unsupported")), "Graphic Attributes: {skipped} unsupported objects were not changed." => Some(("graphic-attributes", "unsupported-skipped")), "Fill transparency" => Some(("graphic-attributes", "fill-transparency")), - "Line linetype" => Some(("graphic-attributes", "line-linetype")), + "Fill change" => Some(("graphic-attributes", "fill-change")), "Line transparency" => Some(("graphic-attributes", "line-transparency")), "Cyl." => Some(("graphic-attributes", "cylindrical-short")), "Spher." => Some(("graphic-attributes", "spherical-short")), diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 9a435c725..1531dd072 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -21,15 +21,14 @@ type DrawDepth = FxHashMap; const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; const COMPACT_BUTTON_HEIGHT: f32 = 22.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"); -/// A usable object linetype scale (CELTSCALE): any positive finite value. -/// Spinner steps are rounded to six decimals so repeated `±0.1` steps do not -/// accumulate floating-point noise; typed values such as the ISO pen widths -/// 0.13, 0.18, 0.25 and 0.35 are kept as entered. +/// A usable object linetype scale (CELTSCALE): any positive finite value, +/// rounded to six decimals so arithmetic noise does not reach the drawing. +/// Typed values such as the ISO pen widths 0.13, 0.18, 0.25 and 0.35 are +/// kept as entered. pub(crate) fn normalized_linetype_scale(value: f64) -> Option { let value = (value * 1.0e6).round() / 1.0e6; (value.is_finite() && value > 0.0).then_some(value) @@ -109,6 +108,9 @@ pub enum GraphicAttributesMsg { LineLinetype(String), LineLineweight(LineWeight), LineLinetypeScale(f64), + /// Text typed in the linetype scale field, applied on Enter. + LineLinetypeScaleInput(String), + LineLinetypeScaleSubmit, LineTransparency(Transparency), FillColor(AcadColor), FillTransparency(Transparency), @@ -150,6 +152,10 @@ pub struct GraphicAttributesState { pub pending_fill_close: Option, /// New fills go to the current layer instead of their object's layer. pub fill_on_current_layer: bool, + /// Text typed in the linetype scale field, until Enter applies it. + pub linetype_scale_input: Option, + /// Whether the linetype scale field has focus (its value was selected). + pub linetype_scale_focused: bool, /// View-side caches; `view` runs after every message, so neither the /// fill lookup nor the gradient preview is rebuilt unless its input changed. fill_index: RefCell>, @@ -694,35 +700,16 @@ fn color_control<'a>( target: ColorPickTarget, changed: fn(AcadColor) -> GraphicAttributesMsg, ) -> Element<'a, Message> { - let label = if info.varies { - Some("*VARIES*") - } else if info.color == AcadColor::ByBlock { - Some("ByBlock") - } else { - None - }; - let label: Element<'a, Message> = match label { - Some(label) => container(text(label).size(10)) - .width(Fill) - .align_x(iced::Left) - .align_y(iced::Center) - .padding([0, 6]) - .into(), - None => container(Space::new()).width(Fill).height(Fill).into(), - }; + // The L / B / C / V button already shows ByLayer, ByBlock and *VARIES*, + // so the swatch carries no text. let display = info.display; - let swatch = button(label) + let swatch = button(Space::new().width(Fill).height(Fill)) .on_press(Message::OpenColorWindow(target.clone(), info.color)) .width(Fill) .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(display)), - text_color: if display.r * 0.299 + display.g * 0.587 + display.b * 0.114 > 0.55 { - iced::Color::BLACK - } else { - iced::Color::WHITE - }, border: Border { color: if matches!(status, button::Status::Hovered) { theme.palette().primary.base.color @@ -818,6 +805,7 @@ fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Eleme item("Set all attributes ByBlock", GraphicAttributesMsg::SetAllByBlock), item("Remove ByLayer / ByBlock", GraphicAttributesMsg::RemoveReferences), item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), + container(iced::widget::rule::horizontal(1)).padding([2, 4]), container( iced::widget::checkbox(fill_on_current_layer) .label(crate::t!("Create fills on the current layer")) @@ -1067,28 +1055,26 @@ fn linetype_row<'a>( .into() } -fn linetype_scale_field<'a>(scale: f64, varies: bool, continuous: bool) -> Element<'a, Message> { - let field: Element<'a, Message> = if varies { - text_input("*VARIES*", "") - .size(crate::ui::ROW_H * 0.42) - .padding([5, 5]) - .style(crate::ui::properties::combo_input_style) - .width(Fill) - .into() - } else { - let value = normalized_linetype_scale(scale).unwrap_or(1.0); - let mut input = iced_aw::number_input( - &value, - (std::ops::Bound::Excluded(0.0), std::ops::Bound::Unbounded), - |value: f64| match normalized_linetype_scale(value) { - Some(value) => msg(GraphicAttributesMsg::LineLinetypeScale(value)), - None => Message::Noop, - }, - ) - .step(LINETYPE_SCALE_STEP) - .set_size(crate::ui::ROW_H * 0.42) +/// Widget id of the linetype scale field, for select-all on focus. +pub(crate) const LINETYPE_SCALE_FIELD: &str = "graphic-linetype-scale"; + +/// Like a Properties field: clicking selects the value, Enter applies it. +fn linetype_scale_field<'a>( + scale: f64, + varies: bool, + continuous: bool, + typed: Option<&str>, +) -> Element<'a, Message> { + let shown = match typed { + Some(typed) => typed.to_string(), + None if varies => String::new(), + None => normalized_linetype_scale(scale).unwrap_or(1.0).to_string(), + }; + let mut field = text_input(if varies { "*VARIES*" } else { "" }, &shown) + .id(iced::widget::Id::new(LINETYPE_SCALE_FIELD)) + .size(crate::ui::ROW_H * 0.42) .padding([5, 5]) - .input_style(move |theme: &Theme, status| { + .style(move |theme: &Theme, status| { let mut style = crate::ui::properties::combo_input_style(theme, status); if continuous { let disabled = theme.palette().background.base.text.scale_alpha(0.42); @@ -1097,25 +1083,12 @@ fn linetype_scale_field<'a>(scale: f64, varies: bool, continuous: bool) -> Eleme } style }) - .style(move |theme: &Theme, status| { - let text = theme.palette().background.base.text; - iced_aw::number_input::number_input::Style { - button_background: None, - icon_color: if continuous || status == iced_aw::style::Status::Disabled { - text.scale_alpha(0.42) - } else { - text - }, - } - }) .width(Fill); - if continuous { - input = input.on_input_maybe(None:: Message>); - } else { - input = input.on_submit(Message::Noop); - } - input.into() - }; + if !continuous { + field = field + .on_input(|value| msg(GraphicAttributesMsg::LineLinetypeScaleInput(value))) + .on_submit(msg(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + } let icon = GraphicAttribute::Solid.icon(); let icon = if continuous { crate::ui::icons::themed_disabled(icon, PALETTE_CONTROL_ICON_SIZE) @@ -1193,8 +1166,10 @@ fn line_section<'a>( no_selection: bool, draw_depth: &DrawDepth, side: DockSide, - open_menu: Option, + state: &GraphicAttributesState, ) -> Vec> { + let open_menu = state.open_menu; + let typed_scale = state.linetype_scale_input.as_deref(); let mut rows = Vec::new(); if handles.is_empty() && !no_selection { return rows; @@ -1246,7 +1221,7 @@ fn line_section<'a>( let effective = effective_lineweight(document, handles, draw_depth, lineweight); rows.push( row![ - linetype_scale_field(scale, scale_varies, continuous), + linetype_scale_field(scale, scale_varies, continuous, typed_scale), lineweight_field( properties, lineweight, @@ -1296,7 +1271,7 @@ pub fn view<'a>( selected.is_empty(), draw_depth, side, - open_menu, + state, )); body = body From 32ed58fe770d0f0005bff45a3e79c74869ec0d55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:24:04 +0000 Subject: [PATCH 10/10] Graphic Attributes: show *VARIES* on the colour swatch again Differing colours are spelled out on the swatch again; ByLayer and ByBlock still show only on the L / B button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- src/ui/window/graphic_attributes.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 1531dd072..25dd14e78 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -700,16 +700,32 @@ fn color_control<'a>( target: ColorPickTarget, changed: fn(AcadColor) -> GraphicAttributesMsg, ) -> Element<'a, Message> { - // The L / B / C / V button already shows ByLayer, ByBlock and *VARIES*, - // so the swatch carries no text. + // ByLayer / ByBlock show only on the L / B button; differing colours are + // also spelled out on the swatch. + let label: Element<'a, Message> = if info.varies { + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]) + .into() + } else { + Space::new().width(Fill).height(Fill).into() + }; let display = info.display; - let swatch = button(Space::new().width(Fill).height(Fill)) + let swatch = button(label) .on_press(Message::OpenColorWindow(target.clone(), info.color)) .width(Fill) .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { background: Some(Background::Color(display)), + text_color: if display.r * 0.299 + display.g * 0.587 + display.b * 0.114 > 0.55 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }, border: Border { color: if matches!(status, button::Status::Hovered) { theme.palette().primary.base.color