feat(hatch): add visual pattern picker

Closes #566
This commit is contained in:
Hakan Seven 2026-07-29 15:46:53 +03:00
commit 6686141d3b
6 changed files with 448 additions and 51 deletions

View file

@ -8,18 +8,24 @@
//! • Geometry → text_input per coordinate / dimension field
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::fmt;
use std::{fmt, sync::Arc};
use crate::ui::ROW_H;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::Handle;
use iced::widget::{button, column, combo_box, container, row, scrollable, text, text_input};
use iced::{Background, Border, Color, Element, Length, Padding, Theme};
use iced::widget::{
button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
};
use iced::{
mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme,
};
// ── Row-height-derived constants ─────────────────────────────────────────
const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px
const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; // fills combo to ROW_H
const SWATCH_SZ: f32 = ROW_H * 0.54; // ≈14 px color swatch
const PATTERN_CARD_W: f32 = 158.0;
const PATTERN_PREVIEW_H: f32 = 58.0;
use crate::app::Message;
use crate::scene::model::object::{PropSection, PropValue};
@ -72,6 +78,124 @@ impl fmt::Display for SelectionGroup {
}
}
#[derive(Clone)]
struct HatchPatternPreview {
pattern: crate::scene::model::hatch_model::HatchPattern,
}
impl canvas::Program<Message> for HatchPatternPreview {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
let mut frame = canvas::Frame::new(renderer, bounds.size());
let palette = theme.extended_palette();
let pad = 4.0;
let sample = canvas::Path::rectangle(
Point::new(pad, pad),
Size::new(
(bounds.width - pad * 2.0).max(0.0),
(bounds.height - pad * 2.0).max(0.0),
),
);
frame.fill(&sample, palette.background.base.color);
match &self.pattern {
HatchPattern::Solid => {
frame.fill(&sample, palette.background.base.text.scale_alpha(0.72));
}
HatchPattern::Gradient { .. } => {
frame.fill(&sample, palette.primary.weak.color);
}
HatchPattern::Pattern(_) => {
let model = HatchModel {
world_origin: [0.0, 0.0],
boundary: Arc::new(vec![
[pad, pad],
[bounds.width - pad, pad],
[bounds.width - pad, bounds.height - pad],
[pad, bounds.height - pad],
]),
boundary_wcs: None,
pattern: self.pattern.clone(),
name: String::new(),
color: [1.0; 4],
angle_offset: 0.0,
scale: hatch_preview_scale(&self.pattern),
draw_depth: 0.0,
};
let stroke = canvas::Stroke::default()
.with_color(palette.background.base.text)
.with_width(1.0);
for segment in model.pattern_segments() {
frame.stroke(
&canvas::Path::line(
Point::new(segment[0][0] as f32, bounds.height - segment[0][1] as f32),
Point::new(segment[1][0] as f32, bounds.height - segment[1][1] as f32),
),
stroke.clone(),
);
}
}
}
frame.stroke(
&sample,
canvas::Stroke::default()
.with_color(palette.background.neutral.color)
.with_width(1.0),
);
vec![frame.into_geometry()]
}
}
fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) -> f32 {
use crate::scene::model::hatch_model::HatchPattern;
let HatchPattern::Pattern(families) = pattern else {
return 1.0;
};
let spacing = families
.iter()
.filter_map(|family| {
let spacing = family.dy.abs();
(spacing > 1.0e-4).then_some(spacing)
})
.fold(f32::INFINITY, f32::min);
if spacing.is_finite() {
(8.0 / spacing).clamp(0.01, 100.0)
} else {
1.0
}
}
fn hatch_pattern_matches(
entry: &crate::scene::model::hatch_patterns::PatternEntry,
search: &str,
) -> bool {
let query = search.trim();
query.is_empty()
|| entry.name.to_lowercase().contains(&query.to_lowercase())
|| entry.description.to_lowercase().contains(&query.to_lowercase())
}
pub(crate) fn filtered_hatch_patterns(
search: &str,
) -> Vec<&'static crate::scene::model::hatch_patterns::PatternEntry> {
crate::scene::model::hatch_patterns::catalog()
.iter()
.filter(|entry| hatch_pattern_matches(entry, search))
.collect()
}
/// All standard CAD lineweight options for the combobox.
pub fn lw_options() -> Vec<LwItem> {
[
@ -131,7 +255,12 @@ pub struct PropertiesPanel {
pub layer_combo: combo_box::State<String>,
pub lineweight_combo: combo_box::State<LwItem>,
pub linetype_combo: combo_box::State<LinetypeItem>,
pub hatch_pattern_combo: combo_box::State<String>,
/// Whether the visual hatch-pattern picker is open.
pub hatch_pattern_picker_open: bool,
/// Case-insensitive filter for the visual hatch-pattern picker.
pub hatch_pattern_search: String,
/// Keyboard/hover focus inside the filtered visual pattern grid.
pub hatch_pattern_focus: usize,
/// In-progress text edits keyed by `field` name.
pub edit_buf: HashMap<String, String>,
/// Entity handles this panel was built for. `refresh_properties` compares
@ -177,7 +306,9 @@ impl Default for PropertiesPanel {
layer_combo: combo_box::State::new(vec![]),
lineweight_combo: combo_box::State::new(lw_options()),
linetype_combo: combo_box::State::new(vec![]),
hatch_pattern_combo: combo_box::State::new(crate::scene::model::hatch_patterns::names()),
hatch_pattern_picker_open: false,
hatch_pattern_search: String::new(),
hatch_pattern_focus: 0,
edit_buf: HashMap::default(),
source_handles: vec![],
color_picker_open: false,
@ -865,38 +996,142 @@ impl PropertiesPanel {
label: &'a str,
current: &'a str,
) -> Element<'a, Message> {
let selected = if current == VARIES_LABEL {
None
} else {
Some(current.to_string())
};
// iced hides `selected` while the combo has focus and shows its
// placeholder instead. Use the real pattern name for a single hatch;
// a fixed VARIES placeholder made a correct ANSI31 value look mixed as
// soon as the dropdown opened (#476).
let placeholder = if current == VARIES_LABEL {
VARIES_LABEL
} else {
current
};
let combo = combo_box(
&self.hatch_pattern_combo,
placeholder,
selected.as_ref(),
Message::PropHatchPatternChanged,
let head = button(
row![
text(crate::ui::text_util::elide(current, 16))
.size(FONT_SZ)
.width(Length::Fill),
if self.hatch_pattern_picker_open {
crate::ui::icons::themed_arrow_up(FONT_SZ)
} else {
crate::ui::icons::themed_arrow_down(FONT_SZ)
},
]
.align_y(iced::Center),
)
.size(FONT_SZ)
.padding(Padding {
top: COMBO_PAD_V,
bottom: COMBO_PAD_V,
left: 6.0,
right: 6.0,
.on_press(Message::PropHatchPatternPickerToggle(current.to_string()))
.style(move |theme: &Theme, status| {
let palette = theme.extended_palette();
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: Some(Background::Color(if hovered {
palette.background.weak.color
} else {
palette.background.base.color
})),
text_color: palette.background.base.text,
border: Border {
color: if self.hatch_pattern_picker_open {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: 1.0,
radius: 2.0.into(),
},
..Default::default()
}
})
.input_style(combo_input_style)
.on_open(Message::PropColorPickerClose)
.padding([COMBO_PAD_V, 6.0])
.width(Length::Fill);
prop_row_widget(label, combo.into())
if !self.hatch_pattern_picker_open {
return prop_row_widget(label, head.into());
}
let search = text_input("Search patterns…", &self.hatch_pattern_search)
.id(iced::widget::Id::new("hatch-pattern-search"))
.on_input(Message::PropHatchPatternSearchChanged)
.on_submit(Message::PropHatchPatternConfirm)
.size(FONT_SZ)
.padding([5, 7])
.width(Length::Fill);
let mut grid = column![].spacing(6);
let visible = filtered_hatch_patterns(&self.hatch_pattern_search);
for (row_index, pair) in visible.chunks(2).enumerate() {
let mut cards = row![].spacing(6);
for (column_index, entry) in pair.iter().enumerate() {
let index = row_index * 2 + column_index;
let selected = current.eq_ignore_ascii_case(&entry.name);
let focused = self.hatch_pattern_focus == index;
let name = entry.name.clone();
let preview = canvas(HatchPatternPreview {
pattern: entry.gpu.clone(),
})
.width(Length::Fill)
.height(PATTERN_PREVIEW_H);
let card = button(
column![
preview,
container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ))
.width(Length::Fill)
.align_x(iced::Center),
]
.spacing(3),
)
.on_press(Message::PropHatchPatternChanged(name))
.style(move |theme: &Theme, status| {
let palette = theme.extended_palette();
let hovered =
matches!(status, button::Status::Hovered | button::Status::Pressed);
let pair = if selected {
palette.primary.weak
} else if hovered || focused {
palette.background.strong
} else {
palette.background.weak
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: if selected || focused {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: if selected || focused { 2.0 } else { 1.0 },
radius: 4.0.into(),
},
..Default::default()
}
})
.padding(5)
.width(PATTERN_CARD_W);
cards = cards.push(
mouse_area(card).on_enter(Message::PropHatchPatternFocus(index)),
);
}
grid = grid.push(cards);
}
let results: Element<'_, Message> = if visible.is_empty() {
container(
text("No matching patterns")
.size(FONT_SZ)
.style(hint_text_style),
)
.padding(12)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else {
scrollable(grid)
.height(Length::Fixed(300.0))
.width(Length::Fill)
.into()
};
let popup = container(column![search, results].spacing(7))
.style(container::bordered_box)
.padding(8)
.width(348)
.max_height(360.0);
prop_row_widget(
label,
crate::ui::color_select::floating_below(head.into(), popup.into()),
)
}
}
@ -1491,3 +1726,26 @@ fn hint_text_style(theme: &Theme) -> iced::widget::text::Style {
color: Some(theme.extended_palette().background.base.text.scale_alpha(0.48)),
}
}
#[cfg(test)]
mod tests {
use super::{hatch_pattern_matches, hatch_preview_scale};
#[test]
fn hatch_picker_filters_names_and_descriptions() {
let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap();
assert!(hatch_pattern_matches(ansi31, "ansi"));
assert!(hatch_pattern_matches(ansi31, &ansi31.description));
assert!(!hatch_pattern_matches(ansi31, "definitely-not-a-pattern"));
}
#[test]
fn hatch_preview_scale_is_finite_and_visible() {
let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap();
let scale = hatch_preview_scale(&ansi31.gpu);
assert!(scale.is_finite());
assert!((0.01..=100.0).contains(&scale));
}
}