Skip to main content

geop_core_math/primitives/
scene.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::{
6    geop_error::{DebugContext, GeopError, GeopResult},
7    scalars::Scalar,
8    vector::Vector3,
9};
10
11use super::{Color10, Line, TriangleFace};
12
13/// Trait for objects that can be uniformly sampled into 3D points for rendering.
14pub trait RasterizableCurve<S: Scalar> {
15    /// Evaluate the curve at parameter `t` and return a 3D point.
16    fn eval_at(&self, t: S) -> GeopResult<Vector3<S>>;
17}
18
19/// Trait for objects that can be uniformly sampled into 3D points for rendering.
20pub trait RasterizableSurface<S: Scalar> {
21    /// Evaluate the surface at `(u, v)` and return a 3D point.
22    fn eval_at(&self, u: S, v: S) -> GeopResult<Vector3<S>>;
23}
24
25// ── unique id counter ─────────────────────────────────────────────────────────
26
27static SCENE_ID: AtomicU64 = AtomicU64::new(0);
28fn next_id() -> u64 {
29    SCENE_ID.fetch_add(1, Ordering::Relaxed)
30}
31
32fn sample_surface_grid<S: Scalar>(
33    surface: &dyn RasterizableSurface<S>,
34    u_min: S,
35    u_max: S,
36    v_min: S,
37    v_max: S,
38    n: usize,
39) -> GeopResult<Vec<Vector3<S>>> {
40    let mut grid = Vec::with_capacity(n * n);
41    for j in 0..n {
42        for i in 0..n {
43            let u = if i == 0 {
44                u_min
45            } else if i == n - 1 {
46                u_max
47            } else {
48                let f = S::from_ratio(i as i64, (n - 1) as i64)?;
49                u_min.add(u_max.sub(u_min).mul(f))
50            };
51            let v = if j == 0 {
52                v_min
53            } else if j == n - 1 {
54                v_max
55            } else {
56                let f = S::from_ratio(j as i64, (n - 1) as i64)?;
57                v_min.add(v_max.sub(v_min).mul(f))
58            };
59            grid.push(surface.eval_at(u, v)?);
60        }
61    }
62    Ok(grid)
63}
64
65// ── PrimitiveScene ────────────────────────────────────────────────────────────
66
67pub struct PrimitiveScene<S: Scalar> {
68    pub points: Vec<(Vector3<S>, Color10)>,
69    pub lines: Vec<(Line<S>, Color10)>,
70    /// Like `lines`, but rendered with depth-testing disabled (and after
71    /// everything else), so they stay visible on top of solid triangles
72    /// instead of being occluded — useful for highlighting intersection
73    /// curves on a solid (non-wireframe) render.
74    pub highlight_lines: Vec<(Line<S>, Color10)>,
75    pub triangles: Vec<(TriangleFace<S>, Color10)>,
76    /// Triangles with an independent color per vertex (`a`, `b`, `c`),
77    /// interpolated across the face — useful for telling the three corners
78    /// of a debug triangle apart at a glance.
79    pub triangles_rgb: Vec<(TriangleFace<S>, Color10, Color10, Color10)>,
80    /// Triangles rendered with alpha blending (`color`, `opacity` in
81    /// `[0, 1]`) — e.g. so a face's fill doesn't hide the trim curves,
82    /// vertices or labels drawn on top of/behind it.
83    pub triangles_transparent: Vec<(TriangleFace<S>, Color10, f64)>,
84    /// Text labels anchored at a 3-D world position (e.g. an entity's id),
85    /// rendered as always-facing-camera HTML overlays via `CSS2DRenderer`.
86    pub labels: Vec<(Vector3<S>, String, Color10)>,
87    /// Thick tubes (`start`, `end`, `radius`, `color`) — e.g. coordinate
88    /// system axes, rendered as solid geometry instead of a `Line`, since a
89    /// `THREE.Line`'s width can't be controlled portably across GPUs.
90    pub cylinders: Vec<(Vector3<S>, Vector3<S>, f64, Color10)>,
91    pub debug_text: String,
92    rendered_path: OnceLock<String>,
93}
94
95impl<S: Scalar> core::fmt::Debug for PrimitiveScene<S> {
96    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
97        write!(
98            f,
99            "PrimitiveScene({} pts, {} lines, {} tris)",
100            self.points.len(),
101            self.lines.len(),
102            self.triangles.len()
103        )
104    }
105}
106
107impl<S: Scalar> PrimitiveScene<S> {
108    pub fn new() -> Self {
109        Self {
110            points: Vec::new(),
111            lines: Vec::new(),
112            highlight_lines: Vec::new(),
113            triangles: Vec::new(),
114            triangles_rgb: Vec::new(),
115            triangles_transparent: Vec::new(),
116            labels: Vec::new(),
117            cylinders: Vec::new(),
118            debug_text: String::new(),
119            rendered_path: OnceLock::new(),
120        }
121    }
122
123    pub fn add_point(&mut self, p: Vector3<S>, c: Color10) {
124        self.points.push((p, c));
125    }
126
127    pub fn add_line(&mut self, l: Line<S>, c: Color10) {
128        self.lines.push((l, c));
129    }
130
131    /// Draw an already-sampled polyline as `n - 1` line segments, skipping
132    /// any consecutive pair of (near-)coincident points.
133    pub fn add_polyline(&mut self, points: &[Vector3<S>], color: Color10) {
134        for w in points.windows(2) {
135            if let Ok(seg) = Line::try_new(w[0], w[1]) {
136                self.add_line(seg, color);
137            }
138        }
139    }
140
141    /// Like `add_line`, but drawn with depth-testing disabled — see
142    /// `highlight_lines`'s doc comment.
143    pub fn add_highlight_line(&mut self, l: Line<S>, c: Color10) {
144        self.highlight_lines.push((l, c));
145    }
146
147    /// Like `add_polyline`, but drawn with depth-testing disabled — see
148    /// `highlight_lines`'s doc comment.
149    pub fn add_highlight_polyline(&mut self, points: &[Vector3<S>], color: Color10) {
150        for w in points.windows(2) {
151            if let Ok(seg) = Line::try_new(w[0], w[1]) {
152                self.add_highlight_line(seg, color);
153            }
154        }
155    }
156
157    pub fn add_triangle(&mut self, t: TriangleFace<S>, c: Color10) {
158        self.triangles.push((t, c));
159    }
160
161    /// Add a triangle with an independent color per vertex (`t.a` → `ca`,
162    /// `t.b` → `cb`, `t.c` → `cc`), interpolated across the face.
163    pub fn add_triangle_rgb(&mut self, t: TriangleFace<S>, ca: Color10, cb: Color10, cc: Color10) {
164        self.triangles_rgb.push((t, ca, cb, cc));
165    }
166
167    /// Add a triangle rendered with alpha blending — `opacity` in `[0, 1]`
168    /// (`0` invisible, `1` opaque).
169    pub fn add_triangle_transparent(&mut self, t: TriangleFace<S>, c: Color10, opacity: f64) {
170        self.triangles_transparent.push((t, c, opacity));
171    }
172
173    /// Add a text label anchored at `pos` in world space — rendered as an
174    /// always-facing-camera HTML overlay, colored `c`.
175    pub fn add_label(&mut self, pos: Vector3<S>, text: impl Into<String>, c: Color10) {
176        self.labels.push((pos, text.into(), c));
177    }
178
179    /// Add a solid cylindrical tube from `start` to `end`, `radius` wide —
180    /// e.g. for drawing thick axes/arrows that stay visibly wide regardless
181    /// of GPU line-width support.
182    pub fn add_cylinder(&mut self, start: Vector3<S>, end: Vector3<S>, radius: f64, c: Color10) {
183        self.cylinders.push((start, end, radius, c));
184    }
185
186    pub fn add_scene(&mut self, other: PrimitiveScene<S>) {
187        self.points.extend(other.points);
188        self.lines.extend(other.lines);
189        self.highlight_lines.extend(other.highlight_lines);
190        self.triangles.extend(other.triangles);
191        self.triangles_rgb.extend(other.triangles_rgb);
192        self.triangles_transparent
193            .extend(other.triangles_transparent);
194        self.labels.extend(other.labels);
195        self.cylinders.extend(other.cylinders);
196        if !other.debug_text.is_empty() {
197            if !self.debug_text.is_empty() {
198                self.debug_text.push('\n');
199            }
200            self.debug_text.push_str(&other.debug_text);
201        }
202    }
203
204    pub fn set_debug_text(&mut self, text: String) {
205        self.debug_text = text;
206    }
207    pub fn add_debug_text(&mut self, text: String) {
208        if !self.debug_text.is_empty() {
209            self.debug_text.push('\n');
210        }
211        self.debug_text.push_str(&text);
212    }
213
214    /// Rasterize a curve: n uniform samples → n-1 line segments.
215    pub fn add_curve(
216        &mut self,
217        curve: &dyn RasterizableCurve<S>,
218        t_min: S,
219        t_max: S,
220        color: Color10,
221        n: usize,
222    ) -> GeopResult<()> {
223        if n < 2 {
224            return Err(GeopError::new("add_curve: n must be >= 2"));
225        }
226        let mut pts = Vec::with_capacity(n);
227        for i in 0..n {
228            let t = if i == 0 {
229                t_min
230            } else if i == n - 1 {
231                t_max
232            } else {
233                let frac = S::from_ratio(i as i64, (n - 1) as i64)?;
234                t_min.add(t_max.sub(t_min).mul(frac))
235            };
236            pts.push(curve.eval_at(t)?);
237        }
238        for i in 0..n - 1 {
239            if let Ok(seg) = Line::try_new(pts[i].clone(), pts[i + 1].clone()) {
240                self.add_line(seg, color);
241            }
242        }
243        Ok(())
244    }
245
246    /// Rasterize a surface: n×n quad grid → 2n² triangles.
247    #[allow(clippy::too_many_arguments)]
248    pub fn add_surface(
249        &mut self,
250        surface: &dyn RasterizableSurface<S>,
251        color: Color10,
252        u_min: S,
253        u_max: S,
254        v_min: S,
255        v_max: S,
256        n: usize,
257    ) -> GeopResult<()> {
258        if n < 2 {
259            return Err(GeopError::new("add_surface: n must be >= 2"));
260        }
261        let grid = sample_surface_grid(surface, u_min, u_max, v_min, v_max, n)?;
262        for j in 0..n - 1 {
263            for i in 0..n - 1 {
264                let p00 = grid[j * n + i].clone();
265                let p10 = grid[j * n + i + 1].clone();
266                let p01 = grid[(j + 1) * n + i].clone();
267                let p11 = grid[(j + 1) * n + i + 1].clone();
268                if let Ok(t) = TriangleFace::try_new(p00, p10.clone(), p01.clone()) {
269                    self.add_triangle(t, color);
270                }
271                if let Ok(t) = TriangleFace::try_new(p10, p11, p01) {
272                    self.add_triangle(t, color);
273                }
274            }
275        }
276        Ok(())
277    }
278
279    /// Wireframe: n×n grid of quads emitted as line segments.
280    #[allow(clippy::too_many_arguments)]
281    pub fn add_surface_wireframe(
282        &mut self,
283        surface: &dyn RasterizableSurface<S>,
284        color: Color10,
285        u_min: S,
286        u_max: S,
287        v_min: S,
288        v_max: S,
289        n: usize,
290    ) -> GeopResult<()> {
291        if n < 2 {
292            return Err(GeopError::new("add_surface_wireframe: n must be >= 2"));
293        }
294        let grid = sample_surface_grid(surface, u_min, u_max, v_min, v_max, n)?;
295        for j in 0..n {
296            for i in 0..n - 1 {
297                if let Ok(l) = Line::try_new(grid[j * n + i].clone(), grid[j * n + i + 1].clone()) {
298                    self.add_line(l, color);
299                }
300            }
301        }
302        for i in 0..n {
303            for j in 0..n - 1 {
304                if let Ok(l) = Line::try_new(grid[j * n + i].clone(), grid[(j + 1) * n + i].clone())
305                {
306                    self.add_line(l, color);
307                }
308            }
309        }
310        Ok(())
311    }
312
313    /// Check that `self.triangles` forms a watertight (closed, 2-manifold)
314    /// mesh: every triangle edge, quantized to a grid of size `eps` and
315    /// identified regardless of direction, must be shared by exactly 2
316    /// triangles.
317    pub fn is_watertight(&self, eps: f64) -> Result<(), String> {
318        let quantize = |p: &Vector3<S>| -> (i64, i64, i64) {
319            (
320                (p[0].to_f64() / eps).round() as i64,
321                (p[1].to_f64() / eps).round() as i64,
322                (p[2].to_f64() / eps).round() as i64,
323            )
324        };
325
326        let mut edge_uses: HashMap<((i64, i64, i64), (i64, i64, i64)), Vec<usize>> = HashMap::new();
327        for (idx, (t, _)) in self.triangles.iter().enumerate() {
328            let qa = quantize(&t.a);
329            let qb = quantize(&t.b);
330            let qc = quantize(&t.c);
331            for (p, q) in [(qa, qb), (qb, qc), (qc, qa)] {
332                let key = if p <= q { (p, q) } else { (q, p) };
333                edge_uses.entry(key).or_default().push(idx);
334            }
335        }
336
337        let bad: Vec<_> = edge_uses
338            .iter()
339            .filter(|(_, tris)| tris.len() != 2)
340            .collect();
341        if bad.is_empty() {
342            return Ok(());
343        }
344        let mut msg = format!(
345            "mesh is not watertight: {} edge(s) not shared by exactly 2 triangles:",
346            bad.len()
347        );
348        for (edge, tris) in bad.iter().take(10) {
349            msg.push_str(&format!(
350                "\n  edge {edge:?} used by {} triangle(s): {tris:?}",
351                tris.len()
352            ));
353        }
354        Err(msg)
355    }
356
357    /// Write a self-contained HTML file with an embedded three.js scene.
358    pub fn save_to_file(&self, filename: &str) -> GeopResult<()> {
359        let html = self.render_html();
360        std::fs::write(filename, html)
361            .map_err(|e| GeopError::new(format!("PrimitiveScene::save_to_file: {e}")))?;
362        Ok(())
363    }
364
365    fn render_html(&self) -> String {
366        let points_js = self
367            .points
368            .iter()
369            .map(|(p, c)| {
370                format!(
371                    "[{},{},{},{}]",
372                    p[0].to_f64(),
373                    p[1].to_f64(),
374                    p[2].to_f64(),
375                    c.to_hex()
376                )
377            })
378            .collect::<Vec<_>>()
379            .join(",");
380
381        let lines_js = self
382            .lines
383            .iter()
384            .map(|(l, c)| {
385                let s = l.start();
386                let e = l.end();
387                format!(
388                    "[{},{},{},{},{},{},{}]",
389                    s[0].to_f64(),
390                    s[1].to_f64(),
391                    s[2].to_f64(),
392                    e[0].to_f64(),
393                    e[1].to_f64(),
394                    e[2].to_f64(),
395                    c.to_hex()
396                )
397            })
398            .collect::<Vec<_>>()
399            .join(",");
400
401        let highlight_lines_js = self
402            .highlight_lines
403            .iter()
404            .map(|(l, c)| {
405                let s = l.start();
406                let e = l.end();
407                format!(
408                    "[{},{},{},{},{},{},{}]",
409                    s[0].to_f64(),
410                    s[1].to_f64(),
411                    s[2].to_f64(),
412                    e[0].to_f64(),
413                    e[1].to_f64(),
414                    e[2].to_f64(),
415                    c.to_hex()
416                )
417            })
418            .collect::<Vec<_>>()
419            .join(",");
420
421        let tris_js = self
422            .triangles
423            .iter()
424            .map(|(t, c)| {
425                format!(
426                    "[{},{},{},{},{},{},{},{},{},{}]",
427                    t.a[0].to_f64(),
428                    t.a[1].to_f64(),
429                    t.a[2].to_f64(),
430                    t.b[0].to_f64(),
431                    t.b[1].to_f64(),
432                    t.b[2].to_f64(),
433                    t.c[0].to_f64(),
434                    t.c[1].to_f64(),
435                    t.c[2].to_f64(),
436                    c.to_hex()
437                )
438            })
439            .collect::<Vec<_>>()
440            .join(",");
441
442        let tris_rgb_js = self
443            .triangles_rgb
444            .iter()
445            .map(|(t, ca, cb, cc)| {
446                format!(
447                    "[{},{},{},{},{},{},{},{},{},{},{},{}]",
448                    t.a[0].to_f64(),
449                    t.a[1].to_f64(),
450                    t.a[2].to_f64(),
451                    t.b[0].to_f64(),
452                    t.b[1].to_f64(),
453                    t.b[2].to_f64(),
454                    t.c[0].to_f64(),
455                    t.c[1].to_f64(),
456                    t.c[2].to_f64(),
457                    ca.to_hex(),
458                    cb.to_hex(),
459                    cc.to_hex()
460                )
461            })
462            .collect::<Vec<_>>()
463            .join(",");
464
465        let tris_transparent_js = self
466            .triangles_transparent
467            .iter()
468            .map(|(t, c, opacity)| {
469                format!(
470                    "[{},{},{},{},{},{},{},{},{},{},{}]",
471                    t.a[0].to_f64(),
472                    t.a[1].to_f64(),
473                    t.a[2].to_f64(),
474                    t.b[0].to_f64(),
475                    t.b[1].to_f64(),
476                    t.b[2].to_f64(),
477                    t.c[0].to_f64(),
478                    t.c[1].to_f64(),
479                    t.c[2].to_f64(),
480                    c.to_hex(),
481                    opacity
482                )
483            })
484            .collect::<Vec<_>>()
485            .join(",");
486
487        let labels_js = self
488            .labels
489            .iter()
490            .map(|(p, text, c)| {
491                let escaped = text.replace('\\', "\\\\").replace('`', "'");
492                format!(
493                    "[{},{},{},`{}`,{}]",
494                    p[0].to_f64(),
495                    p[1].to_f64(),
496                    p[2].to_f64(),
497                    escaped,
498                    c.to_hex()
499                )
500            })
501            .collect::<Vec<_>>()
502            .join(",");
503
504        let cylinders_js = self
505            .cylinders
506            .iter()
507            .map(|(s, e, r, c)| {
508                format!(
509                    "[{},{},{},{},{},{},{},{}]",
510                    s[0].to_f64(),
511                    s[1].to_f64(),
512                    s[2].to_f64(),
513                    e[0].to_f64(),
514                    e[1].to_f64(),
515                    e[2].to_f64(),
516                    r,
517                    c.to_hex()
518                )
519            })
520            .collect::<Vec<_>>()
521            .join(",");
522
523        let text_js = self.debug_text.replace('`', "'").replace('\\', "\\\\");
524
525        format!(
526            r#"<!DOCTYPE html>
527<html><head><meta charset="utf-8">
528<title>Geop Debug Scene</title>
529<style>body{{margin:0;overflow:hidden;background:#1a1a2e}}#info{{position:absolute;top:8px;left:8px;color:#ccc;font:13px monospace;white-space:pre;pointer-events:none}}.geop-label{{font:11px monospace;padding:0 2px;background:rgba(0,0,0,0.55);border-radius:2px;white-space:nowrap;pointer-events:none}}</style>
530<script type="importmap">{{"imports":{{"three":"https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"}}}}</script>
531</head><body>
532<div id="info"></div>
533<script type="module">
534import * as THREE from 'three';
535import {{OrbitControls}} from 'three/addons/controls/OrbitControls.js';
536import {{CSS2DRenderer, CSS2DObject}} from 'three/addons/renderers/CSS2DRenderer.js';
537
538const renderer=new THREE.WebGLRenderer({{antialias:true}});
539renderer.setSize(window.innerWidth,window.innerHeight);
540renderer.setPixelRatio(devicePixelRatio);
541document.body.appendChild(renderer.domElement);
542
543const labelRenderer=new CSS2DRenderer();
544labelRenderer.setSize(window.innerWidth,window.innerHeight);
545labelRenderer.domElement.style.position='absolute';
546labelRenderer.domElement.style.top='0';
547labelRenderer.domElement.style.left='0';
548labelRenderer.domElement.style.pointerEvents='none';
549document.body.appendChild(labelRenderer.domElement);
550
551const scene=new THREE.Scene();
552scene.background=new THREE.Color(0x1a1a2e);
553scene.add(new THREE.AmbientLight(0xffffff,0.6));
554const dLight=new THREE.DirectionalLight(0xffffff,0.8);
555dLight.position.set(5,10,7);
556scene.add(dLight);
557
558const camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,0.001,10000);
559const controls=new OrbitControls(camera,renderer.domElement);
560controls.enableDamping=true;
561
562const POINTS=[{points_js}];
563const LINES=[{lines_js}];
564const HIGHLIGHT_LINES=[{highlight_lines_js}];
565const TRIS=[{tris_js}];
566const TRIS_RGB=[{tris_rgb_js}];
567const TRIS_TRANSPARENT=[{tris_transparent_js}];
568const LABELS=[{labels_js}];
569const CYLINDERS=[{cylinders_js}];
570const TEXT=`{text_js}`;
571
572document.getElementById('info').textContent=TEXT;
573
574// Points
575const ptGeo=new THREE.BufferGeometry();
576if(POINTS.length){{
577  const pos=new Float32Array(POINTS.length*3);
578  const col=new Float32Array(POINTS.length*3);
579  POINTS.forEach(([x,y,z,hex],i)=>{{
580    pos[i*3]=x;pos[i*3+1]=y;pos[i*3+2]=z;
581    const c=new THREE.Color(hex);col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b;
582  }});
583  ptGeo.setAttribute('position',new THREE.BufferAttribute(pos,3));
584  ptGeo.setAttribute('color',new THREE.BufferAttribute(col,3));
585  scene.add(new THREE.Points(ptGeo,new THREE.PointsMaterial({{size:0.05,vertexColors:true}})));
586}}
587
588// Lines
589if(LINES.length){{
590  const pos=new Float32Array(LINES.length*6);
591  const col=new Float32Array(LINES.length*6);
592  LINES.forEach(([x1,y1,z1,x2,y2,z2,hex],i)=>{{
593    pos[i*6]=x1;pos[i*6+1]=y1;pos[i*6+2]=z1;
594    pos[i*6+3]=x2;pos[i*6+4]=y2;pos[i*6+5]=z2;
595    const c=new THREE.Color(hex);
596    col[i*6]=c.r;col[i*6+1]=c.g;col[i*6+2]=c.b;
597    col[i*6+3]=c.r;col[i*6+4]=c.g;col[i*6+5]=c.b;
598  }});
599  const geo=new THREE.BufferGeometry();
600  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
601  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
602  scene.add(new THREE.LineSegments(geo,new THREE.LineBasicMaterial({{vertexColors:true}})));
603}}
604
605// Highlight lines (depth-test disabled, drawn last, so they stay visible on
606// top of solid triangles instead of being occluded)
607if(HIGHLIGHT_LINES.length){{
608  const pos=new Float32Array(HIGHLIGHT_LINES.length*6);
609  const col=new Float32Array(HIGHLIGHT_LINES.length*6);
610  HIGHLIGHT_LINES.forEach(([x1,y1,z1,x2,y2,z2,hex],i)=>{{
611    pos[i*6]=x1;pos[i*6+1]=y1;pos[i*6+2]=z1;
612    pos[i*6+3]=x2;pos[i*6+4]=y2;pos[i*6+5]=z2;
613    const c=new THREE.Color(hex);
614    col[i*6]=c.r;col[i*6+1]=c.g;col[i*6+2]=c.b;
615    col[i*6+3]=c.r;col[i*6+4]=c.g;col[i*6+5]=c.b;
616  }});
617  const geo=new THREE.BufferGeometry();
618  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
619  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
620  const seg=new THREE.LineSegments(geo,new THREE.LineBasicMaterial({{vertexColors:true,depthTest:false,depthWrite:false}}));
621  seg.renderOrder=999;
622  scene.add(seg);
623}}
624
625// Triangles
626if(TRIS.length){{
627  const pos=new Float32Array(TRIS.length*9);
628  const col=new Float32Array(TRIS.length*9);
629  TRIS.forEach(([ax,ay,az,bx,by,bz,cx,cy,cz,hex],i)=>{{
630    pos[i*9+0]=ax;pos[i*9+1]=ay;pos[i*9+2]=az;
631    pos[i*9+3]=bx;pos[i*9+4]=by;pos[i*9+5]=bz;
632    pos[i*9+6]=cx;pos[i*9+7]=cy;pos[i*9+8]=cz;
633    const c=new THREE.Color(hex);
634    for(let k=0;k<3;k++){{col[i*9+k*3]=c.r;col[i*9+k*3+1]=c.g;col[i*9+k*3+2]=c.b;}}
635  }});
636  const geo=new THREE.BufferGeometry();
637  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
638  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
639  geo.computeVertexNormals();
640  scene.add(new THREE.Mesh(geo,new THREE.MeshLambertMaterial({{vertexColors:true,side:THREE.DoubleSide}})));
641}}
642
643// Vertex-colored triangles
644if(TRIS_RGB.length){{
645  const pos=new Float32Array(TRIS_RGB.length*9);
646  const col=new Float32Array(TRIS_RGB.length*9);
647  TRIS_RGB.forEach(([ax,ay,az,bx,by,bz,cx,cy,cz,ha,hb,hc],i)=>{{
648    pos[i*9+0]=ax;pos[i*9+1]=ay;pos[i*9+2]=az;
649    pos[i*9+3]=bx;pos[i*9+4]=by;pos[i*9+5]=bz;
650    pos[i*9+6]=cx;pos[i*9+7]=cy;pos[i*9+8]=cz;
651    const ca=new THREE.Color(ha),cb=new THREE.Color(hb),cc=new THREE.Color(hc);
652    col[i*9+0]=ca.r;col[i*9+1]=ca.g;col[i*9+2]=ca.b;
653    col[i*9+3]=cb.r;col[i*9+4]=cb.g;col[i*9+5]=cb.b;
654    col[i*9+6]=cc.r;col[i*9+7]=cc.g;col[i*9+8]=cc.b;
655  }});
656  const geo=new THREE.BufferGeometry();
657  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
658  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
659  geo.computeVertexNormals();
660  scene.add(new THREE.Mesh(geo,new THREE.MeshLambertMaterial({{vertexColors:true,side:THREE.DoubleSide}})));
661}}
662
663// Transparent triangles: one mesh per distinct (color, opacity) pair — not
664// one per triangle (opacity is a per-material, not per-vertex, property in
665// three.js, so triangles can't just be vertex-colored into a single mesh
666// like TRIS/TRIS_RGB above) — but a curved face rasterized at any real
667// resolution has thousands of same-colored triangles, and a separate
668// Mesh/BufferGeometry/Material per one of those tanks frame rate; grouping
669// keeps draw calls down to the number of distinct (color, opacity) pairs
670// actually used, typically a handful.
671{{
672  const groups=new Map();
673  TRIS_TRANSPARENT.forEach(([ax,ay,az,bx,by,bz,cx,cy,cz,hex,opacity])=>{{
674    const key=hex+'|'+opacity;
675    if(!groups.has(key))groups.set(key,{{hex,opacity,verts:[]}});
676    groups.get(key).verts.push(ax,ay,az,bx,by,bz,cx,cy,cz);
677  }});
678  groups.forEach(({{hex,opacity,verts}})=>{{
679    const geo=new THREE.BufferGeometry();
680    geo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(verts),3));
681    geo.computeVertexNormals();
682    const mat=new THREE.MeshLambertMaterial({{color:hex,transparent:true,opacity,side:THREE.DoubleSide,depthWrite:false}});
683    scene.add(new THREE.Mesh(geo,mat));
684  }});
685}}
686
687// Cylinders (solid tubes, e.g. thick axes)
688CYLINDERS.forEach(([x1,y1,z1,x2,y2,z2,radius,hex])=>{{
689  const start=new THREE.Vector3(x1,y1,z1),end=new THREE.Vector3(x2,y2,z2);
690  const dir=new THREE.Vector3().subVectors(end,start);
691  const height=dir.length();
692  if(height<=0)return;
693  const geo=new THREE.CylinderGeometry(radius,radius,height,12);
694  const mat=new THREE.MeshLambertMaterial({{color:hex}});
695  const mesh=new THREE.Mesh(geo,mat);
696  mesh.position.copy(start).addScaledVector(dir,0.5);
697  mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),dir.clone().normalize());
698  scene.add(mesh);
699}});
700
701// Labels (CSS2D overlays, always facing the camera). Labels anchored at the
702// same (or nearly the same) world position — e.g. a vertex and the midpoint
703// label of a very short edge touching it — would otherwise land on the same
704// screen pixels and hide each other, which reads as "nothing is there"
705// instead of "there are two things here that need a closer look". Bucketing
706// by a rounded position key and stacking each bucket's labels vertically
707// (via a per-label CSS offset on an inner span, so it stays correct every
708// frame without needing to redo the stacking on every camera move) keeps
709// every label visible.
710const labelBuckets=new Map();
711LABELS.forEach(([x,y,z,text,hex])=>{{
712  const key=x.toFixed(3)+','+y.toFixed(3)+','+z.toFixed(3);
713  const bucket=labelBuckets.get(key)||[];
714  bucket.push([x,y,z,text,hex]);
715  labelBuckets.set(key,bucket);
716}});
717labelBuckets.forEach(bucket=>{{
718  bucket.forEach(([x,y,z,text,hex],i)=>{{
719    const outer=document.createElement('div');
720    const inner=document.createElement('div');
721    inner.className='geop-label';
722    inner.textContent=text;
723    inner.style.color='#'+hex.toString(16).padStart(6,'0');
724    if(bucket.length>1){{
725      inner.style.transform=`translateY(${{i*14}}px)`;
726      inner.style.outline='1px solid #'+hex.toString(16).padStart(6,'0');
727    }}
728    outer.appendChild(inner);
729    const obj=new CSS2DObject(outer);
730    obj.position.set(x,y,z);
731    scene.add(obj);
732  }});
733}});
734
735// Fit camera to content
736const box=new THREE.Box3().setFromObject(scene);
737if(!box.isEmpty()){{
738  const center=box.getCenter(new THREE.Vector3());
739  const size=box.getSize(new THREE.Vector3()).length();
740  camera.position.copy(center).addScaledVector(new THREE.Vector3(0.5,0.5,1).normalize(),size*1.5);
741  controls.target.copy(center);
742  camera.near=size/1000;camera.far=size*100;camera.updateProjectionMatrix();
743}}
744
745window.addEventListener('resize',()=>{{
746  camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();
747  renderer.setSize(innerWidth,innerHeight);
748  labelRenderer.setSize(innerWidth,innerHeight);
749}});
750
751(function animate(){{requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);labelRenderer.render(scene,camera);}})();
752</script></body></html>"#,
753            points_js = points_js,
754            lines_js = lines_js,
755            tris_js = tris_js,
756            tris_rgb_js = tris_rgb_js,
757            tris_transparent_js = tris_transparent_js,
758            labels_js = labels_js,
759            text_js = text_js,
760        )
761    }
762}
763
764impl<S: Scalar> Default for PrimitiveScene<S> {
765    fn default() -> Self {
766        Self::new()
767    }
768}
769
770impl<S: Scalar> DebugContext for PrimitiveScene<S> {
771    fn label(&self) -> &str {
772        self.rendered_path.get_or_init(|| {
773            let path = format!("/tmp/geop_scene_{}.html", next_id());
774            if let Err(e) = self.save_to_file(&path) {
775                return format!("PrimitiveScene (render failed: {e})");
776            }
777            path
778        })
779    }
780}
781
782// ── PrimitiveSceneRecorder ────────────────────────────────────────────────────
783
784pub struct PrimitiveSceneRecorder<S: Scalar> {
785    pub scenes: Vec<PrimitiveScene<S>>,
786    rendered_path: OnceLock<String>,
787}
788
789impl<S: Scalar> core::fmt::Debug for PrimitiveSceneRecorder<S> {
790    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
791        write!(f, "PrimitiveSceneRecorder({} scenes)", self.scenes.len())
792    }
793}
794
795impl<S: Scalar> PrimitiveSceneRecorder<S> {
796    pub fn new() -> Self {
797        Self {
798            scenes: Vec::new(),
799            rendered_path: OnceLock::new(),
800        }
801    }
802
803    pub fn add_scene(&mut self, scene: PrimitiveScene<S>) {
804        self.scenes.push(scene);
805    }
806
807    /// Write `scene_0.html`, `scene_1.html`, … into `folder_path`.
808    pub fn save_to_folder(&self, folder_path: &str) -> GeopResult<()> {
809        std::fs::create_dir_all(folder_path)
810            .map_err(|e| GeopError::new(format!("PrimitiveSceneRecorder: mkdir {e}")))?;
811        for (i, scene) in self.scenes.iter().enumerate() {
812            let path = format!("{folder_path}/scene_{i}.html");
813            scene.save_to_file(&path)?;
814        }
815        Ok(())
816    }
817}
818
819impl<S: Scalar> Default for PrimitiveSceneRecorder<S> {
820    fn default() -> Self {
821        Self::new()
822    }
823}
824
825impl<S: Scalar> DebugContext for PrimitiveSceneRecorder<S> {
826    fn label(&self) -> &str {
827        self.rendered_path.get_or_init(|| {
828            let folder = format!("/tmp/geop_rec_{}", next_id());
829            if let Err(e) = self.save_to_folder(&folder) {
830                return format!("PrimitiveSceneRecorder (render failed: {e})");
831            }
832            folder
833        })
834    }
835}