use std::any::Any;
use std::hash::{Hash, Hasher};
use crate::color::Color;
use crate::dyn_compare::{DynEq, DynHash};
use crate::geometry::{Anchor, Constraints, Rect, Transform, Vec2};
use crate::scalar::clamp_unit;
use crate::Keyable;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct VectorGraphic {
pub view_box: Rect,
pub root: Node,
}
pub trait VectorComponent: VectorComponentClone + DynEq + DynHash {
fn layout(&self, constraints: Constraints) -> Vec2;
fn paint_bounds(&self, size: Vec2) -> Rect {
Rect {
origin: Vec2::ZERO,
size,
}
}
fn render(&self, size: Vec2) -> VectorGraphic;
fn arrangement_name(&self) -> Option<String> {
None
}
fn boxed(self) -> Box<dyn VectorComponent>
where
Self: Sized + 'static,
{
Box::new(self)
}
}
const _: Option<&dyn VectorComponent> = None;
pub trait VectorComponentClone {
fn clone_box(&self) -> Box<dyn VectorComponent>;
}
impl<T: VectorComponent + Clone + 'static> VectorComponentClone for T {
fn clone_box(&self) -> Box<dyn VectorComponent> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn VectorComponent> {
fn clone(&self) -> Self {
self.clone_box()
}
}
impl PartialEq for dyn VectorComponent {
fn eq(&self, other: &Self) -> bool {
DynEq::dyn_eq(self, other.as_any())
}
}
impl Hash for dyn VectorComponent {
fn hash<H: Hasher>(&self, state: &mut H) {
Any::type_id(self.as_any()).hash(state);
DynHash::dyn_hash(self, state);
}
}
#[derive(Clone, Keyable)]
pub struct Transformed {
pub transform: Transform,
pub pivot: Anchor,
pub opacity: f32,
pub child: Box<dyn VectorComponent>,
}
impl Transformed {
pub fn new<C: VectorComponent + 'static>(transform: Transform, child: C) -> Self {
Self::from_box(transform, Box::new(child))
}
pub fn from_box(transform: Transform, child: Box<dyn VectorComponent>) -> Self {
Self {
transform,
pivot: Anchor::TOP_LEFT,
opacity: 1.0,
child,
}
}
pub fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity;
self
}
fn effective_transform(&self, size: Vec2) -> Transform {
if self.pivot == Anchor::TOP_LEFT {
return self.transform;
}
let point = Vec2(size.0 * self.pivot.rx, size.1 * self.pivot.ry);
Transform::around_point(point, self.transform)
}
}
impl VectorComponent for Transformed {
fn layout(&self, constraints: Constraints) -> Vec2 {
self.child.layout(constraints)
}
fn paint_bounds(&self, size: Vec2) -> Rect {
transformed_bounds(
size,
self.child.paint_bounds(size),
self.effective_transform(size),
)
}
fn render(&self, size: Vec2) -> VectorGraphic {
if self.opacity.is_nan() || self.opacity <= 0.0 {
return VectorGraphic {
view_box: Rect {
origin: Vec2::ZERO,
size,
},
root: Node::empty(),
};
}
let inner = self.child.render(size);
let transform = self.effective_transform(size);
VectorGraphic {
view_box: transformed_bounds(size, inner.view_box, transform),
root: Node::single_group(transform, self.opacity, inner.root),
}
}
}
impl From<Transformed> for Box<dyn VectorComponent> {
fn from(transformed: Transformed) -> Self {
Box::new(transformed)
}
}
pub trait VectorTransform: VectorComponent + Sized + 'static {
fn transform(self, transform: Transform) -> Transformed {
Transformed::new(transform, self)
}
fn transform_around(self, anchor: Anchor, transform: Transform) -> Transformed {
let mut transformed = Transformed::new(transform, self);
transformed.pivot = anchor;
transformed
}
fn opacity(self, opacity: f32) -> Transformed {
Transformed::new(Transform::IDENTITY, self).opacity(opacity)
}
}
impl<T: VectorComponent + 'static> VectorTransform for T {}
fn transformed_bounds(size: Vec2, child_bounds: Rect, transform: Transform) -> Rect {
let layout_bounds = Rect {
origin: Vec2::ZERO,
size,
};
let base_bounds = union_rect(layout_bounds, child_bounds);
union_rect(base_bounds, transform.transform_rect(base_bounds))
}
fn union_rect(a: Rect, b: Rect) -> Rect {
let a_end = Vec2(a.origin.0 + a.size.0, a.origin.1 + a.size.1);
let b_end = Vec2(b.origin.0 + b.size.0, b.origin.1 + b.size.1);
let origin = Vec2(a.origin.0.min(b.origin.0), a.origin.1.min(b.origin.1));
let end = Vec2(a_end.0.max(b_end.0), a_end.1.max(b_end.1));
Rect {
origin,
size: Vec2(end.0 - origin.0, end.1 - origin.1),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Node {
Group(Group),
SingleGroup(SingleGroup),
ClipGroup(ClipGroup),
Path(Path),
}
impl Node {
pub fn single_group(transform: Transform, opacity: f32, child: Node) -> Self {
Self::SingleGroup(SingleGroup {
transform,
opacity: clamp_unit(opacity),
child: Box::new(child),
})
}
pub fn empty() -> Self {
Self::Group(Group {
transform: Transform::IDENTITY,
opacity: 1.0,
children: Vec::new(),
})
}
pub(crate) fn is_empty(&self) -> bool {
match self {
Node::Group(group) => group.opacity <= 0.0 || group.children.iter().all(Node::is_empty),
Node::SingleGroup(group) => group.opacity <= 0.0 || group.child.is_empty(),
Node::ClipGroup(group) => group.child.is_empty(),
Node::Path(path) => {
!path.fill.as_ref().is_some_and(Fill::is_visible)
&& !path.stroke.as_ref().is_some_and(Stroke::is_visible)
}
}
}
}
#[derive(Debug, Clone, Keyable)]
pub struct Group {
pub transform: Transform,
pub opacity: f32,
pub children: Vec<Node>,
}
#[derive(Debug, Clone, Keyable)]
pub struct SingleGroup {
pub transform: Transform,
pub opacity: f32,
pub child: Box<Node>,
}
#[derive(Debug, Clone, Keyable)]
pub struct ClipGroup {
pub commands: Vec<PathCommand>,
pub transform: Transform,
pub child: Box<Node>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Path {
pub commands: Vec<PathCommand>,
pub fill: Option<Fill>,
pub stroke: Option<Stroke>,
pub transform: Transform,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PathCommand {
MoveTo(Vec2),
LineTo(Vec2),
QuadTo { control: Vec2, to: Vec2 },
CubicTo { c1: Vec2, c2: Vec2, to: Vec2 },
Close,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Fill {
pub paint: Paint,
}
impl Fill {
pub fn is_visible(&self) -> bool {
self.paint.is_visible()
}
}
pub const DEFAULT_STROKE_MITER_LIMIT: f32 = 4.0;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum StrokeCap {
Butt,
Square,
#[default]
Round,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum StrokeJoin {
Bevel,
Miter,
#[default]
Round,
}
#[derive(Debug, Clone, Keyable)]
#[non_exhaustive]
pub struct Stroke {
pub paint: Paint,
pub width: f32,
pub cap: StrokeCap,
pub join: StrokeJoin,
miter_limit: f32,
pub dash: Option<DashPattern>,
}
impl Stroke {
pub fn new(paint: impl Into<Paint>, width: f32) -> Self {
Self {
paint: paint.into(),
width,
cap: StrokeCap::default(),
join: StrokeJoin::default(),
miter_limit: DEFAULT_STROKE_MITER_LIMIT,
dash: None,
}
}
pub fn is_visible(&self) -> bool {
self.width > 0.0 && self.paint.is_visible()
}
pub(crate) fn conservative_outset(&self) -> f32 {
let half_width = (self.width * 0.5).max(0.0);
let join_factor = if self.join == StrokeJoin::Miter {
self.miter_limit()
} else {
1.0
};
let cap_factor = if self.cap == StrokeCap::Square {
std::f32::consts::SQRT_2
} else {
1.0
};
half_width * join_factor.max(cap_factor)
}
pub fn miter_limit(&self) -> f32 {
self.miter_limit
}
pub fn with_dash(mut self, dash: DashPattern) -> Self {
self.dash = Some(dash);
self
}
pub fn with_cap(mut self, cap: StrokeCap) -> Self {
self.cap = cap;
self
}
pub fn with_join(mut self, join: StrokeJoin) -> Self {
self.join = join;
self
}
pub fn with_miter_limit(mut self, miter_limit: f32) -> Self {
self.miter_limit = normalize_miter_limit(miter_limit);
self
}
}
fn normalize_miter_limit(miter_limit: f32) -> f32 {
let clamped = if miter_limit.is_finite() {
miter_limit.clamp(1.0, half::f16::MAX.to_f32())
} else {
DEFAULT_STROKE_MITER_LIMIT
};
half::f16::from_f32(clamped).to_f32()
}
#[derive(Debug, Clone, Keyable)]
pub struct DashPattern {
pub lengths: Vec<f32>,
pub offset: f32,
}
impl DashPattern {
pub fn new(lengths: impl Into<Vec<f32>>, offset: f32) -> Self {
Self {
lengths: lengths.into(),
offset,
}
}
pub fn normalized_lengths(&self) -> Option<Vec<f32>> {
if self.lengths.is_empty() || self.lengths.iter().any(|&len| len < 0.0) {
return None;
}
let total: f32 = self.lengths.iter().sum();
if total <= 0.0 {
return None;
}
if self.lengths.len().is_multiple_of(2) {
Some(self.lengths.clone())
} else {
let mut doubled = self.lengths.clone();
doubled.extend_from_slice(&self.lengths);
Some(doubled)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Paint {
Solid(Color),
}
impl Paint {
pub const fn solid(color: Color) -> Self {
Self::Solid(color)
}
pub fn is_visible(&self) -> bool {
match self {
Paint::Solid(c) => c.a > 0.0,
}
}
}
impl From<Color> for Paint {
fn from(color: Color) -> Self {
Self::solid(color)
}
}
impl From<Color> for Option<Paint> {
fn from(color: Color) -> Self {
Some(color.into())
}
}
impl From<Paint> for Fill {
fn from(paint: Paint) -> Self {
Self { paint }
}
}
impl From<Color> for Fill {
fn from(color: Color) -> Self {
Paint::from(color).into()
}
}
impl From<Paint> for Option<Fill> {
fn from(paint: Paint) -> Self {
Some(Fill { paint })
}
}
impl From<Color> for Option<Fill> {
fn from(color: Color) -> Self {
Some(color.into())
}
}
impl From<Paint> for Stroke {
fn from(paint: Paint) -> Self {
Self {
paint,
width: 1.0,
cap: StrokeCap::default(),
join: StrokeJoin::default(),
miter_limit: DEFAULT_STROKE_MITER_LIMIT,
dash: None,
}
}
}
impl From<Color> for Stroke {
fn from(color: Color) -> Self {
Paint::from(color).into()
}
}
impl From<Paint> for Option<Stroke> {
fn from(paint: Paint) -> Self {
Some(paint.into())
}
}
impl From<Color> for Option<Stroke> {
fn from(color: Color) -> Self {
Some(color.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::Rectangle;
#[test]
fn color_converts_to_paint_fill_and_stroke() {
let color = Color::rgb_u8(10, 20, 30);
assert_eq!(Paint::solid(color), Paint::Solid(color));
assert_eq!(Paint::from(color), Paint::Solid(color));
assert_eq!(Fill::from(color).paint, Paint::Solid(color));
let stroke = Stroke::from(color);
assert_eq!(stroke.paint, Paint::Solid(color));
assert_eq!(stroke.width, 1.0);
assert_eq!(stroke.cap, StrokeCap::Round);
assert_eq!(stroke.join, StrokeJoin::Round);
assert_eq!(stroke.miter_limit(), DEFAULT_STROKE_MITER_LIMIT);
}
#[test]
fn boxed_vector_component_clone_is_dyn_equal() {
let original: Box<dyn VectorComponent> = Box::new(Rectangle {
size: Vec2(10.0, 20.0),
fill: Option::<Fill>::from(Color::rgb_u8(1, 2, 3)),
stroke: None,
});
let cloned = original.clone();
assert!(DynEq::dyn_eq(original.as_ref(), cloned.as_ref().as_any(),));
}
#[test]
fn color_converts_to_optional_paint_styles() {
let color = Color::rgb_u8(40, 50, 60);
assert_eq!(Option::<Paint>::from(color), Some(Paint::Solid(color)));
assert_eq!(
Option::<Fill>::from(color),
Some(Fill {
paint: Paint::Solid(color),
})
);
assert_eq!(
Option::<Stroke>::from(color),
Some(Stroke {
paint: Paint::Solid(color),
width: 1.0,
cap: StrokeCap::Round,
join: StrokeJoin::Round,
miter_limit: DEFAULT_STROKE_MITER_LIMIT,
dash: None,
})
);
}
#[test]
fn transformed_layout_is_layout_neutral() {
let transformed = Rectangle {
size: Vec2(10.0, 20.0),
fill: None,
stroke: None,
}
.transform(Transform::translate(Vec2(5.0, 7.0)));
assert_eq!(transformed.layout(Constraints::UNBOUNDED), Vec2(10.0, 20.0));
assert_eq!(
transformed.paint_bounds(Vec2(10.0, 20.0)),
Rect {
origin: Vec2::ZERO,
size: Vec2(15.0, 27.0),
}
);
assert_eq!(
transformed.render(Vec2(10.0, 20.0)).view_box,
Rect {
origin: Vec2::ZERO,
size: Vec2(15.0, 27.0),
}
);
}
#[test]
fn transformed_bounds_do_not_shrink_the_layout_box() {
let transformed = Rectangle {
size: Vec2(4.0, 20.0),
fill: None,
stroke: None,
}
.transform(Transform {
a: 0.0,
b: 1.0,
c: -1.0,
d: 0.0,
tx: 12.0,
ty: 8.0,
});
let expected = Rect {
origin: Vec2(-8.0, 0.0),
size: Vec2(20.0, 20.0),
};
assert_eq!(transformed.layout(Constraints::UNBOUNDED), Vec2(4.0, 20.0));
assert_eq!(transformed.paint_bounds(Vec2(4.0, 20.0)), expected);
assert_eq!(transformed.render(Vec2(4.0, 20.0)).view_box, expected);
}
#[test]
fn transform_around_resolves_the_pivot_against_the_size() {
let angle = 0.5_f32;
let transformed = Rectangle {
size: Vec2(4.0, 2.0),
fill: None,
stroke: None,
}
.transform_around(Anchor::CENTER, Transform::rotate(angle));
let graphic = transformed.render(Vec2(4.0, 2.0));
let Node::SingleGroup(group) = graphic.root else {
panic!("Transformed should render as a single-child group");
};
assert_eq!(
group.transform,
Transform::around_point(Vec2(2.0, 1.0), Transform::rotate(angle))
);
}
#[test]
fn transform_with_origin_pivot_stays_verbatim() {
let transform = Transform::rotate(0.5);
let transformed = Rectangle {
size: Vec2(4.0, 2.0),
fill: None,
stroke: None,
}
.transform(transform);
let graphic = transformed.render(Vec2(4.0, 2.0));
let Node::SingleGroup(group) = graphic.root else {
panic!("Transformed should render as a single-child group");
};
assert_eq!(group.transform, transform);
}
#[test]
fn transformed_nan_or_zero_opacity_renders_nothing() {
for opacity in [0.0, -1.0, f32::NAN] {
let transformed = Rectangle {
size: Vec2(1.0, 1.0),
fill: Option::<Fill>::from(Color::rgb_u8(255, 0, 0)),
stroke: None,
}
.opacity(opacity);
assert_eq!(transformed.render(Vec2(1.0, 1.0)).root, Node::empty());
}
}
#[test]
fn invisible_shapes_render_no_ink() {
let bare = Rectangle {
size: Vec2(4.0, 2.0),
fill: None,
stroke: None,
};
assert_eq!(bare.render(Vec2(4.0, 2.0)).root, Node::empty());
let ghost = Rectangle {
size: Vec2(4.0, 2.0),
fill: Option::<Fill>::from(Color::rgba_u8(255, 0, 0, 0)),
stroke: None,
};
let graphic = ghost.render(Vec2(4.0, 2.0));
assert_eq!(graphic.root, Node::empty());
assert_eq!(graphic.view_box.size, Vec2(4.0, 2.0));
}
#[test]
fn invisible_fill_is_dropped_but_visible_stroke_keeps_painting() {
let outlined = Rectangle {
size: Vec2(4.0, 2.0),
fill: Option::<Fill>::from(Color::rgba_u8(255, 0, 0, 0)),
stroke: Option::<Stroke>::from(Color::rgb_u8(0, 0, 0)),
};
let Node::Path(path) = outlined.render(Vec2(4.0, 2.0)).root else {
panic!("a stroked rectangle still paints");
};
assert!(path.fill.is_none());
assert!(path.stroke.is_some());
}
#[test]
fn stroke_new_uses_round_defaults_and_has_no_dash() {
let stroke = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0);
assert_eq!(stroke.cap, StrokeCap::Round);
assert_eq!(stroke.join, StrokeJoin::Round);
assert_eq!(stroke.miter_limit(), DEFAULT_STROKE_MITER_LIMIT);
assert_eq!(stroke.dash, None);
let dashed = stroke.with_dash(DashPattern::new(vec![4.0, 2.0], 0.0));
assert!(dashed.dash.is_some());
}
#[test]
fn stroke_style_builders_override_defaults() {
let stroke = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0)
.with_cap(StrokeCap::Square)
.with_join(StrokeJoin::Miter)
.with_miter_limit(8.0);
assert_eq!(stroke.cap, StrokeCap::Square);
assert_eq!(stroke.join, StrokeJoin::Miter);
assert_eq!(stroke.miter_limit(), 8.0);
}
#[test]
fn stroke_miter_limit_builder_normalizes_invalid_values() {
let stroke = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0);
assert_eq!(stroke.clone().with_miter_limit(0.5).miter_limit(), 1.0);
assert_eq!(
stroke.clone().with_miter_limit(f32::NAN).miter_limit(),
DEFAULT_STROKE_MITER_LIMIT
);
assert_eq!(
stroke.with_miter_limit(f32::INFINITY).miter_limit(),
DEFAULT_STROKE_MITER_LIMIT
);
assert_eq!(
Stroke::new(Color::rgb_u8(0, 0, 0), 2.0)
.with_miter_limit(f32::MAX)
.miter_limit(),
half::f16::MAX.to_f32()
);
}
#[test]
fn stroke_miter_limit_uses_renderer_shared_precision() {
let stroke = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0).with_miter_limit(4.001);
assert_eq!(stroke.miter_limit(), 4.0);
}
#[test]
fn conservative_stroke_outset_accounts_for_caps_and_joins() {
let round = Stroke::new(Color::rgb_u8(0, 0, 0), 4.0);
assert_eq!(round.conservative_outset(), 2.0);
assert_eq!(
round
.clone()
.with_cap(StrokeCap::Square)
.conservative_outset(),
2.0 * std::f32::consts::SQRT_2
);
assert_eq!(
round
.clone()
.with_join(StrokeJoin::Miter)
.conservative_outset(),
8.0
);
assert_eq!(
round
.with_join(StrokeJoin::Miter)
.with_miter_limit(f32::NEG_INFINITY)
.conservative_outset(),
8.0
);
}
#[test]
fn even_length_dash_pattern_is_unchanged() {
let dash = DashPattern::new(vec![4.0, 2.0, 1.0, 2.0], 0.0);
assert_eq!(dash.normalized_lengths(), Some(vec![4.0, 2.0, 1.0, 2.0]));
}
#[test]
fn odd_length_dash_pattern_is_doubled() {
let dash = DashPattern::new(vec![18.0], 0.0);
assert_eq!(dash.normalized_lengths(), Some(vec![18.0, 18.0]));
let dash = DashPattern::new(vec![10.0, 5.0, 3.0], 0.0);
assert_eq!(
dash.normalized_lengths(),
Some(vec![10.0, 5.0, 3.0, 10.0, 5.0, 3.0])
);
}
#[test]
fn degenerate_dash_patterns_normalize_to_none() {
assert_eq!(DashPattern::new(vec![], 0.0).normalized_lengths(), None);
assert_eq!(
DashPattern::new(vec![0.0, 0.0], 0.0).normalized_lengths(),
None
);
assert_eq!(
DashPattern::new(vec![-1.0, 2.0], 0.0).normalized_lengths(),
None
);
}
#[test]
fn strokes_with_different_dash_patterns_compare_unequal() {
let solid = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0);
let dashed = solid
.clone()
.with_dash(DashPattern::new(vec![4.0, 2.0], 0.0));
assert_ne!(solid, dashed);
let same_dash = solid.with_dash(DashPattern::new(vec![4.0, 2.0], 0.0));
assert_eq!(dashed, same_dash);
}
#[test]
fn strokes_with_different_cap_join_or_miter_limit_compare_unequal() {
let round = Stroke::new(Color::rgb_u8(0, 0, 0), 2.0);
assert_ne!(round, round.clone().with_cap(StrokeCap::Butt));
assert_ne!(round, round.clone().with_join(StrokeJoin::Bevel));
let miter = round.with_join(StrokeJoin::Miter);
assert_ne!(miter, miter.clone().with_miter_limit(8.0));
}
}