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}