From a64f1184cde7f7b2a69aa532fdbb19c0ca216d10 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Jul 2026 03:50:02 +0300 Subject: [PATCH] feat: add star sparkle brush preset and enhance star brush parameters --- hcie-brush-engine/src/lib.rs | 201 +++++++++++++----- .../crates/hcie-dry-media-brushes/src/lib.rs | 12 +- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 30 +++ .../crates/hcie-iced-gui/src/dock/view.rs | 3 + .../hcie-iced-gui/src/panels/brushes.rs | 128 +++++++++-- .../crates/hcie-iced-gui/src/settings.rs | 18 ++ 6 files changed, 318 insertions(+), 74 deletions(-) diff --git a/hcie-brush-engine/src/lib.rs b/hcie-brush-engine/src/lib.rs index b3a7990..f01d498 100644 --- a/hcie-brush-engine/src/lib.rs +++ b/hcie-brush-engine/src/lib.rs @@ -111,7 +111,22 @@ pub fn generate_brush_stamp(tip: &BrushTip) -> Vec { match tip.style { BrushStyle::Round => generate_round_stamp(d, center, r, tip.hardness), BrushStyle::Square => generate_square_stamp(d, center, r, tip.hardness), - BrushStyle::Star => generate_star_stamp(d, center, r, tip.hardness), + BrushStyle::Star => generate_star_stamp( + d, + center, + r, + tip.hardness, + if tip.roundness > 0.0 { + tip.roundness + } else { + 0.22 + }, + if tip.density > 0.0 { + tip.density + } else { + 0.08 + }, + ), BrushStyle::SoftRound => generate_round_stamp(d, center, r, 0.0), BrushStyle::HardRound => generate_round_stamp(d, center, r, 1.0), BrushStyle::Bitmap => { @@ -200,10 +215,19 @@ fn generate_square_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec Vec { +fn generate_star_stamp( + d: usize, + center: f32, + r: f32, + hardness: f32, + arm_width_ratio: f32, + inner_r_ratio: f32, +) -> Vec { let arm_count = 4u32; - let arm_width = r * 0.22; - let inner_r = r * 0.08; + let arm_width = arm_width_ratio.clamp(0.08, 0.35); + let inner_r = r * inner_r_ratio.clamp(0.04, 0.12); + let hardness = hardness.clamp(0.0, 1.0); + let point_sharpness = 1.2 + (0.35 - arm_width) / 0.27 * 2.8; (0..d * d) .map(|i| { let x = (i % d) as f32 - center; @@ -212,21 +236,19 @@ fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec let dist = (x * x + y * y).sqrt(); let point_angle = std::f32::consts::PI * 2.0 / arm_count as f32; let a = angle.rem_euclid(point_angle); - let arm_center = point_angle / 2.0; - let arm_dist = (a - arm_center).abs(); - let max_arm_dist = arm_width / r; - if dist > r { + let arm_dist = a.min(point_angle - a); + let angular_distance = (arm_dist / (point_angle * 0.5)).clamp(0.0, 1.0); + let point_profile = (1.0 - angular_distance).powf(point_sharpness); + let edge = inner_r + (r - inner_r) * point_profile; + let soft_width = (r * (1.0 - hardness) * 0.35).max(0.75); + let hard_edge = (edge - soft_width).max(0.0); + + if dist >= edge { 0 - } else if arm_dist < max_arm_dist && dist > inner_r { - let arm_falloff = 1.0 - (arm_dist / max_arm_dist); - let radial = ((dist - inner_r) / (r - inner_r)).clamp(0.0, 1.0); - let alpha = arm_falloff * (1.0 - radial.powf(1.0 - hardness.clamp(0.0, 0.99))); - (alpha * 255.0).round() as u8 - } else if dist <= inner_r { - let alpha = (1.0 - dist / inner_r).max(0.5); - (alpha * 255.0).round() as u8 + } else if dist <= hard_edge { + 255 } else { - 0 + (((edge - dist) / soft_width).clamp(0.0, 1.0) * 255.0).round() as u8 } }) .collect() @@ -650,6 +672,10 @@ fn draw_brush_style_dab( brush_hardness, is_eraser, mask, + angle, + rotation_random, + roundness, + density, ), BrushStyle::Rock => draw_rock_brush( pixels, @@ -1621,59 +1647,134 @@ fn draw_star_brush( hardness: f32, is_eraser: bool, mask: Option<&[u8]>, + angle: f32, + rotation_random: f32, + roundness: f32, + density: f32, ) { - let effective_size = size * pressure; + let seed = cx.to_bits().wrapping_mul(73_856_093) + ^ cy.to_bits().wrapping_mul(19_349_663); + + let size_variance = roundness; + let size_scale = if size_variance > 0.0 { + let r = ((seed & 0xFFFF) as f32 / 65536.0 - 0.5) * 2.0 * size_variance; + (1.0 + r).clamp(0.3, 2.0) + } else { + 1.0 + }; + let effective_size = size * pressure * size_scale; + + let angle_variance = rotation_random; + let dab_angle = if angle_variance > 0.0 { + let r = ((seed >> 16) as f32 / 65536.0 - 0.5) * 2.0 * angle_variance; + angle + r * std::f32::consts::PI + } else { + angle + }; + + let arm_width_ratio = if roundness > 0.0 { roundness } else { 0.22 }; + let inner_r_ratio = if density > 0.0 { density } else { 0.08 }; + let tip = BrushTip { style: BrushStyle::Star, size: effective_size * 0.5, hardness, + roundness: arm_width_ratio, + density: inner_r_ratio, ..BrushTip::default() }; let stamp = generate_brush_stamp(&tip); - draw_dab_with_stamp( - pixels, - width, - height, - cx, - cy, - effective_size, - hardness, - color, - opacity * pressure, - is_eraser, - mask, - Some(&stamp), - ); - let sparkle_count = ((effective_size * 0.15) as u32).clamp(2, 8); - let sparkle_r = effective_size * 0.12; - let tip_sm = BrushTip { - style: BrushStyle::Star, - size: sparkle_r, - hardness, - ..BrushTip::default() + + let cos_a = dab_angle.cos(); + let sin_a = dab_angle.sin(); + let stamp_rotated = if dab_angle.abs() > f32::EPSILON { + let stamp_d = (effective_size * 0.5 * 2.0).ceil() as usize; + let stamp_center = stamp_d as f32 * 0.5; + let d = stamp_d; + let center = d as f32 * 0.5; + let mut rotated = vec![0u8; d * d]; + for y in 0..d { + for x in 0..d { + let px = x as f32 - center; + let py = y as f32 - center; + let rx = px * cos_a + py * sin_a; + let ry = -px * sin_a + py * cos_a; + let sx = rx + stamp_center; + let sy = ry + stamp_center; + let ix = sx.floor() as i32; + let iy = sy.floor() as i32; + if ix >= 0 && iy >= 0 && ix < stamp_d as i32 - 1 && iy < stamp_d as i32 - 1 { + let fx = sx - ix as f32; + let fy = sy - iy as f32; + let d00 = stamp[iy as usize * stamp_d + ix as usize] as f32; + let d10 = stamp[iy as usize * stamp_d + ix as usize + 1] as f32; + let d01 = stamp[(iy as usize + 1) * stamp_d + ix as usize] as f32; + let d11 = stamp[(iy as usize + 1) * stamp_d + ix as usize + 1] as f32; + let v = d00 * (1.0 - fx) * (1.0 - fy) + + d10 * fx * (1.0 - fy) + + d01 * (1.0 - fx) * fy + + d11 * fx * fy; + rotated[y * d + x] = v.round() as u8; + } + } + } + rotated + } else { + stamp }; - let stamp_sm = generate_brush_stamp(&tip_sm); - let seed = cx.to_bits().wrapping_mul(73_856_093) - ^ cy.to_bits().wrapping_mul(19_349_663); + + let sparkle_count = if density > 0.0 { + (effective_size * 0.08 * density).clamp(2.0, 6.0) as u32 + } else { + ((effective_size * 0.05) as u32).clamp(2, 4) + }; + let base_star_size = effective_size * 0.24; + let footprint_radius = effective_size * 0.5; + let phase = (seed & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU; + const GOLDEN_ANGLE: f32 = 2.399_963_1; + for i in 0..sparkle_count { let hash_i = seed.wrapping_add(i.wrapping_mul(668_265_263)); - let angle = (hash_i & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU; - let dist = ((hash_i >> 16) & 0xFFFF) as f32 / 65536.0 * effective_size * 0.6; - let sx = cx + angle.cos() * dist; - let sy = cy + angle.sin() * dist; + let spark_size_var = if size_variance > 0.0 { + let rv = ((hash_i.wrapping_add(31337) & 0xFFFF) as f32 / 65536.0 - 0.5) + * 2.0 + * size_variance; + (1.0 + rv).clamp(0.3, 2.0) + } else { + 1.0 + }; + let star_size = base_star_size * spark_size_var; + let max_center_radius = (footprint_radius - star_size * 0.5).max(0.0); + let radial_fraction = ((i as f32 + 0.5) / sparkle_count as f32).sqrt(); + let spark_angle = phase + i as f32 * GOLDEN_ANGLE; + let dist = radial_fraction * max_center_radius; + let sx = cx + spark_angle.cos() * dist; + let sy = cy + spark_angle.sin() * dist; + + let spark_color = if color[3] > 0 { + let hue_shift = ((hash_i.wrapping_add(7919) & 0xFF) as f32 / 255.0 - 0.5) * 30.0; + let [r, g, b, a] = color; + let (h, s, l) = rgb_to_hsl(r, g, b); + let h = (h + hue_shift).rem_euclid(360.0); + let (rr, gg, bb) = hsl_to_rgb(h, s, l); + [rr, gg, bb, a] + } else { + color + }; + draw_dab_with_stamp( pixels, width, height, sx, sy, - sparkle_r * 2.0, + star_size, hardness, - color, - opacity * pressure * 0.5, + spark_color, + opacity * pressure * 0.9, is_eraser, mask, - Some(&stamp_sm), + Some(&stamp_rotated), ); } } diff --git a/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs index 65075de..8b99098 100644 --- a/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs +++ b/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs @@ -233,6 +233,14 @@ pub fn dry_media_presets() -> Vec { false, true, ), + preset( + "star_sparkle", + "Star Sparkle", + "Effects", + tip(BrushStyle::Star, 14.0, 0.95, 1.0, 0.35), + true, + false, + ), ] } @@ -245,14 +253,14 @@ mod tests { #[test] fn dry_media_catalog_is_complete_and_unique() { let presets = dry_media_presets(); - assert_eq!(presets.len(), 19); + assert_eq!(presets.len(), 20); assert_eq!( presets .iter() .map(|preset| &preset.id) .collect::>() .len(), - 19 + 20 ); assert!(presets .iter() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index d60e4c9..e81e1f0 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -587,6 +587,12 @@ pub struct ToolState { pub brush_color_variant: bool, /// Magnitude of the per-dab color variation (0.0..1.0). pub brush_variant_amount: f32, + /// Star brush base rotation angle in radians. + pub brush_angle: f32, + /// Star brush per-dab size variance (0.0..1.0). + pub brush_size_variance: f32, + /// Star brush per-dab angle variance (0.0..1.0). + pub brush_angle_variance: 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). @@ -625,6 +631,9 @@ impl Default for ToolState { brush_spacing: 1.0, brush_color_variant: false, brush_variant_amount: 0.0, + brush_angle: 0.0, + brush_size_variance: 0.0, + brush_angle_variance: 0.0, brush_cyclic_color: false, brush_cyclic_speed: 0.5, last_update_instant: std::time::Instant::now(), @@ -784,6 +793,9 @@ pub enum Message { BrushSpacingChanged(f32), BrushColorVariantToggled(bool), BrushVariantAmountChanged(f32), + BrushAngleChanged(f32), + BrushSizeVarianceChanged(f32), + BrushAngleVarianceChanged(f32), BrushCyclicColorToggled(bool), BrushCyclicSpeedChanged(f32), BrushImportAbr, @@ -1323,6 +1335,9 @@ fn build_brush_tip(state: &ToolState) -> BrushTip { spacing: state.brush_spacing, color_variant: state.brush_color_variant, variant_amount: state.brush_variant_amount, + angle: state.brush_angle, + roundness: state.brush_size_variance, + rotation_random: state.brush_angle_variance, ..BrushTip::default() } } @@ -4976,6 +4991,21 @@ impl HcieIcedApp { self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } + Message::BrushAngleChanged(angle) => { + self.tool_state.brush_angle = angle; + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } + Message::BrushSizeVarianceChanged(variance) => { + self.tool_state.brush_size_variance = variance; + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } + Message::BrushAngleVarianceChanged(variance) => { + self.tool_state.brush_angle_variance = variance; + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } Message::BrushCyclicColorToggled(enabled) => { self.tool_state.brush_cyclic_color = enabled; self.documents[self.active_doc] diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index e3f866e..e2b95d8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -186,6 +186,9 @@ pub fn panel_body<'a>( app.tool_state.brush_variant_amount, app.tool_state.brush_cyclic_color, app.tool_state.brush_cyclic_speed, + app.tool_state.brush_angle, + app.tool_state.brush_size_variance, + app.tool_state.brush_angle_variance, app.brush_category, &app.tool_state.imported_brushes, app.tool_state.active_brush_preset.as_ref(), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 21137c6..fc8b6ab 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -113,9 +113,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[ category: BrushCategory::Drawing, }, BrushStyleEntry { - name: "Star", + name: "Star Sparkle", style: BrushStyle::Star, - category: BrushCategory::Drawing, + category: BrushCategory::Effects, }, BrushStyleEntry { name: "Oil", @@ -510,6 +510,9 @@ fn build_tip_from_state( opacity: f32, hardness: f32, cyclic_color: bool, + angle: f32, + size_variance: f32, + angle_variance: f32, ) -> hcie_engine_api::BrushTip { hcie_engine_api::BrushTip { style, @@ -523,6 +526,9 @@ fn build_tip_from_state( time_opacity_end: 1.0, time_angle_start: 0.0, time_angle_end: 0.0, + angle, + roundness: size_variance, + rotation_random: angle_variance, ..hcie_engine_api::BrushTip::default() } } @@ -575,6 +581,9 @@ fn get_live_stroke_preview( hardness: f32, cyclic_color: bool, fg_color: [u8; 4], + angle: f32, + size_variance: f32, + angle_variance: f32, ) -> iced::widget::image::Handle { // 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 @@ -588,7 +597,16 @@ fn get_live_stroke_preview( if let Some(handle) = cache.get(&key) { return handle.clone(); } - let tip = build_tip_from_state(style, preview_size, opacity, hardness, cyclic_color); + let tip = build_tip_from_state( + style, + preview_size, + opacity, + hardness, + cyclic_color, + angle, + size_variance, + angle_variance, + ); let pixels = render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color); let handle = iced::widget::image::Handle::from_rgba( @@ -656,6 +674,9 @@ pub fn view( brush_variant_amount: f32, brush_cyclic_color: bool, brush_cyclic_speed: f32, + brush_angle: f32, + brush_size_variance: f32, + brush_angle_variance: f32, active_category: BrushCategory, imported_presets: &[hcie_engine_api::BrushPreset], active_brush_preset: Option<&hcie_engine_api::BrushPreset>, @@ -1051,6 +1072,50 @@ pub fn view( Message::BrushCyclicSpeedChanged, ); + let is_star = current_style == BrushStyle::Star; + let star_angle_slider = if is_star { + Some(plain_slider( + "Star Angle", + brush_angle, + 0.0..=std::f32::consts::TAU, + 0.01, + "rad", + 2, + colors, + Message::BrushAngleChanged, + )) + } else { + None + }; + let star_size_var_slider = if is_star { + Some(plain_slider( + "Size Variance", + brush_size_variance, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushSizeVarianceChanged, + )) + } else { + None + }; + let star_angle_var_slider = if is_star { + Some(plain_slider( + "Angle Variance", + brush_angle_variance, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushAngleVarianceChanged, + )) + } else { + None + }; + // 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. let color_variant_check = row![ @@ -1082,6 +1147,9 @@ pub fn view( current_hardness, brush_cyclic_color, fg_color, + brush_angle, + brush_size_variance, + brush_angle_variance, ); let live_preview = container( iced::widget::image(preview_handle) @@ -1095,12 +1163,16 @@ pub fn view( .style(move |_theme| styles::recessed_control(colors)); // Panel title is in the dock tab — no duplicate inside the panel. - let panel = column![ - tabs, - horizontal_rule(1), + let mut panel_children: Vec> = vec![ + tabs.into(), + horizontal_rule(1).into(), scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING)) - .height(Length::Fill), - horizontal_rule(1), + .height(Length::Fill) + .into(), + horizontal_rule(1).into(), + ]; + + panel_children.push( row![button(text("Import ABR").size(LABEL_SIZE)) .on_press(Message::BrushImportAbr) .padding([3, 7]) @@ -1122,19 +1194,31 @@ pub fn view( } } )] - .spacing(GRID_SPACING), - text("Preview").size(LABEL_SIZE + 1), - live_preview, - size_slider, - opacity_slider, - hardness_slider, - color_variant_check, - variant_slider, - cyclic_color_check, - cyclic_speed_slider, - ] - .spacing(ITEM_SPACING) - .padding(3); + .spacing(GRID_SPACING) + .into(), + ); + panel_children.push(text("Preview").size(LABEL_SIZE + 1).into()); + panel_children.push(live_preview.into()); + panel_children.push(size_slider.into()); + panel_children.push(opacity_slider.into()); + panel_children.push(hardness_slider.into()); + panel_children.push(color_variant_check.into()); + panel_children.push(variant_slider.into()); + panel_children.push(cyclic_color_check.into()); + panel_children.push(cyclic_speed_slider.into()); + if let Some(s) = star_angle_slider { + panel_children.push(s.into()); + } + if let Some(s) = star_size_var_slider { + panel_children.push(s.into()); + } + if let Some(s) = star_angle_var_slider { + panel_children.push(s.into()); + } + + let panel = iced::widget::Column::with_children(panel_children) + .spacing(ITEM_SPACING) + .padding(3); container(panel) .width(Length::Fill) @@ -1181,7 +1265,7 @@ mod tests { presets.extend(hcie_paint_brushes::paint_presets()); presets.extend(hcie_digital_brushes::digital_presets()); presets.extend(hcie_watercolor_brushes::watercolor_presets()); - assert_eq!(presets.len(), 47); + assert_eq!(presets.len(), 48); assert_eq!( presets .iter() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index 5c2094c..dad6d08 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -59,6 +59,15 @@ pub struct ToolSettings { /// Magnitude of the per-dab color variation (0.0..1.0). #[serde(default)] pub brush_variant_amount: f32, + /// Star brush base rotation angle in radians. + #[serde(default)] + pub brush_angle: f32, + /// Star brush per-dab size variance (0.0..1.0). + #[serde(default)] + pub brush_size_variance: f32, + /// Star brush per-dab angle variance (0.0..1.0). + #[serde(default)] + pub brush_angle_variance: f32, /// True when the brush color should cycle through the hue over stroke distance. #[serde(default)] pub brush_cyclic_color: bool, @@ -198,6 +207,9 @@ impl Default for ToolSettings { brush_spacing: 1.0, brush_color_variant: false, brush_variant_amount: 0.0, + brush_angle: 0.0, + brush_size_variance: 0.0, + brush_angle_variance: 0.0, brush_cyclic_color: false, brush_cyclic_speed: 0.5, eraser_size: 20.0, @@ -352,6 +364,9 @@ impl AppSettings { self.tool_settings.brush_hardness = tool_state.brush_hardness; 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_angle = tool_state.brush_angle; + self.tool_settings.brush_size_variance = tool_state.brush_size_variance; + self.tool_settings.brush_angle_variance = tool_state.brush_angle_variance; 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); @@ -364,6 +379,9 @@ impl AppSettings { tool_state.brush_hardness = self.tool_settings.brush_hardness; 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_angle = self.tool_settings.brush_angle; + tool_state.brush_size_variance = self.tool_settings.brush_size_variance; + tool_state.brush_angle_variance = self.tool_settings.brush_angle_variance; tool_state.brush_cyclic_color = self.tool_settings.brush_cyclic_color; tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed; }