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