use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Read;
use std::io::Write;
use gltf_json as json;
use json::validation::Checked::Valid;
use json::validation::USize64;
use rootcause::Report;
use thiserror::Error;
use crate::game_params::types::ArmorMap;
use crate::models::assets_bin::PrototypeDatabase;
use crate::models::geometry::MergedGeometry;
use crate::models::merged_models::MergedModels;
use crate::models::merged_models::SpaceInstances;
use crate::models::speedtree::SpeedTreeMesh;
use crate::models::terrain::Terrain;
use crate::models::vertex_format;
use crate::models::vertex_format::AttributeSemantic;
use crate::models::vertex_format::VertexFormat;
use crate::models::visual::VisualPrototype;
use super::texture;
#[derive(Debug, Error)]
pub enum ExportError {
#[error("no LOD {0} in visual (max LOD: {1})")]
LodOutOfRange(usize, usize),
#[error("no .visual files found in directory: {0}")]
NoVisualFiles(String),
#[error("render set name 0x{0:08X} not found among render sets")]
RenderSetNotFound(u32),
#[error("vertices mapping id 0x{id:08X} not found in geometry")]
VerticesMappingNotFound { id: u32 },
#[error("indices mapping id 0x{id:08X} not found in geometry")]
IndicesMappingNotFound { id: u32 },
#[error("buffer index {index} out of range (count: {count})")]
BufferIndexOutOfRange { index: usize, count: usize },
#[error("vertex decode error: {0}")]
VertexDecode(String),
#[error("index decode error: {0}")]
IndexDecode(String),
#[error("vertex format stride mismatch: format says {format_stride}, geometry says {geo_stride}")]
StrideMismatch { format_stride: usize, geo_stride: usize },
#[error("glTF serialization error: {0}")]
Serialize(String),
#[error("I/O error: {0}")]
Io(String),
}
struct DecodedPrimitive {
positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
material_name: String,
mfm_stem: Option<String>,
mfm_full_path: Option<String>,
mfm_path_id: u64,
}
pub fn export_glb(
visual: &VisualPrototype,
geometry: &MergedGeometry,
db: &PrototypeDatabase<'_>,
lod: usize,
texture_set: &TextureSet,
damaged: bool,
writer: &mut impl Write,
) -> Result<(), Report<ExportError>> {
if visual.lods.is_empty() {
return Err(Report::new(ExportError::LodOutOfRange(lod, 0)));
}
if lod >= visual.lods.len() {
return Err(Report::new(ExportError::LodOutOfRange(lod, visual.lods.len() - 1)));
}
let lod_entry = &visual.lods[lod];
let self_id_index = db.build_self_id_index();
let primitives = collect_primitives(visual, geometry, Some(db), Some(&self_id_index), lod_entry, damaged, None)?;
if primitives.is_empty() {
eprintln!("Warning: no primitives found for LOD {lod}");
}
let mut root = json::Root {
asset: json::Asset {
version: "2.0".to_string(),
generator: Some("wowsunpack".to_string()),
..Default::default()
},
..Default::default()
};
let mut bin_data: Vec<u8> = Vec::new();
let mut gltf_primitives = Vec::new();
let mut mat_cache = MaterialCache::new();
for prim in &primitives {
let gltf_prim = add_primitive_to_root(&mut root, &mut bin_data, prim, texture_set, &mut mat_cache)?;
gltf_primitives.push(gltf_prim);
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
if !bin_data.is_empty() {
let buffer = root.push(json::Buffer {
byte_length: USize64::from(bin_data.len()),
uri: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
for bv in root.buffer_views.iter_mut() {
bv.buffer = buffer;
}
}
let mesh = root.push(json::Mesh {
primitives: gltf_primitives,
weights: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let root_node = root.push(json::Node { mesh: Some(mesh), ..Default::default() });
let scene = root.push(json::Scene {
nodes: vec![root_node],
name: None,
extensions: Default::default(),
extras: Default::default(),
});
root.scene = Some(scene);
add_variants_extension(&mut root, texture_set);
let json_string =
json::serialize::to_string(&root).map_err(|e| Report::new(ExportError::Serialize(e.to_string())))?;
let glb = gltf::binary::Glb {
header: gltf::binary::Header {
magic: *b"glTF",
version: 2,
length: 0, },
json: Cow::Owned(json_string.into_bytes()),
bin: if bin_data.is_empty() { None } else { Some(Cow::Owned(bin_data)) },
};
glb.to_writer(writer).map_err(|e| Report::new(ExportError::Io(e.to_string())))?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct SpaceBounds {
pub min_x: f32,
pub max_x: f32,
pub min_z: f32,
pub max_z: f32,
}
pub struct TerrainConfig<'a> {
pub terrain: &'a Terrain,
pub bounds: &'a SpaceBounds,
pub step: u32,
pub sea_level: f32,
pub lightmap_path: Option<String>,
}
pub struct WaterConfig<'a> {
pub bounds: &'a SpaceBounds,
pub sea_level: f32,
}
pub struct MapEnvironment<'a> {
pub terrain: Option<TerrainConfig<'a>>,
pub water: Option<WaterConfig<'a>>,
}
pub struct MapMesh {
pub name: String,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
pub albedo_texture: Option<usize>,
pub base_color: [f32; 4],
pub alpha_blend: bool,
pub alpha_cutoff: Option<f32>,
}
pub struct MapModelInstance {
pub mesh_range: std::ops::Range<usize>,
pub transform: [f32; 16],
}
pub struct MapScene {
pub model_meshes: Vec<MapMesh>,
pub model_instances: Vec<MapModelInstance>,
pub textures: Vec<Vec<u8>>,
pub terrain: Option<MapMesh>,
pub water: Option<MapMesh>,
pub bounds: SpaceBounds,
pub vegetation_instances: Vec<(usize, Vec<[f32; 3]>)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct MapMaterialKey {
albedo_texture: Option<usize>,
base_color_bits: [u32; 4],
alpha_blend: bool,
alpha_cutoff_bits: Option<u32>,
}
type MapMaterialCache = HashMap<MapMaterialKey, json::Index<json::Material>>;
pub struct VegetationSpecies {
pub mesh: SpeedTreeMesh,
pub albedo_png: Option<Vec<u8>>,
}
pub struct VegetationData {
pub species: Vec<VegetationSpecies>,
pub instances: Vec<(usize, [f32; 3])>,
}
pub struct BuildMapSceneParams<'a> {
pub merged: &'a MergedModels,
pub geometry: &'a MergedGeometry<'a>,
pub space: Option<&'a SpaceInstances>,
pub db: Option<&'a PrototypeDatabase<'a>>,
pub lod: usize,
pub vfs: Option<&'a vfs::VfsPath>,
pub env: &'a MapEnvironment<'a>,
pub bounds: SpaceBounds,
pub max_texture_size: Option<u32>,
pub vegetation: Option<&'a VegetationData>,
pub vegetation_density: f32,
}
pub fn build_map_scene(params: &BuildMapSceneParams<'_>) -> Result<MapScene, Report<ExportError>> {
let BuildMapSceneParams {
merged,
geometry,
space,
db,
lod,
vfs,
env,
ref bounds,
max_texture_size,
vegetation,
vegetation_density,
} = *params;
let self_id_index = db.map(|db| db.build_self_id_index());
let mut textures: Vec<Vec<u8>> = Vec::new();
let mut texture_cache: HashMap<String, Option<usize>> = HashMap::new();
let path_to_model: HashMap<u64, usize> = merged.models.iter().enumerate().map(|(i, r)| (r.path_id, i)).collect();
let mut model_meshes: Vec<MapMesh> = Vec::new();
let mut model_mesh_ranges: Vec<std::ops::Range<usize>> = Vec::new();
for (model_idx, record) in merged.models.iter().enumerate() {
let vp = &record.visual_proto;
let range_start = model_meshes.len();
if vp.lods.is_empty() || lod >= vp.lods.len() {
model_mesh_ranges.push(range_start..range_start);
continue;
}
let lod_entry = &vp.lods[lod];
let primitives = match collect_primitives(vp, geometry, db, self_id_index.as_ref(), lod_entry, false, None) {
Ok(p) => p,
Err(e) => {
eprintln!("Warning: model[{model_idx}]: {e}");
model_mesh_ranges.push(range_start..range_start);
continue;
}
};
for prim in primitives {
let albedo_texture = if let Some(vfs) = vfs
&& let Some(mfm_path) = &prim.mfm_full_path
{
*texture_cache.entry(mfm_path.clone()).or_insert_with(|| {
texture::load_or_bake_albedo(
vfs,
mfm_path,
prim.mfm_path_id,
db,
self_id_index.as_ref(),
max_texture_size,
)
.map(|png_bytes| {
let idx = textures.len();
textures.push(png_bytes);
idx
})
})
} else {
None
};
model_meshes.push(MapMesh {
name: prim.material_name,
positions: prim.positions,
normals: prim.normals,
uvs: prim.uvs,
indices: prim.indices,
albedo_texture,
base_color: [1.0, 1.0, 1.0, 1.0],
alpha_blend: false,
alpha_cutoff: None,
});
}
model_mesh_ranges.push(range_start..model_meshes.len());
}
let mut model_instances: Vec<MapModelInstance> = Vec::new();
let mut vegetation_instances: Vec<(usize, Vec<[f32; 3]>)> = Vec::new();
if let Some(space) = space {
for inst in &space.instances {
let Some(&model_idx) = path_to_model.get(&inst.path_id) else {
continue;
};
let range = &model_mesh_ranges[model_idx];
if range.is_empty() {
continue;
}
model_instances.push(MapModelInstance { mesh_range: range.clone(), transform: inst.transform.0 });
}
} else {
for (model_idx, range) in model_mesh_ranges.iter().enumerate() {
if range.is_empty() {
continue;
}
let _ = model_idx;
model_instances.push(MapModelInstance {
mesh_range: range.clone(),
transform: [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
});
}
}
let terrain = env.terrain.as_ref().map(|cfg| {
let mut mesh = generate_terrain_mesh(cfg);
if let Some(vfs) = vfs
&& let Some(lm_path) = &cfg.lightmap_path
{
let dds_bytes: Option<Vec<u8>> = (|| {
let mut buf = Vec::new();
vfs.join(lm_path).ok()?.open_file().ok()?.read_to_end(&mut buf).ok()?;
if buf.is_empty() { None } else { Some(buf) }
})();
match dds_bytes {
Some(dds_bytes) => match texture::dds_to_png_resized(&dds_bytes, max_texture_size) {
Ok(mut png_bytes) => {
texture::force_png_opaque(&mut png_bytes);
let idx = textures.len();
textures.push(png_bytes);
mesh.albedo_texture = Some(idx);
mesh.base_color = [1.0, 1.0, 1.0, 1.0];
eprintln!(" Terrain lightmap loaded: {lm_path}");
}
Err(e) => eprintln!(" Warning: failed to decode terrain lightmap: {e}"),
},
None => eprintln!(" Warning: terrain lightmap not found: {lm_path}"),
}
}
mesh
});
let water = env.water.as_ref().map(generate_water_mesh);
if let Some(veg) = vegetation {
let mut species_mesh_ranges: Vec<Option<usize>> = Vec::new();
for (sp_idx, species) in veg.species.iter().enumerate() {
if species.mesh.positions.is_empty() || species.mesh.indices.is_empty() {
species_mesh_ranges.push(None);
continue;
}
let albedo_texture = species.albedo_png.as_ref().map(|png| {
let idx = textures.len();
textures.push(png.clone());
idx
});
let mesh_idx = model_meshes.len();
model_meshes.push(MapMesh {
name: format!("Vegetation_{sp_idx}"),
positions: species.mesh.positions.clone(),
normals: species.mesh.normals.clone(),
uvs: species.mesh.uvs.clone(),
indices: species.mesh.indices.clone(),
albedo_texture,
base_color: [1.0, 1.0, 1.0, 1.0],
alpha_blend: false,
alpha_cutoff: Some(0.5),
});
species_mesh_ranges.push(Some(mesh_idx));
}
let num_species = veg.species.len();
let mut per_species: Vec<Vec<[f32; 3]>> = vec![Vec::new(); num_species];
let mut kept = 0usize;
if vegetation_density > 0.0 {
let inv_cell = 1.0 / vegetation_density;
let mut occupied: HashSet<(usize, i32, i32)> = HashSet::new();
for &(sp_idx, [x, y, z]) in &veg.instances {
if species_mesh_ranges.get(sp_idx).and_then(|v| *v).is_none() {
continue;
}
let cx = (x * inv_cell).floor() as i32;
let cz = (z * inv_cell).floor() as i32;
if !occupied.insert((sp_idx, cx, cz)) {
continue;
}
per_species[sp_idx].push([x, y, -z]);
kept += 1;
}
} else {
for &(sp_idx, [x, y, z]) in &veg.instances {
if species_mesh_ranges.get(sp_idx).and_then(|v| *v).is_none() {
continue;
}
per_species[sp_idx].push([x, y, -z]);
kept += 1;
}
}
for (sp_idx, positions) in per_species.into_iter().enumerate() {
if positions.is_empty() {
continue;
}
if let Some(Some(mesh_idx)) = species_mesh_ranges.get(sp_idx) {
vegetation_instances.push((*mesh_idx, positions));
}
}
eprintln!(
" Vegetation: {} species, {} instances (kept {kept}, cell {vegetation_density}m)",
veg.species.len(),
veg.instances.len(),
);
}
let tex_tried = texture_cache.len();
let tex_loaded = textures.len();
eprintln!(
"Map scene: {} model meshes, {} instances, {tex_loaded}/{tex_tried} textures loaded",
model_meshes.len(),
model_instances.len(),
);
if terrain.is_some() {
eprintln!(" Terrain mesh generated");
}
if water.is_some() {
eprintln!(" Water plane generated");
}
Ok(MapScene {
model_meshes,
model_instances,
textures,
terrain,
water,
bounds: bounds.clone(),
vegetation_instances,
})
}
fn generate_terrain_mesh(cfg: &TerrainConfig<'_>) -> MapMesh {
let terrain = cfg.terrain;
let bounds = cfg.bounds;
let step = cfg.step.max(1);
let sea = cfg.sea_level;
let src_w = terrain.width as usize;
let src_h = terrain.height as usize;
let out_w = (src_w - 1) / step as usize + 1;
let out_h = (src_h - 1) / step as usize + 1;
let world_width = bounds.max_x - bounds.min_x;
let world_depth = bounds.max_z - bounds.min_z;
let cell_x = world_width / (src_w - 1) as f32;
let cell_z = world_depth / (src_h - 1) as f32;
let height_at = |sx: usize, sy: usize| -> f32 { terrain.heightmap[sy * src_w + sx].max(sea) };
let mut above_sea = vec![false; out_w * out_h];
for gy in 0..out_h {
let sy = (gy * step as usize).min(src_h - 1);
for gx in 0..out_w {
let sx = (gx * step as usize).min(src_w - 1);
above_sea[gy * out_w + gx] = terrain.heightmap[sy * src_w + sx] > sea;
}
}
let vert_count = out_w * out_h;
let mut positions = Vec::with_capacity(vert_count);
let mut normals = Vec::with_capacity(vert_count);
let mut uvs = Vec::with_capacity(vert_count);
for gy in 0..out_h {
let sy = (gy * step as usize).min(src_h - 1);
for gx in 0..out_w {
let sx = (gx * step as usize).min(src_w - 1);
let world_x = bounds.min_x + sx as f32 * cell_x;
let world_z = bounds.min_z + sy as f32 * cell_z;
let height = height_at(sx, sy);
positions.push([world_x, height, -world_z]);
let u = sx as f32 / (src_w - 1) as f32;
let v = sy as f32 / (src_h - 1) as f32;
uvs.push([u, v]);
}
}
for gy in 0..out_h {
let sy = (gy * step as usize).min(src_h - 1);
for gx in 0..out_w {
let sx = (gx * step as usize).min(src_w - 1);
let sx_left = sx.saturating_sub(step as usize);
let sx_right = (sx + step as usize).min(src_w - 1);
let sy_up = sy.saturating_sub(step as usize);
let sy_down = (sy + step as usize).min(src_h - 1);
let h_left = height_at(sx_left, sy);
let h_right = height_at(sx_right, sy);
let h_up = height_at(sx, sy_up);
let h_down = height_at(sx, sy_down);
let dx = (sx_right - sx_left) as f32 * cell_x;
let dz = (sy_down - sy_up) as f32 * cell_z;
let dh_x = h_right - h_left;
let dh_z = h_down - h_up;
let nx = -dh_x * dz;
let ny = dx * dz;
let nz = dx * dh_z;
let len = (nx * nx + ny * ny + nz * nz).sqrt();
if len > 1e-10 {
normals.push([nx / len, ny / len, nz / len]);
} else {
normals.push([0.0, 1.0, 0.0]);
}
}
}
let mut indices = Vec::new();
for gy in 0..(out_h - 1) {
for gx in 0..(out_w - 1) {
let tl_idx = gy * out_w + gx;
let tr_idx = tl_idx + 1;
let bl_idx = (gy + 1) * out_w + gx;
let br_idx = bl_idx + 1;
if !above_sea[tl_idx] && !above_sea[tr_idx] && !above_sea[bl_idx] && !above_sea[br_idx] {
continue;
}
let tl = tl_idx as u32;
let tr = tr_idx as u32;
let bl = bl_idx as u32;
let br = br_idx as u32;
indices.push(tl);
indices.push(bl);
indices.push(tr);
indices.push(tr);
indices.push(bl);
indices.push(br);
}
}
eprintln!(
" Terrain: {}×{} grid (step {}), {} vertices, {} triangles (culled submerged)",
out_w,
out_h,
step,
positions.len(),
indices.len() / 3,
);
MapMesh {
name: "Terrain".to_string(),
positions,
normals,
uvs,
indices,
albedo_texture: None,
base_color: [0.3, 0.35, 0.25, 1.0],
alpha_blend: false,
alpha_cutoff: None,
}
}
fn generate_water_mesh(cfg: &WaterConfig<'_>) -> MapMesh {
let bounds = cfg.bounds;
let y = cfg.sea_level;
let positions = vec![
[bounds.min_x, y, -bounds.min_z],
[bounds.max_x, y, -bounds.min_z],
[bounds.max_x, y, -bounds.max_z],
[bounds.min_x, y, -bounds.max_z],
];
let normals = vec![[0.0, 1.0, 0.0]; 4];
let uvs = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let indices = vec![0, 3, 1, 1, 3, 2];
MapMesh {
name: "Water".to_string(),
positions,
normals,
uvs,
indices,
albedo_texture: None,
base_color: [0.1, 0.3, 0.5, 0.85],
alpha_blend: true,
alpha_cutoff: None,
}
}
pub fn export_map_scene_glb(scene: &MapScene, writer: &mut impl Write) -> Result<(), Report<ExportError>> {
let mut root = json::Root {
asset: json::Asset {
version: "2.0".to_string(),
generator: Some("wowsunpack".to_string()),
..Default::default()
},
..Default::default()
};
let mut bin_data: Vec<u8> = Vec::new();
let mut gltf_texture_cache: HashMap<usize, json::Index<json::Texture>> = HashMap::new();
for (tex_idx, png_bytes) in scene.textures.iter().enumerate() {
let byte_offset = bin_data.len();
bin_data.extend_from_slice(png_bytes);
pad_to_4(&mut bin_data);
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(png_bytes.len()),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let image = root.push(json::Image {
buffer_view: Some(bv),
mime_type: Some(json::image::MimeType("image/png".to_string())),
uri: None,
name: Some(format!("texture_{tex_idx}")),
extensions: Default::default(),
extras: Default::default(),
});
let tex = root.push(json::Texture {
source: image,
sampler: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
gltf_texture_cache.insert(tex_idx, tex);
}
let mut mat_cache: MapMaterialCache = HashMap::new();
let mut gltf_mesh_cache: HashMap<usize, json::Index<json::Mesh>> = HashMap::new();
let mut scene_nodes: Vec<json::Index<json::Node>> = Vec::new();
let build_gltf_mesh = |_mesh_idx: usize,
mesh: &MapMesh,
root: &mut json::Root,
bin_data: &mut Vec<u8>,
mat_cache: &mut MapMaterialCache,
gltf_texture_cache: &HashMap<usize, json::Index<json::Texture>>|
-> json::Index<json::Mesh> {
let prim = build_map_mesh_primitive(root, bin_data, mesh, mat_cache, gltf_texture_cache);
root.push(json::Mesh {
primitives: vec![prim],
weights: None,
name: Some(mesh.name.clone()),
extensions: Default::default(),
extras: Default::default(),
})
};
for (i, inst) in scene.model_instances.iter().enumerate() {
let mut instance_meshes = Vec::new();
for mesh_idx in inst.mesh_range.clone() {
let gltf_mesh = if let Some(&cached) = gltf_mesh_cache.get(&mesh_idx) {
cached
} else {
let mesh = &scene.model_meshes[mesh_idx];
let m = build_gltf_mesh(mesh_idx, mesh, &mut root, &mut bin_data, &mut mat_cache, &gltf_texture_cache);
gltf_mesh_cache.insert(mesh_idx, m);
m
};
instance_meshes.push(gltf_mesh);
}
if instance_meshes.is_empty() {
continue;
}
if instance_meshes.len() == 1 {
let node = root.push(json::Node {
mesh: Some(instance_meshes[0]),
name: Some(format!("Instance_{i}")),
matrix: Some(inst.transform),
..Default::default()
});
scene_nodes.push(node);
} else {
let children: Vec<json::Index<json::Node>> = instance_meshes
.iter()
.enumerate()
.map(|(j, &mesh)| {
root.push(json::Node {
mesh: Some(mesh),
name: Some(format!("Instance_{i}_part_{j}")),
..Default::default()
})
})
.collect();
let parent = root.push(json::Node {
children: Some(children),
name: Some(format!("Instance_{i}")),
matrix: Some(inst.transform),
..Default::default()
});
scene_nodes.push(parent);
}
}
if let Some(terrain) = &scene.terrain {
let prim = build_map_mesh_primitive(&mut root, &mut bin_data, terrain, &mut mat_cache, &gltf_texture_cache);
let gltf_mesh = root.push(json::Mesh {
primitives: vec![prim],
weights: None,
name: Some("Terrain".to_string()),
extensions: Default::default(),
extras: Default::default(),
});
let node =
root.push(json::Node { mesh: Some(gltf_mesh), name: Some("Terrain".to_string()), ..Default::default() });
scene_nodes.push(node);
}
if let Some(water) = &scene.water {
let prim = build_map_mesh_primitive(&mut root, &mut bin_data, water, &mut mat_cache, &gltf_texture_cache);
let gltf_mesh = root.push(json::Mesh {
primitives: vec![prim],
weights: None,
name: Some("Water".to_string()),
extensions: Default::default(),
extras: Default::default(),
});
let node =
root.push(json::Node { mesh: Some(gltf_mesh), name: Some("Water".to_string()), ..Default::default() });
scene_nodes.push(node);
}
for (mesh_idx, positions) in &scene.vegetation_instances {
let gltf_mesh = if let Some(&cached) = gltf_mesh_cache.get(mesh_idx) {
cached
} else {
let mesh = &scene.model_meshes[*mesh_idx];
let m = build_gltf_mesh(*mesh_idx, mesh, &mut root, &mut bin_data, &mut mat_cache, &gltf_texture_cache);
gltf_mesh_cache.insert(*mesh_idx, m);
m
};
for (i, pos) in positions.iter().enumerate() {
let node = root.push(json::Node {
mesh: Some(gltf_mesh),
name: Some(format!("Tree_{mesh_idx}_{i}")),
translation: Some(*pos),
..Default::default()
});
scene_nodes.push(node);
}
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
if !bin_data.is_empty() {
let buffer = root.push(json::Buffer {
byte_length: USize64::from(bin_data.len()),
uri: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
for bv in root.buffer_views.iter_mut() {
bv.buffer = buffer;
}
}
let scene = root.push(json::Scene {
nodes: scene_nodes,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
root.scene = Some(scene);
let json_string =
json::serialize::to_string(&root).map_err(|e| Report::new(ExportError::Serialize(e.to_string())))?;
let glb = gltf::binary::Glb {
header: gltf::binary::Header { magic: *b"glTF", version: 2, length: 0 },
json: Cow::Owned(json_string.into_bytes()),
bin: if bin_data.is_empty() { None } else { Some(Cow::Owned(bin_data)) },
};
glb.to_writer(writer).map_err(|e| Report::new(ExportError::Io(e.to_string())))?;
Ok(())
}
fn build_map_mesh_primitive(
root: &mut json::Root,
bin_data: &mut Vec<u8>,
mesh: &MapMesh,
mat_cache: &mut MapMaterialCache,
gltf_texture_cache: &HashMap<usize, json::Index<json::Texture>>,
) -> json::mesh::Primitive {
let mut attributes = BTreeMap::new();
let pos_accessor = if !mesh.positions.is_empty() {
let (min, max) = bounding_coords(&mesh.positions);
let byte_offset = bin_data.len();
for pos in &mesh.positions {
bin_data.extend_from_slice(&pos[0].to_le_bytes());
bin_data.extend_from_slice(&pos[1].to_le_bytes());
bin_data.extend_from_slice(&pos[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(mesh.positions.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: Some(json::Value::from(min.to_vec())),
max: Some(json::Value::from(max.to_vec())),
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let norm_accessor = if !mesh.normals.is_empty() {
let byte_offset = bin_data.len();
for n in &mesh.normals {
bin_data.extend_from_slice(&n[0].to_le_bytes());
bin_data.extend_from_slice(&n[1].to_le_bytes());
bin_data.extend_from_slice(&n[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(mesh.normals.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let uv_accessor = if !mesh.uvs.is_empty() {
let byte_offset = bin_data.len();
for uv in &mesh.uvs {
bin_data.extend_from_slice(&uv[0].to_le_bytes());
bin_data.extend_from_slice(&uv[1].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(mesh.uvs.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec2),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let indices_accessor = if !mesh.indices.is_empty() {
let byte_offset = bin_data.len();
for &idx in &mesh.indices {
bin_data.extend_from_slice(&idx.to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ElementArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(mesh.indices.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::U32)),
type_: Valid(json::accessor::Type::Scalar),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
if let Some(pos) = pos_accessor {
attributes.insert(Valid(json::mesh::Semantic::Positions), pos);
}
if let Some(norm) = norm_accessor {
attributes.insert(Valid(json::mesh::Semantic::Normals), norm);
}
if let Some(uv) = uv_accessor {
attributes.insert(Valid(json::mesh::Semantic::TexCoords(0)), uv);
}
let mat_key = MapMaterialKey {
albedo_texture: mesh.albedo_texture,
base_color_bits: mesh.base_color.map(|c| c.to_bits()),
alpha_blend: mesh.alpha_blend,
alpha_cutoff_bits: mesh.alpha_cutoff.map(|c| c.to_bits()),
};
let material = *mat_cache.entry(mat_key).or_insert_with(|| {
if let Some(tex_idx) = mesh.albedo_texture
&& let Some(&gltf_tex) = gltf_texture_cache.get(&tex_idx)
{
let (alpha_mode, alpha_cutoff_val, double_sided) = if let Some(cutoff) = mesh.alpha_cutoff {
(Valid(json::material::AlphaMode::Mask), Some(json::material::AlphaCutoff(cutoff)), true)
} else {
(Valid(json::material::AlphaMode::Opaque), None, false)
};
root.push(json::Material {
name: Some(mesh.name.clone()),
pbr_metallic_roughness: json::material::PbrMetallicRoughness {
base_color_texture: Some(json::texture::Info {
index: gltf_tex,
tex_coord: 0,
extensions: Default::default(),
extras: Default::default(),
}),
base_color_factor: json::material::PbrBaseColorFactor([1.0, 1.0, 1.0, 1.0]),
..Default::default()
},
alpha_mode,
alpha_cutoff: alpha_cutoff_val,
double_sided,
..Default::default()
})
} else if mesh.alpha_blend {
root.push(json::Material {
name: Some(mesh.name.clone()),
pbr_metallic_roughness: json::material::PbrMetallicRoughness {
base_color_factor: json::material::PbrBaseColorFactor(mesh.base_color),
..Default::default()
},
alpha_mode: Valid(json::material::AlphaMode::Blend),
..Default::default()
})
} else {
root.push(json::Material {
name: Some(mesh.name.clone()),
pbr_metallic_roughness: json::material::PbrMetallicRoughness {
base_color_factor: json::material::PbrBaseColorFactor(mesh.base_color),
..Default::default()
},
..Default::default()
})
}
});
json::mesh::Primitive {
attributes,
indices: indices_accessor,
material: Some(material),
mode: Valid(json::mesh::Mode::Triangles),
targets: None,
extensions: Default::default(),
extras: Default::default(),
}
}
pub fn export_merged_models_glb(
merged: &MergedModels,
geometry: &MergedGeometry,
space: Option<&SpaceInstances>,
db: Option<&PrototypeDatabase<'_>>,
lod: usize,
writer: &mut impl Write,
) -> Result<(), Report<ExportError>> {
let mut root = json::Root {
asset: json::Asset {
version: "2.0".to_string(),
generator: Some("wowsunpack".to_string()),
..Default::default()
},
..Default::default()
};
let mut bin_data: Vec<u8> = Vec::new();
let mut mat_cache = MaterialCache::new();
let empty_textures = TextureSet::empty();
let mut scene_nodes = Vec::new();
let mut exported = 0usize;
let self_id_index = db.map(|db| db.build_self_id_index());
let path_to_model: HashMap<u64, usize> = merged.models.iter().enumerate().map(|(i, r)| (r.path_id, i)).collect();
let mut mesh_cache: HashMap<usize, json::Index<json::Mesh>> = HashMap::new();
let build_mesh = |model_idx: usize,
root: &mut json::Root,
bin_data: &mut Vec<u8>,
mat_cache: &mut MaterialCache|
-> Result<Option<json::Index<json::Mesh>>, Report<ExportError>> {
let record = &merged.models[model_idx];
let vp = &record.visual_proto;
if vp.lods.is_empty() || lod >= vp.lods.len() {
return Ok(None);
}
let lod_entry = &vp.lods[lod];
let primitives = match collect_primitives(vp, geometry, db, self_id_index.as_ref(), lod_entry, false, None) {
Ok(p) => p,
Err(e) => {
eprintln!("Warning: model[{model_idx}]: {e}");
return Ok(None);
}
};
if primitives.is_empty() {
return Ok(None);
}
let mut gltf_primitives = Vec::new();
for prim in &primitives {
let gltf_prim = add_primitive_to_root(root, bin_data, prim, &empty_textures, mat_cache)?;
gltf_primitives.push(gltf_prim);
}
let name = format!("Model_{model_idx}");
let mesh = root.push(json::Mesh {
primitives: gltf_primitives,
weights: None,
name: Some(name),
extensions: Default::default(),
extras: Default::default(),
});
Ok(Some(mesh))
};
if let Some(space) = space {
for (i, inst) in space.instances.iter().enumerate() {
let Some(&model_idx) = path_to_model.get(&inst.path_id) else {
continue;
};
let mesh = if let Some(&cached) = mesh_cache.get(&model_idx) {
cached
} else {
match build_mesh(model_idx, &mut root, &mut bin_data, &mut mat_cache)? {
Some(m) => {
mesh_cache.insert(model_idx, m);
m
}
None => continue,
}
};
let node = root.push(json::Node {
mesh: Some(mesh),
name: Some(format!("Instance_{i}")),
matrix: Some(inst.transform.0),
..Default::default()
});
scene_nodes.push(node);
exported += 1;
}
} else {
for (i, _record) in merged.models.iter().enumerate() {
let Some(mesh) = build_mesh(i, &mut root, &mut bin_data, &mut mat_cache)? else {
continue;
};
let node =
root.push(json::Node { mesh: Some(mesh), name: Some(format!("Model_{i}")), ..Default::default() });
scene_nodes.push(node);
exported += 1;
}
}
if exported == 0 {
eprintln!("Warning: no models exported for LOD {lod}");
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
if !bin_data.is_empty() {
let buffer = root.push(json::Buffer {
byte_length: USize64::from(bin_data.len()),
uri: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
for bv in root.buffer_views.iter_mut() {
bv.buffer = buffer;
}
}
let scene = root.push(json::Scene {
nodes: scene_nodes,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
root.scene = Some(scene);
let json_string =
json::serialize::to_string(&root).map_err(|e| Report::new(ExportError::Serialize(e.to_string())))?;
let glb = gltf::binary::Glb {
header: gltf::binary::Header { magic: *b"glTF", version: 2, length: 0 },
json: Cow::Owned(json_string.into_bytes()),
bin: if bin_data.is_empty() { None } else { Some(Cow::Owned(bin_data)) },
};
glb.to_writer(writer).map_err(|e| Report::new(ExportError::Io(e.to_string())))?;
Ok(())
}
pub fn export_geometry_raw(geometry: &MergedGeometry, writer: &mut impl Write) -> Result<(), Report<ExportError>> {
let pair_count = geometry.vertices_mapping.len().min(geometry.indices_mapping.len());
if geometry.vertices_mapping.len() != geometry.indices_mapping.len() {
eprintln!(
"Warning: {} vertex mappings vs {} index mappings; exporting {} pairs",
geometry.vertices_mapping.len(),
geometry.indices_mapping.len(),
pair_count,
);
}
if pair_count == 0 {
eprintln!("Warning: no mapping entries found; producing empty GLB");
}
let mut root = json::Root {
asset: json::Asset {
version: "2.0".to_string(),
generator: Some("wowsunpack".to_string()),
..Default::default()
},
..Default::default()
};
let mut bin_data: Vec<u8> = Vec::new();
let mut gltf_primitives = Vec::new();
for i in 0..pair_count {
let vert_mapping = &geometry.vertices_mapping[i];
let idx_mapping = &geometry.indices_mapping[i];
let vbuf_idx = vert_mapping.merged_buffer_index as usize;
if vbuf_idx >= geometry.merged_vertices.len() {
eprintln!("Warning: primitive {i}: vertex buffer index {vbuf_idx} out of range, skipping");
continue;
}
let vert_proto = &geometry.merged_vertices[vbuf_idx];
let ibuf_idx = idx_mapping.merged_buffer_index as usize;
if ibuf_idx >= geometry.merged_indices.len() {
eprintln!("Warning: primitive {i}: index buffer index {ibuf_idx} out of range, skipping");
continue;
}
let idx_proto = &geometry.merged_indices[ibuf_idx];
let decoded_vertices = match vert_proto.data.decode() {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: primitive {i}: vertex decode error: {e:?}, skipping");
continue;
}
};
let decoded_indices = match idx_proto.data.decode() {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: primitive {i}: index decode error: {e:?}, skipping");
continue;
}
};
let format = vertex_format::parse_vertex_format(&vert_proto.format_name);
let stride = vert_proto.stride_in_bytes as usize;
let vert_offset = vert_mapping.items_offset as usize;
let vert_count = vert_mapping.items_count as usize;
let vert_start = vert_offset * stride;
let vert_end = vert_start + vert_count * stride;
if vert_end > decoded_vertices.len() {
eprintln!(
"Warning: primitive {i}: vertex range {vert_start}..{vert_end} exceeds buffer size {}, skipping",
decoded_vertices.len()
);
continue;
}
let vert_slice = &decoded_vertices[vert_start..vert_end];
let idx_offset = idx_mapping.items_offset as usize;
let idx_count = idx_mapping.items_count as usize;
let index_size = idx_proto.index_size as usize;
let idx_start = idx_offset * index_size;
let idx_end = idx_start + idx_count * index_size;
if idx_end > decoded_indices.len() {
eprintln!(
"Warning: primitive {i}: index range {idx_start}..{idx_end} exceeds buffer size {}, skipping",
decoded_indices.len()
);
continue;
}
let idx_slice = &decoded_indices[idx_start..idx_end];
let indices: Vec<u32> = match index_size {
2 => idx_slice.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as u32).collect(),
4 => idx_slice.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(),
_ => {
eprintln!("Warning: primitive {i}: unsupported index size {index_size}, skipping");
continue;
}
};
let verts = unpack_vertices(vert_slice, stride, &format);
let prim = DecodedPrimitive {
positions: verts.positions,
normals: verts.normals,
uvs: verts.uvs,
indices,
material_name: format!("Primitive_{i}"),
mfm_stem: None,
mfm_full_path: None,
mfm_path_id: 0,
};
let empty_textures = TextureSet::empty();
let mut mat_cache = MaterialCache::new();
let gltf_prim = add_primitive_to_root(&mut root, &mut bin_data, &prim, &empty_textures, &mut mat_cache)?;
gltf_primitives.push(gltf_prim);
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
if !bin_data.is_empty() {
let buffer = root.push(json::Buffer {
byte_length: USize64::from(bin_data.len()),
uri: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
for bv in root.buffer_views.iter_mut() {
bv.buffer = buffer;
}
}
let mesh = root.push(json::Mesh {
primitives: gltf_primitives,
weights: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let root_node = root.push(json::Node { mesh: Some(mesh), ..Default::default() });
let scene = root.push(json::Scene {
nodes: vec![root_node],
name: None,
extensions: Default::default(),
extras: Default::default(),
});
root.scene = Some(scene);
let json_string =
json::serialize::to_string(&root).map_err(|e| Report::new(ExportError::Serialize(e.to_string())))?;
let glb = gltf::binary::Glb {
header: gltf::binary::Header { magic: *b"glTF", version: 2, length: 0 },
json: Cow::Owned(json_string.into_bytes()),
bin: if bin_data.is_empty() { None } else { Some(Cow::Owned(bin_data)) },
};
glb.to_writer(writer).map_err(|e| Report::new(ExportError::Io(e.to_string())))?;
println!(" Exported {pair_count} raw primitives");
Ok(())
}
const INTACT_EXCLUDE: &[&str] = &["_crack_", "_hide"];
const DAMAGED_EXCLUDE: &[&str] = &["_patch_", "_hide"];
fn collect_primitives(
visual: &VisualPrototype,
geometry: &MergedGeometry,
db: Option<&PrototypeDatabase<'_>>,
self_id_index: Option<&HashMap<u64, usize>>,
lod: &crate::models::visual::Lod,
damaged: bool,
barrel_pitch: Option<&BarrelPitch>,
) -> Result<Vec<DecodedPrimitive>, Report<ExportError>> {
let mut result = Vec::new();
let exclude = if damaged { DAMAGED_EXCLUDE } else { INTACT_EXCLUDE };
for &rs_name_id in &lod.render_set_names {
let rs = visual
.render_sets
.iter()
.find(|rs| rs.name_id == rs_name_id)
.ok_or_else(|| Report::new(ExportError::RenderSetNotFound(rs_name_id)))?;
if let Some(db) = db
&& let Some(rs_name) = db.strings.get_string_by_id(rs_name_id)
&& exclude.iter().any(|sub| rs_name.contains(sub))
{
continue;
}
let vertices_mapping_id = rs.vertices_mapping_id;
let indices_mapping_id = rs.indices_mapping_id;
let vert_mapping = geometry
.vertices_mapping
.iter()
.find(|m| m.mapping_id == vertices_mapping_id)
.ok_or_else(|| Report::new(ExportError::VerticesMappingNotFound { id: vertices_mapping_id }))?;
let idx_mapping = geometry
.indices_mapping
.iter()
.find(|m| m.mapping_id == indices_mapping_id)
.ok_or_else(|| Report::new(ExportError::IndicesMappingNotFound { id: indices_mapping_id }))?;
let vbuf_idx = vert_mapping.merged_buffer_index as usize;
if vbuf_idx >= geometry.merged_vertices.len() {
return Err(Report::new(ExportError::BufferIndexOutOfRange {
index: vbuf_idx,
count: geometry.merged_vertices.len(),
}));
}
let vert_proto = &geometry.merged_vertices[vbuf_idx];
let ibuf_idx = idx_mapping.merged_buffer_index as usize;
if ibuf_idx >= geometry.merged_indices.len() {
return Err(Report::new(ExportError::BufferIndexOutOfRange {
index: ibuf_idx,
count: geometry.merged_indices.len(),
}));
}
let idx_proto = &geometry.merged_indices[ibuf_idx];
let decoded_vertices =
vert_proto.data.decode().map_err(|e| Report::new(ExportError::VertexDecode(format!("{e:?}"))))?;
let decoded_indices =
idx_proto.data.decode().map_err(|e| Report::new(ExportError::IndexDecode(format!("{e:?}"))))?;
let format = vertex_format::parse_vertex_format(&vert_proto.format_name);
let stride = vert_proto.stride_in_bytes as usize;
if format.stride != stride {
eprintln!(
"Warning: format \"{}\" parsed stride {} != geometry stride {}; using geometry stride",
vert_proto.format_name, format.stride, stride
);
}
let vert_offset = vert_mapping.items_offset as usize;
let vert_count = vert_mapping.items_count as usize;
let vert_start = vert_offset * stride;
let vert_end = vert_start + vert_count * stride;
if vert_end > decoded_vertices.len() {
return Err(Report::new(ExportError::VertexDecode(format!(
"vertex range {}..{} exceeds buffer size {}",
vert_start,
vert_end,
decoded_vertices.len()
))));
}
let vert_slice = &decoded_vertices[vert_start..vert_end];
let idx_offset = idx_mapping.items_offset as usize;
let idx_count = idx_mapping.items_count as usize;
let index_size = idx_proto.index_size as usize;
let idx_start = idx_offset * index_size;
let idx_end = idx_start + idx_count * index_size;
if idx_end > decoded_indices.len() {
return Err(Report::new(ExportError::IndexDecode(format!(
"index range {}..{} exceeds buffer size {}",
idx_start,
idx_end,
decoded_indices.len()
))));
}
let idx_slice = &decoded_indices[idx_start..idx_end];
let indices: Vec<u32> = match index_size {
2 => idx_slice.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as u32).collect(),
4 => idx_slice.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(),
_ => {
return Err(Report::new(ExportError::IndexDecode(format!("unsupported index size: {index_size}"))));
}
};
let mut verts = unpack_vertices(vert_slice, stride, &format);
if let Some(bp) = barrel_pitch {
apply_barrel_pitch(&mut verts.positions, &mut verts.normals, vert_slice, stride, &format, bp);
}
let material_name = db
.and_then(|db| db.strings.get_string_by_id(rs.material_name_id))
.map(|s| s.to_string())
.unwrap_or_else(|| format!("material_0x{:08X}", rs.material_name_id));
let (mfm_stem, mfm_full_path) = if rs.material_mfm_path_id != 0 {
self_id_index
.and_then(|idx_map| idx_map.get(&rs.material_mfm_path_id))
.and_then(|&idx| {
db.map(|db| {
let full_path = db.reconstruct_path(idx, self_id_index.unwrap());
let leaf = &db.paths_storage[idx].name;
let stem = leaf.strip_suffix(".mfm").unwrap_or(leaf).to_string();
(Some(stem), Some(full_path))
})
})
.unwrap_or((None, None))
} else {
(None, None)
};
result.push(DecodedPrimitive {
positions: verts.positions,
normals: verts.normals,
uvs: verts.uvs,
indices,
material_name,
mfm_stem,
mfm_full_path,
mfm_path_id: rs.material_mfm_path_id,
});
}
Ok(result)
}
struct UnpackedVertices {
positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
}
fn unpack_vertices(data: &[u8], stride: usize, format: &VertexFormat) -> UnpackedVertices {
let count = data.len() / stride;
let mut positions = Vec::with_capacity(count);
let mut normals = Vec::with_capacity(count);
let mut uvs = Vec::with_capacity(count);
let pos_attr = format.attributes.iter().find(|a| a.semantic == AttributeSemantic::Position);
let norm_attr = format.attributes.iter().find(|a| a.semantic == AttributeSemantic::Normal);
let uv_attr = format.attributes.iter().find(|a| a.semantic == AttributeSemantic::TexCoord0);
for i in 0..count {
let base = i * stride;
if let Some(attr) = pos_attr {
let off = base + attr.offset;
let x = f32::from_le_bytes(data[off..off + 4].try_into().unwrap());
let y = f32::from_le_bytes(data[off + 4..off + 8].try_into().unwrap());
let z = f32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap());
positions.push([x, y, -z]);
}
if let Some(attr) = norm_attr {
let off = base + attr.offset;
let packed = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
let [nx, ny, nz] = vertex_format::unpack_normal(packed);
normals.push([nx, ny, -nz]);
}
if let Some(attr) = uv_attr {
let off = base + attr.offset;
let packed = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
uvs.push(vertex_format::unpack_uv(packed));
}
}
UnpackedVertices { positions, normals, uvs }
}
pub(super) fn negate_z_transform(m: [f32; 16]) -> [f32; 16] {
[
m[0], m[1], -m[2], m[3], m[4], m[5], -m[6], m[7], -m[8], -m[9], m[10], m[11], m[12], m[13], -m[14], m[15], ]
}
fn apply_barrel_pitch(
positions: &mut [[f32; 3]],
normals: &mut [[f32; 3]],
vert_data: &[u8],
stride: usize,
format: &VertexFormat,
bp: &BarrelPitch,
) {
use crate::models::vertex_format::AttributeSemantic;
let bone_idx_attr = format.attributes.iter().find(|a| a.semantic == AttributeSemantic::BoneIndices);
let Some(bone_attr) = bone_idx_attr else {
return; };
let m = &bp.pitch_matrix;
let count = positions.len();
for i in 0..count {
let base = i * stride;
let off = base + bone_attr.offset;
let dominant_bone = vert_data[off];
if !bp.barrel_bone_indices.contains(&dominant_bone) {
continue;
}
let [px, py, pz] = positions[i];
positions[i] = [
m[0] * px + m[4] * py + m[8] * pz + m[12],
m[1] * px + m[5] * py + m[9] * pz + m[13],
m[2] * px + m[6] * py + m[10] * pz + m[14],
];
if i < normals.len() {
let [nx, ny, nz] = normals[i];
normals[i] = [
m[0] * nx + m[4] * ny + m[8] * nz,
m[1] * nx + m[5] * ny + m[9] * nz,
m[2] * nx + m[6] * ny + m[10] * nz,
];
}
}
}
pub struct TextureSet {
pub base: HashMap<String, Vec<u8>>,
pub camo_schemes: Vec<(String, HashMap<String, Vec<u8>>)>,
pub tiled_uv_transforms: HashMap<(usize, String), [f32; 4]>,
}
impl TextureSet {
pub fn empty() -> Self {
Self { base: HashMap::new(), camo_schemes: Vec::new(), tiled_uv_transforms: HashMap::new() }
}
}
struct CachedMaterial {
default_mat: json::Index<json::Material>,
variant_mats: Vec<Option<json::Index<json::Material>>>,
}
struct MaterialCache {
materials: HashMap<String, CachedMaterial>,
}
impl MaterialCache {
fn new() -> Self {
Self { materials: HashMap::new() }
}
}
fn create_textured_material(
root: &mut json::Root,
bin_data: &mut Vec<u8>,
png_bytes: &[u8],
material_name: &str,
image_name: Option<String>,
uv_transform: Option<[f32; 4]>,
) -> json::Index<json::Material> {
let byte_offset = bin_data.len();
bin_data.extend_from_slice(png_bytes);
pad_to_4(bin_data);
let byte_length = png_bytes.len();
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let image = root.push(json::Image {
buffer_view: Some(bv),
mime_type: Some(json::image::MimeType("image/png".to_string())),
uri: None,
name: image_name,
extensions: Default::default(),
extras: Default::default(),
});
let sampler = root.push(json::texture::Sampler {
mag_filter: Some(Valid(json::texture::MagFilter::Linear)),
min_filter: Some(Valid(json::texture::MinFilter::LinearMipmapLinear)),
wrap_s: Valid(json::texture::WrappingMode::Repeat),
wrap_t: Valid(json::texture::WrappingMode::Repeat),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let texture = root.push(json::Texture {
source: image,
sampler: Some(sampler),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let tex_transform_ext = uv_transform.map(|t| json::extensions::texture::Info {
texture_transform: Some(json::extensions::texture::TextureTransform {
scale: json::extensions::texture::TextureTransformScale(t[0..2].try_into().unwrap()),
offset: json::extensions::texture::TextureTransformOffset(t[2..4].try_into().unwrap()),
rotation: Default::default(),
tex_coord: Some(0),
extras: Default::default(),
}),
});
let texture_info =
json::texture::Info { index: texture, tex_coord: 0, extensions: tex_transform_ext, extras: Default::default() };
root.push(json::Material {
name: Some(material_name.to_string()),
pbr_metallic_roughness: json::material::PbrMetallicRoughness {
base_color_texture: Some(texture_info),
..Default::default()
},
..Default::default()
})
}
fn create_untextured_material(root: &mut json::Root, material_name: &str) -> json::Index<json::Material> {
root.push(json::Material { name: Some(material_name.to_string()), ..Default::default() })
}
fn add_primitive_to_root(
root: &mut json::Root,
bin_data: &mut Vec<u8>,
prim: &DecodedPrimitive,
texture_set: &TextureSet,
mat_cache: &mut MaterialCache,
) -> Result<json::mesh::Primitive, Report<ExportError>> {
let mut attributes = BTreeMap::new();
let pos_accessor = if !prim.positions.is_empty() {
let (min, max) = bounding_coords(&prim.positions);
let byte_offset = bin_data.len();
for pos in &prim.positions {
bin_data.extend_from_slice(&pos[0].to_le_bytes());
bin_data.extend_from_slice(&pos[1].to_le_bytes());
bin_data.extend_from_slice(&pos[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(prim.positions.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: Some(json::Value::from(min.to_vec())),
max: Some(json::Value::from(max.to_vec())),
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let norm_accessor = if !prim.normals.is_empty() {
let byte_offset = bin_data.len();
for n in &prim.normals {
bin_data.extend_from_slice(&n[0].to_le_bytes());
bin_data.extend_from_slice(&n[1].to_le_bytes());
bin_data.extend_from_slice(&n[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(prim.normals.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let uv_accessor = if !prim.uvs.is_empty() {
let byte_offset = bin_data.len();
for uv in &prim.uvs {
bin_data.extend_from_slice(&uv[0].to_le_bytes());
bin_data.extend_from_slice(&uv[1].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(prim.uvs.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec2),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
let indices_accessor = if !prim.indices.is_empty() {
let byte_offset = bin_data.len();
for &idx in &prim.indices {
bin_data.extend_from_slice(&idx.to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ElementArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
Some(root.push(json::Accessor {
buffer_view: Some(bv),
byte_offset: Some(USize64(0)),
count: USize64::from(prim.indices.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::U32)),
type_: Valid(json::accessor::Type::Scalar),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
}))
} else {
None
};
if let Some(pos) = pos_accessor {
attributes.insert(Valid(json::mesh::Semantic::Positions), pos);
}
if let Some(norm) = norm_accessor {
attributes.insert(Valid(json::mesh::Semantic::Normals), norm);
}
if let Some(uv) = uv_accessor {
attributes.insert(Valid(json::mesh::Semantic::TexCoords(0)), uv);
}
let cache_key = prim.mfm_stem.clone().unwrap_or_else(|| prim.material_name.clone());
if !mat_cache.materials.contains_key(&cache_key) {
let default_mat = if let Some(png_bytes) = prim.mfm_stem.as_ref().and_then(|stem| texture_set.base.get(stem)) {
create_textured_material(root, bin_data, png_bytes, &prim.material_name, prim.mfm_stem.clone(), None)
} else {
create_untextured_material(root, &prim.material_name)
};
let variant_mats: Vec<Option<json::Index<json::Material>>> = texture_set
.camo_schemes
.iter()
.enumerate()
.map(|(scheme_idx, (scheme_name, scheme_textures))| {
prim.mfm_stem.as_ref().and_then(|stem| {
let png_bytes = scheme_textures.get(stem)?;
let uv_xform = texture_set.tiled_uv_transforms.get(&(scheme_idx, stem.clone())).copied();
Some(create_textured_material(
root,
bin_data,
png_bytes,
&format!("{} [{}]", prim.material_name, scheme_name),
Some(format!("{stem}_{scheme_name}")),
uv_xform,
))
})
})
.collect();
mat_cache.materials.insert(cache_key.clone(), CachedMaterial { default_mat, variant_mats });
}
let cached = &mat_cache.materials[&cache_key];
let prim_variants_ext = if !texture_set.camo_schemes.is_empty() {
let mut mappings = Vec::new();
for (variant_idx, variant_mat) in cached.variant_mats.iter().enumerate() {
let mat_index = variant_mat.unwrap_or(cached.default_mat);
mappings.push(json::extensions::mesh::Mapping {
material: mat_index.value() as u32,
variants: vec![variant_idx as u32],
});
}
Some(json::extensions::mesh::KhrMaterialsVariants { mappings })
} else {
None
};
Ok(json::mesh::Primitive {
attributes,
indices: indices_accessor,
material: Some(cached.default_mat),
mode: Valid(json::mesh::Mode::Triangles),
targets: None,
extensions: Some(json::extensions::mesh::Primitive { khr_materials_variants: prim_variants_ext }),
extras: Default::default(),
})
}
fn add_armor_primitive_to_root(
root: &mut json::Root,
bin_data: &mut Vec<u8>,
armor: &ArmorSubModel,
) -> Result<json::mesh::Primitive, Report<ExportError>> {
let mut attributes = BTreeMap::new();
let (min, max) = bounding_coords(&armor.positions);
let byte_offset = bin_data.len();
for pos in &armor.positions {
bin_data.extend_from_slice(&pos[0].to_le_bytes());
bin_data.extend_from_slice(&pos[1].to_le_bytes());
bin_data.extend_from_slice(&pos[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let pos_bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let pos_acc = root.push(json::Accessor {
buffer_view: Some(pos_bv),
byte_offset: Some(USize64(0)),
count: USize64::from(armor.positions.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: Some(json::Value::from(min.to_vec())),
max: Some(json::Value::from(max.to_vec())),
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
});
attributes.insert(Valid(json::mesh::Semantic::Positions), pos_acc);
let byte_offset = bin_data.len();
for n in &armor.normals {
bin_data.extend_from_slice(&n[0].to_le_bytes());
bin_data.extend_from_slice(&n[1].to_le_bytes());
bin_data.extend_from_slice(&n[2].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let norm_bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let norm_acc = root.push(json::Accessor {
buffer_view: Some(norm_bv),
byte_offset: Some(USize64(0)),
count: USize64::from(armor.normals.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec3),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
});
attributes.insert(Valid(json::mesh::Semantic::Normals), norm_acc);
if !armor.colors.is_empty() {
let byte_offset = bin_data.len();
for c in &armor.colors {
bin_data.extend_from_slice(&c[0].to_le_bytes());
bin_data.extend_from_slice(&c[1].to_le_bytes());
bin_data.extend_from_slice(&c[2].to_le_bytes());
bin_data.extend_from_slice(&c[3].to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let color_bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let color_acc = root.push(json::Accessor {
buffer_view: Some(color_bv),
byte_offset: Some(USize64(0)),
count: USize64::from(armor.colors.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::F32)),
type_: Valid(json::accessor::Type::Vec4),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
});
attributes.insert(Valid(json::mesh::Semantic::Colors(0)), color_acc);
}
let byte_offset = bin_data.len();
for &idx in &armor.indices {
bin_data.extend_from_slice(&idx.to_le_bytes());
}
pad_to_4(bin_data);
let byte_length = bin_data.len() - byte_offset;
let idx_bv = root.push(json::buffer::View {
buffer: json::Index::new(0),
byte_length: USize64::from(byte_length),
byte_offset: Some(USize64::from(byte_offset)),
byte_stride: None,
target: Some(Valid(json::buffer::Target::ElementArrayBuffer)),
name: None,
extensions: Default::default(),
extras: Default::default(),
});
let idx_acc = root.push(json::Accessor {
buffer_view: Some(idx_bv),
byte_offset: Some(USize64(0)),
count: USize64::from(armor.indices.len()),
component_type: Valid(json::accessor::GenericComponentType(json::accessor::ComponentType::U32)),
type_: Valid(json::accessor::Type::Scalar),
min: None,
max: None,
name: None,
normalized: false,
sparse: None,
extensions: Default::default(),
extras: Default::default(),
});
let material = root.push(json::Material {
name: Some(format!("armor_{}", armor.name)),
alpha_mode: Valid(json::material::AlphaMode::Blend),
pbr_metallic_roughness: json::material::PbrMetallicRoughness {
base_color_factor: json::material::PbrBaseColorFactor([1.0, 1.0, 1.0, 1.0]),
metallic_factor: json::material::StrengthFactor(0.0),
roughness_factor: json::material::StrengthFactor(0.8),
..Default::default()
},
double_sided: true,
..Default::default()
});
Ok(json::mesh::Primitive {
attributes,
indices: Some(idx_acc),
material: Some(material),
mode: Valid(json::mesh::Mode::Triangles),
targets: None,
extensions: None,
extras: Default::default(),
})
}
fn add_variants_extension(root: &mut json::Root, texture_set: &TextureSet) {
if texture_set.camo_schemes.is_empty() {
return;
}
let variants: Vec<json::extensions::scene::khr_materials_variants::Variant> = texture_set
.camo_schemes
.iter()
.map(|(name, _)| json::extensions::scene::khr_materials_variants::Variant { name: name.clone() })
.collect();
let ext = json::extensions::root::KhrMaterialsVariants { variants };
root.extensions = Some(json::extensions::root::Root { khr_materials_variants: Some(ext) });
root.extensions_used.push("KHR_materials_variants".to_string());
if !texture_set.tiled_uv_transforms.is_empty() {
root.extensions_used.push("KHR_texture_transform".to_string());
}
}
fn pad_to_4(data: &mut Vec<u8>) {
while !data.len().is_multiple_of(4) {
data.push(0);
}
}
fn bounding_coords(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
let mut min = [f32::MAX; 3];
let mut max = [f32::MIN; 3];
for p in points {
for i in 0..3 {
min[i] = f32::min(min[i], p[i]);
max[i] = f32::max(max[i], p[i]);
}
}
(min, max)
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ArmorTriangleInfo {
pub model_index: u32,
pub triangle_index: u32,
pub material_id: u8,
pub material_name: String,
pub zone: String,
pub thickness_mm: f32,
pub layers: Vec<f32>,
pub color: [f32; 4],
pub hidden: bool,
}
fn lookup_all_layers(mat_id: u32, mount_armor: Option<&ArmorMap>, armor_map: Option<&ArmorMap>) -> (Vec<f32>, f32) {
let layers_map = mount_armor.and_then(|m| m.get(&mat_id)).or_else(|| armor_map.and_then(|m| m.get(&mat_id)));
let layers: Vec<f32> = layers_map.map(|m| m.values().copied().filter(|&v| v > 0.0).collect()).unwrap_or_default();
let total: f32 = layers.iter().sum();
(layers, total)
}
fn lookup_thickness(
mat_id: u32,
model_index: u32,
mount_armor: Option<&ArmorMap>,
armor_map: Option<&ArmorMap>,
) -> f32 {
mount_armor
.and_then(|m| m.get(&mat_id))
.and_then(|layers| layers.get(&model_index))
.copied()
.or_else(|| armor_map.and_then(|m| m.get(&mat_id)).and_then(|layers| layers.get(&model_index)).copied())
.unwrap_or(0.0)
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InteractiveArmorMesh {
pub name: String,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub indices: Vec<u32>,
pub colors: Vec<[f32; 4]>,
pub triangle_info: Vec<ArmorTriangleInfo>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub transform: Option<[f32; 16]>,
}
impl InteractiveArmorMesh {
pub fn from_armor_model(
armor: &crate::models::geometry::ArmorModel,
armor_map: Option<&ArmorMap>,
mount_armor: Option<&ArmorMap>,
) -> Self {
let tri_count = armor.triangles.len();
let vert_count = tri_count * 3;
let mut positions = Vec::with_capacity(vert_count);
let mut normals = Vec::with_capacity(vert_count);
let mut indices = Vec::with_capacity(vert_count);
let mut colors = Vec::with_capacity(vert_count);
let mut triangle_info = Vec::with_capacity(tri_count);
for (ti, tri) in armor.triangles.iter().enumerate() {
let mat_name = collision_material_name(tri.material_id);
let zone = zone_from_material_name(mat_name).to_string();
let mat_id = tri.material_id as u32;
let layer = tri.layer_index as u32;
let thickness_mm = lookup_thickness(mat_id, layer, mount_armor, armor_map);
let (all_layers, _) = lookup_all_layers(mat_id, mount_armor, armor_map);
let color = thickness_to_color(thickness_mm);
for v in 0..3 {
let [px, py, pz] = tri.vertices[v];
positions.push([px, py, -pz]);
let [nx, ny, nz] = tri.normals[v];
normals.push([nx, ny, -nz]);
indices.push((ti * 3 + v) as u32);
colors.push(color);
}
let hidden = matches!(zone.as_str(), "Hull" | "SteeringGear" | "Default");
triangle_info.push(ArmorTriangleInfo {
model_index: layer,
triangle_index: ti as u32,
material_id: tri.material_id,
material_name: mat_name.to_string(),
zone,
thickness_mm,
layers: if all_layers.len() > 1 { all_layers } else { vec![thickness_mm] },
color,
hidden,
});
}
Self { name: armor.name.clone(), positions, normals, indices, colors, triangle_info, transform: None }
}
}
pub struct ArmorSubModel {
pub name: String,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub indices: Vec<u32>,
pub colors: Vec<[f32; 4]>,
pub transform: Option<[f32; 16]>,
}
impl ArmorSubModel {
pub fn from_armor_model(
armor: &crate::models::geometry::ArmorModel,
armor_map: Option<&ArmorMap>,
mount_armor: Option<&ArmorMap>,
) -> Self {
let tri_count = armor.triangles.len();
let vert_count = tri_count * 3;
let mut positions = Vec::with_capacity(vert_count);
let mut normals = Vec::with_capacity(vert_count);
let mut indices = Vec::with_capacity(vert_count);
let mut colors = Vec::with_capacity(vert_count);
for (ti, tri) in armor.triangles.iter().enumerate() {
let mat_id = tri.material_id as u32;
let layer = tri.layer_index as u32;
let thickness_mm = lookup_thickness(mat_id, layer, mount_armor, armor_map);
let color = thickness_to_color(thickness_mm);
for v in 0..3 {
let [px, py, pz] = tri.vertices[v];
positions.push([px, py, -pz]);
let [nx, ny, nz] = tri.normals[v];
normals.push([nx, ny, -nz]);
indices.push((ti * 3 + v) as u32);
colors.push(color);
}
}
Self { name: armor.name.clone(), positions, normals, indices, colors, transform: None }
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InteractiveHullMesh {
pub name: String,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
pub mfm_path: Option<String>,
pub colors: Vec<[f32; 4]>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub transform: Option<[f32; 16]>,
}
pub fn collect_hull_meshes(
visual: &VisualPrototype,
geometry: &MergedGeometry,
db: &PrototypeDatabase<'_>,
lod: usize,
damaged: bool,
barrel_pitch: Option<&BarrelPitch>,
) -> Result<Vec<InteractiveHullMesh>, Report<ExportError>> {
let mut result = Vec::new();
if visual.lods.is_empty() || lod >= visual.lods.len() {
return Ok(result);
}
let lod_entry = &visual.lods[lod];
let exclude = if damaged { DAMAGED_EXCLUDE } else { INTACT_EXCLUDE };
let self_id_index = db.build_self_id_index();
for &rs_name_id in &lod_entry.render_set_names {
let rs = visual
.render_sets
.iter()
.find(|rs| rs.name_id == rs_name_id)
.ok_or_else(|| Report::new(ExportError::RenderSetNotFound(rs_name_id)))?;
let rs_name = db.strings.get_string_by_id(rs_name_id).unwrap_or("<unknown>");
if exclude.iter().any(|sub| rs_name.contains(sub)) {
continue;
}
let vertices_mapping_id = rs.vertices_mapping_id;
let indices_mapping_id = rs.indices_mapping_id;
let vert_mapping = geometry
.vertices_mapping
.iter()
.find(|m| m.mapping_id == vertices_mapping_id)
.ok_or_else(|| Report::new(ExportError::VerticesMappingNotFound { id: vertices_mapping_id }))?;
let idx_mapping = geometry
.indices_mapping
.iter()
.find(|m| m.mapping_id == indices_mapping_id)
.ok_or_else(|| Report::new(ExportError::IndicesMappingNotFound { id: indices_mapping_id }))?;
let vbuf_idx = vert_mapping.merged_buffer_index as usize;
if vbuf_idx >= geometry.merged_vertices.len() {
return Err(Report::new(ExportError::BufferIndexOutOfRange {
index: vbuf_idx,
count: geometry.merged_vertices.len(),
}));
}
let vert_proto = &geometry.merged_vertices[vbuf_idx];
let ibuf_idx = idx_mapping.merged_buffer_index as usize;
if ibuf_idx >= geometry.merged_indices.len() {
return Err(Report::new(ExportError::BufferIndexOutOfRange {
index: ibuf_idx,
count: geometry.merged_indices.len(),
}));
}
let idx_proto = &geometry.merged_indices[ibuf_idx];
let decoded_vertices =
vert_proto.data.decode().map_err(|e| Report::new(ExportError::VertexDecode(format!("{e:?}"))))?;
let decoded_indices =
idx_proto.data.decode().map_err(|e| Report::new(ExportError::IndexDecode(format!("{e:?}"))))?;
let format = vertex_format::parse_vertex_format(&vert_proto.format_name);
let stride = vert_proto.stride_in_bytes as usize;
let vert_offset = vert_mapping.items_offset as usize;
let vert_count = vert_mapping.items_count as usize;
let vert_start = vert_offset * stride;
let vert_end = vert_start + vert_count * stride;
if vert_end > decoded_vertices.len() {
return Err(Report::new(ExportError::VertexDecode(format!(
"vertex range {}..{} exceeds buffer size {}",
vert_start,
vert_end,
decoded_vertices.len()
))));
}
let vert_slice = &decoded_vertices[vert_start..vert_end];
let idx_offset = idx_mapping.items_offset as usize;
let idx_count = idx_mapping.items_count as usize;
let index_size = idx_proto.index_size as usize;
let idx_start = idx_offset * index_size;
let idx_end = idx_start + idx_count * index_size;
if idx_end > decoded_indices.len() {
return Err(Report::new(ExportError::IndexDecode(format!(
"index range {}..{} exceeds buffer size {}",
idx_start,
idx_end,
decoded_indices.len()
))));
}
let idx_slice = &decoded_indices[idx_start..idx_end];
let indices: Vec<u32> = match index_size {
2 => idx_slice.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as u32).collect(),
4 => idx_slice.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(),
_ => {
return Err(Report::new(ExportError::IndexDecode(format!("unsupported index size: {index_size}"))));
}
};
let mut verts = unpack_vertices(vert_slice, stride, &format);
if let Some(bp) = barrel_pitch {
apply_barrel_pitch(&mut verts.positions, &mut verts.normals, vert_slice, stride, &format, bp);
}
let mfm_path = if rs.material_mfm_path_id != 0 {
self_id_index.get(&rs.material_mfm_path_id).map(|&idx| db.reconstruct_path(idx, &self_id_index))
} else {
None
};
result.push(InteractiveHullMesh {
name: rs_name.to_string(),
positions: verts.positions,
normals: verts.normals,
uvs: verts.uvs,
indices,
mfm_path,
colors: Vec::new(),
transform: None,
});
}
Ok(result)
}
const ARMOR_COLOR_SCALE: &[(f32, f32, f32, f32)] = &[
(14.0, 110.0 / 255.0, 209.0 / 255.0, 176.0 / 255.0), (16.0, 149.0 / 255.0, 210.0 / 255.0, 127.0 / 255.0), (24.0, 170.0 / 255.0, 201.0 / 255.0, 102.0 / 255.0), (26.0, 192.0 / 255.0, 193.0 / 255.0, 80.0 / 255.0), (28.0, 226.0 / 255.0, 195.0 / 255.0, 62.0 / 255.0), (33.0, 225.0 / 255.0, 171.0 / 255.0, 54.0 / 255.0), (75.0, 227.0 / 255.0, 144.0 / 255.0, 49.0 / 255.0), (160.0, 230.0 / 255.0, 115.0 / 255.0, 49.0 / 255.0), (399.0, 220.0 / 255.0, 78.0 / 255.0, 48.0 / 255.0), (999.0, 185.0 / 255.0, 47.0 / 255.0, 48.0 / 255.0), ];
pub fn thickness_to_color(thickness_mm: f32) -> [f32; 4] {
if thickness_mm <= 0.0 {
return [0.8, 0.8, 0.8, 0.5]; }
let idx =
ARMOR_COLOR_SCALE.iter().position(|&(bp, _, _, _)| thickness_mm <= bp).unwrap_or(ARMOR_COLOR_SCALE.len() - 1);
let (_, r, g, b) = ARMOR_COLOR_SCALE[idx];
[r, g, b, 0.8]
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ArmorLegendEntry {
pub min_mm: f32,
pub max_mm: f32,
pub color: [f32; 4],
pub color_name: String,
}
pub fn armor_color_legend() -> Vec<ArmorLegendEntry> {
let color_names = [
"teal",
"light green",
"yellow-green",
"olive",
"gold",
"orange-gold",
"orange",
"dark orange",
"red-orange",
"dark red",
];
ARMOR_COLOR_SCALE
.iter()
.enumerate()
.map(|(i, &(max_mm, r, g, b))| {
let min_mm = if i == 0 { 0.0 } else { ARMOR_COLOR_SCALE[i - 1].0 + 1.0 };
ArmorLegendEntry { min_mm, max_mm, color: [r, g, b, 0.8], color_name: color_names[i].to_string() }
})
.collect()
}
pub fn zone_from_material_name(mat_name: &str) -> &'static str {
use std::collections::HashSet;
use std::sync::Mutex;
static WARNED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
if let Some(rest) = mat_name.strip_prefix("Dual_") {
if rest.starts_with("Cit") {
return "Citadel";
}
if rest.starts_with("OCit") {
return "Citadel";
}
if rest.starts_with("Cas") {
return "Casemate";
}
if rest.starts_with("SSC") {
return "Superstructure";
}
if rest.starts_with("Bow") {
return "Bow";
}
if rest.starts_with("St_") {
return "Stern";
}
if rest.starts_with("SS_") {
return "Superstructure";
}
{
let mut warned = WARNED.lock().unwrap();
let set = warned.get_or_insert_with(HashSet::new);
if set.insert(mat_name.to_string()) {
eprintln!(
"BUG: unrecognized Dual_ collision material '{mat_name}' — \
zone_from_material_name needs updating"
);
}
}
return "Other";
}
if mat_name.ends_with("Cit") {
return "Citadel";
}
if mat_name.ends_with("Cas") {
return "Casemate";
}
if mat_name.ends_with("SSC") {
return "Superstructure";
}
if mat_name.ends_with("Bow") {
return "Bow";
}
if mat_name.ends_with("Stern") {
return "Stern";
}
if mat_name.ends_with("SS") && !mat_name.starts_with("Dual_") {
if mat_name.starts_with("SG") {
return "SteeringGear";
}
return "Superstructure";
}
if mat_name.starts_with("Bow") {
return "Bow";
}
if mat_name.starts_with("St_") {
return "Stern";
}
if mat_name.starts_with("Cit") {
return "Citadel";
}
if mat_name.starts_with("OCit") {
return "Citadel";
}
if mat_name.starts_with("Cas") {
return "Casemate";
}
if mat_name.starts_with("SSC") || mat_name == "SSCasemate" {
return "Superstructure";
}
if mat_name.starts_with("SS_") {
return "Superstructure";
}
if mat_name.starts_with("Tur") || mat_name.starts_with("AuTurret") {
return "Turret";
}
if mat_name.starts_with("Art") {
return "Turret";
}
if mat_name.starts_with("Rudder") || mat_name.starts_with("SG") {
return "SteeringGear";
}
if mat_name.starts_with("Bulge") {
return "TorpedoProtection";
}
if mat_name.starts_with("Bridge") || mat_name.starts_with("Funnel") {
return "Superstructure";
}
if mat_name.starts_with("Kdp") {
return "Hull";
}
match mat_name {
"Deck" | "ConstrSide" | "Hull" | "Side" | "Bottom" | "Top" | "Belt" | "Trans" | "Inclin" => "Hull",
"common" | "zero" => "Default",
_ => {
let mut warned = WARNED.lock().unwrap();
let set = warned.get_or_insert_with(HashSet::new);
if set.insert(mat_name.to_string()) {
eprintln!(
"BUG: unrecognized collision material '{mat_name}' — \
zone_from_material_name needs updating"
);
}
"Other"
}
}
}
const COLLISION_MATERIAL_NAMES: &[&str] = &[
"common", "zero", "Dual_SSC_Bow_Side", "Dual_SSC_St_Side", "Dual_Cas_OCit_Belt", "Dual_OCit_St_Trans", "Dual_OCit_Bow_Trans", "Dual_Cit_Bow_Side", "Dual_Cit_Bow_Belt", "Dual_Cit_Bow_ArtSide", "Dual_Cit_St_Side", "Dual_Cit_St_Belt", "Bottom", "Dual_Cit_St_ArtSide", "Dual_Cas_Bow_Belt", "Dual_Cas_St_Belt", "Dual_Cas_SSC_Belt", "Dual_SSC_Bow_ConstrSide", "Dual_SSC_St_ConstrSide", "Cas_Inclin", "SSC_Inclin", "Dual_Cas_SSC_Inclin", "Dual_Cas_Bow_Inclin", "Dual_Cas_St_Inclin", "Dual_SSC_Bow_Inclin", "Dual_SSC_St_Inclin", "Dual_Cit_Bow_Bulge", "Dual_Cit_St_Bulge", "Dual_Cas_SS_Belt", "Dual_Cit_Cas_ArtDeck", "Dual_Cit_Cas_ArtSide", "Dual_OCit_OCit_Side", "TurretSide", "TurretTop", "TurretFront", "TurretAft", "FunnelSide", "ArtBottom", "ArtSide", "ArtTop", "AuTurretAft", "AuTurretBarbette", "AuTurretDown", "AuTurretFwd", "AuTurretSide", "AuTurretTop", "Bow_Belt", "Bow_Bottom", "Bow_ConstrSide", "Bow_Deck", "Bow_Inclin", "Bow_Trans", "BridgeBottom", "BridgeSide", "BridgeTop", "Cas_AftTrans", "Cas_Belt", "Cas_Deck", "Cas_FwdTrans", "Cit_AftTrans", "Cit_Barbette", "Cit_Belt", "Cit_Bottom", "Cit_Bulge", "Cit_Deck", "Cit_FwdTrans", "Cit_Inclin", "Cit_Side", "Dual_Cit_Cas_Bulge", "ConstrSide", "Dual_Cit_Cas_Belt", "Bow_Fdck", "St_Fdck", "KdpBottom", "KdpSide", "KdpTop", "OCit_AftTrans", "OCit_Belt", "OCit_Deck", "OCit_FwdTrans", "RudderAft", "RudderFwd", "RudderSide", "RudderTop", "SSC_AftTrans", "SSCasemate", "SSC_ConstrSide", "SSC_Deck", "SSC_FwdTrans", "SS_Side", "SS_Top", "St_Belt", "St_Bottom", "St_ConstrSide", "St_Deck", "St_Inclin", "St_Trans", "TurretBarbette", "TurretBarbette2", "TurretDown", "TurretFwd", "Bulge", "Trans", "Deck", "Belt", "Dual_Cit_SSC_Bulge", "Inclin", "SS_BridgeTop", "SS_BridgeSide", "SS_BridgeBottom", "Cas_Bottom", "SideCit", "DeckCit", "TransCit", "InclinCit", "SideCas", "DeckCas", "TransCas", "InclinCas", "SideSSC", "DeckSSC", "TransSSC", "InclinSSC", "SideBow", "DeckBow", "TransBow", "InclinBow", "SideStern", "DeckStern", "TransStern", "InclinStern", "SideSS", "DeckSS", "TransSS", "Tur1GkBar", "Tur2GkBar", "Tur3GkBar", "Tur4GkBar", "Tur5GkBar", "Tur6GkBar", "Tur7GkBar", "Tur8GkBar", "Tur9GkBar", "Tur10GkBar", "Tur11GkBar", "Tur12GkBar", "Tur13GkBar", "Tur14GkBar", "Tur15GkBar", "Tur16GkBar", "Tur17GkBar", "Tur18GkBar", "Tur19GkBar", "Tur20GkBar", "Dual_Cas_Bow_Trans", "Dual_Cas_Bow_Deck", "Dual_Cas_St_Trans", "Dual_Cas_St_Deck", "Dual_Cas_SSC_Deck", "Dual_Cas_SSC_Trans", "Dual_Cas_SS_Deck", "Dual_Cas_SS_Trans", "Dual_SSC_Bow_Trans", "Dual_SSC_Bow_Deck", "Dual_SSC_St_Trans", "Dual_SSC_St_Deck", "Dual_SSC_SS_Deck", "Dual_SSC_SS_Trans", "Dual_Bow_SS_Deck", "Dual_Bow_SS_Trans", "Dual_St_SS_Deck", "Dual_St_SS_Trans", "Dual_Cit_Bow_Bottom", "Dual_Cit_St_Bottom", "Tur1GkDown", "Tur2GkDown", "Tur3GkDown", "Tur4GkDown", "Tur5GkDown", "Tur6GkDown", "Tur7GkDown", "Tur8GkDown", "Tur9GkDown", "Tur10GkDown", "Tur11GkDown", "Tur12GkDown", "Tur13GkDown", "Tur14GkDown", "Tur15GkDown", "Tur16GkDown", "Tur17GkDown", "Tur18GkDown", "Tur19GkDown", "Tur20GkDown", "Dual_Cit_Cit_Deck", "Dual_Cit_Cit_Inclin", "Dual_Cit_Cit_Trans", "Dual_Cit_Cit_Side", "Dual_Cas_Cas_Belt", "Dual_Cas_Cas_Deck", "Dual_SSC_SSC_ConstrSide", "Dual_SSC_SSC_Deck", "Dual_Bow_Bow_Deck", "Dual_Bow_Bow_ConstrSide", "Dual_St_St_Deck", "Dual_St_St_ConstrSide", "Dual_SS_SS_Top", "Dual_SS_SS_Side", "Dual_Cit_Bow_ArtDeck", "Dual_Cit_St_ArtDeck", "Dual_Cas_Bow_Side", "Dual_Cas_St_Side", "Dual_Cit_Cas_Side", "Dual_Cit_SSC_Side", "Tur1GkTop", "Tur2GkTop", "Tur3GkTop", "Tur4GkTop", "Tur5GkTop", "Tur6GkTop", "Tur7GkTop", "Tur8GkTop", "Tur9GkTop", "Tur10GkTop", "Tur11GkTop", "Tur12GkTop", "Tur13GkTop", "Tur14GkTop", "Tur15GkTop", "Tur16GkTop", "Tur17GkTop", "Tur18GkTop", "Tur19GkTop", "Tur20GkTop", "Cas_Hang", "Cas_Fdck", "SSC_Fdck", "SSC_Hang", "SS_SGBarbette", "SS_SGDown", "SGBarbetteSS", "SGDownSS", "Dual_Cit_Cas_Deck", "Dual_Cit_Cas_Inclin", "Dual_Cit_Cas_Trans", "Dual_Cit_SSC_Deck", "Dual_Cit_SSC_Inclin", "Dual_Cit_SSC_Trans", "Dual_Cit_Bow_Trans", "Dual_Cit_Bow_Inclin", "Dual_Cit_Bow_Deck", "Dual_Cit_St_Trans", "Dual_Cit_St_Inclin", "Dual_Cit_St_Deck", "Dual_Cit_SS_Deck", ];
pub fn collision_material_name(id: u8) -> &'static str {
use std::sync::Mutex;
static WARNED: Mutex<[bool; 256]> = Mutex::new([false; 256]);
let idx = id as usize;
if idx < COLLISION_MATERIAL_NAMES.len() {
COLLISION_MATERIAL_NAMES[idx]
} else {
let mut warned = WARNED.lock().unwrap();
if !warned[idx] {
warned[idx] = true;
eprintln!(
"BUG: collision material ID {id} is beyond the known table (max {}). \
The game's material table has likely been updated — \
see MODELS.md for how to re-extract it.",
COLLISION_MATERIAL_NAMES.len() - 1
);
}
"unknown"
}
}
pub fn armor_sub_models_by_zone(
armor: &crate::models::geometry::ArmorModel,
armor_map: Option<&ArmorMap>,
mount_armor: Option<&ArmorMap>,
) -> Vec<ArmorSubModel> {
let mut zone_tris: std::collections::BTreeMap<String, Vec<(&crate::models::geometry::ArmorTriangle, [f32; 4])>> =
std::collections::BTreeMap::new();
for tri in &armor.triangles {
let mat_id = tri.material_id as u32;
let layer = tri.layer_index as u32;
let thickness_mm = lookup_thickness(mat_id, layer, mount_armor, armor_map);
let color = thickness_to_color(thickness_mm);
let mat_name = collision_material_name(tri.material_id);
let zone_name = zone_from_material_name(mat_name).to_string();
zone_tris.entry(zone_name).or_default().push((tri, color));
}
zone_tris
.into_iter()
.map(|(zone_name, tris)| {
let vert_count = tris.len() * 3;
let mut positions = Vec::with_capacity(vert_count);
let mut normals = Vec::with_capacity(vert_count);
let mut indices = Vec::with_capacity(vert_count);
let mut colors = Vec::with_capacity(vert_count);
for (vi, (tri, color)) in tris.iter().enumerate() {
for v in 0..3 {
positions.push(tri.vertices[v]);
normals.push(tri.normals[v]);
indices.push((vi * 3 + v) as u32);
colors.push(*color);
}
}
ArmorSubModel { name: format!("Armor_{}", zone_name), positions, normals, indices, colors, transform: None }
})
.collect()
}
pub struct SubModel<'a> {
pub name: String,
pub visual: &'a VisualPrototype,
pub geometry: &'a MergedGeometry<'a>,
pub transform: Option<[f32; 16]>,
pub group: &'static str,
pub barrel_pitch: Option<BarrelPitch>,
}
#[derive(Clone)]
pub struct BarrelPitch {
pub pitch_matrix: [f32; 16],
pub barrel_bone_indices: Vec<u8>,
}
pub fn export_ship_glb(
sub_models: &[SubModel<'_>],
armor_models: &[ArmorSubModel],
db: &PrototypeDatabase<'_>,
lod: usize,
texture_set: &TextureSet,
damaged: bool,
writer: &mut impl Write,
) -> Result<(), Report<ExportError>> {
let mut root = json::Root {
asset: json::Asset {
version: "2.0".to_string(),
generator: Some("wowsunpack".to_string()),
..Default::default()
},
..Default::default()
};
let mut bin_data: Vec<u8> = Vec::new();
let mut mat_cache = MaterialCache::new();
let mut grouped_nodes: BTreeMap<&str, Vec<json::Index<json::Node>>> = BTreeMap::new();
let self_id_index = db.build_self_id_index();
for sub in sub_models {
if sub.visual.lods.is_empty() || lod >= sub.visual.lods.len() {
eprintln!(
"Warning: sub-model '{}' has {} LODs, skipping (requested LOD {})",
sub.name,
sub.visual.lods.len(),
lod
);
continue;
}
let lod_entry = &sub.visual.lods[lod];
let primitives = collect_primitives(
sub.visual,
sub.geometry,
Some(db),
Some(&self_id_index),
lod_entry,
damaged,
sub.barrel_pitch.as_ref(),
)?;
if primitives.is_empty() {
eprintln!("Warning: sub-model '{}' has no primitives for LOD {lod}", sub.name);
continue;
}
let mut gltf_primitives = Vec::new();
for prim in &primitives {
let gltf_prim = add_primitive_to_root(&mut root, &mut bin_data, prim, texture_set, &mut mat_cache)?;
gltf_primitives.push(gltf_prim);
}
let mesh = root.push(json::Mesh {
primitives: gltf_primitives,
weights: None,
name: Some(sub.name.clone()),
extensions: Default::default(),
extras: Default::default(),
});
let node = root.push(json::Node {
mesh: Some(mesh),
name: Some(sub.name.clone()),
matrix: sub.transform.map(negate_z_transform),
..Default::default()
});
grouped_nodes.entry(sub.group).or_default().push(node);
}
let mut armor_nodes: Vec<json::Index<json::Node>> = Vec::new();
for armor in armor_models {
if armor.positions.is_empty() {
continue;
}
let gltf_prim = add_armor_primitive_to_root(&mut root, &mut bin_data, armor)?;
let mesh = root.push(json::Mesh {
primitives: vec![gltf_prim],
weights: None,
name: Some(armor.name.clone()),
extensions: Default::default(),
extras: Default::default(),
});
let node = root.push(json::Node {
mesh: Some(mesh),
name: Some(armor.name.clone()),
matrix: armor.transform.map(negate_z_transform),
..Default::default()
});
armor_nodes.push(node);
}
if !armor_nodes.is_empty() {
grouped_nodes.insert("Armor", armor_nodes);
}
let mut scene_nodes = Vec::new();
for (group_name, children) in &grouped_nodes {
let parent = root.push(json::Node {
children: Some(children.clone()),
name: Some(group_name.to_string()),
..Default::default()
});
scene_nodes.push(parent);
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0);
}
if !bin_data.is_empty() {
let buffer = root.push(json::Buffer {
byte_length: USize64::from(bin_data.len()),
uri: None,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
for bv in root.buffer_views.iter_mut() {
bv.buffer = buffer;
}
}
let scene = root.push(json::Scene {
nodes: scene_nodes,
name: None,
extensions: Default::default(),
extras: Default::default(),
});
root.scene = Some(scene);
add_variants_extension(&mut root, texture_set);
let json_string =
json::serialize::to_string(&root).map_err(|e| Report::new(ExportError::Serialize(e.to_string())))?;
let glb = gltf::binary::Glb {
header: gltf::binary::Header { magic: *b"glTF", version: 2, length: 0 },
json: Cow::Owned(json_string.into_bytes()),
bin: if bin_data.is_empty() { None } else { Some(Cow::Owned(bin_data)) },
};
glb.to_writer(writer).map_err(|e| Report::new(ExportError::Io(e.to_string())))?;
Ok(())
}