1#![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
20pub const FORMAT_VERSION: u32 = 1;
22
23#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct SceneScript {
50 pub path: String,
51 pub class: String,
52}
53
54#[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
67pub 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
93pub 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
166pub 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
181pub 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
230pub 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
255pub 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
327pub 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}