use super::{DarklyEngine, ReadbackContext};
use crate::coord::{CanvasPoint, CanvasRect, LayerRect};
use crate::gpu::atlas::CanvasFrame;
use crate::gpu::compositor::Compositor;
use crate::gpu::context::GpuContext;
use crate::gpu::readback::{self, ReadbackScheduler};
use crate::gpu::region_store::{EntryPixels, RegionScratch, Snapshot, UndoRegionEntry};
use crate::gpu::view::{ViewParams, ViewTransform};
use crate::layer::LayerId;
use crate::undo::GpuRegionAction;
#[derive(Copy, Clone, Debug)]
pub enum PickSource {
Merged,
Layer(LayerId),
}
impl PickSource {
fn resolve<'a>(
&self,
compositor: &'a Compositor,
point: CanvasPoint,
canvas_origin: CanvasPoint,
) -> Option<(&'a wgpu::Texture, LayerRect)> {
match self {
PickSource::Merged => {
let wx = point.x - canvas_origin.x;
let wy = point.y - canvas_origin.y;
if wx < 0 || wy < 0 {
return None;
}
Some((
compositor.composited_texture(),
LayerRect::from_xywh(wx as u32, wy as u32, 1, 1),
))
}
PickSource::Layer(id) => {
let layer_tex = compositor.node_texture(*id)?;
if layer_tex.format() != wgpu::TextureFormat::Rgba8Unorm {
return None;
}
let p = layer_tex.canvas_to_layer(point)?;
Some((layer_tex.texture(), LayerRect::from_xywh(p.x, p.y, 1, 1)))
}
}
}
}
pub const DEFAULT_THUMB_SIZE: u32 = 36;
impl DarklyEngine {
pub fn set_view_transform(
&mut self,
pan_x: f32,
pan_y: f32,
zoom: f32,
rotation: f32,
mirror_h: bool,
screen_w: f32,
screen_h: f32,
) {
let rotation_changed = self.view_params.rotation != rotation;
self.view_params = ViewParams {
pan_x,
pan_y,
zoom,
rotation,
mirror_h,
screen_w,
screen_h,
};
self.rebuild_view_transform();
if rotation_changed {
if let Some(pose) = self.last_cursor_preview_pose {
self.regenerate_brush_cursor_preview_with_pen_internal(pose);
}
}
}
pub(crate) fn rebuild_view_transform(&mut self) {
let p = self.view_params;
let transform = ViewTransform::from_pan_zoom_rotate(
p.pan_x,
p.pan_y,
p.zoom,
p.rotation,
p.mirror_h,
p.screen_w,
p.screen_h,
self.doc.width as f32,
self.doc.height as f32,
);
self.view_transform = transform;
self.compositor
.update_view_transform(&self.gpu.queue, &transform);
self.compositor.mark_needs_present();
}
pub fn screen_to_plane(&self, screen_x: f32, screen_y: f32) -> (f32, f32) {
let o = self.doc.canvas_origin;
self.view_transform
.screen_to_plane(screen_x, screen_y, o.x as f32, o.y as f32)
}
pub fn set_viewport_bg(&mut self, bg: [f32; 4]) {
self.compositor.set_viewport_bg(&self.gpu.queue, bg);
}
pub fn set_pixel_filter(&mut self, mode: &str) {
self.compositor.set_pixel_filter(&self.gpu.queue, mode);
}
pub fn pick_color(&mut self, x: f32, y: f32, source: PickSource) -> [u8; 4] {
let canvas_w = self.compositor.canvas_width();
let canvas_h = self.compositor.canvas_height();
let o = self.doc.canvas_origin;
let wx = x as i32 - o.x;
let wy = y as i32 - o.y;
if wx < 0 || wy < 0 || wx as u32 >= canvas_w || wy as u32 >= canvas_h {
return [0, 0, 0, 0];
}
let point = CanvasPoint::new(x as i32, y as i32);
let resolved = source.resolve(&self.compositor, point, o).or_else(|| {
PickSource::Merged.resolve(&self.compositor, point, o)
});
let Some((texture, rect)) = resolved else {
return [0, 0, 0, 0];
};
self.gpu.encode("pick-color", |encoder| {
let request = readback::request_readback(
&self.gpu.device,
encoder,
texture,
wgpu::TextureFormat::Rgba8Unorm,
rect,
);
self.readbacks.submit(request, ReadbackContext::ColorPick);
});
self.last_picked_color
}
pub fn node_thumbnail(&self, node_id: LayerId, thumb_w: u32, thumb_h: u32) -> Vec<u8> {
self.thumbnail_cache
.get(node_id)
.cloned()
.unwrap_or_else(|| vec![0u8; (thumb_w * thumb_h * 4) as usize])
}
fn request_thumbnail_readback(&mut self, node_id: LayerId, thumb_w: u32, thumb_h: u32) {
if self
.readbacks
.any(|c| matches!(c, ReadbackContext::Thumbnail { node_id: id, .. } if *id == node_id))
{
return;
}
let (texture, format, layer_rect) = match self.compositor.node_texture(node_id) {
Some(t) => (t.texture(), t.format(), t.layer_extent()),
None => return,
};
let tex_w = layer_rect.width;
let tex_h = layer_rect.height;
self.gpu.encode("thumb-readback", |encoder| {
let request =
readback::request_readback(&self.gpu.device, encoder, texture, format, layer_rect);
self.readbacks.submit(
request,
ReadbackContext::Thumbnail {
node_id,
source_w: tex_w,
source_h: tex_h,
thumb_w,
thumb_h,
},
);
});
}
fn poll_pending(&mut self) -> bool {
if self.diff_rect.is_pending() {
if let Some(result) = self.diff_rect.poll(&self.gpu.device) {
if let Some(commit) = self.pending_undo_commit.take() {
if let Some(rect) = result {
if let Some(layer_frame) = self
.compositor
.node_texture(commit.layer_id)
.map(|t| t.canvas_frame())
{
let entry = commit_undo_region(
&self.gpu,
&self.region_scratch,
&mut self.readbacks,
"brush-stroke-end",
commit.layer_id,
&layer_frame,
&commit.snapshot,
rect,
);
self.push_undo(Box::new(GpuRegionAction::new(entry)));
}
}
}
}
}
let bounds_completed = self.compositor.poll_content_bounds(&self.gpu.device);
let mut any_completed = false;
if let Some(pt) = &self.pending_transform {
if bounds_completed.contains(&pt.node_id) {
let node_id = pt.node_id;
self.pending_transform = None;
if self.floating.is_none() {
if let Some(bounds) = self.compositor.content_bounds(node_id) {
let [bx, by, bw, bh] = bounds;
let canvas_origin = self
.compositor
.node_texture(node_id)
.map(|t| t.layer_to_canvas(crate::coord::LayerPoint::new(bx, by)))
.unwrap_or(crate::coord::CanvasPoint::new(bx as i32, by as i32));
self.setup_transform(node_id, (canvas_origin.x, canvas_origin.y), bw, bh);
any_completed = true;
}
}
}
}
self.drain_readbacks() || any_completed
}
pub(crate) fn drain_readbacks(&mut self) -> bool {
let completed = self.readbacks.poll(&self.gpu.device);
if completed.is_empty() {
return false;
}
for (ctx, pixels) in completed {
self.handle_completed_readback(ctx, pixels);
}
true
}
pub(crate) fn handle_completed_readback(&mut self, ctx: ReadbackContext, pixels: Vec<u8>) {
match ctx {
ReadbackContext::FloodFill {
node_id,
seed_canvas,
color,
tolerance,
extent,
} => self.complete_flood_fill(node_id, seed_canvas, color, tolerance, extent, pixels),
ReadbackContext::ColorPick => {
if pixels.len() >= 4 {
self.last_picked_color = [pixels[0], pixels[1], pixels[2], pixels[3]];
}
}
ReadbackContext::Copy {
node_id,
region,
is_cut,
} => {
self.complete_copy(node_id, region, is_cut, pixels);
}
ReadbackContext::MagicWand {
was_active,
node_id,
seed_canvas,
tolerance,
mode,
extent,
} => {
self.complete_magic_wand(
was_active,
node_id,
seed_canvas,
tolerance,
mode,
extent,
pixels,
);
}
ReadbackContext::ExportImage { width, height } => {
self.complete_export(width, height, pixels);
}
ReadbackContext::SaveDocument {
kind,
width,
height,
} => {
self.complete_save_readback(kind, width, height, pixels);
}
ReadbackContext::SelectionReadback => {
self.update_selection_overlay_from_readback(pixels);
if let Some(pc) = self.pending_copy.take() {
self.start_copy_readback(pc.layer_id, pc.is_cut);
}
if self.selection_pixel_bounds().is_some() {
if let Some(pt) = self.pending_transform.take() {
if self.floating.is_none() {
self.begin_transform(pt.node_id);
}
}
if let Some(pf) = self.pending_flip.take() {
self.flip_node(pf.node_id, pf.xform);
}
if let Some(pa) = self.pending_filter.take() {
self.apply_filter(pa.node_id, &pa.filter_type);
}
}
}
ReadbackContext::Thumbnail {
node_id,
source_w,
source_h,
thumb_w,
thumb_h,
} => {
let is_r8 = self
.compositor
.node_texture(node_id)
.map(|t| t.format() == wgpu::TextureFormat::R8Unorm)
.unwrap_or(false);
let thumb = if is_r8 {
generate_mask_thumbnail_from_pixels(
&pixels, source_w, source_h, thumb_w, thumb_h,
)
} else {
generate_rgba_thumbnail_from_pixels(
&pixels, source_w, source_h, thumb_w, thumb_h,
)
};
self.thumbnail_cache.insert(node_id, thumb);
self.thumbnail_version = self.thumbnail_version.wrapping_add(1);
}
ReadbackContext::BrushStrokePreview {
width,
height,
graph_version,
} => {
if graph_version == self.brush_graph_version() {
let (tw, th) = super::brush_library::BRUSH_THUMBNAIL_SIZE;
let framed = frame_stroke_thumbnail(
&pixels,
width,
height,
tw,
th,
self.preview_theme_bg,
);
let png_bytes = encode_rgba_as_png(&framed, tw, th);
if !png_bytes.is_empty() {
self.brush_stroke_preview_cache = Some(png_bytes);
}
}
}
ReadbackContext::BrushThumbnailForSave {
name,
width,
height,
} => {
let (tw, th) = super::brush_library::BRUSH_THUMBNAIL_SIZE;
let framed =
frame_stroke_thumbnail(&pixels, width, height, tw, th, self.preview_theme_bg);
let png_bytes = encode_rgba_as_png(&framed, tw, th);
if !png_bytes.is_empty() {
self.brush_library.set_thumbnail(&name, png_bytes);
}
}
ReadbackContext::BrushDabThumbnail {
name,
width,
height,
} => {
let png_bytes = frame_dab_thumbnail(&pixels, width, height, self.preview_theme_bg);
if !png_bytes.is_empty() {
self.brush_library.set_dab_thumbnail(&name, png_bytes);
}
}
ReadbackContext::BrushCursorPreviewScale {
topology_version,
width,
height,
} => {
if topology_version == self.brush_topology_version() {
let scale = cursor_preview_scale_from_mask(&pixels, width, height);
self.compositor
.tool_overlay_mut()
.set_preview_coverage_scale(scale);
self.compositor.mark_needs_present();
}
}
ReadbackContext::PreviewFrame {
kind,
type_id,
frame_idx,
total,
} => {
if let Some(job) = self.previews.get_mut(&(kind, type_id)) {
if job.frames.len() == total as usize {
if let Some(slot) = job.frames.get_mut(frame_idx as usize) {
*slot = Some(pixels);
}
}
}
}
ReadbackContext::UndoRegionReady { cell } => {
*cell.borrow_mut() = EntryPixels::Ready(pixels);
}
ReadbackContext::ActiveBrushDab { topology_version } => {
if topology_version == self.brush_topology_version() {
let (w, h) = super::brush_library::BRUSH_DAB_RENDER_SIZE;
let png_bytes = frame_dab_thumbnail(&pixels, w, h, self.preview_theme_bg);
if !png_bytes.is_empty() {
self.active_dab_preview_cache = Some(png_bytes);
}
}
}
}
}
pub fn last_picked_color(&self) -> [u8; 4] {
self.last_picked_color
}
pub fn has_pending_color_pick(&self) -> bool {
self.readbacks
.any(|c| matches!(c, ReadbackContext::ColorPick))
}
pub fn thumbnail_version(&self) -> u32 {
self.thumbnail_version
}
fn drain_dirty_thumbnail_readbacks(&mut self) {
let nodes = self.compositor.drain_dirty_pixels();
for node_id in nodes {
self.request_thumbnail_readback(node_id, DEFAULT_THUMB_SIZE, DEFAULT_THUMB_SIZE);
}
}
pub fn render(&mut self, time_secs: f32) -> bool {
let t_poll = web_time::Instant::now();
let pending_completed = self.poll_pending();
if pending_completed {
self.compositor.mark_dirty();
}
let poll_us = t_poll.elapsed().as_micros() as u64;
let t_thumb = web_time::Instant::now();
self.drain_dirty_thumbnail_readbacks();
let thumb_us = t_thumb.elapsed().as_micros() as u64;
let (surface, surface_config) = match (&self.gpu.surface, &self.gpu.surface_config) {
(Some(s), Some(c)) => (s, c),
_ => {
self.last_frame_phases = super::FrameRenderPhases {
poll_us,
thumb_us,
anim_us: 0,
compositor_us: 0,
};
return self.readbacks.has_pending()
|| self.compositor.has_pending_content_bounds()
|| self.diff_rect.is_pending();
}
};
if surface_config.width == 0 || surface_config.height == 0 {
self.last_frame_phases = super::FrameRenderPhases {
poll_us,
thumb_us,
anim_us: 0,
compositor_us: 0,
};
return self.readbacks.has_pending() || self.compositor.has_pending_content_bounds();
}
let t_anim = web_time::Instant::now();
self.compositor
.update_animations(&self.gpu.queue, time_secs, &self.doc);
let anim_us = t_anim.elapsed().as_micros() as u64;
let t_comp = web_time::Instant::now();
self.compositor.render(
&self.gpu.device,
&self.gpu.queue,
surface,
surface_config,
&mut self.doc,
);
let compositor_us = t_comp.elapsed().as_micros() as u64;
self.last_frame_phases = super::FrameRenderPhases {
poll_us,
thumb_us,
anim_us,
compositor_us,
};
self.compositor.needs_animation(&self.doc)
|| self.readbacks.has_pending()
|| self.compositor.has_pending_content_bounds()
|| self.diff_rect.is_pending()
}
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 || self.gpu.is_headless() {
return;
}
self.gpu.resize(width, height);
self.compositor
.veil_chain_mut()
.resize(&self.gpu.device, &self.gpu.queue, width, height);
self.compositor.mark_needs_present();
}
pub fn undo(&mut self) {
self.auto_commit_floating();
self.apply_undo(UndoDirection::Undo);
}
pub fn redo(&mut self) {
self.auto_commit_floating();
self.apply_undo(UndoDirection::Redo);
}
fn apply_undo(&mut self, direction: UndoDirection) {
self.flush_pending_undo_commit();
let action = match direction {
UndoDirection::Undo => self.undo_stack.pop_for_undo(),
UndoDirection::Redo => self.undo_stack.pop_for_redo(),
};
let mut action = match action {
Some(a) => a,
None => return,
};
match direction {
UndoDirection::Undo => {
action.undo(&mut self.doc);
}
UndoDirection::Redo => {
action.redo(&mut self.doc);
}
}
self.sync_compositor_layers();
if self.doc.canvas_rect() != self.compositor.canvas_rect() {
self.apply_canvas_rect_to_compositor();
}
let label = match direction {
UndoDirection::Undo => "undo-restore",
UndoDirection::Redo => "redo-restore",
};
for entry in action.gpu_region_entries_mut() {
let node_id = entry.layer_id;
let cur_extent = match self.compositor.node_texture(node_id) {
Some(t) => t.canvas_extent(),
None => continue,
};
let target_extent = self
.doc
.node_pixel_bounds(node_id)
.filter(|b| *b != cur_extent);
match target_extent {
None => {
let frame = self
.compositor
.node_texture(node_id)
.expect("checked above")
.canvas_frame();
restore_gpu_region(
&self.gpu,
&self.region_scratch,
&mut self.readbacks,
entry,
&frame,
label,
);
}
Some(target_extent) => {
let (forward, request) = {
let cur_frame = self
.compositor
.node_texture(node_id)
.expect("checked above")
.canvas_frame();
self.gpu.encode_ret(label, |enc| {
self.region_scratch.capture_region(
enc,
&self.gpu.device,
entry.layer_id,
entry.format,
&cur_frame,
cur_extent,
)
})
};
self.readbacks.submit(
request,
ReadbackContext::UndoRegionReady {
cell: forward.pixels.clone(),
},
);
self.gpu.encode("undo-rescale-realloc", |enc| {
self.compositor.realloc_node_texture(
&self.gpu.device,
&self.gpu.queue,
enc,
node_id,
target_extent,
false,
);
});
let new_frame = self
.compositor
.node_texture(node_id)
.expect("realloc'd above")
.canvas_frame();
self.gpu.encode(label, |enc| {
self.region_scratch
.upload_region(enc, &self.gpu.device, entry, &new_frame);
});
*entry = forward;
}
}
self.compositor.mark_node_pixels_dirty(node_id);
}
if let Some(restored_active) = action.swap_selection_active(self.has_selection()) {
self.set_selection_active(restored_active);
if let Some(entry) = action.selection_region_entry_mut() {
let frame = self.compositor.selection_state().map(|s| s.canvas_frame());
if let Some(frame) = frame {
let label = match direction {
UndoDirection::Undo => "undo-sel-restore",
UndoDirection::Redo => "redo-sel-restore",
};
restore_gpu_region(
&self.gpu,
&self.region_scratch,
&mut self.readbacks,
entry,
&frame,
label,
);
}
}
self.set_selection_pixel_bounds(None); self.kick_selection_readback();
}
match direction {
UndoDirection::Undo => self.undo_stack.complete_undo(action),
UndoDirection::Redo => self.undo_stack.complete_redo(action),
}
self.compositor.mark_dirty();
}
pub(crate) fn sync_compositor_layers(&mut self) {
let isolated = self.isolated_node;
let isolated_host = |node_id: LayerId| -> bool {
match isolated {
Some(t) => self.doc.filters_of(node_id).contains(&t),
None => false,
}
};
let content_layers = self.doc.all_content_layers();
for layer in &content_layers {
self.compositor
.ensure_layer(&self.gpu.device, &self.gpu.queue, layer);
let blend = layer.blend();
self.compositor.update_layer_uniforms(
&self.gpu.queue,
layer.id(),
blend.opacity,
blend.blend_mode.gpu_value,
isolated_host(layer.id()),
);
if let Some((params, transform)) = layer.void_state() {
let id = layer.id();
self.compositor
.update_void_layer_params(&self.gpu.queue, id, params);
self.compositor
.update_void_layer_transform(&self.gpu.queue, id, transform);
}
}
struct MaskInfo {
filter_id: LayerId,
host_id: LayerId,
bounds: crate::coord::CanvasRect,
}
let mask_infos: Vec<MaskInfo> = self
.doc
.all_filters()
.into_iter()
.filter_map(|m| {
let buf = m.pixels()?;
if buf.format != wgpu::TextureFormat::R8Unorm {
return None;
}
let host_id = self.doc.parent_of(m.id)?;
Some(MaskInfo {
filter_id: m.id,
host_id,
bounds: buf.bounds,
})
})
.collect();
for info in mask_infos {
if self.compositor.node_texture(info.filter_id).is_none() {
self.compositor.ensure_node_texture(
&self.gpu.device,
&self.gpu.queue,
info.filter_id,
wgpu::TextureFormat::R8Unorm,
info.bounds,
);
}
self.compositor
.ensure_mask_snapshot_state(&self.gpu.device, info.host_id);
}
let groups: Vec<(LayerId, bool, f32, u32, bool)> = self
.doc
.all_groups()
.iter()
.map(|g| {
(
g.id,
g.passthrough,
g.blend.opacity,
g.blend.blend_mode.gpu_value,
isolated_host(g.id),
)
})
.collect();
for (id, passthrough, opacity, blend_mode_gpu, isolated_flag) in groups {
if !passthrough {
self.compositor
.ensure_group_state(&self.gpu.device, &self.gpu.queue, id);
}
self.compositor.update_group_uniforms(
&self.gpu.queue,
id,
opacity,
blend_mode_gpu,
isolated_flag,
);
}
}
}
enum UndoDirection {
Undo,
Redo,
}
fn restore_gpu_region(
gpu: &GpuContext,
region_scratch: &RegionScratch,
scheduler: &mut ReadbackScheduler<ReadbackContext>,
entry: &mut UndoRegionEntry,
frame: &CanvasFrame<'_>,
label: &str,
) {
let (forward, request) = gpu.encode_ret(label, |encoder| {
region_scratch.restore_region(encoder, &gpu.device, entry, frame)
});
*entry = forward;
scheduler.submit(
request,
ReadbackContext::UndoRegionReady {
cell: entry.pixels.clone(),
},
);
}
pub(crate) fn commit_undo_region(
gpu: &GpuContext,
region_scratch: &RegionScratch,
scheduler: &mut ReadbackScheduler<ReadbackContext>,
label: &'static str,
layer_id: LayerId,
frame: &CanvasFrame<'_>,
snapshot: &Snapshot,
canvas_rect: CanvasRect,
) -> UndoRegionEntry {
let (entry, request) = gpu.encode_ret(label, |encoder| {
region_scratch.commit_region(encoder, &gpu.device, layer_id, frame, snapshot, canvas_rect)
});
scheduler.submit(
request,
ReadbackContext::UndoRegionReady {
cell: entry.pixels.clone(),
},
);
entry
}
fn generate_rgba_thumbnail_from_pixels(
pixels: &[u8],
doc_w: u32,
doc_h: u32,
thumb_w: u32,
thumb_h: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; (thumb_w * thumb_h * 4) as usize];
for oy in 0..thumb_h {
let cy = (oy * doc_h / thumb_h).min(doc_h - 1);
for ox in 0..thumb_w {
let cx = (ox * doc_w / thumb_w).min(doc_w - 1);
let src = ((cy * doc_w + cx) * 4) as usize;
let (r, g, b, a) = if src + 3 < pixels.len() {
(
pixels[src],
pixels[src + 1],
pixels[src + 2],
pixels[src + 3],
)
} else {
(0, 0, 0, 0)
};
let off = ((oy * thumb_w + ox) * 4) as usize;
let check = if ((ox / 4) + (oy / 4)) % 2 == 0 {
102u8
} else {
153u8
};
let af = a as f32 / 255.0;
buf[off] = (r as f32 * af + check as f32 * (1.0 - af)) as u8;
buf[off + 1] = (g as f32 * af + check as f32 * (1.0 - af)) as u8;
buf[off + 2] = (b as f32 * af + check as f32 * (1.0 - af)) as u8;
buf[off + 3] = 255;
}
}
buf
}
const DAB_THUMBNAIL_OUTPUT_SIZE: u32 = 96;
const CURSOR_PREVIEW_TARGET_COVERAGE: f32 = 130.0 / 255.0;
const CURSOR_PREVIEW_MAX_BOOST: f32 = 8.0;
pub fn frame_dab_thumbnail(pixels: &[u8], width: u32, height: u32, bg: [f32; 4]) -> Vec<u8> {
let expected = (width * height * 4) as usize;
if pixels.len() < expected {
log::error!(
"dab thumbnail pixel buffer too small: {} < {expected}",
pixels.len()
);
return Vec::new();
}
let bg_u8 = [
(bg[0].clamp(0.0, 1.0) * 255.0).round() as u8,
(bg[1].clamp(0.0, 1.0) * 255.0).round() as u8,
(bg[2].clamp(0.0, 1.0) * 255.0).round() as u8,
];
const TOLERANCE: i32 = 12;
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut found = false;
for y in 0..height {
for x in 0..width {
let i = ((y * width + x) * 4) as usize;
let dr = (pixels[i] as i32 - bg_u8[0] as i32).abs();
let dg = (pixels[i + 1] as i32 - bg_u8[1] as i32).abs();
let db = (pixels[i + 2] as i32 - bg_u8[2] as i32).abs();
if dr > TOLERANCE || dg > TOLERANCE || db > TOLERANCE {
if x < min_x {
min_x = x;
}
if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
found = true;
}
}
}
let Some(src) = image::RgbaImage::from_raw(width, height, pixels.to_vec()) else {
return Vec::new();
};
let cropped = if found {
let bbox_w = max_x - min_x + 1;
let bbox_h = max_y - min_y + 1;
let raw_side = bbox_w.max(bbox_h);
let margin = (raw_side / 10).max(2);
let side = (raw_side + 2 * margin).min(width.min(height));
let cx = min_x + bbox_w / 2;
let cy = min_y + bbox_h / 2;
let half = side / 2;
let crop_x = cx.saturating_sub(half).min(width - side);
let crop_y = cy.saturating_sub(half).min(height - side);
image::imageops::crop_imm(&src, crop_x, crop_y, side, side).to_image()
} else {
let side = width.min(height);
let crop_x = (width - side) / 2;
let crop_y = (height - side) / 2;
image::imageops::crop_imm(&src, crop_x, crop_y, side, side).to_image()
};
let resized = image::imageops::resize(
&cropped,
DAB_THUMBNAIL_OUTPUT_SIZE,
DAB_THUMBNAIL_OUTPUT_SIZE,
image::imageops::FilterType::Triangle,
);
let mut out = Vec::new();
let cursor = std::io::Cursor::new(&mut out);
let encoder = image::codecs::png::PngEncoder::new(cursor);
use image::ImageEncoder;
if let Err(e) = encoder.write_image(
resized.as_raw(),
DAB_THUMBNAIL_OUTPUT_SIZE,
DAB_THUMBNAIL_OUTPUT_SIZE,
image::ExtendedColorType::Rgba8,
) {
log::error!("dab thumbnail PNG encode failed: {e}");
return Vec::new();
}
out
}
pub(crate) fn cursor_preview_scale_from_mask(pixels: &[u8], width: u32, height: u32) -> f32 {
let expected = (width * height * 4) as usize;
if pixels.len() < expected || width == 0 || height == 0 {
return 1.0;
}
const TOLERANCE: u8 = 12;
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut found = false;
for y in 0..height {
for x in 0..width {
let alpha = pixels[((y * width + x) * 4 + 3) as usize];
if alpha > TOLERANCE {
if x < min_x {
min_x = x;
}
if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
found = true;
}
}
}
if !found {
return 1.0;
}
let bbox_w = max_x - min_x + 1;
let bbox_h = max_y - min_y + 1;
let raw_side = bbox_w.max(bbox_h);
let margin = (raw_side / 10).max(2);
let side = (raw_side + 2 * margin).min(width.min(height));
let cx = min_x + bbox_w / 2;
let cy = min_y + bbox_h / 2;
let half = side / 2;
let crop_x = cx.saturating_sub(half).min(width - side);
let crop_y = cy.saturating_sub(half).min(height - side);
let mut sum: u64 = 0;
let mut count: u64 = 0;
for y in crop_y..(crop_y + side) {
for x in crop_x..(crop_x + side) {
sum += pixels[((y * width + x) * 4 + 3) as usize] as u64;
count += 1;
}
}
if count == 0 {
return 1.0;
}
let mean = (sum as f32 / count as f32) / 255.0;
if mean <= 0.0 || mean >= CURSOR_PREVIEW_TARGET_COVERAGE {
return 1.0;
}
(CURSOR_PREVIEW_TARGET_COVERAGE / mean).min(CURSOR_PREVIEW_MAX_BOOST)
}
fn frame_stroke_thumbnail(
pixels: &[u8],
src_w: u32,
src_h: u32,
dst_w: u32,
dst_h: u32,
bg: [f32; 4],
) -> Vec<u8> {
let expected = (src_w * src_h * 4) as usize;
if pixels.len() < expected || dst_w == 0 || dst_h == 0 {
log::error!(
"stroke thumbnail pixel buffer too small: {} < {expected}",
pixels.len()
);
return Vec::new();
}
let bg_u8 = [
(bg[0].clamp(0.0, 1.0) * 255.0).round() as u8,
(bg[1].clamp(0.0, 1.0) * 255.0).round() as u8,
(bg[2].clamp(0.0, 1.0) * 255.0).round() as u8,
];
const TOLERANCE: i32 = 12;
let mut min_x = src_w;
let mut min_y = src_h;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut found = false;
for y in 0..src_h {
for x in 0..src_w {
let i = ((y * src_w + x) * 4) as usize;
let dr = (pixels[i] as i32 - bg_u8[0] as i32).abs();
let dg = (pixels[i + 1] as i32 - bg_u8[1] as i32).abs();
let db = (pixels[i + 2] as i32 - bg_u8[2] as i32).abs();
if dr > TOLERANCE || dg > TOLERANCE || db > TOLERANCE {
if x < min_x {
min_x = x;
}
if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
found = true;
}
}
}
let Some(src) = image::RgbaImage::from_raw(src_w, src_h, pixels.to_vec()) else {
return Vec::new();
};
let cropped = if found {
let bbox_w = max_x - min_x + 1;
let bbox_h = max_y - min_y + 1;
let target_aspect = dst_w as f32 / dst_h as f32;
let bbox_aspect = bbox_w as f32 / bbox_h as f32;
let (mut crop_w, mut crop_h) = if bbox_aspect < target_aspect {
let w = (bbox_h as f32 * target_aspect).ceil() as u32;
(w.max(bbox_w), bbox_h)
} else {
let h = (bbox_w as f32 / target_aspect).ceil() as u32;
(bbox_w, h.max(bbox_h))
};
let margin_w = (crop_w / 10).max(2);
let margin_h = (crop_h / 10).max(2);
crop_w = (crop_w + 2 * margin_w).min(src_w);
crop_h = (crop_h + 2 * margin_h).min(src_h);
let cx = min_x + bbox_w / 2;
let cy = min_y + bbox_h / 2;
let crop_x = cx.saturating_sub(crop_w / 2).min(src_w - crop_w);
let crop_y = cy.saturating_sub(crop_h / 2).min(src_h - crop_h);
image::imageops::crop_imm(&src, crop_x, crop_y, crop_w, crop_h).to_image()
} else {
let mut buf = Vec::with_capacity((dst_w * dst_h * 4) as usize);
let bg_a = (bg[3].clamp(0.0, 1.0) * 255.0).round() as u8;
for _ in 0..(dst_w * dst_h) {
buf.extend_from_slice(&[bg_u8[0], bg_u8[1], bg_u8[2], bg_a]);
}
return buf;
};
let resized = image::imageops::resize(
&cropped,
dst_w,
dst_h,
image::imageops::FilterType::Triangle,
);
resized.into_raw()
}
pub(crate) fn encode_rgba_as_png(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
let expected = (width * height * 4) as usize;
if pixels.len() < expected {
log::error!(
"brush thumbnail pixel buffer too small: {} < {expected}",
pixels.len()
);
return Vec::new();
}
let mut out = Vec::with_capacity(expected / 4);
let cursor = std::io::Cursor::new(&mut out);
let encoder = image::codecs::png::PngEncoder::new(cursor);
use image::ImageEncoder;
if let Err(e) = encoder.write_image(
&pixels[..expected],
width,
height,
image::ExtendedColorType::Rgba8,
) {
log::error!("brush thumbnail PNG encode failed: {e}");
return Vec::new();
}
out
}
fn generate_mask_thumbnail_from_pixels(
pixels: &[u8],
doc_w: u32,
doc_h: u32,
thumb_w: u32,
thumb_h: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; (thumb_w * thumb_h * 4) as usize];
for oy in 0..thumb_h {
let cy = (oy * doc_h / thumb_h).min(doc_h - 1);
for ox in 0..thumb_w {
let cx = (ox * doc_w / thumb_w).min(doc_w - 1);
let v = pixels
.get((cy * doc_w + cx) as usize)
.copied()
.unwrap_or(255);
let off = ((oy * thumb_w + ox) * 4) as usize;
buf[off] = v;
buf[off + 1] = v;
buf[off + 2] = v;
buf[off + 3] = 255;
}
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
fn fill_with_rect(
src_w: u32,
src_h: u32,
bg: [u8; 4],
fg: [u8; 4],
x0: u32,
y0: u32,
x1: u32,
y1: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; (src_w * src_h * 4) as usize];
for y in 0..src_h {
for x in 0..src_w {
let i = ((y * src_w + x) * 4) as usize;
let c = if x >= x0 && x < x1 && y >= y0 && y < y1 {
fg
} else {
bg
};
buf[i..i + 4].copy_from_slice(&c);
}
}
buf
}
#[test]
fn frame_stroke_empty_render_returns_bg_field() {
let bg = [0.05, 0.05, 0.05, 1.0];
let bg_u8 = [13u8, 13, 13, 255];
let pixels = fill_with_rect(640, 240, bg_u8, bg_u8, 0, 0, 0, 0);
let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg);
assert_eq!(framed.len(), (320 * 120 * 4) as usize);
for chunk in framed.chunks_exact(4) {
assert_eq!(chunk[0], bg_u8[0]);
assert_eq!(chunk[1], bg_u8[1]);
assert_eq!(chunk[2], bg_u8[2]);
}
}
#[test]
fn frame_stroke_tiny_bbox_is_upscaled() {
let bg = [0.0, 0.0, 0.0, 1.0];
let pixels = fill_with_rect(
640,
240,
[0, 0, 0, 255],
[255, 255, 255, 255],
315,
115,
325,
125,
);
let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg);
assert_eq!(framed.len(), (320 * 120 * 4) as usize);
let mut bright = 0;
for y in 40..80 {
for x in 120..200 {
let i = ((y * 320 + x) * 4) as usize;
if framed[i] > 128 {
bright += 1;
}
}
}
assert!(
bright > 1000,
"expected upscaled square to fill most of center region, got {bright}"
);
}
#[test]
fn frame_stroke_fullcanvas_bbox_resizes_down() {
let bg = [0.0, 0.0, 0.0, 1.0];
let pixels = fill_with_rect(
640,
240,
[0, 0, 0, 255],
[255, 255, 255, 255],
0,
110,
640,
130,
);
let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg);
let bright = framed.chunks_exact(4).filter(|p| p[0] > 128).count();
assert!(
bright > 100,
"full-canvas stripe should survive the downscale, got {bright} bright pixels"
);
}
#[test]
fn frame_stroke_off_center_bbox_is_recentered() {
let bg = [0.0, 0.0, 0.0, 1.0];
let pixels = fill_with_rect(
640,
240,
[0, 0, 0, 255],
[255, 255, 255, 255],
10,
10,
120,
30,
);
let framed = frame_stroke_thumbnail(&pixels, 640, 240, 320, 120, bg);
let bright = framed.chunks_exact(4).filter(|p| p[0] > 128).count();
assert!(
bright > 200,
"off-center stripe should appear in framed output, got {bright}"
);
}
}