Skip to main content

linkage_blaze/
render.rs

1//! Evaluated three-dimensional drawing geometry and projection helpers.
2//!
3//! [`Item3d`] values are produced by [`crate::LinkageView::draw_items_3d`].
4//! Use [`Item3d::project`] with a [`Projection`] when adapting them to a
5//! Device Envoy display, or inspect the payloads directly in another renderer.
6//!
7//! # Rendering evaluated items
8//!
9//! ```rust
10//! # use embedded_graphics::prelude::Point;
11//! # use linkage_blaze::{LinkageFixed, Vec3};
12//! # use linkage_blaze::render::Item3d;
13//! # use linkage_blaze::render::Projection;
14//! # fn main() -> Result<(), linkage_blaze::Error> {
15//! const LINKAGE: LinkageFixed<0, 0, 5> = LinkageFixed::start()
16//!     .forward(1.0)
17//!     .disk(0.25)
18//!     .sphere(0.5);
19//! let projection = Projection::front_orthographic(Point::new(0, 0), 10.0);
20//! let view = LINKAGE.view();
21//! let mut items = view.draw_items_3d(&[])?;
22//! match items.next() {
23//!     Some(Item3d::Stroke(stroke)) => {
24//!         let _display_item = Item3d::Stroke(stroke).project(&projection);
25//!         let _ = (stroke.start(), stroke.end(), stroke.color(), stroke.width());
26//!     }
27//!     _ => return Err(linkage_blaze::Error::EmptyLinkage),
28//! }
29//! match items.next() {
30//!     Some(Item3d::Disk(disk)) => {
31//!         let _ = (disk.pose(), disk.radius(), disk.color());
32//!     }
33//!     _ => return Err(linkage_blaze::Error::EmptyLinkage),
34//! }
35//! match items.next() {
36//!     Some(Item3d::Sphere(sphere)) => {
37//!         let _ = (sphere.pose(), sphere.radius(), sphere.color());
38//!     }
39//!     _ => return Err(linkage_blaze::Error::EmptyLinkage),
40//! }
41//! let _screen_direction = projection.project_dir(
42//!     linkage_blaze::Pose::start(),
43//!     Vec3::from([1.0, 0.0, 0.0]),
44//!     1.0,
45//! );
46//! # Ok(())
47//! # }
48//! ```
49
50use super::{Pose, Vec3};
51use embedded_graphics::prelude::Point;
52
53use super::Rgb888;
54
55/// A drawable pen-down movement emitted while evaluating a linkage.
56#[derive(Clone, Copy, Debug)]
57pub struct Stroke {
58    pub(crate) start: Pose,
59    pub(crate) end: Pose,
60    pub(crate) color: Rgb888,
61    pub(crate) width: f32,
62}
63
64impl Stroke {
65    /// Return the pose at the start of the segment.
66    /// See the [rendering evaluated items example](#rendering-evaluated-items).
67    #[must_use]
68    pub const fn start(self) -> Pose {
69        self.start
70    }
71    /// Return the pose at the end of the segment.
72    /// See the [rendering evaluated items example](#rendering-evaluated-items).
73    #[must_use]
74    pub const fn end(self) -> Pose {
75        self.end
76    }
77    /// Return the segment color.
78    /// See the [rendering evaluated items example](#rendering-evaluated-items).
79    #[must_use]
80    pub const fn color(self) -> Rgb888 {
81        self.color
82    }
83    /// Return the segment width in linkage units.
84    /// See the [rendering evaluated items example](#rendering-evaluated-items).
85    #[must_use]
86    pub const fn width(self) -> f32 {
87        self.width
88    }
89}
90
91/// A filled disk emitted while evaluating a linkage.
92#[derive(Clone, Copy, Debug)]
93pub struct Disk {
94    pub(crate) pose: Pose,
95    pub(crate) radius: f32,
96    pub(crate) color: Rgb888,
97}
98
99impl Disk {
100    /// Return the disk center pose.
101    /// See the [rendering evaluated items example](#rendering-evaluated-items).
102    #[must_use]
103    pub const fn pose(self) -> Pose {
104        self.pose
105    }
106    #[must_use]
107    /// Return the disk radius in linkage units.
108    /// See the [rendering evaluated items example](#rendering-evaluated-items).
109    pub const fn radius(self) -> f32 {
110        self.radius
111    }
112    #[must_use]
113    /// Return the disk color.
114    /// See the [rendering evaluated items example](#rendering-evaluated-items).
115    pub const fn color(self) -> Rgb888 {
116        self.color
117    }
118}
119
120/// A sphere emitted while evaluating a linkage.
121#[derive(Clone, Copy, Debug)]
122pub struct Sphere {
123    pub(crate) pose: Pose,
124    pub(crate) radius: f32,
125    pub(crate) color: Rgb888,
126}
127
128impl Sphere {
129    /// Return the sphere center pose.
130    /// See the [rendering evaluated items example](#rendering-evaluated-items).
131    #[must_use]
132    pub const fn pose(self) -> Pose {
133        self.pose
134    }
135    #[must_use]
136    /// Return the sphere radius in linkage units.
137    /// See the [rendering evaluated items example](#rendering-evaluated-items).
138    pub const fn radius(self) -> f32 {
139        self.radius
140    }
141    #[must_use]
142    /// Return the sphere color.
143    /// See the [rendering evaluated items example](#rendering-evaluated-items).
144    pub const fn color(self) -> Rgb888 {
145        self.color
146    }
147}
148
149/// Three-dimensional geometry emitted while evaluating a linkage.
150#[derive(Clone, Copy, Debug)]
151pub enum Item3d {
152    /// A pen-down movement represented as a colored segment.
153    Stroke(Stroke),
154    /// A filled disk at the current pose.
155    Disk(Disk),
156    /// A sphere centered at the current pose.
157    Sphere(Sphere),
158}
159
160impl Item3d {
161    /// Project this item into a Device Envoy 2D display draw item.
162    ///
163    /// The projection controls the camera orientation, scale, target pixel,
164    /// and optional perspective depth scaling.
165    /// See the [rendering evaluated items example](#rendering-evaluated-items).
166    #[must_use]
167    pub fn project(self, projection: &Projection) -> device_envoy_core::cyd::display::DrawItem {
168        match self {
169            Self::Stroke(stroke) => device_envoy_core::cyd::display::DrawItem::Stroke {
170                start: stroke.start().project(projection),
171                end: stroke.end().project(projection),
172                color: stroke.color(),
173                pixel_width: projection.project_width(stroke.width()),
174            },
175            Self::Disk(disk) => {
176                let orientation = disk.pose().orientation();
177                device_envoy_core::cyd::display::DrawItem::Ellipse {
178                    center: disk.pose().project(projection),
179                    axis_a: projection.project_dir(
180                        disk.pose(),
181                        orientation.forward(),
182                        disk.radius(),
183                    ),
184                    axis_b: projection.project_dir(disk.pose(), orientation.left(), disk.radius()),
185                    color: disk.color(),
186                }
187            }
188            Self::Sphere(sphere) => device_envoy_core::cyd::display::DrawItem::Circle {
189                center: sphere.pose().project(projection),
190                pixel_radius: projection.project_radius(sphere.pose(), sphere.radius()),
191                color: sphere.color(),
192            },
193        }
194    }
195}
196
197/// Camera projection from Linkage Blaze world coordinates to pixel coordinates.
198///
199/// The rotation maps world axes onto camera axes: row 0 is depth, row 1 is the
200/// source of screen X, and row 2 is the source of screen Y. Named constructors
201/// provide orthographic front/top views and a perspective front view.
202///
203/// Use [`front_orthographic`](Self::front_orthographic) for a stable front
204/// camera, [`top_orthographic`](Self::top_orthographic) for a top camera, or
205/// [`front_perspective`](Self::front_perspective) when depth should affect
206/// scale. [`Item3d::project`] and [`crate::Pose::project`] consume the result.
207pub struct Projection {
208    pub(crate) rotation: super::Mat3,
209    pub(crate) target_origin: Point,
210    pub(crate) scale: f32,
211    /// `None` is orthographic; `Some(focal)` is perspective.
212    pub(crate) focal: Option<f32>,
213}
214
215const NEG_X_BASIS: super::Mat3 = super::Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
216const NEG_Z_BASIS: super::Mat3 = super::Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
217
218impl Projection {
219    /// Create an orthographic front view looking along negative X.
220    /// See the [rendering evaluated items example](#rendering-evaluated-items).
221    pub const fn front_orthographic(target_origin: Point, scale: f32) -> Self {
222        Self {
223            rotation: NEG_X_BASIS,
224            target_origin,
225            scale,
226            focal: None,
227        }
228    }
229
230    /// Create an orthographic top view looking along negative Z.
231    /// See the [rendering evaluated items example](#rendering-evaluated-items).
232    pub const fn top_orthographic(target_origin: Point, scale: f32) -> Self {
233        Self {
234            rotation: NEG_Z_BASIS,
235            target_origin,
236            scale,
237            focal: None,
238        }
239    }
240
241    /// Create a perspective front view looking along negative X.
242    /// See the [rendering evaluated items example](#rendering-evaluated-items).
243    pub const fn front_perspective(target_origin: Point, scale: f32, focal: f32) -> Self {
244        Self {
245            rotation: NEG_X_BASIS,
246            target_origin,
247            scale,
248            focal: Some(focal),
249        }
250    }
251
252    pub(crate) fn world_to_camera(&self, vector: Vec3) -> [f32; 3] {
253        let rotation = &self.rotation;
254        [
255            rotation[0][0] * vector[0] + rotation[0][1] * vector[1] + rotation[0][2] * vector[2],
256            rotation[1][0] * vector[0] + rotation[1][1] * vector[1] + rotation[1][2] * vector[2],
257            rotation[2][0] * vector[0] + rotation[2][1] * vector[1] + rotation[2][2] * vector[2],
258        ]
259    }
260
261    pub(crate) fn depth_factor(&self, depth: f32) -> f32 {
262        match self.focal {
263            None => 1.0,
264            Some(focal) => focal / (focal + depth).max(focal * 0.05),
265        }
266    }
267
268    /// Project a world-space direction, scaled by a world-space radius.
269    /// See the [rendering evaluated items example](#rendering-evaluated-items).
270    pub fn project_dir(&self, pose: Pose, world_dir: Vec3, radius: f32) -> (f32, f32) {
271        let factor = self.depth_factor(self.world_to_camera(pose.position())[0]);
272        let direction = self.world_to_camera(world_dir);
273        let scaled_radius = radius * self.scale * factor;
274        (-direction[1] * scaled_radius, -direction[2] * scaled_radius)
275    }
276
277    /// Convert a world-space sphere radius to pixels at a pose's depth.
278    /// See the [rendering evaluated items example](#rendering-evaluated-items).
279    pub fn project_radius(&self, pose: Pose, radius: f32) -> f32 {
280        radius * self.scale * self.depth_factor(self.world_to_camera(pose.position())[0])
281    }
282
283    /// Convert a world-space stroke width to pixels.
284    /// See the [rendering evaluated items example](#rendering-evaluated-items).
285    pub fn project_width(&self, width: f32) -> f32 {
286        (width * self.scale).max(1.0)
287    }
288}