feat: add cyclic color and speed options for brush settings
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run

This commit is contained in:
Your Name
2026-07-25 17:29:26 +03:00
parent 683dac06ec
commit 19a7e51813
6 changed files with 232 additions and 5 deletions
+39 -2
View File
@@ -587,6 +587,10 @@ pub struct ToolState {
pub brush_color_variant: bool, pub brush_color_variant: bool,
/// Magnitude of the per-dab color variation (0.0..1.0). /// Magnitude of the per-dab color variation (0.0..1.0).
pub brush_variant_amount: f32, pub brush_variant_amount: f32,
/// True when the brush color should cycle through the hue over stroke distance.
pub brush_cyclic_color: bool,
/// Speed of the hue cycle along the stroke (0.01..5.0).
pub brush_cyclic_speed: f32,
/// Timestamp of the last update() call for frame timing. /// Timestamp of the last update() call for frame timing.
pub last_update_instant: std::time::Instant, pub last_update_instant: std::time::Instant,
/// Imported brush presets from ABR/KPP files. /// Imported brush presets from ABR/KPP files.
@@ -621,6 +625,8 @@ impl Default for ToolState {
brush_spacing: 1.0, brush_spacing: 1.0,
brush_color_variant: false, brush_color_variant: false,
brush_variant_amount: 0.0, brush_variant_amount: 0.0,
brush_cyclic_color: false,
brush_cyclic_speed: 0.5,
last_update_instant: std::time::Instant::now(), last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(), imported_brushes: Vec::new(),
active_brush_preset: None, active_brush_preset: None,
@@ -778,6 +784,8 @@ pub enum Message {
BrushSpacingChanged(f32), BrushSpacingChanged(f32),
BrushColorVariantToggled(bool), BrushColorVariantToggled(bool),
BrushVariantAmountChanged(f32), BrushVariantAmountChanged(f32),
BrushCyclicColorToggled(bool),
BrushCyclicSpeedChanged(f32),
BrushImportAbr, BrushImportAbr,
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>), BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
ResetToolDefaults, ResetToolDefaults,
@@ -1319,6 +1327,7 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
} }
} }
/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`. /// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`.
/// ///
/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with /// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with
@@ -2564,7 +2573,7 @@ impl HcieIcedApp {
/// Apply brush parameters from tool state to the engine. /// Apply brush parameters from tool state to the engine.
/// ///
/// Called before starting a stroke to ensure the engine uses /// Called before starting a stroke to ensure the engine uses
/// the current brush size, opacity, hardness, and style. /// the current brush size, opacity, hardness, style, and cyclic color.
fn apply_brush_params(&mut self) { fn apply_brush_params(&mut self) {
let mut tip = self let mut tip = self
.tool_state .tool_state
@@ -2577,6 +2586,15 @@ impl HcieIcedApp {
tip.opacity = self.tool_state.brush_opacity; tip.opacity = self.tool_state.brush_opacity;
tip.hardness = self.tool_state.brush_hardness; tip.hardness = self.tool_state.brush_hardness;
tip.spacing = self.tool_state.brush_spacing; tip.spacing = self.tool_state.brush_spacing;
// Cyclic color is handled by engine state, not the tip, but preview rendering
// also reads the tip's time dynamics. Synchronize both here.
tip.time_enabled = self.tool_state.brush_cyclic_color;
tip.time_size_start = 1.0;
tip.time_size_end = 1.0;
tip.time_opacity_start = 1.0;
tip.time_opacity_end = 1.0;
tip.time_angle_start = 0.0;
tip.time_angle_end = 0.0;
match self.tool_state.active_tool { match self.tool_state.active_tool {
Tool::Pen => { Tool::Pen => {
tip.style = BrushStyle::Round; tip.style = BrushStyle::Round;
@@ -2592,7 +2610,10 @@ impl HcieIcedApp {
} }
_ => {} _ => {}
} }
self.documents[self.active_doc].engine.set_brush_tip(tip); let engine = &mut self.documents[self.active_doc].engine;
engine.set_brush_tip(tip);
engine.set_cyclic_color(self.tool_state.brush_cyclic_color);
engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed);
} }
/// Push the current SVG editor model into the active vector shape so the /// Push the current SVG editor model into the active vector shape so the
@@ -4955,6 +4976,22 @@ impl HcieIcedApp {
self.settings.update_from_tool_state(&self.tool_state); self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save(); let _ = self.settings.save();
} }
Message::BrushCyclicColorToggled(enabled) => {
self.tool_state.brush_cyclic_color = enabled;
self.documents[self.active_doc]
.engine
.set_cyclic_color(enabled);
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushCyclicSpeedChanged(speed) => {
self.tool_state.brush_cyclic_speed = speed;
self.documents[self.active_doc]
.engine
.set_cyclic_speed(speed);
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::SelectionToleranceChanged(tol) => { Message::SelectionToleranceChanged(tol) => {
self.settings.tool_settings.magic_wand_tolerance = tol as f32; self.settings.tool_settings.magic_wand_tolerance = tol as f32;
self.settings.tool_settings.flood_fill_tolerance = tol as f32; self.settings.tool_settings.flood_fill_tolerance = tol as f32;
@@ -184,6 +184,8 @@ pub fn panel_body<'a>(
app.tool_state.brush_hardness, app.tool_state.brush_hardness,
app.tool_state.brush_color_variant, app.tool_state.brush_color_variant,
app.tool_state.brush_variant_amount, app.tool_state.brush_variant_amount,
app.tool_state.brush_cyclic_color,
app.tool_state.brush_cyclic_speed,
app.brush_category, app.brush_category,
&app.tool_state.imported_brushes, &app.tool_state.imported_brushes,
app.tool_state.active_brush_preset.as_ref(), app.tool_state.active_brush_preset.as_ref(),
@@ -236,6 +238,10 @@ pub fn panel_body<'a>(
app.tool_state.brush_size, app.tool_state.brush_size,
app.tool_state.brush_opacity, app.tool_state.brush_opacity,
app.tool_state.brush_hardness, app.tool_state.brush_hardness,
app.tool_state.brush_color_variant,
app.tool_state.brush_variant_amount,
app.tool_state.brush_cyclic_color,
app.tool_state.brush_cyclic_speed,
doc.selection_rect, doc.selection_rect,
doc.vector_draw, doc.vector_draw,
colors, colors,
@@ -290,6 +290,7 @@ struct PreviewKey {
size: u8, size: u8,
hardness: u8, hardness: u8,
opacity: u8, opacity: u8,
cyclic: bool,
r: u8, r: u8,
g: u8, g: u8,
b: u8, b: u8,
@@ -508,12 +509,20 @@ fn build_tip_from_state(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
cyclic_color: bool,
) -> hcie_engine_api::BrushTip { ) -> hcie_engine_api::BrushTip {
hcie_engine_api::BrushTip { hcie_engine_api::BrushTip {
style, style,
size, size,
opacity, opacity,
hardness, hardness,
time_enabled: cyclic_color,
time_size_start: 1.0,
time_size_end: 1.0,
time_opacity_start: 1.0,
time_opacity_end: 1.0,
time_angle_start: 0.0,
time_angle_end: 0.0,
..hcie_engine_api::BrushTip::default() ..hcie_engine_api::BrushTip::default()
} }
} }
@@ -527,6 +536,7 @@ fn preview_key(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
cyclic: bool,
fg_color: [u8; 4], fg_color: [u8; 4],
) -> PreviewKey { ) -> PreviewKey {
PreviewKey { PreviewKey {
@@ -534,6 +544,7 @@ fn preview_key(
size: size.clamp(1.0, 255.0) as u8, size: size.clamp(1.0, 255.0) as u8,
hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8, hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8,
opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8, opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8,
cyclic,
r: fg_color[0], r: fg_color[0],
g: fg_color[1], g: fg_color[1],
b: fg_color[2], b: fg_color[2],
@@ -562,13 +573,14 @@ fn get_live_stroke_preview(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
cyclic_color: bool,
fg_color: [u8; 4], fg_color: [u8; 4],
) -> iced::widget::image::Handle { ) -> iced::widget::image::Handle {
// Clamp the rendered brush size so a 110 px soft brush does not fill the // Clamp the rendered brush size so a 110 px soft brush does not fill the
// entire 40 px preview well with a solid blob. The preview shows texture and // entire 40 px preview well with a solid blob. The preview shows texture and
// stroke character; the slider still reports the real painting size. // stroke character; the slider still reports the real painting size.
let preview_size = size.clamp(4.0, LIVE_PREVIEW_BRUSH_SIZE); let preview_size = size.clamp(4.0, LIVE_PREVIEW_BRUSH_SIZE);
let key = preview_key(style, preview_size, opacity, hardness, fg_color); let key = preview_key(style, preview_size, opacity, hardness, cyclic_color, fg_color);
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache let mut cache = cache
.lock() .lock()
@@ -576,7 +588,7 @@ fn get_live_stroke_preview(
if let Some(handle) = cache.get(&key) { if let Some(handle) = cache.get(&key) {
return handle.clone(); return handle.clone();
} }
let tip = build_tip_from_state(style, preview_size, opacity, hardness); let tip = build_tip_from_state(style, preview_size, opacity, hardness, cyclic_color);
let pixels = let pixels =
render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color); render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color);
let handle = iced::widget::image::Handle::from_rgba( let handle = iced::widget::image::Handle::from_rgba(
@@ -588,6 +600,11 @@ fn get_live_stroke_preview(
handle handle
} }
/// Clamp a cyclic speed value to the engine's valid range.
fn clamp_cyclic_speed(speed: f32) -> f32 {
speed.clamp(0.01, 5.0)
}
/// Get or generate a realistic stroke preview for a built-in brush style. /// Get or generate a realistic stroke preview for a built-in brush style.
/// ///
/// **Purpose:** Provides the thumbnails shown in the built-in style grid. The /// **Purpose:** Provides the thumbnails shown in the built-in style grid. The
@@ -637,6 +654,8 @@ pub fn view(
current_hardness: f32, current_hardness: f32,
brush_color_variant: bool, brush_color_variant: bool,
brush_variant_amount: f32, brush_variant_amount: f32,
brush_cyclic_color: bool,
brush_cyclic_speed: f32,
active_category: BrushCategory, active_category: BrushCategory,
imported_presets: &[hcie_engine_api::BrushPreset], imported_presets: &[hcie_engine_api::BrushPreset],
active_brush_preset: Option<&hcie_engine_api::BrushPreset>, active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
@@ -1021,6 +1040,16 @@ pub fn view(
colors, colors,
Message::BrushVariantAmountChanged, Message::BrushVariantAmountChanged,
); );
let cyclic_speed_slider = plain_slider(
"Cyclic Speed",
clamp_cyclic_speed(brush_cyclic_speed),
0.01..=5.0,
0.01,
"x",
2,
colors,
Message::BrushCyclicSpeedChanged,
);
// Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so // Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so
// the control matches the rest of the compact panel typography. // the control matches the rest of the compact panel typography.
@@ -1033,6 +1062,15 @@ pub fn view(
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
let cyclic_color_check = row![
checkbox("", brush_cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(12),
text("Cyclic color").size(LABEL_SIZE),
]
.spacing(4)
.align_y(iced::Alignment::Center);
// Live preview — show a realistic engine-rendered stroke using the active // Live preview — show a realistic engine-rendered stroke using the active
// size, opacity, hardness, and foreground color. The preview is // size, opacity, hardness, and foreground color. The preview is
// parameter-keyed and cached so it updates in real time without // parameter-keyed and cached so it updates in real time without
@@ -1042,6 +1080,7 @@ pub fn view(
current_size, current_size,
current_opacity, current_opacity,
current_hardness, current_hardness,
brush_cyclic_color,
fg_color, fg_color,
); );
let live_preview = container( let live_preview = container(
@@ -1091,6 +1130,8 @@ pub fn view(
hardness_slider, hardness_slider,
color_variant_check, color_variant_check,
variant_slider, variant_slider,
cyclic_color_check,
cyclic_speed_slider,
] ]
.spacing(ITEM_SPACING) .spacing(ITEM_SPACING)
.padding(3); .padding(3);
@@ -93,6 +93,10 @@ pub fn view<'a>(
brush_size: f32, brush_size: f32,
brush_opacity: f32, brush_opacity: f32,
brush_hardness: f32, brush_hardness: f32,
brush_color_variant: bool,
brush_variant_amount: f32,
brush_cyclic_color: bool,
brush_cyclic_speed: f32,
selection_rect: Option<(f32, f32, f32, f32)>, selection_rect: Option<(f32, f32, f32, f32)>,
vector_draw: Option<((f32, f32), (f32, f32))>, vector_draw: Option<((f32, f32), (f32, f32))>,
colors: ThemeColors, colors: ThemeColors,
@@ -134,12 +138,25 @@ pub fn view<'a>(
// Build tool/shape-specific section // Build tool/shape-specific section
let tool_section: Element<'a, Message> = match active_tool { let tool_section: Element<'a, Message> = match active_tool {
Tool::Pen | Tool::Brush | Tool::Eraser => { Tool::Pen | Tool::Brush | Tool::Eraser => {
brush_properties(brush_size, brush_opacity, brush_hardness, colors) brush_properties(
brush_size,
brush_opacity,
brush_hardness,
brush_color_variant,
brush_variant_amount,
brush_cyclic_color,
brush_cyclic_speed,
colors,
)
} }
Tool::Spray => spray_properties( Tool::Spray => spray_properties(
brush_size, brush_size,
brush_opacity, brush_opacity,
brush_hardness, brush_hardness,
brush_color_variant,
brush_variant_amount,
brush_cyclic_color,
brush_cyclic_speed,
settings_spray_particle_size, settings_spray_particle_size,
settings_spray_density, settings_spray_density,
colors, colors,
@@ -511,11 +528,20 @@ fn vector_tool_properties<'a>(
col.into() col.into()
} }
/// Shared cyclic speed clamp used by brush and spray property sections.
fn clamp_cyclic_speed(speed: f32) -> f32 {
speed.clamp(0.01, 5.0)
}
/// Brush tool properties (Pen, Brush, Eraser). /// Brush tool properties (Pen, Brush, Eraser).
fn brush_properties<'a>( fn brush_properties<'a>(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
color_variant: bool,
variant_amount: f32,
cyclic_color: bool,
cyclic_speed: f32,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
column![ column![
@@ -529,6 +555,40 @@ fn brush_properties<'a>(
plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
Message::BrushHardnessChanged(v) Message::BrushHardnessChanged(v)
},), },),
row![
checkbox("Color Variant", color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(11),
]
.spacing(4)
.align_y(iced::Alignment::Center),
plain_slider(
"Variant Amount",
variant_amount,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushVariantAmountChanged,
),
row![
checkbox("Cyclic Color", cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(11),
]
.spacing(4)
.align_y(iced::Alignment::Center),
plain_slider(
"Cyclic Speed",
clamp_cyclic_speed(cyclic_speed),
0.01..=5.0,
0.01,
"x",
2,
colors,
Message::BrushCyclicSpeedChanged,
),
] ]
.spacing(4) .spacing(4)
.into() .into()
@@ -539,6 +599,10 @@ fn spray_properties<'a>(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
color_variant: bool,
variant_amount: f32,
cyclic_color: bool,
cyclic_speed: f32,
particle_size: f32, particle_size: f32,
density: u32, density: u32,
colors: ThemeColors, colors: ThemeColors,
@@ -574,6 +638,40 @@ fn spray_properties<'a>(
plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
Message::BrushHardnessChanged(v) Message::BrushHardnessChanged(v)
},), },),
row![
checkbox("Color Variant", color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(11),
]
.spacing(4)
.align_y(iced::Alignment::Center),
plain_slider(
"Variant Amount",
variant_amount,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushVariantAmountChanged,
),
row![
checkbox("Cyclic Color", cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(11),
]
.spacing(4)
.align_y(iced::Alignment::Center),
plain_slider(
"Cyclic Speed",
clamp_cyclic_speed(cyclic_speed),
0.01..=5.0,
0.01,
"x",
2,
colors,
Message::BrushCyclicSpeedChanged,
),
] ]
.spacing(4) .spacing(4)
.into() .into()
@@ -134,6 +134,17 @@ fn brush_settings(
|v| Message::BrushVariantAmountChanged(v / 100.0), |v| Message::BrushVariantAmountChanged(v / 100.0),
c c
), ),
checkbox("Cyclic Color", ts.brush_cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(11),
prop_slider(
"Cyclic Speed",
ts.brush_cyclic_speed.clamp(0.01, 5.0),
0.01,
5.0,
Message::BrushCyclicSpeedChanged,
c
),
] ]
.spacing(4) .spacing(4)
} }
@@ -186,6 +197,28 @@ fn spray_settings(
|v| Message::BrushHardnessChanged(v / 100.0), |v| Message::BrushHardnessChanged(v / 100.0),
c c
), ),
checkbox("Color Variant", ts.brush_color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(11),
prop_slider(
"Variant Amount",
ts.brush_variant_amount * 100.0,
0.0,
100.0,
|v| Message::BrushVariantAmountChanged(v / 100.0),
c
),
checkbox("Cyclic Color", ts.brush_cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(11),
prop_slider(
"Cyclic Speed",
ts.brush_cyclic_speed.clamp(0.01, 5.0),
0.01,
5.0,
Message::BrushCyclicSpeedChanged,
c
),
] ]
.spacing(4) .spacing(4)
} }
@@ -59,6 +59,12 @@ pub struct ToolSettings {
/// Magnitude of the per-dab color variation (0.0..1.0). /// Magnitude of the per-dab color variation (0.0..1.0).
#[serde(default)] #[serde(default)]
pub brush_variant_amount: f32, pub brush_variant_amount: f32,
/// True when the brush color should cycle through the hue over stroke distance.
#[serde(default)]
pub brush_cyclic_color: bool,
/// Speed of the hue cycle along the stroke (0.01..5.0).
#[serde(default)]
pub brush_cyclic_speed: f32,
pub eraser_size: f32, pub eraser_size: f32,
pub eraser_opacity: f32, pub eraser_opacity: f32,
pub pen_size: f32, pub pen_size: f32,
@@ -192,6 +198,8 @@ impl Default for ToolSettings {
brush_spacing: 1.0, brush_spacing: 1.0,
brush_color_variant: false, brush_color_variant: false,
brush_variant_amount: 0.0, brush_variant_amount: 0.0,
brush_cyclic_color: false,
brush_cyclic_speed: 0.5,
eraser_size: 20.0, eraser_size: 20.0,
eraser_opacity: 1.0, eraser_opacity: 1.0,
pen_size: 2.0, pen_size: 2.0,
@@ -344,6 +352,8 @@ impl AppSettings {
self.tool_settings.brush_hardness = tool_state.brush_hardness; self.tool_settings.brush_hardness = tool_state.brush_hardness;
self.tool_settings.brush_color_variant = tool_state.brush_color_variant; self.tool_settings.brush_color_variant = tool_state.brush_color_variant;
self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount; self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount;
self.tool_settings.brush_cyclic_color = tool_state.brush_cyclic_color;
self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed;
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool); self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
} }
@@ -354,6 +364,8 @@ impl AppSettings {
tool_state.brush_hardness = self.tool_settings.brush_hardness; tool_state.brush_hardness = self.tool_settings.brush_hardness;
tool_state.brush_color_variant = self.tool_settings.brush_color_variant; tool_state.brush_color_variant = self.tool_settings.brush_color_variant;
tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount; tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount;
tool_state.brush_cyclic_color = self.tool_settings.brush_cyclic_color;
tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed;
} }
} }