concinnity-cook 0.19.16

Authored world model, validation, and the asset cook pipeline that bakes a Concinnity world into a blob
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
//! Per-type dispatch for the compile pass: which `BuildAsset` impl compiles a
//! payload, and which inputs that compile reads.

use crate::registry::RegisteredType;

// Dispatch payload compilation by RegisteredType. Every variant listed below
// has a `BuildAsset` impl in its asset file; the body of each call here is a
// one-liner that delegates to the trait. Adding a new compiled component
// means:
//   1. impl `Component` with `PAYLOAD = AssetPayload::Compiled` for the type
//   2. impl `BuildAsset` for the type in its asset file
//   3. Add one match arm here
pub(super) fn compile_by_type(
    ct: RegisteredType,
    args: &serde_json::Value,
    ctx: &crate::asset::BuildCtx<'_>,
) -> std::io::Result<Vec<u8>> {
    use crate::asset::BuildAsset;
    use crate::components::{File, ProceduralMesh, Room, SdfVolume, Shader, VoxelChunk};
    match ct {
        RegisteredType::ProceduralMesh => {
            <ProceduralMesh as BuildAsset>::compile_payload(args, ctx)
        }
        RegisteredType::VoxelChunk => <VoxelChunk as BuildAsset>::compile_payload(args, ctx),
        RegisteredType::File => <File as BuildAsset>::compile_payload(args, ctx),
        RegisteredType::Room => <Room as BuildAsset>::compile_payload(args, ctx),
        RegisteredType::Shader => <Shader as BuildAsset>::compile_payload(args, ctx),
        RegisteredType::SdfVolume => <SdfVolume as BuildAsset>::compile_payload(args, ctx),
        other => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
                "Asset '{}' is marked Compiled but has no BuildAsset impl (RegisteredType {:?})",
                ctx.name, other
            ),
        )),
    }
}

// Dispatch each asset's payload-cache contribution by RegisteredType. Mirrors
// `compile_by_type` so the cache layer can fold a hash of every input the
// compile reads into its payload key. Types with no `BuildAsset` impl, or with
// the trait default, contribute nothing.
//
// `source_files` and `TARGET_DEPENDENT` are read together per arm: a new
// asset whose payload differs per backend cannot pick up one without the
// other.
pub(super) fn cache_inputs_by_type(
    ct: RegisteredType,
    args: &serde_json::Value,
    ctx: &crate::asset::BuildCtx<'_>,
) -> crate::asset::CacheInputs {
    use crate::asset::{BuildAsset, CacheInputs};
    use crate::components::{File, ProceduralMesh, Room, SdfVolume, Shader, VoxelChunk};
    macro_rules! inputs {
        ($t:ty) => {
            CacheInputs {
                sources: <$t as BuildAsset>::source_files(args, ctx),
                target_dependent: <$t as BuildAsset>::TARGET_DEPENDENT,
            }
        };
    }
    match ct {
        RegisteredType::ProceduralMesh => inputs!(ProceduralMesh),
        RegisteredType::VoxelChunk => inputs!(VoxelChunk),
        RegisteredType::File => inputs!(File),
        RegisteredType::Room => inputs!(Room),
        RegisteredType::Shader => inputs!(Shader),
        RegisteredType::SdfVolume => inputs!(SdfVolume),
        _ => CacheInputs::extra(Vec::new()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline::fixtures::wja;
    use crate::resource_handles::ResourceAssetCompile;

    #[test]
    fn voxel_chunk_payload_compiles_end_to_end() {
        let world = r#"{"name":"scene_shader","type":"Shader","args":{"fragment":"x.slang"}}
{"name":"air","type":"BlockType","args":{"solid":false}}
{"name":"stone","type":"BlockType","args":{"uv_min":[0,0],"uv_max":[1,1]}}
{"name":"chunk","type":"VoxelChunk","args":{"palette":["air","stone"],"dim":[2,1,1],"blocks":[1,1]}}
"#;
        // We can't easily compile shaders here, so go through the geometry
        // entry point directly to verify the voxel chunk produces a non-empty
        // payload for two adjacent solid blocks (10 faces after interior cull).
        let chunk_args = serde_json::json!({
            "palette": ["air", "stone"],
            "dim": [2, 1, 1],
            "blocks": [1, 1],
            "block_size": 1.0,
        });
        let bt = |name: &str| -> Option<serde_json::Value> {
            match name {
                "air" => Some(serde_json::json!({"solid": false})),
                "stone" => Some(serde_json::json!({"uv_min":[0,0],"uv_max":[1,1]})),
                _ => None,
            }
        };
        let bytes = crate::compile::geometry::compile_voxel_chunk_payload(&chunk_args, bt).unwrap();
        assert!(!bytes.is_empty());
        let _ = world; // keeps the inline jsonl reference for documentation
    }

    fn ctx() -> crate::asset::BuildCtx<'static> {
        crate::asset::BuildCtx {
            platform: concinnity_core::platform::Platform::Metal,
            name: "test",
            assets_dir: None,
            artifacts_dir: None,
            all_assets: &[],
        }
    }

    #[test]
    fn compile_by_type_without_build_impl_errors() {
        let ct = RegisteredType::parse("Prop").expect("Prop is a registered component");
        let err = compile_by_type(ct, &serde_json::json!({}), &ctx())
            .expect_err("Prop has no BuildAsset impl");
        assert!(err.to_string().contains("no BuildAsset impl"), "got: {err}");
    }

    #[test]
    fn cache_inputs_by_type_defaults_to_empty_extras() {
        use crate::asset::SourceFiles;
        let ct = RegisteredType::parse("Prop").expect("Prop is a registered component");
        let inputs = cache_inputs_by_type(ct, &serde_json::json!({}), &ctx());
        assert_eq!(inputs.sources, SourceFiles::Extra(Vec::new()));
        assert!(!inputs.target_dependent);
    }

    // The arms that take the trait default report no inputs of their own: every
    // file they read is named by an args string, which the payload cache's
    // generic walk already hashes.
    #[test]
    fn cache_inputs_by_type_covers_the_args_walk_arms() {
        use crate::asset::SourceFiles;
        for name in ["ProceduralMesh", "VoxelChunk", "File", "Room"] {
            let inputs = cache_inputs_by_type(ct(name), &serde_json::json!({}), &ctx());
            assert_eq!(
                inputs.sources,
                SourceFiles::Extra(Vec::new()),
                "{name} must not narrow the generic args walk"
            );
            assert!(
                !inputs.target_dependent,
                "{name} compiles the same everywhere"
            );
        }
    }

    // AudioClip compiles through `RegisteredType` now, not `compile_by_type`
    // (it left the component registry). Its source-less error still surfaces, and
    // its source file is folded into the payload cache key.
    #[test]
    fn resource_asset_types_compile_audio_clip_texture_cubemap_env_lut_and_font() {
        use crate::registry::RegisteredType;
        let rt = RegisteredType::parse("AudioClip").expect("AudioClip is a resource asset");
        let err = rt
            .compile_payload(&serde_json::json!({}), None)
            .expect_err("a source-less AudioClip must fail to compile");
        assert!(err.to_string().contains("missing 'source'"), "got: {err}");
        assert_eq!(
            rt.source_files(&serde_json::json!({"source": "a.wav"}), None),
            vec!["a.wav".to_string()]
        );
        assert!(rt.source_files(&serde_json::json!({}), None).is_empty());

        // Texture is also a resource asset (it left the component registry). A
        // procedural texture compiles a non-empty payload, and a file-backed one
        // folds its source into the payload cache key.
        let tex = RegisteredType::parse("Texture").expect("Texture is a resource asset");
        let bytes = tex
            .compile_payload(
                &serde_json::json!({"generator": "checker", "resolution": 32}),
                None,
            )
            .expect("a procedural texture compiles");
        assert!(!bytes.is_empty());
        assert_eq!(
            tex.source_files(&serde_json::json!({"source": "a.png"}), None),
            vec!["a.png".to_string()]
        );

        // CubemapTexture is a resource asset too. Source-less args fail, and its
        // `.hdr` source folds into the payload cache key.
        let cube =
            RegisteredType::parse("CubemapTexture").expect("CubemapTexture is a resource asset");
        let err = cube
            .compile_payload(&serde_json::json!({}), None)
            .expect_err("a source-less CubemapTexture must fail to compile");
        assert!(
            err.to_string().contains("requires a `source` path"),
            "got: {err}"
        );
        assert_eq!(
            cube.source_files(&serde_json::json!({"source": "c.hdr"}), None),
            vec!["c.hdr".to_string()]
        );

        // EnvironmentMap and ColorLut are resource assets too. Both surface their
        // source-less error through `RegisteredType::compile_payload`, and fold
        // their `source` into the payload cache key.
        let env =
            RegisteredType::parse("EnvironmentMap").expect("EnvironmentMap is a resource asset");
        let err = env
            .compile_payload(&serde_json::json!({}), None)
            .expect_err("a source-less EnvironmentMap must fail to compile");
        assert!(
            err.to_string()
                .contains("requires either `source` or `generator`"),
            "got: {err}"
        );
        assert_eq!(
            env.source_files(&serde_json::json!({"source": "e.hdr"}), None),
            vec!["e.hdr".to_string()]
        );

        let lut = RegisteredType::parse("ColorLut").expect("ColorLut is a resource asset");
        let err = lut
            .compile_payload(&serde_json::json!({}), None)
            .expect_err("a source-less ColorLut must fail to compile");
        assert!(
            err.to_string().contains("requires a `source` path"),
            "got: {err}"
        );
        assert_eq!(
            lut.source_files(&serde_json::json!({"source": "l.cube"}), None),
            vec!["l.cube".to_string()]
        );

        // Font is a resource asset. The built-in font (empty `path`) compiles a
        // non-empty atlas, and a file-backed font folds its `path` (not `source`)
        // into the payload cache key.
        let font = RegisteredType::parse("Font").expect("Font is a resource asset");
        let bytes = font
            .compile_payload(&serde_json::json!({"size_px": 20}), None)
            .expect("the built-in font compiles");
        assert!(!bytes.is_empty());
        assert_eq!(
            font.source_files(&serde_json::json!({"path": "f.ttf"}), None),
            vec!["f.ttf".to_string()]
        );
        assert!(
            font.source_files(&serde_json::json!({"source": "x.ttf"}), None)
                .is_empty()
        );
    }

    // A mesh whose source is a text `.gltf` reads sibling files the args never
    // name; `source_files` must report them so an edited external buffer or
    // image busts the payload cache.
    #[test]
    fn gltf_sources_fold_referenced_sibling_files_into_source_files() {
        use crate::registry::RegisteredType;

        let dir = tempfile::tempdir().unwrap();
        let json = serde_json::json!({
            "asset": {"version": "2.0"},
            "buffers": [{"byteLength": 4, "uri": "geo.bin"}],
            "images": [{"uri": "albedo.png"}]
        });
        let gltf_path = dir.path().join("tri.gltf");
        std::fs::write(&gltf_path, serde_json::to_vec(&json).unwrap()).unwrap();
        let src = gltf_path.to_str().unwrap().to_string();

        for rt in [RegisteredType::Mesh, RegisteredType::SkinnedMesh] {
            let files = rt.source_files(&serde_json::json!({"source": src}), None);
            assert_eq!(files.len(), 3, "{rt:?}: {files:?}");
            assert_eq!(files[0], src);
            assert!(files.iter().any(|f| f.ends_with("geo.bin")), "{files:?}");
            assert!(files.iter().any(|f| f.ends_with("albedo.png")), "{files:?}");
        }

        // A `.glb` source reports only itself.
        let glb =
            RegisteredType::Mesh.source_files(&serde_json::json!({"source": "scene.glb"}), None);
        assert_eq!(glb, vec!["scene.glb".to_string()]);
    }

    // Dispatch coverage: compile_by_type / source_files_by_type route each
    // compiled RegisteredType to its asset_impls wrapper.

    fn ct(name: &str) -> RegisteredType {
        RegisteredType::parse(name).unwrap_or_else(|| panic!("{name} is a registered component"))
    }

    // Arms whose outcome is deterministic from inline args alone: a valid
    // minimal payload for the ones that need no source file, and the expected
    // error for the ones that require a source but got none.
    #[test]
    fn compile_by_type_dispatches_deterministic_arms() {
        // Mesh is a resource asset now: it compiles through
        // `RegisteredType::compile_payload`, not the RegisteredType dispatch.
        let mesh_bytes = crate::registry::RegisteredType::Mesh
            .compile_payload(
                &serde_json::json!({"generator": "box", "half_extents": [1, 1, 1]}),
                None,
            )
            .expect("Mesh compiles through the resource path");
        assert!(!mesh_bytes.is_empty());

        let ok_cases: &[(&str, serde_json::Value)] = &[
            (
                "ProceduralMesh",
                serde_json::json!({"generator": "sphere", "radius": 1.0}),
            ),
            ("Room", serde_json::json!({})),
        ];
        for case in ok_cases {
            let name = case.0;
            let args = &case.1;
            let bytes = compile_by_type(ct(name), args, &ctx())
                .unwrap_or_else(|e| panic!("{name} should compile: {e}"));
            assert!(!bytes.is_empty(), "{name} payload should be non-empty");
        }

        let err_cases: &[(&str, serde_json::Value, &str)] =
            &[("File", serde_json::json!({}), "unsupported File kind")];
        for case in err_cases {
            let name = case.0;
            let args = &case.1;
            let needle = case.2;
            let err = compile_by_type(ct(name), args, &ctx())
                .expect_err(&format!("{name} with empty args should error"));
            assert!(
                err.to_string().contains(needle),
                "{name} error should mention '{needle}', got: {err}"
            );
        }
    }

    // The File wrapper decodes an OBJ mesh source into a non-empty payload.
    #[test]
    fn compile_by_type_file_compiles_an_obj_source() {
        let dir = tempfile::tempdir().expect("tempdir");
        let obj = dir.path().join("tri.obj");
        std::fs::write(&obj, "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n").expect("write obj");
        let args = serde_json::json!({"path": obj.to_str().unwrap(), "kind": "obj"});
        let bytes = compile_by_type(ct("File"), &args, &ctx()).expect("obj compiles");
        assert!(!bytes.is_empty());
    }

    // The SkinnedMesh resource compiler deserialises args + an optional
    // skeleton, then bakes geometry: one vertex is enough for a payload, no
    // vertices and a malformed skeleton are the two error arms. Its baked
    // data form carries the interned name id and drops the geometry.
    #[test]
    fn skinned_mesh_resource_compile_paths() {
        use crate::registry::RegisteredType;
        let rt = RegisteredType::SkinnedMesh;

        let ok = serde_json::json!({"vertices": [{"pos": [0.0, 0.0, 0.0]}], "indices": []});
        let bytes = rt.compile_payload(&ok, None).expect("skinned compiles");
        assert!(!bytes.is_empty());

        let no_verts = rt
            .compile_payload(&serde_json::json!({}), None)
            .expect_err("no vertices");
        assert!(
            no_verts.to_string().contains("at least one vertex"),
            "got: {no_verts}"
        );

        let bad_skeleton = rt
            .compile_payload(
                &serde_json::json!({"vertices": [{"pos": [0.0, 0.0, 0.0]}], "skeleton": 5}),
                None,
            )
            .expect_err("malformed skeleton");
        assert!(
            bad_skeleton.to_string().contains("invalid skeleton args"),
            "got: {bad_skeleton}"
        );

        // The baked data tuple: name id first, then the clamped mesh with its
        // geometry cleared.
        crate::ecs::asset_id::reset_interner();
        let name_id = crate::ecs::asset_id::intern("hero");
        let data = rt
            .compile_data(
                "hero",
                &serde_json::json!({
                    "vertices": [{"pos": [0.0, 0.0, 0.0]}],
                    "scale": [0.0, 0.0, 0.0],
                    "max_instances": 999999,
                    "capsule": {"half_height": 0.6, "radius": 0.2},
                }),
            )
            .expect("data bakes")
            .expect("skinned mesh carries baked data");
        let (baked_name, sm): (u32, crate::components::SkinnedMesh) =
            postcard::from_bytes(&data).unwrap();
        assert_eq!(baked_name, name_id.0);
        assert_eq!(sm.scale, [1.0, 1.0, 1.0], "zero scale clamps to unit");
        assert_eq!(sm.max_instances, 4096, "reserve caps at 4096");
        assert!(sm.vertices.is_empty(), "geometry rides the payload");
        assert!(sm.capsule.is_some());
    }

    // The VoxelChunk wrapper resolves its palette from sibling BlockType assets
    // in the build context.
    #[test]
    fn compile_by_type_voxel_chunk_resolves_palette_from_ctx() {
        let blocks = vec![
            wja("air", "BlockType", serde_json::json!({"solid": false})),
            wja(
                "stone",
                "BlockType",
                serde_json::json!({"uv_min": [0, 0], "uv_max": [1, 1]}),
            ),
        ];
        let vctx = crate::asset::BuildCtx {
            platform: concinnity_core::platform::Platform::Metal,
            name: "chunk",
            assets_dir: None,
            artifacts_dir: None,
            all_assets: &blocks,
        };
        let args = serde_json::json!({
            "palette": ["air", "stone"],
            "dim": [2, 1, 1],
            "blocks": [1, 1],
            "block_size": 1.0,
        });
        let bytes = compile_by_type(ct("VoxelChunk"), &args, &vctx).expect("voxel compiles");
        assert!(!bytes.is_empty());
    }

    // The SdfVolume wrapper compiles the world's distance field through slangc,
    // so a field that does not compile fails the build here rather than a
    // renderer's init. A missing source is a hard error, not an empty payload.
    #[test]
    fn compile_by_type_sdf_volume_compiles_the_declared_field() {
        use concinnity_core::components::sdf_programs::SdfPrograms;
        if !crate::slangc_gate::slangc_available() {
            return;
        }
        let dir = tempfile::tempdir().expect("tempdir");
        let field = dir.path().join("blob.slang");
        std::fs::write(
            &field,
            "float map(float3 p, SdfParams q, float t) { return sdSphere(p, 0.5); }\n\
             SdfSurface shade(float3 p, float3 n, SdfParams q, float t, float2 uv) {\n\
                 SdfSurface s; s.albedo = float3(1.0, 1.0, 1.0); s.roughness = 0.5;\n\
                 s.metallic = 0.0; s.emissive = float3(0.0, 0.0, 0.0);\n\
                 s.transmitted = float3(0.0, 0.0, 0.0); return s; }\n",
        )
        .expect("write field");
        let args = serde_json::json!({ "fragment_shader": field.to_str().unwrap() });

        let bytes = compile_by_type(ct("SdfVolume"), &args, &ctx()).expect("sdf compiles");
        let programs: SdfPrograms = postcard::from_bytes(&bytes).expect("payload decodes");
        // A surface volume that casts no shadow compiles its own pair only.
        // How those two entries are grouped into artifacts is the backend's
        // business, so the assertion is over the entries, not the artifacts.
        let mut entries: Vec<&str> = programs
            .programs
            .iter()
            .flat_map(|p| p.entries.iter().map(String::as_str))
            .collect();
        entries.sort_unstable();
        assert_eq!(entries, ["raymarch_fragment", "raymarch_vertex"]);
        assert!(programs.programs.iter().all(|p| !p.artifact.is_empty()));
        assert!(programs.field.contains("float map("));

        let err = compile_by_type(ct("SdfVolume"), &serde_json::json!({}), &ctx())
            .expect_err("no distance field");
        assert!(
            err.to_string().contains("no distance field declared"),
            "got: {err}"
        );
    }

    // The Shader wrapper's non-compiling arm: a declaration with no fragment
    // file is a hard error before any compiler runs, so the test stays
    // backend-agnostic.
    #[test]
    fn compile_by_type_shader_without_a_fragment_file_does_not_shell_out() {
        let err = compile_by_type(ct("Shader"), &serde_json::json!({}), &ctx())
            .expect_err("no fragment file");
        assert!(
            err.to_string().contains("no `fragment` file declared"),
            "got: {err}"
        );
    }

    // cache_inputs_by_type routes to the two overriding wrappers. Both report
    // `Only` -- the complete input set the current backend reads -- so an edit
    // to a sibling backend's shader leaves this backend's payload cached.
    #[test]
    fn cache_inputs_by_type_covers_the_overriding_wrappers() {
        use crate::asset::SourceFiles;
        let dir = tempfile::tempdir().expect("tempdir");
        let shader = dir.path().join("blob.slang");
        std::fs::write(&shader, b"x").expect("write field");
        let path = shader.to_str().unwrap();

        // SdfVolume reports the one declared field, and compiles it to a
        // different artifact per backend, so the target is an input too.
        let sdf_args = serde_json::json!({ "fragment_shader": path });
        let sdf = cache_inputs_by_type(ct("SdfVolume"), &sdf_args, &ctx());
        assert_eq!(sdf.sources, SourceFiles::Only(vec![path.to_string()]));
        assert!(sdf.target_dependent);
        assert_eq!(
            cache_inputs_by_type(ct("SdfVolume"), &serde_json::json!({}), &ctx()).sources,
            SourceFiles::Only(Vec::new())
        );

        // Shader compiles its files per backend, so the target is an input.
        let no_source = cache_inputs_by_type(ct("Shader"), &serde_json::json!({}), &ctx());
        assert_eq!(no_source.sources, SourceFiles::Only(Vec::new()));
        assert!(no_source.target_dependent);
    }
}