Improve CAD workspace functionality and user experience

This commit is contained in:
Marcel committed 2026-09-27 21:13:24 +02:00
1 parent 6b37476687
commit 65bd8e1b4c
28 files changed
+3628 -279

No files matched your search

+15 -13
View File
@@ -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 {
+16 -3
View File
@@ -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);
}
}
}
+28 -6
View File
@@ -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<Message> for HatchPatternPreview {
@@ -112,7 +113,7 @@ impl canvas::Program<Message> 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,
+424
View File
@@ -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<acadrust::Handle>,
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<u8>,
}
impl GradientEditorState {
pub fn from_hatch(
handles: Vec<acadrust::Handle>,
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::<f64>()
.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()
}
+668
View File
@@ -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<Handle, Option<GraphicAttribute>> =
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<Handle> {
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<Handle> {
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<u64, [f32; 2]>,
) -> Option<FillTransparencyInfo> {
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<u64, [f32; 2]>,
) -> Option<SolidFillColorInfo> {
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<u64, [f32; 2]>,
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<u64, [f32; 2]>,
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()
}
+36
View File
@@ -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()
}
+3
View File
@@ -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;