Skip to main content

ranim_items/vitem/render/
mod.rs

1//! Render-world data and GPU implementation for vitem items.
2
3mod buffer;
4mod pipeline;
5
6use ranim_core::{
7    components::{rgba::Rgba, width::Width},
8    glam::{Mat4, Vec3, Vec4},
9};
10
11pub(super) fn install(app: &mut bevy_app::App) {
12    use bevy_ecs::schedule::IntoScheduleConfigs;
13    use ranim_render::{
14        extract::ExtractPlugin,
15        schedule::{RenderSchedule, ViewPhase},
16        utils::WgpuContext,
17    };
18
19    use super::{VItem, geometry::*, svg::SvgItem};
20
21    let buffer = buffer::VItemsBuffer::new(app.world().resource::<WgpuContext>());
22    app.insert_resource(buffer).add_plugins((
23        ExtractPlugin::<VItem>::default(),
24        ExtractPlugin::<Vec<VItem>>::default(),
25        ExtractPlugin::<Arc>::default(),
26        ExtractPlugin::<ArcBetweenPoints>::default(),
27        ExtractPlugin::<Circle>::default(),
28        ExtractPlugin::<Ellipse>::default(),
29        ExtractPlugin::<EllipticArc>::default(),
30        ExtractPlugin::<Line>::default(),
31        ExtractPlugin::<Parallelogram>::default(),
32        ExtractPlugin::<Polygon>::default(),
33        ExtractPlugin::<Rectangle>::default(),
34        ExtractPlugin::<RegularPolygon>::default(),
35        ExtractPlugin::<Square>::default(),
36        ExtractPlugin::<SvgItem>::default(),
37    ));
38    #[cfg(feature = "typst")]
39    app.add_plugins((
40        ExtractPlugin::<super::text::TextItem>::default(),
41        ExtractPlugin::<super::typst::TypstText>::default(),
42    ));
43
44    app.add_systems(RenderSchedule::Prepare, pipeline::prepare_vitems)
45        .add_systems(
46            RenderSchedule::View,
47            (
48                pipeline::compute.in_set(ViewPhase::Compute),
49                pipeline::depth.in_set(ViewPhase::Depth),
50                pipeline::color.in_set(ViewPhase::Color),
51            ),
52        );
53}
54
55/// Compute a normal vector from the ordered VPoints of a RenderVItem.
56///
57/// The primary path uses the 3D equivalent of the shoelace formula on anchor
58/// points. If the accumulated area is degenerate (for example, a single
59/// curved segment), all VPoints are scanned for a non-collinear triple.
60/// A collinear item uses a deterministic plane containing its line, while an
61/// item whose points all coincide falls back to the Z axis.
62pub fn vitem_normal_from_points(points: &[Vec4]) -> Vec3 {
63    if points.len() < 3 {
64        return Vec3::Z;
65    }
66
67    let point3 = |point: &Vec4| point.truncate();
68    let origin = point3(&points[0]);
69
70    // VPoints alternate anchors and handles, so triangulating the ordered
71    // even-indexed anchors is Newell's method in fan form.
72    let mut area_normal = Vec3::ZERO;
73    let mut previous = origin;
74    let mut scale_squared = 0.0_f32;
75    for point in points.iter().step_by(2).skip(1) {
76        let current = point3(point);
77        let previous_offset = previous - origin;
78        let current_offset = current - origin;
79        area_normal += previous_offset.cross(current_offset);
80        scale_squared = scale_squared
81            .max(previous_offset.length_squared())
82            .max(current_offset.length_squared());
83        previous = current;
84    }
85    if area_normal.length_squared() > f32::EPSILON * scale_squared * scale_squared {
86        return area_normal.normalize();
87    }
88
89    // Two-anchor curves have zero shoelace area, but a control point can
90    // still determine their plane. Preserve point order so the sign remains
91    // deterministic across animation frames.
92    let mut direction: Option<Vec3> = None;
93    for point in &points[1..] {
94        let candidate = point3(point) - origin;
95        let candidate_length_squared = candidate.length_squared();
96        if candidate_length_squared <= f32::EPSILON {
97            continue;
98        }
99        if let Some(direction) = direction {
100            let normal = direction.cross(candidate);
101            if normal.length_squared()
102                > f32::EPSILON * direction.length_squared() * candidate_length_squared
103            {
104                return normal.normalize();
105            }
106        } else {
107            direction = Some(candidate);
108        }
109    }
110
111    if let Some(direction) = direction {
112        let direction = direction.normalize();
113        let reference = if direction.dot(Vec3::Z).abs() < 0.99 {
114            Vec3::Z
115        } else {
116            Vec3::X
117        };
118        return (reference - direction * direction.dot(reference)).normalize();
119    }
120
121    Vec3::Z
122}
123
124#[derive(bevy_ecs::component::Component, Debug, Clone, PartialEq)]
125/// A primitive for rendering a vitem.
126pub struct RenderVItem {
127    /// The normal vector of the projection target plane in local space.
128    /// If `None`, the normal will be derived from the points at render time
129    /// and converted to world space along with the item transform.
130    pub normal: Option<Vec3>,
131    /// The points of the item in local space.
132    /// (x, y, z, is_closed)
133    pub points: Vec<Vec4>,
134    /// The local-to-world transform applied when flattening onto the plane.
135    pub transform: Mat4,
136    /// Fill rgbas, see [`Rgba`].
137    pub fill_rgbas: Vec<Rgba>,
138    /// Stroke rgbs, see [`Rgba`].
139    pub stroke_rgbas: Vec<Rgba>,
140    /// Stroke widths, see [`Width`].
141    pub stroke_widths: Vec<Width>,
142}
143
144impl Default for RenderVItem {
145    fn default() -> Self {
146        Self {
147            normal: None,
148            points: vec![Vec4::ZERO; 3],
149            transform: Mat4::IDENTITY,
150            stroke_widths: vec![Width::default(); 2],
151            stroke_rgbas: vec![Rgba::default(); 2],
152            fill_rgbas: vec![Rgba::default(); 2],
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use ranim_core::glam::{Vec3, Vec4};
160
161    use super::vitem_normal_from_points;
162
163    fn point(point: Vec3) -> Vec4 {
164        point.extend(0.0)
165    }
166
167    #[test]
168    fn inferred_normal_matches_the_polygon_plane() {
169        let anchors = [
170            Vec3::new(2.0, -1.0, -1.0),
171            Vec3::new(2.0, 1.0, -1.0),
172            Vec3::new(2.0, 1.0, 1.0),
173            Vec3::new(2.0, -1.0, 1.0),
174            Vec3::new(2.0, -1.0, -1.0),
175        ];
176        let mut points = Vec::new();
177        for edge in anchors.windows(2) {
178            points.push(point(edge[0]));
179            points.push(point((edge[0] + edge[1]) * 0.5));
180        }
181        points.push(point(*anchors.last().unwrap()));
182
183        let normal = vitem_normal_from_points(&points);
184        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
185    }
186
187    #[test]
188    fn normal_inference_falls_back_for_degenerate_inputs() {
189        // A single curved segment has no polygon loop: use its control points.
190        let curved = [point(Vec3::ZERO), point(Vec3::Y), point(Vec3::X)];
191        assert!(vitem_normal_from_points(&curved).abs_diff_eq(Vec3::NEG_Z, 1e-6));
192
193        // Collinear geometry has no unique plane: use the default normal.
194        let collinear = [point(Vec3::ZERO), point(Vec3::X), point(Vec3::X * 2.0)];
195        assert_eq!(vitem_normal_from_points(&collinear), Vec3::Z);
196
197        // A vertical line picks a plane that contains the line.
198        let vertical = [point(Vec3::ZERO), point(Vec3::Z), point(Vec3::Z * 2.0)];
199        let normal = vitem_normal_from_points(&vertical);
200        assert!(normal.abs_diff_eq(Vec3::X, 1e-6));
201        assert!(normal.dot(Vec3::Z).abs() < 1e-6);
202    }
203}