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    // Install this build's shader toolchain (and the backend's shader-layout
18    // validator, where it ships one) before any shader compiles: the cook has no
19    // compiler of its own, and a user shader that mis-declares an engine buffer
20    // struct should fail the build with a clear message instead of faulting the
21    // GPU at run time. Idempotent, so covering `run` and the FFI entry points
22    // here costs nothing when the CLI already installed at startup.
23    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
35// Normalized asset-type match (lowercase, underscores stripped), matching the
36// convention used across the cook world passes.
37fn type_is(asset: &concinnity_cook::authoring::world::WorldJsonlAsset, norm_type: &str) -> bool {
38    asset.asset_type.to_lowercase().replace('_', "") == norm_type
39}
40
41// The first declared ColorLut's authored `source` path (non-empty), or `None`.
42// Dev-only; feeds the hot-reload watcher.
43fn 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
54// The first declared file-backed EnvironmentMap's re-bake inputs, or `None` (a
55// procedural `generator` has no file to watch). The face-size / sample defaults
56// mirror the EnvironmentMap schema defaults in `concinnity-core/src/components/environment_map.rs`.
57fn 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
91/// Compile a prepared world and assemble it into an in-memory World, ready to
92/// run without touching any blob files on disk.
93pub fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
94    // Capture the dev-only hot-reload source info for the singleton ColorLut and
95    // EnvironmentMap resources BEFORE `build_compiled` consumes the asset list.
96    // These kinds are authored (never injected by an expansion pass), so the raw
97    // world list carries every one; the renderer's `capture_sources` path reads
98    // these to seed the file-reload watcher now that the drained `source` field is
99    // gone. Only the first of each is used (the runtime uses handle 0).
100    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    // The material name catalogue, read before the result is taken apart below.
111    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    // Index every named component's entity as it is minted, matching the
119    // shipped runtime's `load_blob`, so name references resolve for any type.
120    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    // Load the compiled resource stream into its per-kind tables, exactly as the
138    // shipped runtime's `load_blob` does, so the in-memory `cn debug` world reads
139    // audio clips and textures by handle too.
140    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    // Dev-only source catalogues for the hot-reload watcher (see the scan above).
147    world.insert_resource(crate::resource::ColorLutSources(color_lut_source));
148    world.insert_resource(crate::resource::EnvironmentMapSources(
149        environment_map_source,
150    ));
151    // Dev-only: the texture source catalogue, so the renderer's hot-reload
152    // capture and the runtime spawn-by-name path can map a texture handle back to
153    // its file / name. Not present in the shipped `load_blob` path.
154    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    // Dev-only: the material name catalogue, so the editor's live draw seam can
166    // resolve a material an edit names to the handle it was compiled at.
167    world.insert_resource(material_names);
168    // Dev-only: the mesh source catalogue, so the renderer's hot-reload capture
169    // can map a mesh handle back to the `.glb`/`.fbx` that backs it.
170    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
185/// Run the full in-memory pipeline on a world.jsonl string, returning a
186/// ready-to-run World without touching any blob files on disk. The editor uses
187/// this to boot an empty (or otherwise non-renderable) world from a seeded
188/// GraphicsConfig so a window still opens.
189pub fn build_world_from_str(content: &str) -> std::io::Result<World> {
190    Ok(build_world_and_shadows(content)?.0)
191}
192
193/// `build_world_from_str`, plus the pre-merge args of every generated asset the
194/// world patches. An authored line over a generated asset is a sparse patch, so
195/// a tool that re-derives one asset's effective args from its line alone needs
196/// the baseline the patch merges over.
197pub(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
205/// Read a world.jsonl file from disk and run the full in-memory pipeline on it,
206/// returning a ready-to-run World. The interpreted `run` (in the CLI crate)
207/// loads its world through here; it is the file-backed counterpart of `prepare`
208/// + `world_from_loaded`.
209pub 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
214/// Compile a world.jsonl file and write the compiled blobs + world-lock.json to
215/// the active state dir's `data/`, exactly as `cn build` does. This is
216/// `cn build` as a library call: the editor's SAVE goes through here to persist
217/// edits, reusing the validated compile + blob-write tail rather than patching
218/// blobs directly. Same-process recompiles are fast because the payload / expand
219/// caches are warm.
220pub 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
225// The string-backed tail of `build_world_to_disk`: compile world content and
226// write the blobs + lock without reading (or writing) a world.jsonl. The
227// editor console's build command goes through here so it compiles the
228// in-memory entries as they stand, saved or not.
229pub(crate) fn build_world_str_to_disk(content: &str) -> std::io::Result<()> {
230    build_world_str_to_disk_with_progress(content, None)
231}
232
233// `build_world_str_to_disk` with a compile-progress callback (the editor's
234// cook workers feed their operation card through it).
235pub(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    // The type match is the cook's normalized one, so `color_lut`, `ColorLut`,
286    // and `colorlut` all name the same kind.
287    #[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        // Only the first is used: the runtime binds handle 0.
301        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    // Nothing to watch: no LUT at all, or one with no authored source path.
314    #[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    // A file-backed environment map carries its re-bake inputs so the watcher
332    // can reproduce the original bake; unset ones fall back to the schema
333    // defaults rather than zero.
334    #[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    // A procedural map has no file behind it, so there is nothing to watch --
366    // even when a stale `source` is still authored alongside the generator.
367    #[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    // The assembled world publishes both dev-only source catalogues, which is
387    // what seeds the hot-reload watcher.
388    #[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        // Every expanded asset landed as a component; nothing was dropped on
413        // the way through compile + assembly.
414        assert_eq!(world.component_count(), expanded);
415        // Every named component's entity is indexed, so name references
416        // resolve for any type, not just decomposed Props.
417        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        // The string path is what the editor uses to seed an empty world; it
426        // must produce the same assembled world as the file-backed path.
427        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    // Restores the previous working directory on drop, so a chdir-in-test does
441    // not leak into other tests (they run in parallel threads of one process).
442    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    // Closes the session's project on the way out, for the same reason.
450    struct ProjectGuard;
451    impl Drop for ProjectGuard {
452        fn drop(&mut self) {
453            crate::project::close();
454        }
455    }
456
457    // The in-memory build records each compiled Material's identity, dense by
458    // the handle cook assigned it, so the live draw seam can resolve a
459    // material an edit names against the running world.
460    #[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    // build_world_to_disk compiles a world.jsonl and writes the blobs + lock to
483    // the installed state tree, exactly as `cn build` does. Runs under the
484    // process cwd lock in an isolated temp dir so it neither races other tests
485    // nor pollutes the repo. Uses a payload-free world (PhysicsConfig) so it
486    // needs no source files or shader compilation.
487    #[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        // The lock file is written relative to the cwd; the blobs go into the
495        // project's `data/`, and no project is open by default.
496        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        // The primary blob (data/0) and the provenance lock are both written.
508        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}