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;