Skip to main content

ranim_core/core_item/
vitem.rs

1use color::{AlphaColor, Srgb};
2use glam::{Vec3, Vec4};
3
4use crate::{
5    Extract,
6    components::{rgba::Rgba, width::Width},
7    core_item::CoreItem,
8    traits::FillColor,
9};
10
11/// Default vitem stroke width
12pub const DEFAULT_STROKE_WIDTH: f32 = 0.02;
13
14/// Compute a normal vector from the ordered VPoints of a VItem.
15///
16/// The primary path uses the 3D equivalent of the shoelace formula on anchor
17/// points. If the accumulated area is degenerate (for example, a single
18/// curved segment), all VPoints are scanned for a non-collinear triple.
19/// A collinear item uses a deterministic plane containing its line, while an
20/// item whose points all coincide falls back to the Z axis.
21pub fn vitem_normal_from_points(points: &[Vec4]) -> Vec3 {
22    if points.len() < 3 {
23        return Vec3::Z;
24    }
25
26    let point3 = |point: &Vec4| point.truncate();
27    let origin = point3(&points[0]);
28
29    // VPoints alternate anchors and handles, so triangulating the ordered
30    // even-indexed anchors is Newell's method in fan form.
31    let mut area_normal = Vec3::ZERO;
32    let mut previous = origin;
33    let mut scale_squared = 0.0_f32;
34    for point in points.iter().step_by(2).skip(1) {
35        let current = point3(point);
36        let previous_offset = previous - origin;
37        let current_offset = current - origin;
38        area_normal += previous_offset.cross(current_offset);
39        scale_squared = scale_squared
40            .max(previous_offset.length_squared())
41            .max(current_offset.length_squared());
42        previous = current;
43    }
44    if area_normal.length_squared() > f32::EPSILON * scale_squared * scale_squared {
45        return area_normal.normalize();
46    }
47
48    // Two-anchor curves have zero shoelace area, but a control point can
49    // still determine their plane. Preserve point order so the sign remains
50    // deterministic across animation frames.
51    let mut direction: Option<Vec3> = None;
52    for point in &points[1..] {
53        let candidate = point3(point) - origin;
54        let candidate_length_squared = candidate.length_squared();
55        if candidate_length_squared <= f32::EPSILON {
56            continue;
57        }
58        if let Some(direction) = direction {
59            let normal = direction.cross(candidate);
60            if normal.length_squared()
61                > f32::EPSILON * direction.length_squared() * candidate_length_squared
62            {
63                return normal.normalize();
64            }
65        } else {
66            direction = Some(candidate);
67        }
68    }
69
70    if let Some(direction) = direction {
71        let direction = direction.normalize();
72        let reference = if direction.dot(Vec3::Z).abs() < 0.99 {
73            Vec3::Z
74        } else {
75            Vec3::X
76        };
77        return (reference - direction * direction.dot(reference)).normalize();
78    }
79
80    Vec3::Z
81}
82
83#[derive(bevy_ecs::component::Component, Debug, Clone, PartialEq)]
84/// A primitive for rendering a vitem.
85pub struct VItem {
86    /// The normal vector of the projection target plane.
87    /// If `None`, the normal will be derived from the points at render time.
88    pub normal: Option<Vec3>,
89    /// The points of the item in world space.
90    /// (x, y, z, is_closed)
91    pub points: Vec<Vec4>,
92    /// Fill rgbas, see [`Rgba`].
93    pub fill_rgbas: Vec<Rgba>,
94    /// Stroke rgbs, see [`Rgba`].
95    pub stroke_rgbas: Vec<Rgba>,
96    /// Stroke widths, see [`Width`].
97    pub stroke_widths: Vec<Width>,
98}
99
100impl Default for VItem {
101    fn default() -> Self {
102        Self {
103            normal: None,
104            points: vec![Vec4::ZERO; 3],
105            stroke_widths: vec![Width::default(); 2],
106            stroke_rgbas: vec![Rgba::default(); 2],
107            fill_rgbas: vec![Rgba::default(); 2],
108        }
109    }
110}
111
112impl Extract for VItem {
113    type Target = CoreItem;
114    fn extract_into(&self, buf: &mut Vec<Self::Target>) {
115        buf.push(CoreItem::VItem(self.clone()));
116    }
117}
118
119impl FillColor for VItem {
120    fn fill_color(&self) -> AlphaColor<Srgb> {
121        let Rgba(rgba) = self.fill_rgbas[0];
122        AlphaColor::new([rgba.x, rgba.y, rgba.z, rgba.w])
123    }
124    fn set_fill_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
125        self.fill_rgbas.fill(color.into());
126        self
127    }
128    fn set_fill_opacity(&mut self, opacity: f32) -> &mut Self {
129        self.fill_rgbas
130            .iter_mut()
131            .for_each(|rgba| rgba.0.w = opacity);
132        self
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use glam::{Vec3, Vec4};
139
140    use super::vitem_normal_from_points;
141
142    fn point(point: Vec3) -> Vec4 {
143        point.extend(0.0)
144    }
145
146    #[test]
147    fn computes_normal_from_interleaved_polygon_anchors() {
148        let anchors = [
149            Vec3::new(2.0, -1.0, -1.0),
150            Vec3::new(2.0, 1.0, -1.0),
151            Vec3::new(2.0, 1.0, 1.0),
152            Vec3::new(2.0, -1.0, 1.0),
153            Vec3::new(2.0, -1.0, -1.0),
154        ];
155        let mut points = Vec::new();
156        for edge in anchors.windows(2) {
157            points.push(point(edge[0]));
158            points.push(point((edge[0] + edge[1]) * 0.5));
159        }
160        points.push(point(*anchors.last().unwrap()));
161
162        let normal = vitem_normal_from_points(&points);
163        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
164    }
165
166    #[test]
167    fn falls_back_to_control_points_for_single_curved_segment() {
168        let points = [point(Vec3::ZERO), point(Vec3::Y), point(Vec3::X)];
169
170        let normal = vitem_normal_from_points(&points);
171        assert!(normal.abs_diff_eq(Vec3::NEG_Z, 1e-6));
172    }
173
174    #[test]
175    fn collinear_points_use_default_normal() {
176        let points = [point(Vec3::ZERO), point(Vec3::X), point(Vec3::X * 2.0)];
177
178        assert_eq!(vitem_normal_from_points(&points), Vec3::Z);
179    }
180
181    #[test]
182    fn vertical_line_uses_a_plane_containing_the_line() {
183        let points = [point(Vec3::ZERO), point(Vec3::Z), point(Vec3::Z * 2.0)];
184
185        let normal = vitem_normal_from_points(&points);
186        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
187        assert!(normal.dot(Vec3::Z).abs() < 1e-6);
188    }
189}