From dbfdcf1e4cf5fd6adb43505ff95db10c927dd8ef Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 31 Oct 2026 01:05:42 +0400 Subject: fix(runtime): follow linked nodes after building helper hits --- crates/fparkan-runtime/Cargo.toml | 3 + crates/fparkan-runtime/src/mesh_collision.rs | 649 ++++++++++++++++++++++++++- 2 files changed, 634 insertions(+), 18 deletions(-) (limited to 'crates/fparkan-runtime') diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index f66551b..8eb3e84 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -17,3 +17,6 @@ fparkan-resource = { path = "../fparkan-resource" } fparkan-script = { path = "../fparkan-script" } fparkan-vfs = { path = "../fparkan-vfs" } fparkan-world = { path = "../fparkan-world" } + +[dev-dependencies] +fparkan-animation = { path = "../fparkan-animation" } diff --git a/crates/fparkan-runtime/src/mesh_collision.rs b/crates/fparkan-runtime/src/mesh_collision.rs index 2f987d2..6e9ae2d 100644 --- a/crates/fparkan-runtime/src/mesh_collision.rs +++ b/crates/fparkan-runtime/src/mesh_collision.rs @@ -1,6 +1,8 @@ //! World-space segment queries over selected building MSH nodes and slots. -use fparkan_msh::{query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId}; +use fparkan_msh::{ + node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId, +}; use fparkan_render::RawCameraTransform; /// One selected MSH node/slot and its affine transform into world space. @@ -41,7 +43,9 @@ const MAX_INTERSECTION_VISITS: usize = 200; /// query excludes `0x0200` while keeping bit `0x0008` visible to the building /// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts /// the query from that world-space point toward the original endpoint, just as -/// the native building camera does. +/// the native building camera does. Each helper's Batch20 `local_batch_index` +/// selects the next Node38 root; that retry visits its eligible descendants +/// child-before-self and queries only nodes with supplied geometry bindings. pub fn query_building_segment_world( model: &ModelAsset, nodes: &[BuildingMeshNode], @@ -72,10 +76,25 @@ pub fn query_building_segment_world( let original_start = world_segment[0]; let original_end = world_segment[1]; let mut segment_start = original_start; + let mut linked_root = None; let mut intersection_visits = 0; loop { let mut nearest = None; - for node in &prepared_nodes { + let query_nodes = if let Some(root) = linked_root { + let order = linked_node_postorder(model, root)?; + let mut ordered = Vec::new(); + for node_id in order { + ordered.extend( + prepared_nodes + .iter() + .filter(|node| node.source.node == node_id), + ); + } + ordered + } else { + prepared_nodes.iter().collect() + }; + for node in query_nodes { let local_segment = [ transform_point(node.node_from_world, segment_start) .ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?, @@ -155,10 +174,113 @@ pub fn query_building_segment_world( if batch_flags & 0x0008 == 0 { return Ok(Some(nearest)); } + let batch = model.batches.get(nearest.batch_index).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "building query returned missing batch {}", + nearest.batch_index + )) + })?; + if batch.local_batch_index == u16::MAX { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has the sentinel linked-node key", + nearest.batch_index + ))); + } + let linked_node = NodeId(u32::from(batch.local_batch_index)); + if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has linked node {} outside Node38 count {}", + nearest.batch_index, linked_node.0, model.node_count + ))); + } segment_start = nearest.position; + linked_root = Some(linked_node); } } +/// Builds the native child-before-self query order rooted at a portal link. +/// +/// Node metadata is traversed even when the caller has no selected geometry +/// binding for that node, because eligible descendants can still carry hits. +fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result, MshError> { + let root_index = usize::try_from(root.0).map_err(|_| { + MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0)) + })?; + if root_index >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "linked query node {} is outside Node38 count {}", + root.0, model.node_count + ))); + } + + let metadata = (0..model.node_count) + .map(|index| { + let node = NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?); + node38_metadata(model, node).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "linked query requires Node38 metadata for node {index}" + )) + }) + }) + .collect::, _>>()?; + for (index, entry) in metadata.iter().enumerate() { + if entry.parent_or_link_raw != u16::MAX + && usize::from(entry.parent_or_link_raw) >= model.node_count + { + return Err(MshError::InvalidGeometry(format!( + "Node38 parent {} for node {index} is outside node count {}", + entry.parent_or_link_raw, model.node_count + ))); + } + } + + // Each stack frame marks entry or postorder exit. This keeps traversal + // bounded by the model table rather than by the native hierarchy depth. + let mut stack = vec![(root_index, false)]; + let mut active = vec![false; model.node_count]; + let mut visited = vec![false; model.node_count]; + let mut ordered = Vec::new(); + while let Some((index, exiting)) = stack.pop() { + if exiting { + active[index] = false; + visited[index] = true; + ordered.push(NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?)); + continue; + } + if active[index] { + return Err(MshError::InvalidGeometry(format!( + "linked Node38 query contains a cycle at node {index}" + ))); + } + if visited[index] { + continue; + } + + active[index] = true; + stack.push((index, true)); + if metadata[index].header_flags & 0x0010 == 0 { + let parent_word = u16::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!( + "Node38 index {index} cannot be represented by its parent field" + )) + })?; + for child in (0..model.node_count).rev() { + if metadata[child].parent_or_link_raw != u16::MAX + && metadata[child].parent_or_link_raw == parent_word + && metadata[child].header_flags & 0x0004 == 0 + { + stack.push((child, false)); + } + } + } + } + Ok(ordered) +} + #[derive(Clone, Copy)] struct PreparedNode { source: BuildingMeshNode, @@ -222,11 +344,22 @@ mod tests { use super::{ query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS, }; - use fparkan_msh::{Batch, ModelAsset, MshError, NodeId, Slot, SlotId, TriangleDescriptor}; + use fparkan_msh::{ + query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId, + TriangleDescriptor, + }; const IDENTITY: [f32; 16] = [ 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, ]; + const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + batch_exclude: 0x0200, + triangle_include: 0, + triangle_exclude: 0x0024, + }; fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode { node_with_slot(node, 0, world_from_node) @@ -245,6 +378,13 @@ mod tests { } fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset { + model_with_slots_and_nodes(planes_per_slot, 2) + } + + fn model_with_slots_and_nodes( + planes_per_slot: &[&[(f32, u16)]], + node_count: usize, + ) -> ModelAsset { let mut positions = Vec::new(); let mut indices = Vec::new(); let mut batches = Vec::new(); @@ -253,18 +393,21 @@ mod tests { for planes in planes_per_slot { let descriptor_start = triangle_descriptors.len(); let batch_start = batches.len(); + let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min); + let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max); + let center_y = (min_y + max_y) * 0.5; + let half_height = (max_y - min_y) * 0.5; for (y, batch_flags) in *planes { let base_vertex = positions.len(); let index_start = indices.len(); - positions.extend([[-2.0, *y, -2.0], [0.0, *y, 2.0], [2.0, *y, -2.0]]); + positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]); indices.extend([0, 1, 2]); batches.push(Batch { batch_flags: *batch_flags, material_index_hi: 0, material_index: 0, lightmap_index: 0, - local_batch_index: u16::try_from(batches.len() - batch_start) - .expect("small synthetic batch index"), + local_batch_index: u16::MAX, index_count: 3, index_start: u32::try_from(index_start).expect("small synthetic index offset"), vertex_count: 3, @@ -284,18 +427,18 @@ mod tests { batch_start: u16::try_from(batch_start).expect("small synthetic batch start"), batch_count: u16::try_from(batches.len() - batch_start) .expect("small synthetic batch count"), - aabb_min: [-2.0, -10.0, -2.0], - aabb_max: [2.0, 10.0, 2.0], - sphere_center: [0.0; 3], - sphere_radius: 0.0, + aabb_min: [-20.0, min_y, -20.0], + aabb_max: [20.0, max_y, 20.0], + sphere_center: [0.0, center_y, 0.0], + sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(), opaque: [0; 5], }); } ModelAsset { node_stride: 38, - node_count: 2, - nodes_raw: vec![0; 76], + node_count, + nodes_raw: vec![0; node_count * 38], type16_present: false, bounding_sphere_center: [0.0; 3], bounding_sphere_radius: 0.0, @@ -312,6 +455,22 @@ mod tests { } } + fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) { + let offset = node * model.node_stride; + model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes()); + model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes()); + } + + fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) { + model.batches[batch].local_batch_index = node; + } + + fn set_root_node_metadata(model: &mut ModelAsset) { + for node in 0..model.node_count { + set_node_metadata(model, node, 0, u16::MAX); + } + } + fn translated(x: f32, y: f32, z: f32) -> [f32; 16] { let mut matrix = IDENTITY; matrix[3] = x; @@ -418,7 +577,9 @@ mod tests { fn retries_from_bit8_helper_to_the_farther_wall() { let helper_plane = [(2.0, 0x0008)]; let wall_plane = [(0.0, 0)]; - let model = model_with_slots(&[&helper_plane, &wall_plane]); + let mut model = model_with_slots(&[&helper_plane, &wall_plane]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 1); let result = query_building_segment_world( &model, &[ @@ -439,10 +600,15 @@ mod tests { #[test] fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() { - let model = model_with_planes(&[(0.0, 0), (0.0, 0x0008)]); + let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 1, 1); let result = query_building_segment_world( &model, - &[node(0, IDENTITY)], + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(0, 1, IDENTITY), + ], [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], ) .expect("valid world query"); @@ -450,6 +616,229 @@ mod tests { assert!(result.is_none()); } + #[test] + fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() { + const START: [f32; 3] = [301.39682, 806.24396, 2.868942]; + const END: [f32; 3] = [304.3922, 806.4102, 2.868942]; + let world_from_node = [ + f32::from_bits(0xbd62_f745), + f32::from_bits(0xbf7f_9b4f), + 0.0, + f32::from_bits(0x4379_43ca), + f32::from_bits(0x3f7f_9b4f), + f32::from_bits(0xbd62_f745), + 0.0, + f32::from_bits(0x4448_b667), + 0.0, + 0.0, + 1.0, + f32::from_bits(0x4110_2494), + 0.0, + 0.0, + 0.0, + 1.0, + ]; + let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 0, 0, u16::MAX); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + let source = node(1, world_from_node); + + // Reproduce the old all-node retry: the world-space first hit maps + // back to a local start just above this same helper plane. + let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix"); + let local_start = + super::transform_point(prepared.node_from_world, START).expect("finite local start"); + let local_end = + super::transform_point(prepared.node_from_world, END).expect("finite local endpoint"); + let first_hit = query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid first helper query") + .expect("first helper crossing"); + let world_hit = + super::transform_point(world_from_node, first_hit.position).expect("finite world hit"); + let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit) + .expect("finite round-tripped start"); + assert!(query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [repeated_local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid repeated helper query") + .is_some()); + + let result = query_building_segment_world(&model, &[source], [START, END]) + .expect("valid portal-linked world query"); + assert!(result.is_none()); + } + + #[test] + fn linked_subtree_finds_drawable_child_through_nondrawable_parent() { + let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked-child query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(3)); + assert_eq!(hit.slot, SlotId(1)); + assert_eq!(hit.batch_index, 1); + assert_point(hit.position, [0.0, 0.0, 0.0]); + } + + #[test] + fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(2, 1, IDENTITY), + node_with_slot(3, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid child-first linked query"); + + let hit = hit(result); + // The equal-distance later candidate replaces the child result. + assert_eq!(hit.node, NodeId(2)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() { + let mut model = model_with_slots_and_nodes( + &[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]], + 7, + ); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x0000, 2); + set_node_metadata(&mut model, 4, 0x0010, 3); + set_node_metadata(&mut model, 5, 0x0004, 3); + set_node_metadata(&mut model, 6, 0x0004, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(4, 1, IDENTITY), + node_with_slot(5, 2, IDENTITY), + node_with_slot(6, 3, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked branch query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(4)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_keeps_native_batch_and_triangle_exclude_masks() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + set_node_metadata(&mut model, 4, 0x00d1, 2); + model + .triangle_descriptors + .as_mut() + .expect("synthetic type-7 table")[1] + .tri_flags = 0x0024; + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + node_with_slot(4, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid query with native exclusion masks"); + + assert!(result.is_none()); + } + + #[test] + fn linked_order_stops_descent_at_header_bit10_but_keeps_self() { + let mut model = model_with_slots_and_nodes(&[&[]], 5); + set_root_node_metadata(&mut model); + set_node_metadata(&mut model, 1, 0, 0); + set_node_metadata(&mut model, 2, 0x0014, 1); + set_node_metadata(&mut model, 3, 0, 2); + set_node_metadata(&mut model, 4, 0, 3); + + let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree"); + + assert_eq!(order, [NodeId(2)]); + } + + #[test] + fn invalid_portal_link_and_linked_cycle_are_explicit_errors() { + let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]); + set_root_node_metadata(&mut invalid_link); + set_batch_link(&mut invalid_link, 0, 2); + assert!(matches!( + query_building_segment_world( + &invalid_link, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("linked") + )); + + let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3); + set_batch_link(&mut cycle, 0, 1); + set_node_metadata(&mut cycle, 0, 0, u16::MAX); + set_node_metadata(&mut cycle, 1, 0, 2); + set_node_metadata(&mut cycle, 2, 0, 1); + assert!(matches!( + query_building_segment_world( + &cycle, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("cycle") + )); + } + #[test] fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() { let mut non_affine = IDENTITY; @@ -493,8 +882,13 @@ mod tests { .map(|index| ((helper_count - index) as f32, 0x0008)) .collect::>(); planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } let result = query_building_segment_world( - &model_with_planes(&planes), + &model, &[node(0, IDENTITY)], [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], ) @@ -512,8 +906,13 @@ mod tests { .map(|index| ((helper_count - index) as f32, 0x0008)) .collect::>(); planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } let result = query_building_segment_world( - &model_with_planes(&planes), + &model, &[node(0, IDENTITY)], [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], ); @@ -536,6 +935,117 @@ mod tests { result } + fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] { + let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation); + let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation); + let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation); + [ + x[0], + y[0], + z[0], + pose.translation[0], + x[1], + y[1], + z[1], + pose.translation[1], + x[2], + y[2], + z[2], + pose.translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] + } + + fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * value[2] - z * value[1]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] + } + + fn scale_matrix(scale: [f32; 3]) -> [f32; 16] { + [ + scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0, + 0.0, 1.0, + ] + } + + fn node_times_from_bindings( + node_count: usize, + bindings: &[fparkan_assets::PreparedControlNodeBinding], + ) -> Vec> { + use fparkan_animation::AnimationTime; + + let mut times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + if binding.flags & 0x04 != 0 { + continue; + } + assert!(binding.node_index < node_count); + let mut blend = if binding.flags & 0x01 != 0 { + let mut value = binding.initial_blend; + if value > 1.0 { + value -= 1.0; + } + if value < 0.0 { + value += 1.0; + } + value + } else { + binding.initial_blend.clamp(0.0, 1.0) + }; + if binding.flags & 0x02 != 0 { + blend = 1.0 - blend; + } + times[binding.node_index] = Some(AnimationTime( + (1.0 - blend) * binding.frame_a + blend * binding.frame_b, + )); + } + times + } + + fn nodes_for_hierarchy( + model: &ModelAsset, + poses: &[fparkan_animation::Pose], + root_from_body: [f32; 16], + owner_scale: [f32; 3], + ) -> Vec { + use fparkan_msh::{selected_slot, Group, Lod}; + + let scale = scale_matrix(owner_scale); + (0..model.node_count) + .filter_map(|index| { + let node = NodeId(u32::try_from(index).expect("model node id fits u32")); + let slot = selected_slot(model, node, Lod(0), Group(0))?; + let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")]; + let batch_start = usize::from(slot_data.batch_start); + let batch_end = batch_start + usize::from(slot_data.batch_count); + if !model.batches[batch_start..batch_end] + .iter() + .any(|batch| batch.index_count != 0) + { + return None; + } + let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available")); + let body_from_node = compose_row_major(scale, node_pose); + let world_from_node = compose_row_major(root_from_body, body_from_node); + Some(BuildingMeshNode { + node, + slot, + world_from_node, + }) + }) + .collect() + } + #[test] #[ignore = "requires licensed corpus"] fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() { @@ -639,4 +1149,107 @@ mod tests { } } } + + #[test] + #[ignore = "requires licensed AutoDemo corpus"] + fn autodemo_portal_uses_node3_authored_closed_and_open_poses() { + use crate::{ + create, load_mission_static_preview_roots, loaded_mission_assets, + loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest, + }; + use fparkan_animation::AnimationTime; + use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times}; + use fparkan_render::LegacyIron3dEulerTransform; + use fparkan_vfs::DirectoryVfs; + use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; + + const OBJECT_INDEX: usize = 4; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"), + ); + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root)))) + .expect("create SingleMission fixture engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::MAX, + ) + .expect("load SingleMission preview roots"); + let assets = loaded_mission_assets(&engine).expect("loaded mission assets"); + let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts"); + let draft = &drafts[OBJECT_INDEX]; + assert!(draft + .resource_name_raw + .eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat")); + let visual_id = *assets + .visuals_for_object(OBJECT_INDEX) + .first() + .expect("PLANT root visual"); + let visual = assets.visual_by_id(visual_id).expect("PLANT visual"); + let model_id = visual.model_id.expect("PLANT MSH model"); + let model = &assets + .model_by_id(model_id) + .expect("prepared PLANT model") + .validated; + assert_eq!(model.node_count, 29); + + let controls = assets + .object_component_controls + .get(OBJECT_INDEX) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + .expect("PLANT component CTLD bindings"); + let node3_binding = controls + .bindings + .iter() + .find(|binding| binding.node_index == 3) + .expect("Node3 CTLD binding"); + assert_eq!(node3_binding.frame_a, 0.0); + assert_eq!(node3_binding.frame_b, 1.0); + assert_eq!(node3_binding.initial_blend, 0.0); + + let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings); + let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model)) + .expect("initial Node38 hierarchy"); + assert_eq!(node_times[3], Some(AnimationTime(0.0))); + + let root_transform = LegacyIron3dEulerTransform { + translation: draft.position, + orientation_radians: draft.orientation_raw, + }; + let root_from_body = root_transform + .try_row_major() + .expect("finite PLANT root matrix"); + let world_door = BODY_DOOR.map(|point| { + root_transform + .try_transform_scaled_point(point, draft.scale) + .expect("finite BODY_DOOR world point") + }); + let closed_nodes = + nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale); + let closed = query_building_segment_world(model, &closed_nodes, world_door) + .expect("closed-door building query") + .expect("authored time-0 doorway blocker"); + assert_eq!(closed.node, NodeId(3)); + assert_eq!(closed.slot, SlotId(25)); + assert_eq!(closed.batch_index, 100); + assert_eq!(closed.triangle_descriptor_index, 855); + assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000); + + node_times[3] = Some(AnimationTime(node3_binding.frame_b)); + let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .expect("authored Node3 time-1 hierarchy"); + let open_nodes = + nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale); + assert!(query_building_segment_world(model, &open_nodes, world_door) + .expect("time-1 building query") + .is_none()); + } } -- cgit v1.2.3