Skip to main content

oxiphysics_io/
visualization_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Visualization output formats for physics simulations.
5//!
6//! Supports: ParaView state files (.pvsm), VisIt database, Blender Python export,
7//! Matplotlib JSON descriptor, D3.js data export, WebGL buffer export,
8//! glTF 2.0 physics annotations, VDB sparse volume, OpenEXR multi-channel,
9//! and Cinema database format.
10
11#![allow(dead_code)]
12#![allow(unused_imports)]
13#![allow(clippy::too_many_arguments)]
14
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17
18// ─────────────────────────────────────────────────────────────────────────────
19// Shared geometry types (no nalgebra — use [f64;3] arrays)
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// A 3D point using plain array storage.
23#[derive(Clone, Debug, PartialEq)]
24pub struct Point3 {
25    /// X coordinate.
26    pub x: f64,
27    /// Y coordinate.
28    pub y: f64,
29    /// Z coordinate.
30    pub z: f64,
31}
32
33impl Point3 {
34    /// Create a new [`Point3`].
35    pub fn new(x: f64, y: f64, z: f64) -> Self {
36        Self { x, y, z }
37    }
38
39    /// Convert to array.
40    pub fn to_array(&self) -> [f64; 3] {
41        [self.x, self.y, self.z]
42    }
43
44    /// Distance to another point.
45    pub fn dist(&self, other: &Point3) -> f64 {
46        let dx = self.x - other.x;
47        let dy = self.y - other.y;
48        let dz = self.z - other.z;
49        (dx * dx + dy * dy + dz * dz).sqrt()
50    }
51}
52
53/// A scalar field on a regular Cartesian grid.
54#[derive(Clone, Debug)]
55pub struct ScalarField3D {
56    /// Number of cells in x.
57    pub nx: usize,
58    /// Number of cells in y.
59    pub ny: usize,
60    /// Number of cells in z.
61    pub nz: usize,
62    /// Cell spacing.
63    pub dx: f64,
64    /// Origin.
65    pub origin: [f64; 3],
66    /// Flat (x-major) data array.
67    pub data: Vec<f64>,
68}
69
70impl ScalarField3D {
71    /// Create a zero-initialised [`ScalarField3D`].
72    pub fn new(nx: usize, ny: usize, nz: usize, dx: f64, origin: [f64; 3]) -> Self {
73        Self {
74            nx,
75            ny,
76            nz,
77            dx,
78            origin,
79            data: vec![0.0; nx * ny * nz],
80        }
81    }
82
83    /// Index into flat array.
84    #[inline]
85    pub fn idx(&self, ix: usize, iy: usize, iz: usize) -> usize {
86        ix * self.ny * self.nz + iy * self.nz + iz
87    }
88
89    /// Set a value.
90    pub fn set(&mut self, ix: usize, iy: usize, iz: usize, val: f64) {
91        let i = self.idx(ix, iy, iz);
92        self.data[i] = val;
93    }
94
95    /// Get a value.
96    pub fn get(&self, ix: usize, iy: usize, iz: usize) -> f64 {
97        self.data[self.idx(ix, iy, iz)]
98    }
99
100    /// Minimum value.
101    pub fn min_val(&self) -> f64 {
102        self.data.iter().cloned().fold(f64::INFINITY, f64::min)
103    }
104
105    /// Maximum value.
106    pub fn max_val(&self) -> f64 {
107        self.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
108    }
109}
110
111// ─────────────────────────────────────────────────────────────────────────────
112// ParaView State File (.pvsm) writer
113// ─────────────────────────────────────────────────────────────────────────────
114
115/// Configuration for a ParaView state file.
116#[derive(Clone, Debug)]
117pub struct ParaviewStateConfig {
118    /// Source VTU file path.
119    pub source_file: String,
120    /// Name of the scalar array to colour by.
121    pub colour_array: String,
122    /// Colour map preset name (e.g., "Rainbow Desaturated").
123    pub colormap: String,
124    /// Scalar range \[min, max\].
125    pub scalar_range: [f64; 2],
126    /// Camera position.
127    pub camera_pos: [f64; 3],
128    /// Camera focal point.
129    pub camera_focal: [f64; 3],
130    /// Camera view-up vector.
131    pub camera_up: [f64; 3],
132    /// Image width in pixels.
133    pub image_width: u32,
134    /// Image height in pixels.
135    pub image_height: u32,
136}
137
138impl ParaviewStateConfig {
139    /// Create a default [`ParaviewStateConfig`].
140    pub fn default_config(source_file: impl Into<String>) -> Self {
141        Self {
142            source_file: source_file.into(),
143            colour_array: "pressure".into(),
144            colormap: "Cool to Warm".into(),
145            scalar_range: [0.0, 1.0],
146            camera_pos: [0.0, 0.0, 5.0],
147            camera_focal: [0.0, 0.0, 0.0],
148            camera_up: [0.0, 1.0, 0.0],
149            image_width: 1920,
150            image_height: 1080,
151        }
152    }
153}
154
155/// Write a minimal ParaView state (.pvsm) XML file to a string.
156pub fn write_paraview_state(cfg: &ParaviewStateConfig) -> String {
157    let mut out = String::new();
158    let _ = writeln!(out, r#"<?xml version="1.0"?>"#);
159    let _ = writeln!(out, r#"<ParaView>"#);
160    let _ = writeln!(out, r#"  <ServerManagerState version="5.10.0">"#);
161    // Source
162    let _ = writeln!(
163        out,
164        r#"    <Proxy group="sources" type="XMLUnstructuredGridReader" id="1001">"#
165    );
166    let _ = writeln!(
167        out,
168        r#"      <Property name="FileName" number_of_elements="1">"#
169    );
170    let _ = writeln!(
171        out,
172        r#"        <Element index="0" value="{}"/>"#,
173        cfg.source_file
174    );
175    let _ = writeln!(out, r#"      </Property>"#);
176    let _ = writeln!(out, r#"    </Proxy>"#);
177    // Display
178    let _ = writeln!(
179        out,
180        r#"    <Proxy group="representations" type="GeometryRepresentation" id="2001">"#
181    );
182    let _ = writeln!(out, r#"      <Property name="ColorArrayName">"#);
183    let _ = writeln!(out, r#"        <Element index="0" value="POINTS"/>"#);
184    let _ = writeln!(
185        out,
186        r#"        <Element index="1" value="{}"/>"#,
187        cfg.colour_array
188    );
189    let _ = writeln!(out, r#"      </Property>"#);
190    let _ = writeln!(out, r#"      <Property name="LookupTable" value="3001"/>"#);
191    let _ = writeln!(out, r#"    </Proxy>"#);
192    // LUT
193    let _ = writeln!(
194        out,
195        r#"    <Proxy group="lookup_tables" type="PVLookupTable" id="3001">"#
196    );
197    let _ = writeln!(
198        out,
199        r#"      <Property name="ColorSpace"><Element index="0" value="HSV"/></Property>"#
200    );
201    let _ = writeln!(
202        out,
203        r#"      <Property name="ScalarRangeInitialized"><Element index="0" value="1"/></Property>"#
204    );
205    let _ = writeln!(
206        out,
207        r#"      <Property name="RGBPoints" number_of_elements="8">"#
208    );
209    let _ = writeln!(
210        out,
211        r#"        <Element index="0" value="{}"/><Element index="1" value="0.23137"/>"#,
212        cfg.scalar_range[0]
213    );
214    let _ = writeln!(
215        out,
216        r#"        <Element index="2" value="0.29803"/><Element index="3" value="0.75294"/>"#
217    );
218    let _ = writeln!(
219        out,
220        r#"        <Element index="4" value="{}"/><Element index="5" value="0.70588"/>"#,
221        cfg.scalar_range[1]
222    );
223    let _ = writeln!(
224        out,
225        r#"        <Element index="6" value="0.01568"/><Element index="7" value="0.14901"/>"#
226    );
227    let _ = writeln!(out, r#"      </Property>"#);
228    let _ = writeln!(out, r#"    </Proxy>"#);
229    // Camera
230    let _ = writeln!(
231        out,
232        r#"    <Proxy group="views" type="RenderView" id="4001">"#
233    );
234    let _ = writeln!(
235        out,
236        r#"      <Property name="CameraPosition" number_of_elements="3"><Element index="0" value="{}"/><Element index="1" value="{}"/><Element index="2" value="{}"/></Property>"#,
237        cfg.camera_pos[0], cfg.camera_pos[1], cfg.camera_pos[2]
238    );
239    let _ = writeln!(
240        out,
241        r#"      <Property name="CameraFocalPoint" number_of_elements="3"><Element index="0" value="{}"/><Element index="1" value="{}"/><Element index="2" value="{}"/></Property>"#,
242        cfg.camera_focal[0], cfg.camera_focal[1], cfg.camera_focal[2]
243    );
244    let _ = writeln!(
245        out,
246        r#"      <Property name="ViewSize" number_of_elements="2"><Element index="0" value="{}"/><Element index="1" value="{}"/></Property>"#,
247        cfg.image_width, cfg.image_height
248    );
249    let _ = writeln!(out, r#"    </Proxy>"#);
250    let _ = writeln!(out, r#"  </ServerManagerState>"#);
251    let _ = writeln!(out, r#"</ParaView>"#);
252    out
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// VisIt database format writer
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// A VisIt database descriptor for a time series.
260#[derive(Clone, Debug)]
261pub struct VisItDatabase {
262    /// Base name of the simulation files.
263    pub basename: String,
264    /// File extension (e.g., "vtk").
265    pub extension: String,
266    /// Time steps.
267    pub times: Vec<f64>,
268    /// Variable names present in each file.
269    pub variables: Vec<String>,
270    /// Spatial dimension (2 or 3).
271    pub ndim: u8,
272}
273
274impl VisItDatabase {
275    /// Create a new [`VisItDatabase`] descriptor.
276    pub fn new(basename: impl Into<String>, extension: impl Into<String>, ndim: u8) -> Self {
277        Self {
278            basename: basename.into(),
279            extension: extension.into(),
280            times: Vec::new(),
281            variables: Vec::new(),
282            ndim,
283        }
284    }
285
286    /// Add a time step.
287    pub fn add_time(&mut self, t: f64) {
288        self.times.push(t);
289    }
290
291    /// Add a variable name.
292    pub fn add_variable(&mut self, name: impl Into<String>) {
293        self.variables.push(name.into());
294    }
295
296    /// Write a .visit index file for VisIt.
297    pub fn write_visit_index(&self) -> String {
298        let mut out = String::new();
299        let _ = writeln!(out, "!NBLOCKS 1");
300        for (i, _t) in self.times.iter().enumerate() {
301            let _ = writeln!(out, "{}{:06}.{}", self.basename, i, self.extension);
302        }
303        out
304    }
305
306    /// Write a summary manifest (JSON-like) for the database.
307    pub fn write_manifest(&self) -> String {
308        let mut out = String::new();
309        let _ = writeln!(out, "{{");
310        let _ = writeln!(out, r#"  "basename": "{}","#, self.basename);
311        let _ = writeln!(out, r#"  "ndim": {},"#, self.ndim);
312        let _ = writeln!(out, r#"  "n_times": {},"#, self.times.len());
313        let _ = writeln!(out, r#"  "variables": ["#);
314        for (i, v) in self.variables.iter().enumerate() {
315            let comma = if i + 1 < self.variables.len() {
316                ","
317            } else {
318                ""
319            };
320            let _ = writeln!(out, r#"    "{}"{}"#, v, comma);
321        }
322        let _ = writeln!(out, r#"  ],"#);
323        let _ = writeln!(out, r#"  "times": ["#);
324        for (i, t) in self.times.iter().enumerate() {
325            let comma = if i + 1 < self.times.len() { "," } else { "" };
326            let _ = writeln!(out, "    {}{}", t, comma);
327        }
328        let _ = writeln!(out, r#"  ]"#);
329        let _ = writeln!(out, "}}");
330        out
331    }
332}
333
334// ─────────────────────────────────────────────────────────────────────────────
335// Blender Python export
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// Configuration for a Blender bpy export script.
339#[derive(Clone, Debug)]
340pub struct BlenderExportConfig {
341    /// Name of the Blender object.
342    pub object_name: String,
343    /// Output .blend file path.
344    pub output_path: String,
345    /// Whether to add a rigid-body physics modifier.
346    pub rigid_body: bool,
347    /// Whether to add subdivision surface.
348    pub subdivision: bool,
349    /// Subdivision level.
350    pub subdiv_level: u32,
351    /// Background colour (RGBA).
352    pub bg_colour: [f64; 4],
353    /// Emission strength for volume shaders.
354    pub emission_strength: f64,
355}
356
357impl BlenderExportConfig {
358    /// Create a default [`BlenderExportConfig`].
359    pub fn new(object_name: impl Into<String>, output_path: impl Into<String>) -> Self {
360        Self {
361            object_name: object_name.into(),
362            output_path: output_path.into(),
363            rigid_body: false,
364            subdivision: false,
365            subdiv_level: 2,
366            bg_colour: [0.05, 0.05, 0.05, 1.0],
367            emission_strength: 1.0,
368        }
369    }
370
371    /// Generate a Blender bpy Python script string.
372    pub fn generate_script(&self, mesh_file: &str) -> String {
373        let mut s = String::new();
374        let _ = writeln!(s, "import bpy");
375        let _ = writeln!(s, "import bmesh");
376        let _ = writeln!(s);
377        let _ = writeln!(s, "# Clear existing objects");
378        let _ = writeln!(s, "bpy.ops.object.select_all(action='SELECT')");
379        let _ = writeln!(s, "bpy.ops.object.delete()");
380        let _ = writeln!(s);
381        let _ = writeln!(s, "# Import mesh");
382        let _ = writeln!(s, "bpy.ops.import_scene.obj(filepath='{}')", mesh_file);
383        let _ = writeln!(s, "obj = bpy.context.selected_objects[0]");
384        let _ = writeln!(s, "obj.name = '{}'", self.object_name);
385        if self.rigid_body {
386            let _ = writeln!(s, "bpy.ops.rigidbody.object_add()");
387            let _ = writeln!(s, "obj.rigid_body.type = 'ACTIVE'");
388        }
389        if self.subdivision {
390            let _ = writeln!(s, "mod = obj.modifiers.new(name='Subdiv', type='SUBSURF')");
391            let _ = writeln!(s, "mod.levels = {}", self.subdiv_level);
392            let _ = writeln!(s, "mod.render_levels = {}", self.subdiv_level);
393        }
394        let _ = writeln!(s, "# World background");
395        let _ = writeln!(
396            s,
397            "bpy.context.scene.world.node_tree.nodes['Background'].inputs[0].default_value = ({}, {}, {}, {})",
398            self.bg_colour[0], self.bg_colour[1], self.bg_colour[2], self.bg_colour[3]
399        );
400        let _ = writeln!(s, "# Save blend file");
401        let _ = writeln!(
402            s,
403            "bpy.ops.wm.save_as_mainfile(filepath='{}')",
404            self.output_path
405        );
406        s
407    }
408
409    /// Generate a Python script for volume rendering.
410    pub fn generate_volume_script(&self, vdb_file: &str) -> String {
411        let mut s = String::new();
412        let _ = writeln!(s, "import bpy");
413        let _ = writeln!(s, "bpy.ops.object.volume_import(filepath='{}')", vdb_file);
414        let _ = writeln!(s, "vol = bpy.context.active_object");
415        let _ = writeln!(s, "mat = bpy.data.materials.new(name='VolMat')");
416        let _ = writeln!(s, "mat.use_nodes = True");
417        let _ = writeln!(s, "nodes = mat.node_tree.nodes");
418        let _ = writeln!(s, "nodes.clear()");
419        let _ = writeln!(s, "emission = nodes.new('ShaderNodeEmission')");
420        let _ = writeln!(
421            s,
422            "emission.inputs['Strength'].default_value = {}",
423            self.emission_strength
424        );
425        let _ = writeln!(s, "vol.data.materials.append(mat)");
426        s
427    }
428}
429
430// ─────────────────────────────────────────────────────────────────────────────
431// Matplotlib JSON descriptor
432// ─────────────────────────────────────────────────────────────────────────────
433
434/// A descriptor for a Matplotlib figure, exportable as JSON.
435#[derive(Clone, Debug)]
436pub struct MatplotlibFigure {
437    /// Figure title.
438    pub title: String,
439    /// X-axis label.
440    pub xlabel: String,
441    /// Y-axis label.
442    pub ylabel: String,
443    /// Figure width in inches.
444    pub width: f64,
445    /// Figure height in inches.
446    pub height: f64,
447    /// Data series.
448    pub series: Vec<DataSeries>,
449    /// Whether to show grid.
450    pub show_grid: bool,
451    /// Legend location.
452    pub legend_loc: String,
453    /// DPI.
454    pub dpi: u32,
455}
456
457/// A single data series for a Matplotlib plot.
458#[derive(Clone, Debug)]
459pub struct DataSeries {
460    /// Series label.
461    pub label: String,
462    /// X data.
463    pub x: Vec<f64>,
464    /// Y data.
465    pub y: Vec<f64>,
466    /// Line style (e.g., "-", "--", "o").
467    pub linestyle: String,
468    /// Colour string.
469    pub colour: String,
470    /// Line width.
471    pub linewidth: f64,
472}
473
474impl DataSeries {
475    /// Create a new [`DataSeries`].
476    pub fn new(label: impl Into<String>, x: Vec<f64>, y: Vec<f64>) -> Self {
477        Self {
478            label: label.into(),
479            x,
480            y,
481            linestyle: "-".into(),
482            colour: "blue".into(),
483            linewidth: 1.5,
484        }
485    }
486}
487
488impl MatplotlibFigure {
489    /// Create a new [`MatplotlibFigure`].
490    pub fn new(
491        title: impl Into<String>,
492        xlabel: impl Into<String>,
493        ylabel: impl Into<String>,
494    ) -> Self {
495        Self {
496            title: title.into(),
497            xlabel: xlabel.into(),
498            ylabel: ylabel.into(),
499            width: 8.0,
500            height: 6.0,
501            series: Vec::new(),
502            show_grid: true,
503            legend_loc: "best".into(),
504            dpi: 150,
505        }
506    }
507
508    /// Add a data series.
509    pub fn add_series(&mut self, s: DataSeries) {
510        self.series.push(s);
511    }
512
513    /// Export as a JSON descriptor string.
514    pub fn to_json(&self) -> String {
515        let mut j = String::new();
516        let _ = writeln!(j, "{{");
517        let _ = writeln!(j, r#"  "title": "{}","#, self.title);
518        let _ = writeln!(j, r#"  "xlabel": "{}","#, self.xlabel);
519        let _ = writeln!(j, r#"  "ylabel": "{}","#, self.ylabel);
520        let _ = writeln!(j, r#"  "figsize": [{}, {}],"#, self.width, self.height);
521        let _ = writeln!(j, r#"  "dpi": {},"#, self.dpi);
522        let _ = writeln!(j, r#"  "grid": {},"#, self.show_grid);
523        let _ = writeln!(j, r#"  "legend_loc": "{}","#, self.legend_loc);
524        let _ = writeln!(j, r#"  "series": ["#);
525        for (i, s) in self.series.iter().enumerate() {
526            let comma = if i + 1 < self.series.len() { "," } else { "" };
527            let _ = writeln!(j, "    {{");
528            let _ = writeln!(j, r#"      "label": "{}","#, s.label);
529            let _ = writeln!(j, r#"      "linestyle": "{}","#, s.linestyle);
530            let _ = writeln!(j, r#"      "color": "{}","#, s.colour);
531            let _ = writeln!(j, r#"      "linewidth": {},"#, s.linewidth);
532            let x_str: Vec<String> = s.x.iter().map(|v| v.to_string()).collect();
533            let y_str: Vec<String> = s.y.iter().map(|v| v.to_string()).collect();
534            let _ = writeln!(j, r#"      "x": [{}],"#, x_str.join(", "));
535            let _ = writeln!(j, r#"      "y": [{}]"#, y_str.join(", "));
536            let _ = writeln!(j, "    }}{}", comma);
537        }
538        let _ = writeln!(j, "  ]");
539        let _ = writeln!(j, "}}");
540        j
541    }
542
543    /// Generate a Python script to reproduce this figure.
544    pub fn to_python_script(&self) -> String {
545        let mut s = String::new();
546        let _ = writeln!(s, "import matplotlib.pyplot as plt");
547        let _ = writeln!(s, "import numpy as np");
548        let _ = writeln!(
549            s,
550            "fig, ax = plt.subplots(figsize=({}, {}))",
551            self.width, self.height
552        );
553        for series in &self.series {
554            let x_vals: Vec<String> = series.x.iter().map(|v| v.to_string()).collect();
555            let y_vals: Vec<String> = series.y.iter().map(|v| v.to_string()).collect();
556            let _ = writeln!(
557                s,
558                "ax.plot([{}], [{}], '{}', color='{}', linewidth={}, label='{}')",
559                x_vals.join(", "),
560                y_vals.join(", "),
561                series.linestyle,
562                series.colour,
563                series.linewidth,
564                series.label
565            );
566        }
567        let _ = writeln!(s, "ax.set_xlabel('{}')", self.xlabel);
568        let _ = writeln!(s, "ax.set_ylabel('{}')", self.ylabel);
569        let _ = writeln!(s, "ax.set_title('{}')", self.title);
570        if self.show_grid {
571            let _ = writeln!(s, "ax.grid(True)");
572        }
573        let _ = writeln!(s, "ax.legend(loc='{}')", self.legend_loc);
574        let _ = writeln!(s, "plt.tight_layout()");
575        let _ = writeln!(s, "plt.savefig('figure.png', dpi={})", self.dpi);
576        s
577    }
578}
579
580// ─────────────────────────────────────────────────────────────────────────────
581// D3.js data export
582// ─────────────────────────────────────────────────────────────────────────────
583
584/// A node in a force-directed graph.
585#[derive(Clone, Debug)]
586pub struct D3Node {
587    /// Node ID.
588    pub id: String,
589    /// Node group (for colouring).
590    pub group: u32,
591    /// Optional radius.
592    pub radius: f64,
593    /// Optional label.
594    pub label: String,
595}
596
597/// An edge in a force-directed graph.
598#[derive(Clone, Debug)]
599pub struct D3Edge {
600    /// Source node ID.
601    pub source: String,
602    /// Target node ID.
603    pub target: String,
604    /// Edge value/weight.
605    pub value: f64,
606}
607
608/// D3.js force-directed graph export.
609#[derive(Clone, Debug, Default)]
610pub struct D3ForceGraph {
611    /// Graph nodes.
612    pub nodes: Vec<D3Node>,
613    /// Graph edges.
614    pub edges: Vec<D3Edge>,
615}
616
617impl D3ForceGraph {
618    /// Create a new [`D3ForceGraph`].
619    pub fn new() -> Self {
620        Self::default()
621    }
622
623    /// Add a node.
624    pub fn add_node(&mut self, id: impl Into<String>, group: u32, radius: f64) {
625        self.nodes.push(D3Node {
626            id: id.into(),
627            group,
628            radius,
629            label: String::new(),
630        });
631    }
632
633    /// Add an edge.
634    pub fn add_edge(&mut self, source: impl Into<String>, target: impl Into<String>, value: f64) {
635        self.edges.push(D3Edge {
636            source: source.into(),
637            target: target.into(),
638            value,
639        });
640    }
641
642    /// Export to D3-compatible JSON.
643    pub fn to_json(&self) -> String {
644        let mut j = String::new();
645        let _ = writeln!(j, "{{");
646        let _ = writeln!(j, r#"  "nodes": ["#);
647        for (i, n) in self.nodes.iter().enumerate() {
648            let comma = if i + 1 < self.nodes.len() { "," } else { "" };
649            let _ = writeln!(
650                j,
651                r#"    {{"id": "{}", "group": {}, "radius": {}}}{}"#,
652                n.id, n.group, n.radius, comma
653            );
654        }
655        let _ = writeln!(j, r#"  ],"#);
656        let _ = writeln!(j, r#"  "links": ["#);
657        for (i, e) in self.edges.iter().enumerate() {
658            let comma = if i + 1 < self.edges.len() { "," } else { "" };
659            let _ = writeln!(
660                j,
661                r#"    {{"source": "{}", "target": "{}", "value": {}}}{}"#,
662                e.source, e.target, e.value, comma
663            );
664        }
665        let _ = writeln!(j, r#"  ]"#);
666        let _ = writeln!(j, "}}");
667        j
668    }
669}
670
671/// Contour data for D3 contour plots.
672#[derive(Clone, Debug)]
673pub struct D3ContourData {
674    /// Grid width.
675    pub width: usize,
676    /// Grid height.
677    pub height: usize,
678    /// Flat row-major data.
679    pub values: Vec<f64>,
680    /// Contour thresholds.
681    pub thresholds: Vec<f64>,
682    /// X extent \[xmin, xmax\].
683    pub x_extent: [f64; 2],
684    /// Y extent \[ymin, ymax\].
685    pub y_extent: [f64; 2],
686}
687
688impl D3ContourData {
689    /// Create a new [`D3ContourData`] instance.
690    pub fn new(width: usize, height: usize, x_extent: [f64; 2], y_extent: [f64; 2]) -> Self {
691        Self {
692            width,
693            height,
694            values: vec![0.0; width * height],
695            thresholds: Vec::new(),
696            x_extent,
697            y_extent,
698        }
699    }
700
701    /// Set value at (ix, iy).
702    pub fn set(&mut self, ix: usize, iy: usize, val: f64) {
703        self.values[iy * self.width + ix] = val;
704    }
705
706    /// Add a contour threshold.
707    pub fn add_threshold(&mut self, t: f64) {
708        self.thresholds.push(t);
709    }
710
711    /// Auto-generate *n* evenly-spaced thresholds between min and max.
712    pub fn auto_thresholds(&mut self, n: usize) {
713        let vmin = self.values.iter().cloned().fold(f64::INFINITY, f64::min);
714        let vmax = self
715            .values
716            .iter()
717            .cloned()
718            .fold(f64::NEG_INFINITY, f64::max);
719        self.thresholds = (0..n)
720            .map(|i| vmin + (vmax - vmin) * i as f64 / (n - 1).max(1) as f64)
721            .collect();
722    }
723
724    /// Export to D3 contour-compatible JSON.
725    pub fn to_json(&self) -> String {
726        let mut j = String::new();
727        let _ = writeln!(j, "{{");
728        let _ = writeln!(j, r#"  "width": {},"#, self.width);
729        let _ = writeln!(j, r#"  "height": {},"#, self.height);
730        let _ = writeln!(
731            j,
732            r#"  "x_extent": [{}, {}],"#,
733            self.x_extent[0], self.x_extent[1]
734        );
735        let _ = writeln!(
736            j,
737            r#"  "y_extent": [{}, {}],"#,
738            self.y_extent[0], self.y_extent[1]
739        );
740        let thresh_str: Vec<String> = self.thresholds.iter().map(|v| v.to_string()).collect();
741        let _ = writeln!(j, r#"  "thresholds": [{}],"#, thresh_str.join(", "));
742        let val_str: Vec<String> = self.values.iter().map(|v| v.to_string()).collect();
743        let _ = writeln!(j, r#"  "values": [{}]"#, val_str.join(", "));
744        let _ = writeln!(j, "}}");
745        j
746    }
747}
748
749// ─────────────────────────────────────────────────────────────────────────────
750// WebGL buffer export
751// ─────────────────────────────────────────────────────────────────────────────
752
753/// WebGL-ready interleaved vertex buffer descriptor.
754#[derive(Clone, Debug)]
755pub struct WebGlBuffer {
756    /// Buffer name.
757    pub name: String,
758    /// Interleaved float data: \[x, y, z, nx, ny, nz, u, v, ...\].
759    pub data: Vec<f32>,
760    /// Index buffer.
761    pub indices: Vec<u32>,
762    /// Stride in bytes.
763    pub stride: u32,
764    /// Attribute offsets: (name → byte offset).
765    pub attributes: Vec<(String, u32, u32)>, // (name, offset, component_count)
766}
767
768impl WebGlBuffer {
769    /// Create a new [`WebGlBuffer`] from separate arrays (positions + normals + uvs).
770    pub fn from_mesh(
771        name: impl Into<String>,
772        positions: &[[f32; 3]],
773        normals: &[[f32; 3]],
774        uvs: &[[f32; 2]],
775        indices: Vec<u32>,
776    ) -> Self {
777        let mut data = Vec::with_capacity(positions.len() * 8);
778        for i in 0..positions.len() {
779            data.extend_from_slice(&positions[i]);
780            if i < normals.len() {
781                data.extend_from_slice(&normals[i]);
782            } else {
783                data.extend_from_slice(&[0.0, 1.0, 0.0]);
784            }
785            if i < uvs.len() {
786                data.extend_from_slice(&uvs[i]);
787            } else {
788                data.extend_from_slice(&[0.0, 0.0]);
789            }
790        }
791        let stride = 8 * 4; // 8 floats × 4 bytes
792        let attributes = vec![
793            ("position".to_string(), 0u32, 3u32),
794            ("normal".to_string(), 12u32, 3u32),
795            ("uv".to_string(), 24u32, 2u32),
796        ];
797        Self {
798            name: name.into(),
799            data,
800            indices,
801            stride,
802            attributes,
803        }
804    }
805
806    /// Export metadata as JSON.
807    pub fn to_json_meta(&self) -> String {
808        let mut j = String::new();
809        let _ = writeln!(j, "{{");
810        let _ = writeln!(j, r#"  "name": "{}","#, self.name);
811        let _ = writeln!(j, r#"  "vertex_count": {},"#, self.data.len() / 8);
812        let _ = writeln!(j, r#"  "index_count": {},"#, self.indices.len());
813        let _ = writeln!(j, r#"  "stride": {},"#, self.stride);
814        let _ = writeln!(j, r#"  "attributes": ["#);
815        for (i, (aname, offset, count)) in self.attributes.iter().enumerate() {
816            let comma = if i + 1 < self.attributes.len() {
817                ","
818            } else {
819                ""
820            };
821            let _ = writeln!(
822                j,
823                r#"    {{"name": "{}", "offset": {}, "components": {}}}{}"#,
824                aname, offset, count, comma
825            );
826        }
827        let _ = writeln!(j, "  ]");
828        let _ = writeln!(j, "}}");
829        j
830    }
831
832    /// Export to binary (little-endian f32 array).
833    pub fn to_bytes(&self) -> Vec<u8> {
834        let mut bytes = Vec::with_capacity(self.data.len() * 4);
835        for &v in &self.data {
836            bytes.extend_from_slice(&v.to_le_bytes());
837        }
838        bytes
839    }
840
841    /// Number of vertices.
842    pub fn vertex_count(&self) -> usize {
843        self.data.len() / 8
844    }
845}
846
847// ─────────────────────────────────────────────────────────────────────────────
848// glTF 2.0 physics annotations
849// ─────────────────────────────────────────────────────────────────────────────
850
851/// Physics body descriptor for glTF KHR_physics_rigid_bodies extension.
852#[derive(Clone, Debug)]
853pub struct GltfPhysicsBody {
854    /// Node name in the glTF scene.
855    pub node_name: String,
856    /// Mass in kg.
857    pub mass: f64,
858    /// Linear velocity \[vx, vy, vz\].
859    pub linear_velocity: [f64; 3],
860    /// Angular velocity \[wx, wy, wz\].
861    pub angular_velocity: [f64; 3],
862    /// Is this a static (non-moving) body?
863    pub is_static: bool,
864    /// Friction coefficient.
865    pub friction: f64,
866    /// Restitution (bounciness).
867    pub restitution: f64,
868    /// Collider shape type.
869    pub collider: GltfColliderShape,
870}
871
872/// Collider shape for glTF physics.
873#[derive(Clone, Debug)]
874pub enum GltfColliderShape {
875    /// Sphere collider with radius.
876    Sphere(f64),
877    /// Box collider with half-extents.
878    Box([f64; 3]),
879    /// Capsule with radius and half-height.
880    Capsule(f64, f64),
881    /// Convex hull (uses mesh).
882    ConvexHull,
883    /// Trimesh (uses mesh).
884    TriMesh,
885}
886
887impl GltfPhysicsBody {
888    /// Create a default dynamic sphere body.
889    pub fn sphere(node_name: impl Into<String>, mass: f64, radius: f64) -> Self {
890        Self {
891            node_name: node_name.into(),
892            mass,
893            linear_velocity: [0.0; 3],
894            angular_velocity: [0.0; 3],
895            is_static: false,
896            friction: 0.5,
897            restitution: 0.3,
898            collider: GltfColliderShape::Sphere(radius),
899        }
900    }
901
902    /// Serialize to glTF extension JSON fragment.
903    pub fn to_gltf_json(&self) -> String {
904        let mut j = String::new();
905        let _ = writeln!(j, "{{");
906        let _ = writeln!(j, r#"  "KHR_physics_rigid_bodies": {{"#);
907        let _ = writeln!(j, r#"    "motion": {{"#);
908        let _ = writeln!(j, r#"      "isKinematic": {},"#, self.is_static);
909        let _ = writeln!(j, r#"      "mass": {},"#, self.mass);
910        let _ = writeln!(
911            j,
912            r#"      "linearVelocity": [{}, {}, {}],"#,
913            self.linear_velocity[0], self.linear_velocity[1], self.linear_velocity[2]
914        );
915        let _ = writeln!(
916            j,
917            r#"      "angularVelocity": [{}, {}, {}]"#,
918            self.angular_velocity[0], self.angular_velocity[1], self.angular_velocity[2]
919        );
920        let _ = writeln!(j, r#"    }},"#);
921        let _ = writeln!(j, r#"    "collider": {{"#);
922        match &self.collider {
923            GltfColliderShape::Sphere(r) => {
924                let _ = writeln!(j, r#"      "shape": "sphere","#);
925                let _ = writeln!(j, r#"      "sphere": {{"radius": {}}}"#, r);
926            }
927            GltfColliderShape::Box(he) => {
928                let _ = writeln!(j, r#"      "shape": "box","#);
929                let _ = writeln!(
930                    j,
931                    r#"      "box": {{"halfExtents": [{}, {}, {}]}}"#,
932                    he[0], he[1], he[2]
933                );
934            }
935            GltfColliderShape::Capsule(r, h) => {
936                let _ = writeln!(j, r#"      "shape": "capsule","#);
937                let _ = writeln!(
938                    j,
939                    r#"      "capsule": {{"radius": {}, "height": {}}}"#,
940                    r,
941                    h * 2.0
942                );
943            }
944            GltfColliderShape::ConvexHull => {
945                let _ = writeln!(j, r#"      "shape": "convexHull""#);
946            }
947            GltfColliderShape::TriMesh => {
948                let _ = writeln!(j, r#"      "shape": "trimesh""#);
949            }
950        }
951        let _ = writeln!(j, r#"    }},"#);
952        let _ = writeln!(
953            j,
954            r#"    "physicsMaterial": {{"friction": {}, "restitution": {}}}"#,
955            self.friction, self.restitution
956        );
957        let _ = writeln!(j, r#"  }}"#);
958        let _ = writeln!(j, "}}");
959        j
960    }
961}
962
963/// A complete glTF scene with physics annotations.
964#[derive(Clone, Debug, Default)]
965pub struct GltfPhysicsScene {
966    /// Scene name.
967    pub name: String,
968    /// Physics bodies.
969    pub bodies: Vec<GltfPhysicsBody>,
970    /// Gravity vector.
971    pub gravity: [f64; 3],
972    /// Fixed time step.
973    pub fixed_dt: f64,
974}
975
976impl GltfPhysicsScene {
977    /// Create a new [`GltfPhysicsScene`].
978    pub fn new(name: impl Into<String>) -> Self {
979        Self {
980            name: name.into(),
981            bodies: Vec::new(),
982            gravity: [0.0, -9.81, 0.0],
983            fixed_dt: 1.0 / 60.0,
984        }
985    }
986
987    /// Add a physics body.
988    pub fn add_body(&mut self, body: GltfPhysicsBody) {
989        self.bodies.push(body);
990    }
991
992    /// Serialize scene physics extension to JSON.
993    pub fn to_scene_json(&self) -> String {
994        let mut j = String::new();
995        let _ = writeln!(j, "{{");
996        let _ = writeln!(j, r#"  "name": "{}","#, self.name);
997        let _ = writeln!(
998            j,
999            r#"  "gravity": [{}, {}, {}],"#,
1000            self.gravity[0], self.gravity[1], self.gravity[2]
1001        );
1002        let _ = writeln!(j, r#"  "fixedTimestep": {},"#, self.fixed_dt);
1003        let _ = writeln!(j, r#"  "bodies": ["#);
1004        for (i, body) in self.bodies.iter().enumerate() {
1005            let comma = if i + 1 < self.bodies.len() { "," } else { "" };
1006            let _ = write!(j, r#"    {{"node": "{}"}}{}"#, body.node_name, comma);
1007            let _ = writeln!(j);
1008        }
1009        let _ = writeln!(j, "  ]");
1010        let _ = writeln!(j, "}}");
1011        j
1012    }
1013}
1014
1015// ─────────────────────────────────────────────────────────────────────────────
1016// VDB sparse volume export
1017// ─────────────────────────────────────────────────────────────────────────────
1018
1019/// A sparse voxel in a VDB-like grid.
1020#[derive(Clone, Debug)]
1021pub struct VdbVoxel {
1022    /// Voxel grid coordinates.
1023    pub ijk: [i32; 3],
1024    /// Scalar value.
1025    pub value: f64,
1026}
1027
1028/// A sparse VDB volume grid (minimal ASCII representation).
1029#[derive(Clone, Debug)]
1030pub struct VdbSparseGrid {
1031    /// Grid name.
1032    pub name: String,
1033    /// Grid type ("float", "vec3s", etc.).
1034    pub grid_type: String,
1035    /// World-space voxel size.
1036    pub voxel_size: f64,
1037    /// Background (default) value.
1038    pub background: f64,
1039    /// Populated voxels.
1040    pub voxels: Vec<VdbVoxel>,
1041}
1042
1043impl VdbSparseGrid {
1044    /// Create a new [`VdbSparseGrid`].
1045    pub fn new(name: impl Into<String>, voxel_size: f64, background: f64) -> Self {
1046        Self {
1047            name: name.into(),
1048            grid_type: "float".into(),
1049            voxel_size,
1050            background,
1051            voxels: Vec::new(),
1052        }
1053    }
1054
1055    /// Add a voxel.
1056    pub fn add_voxel(&mut self, i: i32, j: i32, k: i32, value: f64) {
1057        self.voxels.push(VdbVoxel {
1058            ijk: [i, j, k],
1059            value,
1060        });
1061    }
1062
1063    /// Populate from a ScalarField3D (only non-background voxels).
1064    pub fn from_scalar_field(
1065        field: &ScalarField3D,
1066        threshold: f64,
1067        name: impl Into<String>,
1068    ) -> Self {
1069        let mut grid = Self::new(name, field.dx, 0.0);
1070        for ix in 0..field.nx {
1071            for iy in 0..field.ny {
1072                for iz in 0..field.nz {
1073                    let v = field.get(ix, iy, iz);
1074                    if v.abs() > threshold {
1075                        grid.add_voxel(ix as i32, iy as i32, iz as i32, v);
1076                    }
1077                }
1078            }
1079        }
1080        grid
1081    }
1082
1083    /// Write a minimal ASCII VDB-like header.
1084    pub fn write_ascii_header(&self) -> String {
1085        let mut s = String::new();
1086        let _ = writeln!(s, "#VDB ASCII v1.0");
1087        let _ = writeln!(s, "name: {}", self.name);
1088        let _ = writeln!(s, "type: {}", self.grid_type);
1089        let _ = writeln!(s, "voxel_size: {}", self.voxel_size);
1090        let _ = writeln!(s, "background: {}", self.background);
1091        let _ = writeln!(s, "n_active: {}", self.voxels.len());
1092        for v in &self.voxels {
1093            let _ = writeln!(s, "v {} {} {} {}", v.ijk[0], v.ijk[1], v.ijk[2], v.value);
1094        }
1095        s
1096    }
1097
1098    /// Number of active voxels.
1099    pub fn n_active(&self) -> usize {
1100        self.voxels.len()
1101    }
1102
1103    /// Bounding box as \[(imin,imax),(jmin,jmax),(kmin,kmax)\].
1104    pub fn bbox(&self) -> [(i32, i32); 3] {
1105        if self.voxels.is_empty() {
1106            return [(0, 0); 3];
1107        }
1108        let mut lo = self.voxels[0].ijk;
1109        let mut hi = self.voxels[0].ijk;
1110        for v in &self.voxels {
1111            for d in 0..3 {
1112                if v.ijk[d] < lo[d] {
1113                    lo[d] = v.ijk[d];
1114                }
1115                if v.ijk[d] > hi[d] {
1116                    hi[d] = v.ijk[d];
1117                }
1118            }
1119        }
1120        [(lo[0], hi[0]), (lo[1], hi[1]), (lo[2], hi[2])]
1121    }
1122}
1123
1124// ─────────────────────────────────────────────────────────────────────────────
1125// OpenEXR multi-channel image descriptor
1126// ─────────────────────────────────────────────────────────────────────────────
1127
1128/// A single channel in an OpenEXR image.
1129#[derive(Clone, Debug)]
1130pub struct ExrChannel {
1131    /// Channel name (e.g., "R", "G", "B", "depth", "normal.X").
1132    pub name: String,
1133    /// Channel data (half-float stored as f32).
1134    pub data: Vec<f32>,
1135    /// Pixel type ("FLOAT", "HALF", "UINT").
1136    pub pixel_type: String,
1137}
1138
1139impl ExrChannel {
1140    /// Create a new [`ExrChannel`].
1141    pub fn new(name: impl Into<String>, width: usize, height: usize) -> Self {
1142        Self {
1143            name: name.into(),
1144            data: vec![0.0; width * height],
1145            pixel_type: "FLOAT".into(),
1146        }
1147    }
1148
1149    /// Fill channel from a closure f(x, y) → f32.
1150    pub fn fill_from<F: Fn(usize, usize) -> f32>(&mut self, width: usize, height: usize, f: F) {
1151        for y in 0..height {
1152            for x in 0..width {
1153                self.data[y * width + x] = f(x, y);
1154            }
1155        }
1156    }
1157}
1158
1159/// A multi-channel OpenEXR image descriptor.
1160#[derive(Clone, Debug)]
1161pub struct OpenExrImage {
1162    /// Image width.
1163    pub width: usize,
1164    /// Image height.
1165    pub height: usize,
1166    /// Channels.
1167    pub channels: Vec<ExrChannel>,
1168    /// Compression type.
1169    pub compression: String,
1170    /// Metadata key-value pairs.
1171    pub metadata: HashMap<String, String>,
1172}
1173
1174impl OpenExrImage {
1175    /// Create a new [`OpenExrImage`].
1176    pub fn new(width: usize, height: usize) -> Self {
1177        Self {
1178            width,
1179            height,
1180            channels: Vec::new(),
1181            compression: "PIZ".into(),
1182            metadata: HashMap::new(),
1183        }
1184    }
1185
1186    /// Add a channel.
1187    pub fn add_channel(&mut self, ch: ExrChannel) {
1188        self.channels.push(ch);
1189    }
1190
1191    /// Insert metadata.
1192    pub fn set_meta(&mut self, key: impl Into<String>, val: impl Into<String>) {
1193        self.metadata.insert(key.into(), val.into());
1194    }
1195
1196    /// Write ASCII header descriptor.
1197    pub fn write_header(&self) -> String {
1198        let mut h = String::new();
1199        let _ = writeln!(h, "OPENEXR ASCII HEADER");
1200        let _ = writeln!(h, "width: {}", self.width);
1201        let _ = writeln!(h, "height: {}", self.height);
1202        let _ = writeln!(h, "compression: {}", self.compression);
1203        let _ = writeln!(h, "channels:");
1204        for ch in &self.channels {
1205            let _ = writeln!(h, "  {} ({})", ch.name, ch.pixel_type);
1206        }
1207        for (k, v) in &self.metadata {
1208            let _ = writeln!(h, "meta {} = {}", k, v);
1209        }
1210        h
1211    }
1212
1213    /// Compute total bytes (f32) for all channels.
1214    pub fn total_bytes(&self) -> usize {
1215        self.channels.iter().map(|ch| ch.data.len() * 4).sum()
1216    }
1217
1218    /// Create a standard RGBA image.
1219    pub fn rgba(width: usize, height: usize) -> Self {
1220        let mut img = Self::new(width, height);
1221        img.add_channel(ExrChannel::new("R", width, height));
1222        img.add_channel(ExrChannel::new("G", width, height));
1223        img.add_channel(ExrChannel::new("B", width, height));
1224        img.add_channel(ExrChannel::new("A", width, height));
1225        img
1226    }
1227}
1228
1229// ─────────────────────────────────────────────────────────────────────────────
1230// Cinema database format
1231// ─────────────────────────────────────────────────────────────────────────────
1232
1233/// A Cinema Science Database (CDB) parameter definition.
1234#[derive(Clone, Debug)]
1235pub struct CinemaParameter {
1236    /// Parameter name.
1237    pub name: String,
1238    /// List of discrete values (stored as strings).
1239    pub values: Vec<String>,
1240    /// Whether this parameter is numeric.
1241    pub is_numeric: bool,
1242}
1243
1244impl CinemaParameter {
1245    /// Create a new numeric parameter from a range.
1246    pub fn numeric_range(name: impl Into<String>, values: Vec<f64>) -> Self {
1247        Self {
1248            name: name.into(),
1249            values: values.iter().map(|v| v.to_string()).collect(),
1250            is_numeric: true,
1251        }
1252    }
1253
1254    /// Create a new string-valued parameter.
1255    pub fn string_param(name: impl Into<String>, values: Vec<String>) -> Self {
1256        Self {
1257            name: name.into(),
1258            values,
1259            is_numeric: false,
1260        }
1261    }
1262}
1263
1264/// A Cinema Science Database (Spec D).
1265#[derive(Clone, Debug)]
1266pub struct CinemaDatabase {
1267    /// Database name.
1268    pub name: String,
1269    /// Parameters sweeping the parameter space.
1270    pub parameters: Vec<CinemaParameter>,
1271    /// Image file extension.
1272    pub file_ext: String,
1273    /// Entries: each entry is a map param_name → value + filename.
1274    pub entries: Vec<HashMap<String, String>>,
1275}
1276
1277impl CinemaDatabase {
1278    /// Create a new [`CinemaDatabase`].
1279    pub fn new(name: impl Into<String>) -> Self {
1280        Self {
1281            name: name.into(),
1282            parameters: Vec::new(),
1283            file_ext: "png".into(),
1284            entries: Vec::new(),
1285        }
1286    }
1287
1288    /// Add a parameter.
1289    pub fn add_parameter(&mut self, param: CinemaParameter) {
1290        self.parameters.push(param);
1291    }
1292
1293    /// Add an image entry.
1294    pub fn add_entry(&mut self, param_values: HashMap<String, String>) {
1295        self.entries.push(param_values);
1296    }
1297
1298    /// Compute total number of images (product of parameter cardinalities).
1299    pub fn total_images(&self) -> usize {
1300        self.parameters
1301            .iter()
1302            .map(|p| p.values.len().max(1))
1303            .product()
1304    }
1305
1306    /// Write Cinema Spec D data.csv header.
1307    pub fn write_csv_header(&self) -> String {
1308        let mut cols: Vec<String> = self.parameters.iter().map(|p| p.name.clone()).collect();
1309        cols.push("FILE".into());
1310        cols.join(",")
1311    }
1312
1313    /// Write Cinema Spec D data.csv body.
1314    pub fn write_csv_body(&self) -> String {
1315        let mut rows = Vec::new();
1316        for entry in &self.entries {
1317            let mut row_vals: Vec<String> = self
1318                .parameters
1319                .iter()
1320                .map(|p| entry.get(&p.name).cloned().unwrap_or_default())
1321                .collect();
1322            row_vals.push(entry.get("FILE").cloned().unwrap_or_default());
1323            rows.push(row_vals.join(","));
1324        }
1325        rows.join("\n")
1326    }
1327
1328    /// Write full CSV (header + body).
1329    pub fn write_csv(&self) -> String {
1330        format!("{}\n{}", self.write_csv_header(), self.write_csv_body())
1331    }
1332
1333    /// Generate a Cinema Spec D info.json.
1334    pub fn write_info_json(&self) -> String {
1335        let mut j = String::new();
1336        let _ = writeln!(j, "{{");
1337        let _ = writeln!(j, r#"  "name_pattern": "{{FILE}}","#);
1338        let _ = writeln!(j, r#"  "arguments": {{"#);
1339        for (i, p) in self.parameters.iter().enumerate() {
1340            let comma = if i + 1 < self.parameters.len() {
1341                ","
1342            } else {
1343                ""
1344            };
1345            let vals: Vec<String> = p.values.iter().map(|v| format!(r#""{}""#, v)).collect();
1346            let _ = writeln!(j, r#"    "{}": [{}]{}"#, p.name, vals.join(", "), comma);
1347        }
1348        let _ = writeln!(j, r#"  }}"#);
1349        let _ = writeln!(j, "}}");
1350        j
1351    }
1352}
1353
1354// ─────────────────────────────────────────────────────────────────────────────
1355// Utility: colour map operations
1356// ─────────────────────────────────────────────────────────────────────────────
1357
1358/// Built-in colour maps.
1359#[derive(Clone, Debug, PartialEq)]
1360pub enum ColourMap {
1361    /// Viridis.
1362    Viridis,
1363    /// Plasma.
1364    Plasma,
1365    /// Cool-warm (Bluered).
1366    CoolWarm,
1367    /// Greyscale.
1368    Greyscale,
1369    /// Hot (black-red-yellow-white).
1370    Hot,
1371}
1372
1373impl ColourMap {
1374    /// Map scalar t ∈ \[0,1\] to RGBA \[r,g,b,a\] using this colour map.
1375    pub fn map(&self, t: f64) -> [f64; 4] {
1376        let t = t.clamp(0.0, 1.0);
1377        match self {
1378            ColourMap::Viridis => {
1379                // Approximate Viridis using polynomial coefficients
1380                let r = 0.267004 + t * (0.004874 + t * (0.329415 + t * (-0.001674)));
1381                let g = 0.004874 + t * (0.872325 + t * (-0.301631 + t * (-0.1)));
1382                let b = 0.329415 + t * (-0.635877 + t * (0.914499 + t * (-0.401658)));
1383                [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), 1.0]
1384            }
1385            ColourMap::Plasma => {
1386                let r = 0.050383 + t * (2.566707 + t * (-2.237019 + t * (0.816839)));
1387                let g = 0.029803 + t * (-0.390895 + t * (1.607541 + t * (-0.892576)));
1388                let b = 0.527975 + t * (1.016567 + t * (-2.476404 + t * (1.476605)));
1389                [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), 1.0]
1390            }
1391            ColourMap::CoolWarm => {
1392                let r = if t < 0.5 { 2.0 * t } else { 1.0 };
1393                let b = if t > 0.5 { 2.0 * (1.0 - t) } else { 1.0 };
1394                let g = 1.0 - 2.0 * (t - 0.5).abs();
1395                [r, g.max(0.0), b, 1.0]
1396            }
1397            ColourMap::Greyscale => [t, t, t, 1.0],
1398            ColourMap::Hot => {
1399                let r = (t * 3.0).min(1.0);
1400                let g = (t * 3.0 - 1.0).clamp(0.0, 1.0);
1401                let b = (t * 3.0 - 2.0).clamp(0.0, 1.0);
1402                [r, g, b, 1.0]
1403            }
1404        }
1405    }
1406
1407    /// Name of the colour map.
1408    pub fn name(&self) -> &str {
1409        match self {
1410            ColourMap::Viridis => "viridis",
1411            ColourMap::Plasma => "plasma",
1412            ColourMap::CoolWarm => "coolwarm",
1413            ColourMap::Greyscale => "greyscale",
1414            ColourMap::Hot => "hot",
1415        }
1416    }
1417}
1418
1419/// Convert a 2D scalar field slice to raw RGBA bytes using a colour map.
1420pub fn scalar_field_to_rgba(
1421    slice: &[f64],
1422    width: usize,
1423    height: usize,
1424    vmin: f64,
1425    vmax: f64,
1426    cmap: &ColourMap,
1427) -> Vec<u8> {
1428    let range = (vmax - vmin).max(1e-30);
1429    let mut bytes = Vec::with_capacity(width * height * 4);
1430    for &v in slice {
1431        let t = (v - vmin) / range;
1432        let rgba = cmap.map(t);
1433        for c in &rgba {
1434            bytes.push((c * 255.0).clamp(0.0, 255.0) as u8);
1435        }
1436    }
1437    bytes
1438}
1439
1440// ─────────────────────────────────────────────────────────────────────────────
1441// Animation frame utilities
1442// ─────────────────────────────────────────────────────────────────────────────
1443
1444/// A single animation frame carrying field data.
1445#[derive(Clone, Debug)]
1446pub struct AnimFrame {
1447    /// Time stamp.
1448    pub time: f64,
1449    /// Frame index.
1450    pub index: usize,
1451    /// Named scalar fields (name → data).
1452    pub scalars: HashMap<String, Vec<f64>>,
1453    /// Named vector fields (name → flat \[x,y,z, ...\]).
1454    pub vectors: HashMap<String, Vec<f64>>,
1455}
1456
1457impl AnimFrame {
1458    /// Create a new [`AnimFrame`].
1459    pub fn new(time: f64, index: usize) -> Self {
1460        Self {
1461            time,
1462            index,
1463            scalars: HashMap::new(),
1464            vectors: HashMap::new(),
1465        }
1466    }
1467
1468    /// Add a scalar field to this frame.
1469    pub fn add_scalar(&mut self, name: impl Into<String>, data: Vec<f64>) {
1470        self.scalars.insert(name.into(), data);
1471    }
1472
1473    /// Add a vector field to this frame.
1474    pub fn add_vector(&mut self, name: impl Into<String>, data: Vec<f64>) {
1475        self.vectors.insert(name.into(), data);
1476    }
1477}
1478
1479/// A sequence of animation frames.
1480#[derive(Clone, Debug, Default)]
1481pub struct AnimSequence {
1482    /// All frames.
1483    pub frames: Vec<AnimFrame>,
1484    /// Frame rate.
1485    pub fps: f64,
1486}
1487
1488impl AnimSequence {
1489    /// Create a new [`AnimSequence`].
1490    pub fn new(fps: f64) -> Self {
1491        Self {
1492            frames: Vec::new(),
1493            fps,
1494        }
1495    }
1496
1497    /// Push a frame.
1498    pub fn push(&mut self, frame: AnimFrame) {
1499        self.frames.push(frame);
1500    }
1501
1502    /// Total duration in seconds.
1503    pub fn duration(&self) -> f64 {
1504        if self.frames.is_empty() {
1505            0.0
1506        } else {
1507            self.frames
1508                .last()
1509                .expect("collection should not be empty")
1510                .time
1511        }
1512    }
1513
1514    /// Get frame at index.
1515    pub fn get_frame(&self, idx: usize) -> Option<&AnimFrame> {
1516        self.frames.get(idx)
1517    }
1518
1519    /// Write a manifest JSON for the animation.
1520    pub fn write_manifest(&self) -> String {
1521        let mut j = String::new();
1522        let _ = writeln!(j, "{{");
1523        let _ = writeln!(j, r#"  "fps": {},"#, self.fps);
1524        let _ = writeln!(j, r#"  "n_frames": {},"#, self.frames.len());
1525        let _ = writeln!(j, r#"  "duration": {},"#, self.duration());
1526        if !self.frames.is_empty() {
1527            let scalar_names: Vec<String> = self.frames[0].scalars.keys().cloned().collect();
1528            let names_str: Vec<String> =
1529                scalar_names.iter().map(|n| format!(r#""{}""#, n)).collect();
1530            let _ = writeln!(j, r#"  "scalar_fields": [{}]"#, names_str.join(", "));
1531        }
1532        let _ = writeln!(j, "}}");
1533        j
1534    }
1535}
1536
1537// ─────────────────────────────────────────────────────────────────────────────
1538// Tests
1539// ─────────────────────────────────────────────────────────────────────────────
1540
1541#[cfg(test)]
1542mod tests {
1543    use super::*;
1544
1545    // ── ScalarField3D ─────────────────────────────────────────────────────────
1546
1547    #[test]
1548    fn test_scalar_field_set_get() {
1549        let mut f = ScalarField3D::new(4, 4, 4, 0.1, [0.0; 3]);
1550        f.set(1, 2, 3, 3.125);
1551        assert!((f.get(1, 2, 3) - 3.125).abs() < 1e-10);
1552    }
1553
1554    #[test]
1555    fn test_scalar_field_min_max() {
1556        let mut f = ScalarField3D::new(2, 2, 2, 0.1, [0.0; 3]);
1557        f.set(0, 0, 0, -5.0);
1558        f.set(1, 1, 1, 10.0);
1559        assert!((f.min_val() - (-5.0)).abs() < 1e-10);
1560        assert!((f.max_val() - 10.0).abs() < 1e-10);
1561    }
1562
1563    #[test]
1564    fn test_scalar_field_default_zero() {
1565        let f = ScalarField3D::new(3, 3, 3, 0.1, [0.0; 3]);
1566        assert_eq!(f.get(1, 1, 1), 0.0);
1567    }
1568
1569    // ── ParaView state ────────────────────────────────────────────────────────
1570
1571    #[test]
1572    fn test_paraview_state_contains_filename() {
1573        let cfg = ParaviewStateConfig::default_config("test.vtu");
1574        let s = write_paraview_state(&cfg);
1575        assert!(s.contains("test.vtu"));
1576    }
1577
1578    #[test]
1579    fn test_paraview_state_contains_xml_header() {
1580        let cfg = ParaviewStateConfig::default_config("test.vtu");
1581        let s = write_paraview_state(&cfg);
1582        assert!(s.contains(r#"<?xml version="1.0"?>"#));
1583    }
1584
1585    #[test]
1586    fn test_paraview_state_contains_camera() {
1587        let mut cfg = ParaviewStateConfig::default_config("test.vtu");
1588        cfg.camera_pos = [1.0, 2.0, 3.0];
1589        let s = write_paraview_state(&cfg);
1590        assert!(s.contains("CameraPosition"));
1591    }
1592
1593    // ── VisIt database ────────────────────────────────────────────────────────
1594
1595    #[test]
1596    fn test_visit_index_contains_files() {
1597        let mut db = VisItDatabase::new("sim", "vtk", 3);
1598        db.add_time(0.0);
1599        db.add_time(0.1);
1600        let idx = db.write_visit_index();
1601        assert!(idx.contains("sim000000.vtk"));
1602        assert!(idx.contains("sim000001.vtk"));
1603    }
1604
1605    #[test]
1606    fn test_visit_manifest_json() {
1607        let mut db = VisItDatabase::new("run", "vtu", 3);
1608        db.add_time(0.0);
1609        db.add_variable("pressure");
1610        let m = db.write_manifest();
1611        assert!(m.contains("\"pressure\""));
1612        assert!(m.contains("\"n_times\": 1"));
1613    }
1614
1615    // ── Blender export ────────────────────────────────────────────────────────
1616
1617    #[test]
1618    fn test_blender_script_imports_bpy() {
1619        let cfg = BlenderExportConfig::new("obj1", "/tmp/out.blend");
1620        let s = cfg.generate_script("mesh.obj");
1621        assert!(s.contains("import bpy"));
1622    }
1623
1624    #[test]
1625    fn test_blender_script_contains_object_name() {
1626        let cfg = BlenderExportConfig::new("my_obj", "/tmp/out.blend");
1627        let s = cfg.generate_script("mesh.obj");
1628        assert!(s.contains("my_obj"));
1629    }
1630
1631    #[test]
1632    fn test_blender_volume_script() {
1633        let cfg = BlenderExportConfig::new("vol", "/tmp/vol.blend");
1634        let s = cfg.generate_volume_script("smoke.vdb");
1635        assert!(s.contains("smoke.vdb"));
1636        assert!(s.contains("ShaderNodeEmission"));
1637    }
1638
1639    // ── Matplotlib JSON ───────────────────────────────────────────────────────
1640
1641    #[test]
1642    fn test_matplotlib_json_contains_title() {
1643        let mut fig = MatplotlibFigure::new("My Plot", "X", "Y");
1644        fig.add_series(DataSeries::new("data", vec![1.0, 2.0], vec![3.0, 4.0]));
1645        let j = fig.to_json();
1646        assert!(j.contains("My Plot"));
1647    }
1648
1649    #[test]
1650    fn test_matplotlib_python_script() {
1651        let fig = MatplotlibFigure::new("Test", "time", "value");
1652        let s = fig.to_python_script();
1653        assert!(s.contains("import matplotlib.pyplot as plt"));
1654    }
1655
1656    #[test]
1657    fn test_matplotlib_series_data_in_json() {
1658        let mut fig = MatplotlibFigure::new("T", "x", "y");
1659        fig.add_series(DataSeries::new("series1", vec![0.0, 1.0], vec![2.0, 3.0]));
1660        let j = fig.to_json();
1661        assert!(j.contains("series1"));
1662    }
1663
1664    // ── D3 force graph ────────────────────────────────────────────────────────
1665
1666    #[test]
1667    fn test_d3_graph_json_nodes() {
1668        let mut g = D3ForceGraph::new();
1669        g.add_node("A", 1, 5.0);
1670        g.add_node("B", 2, 3.0);
1671        g.add_edge("A", "B", 1.0);
1672        let j = g.to_json();
1673        assert!(j.contains(r#""id": "A""#));
1674        assert!(j.contains(r#""source": "A""#));
1675    }
1676
1677    #[test]
1678    fn test_d3_contour_json() {
1679        let mut cd = D3ContourData::new(4, 4, [0.0, 1.0], [0.0, 1.0]);
1680        cd.set(2, 2, 1.5);
1681        cd.auto_thresholds(5);
1682        let j = cd.to_json();
1683        assert!(j.contains("\"width\": 4"));
1684        assert!(!cd.thresholds.is_empty());
1685    }
1686
1687    // ── WebGL buffer ──────────────────────────────────────────────────────────
1688
1689    #[test]
1690    fn test_webgl_buffer_vertex_count() {
1691        let pos = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1692        let nor = vec![[0.0f32, 0.0, 1.0]; 3];
1693        let uv = vec![[0.0f32, 0.0]; 3];
1694        let idx = vec![0u32, 1, 2];
1695        let buf = WebGlBuffer::from_mesh("tri", &pos, &nor, &uv, idx);
1696        assert_eq!(buf.vertex_count(), 3);
1697    }
1698
1699    #[test]
1700    fn test_webgl_buffer_bytes_len() {
1701        let pos = vec![[0.0f32, 0.0, 0.0]];
1702        let nor = vec![[0.0f32, 0.0, 1.0]];
1703        let uv = vec![[0.0f32, 0.0]];
1704        let buf = WebGlBuffer::from_mesh("p", &pos, &nor, &uv, vec![0]);
1705        let bytes = buf.to_bytes();
1706        assert_eq!(bytes.len(), 8 * 4); // 8 floats × 4 bytes
1707    }
1708
1709    #[test]
1710    fn test_webgl_buffer_meta_json() {
1711        let buf = WebGlBuffer::from_mesh("mesh", &[], &[], &[], vec![]);
1712        let j = buf.to_json_meta();
1713        assert!(j.contains("\"name\": \"mesh\""));
1714    }
1715
1716    // ── glTF physics ──────────────────────────────────────────────────────────
1717
1718    #[test]
1719    fn test_gltf_physics_body_json_sphere() {
1720        let body = GltfPhysicsBody::sphere("node1", 1.0, 0.5);
1721        let j = body.to_gltf_json();
1722        assert!(j.contains("sphere"));
1723        assert!(j.contains("KHR_physics_rigid_bodies"));
1724    }
1725
1726    #[test]
1727    fn test_gltf_scene_json() {
1728        let mut scene = GltfPhysicsScene::new("test_scene");
1729        scene.add_body(GltfPhysicsBody::sphere("ball", 1.0, 0.5));
1730        let j = scene.to_scene_json();
1731        assert!(j.contains("test_scene"));
1732        assert!(j.contains("ball"));
1733    }
1734
1735    // ── VDB sparse grid ───────────────────────────────────────────────────────
1736
1737    #[test]
1738    fn test_vdb_add_voxel() {
1739        let mut g = VdbSparseGrid::new("smoke", 0.1, 0.0);
1740        g.add_voxel(1, 2, 3, 0.5);
1741        assert_eq!(g.n_active(), 1);
1742    }
1743
1744    #[test]
1745    fn test_vdb_from_scalar_field() {
1746        let mut field = ScalarField3D::new(4, 4, 4, 0.1, [0.0; 3]);
1747        field.set(2, 2, 2, 5.0);
1748        let grid = VdbSparseGrid::from_scalar_field(&field, 1.0, "f");
1749        assert_eq!(grid.n_active(), 1);
1750    }
1751
1752    #[test]
1753    fn test_vdb_ascii_header() {
1754        let mut g = VdbSparseGrid::new("density", 0.05, 0.0);
1755        g.add_voxel(0, 0, 0, 1.0);
1756        let h = g.write_ascii_header();
1757        assert!(h.contains("#VDB ASCII"));
1758        assert!(h.contains("density"));
1759    }
1760
1761    #[test]
1762    fn test_vdb_bbox() {
1763        let mut g = VdbSparseGrid::new("vol", 0.1, 0.0);
1764        g.add_voxel(-1, 0, 0, 1.0);
1765        g.add_voxel(5, 3, 2, 2.0);
1766        let bb = g.bbox();
1767        assert_eq!(bb[0], (-1, 5));
1768    }
1769
1770    // ── OpenEXR ───────────────────────────────────────────────────────────────
1771
1772    #[test]
1773    fn test_exr_rgba_channels() {
1774        let img = OpenExrImage::rgba(8, 8);
1775        assert_eq!(img.channels.len(), 4);
1776    }
1777
1778    #[test]
1779    fn test_exr_total_bytes() {
1780        let img = OpenExrImage::rgba(4, 4);
1781        assert_eq!(img.total_bytes(), 4 * 4 * 4 * 4); // 4 channels × 16 px × 4 bytes
1782    }
1783
1784    #[test]
1785    fn test_exr_header_contains_channels() {
1786        let img = OpenExrImage::rgba(2, 2);
1787        let h = img.write_header();
1788        assert!(h.contains("R"));
1789        assert!(h.contains("G"));
1790    }
1791
1792    #[test]
1793    fn test_exr_channel_fill() {
1794        let mut ch = ExrChannel::new("Z", 3, 3);
1795        ch.fill_from(3, 3, |x, y| (x + y) as f32);
1796        assert!((ch.data[4] - 2.0).abs() < 1e-6); // (1,1) → 2
1797    }
1798
1799    // ── Cinema database ───────────────────────────────────────────────────────
1800
1801    #[test]
1802    fn test_cinema_total_images() {
1803        let mut db = CinemaDatabase::new("sim");
1804        db.add_parameter(CinemaParameter::numeric_range(
1805            "phi",
1806            vec![0.0, 90.0, 180.0, 270.0],
1807        ));
1808        db.add_parameter(CinemaParameter::numeric_range(
1809            "theta",
1810            vec![0.0, 45.0, 90.0],
1811        ));
1812        assert_eq!(db.total_images(), 12);
1813    }
1814
1815    #[test]
1816    fn test_cinema_csv_header() {
1817        let mut db = CinemaDatabase::new("test");
1818        db.add_parameter(CinemaParameter::numeric_range("time", vec![0.0, 1.0]));
1819        let h = db.write_csv_header();
1820        assert!(h.contains("time"));
1821        assert!(h.contains("FILE"));
1822    }
1823
1824    #[test]
1825    fn test_cinema_info_json() {
1826        let mut db = CinemaDatabase::new("db");
1827        db.add_parameter(CinemaParameter::numeric_range("angle", vec![0.0, 90.0]));
1828        let j = db.write_info_json();
1829        assert!(j.contains("\"angle\""));
1830    }
1831
1832    // ── Colour map ────────────────────────────────────────────────────────────
1833
1834    #[test]
1835    fn test_colourmap_greyscale_zero() {
1836        let rgba = ColourMap::Greyscale.map(0.0);
1837        for &c in &rgba[..3] {
1838            assert!((c - 0.0).abs() < 1e-10);
1839        }
1840    }
1841
1842    #[test]
1843    fn test_colourmap_greyscale_one() {
1844        let rgba = ColourMap::Greyscale.map(1.0);
1845        for &c in &rgba[..3] {
1846            assert!((c - 1.0).abs() < 1e-10);
1847        }
1848    }
1849
1850    #[test]
1851    fn test_colourmap_hot_red_at_quarter() {
1852        let rgba = ColourMap::Hot.map(0.4);
1853        assert!(rgba[0] > 0.5); // red
1854    }
1855
1856    #[test]
1857    fn test_scalar_to_rgba_len() {
1858        let data = vec![0.0, 0.5, 1.0, 0.25];
1859        let bytes = scalar_field_to_rgba(&data, 2, 2, 0.0, 1.0, &ColourMap::CoolWarm);
1860        assert_eq!(bytes.len(), 16); // 4 pixels × 4 channels
1861    }
1862
1863    // ── AnimSequence ──────────────────────────────────────────────────────────
1864
1865    #[test]
1866    fn test_anim_sequence_duration() {
1867        let mut seq = AnimSequence::new(24.0);
1868        seq.push(AnimFrame::new(0.0, 0));
1869        seq.push(AnimFrame::new(1.0, 1));
1870        assert!((seq.duration() - 1.0).abs() < 1e-10);
1871    }
1872
1873    #[test]
1874    fn test_anim_frame_scalars() {
1875        let mut frame = AnimFrame::new(0.0, 0);
1876        frame.add_scalar("pressure", vec![1.0, 2.0, 3.0]);
1877        assert_eq!(frame.scalars["pressure"].len(), 3);
1878    }
1879
1880    #[test]
1881    fn test_anim_manifest_json() {
1882        let mut seq = AnimSequence::new(30.0);
1883        let mut f = AnimFrame::new(0.0, 0);
1884        f.add_scalar("vel", vec![0.0]);
1885        seq.push(f);
1886        let m = seq.write_manifest();
1887        assert!(m.contains("\"n_frames\": 1"));
1888    }
1889
1890    // ── Point3 ────────────────────────────────────────────────────────────────
1891
1892    #[test]
1893    fn test_point3_dist() {
1894        let a = Point3::new(0.0, 0.0, 0.0);
1895        let b = Point3::new(3.0, 4.0, 0.0);
1896        assert!((a.dist(&b) - 5.0).abs() < 1e-10);
1897    }
1898
1899    #[test]
1900    fn test_point3_to_array() {
1901        let p = Point3::new(1.0, 2.0, 3.0);
1902        assert_eq!(p.to_array(), [1.0, 2.0, 3.0]);
1903    }
1904}