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 = concinnity_cook::prepare_world(
26 content,
27 crate::project::assets_dir().as_deref(),
28 crate::cook_platform(),
29 )
30 .map_err(|errs| concinnity_cook::check::report_validation_errors(&errs))?;
31
32 Ok(loaded)
33}
34
35fn type_is(asset: &concinnity_cook::authoring::world::WorldJsonlAsset, norm_type: &str) -> bool {
38 asset.asset_type.to_lowercase().replace('_', "") == norm_type
39}
40
41fn scan_color_lut_source(
44 assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
45) -> Option<String> {
46 assets
47 .iter()
48 .find(|a| type_is(a, "colorlut"))
49 .and_then(|a| a.args.get("source").and_then(|v| v.as_str()))
50 .filter(|s| !s.is_empty())
51 .map(str::to_string)
52}
53
54fn scan_environment_map_source(
58 assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
59) -> Option<crate::resource::EnvironmentMapSourceInfo> {
60 let a = assets.iter().find(|a| type_is(a, "environmentmap"))?;
61 let generator = a
62 .args
63 .get("generator")
64 .and_then(|v| v.as_str())
65 .unwrap_or("");
66 let source = a.args.get("source").and_then(|v| v.as_str()).unwrap_or("");
67 if !generator.is_empty() || source.is_empty() {
68 return None;
69 }
70 let u32_arg = |key: &str, default: u32| {
71 a.args
72 .get(key)
73 .and_then(|v| v.as_u64())
74 .map(|v| v as u32)
75 .unwrap_or(default)
76 };
77 Some(crate::resource::EnvironmentMapSourceInfo {
78 source: source.to_string(),
79 prefilter_face_size: u32_arg("prefilter_face_size", 512),
80 irradiance_face_size: u32_arg("irradiance_face_size", 8),
81 prefilter_samples: u32_arg("prefilter_samples", 1024),
82 prefilter_clamp: a
83 .args
84 .get("prefilter_clamp")
85 .and_then(|v| v.as_f64())
86 .map(|v| v as f32)
87 .unwrap_or(12.0),
88 })
89}
90
91pub fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
94 let color_lut_source = scan_color_lut_source(&loaded.assets);
101 let environment_map_source = scan_environment_map_source(&loaded.assets);
102
103 let mut result = build_compiled(
104 loaded.assets,
105 crate::project::assets_dir().as_deref(),
106 None,
107 crate::cook_platform(),
108 )?;
109
110 let material_names = crate::resource::MaterialNames(
112 result.resource_names(concinnity_cook::resource_handles::ResourceKind::Material),
113 );
114
115 let payload_sections: Vec<Option<Vec<u8>>> = result.payloads.into_iter().map(Some).collect();
116 let mut world =
117 concinnity_engine::blob::world_from(crate::blob::BlobData::new(payload_sections));
118 let mut by_name = std::collections::BTreeMap::new();
121 for def in &result.defs {
122 let mut component = ComponentAsset::from_baked(def).map_err(|e| {
123 std::io::Error::new(
124 std::io::ErrorKind::InvalidData,
125 format!("Asset construction failed: {:?}", e),
126 )
127 })?;
128 if let Some(locator) = &def.payload {
129 component.inject_locator(locator.clone());
130 }
131 let entity = world.add(component);
132 if let Some(id) = def.name {
133 by_name.insert(id, entity);
134 }
135 }
136 world.insert_resource(concinnity_core::ecs::EntityByName(by_name));
137 crate::resource::install_resource_tables(&mut world, &mut result.resources);
141 world.insert_resource(crate::ecs::BlobSceneGroups(result.scene_groups));
142 world.insert_resource(crate::ecs::BlobMeshBounds(result.mesh_bounds));
143 if let Some(budget) = result.physics_budget {
144 world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
145 }
146 world.insert_resource(crate::resource::ColorLutSources(color_lut_source));
148 world.insert_resource(crate::resource::EnvironmentMapSources(
149 environment_map_source,
150 ));
151 world.insert_resource(crate::resource::TextureSources(
155 result
156 .texture_sources
157 .iter()
158 .map(|t| crate::resource::TextureSource {
159 name_id: t.name_id,
160 source: t.source.clone(),
161 image_index: t.image_index,
162 })
163 .collect(),
164 ));
165 world.insert_resource(material_names);
168 world.insert_resource(crate::resource::MeshSources(
171 result
172 .mesh_sources
173 .iter()
174 .map(|m| crate::resource::MeshSource {
175 source: m.source.clone(),
176 primitive_index: m.primitive_index,
177 lod_levels: m.lod_levels,
178 lod_distances: m.lod_distances.clone(),
179 })
180 .collect(),
181 ));
182 Ok(world)
183}
184
185pub fn build_world_from_str(content: &str) -> std::io::Result<World> {
190 Ok(build_world_and_shadows(content)?.0)
191}
192
193pub(crate) fn build_world_and_shadows(
198 content: &str,
199) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
200 let loaded = prepare(content)?;
201 let shadowed = loaded.shadowed.clone();
202 Ok((world_from_loaded(loaded)?, shadowed))
203}
204
205pub fn build_world_from_path(world_path: &str) -> std::io::Result<World> {
210 let content = std::fs::read_to_string(world_path)?;
211 build_world_from_str(&content)
212}
213
214pub fn build_world_to_disk(world_path: &str) -> std::io::Result<()> {
221 let content = std::fs::read_to_string(world_path)?;
222 build_world_str_to_disk(&content)
223}
224
225pub(crate) fn build_world_str_to_disk(content: &str) -> std::io::Result<()> {
230 build_world_str_to_disk_with_progress(content, None)
231}
232
233pub(crate) fn build_world_str_to_disk_with_progress(
236 content: &str,
237 progress: Option<&(dyn Fn(concinnity_cook::BuildProgress) + Sync)>,
238) -> std::io::Result<()> {
239 let loaded = prepare(content)?;
240 let result = concinnity_cook::build_compiled_with_progress(
241 loaded.assets,
242 crate::project::assets_dir().as_deref(),
243 None,
244 crate::cook_platform(),
245 progress,
246 )?;
247 if let Some(p) = progress {
248 p(concinnity_cook::BuildProgress {
249 stage: "write",
250 done: 0,
251 total: 0,
252 });
253 }
254 concinnity_cook::write_build_outputs(
255 &crate::project::require()?,
256 &result,
257 &loaded.injected,
258 &loaded.shadowed,
259 )?;
260 Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn prepare_accepts_a_valid_world() {
269 let loaded =
270 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
271 assert!(loaded.assets.iter().any(|a| a.name == "phys"));
272 assert!(loaded.authored.contains(&"phys".to_string()));
273 }
274
275 #[test]
276 fn prepare_rejects_an_invalid_world() {
277 assert!(prepare("{\"name\":\"odd\",\"type\":\"NotARealAssetType\"}\n").is_err());
278 assert!(prepare("{ not json\n").is_err());
279 }
280
281 fn asset(json: serde_json::Value) -> concinnity_cook::authoring::world::WorldJsonlAsset {
282 concinnity_cook::authoring::world::WorldJsonlAsset::from_value(&json)
283 }
284
285 #[test]
288 fn the_lut_scan_takes_the_first_source_however_the_type_is_spelled() {
289 for ty in ["ColorLut", "color_lut", "colorlut", "COLOR_LUT"] {
290 let assets = [asset(
291 serde_json::json!({"name":"grade","type":ty,"args":{"source":"luts/warm.cube"}}),
292 )];
293 assert_eq!(
294 scan_color_lut_source(&assets),
295 Some("luts/warm.cube".to_string()),
296 "type {ty}"
297 );
298 }
299
300 let assets = [
302 asset(serde_json::json!({"name":"a","type":"ColorLut","args":{"source":"first.cube"}})),
303 asset(
304 serde_json::json!({"name":"b","type":"ColorLut","args":{"source":"second.cube"}}),
305 ),
306 ];
307 assert_eq!(
308 scan_color_lut_source(&assets),
309 Some("first.cube".to_string())
310 );
311 }
312
313 #[test]
315 fn the_lut_scan_yields_nothing_without_a_source() {
316 assert_eq!(scan_color_lut_source(&[]), None);
317 let no_source = [asset(
318 serde_json::json!({"name":"grade","type":"ColorLut","args":{}}),
319 )];
320 assert_eq!(scan_color_lut_source(&no_source), None);
321 let empty = [asset(
322 serde_json::json!({"name":"grade","type":"ColorLut","args":{"source":""}}),
323 )];
324 assert_eq!(scan_color_lut_source(&empty), None);
325 let other_kind = [asset(
326 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"x.hdr"}}),
327 )];
328 assert_eq!(scan_color_lut_source(&other_kind), None);
329 }
330
331 #[test]
335 fn the_environment_map_scan_defaults_the_unset_bake_inputs() {
336 let assets = [asset(
337 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"studio.hdr"}}),
338 )];
339 let info = scan_environment_map_source(&assets).expect("a file-backed map");
340 assert_eq!(info.source, "studio.hdr");
341 assert_eq!(info.prefilter_face_size, 512);
342 assert_eq!(info.irradiance_face_size, 8);
343 assert_eq!(info.prefilter_samples, 1024);
344 assert_eq!(info.prefilter_clamp, 12.0);
345 }
346
347 #[test]
348 fn the_environment_map_scan_carries_the_authored_bake_inputs() {
349 let assets = [asset(serde_json::json!({
350 "name":"sky","type":"environment_map","args":{
351 "source":"studio.hdr",
352 "prefilter_face_size": 256,
353 "irradiance_face_size": 16,
354 "prefilter_samples": 64,
355 "prefilter_clamp": 4.5
356 }
357 }))];
358 let info = scan_environment_map_source(&assets).expect("a file-backed map");
359 assert_eq!(info.prefilter_face_size, 256);
360 assert_eq!(info.irradiance_face_size, 16);
361 assert_eq!(info.prefilter_samples, 64);
362 assert_eq!(info.prefilter_clamp, 4.5);
363 }
364
365 #[test]
368 fn the_environment_map_scan_skips_a_procedural_map() {
369 let generated = [asset(serde_json::json!({
370 "name":"sky","type":"EnvironmentMap","args":{"generator":"sky"}
371 }))];
372 assert!(scan_environment_map_source(&generated).is_none());
373
374 let both = [asset(serde_json::json!({
375 "name":"sky","type":"EnvironmentMap","args":{"generator":"sky","source":"studio.hdr"}
376 }))];
377 assert!(scan_environment_map_source(&both).is_none());
378
379 assert!(scan_environment_map_source(&[]).is_none());
380 let no_source = [asset(
381 serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{}}),
382 )];
383 assert!(scan_environment_map_source(&no_source).is_none());
384 }
385
386 #[test]
389 fn the_assembled_world_publishes_the_watcher_source_catalogues() {
390 let loaded =
391 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
392 let world = world_from_loaded(loaded).unwrap();
393 assert!(
394 world
395 .resource::<crate::resource::ColorLutSources>()
396 .is_some_and(|s| s.0.is_none()),
397 "a world with no LUT publishes an empty catalogue, not none at all"
398 );
399 assert!(
400 world
401 .resource::<crate::resource::EnvironmentMapSources>()
402 .is_some()
403 );
404 }
405
406 #[test]
407 fn world_from_loaded_assembles_an_in_memory_world() {
408 let loaded =
409 prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
410 let expanded = loaded.assets.len();
411 let world = world_from_loaded(loaded).unwrap();
412 assert_eq!(world.component_count(), expanded);
415 let index = world
418 .resource::<concinnity_core::ecs::EntityByName>()
419 .expect("assembly publishes the name -> entity index");
420 assert_eq!(index.0.len(), expanded);
421 }
422
423 #[test]
424 fn build_world_from_str_assembles_an_in_memory_world() {
425 let world =
428 build_world_from_str("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n")
429 .unwrap();
430 assert!(world.component_count() >= 1);
431 }
432
433 #[test]
434 fn build_world_from_missing_path_is_not_found() {
435 let err = build_world_from_path("/no/such/concinnity-world-xyz.jsonl")
436 .expect_err("a missing world path must error");
437 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
438 }
439
440 struct CwdGuard(std::path::PathBuf);
443 impl Drop for CwdGuard {
444 fn drop(&mut self) {
445 let _ = std::env::set_current_dir(&self.0);
446 }
447 }
448
449 struct ProjectGuard;
451 impl Drop for ProjectGuard {
452 fn drop(&mut self) {
453 crate::project::close();
454 }
455 }
456
457 #[test]
461 fn an_in_memory_build_records_its_material_identities() {
462 let _guard = crate::test_support::lock();
463 crate::test_support::isolate_state_dir();
464 let world = build_world_from_str(concat!(
465 "{\"name\":\"steel\",\"type\":\"Material\",\"args\":{\"roughness\":0.4}}\n",
466 "{\"name\":\"glass\",\"type\":\"Material\",\"args\":{\"transparent\":true}}\n",
467 ))
468 .expect("a material-only world compiles");
469 let names = world
470 .resource::<crate::resource::MaterialNames>()
471 .expect("the catalogue is installed");
472 assert_eq!(
473 names.0,
474 vec![
475 crate::ecs::asset_id::intern("steel").0,
476 crate::ecs::asset_id::intern("glass").0,
477 ],
478 "declaration order is handle order"
479 );
480 }
481
482 #[test]
488 fn build_world_to_disk_writes_blobs_and_lock() {
489 let _guard = crate::test_support::lock();
490 let dir = concinnity_testing::TempTree::new();
491 let prev = std::env::current_dir().unwrap();
492 std::env::set_current_dir(dir.path()).unwrap();
493 let _cwd = CwdGuard(prev);
494 crate::project::open(concinnity_host::store::paths::StateTree::at(dir.path()));
497 let _project = ProjectGuard;
498
499 std::fs::write(
500 "world.jsonl",
501 "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n",
502 )
503 .unwrap();
504
505 build_world_to_disk("world.jsonl").expect("compile + write should succeed");
506
507 assert!(
509 concinnity_host::store::blob::primary_in(
510 &crate::project::data_dir().expect("the test opened a project")
511 )
512 .exists(),
513 "data/0 blob written"
514 );
515 assert!(
516 dir.path().join("world-lock.json").exists(),
517 "world-lock.json written"
518 );
519 }
520}