diff --git a/rust_parser/core/src/mapdata/describe.rs b/rust_parser/core/src/mapdata/describe.rs index 8e56109..b6b7f37 100644 --- a/rust_parser/core/src/mapdata/describe.rs +++ b/rust_parser/core/src/mapdata/describe.rs @@ -425,31 +425,21 @@ pub fn describe_instance(store: &SaveStore, index: &MapIndex, instance_name: &st } } - // Mixed-mark pipe network detection (rated members only). + // Mixed-mark pipe detection, scoped to the hovered segment's LINE (walk + // ends at splitting/merging junctions), not the whole FGPipeNetwork -- + // a Mk2 trunk legitimately feeding two Mk1 branches through a junction + // is not a bottleneck, matching how conveyor chains end at splitters. if queries::pipe_flow_limit_per_minute(type_path.as_deref()).is_some() { - for connector_suffix in PIPE_CONNECTOR_SUFFIXES { - connector_key.clear(); - connector_key.extend_from_slice(instance_name.as_bytes()); - connector_key.extend_from_slice(connector_suffix.as_bytes()); - let Some(connector_object) = index.parse_object_by_name(store, &connector_key) else { - continue; - }; - let network_id = props::int(&connector_object.properties, data, b"mPipeNetworkID"); - let member_names = - network_id.and_then(|id| index.pipe_network_id_to_members.get(&id)); - // `if memberNames:` -- the break sits inside this truthy gate. - if let Some(member_names) = member_names { - if !member_names.is_empty() { - if let Some(network_bottleneck) = queries::pipe_network_bottleneck( - store, - index, - member_names, - type_path.as_deref(), - ) { - result.insert("lineBottleneck".into(), network_bottleneck); - } - break; - } + let member_names = + queries::pipe_line_member_names(store, index, instance_name.as_bytes()); + if !member_names.is_empty() { + if let Some(line_bottleneck) = queries::pipe_network_bottleneck( + store, + index, + &member_names, + type_path.as_deref(), + ) { + result.insert("lineBottleneck".into(), line_bottleneck); } } } diff --git a/rust_parser/core/src/mapdata/queries.rs b/rust_parser/core/src/mapdata/queries.rs index 5547d37..66b930f 100644 --- a/rust_parser/core/src/mapdata/queries.rs +++ b/rust_parser/core/src/mapdata/queries.rs @@ -388,7 +388,76 @@ pub fn conveyor_chain_bottleneck( ) } -/// sav_map_data._pipeNetworkBottleneck. +/// Rated members of the hovered segment's pipe LINE (not the whole +/// FGPipeNetwork): walks mConnectedComponent links outward from `start`, +/// continuing through rated pipes/pumps and through junctions acting as +/// plain couplings (<= 2 connected ports), and stopping at junctions that +/// actually split or merge the flow (>= 3 connected ports) and at anything +/// unrated (machines, valves, tanks). Mirrors conveyor bottleneck scoping, +/// where the game's chain actors end at splitters/mergers -- a Mk2 feeding +/// two Mk1 branches through a junction is three separate lines, not one +/// mixed-mark network. +pub fn pipe_line_member_names( + store: &SaveStore, + index: &MapIndex, + start_instance_name: &[u8], +) -> Vec { + let data: &[u8] = &store.data; + // Connected peer OWNER instances of one member (component objects are + // named .; a port without a peer has no + // mConnectedComponent). + let connected_peers = |instance: &[u8]| -> Vec> { + let mut peers = Vec::new(); + let mut key: Vec = Vec::new(); + for suffix in PIPE_CONNECTOR_SUFFIXES { + key.clear(); + key.extend_from_slice(instance); + key.extend_from_slice(suffix.as_bytes()); + let Some(component) = index.parse_object_by_name(store, &key) else { continue }; + let Some(peer) = props::object_ref(&component.properties, data, b"mConnectedComponent") + else { + continue; + }; + let peer_path = peer.path_name.bytes(data); + if peer_path.is_empty() { + continue; + } + if let Some(dot) = peer_path.iter().rposition(|&b| b == b'.') { + peers.push(peer_path[..dot].to_vec()); + } + } + peers + }; + + let mut members: Vec = Vec::new(); + let mut visited: HashSet> = HashSet::new(); + let mut queue: Vec> = vec![start_instance_name.to_vec()]; + while let Some(instance) = queue.pop() { + if !visited.insert(instance.clone()) { + continue; + } + let type_path = match index.header_by_name(store, &instance) { + Some(Header::Actor(a)) => a.type_path.to_string(data), + _ => continue, + }; + if pipe_flow_limit_per_minute(Some(&type_path)).is_some() { + members.push(props::lossy(&instance)); + queue.extend(connected_peers(&instance)); + } else if short_class_name(&type_path).contains("PipelineJunction") { + let peers = connected_peers(&instance); + if peers.len() <= 2 { + queue.extend(peers); // plain coupling: pass through + } + // >= 3 connected ports: the line ends here, like a belt + // chain ends at a splitter/merger. + } + // Anything else (machine, valve, tank, ...) terminates the line. + } + members +} + +/// sav_map_data._pipeNetworkBottleneck, rescoped from the whole FGPipeNetwork +/// to the hovered segment's line (see pipe_line_member_names). pub fn pipe_network_bottleneck( store: &SaveStore, index: &MapIndex, @@ -402,7 +471,7 @@ pub fn pipe_network_bottleneck( &member_names, hovered_type_path, &pipe_flow_limit_per_minute, - "network", + "line", "m³/min", ) }