1use std::path::Path;
6
7use concinnity_core::platform::Platform;
8
9use crate::asset_api::{self, AssetRequest};
10use crate::authoring::world::WorldJsonlAsset;
11use crate::ecs::{BlobAssetDef, asset_id};
12
13use super::desugar::{
14 desugar_animation_imports, desugar_fbx_meshes, desugar_fbx_skinned_meshes, desugar_gltf_meshes,
15 desugar_gltf_skinned_meshes, desugar_root_motion,
16};
17use super::errors_to_io;
18use super::pack::{PackContext, compile_and_pack_payloads, probe_mesh_payload_cache};
19use super::result::{MeshSourceInfo, PipelineResult, TextureSourceInfo};
20use super::scene_refs::resolve_scene_refs;
21
22pub fn build_from_path(
28 tree: &crate::paths::StateTree,
29 json_path: &str,
30 platform: Platform,
31) -> std::io::Result<()> {
32 let content = std::fs::read_to_string(json_path)?;
33 let assets_dir = tree.assets_dir();
34 let loaded = crate::build_only::prepare_world(&content, Some(&assets_dir))
35 .map_err(|errs| crate::check::report_validation_errors(&errs))?;
36
37 let result = build_compiled(loaded.assets, Some(&assets_dir), None, platform)?;
38
39 let pack_result = write_build_outputs(tree, &result, &loaded.injected, &loaded.shadowed)?;
40 for (blob_idx, path) in pack_result.blob_paths.iter().enumerate() {
41 let payload_bytes = result.payloads.get(blob_idx).map(|b| b.len()).unwrap_or(0);
42 println!("Wrote {} ({} payload bytes)", path, payload_bytes);
43 }
44
45 if result.cache_hits + result.cache_misses > 0 {
46 println!(
47 "Build cache: {} reused, {} compiled",
48 result.cache_hits, result.cache_misses
49 );
50 }
51
52 let lock = tree.world_lock_path();
53 if !loaded.injected.is_empty() {
54 println!(
55 "Injected {} default asset(s) (see {})",
56 loaded.injected.len(),
57 lock.display()
58 );
59 }
60 println!("Wrote {}", lock.display());
61
62 Ok(())
63}
64
65pub fn write_blobs_to(
70 result: &PipelineResult,
71 primary: &std::path::Path,
72) -> std::io::Result<crate::blob::PackResult> {
73 crate::blob::write_blobs(
74 crate::blob::BlobStreams {
75 defs: &result.defs,
76 resources: &result.resources,
77 scene_groups: &result.scene_groups,
78 mesh_bounds: &result.mesh_bounds,
79 physics_budget: result.physics_budget,
80 },
81 &result.payloads,
82 primary,
83 )
84}
85
86pub fn write_build_outputs(
90 tree: &crate::paths::StateTree,
91 result: &PipelineResult,
92 injected: &[crate::build_only::InjectedAsset],
93 shadowed: &[crate::build_only::ShadowedAsset],
94) -> std::io::Result<crate::blob::PackResult> {
95 let pack_result = write_blobs_to(
96 result,
97 &concinnity_host::store::blob::primary_in(&tree.data_dir()),
98 )?;
99 let named_refs: Vec<(&str, &BlobAssetDef)> = result
100 .names
101 .iter()
102 .map(|n| n.as_str())
103 .zip(result.defs.iter())
104 .collect();
105 crate::blob::write_lock(
106 tree,
107 &named_refs,
108 &result.resource_locks,
109 injected,
110 shadowed,
111 &pack_result.blob_paths,
112 )?;
113 let thumbs = crate::compile::thumbnail::bake_thumbnails(result);
117 if thumbs.baked > 0 {
118 println!(
119 "Baked {} thumbnail(s) ({} reused)",
120 thumbs.baked, thumbs.reused
121 );
122 }
123 Ok(pack_result)
124}
125
126pub fn build_pipeline_from_str(
133 content: &str,
134 assets_dir: Option<&Path>,
135 artifacts_dir: Option<&str>,
136 platform: Platform,
137) -> std::io::Result<PipelineResult> {
138 let loaded = crate::build_only::prepare_world(content, assets_dir).map_err(errors_to_io)?;
139 build_compiled(loaded.assets, assets_dir, artifacts_dir, platform)
140}
141
142#[derive(Debug, Clone, Copy)]
146pub struct BuildProgress {
147 pub stage: &'static str,
149 pub done: u32,
151 pub total: u32,
153}
154
155pub fn build_compiled(
161 assets: Vec<WorldJsonlAsset>,
162 assets_dir: Option<&Path>,
163 artifacts_dir: Option<&str>,
164 platform: Platform,
165) -> std::io::Result<PipelineResult> {
166 build_compiled_with_progress(assets, assets_dir, artifacts_dir, platform, None)
167}
168
169pub fn build_compiled_with_progress(
173 mut assets: Vec<WorldJsonlAsset>,
174 assets_dir: Option<&Path>,
175 artifacts_dir: Option<&str>,
176 platform: Platform,
177 progress: Option<&(dyn Fn(BuildProgress) + Sync)>,
178) -> std::io::Result<PipelineResult> {
179 if let Some(p) = progress {
180 p(BuildProgress {
181 stage: "desugar",
182 done: 0,
183 total: 0,
184 });
185 }
186
187 let mesh_cache = probe_mesh_payload_cache(&assets, assets_dir, artifacts_dir, platform);
195
196 desugar_gltf_skinned_meshes(&mut assets, &mesh_cache, assets_dir)?;
202 desugar_fbx_skinned_meshes(&mut assets, &mesh_cache)?;
203 desugar_gltf_meshes(&mut assets, &mesh_cache, assets_dir)?;
204 desugar_fbx_meshes(&mut assets, &mesh_cache)?;
205 desugar_animation_imports(&mut assets, assets_dir)?;
206 desugar_root_motion(&mut assets)?;
207 crate::compile::character_shape::warn_unresolved(&assets);
208 crate::compile::character::bake::bake_shapes(&mut assets, |name| {
209 mesh_cache
210 .get(name)
211 .map(|e| crate::compile::character::bake::TargetCache {
212 key: e.key.clone(),
213 replayed: e.capsule_scale,
214 })
215 })?;
216
217 asset_id::reset_interner();
220 let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
221 asset_id::intern_all(&names);
222 resolve_scene_refs(&mut assets);
223
224 crate::resource_handles::reset_resource_handles();
234 let resource_assets = assets.iter().filter_map(|a| {
235 crate::resource_handles::asset_resource_kind(&a.asset_type)
236 .map(|kind| (asset_id::intern(&a.name), kind))
237 });
238 let mut resource_handles =
239 crate::resource_handles::ResourceHandles::from_assets(resource_assets);
240 crate::resource_handles::assign_mesh_source_handles(&mut resource_handles, &assets);
245 crate::resource_handles::assign_shader_handles(&mut resource_handles, &assets);
248 crate::resource_handles::install_resource_handles(resource_handles.clone());
251
252 use crate::registry::RegisteredType;
259 let mut named: Vec<(String, BlobAssetDef)> = Vec::new();
260 let mut named_src: Vec<usize> = Vec::new();
261 let mut resource_jobs: Vec<(usize, RegisteredType, u32)> = Vec::new();
262 for (i, asset) in assets.iter().enumerate() {
263 if let Some((rt, kind)) =
264 RegisteredType::parse(&asset.asset_type).and_then(|t| t.resource_kind().map(|k| (t, k)))
265 {
266 let id = asset_id::intern(&asset.name);
267 let handle = resource_handles
268 .get(kind, id)
269 .expect("resource asset was assigned a handle above");
270 resource_jobs.push((i, rt, handle));
271 continue;
272 }
273 let req = AssetRequest {
274 asset_type: asset.asset_type.clone(),
275 args: Some(asset.args.clone()),
276 };
277 let mut def = asset_api::create_asset_def(&req).map_err(|e| {
278 std::io::Error::new(
279 std::io::ErrorKind::InvalidData,
280 format!("Asset '{}': {}", asset.name, e),
281 )
282 })?;
283 def.name = Some(asset_id::intern(&asset.name));
284 named.push((asset.name.clone(), def));
285 named_src.push(i);
286 }
287
288 let texture_count = resource_jobs
293 .iter()
294 .filter(|(_, rt, _)| *rt == RegisteredType::Texture)
295 .map(|(_, _, h)| *h as usize + 1)
296 .max()
297 .unwrap_or(0);
298 let mut texture_sources = vec![TextureSourceInfo::default(); texture_count];
299 for (asset_idx, rt, handle) in &resource_jobs {
300 if *rt != RegisteredType::Texture {
301 continue;
302 }
303 let asset = &assets[*asset_idx];
304 let generator = asset
305 .args
306 .get("generator")
307 .and_then(|v| v.as_str())
308 .unwrap_or("");
309 let (source, image_index) = if generator.is_empty() {
310 (
311 asset
312 .args
313 .get("source")
314 .and_then(|v| v.as_str())
315 .unwrap_or("")
316 .to_string(),
317 asset
318 .args
319 .get("image_index")
320 .and_then(|v| v.as_u64())
321 .unwrap_or(0) as u32,
322 )
323 } else {
324 (String::new(), 0)
325 };
326 texture_sources[*handle as usize] = TextureSourceInfo {
327 name_id: asset_id::intern(&asset.name).0,
328 source,
329 image_index,
330 };
331 }
332
333 let mesh_count = resource_jobs
338 .iter()
339 .filter(|(_, rt, _)| *rt == RegisteredType::Mesh)
340 .map(|(_, _, h)| *h as usize + 1)
341 .max()
342 .unwrap_or(0);
343 let mut mesh_sources = vec![MeshSourceInfo::default(); mesh_count];
344 for (asset_idx, rt, handle) in &resource_jobs {
345 if *rt != RegisteredType::Mesh {
346 continue;
347 }
348 let args = &assets[*asset_idx].args;
349 let str_arg = |key: &str| {
350 args.get(key)
351 .and_then(|v| v.as_str())
352 .unwrap_or("")
353 .to_string()
354 };
355 let u32_arg = |key: &str, default: u32| {
356 args.get(key)
357 .and_then(|v| v.as_u64())
358 .unwrap_or(default as u64) as u32
359 };
360 mesh_sources[*handle as usize] = MeshSourceInfo {
361 source: str_arg("source"),
362 primitive_index: u32_arg("primitive_index", 0),
363 lod_levels: u32_arg("lod_levels", 1),
364 lod_distances: args
365 .get("lod_distances")
366 .and_then(|v| v.as_array())
367 .map(|a| {
368 a.iter()
369 .filter_map(|d| d.as_f64())
370 .map(|d| d as f32)
371 .collect()
372 })
373 .unwrap_or_default(),
374 };
375 }
376
377 let partition = crate::compile::scene_partition::partition_scenes(&assets);
380
381 let physics_budget = crate::compile::physics_budget::compute(&assets);
384 crate::compile::physics_budget::report_spawn_reservation(&assets);
385
386 let compiled = compile_and_pack_payloads(
387 &mut named,
388 &named_src,
389 PackContext {
390 assets: &assets,
391 resource_jobs: &resource_jobs,
392 partition: &partition,
393 mesh_source_handles: &resource_handles,
394 max_blob_bytes: crate::blob::DEFAULT_MAX_BLOB_BYTES,
395 assets_dir,
396 artifacts_dir,
397 platform,
398 mesh_cache: &mesh_cache,
399 progress,
400 },
401 )?;
402
403 let resource_locks: Vec<crate::blob::LockedResource> = resource_jobs
408 .iter()
409 .zip(compiled.resources.iter())
410 .map(|((asset_idx, rt, handle), record)| {
411 let asset = &assets[*asset_idx];
412 crate::blob::LockedResource {
413 name: asset.name.clone(),
414 id: Some(asset_id::intern(&asset.name).0),
417 kind: rt.as_str().to_string(),
418 handle: *handle,
419 args_hash: crate::blob::checksum(asset.args.to_string().as_bytes()),
420 payload_blob: record.payload.as_ref().map(|p| p.blob_index),
421 texture_source: (*rt == RegisteredType::Texture).then(|| {
422 let t = &texture_sources[*handle as usize];
423 crate::blob::LockedTextureSource {
424 source: t.source.clone(),
425 image_index: t.image_index,
426 }
427 }),
428 mesh_source: (*rt == RegisteredType::Mesh).then(|| {
429 let m = &mesh_sources[*handle as usize];
430 crate::blob::LockedMeshSource {
431 source: m.source.clone(),
432 primitive_index: m.primitive_index,
433 lod_levels: m.lod_levels,
434 lod_distances: m.lod_distances.clone(),
435 }
436 }),
437 }
438 })
439 .collect();
440
441 let (names, defs): (Vec<String>, Vec<BlobAssetDef>) = named.into_iter().unzip();
446
447 crate::cache::flush();
450
451 Ok(PipelineResult {
452 defs,
453 names,
454 resources: compiled.resources,
455 scene_groups: compiled.scene_groups,
456 mesh_bounds: compiled.mesh_bounds,
457 physics_budget,
458 mesh_component_names: compiled.mesh_component_names,
459 payloads: compiled.blobs,
460 cache_hits: compiled.cache_hits,
461 cache_misses: compiled.cache_misses,
462 texture_sources,
463 mesh_sources,
464 resource_locks,
465 })
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::pipeline::MESH_TYPE;
472 use crate::pipeline::fixtures::{wja, write_fixture};
473
474 #[test]
475 fn build_pipeline_interns_names_and_resolves_refs() {
476 let world = concat!(
478 r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
479 "\n",
480 r#"{"name":"day","type":"Scene","args":{}}"#,
481 "\n",
482 r#"{"name":"day_crate","type":"Prop","args":{"mesh":"box"}}"#,
483 "\n",
484 );
485 let result =
486 build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
487
488 let prop = result
490 .defs
491 .iter()
492 .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(2)))
493 .expect("day_crate def present with interned id 2");
494
495 let baked: crate::components::Prop = postcard::from_bytes(&prop.args_bytes).unwrap();
496 assert_eq!(baked.mesh, Some(crate::ecs::MeshHandle(0)));
498 assert_eq!(baked.scene, Some(crate::ecs::asset_id::AssetId(1)));
500 }
501
502 #[test]
506 fn a_physics_world_carries_no_config_into_the_blob() {
507 let world = concat!(
508 r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
509 "\n",
510 r#"{"name":"crate_a","type":"Prop","args":{"mesh":"box","collider":{"shape":"cuboid"}}}"#,
511 "\n",
512 r#"{"name":"crate_body","type":"PropBody","args":{"prop_name":"crate_a"}}"#,
513 "\n",
514 );
515 let result =
516 build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
517
518 assert!(
519 !result.names.iter().any(|n| n == "physics_config"),
520 "no config is compiled in: {:?}",
521 result.names
522 );
523 let budget = result.physics_budget.expect("a physics budget");
526 assert_eq!(budget.spawn_headroom, 0);
527 assert_eq!(budget.dynamic, 1, "the crate");
528 }
529
530 #[test]
533 fn engine_defaults_reach_the_blob_as_a_component() {
534 let world = concat!(
535 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
536 "\n",
537 r#"{"name":"defaults","type":"EngineDefaults","args":{"sky":false}}"#,
538 "\n",
539 );
540 let result =
541 build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
542
543 let index = result
544 .names
545 .iter()
546 .position(|n| n == "defaults")
547 .expect("the directive compiled into the blob");
548 let baked: concinnity_core::components::EngineDefaults =
549 postcard::from_bytes(&result.defs[index].args_bytes).unwrap();
550 assert!(!baked.sky);
551 assert!(baked.debug_hud, "the flags it does not name stay on");
552 }
553
554 #[test]
558 fn build_pipeline_records_resource_lock_provenance() {
559 let world = concat!(
560 r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
561 "\n",
562 r#"{"name":"pause","type":"Screen","args":{}}"#,
563 "\n",
564 );
565 let result = build_pipeline_from_str(world, None, None, Platform::Metal).expect("build");
566
567 assert_eq!(result.resource_locks.len(), result.resources.len());
568 let font = result
569 .resource_locks
570 .iter()
571 .find(|r| r.name == "f")
572 .expect("font provenance recorded");
573 assert_eq!(font.kind, "Font");
574 assert_eq!(font.handle, 0);
575 assert_eq!(font.args_hash.len(), 64);
576 assert!(font.payload_blob.is_some());
578 assert!(!result.names.iter().any(|n| n == "f"));
580 }
581
582 #[test]
583 fn build_from_path_missing_world_file_errors() {
584 let tree = crate::paths::StateTree::at(concinnity_testing::TempTree::new().path());
585 assert!(build_from_path(&tree, "/no/such/world.jsonl", Platform::Metal).is_err());
586 }
587
588 #[test]
589 fn build_from_path_reports_a_malformed_world_file() {
590 let dir = concinnity_testing::TempTree::new();
591 let world = dir.path().join("world.jsonl");
592 std::fs::write(&world, "{not json\n").expect("write world");
593 let tree = crate::paths::StateTree::at(dir.path());
594 let err = build_from_path(&tree, world.to_str().unwrap(), Platform::Metal)
595 .expect_err("malformed world");
596 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
597 }
598
599 #[test]
602 fn write_build_outputs_fails_when_the_lock_cannot_be_written() {
603 let output = crate::blob::test_output::Output::new();
604 std::fs::create_dir_all(output.lock_path()).expect("occupy the lock path");
606
607 let result = PipelineResult {
608 defs: Vec::new(),
609 names: Vec::new(),
610 resources: Vec::new(),
611 scene_groups: Vec::new(),
612 mesh_bounds: Vec::new(),
613 physics_budget: None,
614 mesh_component_names: Vec::new(),
615 payloads: vec![vec![1, 2, 3]],
616 cache_hits: 0,
617 cache_misses: 0,
618 texture_sources: Vec::new(),
619 mesh_sources: Vec::new(),
620 resource_locks: Vec::new(),
621 };
622 assert!(
623 write_build_outputs(output.tree(), &result, &[], &[]).is_err(),
624 "an unwritable lock must fail the build"
625 );
626 }
627
628 #[test]
631 fn build_from_path_writes_the_blobs_and_the_lock_beside_them() {
632 let output = crate::blob::test_output::Output::new();
633
634 let dir = concinnity_testing::TempTree::new();
635 let world_path = dir.path().join("world.jsonl");
636 std::fs::write(
637 &world_path,
638 concat!(
639 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
640 "\n",
641 r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
642 "\n",
643 r#"{"name":"pause","type":"Screen","args":{}}"#,
644 "\n",
645 ),
646 )
647 .expect("write world");
648
649 build_from_path(output.tree(), world_path.to_str().unwrap(), Platform::Metal)
650 .expect("build");
651
652 let raw = std::fs::read_to_string(output.lock_path()).expect("lock written");
653 let lock: crate::blob::BlobLock = serde_json::from_str(&raw).expect("lock is valid json");
654 assert_eq!(lock.blobs.len(), 1);
655
656 let (meta, _) = crate::blob::read_cnb(&lock.blobs[0].path).expect("blob 0 parses");
657 assert_eq!(
658 meta.defs.len(),
659 lock.assets.len(),
660 "the lock names every def the blob ships"
661 );
662 assert_eq!(meta.resources.len(), lock.resources.len());
663 assert!(lock.assets.iter().any(|a| a.name == "pause"));
664
665 let font = lock
666 .resources
667 .iter()
668 .find(|r| r.name == "f")
669 .expect("the font is recorded in the resource stream");
670 assert_eq!(font.kind, "Font");
671 assert_eq!(font.payload_blob, Some(0));
672 assert!(
673 !lock.injected.is_empty(),
674 "engine defaults are recorded so they can be overridden"
675 );
676 }
677
678 #[test]
679 fn build_pipeline_from_str_rejects_malformed_jsonl() {
680 let Err(err) = build_pipeline_from_str("{not json\n", None, None, Platform::Metal) else {
681 panic!("malformed line must not build");
682 };
683 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
684 }
685
686 #[test]
687 fn build_pipeline_from_str_reports_unknown_asset_types() {
688 let world = r#"{"name":"mystery","type":"NotAType","args":{}}"#;
689 let Err(err) = build_pipeline_from_str(world, None, None, Platform::Metal) else {
690 panic!("unknown type must not build");
691 };
692 assert!(
693 err.to_string().contains("NotAType"),
694 "error should name the unknown type: {err}"
695 );
696 }
697
698 #[test]
701 fn build_compiled_names_the_asset_whose_type_will_not_resolve() {
702 let assets = vec![wja("mystery", "NotAType", serde_json::json!({}))];
703 let Err(err) = build_compiled(assets, None, None, Platform::Metal) else {
704 panic!("unknown type must not compile");
705 };
706 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
707 assert!(err.to_string().contains("Asset 'mystery'"), "got: {err}");
708 }
709
710 #[test]
713 fn build_compiled_surfaces_a_payload_compile_failure() {
714 let assets = vec![wja(
715 "shape",
716 "ProceduralMesh",
717 serde_json::json!({"generator": "not_a_generator"}),
718 )];
719 let Err(err) = build_compiled(assets, None, None, Platform::Metal) else {
720 panic!("an uncompilable payload must not build");
721 };
722 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
723 assert!(err.to_string().contains("not_a_generator"), "got: {err}");
724 }
725
726 fn tga_2x2() -> Vec<u8> {
729 let mut v = vec![0u8; 18];
730 v[2] = 2; v[12..14].copy_from_slice(&2u16.to_le_bytes());
732 v[14..16].copy_from_slice(&2u16.to_le_bytes());
733 v[16] = 24;
734 v[17] = 0x20; v.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]);
736 v
737 }
738
739 #[test]
743 fn build_compiled_records_hot_reload_sources_in_handle_order() {
744 let dir = tempfile::tempdir().expect("tempdir");
745 let tga = write_fixture(&dir, "wall.tga", &tga_2x2());
746 let glb = write_fixture(
747 &dir,
748 "scene.glb",
749 &crate::import::glb::test_fixtures::static_triangle_glb(),
750 );
751 let assets = vec![
752 wja(
753 "proc_tex",
754 "Texture",
755 serde_json::json!({"generator": "checker", "resolution": 8}),
756 ),
757 wja(
758 "wall_tex",
759 "Texture",
760 serde_json::json!({"source": tga, "image_index": 3}),
761 ),
762 wja(
763 "inline_mesh",
764 MESH_TYPE,
765 serde_json::json!({"generator": "box", "half_extents": [1, 1, 1]}),
766 ),
767 wja(
768 "file_mesh",
769 MESH_TYPE,
770 serde_json::json!({
771 "source": glb,
772 "primitive_index": 0,
773 "lod_levels": 3,
774 "lod_distances": [10.0, 20.0],
775 }),
776 ),
777 ];
778 let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
779
780 assert_eq!(result.texture_sources.len(), 2);
781 assert_eq!(
782 result.texture_sources[0],
783 TextureSourceInfo {
784 name_id: 0,
785 source: String::new(),
786 image_index: 0,
787 },
788 "a generated texture has no file to watch"
789 );
790 assert_eq!(
791 result.texture_sources[1],
792 TextureSourceInfo {
793 name_id: 1,
794 source: tga.clone(),
795 image_index: 3,
796 }
797 );
798
799 assert_eq!(result.mesh_sources.len(), 2);
800 assert_eq!(
801 result.mesh_sources[0],
802 MeshSourceInfo {
803 source: String::new(),
804 primitive_index: 0,
805 lod_levels: 1,
806 lod_distances: Vec::new(),
807 },
808 "a generated mesh has no file to watch"
809 );
810 assert_eq!(
811 result.mesh_sources[1],
812 MeshSourceInfo {
813 source: glb.clone(),
814 primitive_index: 0,
815 lod_levels: 3,
816 lod_distances: vec![10.0, 20.0],
817 }
818 );
819
820 let lock_tex: Vec<_> = result
823 .resource_locks
824 .iter()
825 .filter(|r| r.kind == "Texture")
826 .collect();
827 assert_eq!(lock_tex.len(), 2);
828 assert_eq!(lock_tex[0].texture_source.as_ref().unwrap().source, "");
829 let wall = lock_tex[1].texture_source.as_ref().unwrap();
830 assert_eq!(wall.source, tga);
831 assert_eq!(wall.image_index, 3);
832 assert!(lock_tex[1].mesh_source.is_none());
833
834 let lock_mesh: Vec<_> = result
835 .resource_locks
836 .iter()
837 .filter(|r| r.kind == "Mesh")
838 .collect();
839 assert_eq!(lock_mesh.len(), 2);
840 assert!(lock_mesh[0].texture_source.is_none());
841 let file_mesh = lock_mesh[1].mesh_source.as_ref().unwrap();
842 assert_eq!(file_mesh.source, glb);
843 assert_eq!(file_mesh.lod_levels, 3);
844 assert_eq!(file_mesh.lod_distances, vec![10.0, 20.0]);
845 }
846
847 #[test]
850 fn build_compiled_keeps_a_data_resource_out_of_the_payload_sections() {
851 let assets = vec![
852 wja("wood", "Material", serde_json::json!({})),
853 wja(
854 "shape",
855 "ProceduralMesh",
856 serde_json::json!({"generator": "box"}),
857 ),
858 ];
859 let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
860
861 assert_eq!(result.resources.len(), 1);
862 let material = &result.resources[0];
863 assert!(material.payload.is_none(), "a Material rides inline");
864 assert!(!material.data_bytes.is_empty());
865 postcard::from_bytes::<crate::components::Material>(&material.data_bytes)
866 .expect("the inline bytes decode as a Material");
867 assert_eq!(result.resource_locks[0].name, "wood");
868 assert_eq!(result.resource_locks[0].payload_blob, None);
869 assert_eq!(result.defs.len(), 1);
871 assert!(result.defs[0].payload.is_some());
872 }
873
874 #[test]
877 fn build_compiled_compiles_only_mesh_kind_file_assets() {
878 let dir = tempfile::tempdir().expect("tempdir");
879 let obj = write_fixture(&dir, "tri.obj", b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n");
880 let png = write_fixture(&dir, "icon.png", b"not read");
881 let assets = vec![
882 wja(
883 "model",
884 "File",
885 serde_json::json!({"path": obj, "kind": "obj"}),
886 ),
887 wja(
888 "icon",
889 "File",
890 serde_json::json!({"path": png, "kind": "png"}),
891 ),
892 ];
893 let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
894
895 assert_eq!(result.names, vec!["model".to_string(), "icon".to_string()]);
896 let mesh_payload = result.defs[0]
897 .payload
898 .as_ref()
899 .expect("the obj File compiles");
900 assert!(mesh_payload.len > 0);
901 assert!(
902 result.defs[1].payload.is_none(),
903 "a png File produces no blob payload"
904 );
905 }
906}