matterviz 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FilePicker.svelte +40 -50
- package/dist/api/optimade.js +10 -7
- package/dist/app.css +15 -0
- package/dist/brillouin/BrillouinZone.svelte +18 -9
- package/dist/brillouin/BrillouinZoneScene.svelte +20 -29
- package/dist/brillouin/compute.js +10 -16
- package/dist/brillouin/geometry.js +2 -2
- package/dist/chempot-diagram/ChemPotDiagram.svelte +18 -15
- package/dist/chempot-diagram/ChemPotDiagram2D.svelte +17 -113
- package/dist/chempot-diagram/ChemPotDiagram3D.svelte +104 -264
- package/dist/chempot-diagram/async-compute.svelte.js +12 -13
- package/dist/chempot-diagram/color.d.ts +21 -1
- package/dist/chempot-diagram/color.js +70 -1
- package/dist/chempot-diagram/compute.d.ts +7 -0
- package/dist/chempot-diagram/compute.js +85 -26
- package/dist/chempot-diagram/export.js +1 -6
- package/dist/chempot-diagram/temperature.js +9 -10
- package/dist/composition/BarChart.svelte +7 -9
- package/dist/composition/Composition.svelte +3 -8
- package/dist/composition/Formula.svelte +2 -2
- package/dist/composition/FormulaFilter.svelte +27 -93
- package/dist/composition/format.js +6 -8
- package/dist/composition/parse.js +50 -105
- package/dist/constants.d.ts +0 -1
- package/dist/constants.js +6 -17
- package/dist/convex-hull/ConvexHull2D.svelte +0 -2
- package/dist/convex-hull/ConvexHull3D.svelte +19 -47
- package/dist/convex-hull/ConvexHull4D.svelte +65 -95
- package/dist/convex-hull/StructurePopup.svelte +1 -1
- package/dist/convex-hull/gas-thermodynamics.js +21 -53
- package/dist/convex-hull/helpers.js +1 -7
- package/dist/convex-hull/thermodynamics.js +19 -27
- package/dist/coordination/CoordinationBarPlot.svelte +12 -21
- package/dist/element/BohrAtom.svelte +0 -1
- package/dist/element/ElementTile.svelte +26 -44
- package/dist/element/data.d.ts +2 -0
- package/dist/element/data.js +5 -1
- package/dist/element/index.d.ts +1 -1
- package/dist/element/index.js +1 -1
- package/dist/fermi-surface/FermiSurface.svelte +22 -23
- package/dist/fermi-surface/FermiSurface.svelte.d.ts +2 -6
- package/dist/fermi-surface/FermiSurfaceScene.svelte +32 -77
- package/dist/fermi-surface/compute.js +22 -55
- package/dist/fermi-surface/parse.js +38 -70
- package/dist/file-viewer/JsonBrowser.svelte +1043 -0
- package/dist/file-viewer/JsonBrowser.svelte.d.ts +9 -0
- package/dist/file-viewer/PlotPanel.svelte +350 -0
- package/dist/file-viewer/PlotPanel.svelte.d.ts +9 -0
- package/dist/file-viewer/TrajectoryWithDos.svelte +30 -0
- package/dist/file-viewer/TrajectoryWithDos.svelte.d.ts +10 -0
- package/dist/file-viewer/detect.d.ts +11 -0
- package/dist/file-viewer/detect.js +381 -0
- package/dist/file-viewer/eligibility.d.ts +4 -0
- package/dist/file-viewer/eligibility.js +53 -0
- package/dist/file-viewer/host-protocol.d.ts +50 -0
- package/dist/file-viewer/host-transfer.d.ts +25 -0
- package/dist/file-viewer/host-transfer.js +42 -0
- package/dist/file-viewer/index.d.ts +5 -0
- package/dist/file-viewer/index.js +5 -0
- package/dist/file-viewer/main.d.ts +44 -0
- package/dist/file-viewer/main.js +535 -0
- package/dist/file-viewer/parse.d.ts +9 -0
- package/dist/file-viewer/parse.js +170 -0
- package/dist/file-viewer/plot-utils.d.ts +24 -0
- package/dist/file-viewer/plot-utils.js +191 -0
- package/dist/file-viewer/types.d.ts +4 -0
- package/dist/file-viewer/types.js +5 -0
- package/dist/heatmap-matrix/HeatmapMatrix.svelte +57 -84
- package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/io/ExportPane.svelte +1 -1
- package/dist/io/decompress.d.ts +2 -0
- package/dist/io/decompress.js +3 -2
- package/dist/io/export.d.ts +7 -4
- package/dist/io/export.js +70 -18
- package/dist/io/file-drop.js +60 -35
- package/dist/io/url-drop.d.ts +2 -0
- package/dist/io/url-drop.js +28 -16
- package/dist/isosurface/Isosurface.svelte +687 -196
- package/dist/isosurface/Isosurface.svelte.d.ts +7 -1
- package/dist/isosurface/IsosurfaceControls.svelte +564 -85
- package/dist/isosurface/IsosurfaceControls.svelte.d.ts +1 -1
- package/dist/isosurface/VolumeSlice.svelte +224 -0
- package/dist/isosurface/VolumeSlice.svelte.d.ts +28 -0
- package/dist/isosurface/coloring.d.ts +20 -0
- package/dist/isosurface/coloring.js +118 -0
- package/dist/isosurface/geometry-worker-types.d.ts +38 -0
- package/dist/isosurface/geometry.worker.d.ts +1 -0
- package/dist/isosurface/geometry.worker.js +59 -0
- package/dist/isosurface/grid.d.ts +10 -0
- package/dist/isosurface/grid.js +31 -0
- package/dist/isosurface/index.d.ts +4 -0
- package/dist/isosurface/index.js +4 -0
- package/dist/isosurface/profile.d.ts +12 -0
- package/dist/isosurface/profile.js +19 -0
- package/dist/isosurface/sampling.d.ts +34 -0
- package/dist/isosurface/sampling.js +408 -0
- package/dist/isosurface/slice-rendering.d.ts +13 -0
- package/dist/isosurface/slice-rendering.js +79 -0
- package/dist/isosurface/slice.d.ts +22 -2
- package/dist/isosurface/slice.js +200 -116
- package/dist/isosurface/types.d.ts +24 -1
- package/dist/isosurface/types.js +120 -8
- package/dist/json-path.d.ts +4 -0
- package/dist/json-path.js +89 -0
- package/dist/labels.js +1 -1
- package/dist/layout/InfoCard.svelte +1 -3
- package/dist/layout/SettingsSection.svelte +52 -61
- package/dist/layout/SettingsSection.svelte.d.ts +6 -7
- package/dist/layout/ViewerChrome.svelte +7 -1
- package/dist/layout/json-tree/JsonNode.svelte +14 -48
- package/dist/layout/json-tree/JsonTree.svelte +48 -95
- package/dist/layout/json-tree/JsonValue.svelte +3 -18
- package/dist/layout/json-tree/utils.d.ts +0 -3
- package/dist/layout/json-tree/utils.js +50 -179
- package/dist/marching-cubes.d.ts +8 -1
- package/dist/marching-cubes.js +115 -48
- package/dist/math.d.ts +3 -2
- package/dist/math.js +29 -38
- package/dist/overlays/ContextMenu.svelte +15 -32
- package/dist/overlays/DraggablePane.svelte +39 -58
- package/dist/overlays/DraggablePane.svelte.d.ts +1 -1
- package/dist/overlays/GlassChip.svelte +3 -1
- package/dist/periodic-table/PeriodicTableControls.svelte +26 -52
- package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +18 -34
- package/dist/phase-diagram/build-diagram.js +1 -6
- package/dist/phase-diagram/svg-to-diagram.js +9 -14
- package/dist/plot/bar/BarPlot.svelte +38 -42
- package/dist/plot/bar/SpacegroupBarPlot.svelte +18 -42
- package/dist/plot/box/BoxPlot.svelte +27 -23
- package/dist/plot/core/auto-place.d.ts +1 -1
- package/dist/plot/core/auto-place.js +1 -1
- package/dist/plot/core/data-transform.d.ts +1 -2
- package/dist/plot/core/data-transform.js +0 -10
- package/dist/plot/core/layout.d.ts +12 -5
- package/dist/plot/core/layout.js +37 -27
- package/dist/plot/core/pan-zoom.svelte.d.ts +2 -4
- package/dist/plot/core/pan-zoom.svelte.js +34 -17
- package/dist/plot/core/placed-tween.svelte.d.ts +3 -0
- package/dist/plot/core/placed-tween.svelte.js +68 -21
- package/dist/plot/core/svg.js +0 -1
- package/dist/plot/core/utils/hierarchy-chart.d.ts +1 -0
- package/dist/plot/core/utils/hierarchy-chart.js +12 -0
- package/dist/plot/core/utils/hierarchy-labels.js +3 -2
- package/dist/plot/histogram/Histogram.svelte +21 -26
- package/dist/plot/sankey/sankey.js +13 -9
- package/dist/plot/scatter/BinnedScatterPlot.svelte +59 -43
- package/dist/plot/scatter/ScatterPlot.svelte +67 -52
- package/dist/plot/scatter/adaptive-density.d.ts +1 -1
- package/dist/plot/scatter/adaptive-density.js +36 -20
- package/dist/plot/sunburst/Sunburst.svelte +20 -20
- package/dist/plot/treemap/Treemap.svelte +175 -60
- package/dist/plot/treemap/Treemap.svelte.d.ts +6 -0
- package/dist/plot/treemap/index.d.ts +1 -0
- package/dist/plot/treemap/labels.d.ts +38 -0
- package/dist/plot/treemap/labels.js +80 -0
- package/dist/plot/treemap/treemap.d.ts +4 -2
- package/dist/plot/treemap/treemap.js +5 -2
- package/dist/rdf/RdfPlot.svelte +17 -35
- package/dist/rdf/calc-rdf.js +59 -44
- package/dist/rdf/index.d.ts +0 -1
- package/dist/sanitize.js +84 -26
- package/dist/scene/SceneCamera.svelte +1 -1
- package/dist/scene/SceneCamera.svelte.d.ts +1 -1
- package/dist/scene/index.d.ts +1 -2
- package/dist/scene/index.js +1 -1
- package/dist/scene/{props.js → props.svelte.js} +15 -1
- package/dist/settings.js +159 -482
- package/dist/spectral/Bands.svelte +151 -158
- package/dist/spectral/BrillouinBandsDos.svelte +4 -19
- package/dist/spectral/Dos.svelte +26 -24
- package/dist/spectral/helpers.js +40 -105
- package/dist/structure/AtomLegend.svelte +8 -13
- package/dist/structure/AtomLegend.svelte.d.ts +2 -3
- package/dist/structure/CellSelect.svelte +35 -10
- package/dist/structure/Structure.svelte +177 -99
- package/dist/structure/Structure.svelte.d.ts +1 -1
- package/dist/structure/StructureCarousel.svelte +10 -3
- package/dist/structure/StructureControls.svelte +8 -26
- package/dist/structure/StructureControls.svelte.d.ts +1 -1
- package/dist/structure/StructureInfoPane.svelte +65 -76
- package/dist/structure/StructureScene.svelte +75 -109
- package/dist/structure/StructureScene.svelte.d.ts +3 -1
- package/dist/structure/StructureViewport.svelte +120 -55
- package/dist/structure/StructureViewport.svelte.d.ts +3 -1
- package/dist/structure/atom-properties.d.ts +1 -1
- package/dist/structure/atom-properties.js +4 -3
- package/dist/structure/bond-order-perception.js +3 -8
- package/dist/structure/bonding.d.ts +0 -1
- package/dist/structure/bonding.js +16 -23
- package/dist/structure/export.js +50 -74
- package/dist/structure/format-detect.js +5 -7
- package/dist/structure/index.js +6 -12
- package/dist/structure/parse.js +38 -100
- package/dist/structure/partial-occupancy.js +7 -4
- package/dist/structure/pbc.js +8 -10
- package/dist/structure/polyhedra.js +5 -4
- package/dist/structure/prop-groups.d.ts +85 -0
- package/dist/structure/prop-groups.js +18 -0
- package/dist/symmetry/SymmetryStats.svelte +0 -4
- package/dist/symmetry/WyckoffTable.svelte +5 -4
- package/dist/symmetry/cell-transform.js +5 -14
- package/dist/symmetry/index.js +6 -9
- package/dist/table/HeatmapTable.svelte +61 -103
- package/dist/table/ToggleMenu.svelte +166 -44
- package/dist/table/ToggleMenu.svelte.d.ts +1 -1
- package/dist/theme/embedded.d.ts +18 -0
- package/dist/theme/embedded.js +206 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +1 -2
- package/dist/theme/themes.mjs +31 -60
- package/dist/tooltip/TooltipContent.svelte +1 -1
- package/dist/tooltip/TooltipContent.svelte.d.ts +1 -1
- package/dist/tooltip/index.d.ts +8 -1
- package/dist/trajectory/Trajectory.svelte +140 -120
- package/dist/trajectory/TrajectoryError.svelte +1 -66
- package/dist/trajectory/TrajectoryExportPane.svelte +0 -1
- package/dist/trajectory/TrajectoryInfoPane.svelte +55 -80
- package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +2 -1
- package/dist/trajectory/extract.js +19 -29
- package/dist/trajectory/format-detect.d.ts +2 -0
- package/dist/trajectory/format-detect.js +7 -5
- package/dist/trajectory/frame-reader.js +3 -4
- package/dist/trajectory/helpers.js +16 -49
- package/dist/trajectory/index.js +2 -8
- package/dist/trajectory/parse/ase.js +1 -1
- package/dist/trajectory/parse/index.d.ts +4 -3
- package/dist/trajectory/parse/index.js +12 -17
- package/dist/trajectory/parse/vaspout-h5.d.ts +0 -1
- package/dist/trajectory/parse/vaspout-h5.js +1 -4
- package/dist/trajectory/parse/xyz.js +48 -2
- package/dist/trajectory/plotting.js +14 -37
- package/dist/xrd/XrdPlot.svelte +43 -68
- package/dist/xrd/calc-xrd.js +22 -58
- package/dist/xrd/parse.js +13 -22
- package/package.json +40 -8
- package/readme.md +6 -5
- package/dist/scene/types.d.ts +0 -26
- package/dist/tooltip/types.d.ts +0 -8
- package/dist/trajectory/constants.d.ts +0 -6
- package/dist/trajectory/constants.js +0 -11
- /package/dist/{scene/types.js → file-viewer/host-protocol.js} +0 -0
- /package/dist/{tooltip/types.js → isosurface/geometry-worker-types.js} +0 -0
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Threlte component that renders isosurface meshes from
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// Threlte component that renders isosurface meshes from one or more volumetric
|
|
3
|
+
// datasets using marching cubes. Each layer picks a geometry-source volume and
|
|
4
|
+
// optionally a different color-source volume whose scalar field is sampled at
|
|
5
|
+
// surface vertices and mapped through a colormap (e.g. density colored by ESP).
|
|
6
|
+
// Geometry is cached per layer so colormap/color-range changes never rerun
|
|
7
|
+
// marching cubes, and sampled scalars are cached so they only remap through the
|
|
8
|
+
// colormap LUT.
|
|
9
|
+
import { marching_cubes_buffers } from '../marching-cubes'
|
|
6
10
|
import type { Matrix3x3, Vec3 } from '../math'
|
|
7
11
|
import { T } from '@threlte/core'
|
|
12
|
+
import { untrack } from 'svelte'
|
|
13
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
8
14
|
import {
|
|
9
15
|
BackSide,
|
|
10
16
|
BufferAttribute,
|
|
@@ -13,20 +19,77 @@
|
|
|
13
19
|
FrontSide,
|
|
14
20
|
Uint32BufferAttribute,
|
|
15
21
|
} from 'three'
|
|
22
|
+
import {
|
|
23
|
+
compute_scalar_range,
|
|
24
|
+
DEFAULT_ISO_COLORMAP,
|
|
25
|
+
is_signed_range,
|
|
26
|
+
scalars_to_vertex_colors,
|
|
27
|
+
} from './coloring'
|
|
28
|
+
import type {
|
|
29
|
+
GeometryWorkerRequest,
|
|
30
|
+
GeometryWorkerResponse,
|
|
31
|
+
TransferableVolume,
|
|
32
|
+
} from './geometry-worker-types'
|
|
33
|
+
import { flatten_grid, grid_point_count, inflate_grid } from './grid'
|
|
34
|
+
import { profile_stage, record_profile, type IsosurfaceProfiler } from './profile'
|
|
35
|
+
import type { DisplayRange } from './sampling'
|
|
36
|
+
import {
|
|
37
|
+
prepare_geometry_grid,
|
|
38
|
+
resolve_volume_display_range,
|
|
39
|
+
sample_volume_at_positions,
|
|
40
|
+
} from './sampling'
|
|
16
41
|
import type { IsosurfaceLayer, IsosurfaceSettings, VolumetricData } from './types'
|
|
17
|
-
import { DEFAULT_ISOSURFACE_SETTINGS,
|
|
42
|
+
import { DEFAULT_ISOSURFACE_SETTINGS, MAX_GRID_POINTS } from './types'
|
|
18
43
|
|
|
19
44
|
let {
|
|
20
|
-
|
|
45
|
+
volumes = [],
|
|
46
|
+
volume = undefined,
|
|
21
47
|
settings = DEFAULT_ISOSURFACE_SETTINGS,
|
|
48
|
+
active_volume_idx = 0,
|
|
49
|
+
tiling = [1, 1, 1],
|
|
50
|
+
profiler = undefined,
|
|
22
51
|
}: {
|
|
23
|
-
|
|
52
|
+
volumes?: VolumetricData[]
|
|
53
|
+
// Deprecated single-volume alias (kept for backwards compatibility)
|
|
54
|
+
volume?: VolumetricData
|
|
24
55
|
settings?: IsosurfaceSettings
|
|
56
|
+
// Volume that implicit single-isovalue settings apply to when no explicit
|
|
57
|
+
// layers are set (also the fallback for layers without volume_idx)
|
|
58
|
+
active_volume_idx?: number
|
|
59
|
+
// Supercell tiling applied to geometry volumes (color sampling always uses
|
|
60
|
+
// the original volume with periodic wrapping for full fidelity)
|
|
61
|
+
tiling?: Vec3
|
|
62
|
+
// Internal benchmark hook. Deliberately not re-exported from the package API.
|
|
63
|
+
profiler?: IsosurfaceProfiler
|
|
25
64
|
} = $props()
|
|
26
65
|
|
|
27
|
-
|
|
28
|
-
let
|
|
29
|
-
|
|
66
|
+
let all_volumes = $derived(volumes.length ? volumes : volume ? [volume] : [])
|
|
67
|
+
let reference_origin = $derived<Vec3>(all_volumes[0]?.origin ?? [0, 0, 0])
|
|
68
|
+
let reference_origin_key = $derived(reference_origin.join(`,`))
|
|
69
|
+
|
|
70
|
+
type ResolvedLayer = IsosurfaceLayer & { volume_idx: number }
|
|
71
|
+
|
|
72
|
+
// Resolve layers: explicit layers array if provided (an empty array means zero
|
|
73
|
+
// surfaces), else one implicit layer from single-isovalue settings bound to
|
|
74
|
+
// the active volume. Layers with an explicitly out-of-range volume_idx are
|
|
75
|
+
// skipped rather than clamped — silently rendering a different volume's data
|
|
76
|
+
// would be scientifically wrong.
|
|
77
|
+
let resolved_layers = $derived.by((): ResolvedLayer[] => {
|
|
78
|
+
const n_vols = all_volumes.length
|
|
79
|
+
if (n_vols === 0) return []
|
|
80
|
+
const clamp_idx = (idx: number) => Math.min(Math.max(idx, 0), n_vols - 1)
|
|
81
|
+
if (settings.layers) {
|
|
82
|
+
return settings.layers
|
|
83
|
+
.filter(
|
|
84
|
+
(layer) =>
|
|
85
|
+
layer.volume_idx === undefined ||
|
|
86
|
+
(layer.volume_idx >= 0 && layer.volume_idx < n_vols),
|
|
87
|
+
)
|
|
88
|
+
.map((layer) => ({
|
|
89
|
+
...layer,
|
|
90
|
+
volume_idx: layer.volume_idx ?? clamp_idx(active_volume_idx),
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
30
93
|
return [
|
|
31
94
|
{
|
|
32
95
|
isovalue: settings.isovalue,
|
|
@@ -35,34 +98,94 @@
|
|
|
35
98
|
visible: true,
|
|
36
99
|
show_negative: settings.show_negative,
|
|
37
100
|
negative_color: settings.negative_color,
|
|
101
|
+
volume_idx: clamp_idx(active_volume_idx),
|
|
38
102
|
},
|
|
39
103
|
]
|
|
40
104
|
})
|
|
41
105
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
for (let idx = 0; idx < vertices.length; idx++) {
|
|
51
|
-
const vert = vertices[idx]
|
|
52
|
-
positions[idx * 3] = vert[0]
|
|
53
|
-
positions[idx * 3 + 1] = vert[1]
|
|
54
|
-
positions[idx * 3 + 2] = vert[2]
|
|
106
|
+
// Stable identity tokens for volume objects so cache keys detect replacement
|
|
107
|
+
let vol_id_counter = 0
|
|
108
|
+
const vol_ids = new WeakMap<VolumetricData, number>()
|
|
109
|
+
const vol_id = (vol: VolumetricData): number => {
|
|
110
|
+
let id = vol_ids.get(vol)
|
|
111
|
+
if (id === undefined) {
|
|
112
|
+
id = ++vol_id_counter
|
|
113
|
+
vol_ids.set(vol, id)
|
|
55
114
|
}
|
|
115
|
+
return id
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Finite extraction range for this volume. Periodic volumes always use one
|
|
119
|
+
// so integer tiling and fractional VESTA-style bounds share exact endpoint
|
|
120
|
+
// semantics; finite volumes only use an explicitly requested crop (the
|
|
121
|
+
// resolver clamps negative halo and only applies it to periodic volumes).
|
|
122
|
+
const effective_range = (vol: VolumetricData): DisplayRange | null =>
|
|
123
|
+
resolve_volume_display_range(vol, {
|
|
124
|
+
display_range: settings.display_range,
|
|
125
|
+
tiling,
|
|
126
|
+
halo: settings.halo,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// === Geometry-volume preparation (range extraction or finite-grid downsampling) ===
|
|
130
|
+
// The range fully determines the prepared grid for a given volume: it encodes
|
|
131
|
+
// tiling and halo for periodic volumes and is null/explicit-crop for finite
|
|
132
|
+
// ones (which ignore tiling and halo entirely).
|
|
133
|
+
type PreparedGrid = {
|
|
134
|
+
key: string
|
|
135
|
+
grid: number[][][]
|
|
136
|
+
lattice: Matrix3x3
|
|
137
|
+
// Cartesian shift applied to marching-cubes vertices: range offset plus this
|
|
138
|
+
// volume's origin delta relative to the scene reference (first volume's origin)
|
|
139
|
+
vertex_shift: Vec3
|
|
140
|
+
}
|
|
141
|
+
// Keyed by volume object so removed/replaced volumes release their prepared
|
|
142
|
+
// grids to GC automatically and index shifts never serve stale grids
|
|
143
|
+
const prepared_cache = new WeakMap<VolumetricData, PreparedGrid>()
|
|
144
|
+
|
|
145
|
+
const range_key = (vol: VolumetricData): string =>
|
|
146
|
+
effective_range(vol)?.flat().join(`,`) ?? ``
|
|
147
|
+
|
|
148
|
+
const prepared_key = (vol: VolumetricData): string =>
|
|
149
|
+
`${range_key(vol)}|${reference_origin_key}`
|
|
150
|
+
const vertex_shift = (origin: Vec3): Vec3 =>
|
|
151
|
+
origin.map((value, axis) => value - reference_origin[axis]) as Vec3
|
|
56
152
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
153
|
+
const current_prepared = (vol: VolumetricData): PreparedGrid | undefined => {
|
|
154
|
+
const cached = prepared_cache.get(vol)
|
|
155
|
+
return cached?.key === prepared_key(vol) ? cached : undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function prepare_geometry_volume(vol: VolumetricData): PreparedGrid {
|
|
159
|
+
const cached = current_prepared(vol)
|
|
160
|
+
if (cached) {
|
|
161
|
+
record_profile(profiler, `prepare_geometry`, 0, { cache_hit: true })
|
|
162
|
+
return cached
|
|
163
|
+
}
|
|
164
|
+
const profile_start = profiler ? performance.now() : 0
|
|
165
|
+
|
|
166
|
+
const range = effective_range(vol)
|
|
167
|
+
const { grid, lattice, origin } = prepare_geometry_grid(vol, range)
|
|
168
|
+
|
|
169
|
+
const prepared: PreparedGrid = {
|
|
170
|
+
key: prepared_key(vol),
|
|
171
|
+
grid,
|
|
172
|
+
lattice,
|
|
173
|
+
vertex_shift: vertex_shift(origin),
|
|
64
174
|
}
|
|
175
|
+
prepared_cache.set(vol, prepared)
|
|
176
|
+
record_profile(profiler, `prepare_geometry`, performance.now() - profile_start, {
|
|
177
|
+
output_points: grid_point_count(grid),
|
|
178
|
+
extracted_range: range !== null,
|
|
179
|
+
})
|
|
180
|
+
return prepared
|
|
181
|
+
}
|
|
65
182
|
|
|
183
|
+
// Build indexed BufferGeometry from marching cubes output
|
|
184
|
+
function build_geometry(
|
|
185
|
+
positions: Float32Array,
|
|
186
|
+
indices: Uint32Array,
|
|
187
|
+
): BufferGeometry | null {
|
|
188
|
+
if (positions.length === 0 || indices.length === 0) return null
|
|
66
189
|
const geometry = new BufferGeometry()
|
|
67
190
|
geometry.setAttribute(`position`, new BufferAttribute(positions, 3))
|
|
68
191
|
geometry.setIndex(new Uint32BufferAttribute(indices, 1))
|
|
@@ -70,210 +193,578 @@
|
|
|
70
193
|
geometry.computeBoundingSphere()
|
|
71
194
|
return geometry
|
|
72
195
|
}
|
|
196
|
+
const geometry_buffer_bytes = (geometry: BufferGeometry): number =>
|
|
197
|
+
[`position`, `normal`, `color`].reduce(
|
|
198
|
+
(total, name) => total + (geometry.getAttribute(name)?.array.byteLength ?? 0),
|
|
199
|
+
geometry.getIndex()?.array.byteLength ?? 0,
|
|
200
|
+
)
|
|
73
201
|
|
|
74
|
-
|
|
75
|
-
let ds_result = $derived.by(() => {
|
|
76
|
-
if (!volume) return undefined
|
|
77
|
-
return downsample_grid(volume.grid, volume.grid_dims)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
// Run marching cubes at the given isovalue with pre-prepared grid/lattice/shift.
|
|
81
|
-
function extract_surface(
|
|
82
|
-
isovalue: number,
|
|
83
|
-
mc_grid: number[][][],
|
|
84
|
-
mc_lattice: Matrix3x3,
|
|
85
|
-
origin_shift: Vec3 | null,
|
|
86
|
-
): BufferGeometry | null {
|
|
202
|
+
function extract_surface(isovalue: number, prepared: PreparedGrid): BufferGeometry | null {
|
|
87
203
|
if (isovalue === 0) return null
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return build_geometry(result.vertices, result.faces)
|
|
204
|
+
const result = profile_stage(profiler, `marching_cubes`, () =>
|
|
205
|
+
marching_cubes_buffers(prepared.grid, isovalue, prepared.lattice, {
|
|
206
|
+
periodic: false,
|
|
207
|
+
interpolate: true,
|
|
208
|
+
centered: false,
|
|
209
|
+
normals: false,
|
|
210
|
+
position_offset: prepared.vertex_shift,
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
return profile_stage(
|
|
214
|
+
profiler,
|
|
215
|
+
`build_geometry`,
|
|
216
|
+
() => build_geometry(result.positions, result.indices),
|
|
217
|
+
(geometry) => ({ buffer_bytes: geometry ? geometry_buffer_bytes(geometry) : 0 }),
|
|
218
|
+
)
|
|
105
219
|
}
|
|
106
220
|
|
|
107
|
-
// ===
|
|
108
|
-
// Each layer produces up to 2
|
|
109
|
-
|
|
110
|
-
|
|
221
|
+
// === Mesh entry management ===
|
|
222
|
+
// Each visible layer produces up to 2 entries (positive + optional negative lobe).
|
|
223
|
+
type MeshEntry = {
|
|
224
|
+
key: string // unique per (layer, sign) — template/each + colored_keys key
|
|
225
|
+
geo_key: string // geometry signature — cache/reuse key
|
|
226
|
+
layer_idx: number
|
|
227
|
+
sign: 1 | -1
|
|
111
228
|
geometry: BufferGeometry
|
|
112
|
-
color: string
|
|
113
|
-
opacity: number
|
|
114
229
|
render_order: number
|
|
230
|
+
// Cached per-vertex scalars from the color-source volume, so colormap and
|
|
231
|
+
// color-range changes only remap through the LUT without resampling
|
|
232
|
+
scalars: Float32Array | null
|
|
233
|
+
scalars_volume_id: number | null
|
|
115
234
|
}
|
|
116
|
-
|
|
235
|
+
// $state.raw: entries hold Three.js objects that must not be proxied; the array
|
|
236
|
+
// is replaced wholesale on rebuild so the template stays reactive.
|
|
237
|
+
let active_entries = $state.raw<MeshEntry[]>([])
|
|
238
|
+
// keys of entries that currently carry a vertex-color attribute (drives
|
|
239
|
+
// vertexColors on materials reactively without proxying entries)
|
|
240
|
+
const colored_keys = new SvelteSet<string>()
|
|
117
241
|
let raf_id = 0
|
|
118
242
|
let debounce_id = 0
|
|
243
|
+
let rebuild_generation = 0
|
|
244
|
+
let geometry_worker: Worker | undefined
|
|
245
|
+
let reject_worker_job: ((error: Error) => void) | undefined
|
|
246
|
+
|
|
247
|
+
function cancel_geometry_worker(): void {
|
|
248
|
+
reject_worker_job?.(new Error(`Isosurface geometry job superseded`))
|
|
249
|
+
reject_worker_job = undefined
|
|
250
|
+
geometry_worker?.terminate()
|
|
251
|
+
geometry_worker = undefined
|
|
252
|
+
}
|
|
119
253
|
|
|
120
|
-
// Dispose all current geometries
|
|
121
254
|
function dispose_all() {
|
|
122
|
-
for (const entry of
|
|
123
|
-
|
|
255
|
+
for (const entry of active_entries) entry.geometry.dispose()
|
|
256
|
+
active_entries = []
|
|
257
|
+
colored_keys.clear()
|
|
124
258
|
}
|
|
125
259
|
|
|
126
|
-
// Dispose on unmount
|
|
127
|
-
$effect(() => () =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// Prepare grid/lattice/shift once for all layers.
|
|
135
|
-
// When halo > 0 for periodic volumes, the downsampled grid is padded with
|
|
136
|
-
// halo cells from the opposite face so isosurfaces extend beyond the unit
|
|
137
|
-
// cell and close into complete enclosed shapes around boundary atoms.
|
|
138
|
-
let mc_grid = ds_result.grid
|
|
139
|
-
let mc_lattice: Matrix3x3 = volume.lattice
|
|
140
|
-
let origin_shift: Vec3 | null = null
|
|
141
|
-
|
|
142
|
-
if (settings.halo > 0 && volume.periodic) {
|
|
143
|
-
const padded = pad_periodic_grid(ds_result.grid, ds_result.dims, settings.halo)
|
|
144
|
-
mc_grid = padded.grid
|
|
145
|
-
// marching_cubes maps [0,1] fractional -> Cartesian via lattice.
|
|
146
|
-
// The padded grid covers a wider fractional range, so scale the lattice
|
|
147
|
-
// to match. Then shift all vertices by the fractional offset.
|
|
148
|
-
const [la, lb, lc] = volume.lattice
|
|
149
|
-
const sx = padded.dims[0] / ds_result.dims[0]
|
|
150
|
-
const sy = padded.dims[1] / ds_result.dims[1]
|
|
151
|
-
const sz = padded.dims[2] / ds_result.dims[2]
|
|
152
|
-
mc_lattice = [
|
|
153
|
-
[la[0] * sx, la[1] * sx, la[2] * sx],
|
|
154
|
-
[lb[0] * sy, lb[1] * sy, lb[2] * sy],
|
|
155
|
-
[lc[0] * sz, lc[1] * sz, lc[2] * sz],
|
|
156
|
-
]
|
|
157
|
-
const [ox, oy, oz] = padded.offset
|
|
158
|
-
origin_shift = [
|
|
159
|
-
ox * la[0] + oy * lb[0] + oz * lc[0],
|
|
160
|
-
ox * la[1] + oy * lb[1] + oz * lc[1],
|
|
161
|
-
ox * la[2] + oy * lb[2] + oz * lc[2],
|
|
162
|
-
]
|
|
163
|
-
}
|
|
260
|
+
// Dispose geometries and cancel pending rebuilds on unmount
|
|
261
|
+
$effect(() => () => {
|
|
262
|
+
rebuild_generation++
|
|
263
|
+
clearTimeout(debounce_id)
|
|
264
|
+
cancelAnimationFrame(raf_id)
|
|
265
|
+
cancel_geometry_worker()
|
|
266
|
+
dispose_all()
|
|
267
|
+
})
|
|
164
268
|
|
|
165
|
-
|
|
166
|
-
|
|
269
|
+
// range_key covers halo + tiling (encoded in the range for periodic volumes;
|
|
270
|
+
// irrelevant for finite ones), so the geometry identity needs no other inputs
|
|
271
|
+
const geometry_key = (layer: ResolvedLayer, sign: 1 | -1): string => {
|
|
272
|
+
const vol = all_volumes[layer.volume_idx]
|
|
273
|
+
return JSON.stringify([
|
|
274
|
+
layer.volume_idx,
|
|
275
|
+
vol ? vol_id(vol) : 0,
|
|
276
|
+
sign * layer.isovalue,
|
|
277
|
+
vol ? range_key(vol) : ``,
|
|
278
|
+
reference_origin_key,
|
|
279
|
+
])
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
type PendingSurface = {
|
|
283
|
+
key: string
|
|
284
|
+
geo_key: string
|
|
285
|
+
layer_idx: number
|
|
286
|
+
sign: 1 | -1
|
|
287
|
+
isovalue: number
|
|
288
|
+
volume: VolumetricData
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type EntryPlan = MeshEntry | PendingSurface
|
|
292
|
+
|
|
293
|
+
const estimated_prepared_points = (vol: VolumetricData): number => {
|
|
294
|
+
const range = effective_range(vol)
|
|
295
|
+
if (!range) return Math.min(grid_point_count(vol.grid), MAX_GRID_POINTS)
|
|
296
|
+
const estimate = range.reduce((total, [lower, upper], axis) => {
|
|
297
|
+
const intervals = vol.periodic
|
|
298
|
+
? vol.grid_dims[axis]
|
|
299
|
+
: Math.max(vol.grid_dims[axis] - 1, 1)
|
|
300
|
+
return total * Math.max(2, Math.round((upper - lower) * intervals) + 1)
|
|
301
|
+
}, 1)
|
|
302
|
+
return Math.min(estimate, MAX_GRID_POINTS)
|
|
303
|
+
}
|
|
167
304
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
.
|
|
174
|
-
.
|
|
305
|
+
const use_geometry_worker = (pending: PendingSurface[]): boolean =>
|
|
306
|
+
typeof window !== `undefined` &&
|
|
307
|
+
typeof Worker !== `undefined` &&
|
|
308
|
+
pending.some(
|
|
309
|
+
(surface) =>
|
|
310
|
+
!current_prepared(surface.volume) &&
|
|
311
|
+
estimated_prepared_points(surface.volume) >= 200_000,
|
|
175
312
|
)
|
|
176
313
|
|
|
314
|
+
function request_worker_geometries(
|
|
315
|
+
pending: PendingSurface[],
|
|
316
|
+
): Promise<GeometryWorkerResponse> {
|
|
317
|
+
const grouped = new Map<VolumetricData, PendingSurface[]>()
|
|
318
|
+
for (const surface of pending) {
|
|
319
|
+
const surfaces = grouped.get(surface.volume) ?? []
|
|
320
|
+
surfaces.push(surface)
|
|
321
|
+
grouped.set(surface.volume, surfaces)
|
|
322
|
+
}
|
|
323
|
+
const transfer: Transferable[] = []
|
|
324
|
+
const request: GeometryWorkerRequest = {
|
|
325
|
+
volumes: [...grouped].map(([vol, surfaces]) => {
|
|
326
|
+
const serialize_start = profiler ? performance.now() : 0
|
|
327
|
+
const transferred_volume: TransferableVolume = {
|
|
328
|
+
grid_values: flatten_grid(vol.grid).values,
|
|
329
|
+
grid_dims: [...vol.grid_dims],
|
|
330
|
+
lattice: vol.lattice.map((row) => [...row]) as Matrix3x3,
|
|
331
|
+
origin: [...vol.origin],
|
|
332
|
+
periodic: vol.periodic,
|
|
333
|
+
}
|
|
334
|
+
transfer.push(transferred_volume.grid_values.buffer)
|
|
335
|
+
record_profile(profiler, `prepare_geometry`, performance.now() - serialize_start, {
|
|
336
|
+
worker: true,
|
|
337
|
+
serialize: true,
|
|
338
|
+
source_points: transferred_volume.grid_values.length,
|
|
339
|
+
})
|
|
340
|
+
return {
|
|
341
|
+
token: vol_id(vol),
|
|
342
|
+
volume: transferred_volume,
|
|
343
|
+
range: effective_range(vol),
|
|
344
|
+
reference_origin: [...reference_origin],
|
|
345
|
+
surfaces: surfaces.map((surface) => ({
|
|
346
|
+
token: surface.key,
|
|
347
|
+
isovalue: surface.isovalue,
|
|
348
|
+
})),
|
|
349
|
+
}
|
|
350
|
+
}),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const worker = new Worker(new URL(`geometry.worker`, import.meta.url), {
|
|
355
|
+
type: `module`,
|
|
356
|
+
})
|
|
357
|
+
geometry_worker = worker
|
|
358
|
+
reject_worker_job = reject
|
|
359
|
+
const cleanup = (): void => {
|
|
360
|
+
worker.terminate()
|
|
361
|
+
if (geometry_worker === worker) geometry_worker = undefined
|
|
362
|
+
reject_worker_job = undefined
|
|
363
|
+
}
|
|
364
|
+
worker.addEventListener(`message`, ({ data }: MessageEvent<GeometryWorkerResponse>) => {
|
|
365
|
+
cleanup()
|
|
366
|
+
resolve(data)
|
|
367
|
+
})
|
|
368
|
+
worker.addEventListener(`error`, (event) => {
|
|
369
|
+
cleanup()
|
|
370
|
+
reject(new Error(event.message || `Isosurface geometry worker failed`))
|
|
371
|
+
})
|
|
372
|
+
try {
|
|
373
|
+
worker.postMessage(request, transfer)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
cleanup()
|
|
376
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function rebuild_geometries(
|
|
382
|
+
layers: ResolvedLayer[],
|
|
383
|
+
generation: number,
|
|
384
|
+
): Promise<void> {
|
|
385
|
+
const profile_start = profiler ? performance.now() : 0
|
|
386
|
+
let cache_hits = 0
|
|
387
|
+
// Reuse geometries whose signature is unchanged (take-once so duplicate
|
|
388
|
+
// layers at the same isovalue never share a geometry instance)
|
|
389
|
+
const reusable = new Map(active_entries.map((entry) => [entry.geo_key, entry]))
|
|
390
|
+
const plans: EntryPlan[] = []
|
|
391
|
+
const pending: PendingSurface[] = []
|
|
392
|
+
|
|
177
393
|
for (let layer_idx = 0; layer_idx < layers.length; layer_idx++) {
|
|
178
394
|
const layer = layers[layer_idx]
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
395
|
+
const vol = all_volumes[layer.volume_idx]
|
|
396
|
+
if (!vol || !layer.visible || layer.isovalue <= 0) continue
|
|
397
|
+
|
|
398
|
+
const signs: (1 | -1)[] = layer.show_negative ? [1, -1] : [1]
|
|
399
|
+
for (const sign of signs) {
|
|
400
|
+
const key = `${layer_idx}:${sign}`
|
|
401
|
+
const geo_key = geometry_key(layer, sign)
|
|
402
|
+
const reused = reusable.get(geo_key)
|
|
403
|
+
if (reused) {
|
|
404
|
+
cache_hits++
|
|
405
|
+
reusable.delete(geo_key)
|
|
406
|
+
// Keep only the cached geometry + scalars; key/layer_idx/sign are
|
|
407
|
+
// rebound to the CURRENT loop values (they override the spread)
|
|
408
|
+
plans.push({ ...reused, key, layer_idx, sign })
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
const surface: PendingSurface = {
|
|
412
|
+
key,
|
|
413
|
+
geo_key,
|
|
414
|
+
layer_idx,
|
|
415
|
+
sign,
|
|
416
|
+
isovalue: sign * layer.isovalue,
|
|
417
|
+
volume: vol,
|
|
418
|
+
}
|
|
419
|
+
pending.push(surface)
|
|
420
|
+
plans.push(surface)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const geometries = new Map<string, BufferGeometry>()
|
|
425
|
+
let build_synchronously = !use_geometry_worker(pending)
|
|
426
|
+
if (!build_synchronously) {
|
|
427
|
+
try {
|
|
428
|
+
const response = await request_worker_geometries(pending)
|
|
429
|
+
if (generation !== rebuild_generation || `error` in response) {
|
|
430
|
+
if (`error` in response && generation === rebuild_generation) {
|
|
431
|
+
throw new Error(response.error)
|
|
432
|
+
}
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
const volume_by_token = new Map(
|
|
436
|
+
pending.map(({ volume: source }) => [vol_id(source), source]),
|
|
437
|
+
)
|
|
438
|
+
for (const volume_result of response.volumes) {
|
|
439
|
+
const source_volume = volume_by_token.get(volume_result.token)
|
|
440
|
+
if (source_volume) {
|
|
441
|
+
const grid = profile_stage(
|
|
442
|
+
profiler,
|
|
443
|
+
`prepare_geometry`,
|
|
444
|
+
() => inflate_grid(volume_result.prepared_values, volume_result.grid_dims),
|
|
445
|
+
{
|
|
446
|
+
worker: true,
|
|
447
|
+
inflate: true,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
prepared_cache.set(source_volume, {
|
|
451
|
+
key: prepared_key(source_volume),
|
|
452
|
+
grid,
|
|
453
|
+
lattice: volume_result.lattice,
|
|
454
|
+
vertex_shift: vertex_shift(volume_result.origin),
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
record_profile(profiler, `prepare_geometry`, volume_result.prepare_geometry_ms, {
|
|
458
|
+
worker: true,
|
|
459
|
+
})
|
|
460
|
+
for (const surface_result of volume_result.surfaces) {
|
|
461
|
+
record_profile(profiler, `marching_cubes`, surface_result.marching_cubes_ms, {
|
|
462
|
+
worker: true,
|
|
463
|
+
vertices: surface_result.positions.length / 3,
|
|
464
|
+
})
|
|
465
|
+
const geometry = profile_stage(
|
|
466
|
+
profiler,
|
|
467
|
+
`build_geometry`,
|
|
468
|
+
() => build_geometry(surface_result.positions, surface_result.indices),
|
|
469
|
+
(built_geometry) => ({
|
|
470
|
+
worker: true,
|
|
471
|
+
buffer_bytes: built_geometry ? geometry_buffer_bytes(built_geometry) : 0,
|
|
472
|
+
}),
|
|
473
|
+
)
|
|
474
|
+
if (geometry) geometries.set(surface_result.token, geometry)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (generation !== rebuild_generation) return
|
|
479
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
480
|
+
record_profile(profiler, `prepare_geometry`, 0, {
|
|
481
|
+
worker_fallback: true,
|
|
482
|
+
error: message,
|
|
191
483
|
})
|
|
484
|
+
console.warn(`Isosurface geometry worker failed; using synchronous fallback`, error)
|
|
485
|
+
build_synchronously = true
|
|
192
486
|
}
|
|
487
|
+
}
|
|
488
|
+
if (build_synchronously) {
|
|
489
|
+
for (const surface of pending) {
|
|
490
|
+
const geometry = extract_surface(
|
|
491
|
+
surface.isovalue,
|
|
492
|
+
prepare_geometry_volume(surface.volume),
|
|
493
|
+
)
|
|
494
|
+
if (geometry) geometries.set(surface.key, geometry)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
193
497
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
498
|
+
if (generation !== rebuild_generation) {
|
|
499
|
+
for (const geometry of geometries.values()) geometry.dispose()
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
const entries: MeshEntry[] = []
|
|
503
|
+
for (const plan of plans) {
|
|
504
|
+
if (`geometry` in plan) entries.push(plan)
|
|
505
|
+
else {
|
|
506
|
+
const geometry = geometries.get(plan.key)
|
|
507
|
+
if (geometry) {
|
|
508
|
+
const { key, geo_key, layer_idx, sign } = plan
|
|
197
509
|
entries.push({
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
510
|
+
key,
|
|
511
|
+
geo_key,
|
|
512
|
+
layer_idx,
|
|
513
|
+
sign,
|
|
514
|
+
geometry,
|
|
515
|
+
render_order: 0,
|
|
516
|
+
scalars: null,
|
|
517
|
+
scalars_volume_id: null,
|
|
202
518
|
})
|
|
203
519
|
}
|
|
204
520
|
}
|
|
205
521
|
}
|
|
206
522
|
|
|
207
|
-
|
|
208
|
-
|
|
523
|
+
// Render outer shells first so per-entry back/front passes interleave
|
|
524
|
+
// roughly back-to-front. Isovalues from different volumes aren't directly
|
|
525
|
+
// comparable, so rank by isovalue as a fraction of each volume's abs_max.
|
|
526
|
+
const shell_fraction = (entry: MeshEntry): number => {
|
|
527
|
+
const layer = layers[entry.layer_idx]
|
|
528
|
+
const abs_max = all_volumes[layer.volume_idx]?.data_range.abs_max ?? 1
|
|
529
|
+
return layer.isovalue / Math.max(abs_max, 1e-30)
|
|
530
|
+
}
|
|
531
|
+
entries
|
|
532
|
+
.sort((entry_a, entry_b) => shell_fraction(entry_a) - shell_fraction(entry_b))
|
|
533
|
+
.forEach((entry, rank) => (entry.render_order = rank * 2))
|
|
534
|
+
|
|
535
|
+
// Dispose old geometries that were not reused, then swap in the new list
|
|
536
|
+
const kept = new Set(entries.map((entry) => entry.geometry))
|
|
537
|
+
for (const entry of active_entries) {
|
|
538
|
+
if (!kept.has(entry.geometry)) entry.geometry.dispose()
|
|
539
|
+
}
|
|
540
|
+
// Sync vertex-color flags: reused geometries keep their color attribute;
|
|
541
|
+
// new entries get colored by the color effect after this rebuild
|
|
542
|
+
colored_keys.clear()
|
|
543
|
+
for (const entry of entries) {
|
|
544
|
+
if (entry.geometry.getAttribute(`color`)) colored_keys.add(entry.key)
|
|
545
|
+
}
|
|
546
|
+
active_entries = entries
|
|
547
|
+
let [vertices, triangles, buffer_bytes] = [0, 0, 0]
|
|
548
|
+
for (const entry of entries) {
|
|
549
|
+
vertices += entry.geometry.getAttribute(`position`).count
|
|
550
|
+
triangles += (entry.geometry.getIndex()?.count ?? 0) / 3
|
|
551
|
+
buffer_bytes += geometry_buffer_bytes(entry.geometry)
|
|
552
|
+
}
|
|
553
|
+
record_profile(profiler, `rebuild_total`, performance.now() - profile_start, {
|
|
554
|
+
entries: entries.length,
|
|
555
|
+
cache_hits,
|
|
556
|
+
vertices,
|
|
557
|
+
triangles,
|
|
558
|
+
buffer_bytes,
|
|
559
|
+
})
|
|
209
560
|
}
|
|
210
561
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
562
|
+
// Geometry-relevant inputs as a string: $derived strings only notify
|
|
563
|
+
// dependents when the value changes, so the rebuild effect below skips
|
|
564
|
+
// color-only updates (colors, opacity, colormap, range are excluded here)
|
|
565
|
+
// without any manual last-signature bookkeeping.
|
|
566
|
+
let geo_sig = $derived(
|
|
567
|
+
resolved_layers
|
|
568
|
+
.map((layer) => {
|
|
569
|
+
const vol = all_volumes[layer.volume_idx]
|
|
570
|
+
if (!vol || !layer.visible || layer.isovalue <= 0) return `off`
|
|
571
|
+
return `${geometry_key(layer, 1)}.${layer.show_negative}`
|
|
572
|
+
})
|
|
573
|
+
.join(`|`),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
// Rebuild layer geometries when the geometry signature changes (debounced for
|
|
577
|
+
// slider drags). The rAF callback reads resolved_layers fresh, so a rebuild
|
|
578
|
+
// scheduled by an earlier signature still sees the latest layer props.
|
|
213
579
|
$effect(() => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
580
|
+
void geo_sig
|
|
581
|
+
const generation = ++rebuild_generation
|
|
582
|
+
clearTimeout(debounce_id)
|
|
583
|
+
cancelAnimationFrame(raf_id)
|
|
584
|
+
cancel_geometry_worker()
|
|
585
|
+
if (untrack(() => all_volumes.length) === 0) {
|
|
586
|
+
untrack(() => dispose_all())
|
|
218
587
|
return
|
|
219
588
|
}
|
|
220
589
|
debounce_id = window.setTimeout(() => {
|
|
221
|
-
|
|
590
|
+
// No reactive context inside rAF: reads the freshest layers untracked
|
|
591
|
+
raf_id = requestAnimationFrame(() => {
|
|
592
|
+
void rebuild_geometries(resolved_layers, generation)
|
|
593
|
+
})
|
|
222
594
|
}, 50)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// === Cross-volume vertex coloring ===
|
|
598
|
+
|
|
599
|
+
// Sample the color volume at this entry's vertices. Vertices are in the scene
|
|
600
|
+
// frame (first volume's origin at the grid corner), so shift back to absolute
|
|
601
|
+
// Cartesian coordinates before sampling. The bulk kernel caches each volume's
|
|
602
|
+
// inverse lattice and avoids per-vertex closures or temporary position arrays.
|
|
603
|
+
function sample_entry_scalars(entry: MeshEntry, color_vol: VolumetricData): Float32Array {
|
|
604
|
+
return profile_stage(profiler, `sample_scalars`, () => {
|
|
605
|
+
const positions = entry.geometry.getAttribute(`position`).array as Float32Array
|
|
606
|
+
return sample_volume_at_positions(color_vol, positions, {
|
|
607
|
+
out_of_bounds: color_vol.periodic ? `clamp` : `fallback`,
|
|
608
|
+
position_offset: reference_origin,
|
|
609
|
+
out: entry.scalars ?? undefined,
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// The layer's scalar-color-source volume, if any
|
|
615
|
+
const color_vol_of = (layer: IsosurfaceLayer | undefined): VolumetricData | undefined =>
|
|
616
|
+
layer?.color_volume_idx != null ? all_volumes[layer.color_volume_idx] : undefined
|
|
617
|
+
|
|
618
|
+
function apply_vertex_colors(entries: MeshEntry[], layers: ResolvedLayer[]) {
|
|
619
|
+
const profile_start = profiler ? performance.now() : 0
|
|
620
|
+
const auto_scalars = new Map<number, Float32Array[]>()
|
|
621
|
+
// First pass: (re)sample scalars for entries with a color source
|
|
622
|
+
for (const entry of entries) {
|
|
623
|
+
const layer = layers[entry.layer_idx]
|
|
624
|
+
const color_vol = color_vol_of(layer)
|
|
625
|
+
if (!layer || !color_vol) {
|
|
626
|
+
if (entry.geometry.getAttribute(`color`)) entry.geometry.deleteAttribute(`color`)
|
|
627
|
+
entry.scalars = null
|
|
628
|
+
entry.scalars_volume_id = null
|
|
629
|
+
colored_keys.delete(entry.key)
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
const color_vol_id = vol_id(color_vol)
|
|
633
|
+
if (!entry.scalars || entry.scalars_volume_id !== color_vol_id) {
|
|
634
|
+
entry.scalars = sample_entry_scalars(entry, color_vol)
|
|
635
|
+
entry.scalars_volume_id = color_vol_id
|
|
636
|
+
} else {
|
|
637
|
+
record_profile(profiler, `sample_scalars`, 0, {
|
|
638
|
+
cache_hit: true,
|
|
639
|
+
vertices: entry.scalars.length,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
if (!layer.color_range) {
|
|
643
|
+
const scalars = auto_scalars.get(entry.layer_idx) ?? []
|
|
644
|
+
scalars.push(entry.scalars)
|
|
645
|
+
auto_scalars.set(entry.layer_idx, scalars)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Default color range per layer: fit to the scalars actually present on that
|
|
650
|
+
// layer's surfaces (both lobes) — whole-volume ranges are dominated by
|
|
651
|
+
// extremes near nuclei that the surface never touches. Signed source fields
|
|
652
|
+
// keep a symmetric range so diverging colormaps stay centered on zero.
|
|
653
|
+
const auto_ranges = new Map<number, [number, number]>()
|
|
654
|
+
for (const [layer_idx, layer_scalars] of auto_scalars) {
|
|
655
|
+
const layer = layers[layer_idx]
|
|
656
|
+
const color_vol = color_vol_of(layer)
|
|
657
|
+
auto_ranges.set(
|
|
658
|
+
layer_idx,
|
|
659
|
+
profile_stage(profiler, `scalar_range`, () =>
|
|
660
|
+
compute_scalar_range(layer_scalars, {
|
|
661
|
+
symmetric: color_vol ? is_signed_range(color_vol.data_range) : false,
|
|
662
|
+
}),
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Second pass: map scalars through the colormap LUT, reusing the existing
|
|
668
|
+
// color attribute's array where possible to avoid GPU buffer churn
|
|
669
|
+
for (const entry of entries) {
|
|
670
|
+
const layer = layers[entry.layer_idx]
|
|
671
|
+
if (!layer || !entry.scalars) continue
|
|
672
|
+
const color_range = layer.color_range ?? auto_ranges.get(entry.layer_idx) ?? [0, 1]
|
|
673
|
+
const existing = entry.geometry.getAttribute(`color`) as BufferAttribute | undefined
|
|
674
|
+
const colors = profile_stage(profiler, `apply_colormap`, () =>
|
|
675
|
+
scalars_to_vertex_colors(
|
|
676
|
+
entry.scalars as Float32Array,
|
|
677
|
+
{
|
|
678
|
+
colormap: layer.colormap ?? DEFAULT_ISO_COLORMAP,
|
|
679
|
+
color_range,
|
|
680
|
+
fallback_color: entry.sign > 0 ? layer.color : layer.negative_color,
|
|
681
|
+
},
|
|
682
|
+
existing?.array instanceof Float32Array ? existing.array : undefined,
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
if (existing && existing.array === colors) existing.needsUpdate = true
|
|
686
|
+
else entry.geometry.setAttribute(`color`, new BufferAttribute(colors, 3))
|
|
687
|
+
colored_keys.add(entry.key)
|
|
226
688
|
}
|
|
689
|
+
record_profile(profiler, `recolor_total`, performance.now() - profile_start, {
|
|
690
|
+
entries: entries.length,
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Color-relevant layer props as a string, including the color-source volume
|
|
695
|
+
// identity (so replacing a color volume in place resamples) but excluding
|
|
696
|
+
// geometry fields like isovalue and opacity, so e.g. isovalue slider drags
|
|
697
|
+
// don't remap colors on every tick.
|
|
698
|
+
let color_sig = $derived(
|
|
699
|
+
resolved_layers
|
|
700
|
+
.map((layer) => {
|
|
701
|
+
const color_vol = color_vol_of(layer)
|
|
702
|
+
return [
|
|
703
|
+
layer.color_volume_idx ?? ``,
|
|
704
|
+
color_vol ? vol_id(color_vol) : ``,
|
|
705
|
+
layer.colormap ?? ``,
|
|
706
|
+
layer.color_range?.join(`,`) ?? ``,
|
|
707
|
+
layer.color,
|
|
708
|
+
layer.negative_color,
|
|
709
|
+
].join(`.`)
|
|
710
|
+
})
|
|
711
|
+
.join(`|`),
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
// Apply/refresh vertex colors when entries are rebuilt or color props change
|
|
715
|
+
$effect(() => {
|
|
716
|
+
void color_sig
|
|
717
|
+
const entries = active_entries
|
|
718
|
+
untrack(() => apply_vertex_colors(entries, resolved_layers))
|
|
227
719
|
})
|
|
228
720
|
</script>
|
|
229
721
|
|
|
230
|
-
<!-- Render each
|
|
231
|
-
{#each
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<!--
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
</T.Mesh>
|
|
722
|
+
<!-- Render each mesh entry; material props react to layer changes without geometry rebuilds -->
|
|
723
|
+
{#each active_entries as entry (entry.key)}
|
|
724
|
+
{@const layer = resolved_layers[entry.layer_idx]}
|
|
725
|
+
{#if layer}
|
|
726
|
+
{@const vertex_colored = colored_keys.has(entry.key)}
|
|
727
|
+
{@const color = vertex_colored
|
|
728
|
+
? `#ffffff`
|
|
729
|
+
: entry.sign > 0
|
|
730
|
+
? layer.color
|
|
731
|
+
: layer.negative_color}
|
|
732
|
+
{@const opacity = layer.opacity}
|
|
733
|
+
{@const transparent = opacity < 1}
|
|
734
|
+
<!-- Recreate materials when vertexColors toggles (needs shader recompile) -->
|
|
735
|
+
{#key vertex_colored}
|
|
736
|
+
{#if settings.wireframe}
|
|
737
|
+
<T.Mesh geometry={entry.geometry} frustumCulled={false}>
|
|
738
|
+
<T.MeshBasicMaterial
|
|
739
|
+
{color}
|
|
740
|
+
vertexColors={vertex_colored}
|
|
741
|
+
wireframe
|
|
742
|
+
{transparent}
|
|
743
|
+
{opacity}
|
|
744
|
+
/>
|
|
745
|
+
</T.Mesh>
|
|
746
|
+
{:else}
|
|
747
|
+
<!-- Transparent surfaces render back then front faces in two passes for
|
|
748
|
+
correct blending; opaque surfaces need a single double-sided pass -->
|
|
749
|
+
{#each transparent ? [BackSide, FrontSide] : [DoubleSide] as side, pass_idx (side)}
|
|
750
|
+
<T.Mesh
|
|
751
|
+
geometry={entry.geometry}
|
|
752
|
+
renderOrder={entry.render_order + pass_idx}
|
|
753
|
+
frustumCulled={false}
|
|
754
|
+
>
|
|
755
|
+
<T.MeshStandardMaterial
|
|
756
|
+
{color}
|
|
757
|
+
vertexColors={vertex_colored}
|
|
758
|
+
{transparent}
|
|
759
|
+
{opacity}
|
|
760
|
+
{side}
|
|
761
|
+
depthWrite={!transparent}
|
|
762
|
+
metalness={0.1}
|
|
763
|
+
roughness={0.6}
|
|
764
|
+
/>
|
|
765
|
+
</T.Mesh>
|
|
766
|
+
{/each}
|
|
767
|
+
{/if}
|
|
768
|
+
{/key}
|
|
278
769
|
{/if}
|
|
279
770
|
{/each}
|