Skip to main content

yog_cli/design/content/
texture.rs

1//! A texture as the editors show and paint it: its picture, animation and
2//! whether it's the mod's own to change.
3
4use serde_json::Value;
5use std::path::{Path, PathBuf};
6
7use super::{texture_bytes, texture_file, texture_id};
8use crate::design::project::mod_root;
9use crate::serve::ServiceError;
10
11#[derive(yog_data::Schema)]
12pub struct Texture {
13    /// `data:image/png;base64,...`
14    pub url: String,
15    /// Its `.png.mcmeta` animation, if it has one.
16    pub animation: Option<Animation>,
17    /// The texture is the mod's own file — the editor can paint it in place
18    /// (otherwise painting copies it into the mod first).
19    pub writable: bool,
20}
21
22/// A texture's animation (`.png.mcmeta`), as the game plays it: frames
23/// `frame_width`×`frame_height` laid row by row over the image, shown in
24/// `frames` order, each for its `time` in ticks.
25#[derive(Clone, Debug, PartialEq, yog_data::Schema)]
26#[data(rename_all = "camelCase")]
27pub struct Animation {
28    pub frame_width: u32,
29    pub frame_height: u32,
30    pub frames: Vec<AnimationFrame>,
31    /// Blends each frame into the next (the game's `interpolate`).
32    pub interpolate: bool,
33}
34
35#[derive(Clone, Debug, PartialEq, yog_data::Schema)]
36pub struct AnimationFrame {
37    pub index: u32,
38    /// Ticks (1/20 s).
39    pub time: u32,
40}
41
42impl Animation {
43    /// The animation of a `size` image from its `.mcmeta` text; `None` when it has none.
44    pub fn read(meta: &str, size: (u32, u32)) -> Option<Animation> {
45        let doc: Value = serde_json::from_str(meta).ok()?;
46        Self::of(&yog_formats::texture::Animation::from_mcmeta(&doc)?, size)
47    }
48
49    /// `a` of a `size` image, every frame resolved: its size (the game's
50    /// default a square of the image's smaller side) and each frame's time.
51    pub fn of(a: &yog_formats::texture::Animation, (w, h): (u32, u32)) -> Option<Animation> {
52        let side = w.min(h);
53        let fw = a.width.unwrap_or(side).max(1);
54        let fh = a.height.unwrap_or(side).max(1);
55        let count = (w / fw) * (h / fh);
56        if count == 0 {
57            return None;
58        }
59        let frame_time = a.frame_time().max(1);
60        let frames = if a.frames.is_empty() {
61            (0..count)
62                .map(|index| AnimationFrame {
63                    index,
64                    time: frame_time,
65                })
66                .collect()
67        } else {
68            a.frames
69                .iter()
70                .filter(|f| f.index < count)
71                .map(|f| AnimationFrame {
72                    index: f.index,
73                    time: f.time.map_or(frame_time, |t| t.max(1)),
74                })
75                .collect()
76        };
77        Some(Animation {
78            frame_width: fw,
79            frame_height: fh,
80            frames,
81            interpolate: a.interpolate,
82        })
83    }
84
85    /// It as a `.ytexture` keeps it, for a `size` image: every frame listed with its time.
86    pub fn to_format(&self, (w, h): (u32, u32)) -> yog_formats::texture::Animation {
87        let side = w.min(h);
88        yog_formats::texture::Animation {
89            frame_time: None,
90            interpolate: self.interpolate,
91            frames: self
92                .frames
93                .iter()
94                .map(|f| yog_formats::texture::Frame {
95                    index: f.index,
96                    time: Some(f.time),
97                })
98                .collect(),
99            width: (self.frame_width != side).then_some(self.frame_width),
100            height: (self.frame_height != side).then_some(self.frame_height),
101        }
102    }
103}
104
105/// Sets the animation of texture `texture` of the mod (`None`: still) in
106/// its `.ytexture`; the file written.
107pub fn write_animation(
108    file: &Path,
109    texture: &str,
110    animation: Option<&Animation>,
111) -> Result<PathBuf, String> {
112    let root = mod_root(file).ok_or("not in a mod")?;
113    let id = texture_id(texture);
114    let animation = match animation {
115        Some(a) => {
116            let png = texture_bytes(file, texture).map_err(|e| e.message)?;
117            let size = image_size(&png).ok_or_else(|| format!("{id} isn't a picture"))?;
118            Some(a.to_format(size))
119        }
120        None => None,
121    };
122    crate::media::save_texture_animation(&root, &id, animation)
123}
124
125/// A body model's texture (`.ymodel`): `@texture:<ns>:<path>`, as a content file's.
126pub fn body_texture(file: &Path, texture: &str) -> Result<Texture, ServiceError> {
127    self::texture(file, texture)
128}
129
130pub fn texture(file: &Path, texture: &str) -> Result<Texture, ServiceError> {
131    let bytes = texture_bytes(file, texture)?;
132    let animation = image_size(&bytes).and_then(|size| {
133        let meta = texture_file(file, texture, "png.mcmeta").ok()?;
134        Animation::read(&String::from_utf8_lossy(&meta), size)
135    });
136    Ok(Texture {
137        url: super::super::raster::png_url(&bytes),
138        animation,
139        writable: own_texture(file, texture).is_some(),
140    })
141}
142
143/// The mod and the id of texture `texture` when it's the mod's own (it
144/// can be painted in place, or added); `None` for the game's, another
145/// mod's, a `#slot`.
146pub fn own_texture(file: &Path, texture: &str) -> Option<(PathBuf, String)> {
147    if texture.starts_with(yog_formats::content::SLOT_MARK) {
148        return None;
149    }
150    let root = mod_root(file)?;
151    let id = texture_id(texture);
152    crate::media::is_own_texture(&root, &id).then_some((root, id))
153}
154
155/// Writes `png` as the picture of the mod's texture `texture`; the `.yres` written.
156pub fn write_texture(file: &Path, texture: &str, png: Vec<u8>) -> Result<PathBuf, String> {
157    if image_size(&png).is_none() {
158        return Err("not a PNG image".into());
159    }
160    let (root, id) = own_texture(file, texture)
161        .ok_or_else(|| format!("{texture} isn't a texture of this mod"))?;
162    crate::media::save_texture_picture(&root, &id, png)
163}
164
165/// Width and height from a PNG header.
166pub(super) fn image_size(png: &[u8]) -> Option<(u32, u32)> {
167    if png.len() < 24 || &png[1..4] != b"PNG" {
168        return None;
169    }
170    let w = u32::from_be_bytes(png[16..20].try_into().ok()?);
171    let h = u32::from_be_bytes(png[20..24].try_into().ok()?);
172    Some((w, h))
173}