Skip to main content

ranim_core/core_item/
vitem.rs

1use color::{AlphaColor, Srgb};
2use glam::{Mat4, Vec3, Vec4};
3
4use crate::{
5    Extract,
6    components::{rgba::Rgba, width::Width},
7    core_item::CoreItem,
8    traits::{FillColor, Interpolatable},
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 in local space.
87    /// If `None`, the normal will be derived from the points at render time
88    /// and converted to world space along with the item transform.
89    pub normal: Option<Vec3>,
90    /// The points of the item in local space.
91    /// (x, y, z, is_closed)
92    pub points: Vec<Vec4>,
93    /// The local-to-world transform applied when flattening onto the plane.
94    pub transform: Mat4,
95    /// Fill rgbas, see [`Rgba`].
96    pub fill_rgbas: Vec<Rgba>,
97    /// Stroke rgbs, see [`Rgba`].
98    pub stroke_rgbas: Vec<Rgba>,
99    /// Stroke widths, see [`Width`].
100    pub stroke_widths: Vec<Width>,
101}
102
103impl Default for VItem {
104    fn default() -> Self {
105        Self {
106            normal: None,
107            points: vec![Vec4::ZERO; 3],
108            transform: Mat4::IDENTITY,
109            stroke_widths: vec![Width::default(); 2],
110            stroke_rgbas: vec![Rgba::default(); 2],
111            fill_rgbas: vec![Rgba::default(); 2],
112        }
113    }
114}
115
116impl Interpolatable for VItem {
117    fn lerp(&self, target: &Self, t: f64) -> Self {
118        Self {
119            normal: if t < 0.5 { self.normal } else { target.normal },
120            points: self.points.lerp(&target.points, t),
121            transform: self.transform.lerp(&target.transform, t),
122            fill_rgbas: self.fill_rgbas.lerp(&target.fill_rgbas, t),
123            stroke_rgbas: self.stroke_rgbas.lerp(&target.stroke_rgbas, t),
124            stroke_widths: self.stroke_widths.lerp(&target.stroke_widths, t),
125        }
126    }
127}
128
129impl Extract for VItem {
130    type Target = CoreItem;
131    fn extract_into(&self, buf: &mut Vec<Self::Target>) {
132        buf.push(CoreItem::VItem(self.clone()));
133    }
134}
135
136impl FillColor for VItem {
137    fn fill_color(&self) -> AlphaColor<Srgb> {
138        let Rgba(rgba) = self.fill_rgbas[0];
139        AlphaColor::new([rgba.x, rgba.y, rgba.z, rgba.w])
140    }
141    fn set_fill_color(&mut self, color: AlphaColor<Srgb>) -> &mut Self {
142        self.fill_rgbas.fill(color.into());
143        self
144    }
145    fn set_fill_opacity(&mut self, opacity: f32) -> &mut Self {
146        self.fill_rgbas
147            .iter_mut()
148            .for_each(|rgba| rgba.0.w = opacity);
149        self
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use glam::{Vec3, Vec4};
156
157    use super::vitem_normal_from_points;
158
159    fn point(point: Vec3) -> Vec4 {
160        point.extend(0.0)
161    }
162
163    #[test]
164    fn inferred_normal_matches_the_polygon_plane() {
165        let anchors = [
166            Vec3::new(2.0, -1.0, -1.0),
167            Vec3::new(2.0, 1.0, -1.0),
168            Vec3::new(2.0, 1.0, 1.0),
169            Vec3::new(2.0, -1.0, 1.0),
170            Vec3::new(2.0, -1.0, -1.0),
171        ];
172        let mut points = Vec::new();
173        for edge in anchors.windows(2) {
174            points.push(point(edge[0]));
175            points.push(point((edge[0] + edge[1]) * 0.5));
176        }
177        points.push(point(*anchors.last().unwrap()));
178
179        let normal = vitem_normal_from_points(&points);
180        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
181    }
182
183    #[test]
184    fn normal_inference_falls_back_for_degenerate_inputs() {
185        // A single curved segment has no polygon loop: use its control points.
186        let curved = [point(Vec3::ZERO), point(Vec3::Y), point(Vec3::X)];
187        assert!(vitem_normal_from_points(&curved).abs_diff_eq(Vec3::NEG_Z, 1e-6));
188
189        // Collinear geometry has no unique plane: use the default normal.
190        let collinear = [point(Vec3::ZERO), point(Vec3::X), point(Vec3::X * 2.0)];
191        assert_eq!(vitem_normal_from_points(&collinear), Vec3::Z);
192
193        // A vertical line picks a plane that contains the line.
194        let vertical = [point(Vec3::ZERO), point(Vec3::Z), point(Vec3::Z * 2.0)];
195        let normal = vitem_normal_from_points(&vertical);
196        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
197        assert!(normal.dot(Vec3::Z).abs() < 1e-6);
198    }
199}