diff --git a/crates/nbt-sim-wasm/src/lib.rs b/crates/nbt-sim-wasm/src/lib.rs index 09e3125..329fb66 100644 --- a/crates/nbt-sim-wasm/src/lib.rs +++ b/crates/nbt-sim-wasm/src/lib.rs @@ -1,10 +1,15 @@ +use std::collections::HashSet; + use redstone_compiler::graph::graphviz::ToGraphvizGraph; -use redstone_compiler::graph::world::WorldGraphBuilder; +use redstone_compiler::graph::world::{WorldGraph, WorldGraphBuilder}; +use redstone_compiler::graph::{GraphNodeId, GraphNodeKind}; use redstone_compiler::nbt::{NBTRoot, ToNBT}; -use redstone_compiler::transform::place_and_route::utils::world_to_logic; -use redstone_compiler::world::block::BlockKind; -use redstone_compiler::world::position::Position; +use redstone_compiler::transform::place_and_route::place_bound::{PlaceBound, PropagateType}; +use redstone_compiler::transform::world_to_logic::WorldToLogicTransformer; +use redstone_compiler::world::block::{Block, BlockKind, Direction}; +use redstone_compiler::world::position::{DimSize, Position}; use redstone_compiler::world::simulator::{SimulationSnapshot, SimulationTraceEntry, Simulator}; +use redstone_compiler::world::{World, World3D}; use serde::Serialize; use wasm_bindgen::prelude::*; @@ -34,8 +39,12 @@ struct SnapshotInfo { #[derive(Serialize)] struct GraphDotInfo { - world_dot: String, + raw_world_dot: String, + raw_world_dot_without_tags: String, + folded_world_dot: String, + folded_world_dot_without_tags: String, logic_dot: String, + logic_dot_without_tags: String, } #[wasm_bindgen] @@ -97,16 +106,90 @@ impl NbtSimulator { pub fn graph_dot(nbt_bytes: &[u8]) -> Result { let nbt = NBTRoot::from_nbt_bytes(nbt_bytes).map_err(to_js_error)?; let world = nbt.to_world(); - let world_graph = WorldGraphBuilder::new(&world).build(); - let logic_graph = world_to_logic(&world).map_err(to_js_error)?; + let raw_world_graph = WorldGraphBuilder::new(&world).build(); + let transformer = + WorldToLogicTransformer::new(raw_world_graph.clone(), true).map_err(to_js_error)?; + let folded_world_dot = transformer.world_graph().to_graphviz(); + let folded_world_dot_without_tags = transformer.world_graph().to_graphviz_without_tags(); + let logic_graph = transformer.transform().map_err(to_js_error)?; let graph_dot = GraphDotInfo { - world_dot: world_graph.to_graphviz(), + raw_world_dot: raw_world_graph.to_graphviz(), + raw_world_dot_without_tags: raw_world_graph.to_graphviz_without_tags(), + folded_world_dot, + folded_world_dot_without_tags, logic_dot: logic_graph.to_graphviz(), + logic_dot_without_tags: logic_graph.to_graphviz_without_tags(), }; serde_wasm_bindgen::to_value(&graph_dot).map_err(to_js_error) } + pub fn selected_graph_dot( + nbt_bytes: &[u8], + folded: bool, + node_ids: JsValue, + ) -> Result { + let selected_node_ids: Vec = + serde_wasm_bindgen::from_value(node_ids).map_err(to_js_error)?; + let selected_node_ids = selected_node_ids.into_iter().collect::>(); + let nbt = NBTRoot::from_nbt_bytes(nbt_bytes).map_err(to_js_error)?; + let world = nbt.to_world(); + let raw_world_graph = WorldGraphBuilder::new(&world).build(); + let source_world_graph = if folded { + let transformer = + WorldToLogicTransformer::new(raw_world_graph, true).map_err(to_js_error)?; + transformer.world_graph().clone() + } else { + raw_world_graph + }; + + let selected_world_graph = + source_world_graph.extract_subgraph_by_node_ids(&selected_node_ids); + let transformer = WorldToLogicTransformer::new(selected_world_graph.clone(), true) + .map_err(to_js_error)?; + let folded_world_dot = transformer.world_graph().to_graphviz(); + let folded_world_dot_without_tags = transformer.world_graph().to_graphviz_without_tags(); + let logic_graph = transformer.transform().map_err(to_js_error)?; + let graph_dot = GraphDotInfo { + raw_world_dot: selected_world_graph.to_graphviz(), + raw_world_dot_without_tags: selected_world_graph.to_graphviz_without_tags(), + folded_world_dot, + folded_world_dot_without_tags, + logic_dot: logic_graph.to_graphviz(), + logic_dot_without_tags: logic_graph.to_graphviz_without_tags(), + }; + + serde_wasm_bindgen::to_value(&graph_dot).map_err(to_js_error) + } + + pub fn selected_nbt( + nbt_bytes: &[u8], + folded: bool, + node_ids: JsValue, + ) -> Result { + let selected_node_ids: Vec = + serde_wasm_bindgen::from_value(node_ids).map_err(to_js_error)?; + let selected_node_ids = selected_node_ids.into_iter().collect::>(); + let nbt = NBTRoot::from_nbt_bytes(nbt_bytes).map_err(to_js_error)?; + let world = nbt.to_world(); + let raw_world_graph = WorldGraphBuilder::new(&world).build(); + let source_world_graph = if folded { + let transformer = + WorldToLogicTransformer::new(raw_world_graph.clone(), true).map_err(to_js_error)?; + transformer.world_graph().clone() + } else { + raw_world_graph.clone() + }; + let selected_world = selected_world_from_graph( + &world, + &raw_world_graph, + &source_world_graph, + &selected_node_ids, + ); + + serde_wasm_bindgen::to_value(&selected_world.to_nbt()).map_err(to_js_error) + } + pub fn switches(&self) -> Result { let switches = self .sim @@ -185,3 +268,184 @@ fn snapshots_to_info(snapshots: &[SimulationSnapshot]) -> Vec { }) .collect() } + +fn selected_world_from_graph( + world: &World, + raw_world_graph: &redstone_compiler::graph::world::WorldGraph, + source_world_graph: &redstone_compiler::graph::world::WorldGraph, + selected_node_ids: &HashSet, +) -> World3D { + let mut positions = HashSet::new(); + let mut raw_node_ids = HashSet::new(); + for node_id in selected_node_ids { + if let Some(position) = source_world_graph.positions.get(node_id) { + positions.insert(*position); + } + + let Some(node) = source_world_graph.graph.find_node_by_id(*node_id) else { + continue; + }; + if !matches!(node.kind, GraphNodeKind::Sequential(_)) && !node.tag.starts_with("Folded ") { + raw_node_ids.insert(*node_id); + continue; + } + for source_node_id in source_node_ids_from_tag(&node.tag) { + if let Some(position) = raw_world_graph.positions.get(&source_node_id) { + positions.insert(*position); + raw_node_ids.insert(source_node_id); + } + } + } + + add_edge_routing_blocks(world, raw_world_graph, &raw_node_ids, &mut positions); + add_attached_blocks(world, &mut positions); + compact_world_from_positions(world, &positions) +} + +fn add_edge_routing_blocks( + world: &World, + raw_world_graph: &WorldGraph, + raw_node_ids: &HashSet, + positions: &mut HashSet, +) { + let world3d = World3D::from(world); + let route_positions = raw_node_ids + .iter() + .flat_map(|source_node_id| { + let Some(source_node) = raw_world_graph.graph.find_node_by_id(*source_node_id) else { + return Vec::new(); + }; + let Some(source_position) = raw_world_graph.positions.get(source_node_id) else { + return Vec::new(); + }; + let source_outputs = source_node + .outputs + .iter() + .copied() + .filter(|target_node_id| raw_node_ids.contains(target_node_id)) + .collect::>(); + + source_outputs + .into_iter() + .filter_map(|target_node_id| raw_world_graph.positions.get(&target_node_id)) + .flat_map(|target_position| { + edge_routing_positions( + *source_position, + *target_position, + &source_node.kind, + &world3d, + ) + }) + .collect::>() + }) + .collect::>(); + + positions.extend(route_positions); +} + +fn edge_routing_positions( + source_position: Position, + target_position: Position, + source_kind: &GraphNodeKind, + world: &World3D, +) -> Vec { + let GraphNodeKind::Block(source_block) = source_kind else { + return Vec::new(); + }; + + PlaceBound(PropagateType::Soft, source_position, source_block.direction) + .propagation_bound(&source_block.kind, Some(world)) + .into_iter() + .filter(|bound| world.size.bound_on(bound.position())) + .filter(|bound| { + bound + .propagate_to(world) + .into_iter() + .any(|(_, position)| position == target_position) + }) + .map(|bound| bound.position()) + .filter(|position| *position != source_position && *position != target_position) + .filter(|position| world.size.bound_on(*position)) + .filter(|position| !world[*position].kind.is_air()) + .collect() +} + +fn add_attached_blocks(world: &World, positions: &mut HashSet) { + let world3d = World3D::from(world); + let attached_positions = positions + .iter() + .filter_map(|position| { + let block = world.blocks.iter().find_map(|(block_position, block)| { + (block_position == position).then_some(block) + })?; + attached_block_position(*position, *block) + }) + .filter(|position| world3d.size.bound_on(*position)) + .filter(|position| !world3d[*position].kind.is_air()) + .collect::>(); + + positions.extend(attached_positions); +} + +fn attached_block_position(position: Position, block: Block) -> Option { + match block.kind { + BlockKind::Redstone { .. } | BlockKind::Repeater { .. } => position.down(), + BlockKind::Switch { .. } | BlockKind::Torch { .. } => { + attached_direction(position, block.direction) + } + BlockKind::Air + | BlockKind::Cobble { .. } + | BlockKind::RedstoneBlock + | BlockKind::Piston { .. } => None, + } +} + +fn attached_direction(position: Position, direction: Direction) -> Option { + if direction == Direction::None { + return None; + } + + position.walk(direction) +} + +fn compact_world_from_positions(world: &World, positions: &HashSet) -> World3D { + if positions.is_empty() { + return World3D::new(DimSize(1, 1, 1)); + } + + let min_x = positions.iter().map(|position| position.0).min().unwrap(); + let min_y = positions.iter().map(|position| position.1).min().unwrap(); + let min_z = positions.iter().map(|position| position.2).min().unwrap(); + let max_x = positions.iter().map(|position| position.0).max().unwrap(); + let max_y = positions.iter().map(|position| position.1).max().unwrap(); + let max_z = positions.iter().map(|position| position.2).max().unwrap(); + let mut selected_world = World3D::new(DimSize( + max_x - min_x + 1, + max_y - min_y + 1, + max_z - min_z + 1, + )); + + for (position, block) in &world.blocks { + if !positions.contains(position) { + continue; + } + selected_world[Position(position.0 - min_x, position.1 - min_y, position.2 - min_z)] = + *block; + } + + selected_world +} + +fn source_node_ids_from_tag(tag: &str) -> Vec { + let Some(start) = tag.rfind('[') else { + return Vec::new(); + }; + let Some(end) = tag[start..].find(']') else { + return Vec::new(); + }; + + tag[start + 1..start + end] + .split(',') + .filter_map(|value| value.trim().parse().ok()) + .collect() +} diff --git a/src/graph/graphviz.rs b/src/graph/graphviz.rs index 44e78ba..9989443 100644 --- a/src/graph/graphviz.rs +++ b/src/graph/graphviz.rs @@ -9,7 +9,6 @@ use super::world::WorldGraph; use super::{Graph, GraphNode, GraphNodeId, SubGraph}; use crate::graph::module::GraphModulePortTarget; -#[derive(Default)] pub struct GraphvizBuilder<'a> { graph: Option<&'a Graph>, module: Option<&'a GraphModule>, @@ -17,10 +16,27 @@ pub struct GraphvizBuilder<'a> { clusters: Option)>>, show_node_id: bool, table_style: bool, + show_tags: bool, named_inputs: Option>, subname: Option>, } +impl<'a> Default for GraphvizBuilder<'a> { + fn default() -> Self { + Self { + graph: None, + module: None, + depth: None, + clusters: None, + show_node_id: false, + table_style: false, + show_tags: true, + named_inputs: None, + subname: None, + } + } +} + impl<'a> GraphvizBuilder<'a> { pub fn with_graph(&mut self, graph: &'a Graph) -> &mut Self { self.graph = Some(graph); @@ -65,6 +81,11 @@ impl<'a> GraphvizBuilder<'a> { self } + pub fn without_tags(&mut self) -> &mut Self { + self.show_tags = false; + self + } + pub fn with_subname(&mut self, subname: HashMap) -> &mut Self { self.subname = Some(subname); self @@ -168,12 +189,23 @@ digraph {graph_name} {{ }; if !self.table_style { - if node.tag.is_empty() { + if node.tag.is_empty() || !self.show_tags { format!(" node{} [label=\"{}\"]", node.id, name) } else { format!(" node{} [label=\"{} | {}\"]", node.id, name, node.tag) } } else { + let colspan = node.inputs.len().max(1); + let tag = if node.tag.is_empty() || !self.show_tags { + String::new() + } else { + format!( + r##" + {} + "##, + colspan, node.tag + ) + }; format!( r#" node{} [label=< @@ -181,12 +213,10 @@ digraph {graph_name} {{ + {}
{}
>]"#, - node.id, - inputs, - node.inputs.len(), - name + node.id, inputs, colspan, name, tag ) } } @@ -294,6 +324,8 @@ digraph {graph_name} {{ pub trait ToGraphvizGraph { fn to_graphviz(&self) -> String; + fn to_graphviz_without_tags(&self) -> String; + fn to_graphviz_with_clusters(&self, clusters: &[SubGraph]) -> String; } @@ -304,6 +336,13 @@ impl ToGraphvizGraph for Graph { .build("DefaultGraph") } + fn to_graphviz_without_tags(&self) -> String { + GraphvizBuilder::default() + .with_graph(self) + .without_tags() + .build("DefaultGraph") + } + fn to_graphviz_with_clusters(&self, clusters: &[SubGraph]) -> String { GraphvizBuilder::default() .with_graph(self) @@ -325,6 +364,13 @@ impl ToGraphvizGraph for LogicGraph { .build("LogicGraph") } + fn to_graphviz_without_tags(&self) -> String { + GraphvizBuilder::default() + .with_graph(&self.graph) + .without_tags() + .build("LogicGraph") + } + fn to_graphviz_with_clusters(&self, clusters: &[SubGraph]) -> String { GraphvizBuilder::default() .with_graph(&self.graph) @@ -357,6 +403,24 @@ impl ToGraphvizGraph for WorldGraph { .build("WorldGraph") } + fn to_graphviz_without_tags(&self) -> String { + let mut subnames = HashMap::new(); + subnames.extend( + self.positions + .iter() + .map(|(id, pos)| (*id, format!("{pos:?}"))), + ); + subnames.extend(self.routings.iter().map(|id| (*id, "Routings".to_string()))); + + GraphvizBuilder::default() + .with_graph(&self.graph) + .with_table() + .with_show_node_id() + .with_subname(subnames) + .without_tags() + .build("WorldGraph") + } + fn to_graphviz_with_clusters(&self, clusters: &[SubGraph]) -> String { GraphvizBuilder::default() .with_graph(&self.graph) @@ -385,6 +449,20 @@ impl ToGraphvizGraph for GraphWithSubGraphs { .build("LogicGraph") } + fn to_graphviz_without_tags(&self) -> String { + GraphvizBuilder::default() + .with_graph(&self.0) + .without_tags() + .with_cluster( + self.1 + .iter() + .enumerate() + .map(|(index, g)| (format!("Cluster {}", index), g.clone())) + .collect_vec(), + ) + .build("LogicGraph") + } + fn to_graphviz_with_clusters(&self, clusters: &[SubGraph]) -> String { GraphvizBuilder::default() .with_graph(&self.0) @@ -412,6 +490,13 @@ impl ToGraphvizGraph for ClusteredGraph { .build("ClusteredGraph") } + fn to_graphviz_without_tags(&self) -> String { + GraphvizBuilder::default() + .with_graph(&self.graph) + .without_tags() + .build("ClusteredGraph") + } + fn to_graphviz_with_clusters(&self, _: &[SubGraph]) -> String { unimplemented!() } @@ -428,3 +513,49 @@ impl ToGraphvizModule for GraphModule { .build("GraphModule") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::{Graph, GraphNode, GraphNodeKind}; + + #[test] + fn table_graphviz_shows_node_tags() { + let graph = Graph { + nodes: vec![GraphNode { + id: 0, + kind: GraphNodeKind::None, + tag: "Folded RS latch feedback SCC [1, 2, 3, 4]".to_owned(), + ..Default::default() + }], + ..Default::default() + }; + + let dot = GraphvizBuilder::default() + .with_graph(&graph) + .with_table() + .build("TaggedGraph"); + + assert!(dot.contains("Folded RS latch feedback SCC")); + } + + #[test] + fn graphviz_can_hide_node_tags() { + let graph = Graph { + nodes: vec![GraphNode { + id: 0, + kind: GraphNodeKind::None, + tag: "debug source tag".to_owned(), + ..Default::default() + }], + ..Default::default() + }; + + let dot = GraphvizBuilder::default() + .with_graph(&graph) + .without_tags() + .build("TaggedGraph"); + + assert!(!dot.contains("debug source tag")); + } +} diff --git a/src/graph/mod.rs b/src/graph/mod.rs index e8297ae..082203a 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -225,6 +225,27 @@ impl Graph { components } + pub fn external_edges(&self, nodes: &HashSet, incoming: bool) -> Vec { + let mut edges = nodes + .iter() + .flat_map(|node_id| { + let node = self.find_node_by_id(*node_id).into_iter(); + node.flat_map(move |node| { + if incoming { + node.inputs.iter() + } else { + node.outputs.iter() + } + }) + }) + .filter(|node_id| !nodes.contains(node_id)) + .copied() + .collect::>(); + edges.sort(); + edges.dedup(); + edges + } + pub fn dominators( &self, target_root: GraphNodeId, @@ -562,6 +583,7 @@ impl Graph { inputs: node.inputs.iter().map(|index| index_map[index]).collect(), outputs: node.outputs.iter().map(|index| index_map[index]).collect(), kind: node.kind, + tag: node.tag, ..Default::default() }) .collect::>(); @@ -595,6 +617,7 @@ impl Graph { inputs: node.inputs.iter().map(|index| indexs[index]).collect(), outputs: node.outputs.iter().map(|index| indexs[index]).collect(), kind: node.kind, + tag: node.tag, ..Default::default() }) .collect::>(); @@ -613,6 +636,52 @@ impl Graph { self.nodes.remove(index); } + pub fn replace_nodes_with( + &mut self, + removed_nodes: &HashSet, + kind: GraphNodeKind, + inputs: Vec, + outputs: Vec, + tag: String, + ) -> GraphNodeId { + let replacement_id = self.max_node_id().unwrap_or(0) + 1; + + for input in &inputs { + if let Some(node) = self.find_node_by_id_mut(*input) { + node.outputs.retain(|id| !removed_nodes.contains(id)); + if !node.outputs.contains(&replacement_id) { + node.outputs.push(replacement_id); + } + } + } + for output in &outputs { + if let Some(node) = self.find_node_by_id_mut(*output) { + node.inputs.retain(|id| !removed_nodes.contains(id)); + if !node.inputs.contains(&replacement_id) { + node.inputs.push(replacement_id); + } + } + } + for node_id in removed_nodes { + self.remove_by_node_id_lazy(*node_id); + } + + self.nodes.push(GraphNode { + id: replacement_id, + kind, + inputs, + outputs, + tag, + }); + self.nodes.sort_by_key(|node| node.id); + self.build_inputs(); + self.build_outputs(); + self.build_producers(); + self.build_consumers(); + + replacement_id + } + // 노드 삭제하고 삭제한 노드의 Input Output끼리 연결함 pub fn remove_and_reconnect_by_node_id_lazy(&mut self, node_id: GraphNodeId) { let Ok(index) = self.nodes.binary_search_by_key(&node_id, |node| node.id) else { @@ -769,6 +838,31 @@ impl Graph { SubGraphWithGraph::from(self, nodes) } + pub fn extract_graph_by_node_ids(&self, node_ids: &HashSet) -> Self { + let mut nodes = self + .nodes + .iter() + .filter(|node| node_ids.contains(&node.id)) + .cloned() + .collect_vec(); + + for node in &mut nodes { + node.inputs.retain(|input| node_ids.contains(input)); + node.outputs.retain(|output| node_ids.contains(output)); + } + nodes.sort_by_key(|node| node.id); + + let mut graph = Self { + nodes, + ..Default::default() + }; + graph.build_inputs(); + graph.build_outputs(); + graph.build_producers(); + graph.build_consumers(); + graph + } + pub fn split_with_outputs(&self) -> Vec { self.outputs() .iter() @@ -1130,6 +1224,56 @@ mod tests { assert_eq!(graph.find_node_by_id(2).unwrap().outputs, vec![3]); } + #[test] + fn extract_graph_by_node_ids_keeps_only_internal_edges() { + let mut graph = Graph { + nodes: vec![ + GraphNode { + id: 0, + outputs: vec![2], + ..Default::default() + }, + GraphNode { + id: 1, + outputs: vec![2], + ..Default::default() + }, + GraphNode { + id: 2, + inputs: vec![0, 1], + outputs: vec![3, 4], + ..Default::default() + }, + GraphNode { + id: 3, + inputs: vec![2], + ..Default::default() + }, + GraphNode { + id: 4, + inputs: vec![2], + ..Default::default() + }, + ], + ..Default::default() + }; + graph.build_producers(); + graph.build_consumers(); + + let selected = HashSet::from([1, 2, 3]); + let subgraph = graph.extract_graph_by_node_ids(&selected); + + assert_eq!( + subgraph.nodes.iter().map(|node| node.id).collect_vec(), + vec![1, 2, 3] + ); + assert_eq!(subgraph.find_node_by_id(1).unwrap().outputs, vec![2]); + assert_eq!(subgraph.find_node_by_id(2).unwrap().inputs, vec![1]); + assert_eq!(subgraph.find_node_by_id(2).unwrap().outputs, vec![3]); + assert_eq!(subgraph.find_node_by_id(3).unwrap().inputs, vec![2]); + subgraph.verify().unwrap(); + } + #[test] fn strongly_connected_components_include_feedback_loop() { let primitive = SequentialPrimitive::rs_latch(); diff --git a/src/graph/world.rs b/src/graph/world.rs index 6dae9c8..d34ae62 100644 --- a/src/graph/world.rs +++ b/src/graph/world.rs @@ -23,6 +23,51 @@ impl Verify for WorldGraph { } } +impl WorldGraph { + pub fn extract_subgraph_by_node_ids(&self, node_ids: &HashSet) -> Self { + Self { + graph: self.graph.extract_graph_by_node_ids(node_ids), + positions: self + .positions + .iter() + .filter(|(node_id, _)| node_ids.contains(node_id)) + .map(|(node_id, position)| (*node_id, *position)) + .collect(), + routings: self + .routings + .iter() + .filter(|node_id| node_ids.contains(node_id)) + .copied() + .collect(), + } + } + + pub fn replace_nodes_with( + &mut self, + removed_nodes: &HashSet, + kind: GraphNodeKind, + inputs: Vec, + outputs: Vec, + tag: String, + ) -> GraphNodeId { + let position = removed_nodes + .iter() + .find_map(|node_id| self.positions.get(node_id).copied()); + let replacement_id = + self.graph + .replace_nodes_with(removed_nodes, kind, inputs, outputs, tag); + for node_id in removed_nodes { + self.positions.remove(node_id); + self.routings.remove(node_id); + } + if let Some(position) = position { + self.positions.insert(replacement_id, position); + } + + replacement_id + } +} + #[derive(Debug, Clone)] pub struct WorldGraphBuilder { world: World3D, diff --git a/src/transform/place_and_route/utils.rs b/src/transform/place_and_route/utils.rs index 8dfc165..77eb653 100644 --- a/src/transform/place_and_route/utils.rs +++ b/src/transform/place_and_route/utils.rs @@ -51,7 +51,7 @@ pub fn world_to_logic_with_outputs( } else if let Some(node) = logic_graph .nodes .iter() - .filter(|node| node.tag == format!("From #{source_id}")) + .filter(|node| is_source_tag(&node.tag, source_id)) .max_by_key(|node| node.id) { node.id @@ -70,6 +70,11 @@ pub fn world_to_logic_with_outputs( normalize_logic_graph(logic_graph.attach_outputs(outputs)?) } +fn is_source_tag(tag: &str, source_id: GraphNodeId) -> bool { + let source = format!("From #{source_id}"); + tag == source || tag.starts_with(&format!("{source}: ")) +} + fn resolve_folded_output_source( raw_source_id: GraphNodeId, raw_graph: &WorldGraph, @@ -158,6 +163,22 @@ fn component_external_edges( .collect() } +#[cfg(test)] +mod tests { + use super::is_source_tag; + + #[test] + fn source_tag_match_accepts_plain_and_annotated_source_tags() { + assert!(is_source_tag("From #12", 12)); + assert!(is_source_tag( + "From #12: Folded redstone component [3, 4]", + 12 + )); + assert!(!is_source_tag("From #123", 12)); + assert!(!is_source_tag("From #12-ish", 12)); + } +} + pub fn equivalent_graph(src: &LogicGraph, tar: &LogicGraph) -> bool { petgraph::algo::is_isomorphic_matching( &src.to_petgraph(), diff --git a/src/transform/world.rs b/src/transform/world.rs index 83cfa63..db57a2b 100644 --- a/src/transform/world.rs +++ b/src/transform/world.rs @@ -5,6 +5,7 @@ use itertools::Itertools; use crate::graph::world::WorldGraph; use crate::graph::{GraphNode, GraphNodeId, GraphNodeKind}; +use crate::sequential::SequentialPrimitive; use crate::world::block::{Block, BlockKind, Direction}; pub struct WorldGraphTransformer { @@ -57,6 +58,7 @@ impl WorldGraphTransformer { let ids: HashSet = nodes.iter().map(|node| node.id).collect(); let mut group_inputs: HashMap> = HashMap::new(); let mut group_outputs: HashMap> = HashMap::new(); + let mut group_members: HashMap> = HashMap::new(); let mut group_ids: HashSet = HashSet::new(); @@ -64,6 +66,7 @@ impl WorldGraphTransformer { // make clustered id group let group_id = cluster.find(*id).unwrap(); group_ids.insert(group_id); + group_members.entry(group_id).or_default().push(*id); // collect group input outputs group_inputs.entry(group_id).or_default().extend( @@ -82,13 +85,16 @@ impl WorldGraphTransformer { // make clustered node let mut next_id = self.graph.graph.max_node_id().unwrap(); + for members in group_members.values_mut() { + members.sort_unstable(); + } // remove redstone node for id in &ids { self.graph.graph.remove_by_node_id_lazy(*id); } - for group_id in &group_ids { + for group_id in group_ids.iter().sorted() { next_id += 1; let node = GraphNode { @@ -104,6 +110,10 @@ impl WorldGraphTransformer { // TODO: optimize this inputs: group_inputs[group_id].clone().into_iter().collect_vec(), outputs: group_outputs[group_id].clone().into_iter().collect_vec(), + tag: format!( + "Folded redstone component {:?}", + group_members.get(group_id).unwrap() + ), ..Default::default() }; @@ -134,6 +144,31 @@ impl WorldGraphTransformer { self.graph.graph.build_consumers(); } + // Collapse physical RS-latch feedback loops before converting the world graph + // into logic. The downstream logic extraction walks nodes in topological order, + // so a cyclic torch/redstone latch core must become one sequential node first. + pub fn fold_rs_latch_feedback_components(&mut self) { + for component in self.graph.graph.strongly_connected_components() { + let component_set = component.iter().copied().collect::>(); + if !is_rs_latch_feedback_component(&self.graph, &component, &component_set) { + continue; + } + + let inputs = self.graph.graph.external_edges(&component_set, true); + if inputs.len() != 2 { + continue; + } + let outputs = self.graph.graph.external_edges(&component_set, false); + self.graph.replace_nodes_with( + &component_set, + GraphNodeKind::Sequential(SequentialPrimitive::rs_latch()), + inputs, + outputs, + format!("Folded RS latch feedback SCC {component:?}"), + ); + } + } + pub fn remove_redstone(&mut self) { self.remove_specific_kind_of_block( |kind| matches!(&kind, BlockKind::Redstone { .. }), @@ -173,6 +208,44 @@ impl WorldGraphTransformer { } } +// Recognize the narrow post-redstone-fold shape used by the current RS latch +// fixtures: two torches cross-coupled through two folded redstone routing nodes. +// Input gating around that core, such as in a D latch, stays outside the SCC and +// remains ordinary combinational logic. +fn is_rs_latch_feedback_component( + graph: &WorldGraph, + component: &[GraphNodeId], + component_set: &HashSet, +) -> bool { + if component.len() != 4 { + return false; + } + + let mut torch_count = 0; + let mut redstone_count = 0; + for node_id in component { + let Some(node) = graph.graph.find_node_by_id(*node_id) else { + return false; + }; + match &node.kind { + GraphNodeKind::Block(block) if matches!(block.kind, BlockKind::Torch { .. }) => { + torch_count += 1; + if !node.inputs.iter().any(|id| component_set.contains(id)) + || !node.outputs.iter().any(|id| component_set.contains(id)) + { + return false; + } + } + GraphNodeKind::Block(block) if matches!(block.kind, BlockKind::Redstone { .. }) => { + redstone_count += 1; + } + _ => return false, + } + } + + torch_count == 2 && redstone_count == 2 +} + #[cfg(test)] mod tests { use super::WorldGraphTransformer; @@ -186,9 +259,24 @@ mod tests { let g = WorldGraphBuilder::new(&nbt.to_world()).build(); let mut transform = WorldGraphTransformer::new(g); + transform.fold_redstone(); + let folded = transform.finish(); + + assert!( + folded + .graph + .nodes + .iter() + .any(|node| node.tag.contains("Folded redstone component")), + "expected folded redstone nodes to keep their source component tag" + ); + + let mut transform = WorldGraphTransformer::new(folded); transform.remove_redstone(); transform.remove_repeater(); - println!("{}", transform.finish().to_graphviz()); + if std::env::var_os("PRINT_WORLD_GRAPHS").is_some() { + println!("{}", transform.finish().to_graphviz()); + } Ok(()) } diff --git a/src/transform/world_to_logic.rs b/src/transform/world_to_logic.rs index 862b698..d23f52e 100644 --- a/src/transform/world_to_logic.rs +++ b/src/transform/world_to_logic.rs @@ -56,6 +56,7 @@ impl WorldToLogicTransformer { fn optimize(graph: WorldGraph, remain_routings: bool) -> WorldGraph { let mut transform = WorldGraphTransformer::new(graph); transform.fold_redstone(); + transform.fold_rs_latch_feedback_components(); if !remain_routings { transform.remove_redstone(); transform.remove_repeater(); @@ -95,69 +96,82 @@ impl WorldToLogicTransformer { .map(|id| new_in_id.get(id).unwrap_or(id)) .copied() .collect(); + let tag = transformed_tag(id, &node.tag); - let new_nodes = match node.kind.as_block().unwrap().kind { - BlockKind::Switch { .. } => { - input_count += 1; - - vec![GraphNode { - id: node.id, - kind: GraphNodeKind::Input(format!("#{}", input_count)), - inputs, - outputs: node.outputs, - tag: format!("From #{id}"), - }] - } - BlockKind::Redstone { .. } | BlockKind::Repeater { .. } => { + let new_nodes = match node.kind { + GraphNodeKind::Sequential(sequential) => { vec![GraphNode { id: node.id, - kind: GraphNodeKind::Logic(Logic { - logic_type: LogicType::Or, - }), + kind: GraphNodeKind::Sequential(sequential), inputs, outputs: node.outputs, - tag: format!("From #{id}"), + tag, }] } - BlockKind::Torch { .. } => { - if node.inputs.len() == 1 { + GraphNodeKind::Block(block) => match block.kind { + BlockKind::Switch { .. } => { + input_count += 1; + vec![GraphNode { id: node.id, - kind: GraphNodeKind::Logic(Logic { - logic_type: LogicType::Not, - }), + kind: GraphNodeKind::Input(format!("#{}", input_count)), inputs, outputs: node.outputs, - tag: format!("From #{id}"), + tag, }] - } else { - next_new_id += 1; - - let or_node = GraphNode { + } + BlockKind::Redstone { .. } | BlockKind::Repeater { .. } => { + vec![GraphNode { id: node.id, kind: GraphNodeKind::Logic(Logic { logic_type: LogicType::Or, }), inputs, - outputs: vec![next_new_id], - tag: format!("From #{id}"), - }; - - let not_node = GraphNode { - id: next_new_id, - kind: GraphNodeKind::Logic(Logic { - logic_type: LogicType::Not, - }), - inputs: vec![or_node.id], - outputs: node.outputs.clone(), - tag: format!("From #{id}"), - }; - - new_in_id.insert(or_node.id, next_new_id); - - vec![or_node, not_node] + outputs: node.outputs, + tag, + }] } - } + BlockKind::Torch { .. } => { + if node.inputs.len() == 1 { + vec![GraphNode { + id: node.id, + kind: GraphNodeKind::Logic(Logic { + logic_type: LogicType::Not, + }), + inputs, + outputs: node.outputs, + tag, + }] + } else { + next_new_id += 1; + + let or_node = GraphNode { + id: node.id, + kind: GraphNodeKind::Logic(Logic { + logic_type: LogicType::Or, + }), + inputs, + outputs: vec![next_new_id], + tag: tag.clone(), + }; + + let not_node = GraphNode { + id: next_new_id, + kind: GraphNodeKind::Logic(Logic { + logic_type: LogicType::Not, + }), + inputs: vec![or_node.id], + outputs: node.outputs.clone(), + tag, + }; + + new_in_id.insert(or_node.id, next_new_id); + + vec![or_node, not_node] + } + } + _ => todo!(), + }, _ => todo!(), }; @@ -182,6 +196,14 @@ impl WorldToLogicTransformer { } } +fn transformed_tag(source_id: GraphNodeId, tag: &str) -> String { + if tag.is_empty() { + format!("From #{source_id}") + } else { + format!("From #{source_id}: {tag}") + } +} + #[cfg(test)] mod tests { @@ -246,6 +268,45 @@ mod tests { Ok(()) } + #[test] + fn world_to_logic_extracts_rs_latch_nbt_as_sequential_node() -> eyre::Result<()> { + let nbt = NBTRoot::load("test/rs-latch.nbt")?; + let logic = crate::transform::place_and_route::utils::world_to_logic(&nbt.to_world())?; + + assert!( + logic + .nodes + .iter() + .any(|node| matches!(node.kind, crate::graph::GraphNodeKind::Sequential(_))), + "expected RS latch NBT to contain a sequential logic node" + ); + assert!( + logic + .nodes + .iter() + .any(|node| node.tag.contains("Folded RS latch feedback SCC")), + "expected folded RS latch tag to be visible in the logic graph" + ); + + Ok(()) + } + + #[test] + fn world_to_logic_extracts_d_latch_nbt_as_sequential_node() -> eyre::Result<()> { + let nbt = NBTRoot::load("test/d-latch.nbt")?; + let logic = crate::transform::place_and_route::utils::world_to_logic(&nbt.to_world())?; + + assert!( + logic + .nodes + .iter() + .any(|node| matches!(node.kind, crate::graph::GraphNodeKind::Sequential(_))), + "expected D latch NBT to contain a sequential logic node" + ); + + Ok(()) + } + #[test] fn world_to_logic_preserves_switch_and_inverted_output_short() -> eyre::Result<()> { let redstone = Block { diff --git a/tools/nbt-viewer/src/main.ts b/tools/nbt-viewer/src/main.ts index 4fa57b0..8091894 100644 --- a/tools/nbt-viewer/src/main.ts +++ b/tools/nbt-viewer/src/main.ts @@ -56,6 +56,12 @@ type TraceAnimation = { token: number; }; type GraphTab = 'world' | 'logic'; +type GraphWorldMode = 'raw' | 'folded'; +type GraphEdgeInfo = { + element: SVGGElement; + source: string; + target: string; +}; type ExampleFile = { name: string; path: string; @@ -63,6 +69,11 @@ type ExampleFile = { }; const TRACE_ANIMATION_INTERVAL_MS = 50; +const GRAPH_MINIMAP_INSET = 0; +const GRAPH_MINIMAP_MAX_WIDTH = 360; +const GRAPH_MINIMAP_MAX_HEIGHT = 240; +const GRAPH_MINIMAP_MIN_WIDTH = 150; +const GRAPH_MINIMAP_MIN_HEIGHT = 48; function resolveAssetPath(path: string): string { return new URL(`${import.meta.env.BASE_URL}${path}`, window.location.origin).href; @@ -139,6 +150,14 @@ document.querySelector('#app')!.innerHTML = `
+
+ + +
+
@@ -148,6 +167,10 @@ document.querySelector('#app')!.innerHTML = `
Open an NBT file to inspect graphs.
+
+ +
+
+ Selected Graphs + +
+
+ + +
+ + +
+ +
+ + + +
+
+
Select world graph nodes to open a focused graph.
+
+
+
+
+
+ +
+
+ Selected NBT + +
+
Select world graph nodes to open focused NBT.
+
+ +
+
+
`; @@ -186,17 +250,43 @@ const closeGraphsButton = document.querySelector('#close-grap const graphDialog = document.querySelector('#graph-dialog')!; const graphWorldTab = document.querySelector('#graph-world-tab')!; const graphLogicTab = document.querySelector('#graph-logic-tab')!; +const graphWorldMode = document.querySelector('#graph-world-mode')!; +const graphWorldRawButton = document.querySelector('#graph-world-raw')!; +const graphWorldFoldedButton = document.querySelector('#graph-world-folded')!; +const graphShowTagsInput = document.querySelector('#graph-show-tags')!; const graphZoomOutButton = document.querySelector('#graph-zoom-out')!; const graphZoomResetButton = document.querySelector('#graph-zoom-reset')!; const graphZoomInButton = document.querySelector('#graph-zoom-in')!; const graphStatus = document.querySelector('#graph-status')!; +const graphViewer = document.querySelector('.graph-viewer')!; const graphOutput = document.querySelector('#graph-output')!; +const graphSelectionActions = document.querySelector('#graph-selection-actions')!; +const openSelectedGraphButton = document.querySelector('#open-selected-graph')!; +const openSelectedNbtButton = document.querySelector('#open-selected-nbt')!; const graphMinimap = document.querySelector('#graph-minimap')!; const graphMinimapContent = document.querySelector('#graph-minimap-content')!; const graphMinimapViewport = document.querySelector('#graph-minimap-viewport')!; +const selectedGraphDialog = document.querySelector('#selected-graph-dialog')!; +const closeSelectedGraphsButton = document.querySelector('#close-selected-graphs')!; +const selectedGraphWorldTab = document.querySelector('#selected-graph-world-tab')!; +const selectedGraphLogicTab = document.querySelector('#selected-graph-logic-tab')!; +const selectedGraphWorldMode = document.querySelector('#selected-graph-world-mode')!; +const selectedGraphWorldRawButton = document.querySelector('#selected-graph-world-raw')!; +const selectedGraphWorldFoldedButton = document.querySelector('#selected-graph-world-folded')!; +const selectedGraphShowTagsInput = document.querySelector('#selected-graph-show-tags')!; +const selectedGraphZoomOutButton = document.querySelector('#selected-graph-zoom-out')!; +const selectedGraphZoomResetButton = document.querySelector('#selected-graph-zoom-reset')!; +const selectedGraphZoomInButton = document.querySelector('#selected-graph-zoom-in')!; +const selectedGraphStatus = document.querySelector('#selected-graph-status')!; +const selectedGraphOutput = document.querySelector('#selected-graph-output')!; +const selectedNbtDialog = document.querySelector('#selected-nbt-dialog')!; +const closeSelectedNbtButton = document.querySelector('#close-selected-nbt')!; +const selectedNbtStatus = document.querySelector('#selected-nbt-status')!; +const selectedNbtCanvas = document.querySelector('#selected-nbt-canvas')!; const viewer = new StructureViewer(canvas); viewer.setSelectionHandler(renderSelection); +const selectedNbtViewer = new StructureViewer(selectedNbtCanvas); let simulation: NbtSimulation | undefined; let selectedBlock: StructureBlock | undefined; @@ -211,10 +301,18 @@ let traceAnimation: TraceAnimation | undefined; let traceAnimationToken = 0; let graphDot: GraphDotInfo | undefined; let graphTab: GraphTab = 'world'; +let graphWorldModeValue: GraphWorldMode = 'raw'; +let graphShowTags = true; let vizPromise: Promise | undefined; let graphMinimapScale = 1; let isDraggingGraphMinimap = false; let graphZoom = 1; +let selectedGraphNode: string | undefined; +let selectedGraphDot: GraphDotInfo | undefined; +let selectedGraphTab: GraphTab = 'world'; +let selectedGraphWorldModeValue: GraphWorldMode = 'raw'; +let selectedGraphShowTags = true; +let selectedGraphZoom = 1; folderInput.setAttribute('webkitdirectory', ''); folderInput.setAttribute('directory', ''); @@ -287,6 +385,19 @@ graphLogicTab.addEventListener('click', () => { void setGraphTab('logic'); }); +graphWorldRawButton.addEventListener('click', () => { + void setGraphWorldMode('raw'); +}); + +graphWorldFoldedButton.addEventListener('click', () => { + void setGraphWorldMode('folded'); +}); + +graphShowTagsInput.addEventListener('change', () => { + graphShowTags = graphShowTagsInput.checked; + void renderGraphTab(); +}); + graphZoomOutButton.addEventListener('click', () => { setGraphZoom(graphZoom - 0.25); }); @@ -299,10 +410,97 @@ graphZoomInButton.addEventListener('click', () => { setGraphZoom(graphZoom + 0.25); }); +openSelectedGraphButton.addEventListener('click', () => { + void openSelectedGraphView(); +}); + +openSelectedNbtButton.addEventListener('click', () => { + void openSelectedNbtView(); +}); + +closeSelectedGraphsButton.addEventListener('click', () => { + selectedGraphDialog.close(); +}); + +selectedGraphDialog.addEventListener('click', event => { + if (event.target === selectedGraphDialog) { + selectedGraphDialog.close(); + } +}); + +closeSelectedNbtButton.addEventListener('click', () => { + selectedNbtDialog.close(); +}); + +selectedNbtDialog.addEventListener('click', event => { + if (event.target === selectedNbtDialog) { + selectedNbtDialog.close(); + } +}); + +selectedGraphWorldTab.addEventListener('click', () => { + selectedGraphTab = 'world'; + void renderSelectedGraphTab(); +}); + +selectedGraphLogicTab.addEventListener('click', () => { + selectedGraphTab = 'logic'; + void renderSelectedGraphTab(); +}); + +selectedGraphWorldRawButton.addEventListener('click', () => { + selectedGraphWorldModeValue = 'raw'; + void renderSelectedGraphTab(); +}); + +selectedGraphWorldFoldedButton.addEventListener('click', () => { + selectedGraphWorldModeValue = 'folded'; + void renderSelectedGraphTab(); +}); + +selectedGraphShowTagsInput.addEventListener('change', () => { + selectedGraphShowTags = selectedGraphShowTagsInput.checked; + void renderSelectedGraphTab(); +}); + +selectedGraphZoomOutButton.addEventListener('click', () => { + setSelectedGraphZoom(selectedGraphZoom - 0.25); +}); + +selectedGraphZoomResetButton.addEventListener('click', () => { + setSelectedGraphZoom(1); +}); + +selectedGraphZoomInButton.addEventListener('click', () => { + setSelectedGraphZoom(selectedGraphZoom + 0.25); +}); + +selectedGraphOutput.addEventListener( + 'wheel', + event => { + if (!event.ctrlKey) return; + + event.preventDefault(); + zoomSelectedGraphAt(event.clientX, event.clientY, selectedGraphZoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12)); + }, + { passive: false }, +); + graphOutput.addEventListener('scroll', () => { updateGraphMinimapViewport(); }); +graphOutput.addEventListener( + 'wheel', + event => { + if (!event.ctrlKey) return; + + event.preventDefault(); + zoomGraphAt(event.clientX, event.clientY, graphZoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12)); + }, + { passive: false }, +); + graphMinimap.addEventListener('pointerdown', event => { if (graphMinimap.classList.contains('hidden')) return; @@ -358,30 +556,310 @@ async function setGraphTab(nextTab: GraphTab): Promise { await renderGraphTab(); } +async function setGraphWorldMode(nextMode: GraphWorldMode): Promise { + graphWorldModeValue = nextMode; + updateGraphTabs(); + if (graphTab === 'world') { + await renderGraphTab(); + } +} + async function renderGraphTab(): Promise { updateGraphTabs(); graphOutput.replaceChildren(); clearGraphMinimap(); + graphSelectionActions.classList.add('hidden'); + graphViewer.classList.remove('has-selection-action'); if (!graphDot) { graphStatus.textContent = currentNbtBytes ? 'Generating graphs...' : 'Open an NBT file before viewing graphs.'; return; } - graphStatus.textContent = graphTab === 'logic' ? 'Logic Graph' : 'World Graph'; + graphStatus.textContent = + graphTab === 'logic' + ? 'Logic Graph' + : graphWorldModeValue === 'folded' + ? 'World Graph - Folded' + : 'World Graph - Raw'; try { - const dot = graphTab === 'logic' ? graphDot.logicDot : graphDot.worldDot; + const dot = currentGraphDot(); const viz = await loadViz(); const svg = viz.renderSVGElement(dot, { engine: 'dot' }); + selectedGraphNode = undefined; graphOutput.append(svg); - applyGraphZoom(); + installGraphNodeHitAreas(svg); + bindGraphSelection(svg); + setGraphZoom(1); renderGraphMinimap(svg); } catch (error) { graphStatus.textContent = error instanceof Error ? error.message : String(error); } } +function currentGraphDot(): string { + if (!graphDot) return ''; + + if (graphTab === 'logic') { + return graphShowTags ? graphDot.logicDot : graphDot.logicDotWithoutTags; + } + + if (graphWorldModeValue === 'folded') { + return graphShowTags ? graphDot.foldedWorldDot : graphDot.foldedWorldDotWithoutTags; + } + + return graphShowTags ? graphDot.rawWorldDot : graphDot.rawWorldDotWithoutTags; +} + +function installGraphNodeHitAreas(svg: SVGSVGElement): void { + svg.querySelectorAll('g.node').forEach(node => { + node.querySelector(':scope > .graph-node-hit-area')?.remove(); + const bbox = node.getBBox(); + if (!bbox) return; + + const hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + hitArea.classList.add('graph-node-hit-area'); + hitArea.setAttribute('x', String(bbox.x)); + hitArea.setAttribute('y', String(bbox.y)); + hitArea.setAttribute('width', String(bbox.width)); + hitArea.setAttribute('height', String(bbox.height)); + node.prepend(hitArea); + }); +} + +function bindGraphSelection(svg: SVGSVGElement): void { + svg.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof Element)) return; + + const node = target.closest('g.node'); + if (!node || !svg.contains(node)) { + selectedGraphNode = undefined; + applyGraphSelection(svg); + updateGraphSelectionStatus(); + return; + } + + const nodeId = graphElementTitle(node); + if (!nodeId) return; + selectedGraphNode = selectedGraphNode === nodeId ? undefined : nodeId; + applyGraphSelection(svg); + updateGraphSelectionStatus(); + }); +} + +function applyGraphSelection(svg: SVGSVGElement): void { + const nodeElements = graphNodeElements(svg); + const edgeElements = graphEdgeElements(svg); + const selectedNodes = new Set(); + const connectedEdges = new Set(); + + if (selectedGraphNode) { + const directionalSelection = collectDirectionalGraphSelection(selectedGraphNode, edgeElements); + directionalSelection.nodes.forEach(nodeId => selectedNodes.add(nodeId)); + directionalSelection.edges.forEach(edge => connectedEdges.add(edge)); + } + + svg.classList.toggle('graph-has-selection', Boolean(selectedGraphNode)); + for (const [nodeId, node] of nodeElements) { + node.classList.toggle('graph-node-selected', selectedNodes.has(nodeId)); + node.classList.toggle('graph-node-root', selectedGraphNode === nodeId); + node.classList.toggle('graph-node-dimmed', Boolean(selectedGraphNode) && !selectedNodes.has(nodeId)); + } + + for (const edge of edgeElements) { + edge.element.classList.toggle('graph-edge-connected', connectedEdges.has(edge.element)); + edge.element.classList.toggle('graph-edge-dimmed', Boolean(selectedGraphNode) && !connectedEdges.has(edge.element)); + } +} + +function collectDirectionalGraphSelection(root: string, edges: GraphEdgeInfo[]): { nodes: Set; edges: Set } { + const nodes = new Set([root]); + const selectedEdges = new Set(); + + collectGraphCone(root, edges, 'incoming', nodes, selectedEdges); + collectGraphCone(root, edges, 'outgoing', nodes, selectedEdges); + + return { nodes, edges: selectedEdges }; +} + +function collectGraphCone( + root: string, + edges: GraphEdgeInfo[], + direction: 'incoming' | 'outgoing', + nodes: Set, + selectedEdges: Set, +): void { + const visited = new Set(); + const queue = [root]; + + while (queue.length > 0) { + const nodeId = queue.pop()!; + if (!visited.add(nodeId)) continue; + + for (const edge of edges) { + const next = direction === 'incoming' && edge.target === nodeId ? edge.source : direction === 'outgoing' && edge.source === nodeId ? edge.target : undefined; + if (!next) continue; + + nodes.add(next); + selectedEdges.add(edge.element); + if (!visited.has(next)) queue.push(next); + } + } +} + +function updateGraphSelectionStatus(): void { + const title = + graphTab === 'logic' + ? 'Logic Graph' + : graphWorldModeValue === 'folded' + ? 'World Graph - Folded' + : 'World Graph - Raw'; + const selectedCount = selectedGraphNode ? graphOutput.querySelectorAll('svg g.node.graph-node-selected').length : 0; + graphStatus.textContent = selectedGraphNode ? `${title} - ${selectedGraphNode} selected (${selectedCount} nodes)` : title; + const canOpenSelection = graphTab === 'world' && selectedCount > 0; + graphSelectionActions.classList.toggle('hidden', !canOpenSelection); + graphViewer.classList.toggle('has-selection-action', canOpenSelection); +} + +async function openSelectedGraphView(): Promise { + const sourceSvg = graphOutput.querySelector('svg'); + if (graphTab !== 'world' || !sourceSvg || !selectedGraphNode) return; + + const nodeIds = selectedWorldGraphNodeIds(sourceSvg); + if (nodeIds.length === 0 || !currentNbtBytes) return; + + if (!selectedGraphDialog.open) selectedGraphDialog.showModal(); + selectedGraphOutput.replaceChildren(); + selectedGraphStatus.textContent = 'Generating selected graph...'; + selectedGraphDot = undefined; + selectedGraphTab = 'world'; + selectedGraphWorldModeValue = graphWorldModeValue; + selectedGraphShowTags = graphShowTags; + selectedGraphShowTagsInput.checked = selectedGraphShowTags; + + try { + selectedGraphDot = await NbtSimulation.selectedGraphDot(currentNbtBytes, graphWorldModeValue === 'folded', nodeIds); + await renderSelectedGraphTab(); + } catch (error) { + selectedGraphStatus.textContent = error instanceof Error ? error.message : String(error); + } +} + +async function openSelectedNbtView(): Promise { + const sourceSvg = graphOutput.querySelector('svg'); + if (graphTab !== 'world' || !sourceSvg || !selectedGraphNode) return; + + const nodeIds = selectedWorldGraphNodeIds(sourceSvg); + if (nodeIds.length === 0 || !currentNbtBytes) return; + + if (!selectedNbtDialog.open) selectedNbtDialog.showModal(); + selectedNbtStatus.textContent = 'Generating selected NBT...'; + + try { + const selectedRoot = await NbtSimulation.selectedNbt(currentNbtBytes, graphWorldModeValue === 'folded', nodeIds); + const structure = toStructureModel(selectedRoot); + if (!structure) { + selectedNbtStatus.textContent = 'Selected graph did not produce a structure.'; + return; + } + + await selectedNbtViewer.setStructure(structure); + selectedNbtStatus.textContent = `Selected NBT - ${structure.blocks.length} blocks`; + } catch (error) { + selectedNbtStatus.textContent = error instanceof Error ? error.message : String(error); + } +} + +function selectedWorldGraphNodeIds(sourceSvg: SVGSVGElement): number[] { + return Array.from(sourceSvg.querySelectorAll('g.node.graph-node-selected')) + .map(node => parseGraphNodeId(graphElementTitle(node))) + .filter((nodeId): nodeId is number => nodeId !== undefined); +} + +function graphNodeElements(svg: SVGSVGElement): Map { + const nodes = new Map(); + svg.querySelectorAll('g.node').forEach(node => { + const nodeId = graphElementTitle(node); + if (nodeId) nodes.set(nodeId, node); + }); + return nodes; +} + +function graphEdgeElements(svg: SVGSVGElement): GraphEdgeInfo[] { + return Array.from(svg.querySelectorAll('g.edge')).flatMap(edge => { + const parsed = parseGraphEdgeTitle(graphElementTitle(edge)); + return parsed ? [{ element: edge, ...parsed }] : []; + }); +} + +function graphElementTitle(element: Element): string | undefined { + return element.querySelector(':scope > title')?.textContent?.trim() || undefined; +} + +function parseGraphEdgeTitle(title: string | undefined): { source: string; target: string } | undefined { + const match = title?.match(/^(node\d+)(?::[^-]+)?->(node\d+)(?::.+)?$/); + if (!match) return undefined; + return { source: match[1], target: match[2] }; +} + +function parseGraphNodeId(title: string | undefined): number | undefined { + const match = title?.match(/^node(\d+)$/); + if (!match) return undefined; + + const nodeId = Number(match[1]); + return Number.isInteger(nodeId) ? nodeId : undefined; +} + +async function renderSelectedGraphTab(): Promise { + updateSelectedGraphTabs(); + selectedGraphOutput.replaceChildren(); + + if (!selectedGraphDot) { + selectedGraphStatus.textContent = 'Select world graph nodes to open a focused graph.'; + return; + } + + selectedGraphStatus.textContent = + selectedGraphTab === 'logic' + ? 'Selected Logic Graph' + : selectedGraphWorldModeValue === 'folded' + ? 'Selected World Graph - Folded' + : 'Selected World Graph - Raw'; + + try { + const viz = await loadViz(); + const svg = viz.renderSVGElement(currentSelectedGraphDot(), { engine: 'dot' }); + selectedGraphOutput.append(svg); + setSelectedGraphZoom(1); + } catch (error) { + selectedGraphStatus.textContent = error instanceof Error ? error.message : String(error); + } +} + +function currentSelectedGraphDot(): string { + if (!selectedGraphDot) return ''; + + if (selectedGraphTab === 'logic') { + return selectedGraphShowTags ? selectedGraphDot.logicDot : selectedGraphDot.logicDotWithoutTags; + } + + if (selectedGraphWorldModeValue === 'folded') { + return selectedGraphShowTags ? selectedGraphDot.foldedWorldDot : selectedGraphDot.foldedWorldDotWithoutTags; + } + + return selectedGraphShowTags ? selectedGraphDot.rawWorldDot : selectedGraphDot.rawWorldDotWithoutTags; +} + +function updateSelectedGraphTabs(): void { + selectedGraphWorldTab.classList.toggle('active', selectedGraphTab === 'world'); + selectedGraphLogicTab.classList.toggle('active', selectedGraphTab === 'logic'); + selectedGraphWorldMode.classList.toggle('hidden', selectedGraphTab !== 'world'); + selectedGraphWorldRawButton.classList.toggle('active', selectedGraphWorldModeValue === 'raw'); + selectedGraphWorldFoldedButton.classList.toggle('active', selectedGraphWorldModeValue === 'folded'); +} + async function loadViz(): Promise { vizPromise ??= import('@viz-js/viz').then(module => module.instance()); return vizPromise; @@ -392,6 +870,22 @@ function setGraphZoom(nextZoom: number): void { applyGraphZoom({ refreshMinimap: true }); } +function zoomGraphAt(clientX: number, clientY: number, nextZoom: number): void { + const previousZoom = graphZoom; + const clampedZoom = Math.max(0.25, Math.min(3, nextZoom)); + if (clampedZoom === previousZoom) return; + + const outputRect = graphOutput.getBoundingClientRect(); + const focusX = graphOutput.scrollLeft + clientX - outputRect.left; + const focusY = graphOutput.scrollTop + clientY - outputRect.top; + graphZoom = clampedZoom; + applyGraphZoom({ refreshMinimap: true }); + + const ratio = clampedZoom / previousZoom; + graphOutput.scrollLeft = focusX * ratio - (clientX - outputRect.left); + graphOutput.scrollTop = focusY * ratio - (clientY - outputRect.top); +} + function applyGraphZoom(options: { refreshMinimap?: boolean } = {}): void { graphZoomResetButton.textContent = `${Math.round(graphZoom * 100)}%`; graphZoomOutButton.disabled = graphZoom <= 0.25; @@ -415,15 +909,54 @@ function applyGraphZoom(options: { refreshMinimap?: boolean } = {}): void { } +function setSelectedGraphZoom(nextZoom: number): void { + selectedGraphZoom = Math.max(0.25, Math.min(3, nextZoom)); + applySelectedGraphZoom(); +} + +function zoomSelectedGraphAt(clientX: number, clientY: number, nextZoom: number): void { + const previousZoom = selectedGraphZoom; + const clampedZoom = Math.max(0.25, Math.min(3, nextZoom)); + if (clampedZoom === previousZoom) return; + + const outputRect = selectedGraphOutput.getBoundingClientRect(); + const focusX = selectedGraphOutput.scrollLeft + clientX - outputRect.left; + const focusY = selectedGraphOutput.scrollTop + clientY - outputRect.top; + selectedGraphZoom = clampedZoom; + applySelectedGraphZoom(); + + const ratio = clampedZoom / previousZoom; + selectedGraphOutput.scrollLeft = focusX * ratio - (clientX - outputRect.left); + selectedGraphOutput.scrollTop = focusY * ratio - (clientY - outputRect.top); +} + +function applySelectedGraphZoom(): void { + selectedGraphZoomResetButton.textContent = `${Math.round(selectedGraphZoom * 100)}%`; + selectedGraphZoomOutButton.disabled = selectedGraphZoom <= 0.25; + selectedGraphZoomInButton.disabled = selectedGraphZoom >= 3; + + const svg = selectedGraphOutput.querySelector('svg'); + if (!svg) return; + + const baseWidth = readSvgBaseSize(svg, 'width', selectedGraphZoom, selectedGraphOutput); + const baseHeight = readSvgBaseSize(svg, 'height', selectedGraphZoom, selectedGraphOutput); + svg.style.width = `${baseWidth * selectedGraphZoom}px`; + svg.style.height = `${baseHeight * selectedGraphZoom}px`; +} + function readGraphBaseSize(svg: SVGSVGElement, dimension: 'width' | 'height'): number { + return readSvgBaseSize(svg, dimension, graphZoom, graphOutput); +} + +function readSvgBaseSize(svg: SVGSVGElement, dimension: 'width' | 'height', zoom: number, fallbackElement: HTMLElement): number { const dataKey = `base${dimension[0].toUpperCase()}${dimension.slice(1)}`; const cached = Number(svg.dataset[dataKey]); if (Number.isFinite(cached) && cached > 0) return cached; const rect = svg.getBoundingClientRect(); const measured = dimension === 'width' ? rect.width : rect.height; - const fallback = dimension === 'width' ? graphOutput.clientWidth : graphOutput.clientHeight; - const value = Math.max(measured / graphZoom, fallback, 1); + const fallback = dimension === 'width' ? fallbackElement.clientWidth : fallbackElement.clientHeight; + const value = Math.max(measured / zoom, fallback, 1); svg.dataset[dataKey] = String(value); return value; } @@ -431,41 +964,65 @@ function readGraphBaseSize(svg: SVGSVGElement, dimension: 'width' | 'height'): n function renderGraphMinimap(svg: SVGSVGElement): void { graphMinimapContent.replaceChildren(); const clone = svg.cloneNode(true) as SVGSVGElement; + clone.querySelectorAll('.graph-node-hit-area').forEach(hitArea => hitArea.remove()); graphMinimapContent.append(clone); graphMinimap.classList.remove('hidden'); requestAnimationFrame(() => { - const contentWidth = Math.max(graphOutput.scrollWidth, 1); - const contentHeight = Math.max(graphOutput.scrollHeight, 1); - const minimapWidth = graphMinimapContent.clientWidth; - const minimapHeight = graphMinimapContent.clientHeight; - graphMinimapScale = Math.min(minimapWidth / contentWidth, minimapHeight / contentHeight); - - clone.style.width = `${contentWidth * graphMinimapScale}px`; - clone.style.height = `${contentHeight * graphMinimapScale}px`; + const graphRect = currentGraphRect(svg); + if (!graphRect) { + clearGraphMinimap(); + return; + } + + sizeGraphMinimap(graphRect.width, graphRect.height); + + const minimapWidth = Math.max(graphMinimapContent.clientWidth, 1); + const minimapHeight = Math.max(graphMinimapContent.clientHeight, 1); + graphMinimapScale = Math.min(minimapWidth / graphRect.width, minimapHeight / graphRect.height); + + const fittedWidth = graphRect.width * graphMinimapScale; + const fittedHeight = graphRect.height * graphMinimapScale; + graphMinimap.style.width = `${fittedWidth + GRAPH_MINIMAP_INSET * 2 + 2}px`; + graphMinimap.style.height = `${fittedHeight + GRAPH_MINIMAP_INSET * 2 + 2}px`; + clone.style.width = `${fittedWidth}px`; + clone.style.height = `${fittedHeight}px`; updateGraphMinimapViewport(); }); } function updateGraphMinimapViewport(): void { if (graphMinimap.classList.contains('hidden')) return; - - graphMinimapViewport.style.width = `${graphOutput.clientWidth * graphMinimapScale}px`; - graphMinimapViewport.style.height = `${graphOutput.clientHeight * graphMinimapScale}px`; - graphMinimapViewport.style.transform = `translate(${graphOutput.scrollLeft * graphMinimapScale}px, ${ - graphOutput.scrollTop * graphMinimapScale + const svg = graphOutput.querySelector('svg'); + const graphRect = svg ? currentGraphRect(svg) : undefined; + if (!graphRect) return; + + const visibleLeft = Math.max(0, graphOutput.scrollLeft - graphRect.left); + const visibleTop = Math.max(0, graphOutput.scrollTop - graphRect.top); + const visibleRight = Math.min(graphRect.width, graphOutput.scrollLeft + graphOutput.clientWidth - graphRect.left); + const visibleBottom = Math.min(graphRect.height, graphOutput.scrollTop + graphOutput.clientHeight - graphRect.top); + const visibleWidth = Math.max(0, visibleRight - visibleLeft); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + + graphMinimapViewport.style.width = `${visibleWidth * graphMinimapScale}px`; + graphMinimapViewport.style.height = `${visibleHeight * graphMinimapScale}px`; + graphMinimapViewport.style.transform = `translate(${visibleLeft * graphMinimapScale}px, ${ + visibleTop * graphMinimapScale }px)`; } function scrollGraphFromMinimap(event: PointerEvent): void { event.preventDefault(); if (graphMinimapScale <= 0) return; + const svg = graphOutput.querySelector('svg'); + const graphRect = svg ? currentGraphRect(svg) : undefined; + if (!graphRect) return; const minimapContentBox = graphMinimapContent.getBoundingClientRect(); const x = Math.max(0, Math.min(minimapContentBox.width, event.clientX - minimapContentBox.left)); const y = Math.max(0, Math.min(minimapContentBox.height, event.clientY - minimapContentBox.top)); - graphOutput.scrollLeft = x / graphMinimapScale - graphOutput.clientWidth / 2; - graphOutput.scrollTop = y / graphMinimapScale - graphOutput.clientHeight / 2; + graphOutput.scrollLeft = graphRect.left + x / graphMinimapScale - graphOutput.clientWidth / 2; + graphOutput.scrollTop = graphRect.top + y / graphMinimapScale - graphOutput.clientHeight / 2; updateGraphMinimapViewport(); } @@ -473,10 +1030,48 @@ function clearGraphMinimap(): void { graphMinimap.classList.add('hidden'); graphMinimapContent.replaceChildren(); graphMinimapViewport.removeAttribute('style'); + graphMinimap.removeAttribute('style'); graphMinimapScale = 1; isDraggingGraphMinimap = false; } +function currentGraphRect(svg: SVGSVGElement): { left: number; top: number; width: number; height: number } | undefined { + const width = readGraphBaseSize(svg, 'width') * graphZoom; + const height = readGraphBaseSize(svg, 'height') * graphZoom; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return undefined; + } + + const outputRect = graphOutput.getBoundingClientRect(); + const svgRect = svg.getBoundingClientRect(); + + return { + left: svgRect.left - outputRect.left + graphOutput.scrollLeft, + top: svgRect.top - outputRect.top + graphOutput.scrollTop, + width, + height, + }; +} + +function sizeGraphMinimap(contentWidth: number, contentHeight: number): void { + const aspect = Math.max(contentWidth / Math.max(contentHeight, 1), 0.1); + const maxContentWidth = Math.min(GRAPH_MINIMAP_MAX_WIDTH, Math.max(GRAPH_MINIMAP_MIN_WIDTH, graphOutput.clientWidth * 0.28)); + const maxContentHeight = Math.min( + GRAPH_MINIMAP_MAX_HEIGHT, + Math.max(GRAPH_MINIMAP_MIN_HEIGHT, graphOutput.clientHeight * 0.28), + ); + + let minimapContentWidth = maxContentWidth; + let minimapContentHeight = minimapContentWidth / aspect; + if (minimapContentHeight > maxContentHeight) { + minimapContentHeight = maxContentHeight; + minimapContentWidth = minimapContentHeight * aspect; + } + + graphMinimap.style.width = `${Math.max(GRAPH_MINIMAP_MIN_WIDTH, minimapContentWidth) + GRAPH_MINIMAP_INSET * 2}px`; + graphMinimap.style.height = `${Math.max(GRAPH_MINIMAP_MIN_HEIGHT, minimapContentHeight) + GRAPH_MINIMAP_INSET * 2}px`; +} + function updateGraphTabs(): void { const tabs: Array<[HTMLButtonElement, GraphTab]> = [ [graphWorldTab, 'world'], @@ -488,6 +1083,11 @@ function updateGraphTabs(): void { button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); } + + graphWorldMode.classList.toggle('hidden', graphTab !== 'world'); + graphWorldRawButton.classList.toggle('active', graphWorldModeValue === 'raw'); + graphWorldFoldedButton.classList.toggle('active', graphWorldModeValue === 'folded'); + graphShowTagsInput.checked = graphShowTags; } async function toggleSelectedSwitch(): Promise { @@ -796,6 +1396,7 @@ async function openFile(file: File, selectedEntry?: Element | null): Promise { + const wasm = await loadWasmModule(); + await wasm.default(resolveAssetPath(wasmBinaryPath)); + const graphDot = wasm.NbtSimulator.selected_graph_dot(nbtBytes, folded, nodeIds); + + return mapGraphDotInfo(graphDot); + } + + static async selectedNbt(nbtBytes: Uint8Array, folded: boolean, nodeIds: number[]): Promise { + const wasm = await loadWasmModule(); + await wasm.default(resolveAssetPath(wasmBinaryPath)); + + return wasm.NbtSimulator.selected_nbt(nbtBytes, folded, nodeIds); } static async create(nbtBytes: Uint8Array): Promise { @@ -149,6 +171,17 @@ export class NbtSimulation { } } +function mapGraphDotInfo(graphDot: RawGraphDotInfo): GraphDotInfo { + return { + rawWorldDot: graphDot.raw_world_dot, + rawWorldDotWithoutTags: graphDot.raw_world_dot_without_tags, + foldedWorldDot: graphDot.folded_world_dot, + foldedWorldDotWithoutTags: graphDot.folded_world_dot_without_tags, + logicDot: graphDot.logic_dot, + logicDotWithoutTags: graphDot.logic_dot_without_tags, + }; +} + function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/tools/nbt-viewer/src/styles.css b/tools/nbt-viewer/src/styles.css index 0a670e4..5b63d2b 100644 --- a/tools/nbt-viewer/src/styles.css +++ b/tools/nbt-viewer/src/styles.css @@ -508,6 +508,61 @@ input { color: #fff; } +.graph-world-mode { + display: flex; + align-items: center; + gap: 2px; + min-height: 28px; + margin-left: 8px; + padding: 2px; + border: 1px solid rgb(154 164 173 / 24%); + border-radius: 5px; + background: rgb(10 13 16 / 42%); +} + +.graph-world-mode.hidden { + display: none; +} + +.graph-mode-button { + min-width: 54px; + height: 22px; + border: 0; + border-radius: 3px; + background: transparent; + color: #aeb8c0; + cursor: pointer; + font-size: 11px; +} + +.graph-mode-button:hover, +.graph-mode-button.active { + background: rgb(255 255 255 / 12%); + color: #fff; +} + +.graph-tag-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + margin-left: 4px; + padding: 0 8px; + border: 1px solid rgb(154 164 173 / 24%); + border-radius: 5px; + background: rgb(10 13 16 / 30%); + color: #cbd4db; + cursor: pointer; + font-size: 12px; + white-space: nowrap; +} + +.graph-tag-toggle input { + width: 14px; + height: 14px; + accent-color: #d33232; +} + .graph-zoom-controls { display: flex; align-items: center; @@ -573,7 +628,91 @@ input { .graph-output svg { display: block; max-width: none; - margin: 18px; + margin: 0; +} + +.graph-selection-actions { + position: absolute; + right: 14px; + top: 14px; + z-index: 2; + display: flex; + gap: 8px; +} + +.graph-selection-actions.hidden { + display: none; +} + +.graph-selection-button { + height: 30px; + padding: 0 10px; + border: 1px solid rgb(17 24 32 / 28%); + border-radius: 5px; + background: rgb(247 249 251 / 92%); + color: #111820; + cursor: pointer; + font-size: 12px; + box-shadow: 0 12px 34px rgb(0 0 0 / 20%); +} + +.graph-selection-button:hover { + background: #fff; +} + +.graph-viewer.has-selection-action .graph-minimap { + bottom: 14px; +} + +.selected-nbt-dialog-surface { + grid-template-rows: auto auto 1fr; +} + +.selected-nbt-viewer { + position: relative; + min-height: 0; + background: #101317; +} + +#selected-nbt-canvas { + display: block; + width: 100%; + height: 100%; +} + +.graph-output svg g.node { + cursor: pointer; +} + +.graph-output svg .graph-node-hit-area { + fill: transparent; + pointer-events: all; +} + +.graph-output svg.graph-has-selection g.node.graph-node-dimmed { + opacity: 0.48; +} + +.graph-output svg.graph-has-selection g.edge.graph-edge-dimmed { + opacity: 0.32; +} + +.graph-output svg g.node.graph-node-selected polygon, +.graph-output svg g.node.graph-node-selected path, +.graph-output svg g.node.graph-node-selected ellipse { + filter: drop-shadow(0 0 4px rgb(209 83 28 / 30%)); + stroke: #d1531c; + stroke-width: 1.5; +} + +.graph-output svg g.edge.graph-edge-connected path { + stroke: #d1531c; + stroke-width: 1.5; +} + +.graph-output svg g.edge.graph-edge-connected polygon { + fill: #d1531c; + stroke: #d1531c; } .graph-minimap { @@ -602,7 +741,7 @@ input { .graph-minimap-content { position: absolute; - inset: 8px; + inset: 0; overflow: hidden; } @@ -614,8 +753,8 @@ input { .graph-minimap-viewport { position: absolute; - left: 8px; - top: 8px; + left: 0; + top: 0; min-width: 8px; min-height: 8px; border: 2px solid #d33232;