summaryrefslogtreecommitdiff
path: root/rust/crates/mask-defs/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/mask-defs/src')
-rw-r--r--rust/crates/mask-defs/src/freeform.rs87
-rw-r--r--rust/crates/mask-defs/src/geometry.rs109
-rw-r--r--rust/crates/mask-defs/src/lib.rs191
-rw-r--r--rust/crates/mask-defs/src/paint.rs133
-rw-r--r--rust/crates/mask-defs/src/shapes.rs260
-rw-r--r--rust/crates/mask-defs/src/split.rs135
-rw-r--r--rust/crates/mask-defs/src/text_mask.rs303
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(
+ &params.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, &params.stroke_color),
+ Mask::CinematicBars { params, .. }
+ | Mask::Rectangle { params, .. }
+ | Mask::Ellipse { params, .. }
+ | Mask::Heart { params, .. }
+ | Mask::Diamond { params, .. }
+ | Mask::Star { params, .. } => (params.stroke_width, &params.stroke_color),
+ Mask::Text { params, .. } => (params.stroke_width, &params.stroke_color),
+ Mask::Freeform { params, .. } => (params.stroke_width, &params.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(
+ &params.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(&params.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(&params.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(())
+}