init
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "hcie-engine-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
hcie-protocol = { path = "../hcie-protocol" }
|
||||
hcie-document = { path = "../hcie-document" }
|
||||
hcie-tile = { path = "../hcie-tile" }
|
||||
hcie-selection = { path = "../hcie-selection" }
|
||||
hcie-text = { path = "../hcie-text" }
|
||||
hcie-history = { path = "../hcie-history" }
|
||||
hcie-fx = { path = "../hcie-fx" }
|
||||
hcie-blend = { path = "../hcie-blend" }
|
||||
hcie-brush-engine = { path = "../hcie-brush-engine" }
|
||||
hcie-draw = { path = "../hcie-draw" }
|
||||
hcie-filter = { path = "../hcie-filter" }
|
||||
hcie-composite = { path = "../hcie-composite" }
|
||||
hcie-io = { path = "../hcie-io" }
|
||||
hcie-psd = { path = "../hcie-psd", package = "psd" }
|
||||
hcie-kra = { path = "../hcie-kra" }
|
||||
hcie-native = { path = "../hcie-native" }
|
||||
hcie-vector = { path = "../hcie-vector" }
|
||||
|
||||
libloading = "0.8"
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp", "gif", "tiff"] }
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "staticlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
sha2 = "0.10"
|
||||
@@ -0,0 +1,223 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ParamDef {
|
||||
pub default: String,
|
||||
#[allow(dead_code)]
|
||||
pub desc: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ComponentDef {
|
||||
#[serde(rename = "type")]
|
||||
pub ctype: String,
|
||||
#[serde(default)]
|
||||
pub pts: Vec<[String; 2]>,
|
||||
#[serde(default)]
|
||||
pub cx: String,
|
||||
#[serde(default)]
|
||||
pub cy: String,
|
||||
#[serde(default)]
|
||||
pub rx: String,
|
||||
#[serde(default)]
|
||||
pub ry: String,
|
||||
#[serde(default)]
|
||||
pub r: String,
|
||||
#[serde(default)]
|
||||
pub n: usize,
|
||||
#[serde(default)]
|
||||
pub fill: String,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ShapeTemplateDef {
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub size_mm: u32,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub parameters: HashMap<String, ParamDef>,
|
||||
pub components: Vec<ComponentDef>,
|
||||
}
|
||||
|
||||
pub struct TemplateComponent {
|
||||
pub pts: Vec<[f32; 2]>,
|
||||
pub closed: bool,
|
||||
pub fill: bool,
|
||||
pub fill_color: String,
|
||||
}
|
||||
|
||||
static TEMPLATES: OnceLock<Vec<ShapeTemplateDef>> = OnceLock::new();
|
||||
|
||||
fn init_templates() -> Vec<ShapeTemplateDef> {
|
||||
let json = include_str!("../../hcie-ai/templates_db.json");
|
||||
serde_json::from_str(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_template_def(name: &str) -> Option<ShapeTemplateDef> {
|
||||
TEMPLATES.get_or_init(init_templates).iter().find(|t| t.name == name).cloned()
|
||||
}
|
||||
|
||||
pub fn all_names() -> Vec<String> {
|
||||
TEMPLATES.get_or_init(init_templates).iter().map(|t| t.name.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn search_templates(query: &str) -> Vec<ShapeTemplateDef> {
|
||||
let q_lower = query.to_lowercase();
|
||||
TEMPLATES.get_or_init(init_templates)
|
||||
.iter()
|
||||
.filter(|t| t.name.to_lowercase().contains(&q_lower) || t.description.to_lowercase().contains(&q_lower))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, String>) -> Vec<TemplateComponent> {
|
||||
let mut num_vars = HashMap::new();
|
||||
let mut str_vars = HashMap::new();
|
||||
|
||||
for (k, v) in &def.parameters {
|
||||
let val_str = provided_params.get(k).unwrap_or(&v.default);
|
||||
if let Ok(num) = val_str.parse::<f32>() {
|
||||
num_vars.insert(k.clone(), num);
|
||||
} else {
|
||||
str_vars.insert(k.clone(), val_str.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let eval = |expr: &str| -> f32 {
|
||||
if let Ok(num) = expr.parse::<f32>() { return num; }
|
||||
math_eval(expr, &num_vars).unwrap_or(0.0)
|
||||
};
|
||||
|
||||
let subst = |expr: &str| -> String {
|
||||
let mut res = expr.to_string();
|
||||
for (k, v) in &str_vars {
|
||||
res = res.replace(&format!("{{{}}}", k), v);
|
||||
}
|
||||
res
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for c in &def.components {
|
||||
let fill_str = subst(&c.fill);
|
||||
let fill = fill_str != "none" && !fill_str.is_empty();
|
||||
|
||||
let mut pts = Vec::new();
|
||||
match c.ctype.as_str() {
|
||||
"path" => {
|
||||
for pt in &c.pts {
|
||||
pts.push([eval(&pt[0]), eval(&pt[1])]);
|
||||
}
|
||||
}
|
||||
"circle" => {
|
||||
let cx = eval(&c.cx);
|
||||
let cy = eval(&c.cy);
|
||||
let rx_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.rx) };
|
||||
let ry_val = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.ry) };
|
||||
pts = circle_points(cx, cy, rx_val, ry_val, c.n.max(8));
|
||||
}
|
||||
"star" => {
|
||||
let outer = eval(&c.rx);
|
||||
let inner = eval(&c.ry);
|
||||
pts = star_points(c.n.max(5), outer, inner);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !pts.is_empty() {
|
||||
out.push(TemplateComponent { pts, closed: c.closed, fill, fill_color: fill_str });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn math_eval(expr: &str, vars: &HashMap<String, f32>) -> Option<f32> {
|
||||
let tokens = tokenize(expr);
|
||||
let mut pos = 0;
|
||||
parse_expr(&tokens, &mut pos, vars)
|
||||
}
|
||||
|
||||
fn tokenize(expr: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
for c in expr.chars() {
|
||||
if c.is_whitespace() { continue; }
|
||||
if "+-*/()".contains(c) {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
tokens.push(c.to_string());
|
||||
} else {
|
||||
current.push(c);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() { tokens.push(current); }
|
||||
tokens
|
||||
}
|
||||
|
||||
fn parse_expr(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
|
||||
let mut left = parse_term(tokens, pos, vars)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = &tokens[*pos];
|
||||
if op == "+" { *pos += 1; left += parse_term(tokens, pos, vars)?; }
|
||||
else if op == "-" { *pos += 1; left -= parse_term(tokens, pos, vars)?; }
|
||||
else { break; }
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
|
||||
fn parse_term(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
|
||||
let mut left = parse_factor(tokens, pos, vars)?;
|
||||
while *pos < tokens.len() {
|
||||
let op = &tokens[*pos];
|
||||
if op == "*" { *pos += 1; left *= parse_factor(tokens, pos, vars)?; }
|
||||
else if op == "/" { *pos += 1; left /= parse_factor(tokens, pos, vars)?; }
|
||||
else { break; }
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
|
||||
fn parse_factor(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
|
||||
if *pos >= tokens.len() { return None; }
|
||||
let t = &tokens[*pos];
|
||||
if t == "(" {
|
||||
*pos += 1;
|
||||
let val = parse_expr(tokens, pos, vars)?;
|
||||
if *pos < tokens.len() && tokens[*pos] == ")" { *pos += 1; }
|
||||
return Some(val);
|
||||
}
|
||||
if let Ok(n) = t.parse::<f32>() {
|
||||
*pos += 1;
|
||||
return Some(n);
|
||||
}
|
||||
if let Some(v) = vars.get(t) {
|
||||
*pos += 1;
|
||||
return Some(*v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn circle_points(cx: f32, cy: f32, rx: f32, ry: f32, n: usize) -> Vec<[f32; 2]> {
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let a = i as f32 / n as f32 * std::f32::consts::TAU;
|
||||
pts.push([cx + a.cos() * rx, cy + a.sin() * ry]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
fn star_points(n: usize, outer: f32, inner: f32) -> Vec<[f32; 2]> {
|
||||
let mut pts = Vec::with_capacity(n * 2);
|
||||
for i in 0..n {
|
||||
let a1 = i as f32 / n as f32 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
|
||||
let a2 = (i as f32 + 0.5) / n as f32 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
|
||||
pts.push([a1.cos() * outer, a1.sin() * outer]);
|
||||
pts.push([a2.cos() * inner, a2.sin() * inner]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)]
|
||||
use std::sync::{OnceLock, atomic::{AtomicU8, Ordering}};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::os::raw::c_char;
|
||||
use std::ffi::CString;
|
||||
use libloading::Library;
|
||||
|
||||
/// Global vision backend preference mirrored into the dynamic `hcie-vision` plugin.
|
||||
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
|
||||
static VISION_BACKEND: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] {
|
||||
hcie_blend::blend_pixels(dst, src, mode.into(), opacity)
|
||||
}
|
||||
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) {
|
||||
hcie_blend::blend_buffers(dst, src, mode.into(), opacity)
|
||||
}
|
||||
|
||||
pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> {
|
||||
let mut fl = hcie_protocol::Layer::new_transparent("", width, height);
|
||||
fl.pixels.copy_from_slice(pixels);
|
||||
hcie_filter::apply_filter(&mut fl, filter_id, params);
|
||||
let len = pixels.len().min(fl.pixels.len());
|
||||
pixels[..len].copy_from_slice(&fl.pixels[..len]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true;
|
||||
}
|
||||
pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true;
|
||||
}
|
||||
pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) {
|
||||
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true;
|
||||
}
|
||||
pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) {
|
||||
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true;
|
||||
}
|
||||
pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) {
|
||||
let style = if tip.style == hcie_protocol::BrushStyle::Default {
|
||||
hcie_brush_engine::BrushStyle::Round
|
||||
} else {
|
||||
unsafe { std::mem::transmute::<i32, hcie_brush_engine::BrushStyle>(tip.style as i32) }
|
||||
};
|
||||
let ct = hcie_brush_engine::BrushTip {
|
||||
style,
|
||||
size: tip.size, opacity: tip.opacity, hardness: tip.hardness,
|
||||
spacing: tip.spacing, flow: tip.flow,
|
||||
jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount,
|
||||
angle: tip.angle, roundness: tip.roundness,
|
||||
spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density,
|
||||
bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h,
|
||||
color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
};
|
||||
hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true;
|
||||
}
|
||||
pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) {
|
||||
let style = match brush_style {
|
||||
hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round,
|
||||
hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square,
|
||||
hcie_protocol::BrushStyle::HardRound => hcie_brush_engine::BrushStyle::HardRound,
|
||||
hcie_protocol::BrushStyle::SoftRound => hcie_brush_engine::BrushStyle::SoftRound,
|
||||
hcie_protocol::BrushStyle::Star => hcie_brush_engine::BrushStyle::Star,
|
||||
hcie_protocol::BrushStyle::Noise => hcie_brush_engine::BrushStyle::Noise,
|
||||
hcie_protocol::BrushStyle::Texture => hcie_brush_engine::BrushStyle::Texture,
|
||||
hcie_protocol::BrushStyle::Spray => hcie_brush_engine::BrushStyle::Spray,
|
||||
hcie_protocol::BrushStyle::Pencil => hcie_brush_engine::BrushStyle::Pencil,
|
||||
hcie_protocol::BrushStyle::Pen => hcie_brush_engine::BrushStyle::Pen,
|
||||
hcie_protocol::BrushStyle::Calligraphy => hcie_brush_engine::BrushStyle::Calligraphy,
|
||||
hcie_protocol::BrushStyle::Oil => hcie_brush_engine::BrushStyle::Oil,
|
||||
hcie_protocol::BrushStyle::Charcoal => hcie_brush_engine::BrushStyle::Charcoal,
|
||||
hcie_protocol::BrushStyle::Leaf => hcie_brush_engine::BrushStyle::Leaf,
|
||||
hcie_protocol::BrushStyle::Rock => hcie_brush_engine::BrushStyle::Rock,
|
||||
hcie_protocol::BrushStyle::Meadow => hcie_brush_engine::BrushStyle::Meadow,
|
||||
hcie_protocol::BrushStyle::Wood => hcie_brush_engine::BrushStyle::Wood,
|
||||
hcie_protocol::BrushStyle::Watercolor => hcie_brush_engine::BrushStyle::Watercolor,
|
||||
hcie_protocol::BrushStyle::Marker => hcie_brush_engine::BrushStyle::Marker,
|
||||
hcie_protocol::BrushStyle::Sketch => hcie_brush_engine::BrushStyle::Sketch,
|
||||
hcie_protocol::BrushStyle::Hatch => hcie_brush_engine::BrushStyle::Hatch,
|
||||
hcie_protocol::BrushStyle::Glow => hcie_brush_engine::BrushStyle::Glow,
|
||||
hcie_protocol::BrushStyle::Airbrush => hcie_brush_engine::BrushStyle::Airbrush,
|
||||
hcie_protocol::BrushStyle::Crayon => hcie_brush_engine::BrushStyle::Crayon,
|
||||
hcie_protocol::BrushStyle::WetPaint => hcie_brush_engine::BrushStyle::WetPaint,
|
||||
hcie_protocol::BrushStyle::InkPen => hcie_brush_engine::BrushStyle::InkPen,
|
||||
hcie_protocol::BrushStyle::Clouds => hcie_brush_engine::BrushStyle::Clouds,
|
||||
hcie_protocol::BrushStyle::Dirt => hcie_brush_engine::BrushStyle::Dirt,
|
||||
hcie_protocol::BrushStyle::Tree => hcie_brush_engine::BrushStyle::Tree,
|
||||
hcie_protocol::BrushStyle::Bristle => hcie_brush_engine::BrushStyle::Bristle,
|
||||
hcie_protocol::BrushStyle::Mixer => hcie_brush_engine::BrushStyle::Mixer,
|
||||
hcie_protocol::BrushStyle::Blender => hcie_brush_engine::BrushStyle::Blender,
|
||||
hcie_protocol::BrushStyle::Default => hcie_brush_engine::BrushStyle::Round,
|
||||
hcie_protocol::BrushStyle::Bitmap => hcie_brush_engine::BrushStyle::Bitmap,
|
||||
};
|
||||
hcie_brush_engine::draw_specialized_stroke(pixels, width, height, points, style, size, hardness, color, opacity, spacing_ratio, is_eraser, sketch_history, spray_particle_size, spray_density, mask, stroke_mask, bg_pixels, color_variant, variant_amount, density)
|
||||
}
|
||||
|
||||
pub fn composite_layers(layers: &[hcie_protocol::Layer], cw: u32, ch: u32) -> Vec<u8> { hcie_composite::composite_layers(layers, cw, ch) }
|
||||
pub fn composite_layers_region(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, x0: u32, y0: u32, x1: u32, y1: u32, out: &mut [u8]) { hcie_composite::composite_layers_region(layers, cw, ch, x0, y0, x1, y1, out) }
|
||||
pub mod tiled {
|
||||
|
||||
pub fn composite_tiled_into(layers: &[hcie_protocol::Layer], tile_layers: &[Option<hcie_tile::TiledLayer>], cw: u32, ch: u32, x0: u32, y0: u32, x1: u32, y1: u32, out: &mut [u8]) {
|
||||
hcie_composite::tiled::composite_tiled_into(layers, tile_layers, cw, ch, x0, y0, x1, y1, out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_image(path: &Path) -> Result<hcie_protocol::Layer, String> { hcie_io::load_image(path) }
|
||||
pub fn save_image(layer: &hcie_protocol::Layer, path: &Path, format: &str) -> Result<(), String> { hcie_io::save_image(layer, path, format) }
|
||||
pub fn import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_psd::import_psd(path) }
|
||||
pub fn export_psd(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, composited: &[u8], path: &Path) -> Result<(), String> { hcie_psd::save_psd(layers, cw, ch, composited, path) }
|
||||
pub fn import_kra(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_kra::import_kra(path) }
|
||||
pub fn export_kra(layers: &[hcie_protocol::Layer], cw: u32, ch: u32, composited: &[u8], path: &Path) -> Result<(), String> { hcie_kra::export_kra(layers, cw, ch, composited, path) }
|
||||
pub fn load_native(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_native::load_native(path) }
|
||||
pub fn save_native(layers: &[hcie_protocol::Layer], path: &Path) -> Result<(), String> { hcie_native::save_native(layers, path) }
|
||||
|
||||
pub mod svg_import {
|
||||
|
||||
pub fn import_svg(data: &[u8]) -> Result<Vec<hcie_protocol::Layer>, String> { hcie_io::svg_import::import_svg(data) }
|
||||
}
|
||||
|
||||
pub mod vector {
|
||||
|
||||
pub fn render_vector_shapes(layer: &mut hcie_protocol::Layer) { hcie_vector::render_vector_shapes(layer) }
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BooleanOp { Union, Intersect, Subtract, Xor }
|
||||
pub fn boolean_shapes(op: BooleanOp, a: &hcie_protocol::VectorShape, b: &hcie_protocol::VectorShape) -> Option<hcie_protocol::VectorShape> {
|
||||
let vop = match op { BooleanOp::Union => hcie_vector::path_boolean::BooleanOp::Union, BooleanOp::Intersect => hcie_vector::path_boolean::BooleanOp::Intersect, BooleanOp::Subtract => hcie_vector::path_boolean::BooleanOp::Subtract, BooleanOp::Xor => hcie_vector::path_boolean::BooleanOp::Xor };
|
||||
hcie_vector::path_boolean::boolean_shapes(vop, a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FFI — only hcie-ai and hcie-vision
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn find_plugin_path(name: &str) -> Result<PathBuf, String> {
|
||||
let filename = if cfg!(target_os = "windows") { format!("{}.dll", name) } else if cfg!(target_os = "macos") { format!("lib{}.dylib", name) } else { format!("lib{}.so", name) };
|
||||
let candidates: Vec<PathBuf> = {
|
||||
let mut v = Vec::new();
|
||||
// 1. Traditional plugins directory next to the executable or cwd.
|
||||
v.push(PathBuf::from("plugins"));
|
||||
if let Some(exe_plugins) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("plugins"))) {
|
||||
v.push(exe_plugins.clone());
|
||||
}
|
||||
// 2. Cargo workspace layout: the dynamic library is placed directly in
|
||||
// the target profile directory (e.g. target/debug/libhcie_ai.so).
|
||||
if let Some(exe_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
v.push(exe_dir.clone());
|
||||
// 3. Also check the deps subdirectory used by Cargo.
|
||||
v.push(exe_dir.join("deps"));
|
||||
// 4. If the executable is in target/<profile>, also try the sibling
|
||||
// target/<other-profile> directory so debug/release builds can find
|
||||
// each other during development.
|
||||
if let Some(profile) = exe_dir.file_name().and_then(|s| s.to_str()) {
|
||||
if let Some(target_dir) = exe_dir.parent() {
|
||||
for other in &["debug", "release"] {
|
||||
if *other != profile {
|
||||
v.push(target_dir.join(other));
|
||||
v.push(target_dir.join(other).join("deps"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. Walk up from the current working directory looking for plugins/ or
|
||||
// target/ directories. This helps when running from the project root.
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let mut c = cwd.clone();
|
||||
for _ in 0..5 {
|
||||
v.push(c.join("plugins"));
|
||||
v.push(c.join("target").join("debug"));
|
||||
v.push(c.join("target").join("debug").join("deps"));
|
||||
v.push(c.join("target").join("release"));
|
||||
v.push(c.join("target").join("release").join("deps"));
|
||||
if !c.pop() { break; }
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
for base in candidates {
|
||||
let p = base.join(&filename);
|
||||
if p.exists() { return Ok(p); }
|
||||
}
|
||||
Err(format!("Plugin '{}' not found", name))
|
||||
}
|
||||
|
||||
macro_rules! sym { ($lib:ident, $name:literal) => { *$lib.get(concat!($name, "\0").as_bytes()).expect(concat!("Failed to resolve ", $name)) }; }
|
||||
|
||||
struct AiPlugins {
|
||||
_lib_ai: Library, _lib_vision: Library,
|
||||
ai_chat_complete_fn: unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_session_create_fn: unsafe extern "C" fn(*const c_char) -> i64,
|
||||
ai_session_push_user_fn: unsafe extern "C" fn(i64, *const c_char) -> bool,
|
||||
ai_session_push_assistant_fn: unsafe extern "C" fn(i64, *const c_char) -> bool,
|
||||
ai_session_clear_fn: unsafe extern "C" fn(i64) -> bool,
|
||||
ai_session_destroy_fn: unsafe extern "C" fn(i64),
|
||||
ai_session_serialize_fn: unsafe extern "C" fn(i64, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_names_fn: unsafe extern "C" fn(*mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_get_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_search_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_template_execute_fn: unsafe extern "C" fn(*const c_char, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_execute_action_fn: unsafe extern "C" fn(*const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
ai_free_buffer_fn: unsafe extern "C" fn(*mut u8, usize),
|
||||
vision_background_removal_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_object_segment_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, u32, u32, u32, u32, u32, u32, *const c_char, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_super_resolution_fn: unsafe extern "C" fn(*const u8, usize, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_inpaint_fn: unsafe extern "C" fn(*mut u8, usize, u32, u32, *const u8, usize, i32) -> bool,
|
||||
vision_build_polygon_mask_fn: unsafe extern "C" fn(*const c_char, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_build_stroke_mask_fn: unsafe extern "C" fn(*const c_char, *const u8, usize, f32, u32, u32, *mut *mut u8, *mut usize) -> bool,
|
||||
vision_set_backend_fn: unsafe extern "C" fn(u8),
|
||||
vision_free_buffer_fn: unsafe extern "C" fn(*mut u8, usize),
|
||||
}
|
||||
|
||||
static AI: OnceLock<AiPlugins> = OnceLock::new();
|
||||
|
||||
fn ai_plugins() -> &'static AiPlugins {
|
||||
AI.get_or_init(|| {
|
||||
let ap = find_plugin_path("hcie_ai").expect("Critical: hcie_ai plugin not found");
|
||||
let vp = find_plugin_path("hcie_vision").expect("Critical: hcie_vision plugin not found");
|
||||
unsafe {
|
||||
let la = Library::new(ap).expect("Failed hcie_ai");
|
||||
let lv = Library::new(vp).expect("Failed hcie_vision");
|
||||
let ai_chat_complete_fn = *la.get(b"hcie_ai_chat_complete_c\0").unwrap();
|
||||
let ai_session_create_fn = *la.get(b"hcie_ai_session_create_c\0").unwrap();
|
||||
let ai_session_push_user_fn = *la.get(b"hcie_ai_session_push_user_c\0").unwrap();
|
||||
let ai_session_push_assistant_fn = *la.get(b"hcie_ai_session_push_assistant_c\0").unwrap();
|
||||
let ai_session_clear_fn = *la.get(b"hcie_ai_session_clear_c\0").unwrap();
|
||||
let ai_session_destroy_fn = *la.get(b"hcie_ai_session_destroy_c\0").unwrap();
|
||||
let ai_session_serialize_fn = *la.get(b"hcie_ai_session_serialize_c\0").unwrap();
|
||||
let ai_template_names_fn = *la.get(b"hcie_ai_template_names_c\0").unwrap();
|
||||
let ai_template_get_fn = *la.get(b"hcie_ai_template_get_c\0").unwrap();
|
||||
let ai_template_search_fn = *la.get(b"hcie_ai_template_search_c\0").unwrap();
|
||||
let ai_template_execute_fn = *la.get(b"hcie_ai_template_execute_c\0").unwrap();
|
||||
let ai_execute_action_fn = *la.get(b"hcie_ai_execute_action_c\0").unwrap();
|
||||
let ai_free_buffer_fn = *la.get(b"free_buffer_c\0").unwrap();
|
||||
let vision_background_removal_fn = *lv.get(b"hcie_vision_background_removal_c\0").unwrap();
|
||||
let vision_object_segment_fn = *lv.get(b"hcie_vision_object_segment_c\0").unwrap();
|
||||
let vision_super_resolution_fn = *lv.get(b"hcie_vision_super_resolution_c\0").unwrap();
|
||||
let vision_inpaint_fn = *lv.get(b"hcie_vision_inpaint_c\0").unwrap();
|
||||
let vision_build_polygon_mask_fn = *lv.get(b"hcie_vision_build_polygon_mask_c\0").unwrap();
|
||||
let vision_build_stroke_mask_fn = *lv.get(b"hcie_vision_build_stroke_mask_c\0").unwrap();
|
||||
let vision_set_backend_fn: unsafe extern "C" fn(u8) = *lv.get(b"hcie_vision_set_backend_c\0").unwrap();
|
||||
let vision_free_buffer_fn = *lv.get(b"free_buffer_c\0").unwrap();
|
||||
// Mirror the current backend preference into the freshly loaded plugin.
|
||||
let backend = VISION_BACKEND.load(Ordering::Relaxed);
|
||||
(vision_set_backend_fn)(backend);
|
||||
|
||||
AiPlugins {
|
||||
_lib_ai: la, _lib_vision: lv,
|
||||
ai_chat_complete_fn, ai_session_create_fn, ai_session_push_user_fn, ai_session_push_assistant_fn,
|
||||
ai_session_clear_fn, ai_session_destroy_fn, ai_session_serialize_fn, ai_template_names_fn,
|
||||
ai_template_get_fn, ai_template_search_fn, ai_template_execute_fn, ai_execute_action_fn, ai_free_buffer_fn,
|
||||
vision_background_removal_fn, vision_object_segment_fn, vision_super_resolution_fn,
|
||||
vision_inpaint_fn, vision_build_polygon_mask_fn, vision_build_stroke_mask_fn,
|
||||
vision_set_backend_fn, vision_free_buffer_fn,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
macro_rules! ai { ($fn:ident, $($a:expr),*) => { unsafe { (ai_plugins().$fn)($($a),*) } }; }
|
||||
|
||||
/// Set the vision backend preference. If `hcie-vision` is already loaded the
|
||||
/// change is forwarded immediately via FFI; otherwise it is applied when the
|
||||
/// plugin is first initialised.
|
||||
pub fn vision_set_backend(backend: u8) {
|
||||
VISION_BACKEND.store(backend, Ordering::Relaxed);
|
||||
if AI.get().is_some() {
|
||||
ai!(vision_set_backend_fn, backend);
|
||||
}
|
||||
}
|
||||
|
||||
pub mod ai {
|
||||
use super::*;
|
||||
macro_rules! c { ($e:expr) => { CString::new($e).unwrap() }; }
|
||||
pub fn chat_complete(config: &str, messages: &str) -> Result<Vec<u8>, String> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_chat_complete_fn, c!(config).as_ptr(), c!(messages).as_ptr(), &mut op, &mut ol) {
|
||||
let data = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Ok(data)
|
||||
} else { Err("AI chat failed".into()) }
|
||||
}
|
||||
pub fn session_create(s: &str) -> i64 { ai!(ai_session_create_fn, c!(s).as_ptr()) }
|
||||
pub fn session_push_user(sid: i64, t: &str) -> bool { ai!(ai_session_push_user_fn, sid, c!(t).as_ptr()) }
|
||||
pub fn session_push_assistant(sid: i64, t: &str) -> bool { ai!(ai_session_push_assistant_fn, sid, c!(t).as_ptr()) }
|
||||
pub fn session_clear(sid: i64) -> bool { ai!(ai_session_clear_fn, sid) }
|
||||
pub fn session_destroy(sid: i64) { ai!(ai_session_destroy_fn, sid) }
|
||||
pub fn session_serialize(sid: i64) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_session_serialize_fn, sid, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
pub fn template_names() -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(ai_template_names_fn, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
pub fn template_get(name: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0); let n = CString::new(name).ok()?;
|
||||
if ai!(ai_template_get_fn, n.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
/// # Purpose
|
||||
/// Searches for AI prompt/action templates matching a specific query or tag.
|
||||
///
|
||||
/// # Logic & Workflow
|
||||
/// 1. Converts the query string to a C-compatible null-terminated string.
|
||||
/// 2. Invokes the dynamically loaded FFI function `ai_template_search_fn`.
|
||||
/// 3. If successful, copies the returned raw byte slice into a Rust Vec and frees the FFI buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - A search query string to filter templates.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(Vec<u8>)` containing the serialized search results (JSON), or `None` if the call failed.
|
||||
///
|
||||
/// # Side Effects / Dependencies
|
||||
/// * Relies on the dynamically loaded `hcie_ai` library.
|
||||
pub fn template_search(query: &str) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let q = CString::new(query).ok()?;
|
||||
if ai!(ai_template_search_fn, q.as_ptr(), &mut op, &mut ol) {
|
||||
let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() };
|
||||
ai!(ai_free_buffer_fn, op, ol);
|
||||
Some(d)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn template_execute(name: &str, params: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let (n, j) = (CString::new(name).ok()?, CString::new(params).ok()?);
|
||||
if ai!(ai_template_execute_fn, n.as_ptr(), j.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
pub fn execute_action(json: &str) -> Option<Vec<u8>> { let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(ai_execute_action_fn, j.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(ai_free_buffer_fn, op, ol); Some(d) } else { None } }
|
||||
}
|
||||
|
||||
pub mod vision {
|
||||
use super::*;
|
||||
pub fn background_removal(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(vision_background_removal_fn, img.as_ptr(), img.len(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
pub fn object_segment(img: &[u8], w: u32, h: u32, px: u32, py: u32, bx0: u32, by0: u32, bx1: u32, by1: u32, model_path: &str) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
let mp = CString::new(model_path).unwrap_or_default();
|
||||
if ai!(vision_object_segment_fn, img.as_ptr(), img.len(), w, h, px, py, bx0, by0, bx1, by1, mp.as_ptr(), &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
pub fn super_resolution(img: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0);
|
||||
if ai!(vision_super_resolution_fn, img.as_ptr(), img.len(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
pub fn inpaint(pixels: &mut [u8], w: u32, h: u32, mask: &[u8], r: i32) -> bool { ai!(vision_inpaint_fn, pixels.as_mut_ptr(), pixels.len(), w, h, mask.as_ptr(), mask.len(), r) }
|
||||
pub fn build_polygon_mask(json: &str, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(vision_build_polygon_mask_fn, j.as_ptr(), w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
pub fn build_stroke_mask(json: &str, sel: &[u8], r: f32, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
let (mut op, mut ol) = (std::ptr::null_mut(), 0); let j = CString::new(json).ok()?;
|
||||
if ai!(vision_build_stroke_mask_fn, j.as_ptr(), sel.as_ptr(), sel.len(), r, w, h, &mut op, &mut ol) { let d = unsafe { std::slice::from_raw_parts(op, ol).to_vec() }; ai!(vision_free_buffer_fn, op, ol); Some(d) } else { None }
|
||||
}
|
||||
/// Forward the CPU/GPU backend preference to the dynamic `hcie-vision` plugin.
|
||||
pub fn set_backend(backend: u8) {
|
||||
crate::dynamic_loader::vision_set_backend(backend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! C FFI layer for HCIE Engine.
|
||||
//!
|
||||
//! Exposes `extern "C"` functions that the Qt6 GUI (or any foreign language)
|
||||
//! can call through a C-compatible ABI. Each function wraps a single Engine
|
||||
//! method call.
|
||||
//!
|
||||
//! RULE: This module is LOCKED after creation. Do not modify without unlock.sh.
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
|
||||
use crate::Engine;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Handle type
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Opaque handle wrapping a boxed Engine. The GUI stores this pointer and
|
||||
/// passes it to every FFI call.
|
||||
#[repr(C)]
|
||||
pub struct EngineHandle {
|
||||
engine: Box<Engine>,
|
||||
}
|
||||
|
||||
/// Helper to extract a safe mutable reference from the opaque handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// The caller must ensure `handle` is a valid pointer obtained from
|
||||
/// `hcie_engine_create`.
|
||||
unsafe fn get_engine<'a>(handle: *mut EngineHandle) -> Option<&'a mut Engine> {
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(&mut (*handle).engine)
|
||||
}
|
||||
|
||||
/// Helper to extract the engine and execute a closure, returning the closure's result.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same invariants as `get_engine`.
|
||||
unsafe fn with_engine<F, R>(handle: *mut EngineHandle, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut Engine) -> R,
|
||||
{
|
||||
get_engine(handle).map(f)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Document lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Create a new engine instance with a transparent canvas of the given size.
|
||||
///
|
||||
/// Returns a non-null handle on success, null on allocation failure.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn hcie_engine_create(w: u32, h: u32) -> *mut EngineHandle {
|
||||
let engine = Engine::new(w, h);
|
||||
let handle = Box::new(EngineHandle { engine: Box::new(engine) });
|
||||
Box::into_raw(handle)
|
||||
}
|
||||
|
||||
/// Destroy an engine instance and free its memory.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `hcie_engine_create` that has not
|
||||
/// already been destroyed. Passing null is a no-op.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_destroy(handle: *mut EngineHandle) {
|
||||
if !handle.is_null() {
|
||||
drop(Box::from_raw(handle));
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Canvas queries
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Get canvas width in pixels. Returns 0 if handle is null.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_canvas_width(handle: *mut EngineHandle) -> u32 {
|
||||
with_engine(handle, |e| e.canvas_width()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get canvas height in pixels. Returns 0 if handle is null.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_canvas_height(handle: *mut EngineHandle) -> u32 {
|
||||
with_engine(handle, |e| e.canvas_height()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Copy the composite RGBA pixel buffer into `out_buf`.
|
||||
///
|
||||
/// `out_len` must be at least `w * h * 4` bytes. Returns the number of bytes
|
||||
/// written, or 0 on failure.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_composite_pixels(
|
||||
handle: *mut EngineHandle,
|
||||
out_buf: *mut u8,
|
||||
out_len: usize,
|
||||
) -> i32 {
|
||||
with_engine(handle, |e| {
|
||||
let pixels = e.get_composite_pixels();
|
||||
let copy_len = pixels.len().min(out_len);
|
||||
std::ptr::copy_nonoverlapping(pixels.as_ptr(), out_buf, copy_len);
|
||||
copy_len as i32
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Layer management
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Add a new raster layer with the given name. Returns the layer ID.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_add_layer(
|
||||
handle: *mut EngineHandle,
|
||||
name: *const c_char,
|
||||
) -> u64 {
|
||||
let name_str = if name.is_null() {
|
||||
"Layer".to_string()
|
||||
} else {
|
||||
CStr::from_ptr(name).to_string_lossy().into_owned()
|
||||
};
|
||||
with_engine(handle, |e| e.add_layer(&name_str)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Delete a layer by ID.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_delete_layer(handle: *mut EngineHandle, id: u64) {
|
||||
with_engine(handle, |e| e.delete_layer(id));
|
||||
}
|
||||
|
||||
/// Set the active (selected) layer by ID.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_active_layer(handle: *mut EngineHandle, id: u64) {
|
||||
with_engine(handle, |e| e.set_active_layer(id));
|
||||
}
|
||||
|
||||
/// Get the active layer ID. Returns 0 if no active layer.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_active_layer_id(handle: *mut EngineHandle) -> u64 {
|
||||
with_engine(handle, |e| e.active_layer_id()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the number of layers.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_layer_count(handle: *mut EngineHandle) -> usize {
|
||||
with_engine(handle, |e| e.get_layer_count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Set layer visibility.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_layer_visible(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
visible: bool,
|
||||
) {
|
||||
with_engine(handle, |e| e.set_layer_visible(id, visible));
|
||||
}
|
||||
|
||||
/// Set layer opacity (0.0 - 1.0).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_layer_opacity(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
opacity: f32,
|
||||
) {
|
||||
with_engine(handle, |e| e.set_layer_opacity(id, opacity));
|
||||
}
|
||||
|
||||
/// Set layer blend mode (encoded as integer index).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_layer_blend_mode(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
mode: i32,
|
||||
) {
|
||||
with_engine(handle, |e| {
|
||||
let modes = crate::BlendMode::ALL;
|
||||
if (mode as usize) < modes.len() {
|
||||
e.set_layer_blend_mode(id, modes[mode as usize]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set layer locked state.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_layer_locked(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
locked: bool,
|
||||
) {
|
||||
with_engine(handle, |e| e.set_layer_locked(id, locked));
|
||||
}
|
||||
|
||||
/// Move layer to a new position in the stack.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_move_layer(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
new_index: i32,
|
||||
) {
|
||||
with_engine(handle, |e| e.move_layer(id, new_index as usize));
|
||||
}
|
||||
|
||||
/// Merge the active layer down into the layer below.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_merge_down(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.merge_down(); });
|
||||
}
|
||||
|
||||
/// Flatten all layers into a single background.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_flatten_image(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.flatten_image(); });
|
||||
}
|
||||
|
||||
/// Clear the active layer's pixels (make transparent).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_clear_active_layer(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.clear_active_layer_pixels(); });
|
||||
}
|
||||
|
||||
/// Rename a layer.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_rename_layer(
|
||||
handle: *mut EngineHandle,
|
||||
id: u64,
|
||||
name: *const c_char,
|
||||
) {
|
||||
if name.is_null() { return; }
|
||||
let name_str = CStr::from_ptr(name).to_string_lossy().into_owned();
|
||||
with_engine(handle, |e| { e.rename_layer(id, &name_str); });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Drawing (stroke API)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Begin a new brush stroke at the given canvas position.
|
||||
///
|
||||
/// This snapshots the layer pixels for undo and initializes stroke caches.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_begin_stroke(
|
||||
handle: *mut EngineHandle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
_pressure: f32,
|
||||
) {
|
||||
with_engine(handle, |e| {
|
||||
let layer_id = e.active_layer_id();
|
||||
e.begin_stroke(layer_id, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
/// Continue a brush stroke to the given position with pressure.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_stroke_to(
|
||||
handle: *mut EngineHandle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
pressure: f32,
|
||||
) {
|
||||
with_engine(handle, |e| {
|
||||
let layer_id = e.active_layer_id();
|
||||
e.stroke_to(layer_id, x, y, pressure);
|
||||
});
|
||||
}
|
||||
|
||||
/// End the current brush stroke and commit an undo snapshot.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_end_stroke(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| {
|
||||
let layer_id = e.active_layer_id();
|
||||
e.end_stroke(layer_id);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Selection
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_select_all(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_all(); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_deselect(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_clear(); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_invert_selection(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.selection_invert(); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_grow_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_grow(amount); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_shrink_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_shrink(amount); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_feather_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_feather(amount as f32); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_erode_selection(handle: *mut EngineHandle, _amount: u32) {
|
||||
// Erode is shrink + feather combined
|
||||
with_engine(handle, |e| {
|
||||
e.selection_shrink(_amount);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_fade_selection(handle: *mut EngineHandle, amount: u32) {
|
||||
with_engine(handle, |e| { e.selection_feather(amount as f32); });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Filters
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Apply a named filter with JSON parameters.
|
||||
///
|
||||
/// `filter_name` and `params_json` must be null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_apply_filter(
|
||||
handle: *mut EngineHandle,
|
||||
filter_name: *const c_char,
|
||||
params_json: *const c_char,
|
||||
) {
|
||||
if filter_name.is_null() { return; }
|
||||
let name = CStr::from_ptr(filter_name).to_string_lossy().into_owned();
|
||||
let params_str = if params_json.is_null() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
CStr::from_ptr(params_json).to_string_lossy().into_owned()
|
||||
};
|
||||
let params: serde_json::Value = serde_json::from_str(¶ms_str).unwrap_or(serde_json::json!({}));
|
||||
|
||||
// Internal bridge commands used by the Qt6 frontend to set brush / color
|
||||
// through the single FFI entry point until dedicated functions are added.
|
||||
with_engine(handle, |e| {
|
||||
match name.as_str() {
|
||||
"__internal_set_brush" => {
|
||||
let mut tip = e.current_tip.clone();
|
||||
if let Some(sz) = params["size"].as_f64() {
|
||||
tip.size = sz as f32;
|
||||
}
|
||||
if let Some(o) = params["opacity"].as_f64() {
|
||||
tip.opacity = o as f32;
|
||||
}
|
||||
e.set_brush_tip(tip);
|
||||
}
|
||||
"__internal_set_color" => {
|
||||
if let Some(hex) = params["hex"].as_str() {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() >= 6 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||
let a = if hex.len() >= 8 {
|
||||
u8::from_str_radix(&hex[6..8], 16).unwrap_or(255)
|
||||
} else {
|
||||
255
|
||||
};
|
||||
e.set_color([r, g, b, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
e.apply_filter(&name, params);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// History (undo/redo)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_undo(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.undo(); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_redo(handle: *mut EngineHandle) {
|
||||
with_engine(handle, |e| { e.redo(); });
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_can_undo(handle: *mut EngineHandle) -> bool {
|
||||
with_engine(handle, |e| e.can_undo()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_can_redo(handle: *mut EngineHandle) -> bool {
|
||||
with_engine(handle, |e| e.can_redo()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_history_current(handle: *mut EngineHandle) -> i32 {
|
||||
with_engine(handle, |e| e.history_current()).unwrap_or(-1)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_history_len(handle: *mut EngineHandle) -> usize {
|
||||
with_engine(handle, |e| e.history_len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Jump to a specific history step by index. Pass -1 for original image.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_jump_to_history(handle: *mut EngineHandle, idx: i32) {
|
||||
with_engine(handle, |e| { e.jump_to_history(idx); });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Transform
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_rotate_canvas(handle: *mut EngineHandle, degrees: f32) {
|
||||
with_engine(handle, |e| {
|
||||
let layer_id = e.active_layer_id();
|
||||
e.rotate_layer(layer_id, degrees);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_flip_canvas(handle: *mut EngineHandle, horizontal: bool) {
|
||||
with_engine(handle, |e| {
|
||||
let layer_id = e.active_layer_id();
|
||||
if horizontal {
|
||||
e.flip_layer_horizontal(layer_id);
|
||||
} else {
|
||||
e.flip_layer_vertical(layer_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Tool
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Set the active drawing tool by numeric ID.
|
||||
///
|
||||
/// Maps to `hcie_protocol::tools::Tool` discriminant.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle, tool_id: i32) {
|
||||
with_engine(handle, |e| {
|
||||
use crate::Tool;
|
||||
let tool = match tool_id {
|
||||
0 => Tool::Eyedropper,
|
||||
1 => Tool::Pen,
|
||||
2 => Tool::Brush,
|
||||
3 => Tool::Eraser,
|
||||
4 => Tool::Spray,
|
||||
5 => Tool::FloodFill,
|
||||
6 => Tool::MagicWand,
|
||||
7 => Tool::Select,
|
||||
8 => Tool::Lasso,
|
||||
9 => Tool::PolygonSelect,
|
||||
10 => Tool::Move,
|
||||
11 => Tool::Crop,
|
||||
12 => Tool::Text,
|
||||
13 => Tool::Gradient,
|
||||
14 => Tool::VectorSelect,
|
||||
15 => Tool::VectorLine,
|
||||
16 => Tool::VectorRect,
|
||||
17 => Tool::VectorCircle,
|
||||
18 => Tool::RedEyeRemoval,
|
||||
19 => Tool::SpotRemoval,
|
||||
20 => Tool::SmartPatch,
|
||||
21 => Tool::AiObjectRemoval,
|
||||
22 => Tool::SmartSelect,
|
||||
23 => Tool::VisionSelect,
|
||||
_ => Tool::Brush,
|
||||
};
|
||||
e.set_tool(tool);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Modified flag
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_is_modified(handle: *mut EngineHandle) -> bool {
|
||||
with_engine(handle, |e| e.is_modified()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn hcie_engine_set_modified(handle: *mut EngineHandle, modified: bool) {
|
||||
with_engine(handle, |e| { e.set_modified(modified); });
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Layer property change operations for the HCIE engine.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Centralises all layer property mutations that do **not** change pixel data
|
||||
//! but do change the global composite: opacity, visibility, blend mode,
|
||||
//! parent/child grouping, layer order, clipping masks, and the active layer.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! - Each setter mutates the requested `Layer` field.
|
||||
//! - It marks `document.composite_dirty` and, when relevant,
|
||||
//! `document.dirty_bounds = full_canvas` so the next render recomposites.
|
||||
//! - It sets `Engine::below_cache_dirty` instead of destroying the tile cache,
|
||||
//! because tile pixels are unchanged.
|
||||
//! - Structural changes (`delete_layer`, `add_layer`, `undo`, `redo`) still clear
|
||||
//! the tile cache because layer count/content changed.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Mutates `Engine.document`, `Engine.below_cache_dirty`, and (for structural
|
||||
//! changes) `Engine.tile_layers` / `Engine.below_cache`.
|
||||
|
||||
use hcie_protocol::{BlendMode, LayerType};
|
||||
use crate::Engine;
|
||||
|
||||
impl Engine {
|
||||
/// Removes all layers and resets layer-dependent caches without marking
|
||||
/// the document as modified.
|
||||
pub fn clear_all_layers(&mut self) {
|
||||
self.document.layers.clear();
|
||||
self.document.clear_history();
|
||||
self.document.active_layer = 0;
|
||||
self.tile_layers.clear();
|
||||
self.svg_sources.clear();
|
||||
self.raw_pixel_backup.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
|
||||
pub fn delete_layer(&mut self, id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||||
self.document.delete_layer(idx);
|
||||
self.tile_layers.clear();
|
||||
self.svg_sources.remove(&id);
|
||||
for layer in &mut self.document.layers {
|
||||
layer.dirty = true;
|
||||
}
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
self.composite_scratch = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_layer(&mut self, name: &str) -> u64 {
|
||||
let id = self.document.add_layer(name);
|
||||
self.tile_layers.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_group(&mut self, name: &str) -> u64 {
|
||||
let id = self.document.add_layer(name);
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.layer_type = hcie_protocol::LayerType::Group;
|
||||
}
|
||||
self.tile_layers.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_raw_layer(&mut self, layer: hcie_protocol::Layer) {
|
||||
self.document.layers.push(layer);
|
||||
self.document.composite_dirty = true;
|
||||
self.tile_layers.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn set_layer_parent(&mut self, id: u64, parent_id: Option<u64>) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.parent_id = parent_id;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
// Parent hierarchy changes the global composite, but the pixel data
|
||||
// of every layer is unchanged. The tile cache therefore remains valid;
|
||||
// only the compositing order/visibility semantics changed.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
/// Switch the active (selected) layer by ID.
|
||||
pub fn set_active_layer(&mut self, id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||||
log::trace!(
|
||||
"[set_active_layer] switching from layer idx={} to idx={} (id={})",
|
||||
self.document.active_layer, idx, id
|
||||
);
|
||||
self.document.set_active_layer(idx);
|
||||
// Active layer change invalidates the below-layer composite cache
|
||||
// (it was built for the previous active layer's position).
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_layer_opacity(&mut self, id: u64, opacity: f32) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.opacity = opacity.clamp(0.0, 1.0);
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
// Opacity only affects how this layer is blended into the composite.
|
||||
// Layer pixel data and tile storage are unchanged.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn set_layer_visible(&mut self, id: u64, visible: bool) {
|
||||
log::trace!("[set_layer_visible] ===== START id={} visible={} =====", id, visible);
|
||||
log::trace!(
|
||||
"[set_layer_visible] BEFORE: active_layer={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}",
|
||||
self.document.active_layer,
|
||||
self.document.composite_dirty,
|
||||
self.document.dirty_bounds,
|
||||
self.below_cache.is_some(),
|
||||
self.below_cache_active_idx,
|
||||
self.tile_layers.len()
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
log::trace!(
|
||||
"[set_layer_visible] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?} pixels_len={}",
|
||||
i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode, l.pixels.len()
|
||||
);
|
||||
}
|
||||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.set_layer_visible(idx, visible);
|
||||
// Visibility only changes whether this layer contributes to the
|
||||
// composite; the tile cache pixels are still correct.
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
log::trace!(
|
||||
"[set_layer_visible] AFTER: tile_layers_len={}, composite_dirty={}, dirty_bounds={:?}, below_cache_dirty={}",
|
||||
self.tile_layers.len(),
|
||||
self.document.composite_dirty,
|
||||
self.document.dirty_bounds,
|
||||
self.below_cache_dirty
|
||||
);
|
||||
for (i, tl) in self.tile_layers.iter().enumerate() {
|
||||
let tile_count = tl.as_ref().map(|t| t.tile_count()).unwrap_or(0);
|
||||
log::trace!("[set_layer_visible] tile_layers[{}] tile_count={}", i, tile_count);
|
||||
}
|
||||
log::trace!("[set_layer_visible] ===== END id={} =====", id);
|
||||
} else {
|
||||
log::trace!("[set_layer_visible] layer id={} NOT FOUND", id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_layer_locked(&mut self, id: u64, locked: bool) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.locked = locked;
|
||||
self.document.modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_layer_locked(&self, id: u64) -> bool {
|
||||
if let Some(l) = self.document.get_layer_by_id(id) {
|
||||
l.locked
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the layer with the given ID can receive pixel edits.
|
||||
///
|
||||
/// Group and Mask layers are not editable because they do not own pixel
|
||||
/// buffers. Locked layers are also not editable.
|
||||
pub fn is_layer_editable(&self, id: u64) -> bool {
|
||||
let Some(layer) = self.document.get_layer_by_id(id) else { return false };
|
||||
if layer.locked { return false; }
|
||||
!matches!(layer.layer_type, LayerType::Group | LayerType::Mask)
|
||||
}
|
||||
|
||||
/// Returns whether the currently active layer can receive pixel edits.
|
||||
pub fn active_layer_is_editable(&self) -> bool {
|
||||
self.document.active_layer()
|
||||
.map(|l| self.is_layer_editable(l.id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_layer_blend_mode(&mut self, id: u64, mode: BlendMode) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.blend_mode = mode;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
// Blend mode changes only affect compositing math, not pixel data.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn move_layer(&mut self, id: u64, new_index: usize) {
|
||||
if let Some(from) = self.document.layer_index_by_id(id) {
|
||||
self.document.move_layer(from, new_index);
|
||||
}
|
||||
// Layer order changes the composite stacking, but each layer's pixel
|
||||
// data and tile cache remain valid.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn toggle_group_collapsed(&mut self, id: u64) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.collapsed = !l.collapsed;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
// Group collapse only changes visibility semantics; tile pixels are
|
||||
// unchanged.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn set_clipping_mask(&mut self, id: u64, enabled: bool) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.clipping_mask = enabled;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
// Clipping-mask flag changes compositing semantics but does not alter
|
||||
// layer pixel data. Tile storage remains valid.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
if self.document.can_undo() {
|
||||
self.document.undo();
|
||||
self.tile_layers.clear();
|
||||
self.document.composite_dirty = true;
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if self.document.can_redo() {
|
||||
self.document.redo();
|
||||
self.tile_layers.clear();
|
||||
self.document.composite_dirty = true;
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jump_to_history(&mut self, idx: i32) {
|
||||
self.document.jump_to_history(idx);
|
||||
self.tile_layers.clear();
|
||||
self.document.composite_dirty = true;
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool { self.document.can_undo() }
|
||||
pub fn can_redo(&self) -> bool { self.document.can_redo() }
|
||||
pub fn history_len(&self) -> usize { self.document.history_len() }
|
||||
pub fn history_current(&self) -> i32 { self.document.history_current() }
|
||||
pub fn history_description(&self, idx: usize) -> Option<String> {
|
||||
self.document.history_description(idx)
|
||||
}
|
||||
|
||||
/// Merge the active layer down into the layer immediately below it.
|
||||
///
|
||||
/// **Purpose:** Combines the active layer's pixel content with the layer
|
||||
/// beneath it, respecting the active layer's blend mode and opacity. The
|
||||
/// active layer is removed after merging.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// 1. Find the active layer index and the layer below it (index - 1).
|
||||
/// 2. Composite the active layer onto the below layer using
|
||||
/// `hcie_composite::composite_layers_region` with only those two layers.
|
||||
/// 3. Copy the composited result into the below layer's pixel buffer.
|
||||
/// 4. Remove the active layer from the document.
|
||||
/// 5. Push an undo snapshot for the below layer.
|
||||
/// 6. Invalidate all caches.
|
||||
///
|
||||
/// **Returns:** `true` if the merge succeeded, `false` if there is no layer
|
||||
/// below the active layer.
|
||||
///
|
||||
/// **Side Effects:** Removes the active layer, modifies the below layer's
|
||||
/// pixels, clears tile caches, marks composite dirty.
|
||||
pub fn merge_down(&mut self) -> bool {
|
||||
let active_idx = self.document.active_layer;
|
||||
if active_idx == 0 || active_idx >= self.document.layers.len() {
|
||||
return false;
|
||||
}
|
||||
let below_idx = active_idx - 1;
|
||||
|
||||
let cw = self.document.canvas_width;
|
||||
let ch = self.document.canvas_height;
|
||||
|
||||
// Snapshot the below layer before modification
|
||||
let before_pixels = self.document.layers[below_idx].pixels.clone();
|
||||
|
||||
// Build a two-layer slice: [below, active] and composite them
|
||||
let active_id = self.document.layers[active_idx].id;
|
||||
let below_id = self.document.layers[below_idx].id;
|
||||
|
||||
{
|
||||
let layers_slice: Vec<hcie_protocol::Layer> = vec![
|
||||
self.document.layers[below_idx].clone(),
|
||||
self.document.layers[active_idx].clone(),
|
||||
];
|
||||
let composited = crate::dynamic_loader::composite_layers(&layers_slice, cw, ch);
|
||||
if let Some(below) = self.document.get_layer_by_id_mut(below_id) {
|
||||
below.pixels = composited;
|
||||
below.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the active layer
|
||||
self.delete_layer(active_id);
|
||||
|
||||
// Push undo snapshot for the below layer
|
||||
if let Some(idx) = self.document.layer_index_by_id(below_id) {
|
||||
self.document.push_draw_snapshot(idx, before_pixels, None, "Merge Down".to_string());
|
||||
}
|
||||
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.tile_layers.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Flatten all visible layers into a single background layer.
|
||||
///
|
||||
/// **Purpose:** Composites every visible layer in the document into one
|
||||
/// raster layer. Invisible layers are discarded. The resulting single
|
||||
/// layer is named "Background" and becomes the active layer.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// 1. Composite all visible layers into a single RGBA buffer.
|
||||
/// 2. Replace the document's layer list with a single new layer containing
|
||||
/// the composited result.
|
||||
/// 3. Invalidate all caches.
|
||||
///
|
||||
/// **Side Effects:** Removes all layers, creates one new layer, clears
|
||||
/// tile caches, marks composite dirty. History is cleared since the
|
||||
/// undo system cannot represent a multi-layer → single-layer transition.
|
||||
pub fn flatten_image(&mut self) {
|
||||
let cw = self.document.canvas_width;
|
||||
let ch = self.document.canvas_height;
|
||||
|
||||
let composited = crate::dynamic_loader::composite_layers(&self.document.layers, cw, ch);
|
||||
|
||||
self.document.layers.clear();
|
||||
let id = self.document.add_layer("Background");
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||||
layer.pixels = composited;
|
||||
layer.dirty = true;
|
||||
}
|
||||
self.document.active_layer = 0;
|
||||
|
||||
self.document.clear_history();
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.tile_layers.clear();
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
//! Partial / dirty-region compositing for the HCIE engine.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Contains the functions that turn layer pixel data into a flat composite
|
||||
//! image: `render_composite_region()` for GUI partial updates,
|
||||
//! `get_composite_pixels()` for full-canvas exports, and the incremental
|
||||
//! tile synchroniser `sync_dirty_tiles()`. These are the most performance-
|
||||
//! sensitive composite paths.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Render dirty vector layers into their pixel buffers.
|
||||
//! 2. Apply layer effects/styles when `effects_dirty` is set, caching the
|
||||
//! result in `layer.effects_cache`.
|
||||
//! 3. Sync the sparse tile cache for any dirty layers (`sync_dirty_tiles`).
|
||||
//! 4. Composite either the dirty sub-region or the full canvas using the
|
||||
//! tiled compositor, optionally reusing the `below_cache` snapshot of
|
||||
//! layers below the active layer.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Mutates layer pixels, `tile_layers`, `composite_scratch`, `raw_pixel_backup`,
|
||||
//! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the
|
||||
//! dynamic `tiled::composite_tiled_into` / `composite_layers` compositors.
|
||||
|
||||
use hcie_tile::TiledLayer;
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::{composite_layers, tiled};
|
||||
use crate::dynamic_loader::vector::render_vector_shapes;
|
||||
|
||||
impl Engine {
|
||||
/// **Purpose:**
|
||||
/// Computes the flat composite RGBA pixel buffer of all layers in the document.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// 1. Renders vector shapes for dirty vector layers.
|
||||
/// 2. Applies layer effects/styles if they are dirty, caching the result.
|
||||
/// 3. Synchronizes `self.tile_layers` for any dirty layers.
|
||||
/// 4. Clears dirty flags.
|
||||
/// 5. Blends all layers into a flat caller-allocated buffer.
|
||||
pub fn get_composite_pixels(&mut self) -> Vec<u8> {
|
||||
log::trace!("[get_composite_pixels] ===== START =====");
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
log::trace!(
|
||||
"[get_composite_pixels] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?}",
|
||||
i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode
|
||||
);
|
||||
}
|
||||
self.apply_effects_and_sync_tiles();
|
||||
self.document.clear_dirty();
|
||||
let output = composite_layers(
|
||||
&self.document.layers,
|
||||
self.document.canvas_width,
|
||||
self.document.canvas_height,
|
||||
);
|
||||
let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
||||
let total_pixels = output.len() / 4;
|
||||
log::trace!(
|
||||
"[get_composite_pixels] completed full composite: size={} bytes, non_zero_alpha={}/{} ({}%), first_pixel={:?}",
|
||||
output.len(),
|
||||
non_zero_alpha,
|
||||
total_pixels,
|
||||
if total_pixels > 0 { non_zero_alpha * 100 / total_pixels } else { 0 },
|
||||
if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] }
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let opaque_count = l.pixels.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
||||
let total = l.pixels.len() / 4;
|
||||
log::trace!(
|
||||
"[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)",
|
||||
i, l.id, l.name, l.visible, l.pixels.len(),
|
||||
opaque_count, total,
|
||||
if total > 0 { opaque_count * 100 / total } else { 0 }
|
||||
);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn render_composite(&mut self) -> Vec<u8> {
|
||||
self.get_composite_pixels()
|
||||
}
|
||||
|
||||
/// Composite only the dirty region into a pooled internal scratch buffer.
|
||||
/// Returns the dirty bounds `[x0, y0, x1, y1]` that were updated,
|
||||
/// or `None` if nothing was dirty (full composite needed).
|
||||
pub fn render_composite_region(&mut self) -> (Option<[u32; 4]>, *const u8, usize) {
|
||||
let has_dirty = self.document.composite_dirty;
|
||||
let dirty = self.document.dirty_bounds;
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
|
||||
if !has_dirty && dirty.is_none() {
|
||||
log::trace!("[render_composite_region] nothing dirty — returning None");
|
||||
return (None, std::ptr::null(), 0);
|
||||
}
|
||||
|
||||
if has_dirty && dirty.is_none() {
|
||||
// composite_dirty was set (e.g., after loading/importing) but no layer-level
|
||||
// dirty_bounds exist. Force a full-canvas composite so the caller sees content
|
||||
// instead of an empty/null region.
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
}
|
||||
log::trace!(
|
||||
"[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====",
|
||||
has_dirty, dirty
|
||||
);
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
let buf_size = (w * h * 4) as usize;
|
||||
|
||||
self.apply_effects_and_sync_tiles();
|
||||
|
||||
let (x0, y0, x1, y1) = match dirty {
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
(x0.min(w), y0.min(h), x1.min(w), y1.min(h))
|
||||
}
|
||||
None => (0, 0, w, h),
|
||||
};
|
||||
|
||||
// Now safe to borrow composite_scratch — no more mutable calls to self
|
||||
// that could touch this buffer until we return.
|
||||
if self.composite_scratch.as_ref().map_or(true, |b| b.len() != buf_size) {
|
||||
self.composite_scratch = Some(vec![0u8; buf_size]);
|
||||
}
|
||||
let buf = self.composite_scratch.as_mut().unwrap();
|
||||
|
||||
if x1 > x0 && y1 > y0 {
|
||||
let wu = w as usize;
|
||||
let x0u = x0 as usize;
|
||||
|
||||
let active_idx = self.document.active_layer;
|
||||
let cache_valid = !self.below_cache_dirty
|
||||
&& self.below_cache.is_some()
|
||||
&& self.below_cache_active_idx == Some(active_idx)
|
||||
&& active_idx > 0;
|
||||
|
||||
log::trace!(
|
||||
"[render_composite_region] BEFORE composite: active_idx={}, cache_valid={}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}, dirty_rect=[{},{},{},{}]",
|
||||
active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let tc = self.tile_layers.get(i).and_then(|t| t.as_ref().map(|tl| tl.tile_count())).unwrap_or(0);
|
||||
log::trace!(
|
||||
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
||||
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
||||
);
|
||||
}
|
||||
|
||||
if cache_valid {
|
||||
let cache = self.below_cache.as_ref().unwrap();
|
||||
let below_non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top",
|
||||
below_non_zero, active_idx, self.document.layers.len()
|
||||
);
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
if end <= cache.len() && end <= buf.len() {
|
||||
buf[start..end].copy_from_slice(&cache[start..end]);
|
||||
}
|
||||
}
|
||||
let tile_start = active_idx.min(self.tile_layers.len());
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[active_idx..],
|
||||
&self.tile_layers[tile_start..],
|
||||
w, h,
|
||||
x0, y0, x1, y1,
|
||||
buf,
|
||||
);
|
||||
log::trace!(
|
||||
"[render_composite_region] cache hit DONE: {} above layers composited, dirty_rect=[{},{},{},{}]",
|
||||
self.document.layers.len() - active_idx, x0, y0, x1, y1
|
||||
);
|
||||
} else {
|
||||
log::trace!(
|
||||
"[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]",
|
||||
self.document.layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
buf[start..end].fill(0);
|
||||
}
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers,
|
||||
&self.tile_layers,
|
||||
w, h,
|
||||
x0, y0, x1, y1,
|
||||
buf,
|
||||
);
|
||||
log::trace!(
|
||||
"[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]",
|
||||
x0, y0, x1, y1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.document.clear_dirty();
|
||||
let ptr = buf.as_ptr();
|
||||
(Some([x0, y0, x1, y1]), ptr, buf_size)
|
||||
}
|
||||
|
||||
/// Shared preprocessing for full and partial compositing:
|
||||
/// - render dirty vector shapes,
|
||||
/// - apply dirty layer effects/styles,
|
||||
/// - sync the sparse tile cache.
|
||||
fn apply_effects_and_sync_tiles(&mut self) {
|
||||
// Render vector shapes before compositing — only dirty vector layers
|
||||
for layer in &mut self.document.layers {
|
||||
if let hcie_protocol::LayerData::Vector { shapes: _ } = &layer.data {
|
||||
if layer.dirty {
|
||||
layer.pixels.fill(0);
|
||||
render_vector_shapes(layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Effects pipeline (two-pass) ─────────────────────────────────────────────
|
||||
// Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
|
||||
// have a backup.
|
||||
{
|
||||
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self.document.layers.iter()
|
||||
.filter(|l| {
|
||||
(!l.effects.is_empty() || !l.styles.is_empty())
|
||||
&& !l.pixels.is_empty()
|
||||
&& !self.raw_pixel_backup.contains_key(&l.id)
|
||||
})
|
||||
.map(|l| (l.id, l.pixels.clone()))
|
||||
.collect();
|
||||
for (id, pixels) in ids_needing_backup {
|
||||
self.raw_pixel_backup.insert(id, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Apply layer effects. Restores from backup first so that each
|
||||
// slider edit always applies on top of the original untouched pixels.
|
||||
for layer in &mut self.document.layers {
|
||||
if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
|
||||
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if let Some(raw) = self.raw_pixel_backup.get(&layer.id) {
|
||||
if raw.len() == layer.pixels.len() {
|
||||
layer.pixels.copy_from_slice(raw);
|
||||
}
|
||||
}
|
||||
let mut effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter()
|
||||
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
|
||||
.collect();
|
||||
effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
||||
if effects.is_empty() { continue; }
|
||||
let processed = hcie_fx::apply_layer_effects(
|
||||
&layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&effects,
|
||||
layer.fill_opacity,
|
||||
);
|
||||
*layer.effects_cache.lock().unwrap() = Some(hcie_protocol::LayerEffects {
|
||||
rendered: processed.clone(),
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
layer.pixels = processed;
|
||||
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.sync_dirty_tiles();
|
||||
}
|
||||
|
||||
/// Incremental tile cache update.
|
||||
/// For dirty layers, only re-tile the tiles overlapping the global
|
||||
/// dirty_bounds region instead of scanning the entire 33MB dense buffer.
|
||||
/// Falls back to full from_dense() when dirty_bounds is None.
|
||||
fn sync_dirty_tiles(&mut self) {
|
||||
let count = self.document.layers.len();
|
||||
if self.tile_layers.len() < count {
|
||||
self.tile_layers.resize_with(count, || None);
|
||||
}
|
||||
let db = self.document.dirty_bounds;
|
||||
let mut synced_count = 0usize;
|
||||
for (i, layer) in self.document.layers.iter().enumerate() {
|
||||
if !layer.dirty || layer.pixels.is_empty() { continue; }
|
||||
if let Some([dx0, dy0, dx1, dy1]) = db {
|
||||
if let Some(ref mut tl) = self.tile_layers[i] {
|
||||
if tl.width() == layer.width && tl.height() == layer.height {
|
||||
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] incremental update layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
||||
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
||||
);
|
||||
} else {
|
||||
*tl = TiledLayer::from_dense(&layer.pixels, layer.width, layer.height);
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] size mismatch - full rebuild TiledLayer for layer[{}] id={} visible={} bounds=[{},{}]",
|
||||
i, layer.id, layer.visible, layer.width, layer.height
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut tl = TiledLayer::new(layer.width, layer.height);
|
||||
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
||||
self.tile_layers[i] = Some(tl);
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] created new TiledLayer for layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
||||
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.tile_layers[i] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||||
log::trace!(
|
||||
"[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)",
|
||||
i, layer.id, layer.visible
|
||||
);
|
||||
}
|
||||
synced_count += 1;
|
||||
}
|
||||
self.tile_layers.truncate(count);
|
||||
if synced_count > 0 {
|
||||
log::trace!("[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}", synced_count, db, self.tile_layers.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! 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 hcie_protocol::{BrushStyle, BrushTip};
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::{
|
||||
draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke,
|
||||
};
|
||||
|
||||
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) {
|
||||
draw_specialized_stroke(
|
||||
&mut layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&[(lx, ly, lp), (x, y, pressure)],
|
||||
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.as_ref().map(|(_, px, _)| px.as_slice()),
|
||||
tip.color_variant,
|
||||
tip.variant_amount,
|
||||
tip.density,
|
||||
);
|
||||
layer.dirty = true;
|
||||
// Effects are expensive (33MB clone + apply_layer_effects on 4K).
|
||||
// Only mark the layer as needing an effects pass if it actually
|
||||
// has any effects or editable styles.
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
let dirty_r = match tip.style {
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
||||
_ => 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.as_ref().map(|(_, px, _)| px.as_slice()),
|
||||
);
|
||||
layer.dirty = true;
|
||||
// Avoid triggering the expensive effects pass for layers that
|
||||
// have no effects/styles.
|
||||
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;
|
||||
// Only request an effects re-render if the layer actually has
|
||||
// effects or editable styles.
|
||||
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 {
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
||||
_ => 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Stroke-level cache management for the HCIE engine.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Holds the functions that set up and tear down a brush stroke, together with
|
||||
//! the `below_cache` snapshot of all layers below the active drawing layer.
|
||||
//! Keeping these in one module makes the stroke-side performance logic
|
||||
//! (mask pooling, below-layer cache reuse, sub-rect undo bounds) easier to
|
||||
//! protect from accidental regression.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! - `begin_stroke()` pools `active_stroke_mask`, snapshots the layer for undo,
|
||||
//! and either reuses or rebuilds the `below_cache` composite.
|
||||
//! - `end_stroke()` commits a sub-rect undo snapshot and retains the below-layer
|
||||
//! cache so the next stroke on the same active layer can reuse it.
|
||||
//! - `expand_stroke_bounds()` tracks the union of all brush stamps for the
|
||||
//! sub-rect snapshot.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! All functions mutate `Engine` state (`document`, `tile_layers`, pooled
|
||||
//! buffers, dirty flags). They depend on `hcie_tile::TiledLayer` and the
|
||||
//! dynamic `tiled::composite_tiled_into` compositor.
|
||||
|
||||
use hcie_protocol::LayerData;
|
||||
use hcie_tile::TiledLayer;
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::tiled;
|
||||
|
||||
impl Engine {
|
||||
/// Begin a new brush stroke on the given layer.
|
||||
///
|
||||
/// # Allocation Behaviour
|
||||
///
|
||||
/// This function performs two allocations per call:
|
||||
///
|
||||
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
|
||||
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
|
||||
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
|
||||
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
|
||||
///
|
||||
/// 2. `stroke_before` — **NOT pooled**: `layer.pixels.clone()` allocates a
|
||||
/// fresh ~33MB buffer on every call. This buffer is consumed by
|
||||
/// `push_draw_snapshot()` during `end_stroke()` and becomes owned by the
|
||||
/// undo history. Pooling would require the history crate to
|
||||
/// participate in a buffer return mechanism. See struct-level doc.
|
||||
///
|
||||
/// # Risk
|
||||
///
|
||||
/// If `active_stroke_mask` is accidentally set to `None` at any point
|
||||
/// after `end_stroke()` (e.g. a partial merge revert), the pooling
|
||||
/// optimization is silently negated and ~8MB will be allocated per stroke.
|
||||
/// The merge artifact cleanup of 2026-05-28 removed exactly this regression.
|
||||
pub fn begin_stroke(&mut self, layer_id: u64, x: f32, y: f32) {
|
||||
self.last_stroke_pos = Some((x, y, 1.0));
|
||||
self.sketch_history.clear();
|
||||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||||
let layer_size = (layer.width * layer.height) as usize;
|
||||
|
||||
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
||||
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
||||
match &mut self.active_stroke_mask {
|
||||
Some(mask) if mask.len() == layer_size => {
|
||||
// Reuse existing buffer — just zero it
|
||||
mask.fill(0);
|
||||
}
|
||||
_ => {
|
||||
// Size changed (different document/layer) — allocate fresh
|
||||
self.active_stroke_mask = Some(vec![0u8; layer_size]);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize last_stroke_bounds with brush radius around first point
|
||||
// Used for sub-rect snapshot in end_stroke (avoid 33MB clone on 4K)
|
||||
let tip = &self.current_tip;
|
||||
let dr = ((tip.size.max(2.0) * 1.3) as i32).max(1);
|
||||
let ix = x as i32;
|
||||
let iy = y as i32;
|
||||
let x0 = (ix - dr).max(0) as u32;
|
||||
let y0 = (iy - dr).max(0) as u32;
|
||||
let x1 = ((ix + dr + 1).min(layer.width as i32 - 1)).max(0) as u32;
|
||||
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
|
||||
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
||||
|
||||
let before = layer.pixels.clone();
|
||||
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||
Some(shapes.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.stroke_before = Some((layer_id, before, before_shapes));
|
||||
|
||||
// Cache selection mask once at stroke start to avoid ~8MB clone per stroke_to().
|
||||
self.cached_selection_mask = self.document.selection_mask.clone();
|
||||
|
||||
// ── Below-layer composite cache ──────────────────────────────
|
||||
self.rebuild_below_cache_if_needed();
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current brush stroke and commit a sub-rect undo snapshot.
|
||||
///
|
||||
/// Uses `last_stroke_bounds` to extract only the changed region (~100KB on a
|
||||
/// 4K canvas with a typical brush) instead of cloning the entire 33MB layer.
|
||||
/// The full `stroke_before` buffer is still used for draw_brush_stroke
|
||||
/// reference, but the history snapshot is sub-rect.
|
||||
pub fn end_stroke(&mut self, layer_id: u64) {
|
||||
log::trace!(
|
||||
"[end_stroke] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
|
||||
layer_id,
|
||||
self.stroke_before.is_some(),
|
||||
self.below_cache.is_some(),
|
||||
self.below_cache_active_idx,
|
||||
self.last_stroke_bounds
|
||||
);
|
||||
if let Some((id, before, before_shapes)) = self.stroke_before.take() {
|
||||
if id == layer_id {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some([sx0, sy0, sx1, sy1]) = self.last_stroke_bounds {
|
||||
if sx0 < sx1 && sy0 < sy1 {
|
||||
let layer = &self.document.layers[idx];
|
||||
let lw = layer.width;
|
||||
let rw = sx1 - sx0;
|
||||
let rh = sy1 - sy0;
|
||||
let rect_size = (rw * rh * 4) as usize;
|
||||
let mut before_rect = vec![0u8; rect_size];
|
||||
let mut after_rect = vec![0u8; rect_size];
|
||||
for row in 0..rh {
|
||||
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
||||
let dst_start = (row * rw * 4) as usize;
|
||||
let len = (rw * 4) as usize;
|
||||
before_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&before[src_start..src_start + len]);
|
||||
after_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&layer.pixels[src_start..src_start + len]);
|
||||
}
|
||||
self.document.push_draw_snapshot_subrect(
|
||||
idx, before_rect, after_rect,
|
||||
(sx0, sy0, sx1, sy1),
|
||||
"Brush Stroke".to_string(),
|
||||
);
|
||||
// Stroke changed layer.pixels — invalidate effects backup so
|
||||
// the next style edit captures the post-stroke raw pixels.
|
||||
self.raw_pixel_backup.remove(&layer_id);
|
||||
self.last_stroke_bounds = None;
|
||||
self.last_stroke_pos = None;
|
||||
// The active layer's pixels changed during the stroke, but
|
||||
// the layers *below* the active layer did not. Retain the
|
||||
// below_cache snapshot and mark it valid so the next
|
||||
// begin_stroke on the same active layer can reuse it.
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[end_stroke] sub-rect snapshot done, below_cache retained for reuse");
|
||||
if let Some(mask) = &mut self.active_stroke_mask {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.cached_selection_mask = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.document.push_draw_snapshot(
|
||||
idx, before, before_shapes,
|
||||
"Brush Stroke".to_string(),
|
||||
);
|
||||
// Stroke changed layer.pixels — invalidate effects backup.
|
||||
self.raw_pixel_backup.remove(&layer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_stroke_pos = None;
|
||||
self.last_stroke_bounds = None;
|
||||
// Retain the below_cache snapshot across strokes: the layers below the
|
||||
// active layer did not change, so the cache is still valid for the
|
||||
// same active layer index.
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[end_stroke] fallback cleanup done, below_cache retained for reuse");
|
||||
if let Some(mask) = &mut self.active_stroke_mask {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.cached_selection_mask = None;
|
||||
}
|
||||
|
||||
/// Expand the stroke bounds to include the given point with radius.
|
||||
pub(crate) fn expand_stroke_bounds(&mut self, x: u32, y: u32, radius: f32) {
|
||||
let r = (radius as i32).max(1);
|
||||
let x0 = (x as i32 - r).max(0) as u32;
|
||||
let y0 = (y as i32 - r).max(0) as u32;
|
||||
let x1 = ((x as i32 + r + 1).min(self.document.canvas_width as i32 - 1)).max(0) as u32;
|
||||
let y1 = ((y as i32 + r + 1).min(self.document.canvas_height as i32 - 1)).max(0) as u32;
|
||||
match &mut self.last_stroke_bounds {
|
||||
Some(b) => {
|
||||
b[0] = b[0].min(x0);
|
||||
b[1] = b[1].min(y0);
|
||||
b[2] = b[2].max(x1);
|
||||
b[3] = b[3].max(y1);
|
||||
}
|
||||
None => {
|
||||
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the `below_cache` snapshot of layers below the active layer
|
||||
/// only when necessary. Reuses the existing buffer when the active layer
|
||||
/// index matches and no layer property that affects the below-layer
|
||||
/// composite has changed since the last stroke.
|
||||
fn rebuild_below_cache_if_needed(&mut self) {
|
||||
let active_idx = self.document.active_layer;
|
||||
let cw = self.document.canvas_width;
|
||||
let ch = self.document.canvas_height;
|
||||
let buf_size = (cw * ch * 4) as usize;
|
||||
|
||||
let can_reuse = !self.below_cache_dirty
|
||||
&& self.below_cache_active_idx == Some(active_idx)
|
||||
&& active_idx > 0
|
||||
&& self.below_cache.as_ref().map_or(false, |b| b.len() == buf_size);
|
||||
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache state: active_idx={}, reuse={}, dirty={}, existing_cache={}, existing_active_idx={:?}",
|
||||
active_idx, can_reuse, self.below_cache_dirty, self.below_cache.is_some(), self.below_cache_active_idx
|
||||
);
|
||||
|
||||
if can_reuse {
|
||||
log::trace!("[begin_stroke] REUSING below_cache for active_idx={}", active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if active_idx > 0 {
|
||||
let lcount = self.document.layers.len();
|
||||
if self.tile_layers.len() < lcount {
|
||||
self.tile_layers.resize_with(lcount, || None);
|
||||
}
|
||||
let mut tiles_built = 0usize;
|
||||
for (ti, tl) in self.document.layers.iter().enumerate() {
|
||||
if ti >= active_idx { break; }
|
||||
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
||||
self.tile_layers[ti] = Some(TiledLayer::from_dense(
|
||||
&tl.pixels, tl.width, tl.height,
|
||||
));
|
||||
tiles_built += 1;
|
||||
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, tl.id);
|
||||
}
|
||||
}
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.fill(0);
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
||||
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||
log::trace!(
|
||||
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
||||
active_idx, tile_slice_len, visible_below, tiles_built
|
||||
);
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw, ch, 0, 0, cw, ch,
|
||||
&mut cache,
|
||||
);
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(), non_zero, active_idx
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
} else {
|
||||
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Automated visual check for the Leaves brush.
|
||||
//!
|
||||
//! Purpose: Renders several Leaf strokes onto a transparent canvas using the
|
||||
//! public engine API and saves the result as a PNG. Used for manual inspection
|
||||
//! and regression detection.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test leaves_check and inspect target/leaves_check.png"]
|
||||
fn leaves_brush_visual_check() {
|
||||
let mut engine = Engine::new_with_options("LeavesCheck", 512, 512, false);
|
||||
let layer_id = engine.active_layer_id();
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Leaf;
|
||||
tip.size = 70.0;
|
||||
tip.opacity = 0.95;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.35;
|
||||
tip.color_variant = true;
|
||||
tip.variant_amount = 0.5;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([40, 140, 40, 255]);
|
||||
|
||||
engine.begin_stroke(layer_id, 100.0, 400.0);
|
||||
for i in 0..=30 {
|
||||
let t = i as f32 / 30.0;
|
||||
let x = 80.0 + t * 120.0;
|
||||
let y = 400.0 - (t * 40.0).sin() * 20.0 - t * 30.0;
|
||||
engine.stroke_to(layer_id, x, y, 0.6 + 0.4 * (1.0 - t));
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
engine.begin_stroke(layer_id, 300.0, 420.0);
|
||||
for i in 0..=25 {
|
||||
let t = i as f32 / 25.0;
|
||||
let x = 300.0 + t * 10.0;
|
||||
let y = 420.0 - t * 80.0;
|
||||
engine.stroke_to(layer_id, x, y, 1.0);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("leaves_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Leaves check produced no colored pixels");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Automated visual check for the Meadow brush.
|
||||
//!
|
||||
//! Purpose: Renders several Meadow strokes onto a transparent canvas using the
|
||||
//! public engine API and saves the result as a PNG. This bypasses the GUI so
|
||||
//! brush behaviour can be inspected directly, and doubles as a regression
|
||||
//! guard for color-variant and blade geometry.
|
||||
//!
|
||||
//! Logic & Workflow:
|
||||
//! 1. Create a 512x512 white/opaque canvas.
|
||||
//! 2. Configure a Meadow brush with color_variant enabled and a green base color.
|
||||
//! 3. Draw multiple short strokes at different positions.
|
||||
//! 4. Save the composited pixels to `target/meadow_check.png`.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test meadow_check and inspect target/meadow_check.png"]
|
||||
fn meadow_brush_visual_check() {
|
||||
let mut engine = Engine::new_with_options("MeadowCheck", 512, 512, false);
|
||||
let layer_id = engine.active_layer_id();
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Meadow;
|
||||
tip.size = 80.0;
|
||||
tip.opacity = 0.95;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.40;
|
||||
tip.color_variant = true;
|
||||
tip.variant_amount = 0.6;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([50, 200, 80, 255]);
|
||||
|
||||
// Stroke 1: a short wavy tuft.
|
||||
engine.begin_stroke(layer_id, 150.0, 400.0);
|
||||
for i in 0..=30 {
|
||||
let t = i as f32 / 30.0;
|
||||
let x = 100.0 + t * 120.0;
|
||||
let y = 400.0 - (t * 60.0).sin() * 25.0 - t * 40.0;
|
||||
engine.stroke_to(layer_id, x, y, 0.6 + 0.4 * (1.0 - t));
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
// Stroke 2: vertical grass clump.
|
||||
engine.begin_stroke(layer_id, 300.0, 420.0);
|
||||
for i in 0..=25 {
|
||||
let t = i as f32 / 25.0;
|
||||
let x = 300.0 + t * 10.0;
|
||||
let y = 420.0 - t * 90.0;
|
||||
engine.stroke_to(layer_id, x, y, 1.0);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
// Stroke 3: a few scattered dabs.
|
||||
engine.begin_stroke(layer_id, 420.0, 410.0);
|
||||
engine.stroke_to(layer_id, 430.0, 360.0, 1.0);
|
||||
engine.stroke_to(layer_id, 440.0, 320.0, 0.8);
|
||||
engine.stroke_to(layer_id, 450.0, 300.0, 0.6);
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("meadow_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
// Basic sanity: output should contain non-white, non-transparent pixels.
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Meadow check produced no colored pixels");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 4K multi-layer brush-stroke performance benchmark.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Measures the per-segment cost of painting on a large (3840x2160), multi-layer
|
||||
//! document using the public `Engine` API. This benchmark is intentionally
|
||||
//! **non-gating** — it only prints timing information so engineers can compare
|
||||
//! before/after optimizations manually. It must never break the deterministic
|
||||
//! golden visual regression suite.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840x2160 document.
|
||||
//! 2. Fills 10 raster layers with semi-random opaque color so that every layer
|
||||
//! contributes to the composite (worst-case workload for the painter).
|
||||
//! 3. Selects the top layer and begins a brush stroke.
|
||||
//! 4. Issues 100 `stroke_to` calls across the canvas, calling
|
||||
//! `render_composite_region` after every segment to mimic the GUI render loop.
|
||||
//! 5. Ends the stroke and prints the average time per segment, per composite,
|
||||
//! and the total wall-clock time.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! This is a standard Rust test: `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`.
|
||||
//! It returns nothing and asserts only that the engine remains in a valid state.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! - Allocates ~400MB of pixel/tile/mask buffers.
|
||||
//! - Uses `std::time::Instant` for measurement; results depend on the host CPU.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
const LAYERS: usize = 10;
|
||||
const SEGMENTS: usize = 100;
|
||||
const COMPOSITE_EVERY_N_SEGMENTS: usize = 1;
|
||||
|
||||
fn make_solid_color(r: u8, g: u8, b: u8) -> [u8; 4] {
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_4k_stroke_on_multilayer_document() {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
// Fill the default background layer.
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, make_solid_color(240, 240, 240));
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
|
||||
// Create and fill additional raster layers so every layer contributes.
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => make_solid_color(180, 80, 80),
|
||||
1 => make_solid_color(80, 180, 80),
|
||||
2 => make_solid_color(80, 80, 180),
|
||||
_ => make_solid_color(160, 140, 100),
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
// Switch to the top layer for painting.
|
||||
let top_id = *layer_ids.last().expect("at least one layer");
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
// Start the stroke roughly in the center.
|
||||
let start_x = W as f32 / 2.0;
|
||||
let start_y = H as f32 / 2.0;
|
||||
engine.begin_stroke(top_id, start_x, start_y);
|
||||
|
||||
let mut segment_times = Vec::with_capacity(SEGMENTS);
|
||||
let mut composite_times = Vec::with_capacity(SEGMENTS);
|
||||
|
||||
let total_start = std::time::Instant::now();
|
||||
for i in 0..SEGMENTS {
|
||||
let t = (i as f32) / SEGMENTS as f32;
|
||||
let angle = t * 6.28 * 2.0;
|
||||
let radius = 400.0 * t;
|
||||
let x = start_x + angle.cos() * radius;
|
||||
let y = start_y + angle.sin() * radius;
|
||||
|
||||
let seg_start = std::time::Instant::now();
|
||||
engine.stroke_to(top_id, x, y, 0.8);
|
||||
segment_times.push(seg_start.elapsed().as_micros() as f64);
|
||||
|
||||
if (i + 1) % COMPOSITE_EVERY_N_SEGMENTS == 0 {
|
||||
let comp_start = std::time::Instant::now();
|
||||
let (region, ptr, _size) = engine.render_composite_region();
|
||||
// Ensure the engine actually produced a buffer pointer; do not
|
||||
// optimize away the call.
|
||||
assert!(!ptr.is_null());
|
||||
let comp_us = comp_start.elapsed().as_micros() as f64;
|
||||
composite_times.push(comp_us);
|
||||
// Keep the region alive so the compiler doesn't discard it.
|
||||
let _ = region;
|
||||
}
|
||||
}
|
||||
engine.end_stroke(top_id);
|
||||
let total_us = total_start.elapsed().as_micros() as f64;
|
||||
|
||||
let avg_segment_us = segment_times.iter().sum::<f64>() / segment_times.len().max(1) as f64;
|
||||
let avg_composite_us = composite_times.iter().sum::<f64>() / composite_times.len().max(1) as f64;
|
||||
let max_segment_us = segment_times.iter().copied().fold(0.0, f64::max);
|
||||
let max_composite_us = composite_times.iter().copied().fold(0.0, f64::max);
|
||||
|
||||
println!("\n=== 4K Multi-Layer Stroke Benchmark ===");
|
||||
println!("Canvas: {}x{}, Layers: {}, Segments: {}", W, H, LAYERS, SEGMENTS);
|
||||
println!("Average stroke_to: {:>8.1} us", avg_segment_us);
|
||||
println!("Max stroke_to: {:>8.1} us", max_segment_us);
|
||||
println!("Average composite: {:>8.1} us", avg_composite_us);
|
||||
println!("Max composite: {:>8.1} us", max_composite_us);
|
||||
println!("Total wall time: {:>8.1} us ({:.2} s)", total_us, total_us / 1_000_000.0);
|
||||
println!("Segments per second: {:>8.1}", SEGMENTS as f64 / (total_us / 1_000_000.0));
|
||||
|
||||
// Soft sanity check: the engine must still report a valid top layer and the
|
||||
// document must be marked modified after painting.
|
||||
assert_eq!(engine.active_layer_id(), top_id);
|
||||
assert!(engine.is_modified());
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Automated visual check for the Stamp Floor (formerly Dirt) brush.
|
||||
//!
|
||||
//! Purpose: Renders several Dirt/StampFloor strokes onto a transparent canvas
|
||||
//! using the public engine API and saves the result as a PNG. Used for manual
|
||||
//! inspection and regression detection.
|
||||
|
||||
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual visual check: run with --ignored --test stamp_floor_check and inspect target/stamp_floor_check.png"]
|
||||
fn stamp_floor_brush_visual_check() {
|
||||
let mut engine = Engine::new_with_options("StampFloorCheck", 512, 512, false);
|
||||
let layer_id = engine.active_layer_id();
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Dirt;
|
||||
tip.size = 90.0;
|
||||
tip.opacity = 0.9;
|
||||
tip.hardness = 0.7;
|
||||
tip.spacing = 0.18;
|
||||
tip.color_variant = true;
|
||||
tip.variant_amount = 0.35;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([120, 110, 100, 255]);
|
||||
|
||||
// Wide horizontal stroke to show banded floor texture.
|
||||
engine.begin_stroke(layer_id, 60.0, 250.0);
|
||||
for i in 0..=80 {
|
||||
let t = i as f32 / 80.0;
|
||||
let x = 60.0 + t * 400.0;
|
||||
let y = 250.0 + (t * 40.0).sin() * 8.0;
|
||||
engine.stroke_to(layer_id, x, y, 1.0);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
// Second overlapping stroke.
|
||||
engine.begin_stroke(layer_id, 60.0, 320.0);
|
||||
for i in 0..=80 {
|
||||
let t = i as f32 / 80.0;
|
||||
let x = 60.0 + t * 400.0;
|
||||
let y = 320.0 + (t * 50.0).cos() * 10.0;
|
||||
engine.stroke_to(layer_id, x, y, 0.8);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
|
||||
output_layer.pixels = pixels.clone();
|
||||
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
let path = out_dir.join("stamp_floor_check.png");
|
||||
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
|
||||
|
||||
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
|
||||
assert!(has_color, "Stamp Floor check produced no colored pixels");
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Golden Visual Regression Test Harness
|
||||
//!
|
||||
//! Purpose:
|
||||
//! Port v3's deterministic golden hash concept to v4 through the public Engine API boundary.
|
||||
//! These tests validate that Engine output remains deterministic and pixel-perfect across changes.
|
||||
//!
|
||||
//! Design:
|
||||
//! - Uses only `hcie_engine_api::Engine` public API (no internal crate access)
|
||||
//! - Creates deterministic canvases, layers, colors, vector shapes, brush strokes, filters
|
||||
//! - Hashes `engine.get_composite_pixels()` with SHA-256
|
||||
//! - Compares against inline golden hashes
|
||||
//!
|
||||
//! Regeneration:
|
||||
//! Set `HCIE_REGEN_GOLDENS=1` to print computed hashes after test verification.
|
||||
//!
|
||||
//! Golden cases:
|
||||
//! - white_canvas_empty_document: validates blank document/composite baseline
|
||||
//! - green_rect_draw_and_composite: validates draw_filled_rect_rgba
|
||||
//! - red_rect_over_green_rect: validates layer compositing order
|
||||
//! - vector_rect_golden: validates vector rendering through Engine API
|
||||
//! - brush_stroke_golden: validates brush engine + draw pipeline
|
||||
//! - invert_filter_golden: validates filter pipeline and deterministic output
|
||||
//! - undo_after_rect_golden: validates history snapshot and restoration
|
||||
|
||||
use hcie_engine_api::Engine;
|
||||
use hcie_engine_api::{BrushTip, BrushStyle, VectorShape};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Compute SHA-256 hash of pixel buffer for golden comparison.
|
||||
fn hash_pixels(pixels: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pixels);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Test: White canvas, empty document baseline.
|
||||
/// Validates that a blank document produces deterministic all-white/transparent RGBA output.
|
||||
#[test]
|
||||
fn white_canvas_empty_document() {
|
||||
let mut engine = Engine::new(8, 8);
|
||||
let pixels = engine.get_composite_pixels();
|
||||
|
||||
// Engine::new creates a transparent document by default
|
||||
assert_eq!(pixels.len(), 8 * 8 * 4, "Canvas size mismatch");
|
||||
// Transparent pixels: alpha = 0
|
||||
for i in (0..pixels.len()).step_by(4) {
|
||||
assert_eq!(pixels[i + 3], 0, "Alpha channel should be 0 (transparent)");
|
||||
}
|
||||
assert_eq!(hash_pixels(&pixels), "5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1");
|
||||
}
|
||||
|
||||
/// Test: Green rectangle draw and composite.
|
||||
/// Validates `draw_filled_rect_rgba` produces deterministic output.
|
||||
#[test]
|
||||
fn green_rect_draw_and_composite() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
engine.draw_filled_rect_rgba(4, 4, 12, 12, [0, 255, 0, 255]);
|
||||
let pixels = engine.get_composite_pixels();
|
||||
|
||||
assert_eq!(pixels.len(), 16 * 16 * 4);
|
||||
// Check center pixel is green
|
||||
let center_idx = (8 * 16 + 8) * 4;
|
||||
assert_eq!(pixels[center_idx], 0);
|
||||
assert_eq!(pixels[center_idx + 1], 255);
|
||||
assert_eq!(pixels[center_idx + 2], 0);
|
||||
assert_eq!(pixels[center_idx + 3], 255);
|
||||
assert_eq!(hash_pixels(&pixels), "01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a");
|
||||
}
|
||||
|
||||
/// Test: Red rectangle over green rectangle.
|
||||
/// Validates layer compositing order with two layers.
|
||||
#[test]
|
||||
fn red_rect_over_green_rect() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
|
||||
// Bottom layer: green rect
|
||||
let green_id = engine.add_layer("green");
|
||||
engine.set_active_layer(green_id);
|
||||
engine.draw_filled_rect_rgba(2, 2, 14, 14, [0, 255, 0, 255]);
|
||||
|
||||
// Top layer: red rect
|
||||
let red_id = engine.add_layer("red");
|
||||
engine.set_active_layer(red_id);
|
||||
engine.draw_filled_rect_rgba(6, 6, 10, 10, [255, 0, 0, 255]);
|
||||
|
||||
let pixels = engine.get_composite_pixels();
|
||||
assert_eq!(pixels.len(), 16 * 16 * 4);
|
||||
|
||||
// Center pixel should be red (top layer wins)
|
||||
let center_idx = (8 * 16 + 8) * 4;
|
||||
assert_eq!(pixels[center_idx], 255);
|
||||
assert_eq!(pixels[center_idx + 1], 0);
|
||||
assert_eq!(pixels[center_idx + 2], 0);
|
||||
assert_eq!(hash_pixels(&pixels), "c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750");
|
||||
}
|
||||
|
||||
/// Test: Vector shape rendering.
|
||||
/// Validates vector shapes are rendered deterministically through Engine API.
|
||||
#[test]
|
||||
fn vector_rect_golden() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
let shape = VectorShape::Rect {
|
||||
x1: 0.0, y1: 0.0, x2: 16.0, y2: 16.0,
|
||||
stroke: 1.0,
|
||||
color: [255, 0, 0, 255],
|
||||
fill_color: [255, 0, 0, 255],
|
||||
fill: true,
|
||||
radius: 0.0,
|
||||
angle: 0.0,
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
};
|
||||
engine.add_vector_shape(shape);
|
||||
let pixels = engine.get_composite_pixels();
|
||||
|
||||
assert_eq!(pixels.len(), 16 * 16 * 4);
|
||||
|
||||
// Verify at least one red pixel (vector rect was drawn)
|
||||
let has_red = pixels.chunks(4).any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
|
||||
assert!(has_red, "Vector rect should contain red pixels");
|
||||
assert_eq!(hash_pixels(&pixels), "71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d");
|
||||
}
|
||||
|
||||
/// Test: Brush stroke rendering.
|
||||
/// Validates brush engine + draw pipeline produces deterministic output.
|
||||
#[test]
|
||||
fn brush_stroke_golden() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
let brush = BrushTip {
|
||||
style: BrushStyle::Round,
|
||||
size: 4.0,
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.1,
|
||||
..Default::default()
|
||||
};
|
||||
engine.set_brush_tip(brush);
|
||||
engine.begin_stroke(engine.active_layer_id(), 8.0, 8.0);
|
||||
engine.stroke_to(engine.active_layer_id(), 12.0, 12.0, 1.0);
|
||||
engine.end_stroke(engine.active_layer_id());
|
||||
let pixels = engine.get_composite_pixels();
|
||||
|
||||
assert_eq!(pixels.len(), 16 * 16 * 4);
|
||||
|
||||
// Brush stroke should produce non-white pixels
|
||||
let has_non_white = pixels.iter().any(|&p| p < 255);
|
||||
assert!(has_non_white, "Brush stroke should produce non-white pixels");
|
||||
assert_eq!(hash_pixels(&pixels), "e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087");
|
||||
}
|
||||
|
||||
/// Test: Invert filter.
|
||||
/// Validates filter pipeline produces deterministic output.
|
||||
#[test]
|
||||
fn invert_filter_golden() {
|
||||
let mut engine = Engine::new(8, 8);
|
||||
engine.draw_filled_rect_rgba(0, 0, 8, 8, [100, 150, 200, 255]);
|
||||
engine.apply_filter("invert", serde_json::json!({}));
|
||||
|
||||
let pixels = engine.get_composite_pixels();
|
||||
assert_eq!(pixels.len(), 8 * 8 * 4);
|
||||
|
||||
// Center pixel should be inverted
|
||||
let idx = (4 * 8 + 4) * 4;
|
||||
assert_eq!(pixels[idx], 155); // 255 - 100
|
||||
assert_eq!(pixels[idx + 1], 105); // 255 - 150
|
||||
assert_eq!(pixels[idx + 2], 55); // 255 - 200
|
||||
assert_eq!(hash_pixels(&pixels), "35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f");
|
||||
}
|
||||
|
||||
/// Test: Undo after rectangle.
|
||||
/// Validates history snapshot and restoration works correctly.
|
||||
#[test]
|
||||
fn undo_after_rect_golden() {
|
||||
let mut engine = Engine::new(8, 8);
|
||||
|
||||
// Initial state: transparent
|
||||
let before = hash_pixels(&engine.get_composite_pixels());
|
||||
|
||||
// Draw green rect
|
||||
engine.draw_filled_rect_rgba(2, 2, 6, 6, [0, 255, 0, 255]);
|
||||
|
||||
// Undo
|
||||
engine.undo();
|
||||
|
||||
let after = hash_pixels(&engine.get_composite_pixels());
|
||||
|
||||
// After undo, should match initial state
|
||||
assert_eq!(before, after, "Undo should restore original canvas state");
|
||||
}
|
||||
|
||||
/// Test: Vector fill toggle and delete.
|
||||
/// Validates that set_vector_shape_fill and delete_vector_shape work correctly.
|
||||
#[test]
|
||||
fn vector_fill_toggle_and_delete() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
|
||||
// Create a rect with fill=false
|
||||
let shape = VectorShape::Rect {
|
||||
x1: 2.0, y1: 2.0, x2: 14.0, y2: 14.0,
|
||||
stroke: 1.0,
|
||||
color: [255, 0, 0, 255],
|
||||
fill_color: [0, 0, 255, 255],
|
||||
fill: false,
|
||||
radius: 0.0,
|
||||
angle: 0.0,
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
};
|
||||
engine.add_vector_shape(shape);
|
||||
let layer_id = engine.active_layer_id();
|
||||
|
||||
// Verify fill is initially false
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(false), "fill should be false initially");
|
||||
|
||||
// Toggle fill ON
|
||||
engine.set_vector_shape_fill(layer_id, 0, true);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(true), "fill should be true after toggle");
|
||||
|
||||
// Verify composite pixels contain blue fill
|
||||
let pixels = engine.get_composite_pixels();
|
||||
let has_blue = pixels.chunks(4).any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
|
||||
assert!(has_blue, "should have blue fill pixels after enabling fill");
|
||||
|
||||
// Toggle fill OFF
|
||||
engine.set_vector_shape_fill(layer_id, 0, false);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert_eq!(shapes[0].fill(), Some(false), "fill should be false after toggling off");
|
||||
|
||||
// Delete the shape
|
||||
engine.delete_vector_shape(layer_id, 0);
|
||||
let shapes = engine.active_vector_shapes().unwrap();
|
||||
assert!(shapes.is_empty(), "shapes should be empty after delete");
|
||||
}
|
||||
Reference in New Issue
Block a user