feat: Add digital, dry, ink, and paint brush presets; implement regression testing hooks
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled

- Introduced new crates for digital brushes, dry media brushes, ink brushes, and paint brushes, each containing various presets.
- Updated the menus to include "Paste Special" and "Paste as New Layer" options.
- Enhanced the feature scorecard tests to validate selection texture encoding.
- Added a regression gate workflow for automated testing on push and pull requests.
- Implemented Git hooks for pre-commit checks to ensure code formatting and run tests before commits.
- Created a settings file for local configurations.
This commit is contained in:
Your Name
2026-07-23 02:28:49 +03:00
parent 2d5b7a37e8
commit e509def0b2
26 changed files with 2446 additions and 335 deletions
+16 -2
View File
@@ -162,8 +162,8 @@ pub fn draw_brush_stroke(
color_variant: tip.color_variant,
variant_amount: tip.variant_amount,
density: tip.density,
drawing_angle: false,
rotation_random: 0.0,
drawing_angle: tip.drawing_angle,
rotation_random: tip.rotation_random,
};
hcie_draw::draw_brush_stroke(
layer,
@@ -198,6 +198,13 @@ pub fn draw_specialized_stroke(
color_variant: bool,
variant_amount: f32,
density: f32,
jitter_amount: f32,
scatter_amount: f32,
angle: f32,
roundness: f32,
rotation_random: f32,
drawing_angle: bool,
include_first_dab: bool,
) {
let style = map_brush_style(brush_style);
hcie_brush_engine::draw_specialized_stroke(
@@ -221,6 +228,13 @@ pub fn draw_specialized_stroke(
color_variant,
variant_amount,
density,
jitter_amount,
scatter_amount,
angle,
roundness,
rotation_random,
drawing_angle,
include_first_dab,
)
}
+55 -19
View File
@@ -24,6 +24,20 @@ use crate::Engine;
use hcie_protocol::{BrushStyle, BrushTip};
impl Engine {
/// Returns a conservative dirty radius including procedural and preset offsets.
fn brush_dirty_radius(tip: &BrushTip) -> f32 {
let procedural = match tip.style {
BrushStyle::Star | BrushStyle::Spray => 1.8,
BrushStyle::Meadow | BrushStyle::Leaf => 3.0,
BrushStyle::Clouds | BrushStyle::Tree => 2.5,
BrushStyle::Watercolor => 1.4,
BrushStyle::WetPaint => 1.2,
_ => 1.0,
};
let offset = tip.jitter_amount.max(0.0) * 0.5 + tip.scatter_amount.max(0.0) * 0.3;
(tip.size * (procedural + offset)).max(2.0)
}
/// Interpolate a brush property along the stroke using accumulated distance.
///
/// `t` is the normalized stroke progress (0.0 at start, 1.0 at end). If the
@@ -137,20 +151,19 @@ impl Engine {
tip.color_variant,
tip.variant_amount,
tip.density,
tip.jitter_amount,
tip.scatter_amount,
tip.angle,
tip.roundness,
tip.rotation_random,
tip.drawing_angle,
false,
);
layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() {
*layer.effects_cache.lock().unwrap() = None;
}
let dirty_r = match tip.style {
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
// Clouds/Tree scatter particles in a wide area.
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
_ => tip.size.max(2.0),
};
let dirty_r = Self::brush_dirty_radius(&tip);
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
self.document.expand_dirty(x as u32, y as u32, dirty_r);
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
@@ -276,6 +289,13 @@ impl Engine {
tip.color_variant,
tip.variant_amount,
tip.density,
tip.jitter_amount,
tip.scatter_amount,
tip.angle,
tip.roundness,
tip.rotation_random,
tip.drawing_angle,
true,
);
layer.dirty = true;
} else {
@@ -297,15 +317,7 @@ impl Engine {
}
self.document.composite_dirty = true;
self.document.modified = true;
let dirty_r = match tip.style {
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
// Clouds/Tree scatter particles in a wide area.
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
_ => tip.size.max(2.0),
};
let dirty_r = Self::brush_dirty_radius(&tip);
for &(px, py, _) in points {
if px >= 0.0
&& py >= 0.0
@@ -322,7 +334,8 @@ impl Engine {
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(tip.style)
);
self.document.push_draw_snapshot(layer_idx, before, None, desc);
self.document
.push_draw_snapshot(layer_idx, before, None, desc);
}
}
@@ -474,6 +487,11 @@ impl Engine {
| BrushStyle::WetPaint
| BrushStyle::Leaf
| BrushStyle::Mixer
| BrushStyle::Blender
| BrushStyle::Noise
| BrushStyle::Texture
| BrushStyle::Pen
| BrushStyle::InkPen
)
}
}
@@ -519,3 +537,21 @@ pub fn brush_style_label(style: BrushStyle) -> &'static str {
BrushStyle::Bitmap => "Bitmap",
}
}
#[cfg(test)]
mod dirty_radius_tests {
use super::*;
/// Ensures partial uploads include procedural watercolor spread and preset offsets.
#[test]
fn watercolor_dirty_radius_covers_jitter_and_scatter() {
let tip = BrushTip {
style: BrushStyle::Watercolor,
size: 40.0,
jitter_amount: 0.4,
scatter_amount: 0.9,
..BrushTip::default()
};
assert!((Engine::brush_dirty_radius(&tip) - 74.8).abs() < 0.001);
}
}
@@ -43,7 +43,7 @@ fn first_vector_shape_is_one_atomic_layer_transaction() {
assert_eq!(engine.history_len(), history_before + 1);
assert_eq!(
engine.history_description(history_before).as_deref(),
Some("Add Vector Shape")
Some("Add Vector Shape (Rect)")
);
assert_eq!(
engine