@ccp-nc/crystvis-js 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.mocharc.yml ADDED
@@ -0,0 +1,4 @@
1
+ require:
2
+ - ./test/setup-dom.cjs
3
+ spec: test/*.js
4
+ reporter: spec
@@ -1,3 +1,7 @@
1
1
  {
2
- "python.pythonPath": "/home/phony_stark/miniconda2/bin/python"
2
+ "files.watcherExclude": {
3
+ "**/node_modules/**": true,
4
+ "**/.git/objects/**": true,
5
+ "**/.git/subtree-cache/**": true
6
+ }
3
7
  }
package/CHANGELOG.md CHANGED
@@ -5,26 +5,44 @@ All notable changes to crystvis-js will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.6.0] - 2025-07-18
9
-
10
- ### Changed
11
- - Updated Three.js from version 0.137 to 0.178
12
- - Fixed color handling across all primitives to work with new Three.js color management
13
- - Fixed shader handling for GLSL 3.0 compatibility
14
- - Improved text rendering with better color handling and transparency
15
- - Refined material properties for better appearance in the new renderer
8
+ ## [0.7.0] - 2026-03-11
16
9
 
17
10
  ### Added
18
- - Documentation on Three.js migration
19
- - Improved error handling for shader compilation
20
- - Better color space handling for all primitives
11
+ - `CrystVis.dispose()` full teardown of the WebGL context, animation loop, orbit
12
+ controls, resize observer and all event listeners. `isDisposed` getter reflects state.
13
+ Methods throw if called on a disposed instance. (#17)
14
+ - `CrystVis.getCameraState()` / `setCameraState(state)` — snapshot and restore the
15
+ camera position, target and zoom level as a plain serialisable object. (#20)
16
+ - `CrystVis.onCameraChange(callback)` — subscribe to live camera-change events
17
+ (rotate / pan / zoom); returns an unsubscribe function. (#20)
18
+ - `CrystVis.getModelSource(name)` — retrieve the raw file text and extension originally
19
+ passed to `loadModels()`. (#20)
20
+ - `CrystVis.getModelParameters(name)` — retrieve the merged loading parameters used when
21
+ a model was last loaded or reloaded. (#20)
22
+ - `CrystVis.getModelMeta(name)` — retrieve `{ prefix, originalName }` metadata stored at
23
+ load time. (#20)
24
+ - `CrystVis.onModelListChange(callback)` — subscribe to events fired whenever the set of
25
+ loaded models changes (load, delete, unloadAll); returns an unsubscribe function. (#20)
26
+ - `CrystVis.onDisplayChange(callback)` — subscribe to events fired whenever the displayed
27
+ model changes; callback receives the new model name or `null`. (#20)
28
+ - `CrystVis.unloadAll()` — remove all loaded models atomically (one renderer clear, one
29
+ set of change events). (#20)
30
+ - `ModelView.toIndices()` — serialise a selection to a plain index array. (#20)
31
+ - `ModelView.toLabels()` — serialise a selection to an array of crystallographic site
32
+ labels (`crystLabel`), resilient to atom-index reordering. (#20)
33
+ - `Model.viewFromIndices(indices)` — reconstruct a `ModelView` from a saved index array. (#20)
34
+ - `Model.viewFromLabels(labels)` — reconstruct a `ModelView` from a saved label array. (#20)
35
+ - `Renderer.getCameraState()` / `setCameraState(state)` / `onCameraChange(callback)` —
36
+ lower-level camera state API used by `CrystVis`. (#20)
37
+ - Visualisation of hyperfine tensors from `magres_old` blocks in Magres files as
38
+ ellipsoids. (#19)
21
39
 
22
40
  ### Fixed
23
- - Color issues when upgrading to Three.js 0.178
24
- - Text rendering and transparency problems
25
- - Shader compilation errors with GLSL 3.0
26
- - EllipsoidMesh color handling with different material types
27
- - Proper handling of atoms and bond materials
41
+ - `loadModels()` return value corrected in README and JSDoc: it returns a status object
42
+ (keys = model names, values = `0` or error string), not an array. (#18)
43
+
44
+ ### Changed
45
+ - Updated several dependencies. (#12, #13, #16)
28
46
 
29
47
  ## [0.6.1] - 2026-01-22
30
48
 
@@ -34,3 +52,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
52
  - Improved Extended XYZ parsing: stricter handling of atom lines and properties to avoid accidental token merging when input contains stray newlines.
35
53
  - Parser now reports clearer errors for malformed Extended XYZ files.
36
54
 
55
+ ## [0.6.0] - 2025-07-18
56
+
57
+ ### Added
58
+ - Documentation on Three.js migration.
59
+ - Improved error handling for shader compilation.
60
+ - Better color space handling for all primitives.
61
+
62
+ ### Changed
63
+ - Updated Three.js from version 0.137 to 0.178.
64
+ - Fixed color handling across all primitives to work with new Three.js color management.
65
+ - Fixed shader handling for GLSL 3.0 compatibility.
66
+ - Improved text rendering with better color handling and transparency.
67
+ - Refined material properties for better appearance in the new renderer.
68
+
69
+ ### Fixed
70
+ - Color issues when upgrading to Three.js 0.178.
71
+ - Text rendering and transparency problems.
72
+ - Shader compilation errors with GLSL 3.0.
73
+ - EllipsoidMesh color handling with different material types.
74
+ - Proper handling of atoms and bond materials.
package/README.md CHANGED
@@ -36,7 +36,7 @@ You can then create a visualizer for your webpage by simply importing and instan
36
36
  ```js
37
37
  import CrystVis from 'crystvis-js';
38
38
 
39
- const visualizer = CrystVis('#target-id', 800, 600)
39
+ const visualizer = new CrystVis('#target-id', 800, 600)
40
40
  ```
41
41
 
42
42
  will create an 800x600 canvas with the visualizer inside the element specified by the given selector. To load a model, simply load the contents of your file as a text string and then pass them to the visualizer's `loadModels` method:
@@ -44,7 +44,80 @@ will create an 800x600 canvas with the visualizer inside the element specified b
44
44
  ```js
45
45
  var loaded = visualizer.loadModels(contents);
46
46
  console.log('Models loaded: ', loaded);
47
- visualizer.displayModel(loaded[0])
47
+ // loaded is an object: keys are model names, values are 0 (success) or an error string
48
+ var modelName = Object.keys(loaded)[0];
49
+ if (loaded[modelName] !== 0) {
50
+ console.error('Failed to load model:', loaded[modelName]);
51
+ } else {
52
+ visualizer.displayModel(modelName);
53
+ }
54
+ ```
55
+
56
+ ### API highlights
57
+
58
+ Full JSDoc documentation is available at [ccp-nc.github.io/crystvis-js](https://ccp-nc.github.io/crystvis-js/).
59
+
60
+ #### Camera state — save, restore and react to view changes
61
+
62
+ ```js
63
+ // Snapshot the current camera (position, target, zoom) — plain JSON-serialisable object
64
+ const snap = visualizer.getCameraState();
65
+ // { position: {x,y,z}, target: {x,y,z}, zoom: 1 }
66
+
67
+ // Restore a previously saved snapshot
68
+ visualizer.setCameraState(snap);
69
+
70
+ // React to every rotate/pan/zoom (returns an unsubscribe function)
71
+ const unsub = visualizer.onCameraChange(state => {
72
+ console.log('Camera moved:', state);
73
+ });
74
+ unsub(); // stop listening
75
+ ```
76
+
77
+ #### Lifecycle events — react to model and display changes
78
+
79
+ ```js
80
+ // Fired whenever models are loaded, deleted, or all cleared
81
+ const unsubList = visualizer.onModelListChange(names => {
82
+ console.log('Loaded models:', names);
83
+ });
84
+
85
+ // Fired whenever displayModel() completes; receives model name or null
86
+ const unsubDisplay = visualizer.onDisplayChange(name => {
87
+ console.log('Now displaying:', name);
88
+ });
89
+
90
+ // Remove all loaded models in one atomic operation
91
+ visualizer.unloadAll();
92
+ ```
93
+
94
+ #### Model metadata — access source and parameters after loading
95
+
96
+ ```js
97
+ // Retrieve the raw file text and extension originally passed to loadModels()
98
+ const src = visualizer.getModelSource(modelName);
99
+ // { text: '...', extension: 'cif' }
100
+
101
+ // Retrieve the merged loading parameters (supercell, molecularCrystal, …)
102
+ const params = visualizer.getModelParameters(modelName);
103
+
104
+ // Retrieve prefix and original structure name
105
+ const meta = visualizer.getModelMeta(modelName);
106
+ // { prefix: 'cif', originalName: 'struct' }
107
+ ```
108
+
109
+ #### Selection serialisation — save and reconstruct atom subsets
110
+
111
+ ```js
112
+ // Serialise a selection to plain data
113
+ const indices = visualizer.selected.toIndices(); // number[]
114
+ const labels = visualizer.selected.toLabels(); // string[] (crystLabel per atom)
115
+
116
+ // Reconstruct from indices later
117
+ visualizer.selected = visualizer.model.viewFromIndices(indices);
118
+
119
+ // Reconstruct from labels — resilient to atom-index reordering on reload
120
+ visualizer.selected = visualizer.model.viewFromLabels(labels);
48
121
  ```
49
122
 
50
123
  ### Preparing for development
package/audit.txt CHANGED
@@ -1,37 +1,24 @@
1
1
  ## Security Audit
2
2
  # npm audit report
3
3
 
4
- elliptic *
5
- Elliptic Uses a Cryptographic Primitive with a Risky Implementation - https://github.com/advisories/GHSA-848j-6mx2-7j84
6
- No fix available
7
- node_modules/elliptic
8
-
9
- 1 low severity vulnerability
10
-
11
- Some issues need review, and may require choosing
12
- a different dependency.
13
-
14
- ## Security Audit
15
- # npm audit report
16
-
17
- diff <8.0.3
4
+ diff 6.0.0 - 8.0.2
18
5
  jsdiff has a Denial of Service vulnerability in parsePatch and applyPatch - https://github.com/advisories/GHSA-73rr-hh4g-fpgx
19
6
  fix available via `npm audit fix --force`
20
- Will install mocha@0.13.0, which is a breaking change
7
+ Will install mocha@11.3.0, which is a breaking change
21
8
  node_modules/diff
22
- mocha 0.14.0 - 12.0.0-beta-3
9
+ mocha 8.0.0 - 12.0.0-beta-3
23
10
  Depends on vulnerable versions of diff
11
+ Depends on vulnerable versions of serialize-javascript
24
12
  node_modules/mocha
25
13
 
26
- elliptic *
27
- Elliptic Uses a Cryptographic Primitive with a Risky Implementation - https://github.com/advisories/GHSA-848j-6mx2-7j84
28
- No fix available
29
- node_modules/elliptic
14
+ serialize-javascript <=7.0.2
15
+ Severity: high
16
+ Serialize JavaScript is Vulnerable to RCE via RegExp.flags and Date.prototype.toISOString() - https://github.com/advisories/GHSA-5c6j-r48x-rmvq
17
+ fix available via `npm audit fix --force`
18
+ Will install mocha@11.3.0, which is a breaking change
19
+ node_modules/serialize-javascript
30
20
 
31
- 3 low severity vulnerabilities
21
+ 3 vulnerabilities (1 low, 2 high)
32
22
 
33
- To address all issues possible (including breaking changes), run:
23
+ To address all issues (including breaking changes), run:
34
24
  npm audit fix --force
35
-
36
- Some issues need review, and may require choosing
37
- a different dependency.
package/changes.txt CHANGED
@@ -1,27 +1,2 @@
1
1
  ## Updated Packages
2
- - "chroma-js": "^3.1.2",
3
- + "chroma-js": "^3.2.0",
4
- - "mathjs": "^14.5.3",
5
- + "mathjs": "^14.9.1",
6
- - "@babel/eslint-parser": "^7.28.0",
7
- - "chai": "^5.2.1",
8
- + "@babel/eslint-parser": "^7.28.6",
9
- + "chai": "^5.3.3",
10
- - "elliptic": ">=6.6.1",
11
- - "esbuild": "^0.25.6",
12
- - "eslint": "^9.31.0",
13
- + "elliptic": "^6.6.1",
14
- + "esbuild": "^0.25.12",
15
- + "eslint": "^9.39.2",
16
- - "glob": "^11.0.3",
17
- - "jpeg-js": ">=0.4.4",
18
- - "jsdoc": "^4.0.4",
19
- + "glob": "^11.1.0",
20
- + "jpeg-js": "^0.4.4",
21
- + "jsdoc": "^4.0.5",
22
- - "mocha": "^11.7.1",
23
- - "msdf-bmfont-xml": "^2.7.0",
24
- + "mocha": "^11.7.5",
25
- + "msdf-bmfont-xml": "^2.8.0",
26
- - "serve": "^14.2.4"
27
- + "serve": "^14.2.5"
2
+ See package.json diff for details
package/demo/demo.css CHANGED
@@ -18,13 +18,18 @@
18
18
  left: 0;
19
19
  }
20
20
 
21
- #colorgrid {
22
- display: grid;
23
- grid-template-rows: repeat(10, 1fr);
24
- grid-template-columns: repeat(30, 1fr);
25
-
26
- width: 300px;
27
- height: 300px;
28
-
29
- background-color: #000000;
30
- }
21
+ #error-banner {
22
+ position: fixed;
23
+ top: 0;
24
+ left: 0;
25
+ width: 100%;
26
+ padding: 10px 16px;
27
+ background-color: #b00020;
28
+ color: #fff;
29
+ font-family: sans-serif;
30
+ font-size: 14px;
31
+ font-weight: bold;
32
+ z-index: 9999;
33
+ box-sizing: border-box;
34
+ cursor: pointer;
35
+ }
package/demo/index.html CHANGED
@@ -16,6 +16,8 @@
16
16
  Loading...
17
17
  </div>
18
18
 
19
+ <div id="error-banner" style="display:none"></div>
20
+
19
21
  <div style="float: right; width: 30%">
20
22
  <table>
21
23
  <caption>Input controls</caption>
@@ -52,12 +54,19 @@
52
54
  <input id="label-check" type="checkbox" name="" value="false" onchange="changeLabels()">Show labels
53
55
  </td>
54
56
  <td colspan="" rowspan="" headers="">
55
- <input id="ellipsoid-check" type="checkbox" name="" value="false" onchange="changeEllipsoids()">Show ellipsoids
57
+ <input id="ellipsoid-check" type="checkbox" name="" value="false" onchange="changeEllipsoids()" disabled>Show MS ellipsoids<br>
58
+ Scale: <input id="ms-scale" type="range" min="0.001" max="0.5" step="0.001" value="0.05" oninput="changeMsScale()" disabled> <span id="ms-scale-val">0.050</span>
59
+ </td>
60
+ </tr>
61
+ <tr>
62
+ <td colspan="" rowspan="" headers="">
63
+ <input id="hf-ellipsoid-check" type="checkbox" name="" value="false" onchange="changeHFEllipsoids()" disabled>Show HF ellipsoids<br>
64
+ Scale: <input id="hf-scale" type="range" min="0.001" max="1.0" step="0.001" value="0.1" oninput="changeHfScale()" disabled> <span id="hf-scale-val">0.100</span>
56
65
  </td>
57
66
  </tr>
58
67
  <tr>
59
68
  <td>
60
- <input id="isosurf-check" type="checkbox" name="" value="false" onchange="changeIsosurface()">Show isosurface
69
+ <input id="isosurf-check" type="checkbox" name="" value="false" onchange="changeIsosurface()" disabled>Show isosurface
61
70
  </td>
62
71
  <td>
63
72
  <input type="text" id="vdw-f" size="5" value="1.0"> Van der Waals scaling
@@ -77,11 +86,29 @@
77
86
  </td>
78
87
 
79
88
  </tr>
89
+ <tr>
90
+ <td colspan="2"><hr style="margin:6px 0"><strong>Camera state</strong></td>
91
+ </tr>
92
+ <tr>
93
+ <td>
94
+ <input type="button" value="Save view" onclick="saveCamera()">
95
+ <input type="button" value="Restore view" onclick="restoreCamera()">
96
+ </td>
97
+ <td>
98
+ <input type="button" value="Copy JSON" onclick="copyCameraJSON()">
99
+ <input type="button" value="Apply JSON" onclick="applyPastedJSON()">
100
+ </td>
101
+ </tr>
102
+ <tr>
103
+ <td colspan="2">
104
+ <textarea id="camera-json" rows="5" style="width:100%;font-family:monospace;font-size:11px;box-sizing:border-box" placeholder="Camera state will appear here…" spellcheck="false"></textarea>
105
+ </td>
106
+ </tr>
107
+ <tr>
108
+ <td colspan="2" id="camera-status" style="font-size:11px;color:#555"></td>
109
+ </tr>
80
110
  </tbody>
81
111
  </table>
82
- <div id='colorgrid'>
83
-
84
- </div>
85
112
  </div>
86
113
 
87
114
  </div>
package/demo/main.js CHANGED
@@ -3,43 +3,15 @@
3
3
  const CrystVis = require('../lib/visualizer.js').CrystVis;
4
4
  const Primitives = require('../lib/primitives/index.js');
5
5
 
6
- const shiftCpkColor = require('../lib/utils').shiftCpkColor;
7
-
8
6
  var visualizer = new CrystVis('#main-app', 0, 0);
9
7
  visualizer.highlight_selected = true;
10
8
  visualizer.theme = 'dark';
11
9
 
12
- // Generate color grid (for testing shiftCpkColor)
13
- const gridEl = document.getElementById('colorgrid');
14
- const gridSize = 10;
15
-
16
- function int2hex(c) {
17
- c = c.toString(16);
18
- return '0'.repeat(6-c.length) + c;
19
- }
20
-
21
- for (let i = 0; i < gridSize; ++i) {
22
- for (let j = 0; j < gridSize; ++j) {
23
-
24
- const hue = parseInt(j/gridSize*360);
25
- const light = parseInt(i/(gridSize-1)*100);
26
- const cbase = `hsl(${hue}, 100%, ${light}%)`;
27
- const cplus = shiftCpkColor(cbase, 1.0);
28
- const cminus = shiftCpkColor(cbase, -1.0);
29
-
30
- let el = document.createElement('div');
31
- el.style['background-color'] = '#' + int2hex(cminus);
32
- gridEl.append(el);
33
-
34
- el = document.createElement('div');
35
- el.style['background-color'] = cbase;
36
- gridEl.append(el);
37
-
38
- el = document.createElement('div');
39
- el.style['background-color'] = '#' + int2hex(cplus);
40
- gridEl.append(el);
41
-
42
- }
10
+ function showError(msg) {
11
+ var banner = document.getElementById('error-banner');
12
+ banner.textContent = msg + ' (click to dismiss)';
13
+ banner.style.display = 'block';
14
+ banner.onclick = function() { banner.style.display = 'none'; };
43
15
  }
44
16
 
45
17
  window.loadFile = function() {
@@ -57,17 +29,54 @@ window.loadFile = function() {
57
29
  reader.onload = function() {
58
30
  var mcryst = document.getElementById('molcryst-check').checked;
59
31
  var name = file.name.split('.')[0];
60
- var loaded = visualizer.loadModels(reader.result, extension, name, {
61
- supercell: [sx, sy, sz],
62
- molecularCrystal: mcryst,
63
- vdwScaling: vdwf
64
- });
32
+ var loaded;
33
+ try {
34
+ loaded = visualizer.loadModels(reader.result, extension, name, {
35
+ supercell: [sx, sy, sz],
36
+ molecularCrystal: mcryst,
37
+ vdwScaling: vdwf
38
+ });
39
+ } catch (e) {
40
+ showError('Could not load file: ' + e.message);
41
+ return;
42
+ }
65
43
 
66
- visualizer.displayModel(Object.keys(loaded)[0]);
44
+ var modelName = Object.keys(loaded)[0];
45
+ if (loaded[modelName] !== 0) {
46
+ showError('Failed to load "' + modelName + '": ' + loaded[modelName]);
47
+ return;
48
+ }
49
+ visualizer.displayModel(modelName);
67
50
  visualizer.displayed = visualizer.model.find({
68
51
  'all': []
69
52
  });
70
53
 
54
+ // Update checkbox/slider states based on available data in the loaded model
55
+ var model = visualizer.model;
56
+
57
+ var ellipsoidCheck = document.getElementById('ellipsoid-check');
58
+ var msSlider = document.getElementById('ms-scale');
59
+ var hasMs = model.hasArray('ms');
60
+ ellipsoidCheck.disabled = !hasMs;
61
+ msSlider.disabled = !hasMs;
62
+ if (!hasMs && ellipsoidCheck.checked) {
63
+ ellipsoidCheck.checked = false;
64
+ visualizer.displayed.removeEllipsoids('ms');
65
+ }
66
+
67
+ var hfEllipsoidCheck = document.getElementById('hf-ellipsoid-check');
68
+ var hfSlider = document.getElementById('hf-scale');
69
+ var hasHf = model.hasArray('hf');
70
+ hfEllipsoidCheck.disabled = !hasHf;
71
+ hfSlider.disabled = !hasHf;
72
+ if (!hasHf && hfEllipsoidCheck.checked) {
73
+ hfEllipsoidCheck.checked = false;
74
+ visualizer.displayed.removeEllipsoids('hf');
75
+ }
76
+
77
+ // Isosurface only makes sense once a model (with a cell) is loaded
78
+ document.getElementById('isosurf-check').disabled = false;
79
+
71
80
  };
72
81
  }
73
82
 
@@ -89,21 +98,55 @@ window.changeLabels = function() {
89
98
 
90
99
  window.changeEllipsoids = function() {
91
100
  var val = document.getElementById('ellipsoid-check').checked;
101
+ var scale = parseFloat(document.getElementById('ms-scale').value);
92
102
  if (val) {
93
103
  visualizer.displayed.find({
94
104
  'elements': 'H'
95
105
  }).addEllipsoids((a) => {
96
106
  return a.getArrayValue('ms');
97
107
  }, 'ms', {
98
- scalingFactor: 0.05,
108
+ scalingFactor: scale,
99
109
  opacity: 0.2
100
110
  });
101
-
102
111
  } else {
103
112
  visualizer.displayed.removeEllipsoids('ms');
104
113
  }
105
114
  }
106
115
 
116
+ window.changeMsScale = function() {
117
+ document.getElementById('ms-scale-val').textContent = parseFloat(document.getElementById('ms-scale').value).toFixed(3);
118
+ if (document.getElementById('ellipsoid-check').checked) {
119
+ window.changeEllipsoids();
120
+ }
121
+ }
122
+
123
+ window.changeHFEllipsoids = function() {
124
+ var val = document.getElementById('hf-ellipsoid-check').checked;
125
+ var scale = parseFloat(document.getElementById('hf-scale').value);
126
+ if (val) {
127
+ // Show HF (hyperfine) tensor ellipsoids for all atoms that have data
128
+ visualizer.displayed.addEllipsoids((a) => {
129
+ try {
130
+ return a.getArrayValue('hf');
131
+ } catch(e) {
132
+ return null;
133
+ }
134
+ }, 'hf', {
135
+ scalingFactor: scale,
136
+ opacity: 0.3
137
+ });
138
+ } else {
139
+ visualizer.displayed.removeEllipsoids('hf');
140
+ }
141
+ }
142
+
143
+ window.changeHfScale = function() {
144
+ document.getElementById('hf-scale-val').textContent = parseFloat(document.getElementById('hf-scale').value).toFixed(3);
145
+ if (document.getElementById('hf-ellipsoid-check').checked) {
146
+ window.changeHFEllipsoids();
147
+ }
148
+ }
149
+
107
150
  var isosurface = null;
108
151
  window.changeIsosurface = function() {
109
152
  var val = document.getElementById('isosurf-check').checked;
@@ -152,4 +195,68 @@ window.displayMessage = function() {
152
195
  // clear messages
153
196
  window.clearMessages = function() {
154
197
  visualizer.clearNotifications();
155
- }
198
+ }
199
+
200
+ // ─── Camera state demo ───────────────────────────────────────────────────────
201
+
202
+ // Keep a snapshot of the last saved camera state so Restore can use it.
203
+ var _savedCameraState = null;
204
+
205
+ /** Pretty-print a camera state snapshot into the textarea. */
206
+ function updateCameraTextarea(state) {
207
+ var ta = document.getElementById('camera-json');
208
+ if (ta) ta.value = JSON.stringify(state, null, 2);
209
+ }
210
+
211
+ /** Mark the status line briefly then clear it. */
212
+ function setCameraStatus(msg) {
213
+ var el = document.getElementById('camera-status');
214
+ if (!el) return;
215
+ el.textContent = msg;
216
+ clearTimeout(el._timer);
217
+ el._timer = setTimeout(() => { el.textContent = ''; }, 2500);
218
+ }
219
+
220
+ // Live-update the textarea whenever the user rotates / pans / zooms.
221
+ visualizer.onCameraChange(function(state) {
222
+ updateCameraTextarea(state);
223
+ });
224
+
225
+ /** Save the current camera position so it can be restored later. */
226
+ window.saveCamera = function() {
227
+ _savedCameraState = visualizer.getCameraState();
228
+ updateCameraTextarea(_savedCameraState);
229
+ setCameraStatus('View saved.');
230
+ };
231
+
232
+ /** Restore the most recently saved camera snapshot. */
233
+ window.restoreCamera = function() {
234
+ if (!_savedCameraState) {
235
+ setCameraStatus('Nothing saved yet — click Save view first.');
236
+ return;
237
+ }
238
+ visualizer.setCameraState(_savedCameraState);
239
+ setCameraStatus('View restored.');
240
+ };
241
+
242
+ /** Copy the current textarea JSON to the clipboard. */
243
+ window.copyCameraJSON = function() {
244
+ var ta = document.getElementById('camera-json');
245
+ if (!ta || !ta.value) { setCameraStatus('Nothing to copy yet.'); return; }
246
+ navigator.clipboard.writeText(ta.value)
247
+ .then(() => setCameraStatus('Copied to clipboard.'))
248
+ .catch(() => { ta.select(); document.execCommand('copy'); setCameraStatus('Copied (fallback).'); });
249
+ };
250
+
251
+ /** Parse whatever is in the textarea and apply it as the camera state. */
252
+ window.applyPastedJSON = function() {
253
+ var ta = document.getElementById('camera-json');
254
+ if (!ta || !ta.value) { setCameraStatus('Textarea is empty.'); return; }
255
+ try {
256
+ var state = JSON.parse(ta.value);
257
+ visualizer.setCameraState(state);
258
+ setCameraStatus('Camera state applied.');
259
+ } catch (e) {
260
+ setCameraStatus('Invalid JSON: ' + e.message);
261
+ }
262
+ };