fix(render): project static scene on xy plane
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 10:53:03 +04:00
parent ea02915695
commit 265d118387
6 changed files with 219 additions and 83 deletions
@@ -18,20 +18,20 @@ pub enum VulkanAssetMeshError {
IndexOutOfRange, IndexOutOfRange,
} }
/// Shared XZ frame for a deliberately top-down diagnostic static scene. /// Shared XY frame for a deliberately top-down diagnostic static scene.
/// ///
/// It is a CPU-side viewer transform, not evidence of the original camera or /// It is a CPU-side viewer transform, not evidence of the original camera or
/// object transform convention. Keeping it explicit prevents separately /// object transform convention. Keeping it explicit prevents separately
/// normalized terrain and model components from being incorrectly overlaid. /// normalized terrain and model components from being incorrectly overlaid.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanStaticXzFrame { pub struct VulkanStaticXyFrame {
center_x: f32, center_x: f32,
center_z: f32, center_y: f32,
extent: f32, extent: f32,
} }
impl VulkanStaticXzFrame { impl VulkanStaticXyFrame {
/// Builds a frame that maps the supplied XZ bounds into the static viewer. /// Builds a frame that maps the supplied XY bounds into the static viewer.
/// ///
/// # Errors /// # Errors
/// ///
@@ -40,16 +40,16 @@ impl VulkanStaticXzFrame {
pub fn from_bounds( pub fn from_bounds(
min_x: f32, min_x: f32,
max_x: f32, max_x: f32,
min_z: f32, min_y: f32,
max_z: f32, max_y: f32,
) -> Result<Self, VulkanAssetMeshError> { ) -> Result<Self, VulkanAssetMeshError> {
let extent = (max_x - min_x).max(max_z - min_z); let extent = (max_x - min_x).max(max_y - min_y);
if !extent.is_finite() || extent <= f32::EPSILON { if !extent.is_finite() || extent <= f32::EPSILON {
return Err(VulkanAssetMeshError::DegenerateViewExtent); return Err(VulkanAssetMeshError::DegenerateViewExtent);
} }
Ok(Self { Ok(Self {
center_x: (min_x + max_x) * 0.5, center_x: (min_x + max_x) * 0.5,
center_z: (min_z + max_z) * 0.5, center_y: (min_y + max_y) * 0.5,
extent, extent,
}) })
} }
@@ -58,14 +58,14 @@ impl VulkanStaticXzFrame {
let scale = 1.6 / self.extent; let scale = 1.6 / self.extent;
[ [
(position[0] - self.center_x) * scale, (position[0] - self.center_x) * scale,
(position[2] - self.center_z) * scale, (position[1] - self.center_y) * scale,
] ]
} }
fn planar_uv(self, position: [f32; 3]) -> [f32; 2] { fn planar_uv(self, position: [f32; 3]) -> [f32; 2] {
[ [
(position[0] - self.center_x) / self.extent + 0.5, (position[0] - self.center_x) / self.extent + 0.5,
(position[2] - self.center_z) / self.extent + 0.5, (position[1] - self.center_y) / self.extent + 0.5,
] ]
} }
} }
@@ -75,7 +75,7 @@ impl std::fmt::Display for VulkanAssetMeshError {
match self { match self {
Self::EmptyGeometry => write!(f, "MSH contains no triangle geometry"), Self::EmptyGeometry => write!(f, "MSH contains no triangle geometry"),
Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"), Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"),
Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XZ view extent"), Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XY view extent"),
Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"), Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"),
} }
} }
@@ -88,7 +88,7 @@ impl std::error::Error for VulkanAssetMeshError {}
/// The original engine's node transforms, camera and material pipeline are not /// The original engine's node transforms, camera and material pipeline are not
/// substituted here. This bridge is intentionally a static asset-viewer step: /// substituted here. This bridge is intentionally a static asset-viewer step:
/// it preserves every batch's `index_start`, `index_count` and `base_vertex`, /// it preserves every batch's `index_start`, `index_count` and `base_vertex`,
/// projects the conventional `Iron3D` XZ ground plane into the current XY shader /// projects the conventional `Iron3D` XY ground plane into the current XY shader
/// input, and scales the used bounds uniformly into the visible clip rectangle. /// input, and scales the used bounds uniformly into the visible clip rectangle.
/// ///
/// # Errors /// # Errors
@@ -99,12 +99,12 @@ pub fn project_msh_to_static_mesh(
model: &ModelAsset, model: &ModelAsset,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> { ) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
let (indices, _) = static_model_indices_and_ranges(model)?; let (indices, _) = static_model_indices_and_ranges(model)?;
let (min_x, max_x, min_z, max_z) = static_mesh_xz_bounds(&model.positions, &indices)?; let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&model.positions, &indices)?;
let frame = VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z)?; let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?;
project_msh_to_static_mesh_in_xz_frame(model, frame, [0.0; 3], [1.0; 3]) project_msh_to_static_mesh_in_xy_frame(model, frame, [0.0; 3], [1.0; 3])
} }
/// Projects MSH geometry with a known translation/scale into a shared XZ frame. /// Projects MSH geometry with a known translation/scale into a shared XY frame.
/// ///
/// The caller supplies only transforms already decoded from mission data. Raw /// The caller supplies only transforms already decoded from mission data. Raw
/// orientation is intentionally not interpreted until its original convention /// orientation is intentionally not interpreted until its original convention
@@ -114,9 +114,9 @@ pub fn project_msh_to_static_mesh(
/// ///
/// Returns [`VulkanAssetMeshError`] when geometry, transform values or the /// Returns [`VulkanAssetMeshError`] when geometry, transform values or the
/// static Vulkan input contract cannot represent the source model. /// static Vulkan input contract cannot represent the source model.
pub fn project_msh_to_static_mesh_in_xz_frame( pub fn project_msh_to_static_mesh_in_xy_frame(
model: &ModelAsset, model: &ModelAsset,
frame: VulkanStaticXzFrame, frame: VulkanStaticXyFrame,
translation: [f32; 3], translation: [f32; 3],
scale: [f32; 3], scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> { ) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
@@ -146,7 +146,7 @@ pub fn project_msh_to_static_mesh_in_xz_frame(
color: [0.82, 0.72, 0.31], color: [0.82, 0.72, 0.31],
// Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units. // Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units.
// Models that omit this optional stream retain the static viewer's // Models that omit this optional stream retain the static viewer's
// XZ planar fallback instead of receiving fabricated raw UV values. // XY planar fallback instead of receiving fabricated raw UV values.
uv: model uv: model
.uv0 .uv0
.as_ref() .as_ref()
@@ -176,7 +176,7 @@ pub fn project_msh_to_static_mesh_in_xz_frame(
/// # Errors /// # Errors
/// ///
/// Returns [`VulkanAssetMeshError`] if the terrain has no triangles, contains /// Returns [`VulkanAssetMeshError`] if the terrain has no triangles, contains
/// non-finite positions, has no usable XZ extent, or references data outside /// non-finite positions, has no usable XY extent, or references data outside
/// the current static Vulkan input contract. /// the current static Vulkan input contract.
pub fn project_land_msh_to_static_mesh( pub fn project_land_msh_to_static_mesh(
terrain: &LandMeshDocument, terrain: &LandMeshDocument,
@@ -195,20 +195,20 @@ pub fn project_land_msh_to_static_mesh(
return Err(VulkanAssetMeshError::EmptyGeometry); return Err(VulkanAssetMeshError::EmptyGeometry);
} }
let (min_x, max_x, min_z, max_z) = static_mesh_xz_bounds(&terrain.positions, &indices)?; let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&terrain.positions, &indices)?;
let frame = VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z)?; let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?;
project_land_msh_to_static_mesh_in_xz_frame(terrain, frame) project_land_msh_to_static_mesh_in_xy_frame(terrain, frame)
} }
/// Projects `Land.msh` terrain geometry into a shared diagnostic XZ frame. /// Projects `Land.msh` terrain geometry into a shared diagnostic XY frame.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`VulkanAssetMeshError`] when terrain geometry or the static Vulkan /// Returns [`VulkanAssetMeshError`] when terrain geometry or the static Vulkan
/// input contract cannot represent the source mesh. /// input contract cannot represent the source mesh.
pub fn project_land_msh_to_static_mesh_in_xz_frame( pub fn project_land_msh_to_static_mesh_in_xy_frame(
terrain: &LandMeshDocument, terrain: &LandMeshDocument,
frame: VulkanStaticXzFrame, frame: VulkanStaticXyFrame,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> { ) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
let mut indices = Vec::with_capacity( let mut indices = Vec::with_capacity(
terrain terrain
@@ -298,14 +298,14 @@ fn static_model_indices_and_ranges(
Ok((indices, draw_ranges)) Ok((indices, draw_ranges))
} }
fn static_mesh_xz_bounds( fn static_mesh_xy_bounds(
positions: &[[f32; 3]], positions: &[[f32; 3]],
indices: &[u16], indices: &[u16],
) -> Result<(f32, f32, f32, f32), VulkanAssetMeshError> { ) -> Result<(f32, f32, f32, f32), VulkanAssetMeshError> {
let mut min_x = f32::INFINITY; let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY; let mut max_x = f32::NEG_INFINITY;
let mut min_z = f32::INFINITY; let mut min_y = f32::INFINITY;
let mut max_z = f32::NEG_INFINITY; let mut max_y = f32::NEG_INFINITY;
for &index in indices { for &index in indices {
let position = positions let position = positions
.get(usize::from(index)) .get(usize::from(index))
@@ -315,10 +315,10 @@ fn static_mesh_xz_bounds(
} }
min_x = min_x.min(position[0]); min_x = min_x.min(position[0]);
max_x = max_x.max(position[0]); max_x = max_x.max(position[0]);
min_z = min_z.min(position[2]); min_y = min_y.min(position[1]);
max_z = max_z.max(position[2]); max_y = max_y.max(position[1]);
} }
Ok((min_x, max_x, min_z, max_z)) Ok((min_x, max_x, min_y, max_y))
} }
#[cfg(test)] #[cfg(test)]
@@ -356,13 +356,13 @@ mod tests {
} }
#[test] #[test]
fn projects_xz_geometry_and_applies_base_vertex() { fn projects_xy_geometry_and_applies_base_vertex() {
let mesh = project_msh_to_static_mesh(&model( let mesh = project_msh_to_static_mesh(&model(
vec![ vec![
[99.0, 0.0, 99.0], [99.0, 99.0, 0.0],
[-2.0, 4.0, -1.0], [-2.0, -1.0, 4.0],
[2.0, 8.0, -1.0], [2.0, -1.0, 8.0],
[-2.0, 1.0, 3.0], [-2.0, 3.0, 1.0],
], ],
vec![0, 1, 2], vec![0, 1, 2],
vec![batch(0, 3, 1)], vec![batch(0, 3, 1)],
@@ -448,10 +448,10 @@ mod tests {
slots_raw: Vec::new(), slots_raw: Vec::new(),
}, },
positions: vec![ positions: vec![
[-2.0, 5.0, -1.0], [-2.0, -1.0, 5.0],
[2.0, 3.0, -1.0], [2.0, -1.0, 3.0],
[-2.0, 9.0, 3.0], [-2.0, 3.0, 9.0],
[2.0, 1.0, 3.0], [2.0, 3.0, 1.0],
], ],
normals: Vec::new(), normals: Vec::new(),
uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]], uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]],
+3 -3
View File
@@ -40,9 +40,9 @@ mod swapchain_resources;
mod validation; mod validation;
pub use self::asset_mesh::{ pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xz_frame, project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_xz_frame, VulkanAssetMeshError, project_msh_to_static_mesh, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError,
VulkanStaticXzFrame, VulkanStaticXyFrame,
}; };
pub use self::capabilities::{ pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
+54 -4
View File
@@ -25,8 +25,7 @@ use fparkan_assets::{
decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile, decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
}; };
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_inspection::inspect_archive_file; use fparkan_inspection::{inspect_archive_file, inspect_land_msh_bounds_file, ArchiveInspection};
use fparkan_inspection::ArchiveInspection;
use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_prototype::build_prototype_graph_report; use fparkan_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository};
@@ -42,6 +41,7 @@ const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1"; const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1";
const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1"; const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
#[derive(Serialize)] #[derive(Serialize)]
struct ArchiveInspectOutput<'a> { struct ArchiveInspectOutput<'a> {
@@ -114,6 +114,15 @@ struct MissionObjectInspectOutput {
scale: [f32; 3], scale: [f32; 3],
} }
#[derive(Serialize)]
struct TerrainInspectOutput {
schema_version: &'static str,
path: String,
positions: usize,
min: [f32; 3],
max: [f32; 3],
}
#[derive(Serialize)] #[derive(Serialize)]
struct GraphFailureOutput { struct GraphFailureOutput {
root_index: usize, root_index: usize,
@@ -178,6 +187,10 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?; let rest = strip_format_json(rest)?;
inspect_mission(&rest) inspect_mission(&rest)
} }
[domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_terrain(&rest)
}
_ => Err(usage()), _ => Err(usage()),
} }
} }
@@ -400,6 +413,22 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
} }
} }
fn inspect_terrain(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "terrain inspect")?;
let bounds = inspect_land_msh_bounds_file(&path)?;
println!(
"{}",
serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: path.display().to_string(),
positions: bounds.positions,
min: bounds.min,
max: bounds.max,
})?
);
Ok(())
}
fn archive_inspect_json( fn archive_inspect_json(
path: &str, path: &str,
kind: &str, kind: &str,
@@ -416,10 +445,14 @@ fn archive_inspect_json(
} }
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> { fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
parse_file_path(args, "archive inspect")
}
fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> {
match args { match args {
[path] => Ok(PathBuf::from(path)), [path] => Ok(PathBuf::from(path)),
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)), [flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
_ => Err("archive inspect requires <file> or --file <file>".to_string()), _ => Err(format!("{command} requires <file> or --file <file>")),
} }
} }
@@ -472,7 +505,7 @@ fn prototype_graph_requiredness_label(
} }
fn usage() -> String { fn usage() -> String {
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string() "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
} }
#[cfg(test)] #[cfg(test)]
@@ -584,4 +617,21 @@ mod tests {
"{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}" "{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}"
); );
} }
#[test]
fn terrain_inspect_output_retains_axis_bounds() {
let json = serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: "DATA/MAPS/AutoMAP/Land.msh".to_string(),
positions: 3,
min: [-1.0, -2.0, -3.0],
max: [4.0, 5.0, 6.0],
})
.expect("serialize terrain inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0]}"
);
}
} }
+22 -22
View File
@@ -30,10 +30,10 @@ use fparkan_render::{
RenderSnapshotDraw, RenderSnapshotDraw,
}; };
use fparkan_render_vulkan::{ use fparkan_render_vulkan::{
project_land_msh_to_static_mesh_in_xz_frame, project_msh_to_static_mesh_in_xz_frame, project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame,
VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer,
VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXzFrame, VulkanStaticXyFrame,
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots,
@@ -206,7 +206,7 @@ struct StaticPreviewScene {
} }
/// Projects the mission terrain plus every MSH component of the explicitly /// Projects the mission terrain plus every MSH component of the explicitly
/// bounded static-preview root into one diagnostic XZ frame. /// bounded static-preview root into one diagnostic XY frame.
/// ///
/// This intentionally takes only the first MAT0 texture request for each MSH /// This intentionally takes only the first MAT0 texture request for each MSH
/// batch selector. The terrain uses an explicit white diagnostic texture; /// batch selector. The terrain uses an explicit white diagnostic texture;
@@ -220,7 +220,7 @@ fn static_preview_mesh_and_materials(
let terrain_mesh = terrain let terrain_mesh = terrain
.source_mesh() .source_mesh()
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
let frame = static_preview_xz_frame(assets, terrain, roots)?; let frame = static_preview_xy_frame(assets, terrain, roots)?;
let mut mesh = VulkanStaticMesh { let mut mesh = VulkanStaticMesh {
vertices: Vec::new(), vertices: Vec::new(),
indices: Vec::new(), indices: Vec::new(),
@@ -234,7 +234,7 @@ fn static_preview_mesh_and_materials(
rgba8: vec![255, 255, 255, 255], rgba8: vec![255, 255, 255, 255],
}, },
}]; }];
let terrain_component = project_land_msh_to_static_mesh_in_xz_frame(terrain_mesh, frame) let terrain_component = project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?; append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?;
let mut mesh_components = 0; let mut mesh_components = 0;
@@ -251,7 +251,7 @@ fn static_preview_mesh_and_materials(
let model = assets.model_by_id(model_id).ok_or_else(|| { let model = assets.model_by_id(model_id).ok_or_else(|| {
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?; })?;
let component = project_msh_to_static_mesh_in_xz_frame( let component = project_msh_to_static_mesh_in_xy_frame(
&model.validated, &model.validated,
frame, frame,
root.position, root.position,
@@ -275,20 +275,20 @@ fn static_preview_mesh_and_materials(
}) })
} }
fn static_preview_xz_frame( fn static_preview_xy_frame(
assets: &MissionAssets, assets: &MissionAssets,
terrain: &TerrainWorld, terrain: &TerrainWorld,
roots: &[MissionObjectDraft], roots: &[MissionObjectDraft],
) -> Result<VulkanStaticXzFrame, String> { ) -> Result<VulkanStaticXyFrame, String> {
let mut min_x = f32::INFINITY; let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY; let mut max_x = f32::NEG_INFINITY;
let mut min_z = f32::INFINITY; let mut min_y = f32::INFINITY;
let mut max_z = f32::NEG_INFINITY; let mut max_y = f32::NEG_INFINITY;
let terrain_positions = terrain let terrain_positions = terrain
.source_positions() .source_positions()
.ok_or_else(|| "runtime terrain does not retain source positions".to_string())?; .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?;
for position in terrain_positions { for position in terrain_positions {
extend_static_preview_xz_bounds(*position, &mut min_x, &mut max_x, &mut min_z, &mut max_z)?; extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?;
} }
for (object_index, root) in roots.iter().enumerate() { for (object_index, root) in roots.iter().enumerate() {
if !root if !root
@@ -314,7 +314,7 @@ fn static_preview_xz_frame(
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?; })?;
for position in &model.validated.positions { for position in &model.validated.positions {
extend_static_preview_xz_bounds( extend_static_preview_xy_bounds(
[ [
position[0] * root.scale[0] + root.position[0], position[0] * root.scale[0] + root.position[0],
position[1] * root.scale[1] + root.position[1], position[1] * root.scale[1] + root.position[1],
@@ -322,30 +322,30 @@ fn static_preview_xz_frame(
], ],
&mut min_x, &mut min_x,
&mut max_x, &mut max_x,
&mut min_z, &mut min_y,
&mut max_z, &mut max_y,
)?; )?;
} }
} }
} }
VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z) VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)
.map_err(|err| format!("build static preview XZ frame: {err}")) .map_err(|err| format!("build static preview XY frame: {err}"))
} }
fn extend_static_preview_xz_bounds( fn extend_static_preview_xy_bounds(
position: [f32; 3], position: [f32; 3],
min_x: &mut f32, min_x: &mut f32,
max_x: &mut f32, max_x: &mut f32,
min_z: &mut f32, min_y: &mut f32,
max_z: &mut f32, max_y: &mut f32,
) -> Result<(), String> { ) -> Result<(), String> {
if !position.iter().all(|value| value.is_finite()) { if !position.iter().all(|value| value.is_finite()) {
return Err("static preview contains a non-finite XZ position".to_string()); return Err("static preview contains a non-finite XY position".to_string());
} }
*min_x = min_x.min(position[0]); *min_x = min_x.min(position[0]);
*max_x = max_x.max(position[0]); *max_x = max_x.max(position[0]);
*min_z = min_z.min(position[2]); *min_y = min_y.min(position[1]);
*max_z = max_z.max(position[2]); *max_y = max_y.max(position[1]);
Ok(()) Ok(())
} }
+77
View File
@@ -142,6 +142,17 @@ pub struct MapInspection {
pub grid_height: u32, pub grid_height: u32,
} }
/// Axis-aligned position bounds of a decoded `Land.msh`.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshBoundsInspection {
/// Number of source positions covered by the bounds.
pub positions: usize,
/// Per-axis inclusive minimum position.
pub min: [f32; 3],
/// Per-axis inclusive maximum position.
pub max: [f32; 3],
}
/// Supported land file kinds. /// Supported land file kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LandFileKind { pub enum LandFileKind {
@@ -450,6 +461,46 @@ pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String>
decode_land_msh(&document).map_err(|err| err.to_string()) decode_land_msh(&document).map_err(|err| err.to_string())
} }
/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
///
/// # Errors
///
/// Returns a string error when the file cannot be read, decoded, or contains
/// no finite source positions.
pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
let mesh = load_land_msh_from_path(path)?;
inspect_land_msh_bounds(&mesh)
}
/// Computes source-coordinate bounds for an already validated `Land.msh`.
///
/// # Errors
///
/// Returns a string error when the mesh has no finite source positions.
pub fn inspect_land_msh_bounds(
mesh: &LandMeshDocument,
) -> Result<LandMeshBoundsInspection, String> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for position in &mesh.positions {
if !position.iter().all(|value| value.is_finite()) {
return Err("Land.msh contains a non-finite source position".to_string());
}
for axis in 0..3 {
min[axis] = min[axis].min(position[axis]);
max[axis] = max[axis].max(position[axis]);
}
}
if mesh.positions.is_empty() {
return Err("Land.msh contains no source positions".to_string());
}
Ok(LandMeshBoundsInspection {
positions: mesh.positions.len(),
min,
max,
})
}
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> { fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
Ok(MapInspection { Ok(MapInspection {
@@ -599,6 +650,7 @@ fn resource_parse_diagnostic(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use fparkan_terrain_format::TerrainSlotTable;
use std::io::Write as _; use std::io::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
@@ -709,6 +761,31 @@ mod tests {
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
} }
#[test]
fn land_mesh_bounds_preserve_each_source_axis() {
let mesh = LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: Vec::new(),
};
let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
assert_eq!(bounds.positions, 2);
assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
}
fn temp_dir(name: &str) -> PathBuf { fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp") let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests") .join("fparkan-inspection-tests")
+21 -12
View File
@@ -938,7 +938,7 @@ from a runtime slot or draw order.
`fparkan-game --backend static-vulkan` is an explicit native-window experiment, `fparkan-game --backend static-vulkan` is an explicit native-window experiment,
not the default planning path. It visits only the first mission root, selects not the default planning path. It visits only the first mission root, selects
every prepared MSH component of that root, merges their static XZ clip-space every prepared MSH component of that root, merges their static XY clip-space
geometry, and renders a requested number of frames through geometry, and renders a requested number of frames through
`VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports `VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports
swapchain/readback telemetry. For each used `Batch20.material_index`, it resolves swapchain/readback telemetry. For each used `Batch20.material_index`, it resolves
@@ -984,7 +984,7 @@ The bounded native preview now retains the already validated `Land.msh` inside
`TerrainWorld`; this lets the application consume source geometry through the `TerrainWorld`; this lets the application consume source geometry through the
runtime boundary instead of decoding archive formats a second time. It merges runtime boundary instead of decoding archive formats a second time. It merges
one terrain component with the first TMA root's 14 reachable MSH components in one terrain component with the first TMA root's 14 reachable MSH components in
one explicit top-down XZ frame. The frame is computed from terrain positions one explicit top-down XY frame. The frame is computed from terrain positions
and the root models after applying only the decoded TMA `position` and `scale`. and the root models after applying only the decoded TMA `position` and `scale`.
Raw TMA orientation is deliberately excluded: its convention is not proven. Raw TMA orientation is deliberately excluded: its convention is not proven.
@@ -1005,7 +1005,7 @@ geometry and descriptor set changed; it is not an original-frame comparison.
The native bridge now combines every mesh-backed visual of each selected root, The native bridge now combines every mesh-backed visual of each selected root,
using that root's preserved TMA `position` and `scale` in the shared diagnostic using that root's preserved TMA `position` and `scale` in the shared diagnostic
XZ frame. It reports the actual selected root count as `preview_roots`, rather XY frame. It reports the actual selected root count as `preview_roots`, rather
than implying that all mission objects are rendered. Raw orientation, original than implying that all mission objects are rendered. Raw orientation, original
camera/frustum and visibility remain unresolved. camera/frustum and visibility remain unresolved.
@@ -1014,8 +1014,8 @@ in 59.7 seconds: 25 submitted MSH components, one terrain component and 26
descriptors on the native 1280x720 two-image swapchain, with validation descriptors on the native 1280x720 two-image swapchain, with validation
warnings/errors `0/0`. The readback FNV-1a remained warnings/errors `0/0`. The readback FNV-1a remained
`10739087367165646439`, equal to the one-root terrain/root run. That equality `10739087367165646439`, equal to the one-root terrain/root run. That equality
does **not** prove the second root is visible or equivalent: it is recorded as does **not** prove the second root is visible or equivalent: it was recorded as
a camera/frustum/overlap investigation target, not hidden as a parity result. an investigation target, not hidden as a parity result.
The reproducible `fparkan-cli mission inspect` probe closes two tempting but The reproducible `fparkan-cli mission inspect` probe closes two tempting but
incorrect explanations for that equality. GOG `Autodemo.00` root 0 is incorrect explanations for that equality. GOG `Autodemo.00` root 0 is
@@ -1023,11 +1023,20 @@ incorrect explanations for that equality. GOG `Autodemo.00` root 0 is
`w_s_wlk1.dat` at `(479.12396, 795.95337, 1.6228507)`; therefore the TMA data `w_s_wlk1.dat` at `(479.12396, 795.95337, 1.6228507)`; therefore the TMA data
is neither duplicate nor co-located. Separately, the Vulkan static submit loop is neither duplicate nor co-located. Separately, the Vulkan static submit loop
calls `cmd_draw_indexed` for every prepared draw range and its depth comparison calls `cmd_draw_indexed` for every prepared draw range and its depth comparison
is `LESS_OR_EQUAL`. The unresolved equality is consequently evidence about the is `LESS_OR_EQUAL`. A later direct `Land.msh` bounds probe identified the actual
diagnostic camera/projection or visibility framing, not grounds to patch a static-viewer defect: GOG `AutoMAP/Land.msh` spans X/Y
missing draw or a `LESS` depth bug. The inspector intentionally reports raw `0..1190.6976` but Z only `0..94.50981`; the same TMA objects use X/Y for their
orientation as well as position and scale so later camera/transform work can world placement and a small Z height. The old XZ CPU frame discarded the second
be checked against the original mission bytes. horizontal TMA component and treated height as the screen axis. The renderer now
uses a shared XY CPU frame and retains Z as geometry height. This proves only
the source-axis convention needed by the diagnostic bridge; it does not recover
the original camera, orientation order, culling or projection matrix.
The new `fparkan-cli terrain inspect <Land.msh>` exposes these three-axis source
bounds without a renderer. The first XY GPU rechecks were intentionally bounded
to 120 seconds but remained at the existing `Graph` loading checkpoint and were
terminated without a native frame/report; therefore no new readback hash or GPU
acceptance result is claimed for the corrected projection yet.
### Camera ownership boundary from the GOG renderer ### Camera ownership boundary from the GOG renderer
@@ -1051,7 +1060,7 @@ This establishes that original camera creation/selection is Terrain-owned and
not a field that can safely be inferred from a TMA transform. It does **not** not a field that can safely be inferred from a TMA transform. It does **not**
yet establish world-to-view matrix layout, FOV, near/far values, projection yet establish world-to-view matrix layout, FOV, near/far values, projection
handedness, or initial mission camera selection. The Vulkan path must therefore handedness, or initial mission camera selection. The Vulkan path must therefore
continue to label its XZ projection as diagnostic until a dynamic capture or continue to label its XY projection as diagnostic until a dynamic capture or
further Terrain disassembly proves these contracts. further Terrain disassembly proves these contracts.
The public `World3D.dll!stdSetCurrentCamera` is a one-pointer `__stdcall` The public `World3D.dll!stdSetCurrentCamera` is a one-pointer `__stdcall`
@@ -1070,7 +1079,7 @@ matrix convention; they must not be treated as a usable renderer ABI yet.
`Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28`
сохраняет source triangle order, а его positions и packed UV0 попадают в тот же сохраняет source triangle order, а его positions и packed UV0 попадают в тот же
static vertex/index upload path. Для текущего диагностического viewer XZ bounds static vertex/index upload path. Для текущего диагностического viewer XY bounds
нормализуются в clip-space, packed UV0 декодируется как signed `int16 / 1024`. нормализуются в clip-space, packed UV0 декодируется как signed `int16 / 1024`.
Один static draw range намеренно не трактует `material_tag`, surface mask, slots Один static draw range намеренно не трактует `material_tag`, surface mask, slots
или auxiliary streams как material/pipeline state: их связь с original terrain или auxiliary streams как material/pipeline state: их связь с original terrain