matterviz 0.3.0 → 0.3.2

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 (286) hide show
  1. package/dist/FilePicker.svelte +37 -20
  2. package/dist/Icon.svelte +2 -2
  3. package/dist/MillerIndexInput.svelte +60 -0
  4. package/dist/MillerIndexInput.svelte.d.ts +7 -0
  5. package/dist/app.css +38 -2
  6. package/dist/brillouin/BrillouinZone.svelte +20 -62
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  8. package/dist/brillouin/BrillouinZoneExportPane.svelte +12 -20
  9. package/dist/brillouin/BrillouinZoneScene.svelte +2 -2
  10. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  11. package/dist/chempot-diagram/ChemPotDiagram.svelte +192 -0
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +677 -0
  14. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  15. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2688 -0
  16. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  17. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -0
  18. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  19. package/dist/chempot-diagram/color.d.ts +10 -0
  20. package/dist/chempot-diagram/color.js +33 -0
  21. package/dist/chempot-diagram/compute.d.ts +38 -0
  22. package/dist/chempot-diagram/compute.js +650 -0
  23. package/dist/chempot-diagram/index.d.ts +5 -0
  24. package/dist/chempot-diagram/index.js +5 -0
  25. package/dist/chempot-diagram/pointer.d.ts +16 -0
  26. package/dist/chempot-diagram/pointer.js +40 -0
  27. package/dist/chempot-diagram/temperature.d.ts +15 -0
  28. package/dist/chempot-diagram/temperature.js +37 -0
  29. package/dist/chempot-diagram/types.d.ts +83 -0
  30. package/dist/chempot-diagram/types.js +27 -0
  31. package/dist/colors/index.d.ts +3 -1
  32. package/dist/colors/index.js +4 -0
  33. package/dist/composition/BarChart.svelte +13 -22
  34. package/dist/composition/BubbleChart.svelte +5 -3
  35. package/dist/composition/FormulaFilter.svelte +770 -90
  36. package/dist/composition/FormulaFilter.svelte.d.ts +37 -1
  37. package/dist/composition/PieChart.svelte +43 -18
  38. package/dist/composition/PieChart.svelte.d.ts +1 -1
  39. package/dist/constants.d.ts +1 -0
  40. package/dist/constants.js +2 -0
  41. package/dist/convex-hull/ConvexHull.svelte +14 -1
  42. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  43. package/dist/convex-hull/ConvexHull2D.svelte +14 -45
  44. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  45. package/dist/convex-hull/ConvexHull3D.svelte +396 -134
  46. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  47. package/dist/convex-hull/ConvexHull4D.svelte +93 -42
  48. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  49. package/dist/convex-hull/ConvexHullControls.svelte +94 -31
  50. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +4 -2
  51. package/dist/convex-hull/ConvexHullStats.svelte +697 -128
  52. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  53. package/dist/convex-hull/ConvexHullTooltip.svelte +1 -0
  54. package/dist/convex-hull/GasPressureControls.svelte +72 -38
  55. package/dist/convex-hull/GasPressureControls.svelte.d.ts +2 -1
  56. package/dist/convex-hull/TemperatureSlider.svelte +46 -19
  57. package/dist/convex-hull/TemperatureSlider.svelte.d.ts +2 -1
  58. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  59. package/dist/convex-hull/demo-temperature.js +36 -0
  60. package/dist/convex-hull/gas-thermodynamics.js +16 -5
  61. package/dist/convex-hull/helpers.d.ts +7 -1
  62. package/dist/convex-hull/helpers.js +45 -15
  63. package/dist/convex-hull/index.d.ts +15 -1
  64. package/dist/convex-hull/index.js +1 -0
  65. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  66. package/dist/convex-hull/thermodynamics.js +106 -17
  67. package/dist/convex-hull/types.d.ts +7 -0
  68. package/dist/convex-hull/types.js +11 -0
  69. package/dist/coordination/CoordinationBarPlot.svelte +29 -46
  70. package/dist/element/BohrAtom.svelte +1 -1
  71. package/dist/element/data.js +2 -14
  72. package/dist/element/data.json.gz +0 -0
  73. package/dist/element/index.d.ts +1 -1
  74. package/dist/element/index.js +1 -0
  75. package/dist/element/types.d.ts +1 -0
  76. package/dist/fermi-surface/FermiSurface.svelte +21 -65
  77. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  78. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  79. package/dist/fermi-surface/FermiSurfaceScene.svelte +1 -1
  80. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  81. package/dist/fermi-surface/compute.js +1 -21
  82. package/dist/fermi-surface/marching-cubes.d.ts +2 -13
  83. package/dist/fermi-surface/marching-cubes.js +2 -519
  84. package/dist/fermi-surface/parse.js +17 -23
  85. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1273 -0
  86. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  87. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +171 -0
  88. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +31 -0
  89. package/dist/heatmap-matrix/index.d.ts +53 -0
  90. package/dist/heatmap-matrix/index.js +100 -0
  91. package/dist/heatmap-matrix/shared.d.ts +2 -0
  92. package/dist/heatmap-matrix/shared.js +4 -0
  93. package/dist/icons.d.ts +119 -0
  94. package/dist/icons.js +119 -0
  95. package/dist/index.d.ts +6 -1
  96. package/dist/index.js +6 -1
  97. package/dist/io/export.js +15 -3
  98. package/dist/io/file-drop.d.ts +7 -0
  99. package/dist/io/file-drop.js +43 -0
  100. package/dist/io/index.d.ts +2 -2
  101. package/dist/io/index.js +2 -112
  102. package/dist/io/types.d.ts +1 -0
  103. package/dist/io/url-drop.d.ts +2 -0
  104. package/dist/io/url-drop.js +118 -0
  105. package/dist/isosurface/Isosurface.svelte +231 -0
  106. package/dist/isosurface/Isosurface.svelte.d.ts +8 -0
  107. package/dist/isosurface/IsosurfaceControls.svelte +273 -0
  108. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +9 -0
  109. package/dist/isosurface/index.d.ts +5 -0
  110. package/dist/isosurface/index.js +6 -0
  111. package/dist/isosurface/parse.d.ts +6 -0
  112. package/dist/isosurface/parse.js +548 -0
  113. package/dist/isosurface/slice.d.ts +11 -0
  114. package/dist/isosurface/slice.js +145 -0
  115. package/dist/isosurface/types.d.ts +55 -0
  116. package/dist/isosurface/types.js +178 -0
  117. package/dist/labels.d.ts +2 -1
  118. package/dist/labels.js +1 -0
  119. package/dist/layout/InfoTag.svelte +62 -62
  120. package/dist/layout/SubpageGrid.svelte +74 -0
  121. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  122. package/dist/layout/index.d.ts +1 -0
  123. package/dist/layout/index.js +1 -0
  124. package/dist/layout/json-tree/JsonNode.svelte +226 -53
  125. package/dist/layout/json-tree/JsonTree.svelte +425 -51
  126. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  127. package/dist/layout/json-tree/JsonValue.svelte +218 -97
  128. package/dist/layout/json-tree/types.d.ts +27 -2
  129. package/dist/layout/json-tree/utils.d.ts +14 -1
  130. package/dist/layout/json-tree/utils.js +254 -0
  131. package/dist/marching-cubes.d.ts +14 -0
  132. package/dist/marching-cubes.js +519 -0
  133. package/dist/math.d.ts +8 -0
  134. package/dist/math.js +374 -7
  135. package/dist/overlays/ContextMenu.svelte +3 -2
  136. package/dist/overlays/DraggablePane.svelte +163 -58
  137. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  138. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +232 -77
  139. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  140. package/dist/phase-diagram/PhaseDiagramControls.svelte +32 -11
  141. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  142. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +103 -0
  143. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  144. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +102 -95
  145. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +7 -0
  146. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +100 -26
  147. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  148. package/dist/phase-diagram/index.d.ts +2 -0
  149. package/dist/phase-diagram/index.js +2 -0
  150. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  151. package/dist/phase-diagram/svg-to-diagram.js +865 -0
  152. package/dist/phase-diagram/types.d.ts +10 -0
  153. package/dist/phase-diagram/utils.d.ts +7 -4
  154. package/dist/phase-diagram/utils.js +149 -59
  155. package/dist/plot/AxisLabel.svelte +26 -0
  156. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  157. package/dist/plot/BarPlot.svelte +473 -228
  158. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  159. package/dist/plot/BarPlotControls.svelte +3 -2
  160. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  161. package/dist/plot/ColorBar.svelte +54 -54
  162. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  163. package/dist/plot/ElementScatter.svelte +4 -3
  164. package/dist/plot/FillArea.svelte +4 -1
  165. package/dist/plot/Histogram.svelte +320 -230
  166. package/dist/plot/Histogram.svelte.d.ts +2 -2
  167. package/dist/plot/HistogramControls.svelte +29 -10
  168. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  169. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +2 -2
  170. package/dist/plot/PlotControls.svelte +109 -27
  171. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  172. package/dist/plot/PlotLegend.svelte +1 -1
  173. package/dist/plot/PortalSelect.svelte +2 -1
  174. package/dist/plot/ReferenceLine.svelte +2 -1
  175. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  176. package/dist/plot/ReferencePlane.svelte +1 -3
  177. package/dist/plot/ScatterPlot.svelte +343 -209
  178. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  179. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  180. package/dist/plot/ScatterPlot3DControls.svelte +203 -250
  181. package/dist/plot/ScatterPlot3DScene.svelte +4 -7
  182. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  183. package/dist/plot/ScatterPlotControls.svelte +95 -55
  184. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  185. package/dist/plot/ZeroLines.svelte +44 -0
  186. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  187. package/dist/plot/ZoomRect.svelte +21 -0
  188. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  189. package/dist/plot/axis-utils.d.ts +1 -1
  190. package/dist/plot/data-cleaning.js +1 -5
  191. package/dist/plot/index.d.ts +6 -2
  192. package/dist/plot/index.js +6 -2
  193. package/dist/plot/interactions.d.ts +8 -10
  194. package/dist/plot/interactions.js +10 -19
  195. package/dist/plot/layout.d.ts +7 -1
  196. package/dist/plot/layout.js +12 -4
  197. package/dist/plot/reference-line.d.ts +4 -21
  198. package/dist/plot/reference-line.js +7 -81
  199. package/dist/plot/types.d.ts +42 -17
  200. package/dist/plot/types.js +10 -0
  201. package/dist/plot/utils/label-placement.js +14 -11
  202. package/dist/plot/utils.d.ts +1 -0
  203. package/dist/plot/utils.js +14 -0
  204. package/dist/rdf/RdfPlot.svelte +55 -66
  205. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  206. package/dist/rdf/index.d.ts +1 -1
  207. package/dist/rdf/index.js +1 -1
  208. package/dist/settings.d.ts +5 -0
  209. package/dist/settings.js +37 -3
  210. package/dist/spectral/Bands.svelte +515 -143
  211. package/dist/spectral/Bands.svelte.d.ts +22 -2
  212. package/dist/spectral/helpers.d.ts +23 -1
  213. package/dist/spectral/helpers.js +65 -9
  214. package/dist/spectral/types.d.ts +2 -0
  215. package/dist/structure/AtomLegend.svelte +31 -10
  216. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  217. package/dist/structure/CellSelect.svelte +92 -22
  218. package/dist/structure/Lattice.svelte +2 -0
  219. package/dist/structure/Structure.svelte +716 -173
  220. package/dist/structure/Structure.svelte.d.ts +7 -2
  221. package/dist/structure/StructureControls.svelte +26 -14
  222. package/dist/structure/StructureControls.svelte.d.ts +5 -1
  223. package/dist/structure/StructureInfoPane.svelte +7 -1
  224. package/dist/structure/StructureScene.svelte +386 -95
  225. package/dist/structure/StructureScene.svelte.d.ts +15 -4
  226. package/dist/structure/atom-properties.d.ts +6 -2
  227. package/dist/structure/atom-properties.js +38 -25
  228. package/dist/structure/export.js +10 -7
  229. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  230. package/dist/structure/ferrox-wasm-types.js +0 -3
  231. package/dist/structure/ferrox-wasm.d.ts +3 -2
  232. package/dist/structure/ferrox-wasm.js +1 -2
  233. package/dist/structure/index.d.ts +7 -0
  234. package/dist/structure/index.js +22 -0
  235. package/dist/structure/parse.js +19 -16
  236. package/dist/structure/partial-occupancy.d.ts +25 -0
  237. package/dist/structure/partial-occupancy.js +102 -0
  238. package/dist/structure/validation.js +6 -3
  239. package/dist/symmetry/SymmetryStats.svelte +18 -4
  240. package/dist/symmetry/WyckoffTable.svelte +18 -10
  241. package/dist/symmetry/index.d.ts +7 -4
  242. package/dist/symmetry/index.js +83 -18
  243. package/dist/table/HeatmapTable.svelte +468 -69
  244. package/dist/table/HeatmapTable.svelte.d.ts +13 -1
  245. package/dist/table/ToggleMenu.svelte +291 -44
  246. package/dist/table/ToggleMenu.svelte.d.ts +4 -1
  247. package/dist/table/index.d.ts +3 -0
  248. package/dist/tooltip/index.d.ts +1 -1
  249. package/dist/tooltip/index.js +1 -0
  250. package/dist/trajectory/Trajectory.svelte +147 -145
  251. package/dist/trajectory/TrajectoryExportPane.svelte +13 -9
  252. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  253. package/dist/trajectory/constants.d.ts +6 -0
  254. package/dist/trajectory/constants.js +7 -0
  255. package/dist/trajectory/extract.js +3 -5
  256. package/dist/trajectory/format-detect.d.ts +9 -0
  257. package/dist/trajectory/format-detect.js +76 -0
  258. package/dist/trajectory/frame-reader.d.ts +17 -0
  259. package/dist/trajectory/frame-reader.js +339 -0
  260. package/dist/trajectory/helpers.d.ts +15 -0
  261. package/dist/trajectory/helpers.js +187 -0
  262. package/dist/trajectory/index.d.ts +1 -0
  263. package/dist/trajectory/index.js +11 -4
  264. package/dist/trajectory/parse/ase.d.ts +2 -0
  265. package/dist/trajectory/parse/ase.js +76 -0
  266. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  267. package/dist/trajectory/parse/hdf5.js +121 -0
  268. package/dist/trajectory/parse/index.d.ts +12 -0
  269. package/dist/trajectory/parse/index.js +304 -0
  270. package/dist/trajectory/parse/lammps.d.ts +5 -0
  271. package/dist/trajectory/parse/lammps.js +169 -0
  272. package/dist/trajectory/parse/vasp.d.ts +2 -0
  273. package/dist/trajectory/parse/vasp.js +65 -0
  274. package/dist/trajectory/parse/xyz.d.ts +2 -0
  275. package/dist/trajectory/parse/xyz.js +109 -0
  276. package/dist/trajectory/types.d.ts +11 -0
  277. package/dist/trajectory/types.js +1 -0
  278. package/dist/utils.d.ts +2 -0
  279. package/dist/utils.js +4 -0
  280. package/dist/xrd/XrdPlot.svelte +6 -4
  281. package/dist/xrd/calc-xrd.js +0 -1
  282. package/package.json +33 -23
  283. package/readme.md +4 -4
  284. package/dist/trajectory/parse.d.ts +0 -42
  285. package/dist/trajectory/parse.js +0 -1267
  286. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
@@ -1,19 +1,25 @@
1
1
  <script lang="ts">import { ELEMENT_COLOR_SCHEMES } from '../colors';
2
2
  import { normalize_show_controls } from '../controls';
3
+ import { StatusMessage } from '../feedback';
3
4
  import Spinner from '../feedback/Spinner.svelte';
4
5
  import Icon from '../Icon.svelte';
5
- import { decompress_file, handle_url_drop, load_from_url } from '../io';
6
+ import { create_file_drop_handler, load_from_url } from '../io';
7
+ import { parse_volumetric_file } from '../isosurface/parse';
8
+ import { auto_isosurface_settings, DEFAULT_ISOSURFACE_SETTINGS, } from '../isosurface/types';
9
+ import { ELEM_SYMBOLS } from '../labels';
6
10
  import { set_fullscreen_bg, toggle_fullscreen } from '../layout';
11
+ import { create_cart_to_frac, create_frac_to_cart } from '../math';
7
12
  import { DEFAULTS } from '../settings';
8
13
  import { colors } from '../state.svelte';
9
- import { get_element_counts, get_pbc_image_sites } from './';
14
+ import { get_element_counts, get_pbc_image_sites, get_site_vector, } from './';
15
+ import { wrap_to_unit_cell } from './pbc';
10
16
  import { is_valid_supercell_input, make_supercell } from './supercell';
11
17
  import * as symmetry from '../symmetry';
12
18
  import { transform_cell } from '../symmetry';
13
19
  import { Canvas } from '@threlte/core';
14
20
  import { untrack } from 'svelte';
15
21
  import { click_outside, tooltip } from 'svelte-multiselect';
16
- import { SvelteMap } from 'svelte/reactivity';
22
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
17
23
  import { get_property_colors } from './atom-properties';
18
24
  import AtomLegend from './AtomLegend.svelte';
19
25
  import CellSelect from './CellSelect.svelte';
@@ -34,7 +40,7 @@ let lattice_props = $state({
34
40
  cell_edge_width: DEFAULTS.structure.cell_edge_width,
35
41
  show_cell_vectors: DEFAULTS.structure.show_cell_vectors,
36
42
  });
37
- let { structure = $bindable(), scene_props: scene_props_in = $bindable(), lattice_props: lattice_props_in = $bindable(), controls_open = $bindable(false), info_pane_open = $bindable(false), enable_measure_mode = $bindable(true), background_color = $bindable(), background_opacity = $bindable(0.1), show_controls, fullscreen = $bindable(false), wrapper = $bindable(), width = $bindable(0), height = $bindable(0), reset_text = `Reset camera (or double-click)`, color_scheme = $bindable(`Vesta`), atom_color_config = $bindable({
43
+ let { structure = $bindable(), scene_props: scene_props_in = $bindable(), lattice_props: lattice_props_in = $bindable(), controls_open = $bindable(false), info_pane_open = $bindable(false), enable_measure_mode = $bindable(true), measure_mode = $bindable(`distance`), background_color = $bindable(), background_opacity = $bindable(0.1), show_controls, fullscreen = $bindable(false), wrapper = $bindable(), width = $bindable(0), height = $bindable(0), reset_text = `Reset camera (or double-click)`, color_scheme = $bindable(`Vesta`), atom_color_config = $bindable({
38
44
  mode: DEFAULTS.structure.atom_color_mode,
39
45
  scale: DEFAULTS.structure.atom_color_scale,
40
46
  scale_type: DEFAULTS.structure.atom_color_scale_type,
@@ -46,9 +52,9 @@ measured_sites = $bindable([]),
46
52
  // expose the displayed structure (with image atoms and supercell) for external use
47
53
  displayed_structure = $bindable(),
48
54
  // Track hidden elements across component lifecycle
49
- hidden_elements = $bindable(new Set()),
55
+ hidden_elements = $bindable(new SvelteSet()),
50
56
  // Track hidden property values (e.g. Wyckoff positions, coordination numbers)
51
- hidden_prop_vals = $bindable(new Set()),
57
+ hidden_prop_vals = $bindable(new SvelteSet()),
52
58
  // Per-element radius overrides (absolute values in Angstroms)
53
59
  element_radius_overrides = $bindable({}),
54
60
  // Per-site radius overrides (absolute values in Angstroms)
@@ -61,7 +67,15 @@ symmetry_settings = $bindable(symmetry.default_sym_settings),
61
67
  // Useful for LAMMPS files where atom types are mapped to H, He, Li by default
62
68
  element_mapping = $bindable(),
63
69
  // Cell type: original, conventional, or primitive (requires symmetry analysis)
64
- cell_type = $bindable(`original`), children, top_right_controls, on_file_load, on_error, on_fullscreen_change, on_camera_move, on_camera_reset, ...rest } = $props();
70
+ cell_type = $bindable(`original`),
71
+ // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
72
+ volumetric_data = $bindable(),
73
+ // Isosurface rendering settings
74
+ isosurface_settings = $bindable({
75
+ ...DEFAULT_ISOSURFACE_SETTINGS,
76
+ }),
77
+ // Active volume index when multiple volumes are present
78
+ active_volume_idx = $bindable(0), children, top_right_controls, on_file_load, on_error, on_fullscreen_change, on_camera_move, on_camera_reset, ...rest } = $props();
65
79
  // Initialize models from incoming props; mutations come from UI controls; we mirror into local dicts (NOTE only doing shallow merge)
66
80
  $effect.pre(() => {
67
81
  if (scene_props_in && typeof scene_props_in === `object`) {
@@ -85,21 +99,8 @@ $effect(() => {
85
99
  const text_content = content instanceof ArrayBuffer
86
100
  ? new TextDecoder().decode(content)
87
101
  : content;
88
- const parsed_structure = parse_any_structure(text_content, filename);
89
- if (parsed_structure) {
90
- structure = parsed_structure;
91
- // Emit file load event
92
- on_file_load?.({
93
- structure,
94
- filename,
95
- file_size: new Blob([content]).size,
96
- total_atoms: structure.sites?.length || 0,
97
- });
98
- }
99
- else {
100
- error_msg = `Failed to parse structure from ${filename}`;
101
- on_error?.({ error_msg, filename });
102
- }
102
+ const parsed = parse_file_content(text_content, filename);
103
+ emit_file_load_event(parsed, filename, content);
103
104
  }
104
105
  catch (error) {
105
106
  error_msg = `Failed to parse structure: ${error instanceof Error ? error.message : String(error)}`;
@@ -141,17 +142,18 @@ $effect(() => {
141
142
  });
142
143
  // Track if force vectors were auto-enabled to prevent repeated triggering
143
144
  let force_vectors_auto_enabled = $state(false);
144
- // Auto-enable force vectors when structure has force data
145
+ // Auto-enable force vectors when structure has vector data (force, magmom, or spin)
145
146
  $effect(() => {
146
147
  if (structure?.sites && !force_vectors_auto_enabled) {
147
- const has_force_data = structure.sites.some((site) => site.properties?.force && Array.isArray(site.properties.force));
148
- // Enable force vectors if structure has force data
149
- if (has_force_data && !scene_props.show_force_vectors) {
148
+ const has_vector_data = structure.sites.some((site) => get_site_vector(site) !== null);
149
+ if (!has_vector_data)
150
+ return;
151
+ if (!scene_props.show_force_vectors) {
150
152
  scene_props.show_force_vectors = true;
151
153
  scene_props.force_scale ??= DEFAULTS.structure.force_scale;
152
154
  scene_props.force_color ??= DEFAULTS.structure.force_color;
153
- force_vectors_auto_enabled = true;
154
155
  }
156
+ force_vectors_auto_enabled = true;
155
157
  }
156
158
  });
157
159
  // Optimize scene props for performance based on structure size and mode
@@ -172,23 +174,40 @@ $effect(() => {
172
174
  let property_colors = $derived(get_property_colors(structure, atom_color_config, scene_props.bonding_strategy, sym_data));
173
175
  let symmetry_run_id = 0;
174
176
  let symmetry_error = $state();
175
- // Trigger symmetry analysis when structure is loaded or settings change
177
+ let last_symmetry_structure_ref = null;
178
+ // Trigger symmetry analysis when structure is loaded or settings change.
179
+ // Skip during atom drags — symmetry doesn't change from moving atoms,
180
+ // and WASM analysis on every drag frame causes severe frame drops.
176
181
  $effect(() => {
182
+ if (dragging_atoms)
183
+ return;
177
184
  if (!structure || !(`lattice` in structure)) {
178
185
  untrack(() => {
179
186
  sym_data = null;
180
187
  symmetry_error = undefined;
181
188
  });
189
+ last_symmetry_structure_ref = null;
182
190
  return;
183
191
  }
184
192
  const current_structure = structure;
193
+ const structure_changed = current_structure !== last_symmetry_structure_ref;
194
+ if (structure_changed) {
195
+ untrack(() => {
196
+ sym_data = null;
197
+ symmetry_error = undefined;
198
+ });
199
+ last_symmetry_structure_ref = current_structure;
200
+ }
201
+ else {
202
+ // Keep previous symmetry data while recomputing so bound consumers
203
+ // (e.g. SymmetryStats inputs) do not unmount and lose focus.
204
+ untrack(() => symmetry_error = undefined);
205
+ }
185
206
  const run_id = ++symmetry_run_id;
186
207
  // Destructure symmetry_settings to ensure Svelte tracks changes to symprec and algo
187
208
  // (reading just the object reference isn't sufficient for fine-grained reactivity)
188
209
  const { symprec, algo } = symmetry_settings ?? symmetry.default_sym_settings;
189
210
  const current_settings = { symprec, algo };
190
- // Use untrack to prevent cascading reactivity when resetting state
191
- untrack(() => [sym_data, symmetry_error] = [null, undefined]);
192
211
  symmetry.ensure_moyo_wasm_ready()
193
212
  .then(() => run_id === symmetry_run_id
194
213
  ? symmetry.analyze_structure_symmetry(current_structure, current_settings)
@@ -200,18 +219,129 @@ $effect(() => {
200
219
  })
201
220
  .catch((err) => {
202
221
  if (run_id === symmetry_run_id) {
222
+ untrack(() => sym_data = null);
203
223
  symmetry_error = `Symmetry analysis failed: ${err?.message || err}`;
204
224
  console.error(`Symmetry analysis failed:`, err);
205
225
  }
206
226
  });
207
227
  });
208
- // Measurement mode and selection state
209
- let measure_mode = $state(`distance`);
210
228
  let measure_menu_open = $state(false);
211
229
  let export_pane_open = $state(false);
212
230
  // Bond customization state
213
231
  let added_bonds = $state([]);
214
232
  let removed_bonds = $state([]);
233
+ // === Edit-atoms mode state ===
234
+ let dragging_atoms = $state(false);
235
+ let undo_stack = $state([]);
236
+ let redo_stack = $state([]);
237
+ const MAX_HISTORY = 20;
238
+ // Flag set before internal edits (undo/redo/delete/add/move) to distinguish
239
+ // them from external structure changes (file load, trajectory step, etc.)
240
+ let is_internal_edit = false;
241
+ // Add-atom sub-mode state (bound to StructureScene)
242
+ let add_atom_mode = $state(false);
243
+ let add_element = $state(`C`);
244
+ let canvas_cursor = $state(`default`);
245
+ let change_element_mode = $state(false);
246
+ let change_element_value = $state(``);
247
+ // Ephemeral toast message for edit operations
248
+ let toast_msg = $state(null);
249
+ let toast_timer;
250
+ function show_toast(msg, duration_ms = 2000) {
251
+ clearTimeout(toast_timer);
252
+ toast_msg = msg;
253
+ toast_timer = setTimeout(() => (toast_msg = null), duration_ms);
254
+ }
255
+ // Normalize and validate element symbol (e.g. "fe" → "Fe", "Xx" → null)
256
+ function normalize_element(input) {
257
+ const normalized = (input.charAt(0).toUpperCase() +
258
+ input.slice(1).toLowerCase());
259
+ return ELEM_SYMBOLS.includes(normalized) ? normalized : null;
260
+ }
261
+ function clear_selection() {
262
+ selected_sites = [];
263
+ measured_sites = [];
264
+ dragging_atoms = false;
265
+ }
266
+ function push_undo() {
267
+ if (!structure)
268
+ return;
269
+ if (undo_stack.length >= MAX_HISTORY) {
270
+ undo_stack.splice(0, undo_stack.length - MAX_HISTORY + 1);
271
+ }
272
+ undo_stack.push($state.snapshot(structure));
273
+ redo_stack.length = 0;
274
+ }
275
+ // Shared undo/redo: pop from `source`, push current state onto `target`
276
+ function apply_history(source, target) {
277
+ if (source.length === 0 || !structure)
278
+ return;
279
+ const restored = source.pop();
280
+ if (!restored)
281
+ return;
282
+ is_internal_edit = true;
283
+ target.push($state.snapshot(structure));
284
+ structure = restored;
285
+ clear_selection();
286
+ }
287
+ const undo = () => apply_history(undo_stack, redo_stack);
288
+ const redo = () => apply_history(redo_stack, undo_stack);
289
+ // Clear undo/redo stacks when structure changes externally (file load, etc.)
290
+ // Internal edits set is_internal_edit=true before modifying structure.
291
+ // This $effect runs after microtask, so the flag is still set from the edit.
292
+ $effect(() => {
293
+ // Track structure to re-run when it changes
294
+ void structure;
295
+ if (is_internal_edit) {
296
+ is_internal_edit = false;
297
+ return;
298
+ }
299
+ // External change — clear history and stale edit-atoms state
300
+ untrack(() => {
301
+ if (undo_stack.length > 0 || redo_stack.length > 0) {
302
+ undo_stack = [];
303
+ redo_stack = [];
304
+ }
305
+ if (measure_mode === `edit-atoms`) {
306
+ if (selected_sites.length > 0 || measured_sites.length > 0)
307
+ clear_selection();
308
+ if (site_radius_overrides?.size > 0)
309
+ site_radius_overrides.clear();
310
+ }
311
+ });
312
+ });
313
+ // Clear selection when switching measure/edit mode so stale state doesn't carry over
314
+ let mode_first_run = true;
315
+ $effect(() => {
316
+ void measure_mode; // track reactively
317
+ if (mode_first_run) {
318
+ mode_first_run = false;
319
+ return;
320
+ }
321
+ untrack(() => {
322
+ if (selected_sites.length > 0 || measured_sites.length > 0)
323
+ clear_selection();
324
+ });
325
+ });
326
+ // Auto-bake cell type transform and clear stale state when entering edit-atoms mode
327
+ $effect(() => {
328
+ if (measure_mode !== `edit-atoms`)
329
+ return;
330
+ untrack(() => {
331
+ // Clear bond edits from edit-bonds mode to avoid stale state
332
+ if (added_bonds.length > 0 || removed_bonds.length > 0) {
333
+ added_bonds = [];
334
+ removed_bonds = [];
335
+ }
336
+ if (cell_type !== `original` && cell_transformed_structure && structure) {
337
+ // Bake the transformed cell: push original to undo, replace structure
338
+ is_internal_edit = true;
339
+ push_undo();
340
+ structure = $state.snapshot(cell_transformed_structure);
341
+ cell_type = `original`;
342
+ }
343
+ });
344
+ });
215
345
  let controls_config = $derived(normalize_show_controls(show_controls));
216
346
  // Normalize structure coordinates: wrap fractional coords to [0,1) and recompute Cartesian
217
347
  // This ensures atoms are rendered inside the unit cell regardless of data source
@@ -242,13 +372,10 @@ let cell_transformed_structure = $derived.by(() => {
242
372
  // Create supercell if needed (uses cell_transformed_structure as base)
243
373
  let supercell_structure = $state(structure);
244
374
  let supercell_loading = $state(false);
375
+ let has_supercell = $derived(!!supercell_scaling && ![``, `1x1x1`, `1`].includes(supercell_scaling));
245
376
  $effect(() => {
246
377
  const base_structure = cell_transformed_structure;
247
- if (!base_structure || !(`lattice` in base_structure)) {
248
- supercell_structure = base_structure;
249
- supercell_loading = false;
250
- }
251
- else if ([``, `1x1x1`, `1`].includes(supercell_scaling)) {
378
+ if (!base_structure || !(`lattice` in base_structure) || !has_supercell) {
252
379
  supercell_structure = base_structure;
253
380
  supercell_loading = false;
254
381
  }
@@ -303,16 +430,20 @@ $effect(() => {
303
430
  return;
304
431
  }
305
432
  untrack(() => {
306
- if (selected_sites.length > 0 || measured_sites.length > 0) {
307
- selected_sites = [];
308
- measured_sites = [];
309
- }
433
+ // In edit-atoms mode, structure changes are intentional user edits
434
+ // (move/add/delete) — preserve the selection so TransformControls stays active
435
+ if (measure_mode === `edit-atoms`)
436
+ return;
437
+ if (selected_sites.length > 0 || measured_sites.length > 0)
438
+ clear_selection();
310
439
  // Clear site radius overrides since site indices are no longer valid
311
440
  if (site_radius_overrides?.size > 0)
312
441
  site_radius_overrides.clear();
313
442
  });
314
443
  });
315
- // Apply element mapping then image atoms to the supercell structure
444
+ // Apply element mapping then image atoms to the supercell structure.
445
+ // Skip get_pbc_image_sites during atom drags — the vector math + doubled site
446
+ // count causes frame drops. Image atoms reappear instantly on drag release.
316
447
  $effect(() => {
317
448
  let struct = supercell_structure;
318
449
  if (struct && element_mapping && Object.keys(element_mapping).length > 0) {
@@ -330,7 +461,8 @@ $effect(() => {
330
461
  };
331
462
  }
332
463
  displayed_structure =
333
- show_image_atoms && struct && `lattice` in struct && struct.lattice
464
+ !dragging_atoms && show_image_atoms && struct && `lattice` in struct &&
465
+ struct.lattice
334
466
  ? get_pbc_image_sites(struct)
335
467
  : struct;
336
468
  });
@@ -342,7 +474,6 @@ let camera = $state(undefined);
342
474
  let orbit_controls = $state(undefined);
343
475
  let rotation_target_ref = $state(undefined);
344
476
  let initial_computed_zoom = $state(undefined);
345
- let camera_move_timeout = $state(null);
346
477
  // Mutual exclusion: opening one pane closes others
347
478
  $effect(() => {
348
479
  if (info_pane_open) {
@@ -364,29 +495,53 @@ $effect(() => {
364
495
  if (structure)
365
496
  camera_has_moved = false;
366
497
  });
367
- // Set camera_has_moved to true when camera starts moving
368
- $effect(() => untrack(() => {
369
- if (camera_is_moving) {
370
- camera_has_moved = true;
371
- // Debounce camera move events to avoid excessive emissions
372
- if (camera_move_timeout)
373
- clearTimeout(camera_move_timeout);
374
- camera_move_timeout = setTimeout(() => {
375
- const { camera_position } = scene_props;
376
- on_camera_move?.({ structure, camera_has_moved, camera_position });
377
- }, 200);
378
- }
379
- }));
498
+ const read_orbit_target = () => {
499
+ if (!orbit_controls?.target)
500
+ return;
501
+ const { x, y, z } = orbit_controls.target;
502
+ return [x, y, z];
503
+ };
504
+ const read_camera_position = () => camera
505
+ ? [camera.position.x, camera.position.y, camera.position.z]
506
+ : scene_props.camera_position;
507
+ // Emit debounced camera updates while controls are active.
508
+ $effect(() => {
509
+ if (!camera_is_moving)
510
+ return;
511
+ camera_has_moved = true;
512
+ const emit_camera_move = () => {
513
+ const camera_position = read_camera_position();
514
+ if (camera_position === undefined)
515
+ return;
516
+ const camera_target = read_orbit_target();
517
+ scene_props.camera_position = camera_position;
518
+ scene_props.camera_target = camera_target;
519
+ on_camera_move?.({
520
+ structure,
521
+ camera_has_moved,
522
+ camera_position,
523
+ camera_target,
524
+ });
525
+ };
526
+ emit_camera_move();
527
+ const emit_interval = setInterval(emit_camera_move, 200);
528
+ return () => clearInterval(emit_interval);
529
+ });
380
530
  function reset_camera() {
381
- // Reset camera position to trigger automatic positioning
531
+ // Reset camera position to trigger automatic positioning.
382
532
  scene_props.camera_position = [0, 0, 0];
533
+ scene_props.camera_target = rotation_target_ref;
383
534
  camera_has_moved = false;
384
- // Manually reset zoom and pan using the exposed initial values
535
+ let camera_position = [0, 0, 0];
536
+ let camera_target = rotation_target_ref;
537
+ // Reset pan/zoom and ensure controls target returns to structure center.
385
538
  if (orbit_controls && camera) {
386
- // Reset the target to the structure center (pan reset)
539
+ if (`reset` in orbit_controls &&
540
+ typeof orbit_controls.reset === `function`)
541
+ orbit_controls.reset();
387
542
  if (orbit_controls.target && rotation_target_ref) {
388
- const [x, y, z] = rotation_target_ref;
389
- orbit_controls.target.set(x, y, z);
543
+ const [target_x, target_y, target_z] = rotation_target_ref;
544
+ orbit_controls.target.set(target_x, target_y, target_z);
390
545
  }
391
546
  // Reset zoom for orthographic camera
392
547
  if (`zoom` in camera && initial_computed_zoom !== undefined) {
@@ -395,11 +550,14 @@ function reset_camera() {
395
550
  ortho_camera.updateProjectionMatrix();
396
551
  }
397
552
  // Call update to apply changes immediately
398
- if (typeof orbit_controls.update === `function`) {
553
+ if (typeof orbit_controls.update === `function`)
399
554
  orbit_controls.update();
400
- }
555
+ camera_position = read_camera_position() ?? camera_position;
556
+ camera_target = read_orbit_target();
401
557
  }
402
- on_camera_reset?.({ structure, camera_has_moved, camera_position: [0, 0, 0] });
558
+ scene_props.camera_position = camera_position;
559
+ scene_props.camera_target = camera_target;
560
+ on_camera_reset?.({ structure, camera_has_moved, camera_position, camera_target });
403
561
  }
404
562
  const emit_file_load_event = (structure, filename, content) => on_file_load?.({
405
563
  structure: structure,
@@ -409,93 +567,320 @@ const emit_file_load_event = (structure, filename, content) => on_file_load?.({
409
567
  : content.byteLength,
410
568
  total_atoms: structure.sites?.length || 0,
411
569
  });
412
- async function handle_file_drop(event) {
413
- event.preventDefault();
414
- dragover = false;
415
- if (!allow_file_drop)
416
- return;
417
- loading = true;
418
- error_msg = undefined; // Clear previous error when a new file is dropped
419
- try {
420
- // Handle URL-based files (e.g. from FilePicker)
421
- const handled = await handle_url_drop(event, on_file_drop || ((content, filename) => {
422
- try {
423
- const text_content = content instanceof ArrayBuffer
424
- ? new TextDecoder().decode(content)
425
- : content;
426
- const parsed_structure = parse_any_structure(text_content, filename);
427
- if (parsed_structure) {
428
- structure = parsed_structure;
429
- emit_file_load_event(parsed_structure, filename, content);
430
- }
431
- else
432
- throw new Error(`Failed to parse structure from ${filename}`);
433
- }
434
- catch (err) {
435
- error_msg = `Failed to parse structure: ${err}`;
436
- on_error?.({ error_msg, filename });
437
- }
438
- })).catch(() => false);
439
- if (handled)
440
- return;
441
- // Handle file system drops
442
- const file = event.dataTransfer?.files[0];
443
- if (file) {
444
- try {
445
- const { content, filename } = await decompress_file(file);
446
- if (content) {
447
- if (on_file_drop)
448
- on_file_drop(content, filename);
449
- else {
450
- // Parse structure internally when no handler provided
451
- try {
452
- const parsed_structure = parse_any_structure(content, filename);
453
- if (parsed_structure) {
454
- structure = parsed_structure;
455
- emit_file_load_event(parsed_structure, filename, content);
456
- }
457
- else
458
- throw new Error(`Failed to parse structure from ${filename}`);
459
- }
460
- catch (err) {
461
- error_msg = `Failed to parse structure: ${err}`;
462
- on_error?.({ error_msg, filename });
463
- }
464
- }
465
- }
466
- }
467
- catch (error) {
468
- error_msg = `Failed to load file ${file.name}: ${error}`;
469
- on_error?.({ error_msg, filename: file.name });
470
- }
471
- }
472
- }
473
- finally {
474
- loading = false;
570
+ // Try to parse content as a volumetric file, setting both structure and volumetric data.
571
+ // Delegates format detection entirely to parse_volumetric_file (filename + content sniffing).
572
+ // Returns the parsed structure on success, or null if the file isn't a volumetric format.
573
+ function try_parse_volumetric(text_content, filename) {
574
+ const vol_result = parse_volumetric_file(text_content, filename);
575
+ if (!vol_result)
576
+ return null;
577
+ // parse_volumetric_file extracts structure from file header;
578
+ // parsers set pbc so the lattice conforms to Crystal's LatticeType
579
+ structure = vol_result.structure;
580
+ volumetric_data = vol_result.volumes;
581
+ // Auto-compute reasonable isosurface settings from data range
582
+ const vol = vol_result.volumes[0];
583
+ if (vol) {
584
+ isosurface_settings = auto_isosurface_settings(vol.data_range);
585
+ active_volume_idx = 0;
475
586
  }
587
+ return structure;
476
588
  }
477
- function onkeydown(event) {
589
+ // Parse file content, trying volumetric format first then falling back to plain structure.
590
+ // Returns the parsed structure on success, throws on failure.
591
+ function parse_file_content(text_content, filename) {
592
+ const vol_struct = try_parse_volumetric(text_content, filename);
593
+ if (vol_struct)
594
+ return vol_struct;
595
+ // Clear stale volumetric data when loading a non-volumetric file
596
+ volumetric_data = [];
597
+ const parsed = parse_any_structure(text_content, filename);
598
+ if (!parsed)
599
+ throw new Error(`Failed to parse structure from ${filename}`);
600
+ structure = parsed;
601
+ return parsed;
602
+ }
603
+ const handle_file_drop = create_file_drop_handler({
604
+ allow: () => allow_file_drop,
605
+ on_drop: (content, filename) => {
606
+ if (on_file_drop)
607
+ return on_file_drop(content, filename);
608
+ try {
609
+ const text_content = content instanceof ArrayBuffer
610
+ ? new TextDecoder().decode(content)
611
+ : content;
612
+ const parsed = parse_file_content(text_content, filename);
613
+ emit_file_load_event(parsed, filename, content);
614
+ }
615
+ catch (err) {
616
+ error_msg = `Failed to parse structure: ${err instanceof Error ? err.message : String(err)}`;
617
+ on_error?.({ error_msg, filename });
618
+ }
619
+ },
620
+ on_error: (msg) => {
621
+ error_msg = msg;
622
+ on_error?.({ error_msg: msg });
623
+ },
624
+ set_loading: (val) => {
625
+ loading = val;
626
+ if (val)
627
+ [error_msg, dragover] = [undefined, false];
628
+ },
629
+ });
630
+ function handle_keydown(event) {
478
631
  // Don't handle shortcuts if user is typing in an input field
479
632
  const target = event.target;
480
633
  const is_input_focused = target.tagName === `INPUT` ||
481
634
  target.tagName === `TEXTAREA`;
635
+ // Allow Escape to cancel add-atom mode even when the element input is focused
636
+ if (event.key === `Escape` && measure_mode === `edit-atoms` && add_atom_mode) {
637
+ event.preventDefault();
638
+ add_atom_mode = false;
639
+ return;
640
+ }
482
641
  if (is_input_focused)
483
642
  return;
484
- // Interface shortcuts
485
- if (event.key === `f` && fullscreen_toggle)
643
+ // Edit-atoms mode shortcuts (including undo/redo)
644
+ if (measure_mode === `edit-atoms`) {
645
+ // Undo/redo shortcuts (Ctrl/Cmd + Z/Y) — only active in edit-atoms mode
646
+ if (event.ctrlKey || event.metaKey) {
647
+ const key = event.key.toLowerCase();
648
+ if (key === `z` && !event.shiftKey) {
649
+ event.preventDefault();
650
+ undo();
651
+ show_toast(`Undo (${undo_stack.length} left)`);
652
+ return;
653
+ }
654
+ else if (key === `y` || (key === `z` && event.shiftKey)) {
655
+ event.preventDefault();
656
+ redo();
657
+ show_toast(`Redo (${redo_stack.length} left)`);
658
+ return;
659
+ }
660
+ }
661
+ if (event.key === `Delete` || event.key === `Backspace`) {
662
+ // Delete selected atoms
663
+ if (selected_sites.length > 0 && structure?.sites) {
664
+ event.preventDefault();
665
+ is_internal_edit = true;
666
+ push_undo();
667
+ const to_delete = scene_to_structure_indices(selected_sites, true);
668
+ const n_deleted = to_delete.size;
669
+ clear_selection();
670
+ structure = {
671
+ ...structure,
672
+ sites: structure.sites.filter((_, idx) => !to_delete.has(idx)),
673
+ };
674
+ // Clear per-site overrides since indices shifted after deletion
675
+ if (site_radius_overrides?.size > 0)
676
+ site_radius_overrides.clear();
677
+ added_bonds = [];
678
+ removed_bonds = [];
679
+ show_toast(`Deleted ${n_deleted} site${n_deleted > 1 ? `s` : ``}`);
680
+ }
681
+ return;
682
+ }
683
+ const key = event.key.toLowerCase();
684
+ const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
685
+ if (key === `a` && plain) {
686
+ // Enter add-atom sub-mode (plain 'a' only, not Ctrl+A/Cmd+A/Alt+A)
687
+ event.preventDefault();
688
+ add_atom_mode = !add_atom_mode;
689
+ return;
690
+ }
691
+ // Change element of selected atoms
692
+ if (key === `e` && plain && selected_sites.length > 0) {
693
+ event.preventDefault();
694
+ change_element_mode = !change_element_mode;
695
+ return;
696
+ }
697
+ // Duplicate selected atoms at a small offset
698
+ if (key === `d` && (event.ctrlKey || event.metaKey) &&
699
+ selected_sites.length > 0 && structure?.sites) {
700
+ event.preventDefault();
701
+ is_internal_edit = true;
702
+ push_undo();
703
+ const orig_indices = scene_to_structure_indices(selected_sites);
704
+ const cart_to_frac = get_cart_to_frac();
705
+ const new_sites = structure.sites
706
+ .filter((_, idx) => orig_indices.has(idx))
707
+ .map((site) => {
708
+ const new_xyz = [
709
+ site.xyz[0] + 0.5,
710
+ site.xyz[1] + 0.5,
711
+ site.xyz[2] + 0.5,
712
+ ];
713
+ return {
714
+ ...site,
715
+ xyz: new_xyz,
716
+ abc: cart_to_frac?.(new_xyz) ?? new_xyz,
717
+ properties: { ...site.properties },
718
+ };
719
+ });
720
+ const base_idx = structure.sites.length;
721
+ structure = {
722
+ ...structure,
723
+ sites: [...structure.sites, ...new_sites],
724
+ };
725
+ // Select the newly duplicated atoms
726
+ selected_sites = new_sites.map((_, idx) => base_idx + idx);
727
+ measured_sites = [...selected_sites];
728
+ show_toast(`Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`);
729
+ return;
730
+ }
731
+ // add_atom_mode Escape is already handled above (before is_input_focused guard)
732
+ if (event.key === `Escape`) {
733
+ if (change_element_mode) {
734
+ change_element_mode = false;
735
+ return;
736
+ }
737
+ if (selected_sites.length > 0) {
738
+ clear_selection();
739
+ return;
740
+ }
741
+ }
742
+ }
743
+ // Interface shortcuts (require Ctrl/Cmd modifier to avoid accidental triggers)
744
+ const has_modifier = event.ctrlKey || event.metaKey;
745
+ if (event.key === `f` && has_modifier && fullscreen_toggle) {
746
+ event.preventDefault();
486
747
  toggle_fullscreen(wrapper);
487
- else if (event.key === `i` && enable_info_pane)
748
+ }
749
+ else if (event.key === `i` && has_modifier && enable_info_pane) {
750
+ event.preventDefault();
488
751
  info_pane_open = !info_pane_open;
752
+ }
489
753
  else if (event.key === `Escape`) {
490
- // Prioritize closing panes over exiting fullscreen
754
+ // Prioritize closing panes, then exit edit modes, then exit fullscreen
491
755
  if (info_pane_open)
492
756
  info_pane_open = false;
493
757
  else if (controls_open)
494
758
  controls_open = false;
495
759
  else if (export_pane_open)
496
760
  export_pane_open = false;
761
+ else if (measure_mode === `edit-bonds` || measure_mode === `edit-atoms`) {
762
+ measure_mode = `distance`;
763
+ }
497
764
  }
498
765
  }
766
+ // === Edit-atoms mode helpers ===
767
+ // Map scene indices (into displayed_structure) back to raw structure indices.
768
+ // Handles supercell atoms via orig_unit_cell_idx property.
769
+ // skip_image_atoms: when true, image atoms (PBC ghosts) are excluded from the result.
770
+ function scene_to_structure_indices(scene_indices, skip_image_atoms = false) {
771
+ const result = new SvelteSet();
772
+ for (const scene_idx of scene_indices) {
773
+ const displayed_site = displayed_structure?.sites?.[scene_idx];
774
+ if (!displayed_site)
775
+ continue;
776
+ if (skip_image_atoms && displayed_site.properties?.orig_site_idx != null) {
777
+ continue;
778
+ }
779
+ if (has_supercell && displayed_site.properties?.orig_unit_cell_idx != null) {
780
+ result.add(displayed_site.properties.orig_unit_cell_idx);
781
+ }
782
+ else if (displayed_site.properties?.orig_site_idx != null) {
783
+ // Image atom (PBC ghost) — map back to its original site index
784
+ result.add(displayed_site.properties.orig_site_idx);
785
+ }
786
+ else {
787
+ result.add(scene_idx);
788
+ }
789
+ }
790
+ return result;
791
+ }
792
+ // Try to create a Cartesian→fractional converter for the current structure's lattice
793
+ function get_cart_to_frac() {
794
+ if (!structure || !(`lattice` in structure))
795
+ return undefined;
796
+ try {
797
+ return create_cart_to_frac(structure.lattice.matrix);
798
+ }
799
+ catch {
800
+ console.warn(`Failed to compute lattice inverse for fractional coordinates`);
801
+ return undefined;
802
+ }
803
+ }
804
+ // Handle atom moves from TransformControls. Applies Cartesian delta and wraps
805
+ // fractional coords inline so normalize_fractional_coords hits its fast path.
806
+ function handle_sites_moved(scene_indices, delta) {
807
+ if (!structure?.sites)
808
+ return;
809
+ is_internal_edit = true;
810
+ const orig_indices = scene_to_structure_indices(scene_indices);
811
+ // For crystals, wrap to [0,1) inline so normalize_fractional_coords fast-paths.
812
+ // For molecules (no lattice), just apply the Cartesian delta directly.
813
+ const lattice = `lattice` in structure
814
+ ? structure.lattice.matrix
815
+ : null;
816
+ const cart_to_frac = lattice ? create_cart_to_frac(lattice) : null;
817
+ const frac_to_cart = lattice ? create_frac_to_cart(lattice) : null;
818
+ structure = {
819
+ ...structure,
820
+ sites: structure.sites.map((site, idx) => {
821
+ if (!orig_indices.has(idx))
822
+ return site;
823
+ const new_xyz = [
824
+ site.xyz[0] + delta[0],
825
+ site.xyz[1] + delta[1],
826
+ site.xyz[2] + delta[2],
827
+ ];
828
+ if (!cart_to_frac || !frac_to_cart) {
829
+ return { ...site, xyz: new_xyz, abc: new_xyz };
830
+ }
831
+ const wrapped_abc = wrap_to_unit_cell(cart_to_frac(new_xyz));
832
+ return { ...site, xyz: frac_to_cart(wrapped_abc), abc: wrapped_abc };
833
+ }),
834
+ };
835
+ }
836
+ // Change element symbol of selected atoms
837
+ function handle_change_element(new_element) {
838
+ if (!structure?.sites || selected_sites.length === 0)
839
+ return;
840
+ const elem = normalize_element(new_element);
841
+ if (!elem)
842
+ return;
843
+ is_internal_edit = true;
844
+ push_undo();
845
+ const orig_indices = scene_to_structure_indices(selected_sites);
846
+ structure = {
847
+ ...structure,
848
+ sites: structure.sites.map((site, idx) => {
849
+ if (!orig_indices.has(idx))
850
+ return site;
851
+ return {
852
+ ...site,
853
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
854
+ label: elem,
855
+ };
856
+ }),
857
+ };
858
+ change_element_mode = false;
859
+ change_element_value = ``;
860
+ show_toast(`Changed ${orig_indices.size} site${orig_indices.size > 1 ? `s` : ``} to ${elem}`);
861
+ }
862
+ // Handle add-atom from StructureScene click-to-place
863
+ function handle_add_atom(xyz, element) {
864
+ if (!structure)
865
+ return;
866
+ const elem = normalize_element(element);
867
+ if (!elem) {
868
+ return console.warn(`Invalid element symbol "${element}", ignoring add-atom`);
869
+ }
870
+ is_internal_edit = true;
871
+ push_undo();
872
+ structure = {
873
+ ...structure,
874
+ sites: [...structure.sites, {
875
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
876
+ xyz,
877
+ abc: get_cart_to_frac()?.(xyz) ?? xyz,
878
+ label: elem,
879
+ properties: {},
880
+ }],
881
+ };
882
+ show_toast(`Added ${elem} at (${xyz.map((c) => c.toFixed(2)).join(`, `)})`);
883
+ }
499
884
  // Only set background override when background_color is explicitly provided
500
885
  $effect(() => {
501
886
  if (typeof window !== `undefined` && wrapper && background_color) {
@@ -530,10 +915,13 @@ $effect(() => {
530
915
  }}
531
916
  />
532
917
 
918
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
533
919
  <div
534
920
  class:dragover
535
921
  class:active={info_pane_open || controls_open || export_pane_open}
536
- role="region"
922
+ role="application"
923
+ tabindex="0"
924
+ style:--canvas-cursor={canvas_cursor}
537
925
  aria-label="Structure viewer"
538
926
  bind:this={wrapper}
539
927
  bind:clientWidth={width}
@@ -567,7 +955,7 @@ $effect(() => {
567
955
  event.preventDefault()
568
956
  dragover = false
569
957
  }}
570
- {onkeydown}
958
+ onkeydown={handle_keydown}
571
959
  {...rest}
572
960
  class="structure {rest.class ?? ``}"
573
961
  >
@@ -579,10 +967,7 @@ $effect(() => {
579
967
  style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)"
580
968
  />
581
969
  {:else if error_msg}
582
- <div class="error-state">
583
- <p class="error">{error_msg}</p>
584
- <button onclick={() => (error_msg = undefined)}>Dismiss</button>
585
- </div>
970
+ <StatusMessage bind:message={error_msg} type="error" dismissible />
586
971
  {:else if (structure?.sites?.length ?? 0) > 0}
587
972
  <section
588
973
  class="control-buttons {controls_config.class}"
@@ -620,7 +1005,7 @@ $effect(() => {
620
1005
  >
621
1006
  <button
622
1007
  onclick={() => (measure_menu_open = !measure_menu_open)}
623
- title="Measurement mode"
1008
+ title="Measure / Edit"
624
1009
  class="view-mode-button"
625
1010
  class:active={measure_menu_open}
626
1011
  aria-expanded={measure_menu_open}
@@ -636,6 +1021,7 @@ $effect(() => {
636
1021
  distance: `Ruler`,
637
1022
  angle: `Angle`,
638
1023
  'edit-bonds': `Link`,
1024
+ 'edit-atoms': `Edit`,
639
1025
  } as const)[measure_mode]}
640
1026
  />
641
1027
  {/if}
@@ -650,7 +1036,7 @@ $effect(() => {
650
1036
  type="button"
651
1037
  aria-label="Reset selection and bond edits"
652
1038
  onclick={() => {
653
- ;[measured_sites, selected_sites] = [[], []]
1039
+ clear_selection()
654
1040
  added_bonds = []
655
1041
  removed_bonds = []
656
1042
  }}
@@ -663,6 +1049,12 @@ $effect(() => {
663
1049
  {#each [
664
1050
  { mode: `distance`, icon: `Ruler`, label: `Distance`, scale: 1.1 },
665
1051
  { mode: `angle`, icon: `Angle`, label: `Angle`, scale: 1.3 },
1052
+ {
1053
+ mode: `edit-atoms`,
1054
+ icon: `Edit`,
1055
+ label: `Edit Atoms`,
1056
+ scale: 1.0,
1057
+ },
666
1058
  {
667
1059
  mode: `edit-bonds`,
668
1060
  icon: `Link`,
@@ -685,6 +1077,84 @@ $effect(() => {
685
1077
  </div>
686
1078
  {/if}
687
1079
  </div>
1080
+
1081
+ <!-- Undo/redo buttons (only in edit-atoms mode) -->
1082
+ {#if measure_mode === `edit-atoms`}
1083
+ <div class="undo-redo-container">
1084
+ <button
1085
+ type="button"
1086
+ aria-label="Undo (Ctrl+Z)"
1087
+ disabled={undo_stack.length === 0}
1088
+ onclick={undo}
1089
+ title="Undo (Ctrl+Z)"
1090
+ class="undo-redo-btn"
1091
+ >
1092
+ <Icon icon="Undo" />
1093
+ {#if undo_stack.length > 0}
1094
+ <span class="history-count">{undo_stack.length}</span>
1095
+ {/if}
1096
+ </button>
1097
+ <button
1098
+ type="button"
1099
+ aria-label="Redo (Ctrl+Y)"
1100
+ disabled={redo_stack.length === 0}
1101
+ onclick={redo}
1102
+ title="Redo (Ctrl+Y)"
1103
+ class="undo-redo-btn"
1104
+ >
1105
+ <Icon icon="Redo" />
1106
+ {#if redo_stack.length > 0}
1107
+ <span class="history-count">{redo_stack.length}</span>
1108
+ {/if}
1109
+ </button>
1110
+ </div>
1111
+ {/if}
1112
+
1113
+ <!-- Add-atom element input (shown when add_atom_mode is active) -->
1114
+ {#if measure_mode === `edit-atoms` && add_atom_mode}
1115
+ <div class="add-atom-input">
1116
+ <label>
1117
+ <span>Element:</span>
1118
+ <input
1119
+ type="text"
1120
+ bind:value={add_element}
1121
+ maxlength="2"
1122
+ placeholder="C"
1123
+ style="width: 3em; text-align: center"
1124
+ />
1125
+ </label>
1126
+ <span style="font-size: 0.75em; opacity: 0.7">Click to place</span>
1127
+ </div>
1128
+ {/if}
1129
+
1130
+ <!-- Change-element input (shown when 'e' pressed with selection) -->
1131
+ {#if measure_mode === `edit-atoms` && change_element_mode &&
1132
+ selected_sites.length > 0}
1133
+ <div class="add-atom-input">
1134
+ <label>
1135
+ <span>New element:</span>
1136
+ <input
1137
+ type="text"
1138
+ bind:value={change_element_value}
1139
+ maxlength="2"
1140
+ placeholder="Fe"
1141
+ style="width: 3em; text-align: center"
1142
+ onkeydown={(event: KeyboardEvent) => {
1143
+ if (event.key === `Enter`) {
1144
+ handle_change_element(change_element_value)
1145
+ } else if (event.key === `Escape`) {
1146
+ change_element_mode = false
1147
+ }
1148
+ event.stopPropagation()
1149
+ }}
1150
+ {@attach (node: HTMLInputElement) => {
1151
+ node.focus()
1152
+ }}
1153
+ />
1154
+ </label>
1155
+ <span style="font-size: 0.75em; opacity: 0.7">Enter to apply</span>
1156
+ </div>
1157
+ {/if}
688
1158
  {/if}
689
1159
 
690
1160
  {#if enable_info_pane && normalized_structure &&
@@ -722,6 +1192,9 @@ $effect(() => {
722
1192
  bind:color_scheme
723
1193
  bind:atom_color_config
724
1194
  bind:cell_type
1195
+ bind:volumetric_data
1196
+ bind:isosurface_settings
1197
+ bind:active_volume_idx
725
1198
  {structure}
726
1199
  {supercell_loading}
727
1200
  {sym_data}
@@ -759,13 +1232,15 @@ $effect(() => {
759
1232
  <!-- prevent from rendering in vitest runner since WebGLRenderingContext not available -->
760
1233
  {#if typeof WebGLRenderingContext !== `undefined`}
761
1234
  <!-- prevent HTML labels from rendering outside of the canvas -->
762
- <div style="overflow: hidden; height: 100%">
1235
+ <div style="overflow: hidden; height: 100%; flex: 1">
763
1236
  <Canvas>
764
1237
  <StructureScene
765
1238
  structure={displayed_structure}
766
1239
  base_structure={cell_transformed_structure}
767
1240
  {...scene_props}
768
1241
  {lattice_props}
1242
+ volumetric_data={volumetric_data?.[active_volume_idx]}
1243
+ {isosurface_settings}
769
1244
  bind:camera_is_moving
770
1245
  bind:selected_sites
771
1246
  bind:measured_sites
@@ -785,6 +1260,13 @@ $effect(() => {
785
1260
  {height}
786
1261
  {atom_color_config}
787
1262
  {sym_data}
1263
+ on_sites_moved={handle_sites_moved}
1264
+ on_operation_start={push_undo}
1265
+ on_add_atom={handle_add_atom}
1266
+ bind:add_atom_mode
1267
+ bind:add_element
1268
+ bind:cursor={canvas_cursor}
1269
+ bind:dragging_atoms
788
1270
  />
789
1271
  </Canvas>
790
1272
  </div>
@@ -794,7 +1276,11 @@ $effect(() => {
794
1276
  {@render bottom_left?.({ structure: displayed_structure })}
795
1277
  </div>
796
1278
 
797
- {#if (measure_mode as string) === `edit-bonds` &&
1279
+ {#if toast_msg}
1280
+ <div class="edit-toast">{toast_msg}</div>
1281
+ {/if}
1282
+
1283
+ {#if measure_mode === `edit-bonds` &&
798
1284
  (added_bonds.length > 0 || removed_bonds.length > 0)}
799
1285
  <div class="bond-edit-status">
800
1286
  {#if added_bonds.length > 0}
@@ -849,6 +1335,11 @@ $effect(() => {
849
1335
  background: var(--struct-dragover-bg, var(--dragover-bg));
850
1336
  border: var(--struct-dragover-border, var(--dragover-border));
851
1337
  }
1338
+ /* Ensure canvas is transparent so the themed --struct-bg shows through */
1339
+ .structure :global(canvas) {
1340
+ background: transparent;
1341
+ cursor: var(--canvas-cursor, default);
1342
+ }
852
1343
  /* Avoid accidental text selection while interacting with the viewer */
853
1344
  .structure :global(canvas),
854
1345
  .structure section.control-buttons,
@@ -891,7 +1382,7 @@ $effect(() => {
891
1382
  display: flex;
892
1383
  padding: 4px;
893
1384
  border-radius: var(--border-radius, 3pt);
894
- font-size: clamp(0.85em, 2cqmin, 2.5em);
1385
+ font-size: clamp(0.85em, 2cqmin, 1.3em);
895
1386
  }
896
1387
  section.control-buttons :global(button:hover) {
897
1388
  background-color: color-mix(in srgb, currentColor 8%, transparent);
@@ -942,7 +1433,7 @@ $effect(() => {
942
1433
  .measure-mode-dropdown > button {
943
1434
  background: transparent;
944
1435
  padding: 0 0 0 4px;
945
- font-size: clamp(0.85em, 2cqmin, 2.5em);
1436
+ font-size: clamp(0.85em, 2cqmin, 1.3em);
946
1437
  }
947
1438
  .selection-limit-text {
948
1439
  font-weight: bold;
@@ -957,32 +1448,6 @@ $effect(() => {
957
1448
  display: grid;
958
1449
  place-content: center;
959
1450
  }
960
- .error-state {
961
- display: flex;
962
- flex-direction: column;
963
- align-items: center;
964
- justify-content: center;
965
- height: var(--struct-height, 500px);
966
- padding: 2rem;
967
- text-align: center;
968
- box-sizing: border-box;
969
- }
970
- .error-state p {
971
- color: var(--error-color, #ff6b6b);
972
- margin: 0 0 1rem;
973
- }
974
- .error-state button {
975
- padding: 0.5rem 1rem;
976
- background: var(--error-color, #ff6b6b);
977
- color: white;
978
- border: none;
979
- border-radius: var(--border-radius, 3pt);
980
- cursor: pointer;
981
- font-size: 0.9rem;
982
- }
983
- .error-state button:hover {
984
- background: var(--error-color-hover, #ff5252);
985
- }
986
1451
  .symmetry-error {
987
1452
  position: absolute;
988
1453
  bottom: 1rem;
@@ -1013,13 +1478,37 @@ $effect(() => {
1013
1478
  .symmetry-error button:hover {
1014
1479
  opacity: 1;
1015
1480
  }
1481
+ .edit-toast {
1482
+ position: absolute;
1483
+ bottom: 3rem;
1484
+ left: 50%;
1485
+ transform: translateX(-50%);
1486
+ background: color-mix(in srgb, var(--page-bg, Canvas) 85%, currentColor);
1487
+ color: var(--text-color, currentColor);
1488
+ padding: 0.4rem 0.8rem;
1489
+ border-radius: var(--border-radius, 3pt);
1490
+ font-size: 0.8rem;
1491
+ z-index: 100;
1492
+ pointer-events: none;
1493
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
1494
+ animation: toast-fade 2s ease-in-out;
1495
+ opacity: 0;
1496
+ }
1497
+ @keyframes toast-fade {
1498
+ 0%, 70% {
1499
+ opacity: 1;
1500
+ }
1501
+ 100% {
1502
+ opacity: 0;
1503
+ }
1504
+ }
1016
1505
  .bond-edit-status {
1017
1506
  position: absolute;
1018
1507
  bottom: 1rem;
1019
1508
  left: 50%;
1020
1509
  transform: translateX(-50%);
1021
- background: rgba(0, 0, 0, 0.8);
1022
- color: white;
1510
+ background: color-mix(in srgb, var(--page-bg, Canvas) 85%, currentColor);
1511
+ color: var(--text-color, currentColor);
1023
1512
  padding: 0.5rem 1rem;
1024
1513
  border-radius: var(--border-radius, 3pt);
1025
1514
  font-size: 0.85rem;
@@ -1027,12 +1516,15 @@ $effect(() => {
1027
1516
  gap: 0.75rem;
1028
1517
  z-index: 100;
1029
1518
  pointer-events: none;
1519
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
1030
1520
  }
1031
1521
  .bond-edit-status .added {
1032
1522
  color: #4caf50;
1523
+ font-weight: bold;
1033
1524
  }
1034
1525
  .bond-edit-status .removed {
1035
1526
  color: #f44336;
1527
+ font-weight: bold;
1036
1528
  }
1037
1529
  /* CellSelect: position at left of legend, show on hover */
1038
1530
  .structure :global(.cell-select) {
@@ -1045,4 +1537,55 @@ $effect(() => {
1045
1537
  opacity: 1;
1046
1538
  pointer-events: auto;
1047
1539
  }
1540
+ .undo-redo-container {
1541
+ display: flex;
1542
+ }
1543
+ .undo-redo-btn {
1544
+ position: relative;
1545
+ display: flex;
1546
+ align-items: center;
1547
+ justify-content: center;
1548
+ }
1549
+ .history-count {
1550
+ position: absolute;
1551
+ bottom: -2px;
1552
+ right: -2px;
1553
+ background: var(--accent-color, #007acc);
1554
+ color: white;
1555
+ border-radius: 50%;
1556
+ width: 12px;
1557
+ height: 12px;
1558
+ font-size: 8px;
1559
+ font-weight: bold;
1560
+ display: flex;
1561
+ align-items: center;
1562
+ justify-content: center;
1563
+ line-height: 1;
1564
+ pointer-events: none;
1565
+ z-index: 1;
1566
+ }
1567
+ .add-atom-input {
1568
+ display: flex;
1569
+ align-items: center;
1570
+ gap: 0.5em;
1571
+ background: color-mix(in srgb, var(--page-bg, Canvas) 85%, currentColor);
1572
+ color: var(--text-color, currentColor);
1573
+ padding: 0.3em 0.6em;
1574
+ border-radius: var(--border-radius, 3pt);
1575
+ font-size: 0.8rem;
1576
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
1577
+ label {
1578
+ display: flex;
1579
+ align-items: center;
1580
+ gap: 0.3em;
1581
+ }
1582
+ input {
1583
+ background: color-mix(in srgb, currentColor 10%, transparent);
1584
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
1585
+ border-radius: 3px;
1586
+ color: inherit;
1587
+ font-size: 0.85rem;
1588
+ padding: 0.1em 0.3em;
1589
+ }
1590
+ }
1048
1591
  </style>