concinnity_core/components/decal.rs
1// Projected-decal schema.
2
3use crate::ecs::TextureHandle;
4use crate::ecs::asset_id::AssetId;
5use crate::ecs::de_opt_texture_handle;
6
7/// A projected texture stamped onto whatever scene geometry sits inside the
8/// decal's oriented box.
9///
10/// The decal is a box volume positioned by `position`/`rotation_deg`/`size` in
11/// world space. The texture is projected down the box's local +Y axis onto the
12/// local X-Z plane and stamped onto the surfaces inside the box; anything
13/// outside the box is unaffected. Surfaces near the box's top and bottom faces
14/// fade out so the stamp doesn't show a hard edge on a curved surface.
15///
16/// The defaults orient the decal as a ground stamp: a flat 1×1 m square laid on
17/// the world X-Z plane, projecting down from +Y. To stamp a wall, rotate so
18/// local +Y points into the surface (e.g. `rotation_deg:[0,0,90]` for a +X
19/// wall).
20///
21/// Decals blend over the lit image without affecting depth, so they layer on
22/// top of the surfaces they stamp.
23///
24/// ```rust
25/// # use concinnity_core::components::Decal;
26/// Decal {
27/// position: [2.0, 0.01, -1.5],
28/// size: [1.5, 0.5, 1.5],
29/// ..Default::default()
30/// };
31/// ```
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33#[serde(default)]
34pub struct Decal {
35 /// Asset identity; injected via `inject_name`. Not part of `args`.
36 #[serde(skip)]
37 pub asset_id: AssetId,
38 /// The [Texture](#texture) asset projected onto the scene.
39 #[serde(deserialize_with = "de_opt_texture_handle")]
40 pub texture: Option<TextureHandle>,
41 /// World-space position of the decal box's centre.
42 pub position: [f32; 3],
43 /// Euler rotation in degrees [pitch, yaw, roll], YXZ order, same as
44 /// [Prop](#prop).
45 pub rotation_deg: [f32; 3],
46 /// Local-space box extents. Local +Y is the projection axis; the texture
47 /// is sampled on the local X-Z plane. A non-positive component disables
48 /// the decal.
49 pub size: [f32; 3],
50 /// Linear-space RGBA tint multiplied with the sampled texture. The alpha
51 /// channel scales the final blend, so `[1,1,1,0]` hides the decal.
52 pub tint: [f32; 4],
53 /// When false the decal is skipped each frame.
54 pub visible: bool,
55}
56
57impl Default for Decal {
58 fn default() -> Self {
59 Self {
60 asset_id: AssetId::default(),
61 texture: None,
62 position: [0.0, 0.0, 0.0],
63 rotation_deg: [0.0, 0.0, 0.0],
64 size: [1.0, 1.0, 1.0],
65 tint: [1.0, 1.0, 1.0, 1.0],
66 visible: true,
67 }
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn a_blank_decal_is_a_visible_untinted_ground_stamp() {
77 let d = Decal::default();
78 assert_eq!(d.rotation_deg, [0.0, 0.0, 0.0]);
79 assert_eq!(d.size, [1.0, 1.0, 1.0]);
80 // An identity tint leaves the sampled texture alone; alpha 1 keeps it
81 // fully blended.
82 assert_eq!(d.tint, [1.0, 1.0, 1.0, 1.0]);
83 assert!(d.visible);
84 assert!(d.texture.is_none());
85 }
86
87 #[test]
88 fn a_wall_stamp_parses_and_round_trips_through_postcard() {
89 crate::test_support::install_resolvers();
90 let d: Decal = serde_json::from_str(
91 r#"{"texture":"tex_bullet","position":[3,1.6,-2],"rotation_deg":[0,0,90],
92 "size":[0.4,0.2,0.4],"tint":[1,1,1,0.5],"visible":false}"#,
93 )
94 .unwrap();
95 assert_eq!(d.texture, Some(TextureHandle(10)));
96 assert_eq!(d.rotation_deg, [0.0, 0.0, 90.0]);
97 assert!(!d.visible);
98
99 let bytes = postcard::to_allocvec(&d).unwrap();
100 let back: Decal = postcard::from_bytes(&bytes).unwrap();
101 assert_eq!(back.texture, Some(TextureHandle(10)));
102 assert_eq!(back.position, [3.0, 1.6, -2.0]);
103 assert_eq!(back.size, [0.4, 0.2, 0.4]);
104 assert_eq!(back.tint, [1.0, 1.0, 1.0, 0.5]);
105 assert_eq!(back.asset_id, AssetId::default());
106 }
107}