Files
Your Name 683dac06ec
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run
refactor: improve code readability by formatting and restructuring function parameters and logic
2026-07-25 16:56:28 +03:00

779 lines
30 KiB
Rust

//! 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 {
/// 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
/// `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
}
/// Helper method to execute a drawing action either on active layer pixels (when editing_mask is false)
/// or on the active layer mask (when editing_mask is true), converting between RGBA and greyscale mask.
/// Ensures active layer's mask is expanded to full canvas dimensions if present.
pub fn ensure_full_canvas_mask(&mut self, layer_id: u64) {
let cw = self.document.canvas_width;
let ch = self.document.canvas_height;
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
let full_size = (cw * ch) as usize;
if let Some(ref mask) = layer.mask_pixels {
if mask.len() != full_size {
let mut full_mask = vec![layer.mask_default_color; full_size];
if let Some(mb) = layer.mask_bounds {
let (m_top, m_left, m_bottom, m_right) = (
mb[0].max(0) as u32,
mb[1].max(0) as u32,
mb[2].max(0) as u32,
mb[3].max(0) as u32,
);
let mw = (m_right - m_left) as usize;
let mh = (m_bottom - m_top) as usize;
if mw > 0 && mh > 0 && mask.len() == mw * mh {
for y in 0..mh {
let gy = m_top as usize + y;
if gy >= ch as usize {
break;
}
for x in 0..mw {
let gx = m_left as usize + x;
if gx >= cw as usize {
break;
}
full_mask[gy * cw as usize + gx] = mask[y * mw + x];
}
}
}
}
layer.mask_pixels = Some(full_mask);
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
}
}
}
}
pub fn draw_target_pixels_or_mask<F>(&mut self, layer_id: u64, f: F)
where
F: FnOnce(
&mut [u8],
u32,
u32,
Option<&[u8]>,
Option<&mut [u8]>,
Option<&[u8]>,
Option<&mut Vec<(f32, f32)>>,
),
{
let is_editing = self.editing_mask;
if is_editing {
self.ensure_full_canvas_mask(layer_id);
}
if !is_editing {
let mask_ref = self.cached_selection_mask.as_deref();
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
let stroke_before_buf = self.stroke_before_buf.as_deref();
let sketch_hist = &mut self.sketch_history;
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
let w = layer.width;
let h = layer.height;
f(
&mut layer.pixels,
w,
h,
mask_ref,
active_stroke_mask,
stroke_before_buf,
Some(sketch_hist),
);
layer.dirty = true;
}
} else {
let cw = self.document.canvas_width as usize;
let ch = self.document.canvas_height as usize;
let mask_len = cw * ch;
if mask_len == 0 {
return;
}
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if layer.mask_pixels.is_none() {
layer.mask_pixels = Some(vec![255u8; mask_len]);
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
layer.mask_default_color = 255;
}
}
let (rx0, ry0, rx1, ry1) = match self.last_stroke_bounds {
Some([x0, y0, x1, y1]) => (
(x0.saturating_sub(15) as usize).min(cw),
(y0.saturating_sub(15) as usize).min(ch),
((x1 + 15) as usize).min(cw),
((y1 + 15) as usize).min(ch),
),
None => (0, 0, cw, ch),
};
let needed_len = mask_len * 4;
let mut scratch = self.mask_rgba_scratch.take().unwrap_or_default();
if scratch.len() != needed_len {
scratch.resize(needed_len, 0);
}
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
if let Some(ref mask) = layer.mask_pixels {
for y in ry0..ry1 {
let row_off = y * cw;
for x in rx0..rx1 {
let idx = row_off + x;
let v = mask[idx];
let off = idx * 4;
scratch[off] = v;
scratch[off + 1] = v;
scratch[off + 2] = v;
scratch[off + 3] = 255;
}
}
}
}
{
let mask_ref = self.cached_selection_mask.as_deref();
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
let stroke_before_buf = self.stroke_before_buf.as_deref();
let sketch_hist = &mut self.sketch_history;
f(
&mut scratch,
cw as u32,
ch as u32,
mask_ref,
active_stroke_mask,
stroke_before_buf,
Some(sketch_hist),
);
}
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let Some(ref mut mask) = layer.mask_pixels {
for y in ry0..ry1 {
let row_off = y * cw;
for x in rx0..rx1 {
let idx = row_off + x;
let off = idx * 4;
let r = scratch[off] as u32;
let g = scratch[off + 1] as u32;
let b = scratch[off + 2] as u32;
let a = scratch[off + 3] as u32;
let gray = (r * 299 + g * 587 + b * 114) / 1000;
mask[idx] = ((gray * a) / 255) as u8;
}
}
}
layer.dirty = true;
}
self.mask_rgba_scratch = Some(scratch);
}
self.below_cache_dirty = true;
self.document.composite_dirty = true;
self.document.modified = true;
}
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;
let tip = self.brush_tip_with_time_dynamics(x, y);
let color = self.apply_cyclic_color(self.current_color);
if Self::is_specialized_style(tip.style) {
if let Some((lx, ly, lp)) = self.last_stroke_pos {
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)]
};
let is_eraser = self.is_eraser;
let dirty_r = Self::brush_dirty_radius(&tip);
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
}
if inside {
self.document.expand_dirty(x as u32, y as u32, dirty_r);
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
}
self.draw_target_pixels_or_mask(
layer_id,
|pixels,
lw,
lh,
mask_ref,
active_stroke_mask,
stroke_before_buf,
sketch_history| {
let sketch_hist = if tip.style == BrushStyle::Sketch {
sketch_history
} else {
None
};
draw_specialized_stroke(
pixels,
lw,
lh,
&points_vec,
tip.style,
tip.size,
tip.hardness,
color,
tip.opacity,
tip.spacing,
is_eraser,
sketch_hist,
tip.spray_particle_size,
tip.spray_density,
mask_ref,
active_stroke_mask,
stroke_before_buf,
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,
);
},
);
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if !layer.effects.is_empty() || !layer.styles.is_empty() {
*layer.effects_cache.lock().unwrap() = None;
}
}
self.document.composite_dirty = true;
self.document.modified = true;
} else {
self.last_stroke_pos = Some((x, y, pressure));
return;
}
} else {
let is_eraser = self.is_eraser;
let dirty_r = tip.size.max(2.0);
if let Some((lx, ly, _)) = self.last_stroke_pos {
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
}
}
if inside {
self.document.expand_dirty(x as u32, y as u32, dirty_r);
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
}
if self.editing_mask {
self.draw_target_pixels_or_mask(
layer_id,
|pixels,
lw,
lh,
mask_ref,
active_stroke_mask,
stroke_before_buf,
_sketch_history| {
let mut temp_layer =
hcie_protocol::Layer::from_rgba("temp", lw, lh, pixels.to_vec());
draw_brush_stroke(
&mut temp_layer,
&[(x, y, pressure)],
color,
&tip,
is_eraser,
mask_ref,
active_stroke_mask,
stroke_before_buf,
);
pixels.copy_from_slice(&temp_layer.pixels);
},
);
} else {
let mask_ref = self.cached_selection_mask.as_deref();
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
let stroke_before_buf = self.stroke_before_buf.as_deref();
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
draw_brush_stroke(
layer,
&[(x, y, pressure)],
color,
&tip,
is_eraser,
mask_ref,
active_stroke_mask,
stroke_before_buf,
);
}
}
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
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;
}
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,
tip.jitter_amount,
tip.scatter_amount,
tip.angle,
tip.roundness,
tip.rotation_random,
tip.drawing_angle,
true,
);
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 = Self::brush_dirty_radius(&tip);
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 {
let desc = format!(
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(tip.style)
);
self.document
.push_draw_snapshot(layer_idx, before, None, desc);
}
}
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 Rectangle".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
| BrushStyle::Blender
| BrushStyle::Noise
| BrushStyle::Texture
| BrushStyle::Pen
| BrushStyle::InkPen
)
}
}
/// Returns a short human-readable label for a built-in brush style.
///
/// Mirrors the names used in the GUI brush preset list so history entries
/// can include the active style, e.g. "Brush Stroke (Round)".
pub fn brush_style_label(style: BrushStyle) -> &'static str {
match style {
BrushStyle::Round | BrushStyle::Default => "Round",
BrushStyle::Square => "Square",
BrushStyle::HardRound => "Hard Round",
BrushStyle::SoftRound => "Soft Round",
BrushStyle::Star => "Star",
BrushStyle::Noise => "Noise",
BrushStyle::Texture => "Texture",
BrushStyle::Spray => "Spray",
BrushStyle::Pencil => "Pencil",
BrushStyle::Pen => "Pen",
BrushStyle::Calligraphy => "Calligraphy",
BrushStyle::Oil => "Oil",
BrushStyle::Charcoal => "Charcoal",
BrushStyle::Leaf => "Leaf",
BrushStyle::Rock => "Rock",
BrushStyle::Meadow => "Meadow",
BrushStyle::Wood => "Wood",
BrushStyle::Watercolor => "Watercolor",
BrushStyle::Marker => "Marker",
BrushStyle::Sketch => "Sketch",
BrushStyle::Hatch => "Hatch",
BrushStyle::Glow => "Glow",
BrushStyle::Airbrush => "Airbrush",
BrushStyle::Crayon => "Crayon",
BrushStyle::WetPaint => "Wet Paint",
BrushStyle::InkPen => "Ink Pen",
BrushStyle::Clouds => "Clouds",
BrushStyle::Dirt => "Dirt",
BrushStyle::Tree => "Tree",
BrushStyle::Bristle => "Bristle",
BrushStyle::Mixer => "Mixer",
BrushStyle::Blender => "Blender",
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);
}
}