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
13pub trait RasterizableCurve<S: Scalar> {
15 fn eval_at(&self, t: S) -> GeopResult<Vector3<S>>;
17}
18
19pub trait RasterizableSurface<S: Scalar> {
21 fn eval_at(&self, u: S, v: S) -> GeopResult<Vector3<S>>;
23}
24
25static 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
65pub struct PrimitiveScene<S: Scalar> {
68 pub points: Vec<(Vector3<S>, Color10)>,
69 pub lines: Vec<(Line<S>, Color10)>,
70 pub highlight_lines: Vec<(Line<S>, Color10)>,
75 pub triangles: Vec<(TriangleFace<S>, Color10)>,
76 pub triangles_rgb: Vec<(TriangleFace<S>, Color10, Color10, Color10)>,
80 pub triangles_transparent: Vec<(TriangleFace<S>, Color10, f64)>,
84 pub labels: Vec<(Vector3<S>, String, Color10)>,
87 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 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 pub fn add_highlight_line(&mut self, l: Line<S>, c: Color10) {
144 self.highlight_lines.push((l, c));
145 }
146
147 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 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 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 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 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 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 #[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 #[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 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 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/three@0.169.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.169.0/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
782pub 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 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}