diff options
Diffstat (limited to 'rust/crates/mask-defs/src')
| -rw-r--r-- | rust/crates/mask-defs/src/freeform.rs | 87 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/geometry.rs | 109 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/lib.rs | 191 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/paint.rs | 133 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/shapes.rs | 260 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/split.rs | 135 | ||||
| -rw-r--r-- | rust/crates/mask-defs/src/text_mask.rs | 303 |
7 files changed, 1218 insertions, 0 deletions
diff --git a/rust/crates/mask-defs/src/freeform.rs b/rust/crates/mask-defs/src/freeform.rs new file mode 100644 index 00000000..6c464977 --- /dev/null +++ b/rust/crates/mask-defs/src/freeform.rs @@ -0,0 +1,87 @@ +use scene::FreeformPathPoint; +use tiny_skia::{Path, PathBuilder}; + +fn local_to_canvas( + x: f64, + y: f64, + center_x: f64, + center_y: f64, + rotation: f64, + scale: f64, + width: f64, + height: f64, +) -> (f64, f64) { + let canvas_center_x = width / 2.0 + center_x * width; + let canvas_center_y = height / 2.0 + center_y * height; + let scaled_x = x * width * scale; + let scaled_y = y * height * scale; + let angle_rad = rotation.to_radians(); + let cos = angle_rad.cos(); + let sin = angle_rad.sin(); + ( + canvas_center_x + scaled_x * cos - scaled_y * sin, + canvas_center_y + scaled_x * sin + scaled_y * cos, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_freeform_path( + points: &[FreeformPathPoint], + center_x: f64, + center_y: f64, + rotation: f64, + scale: f64, + width: f64, + height: f64, + closed: bool, +) -> Option<Path> { + if points.is_empty() { + return None; + } + + let to_canvas = |x: f64, y: f64| { + local_to_canvas(x, y, center_x, center_y, rotation, scale, width, height) + }; + + let anchors: Vec<((f64, f64), (f64, f64), (f64, f64))> = points + .iter() + .map(|point| { + ( + to_canvas(point.x, point.y), + to_canvas(point.x + point.in_x, point.y + point.in_y), + to_canvas(point.x + point.out_x, point.y + point.out_y), + ) + }) + .collect(); + + let mut builder = PathBuilder::new(); + builder.move_to(anchors[0].0 .0 as f32, anchors[0].0 .1 as f32); + for index in 1..anchors.len() { + let previous = anchors[index - 1]; + let current = anchors[index]; + builder.cubic_to( + previous.2 .0 as f32, + previous.2 .1 as f32, + current.1 .0 as f32, + current.1 .1 as f32, + current.0 .0 as f32, + current.0 .1 as f32, + ); + } + + if closed && anchors.len() > 1 { + let last = anchors[anchors.len() - 1]; + let first = anchors[0]; + builder.cubic_to( + last.2 .0 as f32, + last.2 .1 as f32, + first.1 .0 as f32, + first.1 .1 as f32, + first.0 .0 as f32, + first.0 .1 as f32, + ); + builder.close(); + } + + builder.finish() +} diff --git a/rust/crates/mask-defs/src/geometry.rs b/rust/crates/mask-defs/src/geometry.rs new file mode 100644 index 00000000..c9ae6b66 --- /dev/null +++ b/rust/crates/mask-defs/src/geometry.rs @@ -0,0 +1,109 @@ +use scene::{RectangleMaskParams, StrokeAlign}; + +pub const MIN_MASK_DIMENSION: f64 = 0.01; + +pub const NORMAL_SNAP_EPSILON: f64 = 1e-10; +pub const MIN_POLYGON_AREA_PX: f64 = 0.5; +pub const INTERSECTION_EPSILON: f64 = 1e-6; +pub const LINE_PARALLEL_EPSILON: f64 = 1e-10; + +#[derive(Clone, Copy, Debug)] +pub struct BoxGeometry { + pub center_x: f64, + pub center_y: f64, + pub mask_width: f64, + pub mask_height: f64, + pub rotation_rad: f64, +} + +pub fn box_like_geometry(params: &RectangleMaskParams, width: f64, height: f64) -> BoxGeometry { + BoxGeometry { + center_x: width / 2.0 + params.center_x * width, + center_y: height / 2.0 + params.center_y * height, + mask_width: params.width.max(MIN_MASK_DIMENSION) * width, + mask_height: params.height.max(MIN_MASK_DIMENSION) * height, + rotation_rad: params.rotation.to_radians(), + } +} + +pub fn stroke_offset(stroke_align: StrokeAlign, stroke_width: f64) -> f64 { + match stroke_align { + StrokeAlign::Inside => -(stroke_width / 2.0), + StrokeAlign::Outside => stroke_width / 2.0, + StrokeAlign::Center => 0.0, + } +} + +pub fn rotate_point( + x: f64, + y: f64, + center_x: f64, + center_y: f64, + rotation_rad: f64, +) -> (f64, f64) { + let dx = x - center_x; + let dy = y - center_y; + let cos = rotation_rad.cos(); + let sin = rotation_rad.sin(); + (center_x + dx * cos - dy * sin, center_y + dx * sin + dy * cos) +} + +#[derive(Clone, Copy, Debug)] +pub struct SplitLine { + pub normal_x: f64, + pub normal_y: f64, + pub line_x: f64, + pub line_y: f64, +} + +pub fn split_line_geometry( + center_x: f64, + center_y: f64, + rotation: f64, + width: f64, + height: f64, +) -> SplitLine { + let angle_rad = rotation.to_radians(); + let cos = angle_rad.cos(); + let sin = angle_rad.sin(); + SplitLine { + normal_x: if cos.abs() < NORMAL_SNAP_EPSILON { 0.0 } else { cos }, + normal_y: if sin.abs() < NORMAL_SNAP_EPSILON { 0.0 } else { sin }, + line_x: width / 2.0 + center_x * width, + line_y: height / 2.0 + center_y * height, + } +} + +pub fn half_plane_sign(line: SplitLine, x: f64, y: f64) -> f64 { + (x - line.line_x) * line.normal_x + (y - line.line_y) * line.normal_y +} + +pub fn line_edge_intersection( + line: SplitLine, + x1: f64, + y1: f64, + x2: f64, + y2: f64, +) -> Option<(f64, f64)> { + let distance1 = half_plane_sign(line, x1, y1); + let distance2 = half_plane_sign(line, x2, y2); + let denom = distance1 - distance2; + if denom.abs() < LINE_PARALLEL_EPSILON { + return None; + } + let t = distance1 / denom; + if !(0.0..=1.0).contains(&t) { + return None; + } + Some((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)) +} + +pub fn polygon_area(vertices: &[(f64, f64)]) -> f64 { + let mut area = 0.0; + for i in 0..vertices.len() { + let (x1, y1) = vertices[i]; + let (x2, y2) = vertices[(i + 1) % vertices.len()]; + area += x1 * y2 - x2 * y1; + } + area.abs() * 0.5 +} diff --git a/rust/crates/mask-defs/src/lib.rs b/rust/crates/mask-defs/src/lib.rs index 8b137891..c86f5bdf 100644 --- a/rust/crates/mask-defs/src/lib.rs +++ b/rust/crates/mask-defs/src/lib.rs @@ -1 +1,192 @@ +mod freeform; +mod geometry; +mod paint; +mod shapes; +mod split; +mod text_mask; +pub use freeform::build_freeform_path; + +use scene::Mask; +use tiny_skia::Pixmap; + +#[derive(Debug, thiserror::Error)] +pub enum MaskRenderError { + #[error("invalid pixmap size {width}x{height}")] + InvalidSize { width: u32, height: u32 }, + #[error("invalid stroke color: {0}")] + InvalidStrokeColor(String), +} + +fn new_pixmap(width: u32, height: u32) -> Result<Pixmap, MaskRenderError> { + Pixmap::new(width, height).ok_or(MaskRenderError::InvalidSize { width, height }) +} + +pub fn is_active(mask: &Mask) -> bool { + match mask { + Mask::Text { params, .. } => !params.content.trim().is_empty(), + Mask::Freeform { params, .. } => params.closed, + _ => true, + } +} + +pub fn feather_for_gpu(mask: &Mask) -> f32 { + match mask { + Mask::Split { .. } => 0.0, + Mask::CinematicBars { params, .. } + | Mask::Rectangle { params, .. } + | Mask::Ellipse { params, .. } + | Mask::Heart { params, .. } + | Mask::Diamond { params, .. } + | Mask::Star { params, .. } => params.feather as f32, + Mask::Text { params, .. } => params.feather as f32, + Mask::Freeform { params, .. } => params.feather as f32, + } +} + +pub fn render_mask_body( + mask: &Mask, + width: u32, + height: u32, +) -> Result<Pixmap, MaskRenderError> { + let mut pixmap = new_pixmap(width, height)?; + let w = width as f64; + let h = height as f64; + match mask { + Mask::Split { params, .. } => { + if params.feather == 0.0 { + paint::fill_white(&split::split_opaque_body_path(params, w, h), &mut pixmap); + } else { + split::fill_split_feather_body(params, w, h, &mut pixmap); + } + } + Mask::CinematicBars { params, .. } => { + paint::fill_white(&shapes::cinematic_bars_body_path(params, w, h), &mut pixmap); + } + Mask::Rectangle { params, .. } => { + paint::fill_white(&shapes::rectangle_body_path(params, w, h), &mut pixmap); + } + Mask::Ellipse { params, .. } => { + paint::fill_white(&shapes::ellipse_body_path(params, w, h), &mut pixmap); + } + Mask::Heart { params, .. } => { + paint::fill_white(&shapes::heart_body_path(params, w, h), &mut pixmap); + } + Mask::Diamond { params, .. } => { + paint::fill_white(&shapes::diamond_body_path(params, w, h), &mut pixmap); + } + Mask::Star { params, .. } => { + paint::fill_white(&shapes::star_body_path(params, w, h), &mut pixmap); + } + Mask::Text { params, .. } => { + text_mask::render_body(params, w, h, &mut pixmap); + } + Mask::Freeform { params, .. } => { + if params.closed { + let path = build_freeform_path( + ¶ms.path, + params.center_x, + params.center_y, + params.rotation, + params.scale, + w, + h, + true, + ); + paint::fill_white(&path, &mut pixmap); + } + } + } + Ok(pixmap) +} + +pub fn render_mask_stroke( + mask: &Mask, + width: u32, + height: u32, +) -> Result<Option<Pixmap>, MaskRenderError> { + let (stroke_width, stroke_color) = match mask { + Mask::Split { params, .. } => (params.stroke_width, ¶ms.stroke_color), + Mask::CinematicBars { params, .. } + | Mask::Rectangle { params, .. } + | Mask::Ellipse { params, .. } + | Mask::Heart { params, .. } + | Mask::Diamond { params, .. } + | Mask::Star { params, .. } => (params.stroke_width, ¶ms.stroke_color), + Mask::Text { params, .. } => (params.stroke_width, ¶ms.stroke_color), + Mask::Freeform { params, .. } => (params.stroke_width, ¶ms.stroke_color), + }; + if stroke_width <= 0.0 { + return Ok(None); + } + + let mut pixmap = new_pixmap(width, height)?; + let w = width as f64; + let h = height as f64; + match mask { + Mask::Split { params, .. } => { + let color = paint::parse_stroke_color(stroke_color)?; + paint::stroke_path_with( + &split::split_stroke_path(params, w, h), + &paint::canvas_stroke(stroke_width), + color, + &mut pixmap, + ); + } + Mask::CinematicBars { .. } + | Mask::Rectangle { .. } + | Mask::Ellipse { .. } + | Mask::Heart { .. } + | Mask::Diamond { .. } + | Mask::Star { .. } => { + let path = match mask { + Mask::CinematicBars { params, .. } => { + shapes::cinematic_bars_stroke_path(params, w, h) + } + Mask::Rectangle { params, .. } => shapes::rectangle_stroke_path(params, w, h), + Mask::Ellipse { params, .. } => shapes::ellipse_stroke_path(params, w, h), + Mask::Heart { params, .. } => shapes::heart_stroke_path(params, w, h), + Mask::Diamond { params, .. } => shapes::diamond_stroke_path(params, w, h), + Mask::Star { params, .. } => shapes::star_stroke_path(params, w, h), + _ => unreachable!(), + }; + let color = paint::parse_stroke_color(stroke_color)?; + paint::stroke_path_with( + &path, + &paint::canvas_stroke(stroke_width), + color, + &mut pixmap, + ); + } + Mask::Text { params, .. } => { + text_mask::render_stroke(params, w, h, &mut pixmap)?; + } + Mask::Freeform { params, .. } => { + if params.closed { + let path = build_freeform_path( + ¶ms.path, + params.center_x, + params.center_y, + params.rotation, + params.scale, + w, + h, + true, + ); + let color = paint::parse_stroke_color(stroke_color)?; + paint::stroke_path_with( + &path, + &paint::round_stroke(stroke_width), + color, + &mut pixmap, + ); + if params.stroke_align != scene::StrokeAlign::Center { + let mut fill = new_pixmap(width, height)?; + paint::fill_white(&path, &mut fill); + paint::apply_stroke_align(&mut pixmap, &fill, params.stroke_align); + } + } + } + } + Ok(Some(pixmap)) +} diff --git a/rust/crates/mask-defs/src/paint.rs b/rust/crates/mask-defs/src/paint.rs new file mode 100644 index 00000000..0b122132 --- /dev/null +++ b/rust/crates/mask-defs/src/paint.rs @@ -0,0 +1,133 @@ +use scene::StrokeAlign; +use tiny_skia::{Color, Paint, Path, Pixmap, PremultipliedColorU8, Stroke, Transform}; + +use crate::MaskRenderError; + +pub fn white_paint() -> Paint<'static> { + Paint { + shader: tiny_skia::Shader::SolidColor(Color::from_rgba8(255, 255, 255, 255)), + anti_alias: true, + ..Paint::default() + } +} + +pub fn fill_white(path: &Option<Path>, pixmap: &mut Pixmap) { + if let Some(path) = path { + pixmap.fill_path( + path, + &white_paint(), + tiny_skia::FillRule::Winding, + Transform::default(), + None, + ); + } +} + +pub fn parse_stroke_color(value: &str) -> Result<Color, MaskRenderError> { + let hex = value + .trim() + .strip_prefix('#') + .ok_or_else(|| MaskRenderError::InvalidStrokeColor(value.to_string()))?; + let expand = |c: u8| (c << 4) | c; + let (r, g, b, a) = match hex.len() { + 3 => { + let v = u32::from_str_radix(hex, 16) + .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?; + ( + expand(((v >> 8) & 0xF) as u8), + expand(((v >> 4) & 0xF) as u8), + expand((v & 0xF) as u8), + 255, + ) + } + 4 => { + let v = u32::from_str_radix(hex, 16) + .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?; + ( + expand(((v >> 12) & 0xF) as u8), + expand(((v >> 8) & 0xF) as u8), + expand(((v >> 4) & 0xF) as u8), + expand((v & 0xF) as u8), + ) + } + 6 => { + let v = u32::from_str_radix(hex, 16) + .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?; + (((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8, 255) + } + 8 => { + let v = u32::from_str_radix(hex, 16) + .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?; + ( + ((v >> 24) & 0xFF) as u8, + ((v >> 16) & 0xFF) as u8, + ((v >> 8) & 0xFF) as u8, + (v & 0xFF) as u8, + ) + } + _ => return Err(MaskRenderError::InvalidStrokeColor(value.to_string())), + }; + Ok(Color::from_rgba8(r, g, b, a)) +} + +pub fn canvas_stroke(stroke_width: f64) -> Stroke { + Stroke { + width: stroke_width as f32, + miter_limit: 10.0, + line_cap: tiny_skia::LineCap::Butt, + line_join: tiny_skia::LineJoin::Miter, + ..Stroke::default() + } +} + +pub fn round_stroke(stroke_width: f64) -> Stroke { + Stroke { + width: stroke_width as f32, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..Stroke::default() + } +} + +pub fn stroke_path_with( + path: &Option<Path>, + stroke: &Stroke, + color: Color, + pixmap: &mut Pixmap, +) { + if let Some(path) = path { + let paint = Paint { + shader: tiny_skia::Shader::SolidColor(color), + anti_alias: true, + ..Paint::default() + }; + pixmap.stroke_path(path, &paint, stroke, Transform::default(), None); + } +} + +pub fn apply_stroke_align(stroke: &mut Pixmap, fill: &Pixmap, align: StrokeAlign) { + let keep_inside = match align { + StrokeAlign::Center => return, + StrokeAlign::Inside => true, + StrokeAlign::Outside => false, + }; + for (dst, src) in stroke.pixels_mut().iter_mut().zip(fill.pixels().iter()) { + let coverage = if keep_inside { + src.alpha() as u16 + } else { + 255 - src.alpha() as u16 + }; + let scale = |channel: u8| ((channel as u16 * coverage + 127) / 255) as u8; + let scaled = PremultipliedColorU8::from_rgba( + scale(dst.red()), + scale(dst.green()), + scale(dst.blue()), + scale(dst.alpha()), + ); + if let Some(scaled) = scaled { + *dst = scaled; + } else { + *dst = PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap(); + } + } +} diff --git a/rust/crates/mask-defs/src/shapes.rs b/rust/crates/mask-defs/src/shapes.rs new file mode 100644 index 00000000..890fc8fe --- /dev/null +++ b/rust/crates/mask-defs/src/shapes.rs @@ -0,0 +1,260 @@ +use scene::RectangleMaskParams; +use tiny_skia::{Path, PathBuilder, Transform}; + +use crate::geometry::{box_like_geometry, rotate_point, stroke_offset}; + +const STAR_INNER_RADIUS_RATIO: f64 = 0.45; +const STAR_VERTEX_COUNT: usize = 10; + +fn polygon_path(points: &[(f64, f64)]) -> Option<Path> { + let mut builder = PathBuilder::new(); + let first = points.first()?; + builder.move_to(first.0 as f32, first.1 as f32); + for point in &points[1..] { + builder.line_to(point.0 as f32, point.1 as f32); + } + builder.close(); + builder.finish() +} + +fn rotated_rect_path( + center_x: f64, + center_y: f64, + half_width: f64, + half_height: f64, + rotation_rad: f64, +) -> Option<Path> { + let corners = [ + (center_x - half_width, center_y - half_height), + (center_x + half_width, center_y - half_height), + (center_x + half_width, center_y + half_height), + (center_x - half_width, center_y + half_height), + ]; + let rotated: Vec<(f64, f64)> = corners + .iter() + .map(|&(x, y)| rotate_point(x, y, center_x, center_y, rotation_rad)) + .collect(); + polygon_path(&rotated) +} + +fn rotated_ellipse_path( + center_x: f64, + center_y: f64, + radius_x: f64, + radius_y: f64, + rotation_rad: f64, +) -> Option<Path> { + let rect = tiny_skia::Rect::from_ltrb( + -radius_x as f32, + -radius_y as f32, + radius_x as f32, + radius_y as f32, + )?; + let path = PathBuilder::from_oval(rect)?; + let cos = rotation_rad.cos() as f32; + let sin = rotation_rad.sin() as f32; + path.transform(Transform::from_row( + cos, + sin, + -sin, + cos, + center_x as f32, + center_y as f32, + )) +} + +fn heart_path( + center_x: f64, + center_y: f64, + half_width: f64, + half_height: f64, + rotation_rad: f64, +) -> Option<Path> { + let to_point = |local_x: f64, local_y: f64| { + rotate_point( + center_x + local_x, + center_y + local_y, + center_x, + center_y, + rotation_rad, + ) + }; + + let start = to_point(0.0, -half_height * 0.475); + let right_control1 = to_point(half_width, -half_height * 1.225); + let right_control2 = to_point(half_width, -half_height * 0.125); + let bottom = to_point(0.0, half_height * 0.725); + let left_control1 = to_point(-half_width, -half_height * 0.125); + let left_control2 = to_point(-half_width, -half_height * 1.225); + + let mut builder = PathBuilder::new(); + builder.move_to(start.0 as f32, start.1 as f32); + builder.cubic_to( + right_control1.0 as f32, + right_control1.1 as f32, + right_control2.0 as f32, + right_control2.1 as f32, + bottom.0 as f32, + bottom.1 as f32, + ); + builder.cubic_to( + left_control1.0 as f32, + left_control1.1 as f32, + left_control2.0 as f32, + left_control2.1 as f32, + start.0 as f32, + start.1 as f32, + ); + builder.close(); + builder.finish() +} + +fn diamond_path( + center_x: f64, + center_y: f64, + half_width: f64, + half_height: f64, + rotation_rad: f64, +) -> Option<Path> { + let points = [ + (center_x, center_y - half_height), + (center_x + half_width, center_y), + (center_x, center_y + half_height), + (center_x - half_width, center_y), + ]; + let rotated: Vec<(f64, f64)> = points + .iter() + .map(|&(x, y)| rotate_point(x, y, center_x, center_y, rotation_rad)) + .collect(); + polygon_path(&rotated) +} + +fn star_path( + center_x: f64, + center_y: f64, + half_width: f64, + half_height: f64, + rotation_rad: f64, +) -> Option<Path> { + let mut points = Vec::with_capacity(STAR_VERTEX_COUNT); + for index in 0..STAR_VERTEX_COUNT { + let is_outer = index % 2 == 0; + let ratio = if is_outer { 1.0 } else { STAR_INNER_RADIUS_RATIO }; + let angle = (index as f64 * std::f64::consts::PI) / 5.0 - std::f64::consts::FRAC_PI_2; + let x = center_x + half_width * ratio * angle.cos(); + let y = center_y + half_height * ratio * angle.sin(); + points.push(rotate_point(x, y, center_x, center_y, rotation_rad)); + } + polygon_path(&points) +} + +fn box_like_body_path( + params: &RectangleMaskParams, + width: f64, + height: f64, + build: fn(f64, f64, f64, f64, f64) -> Option<Path>, +) -> Option<Path> { + let geometry = box_like_geometry(params, width, height); + build( + geometry.center_x, + geometry.center_y, + geometry.mask_width / 2.0, + geometry.mask_height / 2.0, + geometry.rotation_rad, + ) +} + +fn box_like_stroke_path( + params: &RectangleMaskParams, + width: f64, + height: f64, + build: fn(f64, f64, f64, f64, f64) -> Option<Path>, +) -> Option<Path> { + let geometry = box_like_geometry(params, width, height); + let offset = stroke_offset(params.stroke_align, params.stroke_width); + build( + geometry.center_x, + geometry.center_y, + (geometry.mask_width / 2.0 + offset).max(1.0), + (geometry.mask_height / 2.0 + offset).max(1.0), + geometry.rotation_rad, + ) +} + +pub fn rectangle_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_body_path(params, width, height, rotated_rect_path) +} + +pub fn rectangle_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_stroke_path(params, width, height, rotated_rect_path) +} + +pub fn ellipse_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_body_path(params, width, height, rotated_ellipse_path) +} + +pub fn ellipse_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_stroke_path(params, width, height, rotated_ellipse_path) +} + +pub fn heart_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_body_path(params, width, height, heart_path) +} + +pub fn heart_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_stroke_path(params, width, height, heart_path) +} + +pub fn diamond_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_body_path(params, width, height, diamond_path) +} + +pub fn diamond_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_stroke_path(params, width, height, diamond_path) +} + +pub fn star_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_body_path(params, width, height, star_path) +} + +pub fn star_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> { + box_like_stroke_path(params, width, height, star_path) +} + +fn cinematic_bars_dimensions(params: &RectangleMaskParams, width: f64, height: f64) -> (f64, f64) { + ( + (params.width * width).max(width), + params.height.max(0.01) * height, + ) +} + +pub fn cinematic_bars_body_path( + params: &RectangleMaskParams, + width: f64, + height: f64, +) -> Option<Path> { + let (mask_width, mask_height) = cinematic_bars_dimensions(params, width, height); + rotated_rect_path( + width / 2.0 + params.center_x * width, + height / 2.0 + params.center_y * height, + mask_width / 2.0, + mask_height / 2.0, + params.rotation.to_radians(), + ) +} + +pub fn cinematic_bars_stroke_path( + params: &RectangleMaskParams, + width: f64, + height: f64, +) -> Option<Path> { + let (mask_width, mask_height) = cinematic_bars_dimensions(params, width, height); + let offset = stroke_offset(params.stroke_align, params.stroke_width); + rotated_rect_path( + width / 2.0 + params.center_x * width, + height / 2.0 + params.center_y * height, + (mask_width / 2.0 + offset).max(1.0), + (mask_height / 2.0 + offset).max(1.0), + params.rotation.to_radians(), + ) +} diff --git a/rust/crates/mask-defs/src/split.rs b/rust/crates/mask-defs/src/split.rs new file mode 100644 index 00000000..d2b67f78 --- /dev/null +++ b/rust/crates/mask-defs/src/split.rs @@ -0,0 +1,135 @@ +use scene::SplitMaskParams; +use tiny_skia::{ + Color, GradientStop, LinearGradient, Paint, Path, PathBuilder, Pixmap, Point, SpreadMode, + Transform, +}; + +use crate::geometry::{ + INTERSECTION_EPSILON, MIN_POLYGON_AREA_PX, SplitLine, half_plane_sign, line_edge_intersection, + polygon_area, split_line_geometry, +}; + +fn canvas_edges(width: f64, height: f64) -> [(f64, f64, f64, f64); 4] { + [ + (0.0, 0.0, width, 0.0), + (width, 0.0, width, height), + (width, height, 0.0, height), + (0.0, height, 0.0, 0.0), + ] +} + +fn points_equal(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() <= INTERSECTION_EPSILON && (a.1 - b.1).abs() <= INTERSECTION_EPSILON +} + +fn split_line(params: &SplitMaskParams, width: f64, height: f64) -> SplitLine { + split_line_geometry( + params.center_x, + params.center_y, + params.rotation, + width, + height, + ) +} + +pub fn split_stroke_segment( + params: &SplitMaskParams, + width: f64, + height: f64, +) -> Option<[(f64, f64); 2]> { + let line = split_line(params, width, height); + let mut intersections: Vec<(f64, f64)> = Vec::new(); + for &(x1, y1, x2, y2) in &canvas_edges(width, height) { + let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) else { + continue; + }; + if intersections.iter().any(|&point| points_equal(point, hit)) { + continue; + } + intersections.push(hit); + } + if intersections.len() != 2 { + return None; + } + Some([intersections[0], intersections[1]]) +} + +pub fn split_stroke_path(params: &SplitMaskParams, width: f64, height: f64) -> Option<Path> { + let segment = split_stroke_segment(params, width, height)?; + let mut builder = PathBuilder::new(); + builder.move_to(segment[0].0 as f32, segment[0].1 as f32); + builder.line_to(segment[1].0 as f32, segment[1].1 as f32); + builder.finish() +} + +pub fn split_opaque_body_path(params: &SplitMaskParams, width: f64, height: f64) -> Option<Path> { + let line = split_line(params, width, height); + let is_inside = |x: f64, y: f64| half_plane_sign(line, x, y) >= 0.0; + + let mut vertices: Vec<(f64, f64)> = Vec::new(); + for &(x1, y1, x2, y2) in &canvas_edges(width, height) { + let vertex1_inside = is_inside(x1, y1); + let vertex2_inside = is_inside(x2, y2); + if vertex1_inside && vertex2_inside { + vertices.push((x2, y2)); + } else if vertex1_inside { + if let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) { + vertices.push(hit); + } + } else if vertex2_inside { + if let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) { + vertices.push(hit); + vertices.push((x2, y2)); + } + } + } + + if vertices.len() < 3 || polygon_area(&vertices) < MIN_POLYGON_AREA_PX { + return None; + } + + let mut builder = PathBuilder::new(); + builder.move_to(vertices[0].0 as f32, vertices[0].1 as f32); + for &(x, y) in &vertices[1..] { + builder.line_to(x as f32, y as f32); + } + builder.close(); + builder.finish() +} + +pub fn fill_split_feather_body( + params: &SplitMaskParams, + width: f64, + height: f64, + pixmap: &mut Pixmap, +) { + let line = split_line(params, width, height); + let feather_half = params.feather / 2.0; + let Some(shader) = LinearGradient::new( + Point::from_xy( + (line.line_x - line.normal_x * feather_half) as f32, + (line.line_y - line.normal_y * feather_half) as f32, + ), + Point::from_xy( + (line.line_x + line.normal_x * feather_half) as f32, + (line.line_y + line.normal_y * feather_half) as f32, + ), + vec![ + GradientStop::new(0.0, Color::from_rgba8(255, 255, 255, 0)), + GradientStop::new(1.0, Color::from_rgba8(255, 255, 255, 255)), + ], + SpreadMode::Pad, + Transform::default(), + ) else { + return; + }; + let paint = Paint { + shader, + anti_alias: true, + ..Paint::default() + }; + let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, width as f32, height as f32) else { + return; + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); +} diff --git a/rust/crates/mask-defs/src/text_mask.rs b/rust/crates/mask-defs/src/text_mask.rs new file mode 100644 index 00000000..148df531 --- /dev/null +++ b/rust/crates/mask-defs/src/text_mask.rs @@ -0,0 +1,303 @@ +use std::cell::RefCell; + +use cosmic_text::{ + Attrs, Buffer, CacheKey, CacheKeyFlags, Command, Family, FontSystem, Metrics, Shaping, + Style, SwashCache, Weight, Wrap, fontdb, +}; +use scene::{TextDecoration, TextFontStyle, TextFontWeight, TextMaskParams}; +use tiny_skia::{PathBuilder, Pixmap}; + +use crate::MaskRenderError; +use crate::paint; + +const FONT_SIZE_SCALE_REFERENCE: f64 = 90.0; +const TEXT_DECORATION_THICKNESS_RATIO: f64 = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO: f64 = 0.35; + +thread_local! { + static TEXT_ENGINE: RefCell<(FontSystem, SwashCache)> = RefCell::new({ + let mut font_system = FontSystem::new(); + let nixos_fonts = std::path::Path::new("/run/current-system/sw/share/X11/fonts"); + if nixos_fonts.is_dir() { + font_system.db_mut().load_fonts_dir(nixos_fonts); + } + (font_system, SwashCache::new()) + }); +} + +struct PositionedGlyph { + font_id: fontdb::ID, + glyph_id: u16, + font_size: f32, + font_weight: fontdb::Weight, + flags: CacheKeyFlags, + x: f32, + baseline: f32, +} + +struct LineMetrics { + y_center: f32, + width: f32, + ascent: f32, + descent: f32, +} + +struct TextLayout { + glyphs: Vec<PositionedGlyph>, + lines: Vec<LineMetrics>, +} + +#[derive(Clone, Copy)] +struct TextTransform { + cos: f32, + sin: f32, + scale: f32, + translate_x: f32, + translate_y: f32, +} + +impl TextTransform { + fn new(params: &TextMaskParams, width: f64, height: f64) -> Self { + let rotation_rad = params.rotation.to_radians(); + TextTransform { + cos: rotation_rad.cos() as f32, + sin: rotation_rad.sin() as f32, + scale: params.scale as f32, + translate_x: (width / 2.0 + params.center_x * width) as f32, + translate_y: (height / 2.0 + params.center_y * height) as f32, + } + } + + fn map(&self, x: f32, y: f32) -> (f32, f32) { + ( + self.translate_x + self.scale * (x * self.cos - y * self.sin), + self.translate_y + self.scale * (x * self.sin + y * self.cos), + ) + } +} + +fn layout_text( + font_system: &mut FontSystem, + params: &TextMaskParams, + canvas_height: f64, +) -> TextLayout { + let empty = TextLayout { glyphs: Vec::new(), lines: Vec::new() }; + let scaled_font_size = params.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE); + if !scaled_font_size.is_finite() || scaled_font_size <= 0.0 { + return empty; + } + let mut line_height_px = scaled_font_size * params.line_height; + if !line_height_px.is_finite() || line_height_px <= 0.0 { + line_height_px = scaled_font_size; + } + + let weight = match params.font_weight { + TextFontWeight::Normal => Weight::NORMAL, + TextFontWeight::Bold => Weight::BOLD, + }; + let style = match params.font_style { + TextFontStyle::Normal => Style::Normal, + TextFontStyle::Italic => Style::Italic, + }; + let attrs = Attrs::new() + .family(Family::Name(¶ms.font_family)) + .weight(weight) + .style(style) + .letter_spacing((params.letter_spacing / scaled_font_size) as f32); + + let content_lines: Vec<&str> = params.content.split('\n').collect(); + let visual_center_offset = + (content_lines.len().saturating_sub(1)) as f64 * line_height_px / 2.0; + + let mut layout = TextLayout { glyphs: Vec::new(), lines: Vec::new() }; + for (index, line) in content_lines.iter().enumerate() { + let y_center = (index as f64 * line_height_px - visual_center_offset) as f32; + if line.is_empty() { + layout.lines.push(LineMetrics { y_center, width: 0.0, ascent: 0.0, descent: 0.0 }); + continue; + } + + let mut buffer = Buffer::new( + font_system, + Metrics::new(scaled_font_size as f32, line_height_px as f32), + ); + buffer.set_wrap(font_system, Wrap::None); + buffer.set_size(font_system, None, None); + buffer.set_text(font_system, line, &attrs, Shaping::Advanced, None); + buffer.shape_until_scroll(font_system, false); + + let mut borrowed = buffer.borrow_with(font_system); + let Some(layout_lines) = borrowed.line_layout(0) else { + continue; + }; + let Some(layout_line) = layout_lines.first() else { + continue; + }; + + let baseline = y_center + (layout_line.max_ascent - layout_line.max_descent) / 2.0; + for glyph in &layout_line.glyphs { + layout.glyphs.push(PositionedGlyph { + font_id: glyph.font_id, + glyph_id: glyph.glyph_id, + font_size: glyph.font_size, + font_weight: glyph.font_weight, + flags: glyph.cache_key_flags, + x: -layout_line.w / 2.0 + glyph.x + glyph.x_offset * glyph.font_size, + baseline: baseline + glyph.y - glyph.y_offset * glyph.font_size, + }); + } + layout.lines.push(LineMetrics { + y_center, + width: layout_line.w, + ascent: layout_line.max_ascent, + descent: layout_line.max_descent, + }); + } + layout +} + +fn push_glyph_outlines( + font_system: &mut FontSystem, + cache: &mut SwashCache, + layout: &TextLayout, + transform: &TextTransform, + builder: &mut PathBuilder, +) { + for glyph in &layout.glyphs { + let (cache_key, _, _) = CacheKey::new( + glyph.font_id, + glyph.glyph_id, + glyph.font_size, + (0.0, 0.0), + glyph.font_weight, + glyph.flags, + ); + let Some(commands) = cache.get_outline_commands(font_system, cache_key) else { + continue; + }; + for command in commands { + match *command { + Command::MoveTo(p) => { + let (x, y) = transform.map(glyph.x + p.x, glyph.baseline - p.y); + builder.move_to(x, y); + } + Command::LineTo(p) => { + let (x, y) = transform.map(glyph.x + p.x, glyph.baseline - p.y); + builder.line_to(x, y); + } + Command::QuadTo(p1, p2) => { + let (x1, y1) = transform.map(glyph.x + p1.x, glyph.baseline - p1.y); + let (x2, y2) = transform.map(glyph.x + p2.x, glyph.baseline - p2.y); + builder.quad_to(x1, y1, x2, y2); + } + Command::CurveTo(p1, p2, p3) => { + let (x1, y1) = transform.map(glyph.x + p1.x, glyph.baseline - p1.y); + let (x2, y2) = transform.map(glyph.x + p2.x, glyph.baseline - p2.y); + let (x3, y3) = transform.map(glyph.x + p3.x, glyph.baseline - p3.y); + builder.cubic_to(x1, y1, x2, y2, x3, y3); + } + Command::Close => builder.close(), + } + } + } +} + +fn push_decoration_rect( + builder: &mut PathBuilder, + transform: &TextTransform, + x_start: f32, + y_start: f32, + width: f32, + height: f32, +) { + let corners = [ + transform.map(x_start, y_start), + transform.map(x_start + width, y_start), + transform.map(x_start + width, y_start + height), + transform.map(x_start, y_start + height), + ]; + builder.move_to(corners[0].0, corners[0].1); + for corner in &corners[1..] { + builder.line_to(corner.0, corner.1); + } + builder.close(); +} + +fn push_decorations( + builder: &mut PathBuilder, + params: &TextMaskParams, + canvas_height: f64, + layout: &TextLayout, + transform: &TextTransform, +) { + if params.text_decoration == TextDecoration::None { + return; + } + let scaled_font_size = params.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE); + let thickness = (scaled_font_size * TEXT_DECORATION_THICKNESS_RATIO).max(1.0) as f32; + for line in &layout.lines { + if line.width <= 0.0 { + continue; + } + let x_start = -line.width / 2.0; + match params.text_decoration { + TextDecoration::Underline => { + let y = line.y_center + line.descent + thickness; + push_decoration_rect(builder, transform, x_start, y, line.width, thickness); + } + TextDecoration::LineThrough => { + let y = line.y_center + - (line.ascent - line.descent) * STRIKETHROUGH_VERTICAL_RATIO as f32; + push_decoration_rect(builder, transform, x_start, y, line.width, thickness); + } + TextDecoration::None => {} + } + } +} + +pub fn render_body(params: &TextMaskParams, width: f64, height: f64, pixmap: &mut Pixmap) { + TEXT_ENGINE.with(|engine| { + let mut engine = engine.borrow_mut(); + let (font_system, cache) = &mut *engine; + let layout = layout_text(font_system, params, height); + let transform = TextTransform::new(params, width, height); + let mut builder = PathBuilder::new(); + push_glyph_outlines(font_system, cache, &layout, &transform, &mut builder); + push_decorations(&mut builder, params, height, &layout, &transform); + paint::fill_white(&builder.finish(), pixmap); + }); +} + +pub fn render_stroke( + params: &TextMaskParams, + width: f64, + height: f64, + pixmap: &mut Pixmap, +) -> Result<(), MaskRenderError> { + let color = paint::parse_stroke_color(¶ms.stroke_color)?; + TEXT_ENGINE.with(|engine| { + let mut engine = engine.borrow_mut(); + let (font_system, cache) = &mut *engine; + let layout = layout_text(font_system, params, height); + if layout.glyphs.is_empty() { + return; + } + let transform = TextTransform::new(params, width, height); + let mut builder = PathBuilder::new(); + push_glyph_outlines(font_system, cache, &layout, &transform, &mut builder); + paint::stroke_path_with( + &builder.finish(), + &paint::round_stroke(params.stroke_width * params.scale), + color, + pixmap, + ); + + if params.stroke_align != scene::StrokeAlign::Center { + if let Some(mut fill) = Pixmap::new(pixmap.width(), pixmap.height()) { + render_body(params, width, height, &mut fill); + paint::apply_stroke_align(pixmap, &fill, params.stroke_align); + } + } + }); + Ok(()) +} |
