rlevo_core/render/payload.rs
1//! Per-family structured-rendering surfaces for the rich report tier.
2//!
3//! The library and live (TUI) tiers consume [`AsciiRenderable`] /
4//! `StyledFrame`; the report tier consumes a richer per-family payload
5//! when an env opts in. This module defines:
6//!
7//! 1. Shared geometry primitives ([`Point2`]).
8//! 2. Three per-family **snapshot** types — pure data, owned by the
9//! producer side, free of any wire-format concerns:
10//! - [`Landscape2DSnapshot`] for `landscapes` envs.
11//! - [`Box2dSnapshot`] (with [`RigidBody2D`] / [`BodyKind`]) for
12//! `box2d` envs.
13//! - [`Locomotion2DSnapshot`] for `locomotion` envs (their **canonical
14//! view** — locomotion has no ASCII path).
15//! 3. Three opt-in **payload-source** traits — one per family — that an
16//! env implements when it wants the recording layer to capture the
17//! richer payload. Each trait has a single method; envs that do not
18//! implement them fall back to the default `FamilyPayload::Ascii`.
19//!
20//! Wire-format conversion (snapshot → `FamilyPayload`) lives in
21//! `rlevo-benchmarks::record` so the wire layer stays owned by the
22//! benchmarks crate. `rlevo-core` knows nothing about bincode.
23//!
24//! [`AsciiRenderable`]: super::AsciiRenderable
25
26use serde::{Deserialize, Serialize};
27
28/// 2D point in the family's natural coordinate frame.
29///
30/// Each family interprets the frame differently:
31/// - landscapes: `(x, y)` in the search domain.
32/// - box2d: world-space metres.
33/// - locomotion: sagittal-plane projection, `x = forward`, `y = up`.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
35pub struct Point2 {
36 pub x: f32,
37 pub y: f32,
38}
39
40impl Point2 {
41 /// Constructs a new [`Point2`] from the given `x` and `y` coordinates.
42 #[must_use]
43 pub const fn new(x: f32, y: f32) -> Self {
44 Self { x, y }
45 }
46}
47
48// ---------------------------------------------------------------------------
49// Landscape2D
50// ---------------------------------------------------------------------------
51
52/// A snapshot of the landscape state at one captured frame.
53///
54/// The landscape itself (the function evaluated at every grid point) is
55/// identified by `label` so the report-tier renderer can reach for a
56/// shared, precomputed heatmap rather than embedding one per frame.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Landscape2DSnapshot {
59 /// Search domain along the x axis.
60 pub bounds_x: (f32, f32),
61 /// Search domain along the y axis.
62 pub bounds_y: (f32, f32),
63 /// Current candidate position.
64 pub current: Point2,
65 /// Best candidate seen so far, if tracked.
66 pub best: Option<Point2>,
67 /// Recent history of `current`, oldest first. Capped by the producer.
68 pub trail: Vec<Point2>,
69 /// Identifier for the underlying landscape (e.g. `"sphere"`,
70 /// `"ackley"`, `"rastrigin"`). The renderer uses this to look up a
71 /// shared heatmap; unknown labels fall back to a plain background.
72 pub label: String,
73}
74
75/// Producer-side trait. An env implements this when it wants its
76/// recording to ship a `FamilyPayload::Landscape2D` instead of `Ascii`.
77pub trait Landscape2DPayloadSource {
78 /// Returns a [`Landscape2DSnapshot`] capturing the current frame.
79 fn landscape2d_snapshot(&self) -> Landscape2DSnapshot;
80}
81
82// ---------------------------------------------------------------------------
83// Box2d
84// ---------------------------------------------------------------------------
85
86/// Semantic class of a [`RigidBody2D`] — drives the client-side CSS
87/// class so colour / stroke / fill choices stay accessible and consistent
88/// across all box2d envs.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[non_exhaustive]
91pub enum BodyKind {
92 Hull,
93 Wheel,
94 Leg,
95 Wing,
96 Ground,
97 Goal,
98 Other,
99}
100
101/// One rigid body's polygon + pose, captured at one frame.
102///
103/// `vertices` are expressed in the body's local frame; the renderer
104/// transforms them via `position` + `rotation_rad` so the wire payload
105/// stays compact when a body moves but does not deform.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct RigidBody2D {
108 /// Polygon corners in the body's **local** frame, counter-clockwise.
109 pub vertices: Vec<Point2>,
110 /// World-space position of the body's local origin.
111 pub position: Point2,
112 /// Rotation of the body about its local origin, in radians.
113 pub rotation_rad: f32,
114 /// Semantic class used by the renderer to choose colour / stroke / fill.
115 pub kind: BodyKind,
116}
117
118/// All bodies + contact points + world bounds, captured at one frame.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct Box2dSnapshot {
121 /// World-space rectangle the renderer fits its viewport to.
122 /// `(min, max)` corners.
123 pub world_bounds: (Point2, Point2),
124 /// All rigid bodies in the scene, in paint order.
125 pub bodies: Vec<RigidBody2D>,
126 /// Active contact points between bodies this frame.
127 pub contacts: Vec<Point2>,
128}
129
130/// Producer-side trait. A box2d env implements this when it wants its
131/// recording to ship a `FamilyPayload::Box2D` instead of `Ascii`.
132pub trait Box2dPayloadSource {
133 /// Returns a [`Box2dSnapshot`] capturing the current frame.
134 fn box2d_snapshot(&self) -> Box2dSnapshot;
135}
136
137// ---------------------------------------------------------------------------
138// Locomotion2D
139// ---------------------------------------------------------------------------
140
141/// Sagittal-plane projection of a locomotion env, captured at one frame.
142///
143/// **This is locomotion's canonical view** — locomotion envs do not
144/// implement [`AsciiRenderable`], so this payload is the only
145/// rendering pathway in the whole stack.
146///
147/// `joints[i]` is the i-th joint position; `bones[k] = (a, b)` means
148/// joint `a` connects to joint `b` with a rigid bone. `ground_y` is the
149/// y-coordinate of the ground line in the same frame. `com` is the
150/// projected centre of mass (optional — not every env tracks it).
151/// `contacts` are footstep contact points the report tier may sprinkle
152/// as small open rings.
153///
154/// [`AsciiRenderable`]: super::AsciiRenderable
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct Locomotion2DSnapshot {
157 /// Positions of each joint in the sagittal-plane frame.
158 pub joints: Vec<Point2>,
159 /// Rigid-bone connectivity: each `(a, b)` pair connects `joints[a]` to
160 /// `joints[b]`.
161 pub bones: Vec<(u32, u32)>,
162 /// Y-coordinate of the ground line in the same frame as the joints.
163 pub ground_y: f32,
164 /// Projected centre of mass. `None` when the env does not track it.
165 pub com: Option<Point2>,
166 /// Footstep contact points; rendered as small open rings on the report
167 /// tier.
168 pub contacts: Vec<Point2>,
169}
170
171/// Producer-side trait. A locomotion env implements this to supply the only
172/// rendering pathway in the stack — locomotion envs do not implement
173/// [`AsciiRenderable`], so this payload is the canonical view.
174///
175/// [`AsciiRenderable`]: super::AsciiRenderable
176pub trait Locomotion2DPayloadSource {
177 /// Returns a [`Locomotion2DSnapshot`] capturing the current frame.
178 fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot;
179}
180
181// ---------------------------------------------------------------------------
182// Grid
183// ---------------------------------------------------------------------------
184
185/// Cardinal facing of the grid agent. Mirrors the env-side `Direction`
186/// (`+x` East, `+y` South); the renderer rotates the agent triangle to match.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188pub enum GridDir {
189 East,
190 South,
191 West,
192 North,
193}
194
195/// The six Minigrid colours, paired with a redundant non-colour signal
196/// (glyph/label) on the report tier per the accessibility contract.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[non_exhaustive]
199pub enum GridColor {
200 Red,
201 Green,
202 Blue,
203 Purple,
204 Yellow,
205 Grey,
206}
207
208/// Open / closed / locked state of a [`GridTile::Door`].
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210pub enum GridDoorState {
211 Open,
212 Closed,
213 Locked,
214}
215
216/// One grid cell's contents, projected from the env-side `Entity`.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub enum GridTile {
220 /// Empty walkable cell.
221 Empty,
222 /// Walkable floor (drawn distinctly from `Empty`).
223 Floor,
224 /// Impassable wall.
225 Wall,
226 /// Terminal goal cell.
227 Goal,
228 /// Hazard cell (ends the episode in failure).
229 Lava,
230 /// Door of the given colour and state.
231 Door(GridColor, GridDoorState),
232 /// Colored key.
233 Key(GridColor),
234 /// Colored ball.
235 Ball(GridColor),
236 /// Colored box.
237 Box(GridColor),
238}
239
240/// The agent marker: cell position, facing, and any carried item.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242pub struct GridAgentMarker {
243 /// Column (0-based, left to right).
244 pub x: u16,
245 /// Row (0-based, top to bottom).
246 pub y: u16,
247 /// Direction the agent faces.
248 pub dir: GridDir,
249 /// Item the agent is holding, if any.
250 pub carrying: Option<GridTile>,
251}
252
253/// A snapshot of a grid (Minigrid-style) environment at one frame.
254///
255/// `tiles` is row-major with `tiles.len() == width * height`; cell
256/// `(x, y)` is `tiles[y * width + x]`. The renderer draws one `<rect>`
257/// per tile, the agent as a rotated triangle, and pickable objects as
258/// shape-distinct glyphs.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct GridSnapshot {
261 /// Grid width in cells.
262 pub width: u16,
263 /// Grid height in cells.
264 pub height: u16,
265 /// Row-major tiles, `len == width * height`.
266 pub tiles: Vec<GridTile>,
267 /// The agent marker.
268 pub agent: GridAgentMarker,
269}
270
271/// Producer-side trait. A grid env implements this so its recording ships
272/// a `FamilyPayload::Grid` rendered from structured tile state instead of
273/// `Ascii` text.
274pub trait GridPayloadSource {
275 /// Returns a [`GridSnapshot`] capturing the current frame.
276 fn grid_snapshot(&self) -> GridSnapshot;
277}
278
279// ---------------------------------------------------------------------------
280// TabularText
281// ---------------------------------------------------------------------------
282
283/// Background class of a [`TabularGrid`] cell — the union of cell semantics
284/// across the grid-shaped toy-text envs (FrozenLake / CliffWalking / Taxi).
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[non_exhaustive]
287pub enum TabularCell {
288 /// Plain walkable cell.
289 Empty,
290 /// Frozen safe surface (FrozenLake).
291 Frozen,
292 /// Episode start cell.
293 Start,
294 /// Terminal goal cell.
295 Goal,
296 /// Hazard cell — falling in a hole / stepping off the cliff.
297 Hazard,
298}
299
300/// Semantic class of a [`TabularMarker`] overlaid on a [`TabularGrid`] cell.
301/// Each maps to a shape-distinct glyph on the report tier.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[non_exhaustive]
304pub enum TabularMarkerKind {
305 /// The controllable agent (elf / taxi).
306 Agent,
307 /// Passenger waiting to be picked up (Taxi).
308 Passenger,
309 /// Drop-off destination (Taxi).
310 Destination,
311 /// A named pickup/drop location (Taxi's R/G/Y/B corners).
312 Location,
313}
314
315/// A point-of-interest overlaid on a grid cell.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317pub struct TabularMarker {
318 /// Column (0-based, left to right).
319 pub x: u16,
320 /// Row (0-based, top to bottom).
321 pub y: u16,
322 /// Semantic role that determines the glyph the renderer draws.
323 pub kind: TabularMarkerKind,
324}
325
326/// Grid layout for the grid-shaped toy-text envs. `cells` is row-major,
327/// `len == width * height`; `markers` overlay agent / passenger / destination.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct TabularGrid {
330 /// Grid width in cells.
331 pub width: u16,
332 /// Grid height in cells.
333 pub height: u16,
334 /// Row-major cells, `len == width * height`; cell `(x, y)` is
335 /// `cells[y * width + x]`.
336 pub cells: Vec<TabularCell>,
337 /// Points-of-interest overlaid on top of the background cells.
338 pub markers: Vec<TabularMarker>,
339}
340
341/// Card-table layout for Blackjack. Card values are blackjack face values
342/// (`1` = ace, `2..=10`, `10` for face cards). `dealer_showing` is the
343/// dealer's single up-card while the hole card is concealed during play;
344/// `dealer_cards` carries the full hand for post-episode review.
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346pub struct CardTable {
347 pub player_cards: Vec<u8>,
348 pub player_total: u8,
349 pub usable_ace: bool,
350 pub dealer_cards: Vec<u8>,
351 pub dealer_showing: u8,
352}
353
354/// Layout discriminant for [`TabularSnapshot`] — grid-shaped envs vs the
355/// Blackjack card table.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[non_exhaustive]
358pub enum TabularLayout {
359 Grid(TabularGrid),
360 Cards(CardTable),
361}
362
363/// A snapshot of a tabular (toy-text) environment at one frame.
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365pub struct TabularSnapshot {
366 /// The layout discriminant, carrying either a grid or a card-table view.
367 pub layout: TabularLayout,
368}
369
370/// Producer-side trait. A toy-text env implements this so its recording
371/// ships a `FamilyPayload::TabularText` rendered from structured layout
372/// state instead of `Ascii` text.
373pub trait TabularPayloadSource {
374 /// Returns a [`TabularSnapshot`] capturing the current frame.
375 fn tabular_snapshot(&self) -> TabularSnapshot;
376}
377
378// ---------------------------------------------------------------------------
379// Classic2D
380// ---------------------------------------------------------------------------
381
382/// Semantic role of a [`Classic2DBody`], driving the report tier's CSS
383/// (colour / stroke / fill) so the parts of each classic-control mechanism
384/// stay visually distinct and accessible.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[non_exhaustive]
387pub enum Classic2DRole {
388 /// The ground line / track / terrain profile.
389 Track,
390 /// The cart (CartPole).
391 Cart,
392 /// A balancing pole (CartPole / Pendulum).
393 Pole,
394 /// A rigid link of a multi-link arm (Acrobot).
395 Link,
396 /// The car (MountainCar).
397 Car,
398 /// A pivot / hinge point (drawn as a small marker).
399 Hinge,
400}
401
402/// One body of a classic-control mechanism, expressed as a **world-space**
403/// polyline (already transformed — no separate pose). A single-point body is
404/// a marker (e.g. a hinge); `closed = true` makes it a filled polygon.
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406pub struct Classic2DBody {
407 /// World-space points in the env's natural frame (`+y` up).
408 pub points: Vec<Point2>,
409 /// What this body is, for styling.
410 pub role: Classic2DRole,
411 /// `true` → render as a closed filled polygon; `false` → open polyline.
412 pub closed: bool,
413}
414
415/// A snapshot of a classic-control env (CartPole / Pendulum / MountainCar /
416/// Acrobot) at one frame: a set of world-space bodies plus the viewport the
417/// renderer fits to.
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct Classic2DSnapshot {
420 /// Bodies in paint order (track first, moving parts last).
421 pub bodies: Vec<Classic2DBody>,
422 /// Viewport rectangle the renderer fits to: `(min, max)` corners.
423 pub bounds: (Point2, Point2),
424}
425
426/// Producer-side trait. A classic-control env implements this so its
427/// recording ships a `FamilyPayload::Classic2D` rendered as SVG line-art
428/// instead of `Ascii` text.
429pub trait Classic2DPayloadSource {
430 /// Returns a [`Classic2DSnapshot`] capturing the current frame.
431 fn classic2d_snapshot(&self) -> Classic2DSnapshot;
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn point2_const_constructor() {
440 const P: Point2 = Point2::new(1.5, -2.5);
441 assert!((P.x - 1.5).abs() < f32::EPSILON);
442 assert!((P.y + 2.5).abs() < f32::EPSILON);
443 }
444
445 #[test]
446 fn landscape_snapshot_constructs_and_compares() {
447 let snap = Landscape2DSnapshot {
448 bounds_x: (-5.0, 5.0),
449 bounds_y: (-5.0, 5.0),
450 current: Point2::new(0.5, -0.25),
451 best: Some(Point2::new(0.0, 0.0)),
452 trail: vec![Point2::new(0.1, 0.2), Point2::new(0.3, 0.4)],
453 label: "sphere".into(),
454 };
455 assert_eq!(snap.trail.len(), 2);
456 assert_eq!(snap.label, "sphere");
457 assert_eq!(snap.clone(), snap);
458 }
459
460 #[test]
461 fn box2d_snapshot_carries_typed_body_kinds() {
462 let snap = Box2dSnapshot {
463 world_bounds: (Point2::new(-10.0, -1.0), Point2::new(10.0, 8.0)),
464 bodies: vec![
465 RigidBody2D {
466 vertices: vec![
467 Point2::new(-0.5, -0.5),
468 Point2::new(0.5, -0.5),
469 Point2::new(0.5, 0.5),
470 Point2::new(-0.5, 0.5),
471 ],
472 position: Point2::new(1.0, 2.0),
473 rotation_rad: 0.25,
474 kind: BodyKind::Hull,
475 },
476 RigidBody2D {
477 vertices: vec![Point2::new(0.0, 0.0)],
478 position: Point2::new(0.0, 0.0),
479 rotation_rad: 0.0,
480 kind: BodyKind::Ground,
481 },
482 ],
483 contacts: vec![Point2::new(0.0, 0.0)],
484 };
485 assert_eq!(snap.bodies.len(), 2);
486 assert_eq!(snap.bodies[0].kind, BodyKind::Hull);
487 assert_eq!(snap.bodies[1].kind, BodyKind::Ground);
488 }
489
490 #[test]
491 fn locomotion_snapshot_default_ground_and_optional_com() {
492 let snap = Locomotion2DSnapshot {
493 joints: vec![Point2::new(0.0, 1.0), Point2::new(0.5, 1.5)],
494 bones: vec![(0, 1)],
495 ground_y: 0.0,
496 com: None,
497 contacts: vec![],
498 };
499 assert_eq!(snap.bones, vec![(0u32, 1u32)]);
500 assert!(snap.com.is_none());
501 }
502
503 /// Sanity: each per-family trait is a "default-free" surface — an
504 /// implementor must supply a non-trivial snapshot. Sticking a stub
505 /// impl here also guards against unintentional accidental renames.
506 struct Stub;
507 impl Landscape2DPayloadSource for Stub {
508 fn landscape2d_snapshot(&self) -> Landscape2DSnapshot {
509 Landscape2DSnapshot {
510 bounds_x: (0.0, 1.0),
511 bounds_y: (0.0, 1.0),
512 current: Point2::default(),
513 best: None,
514 trail: vec![],
515 label: "stub".into(),
516 }
517 }
518 }
519 impl Box2dPayloadSource for Stub {
520 fn box2d_snapshot(&self) -> Box2dSnapshot {
521 Box2dSnapshot {
522 world_bounds: (Point2::default(), Point2::new(1.0, 1.0)),
523 bodies: vec![],
524 contacts: vec![],
525 }
526 }
527 }
528 impl Locomotion2DPayloadSource for Stub {
529 fn locomotion2d_snapshot(&self) -> Locomotion2DSnapshot {
530 Locomotion2DSnapshot {
531 joints: vec![],
532 bones: vec![],
533 ground_y: 0.0,
534 com: None,
535 contacts: vec![],
536 }
537 }
538 }
539
540 #[test]
541 fn payload_source_traits_compose_via_stub() {
542 let stub = Stub;
543 assert_eq!(stub.landscape2d_snapshot().label, "stub");
544 assert_eq!(stub.box2d_snapshot().bodies.len(), 0);
545 assert_eq!(stub.locomotion2d_snapshot().joints.len(), 0);
546 }
547}