Skip to main content

geop_core_rasterize/debug/
scene.rs

1use geop_core_geometry::{nurb_curve::NurbCurve3D, nurb_surface::NurbSurface3D};
2use geop_core_math::{
3    geop_error::{GeopError, GeopResult},
4    primitives::TriangleFace,
5    scalars::Scalar,
6    vector::Vector3,
7};
8
9use super::Color10;
10
11fn sample_surface_grid(
12    surface: &NurbSurface3D,
13    u_min: Scalar,
14    u_max: Scalar,
15    v_min: Scalar,
16    v_max: Scalar,
17    n: usize,
18) -> GeopResult<Vec<Vector3>> {
19    let mut grid = Vec::with_capacity(n * n);
20    for j in 0..n {
21        for i in 0..n {
22            let u = if i == 0 {
23                u_min
24            } else if i == n - 1 {
25                u_max
26            } else {
27                let f = Scalar::from_ratio(i as i64, (n - 1) as i64)?;
28                u_min.add(u_max.sub(u_min).mul(f))
29            };
30            let v = if j == 0 {
31                v_min
32            } else if j == n - 1 {
33                v_max
34            } else {
35                let f = Scalar::from_ratio(j as i64, (n - 1) as i64)?;
36                v_min.add(v_max.sub(v_min).mul(f))
37            };
38            grid.push(surface.evaluate(u, v)?);
39        }
40    }
41    Ok(grid)
42}
43
44// ── PrimitiveScene ────────────────────────────────────────────────────────────
45
46pub struct PrimitiveScene {
47    pub points: Vec<(Vector3, Color10)>,
48    /// Line segments, as their two ends.
49    pub lines: Vec<([Vector3; 2], Color10)>,
50    pub triangles: Vec<(TriangleFace, Color10)>,
51    /// Triangles rendered with alpha blending (`color`, `opacity` in
52    /// `[0, 1]`) — e.g. so a face's fill doesn't hide the trim curves,
53    /// vertices or labels drawn on top of/behind it.
54    pub triangles_transparent: Vec<(TriangleFace, Color10, f64)>,
55    /// Text labels anchored at a 3-D world position (e.g. an entity's id),
56    /// rendered as always-facing-camera HTML overlays via `CSS2DRenderer`.
57    pub labels: Vec<(Vector3, String, Color10)>,
58    /// Thick tubes (`start`, `end`, `radius`, `color`) — e.g. coordinate
59    /// system axes, rendered as solid geometry instead of a `Line`, since a
60    /// `THREE.Line`'s width can't be controlled portably across GPUs.
61    pub cylinders: Vec<(Vector3, Vector3, f64, Color10)>,
62}
63
64impl core::fmt::Debug for PrimitiveScene {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        write!(
67            f,
68            "PrimitiveScene({} pts, {} lines, {} tris)",
69            self.points.len(),
70            self.lines.len(),
71            self.triangles.len()
72        )
73    }
74}
75
76impl PrimitiveScene {
77    pub fn new() -> Self {
78        Self {
79            points: Vec::new(),
80            lines: Vec::new(),
81            triangles: Vec::new(),
82            triangles_transparent: Vec::new(),
83            labels: Vec::new(),
84            cylinders: Vec::new(),
85        }
86    }
87
88    pub fn add_point(&mut self, p: Vector3, c: Color10) {
89        self.points.push((p, c));
90    }
91
92    /// Draw an already-sampled polyline as `n - 1` line segments, skipping
93    /// any between consecutive points that could coincide.
94    pub fn add_polyline(&mut self, points: &[Vector3], color: Color10) {
95        for w in points.windows(2) {
96            if !w[1].sub(&w[0]).norm_sq().could_be_equal(Scalar::ZERO) {
97                self.lines.push(([w[0], w[1]], color));
98            }
99        }
100    }
101
102    pub fn add_triangle(&mut self, t: TriangleFace, c: Color10) {
103        self.triangles.push((t, c));
104    }
105
106    /// Add a triangle rendered with alpha blending — `opacity` in `[0, 1]`
107    /// (`0` invisible, `1` opaque).
108    pub fn add_triangle_transparent(&mut self, t: TriangleFace, c: Color10, opacity: f64) {
109        self.triangles_transparent.push((t, c, opacity));
110    }
111
112    /// Add a text label anchored at `pos` in world space — rendered as an
113    /// always-facing-camera HTML overlay, colored `c`.
114    pub fn add_label(&mut self, pos: Vector3, text: impl Into<String>, c: Color10) {
115        self.labels.push((pos, text.into(), c));
116    }
117
118    /// Add a solid cylindrical tube from `start` to `end`, `radius` wide —
119    /// e.g. for drawing thick axes/arrows that stay visibly wide regardless
120    /// of GPU line-width support.
121    pub fn add_cylinder(&mut self, start: Vector3, end: Vector3, radius: f64, c: Color10) {
122        self.cylinders.push((start, end, radius, c));
123    }
124
125    /// Rasterize a curve: n uniform samples → n-1 line segments.
126    pub fn add_curve(
127        &mut self,
128        curve: &NurbCurve3D,
129        t_min: Scalar,
130        t_max: Scalar,
131        color: Color10,
132        n: usize,
133    ) -> GeopResult<()> {
134        if n < 2 {
135            return Err(GeopError::new("add_curve: n must be >= 2"));
136        }
137        let mut pts = Vec::with_capacity(n);
138        for i in 0..n {
139            let t = if i == 0 {
140                t_min
141            } else if i == n - 1 {
142                t_max
143            } else {
144                let frac = Scalar::from_ratio(i as i64, (n - 1) as i64)?;
145                t_min.add(t_max.sub(t_min).mul(frac))
146            };
147            pts.push(curve.evaluate(t)?);
148        }
149        self.add_polyline(&pts, color);
150        Ok(())
151    }
152
153    /// Wireframe: n×n grid of quads emitted as line segments.
154    #[allow(clippy::too_many_arguments)]
155    pub fn add_surface_wireframe(
156        &mut self,
157        surface: &NurbSurface3D,
158        color: Color10,
159        u_min: Scalar,
160        u_max: Scalar,
161        v_min: Scalar,
162        v_max: Scalar,
163        n: usize,
164    ) -> GeopResult<()> {
165        if n < 2 {
166            return Err(GeopError::new("add_surface_wireframe: n must be >= 2"));
167        }
168        let grid = sample_surface_grid(surface, u_min, u_max, v_min, v_max, n)?;
169        for row in grid.chunks(n) {
170            self.add_polyline(row, color);
171        }
172        for i in 0..n {
173            let column: Vec<_> = (0..n).map(|j| grid[j * n + i]).collect();
174            self.add_polyline(&column, color);
175        }
176        Ok(())
177    }
178
179    /// Write a self-contained HTML file with an embedded three.js scene.
180    pub fn save_to_file(&self, filename: &str) -> GeopResult<()> {
181        let html = self.render_html();
182        std::fs::write(filename, html)
183            .map_err(|e| GeopError::new(format!("PrimitiveScene::save_to_file: {e}")))?;
184        Ok(())
185    }
186
187    fn render_html(&self) -> String {
188        let points_js = self
189            .points
190            .iter()
191            .map(|(p, c)| {
192                format!(
193                    "[{},{},{},{}]",
194                    p[0].to_f64(),
195                    p[1].to_f64(),
196                    p[2].to_f64(),
197                    c.to_hex()
198                )
199            })
200            .collect::<Vec<_>>()
201            .join(",");
202
203        let lines_js = self
204            .lines
205            .iter()
206            .map(|([s, e], c)| {
207                format!(
208                    "[{},{},{},{},{},{},{}]",
209                    s[0].to_f64(),
210                    s[1].to_f64(),
211                    s[2].to_f64(),
212                    e[0].to_f64(),
213                    e[1].to_f64(),
214                    e[2].to_f64(),
215                    c.to_hex()
216                )
217            })
218            .collect::<Vec<_>>()
219            .join(",");
220
221        let tris_js = self
222            .triangles
223            .iter()
224            .map(|(t, c)| {
225                format!(
226                    "[{},{},{},{},{},{},{},{},{},{}]",
227                    t.a[0].to_f64(),
228                    t.a[1].to_f64(),
229                    t.a[2].to_f64(),
230                    t.b[0].to_f64(),
231                    t.b[1].to_f64(),
232                    t.b[2].to_f64(),
233                    t.c[0].to_f64(),
234                    t.c[1].to_f64(),
235                    t.c[2].to_f64(),
236                    c.to_hex()
237                )
238            })
239            .collect::<Vec<_>>()
240            .join(",");
241
242        let tris_transparent_js = self
243            .triangles_transparent
244            .iter()
245            .map(|(t, c, opacity)| {
246                format!(
247                    "[{},{},{},{},{},{},{},{},{},{},{}]",
248                    t.a[0].to_f64(),
249                    t.a[1].to_f64(),
250                    t.a[2].to_f64(),
251                    t.b[0].to_f64(),
252                    t.b[1].to_f64(),
253                    t.b[2].to_f64(),
254                    t.c[0].to_f64(),
255                    t.c[1].to_f64(),
256                    t.c[2].to_f64(),
257                    c.to_hex(),
258                    opacity
259                )
260            })
261            .collect::<Vec<_>>()
262            .join(",");
263
264        let labels_js = self
265            .labels
266            .iter()
267            .map(|(p, text, c)| {
268                let escaped = text.replace('\\', "\\\\").replace('`', "'");
269                format!(
270                    "[{},{},{},`{}`,{}]",
271                    p[0].to_f64(),
272                    p[1].to_f64(),
273                    p[2].to_f64(),
274                    escaped,
275                    c.to_hex()
276                )
277            })
278            .collect::<Vec<_>>()
279            .join(",");
280
281        let cylinders_js = self
282            .cylinders
283            .iter()
284            .map(|(s, e, r, c)| {
285                format!(
286                    "[{},{},{},{},{},{},{},{}]",
287                    s[0].to_f64(),
288                    s[1].to_f64(),
289                    s[2].to_f64(),
290                    e[0].to_f64(),
291                    e[1].to_f64(),
292                    e[2].to_f64(),
293                    r,
294                    c.to_hex()
295                )
296            })
297            .collect::<Vec<_>>()
298            .join(",");
299
300        format!(
301            r#"<!DOCTYPE html>
302<html><head><meta charset="utf-8">
303<title>Geop Debug Scene</title>
304<style>body{{margin:0;overflow:hidden;background:#1a1a2e}}.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>
305<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>
306</head><body>
307<script type="module">
308import * as THREE from 'three';
309import {{OrbitControls}} from 'three/addons/controls/OrbitControls.js';
310import {{CSS2DRenderer, CSS2DObject}} from 'three/addons/renderers/CSS2DRenderer.js';
311
312const renderer=new THREE.WebGLRenderer({{antialias:true}});
313renderer.setSize(window.innerWidth,window.innerHeight);
314renderer.setPixelRatio(devicePixelRatio);
315document.body.appendChild(renderer.domElement);
316
317const labelRenderer=new CSS2DRenderer();
318labelRenderer.setSize(window.innerWidth,window.innerHeight);
319labelRenderer.domElement.style.position='absolute';
320labelRenderer.domElement.style.top='0';
321labelRenderer.domElement.style.left='0';
322labelRenderer.domElement.style.pointerEvents='none';
323document.body.appendChild(labelRenderer.domElement);
324
325const scene=new THREE.Scene();
326scene.background=new THREE.Color(0x1a1a2e);
327scene.add(new THREE.AmbientLight(0xffffff,0.6));
328const dLight=new THREE.DirectionalLight(0xffffff,0.8);
329dLight.position.set(5,10,7);
330scene.add(dLight);
331
332const camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,0.001,10000);
333const controls=new OrbitControls(camera,renderer.domElement);
334controls.enableDamping=true;
335
336const POINTS=[{points_js}];
337const LINES=[{lines_js}];
338const TRIS=[{tris_js}];
339const TRIS_TRANSPARENT=[{tris_transparent_js}];
340const LABELS=[{labels_js}];
341const CYLINDERS=[{cylinders_js}];
342
343// Points
344const ptGeo=new THREE.BufferGeometry();
345if(POINTS.length){{
346  const pos=new Float32Array(POINTS.length*3);
347  const col=new Float32Array(POINTS.length*3);
348  POINTS.forEach(([x,y,z,hex],i)=>{{
349    pos[i*3]=x;pos[i*3+1]=y;pos[i*3+2]=z;
350    const c=new THREE.Color(hex);col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b;
351  }});
352  ptGeo.setAttribute('position',new THREE.BufferAttribute(pos,3));
353  ptGeo.setAttribute('color',new THREE.BufferAttribute(col,3));
354  scene.add(new THREE.Points(ptGeo,new THREE.PointsMaterial({{size:0.05,vertexColors:true}})));
355}}
356
357// Lines
358if(LINES.length){{
359  const pos=new Float32Array(LINES.length*6);
360  const col=new Float32Array(LINES.length*6);
361  LINES.forEach(([x1,y1,z1,x2,y2,z2,hex],i)=>{{
362    pos[i*6]=x1;pos[i*6+1]=y1;pos[i*6+2]=z1;
363    pos[i*6+3]=x2;pos[i*6+4]=y2;pos[i*6+5]=z2;
364    const c=new THREE.Color(hex);
365    col[i*6]=c.r;col[i*6+1]=c.g;col[i*6+2]=c.b;
366    col[i*6+3]=c.r;col[i*6+4]=c.g;col[i*6+5]=c.b;
367  }});
368  const geo=new THREE.BufferGeometry();
369  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
370  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
371  scene.add(new THREE.LineSegments(geo,new THREE.LineBasicMaterial({{vertexColors:true}})));
372}}
373
374// Triangles
375if(TRIS.length){{
376  const pos=new Float32Array(TRIS.length*9);
377  const col=new Float32Array(TRIS.length*9);
378  TRIS.forEach(([ax,ay,az,bx,by,bz,cx,cy,cz,hex],i)=>{{
379    pos[i*9+0]=ax;pos[i*9+1]=ay;pos[i*9+2]=az;
380    pos[i*9+3]=bx;pos[i*9+4]=by;pos[i*9+5]=bz;
381    pos[i*9+6]=cx;pos[i*9+7]=cy;pos[i*9+8]=cz;
382    const c=new THREE.Color(hex);
383    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;}}
384  }});
385  const geo=new THREE.BufferGeometry();
386  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
387  geo.setAttribute('color',new THREE.BufferAttribute(col,3));
388  geo.computeVertexNormals();
389  scene.add(new THREE.Mesh(geo,new THREE.MeshLambertMaterial({{vertexColors:true,side:THREE.DoubleSide}})));
390}}
391
392// Transparent triangles: one mesh per distinct (color, opacity) pair — not
393// one per triangle (opacity is a per-material, not per-vertex, property in
394// three.js, so triangles can't just be vertex-colored into a single mesh
395// like TRIS above) — but a curved face rasterized at any real
396// resolution has thousands of same-colored triangles, and a separate
397// Mesh/BufferGeometry/Material per one of those tanks frame rate; grouping
398// keeps draw calls down to the number of distinct (color, opacity) pairs
399// actually used, typically a handful.
400{{
401  const groups=new Map();
402  TRIS_TRANSPARENT.forEach(([ax,ay,az,bx,by,bz,cx,cy,cz,hex,opacity])=>{{
403    const key=hex+'|'+opacity;
404    if(!groups.has(key))groups.set(key,{{hex,opacity,verts:[]}});
405    groups.get(key).verts.push(ax,ay,az,bx,by,bz,cx,cy,cz);
406  }});
407  groups.forEach(({{hex,opacity,verts}})=>{{
408    const geo=new THREE.BufferGeometry();
409    geo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(verts),3));
410    geo.computeVertexNormals();
411    const mat=new THREE.MeshLambertMaterial({{color:hex,transparent:true,opacity,side:THREE.DoubleSide,depthWrite:false}});
412    scene.add(new THREE.Mesh(geo,mat));
413  }});
414}}
415
416// Cylinders (solid tubes, e.g. thick axes)
417CYLINDERS.forEach(([x1,y1,z1,x2,y2,z2,radius,hex])=>{{
418  const start=new THREE.Vector3(x1,y1,z1),end=new THREE.Vector3(x2,y2,z2);
419  const dir=new THREE.Vector3().subVectors(end,start);
420  const height=dir.length();
421  if(height<=0)return;
422  const geo=new THREE.CylinderGeometry(radius,radius,height,12);
423  const mat=new THREE.MeshLambertMaterial({{color:hex}});
424  const mesh=new THREE.Mesh(geo,mat);
425  mesh.position.copy(start).addScaledVector(dir,0.5);
426  mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),dir.clone().normalize());
427  scene.add(mesh);
428}});
429
430// Labels (CSS2D overlays, always facing the camera). Labels anchored at the
431// same (or nearly the same) world position — e.g. a vertex and the midpoint
432// label of a very short edge touching it — would otherwise land on the same
433// screen pixels and hide each other, which reads as "nothing is there"
434// instead of "there are two things here that need a closer look". Bucketing
435// by a rounded position key and stacking each bucket's labels vertically
436// (via a per-label CSS offset on an inner span, so it stays correct every
437// frame without needing to redo the stacking on every camera move) keeps
438// every label visible.
439const labelBuckets=new Map();
440LABELS.forEach(([x,y,z,text,hex])=>{{
441  const key=x.toFixed(3)+','+y.toFixed(3)+','+z.toFixed(3);
442  const bucket=labelBuckets.get(key)||[];
443  bucket.push([x,y,z,text,hex]);
444  labelBuckets.set(key,bucket);
445}});
446labelBuckets.forEach(bucket=>{{
447  bucket.forEach(([x,y,z,text,hex],i)=>{{
448    const outer=document.createElement('div');
449    const inner=document.createElement('div');
450    inner.className='geop-label';
451    inner.textContent=text;
452    inner.style.color='#'+hex.toString(16).padStart(6,'0');
453    if(bucket.length>1){{
454      inner.style.transform=`translateY(${{i*14}}px)`;
455      inner.style.outline='1px solid #'+hex.toString(16).padStart(6,'0');
456    }}
457    outer.appendChild(inner);
458    const obj=new CSS2DObject(outer);
459    obj.position.set(x,y,z);
460    scene.add(obj);
461  }});
462}});
463
464// Fit camera to content
465const box=new THREE.Box3().setFromObject(scene);
466if(!box.isEmpty()){{
467  const center=box.getCenter(new THREE.Vector3());
468  const size=box.getSize(new THREE.Vector3()).length();
469  camera.position.copy(center).addScaledVector(new THREE.Vector3(0.5,0.5,1).normalize(),size*1.5);
470  controls.target.copy(center);
471  camera.near=size/1000;camera.far=size*100;camera.updateProjectionMatrix();
472}}
473
474window.addEventListener('resize',()=>{{
475  camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();
476  renderer.setSize(innerWidth,innerHeight);
477  labelRenderer.setSize(innerWidth,innerHeight);
478}});
479
480(function animate(){{requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);labelRenderer.render(scene,camera);}})();
481</script></body></html>"#,
482            points_js = points_js,
483            lines_js = lines_js,
484            tris_js = tris_js,
485            tris_transparent_js = tris_transparent_js,
486            labels_js = labels_js,
487        )
488    }
489}
490
491impl Default for PrimitiveScene {
492    fn default() -> Self {
493        Self::new()
494    }
495}