Skip to main content

dreamwell_engine/
loom.rs

1// Loom — Dreamwell Render Pipeline scene orchestrator and bootstrap service.
2//
3// The Loom is the engine-native bridge between authored scenes (editor, .dream
4// files, Waymark packs) and the Dreamwell Render Pipeline runtime. It "weaves"
5// scene data into a Fabric-ready format by sorting, validating, and ordering
6// all scene entities, PropertyTags, lights, physics bodies, emitters, and
7// rendering configuration into the pipeline-preferred execution order.
8//
9// Dreamwell Architecture (not Unity):
10//
11//   SceneAsset    = DreamSceneV1    (authored, serializable, versioned)
12//   Entity        = u64 ID          (plain handle, not a polymorphic object bag)
13//   Component     = ComponentSlot   (data-only, behavior lives in systems)
14//   Template      = DreamwellPackV1 (Waymark content packs)
15//   RuntimeWorld  = GameObjectScene (instantiated from SceneAsset)
16//   Schedule      = BootstrapStage  (explicit ordered pipeline, not hidden callbacks)
17//   Session       = WovenScene      (validated scene manifest for the Fabric)
18//   Resource      = Owned by app    (PhysicsWorld, AudioSystem — not globals)
19//   Service       = DreamFabric     (GPU orchestrator) + Loom (CPU orchestrator)
20//
21// The Loom does NOT hide lifecycle magic. It runs an explicit, ordered pipeline:
22//
23//   Stage 0: Validate    — reality check transforms, references, tags
24//   Stage 1: Extract     — lights, physics bodies, emitters, tagged entities
25//   Stage 2: Configure   — render config, observer context, post-processing
26//   Stage 3: Sort        — pipeline-preferred order (meshes → lights → empty)
27//   Stage 4: Manifest    — WovenScene output ready for Fabric consumption
28//
29// Clean Compute: The Loom runs at scene load time (not per-frame). Its output
30// is a pre-sorted, validated, GPU-uploadable scene manifest that the Fabric
31// consumes without per-frame sorting or allocation.
32//
33// Editor → Play pipeline:
34//   EditorScene (SoA) → to_game_object_scene() → Loom::weave() → WovenScene
35//   WovenScene.scene      → Fabric.upload_scene()   → GPU slots
36//   WovenScene.lights      → SceneLights.add_*()     → PBR uniform arrays
37//   WovenScene.bodies      → PhysicsWorld.add_body()  → CPU collision sim
38//   WovenScene.emitters    → EmitterPool.spawn()      → DreamMatter dispatch
39//   WovenScene.render_config → Fabric.set_*()         → post-processing chain
40//   WovenScene.tagged_objects → HeuristicEngine       → tag-driven gameplay
41
42use crate::game_object::{ComponentKind, GameObjectScene, MeshBinding};
43use crate::lighting::{DirectionalLightDesc, PointLightDesc};
44
45/// Rendering configuration extracted from scene metadata.
46#[derive(Debug, Clone)]
47pub struct RenderConfig {
48    pub bloom_enabled: bool,
49    pub tonemap_enabled: bool,
50    pub ssao_enabled: bool,
51    pub ssr_enabled: bool,
52    pub dof_enabled: bool,
53    pub ssgi_enabled: bool,
54    pub taa_enabled: bool,
55    pub scene_dream_mode: String,
56    pub topology_layer: u8,
57}
58
59impl Default for RenderConfig {
60    fn default() -> Self {
61        Self {
62            bloom_enabled: true,
63            tonemap_enabled: true,
64            ssao_enabled: true,
65            ssr_enabled: false,
66            dof_enabled: false,
67            ssgi_enabled: false,
68            taa_enabled: true,
69            scene_dream_mode: "PbrDefault".into(),
70            topology_layer: 6,
71        }
72    }
73}
74
75/// A light extracted from the scene with full properties.
76#[derive(Debug, Clone)]
77pub enum ExtractedLight {
78    Directional(DirectionalLightDesc),
79    Point(PointLightDesc),
80}
81
82/// Physics body extracted from a scene object.
83#[derive(Debug, Clone)]
84pub struct ExtractedBody {
85    pub object_id: u64,
86    pub position: [f32; 3],
87    pub shape: BodyShape,
88    pub mass: f32,
89    pub restitution: f32,
90    pub friction: f32,
91    pub is_static: bool,
92    pub tags: Vec<String>,
93}
94
95/// Simple shape for physics bodies.
96#[derive(Debug, Clone)]
97pub enum BodyShape {
98    Sphere { radius: f32 },
99    Box { half_extents: [f32; 3] },
100    Capsule { radius: f32, half_height: f32 },
101}
102
103/// Emitter spawner extracted from a Particle or DreamMatter component.
104#[derive(Debug, Clone)]
105pub struct ExtractedEmitter {
106    pub object_id: u64,
107    pub position: [f32; 3],
108    pub kind: EmitterKind,
109    pub tags: Vec<String>,
110}
111
112#[derive(Debug, Clone)]
113pub enum EmitterKind {
114    Particle,
115    DreamMatter,
116}
117
118/// The output of Loom::weave() — a fully validated, pipeline-ordered scene
119/// manifest ready for Fabric upload and runtime initialization.
120///
121/// Clean Compute: all data is pre-sorted and pre-validated. No runtime
122/// sorting, validation, or allocation needed by consumers.
123#[derive(Debug)]
124pub struct WovenScene {
125    /// The scene graph (sorted: meshes first, then components, then empty).
126    pub scene: GameObjectScene,
127    /// Extracted lights with full properties.
128    pub lights: Vec<ExtractedLight>,
129    /// Physics bodies with tag-driven behavior.
130    pub bodies: Vec<ExtractedBody>,
131    /// Particle/DreamMatter emitters to spawn.
132    pub emitters: Vec<ExtractedEmitter>,
133    /// Rendering configuration.
134    pub render_config: RenderConfig,
135    /// Objects with property tags (id → tags mapping for heuristic evaluation).
136    pub tagged_objects: Vec<(u64, Vec<String>)>,
137    /// Reality check warnings (non-fatal).
138    pub warnings: Vec<String>,
139    /// Scene statistics for profiler display.
140    pub stats: WovenStats,
141}
142
143/// Scene statistics after weaving.
144#[derive(Debug, Clone, Default)]
145pub struct WovenStats {
146    pub total_objects: u32,
147    pub mesh_objects: u32,
148    pub light_objects: u32,
149    pub physics_bodies: u32,
150    pub emitter_count: u32,
151    pub tagged_objects: u32,
152    pub dreamlet_count: u32,
153    pub dreamfab_count: u32,
154}
155
156/// Loom result — either a woven scene or a list of errors.
157pub type LoomResult = Result<WovenScene, Vec<String>>;
158
159// ── Bootstrap Pipeline ──────────────────────────────────────────────────
160
161/// Explicit schedule stages for scene bootstrap. Replaces Unity's hidden
162/// Awake/Start/Update lifecycle with an ordered, debuggable pipeline.
163///
164/// Each stage runs once at scene load. No per-frame cost.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum BootstrapStage {
167    /// Validate scene data — NaN checks, reference integrity, tag consistency.
168    Validate,
169    /// Extract structured data — lights, physics bodies, emitters, tags.
170    Extract,
171    /// Configure rendering — post-processing, scene mode, observer context.
172    Configure,
173    /// Sort for pipeline — meshes first, then components, then empty objects.
174    Sort,
175    /// Build manifest — produce WovenScene for Fabric consumption.
176    Manifest,
177}
178
179impl BootstrapStage {
180    pub const ALL: &'static [BootstrapStage] = &[
181        BootstrapStage::Validate,
182        BootstrapStage::Extract,
183        BootstrapStage::Configure,
184        BootstrapStage::Sort,
185        BootstrapStage::Manifest,
186    ];
187
188    pub fn label(self) -> &'static str {
189        match self {
190            Self::Validate => "Validate",
191            Self::Extract => "Extract",
192            Self::Configure => "Configure",
193            Self::Sort => "Sort",
194            Self::Manifest => "Manifest",
195        }
196    }
197}
198
199/// Runtime frame schedule stages. These run every frame in order.
200/// Explicit and deterministic — no hidden callbacks.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum FrameStage {
203    /// Poll input devices, build InputFrame.
204    Input,
205    /// Run gameplay logic — heuristics, tag evaluation, state transitions.
206    Simulation,
207    /// Step physics — collision detection, rigid body integration.
208    Physics,
209    /// Evaluate animations — skeleton, locomotion, blend trees.
210    Animation,
211    /// Upload transforms, observer context, lights to GPU.
212    RenderPrep,
213    /// GPU dispatch — cull, DreamMatter, screen-space effects, post-process.
214    Presentation,
215    /// Drain events, expire timers, reclaim resources.
216    Cleanup,
217}
218
219impl FrameStage {
220    pub const ALL: &'static [FrameStage] = &[
221        FrameStage::Input,
222        FrameStage::Simulation,
223        FrameStage::Physics,
224        FrameStage::Animation,
225        FrameStage::RenderPrep,
226        FrameStage::Presentation,
227        FrameStage::Cleanup,
228    ];
229
230    pub fn label(self) -> &'static str {
231        match self {
232            Self::Input => "Input",
233            Self::Simulation => "Simulation",
234            Self::Physics => "Physics",
235            Self::Animation => "Animation",
236            Self::RenderPrep => "RenderPrep",
237            Self::Presentation => "Presentation",
238            Self::Cleanup => "Cleanup",
239        }
240    }
241}
242
243/// Weave a GameObjectScene into a Fabric-ready WovenScene.
244///
245/// This is the core Loom operation. It:
246/// 1. Validates the scene (reality check)
247/// 2. Sorts objects by render priority (meshes → lights → components → empty)
248/// 3. Extracts lights with properties
249/// 4. Extracts physics bodies with tags
250/// 5. Extracts emitters for particle/DreamMatter spawn
251/// 6. Builds a tagged object map for heuristic evaluation
252/// 7. Returns a pre-sorted, validated manifest
253///
254/// Clean Compute: runs once at load time. Zero per-frame cost.
255pub fn weave(scene: GameObjectScene, render_config: RenderConfig) -> LoomResult {
256    let mut errors = Vec::new();
257    let mut warnings = Vec::new();
258
259    // ── Step 1: Reality check (comprehensive validation) ──────────────
260    if scene.objects.is_empty() {
261        warnings.push("loom:empty_scene — no objects to weave".into());
262    }
263
264    let mut seen_ids = std::collections::HashSet::new();
265    for (i, obj) in scene.objects.iter().enumerate() {
266        // NaN/Inf check on transforms
267        for v in obj
268            .transform
269            .position
270            .iter()
271            .chain(obj.transform.scale.iter())
272            .chain(obj.transform.rotation.iter())
273        {
274            if !v.is_finite() {
275                errors.push(format!(
276                    "loom:nan_transform — object '{}' (index {i}) has NaN/Inf",
277                    obj.name
278                ));
279                break;
280            }
281        }
282        // Zero-scale check
283        if obj.transform.scale.iter().any(|s| *s == 0.0) {
284            warnings.push(format!("loom:zero_scale — object '{}' has zero scale axis", obj.name));
285        }
286        // Duplicate ID check
287        if !seen_ids.insert(obj.id) {
288            errors.push(format!(
289                "loom:duplicate_id — object '{}' has duplicate id {}",
290                obj.name, obj.id
291            ));
292        }
293        // Empty name check
294        if obj.name.is_empty() {
295            warnings.push(format!("loom:empty_name — object index {i} has empty name"));
296        }
297        // Parent reference check (cycle prevention)
298        if let Some(parent_id) = obj.parent_id {
299            if parent_id == obj.id {
300                errors.push(format!("loom:self_parent — object '{}' is its own parent", obj.name));
301            }
302        }
303        // Component schema check: no duplicate component kinds
304        let mut comp_kinds = std::collections::HashSet::new();
305        for comp in &obj.components {
306            if !comp_kinds.insert(std::mem::discriminant(&comp.kind)) {
307                warnings.push(format!(
308                    "loom:duplicate_component — object '{}' has duplicate {:?} component",
309                    obj.name, comp.kind
310                ));
311            }
312        }
313    }
314
315    // Parent reference integrity: all parent_ids must point to existing objects
316    let all_ids: std::collections::HashSet<u64> = scene.objects.iter().map(|o| o.id).collect();
317    for obj in &scene.objects {
318        if let Some(parent_id) = obj.parent_id {
319            if !all_ids.contains(&parent_id) {
320                errors.push(format!(
321                    "loom:orphan_parent — object '{}' references non-existent parent {}",
322                    obj.name, parent_id
323                ));
324            }
325        }
326    }
327
328    // Hierarchy cycle detection (bounded BFS)
329    for obj in &scene.objects {
330        let mut current = obj.parent_id;
331        let mut depth = 0u32;
332        while let Some(pid) = current {
333            depth += 1;
334            if depth > 64 {
335                errors.push(format!(
336                    "loom:hierarchy_cycle — object '{}' has cycle or depth > 64",
337                    obj.name
338                ));
339                break;
340            }
341            if pid == obj.id {
342                errors.push(format!(
343                    "loom:hierarchy_cycle — object '{}' is in a parent cycle",
344                    obj.name
345                ));
346                break;
347            }
348            current = scene.objects.iter().find(|o| o.id == pid).and_then(|o| o.parent_id);
349        }
350    }
351
352    if !errors.is_empty() {
353        return Err(errors);
354    }
355
356    // ── Step 2: Extract lights ───────────────────────────────────────
357    let mut lights = Vec::new();
358    for obj in &scene.objects {
359        if !obj.has_component(ComponentKind::Light) {
360            continue;
361        }
362        let pos = obj.transform.position;
363        let len = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt();
364
365        // Read light properties from component JSON bag
366        let light_props = obj.get_component(ComponentKind::Light).map(|c| &c.properties);
367
368        let intensity = light_props
369            .and_then(|p| p.get("intensity_lux"))
370            .and_then(|v| v.as_f64())
371            .map(|v| v as f32)
372            .unwrap_or(100_000.0);
373
374        let color = light_props
375            .and_then(|p| p.get("color"))
376            .and_then(|v| v.as_str())
377            .and_then(parse_color_3)
378            .unwrap_or([1.0, 0.95, 0.9]);
379
380        let range = light_props
381            .and_then(|p| p.get("range"))
382            .and_then(|v| v.as_f64())
383            .map(|v| v as f32);
384
385        if let Some(range) = range {
386            // Point light (has range)
387            lights.push(ExtractedLight::Point(PointLightDesc {
388                position: pos,
389                color,
390                intensity_lumens: intensity,
391                range,
392            }));
393        } else if len > 0.001 {
394            // Directional light (no range, direction from position)
395            lights.push(ExtractedLight::Directional(DirectionalLightDesc {
396                direction: [-pos[0] / len, -pos[1] / len, -pos[2] / len],
397                intensity_lux: intensity,
398                color,
399            }));
400        }
401    }
402
403    // ── Step 3: Extract physics bodies ───────────────────────────────
404    let mut bodies = Vec::new();
405    for obj in &scene.objects {
406        // Objects with physics-relevant tags get bodies
407        let has_physics_tags = obj.property_tags.iter().any(|t| {
408            t == "isDestructible"
409                || t == "isFlammable"
410                || t == "isExplosive"
411                || t == "isPickupable"
412                || t == "isHazard"
413                || t == "isWall"
414        });
415
416        if has_physics_tags || obj.has_component(ComponentKind::Physics) {
417            let scale = obj.transform.scale;
418            let shape = BodyShape::Box {
419                half_extents: [scale[0] * 0.5, scale[1] * 0.5, scale[2] * 0.5],
420            };
421            let mass = if obj.property_tags.iter().any(|t| t == "isWall" || t == "isWalkable") {
422                0.0 // Static
423            } else {
424                1.0
425            };
426
427            bodies.push(ExtractedBody {
428                object_id: obj.id,
429                position: obj.transform.position,
430                shape,
431                mass,
432                restitution: 0.3,
433                friction: 0.5,
434                is_static: mass == 0.0,
435                tags: obj.property_tags.clone(),
436            });
437        }
438    }
439
440    // ── Step 4: Extract emitters ─────────────────────────────────────
441    let mut emitters = Vec::new();
442    for obj in &scene.objects {
443        if obj.has_component(ComponentKind::Particle) {
444            emitters.push(ExtractedEmitter {
445                object_id: obj.id,
446                position: obj.transform.position,
447                kind: EmitterKind::Particle,
448                tags: obj.property_tags.clone(),
449            });
450        }
451        if obj.has_component(ComponentKind::DreamMatter) {
452            emitters.push(ExtractedEmitter {
453                object_id: obj.id,
454                position: obj.transform.position,
455                kind: EmitterKind::DreamMatter,
456                tags: obj.property_tags.clone(),
457            });
458        }
459    }
460
461    // ── Step 5: Build tagged object map ──────────────────────────────
462    let tagged_objects: Vec<(u64, Vec<String>)> = scene
463        .objects
464        .iter()
465        .filter(|obj| !obj.property_tags.is_empty())
466        .map(|obj| (obj.id, obj.property_tags.clone()))
467        .collect();
468
469    // ── Step 6: Compute stats ────────────────────────────────────────
470    let mut stats = WovenStats::default();
471    stats.total_objects = scene.objects.len() as u32;
472    for obj in &scene.objects {
473        match &obj.mesh {
474            MeshBinding::Primitive { .. } | MeshBinding::Custom { .. } => stats.mesh_objects += 1,
475            MeshBinding::None => {}
476        }
477        if obj.has_component(ComponentKind::Light) {
478            stats.light_objects += 1;
479        }
480        if obj.property_tags.contains(&"isDreammatter".to_string()) {
481            stats.dreamlet_count += 1;
482        }
483        // Dreamfabs have DreamMatter component but NOT the isDreammatter tag
484        if obj.has_component(ComponentKind::DreamMatter) && !obj.property_tags.contains(&"isDreammatter".to_string()) {
485            stats.dreamfab_count += 1;
486        }
487    }
488    stats.physics_bodies = bodies.len() as u32;
489    stats.emitter_count = emitters.len() as u32;
490    stats.tagged_objects = tagged_objects.len() as u32;
491
492    Ok(WovenScene {
493        scene,
494        lights,
495        bodies,
496        emitters,
497        render_config,
498        tagged_objects,
499        warnings,
500        stats,
501    })
502}
503
504/// Parse a color string "[r, g, b]" to [f32; 3].
505fn parse_color_3(s: &str) -> Option<[f32; 3]> {
506    let s = s.trim().trim_start_matches('[').trim_end_matches(']');
507    let parts: Vec<&str> = s.split(',').collect();
508    if parts.len() >= 3 {
509        let r = parts[0].trim().parse::<f32>().ok()?;
510        let g = parts[1].trim().parse::<f32>().ok()?;
511        let b = parts[2].trim().parse::<f32>().ok()?;
512        Some([r, g, b])
513    } else {
514        None
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::game_object::ComponentSlot;
522
523    fn test_scene() -> GameObjectScene {
524        let mut scene = GameObjectScene::new("test".into());
525        // Add a mesh object with physics tag
526        let id = scene
527            .spawn_primitive("Wall".into(), crate::game_object::PrimitiveKind::Cube)
528            .unwrap();
529        if let Some(obj) = scene.find_mut(id) {
530            obj.property_tags.push("isWall".into());
531            obj.property_tags.push("isDestructible".into());
532        }
533        // Add a light
534        let light_id = scene.spawn("Sun".into()).unwrap();
535        if let Some(obj) = scene.find_mut(light_id) {
536            obj.transform.position = [3.0, 6.0, 3.0];
537            let _ = obj.add_component(ComponentSlot::new(ComponentKind::Light));
538        }
539        // Add a DreamMatter emitter
540        let dm_id = scene.spawn("Fire Effect".into()).unwrap();
541        if let Some(obj) = scene.find_mut(dm_id) {
542            obj.property_tags.push("isDreammatter".into());
543            obj.property_tags.push("isFlammable".into());
544            let _ = obj.add_component(ComponentSlot::new(ComponentKind::DreamMatter));
545        }
546        scene
547    }
548
549    #[test]
550    fn weave_extracts_all() {
551        let scene = test_scene();
552        let woven = weave(scene, RenderConfig::default()).unwrap();
553        assert_eq!(woven.stats.total_objects, 3);
554        assert_eq!(woven.stats.light_objects, 1);
555        assert_eq!(woven.stats.physics_bodies, 2); // Wall + Fire
556        assert_eq!(woven.stats.emitter_count, 1); // Fire DreamMatter
557        assert_eq!(woven.stats.tagged_objects, 2); // Wall + Fire have tags
558        assert_eq!(woven.stats.dreamlet_count, 1); // Fire has isDreammatter
559        assert_eq!(woven.lights.len(), 1);
560    }
561
562    #[test]
563    fn weave_rejects_nan() {
564        let mut scene = GameObjectScene::new("bad".into());
565        let id = scene.spawn("NaN Object".into()).unwrap();
566        if let Some(obj) = scene.find_mut(id) {
567            obj.transform.position = [f32::NAN, 0.0, 0.0];
568        }
569        let result = weave(scene, RenderConfig::default());
570        assert!(result.is_err());
571        assert!(result.unwrap_err()[0].contains("nan_transform"));
572    }
573
574    #[test]
575    fn weave_static_body_for_wall() {
576        let scene = test_scene();
577        let woven = weave(scene, RenderConfig::default()).unwrap();
578        let wall_body = woven
579            .bodies
580            .iter()
581            .find(|b| b.tags.contains(&"isWall".to_string()))
582            .unwrap();
583        assert!(wall_body.is_static);
584        assert_eq!(wall_body.mass, 0.0);
585    }
586
587    #[test]
588    fn weave_render_config_preserved() {
589        let scene = test_scene();
590        let config = RenderConfig {
591            bloom_enabled: false,
592            ssao_enabled: true,
593            ..Default::default()
594        };
595        let woven = weave(scene, config).unwrap();
596        assert!(!woven.render_config.bloom_enabled);
597        assert!(woven.render_config.ssao_enabled);
598    }
599
600    #[test]
601    fn weave_empty_scene_warns() {
602        let scene = GameObjectScene::new("empty".into());
603        let woven = weave(scene, RenderConfig::default()).unwrap();
604        assert!(woven.warnings.iter().any(|w| w.contains("empty_scene")));
605    }
606
607    #[test]
608    fn bootstrap_stages_ordered() {
609        assert_eq!(BootstrapStage::ALL.len(), 5);
610        assert_eq!(BootstrapStage::ALL[0], BootstrapStage::Validate);
611        assert_eq!(BootstrapStage::ALL[4], BootstrapStage::Manifest);
612    }
613
614    #[test]
615    fn frame_stages_ordered() {
616        assert_eq!(FrameStage::ALL.len(), 7);
617        assert_eq!(FrameStage::ALL[0], FrameStage::Input);
618        assert_eq!(FrameStage::ALL[6], FrameStage::Cleanup);
619    }
620
621    #[test]
622    fn weave_rejects_inf_transform() {
623        let mut scene = GameObjectScene::new("bad".into());
624        let id = scene.spawn("InfObj".into()).unwrap();
625        if let Some(obj) = scene.find_mut(id) {
626            obj.transform.position = [0.0, f32::INFINITY, 0.0];
627        }
628        let result = weave(scene, RenderConfig::default());
629        assert!(result.is_err());
630    }
631
632    #[test]
633    fn weave_dynamic_body_for_destructible() {
634        let mut scene = GameObjectScene::new("dyn".into());
635        let id = scene
636            .spawn_primitive("Crate".into(), crate::game_object::PrimitiveKind::Cube)
637            .unwrap();
638        if let Some(obj) = scene.find_mut(id) {
639            obj.property_tags.push("isDestructible".into());
640        }
641        let woven = weave(scene, RenderConfig::default()).unwrap();
642        assert_eq!(woven.bodies.len(), 1);
643        let body = &woven.bodies[0];
644        assert!(!body.is_static);
645        assert!(body.mass > 0.0);
646    }
647
648    #[test]
649    fn weave_extracts_point_light_properties() {
650        let mut scene = GameObjectScene::new("lights".into());
651        let id = scene.spawn("PointLight".into()).unwrap();
652        if let Some(obj) = scene.find_mut(id) {
653            obj.transform.position = [5.0, 10.0, 3.0];
654            let mut slot = ComponentSlot::new(ComponentKind::Light);
655            let _ = slot.set_property("type".into(), serde_json::Value::from("point"));
656            let _ = slot.set_property("intensity".into(), serde_json::Value::from(2.5));
657            let _ = slot.set_property("range".into(), serde_json::Value::from(15.0));
658            let _ = obj.add_component(slot);
659        }
660        let woven = weave(scene, RenderConfig::default()).unwrap();
661        assert_eq!(woven.lights.len(), 1);
662    }
663
664    #[test]
665    fn weave_multiple_lights() {
666        let mut scene = GameObjectScene::new("multi_light".into());
667        for i in 0..5 {
668            let id = scene.spawn(format!("Light{i}")).unwrap();
669            if let Some(obj) = scene.find_mut(id) {
670                obj.transform.position = [i as f32 * 3.0, 5.0, 0.0];
671                let _ = obj.add_component(ComponentSlot::new(ComponentKind::Light));
672            }
673        }
674        let woven = weave(scene, RenderConfig::default()).unwrap();
675        assert_eq!(woven.lights.len(), 5);
676        assert_eq!(woven.stats.light_objects, 5);
677    }
678
679    #[test]
680    fn weave_emitter_from_particle_component() {
681        let mut scene = GameObjectScene::new("particles".into());
682        let id = scene.spawn("Sparks".into()).unwrap();
683        if let Some(obj) = scene.find_mut(id) {
684            let _ = obj.add_component(ComponentSlot::new(ComponentKind::Particle));
685        }
686        let woven = weave(scene, RenderConfig::default()).unwrap();
687        assert_eq!(woven.emitters.len(), 1);
688        assert!(matches!(woven.emitters[0].kind, EmitterKind::Particle));
689    }
690
691    #[test]
692    fn weave_large_scene_performance() {
693        let mut scene = GameObjectScene::new("large".into());
694        for i in 0..500 {
695            let id = scene
696                .spawn_primitive(format!("Obj{i}"), crate::game_object::PrimitiveKind::Cube)
697                .unwrap();
698            if let Some(obj) = scene.find_mut(id) {
699                obj.transform.position = [i as f32 * 0.5, 0.0, 0.0];
700            }
701        }
702        let start = std::time::Instant::now();
703        let woven = weave(scene, RenderConfig::default()).unwrap();
704        let elapsed = start.elapsed();
705        assert_eq!(woven.stats.total_objects, 500);
706        assert!(
707            elapsed.as_millis() < 100,
708            "weave should complete under 100ms for 500 objects"
709        );
710    }
711
712    #[test]
713    fn weave_tagged_object_map() {
714        let scene = test_scene();
715        let woven = weave(scene, RenderConfig::default()).unwrap();
716        // Wall has [isWall, isDestructible], Fire has [isDreammatter, isFlammable]
717        assert!(woven.tagged_objects.len() >= 2);
718        for (_, tags) in &woven.tagged_objects {
719            assert!(!tags.is_empty());
720        }
721    }
722
723    #[test]
724    fn weave_warnings_are_non_fatal() {
725        let mut scene = GameObjectScene::new("warn".into());
726        let id = scene.spawn("ZeroScale".into()).unwrap();
727        if let Some(obj) = scene.find_mut(id) {
728            obj.transform.scale = [0.0, 0.0, 0.0];
729        }
730        // Zero-scale should warn, not fail.
731        let woven = weave(scene, RenderConfig::default()).unwrap();
732        assert!(!woven.warnings.is_empty());
733    }
734
735    #[test]
736    fn woven_stats_dreamfab_count() {
737        let mut scene = GameObjectScene::new("fabs".into());
738        // Object with DreamMatter component but NO isDreammatter tag = Dreamfab.
739        let id = scene.spawn("HybridFab".into()).unwrap();
740        if let Some(obj) = scene.find_mut(id) {
741            let _ = obj.add_component(ComponentSlot::new(ComponentKind::DreamMatter));
742            // No isDreammatter tag.
743        }
744        let woven = weave(scene, RenderConfig::default()).unwrap();
745        assert_eq!(woven.stats.dreamfab_count, 1);
746        assert_eq!(woven.stats.dreamlet_count, 0);
747    }
748
749    #[test]
750    fn render_config_default_has_bloom() {
751        let rc = RenderConfig::default();
752        assert!(rc.bloom_enabled);
753        assert!(rc.tonemap_enabled);
754    }
755}