This commit is contained in:
2026-07-09 02:59:53 +03:00
commit 7ace048d94
817 changed files with 234289 additions and 0 deletions
+40
View File
@@ -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"
+223
View File
@@ -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
}
+359
View File
@@ -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);
}
}
+503
View File
@@ -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(&params_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); });
}
+407
View File
@@ -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
+321
View File
@@ -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());
}
}
}
+409
View File
@@ -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)
}
}
+277
View File
@@ -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;
}
}
}
+54
View File
@@ -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");
}
+71
View File
@@ -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");
}
+235
View File
@@ -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");
}