matterviz 0.3.5 → 0.3.7

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 (229) hide show
  1. package/dist/MillerIndexInput.svelte +5 -5
  2. package/dist/api/optimade.js +3 -3
  3. package/dist/brillouin/BrillouinZone.svelte +5 -2
  4. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  5. package/dist/brillouin/BrillouinZoneExportPane.svelte +1 -3
  6. package/dist/brillouin/BrillouinZoneInfoPane.svelte +1 -1
  7. package/dist/brillouin/BrillouinZoneScene.svelte +5 -5
  8. package/dist/brillouin/compute.js +21 -21
  9. package/dist/brillouin/index.d.ts +1 -1
  10. package/dist/brillouin/index.js +0 -1
  11. package/dist/brillouin/types.d.ts +8 -13
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte +3 -3
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +3 -4
  14. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +33 -34
  15. package/dist/chempot-diagram/compute.js +1 -7
  16. package/dist/chempot-diagram/temperature.d.ts +1 -1
  17. package/dist/chempot-diagram/temperature.js +1 -3
  18. package/dist/chempot-diagram/types.d.ts +4 -9
  19. package/dist/colors/index.js +5 -5
  20. package/dist/composition/Composition.svelte +2 -1
  21. package/dist/composition/Formula.svelte +7 -4
  22. package/dist/composition/FormulaFilter.svelte +1 -3
  23. package/dist/composition/format.js +4 -4
  24. package/dist/composition/parse.d.ts +2 -1
  25. package/dist/composition/parse.js +61 -46
  26. package/dist/convex-hull/ConvexHull2D.svelte +62 -51
  27. package/dist/convex-hull/ConvexHull3D.svelte +101 -90
  28. package/dist/convex-hull/ConvexHull4D.svelte +70 -58
  29. package/dist/convex-hull/ConvexHullControls.svelte +24 -35
  30. package/dist/convex-hull/ConvexHullInfoPane.svelte +8 -5
  31. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +2 -0
  32. package/dist/convex-hull/ConvexHullStats.svelte +9 -2
  33. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +2 -0
  34. package/dist/convex-hull/GasPressureControls.svelte +7 -7
  35. package/dist/convex-hull/StructurePopup.svelte +65 -30
  36. package/dist/convex-hull/StructurePopup.svelte.d.ts +6 -6
  37. package/dist/convex-hull/TemperatureSlider.svelte +8 -5
  38. package/dist/convex-hull/barycentric-coords.d.ts +2 -2
  39. package/dist/convex-hull/barycentric-coords.js +2 -2
  40. package/dist/convex-hull/gas-thermodynamics.js +2 -4
  41. package/dist/convex-hull/helpers.d.ts +13 -2
  42. package/dist/convex-hull/helpers.js +37 -16
  43. package/dist/convex-hull/index.d.ts +1 -0
  44. package/dist/convex-hull/index.js +1 -0
  45. package/dist/convex-hull/thermodynamics.d.ts +2 -1
  46. package/dist/convex-hull/thermodynamics.js +7 -7
  47. package/dist/convex-hull/types.d.ts +15 -15
  48. package/dist/effects.svelte.d.ts +12 -0
  49. package/dist/effects.svelte.js +37 -0
  50. package/dist/element/BohrAtom.svelte +4 -4
  51. package/dist/element/data.json.gz.d.ts +3 -1
  52. package/dist/element/index.d.ts +1 -1
  53. package/dist/element/index.js +0 -1
  54. package/dist/fermi-surface/FermiSurface.svelte +4 -4
  55. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  56. package/dist/fermi-surface/FermiSurfaceControls.svelte +15 -19
  57. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  58. package/dist/fermi-surface/FermiSurfaceScene.svelte +8 -6
  59. package/dist/fermi-surface/compute.js +2 -2
  60. package/dist/fermi-surface/export.js +13 -26
  61. package/dist/fermi-surface/parse.js +8 -12
  62. package/dist/fermi-surface/types.d.ts +2 -5
  63. package/dist/heatmap-matrix/HeatmapMatrix.svelte +21 -3
  64. package/dist/heatmap-matrix/index.js +6 -6
  65. package/dist/io/decompress.d.ts +2 -1
  66. package/dist/io/decompress.js +1 -1
  67. package/dist/io/export.js +1 -1
  68. package/dist/io/index.d.ts +1 -1
  69. package/dist/io/index.js +0 -1
  70. package/dist/io/url-drop.js +7 -1
  71. package/dist/isosurface/IsosurfaceControls.svelte +11 -25
  72. package/dist/isosurface/slice.js +1 -1
  73. package/dist/isosurface/types.js +12 -12
  74. package/dist/labels.d.ts +1 -1
  75. package/dist/labels.js +14 -11
  76. package/dist/layout/InfoTag.svelte +6 -4
  77. package/dist/layout/PropertyFilter.svelte +4 -2
  78. package/dist/layout/json-tree/JsonTree.svelte +22 -14
  79. package/dist/layout/json-tree/JsonValue.svelte +2 -2
  80. package/dist/layout/json-tree/types.d.ts +3 -2
  81. package/dist/layout/json-tree/types.js +0 -1
  82. package/dist/layout/json-tree/utils.d.ts +4 -4
  83. package/dist/layout/json-tree/utils.js +12 -20
  84. package/dist/marching-cubes.js +13 -15
  85. package/dist/math.d.ts +11 -1
  86. package/dist/math.js +15 -6
  87. package/dist/overlays/DragControlTab.svelte +98 -0
  88. package/dist/overlays/DragControlTab.svelte.d.ts +8 -0
  89. package/dist/overlays/DraggablePane.svelte +7 -84
  90. package/dist/overlays/index.d.ts +1 -0
  91. package/dist/overlays/index.js +1 -0
  92. package/dist/periodic-table/PeriodicTable.svelte +11 -11
  93. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +4 -2
  94. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  95. package/dist/phase-diagram/PhaseDiagramControls.svelte +4 -9
  96. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  97. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +2 -10
  98. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +2 -3
  99. package/dist/phase-diagram/TdbInfoPanel.svelte +3 -3
  100. package/dist/phase-diagram/build-diagram.js +11 -18
  101. package/dist/phase-diagram/diagram-input.d.ts +5 -9
  102. package/dist/phase-diagram/index.d.ts +2 -2
  103. package/dist/phase-diagram/index.js +0 -2
  104. package/dist/phase-diagram/parse.d.ts +2 -2
  105. package/dist/phase-diagram/parse.js +6 -10
  106. package/dist/phase-diagram/svg-to-diagram.js +15 -15
  107. package/dist/phase-diagram/types.d.ts +5 -11
  108. package/dist/phase-diagram/utils.d.ts +2 -2
  109. package/dist/phase-diagram/utils.js +9 -11
  110. package/dist/plot/BarPlot.svelte +162 -314
  111. package/dist/plot/BarPlot.svelte.d.ts +5 -4
  112. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  113. package/dist/plot/BinnedScatterPlot.svelte +1114 -0
  114. package/dist/plot/BinnedScatterPlot.svelte.d.ts +66 -0
  115. package/dist/plot/ColorBar.svelte +19 -17
  116. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  117. package/dist/plot/FillArea.svelte +2 -4
  118. package/dist/plot/FillArea.svelte.d.ts +1 -1
  119. package/dist/plot/Histogram.svelte +167 -281
  120. package/dist/plot/Histogram.svelte.d.ts +1 -1
  121. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  122. package/dist/plot/InteractiveAxisLabel.svelte +5 -3
  123. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  124. package/dist/plot/PlotAxis.svelte +169 -0
  125. package/dist/plot/PlotAxis.svelte.d.ts +24 -0
  126. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  127. package/dist/plot/ReferenceLine3D.svelte +53 -51
  128. package/dist/plot/ReferencePlane.svelte +39 -42
  129. package/dist/plot/ScatterPlot.svelte +300 -367
  130. package/dist/plot/ScatterPlot.svelte.d.ts +8 -5
  131. package/dist/plot/ScatterPlot3D.svelte +33 -6
  132. package/dist/plot/ScatterPlot3D.svelte.d.ts +3 -2
  133. package/dist/plot/ScatterPlot3DControls.svelte +9 -9
  134. package/dist/plot/ScatterPlotControls.svelte +3 -4
  135. package/dist/plot/ScatterPoint.svelte +18 -27
  136. package/dist/plot/ScatterPoint.svelte.d.ts +4 -3
  137. package/dist/plot/Surface3D.svelte +4 -7
  138. package/dist/plot/ZeroLines.svelte +2 -1
  139. package/dist/plot/ZeroLines.svelte.d.ts +2 -1
  140. package/dist/plot/ZoomRect.svelte +2 -2
  141. package/dist/plot/ZoomRect.svelte.d.ts +3 -3
  142. package/dist/plot/adaptive-density.d.ts +69 -0
  143. package/dist/plot/adaptive-density.js +191 -0
  144. package/dist/plot/auto-place.d.ts +43 -0
  145. package/dist/plot/auto-place.js +122 -0
  146. package/dist/plot/axis-utils.js +3 -5
  147. package/dist/plot/binned-scatter-types.d.ts +59 -0
  148. package/dist/plot/binned-scatter-types.js +1 -0
  149. package/dist/plot/data-cleaning.js +1 -1
  150. package/dist/plot/data-transform.js +1 -1
  151. package/dist/plot/fill-utils.d.ts +4 -9
  152. package/dist/plot/fill-utils.js +29 -44
  153. package/dist/plot/index.d.ts +4 -0
  154. package/dist/plot/index.js +2 -0
  155. package/dist/plot/interactions.d.ts +4 -4
  156. package/dist/plot/interactions.js +4 -3
  157. package/dist/plot/layout.d.ts +20 -2
  158. package/dist/plot/layout.js +59 -16
  159. package/dist/plot/reference-line.d.ts +1 -1
  160. package/dist/plot/reference-line.js +9 -11
  161. package/dist/plot/scales.d.ts +1 -1
  162. package/dist/plot/scales.js +20 -23
  163. package/dist/plot/types.d.ts +30 -58
  164. package/dist/plot/types.js +2 -6
  165. package/dist/plot/utils/label-placement.d.ts +24 -3
  166. package/dist/plot/utils/label-placement.js +82 -12
  167. package/dist/plot/utils/series-visibility.d.ts +8 -2
  168. package/dist/plot/utils/series-visibility.js +23 -5
  169. package/dist/rdf/RdfPlot.svelte +5 -5
  170. package/dist/rdf/calc-rdf.js +3 -3
  171. package/dist/sanitize.d.ts +2 -0
  172. package/dist/sanitize.js +2 -0
  173. package/dist/spectral/Bands.svelte +1 -1
  174. package/dist/spectral/BandsAndDos.svelte +22 -16
  175. package/dist/spectral/BrillouinBandsDos.svelte +20 -16
  176. package/dist/spectral/Dos.svelte +1 -1
  177. package/dist/spectral/helpers.d.ts +4 -2
  178. package/dist/spectral/helpers.js +44 -35
  179. package/dist/spectral/index.d.ts +1 -1
  180. package/dist/spectral/index.js +0 -1
  181. package/dist/structure/AtomLegend.svelte +23 -6
  182. package/dist/structure/AtomLegend.svelte.d.ts +1 -0
  183. package/dist/structure/CanvasTooltip.svelte +9 -9
  184. package/dist/structure/CanvasTooltip.svelte.d.ts +1 -1
  185. package/dist/structure/CellSelect.svelte +14 -16
  186. package/dist/structure/Structure.svelte +317 -68
  187. package/dist/structure/Structure.svelte.d.ts +4 -2
  188. package/dist/structure/StructureControls.svelte +20 -45
  189. package/dist/structure/StructureExportPane.svelte +2 -1
  190. package/dist/structure/StructureInfoPane.svelte +10 -8
  191. package/dist/structure/StructureScene.svelte +527 -177
  192. package/dist/structure/StructureScene.svelte.d.ts +5 -2
  193. package/dist/structure/atom-properties.js +4 -4
  194. package/dist/structure/bond-order-perception.js +115 -98
  195. package/dist/structure/bonding.d.ts +27 -1
  196. package/dist/structure/bonding.js +187 -16
  197. package/dist/structure/export.js +1 -1
  198. package/dist/structure/index.d.ts +3 -2
  199. package/dist/structure/index.js +0 -2
  200. package/dist/structure/parse.js +88 -59
  201. package/dist/symmetry/WyckoffTable.svelte +7 -0
  202. package/dist/symmetry/index.js +13 -14
  203. package/dist/table/HeatmapTable.svelte +45 -66
  204. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  205. package/dist/table/ToggleMenu.svelte +19 -10
  206. package/dist/theme/themes.mjs +12 -0
  207. package/dist/tooltip/index.d.ts +1 -1
  208. package/dist/tooltip/index.js +0 -1
  209. package/dist/trajectory/Trajectory.svelte +43 -15
  210. package/dist/trajectory/TrajectoryInfoPane.svelte +2 -2
  211. package/dist/trajectory/extract.js +1 -1
  212. package/dist/trajectory/frame-reader.js +4 -4
  213. package/dist/trajectory/helpers.d.ts +5 -4
  214. package/dist/trajectory/helpers.js +9 -17
  215. package/dist/trajectory/index.d.ts +2 -2
  216. package/dist/trajectory/index.js +2 -2
  217. package/dist/trajectory/parse/ase.js +4 -4
  218. package/dist/trajectory/parse/hdf5.js +1 -1
  219. package/dist/trajectory/parse/index.js +2 -3
  220. package/dist/trajectory/parse/lammps.js +1 -1
  221. package/dist/trajectory/parse/vasp.js +1 -1
  222. package/dist/trajectory/plotting.d.ts +1 -1
  223. package/dist/trajectory/plotting.js +38 -38
  224. package/dist/trajectory/types.d.ts +1 -1
  225. package/dist/utils.d.ts +1 -0
  226. package/dist/utils.js +9 -0
  227. package/dist/xrd/calc-xrd.js +3 -4
  228. package/dist/xrd/parse.js +1 -1
  229. package/package.json +42 -22
@@ -16,10 +16,11 @@
16
16
  VectorLayerConfig,
17
17
  } from '../settings'
18
18
  import { DEFAULTS } from '../settings'
19
- import { sanitize_html } from '../sanitize'
19
+ import { create_pulse_animation } from '../effects.svelte'
20
20
  import { colors } from '../state.svelte'
21
21
  import type {
22
22
  AnyStructure,
23
+ BondEditMode,
23
24
  BondOrder,
24
25
  BondPair,
25
26
  MeasureMode,
@@ -52,15 +53,19 @@
52
53
  import * as extras from '@threlte/extras'
53
54
  import { type ComponentProps, type Snippet, untrack } from 'svelte'
54
55
  import { SvelteMap, SvelteSet } from 'svelte/reactivity'
55
- import { type Camera, Color, type Mesh, type Scene } from 'three'
56
+ import { type Camera, Color, type Mesh, type Object3D, type Scene, Vector3 } from 'three'
56
57
  import Bond from './Bond.svelte'
57
- import type { BondingStrategy } from './bonding'
58
+ import type { BondEditResult, BondingStrategy, BondKeyTarget } from './bonding'
58
59
  import {
60
+ add_or_restore_bond,
59
61
  BONDING_STRATEGIES,
62
+ BOND_ORDER_OPTIONS,
63
+ canonicalize_bond_target,
64
+ delete_bond as apply_delete_bond,
60
65
  get_bond_key,
61
66
  get_bond_render_matrices,
62
67
  get_explicit_bond_metadata,
63
- normalize_structure_bond,
68
+ set_bond_order as apply_set_bond_order,
64
69
  structure_bond_to_bond_pair,
65
70
  } from './bonding'
66
71
  import {
@@ -82,29 +87,40 @@
82
87
  atoms: (typeof atom_data)[number][]
83
88
  }
84
89
 
90
+ function instanced_atom_group_key(
91
+ { element, radius, color, is_image_atom, atoms }: InstancedAtomGroup,
92
+ measure_mode: MeasureMode,
93
+ ): string {
94
+ const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom
95
+ return `${element}-${format_num(radius, `.3~`)}-${color}-${
96
+ is_image_atom ? `img` : `base`
97
+ }-${edit_mode_image}-${atoms.length}`
98
+ }
99
+
100
+ type EditableAtomHitTarget = {
101
+ site_idx: number
102
+ position: Vec3
103
+ radius: number
104
+ }
105
+
85
106
  type BondContextMenu = {
86
107
  site_idx_1: number
87
108
  site_idx_2: number
88
109
  cell_shift?: Vec3
89
110
  position: Vec3
90
111
  }
91
- type BondKeyTarget = Pick<StructureBond, `site_idx_1` | `site_idx_2` | `cell_shift`>
92
112
 
93
- let pulse_time = $state(0)
94
- let pulse_opacity = $derived(0.15 + 0.25 * Math.sin(pulse_time * 5))
95
- $effect(() => {
96
- if (!selected_sites?.length && !active_sites?.length) return
97
- if (typeof globalThis === `undefined`) return
98
- const reduce = globalThis.matchMedia?.(`(prefers-reduced-motion: reduce)`).matches
99
- if (reduce) return
100
- let frame_id = 0
101
- const animate = () => {
102
- pulse_time += 0.015
103
- frame_id = requestAnimationFrame(animate)
104
- }
105
- frame_id = requestAnimationFrame(animate)
106
- return () => cancelAnimationFrame(frame_id)
107
- })
113
+ type BondPointerEvent = PointerEvent & {
114
+ nativeEvent?: PointerEvent
115
+ object?: Object3D
116
+ point?: Vector3
117
+ }
118
+
119
+ type BondContextMenuEvent = MouseEvent & {
120
+ nativeEvent?: MouseEvent
121
+ object?: Object3D
122
+ point?: Vector3
123
+ }
108
124
 
109
125
  let {
110
126
  structure = undefined,
@@ -169,6 +185,8 @@
169
185
  removed_bonds = $bindable([]),
170
186
  bond_order_overrides = $bindable([]),
171
187
  bond_edits_enabled = true,
188
+ bond_edit_mode = $bindable<BondEditMode>(`add`),
189
+ bond_edit_order = 1,
172
190
  selection_highlight_color = `#6cf0ff`,
173
191
  // Active highlight group with different color
174
192
  active_sites = $bindable([]),
@@ -192,6 +210,7 @@
192
210
  // Edit-atoms mode callbacks
193
211
  on_sites_moved,
194
212
  on_operation_start,
213
+ on_bond_edit_start,
195
214
  on_add_atom,
196
215
  add_atom_mode = $bindable(false),
197
216
  add_element = $bindable(`C`),
@@ -265,6 +284,8 @@
265
284
  removed_bonds?: StructureBond[]
266
285
  bond_order_overrides?: StructureBond[]
267
286
  bond_edits_enabled?: boolean
287
+ bond_edit_mode?: BondEditMode
288
+ bond_edit_order?: BondOrder
268
289
  selection_highlight_color?: string
269
290
  // Support for active highlight group with different color
270
291
  active_sites?: number[]
@@ -285,6 +306,7 @@
285
306
  // Edit-atoms mode callbacks and state
286
307
  on_sites_moved?: (scene_indices: number[], delta: Vec3) => void
287
308
  on_operation_start?: () => void
309
+ on_bond_edit_start?: () => void
288
310
  on_add_atom?: (xyz: Vec3, element: ElementSymbol) => void
289
311
  add_atom_mode?: boolean // whether user is in click-to-place add-atom sub-mode
290
312
  add_element?: ElementSymbol // element to add when clicking in add-atom mode
@@ -294,6 +316,12 @@
294
316
  isosurface_settings?: IsosurfaceSettings // Isosurface rendering settings
295
317
  } = $props()
296
318
 
319
+ const pulse = create_pulse_animation(
320
+ () => selected_sites.length > 0 || active_sites.length > 0,
321
+ { step: 0.015, frequency: 5 },
322
+ )
323
+ let pulse_opacity = $derived(0.15 + 0.25 * pulse.unit)
324
+
297
325
  const threlte = useThrelte()
298
326
  $effect(() => {
299
327
  scene = threlte.scene
@@ -318,15 +346,54 @@
318
346
  })
319
347
 
320
348
  let bond_pairs: BondPair[] = $state([])
321
- let active_tooltip = $state<`atom` | `bond` | null>(null)
349
+ let atom_tooltip_active = $state(false)
322
350
  let hovered_bond_key = $state<string | null>(null)
351
+ const ATOM_HOVER_CLEAR_DELAY_MS = 200
352
+ let clear_atom_hover_timeout: ReturnType<typeof setTimeout> | null = null
353
+
354
+ function cancel_atom_hover_clear(): void {
355
+ if (clear_atom_hover_timeout == null) return
356
+ clearTimeout(clear_atom_hover_timeout)
357
+ clear_atom_hover_timeout = null
358
+ }
359
+
360
+ function set_atom_hover(site_idx: number): void {
361
+ cancel_atom_hover_clear()
362
+ if (hovered_idx !== site_idx) hovered_idx = site_idx
363
+ if (!atom_tooltip_active) atom_tooltip_active = true
364
+ }
365
+
366
+ function schedule_atom_hover_clear(site_idx: number): void {
367
+ cancel_atom_hover_clear()
368
+ clear_atom_hover_timeout = setTimeout(() => {
369
+ clear_atom_hover_timeout = null
370
+ if (hovered_idx !== site_idx) return
371
+ hovered_idx = null
372
+ atom_tooltip_active = false
373
+ }, ATOM_HOVER_CLEAR_DELAY_MS)
374
+ }
375
+
376
+ const atom_hover_props = (site_idx: number | null, disabled = false) => ({
377
+ onpointerenter: () => {
378
+ if (!disabled && site_idx != null) set_atom_hover(site_idx)
379
+ },
380
+ onpointermove: () => {
381
+ if (!disabled && site_idx != null) set_atom_hover(site_idx)
382
+ },
383
+ onpointerleave: () => {
384
+ if (!disabled && site_idx != null) schedule_atom_hover_clear(site_idx)
385
+ },
386
+ })
323
387
 
324
388
  // Cursor style for the canvas, derived from mode and hover state
325
389
  let canvas_cursor = $derived.by(() => {
326
390
  if (measure_mode === `edit-atoms` && add_atom_mode) return `crosshair`
391
+ if (measure_mode === `edit-bonds` && hovered_bond_key != null) {
392
+ return bond_edits_enabled ? `pointer` : `not-allowed`
393
+ }
327
394
  if (hovered_idx != null) {
328
395
  if (measure_mode === `edit-bonds`) {
329
- return bond_edits_enabled && is_editable_bond_site(hovered_idx)
396
+ return bond_edit_mode === `add` && can_select_bond_site(hovered_idx)
330
397
  ? `pointer`
331
398
  : `not-allowed`
332
399
  }
@@ -356,12 +423,6 @@
356
423
  // snaps to the new wrapped centroid.
357
424
  let frozen_centroid = $state<Vec3 | null>(null)
358
425
 
359
- const BOND_ORDER_OPTIONS: { order: BondOrder; label: string }[] = [
360
- { order: 1, label: `Single` },
361
- { order: 2, label: `Double` },
362
- { order: 3, label: `Triple` },
363
- { order: `aromatic`, label: `Aromatic` },
364
- ]
365
426
  let bond_context_menu = $state<BondContextMenu | null>(null)
366
427
  // Threlte/HTML pointer events can close the visible menu before a button
367
428
  // handler runs, so keep the target bond separately for menu actions.
@@ -372,27 +433,45 @@
372
433
  bond_context_target = null
373
434
  }
374
435
 
375
- const make_bond_record = (
376
- site_idx_1: number,
377
- site_idx_2: number,
378
- order: BondOrder,
379
- cell_shift?: Vec3,
380
- ): StructureBond => normalize_structure_bond(site_idx_1, site_idx_2, order, cell_shift)
436
+ const canonical_bond_target = (bond: BondKeyTarget): BondKeyTarget =>
437
+ canonicalize_bond_target(bond, structure?.sites)
381
438
 
382
- const bond_key_for = (bond: BondKeyTarget): string =>
439
+ const bond_key_for = (bond: BondKeyTarget): string => {
440
+ const target = canonical_bond_target(bond)
441
+ return get_bond_key(target.site_idx_1, target.site_idx_2, target.cell_shift)
442
+ }
443
+ const rendered_bond_key_for = (bond: BondKeyTarget): string =>
383
444
  get_bond_key(bond.site_idx_1, bond.site_idx_2, bond.cell_shift)
384
445
 
385
446
  const matches_bond_key = (bond: BondKeyTarget, key: string): boolean =>
386
447
  bond_key_for(bond) === key
387
448
 
388
- function is_editable_bond_site(site_idx: number): boolean {
389
- return structure?.sites?.[site_idx]?.properties?.orig_site_idx == null
449
+ const find_added_bond_by_rendered_key = (key: string): StructureBond | undefined =>
450
+ added_bonds.find((bond) => rendered_bond_key_for(bond) === key)
451
+
452
+ function resolve_bond_edit_target(
453
+ site_idx_1: number,
454
+ site_idx_2: number,
455
+ cell_shift?: Vec3,
456
+ ): BondKeyTarget {
457
+ const rendered_target = { site_idx_1, site_idx_2, cell_shift }
458
+ const rendered_key = rendered_bond_key_for(rendered_target)
459
+ return find_added_bond_by_rendered_key(rendered_key) ??
460
+ canonical_bond_target(rendered_target)
390
461
  }
391
462
 
392
- const can_edit_bond = (bond: BondKeyTarget): boolean =>
393
- bond_edits_enabled &&
394
- is_editable_bond_site(bond.site_idx_1) &&
395
- is_editable_bond_site(bond.site_idx_2)
463
+ const is_image_bond_site = (site_idx: number): boolean =>
464
+ structure?.sites?.[site_idx]?.properties?.orig_site_idx != null
465
+
466
+ const can_select_bond_site = (site_idx: number): boolean =>
467
+ bond_edits_enabled && structure?.sites?.[site_idx] != null
468
+
469
+ const can_edit_bond = (bond: BondKeyTarget): boolean => {
470
+ const target = canonical_bond_target(bond)
471
+ return bond_edits_enabled &&
472
+ !is_image_bond_site(target.site_idx_1) &&
473
+ !is_image_bond_site(target.site_idx_2)
474
+ }
396
475
 
397
476
  const format_bond_order = (order: BondOrder | undefined): string =>
398
477
  order === undefined ? `1` : `${order}`
@@ -403,7 +482,8 @@
403
482
  cell_shift?: Vec3,
404
483
  ): BondOrder | undefined {
405
484
  const key = get_bond_key(site_idx_1, site_idx_2, cell_shift)
406
- return bond_order_overrides.find((bond) => matches_bond_key(bond, key))?.order ??
485
+ return find_added_bond_by_rendered_key(key)?.order ??
486
+ bond_order_overrides.find((bond) => matches_bond_key(bond, key))?.order ??
407
487
  added_bonds.find((bond) => matches_bond_key(bond, key))?.order ??
408
488
  filtered_bond_pairs.find((bond) => matches_bond_key(bond, key))?.bond_order
409
489
  }
@@ -414,42 +494,168 @@
414
494
  (pos_1[2] + pos_2[2]) / 2,
415
495
  ]
416
496
 
497
+ const BOND_ENDPOINT_HIT_FRACTION = 0.3
498
+ const BOND_ENDPOINT_SITE_MATCH_TOLERANCE = 1e-6
499
+ const EDITABLE_ATOM_HIT_RADIUS_SCALE = 1.15
500
+ const skip_raycast = (): void => undefined
501
+
502
+ function apply_bond_transform(mesh: Mesh, bond: BondPair): void {
503
+ mesh.matrix.fromArray(bond.transform_matrix)
504
+ mesh.matrixWorldNeedsUpdate = true
505
+ }
506
+
507
+ function apply_non_raycastable_bond_hit_transform(mesh: Mesh, bond: BondPair): void {
508
+ apply_bond_transform(mesh, bond)
509
+ disable_raycast(mesh)
510
+ }
511
+
512
+ function disable_raycast(mesh: Mesh): void {
513
+ mesh.raycast = skip_raycast
514
+ }
515
+
516
+ function site_world_position(parent: Object3D, site: Site): Vector3 {
517
+ const position = new Vector3(...site.xyz)
518
+ return parent.localToWorld(position)
519
+ }
520
+
521
+ function get_bond_endpoint_site_idx(
522
+ site_idx: number,
523
+ world_position: Vector3,
524
+ parent: Object3D,
525
+ ): number {
526
+ if (!structure?.sites) return site_idx
527
+ const site = structure.sites[site_idx]
528
+ if (!site) return site_idx
529
+
530
+ const matches_world_position = (candidate_site: Site): boolean =>
531
+ site_world_position(parent, candidate_site).distanceTo(world_position) <=
532
+ BOND_ENDPOINT_SITE_MATCH_TOLERANCE
533
+
534
+ if (matches_world_position(site)) {
535
+ return site_idx
536
+ }
537
+
538
+ const image_site_idx = structure.sites.findIndex((candidate_site) =>
539
+ candidate_site.properties?.orig_site_idx === site_idx &&
540
+ matches_world_position(candidate_site)
541
+ )
542
+ return image_site_idx === -1 ? site_idx : image_site_idx
543
+ }
544
+
545
+ function get_bond_endpoint_hit_site_idx(
546
+ bond: BondPair,
547
+ event: BondPointerEvent,
548
+ ): number | null {
549
+ if (!event.point) return null
550
+ const parent = event.object?.parent
551
+ if (!parent) return null
552
+
553
+ const world_pos_1 = new Vector3(...bond.pos_1)
554
+ const world_pos_2 = new Vector3(...bond.pos_2)
555
+ parent.localToWorld(world_pos_1)
556
+ parent.localToWorld(world_pos_2)
557
+
558
+ const bond_vec = world_pos_2.clone().sub(world_pos_1)
559
+ const length_sq = bond_vec.lengthSq()
560
+ if (length_sq <= math.EPS) return null
561
+
562
+ const hit_vec = event.point.clone().sub(world_pos_1)
563
+ const bond_fraction = hit_vec.dot(bond_vec) / length_sq
564
+ if (bond_fraction <= BOND_ENDPOINT_HIT_FRACTION) {
565
+ return get_bond_endpoint_site_idx(bond.site_idx_1, world_pos_1, parent)
566
+ }
567
+ if (bond_fraction >= 1 - BOND_ENDPOINT_HIT_FRACTION) {
568
+ return get_bond_endpoint_site_idx(bond.site_idx_2, world_pos_2, parent)
569
+ }
570
+ return null
571
+ }
572
+
417
573
  let label_screen_margin = $derived(site_label_size * 10 + site_label_padding)
418
574
 
419
- function open_bond_context_menu(bond: BondPair) {
575
+ function get_bond_context_menu_position(
576
+ bond: BondPair,
577
+ event?: BondContextMenuEvent,
578
+ ): Vec3 {
579
+ const parent = event?.object?.parent
580
+ if (!event?.point || !parent) return midpoint(bond.pos_1, bond.pos_2)
581
+
582
+ const local_point = event.point.clone()
583
+ parent.worldToLocal(local_point)
584
+ return [local_point.x, local_point.y, local_point.z]
585
+ }
586
+
587
+ function open_bond_context_menu(bond: BondPair, event?: BondContextMenuEvent) {
420
588
  if (!can_edit_bond(bond)) return
421
589
  bond_context_target = {
422
590
  site_idx_1: bond.site_idx_1,
423
591
  site_idx_2: bond.site_idx_2,
424
592
  cell_shift: bond.cell_shift,
425
- position: midpoint(bond.pos_1, bond.pos_2),
593
+ position: get_bond_context_menu_position(bond, event),
426
594
  }
427
595
  bond_context_menu = bond_context_target
428
596
  }
429
597
 
430
- // Toggle a bond between two atoms: cycles through add → remove → restore states
431
- function toggle_bond(site_1: number, site_2: number, cell_shift?: Vec3) {
432
- if (!can_edit_bond({ site_idx_1: site_1, site_idx_2: site_2, cell_shift })) return
433
- const record = make_bond_record(site_1, site_2, 1, cell_shift)
434
- const key = bond_key_for(record)
435
- if (added_bonds.some((bond) => matches_bond_key(bond, key))) {
436
- added_bonds = added_bonds.filter((bond) => !matches_bond_key(bond, key))
437
- return
438
- }
439
- if (removed_bonds.some((bond) => matches_bond_key(bond, key))) {
440
- removed_bonds = removed_bonds.filter((bond) => !matches_bond_key(bond, key))
441
- return
442
- }
443
- const has_calculated_bond = bond_pairs.some((bond) => matches_bond_key(bond, key))
444
- if (bond_order_overrides.some((bond) => matches_bond_key(bond, key))) {
445
- bond_order_overrides = bond_order_overrides.filter((bond) =>
446
- !matches_bond_key(bond, key)
447
- )
448
- if (has_calculated_bond) removed_bonds = [...removed_bonds, record]
598
+ const current_bond_edit_state = () => ({
599
+ added_bonds,
600
+ removed_bonds,
601
+ bond_order_overrides,
602
+ })
603
+
604
+ function apply_bond_edit_result(result: BondEditResult, close_menu = true) {
605
+ if (!result.changed) return
606
+ on_bond_edit_start?.()
607
+ added_bonds = result.state.added_bonds
608
+ removed_bonds = result.state.removed_bonds
609
+ bond_order_overrides = result.state.bond_order_overrides
610
+ if (close_menu) close_bond_context_menu()
611
+ }
612
+
613
+ const find_visible_bond = (
614
+ target: BondKeyTarget,
615
+ canonical_target: BondKeyTarget = target,
616
+ ): BondPair | undefined => {
617
+ const rendered_key = rendered_bond_key_for(target)
618
+ const canonical_key = bond_key_for(canonical_target)
619
+ return filtered_bond_pairs.find((bond) => rendered_bond_key_for(bond) === rendered_key) ??
620
+ filtered_bond_pairs.find((bond) => bond_key_for(bond) === canonical_key)
621
+ }
622
+
623
+ function open_bond_order_menu_for_target(
624
+ target: BondKeyTarget,
625
+ canonical_target: BondKeyTarget = target,
626
+ ) {
627
+ const bond = find_visible_bond(target, canonical_target)
628
+ if (bond) open_bond_context_menu(bond)
629
+ }
630
+
631
+ function add_or_restore_pair(site_idx_1: number, site_idx_2: number) {
632
+ const rendered_target = { site_idx_1, site_idx_2 }
633
+ if (!can_edit_bond(rendered_target)) return
634
+ const edit_state = current_bond_edit_state()
635
+ const canonical_target = canonical_bond_target(rendered_target)
636
+ const canonical_result = add_or_restore_bond(
637
+ edit_state,
638
+ canonical_target,
639
+ editable_perceived_bond_pairs,
640
+ bond_edit_order,
641
+ )
642
+ const use_rendered_target =
643
+ canonical_result.action === `added` &&
644
+ rendered_bond_key_for(canonical_target) !== rendered_bond_key_for(rendered_target)
645
+ const target = use_rendered_target ? rendered_target : canonical_target
646
+ const result = use_rendered_target
647
+ ? add_or_restore_bond(
648
+ edit_state,
649
+ rendered_target,
650
+ editable_perceived_bond_pairs,
651
+ bond_edit_order,
652
+ )
653
+ : canonical_result
654
+ if (result.action === `already-visible`) {
655
+ open_bond_order_menu_for_target(rendered_target, target)
449
656
  return
450
657
  }
451
- if (has_calculated_bond) removed_bonds = [...removed_bonds, record]
452
- else added_bonds = [...added_bonds, record]
658
+ apply_bond_edit_result(result, false)
453
659
  }
454
660
 
455
661
  function set_bond_order(
@@ -458,21 +664,16 @@
458
664
  order: BondOrder,
459
665
  cell_shift?: Vec3,
460
666
  ) {
461
- if (!can_edit_bond({ site_idx_1, site_idx_2, cell_shift })) return
462
- const key = get_bond_key(site_idx_1, site_idx_2, cell_shift)
463
- const new_record = make_bond_record(site_idx_1, site_idx_2, order, cell_shift)
464
- added_bonds = added_bonds.filter((bond) => !matches_bond_key(bond, key))
465
- const has_calculated_bond = bond_pairs.some((bond) => matches_bond_key(bond, key))
466
- if (has_calculated_bond) {
467
- bond_order_overrides = [
468
- ...bond_order_overrides.filter((bond) => !matches_bond_key(bond, key)),
469
- new_record,
470
- ]
471
- removed_bonds = removed_bonds.filter((bond) => !matches_bond_key(bond, key))
472
- } else {
473
- added_bonds = [...added_bonds, new_record]
474
- }
475
- close_bond_context_menu()
667
+ const target = resolve_bond_edit_target(site_idx_1, site_idx_2, cell_shift)
668
+ if (!can_edit_bond(target)) return
669
+ apply_bond_edit_result(
670
+ apply_set_bond_order(
671
+ current_bond_edit_state(),
672
+ target,
673
+ editable_perceived_bond_pairs,
674
+ order,
675
+ ),
676
+ )
476
677
  }
477
678
 
478
679
  function set_context_bond_order(order: BondOrder) {
@@ -482,22 +683,15 @@
482
683
  }
483
684
 
484
685
  function remove_bond(site_idx_1: number, site_idx_2: number, cell_shift?: Vec3) {
485
- if (!can_edit_bond({ site_idx_1, site_idx_2, cell_shift })) return
486
- const key = get_bond_key(site_idx_1, site_idx_2, cell_shift)
487
- added_bonds = added_bonds.filter((bond) => !matches_bond_key(bond, key))
488
- bond_order_overrides = bond_order_overrides.filter((bond) =>
489
- !matches_bond_key(bond, key)
686
+ const target = resolve_bond_edit_target(site_idx_1, site_idx_2, cell_shift)
687
+ if (!can_edit_bond(target)) return
688
+ apply_bond_edit_result(
689
+ apply_delete_bond(
690
+ current_bond_edit_state(),
691
+ target,
692
+ editable_perceived_bond_pairs,
693
+ ),
490
694
  )
491
- if (
492
- bond_pairs.some((bond) => matches_bond_key(bond, key)) &&
493
- !removed_bonds.some((bond) => matches_bond_key(bond, key))
494
- ) {
495
- removed_bonds = [
496
- ...removed_bonds,
497
- make_bond_record(site_idx_1, site_idx_2, 1, cell_shift),
498
- ]
499
- }
500
- close_bond_context_menu()
501
695
  }
502
696
 
503
697
  function remove_context_bond() {
@@ -510,19 +704,57 @@
510
704
  // intercept the same native click, only the first intersection should fire.
511
705
  // All threlte intersection events from one click share the same nativeEvent ref.
512
706
  let last_native_event: Event | null = null
707
+ // extras.Instance does not always emit pointerdown, so edit-bonds also falls
708
+ // back to click. When pointerdown did fire, skip the matching click once.
709
+ let last_edit_bonds_pointerdown_site_idx: number | null = null
710
+ let clear_edit_bonds_pointerdown_site_timeout:
711
+ | ReturnType<typeof setTimeout>
712
+ | null = null
713
+
714
+ function remember_edit_bonds_pointerdown_site(site_idx: number) {
715
+ last_edit_bonds_pointerdown_site_idx = site_idx
716
+ if (clear_edit_bonds_pointerdown_site_timeout != null) {
717
+ clearTimeout(clear_edit_bonds_pointerdown_site_timeout)
718
+ }
719
+ clear_edit_bonds_pointerdown_site_timeout = setTimeout(() => {
720
+ last_edit_bonds_pointerdown_site_idx = null
721
+ clear_edit_bonds_pointerdown_site_timeout = null
722
+ }, 250)
723
+ }
724
+
725
+ function select_edit_bonds_site(site_idx: number, event: Event): void {
726
+ toggle_selection(site_idx, event)
727
+ remember_edit_bonds_pointerdown_site(site_idx)
728
+ }
729
+
730
+ function skip_duplicate_edit_bonds_click(site_idx: number) {
731
+ if (last_edit_bonds_pointerdown_site_idx !== site_idx) return false
732
+
733
+ last_edit_bonds_pointerdown_site_idx = null
734
+ if (clear_edit_bonds_pointerdown_site_timeout != null) {
735
+ clearTimeout(clear_edit_bonds_pointerdown_site_timeout)
736
+ clear_edit_bonds_pointerdown_site_timeout = null
737
+ }
738
+ return true
739
+ }
513
740
 
514
741
  function toggle_selection(site_index: number, evt?: Event) {
515
742
  evt?.stopPropagation?.()
516
- const native_event = (evt as Event & { nativeEvent?: unknown } | undefined)
517
- ?.nativeEvent
743
+ const event_with_native = evt as Event & { nativeEvent?: unknown } | undefined
744
+ const native_event = event_with_native?.nativeEvent ?? evt
518
745
  if (native_event instanceof Event) {
519
746
  if (native_event === last_native_event) return
520
747
  last_native_event = native_event
521
748
  }
522
749
 
523
750
  if (measure_mode === `edit-bonds`) {
524
- if (!bond_edits_enabled || !is_editable_bond_site(site_index)) return
525
- // In edit-bonds mode, select atoms to add/remove bonds between them
751
+ if (bond_edit_mode === `delete`) {
752
+ measured_sites = []
753
+ selected_sites = []
754
+ return
755
+ }
756
+ if (!can_select_bond_site(site_index)) return
757
+ // In Add mode, select atom pairs without making existing bonds destructive.
526
758
  const new_sites = measured_sites.includes(site_index)
527
759
  ? measured_sites.filter((idx) => idx !== site_index)
528
760
  : [...measured_sites, site_index]
@@ -530,9 +762,9 @@
530
762
  measured_sites = new_sites
531
763
  selected_sites = new_sites
532
764
 
533
- // When two atoms are selected, toggle the bond between them
534
- if (measured_sites.length === 2) {
535
- toggle_bond(measured_sites[0], measured_sites[1])
765
+ // When two atoms are selected, add/restore or open order editing.
766
+ if (new_sites.length === 2) {
767
+ add_or_restore_pair(new_sites[0], new_sites[1])
536
768
  measured_sites = []
537
769
  selected_sites = []
538
770
  }
@@ -584,6 +816,7 @@
584
816
  $effect(() => {
585
817
  void structure
586
818
  void measure_mode
819
+ void bond_edit_mode
587
820
  void bond_edits_enabled
588
821
  untrack(() => {
589
822
  close_bond_context_menu()
@@ -626,9 +859,16 @@
626
859
  : [0, 0, 0] as Vec3,
627
860
  )
628
861
 
629
- let structure_size = $derived(
630
- lattice ? (lattice.a + lattice.b + lattice.c) / 2 : 10,
631
- )
862
+ let structure_size = $derived.by(() => {
863
+ if (lattice) return (lattice.a + lattice.b + lattice.c) / 2
864
+ if (!structure?.sites?.length) return 10
865
+
866
+ const ranges = [0, 1, 2].map((axis_idx) => {
867
+ const coords = structure.sites.map((site) => site.xyz[axis_idx])
868
+ return Math.max(...coords) - Math.min(...coords)
869
+ })
870
+ return Math.max(1, ...ranges)
871
+ })
632
872
 
633
873
  // Characteristic inter-atomic spacing: cube root of volume per atom.
634
874
  // Excludes PBC image atoms (orig_site_idx) so toggling image atoms doesn't affect arrow sizing.
@@ -799,6 +1039,10 @@
799
1039
  )
800
1040
  })
801
1041
 
1042
+ let editable_perceived_bond_pairs = $derived(
1043
+ perceived_bond_pairs.map((bond) => ({ ...bond, ...canonical_bond_target(bond) })),
1044
+ )
1045
+
802
1046
  let filtered_bond_pairs = $derived.by(() => {
803
1047
  if (!structure?.sites) return perceived_bond_pairs
804
1048
 
@@ -806,14 +1050,18 @@
806
1050
  const removed_keys = new Set(
807
1051
  removed_bonds.map(bond_key_for),
808
1052
  )
1053
+ const added_keys = new Set(
1054
+ added_bonds.map(bond_key_for),
1055
+ )
809
1056
  const order_overrides = new Map(
810
1057
  bond_order_overrides.map((bond) => [bond_key_for(bond), bond.order]),
811
1058
  )
812
1059
 
813
- // Filter calculated bonds: exclude removed and hidden
1060
+ // Filter calculated bonds: exclude removed, replaced by manual additions, and hidden.
814
1061
  const calculated = perceived_bond_pairs
815
1062
  .filter((bond) => {
816
- if (removed_keys.has(bond_key_for(bond))) return false
1063
+ const key = bond_key_for(bond)
1064
+ if (removed_keys.has(key) || added_keys.has(key)) return false
817
1065
  return is_site_visible(bond.site_idx_1) && is_site_visible(bond.site_idx_2)
818
1066
  })
819
1067
  .map((bond) => {
@@ -907,6 +1155,28 @@
907
1155
  return map
908
1156
  })
909
1157
 
1158
+ let editable_atom_hit_targets = $derived.by(() => {
1159
+ if (
1160
+ measure_mode !== `edit-bonds` ||
1161
+ bond_edit_mode !== `add` ||
1162
+ !bond_edits_enabled
1163
+ ) {
1164
+ return []
1165
+ }
1166
+
1167
+ const targets = new SvelteMap<number, EditableAtomHitTarget>()
1168
+ for (const atom of atom_data) {
1169
+ if (!can_select_bond_site(atom.site_idx)) continue
1170
+ if (targets.has(atom.site_idx)) continue
1171
+ targets.set(atom.site_idx, {
1172
+ site_idx: atom.site_idx,
1173
+ position: atom.position,
1174
+ radius: atom.radius,
1175
+ })
1176
+ }
1177
+ return [...targets.values()]
1178
+ })
1179
+
910
1180
  // Get radius for a site (for highlight fallback when site is hidden/filtered)
911
1181
  // Checks site_radius_overrides first for consistency with visible atoms
912
1182
  const get_site_radius = (site: Site, site_idx: number | null): number => {
@@ -1138,6 +1408,7 @@
1138
1408
  dampingFactor: rotation_damping,
1139
1409
  onstart: () => {
1140
1410
  camera_is_moving = true
1411
+ cancel_atom_hover_clear()
1141
1412
  hovered_idx = null
1142
1413
  bond_context_menu = null
1143
1414
  },
@@ -1178,29 +1449,45 @@
1178
1449
  {#if atom_label}
1179
1450
  {@render atom_label({ site, site_idx })}
1180
1451
  {:else}
1181
- <span
1452
+ <button
1453
+ type="button"
1182
1454
  class="atom-label"
1183
1455
  style:font-size="{site_label_size * 0.85}em"
1184
1456
  style:background={site_label_bg_color}
1185
1457
  style:padding="{site_label_padding}px"
1186
1458
  style:color={site_label_color}
1459
+ onpointerdown={(event) => {
1460
+ event.preventDefault()
1461
+ event.stopImmediatePropagation()
1462
+ toggle_selection(site_idx, event)
1463
+ }}
1464
+ onclick={(event) => {
1465
+ event.preventDefault()
1466
+ event.stopImmediatePropagation()
1467
+ }}
1468
+ onkeydown={(event) => {
1469
+ if (event.key !== `Enter` && event.key !== ` `) return
1470
+ event.preventDefault()
1471
+ event.stopPropagation()
1472
+ toggle_selection(site_idx, event)
1473
+ }}
1187
1474
  >
1188
1475
  {#if show_site_labels}
1189
1476
  {#if site.species.length === 1}
1190
1477
  {site.species[0].element}{#if show_site_indices}-{site_idx + 1}{/if}
1191
1478
  {:else}
1192
- {@html sanitize_html(site.species.map((spec) =>
1193
- `${spec.element}<sub>${
1194
- format_num(spec.occu, `.3~`).replace(`0.`, `.`)
1195
- }</sub>`
1196
- ).join(``))}{#if show_site_indices}-{
1197
- site_idx + 1
1198
- }{/if}
1479
+ {#each site.species as
1480
+ { element, occu, oxidation_state }
1481
+ (`${element}-${occu}-${oxidation_state}`)
1482
+ }
1483
+ {element}<sub>{format_num(occu, `.3~`).replace(`0.`, `.`)}</sub>
1484
+ {/each}
1485
+ {#if show_site_indices}-{site_idx + 1}{/if}
1199
1486
  {/if}
1200
1487
  {:else if show_site_indices}
1201
1488
  {site_idx + 1}
1202
1489
  {/if}
1203
- </span>
1490
+ </button>
1204
1491
  {/if}
1205
1492
  </extras.HTML>
1206
1493
  {/snippet}
@@ -1240,13 +1527,11 @@
1240
1527
  <T.Group position={math.scale(rotation_target, -1)}>
1241
1528
  {#if show_atoms}
1242
1529
  <!-- Instanced rendering for full occupancy atoms -->
1243
- {#each instanced_atom_groups as
1244
- { element, radius, color, is_image_atom, atoms }
1245
- (`${element}-${radius}-${color}-${is_image_atom ? `img` : `base`}`)
1246
- }
1530
+ {#each instanced_atom_groups as atom_group (instanced_atom_group_key(atom_group, measure_mode))}
1531
+ {@const { element, radius, color, is_image_atom, atoms } = atom_group}
1247
1532
  {@const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom}
1248
1533
  <extras.InstancedMesh
1249
- key="{element}-{format_num(radius, `.3~`)}-{color}-{is_image_atom ? `img` : `base`}-{edit_mode_image}"
1534
+ key={instanced_atom_group_key(atom_group, measure_mode)}
1250
1535
  limit={atoms.length}
1251
1536
  range={atoms.length}
1252
1537
  frustumCulled={false}
@@ -1261,18 +1546,26 @@
1261
1546
  <extras.Instance
1262
1547
  position={atom.position}
1263
1548
  scale={atom.radius}
1264
- onpointerenter={() => {
1265
- if (edit_mode_image) return
1266
- hovered_idx = atom.site_idx
1267
- active_tooltip = `atom`
1268
- }}
1269
- onpointerleave={() => {
1270
- if (edit_mode_image) return
1271
- hovered_idx = null
1272
- active_tooltip = null
1549
+ {...atom_hover_props(atom.site_idx, edit_mode_image)}
1550
+ onpointerdown={(event: PointerEvent) => {
1551
+ if (
1552
+ edit_mode_image ||
1553
+ measure_mode !== `edit-bonds` ||
1554
+ bond_edit_mode !== `add`
1555
+ ) {
1556
+ return
1557
+ }
1558
+ select_edit_bonds_site(atom.site_idx, event)
1273
1559
  }}
1274
1560
  onclick={(event: MouseEvent) => {
1275
1561
  if (edit_mode_image) return
1562
+ if (measure_mode === `edit-bonds`) {
1563
+ if (bond_edit_mode !== `add`) return
1564
+ if (skip_duplicate_edit_bonds_click(atom.site_idx)) {
1565
+ event.stopPropagation()
1566
+ return
1567
+ }
1568
+ }
1276
1569
  toggle_selection(atom.site_idx, event)
1277
1570
  }}
1278
1571
  />
@@ -1290,18 +1583,26 @@
1290
1583
  <T.Group
1291
1584
  position={atom.position}
1292
1585
  scale={atom.radius}
1293
- onpointerenter={() => {
1294
- if (partial_edit_image) return
1295
- hovered_idx = atom.site_idx
1296
- active_tooltip = `atom`
1297
- }}
1298
- onpointerleave={() => {
1299
- if (partial_edit_image) return
1300
- hovered_idx = null
1301
- active_tooltip = null
1586
+ {...atom_hover_props(atom.site_idx, partial_edit_image)}
1587
+ onpointerdown={(event: PointerEvent) => {
1588
+ if (
1589
+ partial_edit_image ||
1590
+ measure_mode !== `edit-bonds` ||
1591
+ bond_edit_mode !== `add`
1592
+ ) {
1593
+ return
1594
+ }
1595
+ select_edit_bonds_site(atom.site_idx, event)
1302
1596
  }}
1303
1597
  onclick={(event: MouseEvent) => {
1304
1598
  if (partial_edit_image) return
1599
+ if (measure_mode === `edit-bonds`) {
1600
+ if (bond_edit_mode !== `add`) return
1601
+ if (skip_duplicate_edit_bonds_click(atom.site_idx)) {
1602
+ event.stopPropagation()
1603
+ return
1604
+ }
1605
+ }
1305
1606
  toggle_selection(atom.site_idx, event)
1306
1607
  }}
1307
1608
  >
@@ -1400,37 +1701,86 @@
1400
1701
  {#if measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1401
1702
  {#each editable_bond_pairs as
1402
1703
  bond
1403
- (`bond-hit-${bond_key_for(bond)}`)
1704
+ (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`)
1404
1705
  }
1405
- {@const bond_key = bond_key_for(bond)}
1706
+ {@const bond_key = rendered_bond_key_for(bond)}
1406
1707
  {@const is_hovered = hovered_bond_key === bond_key}
1708
+ {@const is_delete_mode = bond_edit_mode === `delete`}
1709
+ {@const bond_hit_radius =
1710
+ bond_thickness * (is_delete_mode ? 5 : 1.25)}
1711
+ {@const bond_hover_radius = bond_thickness * 1.1}
1407
1712
  <T.Mesh
1408
1713
  matrixAutoUpdate={false}
1409
- oncreate={(ref) => {
1410
- ref.matrix.fromArray(bond.transform_matrix)
1411
- ref.matrixWorldNeedsUpdate = true
1412
- }}
1413
- onpointerdown={(event: PointerEvent & { nativeEvent?: PointerEvent }) => {
1714
+ oncreate={(ref) => apply_bond_transform(ref, bond)}
1715
+ onpointerdown={(event: BondPointerEvent) => {
1414
1716
  if (event.nativeEvent?.button === 2) return
1415
1717
  event.stopPropagation()
1416
- toggle_bond(bond.site_idx_1, bond.site_idx_2, bond.cell_shift)
1417
- measured_sites = []
1418
- selected_sites = []
1419
- hovered_bond_key = null
1718
+ if (is_delete_mode) {
1719
+ remove_bond(bond.site_idx_1, bond.site_idx_2, bond.cell_shift)
1720
+ measured_sites = []
1721
+ selected_sites = []
1722
+ hovered_bond_key = null
1723
+ } else {
1724
+ const endpoint_site_idx = get_bond_endpoint_hit_site_idx(bond, event)
1725
+ if (endpoint_site_idx != null) {
1726
+ select_edit_bonds_site(endpoint_site_idx, event)
1727
+ }
1728
+ }
1420
1729
  }}
1421
- oncontextmenu={(event: MouseEvent & { nativeEvent?: MouseEvent }) => {
1730
+ oncontextmenu={(event: BondContextMenuEvent) => {
1422
1731
  event.nativeEvent?.preventDefault()
1423
1732
  event.stopPropagation?.()
1424
- open_bond_context_menu(bond)
1733
+ open_bond_context_menu(bond, event)
1425
1734
  }}
1426
1735
  onpointerenter={() => (hovered_bond_key = bond_key)}
1736
+ onpointermove={() => (hovered_bond_key = bond_key)}
1427
1737
  onpointerleave={() => (hovered_bond_key = null)}
1428
1738
  >
1429
- <T.CylinderGeometry args={[bond_thickness * 3, bond_thickness * 3, 1, 6]} />
1739
+ <T.CylinderGeometry
1740
+ args={[
1741
+ bond_hit_radius,
1742
+ bond_hit_radius,
1743
+ 1,
1744
+ 6,
1745
+ ]}
1746
+ />
1430
1747
  <T.MeshBasicMaterial
1431
1748
  transparent
1432
- opacity={is_hovered ? 0.25 : 0}
1433
- color={is_hovered ? `#ff4444` : `white`}
1749
+ opacity={0}
1750
+ depthWrite={false}
1751
+ />
1752
+ </T.Mesh>
1753
+ {#if is_hovered}
1754
+ <T.Mesh
1755
+ matrixAutoUpdate={false}
1756
+ oncreate={(ref) => apply_non_raycastable_bond_hit_transform(ref, bond)}
1757
+ >
1758
+ <T.CylinderGeometry args={[bond_hover_radius, bond_hover_radius, 1, 6]} />
1759
+ <T.MeshBasicMaterial
1760
+ transparent
1761
+ opacity={0.25}
1762
+ color={is_delete_mode ? `#ff4444` : `#6cf0ff`}
1763
+ depthWrite={false}
1764
+ />
1765
+ </T.Mesh>
1766
+ {/if}
1767
+ {/each}
1768
+ {/if}
1769
+
1770
+ {#if editable_atom_hit_targets.length > 0}
1771
+ {#each editable_atom_hit_targets as atom_hit (atom_hit.site_idx)}
1772
+ <T.Mesh
1773
+ position={atom_hit.position}
1774
+ scale={atom_hit.radius * EDITABLE_ATOM_HIT_RADIUS_SCALE}
1775
+ {...atom_hover_props(atom_hit.site_idx)}
1776
+ onpointerdown={(event: PointerEvent) => {
1777
+ select_edit_bonds_site(atom_hit.site_idx, event)
1778
+ }}
1779
+ >
1780
+ <T.SphereGeometry args={[0.5, 12, 12]} />
1781
+ <T.MeshBasicMaterial
1782
+ transparent
1783
+ opacity={0}
1434
1784
  depthWrite={false}
1435
1785
  />
1436
1786
  </T.Mesh>
@@ -1443,7 +1793,7 @@
1443
1793
  bond_context_menu.site_idx_2,
1444
1794
  bond_context_menu.cell_shift,
1445
1795
  )}
1446
- <extras.HTML center position={bond_context_menu.position}>
1796
+ <extras.HTML autoRender={false} position={bond_context_menu.position}>
1447
1797
  <div class="bond-context-menu">
1448
1798
  <strong>Bond Order ({format_bond_order(current_order)})</strong>
1449
1799
  {#each BOND_ORDER_OPTIONS as { order, label } (label)}
@@ -1537,11 +1887,7 @@
1537
1887
  <T.Mesh
1538
1888
  position={xyz}
1539
1889
  scale={1.2 * highlight_radius}
1540
- onclick={(event: MouseEvent) => {
1541
- if (site_idx !== null && Number.isInteger(site_idx)) {
1542
- toggle_selection(site_idx, event)
1543
- }
1544
- }}
1890
+ oncreate={disable_raycast}
1545
1891
  >
1546
1892
  <T.SphereGeometry args={[0.5, 22, 22]} />
1547
1893
  <T.MeshStandardMaterial
@@ -1557,9 +1903,10 @@
1557
1903
  {/if}
1558
1904
  {/each}
1559
1905
 
1560
- <!-- selection order labels (1, 2, 3, ...) for measured sites (hidden in edit-atoms mode) -->
1906
+ <!-- selection order labels (1, 2, 3, ...) for measurements and bond editing -->
1561
1907
  {#if structure?.sites && (measured_sites?.length ?? 0) > 0 &&
1562
- measure_mode !== `edit-atoms`}
1908
+ (measure_mode === `distance` || measure_mode === `angle` ||
1909
+ measure_mode === `edit-bonds`)}
1563
1910
  {#each measured_sites as site_index, loop_idx (site_index)}
1564
1911
  {@const site = structure.sites[site_index]}
1565
1912
  {#if site}
@@ -1575,7 +1922,7 @@
1575
1922
 
1576
1923
  <!-- hovered site tooltip -->
1577
1924
  {#if hovered_site && !camera_is_moving &&
1578
- (active_tooltip === `atom` || active_sites.includes(hovered_idx ?? -1))}
1925
+ (atom_tooltip_active || active_sites.includes(hovered_idx ?? -1))}
1579
1926
  {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(
1580
1927
  `, `,
1581
1928
  )}
@@ -1585,13 +1932,13 @@
1585
1932
  {@const bond_neighbors = (() => {
1586
1933
  if (hovered_idx == null || !structure?.sites) return []
1587
1934
  return filtered_bond_pairs
1588
- .filter((b) =>
1589
- b.site_idx_1 === hovered_idx || b.site_idx_2 === hovered_idx
1935
+ .filter((bond) =>
1936
+ bond.site_idx_1 === hovered_idx || bond.site_idx_2 === hovered_idx
1590
1937
  )
1591
- .map((b) => {
1592
- const neighbor_idx = b.site_idx_1 === hovered_idx
1593
- ? b.site_idx_2
1594
- : b.site_idx_1
1938
+ .map((bond) => {
1939
+ const neighbor_idx = bond.site_idx_1 === hovered_idx
1940
+ ? bond.site_idx_2
1941
+ : bond.site_idx_1
1595
1942
  return structure.sites[neighbor_idx]?.species[0]?.element ?? `?`
1596
1943
  })
1597
1944
  })()}
@@ -1614,11 +1961,6 @@
1614
1961
  idx
1615
1962
  (`${element ?? ``}-${occu ?? ``}-${oxi_state ?? ``}-${idx}`)
1616
1963
  }
1617
- {@const oxi_str = (oxi_state != null && oxi_state !== 0)
1618
- ? `<sup>${Math.abs(oxi_state)}${
1619
- oxi_state > 0 ? `+` : `−`
1620
- }</sup>`
1621
- : ``}
1622
1964
  {@const element_name = element_data.find((elem) =>
1623
1965
  elem.symbol === element
1624
1966
  )?.name ??
@@ -1627,7 +1969,11 @@
1627
1969
  {#if occu !== 1}<span class="occupancy">{
1628
1970
  format_num(occu, `.3~f`)
1629
1971
  }</span>{/if}
1630
- <strong>{element}{@html sanitize_html(oxi_str)}</strong>
1972
+ <strong>
1973
+ {element}{#if oxi_state != null && oxi_state !== 0}<sup>{Math.abs(
1974
+ oxi_state,
1975
+ )}{oxi_state > 0 ? `+` : `−`}</sup>{/if}
1976
+ </strong>
1631
1977
  {#if element_name}<span class="elem-name">{element_name}</span>{/if}
1632
1978
  {/each}
1633
1979
  </div>
@@ -1822,7 +2168,11 @@
1822
2168
  }
1823
2169
  .atom-label {
1824
2170
  background: var(--struct-atom-label-bg, rgba(0, 0, 0, 0.1));
2171
+ border: 0;
1825
2172
  border-radius: var(--struct-atom-label-border-radius, var(--border-radius, 3pt));
2173
+ color: inherit;
2174
+ cursor: pointer;
2175
+ font: inherit;
1826
2176
  padding: var(--struct-atom-label-padding, 0 3px);
1827
2177
  white-space: nowrap;
1828
2178
  }
@@ -1861,7 +2211,7 @@
1861
2211
  display: grid;
1862
2212
  min-width: 8rem;
1863
2213
  gap: 2pt;
1864
- padding: 5pt;
2214
+ padding: 3pt 5pt;
1865
2215
  border-radius: var(--border-radius, 3pt);
1866
2216
  background: var(--surface-bg, Canvas);
1867
2217
  color: var(--text-color, currentColor);