concinnity_dev/authoring/
build.rs1pub(crate) use concinnity_cook::build_compiled;
4
5use crate::ecs::{ComponentAsset, World};
6use concinnity_cook::build_only::LoadedWorld;
7
8pub(crate) fn prepare(content: &str) -> std::io::Result<LoadedWorld> {
17 concinnity_shader::install();
24
25 let loaded =
26 concinnity_cook::prepare_world(content, super::assets_root::assets_dir().as_deref())
27 .map_err(|errs| concinnity_cook::check::report_validation_errors(&errs))?;
28
29 Ok(loaded)
30}
31
32fn type_is(asset: &concinnity_cook::authoring::world::WorldJsonlAsset, norm_type: &str) -> bool {
35 asset.asset_type.to_lowercase().replace('_', "") == norm_type
36}
37
38fn scan_color_lut_source(
41 assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
42) -> Option<String> {
43 assets
44 .iter()
45 .find(|a| type_is(a, "colorlut"))
46 .and_then(|a| a.args.get("source").and_then(|v| v.as_str()))
47 .filter(|s| !s.is_empty())
48 .map(str::to_string)
49}
50
51fn scan_environment_map_source(
55 assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
56) -> Option<crate::resource::EnvironmentMapSourceInfo> {
57 let a = assets.iter().find(|a| type_is(a, "environmentmap"))?;
58 let generator = a
59 .args
60 .get("generator")
61 .and_then(|v| v.as_str())
62 .unwrap_or("");
63 let source = a.args.get("source").and_then(|v| v.as_str()).unwrap_or("");
64 if !generator.is_empty() || source.is_empty() {
65 return None;
66 }
67 let u32_arg = |key: &str, default: u32| {
68 a.args
69 .get(key)
70 .and_then(|v| v.as_u64())
71 .map(|v| v as u32)
72 .unwrap_or(default)
73 };
74 Some(crate::resource::EnvironmentMapSourceInfo {
75 source: source.to_string(),
76 prefilter_face_size: u32_arg("prefilter_face_size", 512),
77 irradiance_face_size: u32_arg("irradiance_face_size", 8),
78 prefilter_samples: u32_arg("prefilter_samples", 1024),
79 prefilter_clamp: a
80 .args
81 .get("prefilter_clamp")
82 .and_then(|v| v.as_f64())
83 .map(|v| v as f32)
84 .unwrap_or(12.0),
85 })
86}
87
88pub fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
91 let color_lut_source = scan_color_lut_source(&loaded.assets);
98 let environment_map_source = scan_environment_map_source(&loaded.assets);
99
100 let mut result = build_compiled(
101 loaded.assets,
102 super::assets_root::assets_dir().as_deref(),
103 None,
104 )?;
105
106 let material_names = crate::resource::MaterialNames(
108 result.resource_names(concinnity_cook::resource_handles::ResourceKind::Material),
109 );
110
111 let payload_sections: Vec<Option<Vec<u8>>> = result.payloads.into_iter().map(Some).collect();
112 let mut world =
113 concinnity_engine::blob::world_from(crate::blob::BlobData::new(payload_sections));
114 let mut by_name = std::collections::BTreeMap::new();
117 for def in &result.defs {
118 let mut component = ComponentAsset::from_baked(def).map_err(|e| {
119 std::io::Error::new(
120 std::io::ErrorKind::InvalidData,
121 format!("Asset construction failed: {:?}", e),
122 )
123 })?;
124 if let Some(locator) = &def.payload {
125 component.inject_locator(locator.clone());
126 }
127 let entity = world.add(component);
128 if let Some(id) = def.name {
129 by_name.insert(id, entity);
130 }
131 }
132 world.insert_resource(concinnity_core::ecs::EntityByName(by_name));
133 crate::resource::install_resource_tables(&mut world, &mut result.resources);
137 world.insert_resource(crate::ecs::BlobSceneGroups(result.scene_groups));
138 world.insert_resource(crate::ecs::BlobMeshBounds(result.mesh_bounds));
139 if let Some(budget) = result.physics_budget {
140 world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
141 }
142 world.insert_resource(crate::resource::ColorLutSources(color_lut_source));
144 world.insert_resource(crate::resource::EnvironmentMapSources(
145 environment_map_source,
146 ));
147 world.insert_resource(crate::resource::TextureSources(
151 result
152 .texture_sources
153 .iter()
154 .map(|t| crate::resource::TextureSource {
155 name_id: t.name_id,
156 source: t.source.clone(),
157 image_index: t.image_index,
158 })
159 .collect(),
160 ));
161 world.insert_resource(material_names);
164 world.insert_resource(crate::resource::MeshSources(
167 result
168 .mesh_sources
169 .iter()
170 .map(|m| crate::resource::MeshSource {
171 source: m.source.clone(),
172 primitive_index: m.primitive_index,
173 lod_levels: m.lod_levels,
174 lod_distances: m.lod_distances.clone(),
175 })
176 .collect(),
177 ));
178 Ok(world)
179}
180
181pub fn build_world_from_str(content: &str) -> std::io::Result<World> {
186 Ok(build_world_and_shadows(content)?.0)
187}
188
189pub(crate) fn build_world_and_shadows(
194 content: &str,
195) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
196 let loaded = prepare(content)?;
197 let shadowed = loaded.shadowed.clone();
198 Ok((world_from_loaded(loaded)?, shadowed))
199}
200
201pub fn build_world_from_path(world_path: &str) -> std::io::Result<World> {
206 let content = std::fs::read_to_string(world_path)?;
207 build_world_from_str(&content)
208}
209
210pub fn build_world_to_disk(world_path: &str) -> std::io::Result<()> {
217 let content = std::fs::read_to_string(world_path)?;
218 build_world_str_to_disk(&content)
219}
220
221pub(crate) fn build_world_str_to_disk(content: &str) -> std::io::Result<()> {
226 build_world_str_to_disk_with_progress(content, None)
227}
228
229pub(crate) fn build_world_str_to_disk_with_progress(
232 content: &str,
233 progress: Option<&(dyn Fn(concinnity_cook::BuildProgress) + Sync)>,
234) -> std::io::Result<()> {
235 let loaded = prepare(content)?;
236 let result = concinnity_cook::build_compiled_with_progress(
237 loaded.assets,
238 super::assets_root::assets_dir().as_deref(),
239 None,
240 progress,
241 )?;
242 if let Some(p) = progress {
243 p(concinnity_cook::BuildProgress {
244 stage: "write",
245 done: 0,
246 total: 0,
247 });
248 }
249 concinnity_cook::write_build_outputs(&result, &loaded.injected, &loaded.shadowed)?;
250 Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn prepare_accepts_a_valid_world() {
259 let loaded =
260 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
261 assert!(loaded.assets.iter().any(|a| a.name == "phys"));
262 assert!(loaded.authored.contains(&"phys".to_string()));
263 }
264
265 #[test]
266 fn prepare_rejects_an_invalid_world() {
267 assert!(prepare("{\"name\":\"odd\",\"type\":\"NotARealAssetType\"}\n").is_err());
268 assert!(prepare("{ not json\n").is_err());
269 }
270
271 fn asset(json: serde_json::Value) -> concinnity_cook::authoring::world::WorldJsonlAsset {
272 concinnity_cook::authoring::world::WorldJsonlAsset::from_value(&json)
273 }
274
275 #[test]
278 fn the_lut_scan_takes_the_first_source_however_the_type_is_spelled() {
279 for ty in ["ColorLut", "color_lut", "colorlut", "COLOR_LUT"] {
280 let assets = [asset(
281 serde_json::json!({"name":"grade","type":ty,"args":{"source":"luts/warm.cube"}}),
282 )];
283 assert_eq!(
284 scan_color_lut_source(&assets),
285 Some("luts/warm.cube".to_string()),
286 "type {ty}"
287 );
288 }
289
290 let assets = [
292 asset(serde_json::json!({"name":"a","type":"ColorLut","args":{"source":"first.cube"}})),
293 asset(
294 serde_json::json!({"name":"b","type":"ColorLut","args":{"source":"second.cube"}}),
295 ),
296 ];
297 assert_eq!(
298 scan_color_lut_source(&assets),
299 Some("first.cube".to_string())
300 );
301 }
302
303 #[test]
305 fn the_lut_scan_yields_nothing_without_a_source() {
306 assert_eq!(scan_color_lut_source(&[]), None);
307 let no_source = [asset(
308 serde_json::json!({"name":"grade","type":"ColorLut","args":{}}),
309 )];
310 assert_eq!(scan_color_lut_source(&no_source), None);
311 let empty = [asset(
312 serde_json::json!({"name":"grade","type":"ColorLut","args":{"source":""}}),
313 )];
314 assert_eq!(scan_color_lut_source(&empty), None);
315 let other_kind = [asset(
316 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"x.hdr"}}),
317 )];
318 assert_eq!(scan_color_lut_source(&other_kind), None);
319 }
320
321 #[test]
325 fn the_environment_map_scan_defaults_the_unset_bake_inputs() {
326 let assets = [asset(
327 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"studio.hdr"}}),
328 )];
329 let info = scan_environment_map_source(&assets).expect("a file-backed map");
330 assert_eq!(info.source, "studio.hdr");
331 assert_eq!(info.prefilter_face_size, 512);
332 assert_eq!(info.irradiance_face_size, 8);
333 assert_eq!(info.prefilter_samples, 1024);
334 assert_eq!(info.prefilter_clamp, 12.0);
335 }
336
337 #[test]
338 fn the_environment_map_scan_carries_the_authored_bake_inputs() {
339 let assets = [asset(serde_json::json!({
340 "name":"sky","type":"environment_map","args":{
341 "source":"studio.hdr",
342 "prefilter_face_size": 256,
343 "irradiance_face_size": 16,
344 "prefilter_samples": 64,
345 "prefilter_clamp": 4.5
346 }
347 }))];
348 let info = scan_environment_map_source(&assets).expect("a file-backed map");
349 assert_eq!(info.prefilter_face_size, 256);
350 assert_eq!(info.irradiance_face_size, 16);
351 assert_eq!(info.prefilter_samples, 64);
352 assert_eq!(info.prefilter_clamp, 4.5);
353 }
354
355 #[test]
358 fn the_environment_map_scan_skips_a_procedural_map() {
359 let generated = [asset(serde_json::json!({
360 "name":"sky","type":"EnvironmentMap","args":{"generator":"sky"}
361 }))];
362 assert!(scan_environment_map_source(&generated).is_none());
363
364 let both = [asset(serde_json::json!({
365 "name":"sky","type":"EnvironmentMap","args":{"generator":"sky","source":"studio.hdr"}
366 }))];
367 assert!(scan_environment_map_source(&both).is_none());
368
369 assert!(scan_environment_map_source(&[]).is_none());
370 let no_source = [asset(
371 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{}}),
372 )];
373 assert!(scan_environment_map_source(&no_source).is_none());
374 }
375
376 #[test]
379 fn the_assembled_world_publishes_the_watcher_source_catalogues() {
380 let loaded =
381 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
382 let world = world_from_loaded(loaded).unwrap();
383 assert!(
384 world
385 .resource::<crate::resource::ColorLutSources>()
386 .is_some_and(|s| s.0.is_none()),
387 "a world with no LUT publishes an empty catalogue, not none at all"
388 );
389 assert!(
390 world
391 .resource::<crate::resource::EnvironmentMapSources>()
392 .is_some()
393 );
394 }
395
396 #[test]
397 fn world_from_loaded_assembles_an_in_memory_world() {
398 let loaded =
399 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
400 let expanded = loaded.assets.len();
401 let world = world_from_loaded(loaded).unwrap();
402 assert_eq!(world.component_count(), expanded);
405 let index = world
408 .resource::<concinnity_core::ecs::EntityByName>()
409 .expect("assembly publishes the name -> entity index");
410 assert_eq!(index.0.len(), expanded);
411 }
412
413 #[test]
414 fn build_world_from_str_assembles_an_in_memory_world() {
415 let world =
418 build_world_from_str("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n")
419 .unwrap();
420 assert!(world.component_count() >= 1);
421 }
422
423 #[test]
424 fn build_world_from_missing_path_is_not_found() {
425 let err = build_world_from_path("/no/such/concinnity-world-xyz.jsonl")
426 .expect_err("a missing world path must error");
427 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
428 }
429
430 struct CwdGuard(std::path::PathBuf);
433 impl Drop for CwdGuard {
434 fn drop(&mut self) {
435 let _ = std::env::set_current_dir(&self.0);
436 }
437 }
438
439 struct StateDirGuard;
441 impl Drop for StateDirGuard {
442 fn drop(&mut self) {
443 concinnity_host::store::paths::clear_state_dir();
444 }
445 }
446
447 #[test]
451 fn an_in_memory_build_records_its_material_identities() {
452 let _guard = crate::test_support::lock();
453 crate::test_support::isolate_state_dir();
454 let world = build_world_from_str(concat!(
455 "{\"name\":\"steel\",\"type\":\"Material\",\"args\":{\"roughness\":0.4}}\n",
456 "{\"name\":\"glass\",\"type\":\"Material\",\"args\":{\"transparent\":true}}\n",
457 ))
458 .expect("a material-only world compiles");
459 let names = world
460 .resource::<crate::resource::MaterialNames>()
461 .expect("the catalogue is installed");
462 assert_eq!(
463 names.0,
464 vec![
465 crate::ecs::asset_id::intern("steel").0,
466 crate::ecs::asset_id::intern("glass").0,
467 ],
468 "declaration order is handle order"
469 );
470 }
471
472 #[test]
478 fn build_world_to_disk_writes_blobs_and_lock() {
479 let _guard = crate::test_support::lock();
480 let dir = tempfile::tempdir().unwrap();
481 let prev = std::env::current_dir().unwrap();
482 std::env::set_current_dir(dir.path()).unwrap();
483 let _cwd = CwdGuard(prev);
484 concinnity_host::store::paths::set_state_dir(dir.path());
487 let _state = StateDirGuard;
488
489 std::fs::write(
490 "world.jsonl",
491 "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n",
492 )
493 .unwrap();
494
495 build_world_to_disk("world.jsonl").expect("compile + write should succeed");
496
497 assert!(
499 concinnity_host::store::paths::data_dir()
500 .expect("the test installs a state dir")
501 .join("0")
502 .exists(),
503 "data/0 blob written"
504 );
505 assert!(
506 dir.path().join("world-lock.json").exists(),
507 "world-lock.json written"
508 );
509 }
510}