This commit is contained in:
2026-07-09 02:59:53 +03:00
commit 7ace048d94
817 changed files with 234289 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "hcie-composite"
version = "0.1.0"
edition = "2021"
[dependencies]
hcie-blend = { path = "../hcie-blend" }
hcie-fx = { path = "../hcie-fx" }
hcie-tile = { path = "../hcie-tile" }
hcie-protocol = { path = "../hcie-protocol" }
rayon = "1.10"
log = "0.4"
[lib]
crate-type = ["staticlib", "rlib"]
[dev-dependencies]
hcie-protocol = { path = "../hcie-protocol" }
rstest = "0.23"
proptest = "1.5"
approx = "0.5"
env_logger = "0.11"
+22
View File
@@ -0,0 +1,22 @@
#![allow(dead_code)]
pub mod parallel;
pub mod tiled;
#[cfg(test)]
mod test_composite;
pub use hcie_protocol::Layer;
pub fn composite_layers(layers: &[Layer], canvas_width: u32, canvas_height: u32) -> Vec<u8> {
parallel::composite_layers_parallel(layers, canvas_width, canvas_height)
}
pub fn composite_layers_region(
layers: &[Layer],
canvas_width: u32,
canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32,
output: &mut [u8],
) {
parallel::composite_layers_region_parallel(layers, canvas_width, canvas_height, x0, y0, x1, y1, output)
}
+399
View File
@@ -0,0 +1,399 @@
use hcie_protocol::Layer;
use hcie_blend::blend_pixels;
use hcie_fx::protocol_to_hcie_fx_effect;
use rayon::prelude::*;
pub(crate) fn apply_curves(r: u8, g: u8, b: u8, lut_r: &[u8], lut_g: &[u8], lut_b: &[u8]) -> [u8; 3] {
[
lut_r[r as usize],
lut_g[g as usize],
lut_b[b as usize],
]
}
pub(crate) fn apply_gradient_map(r: u8, g: u8, b: u8, lut_r: &[u8], lut_g: &[u8], lut_b: &[u8]) -> [u8; 3] {
let dr = r as f32;
let dg = g as f32;
let db = b as f32;
let l = (0.30 * dr + 0.59 * dg + 0.11 * db).clamp(0.0, 255.0).round() as usize;
[
lut_r[l],
lut_g[l],
lut_b[l],
]
}
pub(crate) fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let c_max = r.max(g).max(b);
let c_min = r.min(g).min(b);
let delta = c_max - c_min;
let mut h = 0.0;
let mut s = 0.0;
let l = (c_max + c_min) / 2.0;
if delta > 1e-5 {
s = if l < 0.5 { delta / (c_max + c_min) } else { delta / (2.0 - c_max - c_min) };
if c_max == r {
h = (g - b) / delta + (if g < b { 6.0 } else { 0.0 });
} else if c_max == g {
h = (b - r) / delta + 2.0;
} else {
h = (r - g) / delta + 4.0;
}
h *= 60.0;
}
(h, s, l)
}
pub(crate) fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
if s < 1e-5 {
return (l, l, l);
}
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = l - c / 2.0;
let (r_prime, g_prime, b_prime) = 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_prime + m, g_prime + m, b_prime + m)
}
pub(crate) fn apply_hue_saturation(r: u8, g: u8, b: u8, dh: i32, ds: i32, dl: i32) -> [u8; 3] {
let fr = r as f32 / 255.0;
let fg = g as f32 / 255.0;
let fb = b as f32 / 255.0;
let (h, s, l) = rgb_to_hsl(fr, fg, fb);
// 1. Hue shift
let mut new_h = h + dh as f32;
while new_h < 0.0 { new_h += 360.0; }
while new_h >= 360.0 { new_h -= 360.0; }
// 2. Saturation relative/clamped shift
let s_factor = ds as f32 / 100.0;
let new_s = if s_factor >= 0.0 {
s + s_factor * (1.0 - s)
} else {
s + s_factor * s
};
let new_s = new_s.clamp(0.0, 1.0);
// 3. Lightness relative/clamped shift
let l_factor = dl as f32 / 100.0;
let new_l = if l_factor >= 0.0 {
l + l_factor * (1.0 - l)
} else {
l + l_factor * l
};
let new_l = new_l.clamp(0.0, 1.0);
let (out_r, out_g, out_b) = hsl_to_rgb(new_h, new_s, new_l);
[
(out_r * 255.0).round().clamp(0.0, 255.0) as u8,
(out_g * 255.0).round().clamp(0.0, 255.0) as u8,
(out_b * 255.0).round().clamp(0.0, 255.0) as u8,
]
}
pub fn composite_layers_parallel(layers: &[Layer], canvas_width: u32, canvas_height: u32) -> Vec<u8> {
let px_count = (canvas_width * canvas_height) as usize;
let mut output = vec![0u8; px_count * 4];
log::debug!("[composite_layers_parallel] START: {} layers, canvas={}x{}, px_count={}",
layers.len(), canvas_width, canvas_height, px_count);
for (i, l) in layers.iter().enumerate() {
log::debug!("[composite_layers_parallel] input layer[{}]: name='{}' visible={} w={} h={} opacity={} blend={:?} clip={} pixels_len={}",
i, l.name, l.visible, l.width, l.height, l.opacity, l.blend_mode, l.clipping_mask, l.pixels.len());
}
// Build a lookup for base layers of clipping masks
let mut base_indices = Vec::with_capacity(layers.len());
for i in 0..layers.len() {
if layers[i].clipping_mask {
let mut base = None;
for j in (0..i).rev() {
if !layers[j].clipping_mask {
base = Some(j);
break;
}
}
base_indices.push(base);
} else {
base_indices.push(None);
}
}
for (rev_i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; }
if layer.layer_type == hcie_protocol::LayerType::Group {
continue;
}
let is_adj = layer.adjustment.is_some();
if !is_adj && (layer.width == 0 || layer.height == 0) { continue; }
let i = rev_i;
let blend: hcie_blend::BlendMode = layer.blend_mode.into();
let opacity = layer.opacity.clamp(0.0, 1.0);
let is_clipping = layer.clipping_mask;
let base_idx = base_indices[i];
// Apply layer effects if present
let fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(protocol_to_hcie_fx_effect).collect();
let effect_pixels = if (!fx_effects.is_empty() || layer.fill_opacity < 1.0) && !is_adj {
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity))
} else {
None
};
let pixels = effect_pixels.as_deref().unwrap_or(&layer.pixels);
if is_adj {
output.par_chunks_exact_mut(canvas_width as usize * 4)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
for x in 0..canvas_width {
let out_idx = x as usize * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
if dst[3] == 0 { continue; }
let mask_val = layer.get_mask_value(x, gy);
if mask_val == 0 { continue; }
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
}
};
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
row[out_idx] = out_r;
row[out_idx + 1] = out_g;
row[out_idx + 2] = out_b;
}
});
} else {
let (offset_x, offset_y) = match &layer.data {
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
_ => (0, 0),
};
let cx0 = (0).max(offset_x).max(0) as u32;
let cx1 = (canvas_width as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
let cy0 = (0).max(offset_y).max(0) as u32;
let cy1 = (canvas_height as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
if cx0 >= cx1 || cy0 >= cy1 { continue; }
output.par_chunks_exact_mut(canvas_width as usize * 4)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < cy0 || gy >= cy1 { return; }
let ly = (y as i32 - offset_y) as u32;
for x in cx0 as usize..cx1 as usize {
let lx = (x as i32 - offset_x) as u32;
let out_idx = x * 4;
let pi = (ly * layer.width + lx) as usize * 4;
let mut src = if pi + 3 < pixels.len() {
[pixels[pi], pixels[pi + 1], pixels[pi + 2], pixels[pi + 3]]
} else {
[0, 0, 0, 0]
};
let mask_val = layer.get_mask_value(x as u32, gy);
if mask_val == 0 { continue; }
if is_clipping {
if let Some(bi) = base_idx {
let base = layers[bi].get_pixel(x as u32, gy);
src[3] = (src[3] as f32 * base[3] as f32 / 255.0).round() as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { continue; }
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[out_idx..out_idx + 4].copy_from_slice(&blended);
}
});
}
}
let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
log::debug!("[composite_layers_parallel] DONE: output non_zero_alpha={}/{} ({}%)",
non_zero_alpha, px_count, if px_count > 0 { non_zero_alpha * 100 / px_count } else { 0 });
output
}
pub fn composite_layers_region_parallel(
layers: &[Layer],
canvas_width: u32,
canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32,
output: &mut [u8],
) {
let w = x1.saturating_sub(x0);
let h = y1.saturating_sub(y0);
if w == 0 || h == 0 { return; }
let mut base_indices = Vec::with_capacity(layers.len());
for i in 0..layers.len() {
if layers[i].clipping_mask {
let mut base = None;
for j in (0..i).rev() {
if !layers[j].clipping_mask {
base = Some(j);
break;
}
}
base_indices.push(base);
} else {
base_indices.push(None);
}
}
for (rev_i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; }
if layer.layer_type == hcie_protocol::LayerType::Group {
continue;
}
let blend: hcie_blend::BlendMode = layer.blend_mode.into();
let opacity = layer.opacity.clamp(0.0, 1.0);
let is_clipping = layer.clipping_mask;
let i = rev_i;
let base_idx = base_indices[i];
let is_adj = layer.adjustment.is_some();
// Apply layer effects if present
let fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(protocol_to_hcie_fx_effect).collect();
let effect_pixels = if (!fx_effects.is_empty() || layer.fill_opacity < 1.0) && !is_adj {
Some(hcie_fx::apply_layer_effects(&layer.pixels, layer.width, layer.height, &fx_effects, layer.fill_opacity))
} else {
None
};
let pixels = effect_pixels.as_deref().unwrap_or(&layer.pixels);
if is_adj {
let region_y0 = y0;
let region_y1 = y1.min(canvas_height);
let region_x0 = x0;
let region_x1 = x1.min(canvas_width);
let row_stride = canvas_width as usize * 4;
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < region_y0 || gy >= region_y1 { return; }
for x in region_x0..region_x1 {
let out_idx = (x as usize) * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
if dst[3] == 0 { continue; }
let mask_val = layer.get_mask_value(x, gy);
if mask_val == 0 { continue; }
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
}
};
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
let out_r = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
let out_g = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
let out_b = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
row[out_idx] = out_r;
row[out_idx + 1] = out_g;
row[out_idx + 2] = out_b;
}
});
} else {
if layer.width == 0 || layer.height == 0 { continue; }
let (offset_x, offset_y) = match &layer.data {
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
_ => (0, 0),
};
let cx0 = (x0 as i32).max(offset_x).max(0) as u32;
let cx1 = (x1 as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
let cy0 = (y0 as i32).max(offset_y).max(0) as u32;
let cy1 = (y1 as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
if cx0 >= cx1 || cy0 >= cy1 { continue; }
let row_stride = canvas_width as usize * 4;
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < cy0 || gy >= cy1 { return; }
let ly = (gy as i32 - offset_y) as u32;
for x in cx0..cx1 {
let lx = (x as i32 - offset_x) as u32;
let out_idx = (x as usize) * 4;
let pi = ((ly * layer.width + lx) * 4) as usize;
let mut src = if pi + 3 < pixels.len() {
[pixels[pi], pixels[pi + 1], pixels[pi + 2], pixels[pi + 3]]
} else {
[0, 0, 0, 0]
};
let mask_val = layer.get_mask_value(x, gy);
if mask_val == 0 { continue; }
if is_clipping {
if let Some(bi) = base_idx {
let base = layers[bi].get_pixel(x, gy);
src[3] = (src[3] as f32 * base[3] as f32 / 255.0).round() as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { continue; }
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[out_idx..out_idx + 4].copy_from_slice(&blended);
}
});
}
}
}
+105
View File
@@ -0,0 +1,105 @@
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
#[test]
fn test_composite_basic() {
use hcie_protocol::BlendMode;
let layer = hcie_protocol::Layer {
name: "Red".into(),
layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::Raster,
pixels: vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255],
width: 2,
height: 2,
visible: true,
opacity: 1.0,
blend_mode: BlendMode::Normal,
locked: false,
dirty: false,
id: 0,
parent_id: None,
styles: vec![],
clipping_mask: false,
collapsed: false,
adjustment: None,
mask_pixels: None,
mask_bounds: None,
mask_default_color: 0,
curve_points: vec![],
fill_opacity: 1.0,
effects: vec![],
effects_dirty: AtomicBool::new(true),
effects_cache: Mutex::new(None),
adjustment_raw: None,
is_section_divider: false,
image_resources_raw: None,
};
let layers = vec![layer];
let out = crate::composite_layers(&layers, 2, 2);
assert_eq!(out, vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255]);
let layer0 = hcie_protocol::Layer {
name: "Red".into(),
layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::Raster,
pixels: vec![255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255],
width: 2,
height: 2,
visible: true,
opacity: 1.0,
blend_mode: BlendMode::Normal,
locked: false,
dirty: false,
id: 0,
parent_id: None,
styles: vec![],
clipping_mask: false,
collapsed: false,
adjustment: None,
mask_pixels: None,
mask_bounds: None,
mask_default_color: 0,
curve_points: vec![],
fill_opacity: 1.0,
effects: vec![],
effects_dirty: AtomicBool::new(true),
effects_cache: Mutex::new(None),
adjustment_raw: None,
is_section_divider: false,
image_resources_raw: None,
};
let layer1 = hcie_protocol::Layer {
name: "Blue".into(),
layer_type: hcie_protocol::LayerType::Raster,
data: hcie_protocol::LayerData::Raster,
pixels: vec![0,0,255,255, 0,0,255,255, 0,0,255,255, 0,0,255,255],
width: 2,
height: 2,
visible: true,
opacity: 1.0,
blend_mode: BlendMode::Normal,
locked: false,
dirty: false,
id: 0,
parent_id: None,
styles: vec![],
clipping_mask: false,
collapsed: false,
adjustment: None,
mask_pixels: None,
mask_bounds: None,
mask_default_color: 0,
curve_points: vec![],
fill_opacity: 1.0,
effects: vec![],
effects_dirty: AtomicBool::new(true),
effects_cache: Mutex::new(None),
adjustment_raw: None,
is_section_divider: false,
image_resources_raw: None,
};
let layers2 = vec![layer0, layer1];
let out2 = crate::composite_layers(&layers2, 2, 2);
assert_eq!(out2, vec![0,0,255,255, 0,0,255,255, 0,0,255,255, 0,0,255,255]);
}
+565
View File
@@ -0,0 +1,565 @@
use hcie_tile::{TiledLayer, TILE_SIZE};
use hcie_protocol::Layer;
use hcie_blend::blend_pixels;
use hcie_fx::apply_layer_effects;
use hcie_fx::protocol_to_hcie_fx_effect;
use rayon::prelude::*;
/// Pixel-area threshold below which the compositor uses a sequential row
/// loop instead of Rayon parallelism. For small dirty regions (e.g. a single
/// brush stamp) the thread-pool overhead of `par_chunks_exact_mut` dominates
/// the actual blending work. 256x256 matches the tile size and is a safe
/// crossover point where sequential iteration is consistently faster.
const SEQUENTIAL_PIXEL_THRESHOLD: u32 = 256 * 256;
/// Composite layers using tile storage.
/// Each layer's dense buffer is read via `layer.get_pixel()`.
/// This is the same as the parallel version but optimized to skip
/// transparent tiles. Uses dirty region to further cull work.
pub fn composite_tiled(
layers: &[Layer],
tile_layers: &[Option<TiledLayer>],
canvas_width: u32,
canvas_height: u32,
) -> Vec<u8> {
let px_count = (canvas_width * canvas_height) as usize;
let mut output = vec![0u8; px_count * 4];
composite_tiled_into(layers, tile_layers, canvas_width, canvas_height, 0, 0, canvas_width, canvas_height, &mut output);
output
}
/// Composite tile layers into an existing buffer within a region.
pub fn composite_tiled_into(
layers: &[Layer],
tile_layers: &[Option<TiledLayer>],
canvas_width: u32,
canvas_height: u32,
x0: u32, y0: u32, x1: u32, y1: u32,
output: &mut [u8],
) {
let region_w = x1.saturating_sub(x0);
let region_h = y1.saturating_sub(y0);
if region_w == 0 || region_h == 0 { return; }
let use_sequential = region_w.saturating_mul(region_h) <= SEQUENTIAL_PIXEL_THRESHOLD;
let row_stride = canvas_width as usize * 4;
// Pre-compute the base layer index for every clipping mask layer once.
// This avoids an O(i) reverse scan per pixel during blending.
let mut base_indices = Vec::with_capacity(layers.len());
for i in 0..layers.len() {
if layers[i].clipping_mask {
let mut base = None;
for j in (0..i).rev() {
if !layers[j].clipping_mask {
base = Some(j);
break;
}
}
base_indices.push(base);
} else {
base_indices.push(None);
}
}
for (i, layer) in layers.iter().enumerate() {
if !layer.visible { continue; }
if layer.layer_type == hcie_protocol::LayerType::Group {
continue;
}
let blend: hcie_blend::BlendMode = layer.blend_mode.into();
let opacity = layer.opacity.clamp(0.0, 1.0);
let is_clipping = layer.clipping_mask;
let base_idx = base_indices[i];
let (offset_x, offset_y) = match &layer.data {
hcie_protocol::LayerData::Text { offset_x, offset_y, .. } => (*offset_x as i32, *offset_y as i32),
_ => (0, 0),
};
let cx0 = (x0 as i32).max(offset_x).max(0) as u32;
let cx1 = (x1 as i32).min(offset_x + layer.width as i32).min(canvas_width as i32).max(0) as u32;
let cy0 = (y0 as i32).max(offset_y).max(0) as u32;
let cy1 = (y1 as i32).min(offset_y + layer.height as i32).min(canvas_height as i32).max(0) as u32;
let layer_w = layer.width.min(canvas_width);
let layer_h = layer.height.min(canvas_height);
let is_adj = layer.adjustment.is_some();
if is_adj {
let region_y0 = y0;
let region_y1 = y1.min(canvas_height);
let region_x0 = x0;
let region_x1 = x1.min(canvas_width);
if use_sequential {
for y in region_y0..region_y1 {
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
let gy = y;
for x in region_x0..region_x1 {
blend_adjustment_pixel(row, x, gy, layer, blend, opacity);
}
}
} else {
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < region_y0 || gy >= region_y1 { return; }
for x in region_x0..region_x1 {
blend_adjustment_pixel(row, x, gy, layer, blend, opacity);
}
});
}
continue;
}
// If layer has effects or non-default fill_opacity, compute effects buffer.
// Prefer the cached `effects_cache` when available to avoid re-running the
// expensive effects pipeline on every partial composite.
let has_effects = !layer.effects.is_empty() || (layer.fill_opacity < 1.0);
let fx_effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter().map(protocol_to_hcie_fx_effect).collect();
let effect_buf = if has_effects {
if let Ok(Some(cached)) = layer.effects_cache.lock().as_deref() {
if cached.width == layer.width && cached.height == layer.height {
Some(cached.rendered.clone())
} else {
None
}
} else {
None
}.unwrap_or_else(|| apply_layer_effects(
&layer.pixels, layer.width, layer.height,
&fx_effects, layer.fill_opacity,
))
} else {
Vec::new()
};
// Try tile path first (only if no effects — effects need full dense buffer)
if !has_effects {
if let Some(Some(tl)) = tile_layers.get(i).filter(|_| layer.layer_type != hcie_protocol::LayerType::Text) {
if tl.tile_count() == 0 && i > 0 {
continue; // fully transparent layer with no tiles
}
let t_start_x = x0 / TILE_SIZE;
let _t_start_y = y0 / TILE_SIZE;
let t_end_x = div_ceil(x1, TILE_SIZE);
let _t_end_y = div_ceil(y1, TILE_SIZE);
if use_sequential {
for y in y0..y1.min(layer_h) {
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
let gy = y;
let ty = gy / TILE_SIZE;
let ly = (gy % TILE_SIZE) as usize;
for tx in t_start_x..t_end_x {
let gx_start = tx * TILE_SIZE;
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w);
if gx_end <= gx_start { continue; }
if let Some(tile) = tl.get_tile(tx, ty) {
let start_x = gx_start.max(x0);
for gx in start_x..gx_end {
blend_tile_pixel(row, gx, gy, ly, tile, layer, is_clipping, base_idx, layers, blend, opacity);
}
}
}
}
} else {
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < y0 || gy >= y1 || gy >= layer_h { return; }
let ty = gy / TILE_SIZE;
let ly = (gy % TILE_SIZE) as usize;
for tx in t_start_x..t_end_x {
let gx_start = tx * TILE_SIZE;
let gx_end = (gx_start + TILE_SIZE).min(x1).min(layer_w);
if gx_end <= gx_start { continue; }
if let Some(tile) = tl.get_tile(tx, ty) {
let start_x = gx_start.max(x0);
for gx in start_x..gx_end {
blend_tile_pixel(row, gx, gy, ly, tile, layer, is_clipping, base_idx, layers, blend, opacity);
}
}
}
});
}
} else {
// Fallback to dense pixel path (no effects)
if cx0 >= cx1 || cy0 >= cy1 { continue; }
if use_sequential {
for y in cy0..cy1 {
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
let gy = y;
let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32;
blend_dense_pixel_offset(row, gx, gy, lx, ly, layer, is_clipping, base_idx, layers, blend, opacity);
}
}
} else {
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < cy0 || gy >= cy1 { return; }
let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32;
blend_dense_pixel_offset(row, gx, gy, lx, ly, layer, is_clipping, base_idx, layers, blend, opacity);
}
});
}
}
} else {
// Effects path — use pre-computed effects buffer
if cx0 >= cx1 || cy0 >= cy1 { continue; }
let eb = effect_buf.as_slice();
if use_sequential {
for y in cy0..cy1 {
let row = &mut output[y as usize * row_stride..(y as usize + 1) * row_stride];
let gy = y;
let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32;
blend_effects_pixel_offset(row, gx, gy, lx, ly, layer, eb, is_clipping, base_idx, layers, blend, opacity);
}
}
} else {
output.par_chunks_exact_mut(row_stride)
.enumerate()
.for_each(|(y, row)| {
let gy = y as u32;
if gy < cy0 || gy >= cy1 { return; }
let ly = (gy as i32 - offset_y) as u32;
for gx in cx0..cx1 {
let lx = (gx as i32 - offset_x) as u32;
blend_effects_pixel_offset(row, gx, gy, lx, ly, layer, eb, is_clipping, base_idx, layers, blend, opacity);
}
});
}
}
}
}
#[inline]
fn blend_adjustment_pixel(
row: &mut [u8],
x: u32,
gy: u32,
layer: &Layer,
blend: hcie_blend::BlendMode,
opacity: f32,
) {
let out_idx = (x as usize) * 4;
let dst = [row[out_idx], row[out_idx + 1], row[out_idx + 2], row[out_idx + 3]];
if dst[3] == 0 { return; }
let mask_val = layer.get_mask_value(x, gy);
if mask_val == 0 { return; }
let adj_rgb = match layer.adjustment.as_ref().unwrap() {
hcie_blend::Adjustment::Curves { lut_r, lut_g, lut_b } => {
crate::parallel::apply_curves(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::GradientMap { lut_r, lut_g, lut_b } => {
crate::parallel::apply_gradient_map(dst[0], dst[1], dst[2], lut_r, lut_g, lut_b)
}
hcie_blend::Adjustment::HueSaturation { hue, saturation, lightness } => {
crate::parallel::apply_hue_saturation(dst[0], dst[1], dst[2], *hue, *saturation, *lightness)
}
};
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended_opaque = blend_pixels([dst[0], dst[1], dst[2], 255], [adj_rgb[0], adj_rgb[1], adj_rgb[2], 255], blend, 1.0);
row[out_idx] = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32).round() as u8;
row[out_idx + 1] = ((1.0 - eff_opacity) * dst[1] as f32 + eff_opacity * blended_opaque[1] as f32).round() as u8;
row[out_idx + 2] = ((1.0 - eff_opacity) * dst[2] as f32 + eff_opacity * blended_opaque[2] as f32).round() as u8;
}
#[inline]
fn blend_tile_pixel(
row: &mut [u8],
gx: u32,
gy: u32,
ly: usize,
tile: &hcie_tile::Tile,
layer: &Layer,
is_clipping: bool,
base_idx: Option<usize>,
layers: &[Layer],
blend: hcie_blend::BlendMode,
opacity: f32,
) {
let lx = (gx % TILE_SIZE) as usize;
let src_idx = (ly * TILE_SIZE as usize + lx) * 4;
let mut src = [
tile.pixels[src_idx],
tile.pixels[src_idx + 1],
tile.pixels[src_idx + 2],
tile.pixels[src_idx + 3],
];
// Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
} else {
layer.mask_default_color
}
} else {
255u8
};
if mask_val == 0 { return; }
if is_clipping {
if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4;
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { return; }
let dst_idx = (gx as usize) * 4;
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
}
#[inline]
fn blend_dense_pixel(
row: &mut [u8],
gx: u32,
gy: u32,
layer: &Layer,
is_clipping: bool,
base_idx: Option<usize>,
layers: &[Layer],
blend: hcie_blend::BlendMode,
opacity: f32,
) {
// Inline get_pixel — hot path
let src_i = (gy * layer.width + gx) as usize * 4;
if src_i + 3 >= layer.pixels.len() {
return;
}
let mut src = [
layer.pixels[src_i],
layer.pixels[src_i + 1],
layer.pixels[src_i + 2],
layer.pixels[src_i + 3],
];
// Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
} else {
layer.mask_default_color
}
} else {
255u8
};
if mask_val == 0 { return; }
if is_clipping {
if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4;
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { return; }
let dst_idx = (gx as usize) * 4;
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
}
#[inline]
fn blend_effects_pixel(
row: &mut [u8],
gx: u32,
gy: u32,
layer: &Layer,
eb: &[u8],
is_clipping: bool,
base_idx: Option<usize>,
layers: &[Layer],
blend: hcie_blend::BlendMode,
opacity: f32,
) {
let src_idx = (gy as usize * layer.width as usize + gx as usize) * 4;
let mut src = [
eb[src_idx],
eb[src_idx + 1],
eb[src_idx + 2],
eb[src_idx + 3],
];
// Inline get_mask_value — hot path
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
} else {
layer.mask_default_color
}
} else {
255u8
};
if mask_val == 0 { return; }
if is_clipping {
if let Some(bi) = base_idx {
// Inline get_pixel for clipping base layer
let base_i = (gy * layers[bi].width + gx) as usize * 4;
let base = [layers[bi].pixels[base_i], layers[bi].pixels[base_i + 1], layers[bi].pixels[base_i + 2], layers[bi].pixels[base_i + 3]];
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { return; }
let dst_idx = (gx as usize) * 4;
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
}
#[inline]
fn blend_dense_pixel_offset(
row: &mut [u8],
gx: u32,
gy: u32,
lx: u32,
ly: u32,
layer: &Layer,
is_clipping: bool,
base_idx: Option<usize>,
layers: &[Layer],
blend: hcie_blend::BlendMode,
opacity: f32,
) {
let src_i = (ly * layer.width + lx) as usize * 4;
if src_i + 3 >= layer.pixels.len() {
return;
}
let mut src = [
layer.pixels[src_i],
layer.pixels[src_i + 1],
layer.pixels[src_i + 2],
layer.pixels[src_i + 3],
];
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
} else {
layer.mask_default_color
}
} else {
255u8
};
if mask_val == 0 { return; }
if is_clipping {
if let Some(bi) = base_idx {
let base = layers[bi].get_pixel(gx, gy);
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { return; }
let dst_idx = (gx as usize) * 4;
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
}
#[inline]
fn blend_effects_pixel_offset(
row: &mut [u8],
gx: u32,
gy: u32,
lx: u32,
ly: u32,
layer: &Layer,
eb: &[u8],
is_clipping: bool,
base_idx: Option<usize>,
layers: &[Layer],
blend: hcie_blend::BlendMode,
opacity: f32,
) {
let src_idx = (ly as usize * layer.width as usize + lx as usize) * 4;
let mut src = [
eb[src_idx],
eb[src_idx + 1],
eb[src_idx + 2],
eb[src_idx + 3],
];
let mask_val = if let (Some(mask), Some(mb)) = (&layer.mask_pixels, &layer.mask_bounds) {
let (m_top, m_left, m_bottom, m_right) = (mb[0] as u32, mb[1] as u32, mb[2] as u32, mb[3] as u32);
if gy >= m_top && gy < m_bottom && gx >= m_left && gx < m_right {
let mask_w = m_right - m_left;
let mask_idx = ((gy - m_top) * mask_w + (gx - m_left)) as usize;
mask.get(mask_idx).copied().unwrap_or(layer.mask_default_color)
} else {
layer.mask_default_color
}
} else {
255u8
};
if mask_val == 0 { return; }
if is_clipping {
if let Some(bi) = base_idx {
let base = layers[bi].get_pixel(gx, gy);
src[3] = (src[3] as f32 * base[3] as f32 / 255.0 + 0.5) as u8;
} else {
src[3] = 0;
}
}
if src[3] == 0 { return; }
let dst_idx = (gx as usize) * 4;
let dst = [row[dst_idx], row[dst_idx + 1], row[dst_idx + 2], row[dst_idx + 3]];
let eff_opacity = opacity * (mask_val as f32 / 255.0);
let blended = blend_pixels(dst, src, blend, eff_opacity);
row[dst_idx..dst_idx + 4].copy_from_slice(&blended);
}
#[inline]
fn div_ceil(a: u32, b: u32) -> u32 {
(a + b - 1) / b
}
+42
View File
@@ -0,0 +1,42 @@
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns)]
use hcie_composite::composite_layers;
use hcie_protocol::Layer as ProtoLayer;
fn count_opaque(pixels: &[u8]) -> usize {
pixels.iter().skip(3).step_by(4).filter(|&&a| a > 0).count()
}
fn proto_to_comp(l: &ProtoLayer) -> ProtoLayer {
l.clone()
}
#[test]
fn test_composite_visibility_from_protocol_layer() {
let _ = env_logger::try_init();
let w = 100u32; let h = 100u32;
// Build layers using hcie_protocol::Layer (like Engine does)
let mut bg = ProtoLayer::new_transparent("bg", w, h);
bg.id = 1;
bg.pixels = vec![255u8; (w*h*4) as usize];
let mut top = ProtoLayer::new_transparent("top", w, h);
top.id = 2;
top.pixels = vec![0u8; (w*h*4) as usize];
for y in 20..80 { for x in 20..80 {
let i = (y*w+x) as usize * 4;
top.pixels[i]=0; top.pixels[i+1]=0; top.pixels[i+2]=0; top.pixels[i+3]=255;
}}
let run_composite = |layers: &[ProtoLayer]| -> Vec<u8> {
composite_layers(layers, w, h)
};
eprintln!("BOTH VISIBLE: opaque={}/10000", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
bg.visible = false;
eprintln!("BG HIDDEN: opaque={}/10000 (expect 3600)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
bg.visible = true; top.visible = false;
eprintln!("TOP HIDDEN: opaque={}/10000 (expect 10000)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
bg.visible = false; top.visible = false;
eprintln!("BOTH HIDDEN: opaque={}/10000 (expect 0)", count_opaque(&run_composite(&[bg.clone(), top.clone()])));
}
+96
View File
@@ -0,0 +1,96 @@
use hcie_composite::composite_layers;
use hcie_protocol::{BlendMode, Layer, LayerType};
fn make_layer(name: &str, w: u32, h: u32, rgba: [u8; 4]) -> Layer {
let mut l = Layer::from_rgba(name, w, h, vec![rgba; (w * h) as usize].into_iter().flatten().collect());
l.blend_mode = BlendMode::Normal;
l
}
fn make_group(name: &str, w: u32, h: u32, id: u64) -> Layer {
let mut l = Layer::new_transparent(name, w, h);
l.layer_type = LayerType::Group;
l.blend_mode = BlendMode::PassThrough;
l.id = id;
l
}
#[test]
fn test_pass_through_group_skipped_in_composite() {
let w = 2u32;
let h = 2u32;
// Background: opaque white
let bg = make_layer("bg", w, h, [255, 255, 255, 255]);
// Group: PassThrough (no pixels)
let group = make_group("group", w, h, 1);
// Child: Multiply on grey
let mut child = make_layer("child", w, h, [128, 128, 128, 255]);
child.blend_mode = BlendMode::Multiply;
child.parent_id = Some(1);
// Composite order: bg, group, child
let layers = vec![bg, group, child];
let out = composite_layers(&layers, w, h);
// PassThrough: child blends directly onto bg.
// Multiply: 255 * 128 / 255 = 128 for each channel.
// Expected: every pixel [128, 128, 128, 255]
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255,
128, 128, 128, 255, 128, 128, 128, 255];
assert_eq!(out, expected, "PassThrough group should let child blend directly onto backdrop");
}
#[test]
fn test_pass_through_group_invisible_children() {
let w = 2u32;
let h = 2u32;
let bg = make_layer("bg", w, h, [255, 255, 255, 255]);
let group = make_group("group", w, h, 1);
let mut child = make_layer("child", w, h, [128, 128, 128, 255]);
child.blend_mode = BlendMode::Multiply;
child.parent_id = Some(1);
child.visible = false;
let layers = vec![bg, group, child];
let out = composite_layers(&layers, w, h);
// Child hidden → only bg visible
let expected = vec![255u8, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255];
assert_eq!(out, expected);
}
#[test]
fn test_pass_through_group_with_opacity() {
let w = 2u32;
let h = 2u32;
// Background white
let bg = make_layer("bg", w, h, [255, 255, 255, 255]);
// Group with 50% opacity (PassThrough)
let mut group = make_group("group", w, h, 1);
group.opacity = 0.5;
// Child with Multiply on grey
let mut child = make_layer("child", w, h, [128, 128, 128, 255]);
child.blend_mode = BlendMode::Multiply;
child.parent_id = Some(1);
// In a true PassThrough implementation the group opacity would multiply
// the child's effective opacity. Our MVP skips group pixels entirely,
// so the child blends at full opacity. We keep this test documenting
// current MVP behaviour.
let layers = vec![bg, group, child];
let out = composite_layers(&layers, w, h);
// Multiply full-strength
let expected = vec![128u8, 128, 128, 255, 128, 128, 128, 255,
128, 128, 128, 255, 128, 128, 128, 255];
assert_eq!(out, expected);
}
+72
View File
@@ -0,0 +1,72 @@
use hcie_composite::composite_layers;
use hcie_protocol::{BlendMode, Layer};
fn count_opaque_pixels(pixels: &[u8]) -> usize {
pixels.iter().skip(3).step_by(4).filter(|&&a| a > 0).count()
}
fn create_test_layer(name: &str, w: u32, h: u32, pixels: Vec<u8>) -> Layer {
let mut l = Layer::from_rgba(name, w, h, pixels);
l.blend_mode = BlendMode::Normal;
l.visible = true;
l.opacity = 1.0;
l
}
#[test]
fn test_composite_visibility_toggle() {
let w = 100u32;
let h = 100u32;
let px = (w * h * 4) as usize;
// Layer 0: opaque white background
let bg = create_test_layer("bg", w, h, vec![255u8; px]);
// Layer 1: opaque black square
let mut top_pixels = vec![0u8; px];
for y in 20..80 {
for x in 20..80 {
let i = ((y * w + x) * 4) as usize;
top_pixels[i] = 0;
top_pixels[i + 1] = 0;
top_pixels[i + 2] = 0;
top_pixels[i + 3] = 255;
}
}
let top = create_test_layer("top", w, h, top_pixels);
let layers = vec![bg.clone(), top.clone()];
// Test 1: both visible → 10000 opaque (white bg behind black square)
let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p);
eprintln!("Test 1 (both visible): opaque={}/10000", opaque);
assert_eq!(opaque, 10000, "all pixels should be opaque");
// Test 2: hide background → only black square visible (3600 px)
let mut bg_hidden = bg.clone();
bg_hidden.visible = false;
let layers = vec![bg_hidden, top.clone()];
let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p);
eprintln!("Test 2 (bg hidden): opaque={}/10000 (expected 3600)", opaque);
assert_eq!(opaque, 3600, "only black square should be visible");
// Test 3: hide top → only white background visible
let mut top_hidden = top.clone();
top_hidden.visible = false;
let layers = vec![bg.clone(), top_hidden];
let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p);
eprintln!("Test 3 (top hidden): opaque={}/10000 (expected 10000)", opaque);
assert_eq!(opaque, 10000, "white background should be visible");
// Test 4: hide both → fully transparent
let mut bg_h = bg.clone(); bg_h.visible = false;
let mut top_h = top.clone(); top_h.visible = false;
let layers = vec![bg_h, top_h];
let p = composite_layers(&layers, w, h);
let opaque = count_opaque_pixels(&p);
eprintln!("Test 4 (both hidden): opaque={}/10000 (expected 0)", opaque);
assert_eq!(opaque, 0, "should be fully transparent");
}