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_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;