Skip to content

Commit 4338120

Browse files
committed
Iso mode: signed/diff support + cutoff label in density units
\`IsosurfaceRenderer\` now accepts a \`signed\` prop. When set (passed from diff mode via \`heatmapSigned\`), it marches cubes twice — at \`+|iso|\` (green) and \`-|iso|\` (red) — to render both shells of a signed volume with the diverging-colormap convention used by \`HeatmapRenderer\`. \`DensityHistogram\` gains a matching \`signed\` flag so the iso scrubber bins by \`|val|\` instead of \`val\` (otherwise all negative samples would land in bin 0 and inflate it). Iso markers reflect \`|isoLevel|\`. Controls drops the \`!isDiff\` gate on the Surface section — the iso toggle / scrubber are now available alongside Volumetric heatmap in diff mode, with the two as orthogonal render modes (the data-source mode is a separate axis). Also: the "Low cutoff: X.XX" label in the Heatmap section now displays in density units (\`cutoff × dataAbsMax\`) so it matches the colorbar legend's tick values. Diff prefixes with "±". The URL/state still stores the normalized fraction (invariant across materials of different scale).
1 parent 976d9e7 commit 4338120

4 files changed

Lines changed: 101 additions & 29 deletions

File tree

pkgs/core/src/components/Controls.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -310,13 +310,13 @@ export function Controls({
310310
</div>
311311
)}
312312

313-
{!isDiff && onUseHeatmapChange && (
313+
{onUseHeatmapChange && (
314314
<DrawerSection id="surface" title="Surface" icon={<Atom size={ICON_SIZE} />} accent={ACCENT.surface} defaultOpen>
315315
{/* Iso surface and Volumetric heatmap are mutually exclusive render
316316
modes — checking one unchecks the other. Mirrors the toggle in
317-
the Heatmap section so iso mode is just as discoverable. Hidden
318-
in diff mode (signed data) since iso surfaces are essentially
319-
special-case heatmap views there. */}
317+
the Heatmap section so iso mode is just as discoverable.
318+
In diff mode the iso surface is signed (renders ±iso shells
319+
with red/green diverging colors). */}
320320
<label className={styles.toggle}>
321321
<input type="checkbox" checked={!useHeatmap} onChange={e => onUseHeatmapChange(!e.target.checked)} />
322322
Iso surface
@@ -347,6 +347,7 @@ export function Controls({
347347
isoLevel={isoLevel}
348348
defaultIsoLevel={defaultIsoLevel}
349349
maxDensity={maxDensity}
350+
signed={isDiff}
350351
onCommit={onIsoLevelChange}
351352
onPreview={onIsoPreview}
352353
/>
@@ -455,9 +456,9 @@ export function Controls({
455456
<div className={styles.controlLabel}>
456457
<div className={styles.sliderHeader}>
457458
<span title={isDiff
458-
? "Densities with |val| / dataAbsMax below this fraction contribute zero alpha"
459-
: "Densities below this fraction contribute zero alpha (hard threshold)"
460-
}>Low cutoff: {(displayedHeatmapLowCutoff ?? heatmapLowCutoff).toFixed(2)}</span>
459+
? "Densities with |val| below this contribute zero alpha (URL stores the normalized fraction)"
460+
: "Densities below this contribute zero alpha (URL stores the normalized fraction)"
461+
}>Low cutoff: {isDiff ? '±' : ''}{(((displayedHeatmapLowCutoff ?? heatmapLowCutoff)) * (dataAbsMax ?? maxDensity)).toFixed(2)}</span>
461462
<button
462463
className={styles.resetBtn}
463464
onClick={() => onHeatmapLowCutoffChange(0)}

pkgs/core/src/components/DensityHistogram.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ interface DensityHistogramProps {
1111
maxDensity: number
1212
/** Quantile-mapped axis = each bin spans equal voxel count. False = density-linear. */
1313
equalize?: boolean
14+
/** Signed/diff volumes: bin `|sortedSamples|` so negatives contribute to the
15+
* same bin as their positive counterparts (the iso renderer draws both ±
16+
* shells, so the histogram represents the magnitude distribution). */
17+
signed?: boolean
1418
/** Number of histogram bins. */
1519
bins?: number
1620
/** Committed iso change — writes to URL via parent's debounced setter. */
@@ -30,7 +34,7 @@ const PAD_BOTTOM = 12
3034

3135
export function DensityHistogram({
3236
sortedSamples, isoLevel, defaultIsoLevel, maxDensity,
33-
equalize = false, bins = 64, onCommit, onPreview,
37+
equalize = false, signed = false, bins = 64, onCommit, onPreview,
3438
}: DensityHistogramProps) {
3539
const svgRef = useRef<SVGSVGElement>(null)
3640
const [hoverFrac, setHoverFrac] = useState<number | null>(null)
@@ -44,14 +48,34 @@ export function DensityHistogram({
4448
const n = sortedSamples.length
4549
if (n === 0) return { bars: new Uint32Array(bins), maxLogC: 0, lo: 0, hi: 1 }
4650
const lo = 0
47-
const xCap = sortedSamples[Math.floor(X_AXIS_QUANTILE * (n - 1))]
51+
// For signed data, the x-axis cap should be the 99.5% quantile of |val|,
52+
// not of `val` itself (which would just give the positive tail). Probe
53+
// both ends since the ascending-sorted array's most-negative and
54+
// most-positive entries are the |val| extremes.
55+
const xCap = signed
56+
? Math.max(
57+
Math.abs(sortedSamples[Math.floor(0.005 * (n - 1))]),
58+
Math.abs(sortedSamples[Math.floor(X_AXIS_QUANTILE * (n - 1))]),
59+
)
60+
: sortedSamples[Math.floor(X_AXIS_QUANTILE * (n - 1))]
4861
// Always include the current iso (and default iso) in the visible range so
4962
// the marker lines never clip off-screen when the user dials past the cap.
5063
const hi = equalize ? 1 : Math.min(maxDensity, Math.max(xCap, isoLevel * 1.05, defaultIsoLevel * 1.05))
5164
const counts = new Uint32Array(bins)
5265
if (equalize) {
5366
const per = n / bins
5467
for (let b = 0; b < bins; b++) counts[b] = Math.floor(per)
68+
} else if (signed) {
69+
// Signed: bin |val| so both ± contribute to the same magnitude bucket.
70+
// sortedSamples isn't sorted by |val|, so single-pass bucketing.
71+
const range = hi - lo || 1
72+
const binW = range / bins
73+
for (let k = 0; k < n; k++) {
74+
const v = Math.abs(sortedSamples[k])
75+
if (v > hi) continue
76+
const b = Math.min(bins - 1, Math.floor(v / binW))
77+
counts[b]++
78+
}
5579
} else {
5680
const range = hi - lo || 1
5781
let j = 0
@@ -68,7 +92,7 @@ export function DensityHistogram({
6892
if (l > maxLogC) maxLogC = l
6993
}
7094
return { bars: counts, maxLogC: Math.max(0.1, maxLogC), lo, hi }
71-
}, [sortedSamples, bins, equalize, maxDensity, isoLevel, defaultIsoLevel])
95+
}, [sortedSamples, bins, equalize, signed, maxDensity, isoLevel, defaultIsoLevel])
7296

7397
const densityToFrac = useCallback((v: number): number => {
7498
if (equalize) return densityToQuantile(v, sortedSamples)
@@ -89,8 +113,10 @@ export function DensityHistogram({
89113
return lo + cf * (hi - lo)
90114
}, [equalize, sortedSamples, lo, hi])
91115

92-
const isoFrac = densityToFrac(isoLevel)
93-
const defaultFrac = densityToFrac(defaultIsoLevel)
116+
// In signed mode the user scrubs the |iso| magnitude (the renderer draws
117+
// both ±isoLevel shells), so the markers reflect |val| positions.
118+
const isoFrac = densityToFrac(signed ? Math.abs(isoLevel) : isoLevel)
119+
const defaultFrac = densityToFrac(signed ? Math.abs(defaultIsoLevel) : defaultIsoLevel)
94120

95121
const fracFromEvent = useCallback((clientX: number): number => {
96122
const svg = svgRef.current

pkgs/core/src/components/DensityViewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export function DensityViewer({
248248
? <HeatmapRenderer volume={volume} dataMin={dataRange.min} dataMax={dataRange.max} signed={heatmapSigned} opacity={surfaceOpacityOverride ?? heatmapOpacity ?? opacity} gamma={heatmapGamma} lowCutoff={heatmapLowCutoff} lowCutoffPreviewRef={heatmapLowCutoffPreviewRef} lowCutoffAnim={heatmapCutoffAnim} stepCount={heatmapStepCount} equalize={heatmapEqualize} sortedSamples={sortedSamples} clipAtoms={showAtoms} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} />
249249
: useGpuVolume
250250
? <VolumeRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
251-
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
251+
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} signed={heatmapSigned} />
252252
}
253253
<CrystalStructure volume={volume} showAtoms={showAtoms} showAtomLabels={showAtomLabels} showAbcCell={showAbcCell} showXyzBox={showXyzBox} dashedLines={dashedLines} lineWidth={lineWidth} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} highlightElement={highlightElement} />
254254
{showSlice && sliceAxis !== undefined && sliceIndex !== undefined && (

pkgs/core/src/components/IsosurfaceRenderer.tsx

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,52 @@ interface IsosurfaceRendererProps {
1313
tileFade?: number
1414
/** Surface color as RGB in [0, 1]. Defaults to `#44aaff` if omitted. */
1515
color?: [number, number, number]
16+
/** Signed/diff volumes: render `|isoLevel|` as a green positive shell AND
17+
* `-|isoLevel|` as a red negative shell, instead of just the positive one.
18+
* Matches the diverging colormap convention used by `HeatmapRenderer`. */
19+
signed?: boolean
1620
}
1721

22+
// Diverging-colormap anchors for signed shells. Mirror `HeatmapRenderer`.
23+
const DIFF_POS_COLOR: [number, number, number] = [0.10, 0.95, 0.30] // green
24+
const DIFF_NEG_COLOR: [number, number, number] = [1.00, 0.20, 0.10] // red
25+
1826
function rgbToHex(c: [number, number, number]): string {
1927
const to = (x: number) => Math.max(0, Math.min(255, Math.round(x * 255))).toString(16).padStart(2, '0')
2028
return `#${to(c[0])}${to(c[1])}${to(c[2])}`
2129
}
2230

23-
export function IsosurfaceRenderer({ volume, isoLevel, opacity, tiles, tilePadding = 0, tileFade = 1, color }: IsosurfaceRendererProps) {
31+
export function IsosurfaceRenderer({ volume, isoLevel, opacity, tiles, tilePadding = 0, tileFade = 1, color, signed = false }: IsosurfaceRendererProps) {
2432
const extended = useMemo(
2533
() => extendPeriodicGrid(volume.grid.data, volume.grid.dims),
2634
[volume],
2735
)
2836

29-
const geometry = useMemo(() => {
37+
const isoAbs = Math.abs(isoLevel)
38+
39+
// Positive shell — always rendered (in unsigned mode this is the only shell).
40+
const geometryPos = useMemo(() => {
41+
return marchingCubes(
42+
extended.data,
43+
extended.dims,
44+
isoAbs,
45+
volume.lattice,
46+
volume.grid.dims,
47+
)
48+
}, [extended, isoAbs, volume.lattice])
49+
50+
// Negative shell — only rendered for signed/diff data. Voxels where the
51+
// volume crosses `-isoAbs` form a shell mirroring the positive one.
52+
const geometryNeg = useMemo(() => {
53+
if (!signed) return null
3054
return marchingCubes(
3155
extended.data,
3256
extended.dims,
33-
isoLevel,
57+
-isoAbs,
3458
volume.lattice,
3559
volume.grid.dims,
3660
)
37-
}, [extended, isoLevel, volume.lattice])
61+
}, [signed, extended, isoAbs, volume.lattice])
3862

3963
const fadeCompile = useMemo(() => {
4064
if (tilePadding <= 0) return undefined
@@ -44,26 +68,47 @@ export function IsosurfaceRenderer({ volume, isoLevel, opacity, tiles, tilePaddi
4468
// Early-return AFTER all hooks — React's hook-order rule. Scrubbing iso to
4569
// near-zero (vacuum density) produces an empty mesh; returning before the
4670
// hooks above crashed the viewer with "Rendered fewer hooks than expected".
47-
if (geometry.getAttribute('position')?.count === 0) return null
71+
const posEmpty = geometryPos.getAttribute('position')?.count === 0
72+
const negEmpty = !geometryNeg || geometryNeg.getAttribute('position')?.count === 0
73+
if (posEmpty && negEmpty) return null
4874

4975
const tileList = tiles ?? [{ fracOffset: [0, 0, 0] as [number, number, number], cartOffset: [0, 0, 0] as [number, number, number], opacity: 1, isPrimary: true }]
76+
const posHex = signed ? rgbToHex(DIFF_POS_COLOR) : (color ? rgbToHex(color) : '#44aaff')
77+
const negHex = rgbToHex(DIFF_NEG_COLOR)
5078

5179
return (
5280
<>
5381
{tileList.map((tile, i) => {
5482
if (tile.opacity <= 0) return null
5583
return (
56-
<mesh key={i} geometry={geometry} position={tile.cartOffset}>
57-
<meshStandardMaterial
58-
key={`iso-${tilePadding}-${tileFade}`}
59-
color={color ? rgbToHex(color) : '#44aaff'}
60-
transparent
61-
opacity={opacity}
62-
side={2 /* DoubleSide */}
63-
depthWrite={false}
64-
onBeforeCompile={fadeCompile}
65-
/>
66-
</mesh>
84+
<group key={i} position={tile.cartOffset}>
85+
{!posEmpty && (
86+
<mesh geometry={geometryPos}>
87+
<meshStandardMaterial
88+
key={`iso-pos-${tilePadding}-${tileFade}`}
89+
color={posHex}
90+
transparent
91+
opacity={opacity}
92+
side={2 /* DoubleSide */}
93+
depthWrite={false}
94+
onBeforeCompile={fadeCompile}
95+
/>
96+
</mesh>
97+
)}
98+
{signed && !negEmpty && geometryNeg && (
99+
<mesh geometry={geometryNeg}>
100+
<meshStandardMaterial
101+
key={`iso-neg-${tilePadding}-${tileFade}`}
102+
color={negHex}
103+
transparent
104+
opacity={opacity}
105+
side={2 /* DoubleSide */}
106+
depthWrite={false}
107+
onBeforeCompile={fadeCompile}
108+
/>
109+
</mesh>
110+
)}
111+
</group>
67112
)
68113
})}
69114
</>

0 commit comments

Comments
 (0)