init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "hcie-brush-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
rand_distr = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
@@ -0,0 +1,506 @@
|
||||
#![allow(deprecated)]
|
||||
use eframe::egui;
|
||||
use eframe::egui::Color32;
|
||||
use egui::ColorImage;
|
||||
use hcie_brush_engine::{BrushStyle, presets::BrushPreset};
|
||||
|
||||
// ── tunables ─────────────────────────────────────────────────────────────────
|
||||
const TILE_SIZE: u32 = 512;
|
||||
|
||||
struct SliderCaps {
|
||||
hardness: bool,
|
||||
opacity: bool,
|
||||
spacing: bool,
|
||||
flow: bool,
|
||||
jitter: bool,
|
||||
scatter: bool,
|
||||
angle: bool,
|
||||
roundness: bool,
|
||||
spray_particle_size: bool,
|
||||
spray_density: bool,
|
||||
}
|
||||
|
||||
fn slider_caps(style: BrushStyle) -> SliderCaps {
|
||||
match style {
|
||||
BrushStyle::Calligraphy => SliderCaps {
|
||||
angle: true, roundness: true, jitter: false, scatter: false,
|
||||
spray_particle_size: false, spray_density: false,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Pencil | BrushStyle::InkPen => SliderCaps {
|
||||
jitter: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Spray | BrushStyle::Airbrush => SliderCaps {
|
||||
jitter: true, scatter: true,
|
||||
spray_particle_size: true, spray_density: true,
|
||||
..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Charcoal => SliderCaps {
|
||||
jitter: true, scatter: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Crayon => SliderCaps {
|
||||
jitter: true, scatter: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Tree => SliderCaps {
|
||||
jitter: true, angle: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Meadow | BrushStyle::Rock | BrushStyle::Dirt => SliderCaps {
|
||||
jitter: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Bristle => SliderCaps {
|
||||
roundness: true, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::HardRound | BrushStyle::SoftRound => SliderCaps {
|
||||
hardness: false, ..SliderCaps::common()
|
||||
},
|
||||
BrushStyle::Mixer | BrushStyle::Blender => SliderCaps {
|
||||
flow: false, ..SliderCaps::common()
|
||||
},
|
||||
_ => SliderCaps::common(),
|
||||
}
|
||||
}
|
||||
|
||||
impl SliderCaps {
|
||||
fn common() -> Self {
|
||||
Self {
|
||||
hardness: true, opacity: true, spacing: true, flow: true,
|
||||
jitter: false, scatter: false, angle: false, roundness: false,
|
||||
spray_particle_size: false, spray_density: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum CanvasPreset { S512, S1024, S2048, S4096 }
|
||||
|
||||
impl CanvasPreset {
|
||||
fn size(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::S512 => (512, 512),
|
||||
Self::S1024 => (1024, 1024),
|
||||
Self::S2048 => (2048, 2048),
|
||||
Self::S4096 => (4096, 4096),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::S512 => "512",
|
||||
Self::S1024 => "1024",
|
||||
Self::S2048 => "2048",
|
||||
Self::S4096 => "4096",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum FpsCap { Unlimited, Fps30, Fps60, Fps165 }
|
||||
|
||||
impl FpsCap {
|
||||
fn value(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::Fps30 => Some(30.0),
|
||||
Self::Fps60 => Some(60.0),
|
||||
Self::Fps165 => Some(165.0),
|
||||
Self::Unlimited => None,
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fps30 => "30",
|
||||
Self::Fps60 => "60",
|
||||
Self::Fps165 => "165",
|
||||
Self::Unlimited => "∞",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── tiled canvas ─────────────────────────────────────────────────────────────
|
||||
|
||||
struct Tile {
|
||||
texture: Option<egui::TextureHandle>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
struct TiledCanvas {
|
||||
pixels: Vec<u8>,
|
||||
tiles: Vec<Tile>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
cols: u32,
|
||||
dirty_count: usize,
|
||||
}
|
||||
|
||||
impl TiledCanvas {
|
||||
fn new(w: u32, h: u32) -> Self {
|
||||
let cols = w.div_ceil(TILE_SIZE);
|
||||
let rows = h.div_ceil(TILE_SIZE);
|
||||
let total = (cols * rows) as usize;
|
||||
Self {
|
||||
pixels: vec![255; (w * h * 4) as usize],
|
||||
tiles: (0..total).map(|_| Tile { texture: None, dirty: true }).collect(),
|
||||
w,
|
||||
h,
|
||||
cols,
|
||||
dirty_count: total,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_dirty_rect(&mut self, cx: f32, cy: f32, radius: f32) {
|
||||
let tx0 = ((cx - radius).max(0.0) / TILE_SIZE as f32) as u32;
|
||||
let tx1 = (((cx + radius).min(self.w as f32 - 1.0) / TILE_SIZE as f32) as u32)
|
||||
.min(self.cols.saturating_sub(1));
|
||||
let ty0 = ((cy - radius).max(0.0) / TILE_SIZE as f32) as u32;
|
||||
let ty1 = (((cy + radius).min(self.h as f32 - 1.0) / TILE_SIZE as f32) as u32)
|
||||
.min(self.h.div_ceil(TILE_SIZE).saturating_sub(1));
|
||||
for ty in ty0..=ty1 {
|
||||
for tx in tx0..=tx1 {
|
||||
let idx = (ty * self.cols + tx) as usize;
|
||||
if !self.tiles[idx].dirty {
|
||||
self.tiles[idx].dirty = true;
|
||||
self.dirty_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_line_dirty(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, radius: f32) {
|
||||
let mut x0 = x0;
|
||||
let mut y0 = y0;
|
||||
let x1 = x1;
|
||||
let y1 = y1;
|
||||
let dx = (x1 - x0).abs();
|
||||
let dy = (y1 - y0).abs();
|
||||
let sx = if x0 < x1 { 1.0 } else { -1.0 };
|
||||
let sy = if y0 < y1 { 1.0 } else { -1.0 };
|
||||
let mut err = dx - dy;
|
||||
let max_steps = (dx + dy) as u32 + 1;
|
||||
|
||||
for _ in 0..max_steps {
|
||||
self.mark_dirty_rect(x0, y0, radius);
|
||||
if (x0 - x1).abs() < f32::EPSILON && (y0 - y1).abs() < f32::EPSILON {
|
||||
break;
|
||||
}
|
||||
let e2 = 2.0 * err;
|
||||
if e2 > -dy { err -= dy; x0 += sx; }
|
||||
if e2 < dx { err += dx; y0 += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_all_dirty(&mut self) {
|
||||
for t in &mut self.tiles { t.dirty = true; }
|
||||
self.dirty_count = self.tiles.len();
|
||||
}
|
||||
|
||||
fn upload_dirty(&mut self, ctx: &egui::Context) {
|
||||
if self.dirty_count == 0 { return; }
|
||||
let rows = self.h.div_ceil(TILE_SIZE);
|
||||
for ty in 0..rows {
|
||||
let tile_h = if ty == rows - 1 {
|
||||
self.h - ty * TILE_SIZE
|
||||
} else {
|
||||
TILE_SIZE
|
||||
} as usize;
|
||||
for tx in 0..self.cols {
|
||||
let idx = (ty * self.cols + tx) as usize;
|
||||
if !self.tiles[idx].dirty { continue; }
|
||||
self.tiles[idx].dirty = false;
|
||||
self.dirty_count = self.dirty_count.saturating_sub(1);
|
||||
|
||||
let tile_w = if tx == self.cols - 1 {
|
||||
self.w - tx * TILE_SIZE
|
||||
} else {
|
||||
TILE_SIZE
|
||||
} as usize;
|
||||
|
||||
let px = (tx * TILE_SIZE) as usize;
|
||||
let py = (ty * TILE_SIZE) as usize;
|
||||
let cap = tile_w * tile_h * 4;
|
||||
let mut data = vec![0u8; cap];
|
||||
for row in 0..tile_h {
|
||||
let src = ((py + row) * self.w as usize + px) * 4;
|
||||
let dst = row * tile_w * 4;
|
||||
data[dst..dst + tile_w * 4]
|
||||
.copy_from_slice(&self.pixels[src..src + tile_w * 4]);
|
||||
}
|
||||
|
||||
let img = ColorImage::from_rgba_unmultiplied(
|
||||
[tile_w, tile_h],
|
||||
&data,
|
||||
);
|
||||
let key = format!("t{}_{}", tx, ty);
|
||||
if let Some(tex) = &mut self.tiles[idx].texture {
|
||||
tex.set(img, egui::TextureOptions::NEAREST);
|
||||
} else {
|
||||
self.tiles[idx].texture =
|
||||
Some(ctx.load_texture(key, img, egui::TextureOptions::NEAREST));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ── app state ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct App {
|
||||
canvas: TiledCanvas,
|
||||
canvas_preset: CanvasPreset,
|
||||
preset: BrushPreset,
|
||||
color: [u8; 4],
|
||||
last_pt: Option<(f32, f32, f32)>,
|
||||
drawing: bool,
|
||||
last_frame: std::time::Instant,
|
||||
fps: f32,
|
||||
stroke_count: u64,
|
||||
fps_cap: FpsCap,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let preset = CanvasPreset::S1024;
|
||||
let (w, h) = preset.size();
|
||||
let now = std::time::Instant::now();
|
||||
Self {
|
||||
canvas: TiledCanvas::new(w, h),
|
||||
canvas_preset: preset,
|
||||
preset: BrushPreset::basic_round(),
|
||||
color: [0, 0, 0, 255],
|
||||
last_pt: None,
|
||||
drawing: false,
|
||||
last_frame: now,
|
||||
fps: 0.0,
|
||||
stroke_count: 0,
|
||||
fps_cap: FpsCap::Fps30,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_canvas_size(&mut self, p: CanvasPreset) {
|
||||
if self.canvas_preset == p { return; }
|
||||
self.canvas_preset = p;
|
||||
let (w, h) = p.size();
|
||||
self.canvas = TiledCanvas::new(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
// real fps throttle — sleep remaining frame time
|
||||
if let Some(limit) = self.fps_cap.value() {
|
||||
let interval = std::time::Duration::from_secs_f32(1.0 / limit);
|
||||
let elapsed = now - self.last_frame;
|
||||
if elapsed < interval {
|
||||
std::thread::sleep(interval - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
// fps counter — measure after sleep for accurate reading
|
||||
let after_sleep = std::time::Instant::now();
|
||||
let dt = (after_sleep - self.last_frame).as_secs_f32();
|
||||
self.last_frame = after_sleep;
|
||||
if dt > 0.0 {
|
||||
self.fps = self.fps * 0.9 + (1.0 / dt) * 0.1;
|
||||
}
|
||||
|
||||
self.canvas.upload_dirty(&ctx);
|
||||
|
||||
// ── left panel ───────────────────────────────────────────────────
|
||||
egui::Panel::left("panel").show(&ctx, |ui| {
|
||||
ui.heading("Brush Test");
|
||||
let limit_label = match self.fps_cap {
|
||||
FpsCap::Fps30 => "30",
|
||||
FpsCap::Fps60 => "60",
|
||||
FpsCap::Fps165 => "165",
|
||||
FpsCap::Unlimited => "∞",
|
||||
};
|
||||
ui.label(format!(
|
||||
"FPS: {:.0}/{} │ dirty: {} │ strokes: {}",
|
||||
self.fps, limit_label, self.canvas.dirty_count, self.stroke_count
|
||||
));
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label("Canvas");
|
||||
egui::ComboBox::from_id_salt("canvas_preset")
|
||||
.selected_text(self.canvas_preset.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for p in &[CanvasPreset::S512, CanvasPreset::S1024, CanvasPreset::S2048, CanvasPreset::S4096] {
|
||||
if ui.selectable_label(self.canvas_preset == *p, p.name()).clicked() {
|
||||
self.set_canvas_size(*p);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.label("FPS Cap");
|
||||
egui::ComboBox::from_id_salt("fps_cap")
|
||||
.selected_text(self.fps_cap.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for c in &[FpsCap::Fps30, FpsCap::Fps60, FpsCap::Fps165, FpsCap::Unlimited] {
|
||||
if ui.selectable_label(self.fps_cap == *c, c.name()).clicked() {
|
||||
self.fps_cap = *c;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.label("Brush");
|
||||
egui::ComboBox::from_id_salt("brush_preset")
|
||||
.selected_text(&self.preset.name)
|
||||
.show_ui(ui, |ui| {
|
||||
for p in BrushPreset::all_defaults() {
|
||||
ui.selectable_value(&mut self.preset, p.clone(), &p.name);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add(egui::Slider::new(&mut self.preset.tip.size, 1.0..=500.0).text("Size"));
|
||||
let caps = slider_caps(self.preset.style);
|
||||
ui.add_enabled(caps.hardness, egui::Slider::new(&mut self.preset.tip.hardness, 0.0..=1.0).text("Hardness"));
|
||||
ui.add_enabled(caps.opacity, egui::Slider::new(&mut self.preset.tip.opacity, 0.0..=1.0).text("Opacity"));
|
||||
ui.add_enabled(caps.spacing, egui::Slider::new(&mut self.preset.tip.spacing, 0.01..=1.0).text("Spacing"));
|
||||
ui.add_enabled(caps.flow, egui::Slider::new(&mut self.preset.tip.flow, 0.01..=1.0).text("Flow"));
|
||||
ui.add_enabled(caps.jitter, egui::Slider::new(&mut self.preset.tip.jitter_amount, 0.0..=5.0).text("Jitter"));
|
||||
ui.add_enabled(caps.scatter, egui::Slider::new(&mut self.preset.tip.scatter_amount, 0.0..=1.0).text("Scatter"));
|
||||
ui.add_enabled(caps.angle, egui::Slider::new(&mut self.preset.tip.angle, 0.0..=360.0).text("Angle"));
|
||||
ui.add_enabled(caps.roundness, egui::Slider::new(&mut self.preset.tip.roundness, 0.0..=1.0).text("Roundness"));
|
||||
ui.add_enabled(caps.spray_particle_size, egui::Slider::new(&mut self.preset.tip.spray_particle_size, 0.5..=20.0).text("Spray Size"));
|
||||
ui.add_enabled(caps.spray_density, egui::Slider::new(&mut self.preset.tip.spray_density, 1..=500).text("Spray Density"));
|
||||
|
||||
let mut rgba = [
|
||||
self.color[0] as f32 / 255.,
|
||||
self.color[1] as f32 / 255.,
|
||||
self.color[2] as f32 / 255.,
|
||||
self.color[3] as f32 / 255.,
|
||||
];
|
||||
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
|
||||
self.color = [
|
||||
(rgba[0] * 255.) as u8, (rgba[1] * 255.) as u8,
|
||||
(rgba[2] * 255.) as u8, (rgba[3] * 255.) as u8,
|
||||
];
|
||||
}
|
||||
|
||||
if ui.button("Clear").clicked() {
|
||||
self.canvas.pixels.fill(255);
|
||||
self.canvas.mark_all_dirty();
|
||||
self.stroke_count = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ── central canvas ────────────────────────────────────────────────
|
||||
egui::CentralPanel::default().show(&ctx, |ui| {
|
||||
let available = ui.available_size_before_wrap();
|
||||
let view_w = available.x.max(64.0);
|
||||
let view_h = available.y.max(64.0);
|
||||
let scale = (view_w / self.canvas.w as f32).min(view_h / self.canvas.h as f32);
|
||||
let disp_w = self.canvas.w as f32 * scale;
|
||||
let disp_h = self.canvas.h as f32 * scale;
|
||||
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(egui::vec2(disp_w, disp_h), egui::Sense::drag());
|
||||
|
||||
// paint tiles
|
||||
if self.canvas.tiles.first().and_then(|t| t.texture.as_ref()).is_some() {
|
||||
let rows = self.canvas.h.div_ceil(TILE_SIZE);
|
||||
for ty in 0..rows {
|
||||
for tx in 0..self.canvas.cols {
|
||||
let idx = (ty * self.canvas.cols + tx) as usize;
|
||||
if let Some(tex) = &self.canvas.tiles[idx].texture {
|
||||
let tile_w = if tx == self.canvas.cols - 1 {
|
||||
self.canvas.w - tx * TILE_SIZE
|
||||
} else {
|
||||
TILE_SIZE
|
||||
} as f32;
|
||||
let tile_h = if ty == rows - 1 {
|
||||
self.canvas.h - ty * TILE_SIZE
|
||||
} else {
|
||||
TILE_SIZE
|
||||
} as f32;
|
||||
let src_x = (tx * TILE_SIZE) as f32 * scale;
|
||||
let src_y = (ty * TILE_SIZE) as f32 * scale;
|
||||
let r = egui::Rect::from_min_size(
|
||||
rect.min + egui::vec2(src_x, src_y),
|
||||
egui::vec2(tile_w * scale, tile_h * scale),
|
||||
);
|
||||
ui.painter().image(
|
||||
tex.id(),
|
||||
r,
|
||||
egui::Rect::from_min_max(egui::pos2(0., 0.), egui::pos2(1., 1.)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// input
|
||||
if resp.dragged() {
|
||||
self.drawing = true;
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let cx = (pos.x - rect.min.x) / scale;
|
||||
let cy = (pos.y - rect.min.y) / scale;
|
||||
if cx >= 0.0 && cy >= 0.0
|
||||
&& cx < self.canvas.w as f32
|
||||
&& cy < self.canvas.h as f32
|
||||
{
|
||||
let pt = (cx, cy, 1.0);
|
||||
let seg = if let Some(l) = self.last_pt {
|
||||
vec![l, pt]
|
||||
} else {
|
||||
vec![pt]
|
||||
};
|
||||
self.last_pt = Some(pt);
|
||||
self.canvas.mark_line_dirty(
|
||||
seg[0].0, seg[0].1,
|
||||
seg[seg.len() - 1].0, seg[seg.len() - 1].1,
|
||||
self.preset.tip.size,
|
||||
);
|
||||
hcie_brush_engine::draw_specialized_stroke(
|
||||
&mut self.canvas.pixels,
|
||||
self.canvas.w,
|
||||
self.canvas.h,
|
||||
&seg,
|
||||
self.preset.style,
|
||||
self.preset.tip.size,
|
||||
self.preset.tip.hardness,
|
||||
self.color,
|
||||
self.preset.tip.opacity,
|
||||
self.preset.tip.spacing,
|
||||
false,
|
||||
None,
|
||||
self.preset.tip.spray_particle_size,
|
||||
self.preset.tip.spray_density,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.preset.tip.color_variant,
|
||||
self.preset.tip.variant_amount,
|
||||
self.preset.tip.density,
|
||||
);
|
||||
self.stroke_count += 1;
|
||||
|
||||
// force upload now so stroke appears immediately
|
||||
self.canvas.upload_dirty(&ctx);
|
||||
}
|
||||
}
|
||||
} else if self.drawing {
|
||||
self.drawing = false;
|
||||
self.last_pt = None;
|
||||
}
|
||||
});
|
||||
|
||||
// keep requesting repaints so egui keeps calling update
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
eframe::run_native(
|
||||
"HCIE Brush Engine — Tiled GPU Test",
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default().with_inner_size([1400., 900.]),
|
||||
vsync: true,
|
||||
..Default::default()
|
||||
},
|
||||
Box::new(|cc| Ok(Box::new(App::new(cc)))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
# HCIE Brush Engine
|
||||
|
||||
High-performance procedural brush engine for paint/drawing applications. Provides 33 brush styles with GPU-accelerated test tool.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
hcie-brush-engine/
|
||||
├── Cargo.toml # Project manifest
|
||||
├── src/
|
||||
│ ├── lib.rs # Core library: brush stamps, strokes, FFI
|
||||
│ ├── dynamics.rs # Pressure, velocity, tilt mapping
|
||||
│ └── presets.rs # Predefined brush presets (28 presets)
|
||||
├── examples/
|
||||
│ └── brush_test.rs # GPU-accelerated tiled test tool (egui/eframe)
|
||||
└── tests/
|
||||
└── stamp.rs # Integration tests
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Run Test GUI (Brush Visualizer)
|
||||
```bash
|
||||
cargo run --example brush_test
|
||||
```
|
||||
|
||||
The test tool opens a window with:
|
||||
- **Canvas**: 512–4096px resolution, auto-scaled to window
|
||||
- **Tiled GPU rendering**: 512px tiles, only modified tiles uploaded
|
||||
- **Brush presets**: 28 preset brushes in categories (Basic, Sketch, Paint, Nature, Texture, Effect, Mixer)
|
||||
- **Parameters**: Size, hardness, opacity, spacing sliders
|
||||
- **FPS cap**: 30 / 60 / 165 / Unlimited selectable
|
||||
- **Live diagnostics**: FPS counter, dirty tile count, stroke counter
|
||||
|
||||
### Run Benchmarks
|
||||
```bash
|
||||
cargo test --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Library API
|
||||
|
||||
### Core Types
|
||||
|
||||
```rust
|
||||
// Brush style enum (33 variants)
|
||||
pub enum BrushStyle { Round, Square, HardRound, SoftRound, Star, Noise, Texture,
|
||||
Spray, Pencil, Pen, Calligraphy, Oil, Charcoal, Leaf, Rock, Meadow, Wood,
|
||||
Watercolor, Marker, Sketch, Hatch, Glow, Airbrush, Crayon, WetPaint, InkPen,
|
||||
Clouds, Dirt, Tree, Bristle, Mixer, Blender, Bitmap }
|
||||
|
||||
// Brush tip configuration
|
||||
pub struct BrushTip {
|
||||
pub style: BrushStyle,
|
||||
pub size: f32, // diameter in pixels
|
||||
pub opacity: f32, // 0.0–1.0
|
||||
pub hardness: f32, // 0.0 (soft) – 1.0 (hard)
|
||||
pub spacing: f32, // 0.01–5.0, relative to size
|
||||
pub jitter_amount: f32, // random position jitter
|
||||
pub scatter_amount: f32, // random scatter offset
|
||||
pub angle: f32, // brush angle in radians
|
||||
pub roundness: f32, // 0.0–1.0, 1.0 = perfect circle
|
||||
pub spray_particle_size: f32,
|
||||
pub spray_density: u32,
|
||||
pub bitmap_pixels: Vec<u8>, // for Bitmap brush style
|
||||
pub bitmap_w: u32,
|
||||
pub bitmap_h: u32,
|
||||
}
|
||||
```
|
||||
|
||||
### Essential Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `generate_brush_stamp(tip: &BrushTip) -> Vec<u8>` | Procedural alpha mask (diameter × diameter, 0-255) |
|
||||
| `sample_stamp(stamp: &[u8], diameter: usize, x: f32, y: f32) -> f32` | Bilinear sample from stamp |
|
||||
| `brush_spacing_pixels(size: f32, spacing_ratio: f32) -> f32` | Effective spacing in pixels |
|
||||
| `jitter_offset(jitter_amount: f32, size: f32) -> (f32, f32)` | Random jitter offset |
|
||||
| `scatter_offset(scatter_amount: f32, size: f32) -> (f32, f32)` | Random scatter offset |
|
||||
| `draw_dab(pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, size: f32, hardness: f32, color: [u8;4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>)` | Single brush dab (RGBA) |
|
||||
| `draw_specialized_stroke(...)` | Full stroke with interpolation for all brush styles |
|
||||
|
||||
### Brush Drawing Functions
|
||||
|
||||
Each brush style has its own signature (oil, charcoal, watercolor, calligraphy, marker, glow, airbrush, spray, pencil, square, wetpaint, mixer, leaf, crayon, tree, meadow, rock, clouds, dirt, stipple, bristle, wood, sketch, hatch).
|
||||
|
||||
See `src/lib.rs` for full signatures.
|
||||
|
||||
### Presets (`hcie_brush_engine::presets`)
|
||||
|
||||
```rust
|
||||
pub struct BrushPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
pub tip: BrushTip,
|
||||
pub style: BrushStyle,
|
||||
pub pressure_size: bool,
|
||||
pub pressure_opacity: bool,
|
||||
pub pressure_flow: bool,
|
||||
pub jitter_position: f32,
|
||||
pub jitter_angle: f32,
|
||||
}
|
||||
|
||||
impl BrushPreset {
|
||||
pub fn all_defaults() -> Vec<Self>; // returns all 28 presets
|
||||
pub fn basic_round() -> Self;
|
||||
pub fn hard_round() -> Self;
|
||||
pub fn soft_round() -> Self;
|
||||
pub fn soft_airbrush() -> Self;
|
||||
pub fn pencil() -> Self;
|
||||
pub fn ink_pen() -> Self;
|
||||
pub fn charcoal() -> Self;
|
||||
pub fn marker() -> Self;
|
||||
pub fn watercolor_wash() -> Self;
|
||||
pub fn watercolor() -> Self;
|
||||
pub fn oil_paint() -> Self;
|
||||
pub fn gouache() -> Self;
|
||||
pub fn crayon() -> Self;
|
||||
pub fn spray() -> Self;
|
||||
pub fn calligraphy() -> Self;
|
||||
pub fn smudge() -> Self;
|
||||
pub fn mixer() -> Self;
|
||||
pub fn blender() -> Self;
|
||||
pub fn meadow_grass() -> Self;
|
||||
pub fn rock_texture() -> Self;
|
||||
pub fn clouds() -> Self;
|
||||
pub fn dirt() -> Self;
|
||||
pub fn tree_bark() -> Self;
|
||||
pub fn stipple() -> Self;
|
||||
pub fn splatter() -> Self;
|
||||
pub fn pastel() -> Self;
|
||||
pub fn bristle() -> Self;
|
||||
pub fn glow() -> Self;
|
||||
pub fn hatch() -> Self;
|
||||
pub fn sketch() -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamics (`hcie_brush_engine::dynamics`)
|
||||
|
||||
```rust
|
||||
pub struct StrokeDynamics {
|
||||
pub pressure: f32, // 0.0–1.0
|
||||
pub velocity: f32, // px/frame
|
||||
pub tilt: (f32, f32), // degrees
|
||||
}
|
||||
|
||||
pub fn effective_size(base: f32, dyn: &StrokeDynamics, sensitive: bool) -> f32;
|
||||
pub fn effective_opacity(base: f32, dyn: &StrokeDynamics, sensitive: bool) -> f32;
|
||||
pub fn effective_flow(base: f32, dyn: &StrokeDynamics, sensitive: bool) -> f32;
|
||||
pub fn velocity_size_modifier(velocity: f32) -> f32;
|
||||
pub fn build_dynamic_tip(base: &BrushTip, dyn: &StrokeDynamics, press_size: bool, press_opacity: bool) -> BrushTip;
|
||||
pub fn simulate_pressure_from_velocity(velocity: f32, max_velocity: f32) -> f32;
|
||||
pub struct VelocityTracker { /* tracks velocity from consecutive points */ }
|
||||
```
|
||||
|
||||
### FFI (C Foreign Function Interface)
|
||||
|
||||
The library builds as `cdylib`. Primary entry point:
|
||||
|
||||
```c
|
||||
// See src/lib.rs for exact signatures
|
||||
void draw_specialized_stroke_ffi(
|
||||
uint8_t* pixels, uint32_t pixels_len,
|
||||
uint32_t width, uint32_t height,
|
||||
const float* points, uint32_t points_count,
|
||||
int32_t brush_style,
|
||||
float size, float hardness,
|
||||
const uint8_t* color,
|
||||
float opacity, float spacing_ratio,
|
||||
uint8_t is_eraser,
|
||||
/* ... mask/stroke_mask/bg params ... */
|
||||
);
|
||||
```
|
||||
|
||||
For detailed FFI signatures, see the `#[no_mangle]` functions at the bottom of `src/lib.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Linking Without Source Code
|
||||
|
||||
### Cargo (Rust Projects)
|
||||
```toml
|
||||
[dependencies]
|
||||
hcie-brush-engine = { git = "https://github.com/your-org/hcie-brush-engine" }
|
||||
# or local path:
|
||||
hcie-brush-engine = { path = "../hcie-brush-engine" }
|
||||
```
|
||||
|
||||
### C/C++ Projects
|
||||
1. Build the library:
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
2. Link against the produced shared library (`target/release/libhcie_brush_engine.so` / `.dylib` / `.dll`).
|
||||
3. Include the header generated by `cbindgen` or use the raw C declarations from the `#[no_mangle]` functions in `src/lib.rs`.
|
||||
|
||||
## License
|
||||
|
||||
[Your license here]
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Brush dynamics — pressure, velocity, tilt mapping for brush strokes.
|
||||
|
||||
use hcie_protocol::BrushTip;
|
||||
|
||||
/// Per-stroke dynamic parameters computed from input state.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct StrokeDynamics {
|
||||
pub pressure: f32, // 0.0–1.0
|
||||
pub velocity: f32, // Pixels per frame
|
||||
pub tilt: (f32, f32), // (x_tilt, y_tilt) in degrees
|
||||
}
|
||||
|
||||
/// Compute effective brush size from base size, dynamics, and pressure.
|
||||
pub fn effective_size(base_size: f32, dynamics: &StrokeDynamics, pressure_sensitive: bool) -> f32 {
|
||||
if !pressure_sensitive {
|
||||
return base_size;
|
||||
}
|
||||
let p = dynamics.pressure.clamp(0.0, 1.0);
|
||||
// Quadratic falloff for natural feel
|
||||
let factor = p * p * (3.0 - 2.0 * p); // smoothstep
|
||||
base_size * factor.max(0.1)
|
||||
}
|
||||
|
||||
/// Compute effective opacity from base opacity, dynamics, and pressure.
|
||||
pub fn effective_opacity(base_opacity: f32, dynamics: &StrokeDynamics, pressure_sensitive: bool) -> f32 {
|
||||
if !pressure_sensitive {
|
||||
return base_opacity;
|
||||
}
|
||||
let p = dynamics.pressure.clamp(0.0, 1.0);
|
||||
base_opacity * p
|
||||
}
|
||||
|
||||
/// Compute effective flow from base flow, dynamics, and pressure.
|
||||
pub fn effective_flow(base_flow: f32, dynamics: &StrokeDynamics, pressure_sensitive: bool) -> f32 {
|
||||
if !pressure_sensitive {
|
||||
return base_flow;
|
||||
}
|
||||
let p = dynamics.pressure.clamp(0.0, 1.0);
|
||||
base_flow * p
|
||||
}
|
||||
|
||||
/// Compute velocity-based size modifier (faster = thinner).
|
||||
pub fn velocity_size_modifier(velocity: f32) -> f32 {
|
||||
// At 0 velocity = 1.0, at 100 px/frame = 0.5
|
||||
let factor = 1.0 - (velocity / 200.0).clamp(0.0, 0.5);
|
||||
factor.clamp(0.5, 1.0)
|
||||
}
|
||||
|
||||
/// Build a `BrushTip` with dynamics applied.
|
||||
pub fn build_dynamic_tip(base: &BrushTip, dynamics: &StrokeDynamics, pressure_size: bool, pressure_opacity: bool) -> BrushTip {
|
||||
let mut tip = base.clone();
|
||||
tip.size = effective_size(base.size, dynamics, pressure_size);
|
||||
tip.opacity = effective_opacity(base.opacity, dynamics, pressure_opacity);
|
||||
tip
|
||||
}
|
||||
|
||||
/// Simulate tablet pressure from mouse velocity (fallback for non-tablet input).
|
||||
/// Slower movement = higher pressure.
|
||||
pub fn simulate_pressure_from_velocity(velocity: f32, max_velocity: f32) -> f32 {
|
||||
let t = (velocity / max_velocity).clamp(0.0, 1.0);
|
||||
// Inverse: slow = high pressure
|
||||
(1.0 - t * t).clamp(0.2, 1.0)
|
||||
}
|
||||
|
||||
/// Track velocity from consecutive points.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VelocityTracker {
|
||||
last_pos: Option<(f32, f32)>,
|
||||
last_time: Option<f32>,
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
pub fn update(&mut self, x: f32, y: f32, time: f32) -> f32 {
|
||||
let velocity = match (self.last_pos, self.last_time) {
|
||||
(Some((lx, ly)), Some(lt)) => {
|
||||
let dt = (time - lt).max(0.001);
|
||||
let dist = ((x - lx).powi(2) + (y - ly).powi(2)).sqrt();
|
||||
dist / dt
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
self.last_pos = Some((x, y));
|
||||
self.last_time = Some(time);
|
||||
velocity
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.last_pos = None;
|
||||
self.last_time = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add jitter to a color for textured brushes.
|
||||
pub fn jitter_color(color: [u8; 4], variation: u8, rng: &mut rand::rngs::ThreadRng) -> [u8; 4] {
|
||||
use rand::Rng;
|
||||
let jitter = variation as f32 / 255.0;
|
||||
let r = (color[0] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let g = (color[1] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let b = (color[2] as f32 * (1.0 - jitter) + rng.gen::<f32>() * jitter * 255.0).clamp(0.0, 255.0) as u8;
|
||||
[r, g, b, color[3]]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
//! Brush presets factory — ported from V2 `hcie-core/src/brush.rs`.
|
||||
|
||||
use hcie_protocol::{BrushTip, BrushStyle};
|
||||
|
||||
fn round_soft(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Round,
|
||||
size,
|
||||
opacity: 1.0,
|
||||
hardness: 0.0,
|
||||
spacing: 0.25,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn round_hard(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Round,
|
||||
size,
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.25,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pencil(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Pencil,
|
||||
size: size.max(1.0),
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.05,
|
||||
flow: 1.0, jitter_amount: 0.15,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn spray(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Spray,
|
||||
size,
|
||||
opacity: 0.6,
|
||||
hardness: 0.3,
|
||||
spacing: 0.35,
|
||||
flow: 1.0, jitter_amount: 0.4,
|
||||
scatter_amount: 0.6,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn calligraphy(size: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Calligraphy,
|
||||
size,
|
||||
opacity: 0.9,
|
||||
hardness: 0.7,
|
||||
spacing: 0.08,
|
||||
flow: 1.0, jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 45.0_f32.to_radians(),
|
||||
roundness: 0.15,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full brush preset with all rendering parameters.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BrushPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
pub tip: BrushTip,
|
||||
pub style: BrushStyle,
|
||||
pub pressure_size: bool,
|
||||
pub pressure_opacity: bool,
|
||||
pub pressure_flow: bool,
|
||||
pub jitter_position: f32,
|
||||
pub jitter_angle: f32,
|
||||
}
|
||||
|
||||
impl BrushPreset {
|
||||
pub fn basic_round() -> Self {
|
||||
Self {
|
||||
id: "basic_round".into(),
|
||||
name: "Basic Round".into(),
|
||||
category: "Basic".into(),
|
||||
tip: round_soft(15.0),
|
||||
style: BrushStyle::Round,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hard_round() -> Self {
|
||||
Self {
|
||||
id: "hard_round".into(),
|
||||
name: "Hard Round".into(),
|
||||
category: "Basic".into(),
|
||||
tip: round_hard(15.0),
|
||||
style: BrushStyle::HardRound,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn soft_round() -> Self {
|
||||
Self {
|
||||
id: "soft_round".into(),
|
||||
name: "Soft Round".into(),
|
||||
category: "Basic".into(),
|
||||
tip: round_soft(40.0),
|
||||
style: BrushStyle::SoftRound,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn soft_airbrush() -> Self {
|
||||
Self {
|
||||
id: "soft_airbrush".into(),
|
||||
name: "Soft Airbrush".into(),
|
||||
category: "Basic".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Airbrush,
|
||||
size: 60.0,
|
||||
opacity: 0.4,
|
||||
hardness: 0.0,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Airbrush,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pencil() -> Self {
|
||||
Self {
|
||||
id: "pencil".into(),
|
||||
name: "Pencil".into(),
|
||||
category: "Sketch".into(),
|
||||
tip: pencil(3.0),
|
||||
style: BrushStyle::Pencil,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ink_pen() -> Self {
|
||||
Self {
|
||||
id: "ink_pen".into(),
|
||||
name: "Ink Pen".into(),
|
||||
category: "Ink".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::InkPen,
|
||||
size: 4.0,
|
||||
opacity: 1.0,
|
||||
hardness: 0.95,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::InkPen,
|
||||
pressure_size: true,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn charcoal() -> Self {
|
||||
Self {
|
||||
id: "charcoal".into(),
|
||||
name: "Charcoal Block".into(),
|
||||
category: "Sketch".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Charcoal,
|
||||
size: 30.0,
|
||||
opacity: 0.7,
|
||||
hardness: 0.5,
|
||||
spacing: 0.15,
|
||||
flow: 1.0,
|
||||
jitter_amount: 2.0,
|
||||
scatter_amount: 0.5,
|
||||
angle: 0.0,
|
||||
roundness: 0.8,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Charcoal,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 2.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn marker() -> Self {
|
||||
Self {
|
||||
id: "marker_fat".into(),
|
||||
name: "Fat Marker".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Marker,
|
||||
size: 25.0,
|
||||
opacity: 0.85,
|
||||
hardness: 0.9,
|
||||
spacing: 0.08,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Marker,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn watercolor_wash() -> Self {
|
||||
Self {
|
||||
id: "watercolor_wash".into(),
|
||||
name: "Watercolor Wash".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Watercolor,
|
||||
size: 80.0,
|
||||
opacity: 0.25,
|
||||
hardness: 0.0,
|
||||
spacing: 0.08,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Watercolor,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn watercolor() -> Self {
|
||||
Self {
|
||||
id: "watercolor_wet".into(),
|
||||
name: "Wet Watercolor".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Watercolor,
|
||||
size: 60.0,
|
||||
opacity: 0.35,
|
||||
hardness: 0.0,
|
||||
spacing: 0.06,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Watercolor,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oil_paint() -> Self {
|
||||
Self {
|
||||
id: "oil_paint_impasto".into(),
|
||||
name: "Oil Paint Impasto".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Oil,
|
||||
size: 35.0,
|
||||
opacity: 0.95,
|
||||
hardness: 0.85,
|
||||
spacing: 0.04,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Oil,
|
||||
pressure_size: true,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gouache() -> Self {
|
||||
Self {
|
||||
id: "gouache_flat".into(),
|
||||
name: "Gouache Flat".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Marker,
|
||||
size: 40.0,
|
||||
opacity: 0.95,
|
||||
hardness: 0.7,
|
||||
spacing: 0.04,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Marker,
|
||||
pressure_size: true,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn crayon() -> Self {
|
||||
Self {
|
||||
id: "chalk_rough".into(),
|
||||
name: "Rough Chalk".into(),
|
||||
category: "Texture".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Crayon,
|
||||
size: 25.0,
|
||||
opacity: 0.85,
|
||||
hardness: 0.5,
|
||||
spacing: 0.12,
|
||||
flow: 1.0,
|
||||
jitter_amount: 1.0,
|
||||
scatter_amount: 0.2,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Crayon,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 1.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spray() -> Self {
|
||||
Self {
|
||||
id: "spray_paint".into(),
|
||||
name: "Spray Paint".into(),
|
||||
category: "Effect".into(),
|
||||
tip: spray(80.0),
|
||||
style: BrushStyle::Spray,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calligraphy() -> Self {
|
||||
Self {
|
||||
id: "calligraphy_flat".into(),
|
||||
name: "Calligraphy Flat".into(),
|
||||
category: "Ink".into(),
|
||||
tip: calligraphy(20.0),
|
||||
style: BrushStyle::Calligraphy,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn smudge() -> Self {
|
||||
Self {
|
||||
id: "smudge_soft".into(),
|
||||
name: "Soft Smudge".into(),
|
||||
category: "Effect".into(),
|
||||
tip: round_soft(40.0),
|
||||
style: BrushStyle::Blender,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mixer() -> Self {
|
||||
Self {
|
||||
id: "mixer_color".into(),
|
||||
name: "Color Mixer".into(),
|
||||
category: "Mixer".into(),
|
||||
tip: round_soft(50.0),
|
||||
style: BrushStyle::Mixer,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blender() -> Self {
|
||||
Self {
|
||||
id: "blender_soft".into(),
|
||||
name: "Soft Blender".into(),
|
||||
category: "Mixer".into(),
|
||||
tip: round_soft(80.0),
|
||||
style: BrushStyle::Blender,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Nature brushes -----
|
||||
|
||||
pub fn meadow_grass() -> Self {
|
||||
Self {
|
||||
id: "meadow_grass".into(),
|
||||
name: "Meadow Grass".into(),
|
||||
category: "Nature".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Meadow,
|
||||
size: 50.0,
|
||||
opacity: 0.9,
|
||||
hardness: 0.8,
|
||||
spacing: 0.40,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.8,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: true,
|
||||
variant_amount: 0.5,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Meadow,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 5.0,
|
||||
jitter_angle: 180.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rock_texture() -> Self {
|
||||
Self {
|
||||
id: "rock_texture".into(),
|
||||
name: "Rock Texture".into(),
|
||||
category: "Nature".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Rock,
|
||||
size: 50.0,
|
||||
opacity: 0.75,
|
||||
hardness: 0.6,
|
||||
spacing: 0.35,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Rock,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 2.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clouds() -> Self {
|
||||
Self {
|
||||
id: "clouds_soft".into(),
|
||||
name: "Soft Clouds".into(),
|
||||
category: "Nature".into(),
|
||||
tip: round_soft(120.0),
|
||||
style: BrushStyle::Clouds,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirt() -> Self {
|
||||
Self {
|
||||
id: "dirt_stamp_floor".into(),
|
||||
name: "Stamp Floor".into(),
|
||||
category: "Texture".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Dirt,
|
||||
size: 60.0,
|
||||
opacity: 0.85,
|
||||
hardness: 0.7,
|
||||
spacing: 0.18,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.5,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: true,
|
||||
variant_amount: 0.35,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Dirt,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 3.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tree_bark() -> Self {
|
||||
Self {
|
||||
id: "tree_bark".into(),
|
||||
name: "Tree Bark".into(),
|
||||
category: "Nature".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Tree,
|
||||
size: 50.0,
|
||||
opacity: 0.9,
|
||||
hardness: 0.6,
|
||||
spacing: 0.30,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.3,
|
||||
scatter_amount: 0.0,
|
||||
angle: 90.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Tree,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Special effect brushes -----
|
||||
|
||||
pub fn stipple() -> Self {
|
||||
Self {
|
||||
id: "stipple_dot".into(),
|
||||
name: "Stipple Dots".into(),
|
||||
category: "Texture".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Star,
|
||||
size: 20.0,
|
||||
opacity: 0.8,
|
||||
hardness: 1.0,
|
||||
spacing: 0.40,
|
||||
flow: 1.0,
|
||||
jitter_amount: 4.0,
|
||||
scatter_amount: 0.6,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Star,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 4.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn splatter() -> Self {
|
||||
Self {
|
||||
id: "splatter_ink".into(),
|
||||
name: "Ink Splatter".into(),
|
||||
category: "Effect".into(),
|
||||
tip: round_soft(50.0),
|
||||
style: BrushStyle::Charcoal,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 8.0,
|
||||
jitter_angle: 360.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pastel() -> Self {
|
||||
Self {
|
||||
id: "pastel_soft".into(),
|
||||
name: "Soft Pastel".into(),
|
||||
category: "Paint".into(),
|
||||
tip: round_soft(30.0),
|
||||
style: BrushStyle::Crayon,
|
||||
pressure_size: false,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.1,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bristle() -> Self {
|
||||
Self {
|
||||
id: "bristle_stiff".into(),
|
||||
name: "Stiff Bristle".into(),
|
||||
category: "Paint".into(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Bristle,
|
||||
size: 20.0,
|
||||
opacity: 0.9,
|
||||
hardness: 0.8,
|
||||
spacing: 0.03,
|
||||
flow: 1.0,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 0.8,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Bristle,
|
||||
pressure_size: true,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn glow() -> Self {
|
||||
Self {
|
||||
id: "glow".into(),
|
||||
name: "Glow".into(),
|
||||
category: "Effect".into(),
|
||||
tip: round_soft(30.0),
|
||||
style: BrushStyle::Glow,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hatch() -> Self {
|
||||
Self {
|
||||
id: "hatch".into(),
|
||||
name: "Hatch".into(),
|
||||
category: "Sketch".into(),
|
||||
tip: round_hard(3.0),
|
||||
style: BrushStyle::Hatch,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sketch() -> Self {
|
||||
Self {
|
||||
id: "sketch".into(),
|
||||
name: "Sketch".into(),
|
||||
category: "Sketch".into(),
|
||||
tip: round_hard(3.0),
|
||||
style: BrushStyle::Sketch,
|
||||
pressure_size: true,
|
||||
pressure_opacity: false,
|
||||
pressure_flow: false,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_defaults() -> Vec<Self> {
|
||||
vec![
|
||||
Self::basic_round(),
|
||||
Self::hard_round(),
|
||||
Self::soft_round(),
|
||||
Self::soft_airbrush(),
|
||||
Self::pencil(),
|
||||
Self::ink_pen(),
|
||||
Self::charcoal(),
|
||||
Self::marker(),
|
||||
Self::watercolor_wash(),
|
||||
Self::watercolor(),
|
||||
Self::oil_paint(),
|
||||
Self::gouache(),
|
||||
Self::crayon(),
|
||||
Self::spray(),
|
||||
Self::calligraphy(),
|
||||
Self::smudge(),
|
||||
Self::mixer(),
|
||||
Self::blender(),
|
||||
Self::meadow_grass(),
|
||||
Self::rock_texture(),
|
||||
Self::clouds(),
|
||||
Self::dirt(),
|
||||
Self::tree_bark(),
|
||||
Self::stipple(),
|
||||
Self::splatter(),
|
||||
Self::pastel(),
|
||||
Self::bristle(),
|
||||
Self::glow(),
|
||||
Self::hatch(),
|
||||
Self::sketch(),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use hcie_brush_engine::{BrushTip, BrushStyle};
|
||||
|
||||
fn make_round_tip(size: f32, hardness: f32) -> BrushTip {
|
||||
BrushTip {
|
||||
style: BrushStyle::Round,
|
||||
size,
|
||||
hardness,
|
||||
..BrushTip::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_brush_stamp_size() {
|
||||
let tip = make_round_tip(10.0, 0.5);
|
||||
let stamp = hcie_brush_engine::generate_brush_stamp(&tip);
|
||||
let expected_d = 20usize;
|
||||
assert_eq!(stamp.len(), expected_d * expected_d, "stamp should be square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_brush_stamp_center_nonzero() {
|
||||
let tip = make_round_tip(10.0, 1.0);
|
||||
let stamp = hcie_brush_engine::generate_brush_stamp(&tip);
|
||||
let d = 20usize;
|
||||
let center = d / 2;
|
||||
assert!(stamp[center * d + center] > 0, "center should be opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_brush_stamp_hardness_gradient() {
|
||||
let soft = hcie_brush_engine::generate_brush_stamp(&make_round_tip(10.0, 0.0));
|
||||
let hard = hcie_brush_engine::generate_brush_stamp(&make_round_tip(10.0, 1.0));
|
||||
assert_eq!(soft.len(), hard.len(), "same size stamp should have same length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brush_spacing_pixels() {
|
||||
let spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.5);
|
||||
assert_eq!(spacing, 10.0, "spacing should be size * ratio");
|
||||
let min_spacing = hcie_brush_engine::brush_spacing_pixels(20.0, 0.0);
|
||||
assert!((min_spacing - 0.2).abs() < 0.001, "min spacing should be ~0.2, got {}", min_spacing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_offset_within_bounds() {
|
||||
let (jx, jy) = hcie_brush_engine::jitter_offset(0.0, 10.0);
|
||||
assert_eq!(jx, 0.0, "no jitter");
|
||||
assert_eq!(jy, 0.0, "no jitter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitmap_stamp_fallback_to_round() {
|
||||
let tip = BrushTip {
|
||||
style: BrushStyle::Bitmap,
|
||||
size: 10.0,
|
||||
bitmap_pixels: vec![],
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
..BrushTip::default()
|
||||
};
|
||||
let stamp = hcie_brush_engine::generate_brush_stamp(&tip);
|
||||
assert!(!stamp.is_empty(), "empty bitmap should fallback to round");
|
||||
}
|
||||
Reference in New Issue
Block a user