Skip to content

Commit 7d897fa

Browse files
committed
Phase 1 progressive loading: pre-computed GLB mesh previews
Bypass live marching-cubes (CPU) or ray-marching (GPU) by loading a pre-computed `.glb` isosurface mesh at the current iso quantile. Client-side this is an instant render once the GLB is fetched (~100 KB-5 MB depending on quantile). - `pkgs/corpora/scripts/generate-glb.py`: pymatgen + skimage marching_cubes + trimesh to export iso surfaces at default quantiles [0.50, 0.75, 0.90, 0.95, 0.99]. Optional `--s3 s3://.../` flag uploads via boto3 with `model/gltf-binary` content-type. - `GlbPreviewRenderer` (`@elvis/core`): uses drei's `useGLTF` to load, re-skins each mesh with our standard transparent/double-sided material and color/opacity props so GLB previews match the live iso styling. - `DensityViewer` accepts `glbUrl` and routes to the preview renderer when set, otherwise falls through to GPU or CPU as before. - `App.tsx` computes the nearest available quantile from `densityQuantiles` + the current iso, picks the matching `/glb/<task>/<q>.glb` URL, and threads it through. `?glb=1` URL param / Shift+G keybind toggle the mode. - `pkgs/static/public/glb/.gitignore` keeps staged GLB samples out of git. Production will serve from S3. Phase 2 (Zarr pyramids for full interactive exploration) still pending per `specs/progressive-loading.md`.
1 parent 0f4b9ce commit 7d897fa

6 files changed

Lines changed: 216 additions & 3 deletions

File tree

pkgs/core/src/components/DensityViewer.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { LatticeMatrix } from '../types.ts'
77
import type { VolumeData } from '../types.ts'
88
import { IsosurfaceRenderer } from './IsosurfaceRenderer.tsx'
99
import { VolumeRenderer } from './VolumeRenderer.tsx'
10+
import { GlbPreviewRenderer } from './GlbPreviewRenderer.tsx'
1011
import { CrystalStructure } from './CrystalStructure.tsx'
1112
import { LatticeGizmo } from './LatticeGizmo.tsx'
1213
import { ScreenOffsetGroup } from './ScreenOffsetGroup.tsx'
@@ -67,6 +68,8 @@ interface DensityViewerProps {
6768
abcIsXyz?: boolean
6869
sliceStepSignRef?: MutableRefObject<number>
6970
useGpuVolume?: boolean
71+
/** If set, bypass live isosurface extraction and render a pre-computed GLB preview. */
72+
glbUrl?: string | null
7073
/** Override surface color/opacity (e.g. from density-quantile ramp). If null, renderers use defaults. */
7174
surfaceColor?: [number, number, number] | null
7275
surfaceOpacityOverride?: number | null
@@ -97,6 +100,7 @@ export function DensityViewer({
97100
abcIsXyz,
98101
sliceStepSignRef,
99102
useGpuVolume,
103+
glbUrl,
100104
surfaceColor,
101105
surfaceOpacityOverride,
102106
}: DensityViewerProps) {
@@ -139,9 +143,11 @@ export function DensityViewer({
139143
<directionalLight position={[10, 10, 10]} intensity={0.8} />
140144
<directionalLight position={[-5, -5, 5]} intensity={0.4} />
141145

142-
{useGpuVolume
143-
? <VolumeRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
144-
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
146+
{glbUrl
147+
? <GlbPreviewRenderer url={glbUrl} opacity={surfaceOpacityOverride ?? opacity} color={surfaceColor ?? undefined} />
148+
: useGpuVolume
149+
? <VolumeRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
150+
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
145151
}
146152
<CrystalStructure volume={volume} showAtoms={showAtoms} showAtomLabels={showAtomLabels} showAbcCell={showAbcCell} showXyzBox={showXyzBox} dashedLines={dashedLines} lineWidth={lineWidth} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} />
147153
{showSlice && sliceAxis !== undefined && sliceIndex !== undefined && (
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useMemo } from 'react'
2+
import { useGLTF } from '@react-three/drei'
3+
import { DoubleSide, MeshStandardMaterial, Mesh } from 'three'
4+
5+
interface GlbPreviewRendererProps {
6+
/** URL of the .glb mesh file (pre-computed isosurface at a specific iso level). */
7+
url: string
8+
opacity?: number
9+
color?: [number, number, number]
10+
}
11+
12+
function rgbToHex(c: [number, number, number]): string {
13+
const to = (x: number) => Math.max(0, Math.min(255, Math.round(x * 255))).toString(16).padStart(2, '0')
14+
return `#${to(c[0])}${to(c[1])}${to(c[2])}`
15+
}
16+
17+
/**
18+
* Renders a pre-computed isosurface mesh loaded from a .glb file. Intended as
19+
* an instant-render preview layer on top of (or in place of) the live CPU /
20+
* GPU isosurface pipelines. The file is typically ~100 KB-5 MB depending on
21+
* iso-level quantile.
22+
*
23+
* Usage:
24+
* <GlbPreviewRenderer url="/glb/mp-1000020/0.95.glb" />
25+
*/
26+
export function GlbPreviewRenderer({ url, opacity = 0.6, color }: GlbPreviewRendererProps) {
27+
const { scene } = useGLTF(url)
28+
29+
// Walk the loaded scene and apply our shared styling (translucent blue,
30+
// double-sided) to every mesh. GLB meshes arrive with their own materials,
31+
// which we replace so iso-surface tuning stays consistent.
32+
const styled = useMemo(() => {
33+
const cloned = scene.clone(true)
34+
cloned.traverse((obj) => {
35+
if (obj instanceof Mesh) {
36+
obj.material = new MeshStandardMaterial({
37+
color: color ? rgbToHex(color) : '#44aaff',
38+
transparent: true,
39+
opacity,
40+
side: DoubleSide,
41+
depthWrite: false,
42+
})
43+
}
44+
})
45+
return cloned
46+
}, [scene, opacity, color])
47+
48+
return <primitive object={styled} />
49+
}

pkgs/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export { FileDropZone } from './components/FileDropZone.tsx'
3131
export { DensityViewer } from './components/DensityViewer.tsx'
3232
export { IsosurfaceRenderer } from './components/IsosurfaceRenderer.tsx'
3333
export { VolumeRenderer } from './components/VolumeRenderer.tsx'
34+
export { GlbPreviewRenderer } from './components/GlbPreviewRenderer.tsx'
3435
export { CrystalStructure as CrystalStructureView } from './components/CrystalStructure.tsx'
3536
export { LatticeGizmo } from './components/LatticeGizmo.tsx'
3637
export { CameraController } from './components/CameraController.tsx'
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env -S uv run --script
2+
# /// script
3+
# requires-python = ">=3.11"
4+
# dependencies = [
5+
# "pymatgen>=2025.1",
6+
# "scikit-image>=0.24",
7+
# "trimesh>=4.5",
8+
# "numpy>=2.0",
9+
# "click>=8.1",
10+
# "boto3>=1.35",
11+
# ]
12+
# ///
13+
"""Pre-compute GLB-encoded isosurface meshes for a CHGCAR at several quantile
14+
iso-levels. Output meshes are tiny (~50-200 KB each) and load instantly in the
15+
browser via Three.js GLTFLoader, bypassing the download-and-march-cubes step.
16+
17+
Usage:
18+
generate-glb.py path/to/mp-XXX.CHGCAR [--s3 s3://bucket/prefix/]
19+
20+
Writes:
21+
<out_dir>/<material_id>/<quantile>.glb
22+
e.g.
23+
out/mp-1000020/0.50.glb (512 KB)
24+
out/mp-1000020/0.75.glb (300 KB)
25+
out/mp-1000020/0.90.glb (190 KB)
26+
out/mp-1000020/0.95.glb (140 KB)
27+
out/mp-1000020/0.99.glb (80 KB)
28+
29+
Iso-level quantiles default to [.50, .75, .90, .95, .99] — same range the
30+
ELvis iso-slider covers, matching the client-side quantile mapping.
31+
"""
32+
from __future__ import annotations
33+
34+
import re
35+
import sys
36+
from functools import partial
37+
from pathlib import Path
38+
39+
import click
40+
import numpy as np
41+
import trimesh
42+
from pymatgen.io.vasp import Chgcar
43+
from skimage import measure
44+
45+
err = partial(print, file=sys.stderr)
46+
47+
48+
def iso_from_quantile(data: np.ndarray, q: float) -> float:
49+
flat = data.ravel()
50+
return float(np.quantile(flat, q))
51+
52+
53+
def extract_mesh(grid: np.ndarray, iso: float, lattice: np.ndarray) -> trimesh.Trimesh | None:
54+
"""Run marching cubes and convert fractional → Cartesian coordinates."""
55+
try:
56+
verts, faces, normals, _ = measure.marching_cubes(grid, level=iso, allow_degenerate=False)
57+
except (ValueError, RuntimeError) as e:
58+
err(f' marching_cubes failed at iso={iso:.3f}: {e}')
59+
return None
60+
if len(verts) == 0:
61+
return None
62+
# `verts` is in voxel index space; normalize to fractional [0, 1]
63+
dims = np.array(grid.shape, dtype=np.float64)
64+
frac = verts / (dims - 1)
65+
# Fractional → Cartesian
66+
cart = frac @ lattice
67+
return trimesh.Trimesh(vertices=cart, faces=faces, vertex_normals=normals, process=False)
68+
69+
70+
@click.command()
71+
@click.option('-o', '--out-dir', type=click.Path(path_type=Path), default='out/glb', show_default=True)
72+
@click.option('-q', '--quantiles', default='0.50,0.75,0.90,0.95,0.99', help='Comma-separated iso quantiles')
73+
@click.option('-s', '--s3-prefix', default=None, help='Upload under this s3://bucket/prefix/ after generating')
74+
@click.option('-f', '--force', is_flag=True, help='Overwrite existing files')
75+
@click.argument('chgcar_path', type=click.Path(exists=True, path_type=Path))
76+
def main(chgcar_path: Path, out_dir: Path, quantiles: str, s3_prefix: str | None, force: bool):
77+
mat_match = re.search(r'(mp-\d+)', chgcar_path.name)
78+
if not mat_match:
79+
raise click.ClickException(f'No mp-XXX ID in filename {chgcar_path.name!r}')
80+
mat_id = mat_match.group(1)
81+
82+
qs = [float(x) for x in quantiles.split(',')]
83+
err(f'[{mat_id}] loading {chgcar_path}')
84+
chg = Chgcar.from_file(str(chgcar_path))
85+
grid = chg.data['total']
86+
lattice = chg.structure.lattice.matrix # 3x3, rows = a/b/c vectors
87+
err(f'[{mat_id}] grid {grid.shape}, min={grid.min():.3f}, max={grid.max():.3f}, lattice volume={chg.structure.lattice.volume:.2f} A^3')
88+
89+
out_mat_dir = out_dir / mat_id
90+
out_mat_dir.mkdir(parents=True, exist_ok=True)
91+
92+
uploaded = []
93+
for q in qs:
94+
out_path = out_mat_dir / f'{q:.2f}.glb'
95+
if out_path.exists() and not force:
96+
err(f' q={q:.2f}: exists, skipping ({out_path})')
97+
continue
98+
iso = iso_from_quantile(grid, q)
99+
err(f' q={q:.2f} → iso={iso:.3f}, extracting mesh...')
100+
mesh = extract_mesh(grid, iso, lattice)
101+
if mesh is None:
102+
err(f' q={q:.2f}: empty mesh, skipping')
103+
continue
104+
mesh.export(str(out_path), file_type='glb')
105+
size_kb = out_path.stat().st_size / 1024
106+
err(f' q={q:.2f}: wrote {out_path} ({size_kb:.1f} KB, {len(mesh.faces)} triangles)')
107+
uploaded.append(out_path)
108+
109+
if s3_prefix and uploaded:
110+
import boto3
111+
from urllib.parse import urlparse
112+
parsed = urlparse(s3_prefix)
113+
if parsed.scheme != 's3':
114+
raise click.ClickException(f'--s3 must be s3://bucket/prefix/: got {s3_prefix!r}')
115+
bucket = parsed.netloc
116+
key_prefix = parsed.path.lstrip('/')
117+
if not key_prefix.endswith('/'):
118+
key_prefix += '/'
119+
s3 = boto3.client('s3')
120+
for p in uploaded:
121+
key = f'{key_prefix}{mat_id}/{p.name}'
122+
s3.upload_file(str(p), bucket, key, ExtraArgs={'ContentType': 'model/gltf-binary'})
123+
err(f' uploaded s3://{bucket}/{key}')
124+
125+
126+
if __name__ == '__main__':
127+
main()

pkgs/static/public/glb/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

pkgs/static/src/App.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export default function App() {
195195
const [isoLevel, setIsoLevel] = useUrlState('iso', optFloatParam({ encoding: 'string', decimals: 1 }), { debounce: 300 })
196196
const [opacity, setOpacity] = useUrlState('op', floatParam({ default: 0.6, encoding: 'string', decimals: 2 }), { debounce: 300 })
197197
const [useGpuVolume, setUseGpuVolume] = useUrlState('gpu', boolParam)
198+
const [useGlbPreview, setUseGlbPreview] = useUrlState('glb', boolParam)
198199
const [colorByDensity, setColorByDensity] = useUrlState('cd', boolParam)
199200
const [showAtoms, setShowAtoms] = useUrlState('ha', boolTrueParam)
200201
const [showAbcCell, setShowAbcCell] = useUrlState('hc', boolTrueParam)
@@ -482,6 +483,14 @@ export default function App() {
482483
defaultBindings: ['v'],
483484
handler: () => setUseGpuVolume(!useGpuVolume),
484485
})
486+
useAction('view:toggle-glb-preview', {
487+
label: 'Toggle GLB mesh preview',
488+
description: 'Load pre-computed GLB isosurface (instant, no CPU/GPU marching)',
489+
keywords: ['glb', 'mesh', 'preview', 'fast', 'isosurface'],
490+
group: 'View',
491+
defaultBindings: ['shift+g'],
492+
handler: () => setUseGlbPreview(!useGlbPreview),
493+
})
485494
useAction('view:toggle-color-by-density', {
486495
label: 'Toggle iso color by density',
487496
description: 'Color/opacity of isosurface varies with iso-level quantile',
@@ -1183,6 +1192,24 @@ export default function App() {
11831192
return sampleRamp(DEFAULT_RAMP, q)
11841193
}, [colorByDensity, densityQuantiles, effectiveIsoLevel])
11851194

1195+
/** Quantiles that have pre-computed GLB previews on the server. */
1196+
const GLB_QUANTILES: number[] = [0.50, 0.75, 0.90, 0.95, 0.99]
1197+
1198+
const glbUrl = useMemo(() => {
1199+
if (!useGlbPreview || !materialId || !densityQuantiles) return null
1200+
const taskId = extractMpId(materialId)
1201+
if (!taskId) return null
1202+
const q = densityToQuantile(effectiveIsoLevel, densityQuantiles)
1203+
// Snap to the nearest pre-computed quantile.
1204+
let best = GLB_QUANTILES[0]
1205+
let bestDiff = Math.abs(q - best)
1206+
for (const cand of GLB_QUANTILES) {
1207+
const diff = Math.abs(q - cand)
1208+
if (diff < bestDiff) { best = cand; bestDiff = diff }
1209+
}
1210+
return `/glb/${taskId}/${best.toFixed(2)}.glb`
1211+
}, [useGlbPreview, materialId, densityQuantiles, effectiveIsoLevel])
1212+
11861213
// Clamp isoLevel URL param when density range changes (e.g. new material)
11871214
useEffect(() => {
11881215
if (isoLevel !== null && primaryFile && isoLevel > maxDensity) {
@@ -1279,6 +1306,7 @@ export default function App() {
12791306
abcIsXyz={abcIsXyz}
12801307
sliceStepSignRef={sliceStepSignRef}
12811308
useGpuVolume={useGpuVolume}
1309+
glbUrl={glbUrl}
12821310
surfaceColor={sampledColor?.color ?? null}
12831311
surfaceOpacityOverride={sampledColor?.opacity ?? null}
12841312
/>

0 commit comments

Comments
 (0)