awsm-renderer-scene 0.4.3

Lean canonical runtime scene schema (scene.toml + assets/) for the awsm-renderer player. Authoring types live in awsm-renderer-editor-protocol.
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
521
522
523
524
525
526
527
528
529
530
531
532
use uuid::Uuid;

use super::{
    assets::AssetId, camera::CameraConfig, collider::ColliderShape, curve::CurveDef,
    decal::DecalConfig, dynamic_material::MaterialInstance, instances::InstancesAlongCurveDef,
    light::LightConfig, line::LineDef, particle::ParticleEmitterDef, primitive::MeshRef,
    sprite::SpriteDef, transform::Trs,
};

#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct EditorNode {
    pub id: NodeId,
    pub name: String,
    #[serde(default)]
    pub transform: Trs,
    pub kind: NodeKind,
    #[serde(default)]
    pub locked: bool,
    /// Per-node visibility toggle from the editor's eye icon. When
    /// `false`, the renderer hides the node's meshes (Model), zeros its
    /// light intensity (Light), or skips its wireframe (Collision); the
    /// node itself stays in the tree and remains editable. Hiding a
    /// `Group` propagates to its descendants. Persisted across save/load.
    #[serde(default = "default_visible")]
    pub visible: bool,
    /// Marks this node as a prefab root. The game-player runtime skips
    /// prefab subtrees during scene-build and exposes them in a prefab
    /// table so per-game code can instantiate copies on demand. The flag
    /// is **root-only**: descendants are not implicitly prefabs and may
    /// themselves be marked, creating nested prefabs (each independently
    /// instantiable). The editor renders prefab subtrees verbatim so
    /// authors can see and edit them.
    #[serde(default)]
    pub prefab: bool,
    #[serde(default)]
    pub children: Vec<EditorNode>,
}

/// `serde(default)` for `bool` is `false`; visibility defaults to `true`,
/// so legacy `project.json` files (no `visible` field) load with every
/// node visible.
fn default_visible() -> bool {
    true
}

#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
#[derive(Eq, Hash, Copy)]
pub struct NodeId(pub Uuid);

// A `NodeId` is a UUID string on the wire — describe it as such for JSON Schema
// (the MCP server's typed tool params) rather than recursing into Uuid.
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for NodeId {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "NodeId".into()
    }
    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
        schemars::json_schema!({ "type": "string", "format": "uuid" })
    }
}

impl NodeId {
    pub fn new() -> Self {
        Self(Uuid::new_v4())
    }

    /// The stable all-zeros sentinel, meaning "no node referenced / unset". Unlike
    /// [`NodeId::default`] (which mints a fresh random id), this is a fixed value
    /// usable as a real "none" marker for optional node references (curve/source
    /// picks, etc.). Pair with [`NodeId::is_nil`].
    pub const fn nil() -> Self {
        Self(Uuid::nil())
    }

    /// True when this is the [`NodeId::nil`] sentinel (an unset reference).
    pub fn is_nil(&self) -> bool {
        self.0.is_nil()
    }

    /// Borrow the NodeId as a 16-byte slice. Used by the player → per-
    /// game-player FFI bridge: `&[u8]` is one of the few zero-config
    /// `wasm-bindgen` parameter types and we want to keep this hot path
    /// allocation-free on the caller side.
    pub fn as_bytes(&self) -> &[u8; 16] {
        self.0.as_bytes()
    }

    /// Counterpart of [`Self::as_bytes`] — recover a `NodeId` from a
    /// 16-byte slice received across an FFI / wasm-bindgen boundary.
    /// Errors if the slice isn't exactly 16 bytes.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, NodeIdFromBytesError> {
        Uuid::from_slice(bytes)
            .map(Self)
            .map_err(|_| NodeIdFromBytesError {
                got_len: bytes.len(),
            })
    }
}

#[derive(Debug, thiserror::Error)]
#[error("NodeId::from_bytes: expected 16 bytes, got {got_len}")]
pub struct NodeIdFromBytesError {
    pub got_len: usize,
}

impl Default for NodeId {
    fn default() -> Self {
        Self::new()
    }
}

impl std::fmt::Display for NodeId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Display::fmt(&self.0, f)
    }
}

/// Reference to a **skinned** glTF mesh: the imported source file + which node
/// (and optionally which primitive) inside it. The renderer's `populate_gltf`
/// builds the skinned mesh + skeleton at import; the bridge looks this node up
/// in the per-import template (keyed by `source`) to find the populate-baked
/// renderer mesh that deforms via the skeleton joints. There is no `MeshRef`/
/// captured-geometry side: skinned geometry lives only in the renderer skin
/// path until `drop_skinning` bakes its bind pose into a captured `Mesh`.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SkinnedMeshRef {
    /// The imported glTF/glb source file's `AssetId` (an `AssetSource::Filename`
    /// entry) — the key under which the bridge caches this import's node template.
    pub source: AssetId,
    /// Which node inside the referenced ORIGINAL glTF file carries this skinned
    /// mesh. Stable identity used by `drop_skinning` / export / the bind-pose
    /// `skinned_bake_cache` (all original-indexed). NOT used to address the rig
    /// glb — see [`Self::rig_node_index`].
    pub node_index: u32,
    /// That same node's index in the re-exported **clean rig glb**
    /// (`assets/<source>.glb`) — the DFS-flatten index `reexport_clean` assigns
    /// (which differs from `node_index` when the source isn't already DFS-ordered).
    /// This is what the MATERIALISER decodes the rig glb at to rebuild the skinned
    /// drawable (geometry + skin) from our-format, uniformly for first-show /
    /// reload / re-materialise. Captured at import from `node_flat_indices`;
    /// `#[serde(default)]` (0) for legacy projects saved before this field — those
    /// re-import to repopulate it. Shares the rig glb's single flat index space
    /// with [`SkinJoint::index`].
    #[serde(default)]
    pub rig_node_index: u32,
    /// Optional primitive index within that node (for a multi-material skinned
    /// node destructured into per-primitive children). `None` = the whole node.
    /// Same value for the original AND the rig glb (re-export preserves primitive
    /// order).
    #[serde(default)]
    pub primitive_index: Option<u32>,
    /// Bone correspondence for driving the rig from our clips: each skeleton
    /// joint's scene bone `NodeId` paired with its node index in the re-exported
    /// clean rig glb (`assets/<source>.glb`) — the index space the player's
    /// `populate_gltf` assigns when it loads that glb. Our clips' `Transform`
    /// tracks target bone `NodeId`s; the player maps those NodeIds → the rig's
    /// baked joint transforms through this table so animating a bone deforms the
    /// skin. Captured at skinned-glTF import; **empty** for legacy projects (the
    /// rig then poses at bind pose). Every skinned node of one import shares the
    /// same table (one rig glb, one flat index space).
    #[serde(default)]
    pub joints: Vec<SkinJoint>,
}

/// One skin-joint correspondence: a skeleton bone's scene [`NodeId`] paired with
/// its node index in the re-exported clean rig glb. See
/// [`SkinnedMeshRef::joints`].
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SkinJoint {
    /// The bone's scene node id (an animation `Transform` track targets this).
    pub node: NodeId,
    /// That bone's node index in the re-exported clean rig glb.
    pub index: u32,
}

/// Reference to a **pre-baked cluster-LOD ("nanite") mesh**: the imported source
/// asset whose baked cluster DAG (`assets/<source>.clusters.bin`) + base geometry
/// (`assets/<source>.glb`) the renderer streams through the bounded cluster
/// pipeline. Like [`SkinnedMeshRef`], this is a deliberately **view-only**,
/// renderer-managed geometry category — it is NOT a `MeshDef`/`ModifierStack`, so
/// it carries no editable stack/overrides. It exists so a large mesh can be brought
/// into the editor and rendered as nanite (bounded draw + VRAM) via the SAME path
/// the player uses, without the dense visibility-geometry explode that would
/// otherwise crash on a multi-million-triangle mesh.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ClusterMeshRef {
    /// The imported source asset's `AssetId`. Its baked cluster DAG side file
    /// (`assets/<source>.clusters.bin`) is loaded + materialized by the cluster
    /// pipeline (`scene-loader::materialize_cluster_mesh`).
    pub source: AssetId,
}

#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
// `Mesh` (the common, dominant variant) inlines a `MaterialInstance`, which makes
// it much larger than the leaf variants. Boxing it would penalise the hot path to
// shrink the rare ones, so accept the size spread (same call as `AssetSource`).
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[allow(clippy::large_enum_variant)]
pub enum NodeKind {
    Group,
    Light(LightConfig),
    Collider(ColliderShape),
    Camera(CameraConfig),
    /// The sole procedural-geometry node. Backed by an `AssetSource::Mesh`
    /// ([`super::material::MeshDef`]) referenced by `MeshRef`; the `MeshDef`
    /// always carries a `ModifierStack { base, modifiers }`, so a box/sphere/…,
    /// a sweep, a lathe, an SDF, or a raw-edited capture are all the same node
    /// kind — only the stack `base` differs. Multiple nodes can share one mesh
    /// asset.
    Mesh {
        mesh: MeshRef,
        /// The node's single material assignment. `None` means *unassigned*
        /// and renders flat magenta (the missing-material sentinel).
        #[serde(default)]
        material: Option<MaterialInstance>,
        #[serde(default)]
        shadow: MeshShadowConfig,
        /// Per-mesh LOD opt-out (default on). Authored in the editable project,
        /// consumed by the export-time LOD bake. See [`MeshLodConfig`].
        #[serde(default)]
        lod: MeshLodConfig,
    },
    /// A **skinned** mesh imported from a glTF — a deliberate *second* geometry
    /// category, distinct from `Mesh`. It is **not** a `MeshDef`/`ModifierStack`
    /// (no base/edits/overrides) and so **not editable**: per-vertex skin
    /// weights can't survive topology-changing edits. It is rendered + deformed
    /// by the renderer's existing glTF skin path (joints driven by the editor's
    /// mirror bones + imported animation clips), NOT the captured-mesh pipeline.
    /// `drop_skinning` is the explicit, terminal bridge to editing: it bakes the
    /// bind-pose geometry into a captured `Mesh{ stack:{base:Captured} }` and
    /// swaps the node to `NodeKind::Mesh`.
    SkinnedMesh {
        skin: SkinnedMeshRef,
        /// Single material assignment (same one-material-per-node model as
        /// `Mesh`); `None` renders flat magenta.
        #[serde(default)]
        material: Option<MaterialInstance>,
        #[serde(default)]
        shadow: MeshShadowConfig,
        /// Per-mesh LOD opt-out (default on). See [`MeshLodConfig`].
        #[serde(default)]
        lod: MeshLodConfig,
    },
    /// A **pre-baked cluster-LOD ("nanite") mesh** — a third, deliberately
    /// **view-only** geometry category (like [`Self::SkinnedMesh`], not editable):
    /// no `MeshDef`/stack, rendered + cut by the renderer's cluster pipeline from a
    /// baked `assets/<source>.clusters.bin`. Brought in via the offline `awsm-renderer-lod-bake`
    /// pre-bake so a huge mesh views as nanite in-editor (bounded) without re-baking
    /// or a dense explode. No `lod` toggle — it IS the LOD.
    ClusterMesh {
        cluster: ClusterMeshRef,
        /// Single material assignment; `None` renders flat magenta.
        #[serde(default)]
        material: Option<MaterialInstance>,
        #[serde(default)]
        shadow: MeshShadowConfig,
    },
    /// Catmull-Rom curve (control points + closed + tension). Emits no renderer
    /// node directly; consumed by sweep / instance / camera nodes.
    Curve(CurveDef),
    /// Place copies of a source node along a curve.
    InstancesAlongCurve(InstancesAlongCurveDef),
    /// Authored polyline (debug-draw / neon rails / curve handles).
    Line(LineDef),
    /// Camera-facing or world-aligned textured quad.
    Sprite(SpriteDef),
    /// CPU particle emitter.
    ParticleEmitter(ParticleEmitterDef),
    /// Projection decal. The node's
    /// transform supplies the oriented unit-cube volume; the
    /// renderer projects the configured texture down the local -Z
    /// axis onto whatever opaque geometry sits inside.
    Decal(DecalConfig),
}

/// Per-mesh shadow flags. Sprite, line, and particle nodes do NOT
/// carry this — they are hard-coded to no-cast / no-receive in v1.
///
/// Defaults to both `cast` and `receive` true. Transparent materials
/// should override this to `TRANSPARENT_DEFAULT` (both off) — the
/// renderer bridge or scene loader is responsible for that
/// reinterpretation since the schema doesn't know the resolved
/// material's alpha mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MeshShadowConfig {
    /// Whether the mesh appears in the shadow-generation pass.
    #[serde(default = "default_true_msc")]
    pub cast: bool,
    /// Whether the mesh's shaded pixels darken under shadow.
    #[serde(default = "default_true_msc")]
    pub receive: bool,
}

impl Default for MeshShadowConfig {
    fn default() -> Self {
        Self {
            cast: true,
            receive: true,
        }
    }
}

impl MeshShadowConfig {
    /// Conservative default for transparent materials.
    pub const TRANSPARENT_DEFAULT: Self = Self {
        cast: false,
        receive: false,
    };
}

fn default_true_msc() -> bool {
    true
}

/// Per-mesh LOD opt-**out** flag. LOD is the norm for a general game renderer,
/// so this defaults **on**; authors flip it off for hero assets where any
/// simplification is unacceptable, already-low-poly meshes (bake cost, no
/// benefit), or HUD/UI meshes.
///
/// Authored in the editable project (persists in `project.toml` like
/// [`MeshShadowConfig`]) and consumed by the **export-time** LOD bake — it has
/// no meaning at import. One `enabled: bool` to start; grows later to carry
/// params (target ratios, level count, error threshold).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MeshLodConfig {
    /// Whether the export bake generates simplified LOD levels for this mesh.
    #[serde(default = "default_true_mlc")]
    pub enabled: bool,
}

impl Default for MeshLodConfig {
    fn default() -> Self {
        Self { enabled: true }
    }
}

fn default_true_mlc() -> bool {
    true
}

impl NodeKind {
    pub fn label(&self) -> &'static str {
        match self {
            Self::Group => "group",
            Self::Light(_) => "light",
            Self::Collider(_) => "collider",
            Self::Camera(_) => "camera",
            Self::Mesh { .. } => "mesh",
            Self::SkinnedMesh { .. } => "skinned_mesh",
            Self::ClusterMesh { .. } => "cluster_mesh",
            Self::Curve(_) => "curve",
            Self::InstancesAlongCurve(_) => "instances",
            Self::Line(_) => "line",
            Self::Sprite(_) => "sprite",
            Self::ParticleEmitter(_) => "particle",
            Self::Decal(_) => "decal",
        }
    }

    /// Returns this node's mesh shadow config if the variant carries
    /// one; returns `None` for non-renderable nodes (groups, lights,
    /// cameras, curves, lines, sprites, particles).
    pub fn mesh_shadow(&self) -> Option<&MeshShadowConfig> {
        match self {
            Self::Mesh { shadow, .. } => Some(shadow),
            Self::SkinnedMesh { shadow, .. } => Some(shadow),
            Self::InstancesAlongCurve(d) => Some(&d.shadow),
            _ => None,
        }
    }

    /// Mutable variant of [`Self::mesh_shadow`].
    pub fn mesh_shadow_mut(&mut self) -> Option<&mut MeshShadowConfig> {
        match self {
            Self::Mesh { shadow, .. } => Some(shadow),
            Self::SkinnedMesh { shadow, .. } => Some(shadow),
            Self::InstancesAlongCurve(d) => Some(&mut d.shadow),
            _ => None,
        }
    }

    /// Returns this node's mesh LOD config if the variant carries one; `None`
    /// for non-mesh kinds. Mirrors [`Self::mesh_shadow`].
    pub fn mesh_lod(&self) -> Option<&MeshLodConfig> {
        match self {
            Self::Mesh { lod, .. } => Some(lod),
            Self::SkinnedMesh { lod, .. } => Some(lod),
            Self::InstancesAlongCurve(d) => Some(&d.lod),
            _ => None,
        }
    }

    /// Mutable variant of [`Self::mesh_lod`].
    pub fn mesh_lod_mut(&mut self) -> Option<&mut MeshLodConfig> {
        match self {
            Self::Mesh { lod, .. } => Some(lod),
            Self::SkinnedMesh { lod, .. } => Some(lod),
            Self::InstancesAlongCurve(d) => Some(&mut d.lod),
            _ => None,
        }
    }
}

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

    /// A `SkinnedMesh` node with a joint table round-trips through TOML — the
    /// bundle's `scene.toml` format. `Vec<SkinJoint>` must serialize as an array
    /// of tables (NOT a `Vec<(NodeId, u32)>` tuple, which would be a mixed-type
    /// TOML array and fail to parse).
    #[test]
    fn skinned_mesh_joints_toml_round_trip() {
        let node = EditorNode {
            id: NodeId::new(),
            name: "Cylinder".into(),
            transform: Trs::default(),
            kind: NodeKind::SkinnedMesh {
                skin: SkinnedMeshRef {
                    source: AssetId::new(),
                    node_index: 2,
                    rig_node_index: 2,
                    primitive_index: None,
                    joints: vec![
                        SkinJoint {
                            node: NodeId::new(),
                            index: 3,
                        },
                        SkinJoint {
                            node: NodeId::new(),
                            index: 4,
                        },
                    ],
                },
                material: None,
                shadow: MeshShadowConfig::default(),
                lod: MeshLodConfig::default(),
            },
            locked: false,
            visible: true,
            prefab: false,
            children: Vec::new(),
        };

        let text = toml::to_string(&node).expect("serialize");
        let back: EditorNode = toml::from_str(&text).expect("deserialize");
        assert_eq!(node, back);
        match back.kind {
            NodeKind::SkinnedMesh { skin, .. } => {
                assert_eq!(skin.joints.len(), 2);
                assert_eq!(skin.joints[0].index, 3);
                assert_eq!(skin.joints[1].index, 4);
            }
            other => panic!("expected SkinnedMesh, got {other:?}"),
        }
    }

    /// A legacy `SkinnedMeshRef` with no `joints` key deserializes to an empty
    /// table (the `#[serde(default)]` path → bind-pose, no animation binding).
    #[test]
    fn skinned_mesh_ref_joints_default_empty() {
        let toml = r#"
            source = "00000000-0000-0000-0000-000000000000"
            node_index = 1
        "#;
        let skin: SkinnedMeshRef = toml::from_str(toml).expect("deserialize legacy");
        assert!(skin.joints.is_empty());
        assert_eq!(skin.node_index, 1);
    }

    /// A `MeshLodConfig` round-trips through TOML, and a legacy `Mesh` node with
    /// no `lod` table defaults to `enabled = true` (LOD is opt-out, default on).
    /// This is the backwards-compat guarantee for projects saved before the LOD
    /// toggle existed.
    #[test]
    fn mesh_lod_config_default_and_round_trip() {
        // Explicit opt-out survives a round-trip.
        let off = MeshLodConfig { enabled: false };
        let text = toml::to_string(&off).expect("serialize");
        let back: MeshLodConfig = toml::from_str(&text).expect("deserialize");
        assert_eq!(off, back);

        // A legacy mesh kind TOML with no `lod` table → enabled defaults true.
        let legacy = r#"
            [mesh]
            mesh = "00000000-0000-0000-0000-000000000000"
        "#;
        let kind: NodeKind = toml::from_str(legacy).expect("deserialize legacy mesh kind");
        assert_eq!(
            kind.mesh_lod().copied(),
            Some(MeshLodConfig { enabled: true }),
            "absent `lod` must default to enabled (opt-out, default on)"
        );
    }
}

#[cfg(all(test, feature = "schemars"))]
mod schema_tests {
    use crate::NodeKind;

    // §3: the generated NodeKind schema must expose every variant's real field
    // shape (transitively, via $defs) — that's what makes it machine-readable for
    // authoring a fresh kind without an existing instance to copy.
    #[test]
    fn node_kind_schema_lists_variant_fields() {
        let json = serde_json::to_string(&schemars::schema_for!(NodeKind)).unwrap();
        for needle in [
            "ParticleEmitterDef",
            "spawn_rate", // a ParticleEmitterDef field
            "CameraConfig",
            "projection",       // a CameraConfig field
            "LightConfig",      // a NodeKind::Light sub-type
            "MaterialInstance", // inlined by NodeKind::Mesh
            "AnisotropyExt",    // a macro-generated KHR PBR extension, deep in the tree
        ] {
            assert!(json.contains(needle), "NodeKind schema missing `{needle}`");
        }
    }
}