Skip to content

Commit 0f4b9ce

Browse files
committed
Polish iso-level UX: viridis ramp, quantile default, iso sweep
- **Viridis color ramp** (`color-ramp.ts`): replaces the previous ad-hoc 4-stop ramp with a perceptually-uniform 5-stop viridis sequence (purple → blue → teal → green → yellow). Matches the slice preview colormap so iso-level sweeps feel coherent with the 2D view. - **Default iso at 95th percentile** (`App.tsx`): previous default of `mean + 2σ` sometimes clipped below meaningful density for highly skewed distributions. Now uses the pre-computed quantile table directly; still falls back to mean+2σ if quantiles aren't ready yet. - **Iso sweep animation** (`Shift+I`): 4-second ease-in-out cycle through the full quantile range. Uses `requestAnimationFrame` + the `densityQuantiles` lookup so each frame advances to a valid density value. Re-pressing cancels mid-animation. Feels butter-smooth in GPU mode; CPU mode keeps up at lower frame rates. - **Centralized quantile utilities**: `quantileToDensity` moved from `Controls.tsx` into `color-ramp.ts` (co-located with `densityToQuantile`) and re-exported from `@elvis/core`.
1 parent f4c46f6 commit 0f4b9ce

5 files changed

Lines changed: 77 additions & 40 deletions

File tree

pkgs/core/src/components/Controls.tsx

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getElement } from '../utils/elements.ts'
2+
import { densityToQuantile, quantileToDensity } from '../utils/color-ramp.ts'
23
import styles from './Controls.module.css'
34

45
const ResetIcon = () => (
@@ -78,34 +79,6 @@ function fmtAngle(n: number): string {
7879
return s.endsWith('.0') ? s.slice(0, -2) : s
7980
}
8081

81-
/** Convert a density value to its quantile position in [0, 1] via binary search. */
82-
function densityToQuantile(v: number, qs: Float32Array): number {
83-
let lo = 0, hi = qs.length - 1
84-
while (lo < hi) {
85-
const mid = (lo + hi) >> 1
86-
if (qs[mid] < v) lo = mid + 1
87-
else hi = mid
88-
}
89-
// Refine between adjacent quantiles for smooth inverse
90-
if (lo > 0 && qs[lo] > v) {
91-
const span = qs[lo] - qs[lo - 1]
92-
const frac = span > 0 ? (v - qs[lo - 1]) / span : 0
93-
return (lo - 1 + frac) / (qs.length - 1)
94-
}
95-
return lo / (qs.length - 1)
96-
}
97-
98-
/** Convert a quantile position in [0, 1] to a density value via linear interp. */
99-
function quantileToDensity(q: number, qs: Float32Array): number {
100-
const n = qs.length
101-
if (q <= 0) return qs[0]
102-
if (q >= 1) return qs[n - 1]
103-
const pos = q * (n - 1)
104-
const i = Math.floor(pos)
105-
const frac = pos - i
106-
return qs[i] * (1 - frac) + qs[Math.min(n - 1, i + 1)] * frac
107-
}
108-
10982
export function Controls({
11083
isoLevel,
11184
defaultIsoLevel,

pkgs/core/src/components/VolumeRenderer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ void main() {
179179
// But simpler: just use the fracNormal direction in world space via the inverse transform
180180
vec3 worldNormal = normalize((inverse(uCartToFrac) * vec4(fracNormal, 0.0)).xyz);
181181
182-
// Lighting (Blinn-Phong)
182+
// Blinn-Phong lighting (single directional + ambient). Matching the
183+
// scene's three-light rig and getting CPU parity is follow-up work.
183184
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.8));
184185
float diffuse = max(dot(worldNormal, lightDir), 0.0);
185186
float ambient = 0.3;

pkgs/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export { parsePymatgenChgcar } from './parsers/pymatgen-chgcar.ts'
1919
// Utils
2020
export { marchingCubes, extendPeriodicGrid } from './utils/marching-cubes.ts'
2121
export { fracToCart, latticeToMatrix4, cellVolume, unitCellEdges, unitCellBoundingBox } from './utils/lattice.ts'
22-
export { sampleRamp, densityToQuantile, DEFAULT_RAMP } from './utils/color-ramp.ts'
22+
export { sampleRamp, densityToQuantile, quantileToDensity, DEFAULT_RAMP } from './utils/color-ramp.ts'
2323
export type { ColorStop, SampledColor } from './utils/color-ramp.ts'
2424
export { computeTiles, atomOpacity, distFromPrimaryCell } from './utils/tiling.ts'
2525
export type { TileInfo } from './utils/tiling.ts'

pkgs/core/src/utils/color-ramp.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ export interface ColorStop {
1313
opacity: number
1414
}
1515

16-
/** Default ramp: diffuse cool → dense hot, mimicking a "charge temperature". */
16+
/**
17+
* Default ramp: a viridis-like perceptual sequence. Low-quantile (diffuse)
18+
* iso surfaces appear purple/blue with low opacity; high-quantile (tight
19+
* atomic-core) surfaces appear yellow-green with higher opacity. Viridis is
20+
* perceptually uniform and colorblind-friendly.
21+
*/
1722
export const DEFAULT_RAMP: ColorStop[] = [
18-
{ q: 0.0, color: [0.15, 0.25, 0.85], opacity: 0.25 },
19-
{ q: 0.5, color: [0.20, 0.70, 0.95], opacity: 0.50 },
20-
{ q: 0.9, color: [0.95, 0.80, 0.30], opacity: 0.75 },
21-
{ q: 1.0, color: [1.00, 0.40, 0.25], opacity: 0.90 },
23+
{ q: 0.0, color: [0.267, 0.004, 0.329], opacity: 0.22 }, // dark purple
24+
{ q: 0.25, color: [0.229, 0.322, 0.546], opacity: 0.38 }, // blue
25+
{ q: 0.5, color: [0.128, 0.567, 0.551], opacity: 0.55 }, // teal
26+
{ q: 0.75, color: [0.369, 0.788, 0.383], opacity: 0.72 }, // green
27+
{ q: 1.0, color: [0.993, 0.906, 0.144], opacity: 0.88 }, // yellow
2228
]
2329

2430
export interface SampledColor {
@@ -49,6 +55,17 @@ export function sampleRamp(stops: ColorStop[], q: number): SampledColor {
4955
return { color: last.color, opacity: last.opacity }
5056
}
5157

58+
/** Linear interpolation in a sorted quantile array, q ∈ [0, 1] → density. */
59+
export function quantileToDensity(q: number, qs: Float32Array): number {
60+
const n = qs.length
61+
if (q <= 0) return qs[0]
62+
if (q >= 1) return qs[n - 1]
63+
const pos = q * (n - 1)
64+
const i = Math.floor(pos)
65+
const frac = pos - i
66+
return qs[i] * (1 - frac) + qs[Math.min(n - 1, i + 1)] * frac
67+
}
68+
5269
/**
5370
* Binary-search a density value `v` in a sorted quantile array (qs[i] = i/(n-1) quantile),
5471
* returning a quantile position in [0, 1] with linear refinement between adjacent bins.

pkgs/static/src/App.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ interface LoadedFile {
4242
filename: string
4343
}
4444

45+
const DEFAULT_ISO_QUANTILE = 0.95
46+
47+
function computeDefaultIsoFromQuantiles(qs: Float32Array): number {
48+
// Quantiles array is sorted; index maps linearly to quantile.
49+
const idx = Math.round(DEFAULT_ISO_QUANTILE * (qs.length - 1))
50+
return qs[idx]
51+
}
52+
53+
/** Fallback default when quantile table isn't available (early load). */
4554
function computeDefaultIsoLevel(data: Float32Array): number {
4655
let sum = 0
4756
let sumSq = 0
@@ -481,6 +490,40 @@ export default function App() {
481490
defaultBindings: ['shift+c'],
482491
handler: () => setColorByDensity(!colorByDensity),
483492
})
493+
const densityQuantilesRef = useRef<Float32Array | null>(null)
494+
const isoSweepRaf = useRef<number | null>(null)
495+
useAction('iso:sweep', {
496+
label: 'Sweep iso level',
497+
description: 'Animate iso from low to high density over ~4s (re-press to cancel)',
498+
keywords: ['iso', 'animation', 'sweep', 'density', 'quantile'],
499+
group: 'Surface',
500+
defaultBindings: ['shift+i'],
501+
handler: () => {
502+
if (isoSweepRaf.current !== null) {
503+
cancelAnimationFrame(isoSweepRaf.current)
504+
isoSweepRaf.current = null
505+
return
506+
}
507+
const qs = densityQuantilesRef.current
508+
if (!qs) return
509+
const durationMs = 4000
510+
const t0 = performance.now()
511+
const tick = (now: number) => {
512+
const elapsed = now - t0
513+
const t = elapsed / durationMs
514+
if (t >= 1) {
515+
setIsoLevel(qs[qs.length - 1])
516+
isoSweepRaf.current = null
517+
return
518+
}
519+
const eased = 0.5 - 0.5 * Math.cos(Math.PI * t)
520+
const idx = Math.round(eased * (qs.length - 1))
521+
setIsoLevel(qs[idx])
522+
isoSweepRaf.current = requestAnimationFrame(tick)
523+
}
524+
isoSweepRaf.current = requestAnimationFrame(tick)
525+
},
526+
})
484527
useActionPair('iso:step', {
485528
label: 'Decrease / increase iso level',
486529
description: 'Adjust isosurface threshold by keyboard step',
@@ -1116,15 +1159,18 @@ export default function App() {
11161159
return max
11171160
}, [primaryFile])
11181161

1119-
const defaultIsoLevel = useMemo(() => {
1120-
if (!primaryFile) return 0
1121-
return computeDefaultIsoLevel(primaryFile.data.grid.data)
1122-
}, [primaryFile])
1123-
11241162
const densityQuantiles = useMemo(() => {
11251163
if (!primaryFile) return null
11261164
return computeDensityQuantiles(primaryFile.data.grid.data)
11271165
}, [primaryFile])
1166+
densityQuantilesRef.current = densityQuantiles
1167+
1168+
const defaultIsoLevel = useMemo(() => {
1169+
if (!primaryFile) return 0
1170+
return densityQuantiles
1171+
? computeDefaultIsoFromQuantiles(densityQuantiles)
1172+
: computeDefaultIsoLevel(primaryFile.data.grid.data)
1173+
}, [primaryFile, densityQuantiles])
11281174

11291175
const effectiveIsoLevel = useMemo(
11301176
() => Math.max(0, Math.min(isoLevel ?? defaultIsoLevel, maxDensity)),

0 commit comments

Comments
 (0)