Skip to main content

bloq_graph/
gltf.rs

1//! glTF geometry and standalone viewer export.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::ops::Range;
5use std::sync::Arc;
6
7use base64::{Engine as _, prelude::BASE64_STANDARD};
8use bloq_utils::{Basis, Pauli, RGBA, UDirection};
9use glam::{IVec3, Vec3, vec3};
10use gltf_json::{
11    Accessor, Asset, Buffer, Material, Root, Value,
12    accessor::{ComponentType, GenericComponentType, Type},
13    buffer::{Target, View},
14    extensions::material::{Material as MaterialExtensions, Unlit},
15    material::{AlphaMode, PbrBaseColorFactor, PbrMetallicRoughness, StrengthFactor},
16    mesh::{Mode, Primitive, Semantic},
17    validation::Checked::Valid,
18};
19
20use crate::{
21    Block, BlockGraphError, BlockKind, Direction, PatchRotationKind, Pipe, Stabilizer,
22    StabilizerGenerator, StabilizerRowKind, WalkingKind, ZXGraph, graph::BlockGraph,
23};
24
25/// A positioned text label carried alongside the geometry.
26#[derive(Debug, Clone)]
27pub struct TextLabel {
28    /// World-space position of the label.
29    pub position: Vec3,
30    /// Label text.
31    pub text: String,
32    /// Label color.
33    pub color: RGBA,
34}
35
36/// Intermediate geometry for glTF export: colored triangles, colored line
37/// segments, and text labels, accumulated before serialization to a glTF model.
38#[derive(Debug, Clone, Default)]
39pub struct GltfData {
40    /// Filled triangles grouped by color.
41    pub triangles: BTreeMap<RGBA, Vec<[Vec3; 3]>>,
42    /// Line segments grouped by color.
43    pub lines: BTreeMap<RGBA, Vec<[Vec3; 2]>>,
44    /// Positioned text labels.
45    pub texts: Vec<TextLabel>,
46    /// Face colors displayed without diffuse shading, for module ownership.
47    pub unlit_colors: BTreeSet<RGBA>,
48}
49
50// Each color's triangle ranges retain definition ownership without copying geometry.
51pub(crate) type ModuleFaceRanges = BTreeMap<RGBA, Vec<(Option<usize>, Range<usize>)>>;
52type ModuleMaterial = (usize, usize, [f32; 4], [f32; 4], &'static str);
53
54/// Selects filled block-graph faces to remove from a glTF export.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum GltfFaceSelector {
57    /// Every face with this outward normal.
58    All(Direction),
59    /// One face of the block at `position`.
60    Block {
61        /// Anchor position of the selected block.
62        position: IVec3,
63        /// Outward normal of the selected face.
64        face: Direction,
65    },
66    /// One face of the pipe between `u` and `v`.
67    Pipe {
68        /// First pipe endpoint.
69        u: IVec3,
70        /// Second pipe endpoint.
71        v: IVec3,
72        /// Outward normal of the selected face.
73        face: Direction,
74    },
75}
76
77impl GltfFaceSelector {
78    fn for_block(self, position: IVec3) -> Option<Direction> {
79        match self {
80            Self::All(face) => Some(face),
81            Self::Block { position: p, face } if p == position => Some(face),
82            _ => None,
83        }
84    }
85
86    fn for_pipe(self, u: IVec3, v: IVec3) -> Option<Direction> {
87        match self {
88            Self::All(face) => Some(face),
89            Self::Pipe { u: a, v: b, face } if (a == u && b == v) || (a == v && b == u) => {
90                Some(face)
91            }
92            _ => None,
93        }
94    }
95}
96
97/// Viewport background for the standalone HTML viewer.
98const VIEWER_BACKGROUND: &str = "#D8E0E6";
99
100/// Ambient brightness shared by the editor and its baked glTF lighting.
101pub const EDITOR_AMBIENT_BRIGHTNESS: f32 = 1065.0;
102/// Diagonal key-light illuminance shared by the editor and its baked glTF lighting.
103pub const EDITOR_DIRECTIONAL_ILLUMINANCE: f32 = 1055.0;
104
105fn editor_face_brightness(triangle: &[Vec3; 3]) -> f32 {
106    let [a, b, c] = *triangle;
107    let normal = (b - a).cross(c - a).normalize_or_zero();
108    let exposure = 2.0_f32.powf(-9.7) / 1.2;
109    exposure
110        * (0.4524 * EDITOR_AMBIENT_BRIGHTNESS
111            + 0.3255 * EDITOR_DIRECTIONAL_ILLUMINANCE * normal.dot(Vec3::ONE.normalize()).max(0.0))
112}
113
114fn linear_palette(color: RGBA) -> [f32; 4] {
115    let mut channels = color.to_f32_array();
116    for channel in &mut channels[..3] {
117        *channel = if *channel <= 0.04045 {
118            *channel / 12.92
119        } else {
120            ((*channel + 0.055) / 1.055).powf(2.4)
121        };
122    }
123    channels
124}
125
126pub(crate) const POPPED_X_FACE_COLOR: RGBA = RGBA::from_hex(0xA61E2D, 255);
127pub(crate) const POPPED_Y_FACE_COLOR: RGBA = RGBA::from_hex(0x188038, 255);
128pub(crate) const POPPED_Z_FACE_COLOR: RGBA = RGBA::from_hex(0x174EA6, 255);
129
130// Fully diffuse: any metallic or specular response makes faces of the same
131// basis read as different colors depending on how they face the light.
132const DEFAULT_METALLIC_FACTOR: f32 = 0.0;
133const DEFAULT_ROUGHNESS_FACTOR: f32 = 1.0;
134
135#[derive(Debug, Clone, Copy)]
136enum FaceStyle {
137    Fill,
138    Outline,
139    FillAndOutline,
140}
141
142impl FaceStyle {
143    fn has_fill(self) -> bool {
144        matches!(self, FaceStyle::Fill | FaceStyle::FillAndOutline)
145    }
146
147    fn has_outline(self) -> bool {
148        matches!(self, FaceStyle::Outline | FaceStyle::FillAndOutline)
149    }
150}
151
152impl GltfData {
153    fn color_module_faces(&mut self, color: RGBA) {
154        let triangles: Vec<_> = std::mem::take(&mut self.triangles)
155            .into_values()
156            .flatten()
157            .collect();
158        if !triangles.is_empty() {
159            self.triangles.insert(color, triangles);
160            self.unlit_colors.insert(color);
161        }
162    }
163
164    /// Returns a copy with every triangle color's alpha multiplied by `scale`
165    /// (clamped to opaque). Lines and labels are unchanged.
166    pub fn scale_face_opacity(&self, scale: f32) -> GltfData {
167        let apply_scale = |color: RGBA| RGBA {
168            r: color.r,
169            g: color.g,
170            b: color.b,
171            a: ((color.a as f32) * scale) as u8,
172        };
173
174        // Scaling can collapse two distinct colors onto the same RGBA (e.g. when
175        // alpha saturates), so merge into the same key instead of letting the
176        // later entry overwrite the earlier one's triangles.
177        let mut triangles: BTreeMap<RGBA, Vec<[Vec3; 3]>> = BTreeMap::new();
178        for (color, tris) in &self.triangles {
179            triangles
180                .entry(apply_scale(*color))
181                .or_default()
182                .extend(tris.iter().copied());
183        }
184
185        GltfData {
186            triangles,
187            lines: self.lines.clone(),
188            texts: self.texts.clone(),
189            unlit_colors: self.unlit_colors.iter().copied().map(apply_scale).collect(),
190        }
191    }
192
193    /// Removes filled faces whose outward normal matches one of `directions`.
194    ///
195    /// Line geometry is retained so the opened graph keeps its silhouette.
196    pub fn pop_faces_at_directions(mut self, directions: &[Direction]) -> GltfData {
197        if directions.is_empty() {
198            return self;
199        }
200
201        self.triangles.retain(|_, triangles| {
202            triangles.retain(|[a, b, c]| {
203                let normal = (*b - *a).cross(*c - *a).normalize_or_zero();
204                !directions
205                    .iter()
206                    .any(|direction| normal.dot(direction.to_vec3()) > 0.999)
207            });
208            !triangles.is_empty()
209        });
210        self
211    }
212
213    /// Merges another data set into this one, combining geometry of matching colors.
214    pub fn extend(&mut self, other: GltfData) {
215        self.unlit_colors.extend(other.unlit_colors);
216        for (color, triangles) in other.triangles {
217            self.add_triangles(color, triangles);
218        }
219        for (color, lines) in other.lines {
220            self.add_line(color, lines);
221        }
222        self.texts.extend(other.texts);
223    }
224
225    fn add_triangles(&mut self, color: RGBA, triangles: impl IntoIterator<Item = [Vec3; 3]>) {
226        self.triangles.entry(color).or_default().extend(triangles);
227    }
228
229    fn add_line(&mut self, color: RGBA, lines: impl IntoIterator<Item = [Vec3; 2]>) {
230        self.lines.entry(color).or_default().extend(lines);
231    }
232
233    fn add_face(&mut self, center: Vec3, side1: Vec3, side2: Vec3, rgba: RGBA, style: FaceStyle) {
234        let origin = center - (side1 + side2) * 0.5;
235        if style.has_fill() {
236            self.add_triangles(
237                rgba,
238                [
239                    [origin, origin + side1, origin + side2],
240                    [origin + side1 + side2, origin + side2, origin + side1],
241                ],
242            );
243        }
244        if style.has_outline() {
245            self.add_line(
246                RGBA::LINE_BLACK,
247                [
248                    [origin, origin + side1],
249                    [origin + side1 + side2, origin + side1],
250                    [origin, origin + side2],
251                    [origin + side1 + side2, origin + side2],
252                ],
253            );
254        }
255    }
256
257    fn add_cube(&mut self, center: Vec3, sides: Vec3, rgba: RGBA, style: FaceStyle) {
258        let [x, y, z] = center.to_array();
259        let [sx, sy, sz] = sides.to_array();
260        let mut f = |c, s1, s2| {
261            self.add_face(c, s1, s2, rgba, style);
262        };
263        f(
264            vec3(x, y, z - sz * 0.5),
265            vec3(0.0, sy, 0.0),
266            vec3(sx, 0.0, 0.0),
267        );
268        f(
269            vec3(x, y, z + sz * 0.5),
270            vec3(sx, 0.0, 0.0),
271            vec3(0.0, sy, 0.0),
272        );
273        f(
274            vec3(x, y - sy * 0.5, z),
275            vec3(sx, 0.0, 0.0),
276            vec3(0.0, 0.0, sz),
277        );
278        f(
279            vec3(x, y + sy * 0.5, z),
280            vec3(0.0, 0.0, sz),
281            vec3(sx, 0.0, 0.0),
282        );
283        f(
284            vec3(x - sx * 0.5, y, z),
285            vec3(0.0, 0.0, sz),
286            vec3(0.0, sy, 0.0),
287        );
288        f(
289            vec3(x + sx * 0.5, y, z),
290            vec3(0.0, sy, 0.0),
291            vec3(0.0, 0.0, sz),
292        );
293    }
294
295    fn add_prism_face(&mut self, corners: [Vec3; 4], rgba: RGBA, style: FaceStyle) {
296        if style.has_fill() {
297            self.add_triangles(
298                rgba,
299                [
300                    [corners[0], corners[1], corners[2]],
301                    [corners[2], corners[3], corners[0]],
302                ],
303            );
304        }
305        if style.has_outline() {
306            self.add_line(
307                RGBA::LINE_BLACK,
308                [
309                    [corners[0], corners[1]],
310                    [corners[1], corners[2]],
311                    [corners[2], corners[3]],
312                    [corners[3], corners[0]],
313                ],
314            );
315        }
316    }
317
318    fn add_double_color_cube(&mut self, center: Vec3, sides: Vec3, rgba1: RGBA, rgba2: RGBA) {
319        self.add_cube(center, sides, RGBA::LINE_BLACK, FaceStyle::Outline);
320        let [x, y, z] = center.to_array();
321        let [sx, sy, sz] = (0.5 * sides).to_array();
322        self.add_triangles(
323            rgba1,
324            [
325                [
326                    vec3(x - sx, y - sy, z - sz),
327                    vec3(x - sx, y - sy, z + sz),
328                    vec3(x - sx, y + sy, z + sz),
329                ],
330                [
331                    vec3(x - sx, y - sy, z - sz),
332                    vec3(x + sx, y - sy, z - sz),
333                    vec3(x + sx, y - sy, z + sz),
334                ],
335                [
336                    vec3(x - sx, y - sy, z + sz),
337                    vec3(x + sx, y - sy, z + sz),
338                    vec3(x + sx, y + sy, z + sz),
339                ],
340                [
341                    vec3(x + sx, y - sy, z - sz),
342                    vec3(x + sx, y + sy, z - sz),
343                    vec3(x + sx, y + sy, z + sz),
344                ],
345                [
346                    vec3(x - sx, y + sy, z - sz),
347                    vec3(x - sx, y + sy, z + sz),
348                    vec3(x + sx, y + sy, z + sz),
349                ],
350                [
351                    vec3(x - sx, y - sy, z - sz),
352                    vec3(x - sx, y + sy, z - sz),
353                    vec3(x + sx, y + sy, z - sz),
354                ],
355            ],
356        );
357        self.add_triangles(
358            rgba2,
359            [
360                [
361                    vec3(x - sx, y + sy, z + sz),
362                    vec3(x - sx, y + sy, z - sz),
363                    vec3(x - sx, y - sy, z - sz),
364                ],
365                [
366                    vec3(x + sx, y - sy, z + sz),
367                    vec3(x - sx, y - sy, z + sz),
368                    vec3(x - sx, y - sy, z - sz),
369                ],
370                [
371                    vec3(x + sx, y + sy, z + sz),
372                    vec3(x - sx, y + sy, z + sz),
373                    vec3(x - sx, y - sy, z + sz),
374                ],
375                [
376                    vec3(x + sx, y + sy, z + sz),
377                    vec3(x + sx, y - sy, z + sz),
378                    vec3(x + sx, y - sy, z - sz),
379                ],
380                [
381                    vec3(x + sx, y + sy, z + sz),
382                    vec3(x + sx, y + sy, z - sz),
383                    vec3(x - sx, y + sy, z - sz),
384                ],
385                [
386                    vec3(x + sx, y + sy, z - sz),
387                    vec3(x + sx, y - sy, z - sz),
388                    vec3(x - sx, y - sy, z - sz),
389                ],
390            ],
391        );
392    }
393
394    /// Swaps the `y` and `z` axes of all geometry, converting from the graph's
395    /// time-up (`z`) convention to the exporter's y-up convention.
396    ///
397    /// Triangle winding is flipped to keep front faces oriented after the swap.
398    pub fn swap_yz(self) -> GltfData {
399        let mut transformed = GltfData {
400            unlit_colors: self.unlit_colors,
401            ..Default::default()
402        };
403        let swap = |v: Vec3| vec3(v.x, v.z, v.y);
404
405        for (color, triangles) in self.triangles {
406            let transformed_triangles = triangles
407                .into_iter()
408                .map(|[a, b, c]| [swap(a), swap(c), swap(b)])
409                .collect();
410            transformed.triangles.insert(color, transformed_triangles);
411        }
412
413        for (color, lines) in self.lines {
414            let transformed_lines = lines.into_iter().map(|[a, b]| [swap(a), swap(b)]).collect();
415            transformed.lines.insert(color, transformed_lines);
416        }
417        transformed.texts = self
418            .texts
419            .into_iter()
420            .map(|label| TextLabel {
421                position: swap(label.position),
422                ..label
423            })
424            .collect();
425
426        transformed
427    }
428
429    fn to_buffer_bytes(&self) -> Vec<u8> {
430        let positions = self
431            .triangles
432            .values()
433            .flatten()
434            .flatten()
435            .chain(self.lines.values().flatten().flatten())
436            .flat_map(Vec3::to_array);
437        // Baking the editor's diffuse light keeps exports independent of the
438        // viewing application's environment and specular-light defaults.
439        // ponytail: double-sided backs keep the front's baked shade; live
440        // lighting is needed if viewing opened geometry from inside must match.
441        let colors = self.triangles.iter().flat_map(|(color, triangles)| {
442            triangles.iter().flat_map(|triangle| {
443                let brightness = if self.unlit_colors.contains(color) {
444                    1.0
445                } else {
446                    editor_face_brightness(triangle)
447                };
448                [[brightness, brightness, brightness, 1.0]; 3]
449                    .into_iter()
450                    .flatten()
451            })
452        });
453        positions.chain(colors).flat_map(f32::to_le_bytes).collect()
454    }
455
456    /// Applies `f` to every vertex, line endpoint, and text-label position.
457    ///
458    /// Triangle winding is preserved as-is; this does not re-orient normals, so
459    /// only use it for orientation-preserving transforms such as translations,
460    /// rotations, and positive scales.
461    pub fn map_points(mut self, f: impl Fn(Vec3) -> Vec3) -> GltfData {
462        for triangles in self.triangles.values_mut() {
463            for triangle in triangles {
464                for point in triangle {
465                    *point = f(*point);
466                }
467            }
468        }
469
470        for lines in self.lines.values_mut() {
471            for line in lines {
472                for point in line {
473                    *point = f(*point);
474                }
475            }
476        }
477
478        for label in &mut self.texts {
479            label.position = f(label.position);
480        }
481
482        self
483    }
484
485    /// Builds a glTF [`Root`] document from the accumulated geometry.
486    fn to_model(&self) -> Root {
487        let mut root = gltf_json::Root {
488            asset: Asset {
489                version: "2.0".to_string(),
490                copyright: Some("Generated by `bloq_graph` crate".to_string()),
491                ..Default::default()
492            },
493            ..Default::default()
494        };
495        root.extensions_used.push("KHR_materials_unlit".to_string());
496        root.extensions_required
497            .push("KHR_materials_unlit".to_string());
498
499        let mut create_material = |color: RGBA, double_sided: bool| {
500            root.push(Material {
501                pbr_metallic_roughness: PbrMetallicRoughness {
502                    // glTF factors use linear RGB; the authored palette uses sRGB.
503                    base_color_factor: PbrBaseColorFactor(linear_palette(color)),
504                    metallic_factor: StrengthFactor(DEFAULT_METALLIC_FACTOR),
505                    roughness_factor: StrengthFactor(DEFAULT_ROUGHNESS_FACTOR),
506                    ..Default::default()
507                },
508                double_sided,
509                extensions: Some(MaterialExtensions {
510                    unlit: Some(Unlit {}),
511                }),
512                alpha_mode: Valid(if color.a < 255 {
513                    AlphaMode::Blend
514                } else {
515                    AlphaMode::Opaque
516                }),
517                ..Default::default()
518            })
519        };
520
521        let tri_materials: BTreeMap<_, _> = self
522            .triangles
523            .keys()
524            .map(|&c| (c, create_material(c, true)))
525            .collect();
526        let line_materials: BTreeMap<_, _> = self
527            .lines
528            .keys()
529            .map(|&c| (c, create_material(c, false)))
530            .collect();
531
532        let buffer_bytes = self.to_buffer_bytes();
533        let buffer_index = root.push(Buffer {
534            uri: Some(format!(
535                "data:application/octet-stream;base64,{}",
536                BASE64_STANDARD.encode(&buffer_bytes)
537            )),
538            byte_length: buffer_bytes.len().into(),
539            extensions: None,
540            extras: Default::default(),
541            name: None,
542        });
543
544        let mut byte_offset = 0;
545        let mut color_byte_offset = (self.triangles.values().map(Vec::len).sum::<usize>() * 3
546            + self.lines.values().map(Vec::len).sum::<usize>() * 2)
547            * 3
548            * 4;
549        let mut primitives = Vec::new();
550
551        let mut add_primitive = |count: usize,
552                                 min: Vec3,
553                                 max: Vec3,
554                                 material_index: gltf_json::Index<Material>,
555                                 mode: Mode| {
556            let byte_length = count * 3 * 4;
557            let view_index = root.push(View {
558                buffer: buffer_index,
559                byte_length: byte_length.into(),
560                byte_offset: Some(byte_offset.into()),
561                byte_stride: None,
562                target: Some(Valid(Target::ArrayBuffer)),
563                extensions: None,
564                extras: Default::default(),
565                name: None,
566            });
567
568            let accessor_index = root.push(Accessor {
569                buffer_view: Some(view_index),
570                byte_offset: Some(0usize.into()),
571                count: count.into(),
572                component_type: Valid(GenericComponentType(ComponentType::F32)),
573                type_: Valid(Type::Vec3),
574                normalized: false,
575                min: Some(Value::from(Vec::from(min.to_array()))),
576                max: Some(Value::from(Vec::from(max.to_array()))),
577                sparse: None,
578                extensions: None,
579                extras: Default::default(),
580                name: None,
581            });
582
583            let mut attributes = BTreeMap::new();
584            attributes.insert(Valid(Semantic::Positions), accessor_index);
585            if mode == Mode::Triangles {
586                let color_byte_length = count * 4 * 4;
587                let color_view = root.push(View {
588                    buffer: buffer_index,
589                    byte_length: color_byte_length.into(),
590                    byte_offset: Some(color_byte_offset.into()),
591                    target: Some(Valid(Target::ArrayBuffer)),
592                    byte_stride: None,
593                    extensions: None,
594                    extras: Default::default(),
595                    name: None,
596                });
597                let color_accessor = root.push(Accessor {
598                    buffer_view: Some(color_view),
599                    count: count.into(),
600                    component_type: Valid(GenericComponentType(ComponentType::F32)),
601                    type_: Valid(Type::Vec4),
602                    byte_offset: Some(0usize.into()),
603                    normalized: false,
604                    min: None,
605                    max: None,
606                    sparse: None,
607                    extensions: None,
608                    extras: Default::default(),
609                    name: None,
610                });
611                attributes.insert(Valid(Semantic::Colors(0)), color_accessor);
612                color_byte_offset += color_byte_length;
613            }
614            primitives.push(Primitive {
615                attributes,
616                indices: None,
617                material: Some(material_index),
618                mode: Valid(mode),
619                targets: None,
620                extensions: None,
621                extras: Default::default(),
622            });
623
624            byte_offset += byte_length;
625        };
626
627        for (color, triangles) in &self.triangles {
628            let count = triangles.len() * 3;
629            let (min, max) = bounds(triangles.iter().flatten());
630            add_primitive(count, min, max, tri_materials[color], Mode::Triangles);
631        }
632
633        for (color, lines) in &self.lines {
634            let count = lines.len() * 2;
635            let (min, max) = bounds(lines.iter().flatten());
636            add_primitive(count, min, max, line_materials[color], Mode::Lines);
637        }
638
639        let mesh_index = root.push(gltf_json::Mesh {
640            primitives,
641            weights: None,
642            extensions: None,
643            extras: Default::default(),
644            name: None,
645        });
646        let node_index = root.push(gltf_json::Node {
647            mesh: Some(mesh_index),
648            ..Default::default()
649        });
650        root.push(gltf_json::Scene {
651            nodes: vec![node_index],
652            extensions: None,
653            extras: Default::default(),
654            name: None,
655        });
656        root
657    }
658
659    fn module_model(&self, ranges: &ModuleFaceRanges) -> (Root, Vec<ModuleMaterial>) {
660        let mut model = self.to_model();
661        let original_primitives = std::mem::take(&mut model.meshes[0].primitives);
662        let mut materials = BTreeMap::new();
663        let mut toggles = Vec::new();
664        for ((color, triangles), primitive) in self.triangles.iter().zip(&original_primitives) {
665            for (owner, range) in &ranges[color] {
666                let mut part = primitive.clone();
667                // Accessors select slices of the existing position and shaded color buffers.
668                for (semantic, bytes_per_vertex) in
669                    [(Semantic::Positions, 12), (Semantic::Colors(0), 16)]
670                {
671                    let mut accessor =
672                        model.accessors[part.attributes[&Valid(semantic.clone())].value()].clone();
673                    accessor.byte_offset = Some((range.start * 3 * bytes_per_vertex).into());
674                    accessor.count = (range.len() * 3).into();
675                    if semantic == Semantic::Positions {
676                        let (min, max) = bounds(triangles[range.clone()].iter().flatten());
677                        accessor.min = Some(Value::from(Vec::from(min.to_array())));
678                        accessor.max = Some(Value::from(Vec::from(max.to_array())));
679                    }
680                    part.attributes
681                        .insert(Valid(semantic), model.push(accessor));
682                }
683                if let Some(owner) = owner {
684                    let material = *materials.entry((*owner, *color)).or_insert_with(|| {
685                        let mut material = model.materials[primitive
686                            .material
687                            .expect("triangle primitive has a material")
688                            .value()]
689                        .clone();
690                        let original = material.pbr_metallic_roughness.base_color_factor.0;
691                        let highlight = linear_palette(crate::module_color(*owner));
692                        let alpha = if color.a < 255 { "BLEND" } else { "OPAQUE" };
693                        material.pbr_metallic_roughness.base_color_factor =
694                            PbrBaseColorFactor(highlight);
695                        material.alpha_mode = Valid(AlphaMode::Opaque);
696                        let index = model.push(material);
697                        toggles.push((*owner, index.value(), original, highlight, alpha));
698                        index
699                    });
700                    part.material = Some(material);
701                }
702                model.meshes[0].primitives.push(part);
703            }
704        }
705        // Outlines and cross-definition pipes retain their ordinary materials.
706        model.meshes[0]
707            .primitives
708            .extend(original_primitives.into_iter().skip(self.triangles.len()));
709        (model, toggles)
710    }
711
712    /// Writes the scene to a `.gltf` file.
713    ///
714    /// Vertex colors bake the editor's diffuse response into unlit materials.
715    /// Lighting stays fixed; the host viewer does not relight the graph.
716    ///
717    /// # Errors
718    ///
719    /// Returns [`BlockGraphError::Io`] if the file cannot be written, or
720    /// [`BlockGraphError::GltfSerialize`] if serialization fails.
721    pub fn to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), BlockGraphError> {
722        let path = path.as_ref();
723        let model = self.to_model();
724        let file = std::fs::File::create(path).map_err(|e| BlockGraphError::Io {
725            path: path.to_path_buf(),
726            source: Arc::new(e),
727        })?;
728        gltf_json::serialize::to_writer(file, &model)
729            .map_err(|e| BlockGraphError::GltfSerialize(Arc::new(e)))
730    }
731
732    /// Renders an HTML page with the glTF scene embedded as a base64 data URI.
733    ///
734    /// Vertex colors bake the editor's diffuse response into unlit materials.
735    /// Lighting stays fixed; the viewer does not relight the graph.
736    /// The `model-viewer` renderer is pulled from a CDN, so the page needs
737    /// network access to display; the model data itself is inlined.
738    ///
739    /// # Errors
740    ///
741    /// Returns [`BlockGraphError::GltfSerialize`] if glTF serialization fails.
742    pub fn to_html_str(&self) -> Result<String, BlockGraphError> {
743        self.to_html_str_with_legend("")
744    }
745
746    pub(crate) fn to_html_str_with_legend(&self, legend: &str) -> Result<String, BlockGraphError> {
747        Self::model_html(&self.to_model(), legend, &[])
748    }
749
750    pub(crate) fn to_module_html_str(
751        &self,
752        legend: &str,
753        ranges: &ModuleFaceRanges,
754    ) -> Result<String, BlockGraphError> {
755        let (model, toggles) = self.module_model(ranges);
756        Self::model_html(&model, legend, &toggles)
757    }
758
759    fn model_html(
760        model: &Root,
761        legend: &str,
762        toggles: &[ModuleMaterial],
763    ) -> Result<String, BlockGraphError> {
764        let bytes = gltf_json::serialize::to_vec(model)
765            .map_err(|error| BlockGraphError::GltfSerialize(Arc::new(error)))?;
766        let toggles = gltf_json::serialize::to_string(toggles)
767            .map_err(|error| BlockGraphError::GltfSerialize(Arc::new(error)))?;
768        let model_data_uri = format!(
769            "data:model/gltf+json;base64,{}",
770            BASE64_STANDARD.encode(&bytes)
771        );
772        Ok(format!(
773            r##"<!DOCTYPE html>
774<html>
775<head>
776  <meta charset="UTF-8" />
777  <meta name="viewport" content="width=device-width, initial-scale=1" />
778  <script id="viewer-runtime" type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.2.0/model-viewer.min.js" onerror="window.viewerLoadFailed = true"></script>
779  <style>
780    body {{ margin: 0; font: 14px system-ui, sans-serif; }}
781    model-viewer {{ width: 100vw; height: 100vh; background-color: {VIEWER_BACKGROUND}; }}
782    .controls {{ position: absolute; top: 12px; left: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: #1f2937; pointer-events: none; }}
783    .controls button {{ pointer-events: auto; }}
784    .module-legend {{ position: absolute; top: 56px; right: 12px; max-width: 45%; max-height: calc(100vh - 150px); overflow: auto; padding: 8px; border-radius: 4px; background: #f8fafcee; color: #1f2937; font-size: 12px; }}
785    .module-legend ul {{ padding: 0; margin: 8px 0 0; list-style: none; }}
786    .module-legend li {{ margin: 6px 0; overflow-wrap: anywhere; }}
787    .module-legend label {{ cursor: pointer; }}
788    .module-legend input {{ vertical-align: middle; margin: 0 5px 0 0; }}
789    .module-legend i {{ display: inline-block; width: 10px; height: 10px; margin-right: 5px; }}
790    #axes {{ position: absolute; bottom: 12px; left: 12px; width: 100px; height: 100px; pointer-events: none; visibility: hidden; }}
791    #axes text {{ font: bold 13px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; paint-order: stroke; stroke: {VIEWER_BACKGROUND}; stroke-width: 3px; }}
792    #status {{ position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; color: #526070; pointer-events: none; }}
793    button {{ padding: 6px 12px; border: 1px solid #94a3b2; border-radius: 4px; background: #f8fafc; color: #1f2937; cursor: pointer; }}
794  </style>
795</head>
796<body>
797  <model-viewer
798    src="{}"
799    alt="Block Graph 3D Model"
800    camera-controls
801    camera-orbit="45deg 54.7356deg auto"
802    field-of-view="45deg"
803    max-field-of-view="45deg"
804    shadow-intensity="0"
805    tone-mapping="none"
806    exposure="1.0"
807    interaction-prompt="none"
808    touch-action="none">
809  </model-viewer>
810  <div id="status" role="status">Loading 3D graph…</div>
811  <div class="controls">
812    <button type="button" disabled aria-label="Reset graph view">Fit</button>
813    <span>Drag to orbit · Right-drag to pan · Scroll to zoom</span>
814  </div>
815  {legend}
816  <svg id="axes" viewBox="0 0 100 100" role="img" aria-label="Graph axes: X and Y are spatial, Z is time">
817    <circle cx="50" cy="50" r="3" fill="#526070" />
818    <g data-axis="X" stroke="#dc2626" fill="#dc2626"><line x1="50" y1="50" stroke-width="2"/><text stroke-width="0">X</text></g>
819    <g data-axis="Y" stroke="#16a34a" fill="#16a34a"><line x1="50" y1="50" stroke-width="2"/><text stroke-width="0">Y</text></g>
820    <g data-axis="Z" stroke="#2563eb" fill="#2563eb"><line x1="50" y1="50" stroke-width="2"/><text stroke-width="0">Z</text></g>
821  </svg>
822  <script>
823    const viewer = document.querySelector('model-viewer');
824    const fit = document.querySelector('button');
825    const status = document.querySelector('#status');
826    const axes = document.querySelector('#axes');
827    const moduleMaterials = {toggles};
828    const moduleToggles = document.querySelectorAll('.module-legend input');
829    for (const toggle of moduleToggles) {{
830      toggle.addEventListener('change', () => {{
831        for (const [owner, index, original, highlight, alpha] of moduleMaterials) {{
832          if (owner !== Number(toggle.dataset.module)) continue;
833          const material = viewer.model.materials[index];
834          material.pbrMetallicRoughness.setBaseColorFactor(toggle.checked ? highlight : original);
835          material.setAlphaMode(toggle.checked ? 'OPAQUE' : alpha);
836        }}
837      }});
838    }}
839    const updateAxes = () => {{
840      const {{theta, phi}} = viewer.getCameraOrbit();
841      axes.style.visibility = 'visible';
842      // Graph (x,y,z) maps to renderer (x,z,-y), just as in Bloq Editor.
843      const directions = {{
844        X: [Math.cos(theta), Math.sin(theta) * Math.cos(phi)],
845        Y: [Math.sin(theta), -Math.cos(theta) * Math.cos(phi)],
846        Z: [0, -Math.sin(phi)]
847      }};
848      for (const group of axes.querySelectorAll('[data-axis]')) {{
849        const [x, y] = directions[group.dataset.axis];
850        const line = group.querySelector('line');
851        line.setAttribute('x2', 50 + 32 * x);
852        line.setAttribute('y2', 50 + 32 * y);
853        const label = group.querySelector('text');
854        label.setAttribute('x', 50 + 43 * x);
855        label.setAttribute('y', 50 + 43 * y);
856      }}
857    }};
858    viewer.addEventListener('camera-change', updateAxes);
859    const showError = () => {{ status.textContent = 'Unable to load the 3D viewer. Check your network connection.'; }};
860    document.querySelector('#viewer-runtime').addEventListener('error', showError);
861    if (window.viewerLoadFailed) showError();
862    viewer.addEventListener('error', showError);
863    viewer.addEventListener('load', () => {{
864      fit.disabled = false;
865      for (const toggle of moduleToggles) toggle.disabled = false;
866      status.textContent = '';
867      updateAxes();
868    }});
869    fit.addEventListener('click', () => {{
870      viewer.cameraTarget = 'auto auto auto';
871      viewer.cameraOrbit = '45deg 54.7356deg auto';
872      viewer.fieldOfView = '45deg';
873    }});
874  </script>
875</body>
876</html>"##,
877            model_data_uri
878        ))
879    }
880}
881
882const HADAMARD_STRIP_WIDTH: f32 = 0.2;
883
884fn bounds<'a>(points: impl Iterator<Item = &'a Vec3>) -> (Vec3, Vec3) {
885    points.fold((Vec3::MAX, Vec3::MIN), |(min, max), v| {
886        (min.min(*v), max.max(*v))
887    })
888}
889
890fn next_axis(u: UDirection) -> UDirection {
891    match u {
892        UDirection::X => UDirection::Y,
893        UDirection::Y => UDirection::Z,
894        UDirection::Z => UDirection::X,
895    }
896}
897
898fn get_basis(dir: UDirection, kind: crate::CubeKind) -> Basis {
899    match dir {
900        UDirection::X => kind.x(),
901        UDirection::Y => kind.y(),
902        UDirection::Z => kind.z(),
903    }
904}
905
906fn walking_as_gltf_data(kind: WalkingKind, pipe_length: f32) -> GltfData {
907    let mut data = GltfData::default();
908    let stride = pipe_length + 1.0;
909    let movement = kind.movement_3d().as_vec3() * stride;
910    let bottom_center = Vec3::NEG_Z * 0.5;
911    let top_center = movement + Vec3::Z * 0.5;
912    let x = Vec3::X * 0.5;
913    let y = Vec3::Y * 0.5;
914
915    let b00 = bottom_center - x - y;
916    let b10 = bottom_center + x - y;
917    let b11 = bottom_center + x + y;
918    let b01 = bottom_center - x + y;
919    let t00 = top_center - x - y;
920    let t10 = top_center + x - y;
921    let t11 = top_center + x + y;
922    let t01 = top_center - x + y;
923
924    let boundary = kind.boundary();
925    data.add_prism_face(
926        [b00, b01, b11, b10],
927        boundary.z().into(),
928        FaceStyle::FillAndOutline,
929    );
930    data.add_prism_face(
931        [t00, t10, t11, t01],
932        boundary.z().into(),
933        FaceStyle::FillAndOutline,
934    );
935    data.add_prism_face(
936        [b10, b11, t11, t10],
937        boundary.x().into(),
938        FaceStyle::FillAndOutline,
939    );
940    data.add_prism_face(
941        [b00, t00, t01, b01],
942        boundary.x().into(),
943        FaceStyle::FillAndOutline,
944    );
945    data.add_prism_face(
946        [b01, t01, t11, b11],
947        boundary.y().into(),
948        FaceStyle::FillAndOutline,
949    );
950    data.add_prism_face(
951        [b00, b10, t10, t00],
952        boundary.y().into(),
953        FaceStyle::FillAndOutline,
954    );
955    data
956}
957
958fn patch_rotation_as_gltf_data(kind: PatchRotationKind, pipe_length: f32) -> GltfData {
959    let mut data = GltfData::default();
960    let stride = pipe_length + 1.0;
961    let movement = kind.movement_3d();
962    let colors = PatchRotationColors::new(kind);
963
964    let (min, max) = patch_rotation_bounds(movement, stride);
965
966    add_patch_rotation_boundary_faces(&mut data, kind, stride, colors);
967
968    data.add_prism_face(
969        [
970            vec3(min.x, min.y, min.z),
971            vec3(min.x, max.y, min.z),
972            vec3(max.x, max.y, min.z),
973            vec3(max.x, min.y, min.z),
974        ],
975        colors.start_time,
976        FaceStyle::FillAndOutline,
977    );
978    data.add_prism_face(
979        [
980            vec3(min.x, min.y, max.z),
981            vec3(max.x, min.y, max.z),
982            vec3(max.x, max.y, max.z),
983            vec3(min.x, max.y, max.z),
984        ],
985        colors.end_time,
986        FaceStyle::FillAndOutline,
987    );
988    data
989}
990
991#[derive(Debug, Clone, Copy)]
992struct PatchRotationColors {
993    before: RGBA,
994    after: RGBA,
995    start_time: RGBA,
996    end_time: RGBA,
997    mixed_side: PatchRotationMixedSide,
998}
999
1000impl PatchRotationColors {
1001    fn new(kind: PatchRotationKind) -> Self {
1002        let before = RGBA::from(kind.x_axis_boundary_basis());
1003        let after = RGBA::from(kind.x_axis_boundary_basis().flip());
1004        let mixed_side = PatchRotationMixedSide::for_kind(kind);
1005        Self {
1006            before,
1007            after,
1008            start_time: after,
1009            end_time: before,
1010            mixed_side,
1011        }
1012    }
1013}
1014
1015fn add_patch_rotation_boundary_faces(
1016    data: &mut GltfData,
1017    kind: PatchRotationKind,
1018    stride: f32,
1019    colors: PatchRotationColors,
1020) {
1021    let min_u = -0.5;
1022    let max_u = 0.5;
1023    let min_v = -0.5;
1024    let max_v = stride + 0.5;
1025    let min_z = -0.5;
1026    let max_z = stride + 0.5;
1027    let mid_v = f32::midpoint(min_v, max_v);
1028    let mid_z = f32::midpoint(min_z, max_z);
1029
1030    add_patch_rotation_local_face(
1031        data,
1032        kind,
1033        [
1034            vec3(min_u, min_v, min_z),
1035            vec3(max_u, min_v, min_z),
1036            vec3(max_u, min_v, max_z),
1037            vec3(min_u, min_v, max_z),
1038        ],
1039        colors.after,
1040    );
1041    add_patch_rotation_local_face(
1042        data,
1043        kind,
1044        [
1045            vec3(min_u, max_v, min_z),
1046            vec3(min_u, max_v, max_z),
1047            vec3(max_u, max_v, max_z),
1048            vec3(max_u, max_v, min_z),
1049        ],
1050        colors.before,
1051    );
1052
1053    for (side, u) in [
1054        (PatchRotationMixedSide::Left, min_u),
1055        (PatchRotationMixedSide::Right, max_u),
1056    ] {
1057        if side == colors.mixed_side {
1058            add_patch_rotation_local_face(
1059                data,
1060                kind,
1061                [
1062                    vec3(u, min_v, min_z),
1063                    vec3(u, mid_v, min_z),
1064                    vec3(u, mid_v, max_z),
1065                    vec3(u, min_v, max_z),
1066                ],
1067                colors.before,
1068            );
1069            add_patch_rotation_local_face(
1070                data,
1071                kind,
1072                [
1073                    vec3(u, mid_v, min_z),
1074                    vec3(u, max_v, min_z),
1075                    vec3(u, max_v, max_z),
1076                    vec3(u, mid_v, max_z),
1077                ],
1078                colors.after,
1079            );
1080        } else {
1081            add_patch_rotation_local_face(
1082                data,
1083                kind,
1084                [
1085                    vec3(u, min_v, min_z),
1086                    vec3(u, max_v, min_z),
1087                    vec3(u, max_v, mid_z),
1088                    vec3(u, min_v, mid_z),
1089                ],
1090                colors.before,
1091            );
1092            add_patch_rotation_local_face(
1093                data,
1094                kind,
1095                [
1096                    vec3(u, min_v, mid_z),
1097                    vec3(u, max_v, mid_z),
1098                    vec3(u, max_v, max_z),
1099                    vec3(u, min_v, max_z),
1100                ],
1101                colors.after,
1102            );
1103        }
1104    }
1105}
1106
1107fn add_patch_rotation_local_face(
1108    data: &mut GltfData,
1109    kind: PatchRotationKind,
1110    mut corners: [Vec3; 4],
1111    color: RGBA,
1112) {
1113    // The left face and the +X coordinate reflection each reverse orientation.
1114    if corners.iter().all(|corner| corner.x < 0.0) ^ (kind.movement().x == 1) {
1115        corners.reverse();
1116    }
1117    data.add_prism_face(
1118        corners.map(|corner| patch_rotation_local_to_global(kind, corner)),
1119        color,
1120        FaceStyle::FillAndOutline,
1121    );
1122}
1123
1124fn patch_rotation_local_to_global(kind: PatchRotationKind, point: Vec3) -> Vec3 {
1125    match kind.movement().to_array() {
1126        [0, 1] => point,
1127        [0, -1] => vec3(-point.x, -point.y, point.z),
1128        [1, 0] => vec3(point.y, point.x, point.z),
1129        [-1, 0] => vec3(-point.y, point.x, point.z),
1130        _ => unreachable!("PatchRotationKind rejects non-cardinal movement"),
1131    }
1132}
1133
1134fn patch_rotation_local_vector_to_global(kind: PatchRotationKind, vector: Vec3) -> Vec3 {
1135    patch_rotation_local_to_global(kind, vector) - patch_rotation_local_to_global(kind, Vec3::ZERO)
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1139enum PatchRotationMixedSide {
1140    Left,
1141    Right,
1142}
1143
1144impl PatchRotationMixedSide {
1145    fn for_kind(kind: PatchRotationKind) -> Self {
1146        match (
1147            kind.x_axis_boundary_basis(),
1148            kind.movement() == glam::IVec2::NEG_X,
1149        ) {
1150            (Basis::X, true) | (Basis::Z, false) => Self::Right,
1151            (Basis::X, false) | (Basis::Z, true) => Self::Left,
1152        }
1153    }
1154}
1155
1156fn patch_rotation_bounds(movement: IVec3, stride: f32) -> (Vec3, Vec3) {
1157    let min = vec3(
1158        (0.min(movement.x) as f32) * stride - 0.5,
1159        (0.min(movement.y) as f32) * stride - 0.5,
1160        -0.5,
1161    );
1162    let max = vec3(
1163        (0.max(movement.x) as f32) * stride + 0.5,
1164        (0.max(movement.y) as f32) * stride + 0.5,
1165        stride + 0.5,
1166    );
1167    (min, max)
1168}
1169
1170fn has_pipe_toward(graph: &BlockGraph, position: IVec3, offset: IVec3) -> bool {
1171    crate::checked_add_position(position, offset)
1172        .is_ok_and(|neighbor| graph.has_pipe_between(position, neighbor))
1173}
1174
1175fn checked_position_delta(lhs: IVec3, rhs: IVec3) -> Option<IVec3> {
1176    Some(IVec3::new(
1177        lhs.x.checked_sub(rhs.x)?,
1178        lhs.y.checked_sub(rhs.y)?,
1179        lhs.z.checked_sub(rhs.z)?,
1180    ))
1181}
1182
1183/// Builds the geometry for a single block, with unit pipe length.
1184pub fn block_as_gltf_data(block: &Block, g: &BlockGraph) -> GltfData {
1185    block_as_gltf_data_with_pipe_length(block, g, 1.0)
1186}
1187
1188/// Builds the geometry for a single block, using `pipe_length` to size the gaps
1189/// between blocks that pipes span.
1190///
1191/// The graph `g` is consulted for neighbor context (e.g. boundary orientation).
1192///
1193/// # Panics
1194///
1195/// Panics if an internally constructed Port block lacks its display color.
1196pub fn block_as_gltf_data_with_pipe_length(
1197    block: &Block,
1198    g: &BlockGraph,
1199    pipe_length: f32,
1200) -> GltfData {
1201    let mut data = GltfData::default();
1202    let p = block.pos;
1203    let center = Vec3::ZERO;
1204
1205    match block.kind {
1206        BlockKind::Port => {
1207            data.add_cube(
1208                center,
1209                Vec3::ONE,
1210                block
1211                    .port_color()
1212                    .expect("Port blocks have a display color"),
1213                FaceStyle::FillAndOutline,
1214            );
1215            let connected_faces = Direction::iter()
1216                .filter(|direction| has_pipe_toward(g, p, direction.to_ivec3()))
1217                .collect::<Vec<_>>();
1218            data = data.pop_faces_at_directions(&connected_faces);
1219        }
1220        BlockKind::Cube(kind) => {
1221            let height_cells = block.height_cells();
1222            if height_cells == 1 {
1223                for dir in Direction::iter() {
1224                    if !has_pipe_toward(g, p, dir.to_ivec3()) {
1225                        let d_vec = dir.to_vec3();
1226                        let basis = get_basis(dir.as_udirection(), kind);
1227                        let color = basis.into();
1228
1229                        let u = dir.as_udirection();
1230                        let next_u = next_axis(u);
1231                        let next_next_u = next_axis(next_u);
1232
1233                        let s1_u = match dir {
1234                            Direction::XPLUS | Direction::YPLUS | Direction::ZPLUS => next_u,
1235                            Direction::XMINUS | Direction::YMINUS | Direction::ZMINUS => {
1236                                next_next_u
1237                            }
1238                        };
1239                        let s2_u = match dir {
1240                            Direction::XPLUS | Direction::YPLUS | Direction::ZPLUS => next_next_u,
1241                            Direction::XMINUS | Direction::YMINUS | Direction::ZMINUS => next_u,
1242                        };
1243
1244                        let s1 = s1_u.to_vec3();
1245                        let s2 = s2_u.to_vec3();
1246
1247                        data.add_face(
1248                            center + d_vec * 0.5,
1249                            s1,
1250                            s2,
1251                            color,
1252                            FaceStyle::FillAndOutline,
1253                        );
1254                    }
1255                }
1256                data.add_cube(center, Vec3::ONE, RGBA::LINE_BLACK, FaceStyle::Outline);
1257            } else {
1258                add_tall_cube_faces(&mut data, g, p, kind, height_cells, pipe_length);
1259            }
1260        }
1261        BlockKind::Walking(kind) => {
1262            data.extend(walking_as_gltf_data(kind, pipe_length));
1263        }
1264        BlockKind::PatchRotation(kind) => {
1265            data.extend(patch_rotation_as_gltf_data(kind, pipe_length));
1266        }
1267        BlockKind::T => data.add_cube(center, Vec3::ONE, RGBA::T_PURPLE, FaceStyle::FillAndOutline),
1268        BlockKind::Y => {
1269            let center = if has_pipe_toward(g, p, IVec3::Z) {
1270                center + Vec3::Z * 0.25
1271            } else if has_pipe_toward(g, p, IVec3::NEG_Z) {
1272                center + Vec3::NEG_Z * 0.25
1273            } else {
1274                center
1275            };
1276            data.add_cube(
1277                center,
1278                vec3(1.0, 1.0, 0.5),
1279                RGBA::Y_GREEN,
1280                FaceStyle::FillAndOutline,
1281            );
1282        }
1283        BlockKind::Measurement(basis) => data.add_cube(
1284            center + Vec3::NEG_Z * 0.25,
1285            vec3(1.0, 1.0, 0.5),
1286            basis.into(),
1287            FaceStyle::FillAndOutline,
1288        ),
1289        BlockKind::Selective(kind) => {
1290            let rgba1 = kind.pauli_if_true().into();
1291            let rgba2 = kind.pauli_if_false().into();
1292            data.add_double_color_cube(
1293                center + Vec3::NEG_Z * 0.25,
1294                vec3(1.0, 1.0, 0.5),
1295                rgba1,
1296                rgba2,
1297            );
1298        }
1299    }
1300    data
1301}
1302
1303fn add_tall_cube_faces(
1304    data: &mut GltfData,
1305    graph: &BlockGraph,
1306    pos: IVec3,
1307    kind: crate::CubeKind,
1308    height_cells: u32,
1309    pipe_length: f32,
1310) {
1311    let stride = pipe_length + 1.0;
1312    let min = vec3(-0.5, -0.5, -0.5);
1313    let max = vec3(0.5, 0.5, (height_cells - 1) as f32 * stride + 0.5);
1314    let top = crate::checked_add_position(pos, IVec3::new(0, 0, height_cells as i32 - 1)).ok();
1315    let x_color: RGBA = kind.x().into();
1316    let y_color: RGBA = kind.y().into();
1317    let z_color: RGBA = kind.z().into();
1318
1319    if !has_pipe_toward(graph, pos, IVec3::NEG_X) {
1320        data.add_prism_face(
1321            [
1322                vec3(min.x, min.y, min.z),
1323                vec3(min.x, min.y, max.z),
1324                vec3(min.x, max.y, max.z),
1325                vec3(min.x, max.y, min.z),
1326            ],
1327            x_color,
1328            FaceStyle::FillAndOutline,
1329        );
1330    }
1331    if !has_pipe_toward(graph, pos, IVec3::X) {
1332        data.add_prism_face(
1333            [
1334                vec3(max.x, min.y, min.z),
1335                vec3(max.x, max.y, min.z),
1336                vec3(max.x, max.y, max.z),
1337                vec3(max.x, min.y, max.z),
1338            ],
1339            x_color,
1340            FaceStyle::FillAndOutline,
1341        );
1342    }
1343    if !has_pipe_toward(graph, pos, IVec3::NEG_Y) {
1344        data.add_prism_face(
1345            [
1346                vec3(min.x, min.y, min.z),
1347                vec3(max.x, min.y, min.z),
1348                vec3(max.x, min.y, max.z),
1349                vec3(min.x, min.y, max.z),
1350            ],
1351            y_color,
1352            FaceStyle::FillAndOutline,
1353        );
1354    }
1355    if !has_pipe_toward(graph, pos, IVec3::Y) {
1356        data.add_prism_face(
1357            [
1358                vec3(min.x, max.y, min.z),
1359                vec3(min.x, max.y, max.z),
1360                vec3(max.x, max.y, max.z),
1361                vec3(max.x, max.y, min.z),
1362            ],
1363            y_color,
1364            FaceStyle::FillAndOutline,
1365        );
1366    }
1367    if !has_pipe_toward(graph, pos, IVec3::NEG_Z) {
1368        data.add_prism_face(
1369            [
1370                vec3(min.x, min.y, min.z),
1371                vec3(min.x, max.y, min.z),
1372                vec3(max.x, max.y, min.z),
1373                vec3(max.x, min.y, min.z),
1374            ],
1375            z_color,
1376            FaceStyle::FillAndOutline,
1377        );
1378    }
1379    if top.is_none_or(|top| !has_pipe_toward(graph, top, IVec3::Z)) {
1380        data.add_prism_face(
1381            [
1382                vec3(min.x, min.y, max.z),
1383                vec3(max.x, min.y, max.z),
1384                vec3(max.x, max.y, max.z),
1385                vec3(min.x, max.y, max.z),
1386            ],
1387            z_color,
1388            FaceStyle::FillAndOutline,
1389        );
1390    }
1391}
1392
1393/// Builds the geometry for a pipe between two blocks.
1394pub fn pipe_as_gltf_data(
1395    u: &Block,
1396    v: &Block,
1397    pipe: &Pipe,
1398    g: &BlockGraph,
1399    pipe_length: f32,
1400) -> GltfData {
1401    pipe_between_positions_as_gltf_data(u.pos, v.pos, pipe, g, pipe_length)
1402}
1403
1404/// Builds the geometry for a pipe between two lattice positions.
1405pub fn pipe_between_positions_as_gltf_data(
1406    u_pos: IVec3,
1407    v_pos: IVec3,
1408    pipe: &Pipe,
1409    g: &BlockGraph,
1410    pipe_length: f32,
1411) -> GltfData {
1412    let mut data = GltfData::default();
1413    let diff = v_pos.as_vec3() - u_pos.as_vec3();
1414    let pipe_height = scaled_spatial_pipe_height(u_pos, v_pos, pipe, g, pipe_length).unwrap_or(1.0);
1415    let center = Vec3::Z * ((pipe_height - 1.0) * 0.5);
1416
1417    let h2 = HADAMARD_STRIP_WIDTH * pipe_length * 0.25;
1418    let p2 = pipe_length * 0.5;
1419    let face_center_delta = f32::midpoint(p2, h2);
1420
1421    let main_axis = if diff.x != 0.0 {
1422        Vec3::X
1423    } else if diff.y != 0.0 {
1424        Vec3::Y
1425    } else {
1426        Vec3::Z
1427    };
1428
1429    let inferred_bases = g.infer_pipe_basis(pipe);
1430    let pipe_dir_u = pipe.dir.as_udirection();
1431
1432    let size = Vec3::ONE + main_axis.abs() * (pipe_length - 1.0) + Vec3::Z * (pipe_height - 1.0);
1433    data.add_cube(center, size, RGBA::LINE_BLACK, FaceStyle::Outline);
1434
1435    for dir in UDirection::iter() {
1436        if dir == pipe_dir_u {
1437            continue;
1438        }
1439
1440        // No basis is inferable for this face when the pipe attaches to a block
1441        // that defines no boundary color (Port/T/…). Render it as undefined gray
1442        // instead of silently masquerading as an X boundary.
1443        let (color_start, color_end, strip_color) = match inferred_bases[dir.index()] {
1444            Some(basis) => {
1445                let basis_end = if pipe.hadamard { basis.flip() } else { basis };
1446                let strip = if pipe.hadamard {
1447                    RGBA::H_YELLOW
1448                } else {
1449                    basis.into()
1450                };
1451                (basis.into(), basis_end.into(), strip)
1452            }
1453            None => (RGBA::PORT_GRAY, RGBA::PORT_GRAY, RGBA::PORT_GRAY),
1454        };
1455
1456        let normal = dir.to_vec3();
1457        let normal_len = if normal.abs().dot(Vec3::Z) > 0.0 {
1458            pipe_height
1459        } else {
1460            1.0
1461        };
1462        let width_dir = main_axis.cross(normal);
1463        let width_len = if width_dir.abs().dot(Vec3::Z) > 0.0 {
1464            pipe_height
1465        } else {
1466            1.0
1467        };
1468        let width_dir = width_dir * width_len;
1469
1470        for polarity in [1.0, -1.0] {
1471            let d = normal * polarity;
1472            let normal_offset = d * (normal_len * 0.5);
1473
1474            let (s1, s2) = if main_axis.cross(width_dir).dot(d) < 0.0 {
1475                (width_dir, main_axis * (p2 - h2))
1476            } else {
1477                (main_axis * (p2 - h2), width_dir)
1478            };
1479
1480            let (s1_strip, s2_strip) = if main_axis.cross(width_dir).dot(d) < 0.0 {
1481                (width_dir, main_axis * h2 * 2.0)
1482            } else {
1483                (main_axis * h2 * 2.0, width_dir)
1484            };
1485
1486            data.add_face(
1487                center - diff * face_center_delta + normal_offset,
1488                s1,
1489                s2,
1490                color_start,
1491                FaceStyle::Fill,
1492            );
1493            data.add_face(
1494                center + diff * face_center_delta + normal_offset,
1495                s1,
1496                s2,
1497                color_end,
1498                FaceStyle::Fill,
1499            );
1500            data.add_face(
1501                center + normal_offset,
1502                s1_strip,
1503                s2_strip,
1504                strip_color,
1505                FaceStyle::Fill,
1506            );
1507        }
1508    }
1509    data
1510}
1511
1512fn scaled_spatial_pipe_height(
1513    u_pos: IVec3,
1514    v_pos: IVec3,
1515    pipe: &Pipe,
1516    g: &BlockGraph,
1517    pipe_length: f32,
1518) -> Option<f32> {
1519    if !pipe.dir.is_spatial() {
1520        return None;
1521    }
1522    let u = g.get_endpoint_block(u_pos)?;
1523    let v = g.get_endpoint_block(v_pos)?;
1524    if !u.kind().is_cube() || !v.kind().is_cube() || u.height_cells() != v.height_cells() {
1525        return None;
1526    }
1527    (u.height_cells() > 1).then_some((u.height_cells() - 1) as f32 * (pipe_length + 1.0) + 1.0)
1528}
1529
1530/// Builds the geometry for an entire block graph, laying blocks out on a grid
1531/// with `pipe_length`-sized gaps between adjacent cells.
1532pub fn block_graph_as_gltf_data(g: &BlockGraph, pipe_length: f32) -> GltfData {
1533    block_graph_as_gltf_data_with_popped_faces(g, pipe_length, &[])
1534}
1535
1536/// Builds an entire block graph while removing the selected filled faces.
1537pub fn block_graph_as_gltf_data_with_popped_faces(
1538    g: &BlockGraph,
1539    pipe_length: f32,
1540    popped_faces: &[GltfFaceSelector],
1541) -> GltfData {
1542    block_graph_as_gltf_data_with_modules(g, pipe_length, popped_faces, None)
1543}
1544
1545pub(crate) fn block_graph_as_gltf_data_with_modules(
1546    g: &BlockGraph,
1547    pipe_length: f32,
1548    popped_faces: &[GltfFaceSelector],
1549    modules: Option<&crate::ModuleView>,
1550) -> GltfData {
1551    build_module_geometry(g, pipe_length, popped_faces, modules, true).0
1552}
1553
1554pub(crate) fn module_html_geometry(
1555    g: &BlockGraph,
1556    pipe_length: f32,
1557    popped_faces: &[GltfFaceSelector],
1558    modules: Option<&crate::ModuleView>,
1559) -> (GltfData, ModuleFaceRanges) {
1560    build_module_geometry(g, pipe_length, popped_faces, modules, false)
1561}
1562
1563fn build_module_geometry(
1564    g: &BlockGraph,
1565    pipe_length: f32,
1566    popped_faces: &[GltfFaceSelector],
1567    modules: Option<&crate::ModuleView>,
1568    highlight: bool,
1569) -> (GltfData, ModuleFaceRanges) {
1570    let mut data = GltfData::default();
1571    let mut ranges = ModuleFaceRanges::new();
1572    let mut append = |mut object: GltfData, owner: Option<usize>, center: Vec3| {
1573        if highlight {
1574            if let Some(owner) = owner {
1575                object.color_module_faces(crate::module_color(owner));
1576            }
1577        } else {
1578            for (color, triangles) in &object.triangles {
1579                let start = data.triangles.get(color).map_or(0, Vec::len);
1580                let end = start + triangles.len();
1581                let spans = ranges.entry(*color).or_default();
1582                if let Some((last_owner, last_range)) = spans.last_mut()
1583                    && *last_owner == owner
1584                {
1585                    last_range.end = end;
1586                } else {
1587                    spans.push((owner, start..end));
1588                }
1589            }
1590        }
1591        data.extend(object.map_points(|v| v + center));
1592    };
1593    let stride = pipe_length + 1.0;
1594
1595    for block in g.blocks() {
1596        let p = block.pos;
1597        let center = p.as_vec3() * stride;
1598        let directions = popped_faces
1599            .iter()
1600            .filter_map(|selector| selector.for_block(p))
1601            .collect::<Vec<_>>();
1602        let block_data = block_as_gltf_data_with_pipe_length(block, g, pipe_length)
1603            .pop_faces_at_directions(&directions);
1604        append(
1605            block_data,
1606            modules.and_then(|view| view.module_for_block(p)),
1607            center,
1608        );
1609    }
1610
1611    for (u_pos, v_pos, _, _, pipe) in g.pipe_endpoints_with_blocks() {
1612        let diff = (v_pos - u_pos).as_vec3();
1613        let center = u_pos.as_vec3() * stride + diff * stride * 0.5;
1614        let directions = popped_faces
1615            .iter()
1616            .filter_map(|selector| selector.for_pipe(u_pos, v_pos))
1617            .collect::<Vec<_>>();
1618        let pipe_data = pipe_between_positions_as_gltf_data(u_pos, v_pos, pipe, g, pipe_length)
1619            .pop_faces_at_directions(&directions);
1620        append(
1621            pipe_data,
1622            modules.and_then(|view| view.module_for_pipe(g, u_pos, v_pos)),
1623            center,
1624        );
1625    }
1626    (data, ranges)
1627}
1628
1629/// Builds the geometry visualizing a stabilizer's support overlaid on the graph.
1630///
1631/// # Errors
1632///
1633/// Returns a [`BlockGraphError`] if the stabilizer cannot be resolved against
1634/// the graph's derived faces.
1635pub fn stabilizer_as_gltf_data(
1636    generator: &StabilizerGenerator,
1637    g: &BlockGraph,
1638    pipe_length: f32,
1639) -> Result<GltfData, BlockGraphError> {
1640    let measurement_label = if let StabilizerRowKind::Measurement { name } = &generator.kind {
1641        ZXGraph::try_from(g)?
1642            .measurement_outcome_position(name)
1643            .map(|position| (position, name.clone()))
1644    } else {
1645        None
1646    };
1647    let stabilizer = &generator.stabilizer;
1648    let g = g.fix_shadowed_faces();
1649    let mut data = GltfData::default();
1650    let stride = pipe_length + 1.0;
1651
1652    generate_interior_edge_surfaces(stabilizer, &g, pipe_length, stride, &mut data)?;
1653    let neighbors = build_neighbor_map(stabilizer, &g);
1654    generate_interior_node_surfaces(stabilizer, &g, stride, &neighbors, &mut data)?;
1655
1656    if let Some((pos, text)) = measurement_label {
1657        data.texts.push(TextLabel {
1658            position: (pos + 0.25 * Vec3::Z) * stride,
1659            text,
1660            color: RGBA::H_YELLOW,
1661        });
1662    }
1663    Ok(data)
1664}
1665
1666fn get_surface_normal(g: &BlockGraph, pipe: &Pipe, p: Pauli) -> Vec3 {
1667    let inferred = g.infer_pipe_basis(pipe);
1668    let pipe_dir = pipe.dir.as_udirection();
1669
1670    UDirection::iter()
1671        .filter(|&d| d != pipe_dir)
1672        .find_map(|dir| {
1673            inferred[dir.index()].and_then(|basis| {
1674                if Pauli::from(basis) == p.flip() {
1675                    Some(dir.to_vec3())
1676                } else {
1677                    None
1678                }
1679            })
1680        })
1681        .unwrap_or_else(|| pipe_dir.to_vec3())
1682}
1683
1684fn generate_interior_edge_surfaces(
1685    stabilizer: &Stabilizer,
1686    g: &BlockGraph,
1687    pipe_length: f32,
1688    stride: f32,
1689    data: &mut GltfData,
1690) -> Result<(), BlockGraphError> {
1691    for (&(u, v), &p) in &stabilizer.interior_edges {
1692        let physical = physical_pipe_for_stabilizer_edge(g, u, v)
1693            .ok_or(BlockGraphError::PipeNotFound(u, v))?;
1694        let pipe = physical.pipe;
1695
1696        let (u, v, p) = if physical.src == pipe.src {
1697            (physical.src, physical.dst, p)
1698        } else {
1699            (
1700                physical.dst,
1701                physical.src,
1702                if pipe.hadamard { p.flip() } else { p },
1703            )
1704        };
1705        let dir_vec = (v - u).as_vec3();
1706        let center = (u.as_vec3() + v.as_vec3()) * 0.5 * stride;
1707
1708        let segment_len = if pipe.hadamard {
1709            pipe_length * 0.5
1710        } else {
1711            pipe_length
1712        };
1713        let pipe_height = scaled_spatial_pipe_height(u, v, pipe, g, pipe_length).unwrap_or(1.0);
1714        let scaled_pipe_center_offset = Vec3::Z * ((pipe_height - 1.0) * 0.5);
1715
1716        for pauli in p.iter_xz() {
1717            let normal = get_surface_normal(g, pipe, pauli);
1718            let offset = if pipe.hadamard {
1719                -0.5 * segment_len * dir_vec
1720            } else {
1721                Vec3::ZERO
1722            };
1723            let seg_center = center + offset + scaled_pipe_center_offset;
1724            let width_vec = scale_z_dimension(dir_vec.cross(normal), pipe_height);
1725
1726            data.add_face(
1727                seg_center,
1728                dir_vec * segment_len,
1729                width_vec,
1730                correlation_surface_color(pauli),
1731                FaceStyle::Fill,
1732            );
1733
1734            if pipe.hadamard {
1735                let p_flipped = pauli.flip();
1736                let color_flipped = correlation_surface_color(p_flipped);
1737                let seg_center = center - offset + scaled_pipe_center_offset;
1738                data.add_face(
1739                    seg_center,
1740                    dir_vec * segment_len,
1741                    width_vec,
1742                    color_flipped,
1743                    FaceStyle::Fill,
1744                );
1745            }
1746        }
1747    }
1748    Ok(())
1749}
1750
1751fn add_walking_node_surface(
1752    block_pos: IVec3,
1753    kind: WalkingKind,
1754    p: Pauli,
1755    stride: f32,
1756    data: &mut GltfData,
1757) {
1758    let start = walking_attachment_point(block_pos, kind, block_pos, stride);
1759    let end = walking_attachment_point(block_pos, kind, kind.end_position(block_pos), stride);
1760    add_walking_surface_between(kind, p, start, end, data);
1761}
1762
1763fn add_walking_surface_between(
1764    kind: WalkingKind,
1765    p: Pauli,
1766    start: Vec3,
1767    end: Vec3,
1768    data: &mut GltfData,
1769) {
1770    let center = (start + end) * 0.5;
1771    let side = end - start;
1772
1773    for pauli in p.iter_xz() {
1774        let Some(width_vec) = walking_surface_width(kind, pauli) else {
1775            continue;
1776        };
1777        data.add_face(
1778            center,
1779            side,
1780            width_vec,
1781            correlation_surface_color(pauli),
1782            FaceStyle::Fill,
1783        );
1784    }
1785}
1786
1787fn walking_attachment_point(
1788    block_pos: IVec3,
1789    kind: WalkingKind,
1790    endpoint: IVec3,
1791    stride: f32,
1792) -> Vec3 {
1793    if endpoint == block_pos {
1794        endpoint.as_vec3() * stride + Vec3::NEG_Z * 0.5
1795    } else if endpoint == kind.end_position(block_pos) {
1796        endpoint.as_vec3() * stride + Vec3::Z * 0.5
1797    } else {
1798        endpoint.as_vec3() * stride
1799    }
1800}
1801
1802fn walking_surface_width(kind: WalkingKind, pauli: Pauli) -> Option<Vec3> {
1803    let boundary = kind.boundary();
1804    [(boundary.x(), Vec3::X), (boundary.y(), Vec3::Y)]
1805        .into_iter()
1806        .find_map(|(basis, axis)| (Pauli::from(basis) == pauli).then_some(axis))
1807}
1808
1809#[derive(Debug, Clone, Copy)]
1810struct PatchRotationStabilizerQuad {
1811    center: Vec3,
1812    side1: Vec3,
1813    side2: Vec3,
1814}
1815
1816fn add_patch_rotation_node_surface(
1817    block_pos: IVec3,
1818    kind: PatchRotationKind,
1819    pauli: Pauli,
1820    stride: f32,
1821    data: &mut GltfData,
1822) {
1823    let Some(shape) = patch_rotation_stabilizer_shape(kind, pauli) else {
1824        return;
1825    };
1826    let color = correlation_surface_color(pauli);
1827    let translation = block_pos.as_vec3() * stride;
1828
1829    for quad in
1830        patch_rotation_stabilizer_quads(shape, stride, PatchRotationMixedSide::for_kind(kind))
1831    {
1832        let center = patch_rotation_local_to_global(kind, quad.center) + translation;
1833        let side1 = patch_rotation_local_vector_to_global(kind, quad.side1);
1834        let side2 = patch_rotation_local_vector_to_global(kind, quad.side2);
1835        data.add_face(center, side1, side2, color, FaceStyle::Fill);
1836    }
1837}
1838
1839fn patch_rotation_stabilizer_shape(kind: PatchRotationKind, pauli: Pauli) -> Option<Pauli> {
1840    let basis = Pauli::from(kind.x_axis_boundary_basis());
1841    if pauli == basis {
1842        Some(Pauli::X)
1843    } else if pauli == basis.flip() {
1844        Some(Pauli::Z)
1845    } else {
1846        None
1847    }
1848}
1849
1850fn patch_rotation_stabilizer_quads(
1851    shape: Pauli,
1852    stride: f32,
1853    mixed_side: PatchRotationMixedSide,
1854) -> Vec<PatchRotationStabilizerQuad> {
1855    let min_x = -0.5;
1856    let max_x = 0.5;
1857    let mid_x = 0.0;
1858    let min_y = -0.5;
1859    let max_y = stride + 0.5;
1860    let min_z = -0.5;
1861    let max_z = stride + 0.5;
1862    let mid_y = f32::midpoint(min_y, max_y);
1863    let upper_line_y = f32::midpoint(min_y, mid_y);
1864    let upper_input_line_y = upper_line_y - 0.5;
1865    let lower_line_y = min_y + (max_y - min_y) * 0.75;
1866    let sheet_margin_y = (max_y - min_y) * 0.125;
1867    let upper_sheet_y = (upper_line_y - sheet_margin_y).max(min_y);
1868    let lower_sheet_y = (lower_line_y + sheet_margin_y).min(max_y);
1869    let lower_output_line_y = f32::midpoint(lower_line_y, max_y);
1870    let in_quarter_z = min_z + (max_z - min_z) * 0.25;
1871    let out_quarter_z = min_z + (max_z - min_z) * 0.75;
1872    let (mixed_x0, mixed_x1, extended_x0, extended_x1) = match mixed_side {
1873        PatchRotationMixedSide::Left => (min_x, mid_x, mid_x, max_x),
1874        PatchRotationMixedSide::Right => (mid_x, max_x, min_x, mid_x),
1875    };
1876
1877    match shape {
1878        Pauli::Z => vec![
1879            xz_quad(mixed_x0, mixed_x1, mid_y, min_z, out_quarter_z),
1880            yz_quad(mid_x, min_y, mid_y, min_z, out_quarter_z),
1881            xy_quad(extended_x0, extended_x1, min_y, mid_y, out_quarter_z),
1882            xy_quad(min_x, max_x, mid_y, lower_sheet_y, out_quarter_z),
1883            xz_quad(min_x, max_x, lower_output_line_y, out_quarter_z, max_z),
1884        ],
1885        Pauli::X => vec![
1886            xz_quad(min_x, max_x, upper_input_line_y, min_z, in_quarter_z),
1887            xy_quad(min_x, max_x, upper_sheet_y, mid_y, in_quarter_z),
1888            xy_quad(extended_x0, extended_x1, mid_y, max_y, in_quarter_z),
1889            xz_quad(mixed_x0, mixed_x1, mid_y, in_quarter_z, max_z),
1890            yz_quad(mid_x, mid_y, max_y, in_quarter_z, max_z),
1891        ],
1892        _ => Vec::new(),
1893    }
1894}
1895
1896fn xy_quad(x0: f32, x1: f32, y0: f32, y1: f32, z: f32) -> PatchRotationStabilizerQuad {
1897    PatchRotationStabilizerQuad {
1898        center: vec3(f32::midpoint(x0, x1), f32::midpoint(y0, y1), z),
1899        side1: Vec3::X * (x1 - x0),
1900        side2: Vec3::Y * (y1 - y0),
1901    }
1902}
1903
1904fn xz_quad(x0: f32, x1: f32, y: f32, z0: f32, z1: f32) -> PatchRotationStabilizerQuad {
1905    PatchRotationStabilizerQuad {
1906        center: vec3(f32::midpoint(x0, x1), y, f32::midpoint(z0, z1)),
1907        side1: Vec3::X * (x1 - x0),
1908        side2: Vec3::Z * (z1 - z0),
1909    }
1910}
1911
1912fn yz_quad(x: f32, y0: f32, y1: f32, z0: f32, z1: f32) -> PatchRotationStabilizerQuad {
1913    PatchRotationStabilizerQuad {
1914        center: vec3(x, f32::midpoint(y0, y1), f32::midpoint(z0, z1)),
1915        side1: Vec3::Y * (y1 - y0),
1916        side2: Vec3::Z * (z1 - z0),
1917    }
1918}
1919
1920fn cube_stabilizer_height(block: &Block, stride: f32) -> f32 {
1921    if block.kind().is_cube() {
1922        (block.height_cells() - 1) as f32 * stride + 1.0
1923    } else {
1924        1.0
1925    }
1926}
1927
1928fn cube_stabilizer_center(block: &Block, stride: f32) -> Vec3 {
1929    let height = cube_stabilizer_height(block, stride);
1930    block.pos().as_vec3() * stride + Vec3::Z * ((height - 1.0) * 0.5)
1931}
1932
1933fn scale_z_dimension(vector: Vec3, height: f32) -> Vec3 {
1934    if vector.abs().dot(Vec3::Z) > 0.0 {
1935        vector * height
1936    } else {
1937        vector
1938    }
1939}
1940
1941fn cube_half_surface_offset(dvec: Vec3, height: f32) -> f32 {
1942    if dvec.abs().dot(Vec3::Z) > 0.0 {
1943        height * 0.25
1944    } else {
1945        0.25
1946    }
1947}
1948
1949fn cube_half_surface_side(dvec: Vec3, height: f32) -> Vec3 {
1950    if dvec.abs().dot(Vec3::Z) > 0.0 {
1951        dvec * (height * 0.5)
1952    } else {
1953        dvec * 0.5
1954    }
1955}
1956
1957fn pipe_for_node_direction<'a>(
1958    g: &'a BlockGraph,
1959    block: &Block,
1960    dir: Direction,
1961) -> Option<(IVec3, &'a Pipe)> {
1962    block
1963        .connectable_offsets()
1964        .into_iter()
1965        .filter_map(|offset| crate::checked_add_position(block.pos(), offset).ok())
1966        .find_map(|endpoint| {
1967            crate::checked_add_position(endpoint, dir.to_ivec3())
1968                .ok()
1969                .and_then(|neighbor| g.get_pipe(endpoint, neighbor))
1970                .map(|pipe| (endpoint, pipe))
1971        })
1972}
1973
1974fn build_neighbor_map(
1975    stabilizer: &Stabilizer,
1976    g: &BlockGraph,
1977) -> HashMap<IVec3, Vec<(Direction, Pauli)>> {
1978    let mut neighbors: HashMap<IVec3, Vec<(Direction, Pauli)>> =
1979        HashMap::with_capacity(stabilizer.interior_nodes.len());
1980
1981    for (&(u, v), &p) in &stabilizer.interior_edges {
1982        if let Some(dir_uv) =
1983            checked_position_delta(v, u).and_then(|delta| Direction::try_from(delta).ok())
1984        {
1985            neighbors.entry(u).or_default().push((dir_uv, p));
1986            let p_v = g
1987                .get_pipe(u, v)
1988                .map(|pipe| if pipe.hadamard { p.flip() } else { p })
1989                .unwrap_or(p);
1990            if let Some(dir_vu) =
1991                checked_position_delta(u, v).and_then(|delta| Direction::try_from(delta).ok())
1992            {
1993                neighbors.entry(v).or_default().push((dir_vu, p_v));
1994            }
1995            continue;
1996        }
1997
1998        let Some(physical) = physical_pipe_for_stabilizer_edge(g, u, v) else {
1999            continue;
2000        };
2001
2002        let p_v = if physical.pipe.hadamard { p.flip() } else { p };
2003        if let Some(dir_uv) = checked_position_delta(physical.dst, physical.src)
2004            .and_then(|delta| Direction::try_from(delta).ok())
2005        {
2006            neighbors.entry(u).or_default().push((dir_uv, p));
2007            neighbors.entry(v).or_default().push((dir_uv.negate(), p_v));
2008        }
2009    }
2010    neighbors
2011}
2012
2013struct PhysicalStabilizerPipe<'a> {
2014    src: IVec3,
2015    dst: IVec3,
2016    pipe: &'a Pipe,
2017}
2018
2019fn physical_pipe_for_stabilizer_edge<'a>(
2020    g: &'a BlockGraph,
2021    u: IVec3,
2022    v: IVec3,
2023) -> Option<PhysicalStabilizerPipe<'a>> {
2024    let u_block = g.get_endpoint_block(u)?;
2025    let v_block = g.get_endpoint_block(v)?;
2026    for u_endpoint in u_block
2027        .connectable_offsets()
2028        .into_iter()
2029        .filter_map(|offset| crate::checked_add_position(u_block.pos(), offset).ok())
2030    {
2031        for v_endpoint in v_block
2032            .connectable_offsets()
2033            .into_iter()
2034            .filter_map(|offset| crate::checked_add_position(v_block.pos(), offset).ok())
2035        {
2036            let Some(pipe) = g.get_pipe(u_endpoint, v_endpoint) else {
2037                continue;
2038            };
2039            return Some(PhysicalStabilizerPipe {
2040                src: u_endpoint,
2041                dst: v_endpoint,
2042                pipe,
2043            });
2044        }
2045    }
2046    None
2047}
2048
2049fn generate_interior_node_surfaces(
2050    stabilizer: &Stabilizer,
2051    g: &BlockGraph,
2052    stride: f32,
2053    neighbors: &HashMap<IVec3, Vec<(Direction, Pauli)>>,
2054    data: &mut GltfData,
2055) -> Result<(), BlockGraphError> {
2056    // A product can cancel a node's algebraic support while leaving a
2057    // through-going sheet on its incident pipes. Reconstruct cube coverage
2058    // from those pipes rather than dropping the middle of that sheet.
2059    let mut nodes = stabilizer.interior_nodes.clone();
2060    for (&pos, incident) in neighbors {
2061        if g.get_block(pos).is_some_and(|block| block.kind().is_cube()) {
2062            let support = incident.iter().fold(Pauli::I, |p, (_, q)| p | *q);
2063            let p = nodes.entry(pos).or_insert(Pauli::I);
2064            *p = *p | support;
2065        }
2066    }
2067    for (&pos, &p) in &nodes {
2068        let block = g
2069            .get_block(pos)
2070            .ok_or(BlockGraphError::BlockNotFound(pos))?;
2071        let center = pos.as_vec3() * stride;
2072
2073        for pauli in p.iter_xz() {
2074            match block.kind {
2075                BlockKind::Walking(kind) => {
2076                    add_walking_node_surface(pos, kind, pauli, stride, data);
2077                }
2078                BlockKind::PatchRotation(kind) => {
2079                    add_patch_rotation_node_surface(pos, kind, pauli, stride, data);
2080                }
2081                BlockKind::Cube(kind) if Pauli::from(kind.normal_basis()) != pauli => {
2082                    let normal = kind.normal_direction().to_vec3();
2083                    if let Some(dvec) = Direction::iter()
2084                        .find(|d| d.as_udirection() != kind.normal_direction())
2085                        .map(Direction::to_vec3)
2086                    {
2087                        let center = cube_stabilizer_center(block, stride);
2088                        let width_vec = scale_z_dimension(
2089                            dvec.cross(normal),
2090                            cube_stabilizer_height(block, stride),
2091                        );
2092                        data.add_face(
2093                            center,
2094                            dvec,
2095                            width_vec,
2096                            correlation_surface_color(pauli),
2097                            FaceStyle::Fill,
2098                        );
2099                    }
2100                }
2101                BlockKind::Cube(_) | BlockKind::Port => {
2102                    let is_cube = block.kind.is_cube();
2103                    let height = cube_stabilizer_height(block, stride);
2104                    let center = if is_cube {
2105                        cube_stabilizer_center(block, stride)
2106                    } else {
2107                        center
2108                    };
2109
2110                    for (dir, _) in neighbors
2111                        .get(&pos)
2112                        .into_iter()
2113                        .flatten()
2114                        .filter(|(_, neighbor_pauli)| *neighbor_pauli & pauli)
2115                    {
2116                        let dvec = dir.to_vec3();
2117                        if let Some((endpoint, pipe)) = pipe_for_node_direction(g, block, *dir) {
2118                            // `get_surface_normal` reads the pipe basis from its
2119                            // source endpoint, so translate the local pauli into
2120                            // that frame (a Hadamard flips X<->Z across the pipe)
2121                            // when this node sits on the pipe's dst side. The face
2122                            // color still uses the un-flipped local `pauli`, which
2123                            // is this node's own correlation surface.
2124                            let effective_pauli = if pipe.hadamard && (endpoint != pipe.src) {
2125                                pauli.flip()
2126                            } else {
2127                                pauli
2128                            };
2129                            let normal = get_surface_normal(g, pipe, effective_pauli);
2130                            let (width_vec, side, offset) = if is_cube {
2131                                (
2132                                    scale_z_dimension(dvec.cross(normal), height),
2133                                    cube_half_surface_side(dvec, height),
2134                                    cube_half_surface_offset(dvec, height),
2135                                )
2136                            } else {
2137                                (dvec.cross(normal), dvec * 0.5, 0.25)
2138                            };
2139
2140                            data.add_face(
2141                                center + dvec * offset,
2142                                side,
2143                                width_vec,
2144                                correlation_surface_color(pauli),
2145                                FaceStyle::Fill,
2146                            );
2147                        }
2148                    }
2149                }
2150                _ => {}
2151            }
2152        }
2153    }
2154    Ok(())
2155}
2156
2157fn correlation_surface_color(pauli: Pauli) -> RGBA {
2158    match pauli {
2159        Pauli::X => RGBA::X_PURE_RED,
2160        Pauli::Y => RGBA::Y_PURE_GREEN,
2161        Pauli::Z => RGBA::Z_PURE_BLUE,
2162        Pauli::I => RGBA::LINE_BLACK,
2163    }
2164}
2165
2166#[cfg(test)]
2167mod tests {
2168    use super::*;
2169    use crate::{GalleryItem, WalkingBoundaryKind, parse_blog_to_graph};
2170    use bloq_utils::PauliString;
2171    use glam::{IVec2, ivec2};
2172
2173    const EPS: f32 = 1e-5;
2174
2175    #[test]
2176    fn gltf_bakes_editor_lighting_without_changing_palette_opacity() {
2177        use gltf_json::validation::Validate;
2178
2179        let mut data = GltfData::default();
2180        // +Y and -Y faces exercise the diagonal key and ambient-only sides.
2181        data.add_triangles(
2182            RGBA::PORT_GRAY,
2183            [
2184                [Vec3::ZERO, Vec3::Z, Vec3::X],
2185                [Vec3::ZERO, Vec3::X, Vec3::Z],
2186            ],
2187        );
2188        data.add_line(RGBA::LINE_BLACK, [[Vec3::ZERO, Vec3::X]]);
2189        let model = data.to_model();
2190        let bytes = gltf_json::serialize::to_vec(&model).unwrap();
2191        let model: Root = gltf_json::deserialize::from_slice(&bytes).unwrap();
2192        let mut errors = Vec::new();
2193        model.validate(&model, gltf_json::Path::new, &mut |path, error| {
2194            errors.push((path(), error));
2195        });
2196        assert!(errors.is_empty(), "invalid glTF: {errors:?}");
2197        assert!(
2198            model
2199                .extensions_required
2200                .iter()
2201                .any(|name| name == "KHR_materials_unlit")
2202        );
2203        assert!(
2204            model
2205                .materials
2206                .iter()
2207                .all(|material| { material.extensions.as_ref().unwrap().unlit.is_some() })
2208        );
2209
2210        let primitive = &model.meshes[0].primitives[0];
2211        let material = &model.materials[primitive.material.unwrap().value()];
2212        assert_eq!(material.alpha_mode, Valid(AlphaMode::Blend));
2213        let [red, _, _, alpha] = material.pbr_metallic_roughness.base_color_factor.0;
2214        assert!((red - 0.7230551).abs() < EPS); // sRGB #DD decoded to linear.
2215        assert!((alpha - 89.0 / 255.0).abs() < EPS);
2216        let positions = &model.accessors[primitive.attributes[&Valid(Semantic::Positions)].value()];
2217        let colors = &model.accessors[primitive.attributes[&Valid(Semantic::Colors(0))].value()];
2218        assert_eq!(colors.type_, Valid(Type::Vec4));
2219        assert_eq!(colors.count, positions.count);
2220        assert_eq!(colors.count.0, 6);
2221        assert!(
2222            !model.meshes[0].primitives[1]
2223                .attributes
2224                .contains_key(&Valid(Semantic::Colors(0)))
2225        );
2226
2227        let uri = model.buffers[0].uri.as_ref().unwrap();
2228        let buffer = BASE64_STANDARD
2229            .decode(uri.split_once(',').unwrap().1)
2230            .unwrap();
2231        let view = &model.buffer_views[colors.buffer_view.unwrap().value()];
2232        let start = view.byte_offset.unwrap().0 as usize;
2233        let end = start + view.byte_length.0 as usize;
2234        assert_eq!(end, buffer.len());
2235        let values: Vec<_> = buffer[start..end]
2236            .as_chunks::<4>()
2237            .0
2238            .iter()
2239            .map(|bytes| f32::from_le_bytes(*bytes))
2240            .collect();
2241        let exposure = 2.0_f32.powf(-9.7) / 1.2;
2242        let ambient = 0.4524 * EDITOR_AMBIENT_BRIGHTNESS * exposure;
2243        let lit = ambient + 0.3255 * EDITOR_DIRECTIONAL_ILLUMINANCE * exposure / 3.0_f32.sqrt();
2244        for (vertex, expected) in values
2245            .as_chunks::<4>()
2246            .0
2247            .iter()
2248            .zip([lit, lit, lit, ambient, ambient, ambient])
2249        {
2250            assert!((vertex[0] - expected).abs() < EPS);
2251            assert_eq!(vertex[0], vertex[1]);
2252            assert_eq!(vertex[1], vertex[2]);
2253            assert_eq!(vertex[3], 1.0); // Opacity is applied only by the material.
2254        }
2255        assert!(linear_palette(RGBA::from_hex(0x080808, 255))[0] < 0.003);
2256    }
2257
2258    #[test]
2259    fn module_faces_keep_the_shared_opaque_color_and_unlit_brightness() {
2260        let mut data = GltfData::default();
2261        data.add_triangles(RGBA::X_RED, [[Vec3::ZERO, Vec3::X, Vec3::Y]]);
2262        data.add_line(RGBA::LINE_BLACK, [[Vec3::ZERO, Vec3::X]]);
2263        let color = crate::module_color(0);
2264        data.color_module_faces(color);
2265        assert_eq!(data.triangles.keys().copied().collect::<Vec<_>>(), [color]);
2266        assert!(data.lines.contains_key(&RGBA::LINE_BLACK));
2267        let data = data.swap_yz();
2268        let model = data.to_model();
2269        let primitive = &model.meshes[0].primitives[0];
2270        let material = &model.materials[primitive.material.unwrap().value()];
2271        assert_eq!(material.alpha_mode, Valid(AlphaMode::Opaque));
2272        assert_eq!(
2273            material.pbr_metallic_roughness.base_color_factor.0,
2274            linear_palette(color)
2275        );
2276        let accessor = &model.accessors[primitive.attributes[&Valid(Semantic::Colors(0))].value()];
2277        let view = &model.buffer_views[accessor.buffer_view.unwrap().value()];
2278        let buffer = data.to_buffer_bytes();
2279        let start = view.byte_offset.unwrap().0 as usize;
2280        for bytes in buffer[start..].as_chunks::<4>().0 {
2281            assert_eq!(f32::from_le_bytes(*bytes), 1.0);
2282        }
2283    }
2284
2285    #[test]
2286    fn module_toggles_preserve_original_faces_opacity_and_seams() {
2287        let source = crate::GalleryItem::ThreeBitAdder.build();
2288        let graph = source.flatten().unwrap();
2289        let modules = crate::ModuleView::from_graph(&source, &graph).unwrap();
2290        let (data, ranges) = module_html_geometry(&graph, 2.0, &[], Some(&modules));
2291        let ordinary = block_graph_as_gltf_data(&graph, 2.0);
2292        assert_eq!(data.triangles, ordinary.triangles);
2293        assert_eq!(data.lines, ordinary.lines);
2294        assert_eq!(data.to_buffer_bytes(), ordinary.to_buffer_bytes());
2295
2296        let (model, toggles) = data.module_model(&ranges);
2297        assert_eq!(model.buffers[0].uri, ordinary.to_model().buffers[0].uri);
2298        let mut primitive_index = 0;
2299        let mut owners = BTreeSet::new();
2300        let mut seams = 0;
2301        let mut transparent = 0;
2302        for (color, triangles) in &data.triangles {
2303            let mut next_triangle = 0;
2304            for (owner, range) in &ranges[color] {
2305                assert_eq!(range.start, next_triangle);
2306                next_triangle = range.end;
2307                transparent += usize::from(color.a < 255);
2308                let primitive = &model.meshes[0].primitives[primitive_index];
2309                primitive_index += 1;
2310                let positions =
2311                    &model.accessors[primitive.attributes[&Valid(Semantic::Positions)].value()];
2312                let colors =
2313                    &model.accessors[primitive.attributes[&Valid(Semantic::Colors(0))].value()];
2314                assert_eq!(positions.byte_offset.unwrap().0 as usize, range.start * 36);
2315                assert_eq!(colors.byte_offset.unwrap().0 as usize, range.start * 48);
2316                assert_eq!(positions.count.0 as usize, range.len() * 3);
2317                assert_eq!(positions.count, colors.count);
2318                let index = primitive.material.unwrap().value();
2319                let material = &model.materials[index];
2320                if let Some(owner) = owner {
2321                    owners.insert(*owner);
2322                    let (_, _, original, highlight, alpha) = toggles
2323                        .iter()
2324                        .find(|(group, material, ..)| group == owner && *material == index)
2325                        .unwrap();
2326                    assert_eq!(*original, linear_palette(*color));
2327                    assert_eq!(*highlight, linear_palette(crate::module_color(*owner)));
2328                    assert_eq!(
2329                        material.pbr_metallic_roughness.base_color_factor.0,
2330                        *highlight
2331                    );
2332                    assert_eq!(*alpha, if color.a < 255 { "BLEND" } else { "OPAQUE" });
2333                } else {
2334                    seams += 1;
2335                    assert_eq!(
2336                        material.alpha_mode,
2337                        Valid(if color.a < 255 {
2338                            AlphaMode::Blend
2339                        } else {
2340                            AlphaMode::Opaque
2341                        })
2342                    );
2343                    assert_eq!(
2344                        material.pbr_metallic_roughness.base_color_factor.0,
2345                        linear_palette(*color)
2346                    );
2347                }
2348            }
2349            assert_eq!(next_triangle, triangles.len());
2350        }
2351        assert_eq!(owners.len(), modules.modules().len());
2352        assert!(seams > 0);
2353        assert!(transparent > 0);
2354        assert_eq!(
2355            model.meshes[0].primitives.len() - primitive_index,
2356            data.lines.len()
2357        );
2358    }
2359
2360    #[test]
2361    fn html_viewer_uses_editor_projection_and_no_tone_mapping() {
2362        let html = GltfData::default().to_html_str().unwrap();
2363        assert!(html.contains("model-viewer/4.2.0/"));
2364        assert!(html.contains("background-color: #D8E0E6"));
2365        assert!(html.contains("camera-orbit=\"45deg 54.7356deg auto\""));
2366        assert!(html.contains("field-of-view=\"45deg\""));
2367        assert!(html.contains("max-field-of-view=\"45deg\""));
2368        assert!(html.contains("tone-mapping=\"none\""));
2369        assert!(html.contains("aria-label=\"Reset graph view\""));
2370        assert!(html.contains("Graph axes: X and Y are spatial, Z is time"));
2371        for axis in ["X", "Y", "Z"] {
2372            assert!(html.contains(&format!("data-axis=\"{axis}\"")));
2373        }
2374        assert!(html.contains("viewer.addEventListener('camera-change', updateAxes)"));
2375    }
2376
2377    fn lerp(min: f32, max: f32, t: f32) -> f32 {
2378        min + (max - min) * t
2379    }
2380
2381    #[test]
2382    fn product_surface_keeps_cube_between_supported_pipes() {
2383        let graph = crate::GalleryItem::CNOT
2384            .build()
2385            .materialize_root_graph()
2386            .expect("gallery flat projection");
2387        let generators = graph.stabilizers().unwrap().generators;
2388        let mut product = generators[1].clone();
2389        product
2390            .stabilizer
2391            .phase_free_mul_assign(&generators[2].stabilizer);
2392        let data = stabilizer_as_gltf_data(&product, &graph, 2.0).unwrap();
2393        assert_patch_rotation_color_at_named(
2394            &data,
2395            Vec3::Z * 3.0,
2396            correlation_surface_color(Pauli::Z),
2397            "Z control sheet crosses its lower junction",
2398        );
2399    }
2400
2401    #[test]
2402    fn moving_prisms_face_outward_and_pop_the_geometric_top() {
2403        let mut kinds = Vec::new();
2404        for x in -1..=1 {
2405            for y in -1..=1 {
2406                if x == 0 && y == 0 {
2407                    continue;
2408                }
2409                let movement = ivec2(x, y);
2410                kinds.push(BlockKind::Walking(
2411                    WalkingKind::new(WalkingBoundaryKind::ZXZ, movement).unwrap(),
2412                ));
2413                if x == 0 || y == 0 {
2414                    for basis in [Basis::X, Basis::Z] {
2415                        kinds.push(BlockKind::PatchRotation(
2416                            PatchRotationKind::new(basis, movement).unwrap(),
2417                        ));
2418                    }
2419                }
2420            }
2421        }
2422        for kind in kinds {
2423            let block = Block::new(IVec3::ZERO, kind);
2424            let data = block_as_gltf_data(&block, &BlockGraph::new());
2425            let (min, max) = triangle_bounds(&data);
2426            let center = (min + max) * 0.5;
2427            for &[a, b, c] in data.triangles.values().flatten() {
2428                assert!(
2429                    (b - a).cross(c - a).dot(a - center) > EPS,
2430                    "inward face: {kind:?} {a:?} {b:?} {c:?}"
2431                );
2432            }
2433            let popped = data.pop_faces_at_directions(&[Direction::ZPLUS]);
2434            let count_at = |z: f32| {
2435                popped
2436                    .triangles
2437                    .values()
2438                    .flatten()
2439                    .filter(|triangle| triangle.iter().all(|vertex| (vertex.z - z).abs() < EPS))
2440                    .count()
2441            };
2442            assert_eq!(count_at(max.z), 0, "top of {kind:?}");
2443            assert_eq!(count_at(min.z), 2, "bottom of {kind:?}");
2444        }
2445    }
2446
2447    #[test]
2448    fn popping_face_direction_removes_only_that_filled_face() {
2449        let graph = GalleryItem::XMemory
2450            .build()
2451            .materialize_root_graph()
2452            .expect("gallery flat projection");
2453        let data = block_graph_as_gltf_data(&graph, 2.0);
2454        let triangle_count = data.triangles.values().map(Vec::len).sum::<usize>();
2455        let line_count = data.lines.values().map(Vec::len).sum::<usize>();
2456
2457        let popped = data.pop_faces_at_directions(&[Direction::YMINUS]);
2458
2459        assert!(popped.triangles.values().map(Vec::len).sum::<usize>() < triangle_count);
2460        assert_eq!(
2461            popped.lines.values().map(Vec::len).sum::<usize>(),
2462            line_count
2463        );
2464        assert!(popped.triangles.values().flatten().all(|[a, b, c]| {
2465            let normal = (*b - *a).cross(*c - *a).normalize_or_zero();
2466            normal.dot(Direction::YMINUS.to_vec3()) < 0.999
2467        }));
2468    }
2469
2470    #[test]
2471    fn popping_selected_faces_leaves_other_elements_closed() {
2472        let mut graph = BlockGraph::new();
2473        graph.add_block(Block::new(
2474            IVec3::ZERO,
2475            BlockKind::Cube(crate::CubeKind::ZXZ),
2476        ));
2477        graph.add_block(Block::new(IVec3::X, BlockKind::Cube(crate::CubeKind::ZXZ)));
2478        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::XPLUS));
2479
2480        let triangle_count = |data: &GltfData| data.triangles.values().map(Vec::len).sum::<usize>();
2481        let closed = block_graph_as_gltf_data(&graph, 2.0);
2482        let scoped = block_graph_as_gltf_data_with_popped_faces(
2483            &graph,
2484            2.0,
2485            &[
2486                GltfFaceSelector::Block {
2487                    position: IVec3::ZERO,
2488                    face: Direction::YMINUS,
2489                },
2490                GltfFaceSelector::Pipe {
2491                    u: IVec3::ZERO,
2492                    v: IVec3::X,
2493                    face: Direction::YMINUS,
2494                },
2495            ],
2496        );
2497        let global = block_graph_as_gltf_data_with_popped_faces(
2498            &graph,
2499            2.0,
2500            &[GltfFaceSelector::All(Direction::YMINUS)],
2501        );
2502
2503        assert_eq!(triangle_count(&closed) - triangle_count(&scoped), 8);
2504        assert_eq!(triangle_count(&closed) - triangle_count(&global), 10);
2505    }
2506
2507    #[test]
2508    fn connected_port_omits_its_pipe_face_but_keeps_the_outline() {
2509        let port = Block::new(IVec3::Z, BlockKind::Port);
2510        let mut graph = BlockGraph::new();
2511        graph.add_block(Block::new(
2512            IVec3::ZERO,
2513            BlockKind::Cube(crate::CubeKind::ZXZ),
2514        ));
2515        graph.add_block(port.clone());
2516        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::ZPLUS));
2517
2518        let connected = block_as_gltf_data(&port, &graph);
2519        let mut isolated_graph = BlockGraph::new();
2520        isolated_graph.add_block(port.clone());
2521        let isolated = block_as_gltf_data(&port, &isolated_graph);
2522
2523        assert_eq!(
2524            isolated.triangles.values().map(Vec::len).sum::<usize>()
2525                - connected.triangles.values().map(Vec::len).sum::<usize>(),
2526            2
2527        );
2528        assert_eq!(
2529            connected.lines.values().map(Vec::len).sum::<usize>(),
2530            isolated.lines.values().map(Vec::len).sum::<usize>()
2531        );
2532        assert!(connected.triangles.values().flatten().all(|[a, b, c]| {
2533            let normal = (*b - *a).cross(*c - *a).normalize_or_zero();
2534            normal.dot(Direction::ZMINUS.to_vec3()) < 0.999
2535        }));
2536    }
2537
2538    #[test]
2539    fn custom_port_color_keeps_port_alpha() {
2540        let port = Block::new(IVec3::ZERO, BlockKind::Port)
2541            .with_port_color([0xeb, 0x40, 0x34])
2542            .expect("Port accepts a color");
2543        let data = block_as_gltf_data(&port, &BlockGraph::new());
2544
2545        assert!(
2546            data.triangles
2547                .contains_key(&RGBA::from_hex(0xeb4034, RGBA::PORT_GRAY.a))
2548        );
2549    }
2550
2551    fn point_in_triangle(point: Vec3, triangle: [Vec3; 3]) -> bool {
2552        let [a, b, c] = triangle;
2553        let normal = (b - a).cross(c - a);
2554        if normal.length_squared() < EPS {
2555            return false;
2556        }
2557        if (point - a).dot(normal).abs() > EPS {
2558            return false;
2559        }
2560
2561        let ab = b - a;
2562        let ac = c - a;
2563        let offset = point - a;
2564        let dot_ab_ab = ab.dot(ab);
2565        let dot_ab_ac = ab.dot(ac);
2566        let dot_ab_offset = ab.dot(offset);
2567        let dot_ac_ac = ac.dot(ac);
2568        let dot_ac_offset = ac.dot(offset);
2569        let denom = dot_ab_ab * dot_ac_ac - dot_ab_ac * dot_ab_ac;
2570        if denom.abs() < EPS {
2571            return false;
2572        }
2573        let inv_denom = 1.0 / denom;
2574        let u = (dot_ac_ac * dot_ab_offset - dot_ab_ac * dot_ac_offset) * inv_denom;
2575        let v = (dot_ab_ab * dot_ac_offset - dot_ab_ac * dot_ab_offset) * inv_denom;
2576
2577        u >= -EPS && v >= -EPS && u + v <= 1.0 + EPS
2578    }
2579
2580    fn colors_at(data: &GltfData, point: Vec3) -> Vec<RGBA> {
2581        let mut colors: Vec<_> = data
2582            .triangles
2583            .iter()
2584            .filter_map(|(color, triangles)| {
2585                triangles
2586                    .iter()
2587                    .any(|&triangle| point_in_triangle(point, triangle))
2588                    .then_some(*color)
2589            })
2590            .collect();
2591        colors.sort();
2592        colors.dedup();
2593        colors
2594    }
2595
2596    fn assert_patch_rotation_color_at(data: &GltfData, point: Vec3, expected: RGBA) {
2597        let colors = colors_at(data, point);
2598        assert_eq!(colors, vec![expected], "unexpected color at {point:?}");
2599    }
2600
2601    fn assert_patch_rotation_color_at_named(
2602        data: &GltfData,
2603        point: Vec3,
2604        expected: RGBA,
2605        name: &str,
2606    ) {
2607        let colors = colors_at(data, point);
2608        assert_eq!(
2609            colors,
2610            vec![expected],
2611            "unexpected color for {name} at {point:?}"
2612        );
2613    }
2614
2615    fn assert_patch_rotation_empty_at_named(data: &GltfData, point: Vec3, name: &str) {
2616        let colors = colors_at(data, point);
2617        assert!(
2618            colors.is_empty(),
2619            "expected no surface for {name} at {point:?}, got {colors:?}"
2620        );
2621    }
2622
2623    fn triangle_bounds(data: &GltfData) -> (Vec3, Vec3) {
2624        let mut vertices = data.triangles.values().flatten().flatten().copied();
2625        let first = vertices.next().expect("mesh should contain triangles");
2626        vertices.fold((first, first), |(min, max), vertex| {
2627            (min.min(vertex), max.max(vertex))
2628        })
2629    }
2630
2631    fn assert_patch_rotation_time_colors(
2632        data: &GltfData,
2633        min: Vec3,
2634        max: Vec3,
2635        z_min_color: RGBA,
2636        z_max_color: RGBA,
2637    ) {
2638        assert_patch_rotation_color_at(
2639            data,
2640            vec3(lerp(min.x, max.x, 0.37), lerp(min.y, max.y, 0.23), min.z),
2641            z_min_color,
2642        );
2643        assert_patch_rotation_color_at(
2644            data,
2645            vec3(lerp(min.x, max.x, 0.29), lerp(min.y, max.y, 0.61), max.z),
2646            z_max_color,
2647        );
2648    }
2649
2650    fn assert_patch_rotation_colors(basis: Basis, movement: IVec2) {
2651        let pipe_length = 1.0;
2652        let stride = pipe_length + 1.0;
2653        let kind = PatchRotationKind::new(basis, movement).unwrap();
2654        let data = patch_rotation_as_gltf_data(kind, pipe_length);
2655        let (min, max) = patch_rotation_bounds(IVec3::new(movement.x, movement.y, 1), stride);
2656        let colors = PatchRotationColors::new(kind);
2657        let measurement_basis = kind.x_axis_boundary_basis();
2658
2659        assert_patch_rotation_time_colors(&data, min, max, colors.start_time, colors.end_time);
2660        assert_eq!(colors.start_time, RGBA::from(measurement_basis.flip()));
2661        assert_eq!(colors.end_time, RGBA::from(measurement_basis));
2662
2663        let local = |x: f32, y: f32, z: f32| patch_rotation_local_to_global(kind, vec3(x, y, z));
2664        let mixed_x = match colors.mixed_side {
2665            PatchRotationMixedSide::Left => -0.5,
2666            PatchRotationMixedSide::Right => 0.5,
2667        };
2668        let extended_x = -mixed_x;
2669        let mid_y = stride * 0.5;
2670        let low_z = 0.25;
2671        let high_z = stride - 0.25;
2672
2673        assert_patch_rotation_color_at(&data, local(0.0, -0.5, low_z), colors.after);
2674        assert_patch_rotation_color_at(&data, local(0.0, stride + 0.5, low_z), colors.before);
2675        assert_patch_rotation_color_at(&data, local(mixed_x, mid_y * 0.5, low_z), colors.before);
2676        assert_patch_rotation_color_at(
2677            &data,
2678            local(mixed_x, mid_y + mid_y * 0.5, low_z),
2679            colors.after,
2680        );
2681        assert_patch_rotation_color_at(&data, local(extended_x, mid_y * 0.5, low_z), colors.before);
2682        assert_patch_rotation_color_at(&data, local(extended_x, mid_y * 0.5, high_z), colors.after);
2683    }
2684
2685    #[test]
2686    fn boundary_blocks_render_with_overflowing_neighbor_directions_absent() {
2687        for position in [IVec3::splat(i32::MIN), IVec3::splat(i32::MAX)] {
2688            let block = Block::new(position, BlockKind::Cube(crate::CubeKind::ZXZ));
2689            let mut graph = BlockGraph::new();
2690            graph.try_add_block(block.clone()).unwrap();
2691
2692            let data = block_as_gltf_data(&block, &graph);
2693            assert!(!data.triangles.is_empty());
2694        }
2695
2696        let scaled = Block::new(
2697            IVec3::new(i32::MAX, 0, 0),
2698            BlockKind::Cube(crate::CubeKind::ZXZ),
2699        )
2700        .with_height("2d".parse().expect("valid height"))
2701        .unwrap();
2702        let mut scaled_graph = BlockGraph::new();
2703        scaled_graph.try_add_block(scaled.clone()).unwrap();
2704        assert!(
2705            !block_as_gltf_data(&scaled, &scaled_graph)
2706                .triangles
2707                .is_empty()
2708        );
2709
2710        assert!(pipe_for_node_direction(&scaled_graph, &scaled, Direction::XPLUS).is_none());
2711    }
2712
2713    #[test]
2714    fn boundary_y_block_uses_its_representable_temporal_neighbor() {
2715        let below = IVec3::new(0, 0, i32::MAX - 1);
2716        let position = IVec3::new(0, 0, i32::MAX);
2717        let block = Block::new(position, BlockKind::Y);
2718        let mut graph = BlockGraph::new();
2719        graph
2720            .try_add_block(Block::new(below, BlockKind::Port))
2721            .unwrap();
2722        graph.try_add_block(block.clone()).unwrap();
2723        graph
2724            .try_add_pipe(Pipe::new(below, Direction::ZPLUS))
2725            .unwrap();
2726
2727        let data = block_as_gltf_data(&block, &graph);
2728        let (min, max) = triangle_bounds(&data);
2729        assert!((min.z + 0.5).abs() < EPS, "unexpected min z: {min:?}");
2730        assert!(max.z.abs() < EPS, "unexpected max z: {max:?}");
2731    }
2732
2733    #[test]
2734    fn measurement_block_is_a_past_facing_basis_colored_cap() {
2735        for basis in [Basis::X, Basis::Z] {
2736            let block = Block::new(IVec3::ZERO, BlockKind::Measurement(basis));
2737            let mut graph = BlockGraph::new();
2738            graph.add_block(block.clone());
2739
2740            let data = block_as_gltf_data(&block, &graph);
2741            let (min, max) = triangle_bounds(&data);
2742            assert!((min.z + 0.5).abs() < EPS, "unexpected min z: {min:?}");
2743            assert!(max.z.abs() < EPS, "unexpected max z: {max:?}");
2744            assert!(data.triangles.contains_key(&RGBA::from(basis)));
2745        }
2746    }
2747
2748    #[test]
2749    fn patch_rotation_visualization_colors_all_movements() {
2750        for basis in [Basis::X, Basis::Z] {
2751            for movement in [ivec2(1, 0), ivec2(-1, 0), ivec2(0, 1), ivec2(0, -1)] {
2752                assert_patch_rotation_colors(basis, movement);
2753            }
2754        }
2755    }
2756
2757    #[test]
2758    fn patch_rotation_visualization_mixed_side_matches_construction_table() {
2759        use PatchRotationMixedSide::{Left, Right};
2760
2761        let cases = [
2762            (Basis::X, ivec2(-1, 0), Left),
2763            (Basis::X, ivec2(1, 0), Right),
2764            (Basis::X, ivec2(0, -1), Left),
2765            (Basis::X, ivec2(0, 1), Left),
2766            (Basis::Z, ivec2(-1, 0), Right),
2767            (Basis::Z, ivec2(1, 0), Left),
2768            (Basis::Z, ivec2(0, -1), Right),
2769            (Basis::Z, ivec2(0, 1), Right),
2770        ];
2771
2772        for (basis, movement, expected) in cases {
2773            let kind = PatchRotationKind::new(basis, movement).unwrap();
2774            assert_eq!(PatchRotationMixedSide::for_kind(kind), expected);
2775        }
2776    }
2777
2778    #[test]
2779    fn spatial_pipe_between_scaled_cubes_matches_cube_height() {
2780        let mut graph = BlockGraph::new();
2781        graph.add_block(
2782            Block::new(IVec3::ZERO, BlockKind::Cube(crate::CubeKind::ZXZ))
2783                .with_height("2d".parse().expect("valid height"))
2784                .expect("valid scaled cube"),
2785        );
2786        graph.add_block(
2787            Block::new(IVec3::X, BlockKind::Cube(crate::CubeKind::ZXZ))
2788                .with_height("2d".parse().expect("valid height"))
2789                .expect("valid scaled cube"),
2790        );
2791        let pipe = Pipe::new(IVec3::ZERO, Direction::XPLUS);
2792        graph.add_pipe(pipe.clone());
2793
2794        let data = pipe_between_positions_as_gltf_data(IVec3::ZERO, IVec3::X, &pipe, &graph, 1.0);
2795        let (min, max) = triangle_bounds(&data);
2796
2797        assert!((min.z + 0.5).abs() < EPS, "unexpected min z: {min:?}");
2798        assert!((max.z - 2.5).abs() < EPS, "unexpected max z: {max:?}");
2799    }
2800
2801    #[test]
2802    fn standalone_pipe_rendering_handles_distant_coordinate_extremes() {
2803        let u = Block::new(IVec3::splat(i32::MIN), BlockKind::Port);
2804        let v = Block::new(IVec3::splat(i32::MAX), BlockKind::Port);
2805        let pipe = Pipe::new(u.pos(), Direction::XPLUS);
2806        let graph = BlockGraph::new();
2807
2808        let positioned = pipe_between_positions_as_gltf_data(u.pos(), v.pos(), &pipe, &graph, 1.0);
2809        assert!(!positioned.triangles.is_empty());
2810
2811        let wrapped = pipe_as_gltf_data(&u, &v, &pipe, &graph, 1.0);
2812        assert!(!wrapped.triangles.is_empty());
2813    }
2814
2815    #[test]
2816    fn stabilizer_visualization_scales_cube_node_surface_height() {
2817        let mut graph = BlockGraph::new();
2818        graph.add_block(
2819            Block::new(IVec3::ZERO, BlockKind::Cube(crate::CubeKind::ZXZ))
2820                .with_height("2d".parse().expect("valid height"))
2821                .expect("valid scaled cube"),
2822        );
2823        let stabilizer = Stabilizer {
2824            paulis: PauliString::new(0),
2825            sign: false,
2826            port_stabilizer: Default::default(),
2827            interior_nodes: [(IVec3::ZERO, Pauli::Z)].into_iter().collect(),
2828            interior_edges: Default::default(),
2829        };
2830        let generator = StabilizerGenerator::new(stabilizer, StabilizerRowKind::Logical);
2831
2832        let data = stabilizer_as_gltf_data(&generator, &graph, 1.0)
2833            .expect("scaled cube stabilizer should render");
2834        let (min, max) = triangle_bounds(&data);
2835
2836        assert!((min.z + 0.5).abs() < EPS, "unexpected min z: {min:?}");
2837        assert!((max.z - 2.5).abs() < EPS, "unexpected max z: {max:?}");
2838    }
2839
2840    #[test]
2841    fn measurement_generator_label_is_derived_at_render_time() {
2842        let graph = GalleryItem::T
2843            .build()
2844            .materialize_root_graph()
2845            .expect("gallery flat projection");
2846        let stabilizers = graph.stabilizers().expect("T stabilizers");
2847        let generator = stabilizers
2848            .generators
2849            .iter()
2850            .find(|generator| generator.is_measurement())
2851            .expect("T has a measurement generator");
2852
2853        let data = stabilizer_as_gltf_data(generator, &graph, 2.0)
2854            .expect("measurement generator should render");
2855
2856        assert_eq!(data.texts.len(), 1);
2857        assert_eq!(data.texts[0].text, generator.measurement_name().unwrap());
2858    }
2859
2860    #[test]
2861    fn stabilizer_visualization_scales_spatial_pipe_between_scaled_cubes() {
2862        let mut graph = BlockGraph::new();
2863        graph.add_block(
2864            Block::new(IVec3::ZERO, BlockKind::Cube(crate::CubeKind::ZXZ))
2865                .with_height("2d".parse().expect("valid height"))
2866                .expect("valid scaled cube"),
2867        );
2868        graph.add_block(
2869            Block::new(IVec3::X, BlockKind::Cube(crate::CubeKind::ZXZ))
2870                .with_height("2d".parse().expect("valid height"))
2871                .expect("valid scaled cube"),
2872        );
2873        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::XPLUS));
2874        let stabilizer = Stabilizer {
2875            paulis: PauliString::new(0),
2876            sign: false,
2877            port_stabilizer: Default::default(),
2878            interior_nodes: Default::default(),
2879            interior_edges: [((IVec3::ZERO, IVec3::X), Pauli::Z)].into_iter().collect(),
2880        };
2881        let generator = StabilizerGenerator::new(stabilizer, StabilizerRowKind::Logical);
2882
2883        let data = stabilizer_as_gltf_data(&generator, &graph, 1.0)
2884            .expect("scaled cube pipe stabilizer should render");
2885        let (min, max) = triangle_bounds(&data);
2886
2887        assert!((min.z + 0.5).abs() < EPS, "unexpected min z: {min:?}");
2888        assert!((max.z - 2.5).abs() < EPS, "unexpected max z: {max:?}");
2889    }
2890
2891    fn assert_patch_rotation_stabilizer_support(
2892        basis: Basis,
2893        movement: IVec2,
2894        pauli: Pauli,
2895        expected_shape: Pauli,
2896    ) {
2897        let kind = PatchRotationKind::new(basis, movement).unwrap();
2898        let stride = 3.0;
2899        let mut data = GltfData::default();
2900        add_patch_rotation_node_surface(IVec3::ZERO, kind, pauli, stride, &mut data);
2901
2902        let color = correlation_surface_color(pauli);
2903        let colors = PatchRotationColors::new(kind);
2904        let (mixed_x0, mixed_x1) = match colors.mixed_side {
2905            PatchRotationMixedSide::Left => (-0.5, 0.0),
2906            PatchRotationMixedSide::Right => (0.0, 0.5),
2907        };
2908        let local = |x: f32, y: f32, z: f32| patch_rotation_local_to_global(kind, vec3(x, y, z));
2909
2910        match expected_shape {
2911            Pauli::Z => {
2912                assert_patch_rotation_color_at_named(
2913                    &data,
2914                    local(f32::midpoint(mixed_x0, mixed_x1), 1.5, 0.5),
2915                    color,
2916                    &format!("{basis:?} {movement:?} {pauli:?} side leg"),
2917                );
2918                assert_patch_rotation_color_at_named(
2919                    &data,
2920                    local(0.0, 1.0, 0.5),
2921                    color,
2922                    &format!("{basis:?} {movement:?} {pauli:?} vertical leg"),
2923                );
2924                assert_patch_rotation_color_at_named(
2925                    &data,
2926                    local(0.0, 3.0, 2.5),
2927                    color,
2928                    &format!("{basis:?} {movement:?} {pauli:?} final line"),
2929                );
2930            }
2931            Pauli::X => {
2932                assert_patch_rotation_color_at_named(
2933                    &data,
2934                    local(0.0, 0.0, 0.0),
2935                    color,
2936                    &format!("{basis:?} {movement:?} {pauli:?} initial line"),
2937                );
2938                assert_patch_rotation_color_at_named(
2939                    &data,
2940                    local(f32::midpoint(mixed_x0, mixed_x1), 1.5, 2.0),
2941                    color,
2942                    &format!("{basis:?} {movement:?} {pauli:?} side leg"),
2943                );
2944                assert_patch_rotation_color_at_named(
2945                    &data,
2946                    local(0.0, 2.0, 2.0),
2947                    color,
2948                    &format!("{basis:?} {movement:?} {pauli:?} vertical leg"),
2949                );
2950            }
2951            Pauli::I | Pauli::Y => unreachable!("patch rotation stabilizer shapes are X/Z"),
2952        }
2953    }
2954
2955    #[test]
2956    fn patch_rotation_stabilizer_visualization_matches_variant_table() {
2957        for basis in [Basis::X, Basis::Z] {
2958            // X-axis motion exchanges the construction's two observable shapes;
2959            // Y-axis motion keeps the basis-labelled shape.
2960            for (movement, shape) in [
2961                (ivec2(1, 0), Pauli::Z),
2962                (ivec2(-1, 0), Pauli::Z),
2963                (ivec2(0, 1), Pauli::X),
2964                (ivec2(0, -1), Pauli::X),
2965            ] {
2966                assert_patch_rotation_stabilizer_support(
2967                    basis,
2968                    movement,
2969                    Pauli::from(basis),
2970                    shape,
2971                );
2972                assert_patch_rotation_stabilizer_support(
2973                    basis,
2974                    movement,
2975                    Pauli::from(basis.flip()),
2976                    shape.flip(),
2977                );
2978            }
2979        }
2980    }
2981
2982    #[test]
2983    fn x_plus_y_stabilizer_visualization_matches_observable_evolution() {
2984        let kind = PatchRotationKind::new(Basis::X, ivec2(0, 1)).unwrap();
2985        let stride = 3.0;
2986        let mut z_data = GltfData::default();
2987        let mut x_data = GltfData::default();
2988        add_patch_rotation_node_surface(IVec3::ZERO, kind, Pauli::Z, stride, &mut z_data);
2989        add_patch_rotation_node_surface(IVec3::ZERO, kind, Pauli::X, stride, &mut x_data);
2990
2991        assert_patch_rotation_color_at_named(
2992            &z_data,
2993            vec3(0.0, 1.0, 0.5),
2994            correlation_surface_color(Pauli::Z),
2995            "x_plus_y Z input L vertical leg",
2996        );
2997        assert_patch_rotation_empty_at_named(
2998            &z_data,
2999            vec3(0.0, 2.0, 0.5),
3000            "x_plus_y Z excluded lower input leg",
3001        );
3002        assert_patch_rotation_color_at_named(
3003            &z_data,
3004            vec3(0.25, 1.0, 2.5),
3005            correlation_surface_color(Pauli::Z),
3006            "x_plus_y Z 3/4 sheet mixed half",
3007        );
3008        assert_patch_rotation_empty_at_named(
3009            &z_data,
3010            vec3(-0.25, 1.0, 2.5),
3011            "x_plus_y Z 3/4 sheet excluded half",
3012        );
3013        assert_patch_rotation_color_at_named(
3014            &z_data,
3015            vec3(0.0, 3.0, 3.0),
3016            correlation_surface_color(Pauli::Z),
3017            "x_plus_y Z output midpoint line",
3018        );
3019        assert_patch_rotation_empty_at_named(
3020            &z_data,
3021            vec3(0.0, 2.75, 3.0),
3022            "x_plus_y Z excluded output line",
3023        );
3024
3025        assert_patch_rotation_color_at_named(
3026            &x_data,
3027            vec3(0.0, 0.0, 0.0),
3028            correlation_surface_color(Pauli::X),
3029            "x_plus_y X shifted input line",
3030        );
3031        assert_patch_rotation_empty_at_named(
3032            &x_data,
3033            vec3(0.0, 0.5, 0.0),
3034            "x_plus_y X excluded input line",
3035        );
3036        assert_patch_rotation_color_at_named(
3037            &x_data,
3038            vec3(0.0, 0.5, 0.5),
3039            correlation_surface_color(Pauli::X),
3040            "x_plus_y X 1/4 sheet input band",
3041        );
3042        assert_patch_rotation_empty_at_named(
3043            &x_data,
3044            vec3(-0.25, 2.0, 0.5),
3045            "x_plus_y X 1/4 sheet excluded half",
3046        );
3047        assert_patch_rotation_color_at_named(
3048            &x_data,
3049            vec3(-0.25, 1.5, 2.0),
3050            correlation_surface_color(Pauli::X),
3051            "x_plus_y X final L horizontal leg",
3052        );
3053        assert_patch_rotation_color_at_named(
3054            &x_data,
3055            vec3(0.0, 2.0, 2.0),
3056            correlation_surface_color(Pauli::X),
3057            "x_plus_y X final L vertical leg",
3058        );
3059    }
3060
3061    #[test]
3062    fn stabilizer_visualization_draws_patch_rotation_node_support() {
3063        let kind = PatchRotationKind::new(Basis::X, ivec2(1, 0)).unwrap();
3064        let mut graph = BlockGraph::new();
3065        graph.add_block(Block::new(IVec3::ZERO, BlockKind::PatchRotation(kind)));
3066
3067        let interior_nodes = [(IVec3::ZERO, Pauli::Z)].into_iter().collect();
3068        let stabilizer = Stabilizer {
3069            paulis: PauliString::new(0),
3070            sign: false,
3071            port_stabilizer: Default::default(),
3072            interior_nodes,
3073            interior_edges: Default::default(),
3074        };
3075        let generator = StabilizerGenerator::new(stabilizer, StabilizerRowKind::Logical);
3076
3077        let data = stabilizer_as_gltf_data(&generator, &graph, 3.0)
3078            .expect("patch rotation stabilizer should render");
3079        assert_patch_rotation_color_at(&data, vec3(2.0, 0.0, 2.0), RGBA::Z_PURE_BLUE);
3080    }
3081
3082    #[test]
3083    fn stabilizer_visualization_handles_walking_end_pipe() {
3084        let mut graph = BlockGraph::new();
3085        graph.add_block(Block::new(
3086            IVec3::new(0, 0, 0),
3087            BlockKind::Walking(
3088                WalkingKind::new(WalkingBoundaryKind::ZXZ, IVec2::new(1, 1)).unwrap(),
3089            ),
3090        ));
3091        graph.add_block(Block::new(IVec3::new(1, 1, 2), BlockKind::Port));
3092        graph.add_block(Block::new(IVec3::new(0, 0, -1), BlockKind::Port));
3093        graph.add_pipe(Pipe::new(IVec3::new(1, 1, 1), Direction::ZPLUS));
3094        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 0), Direction::ZMINUS));
3095
3096        let stabilizers = graph.stabilizers().expect("walking graph stabilizers");
3097        assert!(!stabilizers.generators.is_empty());
3098        for generator in &stabilizers.generators {
3099            let mesh = stabilizer_as_gltf_data(generator, &graph, 2.0)
3100                .expect("walking stabilizer should convert to mesh data");
3101            assert!(!mesh.triangles.is_empty());
3102        }
3103    }
3104
3105    #[test]
3106    fn stabilizer_visualization_draws_walking_surface_from_walking_node_support() {
3107        let graph = parse_blog_to_graph(
3108            "BLOG 1.0\n\n  0: XZZ [0, 0, 0]\n  1: walk XZZ [0, 0, 1] -> [1, 1, 2]\n  [0, 0, 0] -> +Z\n",
3109        )
3110        .expect("walking start-pipe graph should parse");
3111        let stabilizers = graph.stabilizers().expect("walking graph stabilizers");
3112        let stride = 3.0;
3113        let expected_top = IVec3::new(1, 1, 2).as_vec3() * stride + Vec3::Z * 0.5;
3114
3115        assert!(stabilizers.generators.iter().any(|generator| {
3116            if !generator
3117                .stabilizer
3118                .interior_nodes
3119                .contains_key(&IVec3::new(0, 0, 1))
3120            {
3121                return false;
3122            }
3123            let mesh = stabilizer_as_gltf_data(generator, &graph, 2.0)
3124                .expect("walking stabilizer should convert to mesh data");
3125            mesh.triangles.values().flatten().flatten().any(|vertex| {
3126                vertex.abs_diff_eq(expected_top + Vec3::X * 0.5, 1e-5)
3127                    || vertex.abs_diff_eq(expected_top - Vec3::X * 0.5, 1e-5)
3128                    || vertex.abs_diff_eq(expected_top + Vec3::Y * 0.5, 1e-5)
3129                    || vertex.abs_diff_eq(expected_top - Vec3::Y * 0.5, 1e-5)
3130            })
3131        }));
3132    }
3133}