Skip to main content

mittens_engine/engine/ecs/system/
asset_system.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use std::cell::RefCell;
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use crate::engine::ecs::component::{
9    LayoutComponent, RaycastableComponent, StyleComponent, TransformComponent,
10};
11use crate::engine::ecs::system::bounds_system::{BoundsSystem, RenderableBoundsMeasure};
12use crate::engine::ecs::system::editor_paint_system::PaintAssetTemplate;
13use crate::engine::ecs::{ComponentId, SignalEmitter, World};
14use crate::scripting::object::Value;
15use crate::scripting::runner::{LoadedMmsModule, MeowMeowRunner};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct AssetModuleId(u32);
19
20#[derive(Debug)]
21pub struct AssetModule {
22    pub id: AssetModuleId,
23    pub path: PathBuf,
24    pub module: Arc<LoadedMmsModule>,
25}
26
27#[derive(Debug, Clone)]
28pub struct AssetItem {
29    pub module_id: AssetModuleId,
30    pub export_name: String,
31    pub title: String,
32    pub description: Option<String>,
33    pub category: Option<String>,
34    pub param_names: Vec<String>,
35}
36
37impl AssetItem {
38    pub fn asset_key(&self, module_path: &Path) -> String {
39        format!("{}::{}", module_path.display(), self.export_name)
40    }
41}
42
43#[derive(Debug, Clone)]
44pub enum AssetSource {
45    MmsModule {
46        module_id: AssetModuleId,
47        export_name: String,
48    },
49    RustFactory {
50        factory_name: String,
51    },
52}
53
54#[derive(Debug, Default)]
55pub struct AssetSystem {
56    modules: HashMap<AssetModuleId, AssetModule>,
57    module_paths: HashMap<PathBuf, AssetModuleId>,
58    pub items: Vec<AssetItem>,
59    next_module_id: u32,
60    asset_dir: Option<PathBuf>,
61    /// Preview shells whose styled content needs remeasurement after layout resolves.
62    /// Each entry is `(preview_shell_id, preview_root_id)`.
63    pending_remeasure: RefCell<Vec<(ComponentId, ComponentId)>>,
64}
65
66impl AssetSystem {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn scan_assets_dir(&mut self, path: &Path) -> Result<(), String> {
72        self.asset_dir = Some(path.to_path_buf());
73        let entries = std::fs::read_dir(path)
74            .map_err(|e| format!("cannot read assets dir '{}': {e}", path.display()))?;
75
76        for entry in entries {
77            let entry = entry.map_err(|e| format!("cannot read assets dir entry: {e}"))?;
78            let path = entry.path();
79            if path.extension().and_then(|s| s.to_str()) == Some("mms") {
80                println!("[AssetSystem][debug] scanning asset module: {:?}", path);
81                self.load_module(path)?;
82            }
83        }
84
85        Ok(())
86    }
87
88    pub fn load_module(&mut self, path: PathBuf) -> Result<(), String> {
89        let normalized_path = path;
90        if self.module_paths.contains_key(&normalized_path) {
91            return Ok(());
92        }
93        let module = Arc::new(MeowMeowRunner::load_module_file(
94            normalized_path
95                .to_str()
96                .ok_or_else(|| format!("non-UTF8 asset path: {}", normalized_path.display()))?,
97        )?);
98
99        let module_id = AssetModuleId(self.next_module_id);
100        self.next_module_id += 1;
101
102        let file_stem = normalized_path
103            .file_stem()
104            .and_then(|stem| stem.to_str())
105            .unwrap_or("asset");
106
107        for (name, value) in &module.named_exports {
108            if let Value::Function { params, .. } = value {
109                println!(
110                    "[AssetSystem][debug]   found export function: {} (params: {:?})",
111                    name, params
112                );
113
114                let title = if name == "main" || name == file_stem {
115                    file_stem.to_string()
116                } else {
117                    format!("{}: {}", file_stem, name)
118                };
119
120                self.items.push(AssetItem {
121                    module_id,
122                    export_name: name.clone(),
123                    title,
124                    description: None,
125                    category: None,
126                    param_names: params.clone(),
127                });
128            }
129        }
130        self.module_paths.insert(normalized_path.clone(), module_id);
131        self.modules.insert(
132            module_id,
133            AssetModule {
134                id: module_id,
135                path: normalized_path,
136                module,
137            },
138        );
139
140        Ok(())
141    }
142
143    pub fn get_item_module(&self, item: &AssetItem) -> Option<&LoadedMmsModule> {
144        self.modules
145            .get(&item.module_id)
146            .map(|module| module.module.as_ref())
147    }
148
149    pub fn asset_function(&self, item: &AssetItem) -> Option<&Value> {
150        self.get_item_module(item)
151            .and_then(|module| module.named_export(&item.export_name))
152    }
153
154    pub fn get_module_name(&self, module_id: AssetModuleId) -> Option<String> {
155        self.modules.get(&module_id).and_then(|m| {
156            m.path
157                .file_stem()
158                .and_then(|s| s.to_str())
159                .map(|s| s.to_string())
160        })
161    }
162
163    pub fn paint_templates(&self) -> Vec<PaintAssetTemplate> {
164        self.items
165            .iter()
166            .filter_map(|item| {
167                let module = self.modules.get(&item.module_id)?;
168                Some(PaintAssetTemplate {
169                    key: item.asset_key(&module.path),
170                    title: item.title.clone(),
171                    module: Arc::clone(&module.module),
172                    export_name: item.export_name.clone(),
173                    param_names: item.param_names.clone(),
174                })
175            })
176            .collect()
177    }
178
179    pub fn build_asset_module_header(
180        &self,
181        world: &mut World,
182        emit: &mut dyn SignalEmitter,
183        module_name: &str,
184    ) -> Result<ComponentId, String> {
185        let asset_header_path = concat!(
186            env!("CARGO_MANIFEST_DIR"),
187            "/assets/components/asset_module_header.mms"
188        );
189
190        MeowMeowRunner::spawn_mms_module_component_from_file(
191            asset_header_path,
192            "asset_module_header",
193            vec![Value::String(module_name.to_string())],
194            None,
195            world,
196            emit,
197        )
198    }
199
200    pub fn spawn_asset_component(
201        &self,
202        item: &AssetItem,
203        args: Vec<Value>,
204        world: &mut World,
205        emit: &mut dyn SignalEmitter,
206    ) -> Result<ComponentId, String> {
207        let module = self.modules.get(&item.module_id).ok_or_else(|| {
208            format!(
209                "asset module not loaded for item '{}::{}'",
210                item.title, item.export_name
211            )
212        })?;
213
214        MeowMeowRunner::spawn_mms_module_component(
215            &module.module,
216            &item.export_name,
217            args,
218            None,
219            world,
220            emit,
221        )
222    }
223
224    pub fn spawn_asset_component_uninitialized(
225        &self,
226        item: &AssetItem,
227        args: Vec<Value>,
228        world: &mut World,
229        render_assets: &mut crate::engine::graphics::RenderAssets,
230        emit: &mut dyn SignalEmitter,
231    ) -> Result<ComponentId, String> {
232        let module = self.modules.get(&item.module_id).ok_or_else(|| {
233            format!(
234                "asset module not loaded for item '{}::{}'",
235                item.title, item.export_name
236            )
237        })?;
238
239        MeowMeowRunner::spawn_mms_module_component_uninitialized_with_assets(
240            &module.module,
241            &item.export_name,
242            args,
243            world,
244            Some(render_assets),
245            emit,
246        )
247    }
248
249    fn assets_panel_asset_path() -> &'static str {
250        concat!(env!("CARGO_MANIFEST_DIR"), "/assets/components/panels.mms")
251    }
252
253    pub fn spawn_assets_panel(
254        &self,
255        world: &mut World,
256        render_assets: &mut crate::engine::graphics::RenderAssets,
257        emit: &mut dyn SignalEmitter,
258        parent: ComponentId,
259        position: (f32, f32, f32),
260    ) -> Result<ComponentId, String> {
261        let panel_title = match self.asset_dir.as_ref() {
262            Some(path) => format!("Assets: {}", path.display()),
263            None => "Assets".to_string(),
264        };
265
266        let panel_root = MeowMeowRunner::spawn_mms_module_component_from_file(
267            Self::assets_panel_asset_path(),
268            "asset_panel",
269            vec![
270                Value::String(panel_title),
271                Value::Array(Vec::new()),
272                Value::Array(vec![
273                    Value::Number(0.90),
274                    Value::Number(1.00),
275                    Value::Number(0.92),
276                    Value::Number(1.0),
277                ]),
278                Value::Array(vec![
279                    Value::Number(0.18),
280                    Value::Number(0.78),
281                    Value::Number(0.22),
282                    Value::Number(0.95),
283                ]),
284                Value::Array(vec![
285                    Value::Number(0.92),
286                    Value::Number(0.97),
287                    Value::Number(0.92),
288                    Value::Number(1.0),
289                ]),
290            ],
291            None,
292            world,
293            emit,
294        )?;
295
296        let wrapper = world.add_component_boxed_named(
297            "assets_panel_shell",
298            Box::new(TransformComponent::new().with_position(position.0, position.1, position.2)),
299        );
300
301        world
302            .add_child(wrapper, panel_root)
303            .map_err(|e| format!("attach assets panel child failed: {e}"))?;
304
305        let content_root = world
306            .find_component(panel_root, "#assets_content_area")
307            .ok_or_else(|| "assets panel missing content area".to_string())?;
308
309        let mut last_module_id = None;
310        for (index, item) in self.items.iter().enumerate() {
311            if last_module_id != Some(item.module_id) {
312                last_module_id = Some(item.module_id);
313                if let Some(module_name) = self.get_module_name(item.module_id) {
314                    let header_root = self.build_asset_module_header(world, emit, &module_name)?;
315                    world
316                        .add_child(content_root, header_root)
317                        .map_err(|e| format!("attach asset header failed: {e}"))?;
318                }
319            }
320
321            let item_root = self.build_asset_item_shell(world, render_assets, emit, item, index)?;
322            world
323                .add_child(content_root, item_root)
324                .map_err(|e| format!("attach asset item failed: {e}"))?;
325        }
326
327        world.init_component_tree(wrapper, emit);
328
329        // Dirty the nearest ancestor LayoutComponent so the layout system
330        // re-measures the content area now that all items are attached.
331        let mut cur = world.parent_of(content_root);
332        while let Some(ancestor) = cur {
333            if let Some(lc) = world.get_component_by_id_as_mut::<LayoutComponent>(ancestor) {
334                lc.dirty = true;
335                break;
336            }
337            cur = world.parent_of(ancestor);
338        }
339
340        emit.push_intent_now(
341            wrapper,
342            crate::engine::ecs::IntentValue::Attach {
343                parents: vec![parent],
344                child: wrapper,
345            },
346        );
347        Ok(wrapper)
348    }
349
350    /// Check if a subtree contains styled elements (`StyleComponent`) without a
351    /// `LayoutComponent` ancestor — meaning it needs a layout root to resolve properly.
352    fn subtree_needs_layout_root(world: &World, root: ComponentId) -> bool {
353        let mut stack = vec![root];
354        let mut visited = HashSet::new();
355
356        while let Some(node) = stack.pop() {
357            if !visited.insert(node) {
358                continue;
359            }
360
361            if world
362                .get_component_by_id_as::<StyleComponent>(node)
363                .is_some()
364            {
365                let mut has_layout = false;
366                let mut current = world.parent_of(node);
367                while let Some(ancestor) = current {
368                    if world
369                        .get_component_by_id_as::<LayoutComponent>(ancestor)
370                        .is_some()
371                    {
372                        has_layout = true;
373                        break;
374                    }
375                    current = world.parent_of(ancestor);
376                }
377                if !has_layout {
378                    return true;
379                }
380            }
381
382            for &child in world.children_of(node) {
383                stack.push(child);
384            }
385        }
386
387        false
388    }
389
390    /// If `root`'s subtree contains styled elements that need layout resolution,
391    /// create a `LayoutComponent` and return its id. Returns `None` if no layout root is needed.
392    pub fn ensure_layout_root_if_needed(
393        world: &mut World,
394        root: ComponentId,
395    ) -> Option<ComponentId> {
396        if !Self::subtree_needs_layout_root(world, root) {
397            return None;
398        }
399
400        let layout_root = world
401            .add_component_boxed_named("preview_layout_root", Box::new(LayoutComponent::new(20.0)));
402
403        Some(layout_root)
404    }
405
406    /// Walk the subtree rooted at `root` and set every `RaycastableComponent`
407    /// to disabled, so preview content doesn't steal pointer events from the
408    /// assets panel's scroll container.
409    fn disable_raycast_in_subtree(world: &mut World, root: ComponentId) {
410        let mut stack = vec![root];
411        let mut visited = HashSet::new();
412        while let Some(node) = stack.pop() {
413            if !visited.insert(node) {
414                continue;
415            }
416            if let Some(rc) = world.get_component_by_id_as_mut::<RaycastableComponent>(node) {
417                rc.enable = false;
418            }
419            for &child in world.children_of(node) {
420                stack.push(child);
421            }
422        }
423    }
424
425    fn remove_preview_placeholder(world: &mut World, item_root: ComponentId) {
426        if let Some(placeholder) = world.find_component(item_root, "#preview_placeholder") {
427            let _ = world.remove_component_subtree(placeholder);
428        }
429    }
430
431    pub fn build_asset_item_shell(
432        &self,
433        world: &mut World,
434        render_assets: &mut crate::engine::graphics::RenderAssets,
435        emit: &mut dyn SignalEmitter,
436        item: &AssetItem,
437        _index: usize,
438    ) -> Result<ComponentId, String> {
439        let asset_item_path = concat!(
440            env!("CARGO_MANIFEST_DIR"),
441            "/assets/components/asset_item.mms"
442        );
443        let module = self.modules.get(&item.module_id).ok_or_else(|| {
444            format!(
445                "asset module not loaded for item '{}::{}'",
446                item.title, item.export_name
447            )
448        })?;
449
450        let item_root = MeowMeowRunner::spawn_mms_module_component_from_file(
451            asset_item_path,
452            "asset_item",
453            vec![
454                Value::String(item.title.clone()),
455                Value::String(item.asset_key(&module.path)),
456                Value::Array(vec![
457                    Value::Number(0.92),
458                    Value::Number(0.97),
459                    Value::Number(0.92),
460                    Value::Number(1.0),
461                ]),
462            ],
463            None,
464            world,
465            emit,
466        )?;
467
468        // Pass dummy arguments based on param_names
469        let mut args = Vec::new();
470        for name in &item.param_names {
471            let lower_name = name.to_lowercase();
472            if lower_name.contains("color") {
473                args.push(Value::Array(vec![
474                    Value::Number(0.5),
475                    Value::Number(0.5),
476                    Value::Number(0.5),
477                    Value::Number(1.0),
478                ]));
479            } else if lower_name.contains("items") || lower_name.contains("sequence") {
480                args.push(Value::Array(Vec::new()));
481            } else if lower_name.contains("path")
482                || lower_name.contains("url")
483                || lower_name.contains("uri")
484            {
485                args.push(Value::String("assets/world/default.mms".to_string()));
486            } else if lower_name.contains("title")
487                || lower_name.contains("label")
488                || lower_name.contains("name")
489                || lower_name.contains("text")
490            {
491                args.push(Value::String("Preview".to_string()));
492            } else {
493                args.push(Value::Null);
494            }
495        }
496
497        match self.spawn_asset_component_uninitialized(item, args, world, render_assets, emit) {
498            Ok(preview_root) => {
499                // Disable raycasting on all preview content so it doesn't steal
500                // pointer events from the assets panel's scroll container.
501                Self::disable_raycast_in_subtree(world, preview_root);
502
503                // TEMP: skip preview rendering for items from panel modules
504                // to isolate the scrolling issue.
505                let is_panel = self
506                    .get_module_name(item.module_id)
507                    .map(|name| name.contains("panel"))
508                    .unwrap_or(false);
509                if is_panel {
510                    // panel module item — skip preview rendering
511                } else {
512                    // Geometry-based preview (icons, meshes, etc.)
513                    let preview_slot = world
514                        .find_component(item_root, "#preview_slot")
515                        .unwrap_or(item_root);
516
517                    let preview_shell = world.add_component_boxed_named(
518                        "asset_preview_shell",
519                        Box::new(TransformComponent::new().with_position(0.0, 0.0, 0.05)),
520                    );
521
522                    let bounds = BoundsSystem::measure_renderable_subtree_bounds(
523                        world,
524                        render_assets,
525                        preview_root,
526                    );
527
528                    if let RenderableBoundsMeasure::Measured(b) = bounds {
529                        let s = 0.2_f32 / b.max_dimension().max(1e-6);
530                        let center = b.center();
531                        emit.push_intent_now(
532                            preview_shell,
533                            crate::engine::ecs::IntentValue::UpdateTransform {
534                                component_ids: vec![preview_shell],
535                                translation: [
536                                    -center[0] * s,
537                                    -center[1] * s,
538                                    -center[2] * s + 0.05,
539                                ],
540                                rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
541                                scale: [s, s, s],
542                            },
543                        );
544                    } else {
545                        emit.push_intent_now(
546                            preview_shell,
547                            crate::engine::ecs::IntentValue::UpdateTransform {
548                                component_ids: vec![preview_shell],
549                                translation: [0.0, 0.0, 0.05],
550                                rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
551                                scale: [0.5, 0.5, 0.5],
552                            },
553                        );
554                    }
555
556                    world
557                        .add_child(preview_shell, preview_root)
558                        .map_err(|e| format!("attach preview failed: {e}"))?;
559                    world
560                        .add_child(preview_slot, preview_shell)
561                        .map_err(|e| format!("attach preview shell failed: {e}"))?;
562                    Self::remove_preview_placeholder(world, item_root);
563                }
564            }
565            Err(e) => {
566                eprintln!(
567                    "[AssetSystem] preview unavailable for '{}::{}': {e}",
568                    item.title, item.export_name
569                );
570            }
571        }
572
573        Ok(item_root)
574    }
575
576    /// After layout has ticked, remeasure any pending preview subtrees that
577    /// were spawned with styled content but no `RenderableComponent` bounds.
578    /// Layout-generated background quads now exist, so bounds should return
579    /// real AABBs that we can use to compute proper scale/offset.
580    pub fn remeasure_pending_previews(
581        &mut self,
582        world: &mut World,
583        render_assets: &crate::engine::graphics::RenderAssets,
584        emit: &mut dyn SignalEmitter,
585    ) {
586        let pending = self.pending_remeasure.replace(Vec::new());
587        for (preview_shell, preview_root) in pending {
588            let bounds =
589                BoundsSystem::calculate_subtree_local_bounds(world, render_assets, preview_root);
590
591            if let Some(b) = bounds {
592                // Scale so the preview is 0.2 units wide, preserving aspect ratio.
593                let target_width_gu = 0.2_f32;
594                let current_width = b.width().max(1e-6);
595                let s = target_width_gu / current_width;
596                let center = b.center();
597
598                emit.push_intent_now(
599                    preview_shell,
600                    crate::engine::ecs::IntentValue::UpdateTransform {
601                        component_ids: vec![preview_shell],
602                        translation: [-center[0] * s, -center[1] * s, -center[2] * s + 0.05],
603                        rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
604                        scale: [s, s, s],
605                    },
606                );
607            } else {
608                // Still no bounds — unlikely but possible. Log and leave as-is.
609                eprintln!(
610                    "[AssetSystem] remeasure: still no bounds for preview shell {:?}",
611                    preview_shell,
612                );
613            }
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::engine::ecs::World;
622    use crate::engine::ecs::command_queue::CommandQueue;
623    use crate::engine::ecs::component::TextComponent;
624    use crate::engine::graphics::RenderAssets;
625    use std::path::PathBuf;
626    use std::time::{SystemTime, UNIX_EPOCH};
627
628    fn temp_asset_directory() -> PathBuf {
629        let now = SystemTime::now()
630            .duration_since(UNIX_EPOCH)
631            .expect("time went backwards")
632            .as_nanos();
633        let tmp_dir = std::env::temp_dir().join(format!("mittens_engine_assets_{}", now));
634        std::fs::create_dir_all(&tmp_dir).expect("create temp dir");
635        tmp_dir
636    }
637
638    fn first_text_under(world: &World, root: ComponentId) -> Option<String> {
639        if let Some(text) = world.get_component_by_id_as::<TextComponent>(root) {
640            return Some(text.text.clone());
641        }
642
643        for child in world.children_of(root) {
644            if let Some(text) = first_text_under(world, *child) {
645                return Some(text);
646            }
647        }
648
649        None
650    }
651
652    #[test]
653    fn scan_assets_dir_loads_mms_function_exports() {
654        let tmp_dir = temp_asset_directory();
655        let asset_path = tmp_dir.join("test_asset.mms");
656        std::fs::write(
657            &asset_path,
658            r#"
659                export fn example() {
660                    let root = T {}
661                    return root
662                }
663            "#,
664        )
665        .expect("write asset file");
666
667        let mut system = AssetSystem::new();
668        system.scan_assets_dir(&tmp_dir).expect("scan assets dir");
669
670        assert_eq!(system.items.len(), 1);
671        assert_eq!(system.items[0].export_name, "example");
672        assert!(system.items[0].title.contains("test_asset::example"));
673    }
674
675    #[test]
676    fn load_module_can_spawn_component() {
677        let tmp_dir = temp_asset_directory();
678        let asset_path = tmp_dir.join("test_asset.mms");
679        std::fs::write(
680            &asset_path,
681            r#"
682                export fn example() {
683                    let root = T {}
684                    return root
685                }
686            "#,
687        )
688        .expect("write asset file");
689
690        let mut system = AssetSystem::new();
691        system.load_module(asset_path.clone()).expect("load module");
692
693        let item = &system.items[0];
694        let mut world = World::default();
695        let mut render_assets = RenderAssets::new();
696        let mut emit = CommandQueue::new();
697        let _id = system
698            .spawn_asset_component(item, vec![], &mut world, &mut emit)
699            .expect("spawn component");
700    }
701
702    #[test]
703    fn build_asset_item_shell_uses_asset_title_as_label() {
704        let tmp_dir = temp_asset_directory();
705        let asset_path = tmp_dir.join("test_asset.mms");
706        std::fs::write(
707            &asset_path,
708            r#"
709                export fn example() {
710                    return T {}
711                }
712            "#,
713        )
714        .expect("write asset file");
715
716        let mut system = AssetSystem::new();
717        system.load_module(asset_path).expect("load module");
718
719        let item = &system.items[0];
720        let mut world = World::default();
721        let mut render_assets = RenderAssets::new();
722        let mut emit = CommandQueue::new();
723        let item_root = system
724            .build_asset_item_shell(&mut world, &mut render_assets, &mut emit, item, 0)
725            .expect("build asset item");
726
727        assert_eq!(
728            first_text_under(&world, item_root),
729            Some("test_asset::example".to_string())
730        );
731    }
732
733    #[test]
734    fn build_asset_item_shell_keeps_item_when_preview_fails() {
735        let tmp_dir = temp_asset_directory();
736        let asset_path = tmp_dir.join("bad_preview.mms");
737        std::fs::write(
738            &asset_path,
739            r#"
740                export fn broken_preview() {
741                    return null
742                }
743            "#,
744        )
745        .expect("write asset file");
746
747        let mut system = AssetSystem::new();
748        system.load_module(asset_path).expect("load module");
749
750        let item = &system.items[0];
751        let mut world = World::default();
752        let mut render_assets = RenderAssets::new();
753        let mut emit = CommandQueue::new();
754        let item_root = system
755            .build_asset_item_shell(&mut world, &mut render_assets, &mut emit, item, 0)
756            .expect("build asset item despite preview failure");
757
758        assert_eq!(world.component_label(item_root), Some("asset_item"));
759        assert_eq!(
760            first_text_under(&world, item_root),
761            Some("bad_preview::broken_preview".to_string())
762        );
763    }
764
765    #[test]
766    fn spawn_asset_component_uninitialized_supports_procedural_renderables() {
767        let tmp_dir = temp_asset_directory();
768        let asset_path = tmp_dir.join("primitives_preview.mms");
769        std::fs::write(
770            &asset_path,
771            r#"
772                export fn heart_preview() {
773                    return T {
774                        R.heart() {}
775                    }
776                }
777            "#,
778        )
779        .expect("write asset file");
780
781        let mut system = AssetSystem::new();
782        system.load_module(asset_path).expect("load module");
783
784        let item = &system.items[0];
785        let mut world = World::default();
786        let mut render_assets = RenderAssets::new();
787        let mut emit = CommandQueue::new();
788        let root = system
789            .spawn_asset_component_uninitialized(
790                item,
791                vec![],
792                &mut world,
793                &mut render_assets,
794                &mut emit,
795            )
796            .expect("spawn procedural preview");
797
798        assert!(world.get_component_record(root).is_some());
799    }
800
801    #[test]
802    fn spawn_assets_panel_shows_assets_dir_in_title() {
803        let tmp_dir = temp_asset_directory();
804        let asset_path = tmp_dir.join("test_asset.mms");
805        std::fs::write(
806            &asset_path,
807            r#"
808                export fn example() {
809                    let root = T {}
810                    return root
811                }
812            "#,
813        )
814        .expect("write asset file");
815
816        let mut system = AssetSystem::new();
817        system.scan_assets_dir(&tmp_dir).expect("scan assets dir");
818
819        let mut world = World::default();
820        let mut render_assets = RenderAssets::new();
821        let mut emit = CommandQueue::new();
822        let parent = world.add_component_boxed_named("parent", Box::new(TransformComponent::new()));
823
824        let wrapper = system
825            .spawn_assets_panel(
826                &mut world,
827                &mut render_assets,
828                &mut emit,
829                parent,
830                (0.0, 0.0, 0.0),
831            )
832            .expect("spawn assets panel");
833
834        let title_bar = world
835            .find_component(wrapper, "#title_bar")
836            .expect("expected title bar component");
837
838        let title_position_transform = world
839            .children_of(title_bar)
840            .iter()
841            .copied()
842            .find(|&child| {
843                world
844                    .get_component_record(child)
845                    .map(|node| node.component_type == "transform")
846                    .unwrap_or(false)
847            })
848            .expect("expected title position transform child");
849
850        let title_text = world
851            .children_of(title_position_transform)
852            .iter()
853            .copied()
854            .find(|&child| {
855                world
856                    .get_component_record(child)
857                    .map(|node| node.component_type == "text")
858                    .unwrap_or(false)
859            })
860            .expect("expected title text component");
861
862        let text_value = world
863            .get_component_by_id_as::<crate::engine::ecs::component::TextComponent>(title_text)
864            .expect("expected text component")
865            .text
866            .clone();
867
868        assert_eq!(text_value, format!("Assets: {}", tmp_dir.display()));
869    }
870}