Files
hcie-rust-v3.05/hcie-engine-api/src/stroke_brush.rs
T
phantom b09795ccff Implement stable serialization and restoration for dock layouts, including auto-hidden and floating panels
- Add `persistence.rs` for stable serialization of dock layouts without runtime identifiers.
- Introduce `preview.rs` for geometry handling of dock drop previews.
- Create `sizing.rs` to manage content-aware sizing policies and constraint solving for dock panels.
- Implement `welcome.rs` to provide a welcome surface when no documents are open, featuring New and Open actions.
2026-07-16 22:10:22 +03:00

477 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Brush stroke drawing functions for the HCIE engine.
//!
//! ## Purpose
//! Implements `stroke_to`, `draw_pen_segment`, and `draw_stroke`, plus the
//! `effects_dirty` conditional flag logic. These are the hot paths called for
//! every pointer event during painting.
//!
//! ## Logic & Workflow
//! - `stroke_to()` draws a single brush stamp or specialized stroke segment.
//! - `draw_pen_segment()` draws an anti-aliased line segment for the pen tool.
//! - `draw_stroke()` draws a complete multi-point stroke in one call.
//! - All paths expand `document.dirty_bounds` only by the brush footprint and
//! set `effects_dirty` only when the target layer actually has effects/styles.
//!
//! ## Side Effects / Dependencies
//! Mutates the active layer's pixel buffer, the document dirty flags, and the
//! engine's stroke state. Calls into `hcie-draw` and `hcie-brush-engine` via
//! the dynamic loader.
use crate::dynamic_loader::{
draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke,
};
use crate::Engine;
use hcie_protocol::{BrushStyle, BrushTip};
impl Engine {
/// 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
/// `time_enabled` flag is off or the start/end values are equal, the base
/// value is returned unchanged. Otherwise the value is lerped from start
/// to end and multiplied with the base.
fn apply_time_interpolation(
base: f32,
start: f32,
end: f32,
time_enabled: bool,
t: f32,
) -> f32 {
if !time_enabled {
return base;
}
let lerp = start + (end - start) * t.clamp(0.0, 1.0);
base * lerp
}
/// Apply time-based dynamics to the brush tip for the current stroke segment.
///
/// Returns a **copy** of `self.current_tip` with `size`, `opacity`, and
/// `angle` interpolated based on stroke progress. The original
/// `self.current_tip` is **not** modified, so each `stroke_to` call starts
/// from the same base values — preventing cumulative drift that would
/// shrink the brush to a thin line over a long stroke.
fn brush_tip_with_time_dynamics(&self, x: f32, y: f32) -> BrushTip {
let cfg = self.current_tip.clone();
if !cfg.time_enabled {
return cfg;
}
// Approximate stroke progress from segment length vs a nominal total.
let segment_len = if let Some((lx, ly, _)) = self.last_stroke_pos {
let dx = x - lx;
let dy = y - ly;
(dx * dx + dy * dy).sqrt()
} else {
0.0
};
let nominal_total = 250.0_f32.max(segment_len);
let t = (segment_len / nominal_total).clamp(0.0, 1.0);
let mut tip = cfg;
tip.size = Self::apply_time_interpolation(
tip.size,
tip.time_size_start,
tip.time_size_end,
tip.time_enabled,
t,
);
tip.opacity = Self::apply_time_interpolation(
tip.opacity,
tip.time_opacity_start,
tip.time_opacity_end,
tip.time_enabled,
t,
);
tip.angle = Self::apply_time_interpolation(
tip.angle,
tip.time_angle_start,
tip.time_angle_end,
tip.time_enabled,
t,
);
tip
}
pub fn stroke_to(&mut self, layer_id: u64, x: f32, y: f32, pressure: f32) {
let w = self.document.canvas_width as f32;
let h = self.document.canvas_height as f32;
let inside = x >= 0.0 && y >= 0.0 && x < w && y < h;
if !inside {
self.last_stroke_pos = None;
return;
}
let tip = self.brush_tip_with_time_dynamics(x, y);
let color = self.apply_cyclic_color(self.current_color);
let mask_ref = self.cached_selection_mask.as_deref();
if Self::is_specialized_style(tip.style) {
if let Some((lx, ly, lp)) = self.last_stroke_pos {
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
// If click without movement, draw a single dab at current position
let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 {
vec![(x, y, pressure)]
} else {
vec![(lx, ly, lp), (x, y, pressure)]
};
draw_specialized_stroke(
&mut layer.pixels,
layer.width,
layer.height,
&points_vec,
tip.style,
tip.size,
tip.hardness,
color,
tip.opacity,
tip.spacing,
self.is_eraser,
if tip.style == BrushStyle::Sketch {
Some(&mut self.sketch_history)
} else {
None
},
tip.spray_particle_size,
tip.spray_density,
mask_ref,
self.active_stroke_mask.as_deref_mut(),
self.stroke_before_buf.as_deref(),
tip.color_variant,
tip.variant_amount,
tip.density,
);
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),
};
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);
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
}
self.document.composite_dirty = true;
self.document.modified = true;
} else {
self.last_stroke_pos = Some((x, y, pressure));
return;
}
} else {
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
draw_brush_stroke(
layer,
&[(x, y, pressure)],
color,
&tip,
self.is_eraser,
mask_ref,
self.active_stroke_mask.as_deref_mut(),
self.stroke_before_buf.as_deref(),
);
layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer
.effects_dirty
.store(true, std::sync::atomic::Ordering::Release);
}
let dirty_r = tip.size.max(2.0);
if let Some((lx, ly, _)) = self.last_stroke_pos {
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
}
self.document.expand_dirty(x as u32, y as u32, dirty_r);
if let Some((lx, ly, _)) = self.last_stroke_pos {
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
}
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
}
self.document.composite_dirty = true;
self.document.modified = true;
}
self.last_stroke_pos = Some((x, y, pressure));
}
/// Pen tool stroke — draws a true anti-aliased line segment.
/// Uses begin_stroke/end_stroke for undo.
pub fn draw_pen_segment(
&mut self,
layer_id: u64,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
pressure: f32,
) {
let tip = self.current_tip.clone();
let color = self.apply_cyclic_color(self.current_color);
let mask_ref = self.cached_selection_mask.as_deref();
let w = self.document.canvas_width as f32;
let h = self.document.canvas_height as f32;
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
let px_color = [
color[0],
color[1],
color[2],
(color[3] as f32 * tip.opacity * pressure).round() as u8,
];
draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer
.effects_dirty
.store(true, std::sync::atomic::Ordering::Release);
}
let dirty_r = tip.size.max(2.0);
if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h {
self.document.expand_dirty(x0 as u32, y0 as u32, dirty_r);
}
if x1 >= 0.0 && y1 >= 0.0 && x1 < w && y1 < h {
self.document.expand_dirty(x1 as u32, y1 as u32, dirty_r);
}
}
self.document.composite_dirty = true;
self.document.modified = true;
}
pub fn draw_stroke(&mut self, points: &[(f32, f32, f32)]) {
let tip = self.current_tip.clone();
let color = self.apply_cyclic_color(self.current_color);
let mask_ref = self.cached_selection_mask.as_deref();
let layer_idx = self.document.active_layer;
let before_pixels = if layer_idx < self.document.layers.len() {
Some(self.document.layers[layer_idx].pixels.clone())
} else {
None
};
if let Some(layer) = self.document.active_layer_mut() {
if Self::is_specialized_style(tip.style) {
draw_specialized_stroke(
&mut layer.pixels,
layer.width,
layer.height,
points,
tip.style,
tip.size,
tip.hardness,
color,
tip.opacity,
tip.spacing,
self.is_eraser,
if tip.style == BrushStyle::Sketch {
Some(&mut self.sketch_history)
} else {
None
},
tip.spray_particle_size,
tip.spray_density,
mask_ref,
self.active_stroke_mask.as_deref_mut(),
before_pixels.as_ref().map(|px| px.as_slice()),
tip.color_variant,
tip.variant_amount,
tip.density,
);
layer.dirty = true;
} else {
draw_brush_stroke(
layer,
points,
color,
&tip,
self.is_eraser,
mask_ref,
self.active_stroke_mask.as_deref_mut(),
before_pixels.as_ref().map(|px| px.as_slice()),
);
}
if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer
.effects_dirty
.store(true, std::sync::atomic::Ordering::Release);
}
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),
};
for &(px, py, _) in points {
if px >= 0.0
&& py >= 0.0
&& px < self.document.canvas_width as f32
&& py < self.document.canvas_height as f32
{
self.document.expand_dirty(px as u32, py as u32, dirty_r);
}
}
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
}
}
pub fn draw_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
let mask_ref = self.cached_selection_mask.as_deref();
let layer_idx = self.document.active_layer;
let before_pixels = if layer_idx < self.document.layers.len() {
Some(self.document.layers[layer_idx].pixels.clone())
} else {
None
};
if let Some(layer) = self.document.active_layer_mut() {
let color = [255, 0, 0, 255];
draw_line(layer, x0, y0, x1, y1, color, 2.0, mask_ref);
self.document.composite_dirty = true;
self.document.modified = true;
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Line".to_string());
}
}
pub fn draw_rect(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
let mask_ref = self.cached_selection_mask.as_deref();
let layer_idx = self.document.active_layer;
let before_pixels = if layer_idx < self.document.layers.len() {
Some(self.document.layers[layer_idx].pixels.clone())
} else {
None
};
if let Some(layer) = self.document.active_layer_mut() {
let color = [255, 0, 0, 255];
draw_filled_rect(layer, x1, y1, x2, y2, color, mask_ref);
self.document.composite_dirty = true;
self.document.modified = true;
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Rectangle".to_string());
}
}
pub fn draw_filled_rect_rgba(&mut self, x1: u32, y1: u32, x2: u32, y2: u32, color: [u8; 4]) {
let mask_ref = self.cached_selection_mask.as_deref();
let layer_idx = self.document.active_layer;
let before_pixels = if layer_idx < self.document.layers.len() {
Some(self.document.layers[layer_idx].pixels.clone())
} else {
None
};
if let Some(layer) = self.document.active_layer_mut() {
let fx1 = x1.min(x2) as f32;
let fy1 = y1.min(y2) as f32;
let fx2 = x2.max(x1) as f32;
let fy2 = y2.max(y1) as f32;
draw_filled_rect(layer, fx1, fy1, fx2, fy2, color, mask_ref);
self.document.composite_dirty = true;
self.document.modified = true;
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
}
}
pub fn draw_ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) {
let mask_ref = self.cached_selection_mask.as_deref();
let layer_idx = self.document.active_layer;
let before_pixels = if layer_idx < self.document.layers.len() {
Some(self.document.layers[layer_idx].pixels.clone())
} else {
None
};
if let Some(layer) = self.document.active_layer_mut() {
let color = [255, 0, 0, 255];
draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask_ref);
self.document.composite_dirty = true;
self.document.modified = true;
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Ellipse".to_string());
}
}
/// Update hue for cyclic color and return the new RGBA color.
fn apply_cyclic_color(&mut self, base_color: [u8; 4]) -> [u8; 4] {
if !self.cyclic_color {
return base_color;
}
self.pen_hue = (self.pen_hue + self.cyclic_speed) % 360.0;
let (r, g, b) = Self::hsl_to_rgb(self.pen_hue);
[r, g, b, base_color[3]]
}
// Simple HSL to RGB: S=100%, L=50% (full saturation, half luminance)
fn hsl_to_rgb(h: f32) -> (u8, u8, u8) {
let h = h % 360.0;
let c = 1.0; // S=100%
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = 0.0; // L=50%, c=1.0 => m=0
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(
((r + m) * 255.0).round() as u8,
((g + m) * 255.0).round() as u8,
((b + m) * 255.0).round() as u8,
)
}
/// Check if the given brush style needs specialized procedural rendering.
/// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false.
pub(crate) fn is_specialized_style(s: BrushStyle) -> bool {
matches!(
s,
BrushStyle::Oil
| BrushStyle::Charcoal
| BrushStyle::Watercolor
| BrushStyle::Calligraphy
| BrushStyle::Marker
| BrushStyle::Glow
| BrushStyle::Airbrush
| BrushStyle::Spray
| BrushStyle::Pencil
| BrushStyle::Crayon
| BrushStyle::Tree
| BrushStyle::Meadow
| BrushStyle::Rock
| BrushStyle::Clouds
| BrushStyle::Dirt
| BrushStyle::Star
| BrushStyle::Bristle
| BrushStyle::Wood
| BrushStyle::Sketch
| BrushStyle::Hatch
| BrushStyle::Square
| BrushStyle::WetPaint
| BrushStyle::Leaf
| BrushStyle::Mixer
)
}
}