Skip to main content

oxiphysics_python/
viz_api.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Visualization API for Python interop.
5//!
6//! Provides Python-friendly types for rendering, camera control, scene management,
7//! stress visualization, streamline tracing, and debug overlays.
8
9#![allow(missing_docs)]
10#![allow(dead_code)]
11
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// Helper functions
16// ---------------------------------------------------------------------------
17
18/// Convert HSV color to RGB.
19///
20/// All inputs and outputs are in \[0, 1\] range.
21pub fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [f64; 3] {
22    if s == 0.0 {
23        return [v, v, v];
24    }
25    let h6 = h * 6.0;
26    let i = h6.floor() as i32;
27    let f = h6 - h6.floor();
28    let p = v * (1.0 - s);
29    let q = v * (1.0 - s * f);
30    let t = v * (1.0 - s * (1.0 - f));
31    match i % 6 {
32        0 => [v, t, p],
33        1 => [q, v, p],
34        2 => [p, v, t],
35        3 => [p, q, v],
36        4 => [t, p, v],
37        _ => [v, p, q],
38    }
39}
40
41/// Compute smooth normals from a triangle mesh.
42///
43/// `vertices` is a flat array of `[x, y, z]` values (length = 3 * num_vertices).
44/// `indices` is a flat array of triangle vertex indices (length = 3 * num_triangles).
45/// Returns a flat array of normals, same length as `vertices`.
46pub fn compute_normals_from_mesh(vertices: &[f64], indices: &[u32]) -> Vec<f64> {
47    let nv = vertices.len() / 3;
48    let mut normals = vec![0.0f64; vertices.len()];
49    let ntri = indices.len() / 3;
50    for t in 0..ntri {
51        let i0 = indices[3 * t] as usize;
52        let i1 = indices[3 * t + 1] as usize;
53        let i2 = indices[3 * t + 2] as usize;
54        let p0 = [vertices[3 * i0], vertices[3 * i0 + 1], vertices[3 * i0 + 2]];
55        let p1 = [vertices[3 * i1], vertices[3 * i1 + 1], vertices[3 * i1 + 2]];
56        let p2 = [vertices[3 * i2], vertices[3 * i2 + 1], vertices[3 * i2 + 2]];
57        let e1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
58        let e2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
59        let n = [
60            e1[1] * e2[2] - e1[2] * e2[1],
61            e1[2] * e2[0] - e1[0] * e2[2],
62            e1[0] * e2[1] - e1[1] * e2[0],
63        ];
64        for idx in [i0, i1, i2] {
65            normals[3 * idx] += n[0];
66            normals[3 * idx + 1] += n[1];
67            normals[3 * idx + 2] += n[2];
68        }
69    }
70    for v in 0..nv {
71        let nx = normals[3 * v];
72        let ny = normals[3 * v + 1];
73        let nz = normals[3 * v + 2];
74        let len = (nx * nx + ny * ny + nz * nz).sqrt();
75        if len > 1e-12 {
76            normals[3 * v] /= len;
77            normals[3 * v + 1] /= len;
78            normals[3 * v + 2] /= len;
79        }
80    }
81    normals
82}
83
84/// Generate a UV sphere mesh.
85///
86/// Returns `(vertices, normals, indices)` as flat `f64`/`u32` arrays.
87pub fn generate_sphere_mesh(
88    radius: f64,
89    lat_segments: u32,
90    lon_segments: u32,
91) -> (Vec<f64>, Vec<f64>, Vec<u32>) {
92    let mut verts = Vec::new();
93    let mut norms = Vec::new();
94    let mut idxs = Vec::new();
95    for lat in 0..=lat_segments {
96        let theta = std::f64::consts::PI * lat as f64 / lat_segments as f64;
97        let sin_t = theta.sin();
98        let cos_t = theta.cos();
99        for lon in 0..=lon_segments {
100            let phi = 2.0 * std::f64::consts::PI * lon as f64 / lon_segments as f64;
101            let x = sin_t * phi.cos();
102            let y = cos_t;
103            let z = sin_t * phi.sin();
104            verts.push(radius * x);
105            verts.push(radius * y);
106            verts.push(radius * z);
107            norms.push(x);
108            norms.push(y);
109            norms.push(z);
110        }
111    }
112    for lat in 0..lat_segments {
113        for lon in 0..lon_segments {
114            let row = lon_segments + 1;
115            let a = lat * row + lon;
116            let b = a + row;
117            let c = b + 1;
118            let d = a + 1;
119            idxs.push(a);
120            idxs.push(b);
121            idxs.push(c);
122            idxs.push(a);
123            idxs.push(c);
124            idxs.push(d);
125        }
126    }
127    (verts, norms, idxs)
128}
129
130/// Generate a box (cuboid) mesh centered at the origin.
131///
132/// `half_extents` = \[hx, hy, hz\].
133/// Returns `(vertices, normals, indices)`.
134pub fn generate_box_mesh(half_extents: [f64; 3]) -> (Vec<f64>, Vec<f64>, Vec<u32>) {
135    let [hx, hy, hz] = half_extents;
136    // 6 faces × 4 vertices = 24 vertices
137    let face_data: &[([f64; 3], [f64; 3])] = &[
138        ([hx, 0.0, 0.0], [1.0, 0.0, 0.0]),
139        ([-hx, 0.0, 0.0], [-1.0, 0.0, 0.0]),
140        ([0.0, hy, 0.0], [0.0, 1.0, 0.0]),
141        ([0.0, -hy, 0.0], [0.0, -1.0, 0.0]),
142        ([0.0, 0.0, hz], [0.0, 0.0, 1.0]),
143        ([0.0, 0.0, -hz], [0.0, 0.0, -1.0]),
144    ];
145    let offsets: &[[f64; 2]] = &[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
146    let mut verts = Vec::new();
147    let mut norms = Vec::new();
148    let mut idxs = Vec::new();
149    for (face_idx, (center, normal)) in face_data.iter().enumerate() {
150        let base = (face_idx * 4) as u32;
151        // Determine the two tangent directions based on the normal
152        let (t1, t2) = if normal[0].abs() > 0.5 {
153            ([0.0, hy, 0.0], [0.0, 0.0, hz])
154        } else if normal[1].abs() > 0.5 {
155            ([hx, 0.0, 0.0], [0.0, 0.0, hz])
156        } else {
157            ([hx, 0.0, 0.0], [0.0, hy, 0.0])
158        };
159        for off in offsets {
160            let x = center[0] + off[0] * t1[0] + off[1] * t2[0];
161            let y = center[1] + off[0] * t1[1] + off[1] * t2[1];
162            let z = center[2] + off[0] * t1[2] + off[1] * t2[2];
163            verts.push(x);
164            verts.push(y);
165            verts.push(z);
166            norms.push(normal[0]);
167            norms.push(normal[1]);
168            norms.push(normal[2]);
169        }
170        idxs.push(base);
171        idxs.push(base + 1);
172        idxs.push(base + 2);
173        idxs.push(base);
174        idxs.push(base + 2);
175        idxs.push(base + 3);
176    }
177    (verts, norms, idxs)
178}
179
180// ---------------------------------------------------------------------------
181// PyColormap
182// ---------------------------------------------------------------------------
183
184/// Named colormap for scalar field visualization.
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub enum ColormapKind {
187    /// Viridis perceptually uniform colormap.
188    Viridis,
189    /// Plasma perceptually uniform colormap.
190    Plasma,
191    /// Magma perceptually uniform colormap.
192    Magma,
193    /// Inferno perceptually uniform colormap.
194    Inferno,
195    /// Turbo rainbow colormap.
196    Turbo,
197    /// Greyscale colormap.
198    Greys,
199    /// Red-Blue diverging colormap.
200    RdBu,
201    /// Spectral diverging colormap.
202    Spectral,
203    /// Coolwarm diverging colormap.
204    Coolwarm,
205    /// Hot colormap.
206    Hot,
207    /// Jet colormap (legacy).
208    Jet,
209}
210
211/// A colormap that maps scalar values in \[0, 1\] to RGBA colors.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct PyColormap {
214    /// The colormap kind.
215    pub kind: ColormapKind,
216    /// Minimum scalar value (maps to t=0).
217    pub vmin: f64,
218    /// Maximum scalar value (maps to t=1).
219    pub vmax: f64,
220    /// Alpha value for all colors (0=transparent, 1=opaque).
221    pub alpha: f64,
222}
223
224impl PyColormap {
225    /// Create a new colormap with explicit range.
226    pub fn new(kind: ColormapKind, vmin: f64, vmax: f64) -> Self {
227        Self {
228            kind,
229            vmin,
230            vmax,
231            alpha: 1.0,
232        }
233    }
234
235    /// Create a Viridis colormap over \[0, 1\].
236    pub fn viridis() -> Self {
237        Self::new(ColormapKind::Viridis, 0.0, 1.0)
238    }
239
240    /// Create a Plasma colormap over \[0, 1\].
241    pub fn plasma() -> Self {
242        Self::new(ColormapKind::Plasma, 0.0, 1.0)
243    }
244
245    /// Create a Jet colormap over \[0, 1\].
246    pub fn jet() -> Self {
247        Self::new(ColormapKind::Jet, 0.0, 1.0)
248    }
249
250    /// Normalize a raw scalar value to t ∈ \[0, 1\].
251    pub fn normalize(&self, value: f64) -> f64 {
252        if (self.vmax - self.vmin).abs() < 1e-15 {
253            return 0.5;
254        }
255        ((value - self.vmin) / (self.vmax - self.vmin)).clamp(0.0, 1.0)
256    }
257
258    /// Map a normalized value t ∈ \[0, 1\] to RGBA \[r, g, b, a\].
259    pub fn map_value(&self, t: f64) -> [f64; 4] {
260        let t = t.clamp(0.0, 1.0);
261        let rgb = match self.kind {
262            ColormapKind::Viridis => viridis_sample(t),
263            ColormapKind::Plasma => plasma_sample(t),
264            ColormapKind::Magma => magma_sample(t),
265            ColormapKind::Inferno => inferno_sample(t),
266            ColormapKind::Turbo => turbo_sample(t),
267            ColormapKind::Greys => [t, t, t],
268            ColormapKind::RdBu => rdbu_sample(t),
269            ColormapKind::Spectral => spectral_sample(t),
270            ColormapKind::Coolwarm => coolwarm_sample(t),
271            ColormapKind::Hot => hot_sample(t),
272            ColormapKind::Jet => jet_sample(t),
273        };
274        [rgb[0], rgb[1], rgb[2], self.alpha]
275    }
276
277    /// Map a raw scalar value directly.
278    pub fn map_scalar(&self, value: f64) -> [f64; 4] {
279        self.map_value(self.normalize(value))
280    }
281}
282
283fn viridis_sample(t: f64) -> [f64; 3] {
284    // Approximate Viridis using polynomial
285    let r = 0.2777 * t.powi(3) - 0.8673 * t.powi(2) + 0.4756 * t + 0.2665;
286    let g = -0.0955 * t.powi(3) + 0.5925 * t.powi(2) + 0.2843 * t + 0.0038;
287    let b = -1.0803 * t.powi(3) + 1.1215 * t.powi(2) - 0.6424 * t + 0.5305;
288    [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]
289}
290
291fn plasma_sample(t: f64) -> [f64; 3] {
292    let r = (0.9 * t + 0.05).clamp(0.0, 1.0);
293    let g = (4.0 * t * (1.0 - t)).clamp(0.0, 1.0);
294    let b = (1.0 - t * 1.2).clamp(0.0, 1.0);
295    [r, g, b]
296}
297
298fn magma_sample(t: f64) -> [f64; 3] {
299    let r = (t * 1.1).clamp(0.0, 1.0);
300    let g = (t * t * 0.9).clamp(0.0, 1.0);
301    let b = if t < 0.5 { t * 1.5 } else { 1.5 - t * 1.5 };
302    [r, g, b.clamp(0.0, 1.0)]
303}
304
305fn inferno_sample(t: f64) -> [f64; 3] {
306    let r = (t * 1.2).clamp(0.0, 1.0);
307    let g = (t * t * 0.8).clamp(0.0, 1.0);
308    let b = ((1.0 - t) * 0.4).clamp(0.0, 1.0);
309    [r, g, b]
310}
311
312fn turbo_sample(t: f64) -> [f64; 3] {
313    hsv_to_rgb((1.0 - t) * 0.667, 1.0, 1.0)
314}
315
316fn rdbu_sample(t: f64) -> [f64; 3] {
317    if t < 0.5 {
318        let s = t * 2.0;
319        [s * 0.7 + 0.3, s * 0.7 + 0.3, 1.0]
320    } else {
321        let s = (t - 0.5) * 2.0;
322        [1.0, (1.0 - s) * 0.7 + 0.3, (1.0 - s) * 0.7 + 0.3]
323    }
324}
325
326fn spectral_sample(t: f64) -> [f64; 3] {
327    hsv_to_rgb(t * 0.833, 0.9, 0.9)
328}
329
330fn coolwarm_sample(t: f64) -> [f64; 3] {
331    if t < 0.5 {
332        let s = (0.5 - t) * 2.0;
333        [0.3 + s * 0.4, 0.3 + s * 0.4, 0.9]
334    } else {
335        let s = (t - 0.5) * 2.0;
336        [0.9, 0.3 + (1.0 - s) * 0.4, 0.3 + (1.0 - s) * 0.4]
337    }
338}
339
340fn hot_sample(t: f64) -> [f64; 3] {
341    let r = (t * 3.0).clamp(0.0, 1.0);
342    let g = (t * 3.0 - 1.0).clamp(0.0, 1.0);
343    let b = (t * 3.0 - 2.0).clamp(0.0, 1.0);
344    [r, g, b]
345}
346
347fn jet_sample(t: f64) -> [f64; 3] {
348    hsv_to_rgb((1.0 - t) * 0.667, 1.0, 1.0)
349}
350
351// ---------------------------------------------------------------------------
352// PyCamera
353// ---------------------------------------------------------------------------
354
355/// 3D camera with orbit, pan, zoom controls.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PyCamera {
358    /// Camera position in world space.
359    pub position: [f64; 3],
360    /// Look-at target point.
361    pub target: [f64; 3],
362    /// Up vector (usually \[0, 1, 0\]).
363    pub up: [f64; 3],
364    /// Vertical field of view in degrees.
365    pub fov_deg: f64,
366    /// Near clipping plane distance.
367    pub near: f64,
368    /// Far clipping plane distance.
369    pub far: f64,
370    /// Viewport aspect ratio (width / height).
371    pub aspect: f64,
372}
373
374impl PyCamera {
375    /// Create a default perspective camera.
376    pub fn new(position: [f64; 3], target: [f64; 3]) -> Self {
377        Self {
378            position,
379            target,
380            up: [0.0, 1.0, 0.0],
381            fov_deg: 60.0,
382            near: 0.01,
383            far: 1000.0,
384            aspect: 16.0 / 9.0,
385        }
386    }
387
388    /// Default camera looking down the negative Z axis.
389    pub fn default_perspective() -> Self {
390        Self::new([0.0, 5.0, 10.0], [0.0, 0.0, 0.0])
391    }
392
393    /// Orbit the camera around the target by delta_yaw and delta_pitch (radians).
394    pub fn orbit(&mut self, delta_yaw: f64, delta_pitch: f64) {
395        let dx = self.position[0] - self.target[0];
396        let dy = self.position[1] - self.target[1];
397        let dz = self.position[2] - self.target[2];
398        let radius = (dx * dx + dy * dy + dz * dz).sqrt();
399        let mut yaw = dz.atan2(dx);
400        let mut pitch = dy.atan2((dx * dx + dz * dz).sqrt());
401        yaw += delta_yaw;
402        pitch = (pitch + delta_pitch).clamp(-1.5, 1.5);
403        self.position[0] = self.target[0] + radius * pitch.cos() * yaw.cos();
404        self.position[1] = self.target[1] + radius * pitch.sin();
405        self.position[2] = self.target[2] + radius * pitch.cos() * yaw.sin();
406    }
407
408    /// Pan the camera by a world-space offset.
409    pub fn pan(&mut self, offset: [f64; 3]) {
410        self.position[0] += offset[0];
411        self.position[1] += offset[1];
412        self.position[2] += offset[2];
413        self.target[0] += offset[0];
414        self.target[1] += offset[1];
415        self.target[2] += offset[2];
416    }
417
418    /// Zoom by moving the camera closer to (factor < 1) or farther from (factor > 1) the target.
419    pub fn zoom(&mut self, factor: f64) {
420        let dx = self.position[0] - self.target[0];
421        let dy = self.position[1] - self.target[1];
422        let dz = self.position[2] - self.target[2];
423        self.position[0] = self.target[0] + dx * factor;
424        self.position[1] = self.target[1] + dy * factor;
425        self.position[2] = self.target[2] + dz * factor;
426    }
427
428    /// Point the camera directly at a given world position.
429    pub fn look_at(&mut self, target: [f64; 3]) {
430        self.target = target;
431    }
432
433    /// Compute the view direction (normalized).
434    pub fn view_direction(&self) -> [f64; 3] {
435        let dx = self.target[0] - self.position[0];
436        let dy = self.target[1] - self.position[1];
437        let dz = self.target[2] - self.position[2];
438        let len = (dx * dx + dy * dy + dz * dz).sqrt();
439        if len < 1e-12 {
440            return [0.0, 0.0, -1.0];
441        }
442        [dx / len, dy / len, dz / len]
443    }
444
445    /// Distance from camera to target.
446    pub fn distance_to_target(&self) -> f64 {
447        let dx = self.position[0] - self.target[0];
448        let dy = self.position[1] - self.target[1];
449        let dz = self.position[2] - self.target[2];
450        (dx * dx + dy * dy + dz * dz).sqrt()
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Material
456// ---------------------------------------------------------------------------
457
458/// Surface material properties for mesh rendering.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct PyMaterial {
461    /// Base color RGBA.
462    pub base_color: [f64; 4],
463    /// Metallic factor (0=dielectric, 1=metal).
464    pub metallic: f64,
465    /// Roughness factor (0=mirror, 1=fully rough).
466    pub roughness: f64,
467    /// Emissive color RGB.
468    pub emissive: [f64; 3],
469    /// Whether to use double-sided rendering.
470    pub double_sided: bool,
471    /// Optional colormap for scalar-driven coloring.
472    pub colormap: Option<PyColormap>,
473}
474
475impl PyMaterial {
476    /// Create a default PBR material.
477    pub fn new(base_color: [f64; 4]) -> Self {
478        Self {
479            base_color,
480            metallic: 0.0,
481            roughness: 0.5,
482            emissive: [0.0, 0.0, 0.0],
483            double_sided: false,
484            colormap: None,
485        }
486    }
487
488    /// Red material.
489    pub fn red() -> Self {
490        Self::new([1.0, 0.0, 0.0, 1.0])
491    }
492
493    /// Blue material.
494    pub fn blue() -> Self {
495        Self::new([0.0, 0.3, 1.0, 1.0])
496    }
497
498    /// Metallic silver material.
499    pub fn silver() -> Self {
500        Self {
501            base_color: [0.8, 0.8, 0.8, 1.0],
502            metallic: 1.0,
503            roughness: 0.1,
504            emissive: [0.0; 3],
505            double_sided: false,
506            colormap: None,
507        }
508    }
509}
510
511// ---------------------------------------------------------------------------
512// PyMeshRenderer
513// ---------------------------------------------------------------------------
514
515/// GPU-ready mesh data with material and optional per-vertex scalar field.
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct PyMeshRenderer {
518    /// Flat array of vertex positions: \[x0, y0, z0, x1, y1, z1, ...\].
519    pub vertices: Vec<f64>,
520    /// Flat array of vertex normals (same length as vertices).
521    pub normals: Vec<f64>,
522    /// Triangle index list (3 indices per triangle).
523    pub indices: Vec<u32>,
524    /// Surface material.
525    pub material: PyMaterial,
526    /// Optional per-vertex scalar values for colormap coloring.
527    pub scalars: Option<Vec<f64>>,
528}
529
530impl PyMeshRenderer {
531    /// Create a new mesh renderer from raw geometry.
532    pub fn new(vertices: Vec<f64>, indices: Vec<u32>, material: PyMaterial) -> Self {
533        let normals = compute_normals_from_mesh(&vertices, &indices);
534        Self {
535            vertices,
536            normals,
537            indices,
538            material,
539            scalars: None,
540        }
541    }
542
543    /// Apply a colormap to per-vertex scalar values.
544    ///
545    /// Sets `material.colormap` and stores the scalar array.
546    pub fn apply_colormap(&mut self, scalars: Vec<f64>, colormap: PyColormap) {
547        self.scalars = Some(scalars);
548        self.material.colormap = Some(colormap);
549    }
550
551    /// Returns the number of vertices.
552    pub fn vertex_count(&self) -> usize {
553        self.vertices.len() / 3
554    }
555
556    /// Returns the number of triangles.
557    pub fn triangle_count(&self) -> usize {
558        self.indices.len() / 3
559    }
560
561    /// Recompute normals from current vertex/index data.
562    pub fn recompute_normals(&mut self) {
563        self.normals = compute_normals_from_mesh(&self.vertices, &self.indices);
564    }
565
566    /// Render to a flat RGBA pixel buffer (software rasterizer stub).
567    ///
568    /// Returns a buffer of `width * height * 4` bytes (u8), cleared to the background color.
569    pub fn render_to_buffer(&self, width: u32, height: u32, background: [u8; 4]) -> Vec<u8> {
570        let n = (width * height * 4) as usize;
571        let mut buf = vec![0u8; n];
572        for i in 0..(width * height) as usize {
573            buf[4 * i] = background[0];
574            buf[4 * i + 1] = background[1];
575            buf[4 * i + 2] = background[2];
576            buf[4 * i + 3] = background[3];
577        }
578        buf
579    }
580}
581
582// ---------------------------------------------------------------------------
583// PyParticleRenderer
584// ---------------------------------------------------------------------------
585
586/// Renderer for particle systems using billboard or instanced geometry.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct PyParticleRenderer {
589    /// Flat array of particle positions: \[x0, y0, z0, x1, y1, z1, ...\].
590    pub positions: Vec<f64>,
591    /// Per-particle radii. If length is 1, all particles share that radius.
592    pub radii: Vec<f64>,
593    /// Per-particle RGBA colors. Flat array: \[r0, g0, b0, a0, r1, ...\].
594    pub colors: Vec<f64>,
595    /// Whether to use billboard rendering (always face camera).
596    pub billboard: bool,
597    /// Whether to use hardware instancing (more efficient for large counts).
598    pub use_instancing: bool,
599    /// Optional colormap applied over a scalar field.
600    pub colormap: Option<PyColormap>,
601}
602
603impl PyParticleRenderer {
604    /// Create a new particle renderer.
605    pub fn new(positions: Vec<f64>) -> Self {
606        let n = positions.len() / 3;
607        Self {
608            positions,
609            radii: vec![0.05; n],
610            colors: vec![0.8, 0.4, 0.1, 1.0]
611                .into_iter()
612                .cycle()
613                .take(n * 4)
614                .collect(),
615            billboard: true,
616            use_instancing: true,
617            colormap: None,
618        }
619    }
620
621    /// Set uniform radius for all particles.
622    pub fn set_uniform_radius(&mut self, radius: f64) {
623        let n = self.positions.len() / 3;
624        self.radii = vec![radius; n];
625    }
626
627    /// Set per-particle colors from a colormap and scalar values.
628    pub fn set_colors_from_scalars(&mut self, scalars: &[f64], colormap: &PyColormap) {
629        let n = self.positions.len() / 3;
630        self.colors.clear();
631        for i in 0..n {
632            let s = scalars.get(i).copied().unwrap_or(0.0);
633            let rgba = colormap.map_scalar(s);
634            self.colors.extend_from_slice(&rgba);
635        }
636        self.colormap = Some(colormap.clone());
637    }
638
639    /// Return the number of particles.
640    pub fn particle_count(&self) -> usize {
641        self.positions.len() / 3
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Transfer function for volume rendering
647// ---------------------------------------------------------------------------
648
649/// A control point in a transfer function (scalar → RGBA).
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct TransferPoint {
652    /// Scalar value in \[0, 1\].
653    pub scalar: f64,
654    /// RGBA color at this point.
655    pub color: [f64; 4],
656}
657
658/// Transfer function mapping scalar densities to RGBA for volume rendering.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct PyTransferFunction {
661    /// Sorted control points.
662    pub control_points: Vec<TransferPoint>,
663}
664
665impl PyTransferFunction {
666    /// Create a simple two-point transfer function.
667    pub fn simple(low_color: [f64; 4], high_color: [f64; 4]) -> Self {
668        Self {
669            control_points: vec![
670                TransferPoint {
671                    scalar: 0.0,
672                    color: low_color,
673                },
674                TransferPoint {
675                    scalar: 1.0,
676                    color: high_color,
677                },
678            ],
679        }
680    }
681
682    /// Sample the transfer function at a scalar value in \[0, 1\].
683    pub fn sample(&self, t: f64) -> [f64; 4] {
684        let pts = &self.control_points;
685        if pts.is_empty() {
686            return [0.0, 0.0, 0.0, 0.0];
687        }
688        if pts.len() == 1 || t <= pts[0].scalar {
689            return pts[0].color;
690        }
691        if t >= pts[pts.len() - 1].scalar {
692            return pts[pts.len() - 1].color;
693        }
694        for i in 1..pts.len() {
695            if t <= pts[i].scalar {
696                let a = pts[i - 1].scalar;
697                let b = pts[i].scalar;
698                let alpha = if (b - a).abs() < 1e-15 {
699                    0.0
700                } else {
701                    (t - a) / (b - a)
702                };
703                let ca = pts[i - 1].color;
704                let cb = pts[i].color;
705                return [
706                    ca[0] + alpha * (cb[0] - ca[0]),
707                    ca[1] + alpha * (cb[1] - ca[1]),
708                    ca[2] + alpha * (cb[2] - ca[2]),
709                    ca[3] + alpha * (cb[3] - ca[3]),
710                ];
711            }
712        }
713        pts[pts.len() - 1].color
714    }
715}
716
717// ---------------------------------------------------------------------------
718// PyVolumeRenderer
719// ---------------------------------------------------------------------------
720
721/// Settings for ray-marching volume rendering.
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct RayMarchSettings {
724    /// Step size along the ray.
725    pub step_size: f64,
726    /// Maximum number of steps.
727    pub max_steps: u32,
728    /// Early termination opacity threshold.
729    pub opacity_threshold: f64,
730    /// Jitter the ray origin to reduce banding.
731    pub jitter: bool,
732}
733
734impl Default for RayMarchSettings {
735    fn default() -> Self {
736        Self {
737            step_size: 0.01,
738            max_steps: 512,
739            opacity_threshold: 0.99,
740            jitter: true,
741        }
742    }
743}
744
745/// Volume renderer for 3D scalar fields.
746#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct PyVolumeRenderer {
748    /// 3D scalar field stored in \[z\]\[y\]\[x\] order, flattened.
749    pub data: Vec<f64>,
750    /// Grid dimensions \[nx, ny, nz\].
751    pub dims: [u32; 3],
752    /// World-space bounding box \[min_x, min_y, min_z, max_x, max_y, max_z\].
753    pub bounds: [f64; 6],
754    /// Transfer function for density → RGBA mapping.
755    pub transfer_function: PyTransferFunction,
756    /// Ray marching settings.
757    pub ray_march: RayMarchSettings,
758}
759
760impl PyVolumeRenderer {
761    /// Create a volume renderer from a flat scalar field.
762    #[allow(clippy::too_many_arguments)]
763    pub fn new(
764        data: Vec<f64>,
765        dims: [u32; 3],
766        bounds: [f64; 6],
767        transfer_function: PyTransferFunction,
768    ) -> Self {
769        Self {
770            data,
771            dims,
772            bounds,
773            transfer_function,
774            ray_march: RayMarchSettings::default(),
775        }
776    }
777
778    /// Sample the scalar field at a grid index (ix, iy, iz).
779    pub fn sample_at(&self, ix: u32, iy: u32, iz: u32) -> f64 {
780        let [nx, ny, _nz] = self.dims;
781        let idx = iz * ny * nx + iy * nx + ix;
782        self.data.get(idx as usize).copied().unwrap_or(0.0)
783    }
784
785    /// Compute the total number of voxels.
786    pub fn voxel_count(&self) -> u64 {
787        self.dims[0] as u64 * self.dims[1] as u64 * self.dims[2] as u64
788    }
789}
790
791// ---------------------------------------------------------------------------
792// PySceneGraph
793// ---------------------------------------------------------------------------
794
795/// A transform node in the scene hierarchy.
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct PySceneNode {
798    /// Node identifier.
799    pub id: u32,
800    /// Optional parent node id.
801    pub parent: Option<u32>,
802    /// Local translation \[tx, ty, tz\].
803    pub translation: [f64; 3],
804    /// Local rotation as quaternion \[qx, qy, qz, qw\].
805    pub rotation: [f64; 4],
806    /// Local scale \[sx, sy, sz\].
807    pub scale: [f64; 3],
808    /// Human-readable label.
809    pub label: String,
810    /// Whether this node is visible.
811    pub visible: bool,
812}
813
814impl PySceneNode {
815    /// Create an identity transform node.
816    pub fn identity(id: u32, label: impl Into<String>) -> Self {
817        Self {
818            id,
819            parent: None,
820            translation: [0.0; 3],
821            rotation: [0.0, 0.0, 0.0, 1.0],
822            scale: [1.0; 3],
823            label: label.into(),
824            visible: true,
825        }
826    }
827}
828
829/// A light source in the scene.
830#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct PyLight {
832    /// Light position \[x, y, z\].
833    pub position: [f64; 3],
834    /// Light color RGB.
835    pub color: [f64; 3],
836    /// Intensity (candela / lm).
837    pub intensity: f64,
838    /// Light type: "point", "directional", "spot".
839    pub light_type: String,
840    /// Spot direction \[dx, dy, dz\] (used for spot lights).
841    pub direction: [f64; 3],
842    /// Spot cone angle in radians.
843    pub cone_angle: f64,
844}
845
846impl PyLight {
847    /// Create a point light.
848    pub fn point(position: [f64; 3], color: [f64; 3], intensity: f64) -> Self {
849        Self {
850            position,
851            color,
852            intensity,
853            light_type: "point".to_owned(),
854            direction: [0.0, -1.0, 0.0],
855            cone_angle: std::f64::consts::PI,
856        }
857    }
858
859    /// Create a directional light (sun).
860    pub fn directional(direction: [f64; 3], color: [f64; 3], intensity: f64) -> Self {
861        Self {
862            position: [0.0; 3],
863            color,
864            intensity,
865            light_type: "directional".to_owned(),
866            direction,
867            cone_angle: std::f64::consts::PI,
868        }
869    }
870}
871
872/// Hierarchical scene graph managing nodes, meshes, particles and lights.
873#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct PySceneGraph {
875    /// All nodes in the scene.
876    pub nodes: Vec<PySceneNode>,
877    /// Mesh renderers attached to node ids.
878    pub meshes: Vec<(u32, PyMeshRenderer)>,
879    /// Particle renderers attached to node ids.
880    pub particles: Vec<(u32, PyParticleRenderer)>,
881    /// Lights in the scene.
882    pub lights: Vec<PyLight>,
883    /// Next free node id.
884    next_id: u32,
885}
886
887impl PySceneGraph {
888    /// Create an empty scene graph.
889    pub fn new() -> Self {
890        Self {
891            nodes: Vec::new(),
892            meshes: Vec::new(),
893            particles: Vec::new(),
894            lights: Vec::new(),
895            next_id: 0,
896        }
897    }
898
899    /// Add a new node and return its id.
900    pub fn add_node(&mut self, label: impl Into<String>) -> u32 {
901        let id = self.next_id;
902        self.next_id += 1;
903        self.nodes.push(PySceneNode::identity(id, label));
904        id
905    }
906
907    /// Attach a mesh to a node.
908    pub fn add_mesh(&mut self, node_id: u32, mesh: PyMeshRenderer) {
909        self.meshes.push((node_id, mesh));
910    }
911
912    /// Attach particles to a node.
913    pub fn add_particles(&mut self, node_id: u32, particles: PyParticleRenderer) {
914        self.particles.push((node_id, particles));
915    }
916
917    /// Add a light to the scene.
918    pub fn add_light(&mut self, light: PyLight) {
919        self.lights.push(light);
920    }
921
922    /// Set visibility for a node.
923    pub fn set_visible(&mut self, node_id: u32, visible: bool) {
924        if let Some(n) = self.nodes.iter_mut().find(|n| n.id == node_id) {
925            n.visible = visible;
926        }
927    }
928
929    /// Set local translation for a node.
930    pub fn set_translation(&mut self, node_id: u32, translation: [f64; 3]) {
931        if let Some(n) = self.nodes.iter_mut().find(|n| n.id == node_id) {
932            n.translation = translation;
933        }
934    }
935
936    /// Traverse all visible nodes and return their ids in order.
937    pub fn traverse_visible(&self) -> Vec<u32> {
938        self.nodes
939            .iter()
940            .filter(|n| n.visible)
941            .map(|n| n.id)
942            .collect()
943    }
944
945    /// Return total number of nodes.
946    pub fn node_count(&self) -> usize {
947        self.nodes.len()
948    }
949}
950
951impl Default for PySceneGraph {
952    fn default() -> Self {
953        Self::new()
954    }
955}
956
957// ---------------------------------------------------------------------------
958// PyPostProcessor
959// ---------------------------------------------------------------------------
960
961/// Screen-space ambient occlusion parameters.
962#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct SsaoParams {
964    /// Sample hemisphere radius in view space.
965    pub radius: f64,
966    /// Bias to avoid self-occlusion.
967    pub bias: f64,
968    /// Number of sample points.
969    pub num_samples: u32,
970    /// Occlusion power exponent.
971    pub power: f64,
972}
973
974impl Default for SsaoParams {
975    fn default() -> Self {
976        Self {
977            radius: 0.5,
978            bias: 0.025,
979            num_samples: 16,
980            power: 2.0,
981        }
982    }
983}
984
985/// Bloom post-processing parameters.
986#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct BloomParams {
988    /// Luminance threshold above which bloom is applied.
989    pub threshold: f64,
990    /// Bloom blur radius in pixels.
991    pub radius: f64,
992    /// Bloom intensity multiplier.
993    pub intensity: f64,
994}
995
996impl Default for BloomParams {
997    fn default() -> Self {
998        Self {
999            threshold: 1.0,
1000            radius: 8.0,
1001            intensity: 0.5,
1002        }
1003    }
1004}
1005
1006/// Tone mapping operator selection.
1007#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1008pub enum ToneMapping {
1009    /// Reinhard global operator.
1010    Reinhard,
1011    /// ACES film curve.
1012    Aces,
1013    /// Filmic tone mapping.
1014    Filmic,
1015    /// No tone mapping (linear).
1016    Linear,
1017}
1018
1019/// Post-processing pipeline configuration.
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct PyPostProcessor {
1022    /// SSAO settings.
1023    pub ssao: Option<SsaoParams>,
1024    /// Bloom settings.
1025    pub bloom: Option<BloomParams>,
1026    /// FXAA edge threshold (0 = disabled).
1027    pub fxaa_edge_threshold: f64,
1028    /// Tone mapping operator.
1029    pub tone_mapping: ToneMapping,
1030    /// Gamma correction exponent (typically 2.2).
1031    pub gamma: f64,
1032    /// Exposure multiplier.
1033    pub exposure: f64,
1034}
1035
1036impl PyPostProcessor {
1037    /// Create a default post-processing pipeline.
1038    pub fn new() -> Self {
1039        Self {
1040            ssao: Some(SsaoParams::default()),
1041            bloom: Some(BloomParams::default()),
1042            fxaa_edge_threshold: 0.063,
1043            tone_mapping: ToneMapping::Aces,
1044            gamma: 2.2,
1045            exposure: 1.0,
1046        }
1047    }
1048
1049    /// Disable all effects (passthrough).
1050    pub fn passthrough() -> Self {
1051        Self {
1052            ssao: None,
1053            bloom: None,
1054            fxaa_edge_threshold: 0.0,
1055            tone_mapping: ToneMapping::Linear,
1056            gamma: 1.0,
1057            exposure: 1.0,
1058        }
1059    }
1060
1061    /// Apply Reinhard tone mapping to an HDR value.
1062    pub fn apply_tone_mapping(&self, hdr: f64) -> f64 {
1063        let v = hdr * self.exposure;
1064        let linear = match self.tone_mapping {
1065            ToneMapping::Reinhard => v / (1.0 + v),
1066            ToneMapping::Aces => {
1067                let a = 2.51;
1068                let b = 0.03;
1069                let c = 2.43;
1070                let d = 0.59;
1071                let e = 0.14;
1072                ((v * (a * v + b)) / (v * (c * v + d) + e)).clamp(0.0, 1.0)
1073            }
1074            ToneMapping::Filmic => {
1075                let x = (v - 0.004).max(0.0);
1076                (x * (6.2 * x + 0.5)) / (x * (6.2 * x + 1.7) + 0.06)
1077            }
1078            ToneMapping::Linear => v.clamp(0.0, 1.0),
1079        };
1080        linear.powf(1.0 / self.gamma)
1081    }
1082}
1083
1084impl Default for PyPostProcessor {
1085    fn default() -> Self {
1086        Self::new()
1087    }
1088}
1089
1090// ---------------------------------------------------------------------------
1091// PyStressVisualizer
1092// ---------------------------------------------------------------------------
1093
1094/// A 3×3 symmetric stress tensor stored as \[s11, s22, s33, s12, s13, s23\].
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct StressTensor {
1097    /// Components \[σ₁₁, σ₂₂, σ₃₃, σ₁₂, σ₁₃, σ₂₃\].
1098    pub components: [f64; 6],
1099}
1100
1101impl StressTensor {
1102    /// Create a new stress tensor.
1103    pub fn new(s11: f64, s22: f64, s33: f64, s12: f64, s13: f64, s23: f64) -> Self {
1104        Self {
1105            components: [s11, s22, s33, s12, s13, s23],
1106        }
1107    }
1108
1109    /// Compute the von Mises stress.
1110    pub fn von_mises(&self) -> f64 {
1111        let [s11, s22, s33, s12, s13, s23] = self.components;
1112        let ds = (s11 - s22).powi(2) + (s22 - s33).powi(2) + (s33 - s11).powi(2);
1113        let shear = s12 * s12 + s13 * s13 + s23 * s23;
1114        (0.5 * ds + 3.0 * shear).sqrt()
1115    }
1116
1117    /// Compute the hydrostatic (mean) stress.
1118    pub fn hydrostatic(&self) -> f64 {
1119        (self.components[0] + self.components[1] + self.components[2]) / 3.0
1120    }
1121
1122    /// Compute the deviatoric stress components.
1123    pub fn deviatoric(&self) -> [f64; 6] {
1124        let p = self.hydrostatic();
1125        let [s11, s22, s33, s12, s13, s23] = self.components;
1126        [s11 - p, s22 - p, s33 - p, s12, s13, s23]
1127    }
1128}
1129
1130/// Output from the stress visualizer for a single tensor.
1131#[derive(Debug, Clone, Serialize, Deserialize)]
1132pub struct StressVisOutput {
1133    /// Von Mises equivalent stress.
1134    pub von_mises: f64,
1135    /// Hydrostatic stress.
1136    pub hydrostatic: f64,
1137    /// Principal stresses \[σ₁, σ₂, σ₃\] (sorted descending).
1138    pub principal_stresses: [f64; 3],
1139    /// Principal directions as flat 3×3 matrix (row-major).
1140    pub principal_directions: [f64; 9],
1141    /// Mohr circle data: \[center_12, radius_12, center_23, radius_23, center_13, radius_13\].
1142    pub mohr_circle: [f64; 6],
1143}
1144
1145/// Stress field visualizer providing principal direction glyphs and Mohr circle data.
1146#[derive(Debug, Clone, Serialize, Deserialize)]
1147pub struct PyStressVisualizer {
1148    /// Input stress tensor field (one per element/node).
1149    pub tensors: Vec<StressTensor>,
1150    /// Colormap for von Mises coloring.
1151    pub colormap: PyColormap,
1152    /// Scale factor for principal direction glyphs.
1153    pub glyph_scale: f64,
1154}
1155
1156impl PyStressVisualizer {
1157    /// Create a new stress visualizer.
1158    pub fn new(tensors: Vec<StressTensor>) -> Self {
1159        let mut cm = PyColormap::new(ColormapKind::Viridis, 0.0, 1.0);
1160        cm.vmax = tensors
1161            .iter()
1162            .map(|t| t.von_mises())
1163            .fold(0.0_f64, f64::max)
1164            .max(1.0);
1165        Self {
1166            tensors,
1167            colormap: cm,
1168            glyph_scale: 1.0,
1169        }
1170    }
1171
1172    /// Compute visualization output for tensor at index i.
1173    pub fn compute(&self, i: usize) -> Option<StressVisOutput> {
1174        let t = self.tensors.get(i)?;
1175        let vm = t.von_mises();
1176        let hydro = t.hydrostatic();
1177        // Approximate principal stresses via Jacobi (2-pass simplified for diagonal dominance)
1178        let [s11, s22, s33, _s12, _s13, _s23] = t.components;
1179        let mut p = [s11, s22, s33];
1180        p.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
1181        let mohr = [
1182            (p[0] + p[1]) * 0.5,
1183            ((p[0] - p[1]) * 0.5).abs(),
1184            (p[1] + p[2]) * 0.5,
1185            ((p[1] - p[2]) * 0.5).abs(),
1186            (p[0] + p[2]) * 0.5,
1187            ((p[0] - p[2]) * 0.5).abs(),
1188        ];
1189        Some(StressVisOutput {
1190            von_mises: vm,
1191            hydrostatic: hydro,
1192            principal_stresses: p,
1193            principal_directions: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
1194            mohr_circle: mohr,
1195        })
1196    }
1197
1198    /// Return von Mises values for all tensors.
1199    pub fn all_von_mises(&self) -> Vec<f64> {
1200        self.tensors.iter().map(|t| t.von_mises()).collect()
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------
1205// PyStreamlineTracer
1206// ---------------------------------------------------------------------------
1207
1208/// Result of streamline tracing.
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1210pub struct Streamline {
1211    /// Flat array of 3D positions along the streamline.
1212    pub points: Vec<f64>,
1213    /// Total arc length.
1214    pub arc_length: f64,
1215}
1216
1217/// Streamline tracer using RK4 integration through a 3D velocity field.
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct PyStreamlineTracer {
1220    /// Flat velocity field \[vx0, vy0, vz0, vx1, ...\] on a uniform grid.
1221    pub velocity_field: Vec<f64>,
1222    /// Grid dimensions \[nx, ny, nz\].
1223    pub dims: [u32; 3],
1224    /// World-space bounds \[min_x, min_y, min_z, max_x, max_y, max_z\].
1225    pub bounds: [f64; 6],
1226    /// Integration step size.
1227    pub step_size: f64,
1228    /// Maximum integration steps per streamline.
1229    pub max_steps: u32,
1230    /// Tube radius for 3D tube rendering.
1231    pub tube_radius: f64,
1232}
1233
1234impl PyStreamlineTracer {
1235    /// Create a new streamline tracer.
1236    pub fn new(velocity_field: Vec<f64>, dims: [u32; 3], bounds: [f64; 6]) -> Self {
1237        Self {
1238            velocity_field,
1239            dims,
1240            bounds,
1241            step_size: 0.01,
1242            max_steps: 512,
1243            tube_radius: 0.01,
1244        }
1245    }
1246
1247    fn sample_velocity(&self, pos: [f64; 3]) -> [f64; 3] {
1248        let [nx, ny, nz] = self.dims;
1249        let [bx0, by0, bz0, bx1, by1, bz1] = self.bounds;
1250        let fx = ((pos[0] - bx0) / (bx1 - bx0) * nx as f64).clamp(0.0, nx as f64 - 1.001);
1251        let fy = ((pos[1] - by0) / (by1 - by0) * ny as f64).clamp(0.0, ny as f64 - 1.001);
1252        let fz = ((pos[2] - bz0) / (bz1 - bz0) * nz as f64).clamp(0.0, nz as f64 - 1.001);
1253        let ix = fx as usize;
1254        let iy = fy as usize;
1255        let iz = fz as usize;
1256        let stride = (nx * ny) as usize;
1257        let base = 3 * (iz * stride + iy * nx as usize + ix);
1258        if base + 2 < self.velocity_field.len() {
1259            [
1260                self.velocity_field[base],
1261                self.velocity_field[base + 1],
1262                self.velocity_field[base + 2],
1263            ]
1264        } else {
1265            [0.0, 0.0, 0.0]
1266        }
1267    }
1268
1269    fn rk4_step(&self, pos: [f64; 3], dt: f64) -> [f64; 3] {
1270        let k1 = self.sample_velocity(pos);
1271        let p2 = [
1272            pos[0] + k1[0] * dt * 0.5,
1273            pos[1] + k1[1] * dt * 0.5,
1274            pos[2] + k1[2] * dt * 0.5,
1275        ];
1276        let k2 = self.sample_velocity(p2);
1277        let p3 = [
1278            pos[0] + k2[0] * dt * 0.5,
1279            pos[1] + k2[1] * dt * 0.5,
1280            pos[2] + k2[2] * dt * 0.5,
1281        ];
1282        let k3 = self.sample_velocity(p3);
1283        let p4 = [
1284            pos[0] + k3[0] * dt,
1285            pos[1] + k3[1] * dt,
1286            pos[2] + k3[2] * dt,
1287        ];
1288        let k4 = self.sample_velocity(p4);
1289        [
1290            pos[0] + dt / 6.0 * (k1[0] + 2.0 * k2[0] + 2.0 * k3[0] + k4[0]),
1291            pos[1] + dt / 6.0 * (k1[1] + 2.0 * k2[1] + 2.0 * k3[1] + k4[1]),
1292            pos[2] + dt / 6.0 * (k1[2] + 2.0 * k2[2] + 2.0 * k3[2] + k4[2]),
1293        ]
1294    }
1295
1296    /// Trace a streamline from a seed point.
1297    pub fn trace(&self, seed: [f64; 3]) -> Streamline {
1298        let mut pts = vec![seed[0], seed[1], seed[2]];
1299        let mut pos = seed;
1300        let mut arc = 0.0;
1301        for _ in 0..self.max_steps {
1302            let next = self.rk4_step(pos, self.step_size);
1303            let dx = next[0] - pos[0];
1304            let dy = next[1] - pos[1];
1305            let dz = next[2] - pos[2];
1306            arc += (dx * dx + dy * dy + dz * dz).sqrt();
1307            pts.push(next[0]);
1308            pts.push(next[1]);
1309            pts.push(next[2]);
1310            pos = next;
1311            let v = self.sample_velocity(pos);
1312            if v[0] * v[0] + v[1] * v[1] + v[2] * v[2] < 1e-18 {
1313                break;
1314            }
1315        }
1316        Streamline {
1317            points: pts,
1318            arc_length: arc,
1319        }
1320    }
1321
1322    /// Trace streamlines from multiple seed points.
1323    pub fn trace_multiple(&self, seeds: &[[f64; 3]]) -> Vec<Streamline> {
1324        seeds.iter().map(|&s| self.trace(s)).collect()
1325    }
1326}
1327
1328// ---------------------------------------------------------------------------
1329// PyDebugOverlay
1330// ---------------------------------------------------------------------------
1331
1332/// A single debug primitive drawn as an overlay.
1333#[derive(Debug, Clone, Serialize, Deserialize)]
1334pub enum DebugPrimitive {
1335    /// A wire-frame sphere.
1336    Sphere {
1337        center: [f64; 3],
1338        radius: f64,
1339        color: [f64; 4],
1340    },
1341    /// An axis-aligned box.
1342    Box {
1343        min: [f64; 3],
1344        max: [f64; 3],
1345        color: [f64; 4],
1346    },
1347    /// An arrow from start to end.
1348    Arrow {
1349        start: [f64; 3],
1350        end: [f64; 3],
1351        color: [f64; 4],
1352        shaft_radius: f64,
1353    },
1354    /// A 3D text label.
1355    Text3D {
1356        position: [f64; 3],
1357        text: String,
1358        color: [f64; 4],
1359        size: f64,
1360    },
1361    /// A contact point with normal.
1362    ContactPoint {
1363        position: [f64; 3],
1364        normal: [f64; 3],
1365        depth: f64,
1366        color: [f64; 4],
1367    },
1368}
1369
1370/// Debug overlay manager for visualizing physics primitives.
1371#[derive(Debug, Clone, Serialize, Deserialize)]
1372pub struct PyDebugOverlay {
1373    /// List of debug primitives to render.
1374    pub primitives: Vec<DebugPrimitive>,
1375    /// Whether to draw wireframe (true) or solid (false).
1376    pub wireframe: bool,
1377    /// Default color for new primitives.
1378    pub default_color: [f64; 4],
1379    /// Whether overlay persists across frames.
1380    pub persistent: bool,
1381}
1382
1383impl PyDebugOverlay {
1384    /// Create a new empty debug overlay.
1385    pub fn new() -> Self {
1386        Self {
1387            primitives: Vec::new(),
1388            wireframe: true,
1389            default_color: [0.0, 1.0, 0.0, 1.0],
1390            persistent: false,
1391        }
1392    }
1393
1394    /// Draw a sphere at the given center.
1395    pub fn draw_sphere(&mut self, center: [f64; 3], radius: f64, color: Option<[f64; 4]>) {
1396        let c = color.unwrap_or(self.default_color);
1397        self.primitives.push(DebugPrimitive::Sphere {
1398            center,
1399            radius,
1400            color: c,
1401        });
1402    }
1403
1404    /// Draw an axis-aligned box.
1405    pub fn draw_box(&mut self, min: [f64; 3], max: [f64; 3], color: Option<[f64; 4]>) {
1406        let c = color.unwrap_or(self.default_color);
1407        self.primitives
1408            .push(DebugPrimitive::Box { min, max, color: c });
1409    }
1410
1411    /// Draw an arrow from start to end.
1412    pub fn draw_arrow(
1413        &mut self,
1414        start: [f64; 3],
1415        end: [f64; 3],
1416        color: Option<[f64; 4]>,
1417        shaft_radius: f64,
1418    ) {
1419        let c = color.unwrap_or(self.default_color);
1420        self.primitives.push(DebugPrimitive::Arrow {
1421            start,
1422            end,
1423            color: c,
1424            shaft_radius,
1425        });
1426    }
1427
1428    /// Draw a 3D text label.
1429    pub fn draw_text_3d(
1430        &mut self,
1431        position: [f64; 3],
1432        text: impl Into<String>,
1433        color: Option<[f64; 4]>,
1434        size: f64,
1435    ) {
1436        let c = color.unwrap_or(self.default_color);
1437        self.primitives.push(DebugPrimitive::Text3D {
1438            position,
1439            text: text.into(),
1440            color: c,
1441            size,
1442        });
1443    }
1444
1445    /// Draw a contact point with normal direction.
1446    pub fn draw_contact_point(
1447        &mut self,
1448        position: [f64; 3],
1449        normal: [f64; 3],
1450        depth: f64,
1451        color: Option<[f64; 4]>,
1452    ) {
1453        let c = color.unwrap_or(self.default_color);
1454        self.primitives.push(DebugPrimitive::ContactPoint {
1455            position,
1456            normal,
1457            depth,
1458            color: c,
1459        });
1460    }
1461
1462    /// Clear all debug primitives.
1463    pub fn clear(&mut self) {
1464        self.primitives.clear();
1465    }
1466
1467    /// Return count of primitives.
1468    pub fn count(&self) -> usize {
1469        self.primitives.len()
1470    }
1471}
1472
1473impl Default for PyDebugOverlay {
1474    fn default() -> Self {
1475        Self::new()
1476    }
1477}
1478
1479// ---------------------------------------------------------------------------
1480// Tests
1481// ---------------------------------------------------------------------------
1482
1483#[cfg(test)]
1484mod tests {
1485    use super::*;
1486
1487    #[test]
1488    fn test_hsv_to_rgb_red() {
1489        let rgb = hsv_to_rgb(0.0, 1.0, 1.0);
1490        assert!((rgb[0] - 1.0).abs() < 1e-9);
1491        assert!(rgb[1] < 1e-9);
1492        assert!(rgb[2] < 1e-9);
1493    }
1494
1495    #[test]
1496    fn test_hsv_to_rgb_green() {
1497        let rgb = hsv_to_rgb(1.0 / 3.0, 1.0, 1.0);
1498        assert!(rgb[1] > 0.99);
1499    }
1500
1501    #[test]
1502    fn test_hsv_to_rgb_achromatic() {
1503        let rgb = hsv_to_rgb(0.0, 0.0, 0.7);
1504        assert!((rgb[0] - 0.7).abs() < 1e-9);
1505        assert!((rgb[1] - 0.7).abs() < 1e-9);
1506        assert!((rgb[2] - 0.7).abs() < 1e-9);
1507    }
1508
1509    #[test]
1510    fn test_colormap_normalize() {
1511        let cm = PyColormap::new(ColormapKind::Viridis, 0.0, 10.0);
1512        assert!((cm.normalize(5.0) - 0.5).abs() < 1e-9);
1513        assert!((cm.normalize(-1.0) - 0.0).abs() < 1e-9);
1514        assert!((cm.normalize(11.0) - 1.0).abs() < 1e-9);
1515    }
1516
1517    #[test]
1518    fn test_colormap_viridis_alpha() {
1519        let cm = PyColormap::viridis();
1520        let rgba = cm.map_value(0.5);
1521        assert_eq!(rgba[3], 1.0);
1522    }
1523
1524    #[test]
1525    fn test_colormap_all_kinds() {
1526        let kinds = [
1527            ColormapKind::Viridis,
1528            ColormapKind::Plasma,
1529            ColormapKind::Magma,
1530            ColormapKind::Inferno,
1531            ColormapKind::Turbo,
1532            ColormapKind::Greys,
1533            ColormapKind::RdBu,
1534            ColormapKind::Spectral,
1535            ColormapKind::Coolwarm,
1536            ColormapKind::Hot,
1537            ColormapKind::Jet,
1538        ];
1539        for kind in kinds {
1540            let cm = PyColormap::new(kind, 0.0, 1.0);
1541            let rgba = cm.map_value(0.5);
1542            for &c in &rgba {
1543                assert!((0.0..=1.0).contains(&c), "component out of range");
1544            }
1545        }
1546    }
1547
1548    #[test]
1549    fn test_camera_default() {
1550        let cam = PyCamera::default_perspective();
1551        assert!(cam.distance_to_target() > 0.0);
1552    }
1553
1554    #[test]
1555    fn test_camera_orbit() {
1556        let mut cam = PyCamera::default_perspective();
1557        let d0 = cam.distance_to_target();
1558        cam.orbit(0.1, 0.05);
1559        let d1 = cam.distance_to_target();
1560        assert!((d0 - d1).abs() < 1e-6);
1561    }
1562
1563    #[test]
1564    fn test_camera_zoom() {
1565        let mut cam = PyCamera::default_perspective();
1566        let d0 = cam.distance_to_target();
1567        cam.zoom(0.5);
1568        let d1 = cam.distance_to_target();
1569        assert!((d1 - d0 * 0.5).abs() < 1e-9);
1570    }
1571
1572    #[test]
1573    fn test_camera_pan() {
1574        let mut cam = PyCamera::default_perspective();
1575        let pos0 = cam.position;
1576        cam.pan([1.0, 2.0, 3.0]);
1577        assert!((cam.position[0] - pos0[0] - 1.0).abs() < 1e-9);
1578    }
1579
1580    #[test]
1581    fn test_camera_view_direction() {
1582        let cam = PyCamera::new([0.0, 0.0, 10.0], [0.0, 0.0, 0.0]);
1583        let d = cam.view_direction();
1584        assert!((d[2] + 1.0).abs() < 1e-9);
1585    }
1586
1587    #[test]
1588    fn test_compute_normals() {
1589        let verts = vec![0.0f64, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
1590        let idx = vec![0u32, 1, 2];
1591        let n = compute_normals_from_mesh(&verts, &idx);
1592        assert_eq!(n.len(), 9);
1593        // Normal should point along +Z
1594        for i in 0..3 {
1595            assert!((n[3 * i + 2] - 1.0).abs() < 1e-9);
1596        }
1597    }
1598
1599    #[test]
1600    fn test_generate_sphere_mesh() {
1601        let (v, n, i) = generate_sphere_mesh(1.0, 8, 16);
1602        assert!(!v.is_empty());
1603        assert_eq!(v.len(), n.len());
1604        assert!(!i.is_empty());
1605    }
1606
1607    #[test]
1608    fn test_generate_box_mesh() {
1609        let (v, n, i) = generate_box_mesh([1.0, 1.0, 1.0]);
1610        assert_eq!(v.len(), 24 * 3);
1611        assert_eq!(n.len(), 24 * 3);
1612        assert_eq!(i.len(), 36);
1613    }
1614
1615    #[test]
1616    fn test_mesh_renderer_vertex_count() {
1617        let (v, _, i) = generate_sphere_mesh(1.0, 4, 8);
1618        let mat = PyMaterial::red();
1619        let mesh = PyMeshRenderer::new(v, i, mat);
1620        assert!(mesh.vertex_count() > 0);
1621        assert!(mesh.triangle_count() > 0);
1622    }
1623
1624    #[test]
1625    fn test_mesh_renderer_apply_colormap() {
1626        let (v, _, i) = generate_sphere_mesh(1.0, 4, 8);
1627        let n = v.len() / 3;
1628        let mat = PyMaterial::blue();
1629        let mut mesh = PyMeshRenderer::new(v, i, mat);
1630        let scalars = vec![0.5; n];
1631        mesh.apply_colormap(scalars, PyColormap::viridis());
1632        assert!(mesh.scalars.is_some());
1633    }
1634
1635    #[test]
1636    fn test_particle_renderer_count() {
1637        let pos = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1638        let pr = PyParticleRenderer::new(pos);
1639        assert_eq!(pr.particle_count(), 2);
1640    }
1641
1642    #[test]
1643    fn test_particle_set_colors_from_scalars() {
1644        let pos = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1645        let mut pr = PyParticleRenderer::new(pos);
1646        pr.set_colors_from_scalars(&[0.0, 1.0], &PyColormap::viridis());
1647        assert_eq!(pr.colors.len(), 8);
1648    }
1649
1650    #[test]
1651    fn test_transfer_function_sample() {
1652        let tf = PyTransferFunction::simple([0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0]);
1653        let mid = tf.sample(0.5);
1654        assert!((mid[0] - 0.5).abs() < 1e-9);
1655        assert!((mid[2] - 0.5).abs() < 1e-9);
1656    }
1657
1658    #[test]
1659    fn test_volume_renderer_sample() {
1660        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1661        let tf = PyTransferFunction::simple([0.0; 4], [1.0; 4]);
1662        let vr = PyVolumeRenderer::new(data, [2, 2, 2], [0.0, 0.0, 0.0, 1.0, 1.0, 1.0], tf);
1663        assert_eq!(vr.sample_at(0, 0, 0), 1.0);
1664        assert_eq!(vr.sample_at(1, 1, 1), 8.0);
1665        assert_eq!(vr.voxel_count(), 8);
1666    }
1667
1668    #[test]
1669    fn test_scene_graph_add_node() {
1670        let mut sg = PySceneGraph::new();
1671        let id = sg.add_node("root");
1672        assert_eq!(id, 0);
1673        assert_eq!(sg.node_count(), 1);
1674    }
1675
1676    #[test]
1677    fn test_scene_graph_visibility() {
1678        let mut sg = PySceneGraph::new();
1679        let id = sg.add_node("node");
1680        sg.set_visible(id, false);
1681        assert_eq!(sg.traverse_visible().len(), 0);
1682        sg.set_visible(id, true);
1683        assert_eq!(sg.traverse_visible().len(), 1);
1684    }
1685
1686    #[test]
1687    fn test_post_processor_tone_mapping() {
1688        let pp = PyPostProcessor::new();
1689        let v = pp.apply_tone_mapping(1.0);
1690        assert!((0.0..=1.0).contains(&v));
1691    }
1692
1693    #[test]
1694    fn test_post_processor_passthrough() {
1695        let pp = PyPostProcessor::passthrough();
1696        assert_eq!(pp.tone_mapping, ToneMapping::Linear);
1697        assert!(pp.ssao.is_none());
1698    }
1699
1700    #[test]
1701    fn test_stress_tensor_von_mises() {
1702        // Pure shear: τ = 1 → von Mises = sqrt(3)
1703        let t = StressTensor::new(0.0, 0.0, 0.0, 1.0, 0.0, 0.0);
1704        let vm = t.von_mises();
1705        assert!((vm - 3.0_f64.sqrt()).abs() < 1e-9);
1706    }
1707
1708    #[test]
1709    fn test_stress_tensor_hydrostatic() {
1710        let t = StressTensor::new(3.0, 3.0, 3.0, 0.0, 0.0, 0.0);
1711        assert!((t.hydrostatic() - 3.0).abs() < 1e-9);
1712    }
1713
1714    #[test]
1715    fn test_stress_visualizer_compute() {
1716        let t = StressTensor::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0);
1717        let vis = PyStressVisualizer::new(vec![t]);
1718        let out = vis.compute(0).unwrap();
1719        assert!(out.von_mises >= 0.0);
1720    }
1721
1722    #[test]
1723    fn test_streamline_tracer_trace() {
1724        // Uniform velocity field in +x direction
1725        let nx = 4u32;
1726        let ny = 4u32;
1727        let nz = 4u32;
1728        let n = (nx * ny * nz) as usize;
1729        let mut vf = vec![0.0f64; n * 3];
1730        for i in 0..n {
1731            vf[3 * i] = 1.0; // vx = 1
1732        }
1733        let tr = PyStreamlineTracer::new(vf, [nx, ny, nz], [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
1734        let sl = tr.trace([0.1, 0.5, 0.5]);
1735        assert!(!sl.points.is_empty());
1736        assert!(sl.arc_length >= 0.0);
1737    }
1738
1739    #[test]
1740    fn test_debug_overlay_draw() {
1741        let mut ov = PyDebugOverlay::new();
1742        ov.draw_sphere([0.0, 0.0, 0.0], 1.0, None);
1743        ov.draw_box([0.0; 3], [1.0; 3], None);
1744        ov.draw_arrow([0.0; 3], [1.0, 0.0, 0.0], None, 0.05);
1745        ov.draw_text_3d([0.0; 3], "hello", None, 0.1);
1746        ov.draw_contact_point([0.0; 3], [0.0, 1.0, 0.0], 0.01, None);
1747        assert_eq!(ov.count(), 5);
1748        ov.clear();
1749        assert_eq!(ov.count(), 0);
1750    }
1751
1752    #[test]
1753    fn test_mesh_renderer_render_to_buffer() {
1754        let (v, _, i) = generate_box_mesh([1.0, 1.0, 1.0]);
1755        let mat = PyMaterial::silver();
1756        let mesh = PyMeshRenderer::new(v, i, mat);
1757        let buf = mesh.render_to_buffer(4, 4, [0, 0, 0, 255]);
1758        assert_eq!(buf.len(), 4 * 4 * 4);
1759    }
1760}