feat(viewer): stage thumbnail grid, per-model albedo, camera zoom fix

Route stage containers to stage_models (decode both single + stage paths, keep
whichever yields more geometry) — fixes stages showing the dummy-root "black
cube". Present a stage's sub-models as a thumbnail grid: each recentred and
uniformly scaled to a fixed cell so a 1-unit prop and a 3000-unit structure are
equally visible; skybox-plane / stray-anchor models (>20000u) are culled. Each
sub-model is textured by matching its name to the container's `_col` albedo.

Fix the orbit camera: zoom radius was hard-capped at 50u with fixed clip planes,
trapping the camera inside multi-thousand-unit stages. Zoom limits and near/far
planes now scale to the framed model.

Adds docs/HANDOFF-stage-mesh-2026-07-12.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-12 20:56:45 +02:00
parent 2053f31d17
commit d23339a3aa
3 changed files with 375 additions and 17 deletions

View File

@@ -32,6 +32,10 @@ pub struct OrbitCamera {
pub orbit_sensitivity: f32,
pub zoom_sensitivity: f32,
pub pan_sensitivity: f32,
/// Zoom limits — set when framing a model so large scenes can be pulled back
/// far enough (the old fixed 0.5..50 cap trapped the camera inside big stages).
pub min_radius: f32,
pub max_radius: f32,
}
impl Default for OrbitCamera {
@@ -44,6 +48,8 @@ impl Default for OrbitCamera {
orbit_sensitivity: 0.005,
zoom_sensitivity: 0.3,
pan_sensitivity: 0.003,
min_radius: 0.05,
max_radius: 500.0,
}
}
}
@@ -54,19 +60,26 @@ fn spawn_camera(mut commands: Commands) {
commands.spawn((
Camera3d::default(),
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
// the planes are re-scaled to the zoom distance each frame (below).
Projection::Perspective(PerspectiveProjection {
near: 0.05,
far: 100_000.0,
..default()
}),
transform,
orbit,
));
}
fn orbit_camera(
mut query: Query<(&mut OrbitCamera, &mut Transform)>,
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
mut mouse_motion: EventReader<MouseMotion>,
mut scroll: EventReader<MouseWheel>,
) {
let Ok((mut cam, mut transform)) = query.get_single_mut() else { return };
let Ok((mut cam, mut transform, mut projection)) = query.get_single_mut() else { return };
let mut delta_motion = Vec2::ZERO;
for ev in mouse_motion.read() {
@@ -99,13 +112,20 @@ fn orbit_camera(
// Zoom (scroll)
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
cam.radius = cam.radius.clamp(0.5, 50.0);
cam.radius = cam.radius.clamp(cam.min_radius, cam.max_radius);
// Reset (R key)
if keys.just_pressed(KeyCode::KeyR) {
*cam = OrbitCamera::default();
}
// Keep the clip planes proportional to the zoom distance so depth precision
// stays usable across scales (tiny prop → whole stage grid).
if let Projection::Perspective(p) = projection.as_mut() {
p.near = (cam.radius * 0.02).clamp(0.02, 50.0);
p.far = (cam.radius * 50.0).max(2000.0);
}
*transform = orbit_transform(&cam);
}

View File

@@ -1556,6 +1556,8 @@ fn spawn_model_preview(
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = extent * 1.4;
cam.min_radius = (extent * 0.02).max(0.02);
cam.max_radius = (extent * 20.0).max(50.0);
}
model_preview.active = true;
@@ -1569,6 +1571,223 @@ fn spawn_model_preview(
);
}
/// Spawn every decoded sub-model of a **stage** container as a side-by-side row
/// (a "cast sheet"): the stage's enemy / prop models are separate objects with no
/// recovered scene transforms, so each is recentred on its own origin and laid
/// out along +X, spaced by its width. The orbit camera frames the whole row.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn spawn_stage_models(
models: &[sylpheed_formats::mesh::Xbg7Model],
xpr_bytes: &[u8],
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
model_preview: &mut ModelPreview,
orbit: &mut Query<&mut crate::camera::OrbitCamera>,
) {
use bevy::render::mesh::{Indices, PrimitiveTopology};
use sylpheed_formats::texture::X360Texture;
// Per-sub-model albedo: match each model's name to its `_col` texture
// (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once
// and cache by texture index; fall back to a neutral tint when no `_col`
// channel matches. Colours are the same "unverified" item as the 2D textures.
let tex_names = X360Texture::texture_names(xpr_bytes);
let neutral = materials.add(StandardMaterial {
base_color: Color::srgb(0.72, 0.74, 0.78),
perceptual_roughness: 0.65,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
});
let mut tex_cache: std::collections::HashMap<usize, Handle<StandardMaterial>> =
std::collections::HashMap::new();
let material_for = |model_name: &str,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
cache: &mut std::collections::HashMap<usize, Handle<StandardMaterial>>|
-> Handle<StandardMaterial> {
// Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix.
let core = model_name
.trim_start_matches('_')
.trim_start_matches("rou_")
.trim_end_matches("_l");
let idx = tex_names.iter().position(|n| {
n.ends_with("_col") && (n.starts_with(core) || n.contains(core))
});
let Some(i) = idx else { return neutral.clone() };
if let Some(h) = cache.get(&i) {
return h.clone();
}
let handle = X360Texture::from_xpr2_index(xpr_bytes, i)
.ok()
.and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
.map(|img| {
materials.add(StandardMaterial {
base_color: Color::WHITE,
base_color_texture: Some(images.add(img)),
perceptual_roughness: 0.7,
metallic: 0.1,
cull_mode: None,
double_sided: true,
..default()
})
})
.unwrap_or_else(|| neutral.clone());
cache.insert(i, handle.clone());
handle
};
let root = commands
.spawn((
PreviewModel,
Transform::default(),
Visibility::default(),
Name::new("stage"),
))
.id();
// A stage holds up to ~450 sub-models at wildly different true scales (a
// 1-unit prop next to a 3000-unit structure) with no recovered scene
// transforms. Laying them end-to-end made a mile-wide strip the camera
// couldn't escape. Instead present a **thumbnail grid**: each model is
// recentred and uniformly scaled to a fixed cell, so all are equally visible
// and browsable regardless of native size. Grid spans the XY plane.
const CELL: f32 = 10.0; // target on-screen size of each thumbnail
const GAP: f32 = 4.0;
let pitch = CELL + GAP;
// Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or
// a stray-vertex mis-anchor rather than a real object — dropped from the grid.
const MAX_EXTENT: f32 = 20_000.0;
let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new();
for model in models {
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
let mut has_geo = false;
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
has_geo = true;
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if has_geo && (hi - lo).max_element() <= MAX_EXTENT {
drawn.push(model);
}
}
let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize;
let mut total_v = 0usize;
let mut total_t = 0usize;
for (i, model) in drawn.iter().enumerate() {
// Per-model bbox → recentre + uniform scale to the cell.
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for sub in &model.meshes {
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
if lo.x > hi.x {
continue;
}
let center = (lo + hi) * 0.5;
let extent = (hi - lo).max_element().max(1e-3);
let scale = CELL / extent;
// Grid cell centre (row-major, growing down in Y).
let col = i % cols;
let row = i / cols;
let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0);
// One entity per model carries the placement + normalising scale; its
// sub-meshes are recentred to the model's own origin underneath it.
let model_root = commands
.spawn((
PreviewModel,
Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)),
Visibility::default(),
Name::new(model.name.clone()),
))
.id();
commands.entity(root).add_child(model_root);
let material = material_for(&model.name, materials, images, &mut tex_cache);
for sub in &model.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
total_v += sub.positions.len();
total_t += sub.indices.len() / 3;
let positions: Vec<[f32; 3]> =
sub.positions.iter().map(|p| {
[p[0] - center.x, p[1] - center.y, p[2] - center.z]
}).collect();
let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() {
sub.uvs.clone()
} else {
vec![[0.0, 0.0]; sub.positions.len()]
};
let normals = if sub.normals.len() == sub.positions.len() {
sub.normals.clone()
} else {
compute_smooth_normals(&positions, &sub.indices)
};
let mut mesh =
Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(sub.indices.clone()));
let child = commands
.spawn((
PreviewModel,
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material.clone()),
Transform::default(),
))
.id();
commands.entity(model_root).add_child(child);
}
}
// Frame the whole grid.
let rows = drawn.len().div_ceil(cols);
let grid_w = cols as f32 * pitch;
let grid_h = rows as f32 * pitch;
let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0);
let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL);
if let Ok(mut cam) = orbit.get_single_mut() {
cam.focus = center;
cam.radius = diag * 0.75;
cam.min_radius = CELL * 0.05;
cam.max_radius = diag * 3.0;
}
model_preview.active = true;
model_preview.name = "stage".to_string();
model_preview.info = format!(
"Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \
{} verts · {} tris (content-anchored; hero bodies with quantized layout \
are skipped)",
drawn.len(),
total_v,
total_t,
);
}
/// Per-vertex smooth normals = normalized sum of incident face normals.
#[cfg(not(target_arch = "wasm32"))]
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
@@ -1673,20 +1892,45 @@ fn apply_loaded_texture(
// models under `hidden/resource3d/`) preview as an orbitable mesh, textured
// with their albedo (`_col`) map. Complex multi-stream body meshes decline
// cleanly (Err) and fall through to the 2D texture preview below.
if let Ok(model) = sylpheed_formats::mesh::Xbg7Model::from_xpr2(&bytes) {
if !model.meshes.is_empty() {
spawn_model_preview(
&model,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
// A container may decode as a single model (weapons / props, via the
// single-block layout) OR as a stage — a collection of many enemy / prop
// sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that
// the single-model path decodes into a degenerate cube, so we can't just try
// it first and stop. Decode both and keep whichever yields more geometry.
use sylpheed_formats::mesh::Xbg7Model;
let single = Xbg7Model::from_xpr2(&bytes)
.ok()
.filter(|m| !m.meshes.is_empty());
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
let stage = Xbg7Model::stage_models(&bytes);
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
if !stage.is_empty() && stage_verts > single_verts {
spawn_stage_models(
&stage,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
if let Some(model) = single {
spawn_model_preview(
&model,
&bytes,
&mut commands,
&mut meshes,
&mut materials,
&mut images,
&mut model_preview,
&mut orbit,
);
return;
}
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.