motion-content 0.2.0

Backend-independent images, text, vectors, particles and artwork
Documentation
//! Brush material baking: tiled distance-field material over a stroke's ink and paint.

use super::super::{Config, Prepared, invalid};
use super::geometry::{Sample, outline, rotated, samples};
use crate::ImageAsset;
use crate::vector::ink::paint::paint_sample;
use motion_core::{GradientExtend, ImagePattern, Paint, Point, Rect, Transform, ValidationError};
use std::sync::Arc;

/// Segments per culling block in the material distance search.
const MATERIAL_BLOCK: usize = 16;

pub(super) struct MaterialSegment {
    first: Sample,
    delta: motion_core::Vector2,
    square: f64,
    angle_delta: f64,
    radius_delta: f64,
    arc_delta: f64,
}

impl MaterialSegment {
    pub(super) fn new(first: Sample, second: Sample) -> Self {
        let delta = second.point - first.point;
        Self {
            first,
            delta,
            square: delta.x * delta.x + delta.y * delta.y,
            angle_delta: second.angle - first.angle,
            radius_delta: second.radius - first.radius,
            arc_delta: second.arc - first.arc,
        }
    }

    pub(super) fn measure(&self, point: Point, aspect: f64) -> (f64, f64, f64, f64) {
        let offset = point - self.first.point;
        let amount = if self.square > 0.0 {
            ((offset.x * self.delta.x + offset.y * self.delta.y) / self.square).clamp(0.0, 1.0)
        } else {
            0.0
        };
        let center = self.first.point + self.delta * amount;
        let offset = point - center;
        let angle = self.first.angle + self.angle_delta * amount;
        let local = rotated(offset.x, offset.y, -angle);
        let radius = self.first.radius + self.radius_delta * amount;
        let distance =
            libm::sqrt(local.x * local.x + local.y * local.y / (aspect * aspect)) - radius;
        (
            distance,
            self.first.arc + self.arc_delta * amount,
            offset.x * self.first.tangent.y - offset.y * self.first.tangent.x,
            radius,
        )
    }
}

pub(super) fn material(stroke: &Config, bounds: Rect) -> Result<Vec<Prepared>, ValidationError> {
    let options = &stroke.advanced;
    let groups = samples(stroke, 1.0)?;
    let path = Arc::new(outline(stroke, &groups)?);
    let segments: Vec<_> = groups
        .iter()
        .flat_map(|group| {
            group
                .windows(2)
                .map(|pair| MaterialSegment::new(pair[0], pair[1]))
        })
        .collect();
    // Boxes widened by the widest radius bound the distance from below, so blocks of
    // far segments are skipped without changing the nearest result or the overlap count.
    let boxes: Vec<_> = groups
        .iter()
        .flat_map(|group| group.windows(2).map(|pair| (pair[0], pair[1])))
        .map(|(first, second)| {
            (
                [
                    first.point.x.min(second.point.x),
                    first.point.y.min(second.point.y),
                ],
                [
                    first.point.x.max(second.point.x),
                    first.point.y.max(second.point.y),
                ],
                first.radius.max(second.radius),
            )
        })
        .collect();
    let blocks: Vec<_> = boxes
        .chunks(MATERIAL_BLOCK)
        .map(|chunk| {
            chunk.iter().skip(1).fold(chunk[0], |total, item| {
                (
                    [total.0[0].min(item.0[0]), total.0[1].min(item.0[1])],
                    [total.1[0].max(item.1[0]), total.1[1].max(item.1[1])],
                    total.2.max(item.2),
                )
            })
        })
        .collect();
    let length = stroke.path.try_length()?;
    let width = bounds.width().ceil().max(1.0);
    let height = bounds.height().ceil().max(1.0);
    if width > 16384.0
        || height > 16384.0
        || width * height > 16_777_216.0
        || (width * height) as u64 * segments.len() as u64 > stroke.material_work_limit
    {
        return Err(invalid(
            "material",
            "Extended material exceeds pixel or total evaluation allowance",
        ));
    }
    let width = width as u32;
    let height = height as u32;
    let columns = width.div_ceil(options.tile_size);
    let rows = height.div_ceil(options.tile_size);
    if u64::from(columns) * u64::from(rows) > 256 {
        return Err(invalid("material_tiles", "At most 256 material tiles"));
    }
    let paint = stroke.paint.clone();
    let paint_bounds = stroke.path.bounding_box();
    let solid = match &paint {
        Paint::Solid(_) => Some(paint_sample(&paint, Point::ORIGIN, paint_bounds)?),
        _ => None,
    };
    let ink = stroke.ink.clone().unwrap_or_default();
    let paper = ink.paper_sampler();
    let mut tiles = Vec::new();
    for row in 0..rows {
        for column in 0..columns {
            let first = column * options.tile_size;
            let top = row * options.tile_size;
            let tile_width = (width - first).min(options.tile_size);
            let tile_height = (height - top).min(options.tile_size);
            let mut pixels = vec![[0.0; 4]; tile_width as usize * tile_height as usize];
            let evaluate = |start: usize, pixels: &mut [[f64; 4]]| -> Result<(), ValidationError> {
                for (offset, pixel) in pixels.iter_mut().enumerate() {
                    let index = start + offset;
                    let point = Point::new(
                        bounds.left + f64::from(first) + (index % tile_width as usize) as f64 + 0.5,
                        bounds.top + f64::from(top) + (index / tile_width as usize) as f64 + 0.5,
                    );
                    let lower_bound = |bounds: &([f64; 2], [f64; 2], f64)| {
                        let horizontal =
                            (bounds.0[0] - point.x).max(point.x - bounds.1[0]).max(0.0);
                        let vertical = (bounds.0[1] - point.y).max(point.y - bounds.1[1]).max(0.0);
                        libm::hypot(horizontal, vertical) - bounds.2
                    };
                    let measure = |index: usize| segments[index].measure(point, stroke.aspect);
                    // The best block seeds the nearest distance, so ties still resolve to the
                    // lowest segment index and skipping becomes effective immediately.
                    let mut nearest = (f64::INFINITY, 0.0, 0.0, 0.0);
                    let mut nearest_index = usize::MAX;
                    if let Some((block, _)) = blocks
                        .iter()
                        .enumerate()
                        .map(|(block, bounds)| (block, lower_bound(bounds)))
                        .min_by(|left, right| left.1.total_cmp(&right.1))
                    {
                        for index in block * MATERIAL_BLOCK
                            ..((block + 1) * MATERIAL_BLOCK).min(segments.len())
                        {
                            let candidate = measure(index);
                            if candidate.0 < nearest.0 {
                                nearest = candidate;
                                nearest_index = index;
                            }
                        }
                    }
                    let mut overlaps = 0usize;
                    let mut previous_overlap = None;
                    for (block, bounds) in blocks.iter().enumerate() {
                        let lower = lower_bound(bounds);
                        if lower > 1e-6 && lower > nearest.0 + 1e-6 {
                            continue;
                        }
                        for index in block * MATERIAL_BLOCK
                            ..((block + 1) * MATERIAL_BLOCK).min(segments.len())
                        {
                            let candidate = measure(index);
                            if candidate.0 < 0.0
                                && previous_overlap.is_none_or(|previous| index > previous + 1)
                            {
                                overlaps += 1;
                                previous_overlap = Some(index);
                            }
                            if candidate.0 < nearest.0
                                || (candidate.0 == nearest.0 && index < nearest_index)
                            {
                                nearest = candidate;
                                nearest_index = index;
                            }
                        }
                    }
                    let paper_point = ink.paper_space(point);
                    let material = ink.sample((point.x, point.y), (paper_point.x, paper_point.y));
                    let load = stroke.load[0]
                        + (stroke.load[1] - stroke.load[0])
                            * if length > 0.0 {
                                nearest.1 / length
                            } else {
                                0.0
                            };
                    let mut alpha = material
                        .shade(
                            material.coverage(nearest.0, 1.0),
                            nearest.0,
                            (nearest.1, nearest.2 * ink.anisotropy),
                            load,
                        )
                        .clamp(0.0, 1.0);
                    if let Some(paper) = &paper {
                        alpha *= paper.factor(paper_point)?;
                    }
                    if overlaps > 1 && ink.build_up > 0.0 {
                        alpha = 1.0
                            - libm::pow(1.0 - alpha, 1.0 + ink.build_up * (overlaps - 1) as f64);
                    }
                    let mut color = match solid {
                        Some(color) => color,
                        None => paint_sample(&paint, point, paint_bounds)?,
                    };
                    let rim = (1.0 - nearest.0.abs() / 2.0).clamp(0.0, 1.0);
                    let darken = 1.0
                        - ink.edge_darkening * rim
                        - ink.build_up * (1.0 - 1.0 / overlaps.max(1) as f64) * 0.5;
                    for channel in &mut color[..3] {
                        *channel *= darken;
                    }
                    if ink.height > 0.0 {
                        let height = ink.height
                            * libm::sqrt((-nearest.0 / nearest.3.max(1e-12)).clamp(0.0, 1.0))
                            * load
                            * (1.0 + ink.build_up * overlaps.saturating_sub(1) as f64);
                        for channel in &mut color[..3] {
                            *channel = motion_core::color::linear_to_srgb(height);
                        }
                    }
                    color[3] *= alpha;
                    *pixel = color;
                }
                Ok(())
            };
            if options.parallel {
                let threads = std::thread::available_parallelism()
                    .map_or(1, usize::from)
                    .min(8);
                let chunk = pixels.len().div_ceil(threads).max(1);
                std::thread::scope(|scope| {
                    let handles: Vec<_> = pixels
                        .chunks_mut(chunk)
                        .enumerate()
                        .map(|(index, pixels)| {
                            let evaluate = &evaluate;
                            scope.spawn(move || evaluate(index * chunk, pixels))
                        })
                        .collect();
                    for handle in handles {
                        handle.join().map_err(|_| {
                            invalid("material_thread", "Material worker panicked")
                        })??;
                    }
                    Ok::<_, ValidationError>(())
                })?;
            } else {
                evaluate(0, &mut pixels)?;
            }
            let image = ImageAsset::from_rgba_float(tile_width, tile_height, pixels)
                .map_err(|error| invalid("material", &error.to_string()))?;
            let mut pattern = ImagePattern::from_asset(Arc::new(image))
                .transform(Transform::translate(
                    bounds.left + f64::from(first),
                    bounds.top + f64::from(top),
                ))
                .extend(GradientExtend::Pad)
                .sampling(motion_core::Sampling::Bilinear);
            pattern.decal = true;
            tiles.push(Prepared {
                path: path.clone(),
                paint: pattern.into(),
                bounds,
            });
        }
    }
    Ok(tiles)
}