Skip to main content

pite_scene/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `pite-scene`: TOML schema, instantiation, validation, migration.
3//!
4//! Depends on `pite-core` only. TOML is canonical; binary is a later
5//! export cache, not a source format.
6
7#![forbid(unsafe_code)]
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use pite_core::{NodeDesc, NodeId, NodeTree, PropValue, Props, ScriptRef};
14use serde::{Deserialize, Serialize};
15
16pub mod cache;
17
18pub use cache::{CacheStatus, CACHE_VERSION};
19
20/// Current scene format version. Bump = migrate or error loudly, never silent.
21pub const FORMAT_VERSION: u32 = 1;
22
23/// Canonical `.pitescene` document.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct SceneDoc {
26    pub format_version: u32,
27    pub root: String,
28    #[serde(default)]
29    pub node: Vec<SceneNode>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub instance: Vec<SceneInstance>,
32}
33
34/// One flat node entry. `props` preserves unknown keys (forward compat).
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct SceneNode {
37    pub id: String,
38    #[serde(rename = "type")]
39    pub type_name: String,
40    pub name: String,
41    pub parent: Option<String>,
42    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43    pub props: BTreeMap<String, toml::Value>,
44    pub script: Option<SceneScript>,
45}
46
47/// Attached script reference (project-relative path + class).
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct SceneScript {
50    pub path: String,
51    pub class: String,
52}
53
54/// An instantiation entry: load the referenced file, clone its subtree
55/// under `parent`, remap ids with `prefix`, apply prop overrides.
56/// Overrides are keyed `"node_id.prop"`; unknown targets fail loudly.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SceneInstance {
59    pub scene: String,
60    pub parent: Option<String>,
61    #[serde(default)]
62    pub prefix: String,
63    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
64    pub overrides: BTreeMap<String, toml::Value>,
65}
66
67/// Format migration hook (§12). New fields are additive; breaking
68/// change = version bump, never silent reinterpretation.
69pub fn migrate(from: u32, _doc: &toml::Value) -> Result<()> {
70    if from == FORMAT_VERSION {
71        return Ok(());
72    }
73    anyhow::bail!("unsupported scene format_version {from} (engine supports {FORMAT_VERSION})");
74}
75
76pub fn parse_scene_str(text: &str) -> Result<SceneDoc> {
77    let raw: toml::Value = toml::from_str(text).context("scene is not valid TOML")?;
78    let version = raw
79        .get("format_version")
80        .and_then(toml::Value::as_integer)
81        .unwrap_or(0);
82    migrate(version as u32, &raw)?;
83    let doc: SceneDoc = raw.try_into().context("scene does not match schema")?;
84    Ok(doc)
85}
86
87pub fn load_scene(path: &Path) -> Result<SceneDoc> {
88    let text = std::fs::read_to_string(path)
89        .with_context(|| format!("cannot read scene {}", path.display()))?;
90    parse_scene_str(&text).with_context(|| format!("cannot parse scene {}", path.display()))
91}
92
93/// Load with the binary cache: a valid cache wins; a missing cache parses
94/// TOML and writes the cache; a corrupt or version-mismatched cache parses
95/// TOML, rewrites the cache, and reports the fallback loudly via the
96/// returned warning — never silently. TOML stays source of truth.
97pub fn load_cached(path: &Path) -> Result<(SceneDoc, CacheStatus, Option<String>)> {
98    let bytes =
99        std::fs::read(path).with_context(|| format!("cannot read scene {}", path.display()))?;
100    let source_hash = cache::fnv1a_u64(&bytes);
101    let source_hex = format!("{source_hash:016x}");
102    let cache_path = cache::cache_path_for(path, &source_hex);
103
104    let mut rebuilt_reason: Option<String> = None;
105    if cache_path.is_file() {
106        match std::fs::read(&cache_path) {
107            Ok(cached) => match cache::decode(&cached) {
108                Ok((doc, header))
109                    if header.source_hash == source_hash
110                        && header.source_len == bytes.len() as u64 =>
111                {
112                    cache::prune_stale(path, &source_hex);
113                    return Ok((doc, CacheStatus::Hit, None));
114                }
115                Ok(_) => {
116                    rebuilt_reason = Some(format!(
117                        "scene cache {} does not match {}",
118                        cache_path.display(),
119                        path.display()
120                    ));
121                }
122                Err(e) => {
123                    rebuilt_reason = Some(format!(
124                        "scene cache {} ignored ({e:#}); rebuilding from TOML",
125                        cache_path.display()
126                    ));
127                }
128            },
129            Err(e) => {
130                rebuilt_reason = Some(format!(
131                    "scene cache {} unreadable ({e:#}); rebuilding from TOML",
132                    cache_path.display()
133                ));
134            }
135        }
136    }
137
138    let text = std::str::from_utf8(&bytes)
139        .with_context(|| format!("cannot parse scene {}", path.display()))?;
140    let doc =
141        parse_scene_str(text).with_context(|| format!("cannot parse scene {}", path.display()))?;
142
143    let payload = cache::encode(&doc, source_hash, bytes.len() as u64);
144    let mut warning = rebuilt_reason.clone();
145    if let Some(dir) = cache_path.parent() {
146        if let Err(e) =
147            std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&cache_path, &payload))
148        {
149            let write_warn = format!("cannot write scene cache {} ({e:#})", cache_path.display());
150            warning = Some(match warning {
151                Some(w) => format!("{w}; {write_warn}"),
152                None => write_warn,
153            });
154        } else {
155            cache::prune_stale(path, &source_hex);
156        }
157    }
158
159    let status = match rebuilt_reason {
160        Some(reason) => CacheStatus::Rebuilt(reason),
161        None => CacheStatus::Miss,
162    };
163    Ok((doc, status, warning))
164}
165
166/// Preferred entry point: [`load_cached`] with the fallback warning
167/// surfaced loudly on stderr. Corruption anywhere falls back to TOML,
168/// never silently.
169pub fn load_scene_cached(path: &Path) -> Result<SceneDoc> {
170    let (doc, _, warning) = load_cached(path)?;
171    if let Some(w) = warning {
172        tracing::warn!("{w}");
173    }
174    Ok(doc)
175}
176
177pub fn save_scene(doc: &SceneDoc) -> Result<String> {
178    toml::to_string(doc).context("cannot serialize scene")
179}
180
181/// Structural validation. Missing script = placeholder, never a load
182/// failure; unknown props warn and are preserved on save.
183pub fn validate(doc: &SceneDoc) -> Vec<String> {
184    let mut issues = Vec::new();
185    if doc.format_version != FORMAT_VERSION {
186        issues.push(format!(
187            "format_version {} != supported {FORMAT_VERSION}",
188            doc.format_version
189        ));
190    }
191    let ids: std::collections::HashSet<&str> = doc.node.iter().map(|n| n.id.as_str()).collect();
192    if !ids.contains(doc.root.as_str()) {
193        issues.push(format!("root {:?} not found in node list", doc.root));
194    }
195    for n in &doc.node {
196        if let Some(parent) = &n.parent {
197            if !ids.contains(parent.as_str()) {
198                issues.push(format!("node {:?} has unknown parent {parent:?}", n.id));
199            }
200        }
201    }
202    issues
203}
204
205fn toml_to_prop(value: &toml::Value) -> PropValue {
206    match value {
207        toml::Value::String(s) => PropValue::Str(s.clone()),
208        toml::Value::Integer(i) => PropValue::Int(*i),
209        toml::Value::Float(f) => PropValue::Num(*f),
210        toml::Value::Boolean(b) => PropValue::Bool(*b),
211        toml::Value::Array(items) => {
212            let nums: Vec<f64> = items
213                .iter()
214                .map(|v| match v {
215                    toml::Value::Integer(i) => *i as f64,
216                    toml::Value::Float(f) => *f,
217                    _ => f64::NAN,
218                })
219                .collect();
220            if nums.len() == 2 && nums.iter().all(|n| !n.is_nan()) {
221                PropValue::Vec2(nums[0], nums[1])
222            } else {
223                PropValue::Str(value.to_string())
224            }
225        }
226        _ => PropValue::Str(value.to_string()),
227    }
228}
229
230/// Convert a scene document into a runtime [`NodeTree`].
231/// Missing scripts are placeholders here, never a load failure.
232pub fn to_node_tree(doc: &SceneDoc) -> Result<NodeTree> {
233    let mut tree = NodeTree::new();
234    for n in &doc.node {
235        let mut props = Props::default();
236        for (k, v) in &n.props {
237            props.insert(k.clone(), toml_to_prop(v));
238        }
239        tree.insert(NodeDesc {
240            id: NodeId::from(n.id.clone()),
241            type_name: n.type_name.clone(),
242            name: n.name.clone(),
243            parent: n.parent.clone().map(NodeId::from),
244            props,
245            script: n.script.as_ref().map(|s| ScriptRef {
246                path: s.path.clone(),
247                class_name: s.class.clone(),
248            }),
249        })
250        .map_err(|e| anyhow::anyhow!("node {:?}: {e}", n.id))?;
251    }
252    Ok(tree)
253}
254
255/// Load the referenced scene, clone its subtree, apply prop overrides.
256/// Returns node records ready to insert into the host tree plus the
257/// remapped id of the referenced scene's root.
258pub fn instantiate(
259    scene_path: &Path,
260    prefix: &str,
261    overrides: &BTreeMap<String, toml::Value>,
262) -> Result<(Vec<NodeDesc>, String)> {
263    let doc = load_scene_cached(scene_path)?;
264    let mut descs = Vec::with_capacity(doc.node.len());
265    for n in &doc.node {
266        let mut props = Props::default();
267        for (k, v) in &n.props {
268            props.insert(k.clone(), toml_to_prop(v));
269        }
270        descs.push(NodeDesc {
271            id: NodeId::from(format!("{prefix}{}", n.id)),
272            type_name: n.type_name.clone(),
273            name: n.name.clone(),
274            parent: n
275                .parent
276                .clone()
277                .map(|p| NodeId::from(format!("{prefix}{p}"))),
278            props,
279            script: n.script.as_ref().map(|s| ScriptRef {
280                path: s.path.clone(),
281                class_name: s.class.clone(),
282            }),
283        });
284    }
285    for (key, value) in overrides {
286        let (node_id, prop) = key.split_once('.').with_context(|| {
287            format!(
288                "override {key:?} must be \"node_id.prop\" in {}",
289                scene_path.display()
290            )
291        })?;
292        let target = format!("{prefix}{node_id}");
293        let desc = descs
294            .iter_mut()
295            .find(|d| d.id.as_str() == target)
296            .with_context(|| {
297                format!(
298                    "override target {target:?} not found in {}",
299                    scene_path.display()
300                )
301            })?;
302        desc.props.insert(prop.to_string(), toml_to_prop(value));
303    }
304    Ok((descs, format!("{prefix}{}", doc.root)))
305}
306
307fn resolve_scene_ref(
308    scene_ref: &str,
309    scene_dir: &Path,
310    project_dir: Option<&Path>,
311) -> Result<PathBuf> {
312    if let Some(rel) = scene_ref.strip_prefix("res://") {
313        let root = project_dir.with_context(|| {
314            format!("scene {scene_ref:?} uses res:// but no project root is known")
315        })?;
316        Ok(root.join(rel))
317    } else {
318        let p = Path::new(scene_ref);
319        if p.is_absolute() {
320            Ok(p.to_path_buf())
321        } else {
322            Ok(scene_dir.join(p))
323        }
324    }
325}
326
327/// Build the full runtime tree: host nodes plus every `[[instance]]`
328/// subtree (loaded, id-remapped, overrides applied).
329pub fn build_tree(
330    doc: &SceneDoc,
331    scene_dir: &Path,
332    project_dir: Option<&Path>,
333) -> Result<NodeTree> {
334    let mut tree = to_node_tree(doc)?;
335    let host_ids: std::collections::HashSet<&str> =
336        doc.node.iter().map(|n| n.id.as_str()).collect();
337    for inst in &doc.instance {
338        let ref_path = resolve_scene_ref(&inst.scene, scene_dir, project_dir)?;
339        let (mut descs, subtree_root) = instantiate(&ref_path, &inst.prefix, &inst.overrides)?;
340        let attach_at = inst.parent.clone().unwrap_or_else(|| doc.root.clone());
341        if !host_ids.contains(attach_at.as_str())
342            && tree.get(&NodeId::from(attach_at.clone())).is_none()
343        {
344            return Err(pite_core::CoreError::MissingParent {
345                child: format!("instance of {}", inst.scene),
346                parent: attach_at,
347            }
348            .into());
349        }
350        for desc in descs.drain(..) {
351            let mut desc = desc;
352            if desc.id.as_str() == subtree_root {
353                desc.parent = Some(NodeId::from(attach_at.clone()));
354            }
355            tree.insert(desc.clone())
356                .map_err(|e| anyhow::anyhow!("instance of {}: {e}", inst.scene))?;
357        }
358    }
359    Ok(tree)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const HOST: &str = r#"
367format_version = 1
368root = "root"
369
370[[node]]
371id = "root"
372type = "Node2D"
373name = "Main"
374
375[[node]]
376id = "player"
377type = "Sprite2D"
378name = "Player"
379parent = "root"
380
381[node.props]
382texture = "res://assets/player.png"
383position = [100.0, 200.0]
384mystery = "kept"
385
386[node.script]
387path = "res://scripts/player.py"
388class = "Player"
389"#;
390
391    #[test]
392    fn round_trip_preserves_unknown_props() {
393        let doc = parse_scene_str(HOST).unwrap();
394        let saved = save_scene(&doc).unwrap();
395        let again = parse_scene_str(&saved).unwrap();
396        assert_eq!(doc, again);
397        let player = again.node.iter().find(|n| n.id == "player").unwrap();
398        assert_eq!(
399            player.props.get("mystery"),
400            Some(&toml::Value::String("kept".to_string()))
401        );
402    }
403
404    #[test]
405    fn version_bump_errors_loudly() {
406        let err = parse_scene_str("format_version = 99\nroot = \"root\"\n").unwrap_err();
407        assert!(err.to_string().contains("unsupported scene format_version"));
408    }
409
410    fn write_fixture(dir: &Path, name: &str, text: &str) -> PathBuf {
411        let path = dir.join(name);
412        std::fs::write(&path, text).unwrap();
413        path
414    }
415
416    #[test]
417    fn instantiate_clones_subtree_and_applies_overrides() {
418        let dir = std::env::temp_dir().join(format!("pite-inst-{}", std::process::id()));
419        std::fs::create_dir_all(&dir).unwrap();
420        let ref_path = write_fixture(
421            &dir,
422            "enemy.pitescene",
423            r#"
424format_version = 1
425root = "enemy"
426
427[[node]]
428id = "enemy"
429type = "Node2D"
430name = "Enemy"
431
432[[node]]
433id = "sprite"
434type = "Sprite2D"
435name = "Sprite"
436parent = "enemy"
437
438[node.props]
439position = [0.0, 0.0]
440"#,
441        );
442        let mut overrides = BTreeMap::new();
443        overrides.insert(
444            "sprite.position".to_string(),
445            toml::Value::Array(vec![toml::Value::Float(10.0), toml::Value::Float(20.0)]),
446        );
447        let (descs, root) = instantiate(&ref_path, "e1_", &overrides).unwrap();
448        assert_eq!(root, "e1_enemy");
449        assert_eq!(descs.len(), 2);
450        let sprite = descs.iter().find(|d| d.id.as_str() == "e1_sprite").unwrap();
451        assert_eq!(sprite.parent, Some(NodeId::from("e1_enemy".to_string())));
452        assert_eq!(
453            sprite.props.get("position"),
454            Some(&PropValue::Vec2(10.0, 20.0))
455        );
456        std::fs::remove_dir_all(&dir).ok();
457    }
458
459    #[test]
460    fn instantiate_unknown_override_target_fails() {
461        let dir = std::env::temp_dir().join(format!("pite-inst-bad-{}", std::process::id()));
462        std::fs::create_dir_all(&dir).unwrap();
463        let ref_path = write_fixture(
464            &dir,
465            "tiny.pitescene",
466            "format_version = 1\nroot = \"solo\"\n\n[[node]]\nid = \"solo\"\ntype = \"Node\"\nname = \"Solo\"\n",
467        );
468        let mut overrides = BTreeMap::new();
469        overrides.insert("ghost.x".to_string(), toml::Value::Integer(1));
470        let err = instantiate(&ref_path, "", &overrides).unwrap_err();
471        assert!(err.to_string().contains("override target"));
472        std::fs::remove_dir_all(&dir).ok();
473    }
474}