concinnity-dev 0.19.2

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
// src/build.rs: shared in-memory build orchestration

pub(crate) use concinnity_cook::build_compiled;

use crate::ecs::{ComponentAsset, World};
use concinnity_cook::build_only::LoadedWorld;

// Load, validate, and (when server credentials are present) fetch the missing
// source files for a world. The returned LoadedWorld has passed the full
// validation front half and is ready for concinnity_cook::build_compiled.
//
// This is the shared front half of every in-memory build: `build_world_from_path`
// (the CLI interpreted `run` and the FFI preview) funnels through here so
// validation and asset fetching behave identically. The `cn build` blob path
// prepares through concinnity_cook directly and does not use this.
pub(crate) fn prepare(content: &str) -> std::io::Result<LoadedWorld> {
    // Install this build's shader toolchain (and the backend's shader-layout
    // validator, where it ships one) before any shader compiles: the cook has no
    // compiler of its own, and a user shader that mis-declares an engine buffer
    // struct should fail the build with a clear message instead of faulting the
    // GPU at run time. Idempotent, so covering `run` and the FFI entry points
    // here costs nothing when the CLI already installed at startup.
    concinnity_shader::install();

    let loaded = concinnity_cook::prepare_world(
        content,
        crate::project::assets_dir().as_deref(),
        crate::cook_platform(),
    )
    .map_err(|errs| concinnity_cook::check::report_validation_errors(&errs))?;

    Ok(loaded)
}

// Normalized asset-type match (lowercase, underscores stripped), matching the
// convention used across the cook world passes.
fn type_is(asset: &concinnity_cook::authoring::world::WorldJsonlAsset, norm_type: &str) -> bool {
    asset.asset_type.to_lowercase().replace('_', "") == norm_type
}

// The first declared ColorLut's authored `source` path (non-empty), or `None`.
// Dev-only; feeds the hot-reload watcher.
fn scan_color_lut_source(
    assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
) -> Option<String> {
    assets
        .iter()
        .find(|a| type_is(a, "colorlut"))
        .and_then(|a| a.args.get("source").and_then(|v| v.as_str()))
        .filter(|s| !s.is_empty())
        .map(str::to_string)
}

// The first declared file-backed EnvironmentMap's re-bake inputs, or `None` (a
// procedural `generator` has no file to watch). The face-size / sample defaults
// mirror the EnvironmentMap schema defaults in `concinnity-core/src/components/environment_map.rs`.
fn scan_environment_map_source(
    assets: &[concinnity_cook::authoring::world::WorldJsonlAsset],
) -> Option<crate::resource::EnvironmentMapSourceInfo> {
    let a = assets.iter().find(|a| type_is(a, "environmentmap"))?;
    let generator = a
        .args
        .get("generator")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let source = a.args.get("source").and_then(|v| v.as_str()).unwrap_or("");
    if !generator.is_empty() || source.is_empty() {
        return None;
    }
    let u32_arg = |key: &str, default: u32| {
        a.args
            .get(key)
            .and_then(|v| v.as_u64())
            .map(|v| v as u32)
            .unwrap_or(default)
    };
    Some(crate::resource::EnvironmentMapSourceInfo {
        source: source.to_string(),
        prefilter_face_size: u32_arg("prefilter_face_size", 512),
        irradiance_face_size: u32_arg("irradiance_face_size", 8),
        prefilter_samples: u32_arg("prefilter_samples", 1024),
        prefilter_clamp: a
            .args
            .get("prefilter_clamp")
            .and_then(|v| v.as_f64())
            .map(|v| v as f32)
            .unwrap_or(12.0),
    })
}

/// Compile a prepared world and assemble it into an in-memory World, ready to
/// run without touching any blob files on disk.
pub fn world_from_loaded(loaded: LoadedWorld) -> std::io::Result<World> {
    // Capture the dev-only hot-reload source info for the singleton ColorLut and
    // EnvironmentMap resources BEFORE `build_compiled` consumes the asset list.
    // These kinds are authored (never injected by an expansion pass), so the raw
    // world list carries every one; the renderer's `capture_sources` path reads
    // these to seed the file-reload watcher now that the drained `source` field is
    // gone. Only the first of each is used (the runtime uses handle 0).
    let color_lut_source = scan_color_lut_source(&loaded.assets);
    let environment_map_source = scan_environment_map_source(&loaded.assets);

    let mut result = build_compiled(
        loaded.assets,
        crate::project::assets_dir().as_deref(),
        None,
        crate::cook_platform(),
    )?;

    // The material name catalogue, read before the result is taken apart below.
    let material_names = crate::resource::MaterialNames(
        result.resource_names(concinnity_cook::resource_handles::ResourceKind::Material),
    );

    let payload_sections: Vec<Option<Vec<u8>>> = result.payloads.into_iter().map(Some).collect();
    let mut world =
        concinnity_engine::blob::world_from(crate::blob::BlobData::new(payload_sections));
    // Index every named component's entity as it is minted, matching the
    // shipped runtime's `load_blob`, so name references resolve for any type.
    let mut by_name = std::collections::BTreeMap::new();
    for def in &result.defs {
        let mut component = ComponentAsset::from_baked(def).map_err(|e| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("Asset construction failed: {:?}", e),
            )
        })?;
        if let Some(locator) = &def.payload {
            component.inject_locator(locator.clone());
        }
        let entity = world.add(component);
        if let Some(id) = def.name {
            by_name.insert(id, entity);
        }
    }
    world.insert_resource(concinnity_core::ecs::EntityByName(by_name));
    // Load the compiled resource stream into its per-kind tables, exactly as the
    // shipped runtime's `load_blob` does, so the in-memory `cn debug` world reads
    // audio clips and textures by handle too.
    crate::resource::install_resource_tables(&mut world, &mut result.resources);
    world.insert_resource(crate::ecs::BlobSceneGroups(result.scene_groups));
    world.insert_resource(crate::ecs::BlobMeshBounds(result.mesh_bounds));
    if let Some(budget) = result.physics_budget {
        world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
    }
    // Dev-only source catalogues for the hot-reload watcher (see the scan above).
    world.insert_resource(crate::resource::ColorLutSources(color_lut_source));
    world.insert_resource(crate::resource::EnvironmentMapSources(
        environment_map_source,
    ));
    // Dev-only: the texture source catalogue, so the renderer's hot-reload
    // capture and the runtime spawn-by-name path can map a texture handle back to
    // its file / name. Not present in the shipped `load_blob` path.
    world.insert_resource(crate::resource::TextureSources(
        result
            .texture_sources
            .iter()
            .map(|t| crate::resource::TextureSource {
                name_id: t.name_id,
                source: t.source.clone(),
                image_index: t.image_index,
            })
            .collect(),
    ));
    // Dev-only: the material name catalogue, so the editor's live draw seam can
    // resolve a material an edit names to the handle it was compiled at.
    world.insert_resource(material_names);
    // Dev-only: the mesh source catalogue, so the renderer's hot-reload capture
    // can map a mesh handle back to the `.glb`/`.fbx` that backs it.
    world.insert_resource(crate::resource::MeshSources(
        result
            .mesh_sources
            .iter()
            .map(|m| crate::resource::MeshSource {
                source: m.source.clone(),
                primitive_index: m.primitive_index,
                lod_levels: m.lod_levels,
                lod_distances: m.lod_distances.clone(),
            })
            .collect(),
    ));
    Ok(world)
}

/// Run the full in-memory pipeline on a world.jsonl string, returning a
/// ready-to-run World without touching any blob files on disk. The editor uses
/// this to boot an empty (or otherwise non-renderable) world from a seeded
/// GraphicsConfig so a window still opens.
pub fn build_world_from_str(content: &str) -> std::io::Result<World> {
    Ok(build_world_and_shadows(content)?.0)
}

/// `build_world_from_str`, plus the pre-merge args of every generated asset the
/// world patches. An authored line over a generated asset is a sparse patch, so
/// a tool that re-derives one asset's effective args from its line alone needs
/// the baseline the patch merges over.
pub(crate) fn build_world_and_shadows(
    content: &str,
) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
    let loaded = prepare(content)?;
    let shadowed = loaded.shadowed.clone();
    Ok((world_from_loaded(loaded)?, shadowed))
}

/// Read a world.jsonl file from disk and run the full in-memory pipeline on it,
/// returning a ready-to-run World. The interpreted `run` (in the CLI crate)
/// loads its world through here; it is the file-backed counterpart of `prepare`
/// + `world_from_loaded`.
pub fn build_world_from_path(world_path: &str) -> std::io::Result<World> {
    let content = std::fs::read_to_string(world_path)?;
    build_world_from_str(&content)
}

/// Compile a world.jsonl file and write the compiled blobs + world-lock.json to
/// the active state dir's `data/`, exactly as `cn build` does. This is
/// `cn build` as a library call: the editor's SAVE goes through here to persist
/// edits, reusing the validated compile + blob-write tail rather than patching
/// blobs directly. Same-process recompiles are fast because the payload / expand
/// caches are warm.
pub fn build_world_to_disk(world_path: &str) -> std::io::Result<()> {
    let content = std::fs::read_to_string(world_path)?;
    build_world_str_to_disk(&content)
}

// The string-backed tail of `build_world_to_disk`: compile world content and
// write the blobs + lock without reading (or writing) a world.jsonl. The
// editor console's build command goes through here so it compiles the
// in-memory entries as they stand, saved or not.
pub(crate) fn build_world_str_to_disk(content: &str) -> std::io::Result<()> {
    build_world_str_to_disk_with_progress(content, None)
}

// `build_world_str_to_disk` with a compile-progress callback (the editor's
// cook workers feed their operation card through it).
pub(crate) fn build_world_str_to_disk_with_progress(
    content: &str,
    progress: Option<&(dyn Fn(concinnity_cook::BuildProgress) + Sync)>,
) -> std::io::Result<()> {
    let loaded = prepare(content)?;
    let result = concinnity_cook::build_compiled_with_progress(
        loaded.assets,
        crate::project::assets_dir().as_deref(),
        None,
        crate::cook_platform(),
        progress,
    )?;
    if let Some(p) = progress {
        p(concinnity_cook::BuildProgress {
            stage: "write",
            done: 0,
            total: 0,
        });
    }
    concinnity_cook::write_build_outputs(
        &crate::project::require()?,
        &result,
        &loaded.injected,
        &loaded.shadowed,
    )?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn prepare_accepts_a_valid_world() {
        let loaded =
            prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
        assert!(loaded.assets.iter().any(|a| a.name == "phys"));
        assert!(loaded.authored.contains(&"phys".to_string()));
    }

    #[test]
    fn prepare_rejects_an_invalid_world() {
        assert!(prepare("{\"name\":\"odd\",\"type\":\"NotARealAssetType\"}\n").is_err());
        assert!(prepare("{ not json\n").is_err());
    }

    fn asset(json: serde_json::Value) -> concinnity_cook::authoring::world::WorldJsonlAsset {
        concinnity_cook::authoring::world::WorldJsonlAsset::from_value(&json)
    }

    // The type match is the cook's normalized one, so `color_lut`, `ColorLut`,
    // and `colorlut` all name the same kind.
    #[test]
    fn the_lut_scan_takes_the_first_source_however_the_type_is_spelled() {
        for ty in ["ColorLut", "color_lut", "colorlut", "COLOR_LUT"] {
            let assets = [asset(
                serde_json::json!({"name":"grade","type":ty,"args":{"source":"luts/warm.cube"}}),
            )];
            assert_eq!(
                scan_color_lut_source(&assets),
                Some("luts/warm.cube".to_string()),
                "type {ty}"
            );
        }

        // Only the first is used: the runtime binds handle 0.
        let assets = [
            asset(serde_json::json!({"name":"a","type":"ColorLut","args":{"source":"first.cube"}})),
            asset(
                serde_json::json!({"name":"b","type":"ColorLut","args":{"source":"second.cube"}}),
            ),
        ];
        assert_eq!(
            scan_color_lut_source(&assets),
            Some("first.cube".to_string())
        );
    }

    // Nothing to watch: no LUT at all, or one with no authored source path.
    #[test]
    fn the_lut_scan_yields_nothing_without_a_source() {
        assert_eq!(scan_color_lut_source(&[]), None);
        let no_source = [asset(
            serde_json::json!({"name":"grade","type":"ColorLut","args":{}}),
        )];
        assert_eq!(scan_color_lut_source(&no_source), None);
        let empty = [asset(
            serde_json::json!({"name":"grade","type":"ColorLut","args":{"source":""}}),
        )];
        assert_eq!(scan_color_lut_source(&empty), None);
        let other_kind = [asset(
            serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"x.hdr"}}),
        )];
        assert_eq!(scan_color_lut_source(&other_kind), None);
    }

    // A file-backed environment map carries its re-bake inputs so the watcher
    // can reproduce the original bake; unset ones fall back to the schema
    // defaults rather than zero.
    #[test]
    fn the_environment_map_scan_defaults_the_unset_bake_inputs() {
        let assets = [asset(
            serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{"source":"studio.hdr"}}),
        )];
        let info = scan_environment_map_source(&assets).expect("a file-backed map");
        assert_eq!(info.source, "studio.hdr");
        assert_eq!(info.prefilter_face_size, 512);
        assert_eq!(info.irradiance_face_size, 8);
        assert_eq!(info.prefilter_samples, 1024);
        assert_eq!(info.prefilter_clamp, 12.0);
    }

    #[test]
    fn the_environment_map_scan_carries_the_authored_bake_inputs() {
        let assets = [asset(serde_json::json!({
            "name":"sky","type":"environment_map","args":{
                "source":"studio.hdr",
                "prefilter_face_size": 256,
                "irradiance_face_size": 16,
                "prefilter_samples": 64,
                "prefilter_clamp": 4.5
            }
        }))];
        let info = scan_environment_map_source(&assets).expect("a file-backed map");
        assert_eq!(info.prefilter_face_size, 256);
        assert_eq!(info.irradiance_face_size, 16);
        assert_eq!(info.prefilter_samples, 64);
        assert_eq!(info.prefilter_clamp, 4.5);
    }

    // A procedural map has no file behind it, so there is nothing to watch --
    // even when a stale `source` is still authored alongside the generator.
    #[test]
    fn the_environment_map_scan_skips_a_procedural_map() {
        let generated = [asset(serde_json::json!({
            "name":"sky","type":"EnvironmentMap","args":{"generator":"sky"}
        }))];
        assert!(scan_environment_map_source(&generated).is_none());

        let both = [asset(serde_json::json!({
            "name":"sky","type":"EnvironmentMap","args":{"generator":"sky","source":"studio.hdr"}
        }))];
        assert!(scan_environment_map_source(&both).is_none());

        assert!(scan_environment_map_source(&[]).is_none());
        let no_source = [asset(
            serde_json::json!({"name":"sky","type":"EnvironmentMap","args":{}}),
        )];
        assert!(scan_environment_map_source(&no_source).is_none());
    }

    // The assembled world publishes both dev-only source catalogues, which is
    // what seeds the hot-reload watcher.
    #[test]
    fn the_assembled_world_publishes_the_watcher_source_catalogues() {
        let loaded =
            prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
        let world = world_from_loaded(loaded).unwrap();
        assert!(
            world
                .resource::<crate::resource::ColorLutSources>()
                .is_some_and(|s| s.0.is_none()),
            "a world with no LUT publishes an empty catalogue, not none at all"
        );
        assert!(
            world
                .resource::<crate::resource::EnvironmentMapSources>()
                .is_some()
        );
    }

    #[test]
    fn world_from_loaded_assembles_an_in_memory_world() {
        let loaded =
            prepare("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n").unwrap();
        let expanded = loaded.assets.len();
        let world = world_from_loaded(loaded).unwrap();
        // Every expanded asset landed as a component; nothing was dropped on
        // the way through compile + assembly.
        assert_eq!(world.component_count(), expanded);
        // Every named component's entity is indexed, so name references
        // resolve for any type, not just decomposed Props.
        let index = world
            .resource::<concinnity_core::ecs::EntityByName>()
            .expect("assembly publishes the name -> entity index");
        assert_eq!(index.0.len(), expanded);
    }

    #[test]
    fn build_world_from_str_assembles_an_in_memory_world() {
        // The string path is what the editor uses to seed an empty world; it
        // must produce the same assembled world as the file-backed path.
        let world =
            build_world_from_str("{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n")
                .unwrap();
        assert!(world.component_count() >= 1);
    }

    #[test]
    fn build_world_from_missing_path_is_not_found() {
        let err = build_world_from_path("/no/such/concinnity-world-xyz.jsonl")
            .expect_err("a missing world path must error");
        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
    }

    // Restores the previous working directory on drop, so a chdir-in-test does
    // not leak into other tests (they run in parallel threads of one process).
    struct CwdGuard(std::path::PathBuf);
    impl Drop for CwdGuard {
        fn drop(&mut self) {
            let _ = std::env::set_current_dir(&self.0);
        }
    }

    // Closes the session's project on the way out, for the same reason.
    struct ProjectGuard;
    impl Drop for ProjectGuard {
        fn drop(&mut self) {
            crate::project::close();
        }
    }

    // The in-memory build records each compiled Material's identity, dense by
    // the handle cook assigned it, so the live draw seam can resolve a
    // material an edit names against the running world.
    #[test]
    fn an_in_memory_build_records_its_material_identities() {
        let _guard = crate::test_support::lock();
        crate::test_support::isolate_state_dir();
        let world = build_world_from_str(concat!(
            "{\"name\":\"steel\",\"type\":\"Material\",\"args\":{\"roughness\":0.4}}\n",
            "{\"name\":\"glass\",\"type\":\"Material\",\"args\":{\"transparent\":true}}\n",
        ))
        .expect("a material-only world compiles");
        let names = world
            .resource::<crate::resource::MaterialNames>()
            .expect("the catalogue is installed");
        assert_eq!(
            names.0,
            vec![
                crate::ecs::asset_id::intern("steel").0,
                crate::ecs::asset_id::intern("glass").0,
            ],
            "declaration order is handle order"
        );
    }

    // build_world_to_disk compiles a world.jsonl and writes the blobs + lock to
    // the installed state tree, exactly as `cn build` does. Runs under the
    // process cwd lock in an isolated temp dir so it neither races other tests
    // nor pollutes the repo. Uses a payload-free world (PhysicsConfig) so it
    // needs no source files or shader compilation.
    #[test]
    fn build_world_to_disk_writes_blobs_and_lock() {
        let _guard = crate::test_support::lock();
        let dir = concinnity_testing::TempTree::new();
        let prev = std::env::current_dir().unwrap();
        std::env::set_current_dir(dir.path()).unwrap();
        let _cwd = CwdGuard(prev);
        // The lock file is written relative to the cwd; the blobs go into the
        // project's `data/`, and no project is open by default.
        crate::project::open(concinnity_host::store::paths::StateTree::at(dir.path()));
        let _project = ProjectGuard;

        std::fs::write(
            "world.jsonl",
            "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}\n",
        )
        .unwrap();

        build_world_to_disk("world.jsonl").expect("compile + write should succeed");

        // The primary blob (data/0) and the provenance lock are both written.
        assert!(
            concinnity_host::store::blob::primary_in(
                &crate::project::data_dir().expect("the test opened a project")
            )
            .exists(),
            "data/0 blob written"
        );
        assert!(
            dir.path().join("world-lock.json").exists(),
            "world-lock.json written"
        );
    }
}