1#![forbid(unsafe_code)]
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use pite_core::{Color, NodeDesc, NodeId, NodeTree, PropValue, Props, ScriptRef};
16use serde::{Deserialize, Serialize};
17
18pub mod cache;
19pub mod color;
20pub mod tilemap;
21
22pub use cache::{CacheStatus, CACHE_VERSION};
23pub use color::{format_hex_color, parse_hex_color};
24pub use tilemap::{
25 load_tilemap, map_to_world, parse_tilemap_str, save_tilemap, world_to_map, TileCell,
26 TileMapDoc, TileMapLayer,
27};
28
29pub const FORMAT_VERSION: u32 = 1;
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct SceneDoc {
35 pub format_version: u32,
36 pub root: String,
37 #[serde(default)]
38 pub node: Vec<SceneNode>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub instance: Vec<SceneInstance>,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct SceneNode {
46 pub id: String,
47 #[serde(rename = "type")]
48 pub type_name: String,
49 pub name: String,
50 pub parent: Option<String>,
51 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
52 pub props: BTreeMap<String, toml::Value>,
53 pub script: Option<SceneScript>,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SceneScript {
59 pub path: String,
60 pub class: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct SceneInstance {
68 pub scene: String,
69 pub parent: Option<String>,
70 #[serde(default)]
71 pub prefix: String,
72 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73 pub overrides: BTreeMap<String, toml::Value>,
74}
75
76pub fn migrate(from: u32, _doc: &toml::Value) -> Result<()> {
79 if from == FORMAT_VERSION {
80 return Ok(());
81 }
82 anyhow::bail!("unsupported scene format_version {from} (engine supports {FORMAT_VERSION})");
83}
84
85pub fn parse_scene_str(text: &str) -> Result<SceneDoc> {
86 let raw: toml::Value = toml::from_str(text).context("scene is not valid TOML")?;
87 let version = raw
88 .get("format_version")
89 .and_then(toml::Value::as_integer)
90 .unwrap_or(0);
91 migrate(version as u32, &raw)?;
92 let doc: SceneDoc = raw.try_into().context("scene does not match schema")?;
93 Ok(doc)
94}
95
96pub fn load_scene(path: &Path) -> Result<SceneDoc> {
97 let text = std::fs::read_to_string(path)
98 .with_context(|| format!("cannot read scene {}", path.display()))?;
99 parse_scene_str(&text).with_context(|| format!("cannot parse scene {}", path.display()))
100}
101
102pub fn load_cached(path: &Path) -> Result<(SceneDoc, CacheStatus, Option<String>)> {
107 let bytes =
108 std::fs::read(path).with_context(|| format!("cannot read scene {}", path.display()))?;
109 let source_hash = cache::fnv1a_u64(&bytes);
110 let source_hex = format!("{source_hash:016x}");
111 let cache_path = cache::cache_path_for(path, &source_hex);
112
113 let mut rebuilt_reason: Option<String> = None;
114 if cache_path.is_file() {
115 match std::fs::read(&cache_path) {
116 Ok(cached) => match cache::decode(&cached) {
117 Ok((doc, header))
118 if header.source_hash == source_hash
119 && header.source_len == bytes.len() as u64 =>
120 {
121 cache::prune_stale(path, &source_hex);
122 return Ok((doc, CacheStatus::Hit, None));
123 }
124 Ok(_) => {
125 rebuilt_reason = Some(format!(
126 "scene cache {} does not match {}",
127 cache_path.display(),
128 path.display()
129 ));
130 }
131 Err(e) => {
132 rebuilt_reason = Some(format!(
133 "scene cache {} ignored ({e:#}); rebuilding from TOML",
134 cache_path.display()
135 ));
136 }
137 },
138 Err(e) => {
139 rebuilt_reason = Some(format!(
140 "scene cache {} unreadable ({e:#}); rebuilding from TOML",
141 cache_path.display()
142 ));
143 }
144 }
145 }
146
147 let text = std::str::from_utf8(&bytes)
148 .with_context(|| format!("cannot parse scene {}", path.display()))?;
149 let doc =
150 parse_scene_str(text).with_context(|| format!("cannot parse scene {}", path.display()))?;
151
152 let payload = cache::encode(&doc, source_hash, bytes.len() as u64);
153 let mut warning = rebuilt_reason.clone();
154 if let Some(dir) = cache_path.parent() {
155 if let Err(e) =
156 std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&cache_path, &payload))
157 {
158 let write_warn = format!("cannot write scene cache {} ({e:#})", cache_path.display());
159 warning = Some(match warning {
160 Some(w) => format!("{w}; {write_warn}"),
161 None => write_warn,
162 });
163 } else {
164 cache::prune_stale(path, &source_hex);
165 }
166 }
167
168 let status = match rebuilt_reason {
169 Some(reason) => CacheStatus::Rebuilt(reason),
170 None => CacheStatus::Miss,
171 };
172 Ok((doc, status, warning))
173}
174
175pub fn load_scene_cached(path: &Path) -> Result<SceneDoc> {
179 let (doc, _, warning) = load_cached(path)?;
180 if let Some(w) = warning {
181 tracing::warn!("{w}");
182 }
183 Ok(doc)
184}
185
186pub fn save_scene(doc: &SceneDoc) -> Result<String> {
187 toml::to_string(doc).context("cannot serialize scene")
188}
189
190pub fn validate(doc: &SceneDoc) -> Vec<String> {
193 let mut issues = Vec::new();
194 if doc.format_version != FORMAT_VERSION {
195 issues.push(format!(
196 "format_version {} != supported {FORMAT_VERSION}",
197 doc.format_version
198 ));
199 }
200 let ids: std::collections::HashSet<&str> = doc.node.iter().map(|n| n.id.as_str()).collect();
201 if !ids.contains(doc.root.as_str()) {
202 issues.push(format!("root {:?} not found in node list", doc.root));
203 }
204 for n in &doc.node {
205 if let Some(parent) = &n.parent {
206 if !ids.contains(parent.as_str()) {
207 issues.push(format!("node {:?} has unknown parent {parent:?}", n.id));
208 }
209 }
210 }
211 issues
212}
213
214pub fn toml_to_prop(value: &toml::Value) -> PropValue {
215 match value {
216 toml::Value::String(s) => {
217 if let Some(c) = parse_hex_color(s) {
218 PropValue::Color(c.r, c.g, c.b, c.a)
219 } else {
220 PropValue::Str(s.clone())
221 }
222 }
223 toml::Value::Integer(i) => PropValue::Int(*i),
224 toml::Value::Float(f) => PropValue::Num(*f),
225 toml::Value::Boolean(b) => PropValue::Bool(*b),
226 toml::Value::Array(items) => {
227 let nums: Vec<f64> = items
228 .iter()
229 .map(|v| match v {
230 toml::Value::Integer(i) => *i as f64,
231 toml::Value::Float(f) => *f,
232 _ => f64::NAN,
233 })
234 .collect();
235 if nums.len() == 2 && nums.iter().all(|n| !n.is_nan()) {
236 PropValue::Vec2(nums[0], nums[1])
237 } else {
238 PropValue::Str(value.to_string())
239 }
240 }
241 _ => PropValue::Str(value.to_string()),
242 }
243}
244
245pub fn prop_to_toml(value: &PropValue) -> toml::Value {
250 match value {
251 PropValue::Str(s) => toml::Value::String(s.clone()),
252 PropValue::Num(f) => toml::Value::Float(*f),
253 PropValue::Int(i) => toml::Value::Integer(*i),
254 PropValue::Bool(b) => toml::Value::Boolean(*b),
255 PropValue::Vec2(x, y) => {
256 toml::Value::Array(vec![toml::Value::Float(*x), toml::Value::Float(*y)])
257 }
258 PropValue::Color(r, g, b, a) => {
259 toml::Value::String(format_hex_color(Color::new(*r, *g, *b, *a)))
260 }
261 }
262}
263
264fn descs_from_doc(doc: &SceneDoc) -> Vec<NodeDesc> {
272 doc.node
273 .iter()
274 .map(|n| {
275 let mut props = Props::default();
276 for prop in pite_core::all_props(&n.type_name) {
277 props.insert(prop.key, prop.default_value.to_prop_value());
278 }
279 for (k, v) in &n.props {
285 let raw = toml_to_prop(v);
286 let coerced = match (props.get(k), raw) {
287 (Some(pite_core::PropValue::Num(_)), pite_core::PropValue::Int(i)) => {
288 pite_core::PropValue::Num(i as f64)
289 }
290 (_, other) => other,
291 };
292 props.insert(k.clone(), coerced);
293 }
294 NodeDesc {
295 id: NodeId::from(n.id.clone()),
296 type_name: n.type_name.clone(),
297 name: n.name.clone(),
298 parent: n.parent.clone().map(NodeId::from),
299 props,
300 script: n.script.as_ref().map(|s| ScriptRef {
301 path: s.path.clone(),
302 class_name: s.class.clone(),
303 }),
304 }
305 })
306 .collect()
307}
308
309pub fn to_node_tree(doc: &SceneDoc) -> Result<NodeTree> {
312 let mut tree = NodeTree::new();
313 for desc in descs_from_doc(doc) {
314 let id_str = desc.id.to_string();
315 tree.insert(desc)
316 .map_err(|e| anyhow::anyhow!("node {id_str:?}: {e}"))?;
317 }
318 Ok(tree)
319}
320
321pub fn instantiate(
325 scene_path: &Path,
326 prefix: &str,
327 overrides: &BTreeMap<String, toml::Value>,
328) -> Result<(Vec<NodeDesc>, String)> {
329 let doc = load_scene_cached(scene_path)?;
330 let mut descs = descs_from_doc(&doc);
331 for desc in &mut descs {
332 desc.id = NodeId::from(format!("{prefix}{}", desc.id.as_str()));
333 desc.parent = desc
334 .parent
335 .as_ref()
336 .map(|p| NodeId::from(format!("{prefix}{}", p.as_str())));
337 }
338 for (key, value) in overrides {
339 let (node_id, prop) = key.split_once('.').with_context(|| {
340 format!(
341 "override {key:?} must be \"node_id.prop\" in {}",
342 scene_path.display()
343 )
344 })?;
345 let target = format!("{prefix}{node_id}");
346 let desc = descs
347 .iter_mut()
348 .find(|d| d.id.as_str() == target)
349 .with_context(|| {
350 format!(
351 "override target {target:?} not found in {}",
352 scene_path.display()
353 )
354 })?;
355 desc.props.insert(prop.to_string(), toml_to_prop(value));
356 }
357 Ok((descs, format!("{prefix}{}", doc.root)))
358}
359
360pub fn resolve_scene_ref(
361 scene_ref: &str,
362 scene_dir: &Path,
363 project_dir: Option<&Path>,
364) -> Result<PathBuf> {
365 if let Some(rel) = scene_ref.strip_prefix("res://") {
366 let root = project_dir.with_context(|| {
367 format!("scene {scene_ref:?} uses res:// but no project root is known")
368 })?;
369 Ok(root.join(rel))
370 } else {
371 let p = Path::new(scene_ref);
372 if p.is_absolute() {
373 Ok(p.to_path_buf())
374 } else {
375 Ok(scene_dir.join(p))
376 }
377 }
378}
379
380pub fn build_tree(
383 doc: &SceneDoc,
384 scene_dir: &Path,
385 project_dir: Option<&Path>,
386) -> Result<NodeTree> {
387 let mut tree = to_node_tree(doc)?;
388 let host_ids: std::collections::HashSet<&str> =
389 doc.node.iter().map(|n| n.id.as_str()).collect();
390 for inst in &doc.instance {
391 let ref_path = resolve_scene_ref(&inst.scene, scene_dir, project_dir)?;
392 let (mut descs, subtree_root) = instantiate(&ref_path, &inst.prefix, &inst.overrides)?;
393 let attach_at = inst.parent.clone().unwrap_or_else(|| doc.root.clone());
394 if !host_ids.contains(attach_at.as_str())
395 && tree.get(&NodeId::from(attach_at.clone())).is_none()
396 {
397 return Err(pite_core::CoreError::MissingParent {
398 child: format!("instance of {}", inst.scene),
399 parent: attach_at,
400 }
401 .into());
402 }
403 for desc in descs.drain(..) {
404 let mut desc = desc;
405 if desc.id.as_str() == subtree_root {
406 desc.parent = Some(NodeId::from(attach_at.clone()));
407 }
408 tree.insert(desc.clone())
409 .map_err(|e| anyhow::anyhow!("instance of {}: {e}", inst.scene))?;
410 }
411 }
412 Ok(tree)
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 const HOST: &str = r#"
420format_version = 1
421root = "root"
422
423[[node]]
424id = "root"
425type = "Node2D"
426name = "Main"
427
428[[node]]
429id = "player"
430type = "Sprite2D"
431name = "Player"
432parent = "root"
433
434[node.props]
435texture = "res://assets/player.png"
436position = [100.0, 200.0]
437mystery = "kept"
438
439[node.script]
440path = "res://scripts/player.py"
441class = "Player"
442"#;
443
444 #[test]
445 fn round_trip_preserves_unknown_props() {
446 let doc = parse_scene_str(HOST).unwrap();
447 let saved = save_scene(&doc).unwrap();
448 let again = parse_scene_str(&saved).unwrap();
449 assert_eq!(doc, again);
450 let player = again.node.iter().find(|n| n.id == "player").unwrap();
451 assert_eq!(
452 player.props.get("mystery"),
453 Some(&toml::Value::String("kept".to_string()))
454 );
455 }
456
457 #[test]
458 fn version_bump_errors_loudly() {
459 let err = parse_scene_str("format_version = 99\nroot = \"root\"\n").unwrap_err();
460 assert!(err.to_string().contains("unsupported scene format_version"));
461 }
462
463 fn write_fixture(dir: &Path, name: &str, text: &str) -> PathBuf {
464 let path = dir.join(name);
465 std::fs::write(&path, text).unwrap();
466 path
467 }
468
469 #[test]
470 fn instantiate_clones_subtree_and_applies_overrides() {
471 let dir = std::env::temp_dir().join(format!("pite-inst-{}", std::process::id()));
472 std::fs::create_dir_all(&dir).unwrap();
473 let ref_path = write_fixture(
474 &dir,
475 "enemy.ptscn",
476 r#"
477format_version = 1
478root = "enemy"
479
480[[node]]
481id = "enemy"
482type = "Node2D"
483name = "Enemy"
484
485[[node]]
486id = "sprite"
487type = "Sprite2D"
488name = "Sprite"
489parent = "enemy"
490
491[node.props]
492position = [0.0, 0.0]
493"#,
494 );
495 let mut overrides = BTreeMap::new();
496 overrides.insert(
497 "sprite.position".to_string(),
498 toml::Value::Array(vec![toml::Value::Float(10.0), toml::Value::Float(20.0)]),
499 );
500 let (descs, root) = instantiate(&ref_path, "e1_", &overrides).unwrap();
501 assert_eq!(root, "e1_enemy");
502 assert_eq!(descs.len(), 2);
503 let sprite = descs.iter().find(|d| d.id.as_str() == "e1_sprite").unwrap();
504 assert_eq!(sprite.parent, Some(NodeId::from("e1_enemy".to_string())));
505 assert_eq!(
506 sprite.props.get("position"),
507 Some(&PropValue::Vec2(10.0, 20.0))
508 );
509 std::fs::remove_dir_all(&dir).ok();
510 }
511
512 #[test]
513 fn instantiate_unknown_override_target_fails() {
514 let dir = std::env::temp_dir().join(format!("pite-inst-bad-{}", std::process::id()));
515 std::fs::create_dir_all(&dir).unwrap();
516 let ref_path = write_fixture(
517 &dir,
518 "tiny.ptscn",
519 "format_version = 1\nroot = \"solo\"\n\n[[node]]\nid = \"solo\"\ntype = \"Node\"\nname = \"Solo\"\n",
520 );
521 let mut overrides = BTreeMap::new();
522 overrides.insert("ghost.x".to_string(), toml::Value::Integer(1));
523 let err = instantiate(&ref_path, "", &overrides).unwrap_err();
524 assert!(err.to_string().contains("override target"));
525 std::fs::remove_dir_all(&dir).ok();
526 }
527
528 #[test]
529 fn color_round_trips_through_toml() {
530 let hex = toml::Value::String("#4ADE80".to_string());
531 let prop = toml_to_prop(&hex);
532 assert_eq!(prop, PropValue::Color(0x4A, 0xDE, 0x80, 0xFF));
533 let back = prop_to_toml(&prop);
534 assert_eq!(back, toml::Value::String("#4ade80ff".to_string()));
535 }
536}