Skip to main content

bloq_graph/
module_view.rs

1//! Definition ownership and colors shared by module viewers.
2
3use std::collections::HashMap;
4
5use bloq_utils::RGBA;
6use glam::IVec3;
7
8use crate::{BlockGraph, ModuleOrientation};
9
10/// One visible definition in a module view.
11#[derive(Debug, Clone)]
12pub struct ModuleViewModule {
13    /// Authored definition name.
14    pub name: String,
15    /// Number of child instances of this definition.
16    pub instance_count: usize,
17    /// Number of visible blocks owned by these instances.
18    pub block_count: usize,
19}
20
21/// Definition ownership for a flat rendering projection of an authored graph.
22///
23/// Reused child definitions share one color. Root blocks and pipes between
24/// different definitions retain their ordinary graph colors.
25#[derive(Debug, Clone)]
26pub struct ModuleView {
27    modules: Vec<ModuleViewModule>,
28    block_modules: HashMap<IVec3, usize>,
29}
30
31impl ModuleView {
32    /// Matches authored definition ownership against a rendering projection.
33    ///
34    /// The root and definitions without visible blocks are omitted. The authored hierarchy
35    /// and its projection are not modified or validated by this operation.
36    ///
37    /// # Panics
38    ///
39    /// Panics if an instance references a missing definition. Pass a validated
40    /// source and its matching flat projection.
41    pub fn from_graph(program: &BlockGraph, graph: &BlockGraph) -> Option<Self> {
42        let mut modules = Vec::new();
43        let mut module_indices = HashMap::new();
44        let mut block_modules = HashMap::new();
45        collect_module_blocks(
46            program,
47            program.root(),
48            ModuleOrientation::IDENTITY,
49            IVec3::ZERO,
50            true,
51            graph,
52            &mut modules,
53            &mut module_indices,
54            &mut block_modules,
55        );
56
57        let mut block_counts = vec![0; modules.len()];
58        for index in block_modules.values() {
59            block_counts[*index] += 1;
60        }
61        let mut remap = vec![None; modules.len()];
62        let mut visible = Vec::new();
63        for (old_index, mut module) in modules.into_iter().enumerate() {
64            let block_count = block_counts[old_index];
65            if block_count == 0 {
66                continue;
67            }
68            module.block_count = block_count;
69            remap[old_index] = Some(visible.len());
70            visible.push(module);
71        }
72        block_modules.retain(|_, index| {
73            let Some(next) = remap[*index] else {
74                return false;
75            };
76            *index = next;
77            true
78        });
79
80        (!visible.is_empty()).then_some(Self {
81            modules: visible,
82            block_modules,
83        })
84    }
85
86    /// Visible definitions in their authored traversal order.
87    pub fn modules(&self) -> &[ModuleViewModule] {
88        &self.modules
89    }
90
91    /// Returns the owning definition's index for a block anchor.
92    pub fn module_for_block(&self, position: IVec3) -> Option<usize> {
93        self.block_modules.get(&position).copied()
94    }
95
96    /// Returns a pipe's owner only when both endpoint blocks share a definition.
97    ///
98    /// Resolves endpoint positions through `graph`, including extended blocks.
99    pub fn module_for_pipe(&self, graph: &BlockGraph, a: IVec3, b: IVec3) -> Option<usize> {
100        let a = self.module_for_block(graph.get_endpoint_block(a)?.pos())?;
101        let b = self.module_for_block(graph.get_endpoint_block(b)?.pos())?;
102        (a == b).then_some(a)
103    }
104}
105
106#[allow(
107    clippy::too_many_arguments,
108    reason = "shared editor traversal preserves ownership ordering"
109)]
110fn collect_module_blocks(
111    program: &BlockGraph,
112    module: &BlockGraph,
113    orientation: ModuleOrientation,
114    translation: IVec3,
115    is_root: bool,
116    graph: &BlockGraph,
117    modules: &mut Vec<ModuleViewModule>,
118    module_indices: &mut HashMap<String, usize>,
119    block_modules: &mut HashMap<IVec3, usize>,
120) {
121    if !is_root {
122        let index = *module_indices
123            .entry(module.name.clone())
124            .or_insert_with(|| {
125                modules.push(ModuleViewModule {
126                    name: module.name.clone(),
127                    instance_count: 0,
128                    block_count: 0,
129                });
130                modules.len() - 1
131            });
132        modules[index].instance_count += 1;
133
134        for block in module.local_body().blocks() {
135            if module
136                .interface
137                .quantum_ports
138                .iter()
139                .any(|port| port.position == block.pos())
140            {
141                continue;
142            }
143            let Some(position) = orientation
144                .try_transform_position(block.pos(), translation)
145                .ok()
146            else {
147                continue;
148            };
149            if graph.has_block_at(position) {
150                block_modules.insert(position, index);
151            }
152        }
153    }
154
155    for instance in &module.instances {
156        let Some(translation) = orientation
157            .try_transform_position(instance.translation, translation)
158            .ok()
159        else {
160            continue;
161        };
162        collect_module_blocks(
163            program,
164            program
165                .module(&instance.definition)
166                .expect("validated instance names a module"),
167            orientation.then(instance.rotation),
168            translation,
169            false,
170            graph,
171            modules,
172            module_indices,
173            block_modules,
174        );
175    }
176}
177
178/// Stable ownership colors used by Bloq Editor and exported module viewers.
179///
180/// The first ten are a qualitative palette. Further definitions continue around
181/// the hue wheel instead of reusing a swatch.
182pub fn module_color(index: usize) -> RGBA {
183    const COLORS: [RGBA; 10] = [
184        RGBA::from_hex(0x4477AA, 255),
185        RGBA::from_hex(0xEE6677, 255),
186        RGBA::from_hex(0x228833, 255),
187        RGBA::from_hex(0xCCBB44, 255),
188        RGBA::from_hex(0x66CCEE, 255),
189        RGBA::from_hex(0xAA3377, 255),
190        RGBA::from_hex(0xEE7733, 255),
191        RGBA::from_hex(0x332288, 255),
192        RGBA::from_hex(0x44AA99, 255),
193        RGBA::from_hex(0x999999, 255),
194    ];
195    if let Some(color) = COLORS.get(index) {
196        return *color;
197    }
198    // Keep the editor's linear-HSV palette and its sRGB conversion.
199    let hue = ((index as f32 * 0.618_034).fract() + 1.0).fract() * 6.0;
200    let fraction = hue - hue.floor();
201    let value = 0.82;
202    let saturation = 0.72;
203    let low = value * (1.0 - saturation);
204    let falling = value * (1.0 - fraction * saturation);
205    let rising = value * (1.0 - (1.0 - fraction) * saturation);
206    let rgb = match hue.floor() as u8 {
207        0 => [value, rising, low],
208        1 => [falling, value, low],
209        2 => [low, value, rising],
210        3 => [low, falling, value],
211        4 => [rising, low, value],
212        _ => [value, low, falling],
213    };
214    let [r, g, b] = rgb.map(|linear: f32| {
215        let gamma = if linear <= 0.003_130_8 {
216            3294.6 * linear
217        } else {
218            269.025 * linear.powf(1.0 / 2.4) - 14.025
219        };
220        (gamma + 0.5) as u8
221    });
222    RGBA { r, g, b, a: 255 }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::GalleryItem;
229    use std::collections::HashSet;
230
231    #[test]
232    fn reused_definitions_keep_one_ownership_group_and_seams_uncolored() {
233        let program = GalleryItem::ThreeBitAdder.build();
234        let graph = program.flatten().unwrap();
235        let view = ModuleView::from_graph(&program, &graph).unwrap();
236        assert_eq!(
237            view.modules()
238                .iter()
239                .map(|module| (module.name.as_str(), module.instance_count))
240                .collect::<Vec<_>>(),
241            [
242                ("InjectedAnd", 3),
243                ("HeadMaj", 1),
244                ("HeadUmaParked", 1),
245                ("BulkMaj", 1),
246                ("BulkUmaParked", 1),
247                ("TailParity", 1)
248            ]
249        );
250        let mut counts = vec![0; view.modules().len()];
251        for block in graph.blocks() {
252            if let Some(index) = view.module_for_block(block.pos()) {
253                counts[index] += 1;
254            } else {
255                assert!(program.root().local_body().has_block_at(block.pos()));
256            }
257        }
258        assert_eq!(
259            counts,
260            view.modules()
261                .iter()
262                .map(|module| module.block_count)
263                .collect::<Vec<_>>()
264        );
265        let mut seams = 0;
266        for (a, b, lhs, rhs, _) in graph.pipe_endpoints_with_blocks() {
267            let left = view.module_for_block(lhs.pos());
268            let right = view.module_for_block(rhs.pos());
269            let expected = left.filter(|_| left == right);
270            assert_eq!(view.module_for_pipe(&graph, a, b), expected);
271            assert_eq!(view.module_for_pipe(&graph, b, a), expected);
272            seams += usize::from(expected.is_none());
273        }
274        assert!(seams > 0);
275        for block in program.root().local_body().blocks() {
276            assert_eq!(view.module_for_block(block.pos()), None);
277        }
278    }
279
280    #[test]
281    fn root_only_and_empty_graphs_have_no_module_highlights() {
282        let program = GalleryItem::OneDYoked.build();
283        let graph = program.flatten().unwrap();
284        assert!(ModuleView::from_graph(&program, &graph).is_none());
285        assert!(ModuleView::from_graph(&BlockGraph::new(), &BlockGraph::new()).is_none());
286    }
287
288    #[test]
289    fn first_thirty_two_module_colors_are_distinct() {
290        assert_eq!((0..32).map(module_color).collect::<HashSet<_>>().len(), 32);
291    }
292}