1#![allow(missing_docs)]
10#![allow(dead_code)]
11
12use serde::{Deserialize, Serialize};
13
14pub 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
41pub 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
84pub 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
130pub fn generate_box_mesh(half_extents: [f64; 3]) -> (Vec<f64>, Vec<f64>, Vec<u32>) {
135 let [hx, hy, hz] = half_extents;
136 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub enum ColormapKind {
187 Viridis,
189 Plasma,
191 Magma,
193 Inferno,
195 Turbo,
197 Greys,
199 RdBu,
201 Spectral,
203 Coolwarm,
205 Hot,
207 Jet,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct PyColormap {
214 pub kind: ColormapKind,
216 pub vmin: f64,
218 pub vmax: f64,
220 pub alpha: f64,
222}
223
224impl PyColormap {
225 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 pub fn viridis() -> Self {
237 Self::new(ColormapKind::Viridis, 0.0, 1.0)
238 }
239
240 pub fn plasma() -> Self {
242 Self::new(ColormapKind::Plasma, 0.0, 1.0)
243 }
244
245 pub fn jet() -> Self {
247 Self::new(ColormapKind::Jet, 0.0, 1.0)
248 }
249
250 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PyCamera {
358 pub position: [f64; 3],
360 pub target: [f64; 3],
362 pub up: [f64; 3],
364 pub fov_deg: f64,
366 pub near: f64,
368 pub far: f64,
370 pub aspect: f64,
372}
373
374impl PyCamera {
375 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 pub fn default_perspective() -> Self {
390 Self::new([0.0, 5.0, 10.0], [0.0, 0.0, 0.0])
391 }
392
393 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 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 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 pub fn look_at(&mut self, target: [f64; 3]) {
430 self.target = target;
431 }
432
433 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct PyMaterial {
461 pub base_color: [f64; 4],
463 pub metallic: f64,
465 pub roughness: f64,
467 pub emissive: [f64; 3],
469 pub double_sided: bool,
471 pub colormap: Option<PyColormap>,
473}
474
475impl PyMaterial {
476 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 pub fn red() -> Self {
490 Self::new([1.0, 0.0, 0.0, 1.0])
491 }
492
493 pub fn blue() -> Self {
495 Self::new([0.0, 0.3, 1.0, 1.0])
496 }
497
498 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#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct PyMeshRenderer {
518 pub vertices: Vec<f64>,
520 pub normals: Vec<f64>,
522 pub indices: Vec<u32>,
524 pub material: PyMaterial,
526 pub scalars: Option<Vec<f64>>,
528}
529
530impl PyMeshRenderer {
531 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 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 pub fn vertex_count(&self) -> usize {
553 self.vertices.len() / 3
554 }
555
556 pub fn triangle_count(&self) -> usize {
558 self.indices.len() / 3
559 }
560
561 pub fn recompute_normals(&mut self) {
563 self.normals = compute_normals_from_mesh(&self.vertices, &self.indices);
564 }
565
566 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#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct PyParticleRenderer {
589 pub positions: Vec<f64>,
591 pub radii: Vec<f64>,
593 pub colors: Vec<f64>,
595 pub billboard: bool,
597 pub use_instancing: bool,
599 pub colormap: Option<PyColormap>,
601}
602
603impl PyParticleRenderer {
604 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 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 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 pub fn particle_count(&self) -> usize {
641 self.positions.len() / 3
642 }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct TransferPoint {
652 pub scalar: f64,
654 pub color: [f64; 4],
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct PyTransferFunction {
661 pub control_points: Vec<TransferPoint>,
663}
664
665impl PyTransferFunction {
666 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct RayMarchSettings {
724 pub step_size: f64,
726 pub max_steps: u32,
728 pub opacity_threshold: f64,
730 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#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct PyVolumeRenderer {
748 pub data: Vec<f64>,
750 pub dims: [u32; 3],
752 pub bounds: [f64; 6],
754 pub transfer_function: PyTransferFunction,
756 pub ray_march: RayMarchSettings,
758}
759
760impl PyVolumeRenderer {
761 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct PySceneNode {
798 pub id: u32,
800 pub parent: Option<u32>,
802 pub translation: [f64; 3],
804 pub rotation: [f64; 4],
806 pub scale: [f64; 3],
808 pub label: String,
810 pub visible: bool,
812}
813
814impl PySceneNode {
815 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#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct PyLight {
832 pub position: [f64; 3],
834 pub color: [f64; 3],
836 pub intensity: f64,
838 pub light_type: String,
840 pub direction: [f64; 3],
842 pub cone_angle: f64,
844}
845
846impl PyLight {
847 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct PySceneGraph {
875 pub nodes: Vec<PySceneNode>,
877 pub meshes: Vec<(u32, PyMeshRenderer)>,
879 pub particles: Vec<(u32, PyParticleRenderer)>,
881 pub lights: Vec<PyLight>,
883 next_id: u32,
885}
886
887impl PySceneGraph {
888 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 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 pub fn add_mesh(&mut self, node_id: u32, mesh: PyMeshRenderer) {
909 self.meshes.push((node_id, mesh));
910 }
911
912 pub fn add_particles(&mut self, node_id: u32, particles: PyParticleRenderer) {
914 self.particles.push((node_id, particles));
915 }
916
917 pub fn add_light(&mut self, light: PyLight) {
919 self.lights.push(light);
920 }
921
922 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct SsaoParams {
964 pub radius: f64,
966 pub bias: f64,
968 pub num_samples: u32,
970 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#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct BloomParams {
988 pub threshold: f64,
990 pub radius: f64,
992 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1008pub enum ToneMapping {
1009 Reinhard,
1011 Aces,
1013 Filmic,
1015 Linear,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct PyPostProcessor {
1022 pub ssao: Option<SsaoParams>,
1024 pub bloom: Option<BloomParams>,
1026 pub fxaa_edge_threshold: f64,
1028 pub tone_mapping: ToneMapping,
1030 pub gamma: f64,
1032 pub exposure: f64,
1034}
1035
1036impl PyPostProcessor {
1037 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct StressTensor {
1097 pub components: [f64; 6],
1099}
1100
1101impl StressTensor {
1102 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 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 pub fn hydrostatic(&self) -> f64 {
1119 (self.components[0] + self.components[1] + self.components[2]) / 3.0
1120 }
1121
1122 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#[derive(Debug, Clone, Serialize, Deserialize)]
1132pub struct StressVisOutput {
1133 pub von_mises: f64,
1135 pub hydrostatic: f64,
1137 pub principal_stresses: [f64; 3],
1139 pub principal_directions: [f64; 9],
1141 pub mohr_circle: [f64; 6],
1143}
1144
1145#[derive(Debug, Clone, Serialize, Deserialize)]
1147pub struct PyStressVisualizer {
1148 pub tensors: Vec<StressTensor>,
1150 pub colormap: PyColormap,
1152 pub glyph_scale: f64,
1154}
1155
1156impl PyStressVisualizer {
1157 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 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 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 pub fn all_von_mises(&self) -> Vec<f64> {
1200 self.tensors.iter().map(|t| t.von_mises()).collect()
1201 }
1202}
1203
1204#[derive(Debug, Clone, Serialize, Deserialize)]
1210pub struct Streamline {
1211 pub points: Vec<f64>,
1213 pub arc_length: f64,
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct PyStreamlineTracer {
1220 pub velocity_field: Vec<f64>,
1222 pub dims: [u32; 3],
1224 pub bounds: [f64; 6],
1226 pub step_size: f64,
1228 pub max_steps: u32,
1230 pub tube_radius: f64,
1232}
1233
1234impl PyStreamlineTracer {
1235 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 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 pub fn trace_multiple(&self, seeds: &[[f64; 3]]) -> Vec<Streamline> {
1324 seeds.iter().map(|&s| self.trace(s)).collect()
1325 }
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize)]
1334pub enum DebugPrimitive {
1335 Sphere {
1337 center: [f64; 3],
1338 radius: f64,
1339 color: [f64; 4],
1340 },
1341 Box {
1343 min: [f64; 3],
1344 max: [f64; 3],
1345 color: [f64; 4],
1346 },
1347 Arrow {
1349 start: [f64; 3],
1350 end: [f64; 3],
1351 color: [f64; 4],
1352 shaft_radius: f64,
1353 },
1354 Text3D {
1356 position: [f64; 3],
1357 text: String,
1358 color: [f64; 4],
1359 size: f64,
1360 },
1361 ContactPoint {
1363 position: [f64; 3],
1364 normal: [f64; 3],
1365 depth: f64,
1366 color: [f64; 4],
1367 },
1368}
1369
1370#[derive(Debug, Clone, Serialize, Deserialize)]
1372pub struct PyDebugOverlay {
1373 pub primitives: Vec<DebugPrimitive>,
1375 pub wireframe: bool,
1377 pub default_color: [f64; 4],
1379 pub persistent: bool,
1381}
1382
1383impl PyDebugOverlay {
1384 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 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 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 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 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 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 pub fn clear(&mut self) {
1464 self.primitives.clear();
1465 }
1466
1467 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#[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 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 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 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; }
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}