Skip to main content

concinnity_dev/authoring/
build.rs

1// src/build.rs: shared in-memory build orchestration
2
3pub(crate) use concinnity_cook::build_compiled;
4
5use crate::ecs::{ComponentAsset, World};
6use concinnity_cook::build_only::LoadedWorld;
7
8// Load, validate, and (when server credentials are present) fetch the missing
9// source files for a world. The returned LoadedWorld has passed the full
10// validation front half and is ready for concinnity_cook::build_compiled.
11//
12// This is the shared front half of every in-memory build: `build_world_from_path`
13// (the CLI interpreted `run` and the FFI preview) funnels through here so
14// validation and asset fetching behave identically. The `cn build` blob path
15// prepares through concinnity_cook directly and does not use this.
16pub(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
23// Normalized asset-type match (lowercase, underscores stripped), matching the
24// convention used across the cook world passes.
25fn type_is(asset: &concinnity_cook::authoring::world::WorldJsonlAsset, norm_type: &str) -> bool {
26    asset.asset_type.to_lowercase().replace('_', "") == norm_type
27}
28
29// The first declared ColorLut's authored `source` path (non-empty), or `None`.
30// Dev-only; feeds the hot-reload watcher.
31fn 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
42// The first declared file-backed EnvironmentMap's re-bake inputs, or `None` (a
43// procedural `generator` has no file to watch). The face-size / sample defaults
44// mirror the EnvironmentMap schema defaults in `concinnity-core/src/components/environment_map.rs`.
45fn 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
79/// Compile a prepared world and assemble it into an in-memory World, ready to
80/// run without touching any blob files on disk.
81pub fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
82    // Capture the dev-only hot-reload source info for the singleton ColorLut and
83    // EnvironmentMap resources BEFORE `build_compiled` consumes the asset list.
84    // These kinds are authored (never injected by an expansion pass), so the raw
85    // world list carries every one; the renderer's `capture_sources` path reads
86    // these to seed the file-reload watcher now that the drained `source` field is
87    // gone. Only the first of each is used (the runtime uses handle 0).
88    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    // The material name catalogue, read before the result is taken apart below.
99    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    // Index every named component's entity as it is minted, matching the
107    // shipped runtime's `load_blob`, so name references resolve for any type.
108    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    // Load the compiled resource stream into its per-kind tables, exactly as the
126    // shipped runtime's `load_blob` does, so the in-memory `cn debug` world reads
127    // audio clips and textures by handle too.
128    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    // Dev-only source catalogues for the hot-reload watcher (see the scan above).
135    world.insert_resource(crate::resource::ColorLutSources(color_lut_source));
136    world.insert_resource(crate::resource::EnvironmentMapSources(
137        environment_map_source,
138    ));
139    // Dev-only: the texture source catalogue, so the renderer's hot-reload
140    // capture and the runtime spawn-by-name path can map a texture handle back to
141    // its file / name. Not present in the shipped `load_blob` path.
142    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    // Dev-only: the material name catalogue, so the editor's live draw seam can
154    // resolve a material an edit names to the handle it was compiled at.
155    world.insert_resource(material_names);
156    // Dev-only: the mesh source catalogue, so the renderer's hot-reload capture
157    // can map a mesh handle back to the `.glb`/`.fbx` that backs it.
158    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
173/// Run the full in-memory pipeline on a world.jsonl string, returning a
174/// ready-to-run World without touching any blob files on disk. The editor uses
175/// this to boot an empty (or otherwise non-renderable) world from a seeded
176/// GraphicsConfig so a window still opens.
177pub fn build_world_from_str(content: &str) -> std::io::Result<World> {
178    Ok(build_world_and_shadows(content)?.0)
179}
180
181/// `build_world_from_str`, plus the pre-merge args of every generated asset the
182/// world patches. An authored line over a generated asset is a sparse patch, so
183/// a tool that re-derives one asset's effective args from its line alone needs
184/// the baseline the patch merges over.
185pub(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
193/// Read a world.jsonl file from disk and run the full in-memory pipeline on it,
194/// returning a ready-to-run World. The interpreted `run` (in the CLI crate)
195/// loads its world through here; it is the file-backed counterpart of `prepare`
196/// + `world_from_loaded`.
197pub 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
202/// Compile a world.jsonl file and write the compiled blobs + world-lock.json to
203/// the active state dir's `data/`, exactly as `cn build` does. This is
204/// `cn build` as a library call: the editor's SAVE goes through here to persist
205/// edits, reusing the validated compile + blob-write tail rather than patching
206/// blobs directly. Same-process recompiles are fast because the payload / expand
207/// caches are warm.
208pub 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
213// The string-backed tail of `build_world_to_disk`: compile world content and
214// write the blobs + lock without reading (or writing) a world.jsonl. The
215// editor console's build command goes through here so it compiles the
216// in-memory entries as they stand, saved or not.
217pub(crate) fn build_world_str_to_disk(content: &str) -> std::io::Result<()> {
218    build_world_str_to_disk_with_progress(content, None)
219}
220
221// `build_world_str_to_disk` with a compile-progress callback (the editor's
222// cook workers feed their operation card through it).
223pub(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    // The type match is the cook's normalized one, so `color_lut`, `ColorLut`,
274    // and `colorlut` all name the same kind.
275    #[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        // Only the first is used: the runtime binds handle 0.
289        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    // Nothing to watch: no LUT at all, or one with no authored source path.
302    #[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    // A file-backed environment map carries its re-bake inputs so the watcher
320    // can reproduce the original bake; unset ones fall back to the schema
321    // defaults rather than zero.
322    #[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    // A procedural map has no file behind it, so there is nothing to watch --
354    // even when a stale `source` is still authored alongside the generator.
355    #[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    // The assembled world publishes both dev-only source catalogues, which is
375    // what seeds the hot-reload watcher.
376    #[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        // Every expanded asset landed as a component; nothing was dropped on
401        // the way through compile + assembly.
402        assert_eq!(world.component_count(), expanded);
403        // Every named component's entity is indexed, so name references
404        // resolve for any type, not just decomposed Props.
405        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        // The string path is what the editor uses to seed an empty world; it
414        // must produce the same assembled world as the file-backed path.
415        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    // Closes the session's project on the way out, for the same reason.
429    struct ProjectGuard;
430    impl Drop for ProjectGuard {
431        fn drop(&mut self) {
432            crate::project::close();
433        }
434    }
435
436    // The in-memory build records each compiled Material's identity, dense by
437    // the handle cook assigned it, so the live draw seam can resolve a
438    // material an edit names against the running world.
439    #[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    // build_world_to_disk compiles a world.jsonl and writes the blobs + lock to
462    // the open project's build root, exactly as `cn build` does. Uses a
463    // payload-free world (PhysicsConfig) so it needs no source files or shader
464    // compilation.
465    #[test]
466    fn build_world_to_disk_writes_blobs_and_lock() {
467        // Opening the session's project is a process-global write.
468        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        // The primary blob (data/0) and the provenance lock both land under the
487        // build root, not beside the authored world.
488        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}