use crate::compat::{fmt, Arc, HashMap, Rc, Vec, VecDeque};
use crate::core::{Color, Font, HorizontalAlignment, ObjectId, Point, Rect, Size};
use crate::render::{
RenderCommand, ShapedText, SoftwareRenderConfig, SoftwareSurface, TextMetrics,
};
use super::paint::{route_command_to_surface, PaintBackend, SoftwarePaintBackend};
#[cfg(feature = "3d")]
use crate::render::math3d::{Mat3, Mat4, Transform, TransformStack, TRANSFORM_STACK_MAX};
#[repr(transparent)]
struct SurfacePaintBackend<'a>(&'a mut SoftwareSurface);
impl PaintBackend for SurfacePaintBackend<'_> {
fn begin_frame(&mut self, clear: Color) {
self.0.begin_frame(clear);
}
fn end_frame(&mut self) {
self.0.end_frame();
}
fn execute_command(&mut self, command: &RenderCommand) {
route_command_to_surface(self.0, command);
}
fn fill_path_non_zero(
&mut self,
points: &[Point],
contours: &[(usize, usize)],
color: Color,
offset_x: i32,
offset_y: i32,
) -> Result<(), crate::render::CompoundPathError> {
self.0.fill_path_non_zero_borrowed(points, contours, color, offset_x, offset_y)
}
fn size(&self) -> Size {
self.0.size()
}
fn set_size(&mut self, size: Size) {
self.0.resize(size);
}
fn dpi_scale(&self) -> f32 {
self.0.dpi_scale()
}
fn set_dpi_scale(&mut self, dpi_scale: f32) {
self.0.set_dpi_scale(dpi_scale);
}
fn measure_text(&self, text: &str, font: &Font) -> TextMetrics {
self.0.measure_text(text, font)
}
fn shape_text(&self, text: &str, font: &Font) -> Rc<ShapedText> {
self.0.shape_text(text, font)
}
fn frame_rgba(&self) -> &[u8] {
self.0.frame_rgba()
}
fn apply_render_config(&mut self, config: SoftwareRenderConfig) {
self.0.apply_render_config(config);
}
fn render_config(&self) -> SoftwareRenderConfig {
self.0.render_config()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BatchId(pub u64);
impl BatchId {
pub const fn new(id: u64) -> Self {
Self(id)
}
pub const fn get(&self) -> u64 {
self.0
}
}
impl From<u64> for BatchId {
fn from(id: u64) -> Self {
Self(id)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchError {
NoActiveBatch,
}
impl fmt::Display for BatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BatchError::NoActiveBatch => {
write!(f, "called record() without an open batch; call begin_batch() first")
}
}
}
}
impl core::error::Error for BatchError {}
#[derive(Debug, Clone)]
pub enum BatchCommand {
FillRect {
rect: Rect,
color: Color,
},
#[cfg(feature = "3d")]
PushTransform {
matrix: Mat3,
},
#[cfg(feature = "3d")]
PopTransform,
#[cfg(feature = "3d")]
PushProjectiveTransform {
matrix: Mat4,
},
#[cfg(feature = "3d")]
PopProjectiveTransform,
StrokeRect {
rect: Rect,
color: Color,
width: f32,
},
DrawLine {
from: Point,
to: Point,
color: Color,
width: f32,
},
DrawImage {
rect: Rect,
image_id: ObjectId,
opacity: f32,
},
DrawImageSubrect {
dest: Rect,
source: Rect,
source_width: u32,
source_height: u32,
image_id: ObjectId,
opacity: f32,
},
DrawText {
position: Point,
text: Arc<str>,
color: Color,
font_size: f32,
},
PushClip {
rect: Rect,
},
PopClip,
Translate {
dx: f32,
dy: f32,
},
SetOpacity {
opacity: f32,
},
}
pub trait BatchRenderer {
fn begin_batch(&mut self) -> BatchId;
fn end_batch(&mut self);
fn record(&mut self, cmd: BatchCommand) -> Result<(), BatchError>;
fn replay(&mut self, id: BatchId);
fn destroy_batch(&mut self, id: BatchId);
fn contains_batch(&self, id: BatchId) -> bool;
fn batch_count(&self) -> usize;
}
const BATCH_DEFAULT_FONT_FAMILY: &str = "Arial";
#[derive(Debug, Clone)]
pub(crate) struct BatchState {
next_id: u64,
current_batch: Option<BatchId>,
batches: HashMap<BatchId, Vec<BatchCommand>>,
pub(crate) images: HashMap<ObjectId, Arc<[u8]>>,
}
impl BatchState {
pub(crate) fn new() -> Self {
Self { next_id: 0, current_batch: None, batches: HashMap::new(), images: HashMap::new() }
}
pub(crate) fn begin_batch(&mut self) -> BatchId {
let id = BatchId::new(self.next_id);
self.next_id += 1;
self.batches.insert(id, Vec::new());
self.current_batch = Some(id);
id
}
pub(crate) fn end_batch(&mut self) {
self.current_batch = None;
}
pub(crate) fn record(&mut self, cmd: BatchCommand) -> Result<(), BatchError> {
let id = self.current_batch.ok_or(BatchError::NoActiveBatch)?;
if let Some(cmds) = self.batches.get_mut(&id) {
cmds.push(cmd);
}
Ok(())
}
pub(crate) fn destroy_batch(&mut self, id: BatchId) {
if self.current_batch == Some(id) {
self.current_batch = None;
}
self.batches.remove(&id);
}
pub(crate) fn contains_batch(&self, id: BatchId) -> bool {
self.batches.contains_key(&id)
}
pub(crate) fn batch_count(&self) -> usize {
self.batches.len()
}
}
#[cfg(feature = "3d")]
fn bounds_of(corners: &[Point; 4]) -> Rect {
let min_x = corners.iter().map(|p| p.x).min().unwrap_or(0);
let min_y = corners.iter().map(|p| p.y).min().unwrap_or(0);
let max_x = corners.iter().map(|p| p.x).max().unwrap_or(0);
let max_y = corners.iter().map(|p| p.y).max().unwrap_or(0);
Rect::new(
min_x,
min_y,
max_x.saturating_sub(min_x).max(0) as u32,
max_y.saturating_sub(min_y).max(0) as u32,
)
}
#[derive(Debug, Clone, Copy)]
struct TransformState {
dx: f32,
dy: f32,
opacity: f32,
#[cfg(feature = "3d")]
matrix: Mat3,
#[cfg(feature = "3d")]
projective: Mat4,
#[cfg(feature = "3d")]
stack: TransformStack,
}
impl TransformState {
#[cfg(feature = "3d")]
fn is_projective(&self) -> bool {
self.projective != Mat4::from_affine(self.matrix)
}
fn apply_to_rect(&self, rect: &Rect) -> Rect {
#[cfg(feature = "3d")]
{
if self.is_projective() {
if let Some(corners) = self.apply_to_projective_quad(rect) {
return bounds_of(&corners);
}
return Rect::new(0, 0, 0, 0);
}
if self.matrix != Mat3::IDENTITY {
return bounds_of(&self.apply_to_quad(rect));
}
}
Rect::new(
(rect.x as f32 + self.dx).round() as i32,
(rect.y as f32 + self.dy).round() as i32,
rect.width,
rect.height,
)
}
fn apply_to_point(&self, pt: &Point) -> Point {
#[cfg(feature = "3d")]
let (x, y) = {
if self.is_projective() {
match self.map_projective(pt.x as f32, pt.y as f32) {
Some((x, y)) => (x + self.dx, y + self.dy),
None => return Point::new(0, 0),
}
} else {
let (x, y) = self.map_xy(pt.x as f32, pt.y as f32);
(x + self.dx, y + self.dy)
}
};
#[cfg(not(feature = "3d"))]
let (x, y) = (pt.x as f32 + self.dx, pt.y as f32 + self.dy);
Point::new(x.round() as i32, y.round() as i32)
}
#[cfg(feature = "3d")]
fn apply_to_quad(&self, rect: &Rect) -> [Point; 4] {
let x0 = rect.x as f32;
let y0 = rect.y as f32;
let x1 = x0 + rect.width as f32;
let y1 = y0 + rect.height as f32;
let source = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
let mut corners = [Point::new(0, 0); 4];
for (slot, (x, y)) in corners.iter_mut().zip(source) {
let (mx, my) = self.map_xy(x, y);
*slot = Point::new((mx + self.dx).round() as i32, (my + self.dy).round() as i32);
}
corners
}
#[cfg(feature = "3d")]
fn map_xy(&self, x: f32, y: f32) -> (f32, f32) {
let [a, b, c, d, e, f] = self.matrix.m;
(a * x + c * y + e, b * x + d * y + f)
}
#[cfg(feature = "3d")]
fn map_projective(&self, x: f32, y: f32) -> Option<(f32, f32)> {
let (px, py, w) = self.projective.project_row(x, y);
if !w.is_finite() || w <= f32::EPSILON {
return None;
}
Some((px / w, py / w))
}
#[cfg(feature = "3d")]
fn apply_to_projective_quad(&self, rect: &Rect) -> Option<[Point; 4]> {
let x0 = rect.x as f32;
let y0 = rect.y as f32;
let x1 = x0 + rect.width as f32;
let y1 = y0 + rect.height as f32;
let source = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
let mut corners = [Point::new(0, 0); 4];
for (slot, (x, y)) in corners.iter_mut().zip(source) {
let (mx, my) = self.map_projective(x, y)?;
*slot = Point::new((mx + self.dx).round() as i32, (my + self.dy).round() as i32);
}
Some(corners)
}
#[cfg(feature = "3d")]
fn projective_length_scale(&self) -> f32 {
let origin = self.map_projective(0.0, 0.0);
let unit_x = self.map_projective(1.0, 0.0);
let unit_y = self.map_projective(0.0, 1.0);
let (Some(origin), Some(unit_x), Some(unit_y)) = (origin, unit_x, unit_y) else {
return 1.0;
};
let x = (unit_x.0 - origin.0).hypot(unit_x.1 - origin.1);
let y = (unit_y.0 - origin.0).hypot(unit_y.1 - origin.1);
let scale = (x + y) * 0.5;
if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
}
}
#[cfg(feature = "3d")]
fn try_apply_to_point(&self, pt: &Point) -> Option<Point> {
if !self.is_projective() {
return Some(self.apply_to_point(pt));
}
let (x, y) = self.map_projective(pt.x as f32, pt.y as f32)?;
Some(Point::new((x + self.dx).round() as i32, (y + self.dy).round() as i32))
}
fn apply_to_color(&self, color: &Color) -> Color {
Color {
r: color.r,
g: color.g,
b: color.b,
a: (color.a as f32 * self.opacity).clamp(0.0, 255.0) as u8,
}
}
}
impl Default for TransformState {
fn default() -> Self {
Self {
dx: 0.0,
dy: 0.0,
opacity: 1.0,
#[cfg(feature = "3d")]
matrix: Mat3::IDENTITY,
#[cfg(feature = "3d")]
projective: Mat4::IDENTITY,
#[cfg(feature = "3d")]
stack: TransformStack::new(),
}
}
}
fn transform_command(
cmd: &BatchCommand,
images: &HashMap<ObjectId, Arc<[u8]>>,
state: &mut TransformState,
) -> Option<RenderCommand> {
match cmd {
BatchCommand::FillRect { rect, color } => {
#[cfg(feature = "3d")]
{
if state.is_projective() {
return state.apply_to_projective_quad(rect).map(|corners| {
RenderCommand::FillQuadAA { corners, color: state.apply_to_color(color) }
});
}
if state.matrix != Mat3::IDENTITY {
return Some(RenderCommand::FillQuad {
corners: state.apply_to_quad(rect),
color: state.apply_to_color(color),
});
}
}
Some(RenderCommand::FillRect {
rect: state.apply_to_rect(rect),
color: state.apply_to_color(color),
})
}
#[cfg(feature = "3d")]
BatchCommand::PushTransform { matrix } => {
let saved = Transform::from_matrix(state.projective);
if !state.stack.push(saved) {
log::warn!(
"[batch] PushTransform rejected: the save stack already holds its maximum of {} entries",
TRANSFORM_STACK_MAX
);
return None;
}
state.matrix = state.matrix.then(matrix);
state.projective = state.projective.then(&Mat4::from_affine(*matrix));
None
}
#[cfg(feature = "3d")]
BatchCommand::PopTransform => {
match state.stack.pop() {
Some(saved) => {
state.matrix = saved.affine();
state.projective = saved.matrix();
}
None => log::warn!("[batch] PopTransform with an empty save stack"),
}
None
}
#[cfg(feature = "3d")]
BatchCommand::PushProjectiveTransform { matrix } => {
let saved = Transform::from_matrix(state.projective);
if !state.stack.push(saved) {
log::warn!(
"[batch] PushProjectiveTransform rejected: the save stack already holds its maximum of {} entries",
TRANSFORM_STACK_MAX
);
return None;
}
state.projective = state.projective.then(matrix);
state.matrix = Transform::from_matrix(state.projective).affine();
None
}
#[cfg(feature = "3d")]
BatchCommand::PopProjectiveTransform => {
match state.stack.pop() {
Some(saved) => {
state.matrix = saved.affine();
state.projective = saved.matrix();
}
None => log::warn!("[batch] PopProjectiveTransform with an empty save stack"),
}
None
}
BatchCommand::StrokeRect { rect, color, width } => {
#[cfg(feature = "3d")]
{
if state.is_projective() {
return state.apply_to_projective_quad(rect).map(|corners| {
RenderCommand::StrokeQuad {
corners,
color: state.apply_to_color(color),
width: (*width).round().max(1.0) as u32,
}
});
}
if state.matrix != Mat3::IDENTITY {
return Some(RenderCommand::StrokeQuad {
corners: state.apply_to_quad(rect),
color: state.apply_to_color(color),
width: (*width).round().max(1.0) as u32,
});
}
}
Some(RenderCommand::DrawRectStroke {
rect: state.apply_to_rect(rect),
color: state.apply_to_color(color),
width: (*width).round().max(1.0) as u32,
})
}
BatchCommand::DrawLine { from, to, color, width } => Some(RenderCommand::DrawLineStroke {
from: state.apply_to_point(from),
to: state.apply_to_point(to),
color: state.apply_to_color(color),
width: (*width).round().max(1.0) as u32,
}),
BatchCommand::DrawImage { rect, image_id, opacity } => {
let Some(source) = images.get(image_id).cloned() else {
log::warn!("[batch] DrawImage references unknown image id {image_id}; dropping");
return None;
};
if source.is_empty() {
log::warn!("[batch] DrawImage references an empty image id {image_id}; dropping");
return None;
}
#[cfg(test)]
note_image_bytes_translated(source.len());
let applied_rect = state.apply_to_rect(rect);
let combined_opacity = state.opacity * opacity;
let data = if combined_opacity < 1.0 && source.len() % 4 == 0 {
baked_image(&source, combined_opacity)
} else {
source
};
#[cfg(feature = "3d")]
{
if state.is_projective() {
return state.apply_to_projective_quad(rect).map(|corners| {
RenderCommand::DrawImageQuad {
corners,
width: rect.width,
height: rect.height,
data,
}
});
}
if state.matrix != Mat3::IDENTITY {
return Some(RenderCommand::DrawImageQuad {
corners: state.apply_to_quad(rect),
width: rect.width,
height: rect.height,
data,
});
}
}
Some(RenderCommand::DrawImage {
x: applied_rect.x,
y: applied_rect.y,
width: applied_rect.width,
height: applied_rect.height,
data,
})
}
BatchCommand::DrawImageSubrect {
dest,
source,
source_width,
source_height,
image_id,
opacity,
} => {
let Some(data) = images.get(image_id) else {
log::warn!(
"[batch] DrawImageSubrect references unknown image id {image_id}; dropping"
);
return None;
};
let Some(cropped) =
crop_source_rect_cached(data, *source_width, *source_height, *source)
else {
log::warn!(
"[batch] DrawImageSubrect (image={image_id}) has an empty or out-of-bounds \
source rectangle {source:?} for a {source_width}x{source_height} image; dropping"
);
return None;
};
#[cfg(test)]
note_image_bytes_translated(cropped.len());
let applied_rect = state.apply_to_rect(dest);
let combined_opacity = state.opacity * opacity;
let data: Arc<[u8]> = cropped;
let data = if combined_opacity < 1.0 && data.len().is_multiple_of(4) {
baked_image(&data, combined_opacity)
} else {
data
};
Some(RenderCommand::DrawImage {
x: applied_rect.x,
y: applied_rect.y,
width: applied_rect.width,
height: applied_rect.height,
data,
})
}
BatchCommand::DrawText { position, text, color, font_size } => {
#[cfg(feature = "3d")]
let scale = if state.is_projective() {
state.projective_length_scale()
} else if state.matrix == Mat3::IDENTITY {
1.0
} else {
state.matrix.length_scale()
};
#[cfg(not(feature = "3d"))]
let scale = 1.0f32;
let font = Font::simple(BATCH_DEFAULT_FONT_FAMILY, (*font_size * scale).max(1.0));
#[cfg(feature = "3d")]
let origin = state.try_apply_to_point(position)?;
#[cfg(not(feature = "3d"))]
let origin = state.apply_to_point(position);
Some(RenderCommand::DrawText {
origin,
text: Arc::clone(text),
font,
color: state.apply_to_color(color),
alignment: HorizontalAlignment::Left,
})
}
BatchCommand::PushClip { rect } => {
let applied = state.apply_to_rect(rect);
Some(RenderCommand::PushClip {
x: applied.x,
y: applied.y,
width: applied.width,
height: applied.height,
})
}
BatchCommand::PopClip => Some(RenderCommand::PopClip),
BatchCommand::Translate { dx, dy } => {
state.dx += dx;
state.dy += dy;
None
}
BatchCommand::SetOpacity { opacity } => {
if opacity.is_finite() {
state.opacity = (state.opacity * opacity).clamp(0.0, 1.0);
} else {
log::warn!(
"[batch] SetOpacity({opacity}) rejected: opacity must be finite; keeping {}",
state.opacity
);
}
None
}
}
}
type BakedImageKey = (usize, usize, u32);
type BakedImageEntry = (Arc<[u8]>, Arc<[u8]>);
thread_local! {
#[cfg_attr(alloc_frugal, allow(clippy::missing_const_for_thread_local))]
static BAKED_IMAGES: core::cell::RefCell<(VecDeque<BakedImageKey>, HashMap<BakedImageKey, BakedImageEntry>)> =
core::cell::RefCell::new((VecDeque::new(), HashMap::new()));
}
const BAKED_IMAGE_CACHE_LIMIT: usize = 32;
fn baked_image(source: &Arc<[u8]>, opacity: f32) -> Arc<[u8]> {
let key = (source.as_ptr() as usize, source.len(), opacity.to_bits());
if let Some(hit) =
BAKED_IMAGES.with(|cache| cache.borrow().1.get(&key).map(|(_, baked)| Arc::clone(baked)))
{
return hit;
}
let mut baked = source.to_vec();
for pixel in baked.as_chunks_mut::<4>().0 {
pixel[3] = (pixel[3] as f32 * opacity).round().clamp(0.0, 255.0) as u8;
}
let baked: Arc<[u8]> = Arc::from(baked);
BAKED_IMAGES.with(|cache| {
let mut cache = cache.borrow_mut();
if cache.0.len() >= BAKED_IMAGE_CACHE_LIMIT {
if let Some(oldest) = cache.0.pop_front() {
cache.1.remove(&oldest);
}
}
cache.0.push_back(key);
cache.1.insert(key, (Arc::clone(source), Arc::clone(&baked)));
});
baked
}
fn crop_source_rect(
data: &[u8],
source_width: u32,
source_height: u32,
source: Rect,
) -> Option<Vec<u8>> {
if source_width == 0 || source_height == 0 {
return None;
}
let expected = (source_width as usize)
.checked_mul(source_height as usize)
.and_then(|pixels| pixels.checked_mul(4))?;
if data.len() < expected {
log::warn!(
"[batch] DrawImageSubrect source buffer is {} bytes but {source_width}x{source_height} \
needs {expected}; dropping",
data.len()
);
return None;
}
let left = i64::from(source.x).max(0);
let top = i64::from(source.y).max(0);
let right = (i64::from(source.x) + i64::from(source.width)).min(i64::from(source_width));
let bottom = (i64::from(source.y) + i64::from(source.height)).min(i64::from(source_height));
if right <= left || bottom <= top {
return None;
}
let width = (right - left) as usize;
let height = (bottom - top) as usize;
let mut cropped = vec![0u8; width * height * 4];
let source_stride = source_width as usize * 4;
let row_bytes = width * 4;
for row in 0..height {
let src = (top as usize + row) * source_stride + left as usize * 4;
let dst = row * row_bytes;
cropped[dst..dst + row_bytes].copy_from_slice(&data[src..src + row_bytes]);
}
Some(cropped)
}
type CroppedImageKey = (usize, u32, u32, i32, i32, u32, u32);
type CroppedImageEntry = (Arc<[u8]>, Arc<[u8]>);
thread_local! {
#[cfg_attr(alloc_frugal, allow(clippy::missing_const_for_thread_local))]
static CROPPED_IMAGES: core::cell::RefCell<(VecDeque<CroppedImageKey>, HashMap<CroppedImageKey, CroppedImageEntry>)> =
core::cell::RefCell::new((VecDeque::new(), HashMap::new()));
}
const CROPPED_IMAGE_CACHE_LIMIT: usize = 32;
fn crop_source_rect_cached(
source: &Arc<[u8]>,
source_width: u32,
source_height: u32,
rect: Rect,
) -> Option<Arc<[u8]>> {
let key = (
source.as_ptr() as usize,
source_width,
source_height,
rect.x,
rect.y,
rect.width,
rect.height,
);
if let Some(hit) = CROPPED_IMAGES
.with(|cache| cache.borrow().1.get(&key).map(|(_, cropped)| Arc::clone(cropped)))
{
return Some(hit);
}
let cropped: Arc<[u8]> = crop_source_rect(source, source_width, source_height, rect)?.into();
CROPPED_IMAGES.with(|cache| {
let mut cache = cache.borrow_mut();
if cache.0.len() >= CROPPED_IMAGE_CACHE_LIMIT {
if let Some(oldest) = cache.0.pop_front() {
cache.1.remove(&oldest);
}
}
cache.0.push_back(key);
cache.1.insert(key, (Arc::clone(source), Arc::clone(&cropped)));
});
Some(cropped)
}
#[cfg(test)]
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static IMAGE_BYTES_TRANSLATED: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
}
#[cfg(test)]
fn note_image_bytes_translated(bytes: usize) {
IMAGE_BYTES_TRANSLATED.with(|cell| cell.set(cell.get() + bytes));
}
#[cfg(test)]
fn take_image_bytes_translated() -> usize {
IMAGE_BYTES_TRANSLATED.with(|cell| cell.replace(0))
}
fn replay_into(
batches: &HashMap<BatchId, Vec<BatchCommand>>,
images: &HashMap<ObjectId, Arc<[u8]>>,
surface: &mut SoftwareSurface,
id: BatchId,
) {
let Some(cmds) = batches.get(&id) else {
return;
};
let mut state = TransformState::default();
let mut backend = SurfacePaintBackend(surface);
for cmd in cmds {
if let Some(rc) = transform_command(cmd, images, &mut state) {
backend.execute_command(&rc);
}
}
}
impl BatchRenderer for SoftwarePaintBackend {
fn begin_batch(&mut self) -> BatchId {
self.batch_state.begin_batch()
}
fn end_batch(&mut self) {
self.batch_state.end_batch()
}
fn record(&mut self, cmd: BatchCommand) -> Result<(), BatchError> {
self.batch_state.record(cmd)
}
fn replay(&mut self, id: BatchId) {
let SoftwarePaintBackend { surface, batch_state } = self;
let BatchState { batches, images, .. } = batch_state;
replay_into(batches, images, surface, id);
}
fn destroy_batch(&mut self, id: BatchId) {
self.batch_state.destroy_batch(id)
}
fn contains_batch(&self, id: BatchId) -> bool {
self.batch_state.contains_batch(id)
}
fn batch_count(&self) -> usize {
self.batch_state.batch_count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_id_new_and_get() {
let id = BatchId::new(42);
assert_eq!(id.get(), 42);
}
#[test]
fn batch_id_from_u64() {
let id: BatchId = 99u64.into();
assert_eq!(id.get(), 99);
}
#[test]
fn batch_id_equality_and_hash() {
let a = BatchId::new(1);
let b = BatchId::new(1);
let c = BatchId::new(2);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn batch_id_copy_behavior() {
let id = BatchId::new(7);
let copied = id; assert_eq!(id, copied);
}
#[test]
fn batch_command_fill_rect_roundtrip() {
let cmd = BatchCommand::FillRect { rect: Rect::new(10, 20, 100, 200), color: Color::RED };
match cmd {
BatchCommand::FillRect { rect, color } => {
assert_eq!(rect, Rect::new(10, 20, 100, 200));
assert_eq!(color, Color::RED);
}
_ => panic!("expected FillRect variant"),
}
}
#[test]
fn batch_command_stroke_rect_roundtrip() {
let cmd = BatchCommand::StrokeRect {
rect: Rect::new(5, 5, 50, 50),
color: Color::GREEN,
width: 2.0,
};
match cmd {
BatchCommand::StrokeRect { rect, color, width } => {
assert_eq!(rect, Rect::new(5, 5, 50, 50));
assert_eq!(color, Color::GREEN);
assert!((width - 2.0).abs() < 1e-6);
}
_ => panic!("expected StrokeRect variant"),
}
}
#[test]
fn batch_command_draw_line_roundtrip() {
let from = Point::new(0, 0);
let to = Point::new(100, 100);
let cmd = BatchCommand::DrawLine { from, to, color: Color::BLUE, width: 3.0 };
match cmd {
BatchCommand::DrawLine { from: f, to: t, color, width } => {
assert_eq!(f, from);
assert_eq!(t, to);
assert_eq!(color, Color::BLUE);
assert!((width - 3.0).abs() < 1e-6);
}
_ => panic!("expected DrawLine variant"),
}
}
#[test]
fn batch_command_draw_image_roundtrip() {
let cmd =
BatchCommand::DrawImage { rect: Rect::new(0, 0, 32, 32), image_id: 1u64, opacity: 0.8 };
match cmd {
BatchCommand::DrawImage { rect, image_id, opacity } => {
assert_eq!(rect, Rect::new(0, 0, 32, 32));
assert_eq!(image_id, 1u64);
assert!((opacity - 0.8).abs() < 1e-6);
}
_ => panic!("expected DrawImage variant"),
}
}
#[test]
fn batch_command_draw_image_subrect_roundtrip() {
let cmd = BatchCommand::DrawImageSubrect {
dest: Rect::new(10, 10, 64, 64),
source: Rect::new(0, 0, 32, 32),
source_width: 128,
source_height: 64,
image_id: 2u64,
opacity: 0.5,
};
match cmd {
BatchCommand::DrawImageSubrect {
dest,
source,
source_width,
source_height,
image_id,
opacity,
} => {
assert_eq!(dest, Rect::new(10, 10, 64, 64));
assert_eq!(source, Rect::new(0, 0, 32, 32));
assert_eq!((source_width, source_height), (128, 64));
assert_eq!(image_id, 2u64);
assert!((opacity - 0.5).abs() < 1e-6);
}
_ => panic!("expected DrawImageSubrect variant"),
}
}
#[test]
fn batch_command_draw_text_roundtrip() {
let cmd = BatchCommand::DrawText {
position: Point::new(15, 30),
text: Arc::from("Hello"),
color: Color::WHITE,
font_size: 16.0,
};
match cmd {
BatchCommand::DrawText { position, text, color, font_size } => {
assert_eq!(position, Point::new(15, 30));
assert_eq!(&*text, "Hello");
assert_eq!(color, Color::WHITE);
assert!((font_size - 16.0).abs() < 1e-6);
}
_ => panic!("expected DrawText variant"),
}
}
#[test]
fn replaying_a_label_shares_the_recorded_text() {
let text: Arc<str> = Arc::from("a label shared by recording and replay");
let recorded = BatchCommand::DrawText {
position: Point::new(4, 8),
text: Arc::clone(&text),
color: Color::BLACK,
font_size: 12.0,
};
let mut state = TransformState::default();
let translated = transform_command(&recorded, &HashMap::new(), &mut state)
.expect("a text command translates");
match translated {
RenderCommand::DrawText { text: replayed, .. } => {
assert!(Arc::ptr_eq(&replayed, &text), "replay clones the refcount, not the text");
}
other => panic!("expected DrawText, got {other:?}"),
}
}
#[test]
fn batch_command_push_clip_roundtrip() {
let cmd = BatchCommand::PushClip { rect: Rect::new(0, 0, 800, 600) };
match cmd {
BatchCommand::PushClip { rect } => {
assert_eq!(rect, Rect::new(0, 0, 800, 600));
}
_ => panic!("expected PushClip variant"),
}
}
#[test]
fn batch_command_pop_clip_roundtrip() {
let cmd = BatchCommand::PopClip;
match cmd {
BatchCommand::PopClip => {} _ => panic!("expected PopClip variant"),
}
}
#[test]
fn batch_command_translate_roundtrip() {
let cmd = BatchCommand::Translate { dx: 10.0, dy: 20.0 };
match cmd {
BatchCommand::Translate { dx, dy } => {
assert!((dx - 10.0).abs() < 1e-6);
assert!((dy - 20.0).abs() < 1e-6);
}
_ => panic!("expected Translate variant"),
}
}
#[test]
fn batch_command_set_opacity_roundtrip() {
let cmd = BatchCommand::SetOpacity { opacity: 0.75 };
match cmd {
BatchCommand::SetOpacity { opacity } => {
assert!((opacity - 0.75).abs() < 1e-6);
}
_ => panic!("expected SetOpacity variant"),
}
}
#[cfg(not(feature = "3d"))]
#[test]
fn transform_state_is_byte_identical_when_3d_is_off() {
assert_eq!(core::mem::size_of::<TransformState>(), 12);
}
#[cfg(feature = "3d")]
#[test]
fn a_pushed_transform_turns_a_fill_rect_into_a_quad_and_pop_restores_it() {
let mut state = TransformState::default();
let images = HashMap::new();
let rotation = Mat3::translation(-5.0, -5.0)
.then(&Mat3::rotation(core::f32::consts::FRAC_PI_4))
.then(&Mat3::translation(5.0, 5.0));
assert!(transform_command(
&BatchCommand::PushTransform { matrix: rotation },
&images,
&mut state
)
.is_none());
let rotated = transform_command(
&BatchCommand::FillRect { rect: Rect::new(0, 0, 10, 10), color: Color::RED },
&images,
&mut state,
);
match rotated {
Some(RenderCommand::FillQuad { corners, .. }) => {
assert_eq!(corners.len(), 4, "a rotated rectangle is four corners");
assert_ne!(corners[0].x, corners[1].x, "the top edge is no longer axis-aligned");
}
_ => panic!("a non-identity transform must produce FillQuad"),
}
assert!(transform_command(&BatchCommand::PopTransform, &images, &mut state).is_none());
let restored = transform_command(
&BatchCommand::FillRect { rect: Rect::new(0, 0, 10, 10), color: Color::RED },
&images,
&mut state,
);
assert!(
matches!(restored, Some(RenderCommand::FillRect { .. })),
"PopTransform must restore the identity and the 2D fill path"
);
}
#[cfg(feature = "3d")]
#[test]
fn a_pushed_transform_turns_a_stroke_into_four_mapped_edges() {
let mut state = TransformState::default();
let images = HashMap::new();
let rotation = Mat3::rotation(core::f32::consts::FRAC_PI_4);
assert!(transform_command(
&BatchCommand::PushTransform { matrix: rotation },
&images,
&mut state
)
.is_none());
let stroked = transform_command(
&BatchCommand::StrokeRect {
rect: Rect::new(0, 0, 10, 10),
color: Color::RED,
width: 2.0,
},
&images,
&mut state,
);
match stroked {
Some(RenderCommand::StrokeQuad { corners, width, .. }) => {
assert_eq!(width, 2, "the stroke width is in destination pixels");
for index in 0..4 {
let a = corners[index];
let b = corners[(index + 1) % 4];
assert!(a != b, "edge {index} must have length");
}
assert_ne!(corners[0].y, corners[1].y, "a rotated edge is no longer axis-aligned");
}
_ => panic!("a rotated stroke must produce StrokeQuad, not a bounding box"),
}
}
#[cfg(feature = "3d")]
#[test]
fn a_rotated_image_maps_its_source_grid_onto_the_quad() {
let mut state = TransformState::default();
let mut images = HashMap::new();
images.insert(7u64, Arc::from(vec![255u8; 2 * 2 * 4]));
let rotation = Mat3::rotation(core::f32::consts::FRAC_PI_4);
assert!(transform_command(
&BatchCommand::PushTransform { matrix: rotation },
&images,
&mut state
)
.is_none());
let drawn = transform_command(
&BatchCommand::DrawImage { rect: Rect::new(0, 0, 2, 2), image_id: 7, opacity: 1.0 },
&images,
&mut state,
);
match drawn {
Some(RenderCommand::DrawImageQuad { corners, width, height, data }) => {
assert_eq!((width, height), (2, 2), "the source grid is the untransformed rect");
assert_eq!(data.len(), 2 * 2 * 4, "the pixels travel with the quad");
assert_ne!(corners[0].x, corners[1].x, "the top edge rotated");
}
_ => panic!("a rotated image must produce DrawImageQuad"),
}
}
#[cfg(feature = "3d")]
#[test]
fn text_under_an_affine_follows_the_transform_scale() {
let mut state = TransformState::default();
let images = HashMap::new();
assert!(transform_command(
&BatchCommand::PushTransform { matrix: Mat3::scaling(2.0, 2.0) },
&images,
&mut state
)
.is_none());
let drawn = transform_command(
&BatchCommand::DrawText {
position: crate::core::Point::new(0, 0),
text: Arc::from("hi"),
color: Color::BLACK,
font_size: 12.0,
},
&images,
&mut state,
);
match drawn {
Some(RenderCommand::DrawText { font, .. }) => {
assert!(
(font.size() - 24.0).abs() < 0.01,
"a 2x scale must double the point size, got {}",
font.size()
);
}
_ => panic!("text under an affine is still a DrawText"),
}
}
#[cfg(feature = "3d")]
#[test]
fn an_unbalanced_pop_transform_leaves_the_matrix_untouched() {
let mut state = TransformState::default();
let images = HashMap::new();
assert!(transform_command(&BatchCommand::PopTransform, &images, &mut state).is_none());
assert_eq!(state.matrix, Mat3::IDENTITY);
}
#[test]
fn batch_state_initial_state() {
let state = BatchState::new();
assert_eq!(state.batch_count(), 0);
assert!(!state.contains_batch(BatchId::new(0)));
assert!(state.current_batch.is_none());
}
#[test]
fn batch_state_begin_end_batch_increments_id() {
let mut state = BatchState::new();
let id1 = state.begin_batch();
assert_eq!(id1, BatchId::new(0));
assert_eq!(state.batch_count(), 1);
assert!(state.contains_batch(id1));
state.end_batch();
let id2 = state.begin_batch();
assert_eq!(id2, BatchId::new(1));
assert_eq!(state.batch_count(), 2);
state.end_batch();
}
#[test]
fn batch_state_record_commands() {
let mut state = BatchState::new();
let id = state.begin_batch();
state
.record(BatchCommand::FillRect { rect: Rect::new(0, 0, 50, 50), color: Color::RED })
.unwrap();
state.record(BatchCommand::PopClip).unwrap();
state.end_batch();
let cmds = state.batches.get(&id).unwrap();
assert_eq!(cmds.len(), 2);
assert!(matches!(cmds[0], BatchCommand::FillRect { .. }));
assert!(matches!(cmds[1], BatchCommand::PopClip));
}
#[test]
fn batch_state_record_without_begin_returns_error() {
let mut state = BatchState::new();
let result = state.record(BatchCommand::PopClip);
assert_eq!(result, Err(BatchError::NoActiveBatch));
}
#[test]
fn batch_state_destroy_batch_removes_it() {
let mut state = BatchState::new();
let id = state.begin_batch();
state.end_batch();
assert_eq!(state.batch_count(), 1);
state.destroy_batch(id);
assert_eq!(state.batch_count(), 0);
assert!(!state.contains_batch(id));
}
#[test]
fn batch_state_destroy_batch_clears_current() {
let mut state = BatchState::new();
let id = state.begin_batch();
state.destroy_batch(id); assert!(state.current_batch.is_none());
}
#[test]
fn batch_state_replay_nonexistent_id_is_noop() {
let size = crate::core::Size::new(1, 1);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.replay(BatchId::new(999));
}
#[test]
fn batch_state_translate_command_skip_translate_and_set_opacity() {
let mut state = BatchState::new();
let id = state.begin_batch();
state.record(BatchCommand::Translate { dx: 5.0, dy: 5.0 }).unwrap();
state.record(BatchCommand::SetOpacity { opacity: 0.5 }).unwrap();
state.end_batch();
let cmds = state.batches.get(&id).unwrap();
assert_eq!(cmds.len(), 2);
assert!(matches!(cmds[0], BatchCommand::Translate { .. }));
assert!(matches!(cmds[1], BatchCommand::SetOpacity { .. }));
}
#[test]
fn batch_renderer_trait_begin_end_record() {
let size = crate::core::Size::new(100, 100);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
let id = backend.begin_batch();
backend
.record(BatchCommand::FillRect { rect: Rect::new(0, 0, 10, 10), color: Color::RED })
.unwrap();
backend.end_batch();
assert!(backend.contains_batch(id));
}
#[test]
fn batch_renderer_destroy_batch() {
let size = crate::core::Size::new(100, 100);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
let id = backend.begin_batch();
backend.end_batch();
assert_eq!(backend.batch_count(), 1);
backend.destroy_batch(id);
assert_eq!(backend.batch_count(), 0);
}
#[test]
fn batch_renderer_replay_fill_rect() {
let size = crate::core::Size::new(50, 50);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let id = backend.begin_batch();
backend
.record(BatchCommand::FillRect { rect: Rect::new(5, 5, 10, 10), color: Color::RED })
.unwrap();
backend.end_batch();
backend.replay(id);
backend.end_frame();
let rgba = backend.frame_rgba();
let stride = 50 * 4;
let idx = 10 * stride + 10 * 4;
assert_eq!(rgba[idx], 255); assert_eq!(rgba[idx + 1], 0); assert_eq!(rgba[idx + 2], 0); assert_eq!(rgba[idx + 3], 255); }
#[test]
fn replaying_a_batch_does_not_copy_unrelated_batches_or_images() {
fn replay_and_measure(unrelated_image_bytes: usize) -> usize {
let size = crate::core::Size::new(64, 64);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let target = backend.begin_batch();
backend
.record(BatchCommand::FillRect { rect: Rect::new(2, 2, 8, 8), color: Color::RED })
.unwrap();
backend.end_batch();
for image_index in 0..2u64 {
let object_id = 1000 + image_index;
backend
.batch_state
.images
.insert(object_id, Arc::from(vec![7u8; unrelated_image_bytes]));
let other = backend.begin_batch();
backend
.record(BatchCommand::DrawImage {
rect: Rect::new(0, 0, 64, 64),
image_id: object_id,
opacity: 1.0,
})
.unwrap();
backend.end_batch();
let _ = other;
}
let _ = take_image_bytes_translated();
backend.replay(target);
take_image_bytes_translated()
}
let small = replay_and_measure(64 * 64 * 4);
let large = replay_and_measure(512 * 64 * 4);
assert_eq!(small, 0, "replaying a batch with no image must copy no image bytes");
assert_eq!(
large, 0,
"unrelated images must not be copied when a different batch is replayed"
);
}
#[test]
fn replay_matches_direct_execution_including_opacity_and_translate() {
let size = crate::core::Size::new(32, 32);
let mut reference = SoftwarePaintBackend::new(size, 1.0);
reference.begin_frame(Color::BLACK);
reference.execute_command(&RenderCommand::FillRect {
rect: Rect::new(0, 0, 10, 10),
color: Color::rgba(255, 0, 0, 128),
});
reference.execute_command(&RenderCommand::FillRect {
rect: Rect::new(4, 4, 6, 6),
color: Color::rgba(0, 255, 0, 128),
});
reference.end_frame();
let expected = reference.frame_rgba().to_vec();
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::BLACK);
let id = backend.begin_batch();
backend
.record(BatchCommand::FillRect {
rect: Rect::new(0, 0, 10, 10),
color: Color::rgba(255, 0, 0, 128),
})
.unwrap();
backend.record(BatchCommand::Translate { dx: 3.0, dy: 3.0 }).unwrap();
backend
.record(BatchCommand::FillRect {
rect: Rect::new(1, 1, 6, 6),
color: Color::rgba(0, 255, 0, 128),
})
.unwrap();
backend.end_batch();
backend.replay(id);
backend.end_frame();
assert_eq!(
backend.frame_rgba(),
expected.as_slice(),
"replay must paint the same pixels as executing the translated commands directly"
);
}
#[test]
fn batch_renderer_contains_batch_after_creation() {
let size = crate::core::Size::new(10, 10);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
let id = backend.begin_batch();
backend
.record(BatchCommand::DrawLine {
from: Point::new(0, 0),
to: Point::new(10, 10),
color: Color::RED,
width: 1.0,
})
.unwrap();
backend.end_batch();
assert!(backend.contains_batch(id));
assert_eq!(backend.batch_count(), 1);
}
#[cfg(feature = "3d")]
fn item_turn_matrix() -> Mat4 {
use crate::render::math3d::Axis;
let distance = 1024.0f32;
Mat4::translation(-50.0, -50.0, 0.0)
.then(&Mat4::rotation(Axis::Y, core::f32::consts::FRAC_PI_4))
.then(&Mat4::translation(0.0, 0.0, -distance))
.then(&Mat4::perspective(
core::f32::consts::FRAC_PI_2,
1.0,
distance * 0.5,
distance * 4.0,
))
.then(&Mat4::from_affine(Mat3::scaling(distance, distance)))
.then(&Mat4::translation(50.0, 50.0, 0.0))
}
#[cfg(feature = "3d")]
#[test]
fn a_projective_fill_reaches_a_pixel_and_keeps_its_perspective() {
use crate::core::Size;
let images = crate::compat::HashMap::new();
let mut state = TransformState::default();
assert!(transform_command(
&BatchCommand::PushProjectiveTransform { matrix: item_turn_matrix() },
&images,
&mut state,
)
.is_none());
let command = transform_command(
&BatchCommand::FillRect { rect: Rect::new(0, 0, 100, 100), color: Color::RED },
&images,
&mut state,
)
.expect("a projected fill must produce a command");
let corners = match command {
RenderCommand::FillQuadAA { corners, color } => {
assert_eq!(color, Color::RED);
corners
}
other => panic!("a perspective fill must use the antialiased quad, got {other:?}"),
};
assert!(
transform_command(&BatchCommand::PopProjectiveTransform, &images, &mut state).is_none()
);
assert!(!state.is_projective(), "the pop restores the flat state");
let left_height = (corners[0].y - corners[3].y).abs();
let right_height = (corners[1].y - corners[2].y).abs();
assert_ne!(left_height, right_height, "the two vertical edges must differ");
let mut backend = SoftwarePaintBackend::new(Size::new(120, 120), 1.0);
backend.begin_frame(Color::WHITE);
backend.execute_command(&RenderCommand::FillQuadAA { corners, color: Color::RED });
backend.end_frame();
let frame = backend.frame_rgba();
assert!(
frame.as_chunks::<4>().0.iter().any(|pixel| pixel[0] > 200 && pixel[1] < 40),
"the projected quad must leave red ink on the surface"
);
}
#[cfg(feature = "3d")]
#[test]
fn an_identity_projective_push_paints_like_the_2d_path() {
use crate::core::Size;
let images = crate::compat::HashMap::new();
let mut with_push = TransformState::default();
assert!(transform_command(
&BatchCommand::PushProjectiveTransform { matrix: Mat4::IDENTITY },
&images,
&mut with_push,
)
.is_none());
let pushed = transform_command(
&BatchCommand::FillRect { rect: Rect::new(2, 3, 8, 6), color: Color::RED },
&images,
&mut with_push,
)
.expect("a fill under an identity push still draws");
let mut plain = TransformState::default();
let direct = transform_command(
&BatchCommand::FillRect { rect: Rect::new(2, 3, 8, 6), color: Color::RED },
&images,
&mut plain,
)
.expect("a fill without a push draws");
assert!(
matches!(&pushed, RenderCommand::FillRect { .. })
&& matches!(&direct, RenderCommand::FillRect { .. }),
"identity must not change the emitted command kind"
);
let paint = |command: &RenderCommand| {
let mut backend = SoftwarePaintBackend::new(Size::new(16, 16), 1.0);
backend.begin_frame(Color::WHITE);
backend.execute_command(command);
backend.end_frame();
backend.frame_rgba().to_vec()
};
assert_eq!(
paint(&pushed),
paint(&direct),
"identity must produce the same pixels through either path"
);
}
#[cfg(feature = "3d")]
#[test]
fn a_projective_fill_behind_the_eye_is_dropped() {
let images = crate::compat::HashMap::new();
let mut state = TransformState::default();
let mut behind = Mat4::IDENTITY;
behind.m[15] = -1.0;
assert!(transform_command(
&BatchCommand::PushProjectiveTransform { matrix: behind },
&images,
&mut state,
)
.is_none());
assert!(
transform_command(
&BatchCommand::FillRect { rect: Rect::new(0, 0, 10, 10), color: Color::RED },
&images,
&mut state,
)
.is_none(),
"a quad with no projectable corner must draw nothing"
);
}
#[cfg(feature = "3d")]
#[test]
fn an_affine_push_still_emits_a_hard_quad() {
let images = crate::compat::HashMap::new();
let mut state = TransformState::default();
let rotation = Mat3::rotation(core::f32::consts::FRAC_PI_6);
assert!(transform_command(
&BatchCommand::PushTransform { matrix: rotation },
&images,
&mut state,
)
.is_none());
let command = transform_command(
&BatchCommand::FillRect { rect: Rect::new(0, 0, 10, 10), color: Color::RED },
&images,
&mut state,
)
.expect("an affine fill produces a command");
assert!(
matches!(command, RenderCommand::FillQuad { .. }),
"the affine path stays on the exact quad, got {command:?}"
);
}
}