matterviz 0.3.3 → 0.3.5

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 (126) hide show
  1. package/dist/FilePicker.svelte +1 -1
  2. package/dist/app.css +7 -0
  3. package/dist/brillouin/BrillouinZone.svelte +5 -2
  4. package/dist/brillouin/compute.js +8 -4
  5. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +6 -6
  6. package/dist/chempot-diagram/async-compute.svelte.js +6 -5
  7. package/dist/chempot-diagram/chempot-worker.js +2 -2
  8. package/dist/chempot-diagram/compute.js +16 -16
  9. package/dist/composition/FormulaFilter.svelte +3 -3
  10. package/dist/constants.js +2 -8
  11. package/dist/convex-hull/ConvexHull.svelte +2 -2
  12. package/dist/convex-hull/ConvexHull2D.svelte +11 -10
  13. package/dist/convex-hull/ConvexHull3D.svelte +16 -14
  14. package/dist/convex-hull/ConvexHull4D.svelte +26 -14
  15. package/dist/convex-hull/ConvexHullControls.svelte +1 -1
  16. package/dist/convex-hull/ConvexHullInfoPane.svelte +68 -61
  17. package/dist/convex-hull/ConvexHullStats.svelte +23 -6
  18. package/dist/convex-hull/GasPressureControls.svelte +3 -3
  19. package/dist/convex-hull/TemperatureSlider.svelte +1 -1
  20. package/dist/convex-hull/barycentric-coords.js +2 -2
  21. package/dist/convex-hull/helpers.js +45 -27
  22. package/dist/convex-hull/thermodynamics.js +2 -2
  23. package/dist/element/BohrAtom.svelte +25 -27
  24. package/dist/element/BohrAtom.svelte.d.ts +2 -2
  25. package/dist/element/data.d.ts +2 -3
  26. package/dist/element/data.js +1 -1
  27. package/dist/fermi-surface/FermiSurface.svelte +5 -2
  28. package/dist/fermi-surface/compute.js +3 -3
  29. package/dist/fermi-surface/parse.js +2 -2
  30. package/dist/fermi-surface/symmetry.js +1 -1
  31. package/dist/heatmap-matrix/HeatmapMatrix.svelte +8 -8
  32. package/dist/icons.d.ts +6 -6
  33. package/dist/icons.js +6 -6
  34. package/dist/io/decompress.js +12 -7
  35. package/dist/io/export.js +20 -16
  36. package/dist/io/is-binary.js +19 -4
  37. package/dist/isosurface/parse.js +8 -8
  38. package/dist/isosurface/types.js +9 -9
  39. package/dist/layout/InfoTag.svelte +1 -1
  40. package/dist/layout/json-tree/JsonNode.svelte +1 -0
  41. package/dist/layout/json-tree/utils.js +2 -1
  42. package/dist/marching-cubes.js +1 -1
  43. package/dist/math.js +1 -1
  44. package/dist/overlays/CopyButton.svelte +45 -0
  45. package/dist/overlays/CopyButton.svelte.d.ts +8 -0
  46. package/dist/overlays/InfoPaneCards.svelte +149 -0
  47. package/dist/overlays/InfoPaneCards.svelte.d.ts +22 -0
  48. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +33 -35
  49. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +2 -2
  50. package/dist/phase-diagram/PhaseDiagramControls.svelte +27 -29
  51. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +2 -2
  52. package/dist/phase-diagram/parse.js +3 -3
  53. package/dist/phase-diagram/svg-to-diagram.js +10 -12
  54. package/dist/plot/BarPlot.svelte +24 -15
  55. package/dist/plot/BarPlot.svelte.d.ts +3 -2
  56. package/dist/plot/FillArea.svelte +2 -3
  57. package/dist/plot/FillArea.svelte.d.ts +3 -2
  58. package/dist/plot/Histogram.svelte +37 -19
  59. package/dist/plot/Line.svelte +2 -3
  60. package/dist/plot/Line.svelte.d.ts +2 -2
  61. package/dist/plot/PlotLegend.svelte +79 -8
  62. package/dist/plot/PlotLegend.svelte.d.ts +4 -0
  63. package/dist/plot/PortalSelect.svelte +5 -5
  64. package/dist/plot/ScatterPlot.svelte +47 -33
  65. package/dist/plot/ScatterPlot.svelte.d.ts +5 -4
  66. package/dist/plot/ScatterPlot3D.svelte +6 -3
  67. package/dist/plot/ScatterPoint.svelte +10 -4
  68. package/dist/plot/ScatterPoint.svelte.d.ts +4 -2
  69. package/dist/plot/SpacegroupBarPlot.svelte +5 -4
  70. package/dist/plot/data-cleaning.js +9 -9
  71. package/dist/plot/index.d.ts +0 -6
  72. package/dist/plot/scales.d.ts +3 -3
  73. package/dist/plot/scales.js +29 -29
  74. package/dist/plot/types.d.ts +5 -9
  75. package/dist/rdf/calc-rdf.js +1 -1
  76. package/dist/sanitize.js +22 -15
  77. package/dist/settings.d.ts +2 -0
  78. package/dist/settings.js +12 -3
  79. package/dist/spectral/Bands.svelte +6 -6
  80. package/dist/spectral/BandsAndDos.svelte +4 -4
  81. package/dist/spectral/BrillouinBandsDos.svelte +3 -3
  82. package/dist/spectral/Dos.svelte +2 -2
  83. package/dist/spectral/helpers.js +1 -1
  84. package/dist/structure/AtomLegend.svelte +4 -4
  85. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  86. package/dist/structure/Cylinder.svelte +7 -7
  87. package/dist/structure/Structure.svelte +169 -27
  88. package/dist/structure/Structure.svelte.d.ts +6 -2
  89. package/dist/structure/StructureControls.svelte +130 -16
  90. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  91. package/dist/structure/StructureInfoPane.svelte +519 -218
  92. package/dist/structure/StructureInfoPane.svelte.d.ts +2 -1
  93. package/dist/structure/StructureScene.svelte +399 -68
  94. package/dist/structure/StructureScene.svelte.d.ts +8 -4
  95. package/dist/structure/atom-properties.js +3 -1
  96. package/dist/structure/bond-order-perception.d.ts +13 -0
  97. package/dist/structure/bond-order-perception.js +367 -0
  98. package/dist/structure/bonding.d.ts +10 -1
  99. package/dist/structure/bonding.js +232 -11
  100. package/dist/structure/export.js +6 -4
  101. package/dist/structure/index.d.ts +19 -4
  102. package/dist/structure/index.js +3 -0
  103. package/dist/structure/label-placement.d.ts +14 -0
  104. package/dist/structure/label-placement.js +72 -0
  105. package/dist/structure/parse.d.ts +2 -1
  106. package/dist/structure/parse.js +25 -36
  107. package/dist/structure/supercell.js +35 -2
  108. package/dist/symmetry/SymmetryStats.svelte +1 -1
  109. package/dist/symmetry/cell-transform.js +15 -1
  110. package/dist/symmetry/index.js +3 -3
  111. package/dist/table/HeatmapTable.svelte +3 -3
  112. package/dist/table/ToggleMenu.svelte +1 -1
  113. package/dist/trajectory/Trajectory.svelte +2 -2
  114. package/dist/trajectory/TrajectoryInfoPane.svelte +14 -88
  115. package/dist/trajectory/extract.js +4 -4
  116. package/dist/trajectory/frame-reader.js +2 -2
  117. package/dist/trajectory/parse/ase.js +2 -6
  118. package/dist/trajectory/parse/hdf5.js +1 -3
  119. package/dist/trajectory/plotting.js +1 -1
  120. package/dist/utils.js +1 -1
  121. package/dist/xrd/calc-xrd.js +1 -1
  122. package/package.json +23 -38
  123. package/dist/structure/ferrox-wasm-types.d.ts +0 -46
  124. package/dist/structure/ferrox-wasm-types.js +0 -18
  125. package/dist/structure/ferrox-wasm.d.ts +0 -94
  126. package/dist/structure/ferrox-wasm.js +0 -249
@@ -10,9 +10,10 @@ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, `onclose`> & {
10
10
  toggle_props?: ComponentProps<typeof DraggablePane>[`toggle_props`];
11
11
  pane_props?: ComponentProps<typeof DraggablePane>[`pane_props`];
12
12
  highlighted_sites?: number[];
13
+ hovered_site_idx?: number | null;
13
14
  selected_sites?: number[];
14
15
  sym_data?: MoyoDataset | null;
15
16
  };
16
- declare const StructureInfoPane: import("svelte").Component<$$ComponentProps, {}, "selected_sites" | "pane_open" | "highlighted_sites">;
17
+ declare const StructureInfoPane: import("svelte").Component<$$ComponentProps, {}, "selected_sites" | "pane_open" | "highlighted_sites" | "hovered_site_idx">;
17
18
  type StructureInfoPane = ReturnType<typeof StructureInfoPane>;
18
19
  export default StructureInfoPane;
@@ -18,7 +18,14 @@
18
18
  import { DEFAULTS } from '../settings'
19
19
  import { sanitize_html } from '../sanitize'
20
20
  import { colors } from '../state.svelte'
21
- import type { AnyStructure, BondPair, MeasureMode, Site } from './'
21
+ import type {
22
+ AnyStructure,
23
+ BondOrder,
24
+ BondPair,
25
+ MeasureMode,
26
+ Site,
27
+ StructureBond,
28
+ } from './'
22
29
  import {
23
30
  Arrow,
24
31
  atomic_radii,
@@ -48,8 +55,24 @@
48
55
  import { type Camera, Color, type Mesh, type Scene } from 'three'
49
56
  import Bond from './Bond.svelte'
50
57
  import type { BondingStrategy } from './bonding'
51
- import { BONDING_STRATEGIES, compute_bond_transform } from './bonding'
52
- import { CanvasTooltip } from './index'
58
+ import {
59
+ BONDING_STRATEGIES,
60
+ get_bond_key,
61
+ get_bond_render_matrices,
62
+ get_explicit_bond_metadata,
63
+ normalize_structure_bond,
64
+ structure_bond_to_bond_pair,
65
+ } from './bonding'
66
+ import {
67
+ CanvasTooltip,
68
+ compose_perceived_bonds,
69
+ perceive_bond_orders,
70
+ } from './index'
71
+ import {
72
+ choose_site_label_offset,
73
+ LABEL_OFFSET_EPS,
74
+ make_label_position_calculator,
75
+ } from './label-placement'
53
76
 
54
77
  type InstancedAtomGroup = {
55
78
  element: string
@@ -59,6 +82,14 @@
59
82
  atoms: (typeof atom_data)[number][]
60
83
  }
61
84
 
85
+ type BondContextMenu = {
86
+ site_idx_1: number
87
+ site_idx_2: number
88
+ cell_shift?: Vec3
89
+ position: Vec3
90
+ }
91
+ type BondKeyTarget = Pick<StructureBond, `site_idx_1` | `site_idx_2` | `cell_shift`>
92
+
62
93
  let pulse_time = $state(0)
63
94
  let pulse_opacity = $derived(0.15 + 0.25 * Math.sin(pulse_time * 5))
64
95
  $effect(() => {
@@ -96,9 +127,9 @@
96
127
  show_site_indices = DEFAULTS.structure.show_site_indices,
97
128
  site_label_size = DEFAULTS.structure.site_label_size,
98
129
  site_label_offset = $bindable(DEFAULTS.structure.site_label_offset),
99
- site_label_bg_color = `color-mix(in srgb, #000000 0%, transparent)`,
100
- site_label_color = `#ffffff`,
101
- site_label_padding = 3,
130
+ site_label_bg_color = DEFAULTS.structure.site_label_bg_color,
131
+ site_label_color = DEFAULTS.structure.site_label_color,
132
+ site_label_padding = DEFAULTS.structure.site_label_padding,
102
133
  vector_configs = $bindable<Record<string, VectorLayerConfig>>({}),
103
134
  vector_scale = DEFAULTS.structure.vector_scale,
104
135
  vector_color = DEFAULTS.structure.vector_color,
@@ -118,6 +149,8 @@
118
149
  bond_thickness = DEFAULTS.structure.bond_thickness,
119
150
  bond_color = DEFAULTS.structure.bond_color,
120
151
  bonding_strategy = DEFAULTS.structure.bonding_strategy,
152
+ auto_bond_order = DEFAULTS.structure.auto_bond_order,
153
+ aromatic_display = DEFAULTS.structure.aromatic_display,
121
154
  bonding_options = {},
122
155
  fov = DEFAULTS.structure.fov,
123
156
  initial_zoom = DEFAULTS.structure.initial_zoom,
@@ -134,6 +167,8 @@
134
167
  measured_sites = $bindable([]),
135
168
  added_bonds = $bindable([]),
136
169
  removed_bonds = $bindable([]),
170
+ bond_order_overrides = $bindable([]),
171
+ bond_edits_enabled = true,
137
172
  selection_highlight_color = `#6cf0ff`,
138
173
  // Active highlight group with different color
139
174
  active_sites = $bindable([]),
@@ -205,6 +240,8 @@
205
240
  bond_thickness?: number
206
241
  bond_color?: string
207
242
  bonding_strategy?: BondingStrategy
243
+ auto_bond_order?: boolean
244
+ aromatic_display?: `aromatic` | `kekule`
208
245
  bonding_options?: Record<string, unknown>
209
246
  fov?: number
210
247
  ambient_light?: number
@@ -224,8 +261,10 @@
224
261
  measure_mode?: MeasureMode
225
262
  selected_sites?: number[]
226
263
  measured_sites?: number[]
227
- added_bonds?: [number, number][]
228
- removed_bonds?: [number, number][]
264
+ added_bonds?: StructureBond[]
265
+ removed_bonds?: StructureBond[]
266
+ bond_order_overrides?: StructureBond[]
267
+ bond_edits_enabled?: boolean
229
268
  selection_highlight_color?: string
230
269
  // Support for active highlight group with different color
231
270
  active_sites?: number[]
@@ -286,6 +325,11 @@
286
325
  let canvas_cursor = $derived.by(() => {
287
326
  if (measure_mode === `edit-atoms` && add_atom_mode) return `crosshair`
288
327
  if (hovered_idx != null) {
328
+ if (measure_mode === `edit-bonds`) {
329
+ return bond_edits_enabled && is_editable_bond_site(hovered_idx)
330
+ ? `pointer`
331
+ : `not-allowed`
332
+ }
289
333
  if (measure_mode === `edit-atoms`) {
290
334
  const site = structure?.sites?.[hovered_idx]
291
335
  if (site?.properties?.orig_site_idx != null) return `not-allowed`
@@ -312,36 +356,154 @@
312
356
  // snaps to the new wrapped centroid.
313
357
  let frozen_centroid = $state<Vec3 | null>(null)
314
358
 
315
- const get_bond_key = (idx1: number, idx2: number): string =>
316
- idx1 < idx2 ? `${idx1}-${idx2}` : `${idx2}-${idx1}`
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
+ let bond_context_menu = $state<BondContextMenu | null>(null)
366
+ // Threlte/HTML pointer events can close the visible menu before a button
367
+ // handler runs, so keep the target bond separately for menu actions.
368
+ let bond_context_target: BondContextMenu | null = null
369
+
370
+ function close_bond_context_menu() {
371
+ bond_context_menu = null
372
+ bond_context_target = null
373
+ }
374
+
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)
381
+
382
+ const bond_key_for = (bond: BondKeyTarget): string =>
383
+ get_bond_key(bond.site_idx_1, bond.site_idx_2, bond.cell_shift)
384
+
385
+ const matches_bond_key = (bond: BondKeyTarget, key: string): boolean =>
386
+ bond_key_for(bond) === key
387
+
388
+ function is_editable_bond_site(site_idx: number): boolean {
389
+ return structure?.sites?.[site_idx]?.properties?.orig_site_idx == null
390
+ }
391
+
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)
396
+
397
+ const format_bond_order = (order: BondOrder | undefined): string =>
398
+ order === undefined ? `1` : `${order}`
399
+
400
+ function get_current_bond_order(
401
+ site_idx_1: number,
402
+ site_idx_2: number,
403
+ cell_shift?: Vec3,
404
+ ): BondOrder | undefined {
405
+ 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 ??
407
+ added_bonds.find((bond) => matches_bond_key(bond, key))?.order ??
408
+ filtered_bond_pairs.find((bond) => matches_bond_key(bond, key))?.bond_order
409
+ }
410
+
411
+ const midpoint = (pos_1: Vec3, pos_2: Vec3): Vec3 => [
412
+ (pos_1[0] + pos_2[0]) / 2,
413
+ (pos_1[1] + pos_2[1]) / 2,
414
+ (pos_1[2] + pos_2[2]) / 2,
415
+ ]
416
+
417
+ let label_screen_margin = $derived(site_label_size * 10 + site_label_padding)
418
+
419
+ function open_bond_context_menu(bond: BondPair) {
420
+ if (!can_edit_bond(bond)) return
421
+ bond_context_target = {
422
+ site_idx_1: bond.site_idx_1,
423
+ site_idx_2: bond.site_idx_2,
424
+ cell_shift: bond.cell_shift,
425
+ position: midpoint(bond.pos_1, bond.pos_2),
426
+ }
427
+ bond_context_menu = bond_context_target
428
+ }
317
429
 
318
430
  // Toggle a bond between two atoms: cycles through add → remove → restore states
319
- function toggle_bond(site_1: number, site_2: number) {
320
- const idx_i = Math.min(site_1, site_2)
321
- const idx_j = Math.max(site_1, site_2)
322
- // added/removed pairs are stored sorted, so direct comparison works
323
- const match = ([a, b]: [number, number]) => a === idx_i && b === idx_j
324
-
325
- const added_idx = added_bonds.findIndex(match)
326
- if (added_idx >= 0) {
327
- added_bonds = added_bonds.toSpliced(added_idx, 1)
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))
328
437
  return
329
438
  }
330
-
331
- const removed_idx = removed_bonds.findIndex(match)
332
- if (removed_idx >= 0) {
333
- removed_bonds = removed_bonds.toSpliced(removed_idx, 1)
439
+ if (removed_bonds.some((bond) => matches_bond_key(bond, key))) {
440
+ removed_bonds = removed_bonds.filter((bond) => !matches_bond_key(bond, key))
334
441
  return
335
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]
449
+ return
450
+ }
451
+ if (has_calculated_bond) removed_bonds = [...removed_bonds, record]
452
+ else added_bonds = [...added_bonds, record]
453
+ }
454
+
455
+ function set_bond_order(
456
+ site_idx_1: number,
457
+ site_idx_2: number,
458
+ order: BondOrder,
459
+ cell_shift?: Vec3,
460
+ ) {
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()
476
+ }
477
+
478
+ function set_context_bond_order(order: BondOrder) {
479
+ const menu = bond_context_target ?? bond_context_menu
480
+ if (!menu) return
481
+ set_bond_order(menu.site_idx_1, menu.site_idx_2, order, menu.cell_shift)
482
+ }
336
483
 
337
- // bond_pairs may not be sorted, so use get_bond_key for comparison
338
- const key = `${idx_i}-${idx_j}`
484
+ 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)
490
+ )
339
491
  if (
340
- bond_pairs.some((bond) =>
341
- get_bond_key(bond.site_idx_1, bond.site_idx_2) === key
342
- )
343
- ) removed_bonds = [...removed_bonds, [idx_i, idx_j]]
344
- else added_bonds = [...added_bonds, [idx_i, idx_j]]
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
+ }
502
+
503
+ function remove_context_bond() {
504
+ const menu = bond_context_target ?? bond_context_menu
505
+ if (!menu) return
506
+ remove_bond(menu.site_idx_1, menu.site_idx_2, menu.cell_shift)
345
507
  }
346
508
 
347
509
  // Deduplicate clicks: when a highlight sphere and the underlying atom both
@@ -359,6 +521,7 @@
359
521
  }
360
522
 
361
523
  if (measure_mode === `edit-bonds`) {
524
+ if (!bond_edits_enabled || !is_editable_bond_site(site_index)) return
362
525
  // In edit-bonds mode, select atoms to add/remove bonds between them
363
526
  const new_sites = measured_sites.includes(site_index)
364
527
  ? measured_sites.filter((idx) => idx !== site_index)
@@ -417,6 +580,17 @@
417
580
  ? selected_sites.filter((idx) => idx !== site_index)
418
581
  : [...selected_sites, site_index]
419
582
  }
583
+
584
+ $effect(() => {
585
+ void structure
586
+ void measure_mode
587
+ void bond_edits_enabled
588
+ untrack(() => {
589
+ close_bond_context_menu()
590
+ hovered_bond_key = null
591
+ })
592
+ })
593
+
420
594
  $effect(() => {
421
595
  const count = structure?.sites?.length ?? 0
422
596
  if (count <= 0) {
@@ -604,45 +778,89 @@
604
778
  return has_visible_element && prop_visible
605
779
  }
606
780
 
781
+ // Perception layer: bond_pairs with optional bond-order perception applied.
782
+ // Off by default (pass-through). Manual overrides are applied downstream in
783
+ // filtered_bond_pairs, so they still win over perceived orders.
784
+ let perceived_bond_pairs: BondPair[] = $derived.by(() => {
785
+ if (!auto_bond_order || !structure?.sites || bond_pairs.length === 0) {
786
+ return bond_pairs
787
+ }
788
+ const total_charge = (`charge` in structure ? structure.charge : 0) ?? 0
789
+ const perceived = perceive_bond_orders(structure.sites, bond_pairs, {
790
+ total_charge,
791
+ })
792
+ // Explicit structure.properties.bonds are user-authoritative and must
793
+ // never be clobbered by perception. Composition + precedence is a pure,
794
+ // unit-tested helper (see compose_perceived_bonds).
795
+ return compose_perceived_bonds(
796
+ perceived,
797
+ get_explicit_bond_metadata(structure),
798
+ aromatic_display,
799
+ )
800
+ })
801
+
607
802
  let filtered_bond_pairs = $derived.by(() => {
608
- if (!structure?.sites) return bond_pairs
803
+ if (!structure?.sites) return perceived_bond_pairs
609
804
 
610
805
  // Build set of removed bond keys for efficient lookup
611
806
  const removed_keys = new Set(
612
- removed_bonds.map(([idx_i, idx_j]) => get_bond_key(idx_i, idx_j)),
807
+ removed_bonds.map(bond_key_for),
808
+ )
809
+ const order_overrides = new Map(
810
+ bond_order_overrides.map((bond) => [bond_key_for(bond), bond.order]),
613
811
  )
614
812
 
615
813
  // Filter calculated bonds: exclude removed and hidden
616
- const calculated = bond_pairs.filter(({ site_idx_1, site_idx_2 }) => {
617
- if (removed_keys.has(get_bond_key(site_idx_1, site_idx_2))) return false
618
- return is_site_visible(site_idx_1) && is_site_visible(site_idx_2)
619
- })
814
+ const calculated = perceived_bond_pairs
815
+ .filter((bond) => {
816
+ if (removed_keys.has(bond_key_for(bond))) return false
817
+ return is_site_visible(bond.site_idx_1) && is_site_visible(bond.site_idx_2)
818
+ })
819
+ .map((bond) => {
820
+ const override = order_overrides.get(bond_key_for(bond))
821
+ return override === undefined ? bond : { ...bond, bond_order: override }
822
+ })
620
823
 
621
824
  // Create BondPair objects for manually added bonds
622
- const added: BondPair[] = added_bonds
623
- .map(([idx_i, idx_j]) => {
624
- if (!is_site_visible(idx_i) || !is_site_visible(idx_j)) return null
625
- const site1 = structure.sites[idx_i]
626
- const site2 = structure.sites[idx_j]
627
- if (!site1 || !site2) return null
825
+ const added: BondPair[] = []
826
+ for (const added_bond of added_bonds) {
827
+ const { site_idx_1: idx_i, site_idx_2: idx_j } = added_bond
828
+ if (!is_site_visible(idx_i) || !is_site_visible(idx_j)) continue
829
+ added.push(structure_bond_to_bond_pair(structure, added_bond))
830
+ }
628
831
 
629
- const pos_1 = site1.xyz
630
- const pos_2 = site2.xyz
631
- const dist = math.euclidean_dist(pos_1, pos_2)
832
+ return [...calculated, ...added]
833
+ })
632
834
 
633
- return {
634
- pos_1,
635
- pos_2,
636
- site_idx_1: idx_i,
637
- site_idx_2: idx_j,
638
- bond_length: dist,
639
- strength: 1.0,
640
- transform_matrix: compute_bond_transform(pos_1, pos_2),
641
- }
642
- })
643
- .filter((bond): bond is BondPair => bond !== null)
835
+ let editable_bond_pairs = $derived(
836
+ bond_edits_enabled ? filtered_bond_pairs.filter(can_edit_bond) : [],
837
+ )
644
838
 
645
- return [...calculated, ...added]
839
+ let smart_site_label_offsets = $derived.by(() => {
840
+ const offsets = new SvelteMap<number, Vec3>()
841
+ if (filtered_bond_pairs.length === 0) return offsets
842
+
843
+ const bond_directions_by_site = new SvelteMap<number, Vec3[]>()
844
+ const add_bond_direction = (site_idx: number, pos_1: Vec3, pos_2: Vec3) => {
845
+ const direction = math.normalize_vec3(
846
+ math.subtract(pos_2, pos_1),
847
+ [0, 0, 0],
848
+ )
849
+ if (Math.hypot(...direction) < LABEL_OFFSET_EPS) return
850
+ bond_directions_by_site.set(site_idx, [
851
+ ...(bond_directions_by_site.get(site_idx) ?? []),
852
+ direction,
853
+ ])
854
+ }
855
+
856
+ for (const { site_idx_1, site_idx_2, pos_1, pos_2 } of filtered_bond_pairs) {
857
+ add_bond_direction(site_idx_1, pos_1, pos_2)
858
+ add_bond_direction(site_idx_2, pos_2, pos_1)
859
+ }
860
+ for (const [site_idx, bond_directions] of bond_directions_by_site) {
861
+ offsets.set(site_idx, choose_site_label_offset(bond_directions, site_label_offset))
862
+ }
863
+ return offsets
646
864
  })
647
865
 
648
866
  let instanced_bond_groups = $derived.by(() => {
@@ -673,8 +891,9 @@
673
891
 
674
892
  const color_start = get_majority_color(site_a)
675
893
  const color_end = get_majority_color(site_b)
676
- const instance = { matrix: bond_data.transform_matrix, color_start, color_end }
677
- group.instances.push(instance)
894
+ for (const matrix of get_bond_render_matrices(bond_data, bond_thickness)) {
895
+ group.instances.push({ matrix, color_start, color_end })
896
+ }
678
897
  }
679
898
 
680
899
  return group.instances.length > 0 ? [group] : []
@@ -920,6 +1139,7 @@
920
1139
  onstart: () => {
921
1140
  camera_is_moving = true
922
1141
  hovered_idx = null
1142
+ bond_context_menu = null
923
1143
  },
924
1144
  onend: () => {
925
1145
  camera_is_moving = false
@@ -944,8 +1164,17 @@
944
1164
 
945
1165
  {#snippet site_label_snippet(position: Vec3, site_idx: number)}
946
1166
  {@const site = structure!.sites[site_idx]}
947
- {@const pos = math.add(position, site_label_offset)}
948
- <extras.HTML center position={pos}>
1167
+ {@const visual_radius = (radius_by_site_idx.get(site_idx) ?? 1) * 0.5}
1168
+ <extras.HTML
1169
+ center
1170
+ position={position}
1171
+ calculatePosition={make_label_position_calculator(
1172
+ position,
1173
+ () => smart_site_label_offsets.get(site_idx) ?? site_label_offset,
1174
+ visual_radius,
1175
+ label_screen_margin,
1176
+ )}
1177
+ >
949
1178
  {#if atom_label}
950
1179
  {@render atom_label({ site, site_idx })}
951
1180
  {:else}
@@ -1168,12 +1397,12 @@
1168
1397
  {/if}
1169
1398
 
1170
1399
  <!-- Clickable bond hit-test cylinders in edit-bonds mode -->
1171
- {#if measure_mode === `edit-bonds` && filtered_bond_pairs.length > 0}
1172
- {#each filtered_bond_pairs as
1400
+ {#if measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1401
+ {#each editable_bond_pairs as
1173
1402
  bond
1174
- (`bond-hit-${bond.site_idx_1}-${bond.site_idx_2}`)
1403
+ (`bond-hit-${bond_key_for(bond)}`)
1175
1404
  }
1176
- {@const bond_key = get_bond_key(bond.site_idx_1, bond.site_idx_2)}
1405
+ {@const bond_key = bond_key_for(bond)}
1177
1406
  {@const is_hovered = hovered_bond_key === bond_key}
1178
1407
  <T.Mesh
1179
1408
  matrixAutoUpdate={false}
@@ -1181,13 +1410,19 @@
1181
1410
  ref.matrix.fromArray(bond.transform_matrix)
1182
1411
  ref.matrixWorldNeedsUpdate = true
1183
1412
  }}
1184
- onclick={(event: MouseEvent) => {
1413
+ onpointerdown={(event: PointerEvent & { nativeEvent?: PointerEvent }) => {
1414
+ if (event.nativeEvent?.button === 2) return
1185
1415
  event.stopPropagation()
1186
- toggle_bond(bond.site_idx_1, bond.site_idx_2)
1416
+ toggle_bond(bond.site_idx_1, bond.site_idx_2, bond.cell_shift)
1187
1417
  measured_sites = []
1188
1418
  selected_sites = []
1189
1419
  hovered_bond_key = null
1190
1420
  }}
1421
+ oncontextmenu={(event: MouseEvent & { nativeEvent?: MouseEvent }) => {
1422
+ event.nativeEvent?.preventDefault()
1423
+ event.stopPropagation?.()
1424
+ open_bond_context_menu(bond)
1425
+ }}
1191
1426
  onpointerenter={() => (hovered_bond_key = bond_key)}
1192
1427
  onpointerleave={() => (hovered_bond_key = null)}
1193
1428
  >
@@ -1202,6 +1437,70 @@
1202
1437
  {/each}
1203
1438
  {/if}
1204
1439
 
1440
+ {#if measure_mode === `edit-bonds` && bond_context_menu}
1441
+ {@const current_order = get_current_bond_order(
1442
+ bond_context_menu.site_idx_1,
1443
+ bond_context_menu.site_idx_2,
1444
+ bond_context_menu.cell_shift,
1445
+ )}
1446
+ <extras.HTML center position={bond_context_menu.position}>
1447
+ <div class="bond-context-menu">
1448
+ <strong>Bond Order ({format_bond_order(current_order)})</strong>
1449
+ {#each BOND_ORDER_OPTIONS as { order, label } (label)}
1450
+ <button
1451
+ type="button"
1452
+ onpointerdown={(event: PointerEvent) => {
1453
+ event.preventDefault()
1454
+ event.stopPropagation()
1455
+ set_context_bond_order(order)
1456
+ }}
1457
+ onkeydown={(event: KeyboardEvent) => {
1458
+ if (event.key !== `Enter` && event.key !== ` `) return
1459
+ event.preventDefault()
1460
+ event.stopPropagation()
1461
+ set_context_bond_order(order)
1462
+ }}
1463
+ >
1464
+ {label}
1465
+ </button>
1466
+ {/each}
1467
+ <button
1468
+ type="button"
1469
+ class="remove"
1470
+ onpointerdown={(event: PointerEvent) => {
1471
+ event.preventDefault()
1472
+ event.stopPropagation()
1473
+ remove_context_bond()
1474
+ }}
1475
+ onkeydown={(event: KeyboardEvent) => {
1476
+ if (event.key !== `Enter` && event.key !== ` `) return
1477
+ event.preventDefault()
1478
+ event.stopPropagation()
1479
+ remove_context_bond()
1480
+ }}
1481
+ >
1482
+ Remove
1483
+ </button>
1484
+ <button
1485
+ type="button"
1486
+ onpointerdown={(event: PointerEvent) => {
1487
+ event.preventDefault()
1488
+ event.stopPropagation()
1489
+ close_bond_context_menu()
1490
+ }}
1491
+ onkeydown={(event: KeyboardEvent) => {
1492
+ if (event.key !== `Enter` && event.key !== ` `) return
1493
+ event.preventDefault()
1494
+ event.stopPropagation()
1495
+ close_bond_context_menu()
1496
+ }}
1497
+ >
1498
+ Close
1499
+ </button>
1500
+ </div>
1501
+ </extras.HTML>
1502
+ {/if}
1503
+
1205
1504
  <!-- highlight hovered, active and selected sites -->
1206
1505
  {#each [
1207
1506
  {
@@ -1275,7 +1574,8 @@
1275
1574
  {/if}
1276
1575
 
1277
1576
  <!-- hovered site tooltip -->
1278
- {#if hovered_site && !camera_is_moving && active_tooltip === `atom`}
1577
+ {#if hovered_site && !camera_is_moving &&
1578
+ (active_tooltip === `atom` || active_sites.includes(hovered_idx ?? -1))}
1279
1579
  {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(
1280
1580
  `, `,
1281
1581
  )}
@@ -1557,6 +1857,37 @@
1557
1857
  font-size: var(--canvas-tooltip-font-size, clamp(8pt, 2cqmin, 18pt));
1558
1858
  box-shadow: var(--measure-label-shadow, 0 1px 6px rgba(0, 0, 0, 0.2));
1559
1859
  }
1860
+ .bond-context-menu {
1861
+ display: grid;
1862
+ min-width: 8rem;
1863
+ gap: 2pt;
1864
+ padding: 5pt;
1865
+ border-radius: var(--border-radius, 3pt);
1866
+ background: var(--surface-bg, Canvas);
1867
+ color: var(--text-color, currentColor);
1868
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
1869
+ pointer-events: auto;
1870
+ strong {
1871
+ font-size: 0.85em;
1872
+ padding: 0 2pt 2pt;
1873
+ white-space: nowrap;
1874
+ }
1875
+ button {
1876
+ border: none;
1877
+ border-radius: var(--border-radius, 3pt);
1878
+ background: transparent;
1879
+ color: inherit;
1880
+ cursor: pointer;
1881
+ padding: 2pt 5pt;
1882
+ text-align: left;
1883
+ }
1884
+ button:hover {
1885
+ background: color-mix(in srgb, currentColor 10%, transparent);
1886
+ }
1887
+ button.remove {
1888
+ color: var(--error-color, #f44336);
1889
+ }
1890
+ }
1560
1891
  .selection-label {
1561
1892
  display: inline-flex;
1562
1893
  align-items: center;
@@ -3,7 +3,7 @@ import type { ElementSymbol } from '../element';
3
3
  import type { IsosurfaceSettings, VolumetricData } from '../isosurface/types';
4
4
  import type { Vec3 } from '../math';
5
5
  import type { CameraProjection, ShowBonds, VectorColorMode, VectorLayerConfig } from '../settings';
6
- import type { AnyStructure, MeasureMode, Site } from './';
6
+ import type { AnyStructure, MeasureMode, Site, StructureBond } from './';
7
7
  import { Lattice } from './';
8
8
  import type { AtomColorConfig } from './atom-properties';
9
9
  import type { MoyoDataset } from '@spglib/moyo-wasm';
@@ -51,6 +51,8 @@ type $$ComponentProps = {
51
51
  bond_thickness?: number;
52
52
  bond_color?: string;
53
53
  bonding_strategy?: BondingStrategy;
54
+ auto_bond_order?: boolean;
55
+ aromatic_display?: `aromatic` | `kekule`;
54
56
  bonding_options?: Record<string, unknown>;
55
57
  fov?: number;
56
58
  ambient_light?: number;
@@ -72,8 +74,10 @@ type $$ComponentProps = {
72
74
  measure_mode?: MeasureMode;
73
75
  selected_sites?: number[];
74
76
  measured_sites?: number[];
75
- added_bonds?: [number, number][];
76
- removed_bonds?: [number, number][];
77
+ added_bonds?: StructureBond[];
78
+ removed_bonds?: StructureBond[];
79
+ bond_order_overrides?: StructureBond[];
80
+ bond_edits_enabled?: boolean;
77
81
  selection_highlight_color?: string;
78
82
  active_sites?: number[];
79
83
  active_highlight_color?: string;
@@ -99,6 +103,6 @@ type $$ComponentProps = {
99
103
  volumetric_data?: VolumetricData;
100
104
  isosurface_settings?: IsosurfaceSettings;
101
105
  };
102
- declare const StructureScene: import("svelte").Component<$$ComponentProps, {}, "cursor" | "site_label_offset" | "vector_configs" | "camera" | "scene" | "orbit_controls" | "hidden_elements" | "hidden_prop_vals" | "element_radius_overrides" | "site_radius_overrides" | "selected_sites" | "hovered_idx" | "hovered_site" | "camera_is_moving" | "measured_sites" | "added_bonds" | "removed_bonds" | "active_sites" | "rotation_target_ref" | "initial_computed_zoom" | "add_atom_mode" | "add_element" | "dragging_atoms">;
106
+ declare const StructureScene: import("svelte").Component<$$ComponentProps, {}, "cursor" | "site_label_offset" | "vector_configs" | "camera" | "scene" | "orbit_controls" | "site_radius_overrides" | "hovered_idx" | "hovered_site" | "camera_is_moving" | "selected_sites" | "measured_sites" | "added_bonds" | "removed_bonds" | "bond_order_overrides" | "active_sites" | "rotation_target_ref" | "initial_computed_zoom" | "hidden_elements" | "hidden_prop_vals" | "element_radius_overrides" | "add_atom_mode" | "add_element" | "dragging_atoms">;
103
107
  type StructureScene = ReturnType<typeof StructureScene>;
104
108
  export default StructureScene;