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.
Files changed (244) hide show
  1. package/dist/FilePicker.svelte +40 -50
  2. package/dist/api/optimade.js +10 -7
  3. package/dist/app.css +15 -0
  4. package/dist/brillouin/BrillouinZone.svelte +18 -9
  5. package/dist/brillouin/BrillouinZoneScene.svelte +20 -29
  6. package/dist/brillouin/compute.js +10 -16
  7. package/dist/brillouin/geometry.js +2 -2
  8. package/dist/chempot-diagram/ChemPotDiagram.svelte +18 -15
  9. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +17 -113
  10. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +104 -264
  11. package/dist/chempot-diagram/async-compute.svelte.js +12 -13
  12. package/dist/chempot-diagram/color.d.ts +21 -1
  13. package/dist/chempot-diagram/color.js +70 -1
  14. package/dist/chempot-diagram/compute.d.ts +7 -0
  15. package/dist/chempot-diagram/compute.js +85 -26
  16. package/dist/chempot-diagram/export.js +1 -6
  17. package/dist/chempot-diagram/temperature.js +9 -10
  18. package/dist/composition/BarChart.svelte +7 -9
  19. package/dist/composition/Composition.svelte +3 -8
  20. package/dist/composition/Formula.svelte +2 -2
  21. package/dist/composition/FormulaFilter.svelte +27 -93
  22. package/dist/composition/format.js +6 -8
  23. package/dist/composition/parse.js +50 -105
  24. package/dist/constants.d.ts +0 -1
  25. package/dist/constants.js +6 -17
  26. package/dist/convex-hull/ConvexHull2D.svelte +0 -2
  27. package/dist/convex-hull/ConvexHull3D.svelte +19 -47
  28. package/dist/convex-hull/ConvexHull4D.svelte +65 -95
  29. package/dist/convex-hull/StructurePopup.svelte +1 -1
  30. package/dist/convex-hull/gas-thermodynamics.js +21 -53
  31. package/dist/convex-hull/helpers.js +1 -7
  32. package/dist/convex-hull/thermodynamics.js +19 -27
  33. package/dist/coordination/CoordinationBarPlot.svelte +12 -21
  34. package/dist/element/BohrAtom.svelte +0 -1
  35. package/dist/element/ElementTile.svelte +26 -44
  36. package/dist/element/data.d.ts +2 -0
  37. package/dist/element/data.js +5 -1
  38. package/dist/element/index.d.ts +1 -1
  39. package/dist/element/index.js +1 -1
  40. package/dist/fermi-surface/FermiSurface.svelte +22 -23
  41. package/dist/fermi-surface/FermiSurface.svelte.d.ts +2 -6
  42. package/dist/fermi-surface/FermiSurfaceScene.svelte +32 -77
  43. package/dist/fermi-surface/compute.js +22 -55
  44. package/dist/fermi-surface/parse.js +38 -70
  45. package/dist/file-viewer/JsonBrowser.svelte +1043 -0
  46. package/dist/file-viewer/JsonBrowser.svelte.d.ts +9 -0
  47. package/dist/file-viewer/PlotPanel.svelte +350 -0
  48. package/dist/file-viewer/PlotPanel.svelte.d.ts +9 -0
  49. package/dist/file-viewer/TrajectoryWithDos.svelte +30 -0
  50. package/dist/file-viewer/TrajectoryWithDos.svelte.d.ts +10 -0
  51. package/dist/file-viewer/detect.d.ts +11 -0
  52. package/dist/file-viewer/detect.js +381 -0
  53. package/dist/file-viewer/eligibility.d.ts +4 -0
  54. package/dist/file-viewer/eligibility.js +53 -0
  55. package/dist/file-viewer/host-protocol.d.ts +50 -0
  56. package/dist/file-viewer/host-transfer.d.ts +25 -0
  57. package/dist/file-viewer/host-transfer.js +42 -0
  58. package/dist/file-viewer/index.d.ts +5 -0
  59. package/dist/file-viewer/index.js +5 -0
  60. package/dist/file-viewer/main.d.ts +44 -0
  61. package/dist/file-viewer/main.js +535 -0
  62. package/dist/file-viewer/parse.d.ts +9 -0
  63. package/dist/file-viewer/parse.js +170 -0
  64. package/dist/file-viewer/plot-utils.d.ts +24 -0
  65. package/dist/file-viewer/plot-utils.js +191 -0
  66. package/dist/file-viewer/types.d.ts +4 -0
  67. package/dist/file-viewer/types.js +5 -0
  68. package/dist/heatmap-matrix/HeatmapMatrix.svelte +57 -84
  69. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +1 -1
  70. package/dist/index.d.ts +2 -2
  71. package/dist/index.js +1 -1
  72. package/dist/io/ExportPane.svelte +1 -1
  73. package/dist/io/decompress.d.ts +2 -0
  74. package/dist/io/decompress.js +3 -2
  75. package/dist/io/export.d.ts +7 -4
  76. package/dist/io/export.js +70 -18
  77. package/dist/io/file-drop.js +60 -35
  78. package/dist/io/url-drop.d.ts +2 -0
  79. package/dist/io/url-drop.js +28 -16
  80. package/dist/isosurface/Isosurface.svelte +687 -196
  81. package/dist/isosurface/Isosurface.svelte.d.ts +7 -1
  82. package/dist/isosurface/IsosurfaceControls.svelte +564 -85
  83. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +1 -1
  84. package/dist/isosurface/VolumeSlice.svelte +224 -0
  85. package/dist/isosurface/VolumeSlice.svelte.d.ts +28 -0
  86. package/dist/isosurface/coloring.d.ts +20 -0
  87. package/dist/isosurface/coloring.js +118 -0
  88. package/dist/isosurface/geometry-worker-types.d.ts +38 -0
  89. package/dist/isosurface/geometry.worker.d.ts +1 -0
  90. package/dist/isosurface/geometry.worker.js +59 -0
  91. package/dist/isosurface/grid.d.ts +10 -0
  92. package/dist/isosurface/grid.js +31 -0
  93. package/dist/isosurface/index.d.ts +4 -0
  94. package/dist/isosurface/index.js +4 -0
  95. package/dist/isosurface/profile.d.ts +12 -0
  96. package/dist/isosurface/profile.js +19 -0
  97. package/dist/isosurface/sampling.d.ts +34 -0
  98. package/dist/isosurface/sampling.js +408 -0
  99. package/dist/isosurface/slice-rendering.d.ts +13 -0
  100. package/dist/isosurface/slice-rendering.js +79 -0
  101. package/dist/isosurface/slice.d.ts +22 -2
  102. package/dist/isosurface/slice.js +200 -116
  103. package/dist/isosurface/types.d.ts +24 -1
  104. package/dist/isosurface/types.js +120 -8
  105. package/dist/json-path.d.ts +4 -0
  106. package/dist/json-path.js +89 -0
  107. package/dist/labels.js +1 -1
  108. package/dist/layout/InfoCard.svelte +1 -3
  109. package/dist/layout/SettingsSection.svelte +52 -61
  110. package/dist/layout/SettingsSection.svelte.d.ts +6 -7
  111. package/dist/layout/ViewerChrome.svelte +7 -1
  112. package/dist/layout/json-tree/JsonNode.svelte +14 -48
  113. package/dist/layout/json-tree/JsonTree.svelte +48 -95
  114. package/dist/layout/json-tree/JsonValue.svelte +3 -18
  115. package/dist/layout/json-tree/utils.d.ts +0 -3
  116. package/dist/layout/json-tree/utils.js +50 -179
  117. package/dist/marching-cubes.d.ts +8 -1
  118. package/dist/marching-cubes.js +115 -48
  119. package/dist/math.d.ts +3 -2
  120. package/dist/math.js +29 -38
  121. package/dist/overlays/ContextMenu.svelte +15 -32
  122. package/dist/overlays/DraggablePane.svelte +39 -58
  123. package/dist/overlays/DraggablePane.svelte.d.ts +1 -1
  124. package/dist/overlays/GlassChip.svelte +3 -1
  125. package/dist/periodic-table/PeriodicTableControls.svelte +26 -52
  126. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +18 -34
  127. package/dist/phase-diagram/build-diagram.js +1 -6
  128. package/dist/phase-diagram/svg-to-diagram.js +9 -14
  129. package/dist/plot/bar/BarPlot.svelte +38 -42
  130. package/dist/plot/bar/SpacegroupBarPlot.svelte +18 -42
  131. package/dist/plot/box/BoxPlot.svelte +27 -23
  132. package/dist/plot/core/auto-place.d.ts +1 -1
  133. package/dist/plot/core/auto-place.js +1 -1
  134. package/dist/plot/core/data-transform.d.ts +1 -2
  135. package/dist/plot/core/data-transform.js +0 -10
  136. package/dist/plot/core/layout.d.ts +12 -5
  137. package/dist/plot/core/layout.js +37 -27
  138. package/dist/plot/core/pan-zoom.svelte.d.ts +2 -4
  139. package/dist/plot/core/pan-zoom.svelte.js +34 -17
  140. package/dist/plot/core/placed-tween.svelte.d.ts +3 -0
  141. package/dist/plot/core/placed-tween.svelte.js +68 -21
  142. package/dist/plot/core/svg.js +0 -1
  143. package/dist/plot/core/utils/hierarchy-chart.d.ts +1 -0
  144. package/dist/plot/core/utils/hierarchy-chart.js +12 -0
  145. package/dist/plot/core/utils/hierarchy-labels.js +3 -2
  146. package/dist/plot/histogram/Histogram.svelte +21 -26
  147. package/dist/plot/sankey/sankey.js +13 -9
  148. package/dist/plot/scatter/BinnedScatterPlot.svelte +59 -43
  149. package/dist/plot/scatter/ScatterPlot.svelte +67 -52
  150. package/dist/plot/scatter/adaptive-density.d.ts +1 -1
  151. package/dist/plot/scatter/adaptive-density.js +36 -20
  152. package/dist/plot/sunburst/Sunburst.svelte +20 -20
  153. package/dist/plot/treemap/Treemap.svelte +175 -60
  154. package/dist/plot/treemap/Treemap.svelte.d.ts +6 -0
  155. package/dist/plot/treemap/index.d.ts +1 -0
  156. package/dist/plot/treemap/labels.d.ts +38 -0
  157. package/dist/plot/treemap/labels.js +80 -0
  158. package/dist/plot/treemap/treemap.d.ts +4 -2
  159. package/dist/plot/treemap/treemap.js +5 -2
  160. package/dist/rdf/RdfPlot.svelte +17 -35
  161. package/dist/rdf/calc-rdf.js +59 -44
  162. package/dist/rdf/index.d.ts +0 -1
  163. package/dist/sanitize.js +84 -26
  164. package/dist/scene/SceneCamera.svelte +1 -1
  165. package/dist/scene/SceneCamera.svelte.d.ts +1 -1
  166. package/dist/scene/index.d.ts +1 -2
  167. package/dist/scene/index.js +1 -1
  168. package/dist/scene/{props.js → props.svelte.js} +15 -1
  169. package/dist/settings.js +159 -482
  170. package/dist/spectral/Bands.svelte +151 -158
  171. package/dist/spectral/BrillouinBandsDos.svelte +4 -19
  172. package/dist/spectral/Dos.svelte +26 -24
  173. package/dist/spectral/helpers.js +40 -105
  174. package/dist/structure/AtomLegend.svelte +8 -13
  175. package/dist/structure/AtomLegend.svelte.d.ts +2 -3
  176. package/dist/structure/CellSelect.svelte +35 -10
  177. package/dist/structure/Structure.svelte +177 -99
  178. package/dist/structure/Structure.svelte.d.ts +1 -1
  179. package/dist/structure/StructureCarousel.svelte +10 -3
  180. package/dist/structure/StructureControls.svelte +8 -26
  181. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  182. package/dist/structure/StructureInfoPane.svelte +65 -76
  183. package/dist/structure/StructureScene.svelte +75 -109
  184. package/dist/structure/StructureScene.svelte.d.ts +3 -1
  185. package/dist/structure/StructureViewport.svelte +120 -55
  186. package/dist/structure/StructureViewport.svelte.d.ts +3 -1
  187. package/dist/structure/atom-properties.d.ts +1 -1
  188. package/dist/structure/atom-properties.js +4 -3
  189. package/dist/structure/bond-order-perception.js +3 -8
  190. package/dist/structure/bonding.d.ts +0 -1
  191. package/dist/structure/bonding.js +16 -23
  192. package/dist/structure/export.js +50 -74
  193. package/dist/structure/format-detect.js +5 -7
  194. package/dist/structure/index.js +6 -12
  195. package/dist/structure/parse.js +38 -100
  196. package/dist/structure/partial-occupancy.js +7 -4
  197. package/dist/structure/pbc.js +8 -10
  198. package/dist/structure/polyhedra.js +5 -4
  199. package/dist/structure/prop-groups.d.ts +85 -0
  200. package/dist/structure/prop-groups.js +18 -0
  201. package/dist/symmetry/SymmetryStats.svelte +0 -4
  202. package/dist/symmetry/WyckoffTable.svelte +5 -4
  203. package/dist/symmetry/cell-transform.js +5 -14
  204. package/dist/symmetry/index.js +6 -9
  205. package/dist/table/HeatmapTable.svelte +61 -103
  206. package/dist/table/ToggleMenu.svelte +166 -44
  207. package/dist/table/ToggleMenu.svelte.d.ts +1 -1
  208. package/dist/theme/embedded.d.ts +18 -0
  209. package/dist/theme/embedded.js +206 -0
  210. package/dist/theme/index.d.ts +4 -0
  211. package/dist/theme/index.js +1 -2
  212. package/dist/theme/themes.mjs +31 -60
  213. package/dist/tooltip/TooltipContent.svelte +1 -1
  214. package/dist/tooltip/TooltipContent.svelte.d.ts +1 -1
  215. package/dist/tooltip/index.d.ts +8 -1
  216. package/dist/trajectory/Trajectory.svelte +140 -120
  217. package/dist/trajectory/TrajectoryError.svelte +1 -66
  218. package/dist/trajectory/TrajectoryExportPane.svelte +0 -1
  219. package/dist/trajectory/TrajectoryInfoPane.svelte +55 -80
  220. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +2 -1
  221. package/dist/trajectory/extract.js +19 -29
  222. package/dist/trajectory/format-detect.d.ts +2 -0
  223. package/dist/trajectory/format-detect.js +7 -5
  224. package/dist/trajectory/frame-reader.js +3 -4
  225. package/dist/trajectory/helpers.js +16 -49
  226. package/dist/trajectory/index.js +2 -8
  227. package/dist/trajectory/parse/ase.js +1 -1
  228. package/dist/trajectory/parse/index.d.ts +4 -3
  229. package/dist/trajectory/parse/index.js +12 -17
  230. package/dist/trajectory/parse/vaspout-h5.d.ts +0 -1
  231. package/dist/trajectory/parse/vaspout-h5.js +1 -4
  232. package/dist/trajectory/parse/xyz.js +48 -2
  233. package/dist/trajectory/plotting.js +14 -37
  234. package/dist/xrd/XrdPlot.svelte +43 -68
  235. package/dist/xrd/calc-xrd.js +22 -58
  236. package/dist/xrd/parse.js +13 -22
  237. package/package.json +40 -8
  238. package/readme.md +6 -5
  239. package/dist/scene/types.d.ts +0 -26
  240. package/dist/tooltip/types.d.ts +0 -8
  241. package/dist/trajectory/constants.d.ts +0 -6
  242. package/dist/trajectory/constants.js +0 -11
  243. /package/dist/{scene/types.js → file-viewer/host-protocol.js} +0 -0
  244. /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 volumetric data using marching cubes.
3
- // Supports multiple layers at different isovalues with independent colors,
4
- // plus positive/negative lobes and two-pass transparency.
5
- import { marching_cubes } from '../marching-cubes'
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, downsample_grid, pad_periodic_grid } from './types'
42
+ import { DEFAULT_ISOSURFACE_SETTINGS, MAX_GRID_POINTS } from './types'
18
43
 
19
44
  let {
20
- volume,
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
- volume: VolumetricData
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
- // Resolve layers: use explicit layers array if provided, else build from single-isovalue settings
28
- let resolved_layers = $derived.by((): IsosurfaceLayer[] => {
29
- if (settings.layers?.length) return settings.layers
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
- // Build indexed BufferGeometry from marching cubes output.
43
- // Uses Three.js index buffer to avoid tripling vertex data, and
44
- // computeVertexNormals() for fast GPU-friendly normals.
45
- function build_geometry(vertices: Vec3[], faces: number[][]): BufferGeometry | null {
46
- if (vertices.length === 0 || faces.length === 0) return null
47
-
48
- // Flatten vertices: Vec3[] → Float32Array
49
- const positions = new Float32Array(vertices.length * 3)
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
- // Flatten face indices: number[][] Uint32Array
58
- const indices = new Uint32Array(faces.length * 3)
59
- for (let idx = 0; idx < faces.length; idx++) {
60
- const face = faces[idx]
61
- indices[idx * 3] = face[0]
62
- indices[idx * 3 + 1] = face[1]
63
- indices[idx * 3 + 2] = face[2]
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
- // Downsample large grids once when volume changes to keep marching cubes interactive
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
- const result = marching_cubes(mc_grid, isovalue, mc_lattice, {
90
- periodic: false,
91
- interpolate: true,
92
- centered: false,
93
- normals: false,
94
- })
95
-
96
- if (origin_shift) {
97
- for (const vert of result.vertices) {
98
- vert[0] += origin_shift[0]
99
- vert[1] += origin_shift[1]
100
- vert[2] += origin_shift[2]
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
- // === Multi-layer geometry management ===
108
- // Each layer produces up to 2 geometries (positive + optional negative lobe).
109
- // Keyed by "layer_idx:sign" for cache invalidation.
110
- type GeoEntry = {
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
- let active_geometries = $state<GeoEntry[]>([])
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 active_geometries) entry.geometry.dispose()
123
- active_geometries = []
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(() => () => dispose_all())
128
-
129
- function rebuild_geometries(layers: IsosurfaceLayer[]) {
130
- if (!volume || !ds_result) return
131
- const old = active_geometries
132
- const entries: GeoEntry[] = []
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
- const surface_at = (isovalue: number) =>
166
- extract_surface(isovalue, mc_grid, mc_lattice, origin_shift)
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
- // Render lower-isovalue (outer) shells earlier so per-layer back/front passes
169
- // interleave back-to-front across shells and reduce transparency artefacts.
170
- const layer_render_rank = new Map<number, number>(
171
- layers
172
- .map((layer, layer_idx) => ({ layer_idx, isovalue: layer.isovalue }))
173
- .sort((layer_a, layer_b) => layer_a.isovalue - layer_b.isovalue)
174
- .map(({ layer_idx }, rank) => [layer_idx, rank]),
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
- if (!layer.visible || layer.isovalue <= 0) continue
180
-
181
- // Each layer gets 4 slots (positive back/front + negative back/front).
182
- const base_order = (layer_render_rank.get(layer_idx) ?? layer_idx) * 4
183
-
184
- const pos_geo = surface_at(layer.isovalue)
185
- if (pos_geo) {
186
- entries.push({
187
- geometry: pos_geo,
188
- color: layer.color,
189
- opacity: layer.opacity,
190
- render_order: base_order,
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
- if (layer.show_negative) {
195
- const neg_geo = surface_at(-layer.isovalue)
196
- if (neg_geo) {
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
- geometry: neg_geo,
199
- color: layer.negative_color,
200
- opacity: layer.opacity,
201
- render_order: base_order + 2,
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
- active_geometries = entries
208
- for (const entry of old) entry.geometry.dispose()
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
- // Rebuild all layer geometries when layers or volume change.
212
- // Debounces rapid changes (e.g. slider drags) to avoid repeated expensive marching cubes.
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
- const layers = resolved_layers
215
- void settings.halo
216
- if (!ds_result) {
217
- dispose_all()
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
- raf_id = requestAnimationFrame(() => rebuild_geometries(layers))
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
- return () => {
224
- clearTimeout(debounce_id)
225
- cancelAnimationFrame(raf_id)
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 geometry entry with appropriate material -->
231
- {#each active_geometries as entry (entry)}
232
- {#if settings.wireframe}
233
- <T.Mesh geometry={entry.geometry} frustumCulled={false}>
234
- <T.MeshBasicMaterial
235
- color={entry.color}
236
- wireframe
237
- transparent={entry.opacity < 1}
238
- opacity={entry.opacity}
239
- />
240
- </T.Mesh>
241
- {:else if entry.opacity < 1}
242
- <!-- Two-pass rendering for correct transparency -->
243
- <T.Mesh geometry={entry.geometry} renderOrder={entry.render_order} frustumCulled={false}>
244
- <T.MeshStandardMaterial
245
- color={entry.color}
246
- transparent
247
- opacity={entry.opacity}
248
- side={BackSide}
249
- depthWrite={false}
250
- metalness={0.1}
251
- roughness={0.6}
252
- />
253
- </T.Mesh>
254
- <T.Mesh
255
- geometry={entry.geometry}
256
- renderOrder={entry.render_order + 1}
257
- frustumCulled={false}
258
- >
259
- <T.MeshStandardMaterial
260
- color={entry.color}
261
- transparent
262
- opacity={entry.opacity}
263
- side={FrontSide}
264
- depthWrite={false}
265
- metalness={0.1}
266
- roughness={0.6}
267
- />
268
- </T.Mesh>
269
- {:else}
270
- <T.Mesh geometry={entry.geometry} frustumCulled={false}>
271
- <T.MeshStandardMaterial
272
- color={entry.color}
273
- side={DoubleSide}
274
- metalness={0.1}
275
- roughness={0.6}
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}