diff --git a/deny.toml b/deny.toml index 12d337b..24623b1 100644 --- a/deny.toml +++ b/deny.toml @@ -70,10 +70,18 @@ unlicensed = "deny" # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ - "MIT", "Apache-2.0", + "BSD-2-Clause", "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "ISC", + "LicenseRef-UFL-1.0", + "MIT", + "MIT-0", + "OFL-1.1", "Unicode-DFS-2016", + "Zlib", ] # List of explictly disallowed licenses # See https://spdx.org/licenses/ for list of possible licenses diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 546caa9..e5c61e4 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -1,7 +1,13 @@ [package] name = "retrolib" +description = "Tools for working with Retro game formats." +authors = ["Luke Street "] +license = "MIT OR Apache-2.0" version = "0.1.0" edition = "2021" +publish = false +repository = "https://github.com/PrimeDecomp/retrotool" +readme = "README.md" [dependencies] anyhow = "1.0.69" diff --git a/lib/src/format/cmdl.rs b/lib/src/format/cmdl.rs index 295be20..736e189 100644 --- a/lib/src/format/cmdl.rs +++ b/lib/src/format/cmdl.rs @@ -774,7 +774,7 @@ pub struct ModelData<'a> { impl ModelData<'_> { pub fn slice<'a>(data: &'a [u8], meta: &[u8], e: Endian) -> Result> { - let (cmdl_desc, mut cmdl_data, _) = FormDescriptor::slice(&data, Endian::Little)?; + let (cmdl_desc, mut cmdl_data, _) = FormDescriptor::slice(data, Endian::Little)?; ensure!(cmdl_desc.id == K_FORM_CMDL); ensure!(cmdl_desc.version_a == 114); ensure!(cmdl_desc.version_b == 125); diff --git a/lib/src/format/txtr.rs b/lib/src/format/txtr.rs index 1146e7f..d73167e 100644 --- a/lib/src/format/txtr.rs +++ b/lib/src/format/txtr.rs @@ -19,7 +19,7 @@ pub const K_CHUNK_GPU: FourCC = FourCC(*b"GPU "); #[binrw] #[repr(u32)] #[brw(repr(u32))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureType { _1D = 0, _2D = 1, @@ -35,7 +35,7 @@ pub enum ETextureType { #[binrw] #[repr(u8)] #[brw(repr(u8))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureWrap { ClampToEdge = 0, Repeat = 1, @@ -48,7 +48,7 @@ pub enum ETextureWrap { #[binrw] #[repr(u8)] #[brw(repr(u8))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureFilter { Nearest = 0, Linear = 1, @@ -57,7 +57,7 @@ pub enum ETextureFilter { #[binrw] #[repr(u8)] #[brw(repr(u8))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureMipFilter { Nearest = 0, Linear = 1, @@ -66,7 +66,7 @@ pub enum ETextureMipFilter { #[binrw] #[repr(u8)] #[brw(repr(u8))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureAnisotropicRatio { None = u8::MAX, _1 = 0, @@ -145,7 +145,7 @@ pub struct STextureMetaData { #[binrw] #[repr(u32)] #[brw(repr(u32))] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ETextureFormat { R8Unorm = 0, R8Snorm = 1, @@ -303,6 +303,31 @@ impl ETextureFormat { ) } + pub fn is_srgb(self) -> bool { + matches!( + self, + ETextureFormat::Rgba8Srgb + | ETextureFormat::RgbaBc1Srgb + | ETextureFormat::RgbaBc2Srgb + | ETextureFormat::RgbaBc3Srgb + | ETextureFormat::RgbaAstc4x4Srgb + | ETextureFormat::RgbaAstc5x4Srgb + | ETextureFormat::RgbaAstc5x5Srgb + | ETextureFormat::RgbaAstc6x5Srgb + | ETextureFormat::RgbaAstc6x6Srgb + | ETextureFormat::RgbaAstc8x5Srgb + | ETextureFormat::RgbaAstc8x6Srgb + | ETextureFormat::RgbaAstc8x8Srgb + | ETextureFormat::RgbaAstc10x5Srgb + | ETextureFormat::RgbaAstc10x6Srgb + | ETextureFormat::RgbaAstc10x8Srgb + | ETextureFormat::RgbaAstc10x10Srgb + | ETextureFormat::RgbaAstc12x10Srgb + | ETextureFormat::RgbaAstc12x12Srgb + | ETextureFormat::BptcUnormSrgb + ) + } + pub fn bytes_per_pixel(self) -> u32 { match self { ETextureFormat::R8Unorm diff --git a/retrotool-gui/src/loaders.rs b/retrotool-gui/src/loaders.rs index 22db65e..2eb13af 100644 --- a/retrotool-gui/src/loaders.rs +++ b/retrotool-gui/src/loaders.rs @@ -15,7 +15,7 @@ use bevy::{ prelude::*, }; use binrw::Endian; -use image::{ImageBuffer, RgbaImage}; +use image::{ RgbaImage}; use retrolib::{ format::{ foot::locate_meta, @@ -54,7 +54,7 @@ impl AssetIo for RetroAssetIo { if let Some(id) = path.file_stem().and_then(|name| Uuid::try_parse(&name.to_string_lossy()).ok()) { - // Load pak header only + // Find pak for UUID and load asset Box::pin(async move { let mut package_path: Option = None; if let Ok(packages) = self.packages.packages.read() { @@ -182,20 +182,20 @@ impl AssetLoader for PackageAssetLoader { #[derive(Debug, Clone, bevy::reflect::TypeUuid)] #[uuid = "83269869-1209-408e-8835-bc6f2496e828"] -pub struct TxtrData { - pub data: TextureData, +pub struct TextureAsset { + pub inner: TextureData, pub rgba: Option>, } -pub struct TxtrAssetLoader; +pub struct TextureAssetLoader; -impl Plugin for TxtrAssetLoader { +impl Plugin for TextureAssetLoader { fn build(&self, app: &mut App) { - app.add_asset::().add_asset_loader(TxtrAssetLoader); + app.add_asset::().add_asset_loader(TextureAssetLoader); } } -impl AssetLoader for TxtrAssetLoader { +impl AssetLoader for TextureAssetLoader { fn load<'a>( &'a self, bytes: &'a [u8], @@ -220,7 +220,7 @@ impl AssetLoader for TxtrAssetLoader { rgba = Some(image.into_raw()); } println!("Loaded texture {:?}", data.head); - load_context.set_default_asset(LoadedAsset::new(TxtrData { data, rgba })); + load_context.set_default_asset(LoadedAsset::new(TextureAsset { inner: data, rgba })); Ok(()) }) } diff --git a/retrotool-gui/src/main.rs b/retrotool-gui/src/main.rs index 119034f..fc8055f 100644 --- a/retrotool-gui/src/main.rs +++ b/retrotool-gui/src/main.rs @@ -1,12 +1,12 @@ mod loaders; -use std::fs::FileType; - use bevy::{ asset::LoadState, prelude::*, - render::render_resource::{ - Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, + render::{ + render_resource::{ + Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, + }, }, }; use bevy_egui::{ @@ -15,29 +15,18 @@ use bevy_egui::{ EguiContext, EguiPlugin, EguiSettings, }; use egui::TextureId; -use retrolib::format::txtr::K_FORM_TXTR; +use retrolib::format::{ + txtr::{ETextureFormat, ETextureType, K_FORM_TXTR}, + FourCC, +}; +use uuid::Uuid; use walkdir::{DirEntry, WalkDir}; use crate::loaders::{ - package_loader_system, PackageAssetLoader, PackageDirectory, RetroAssetIoPlugin, - TxtrAssetLoader, TxtrData, + package_loader_system, PackageAssetLoader, PackageDirectory, RetroAssetIoPlugin, TextureAsset, + TextureAssetLoader, }; -struct Images { - bevy_icon: Handle, - bevy_icon_inverted: Handle, -} - -impl FromWorld for Images { - fn from_world(world: &mut World) -> Self { - let asset_server = world.get_resource_mut::().unwrap(); - Self { - bevy_icon: asset_server.load("icon.png"), - bevy_icon_inverted: asset_server.load("icon_inverted.png"), - } - } -} - /// This example demonstrates the following functionality and use-cases of bevy_egui: /// - rendering loaded assets; /// - toggling hidpi scaling (by pressing '/' button); @@ -46,26 +35,17 @@ fn main() { App::new() .insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0))) .insert_resource(Msaa { samples: 1 }) + .insert_resource(bevy::render::settings::WgpuSettings { + features: bevy::render::settings::WgpuFeatures::TEXTURE_COMPRESSION_BC, + ..Default::default() + }) .init_resource::() .init_resource::() - .init_resource::() .init_resource::() - .add_plugins( - DefaultPlugins - .build() - // the custom asset io plugin must be inserted in-between the - // `CorePlugin' and `AssetPlugin`. It needs to be after the - // CorePlugin, so that the IO task pool has already been constructed. - // And it must be before the `AssetPlugin` so that the asset plugin - // doesn't create another instance of an asset server. In general, - // the AssetPlugin should still run so that other aspects of the - // asset system are initialized correctly. - .add_before::(RetroAssetIoPlugin), - ) + .add_plugins(DefaultPlugins.build().add_before::(RetroAssetIoPlugin)) .add_plugin(PackageAssetLoader) - .add_plugin(TxtrAssetLoader) + .add_plugin(TextureAssetLoader) .add_plugin(EguiPlugin) - .add_startup_system(configure_visuals) .add_startup_system(preload_package) .add_system(update_ui_scale_factor) .add_system(ui_example) @@ -74,9 +54,15 @@ fn main() { .run(); } +#[derive(Debug, Clone, Default, Eq, PartialEq)] +struct AssetRef { + id: Uuid, + kind: FourCC, +} + enum TabType { Directory, - Asset(HandleUntyped, Option), + Texture(AssetRef, Handle, Vec), Empty, } @@ -96,8 +82,9 @@ impl Default for UiState { struct TabViewer<'a> { server: Res<'a, AssetServer>, packages: Res<'a, Assets>, - textures: Res<'a, Assets>, + textures: Res<'a, Assets>, open_asset: ResMut<'a, OpenAsset>, + tab_assets: Vec, } impl egui_dock::TabViewer for TabViewer<'_> { @@ -106,7 +93,10 @@ impl egui_dock::TabViewer for TabViewer<'_> { fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) { match tab { TabType::Directory => { - for (_, package) in self.packages.iter() { + let mut packages_sorted = + self.packages.iter().map(|(_, p)| p).collect::>(); + packages_sorted.sort_by_key(|p| &p.name); + for package in packages_sorted { egui::CollapsingHeader::new(&package.name).show(ui, |ui| { for entry in &package.entries { let mut job = LayoutJob::simple( @@ -122,30 +112,71 @@ impl egui_dock::TabViewer for TabViewer<'_> { TextFormat::simple(FontId::monospace(12.0), Color32::WHITE), ); } - if egui::SelectableLabel::new(false, job).ui(ui).clicked() { - self.open_asset.0 = match entry.kind { + let asset_ref = AssetRef { id: entry.id, kind: entry.kind }; + if egui::SelectableLabel::new(self.tab_assets.contains(&asset_ref), job) + .ui(ui) + .clicked() + { + *self.open_asset = OpenAsset(asset_ref, match entry.kind { K_FORM_TXTR => Some( self.server - .load::(format!( + .load::(format!( "{}.{}", entry.id, entry.kind )) .into(), ), _ => None, - }; + }); } } }); } } - TabType::Asset(handle, image) => { - if let Some(txtr) = self.textures.get(&handle.typed_weak::()) { - if let Some(image) = image { - ui.add(egui::widgets::Image::new(*image, egui::Vec2 { - x: txtr.data.head.width as f32, - y: txtr.data.head.height as f32, - })); + TabType::Texture(asset_ref, handle, images) => { + ui.label(format!("{} {}", asset_ref.kind, asset_ref.id)); + if let Some(txtr) = self.textures.get(handle) { + ui.label(format!("Type: {:?}", txtr.inner.head.kind)); + ui.label(format!("Format: {:?}", txtr.inner.head.format)); + ui.label(format!( + "Dimensions: {}x{}x{} (mips: {})", + txtr.inner.head.width, + txtr.inner.head.height, + txtr.inner.head.layers, + txtr.inner.head.mip_sizes.len() + )); + if txtr.inner.head.kind == ETextureType::Cube && images.len() == 6 { + let width = txtr.inner.head.width * 2; + let height = txtr.inner.head.height * 2; + let (_, rect) = ui.allocate_space(egui::Vec2 { + x: (width * 4) as f32, + y: (height * 3) as f32, + }); + let size = egui::Vec2 { x: width as f32, y: height as f32 }; + let mut draw_image = |i: usize, x: u32, y: u32| { + let min = egui::Vec2 { x: (width * x) as f32, y: (height * y) as f32 }; + let max = egui::Vec2 { + x: (width * (x + 1)) as f32, + y: (height * (y + 1)) as f32, + }; + egui::widgets::Image::new(images[i], size).paint_at(ui, egui::Rect { + min: rect.min + min, + max: rect.min + max, + }); + }; + draw_image(2, 1, 0); + draw_image(1, 0, 1); + draw_image(4, 1, 1); + draw_image(0, 2, 1); + draw_image(5, 3, 1); + draw_image(3, 1, 2); + } else { + for image in images { + ui.add(egui::widgets::Image::new(*image, egui::Vec2 { + x: txtr.inner.head.width as f32, + y: txtr.inner.head.height as f32, + })); + } } } } @@ -156,22 +187,16 @@ impl egui_dock::TabViewer for TabViewer<'_> { fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText { match tab { TabType::Directory => "Directory".into(), - TabType::Asset(_, _) => "Asset view".into(), + TabType::Texture(asset_ref, _, _) => { + format!("{} {}", asset_ref.kind, asset_ref.id).into() + } TabType::Empty => "Placeholder".into(), } } } -fn configure_visuals(mut egui_ctx: ResMut) { - // egui_ctx - // .ctx_mut() - // .set_visuals(egui::Visuals { window_rounding: 0.0.into(), ..Default::default() }); -} - #[derive(Default, Resource)] struct Packages(Vec>); -#[derive(Default, Resource)] -struct Textures(Vec>); fn is_hidden(entry: &DirEntry) -> bool { entry.file_name().to_str().map(|s| s.starts_with('.')).unwrap_or(false) @@ -190,42 +215,147 @@ fn check_assets_ready( server: Res, mut loading: ResMut, mut ui_state: ResMut, - textures: Res>, + textures: Res>, mut egui_ctx: ResMut, mut images: ResMut>, ) { - if let Some(handle) = &loading.0 { + let OpenAsset(asset_ref, opt_handle) = &mut *loading; + if let Some(handle) = &*opt_handle { let state = server.get_load_state(handle); match state { LoadState::NotLoaded => {} LoadState::Loading => {} LoadState::Loaded => { - let handle = std::mem::take(&mut loading.0).unwrap(); - let txtr = textures.get(&handle.typed_weak::()).unwrap(); - let image = if let Some(rgba) = &txtr.rgba { + let handle = std::mem::take(opt_handle).unwrap().typed::(); + let txtr = textures.get(&handle).unwrap(); + let mut texture_ids = Vec::new(); + if let Some(rgba) = &txtr.rgba { let image_handle = images.add(Image { data: rgba.clone(), texture_descriptor: TextureDescriptor { label: None, size: Extent3d { - width: txtr.data.head.width, - height: txtr.data.head.height, + width: txtr.inner.head.width, + height: txtr.inner.head.height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: TextureDimension::D2, - format: TextureFormat::Rgba8Unorm, + format: if txtr.inner.head.format.is_srgb() { + TextureFormat::Rgba8UnormSrgb + } else { + TextureFormat::Rgba8Unorm + }, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, }, sampler_descriptor: Default::default(), texture_view_descriptor: None, }); - Some(egui_ctx.add_image(image_handle)) + texture_ids.push(egui_ctx.add_image(image_handle)); } else { - None + let array_stride: usize = (txtr.inner.head.mip_sizes.iter().sum::() + / txtr.inner.head.layers) + as usize; + for layer in 0..txtr.inner.head.layers as usize { + let image_handle = images.add(Image { + data: txtr.inner.data + [layer * array_stride..(layer * array_stride) + array_stride] + .to_vec(), + texture_descriptor: TextureDescriptor { + label: None, + size: Extent3d { + width: txtr.inner.head.width, + height: txtr.inner.head.height, + depth_or_array_layers: 1, + }, + mip_level_count: txtr.inner.head.mip_sizes.len() as u32, + sample_count: 1, + dimension: match txtr.inner.head.kind { + ETextureType::_1D => TextureDimension::D1, + ETextureType::_2D => TextureDimension::D2, + ETextureType::_3D => TextureDimension::D3, + ETextureType::Cube => TextureDimension::D2, + ETextureType::_1DArray => TextureDimension::D1, + ETextureType::_2DArray => TextureDimension::D2, + ETextureType::_2DMultisample => TextureDimension::D2, + ETextureType::_2DMultisampleArray => TextureDimension::D2, + ETextureType::CubeArray => TextureDimension::D2, + }, + format: match txtr.inner.head.format { + ETextureFormat::R8Unorm => TextureFormat::Rgba8Unorm, + ETextureFormat::R8Snorm => TextureFormat::R8Snorm, + ETextureFormat::R8Uint => TextureFormat::R8Uint, + ETextureFormat::R8Sint => TextureFormat::R8Sint, + ETextureFormat::R16Unorm => TextureFormat::R16Unorm, + ETextureFormat::R16Snorm => TextureFormat::R16Snorm, + ETextureFormat::R16Uint => TextureFormat::R16Uint, + ETextureFormat::R16Sint => TextureFormat::R16Sint, + ETextureFormat::R16Float => TextureFormat::R16Float, + ETextureFormat::R32Uint => TextureFormat::R32Uint, + ETextureFormat::R32Sint => TextureFormat::R32Sint, + ETextureFormat::Rgba8Unorm => TextureFormat::Rgba8Unorm, + ETextureFormat::Rgba8Srgb => TextureFormat::Rgba8UnormSrgb, + ETextureFormat::Rgba16Float => TextureFormat::Rgba16Float, + ETextureFormat::Rgba32Float => TextureFormat::Rgba32Float, + ETextureFormat::Depth16Unorm => TextureFormat::Depth16Unorm, + ETextureFormat::Depth16Unorm2 => TextureFormat::Depth16Unorm, + ETextureFormat::Depth24S8Unorm => { + TextureFormat::Depth24PlusStencil8 + } + ETextureFormat::Depth32Float => TextureFormat::Depth32Float, + ETextureFormat::RgbaBc1Unorm => TextureFormat::Bc1RgbaUnorm, + ETextureFormat::RgbaBc1Srgb => TextureFormat::Bc1RgbaUnormSrgb, + ETextureFormat::RgbaBc2Unorm => TextureFormat::Bc2RgbaUnorm, + ETextureFormat::RgbaBc2Srgb => TextureFormat::Bc2RgbaUnormSrgb, + ETextureFormat::RgbaBc3Unorm => TextureFormat::Bc3RgbaUnorm, + ETextureFormat::RgbaBc3Srgb => TextureFormat::Bc3RgbaUnormSrgb, + ETextureFormat::RgbaBc4Unorm => TextureFormat::Bc4RUnorm, + ETextureFormat::RgbaBc4Snorm => TextureFormat::Bc4RSnorm, + ETextureFormat::RgbaBc5Unorm => TextureFormat::Bc5RgUnorm, + ETextureFormat::RgbaBc5Snorm => TextureFormat::Bc5RgSnorm, + ETextureFormat::Rg11B10Float => TextureFormat::Rg11b10Float, + ETextureFormat::R32Float => TextureFormat::R32Float, + ETextureFormat::Rg8Unorm => TextureFormat::Rg8Unorm, + ETextureFormat::Rg8Snorm => TextureFormat::Rg8Snorm, + ETextureFormat::Rg8Uint => TextureFormat::Rg8Uint, + ETextureFormat::Rg8Sint => TextureFormat::Rg8Sint, + ETextureFormat::Rg16Float => TextureFormat::Rg16Float, + ETextureFormat::Rg16Unorm => TextureFormat::Rg16Unorm, + ETextureFormat::Rg16Snorm => TextureFormat::Rg16Snorm, + ETextureFormat::Rg16Uint => TextureFormat::Rg16Uint, + ETextureFormat::Rg16Sint => TextureFormat::Rg16Sint, + ETextureFormat::Rgb10A2Unorm => TextureFormat::Rgb10a2Unorm, + ETextureFormat::Rg32Uint => TextureFormat::Rg32Uint, + ETextureFormat::Rg32Sint => TextureFormat::Rg32Sint, + ETextureFormat::Rg32Float => TextureFormat::Rg32Float, + ETextureFormat::Rgba16Unorm => TextureFormat::Rgba16Unorm, + ETextureFormat::Rgba16Snorm => TextureFormat::Rgba16Snorm, + ETextureFormat::Rgba16Uint => TextureFormat::Rgba16Uint, + ETextureFormat::Rgba16Sint => TextureFormat::Rgba16Sint, + ETextureFormat::Rgba32Uint => TextureFormat::Rgba32Uint, + ETextureFormat::Rgba32Sint => TextureFormat::Rgba32Sint, + ETextureFormat::BptcUfloat => TextureFormat::Bc6hRgbUfloat, + ETextureFormat::BptcSfloat => TextureFormat::Bc6hRgbSfloat, + ETextureFormat::BptcUnorm => TextureFormat::Bc7RgbaUnorm, + ETextureFormat::BptcUnormSrgb => { + TextureFormat::Bc7RgbaUnormSrgb + } + _ => todo!(), + }, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + }, + sampler_descriptor: Default::default(), + texture_view_descriptor: None, + }); + texture_ids.push(egui_ctx.add_image(image_handle)); + } }; - ui_state.tree.push_to_first_leaf(TabType::Asset(handle, image)); + ui_state.tree.push_to_first_leaf(TabType::Texture( + asset_ref.clone(), + handle, + texture_ids, + )); } LoadState::Failed => {} LoadState::Unloaded => {} @@ -251,13 +381,13 @@ fn update_ui_scale_factor( } #[derive(Default, Resource)] -struct OpenAsset(Option); +struct OpenAsset(AssetRef, Option); fn ui_example( mut egui_ctx: ResMut, mut ui_state: ResMut, packages: Res>, - textures: Res>, + textures: Res>, server: Res, open_asset: ResMut, ) { @@ -272,7 +402,27 @@ fn ui_example( }); }); + let mut tab_assets = vec![]; + for node in ui_state.tree.iter() { + if let egui_dock::Node::Leaf { tabs, .. } = node { + for tab in tabs { + match tab { + TabType::Directory => {} + TabType::Texture(asset_ref, _, _) => { + tab_assets.push(asset_ref.clone()); + } + TabType::Empty => {} + } + } + } + } egui_dock::DockArea::new(&mut ui_state.tree) .style(egui_dock::Style::from_egui(egui_ctx.ctx_mut().style().as_ref())) - .show(egui_ctx.ctx_mut(), &mut TabViewer { server, packages, textures, open_asset }); + .show(egui_ctx.ctx_mut(), &mut TabViewer { + server, + packages, + textures, + open_asset, + tab_assets, + }); }