simview 3.2__py3-none-any.whl

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 (163) hide show
  1. simview/__init__.py +69 -0
  2. simview/__main__.py +174 -0
  3. simview/launcher.py +109 -0
  4. simview/live.py +204 -0
  5. simview/merge.py +593 -0
  6. simview/model.py +690 -0
  7. simview/py.typed +0 -0
  8. simview/scene.py +603 -0
  9. simview/server.py +739 -0
  10. simview/state.py +190 -0
  11. simview/static/css/controls.css +36 -0
  12. simview/static/js/SimView.js +601 -0
  13. simview/static/js/components/AnimationController.js +654 -0
  14. simview/static/js/components/BatchManager.js +198 -0
  15. simview/static/js/components/InteractionController.js +446 -0
  16. simview/static/js/components/InteractionControls.js +116 -0
  17. simview/static/js/components/Scene.js +166 -0
  18. simview/static/js/components/StateStore.js +188 -0
  19. simview/static/js/config.js +233 -0
  20. simview/static/js/main.js +3 -0
  21. simview/static/js/objects/Body.js +616 -0
  22. simview/static/js/objects/StaticObject.js +181 -0
  23. simview/static/js/objects/Terrain.js +514 -0
  24. simview/static/js/objects/utils.js +378 -0
  25. simview/static/js/ui/AnalysisPanel.js +233 -0
  26. simview/static/js/ui/BatchLegend.js +207 -0
  27. simview/static/js/ui/BodyStateWindow.js +521 -0
  28. simview/static/js/ui/Controls.js +586 -0
  29. simview/static/js/ui/ErrorMetrics.js +664 -0
  30. simview/static/js/ui/Legend.js +87 -0
  31. simview/static/js/ui/PlaybackControls.js +314 -0
  32. simview/static/js/ui/ScalarPlotter.js +575 -0
  33. simview/static/js/utils/blobCodec.js +73 -0
  34. simview/static/js/utils/bodyTransforms.js +151 -0
  35. simview/static/js/utils/csv.js +46 -0
  36. simview/static/js/utils/errorMath.js +69 -0
  37. simview/static/js/utils/injectStyles.js +16 -0
  38. simview/static/js/utils/interpolate.js +65 -0
  39. simview/static/js/utils/liveFollow.js +17 -0
  40. simview/static/js/utils/loadRecordingLibs.js +34 -0
  41. simview/static/js/utils/viewState.js +226 -0
  42. simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
  43. simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
  44. simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
  45. simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
  46. simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
  47. simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
  48. simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
  49. simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
  50. simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
  51. simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
  52. simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
  53. simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
  54. simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
  55. simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
  56. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
  57. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
  58. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
  59. simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
  60. simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
  61. simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
  62. simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
  63. simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
  64. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
  65. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
  66. simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
  67. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
  68. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
  69. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
  70. simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
  71. simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
  72. simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
  73. simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
  74. simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
  75. simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
  76. simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
  77. simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
  78. simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
  79. simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
  80. simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
  81. simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
  82. simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
  83. simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
  84. simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
  85. simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
  86. simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
  87. simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
  88. simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
  89. simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
  90. simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
  91. simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
  92. simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
  93. simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
  94. simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
  95. simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
  96. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
  97. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
  98. simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
  99. simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
  100. simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
  101. simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
  102. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
  103. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
  104. simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
  105. simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
  106. simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
  107. simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
  108. simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
  109. simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
  110. simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
  111. simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
  112. simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
  113. simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
  114. simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
  115. simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
  116. simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
  117. simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
  118. simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
  119. simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
  120. simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
  121. simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
  122. simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
  123. simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
  124. simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
  125. simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
  126. simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
  127. simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
  128. simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
  129. simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
  130. simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
  131. simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
  132. simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
  133. simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
  134. simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
  135. simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
  136. simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
  137. simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
  138. simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
  139. simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
  140. simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
  141. simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
  142. simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
  143. simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
  144. simview/static/lib/download.js +132 -0
  145. simview/static/lib/js-colormaps.js +19335 -0
  146. simview/static/lib/tar.js +334 -0
  147. simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
  148. simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
  149. simview/static/lib/three-0.174.0/three.core.js +48830 -0
  150. simview/static/lib/three-0.174.0/three.module.js +17313 -0
  151. simview/static/lib/uPlot.esm.js +6140 -0
  152. simview/static/lib/uPlot.min.css +2 -0
  153. simview/static/textures/contacts/red-cross0.png +0 -0
  154. simview/static/textures/points/ball0.png +0 -0
  155. simview/static/textures/points/ball1.png +0 -0
  156. simview/templates/index.html +81 -0
  157. simview/utils.py +41 -0
  158. simview-3.2.dist-info/METADATA +552 -0
  159. simview-3.2.dist-info/RECORD +163 -0
  160. simview-3.2.dist-info/WHEEL +5 -0
  161. simview-3.2.dist-info/entry_points.txt +2 -0
  162. simview-3.2.dist-info/licenses/LICENSE +28 -0
  163. simview-3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,198 @@
1
+ import * as THREE from "three";
2
+ import { BATCH_PALETTE_GENERATION_CONFIG } from "../config.js";
3
+ import { generateDivergingPalette } from "../objects/utils.js";
4
+
5
+ export class BatchManager {
6
+ constructor(app, modelData) {
7
+ this.app = app;
8
+ this.simBatches = 1; // Default to single batch
9
+ this.currentlyActiveBatch = 0; // Default to the first batch
10
+ this.collapsedMode = false; // Flag for collapsed mode, TODO implement this
11
+ this.focusedMode = false; // Flag for focused mode, TODO implement this
12
+
13
+ // Batch offset configuration
14
+ this.spacing = 0.5; // Spacing between batches in meters
15
+ this.batchOffsets = []; // Array of {x, y, z} offsets for each batch
16
+ this.batchPalette = []; // Array of colors for each batch
17
+ this.batchNames = []; // Array of display names for each batch
18
+ this._initialize(modelData);
19
+ }
20
+
21
+ _initialize(modelData) {
22
+ if (!modelData) return;
23
+
24
+ // Set batch count from model data
25
+ if (modelData.simBatches !== undefined) {
26
+ this.simBatches = Math.max(1, parseInt(modelData.simBatches));
27
+ console.log(
28
+ `Initializing with ${this.simBatches} simulation batches`
29
+ );
30
+ this.app.simBatches = this.simBatches;
31
+ } else if (modelData.batchSize !== undefined) {
32
+ // Fallback for backward compatibility if needed, though README says simBatches
33
+ this.simBatches = Math.max(1, parseInt(modelData.batchSize));
34
+ console.log(`Initializing with ${this.simBatches} simulation batches (fallback from batchSize)`);
35
+ this.app.simBatches = this.simBatches;
36
+ }
37
+
38
+ this.sideLength = Math.ceil(Math.sqrt(this.simBatches));
39
+ const sideLength = this.sideLength;
40
+ // Use README-defined terrain dimension names: sizeX, sizeY
41
+ const { sizeX, sizeY } = modelData.terrain.dimensions;
42
+
43
+ // Initialize batch offsets
44
+ this.batchOffsets = [];
45
+ for (let i = 0; i < this.simBatches; i++) {
46
+ const rowIdx = Math.floor(i / sideLength);
47
+ const colIdx = i % sideLength;
48
+ this.batchOffsets.push({
49
+ x: colIdx * (sizeX + this.spacing),
50
+ y: rowIdx * (sizeY + this.spacing),
51
+ z: 0,
52
+ });
53
+ }
54
+ console.debug("Batch offsets initialized:", this.batchOffsets);
55
+
56
+ this.batchPalette = generateDivergingPalette(
57
+ BATCH_PALETTE_GENERATION_CONFIG.colors,
58
+ this.simBatches + 1,
59
+ BATCH_PALETTE_GENERATION_CONFIG.correctLightness
60
+ );
61
+ console.debug("Batch palette initialized:", this.batchPalette);
62
+
63
+ const providedNames = Array.isArray(modelData.batchNames)
64
+ ? modelData.batchNames
65
+ : null;
66
+ this.batchNames = Array.from({ length: this.simBatches }, (_, i) =>
67
+ providedNames && typeof providedNames[i] === "string" && providedNames[i]
68
+ ? providedNames[i]
69
+ : `Batch ${i}`
70
+ );
71
+ }
72
+
73
+ getBatchName(batchIndex) {
74
+ return this.batchNames[batchIndex] ?? `Batch ${batchIndex}`;
75
+ }
76
+
77
+ setBatchName(batchIndex, name) {
78
+ if (batchIndex < 0 || batchIndex >= this.simBatches) return;
79
+ const trimmed = (name || "").trim() || `Batch ${batchIndex}`;
80
+ this.batchNames[batchIndex] = trimmed;
81
+ this._persistBatchNames();
82
+ if (this.app.batchLegend) this.app.batchLegend.refresh();
83
+ if (this.app.errorMetrics) this.app.errorMetrics.refreshBatchLabels();
84
+ }
85
+
86
+ async _persistBatchNames() {
87
+ try {
88
+ const response = await fetch("/batch-names", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({ names: this.batchNames }),
92
+ });
93
+ if (!response.ok) {
94
+ console.warn("Failed to persist batch names:", response.status);
95
+ }
96
+ } catch (e) {
97
+ console.warn("Failed to persist batch names:", e);
98
+ }
99
+ }
100
+
101
+ getSimBatches() {
102
+ return this.simBatches;
103
+ }
104
+
105
+ getColorForBatch(batchIndex) {
106
+ if (batchIndex < 0 || batchIndex >= this.simBatches) {
107
+ return new THREE.Color(0x000000); // Default to black for invalid index
108
+ }
109
+ return this.batchPalette[batchIndex];
110
+ }
111
+
112
+ getRowColFromBatchIndex(batchIndex) {
113
+ if (batchIndex < 0 || batchIndex >= this.simBatches) {
114
+ return { row: -1, col: -1 };
115
+ }
116
+ const sideLength = this.sideLength;
117
+ const rowIdx = Math.floor(batchIndex / sideLength);
118
+ const colIdx = batchIndex % sideLength;
119
+ return { row: rowIdx, col: colIdx };
120
+ }
121
+
122
+ getBatchIndexFromRowCol(rowIdx, colIdx) {
123
+ const sideLength = this.sideLength;
124
+ if (
125
+ rowIdx < 0 ||
126
+ colIdx < 0 ||
127
+ rowIdx >= sideLength ||
128
+ colIdx >= sideLength
129
+ ) {
130
+ return -1;
131
+ }
132
+ const batchIndex = rowIdx * sideLength + colIdx;
133
+ if (batchIndex < 0 || batchIndex >= this.simBatches) {
134
+ return -1;
135
+ }
136
+ return batchIndex;
137
+ }
138
+
139
+ getOffsetByRowCol(rowIdx, colIdx) {
140
+ const sideLength = this.sideLength;
141
+ if (
142
+ rowIdx < 0 ||
143
+ colIdx < 0 ||
144
+ rowIdx >= sideLength ||
145
+ colIdx >= sideLength
146
+ ) {
147
+ throw new Error("Invalid row or column index");
148
+ }
149
+ const batchIndex = rowIdx * sideLength + colIdx;
150
+ return this.getBatchOffset(batchIndex);
151
+ }
152
+
153
+ // Get offset for a specific batch
154
+ getBatchOffset(batchIndex) {
155
+ if (batchIndex >= 0 && batchIndex < this.batchOffsets.length) {
156
+ return this.batchOffsets[batchIndex];
157
+ }
158
+ return { x: 0, y: 0, z: 0 };
159
+ }
160
+
161
+ changeFocusOnBatchByRowCol(row, col) {
162
+ const { camera, controls } = this.app.scene;
163
+ const { x, y, z } = this.getOffsetByRowCol(row, col);
164
+ const newCameraTarget = new THREE.Vector3(x, y, z);
165
+ const oldCameraTarget = controls.target.clone();
166
+ controls.target = newCameraTarget;
167
+ camera.position.add(newCameraTarget.clone().sub(oldCameraTarget));
168
+ }
169
+
170
+ changeFocusOnBatchByIndex(batchIndex) {
171
+ const { row, col } = this.getRowColFromBatchIndex(batchIndex);
172
+ this.changeFocusOnBatchByRowCol(row, col);
173
+ }
174
+
175
+ setActiveBatch(batchIndex) {
176
+ if (batchIndex >= 0 && batchIndex < this.simBatches) {
177
+ this.currentlyActiveBatch = batchIndex;
178
+ this.changeFocusOnBatchByIndex(batchIndex);
179
+ } else {
180
+ console.warn("Invalid batch index:", batchIndex);
181
+ }
182
+ this.app.bodyStateWindow.setSelectedBatch(batchIndex);
183
+ if (this.app.scalarPlotter) {
184
+ this.app.scalarPlotter.setFocusedBatch(batchIndex);
185
+ }
186
+ if (this.app.batchLegend) {
187
+ this.app.batchLegend.highlightActive();
188
+ }
189
+ }
190
+
191
+ setActiveBatchByRowCol(row, col) {
192
+ const batchIndex = this.getBatchIndexFromRowCol(row, col);
193
+ if (batchIndex === -1) {
194
+ return;
195
+ }
196
+ this.setActiveBatch(batchIndex);
197
+ }
198
+ }
@@ -0,0 +1,446 @@
1
+ import * as THREE from "three";
2
+ import { SELECTION_CONFIG } from "../config.js";
3
+
4
+ export class InteractionController {
5
+ constructor(app) {
6
+ this.app = app;
7
+ this.raycaster = new THREE.Raycaster();
8
+ this.mouse = new THREE.Vector2();
9
+ this.hoveredObject = null;
10
+ this.selectedObject = null;
11
+
12
+ this.isDragging = false;
13
+ this.startPoint = { x: 0, y: 0 };
14
+ this.selectionBox = null;
15
+
16
+ this.currentSelectionMode = null;
17
+
18
+ // Cache for getIntersectableObjects — rebuilt only when selection mode changes
19
+ this._intersectableCache = null;
20
+ this._intersectableCacheMode = null;
21
+ // Reusable Vector3 for screen-projection in selection box
22
+ this._projPoint = new THREE.Vector3();
23
+
24
+ // Create the probe sphere
25
+ const sphereGeo = new THREE.SphereGeometry(0.05, 16, 16);
26
+ const sphereMat = new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false });
27
+ this.probeSphere = new THREE.Mesh(sphereGeo, sphereMat);
28
+ this.probeSphere.renderOrder = 999; // Draw on top
29
+ this.probeSphere.visible = false;
30
+ if (this.app.scene) {
31
+ this.app.scene.addObject3D(this.probeSphere);
32
+ }
33
+
34
+ // Bind methods to preserve context
35
+ this.onMouseMove = this.onMouseMove.bind(this);
36
+ this.onClick = this.onClick.bind(this);
37
+ this.handleHover = this.handleHover.bind(this);
38
+ this.onMouseDown = this.onMouseDown.bind(this);
39
+ this.onMouseDrag = this.onMouseDrag.bind(this);
40
+ this.onMouseUp = this.onMouseUp.bind(this);
41
+ this.onKeyUp = this.onKeyUp.bind(this);
42
+
43
+ // handleHover does a full raycast; mousemove can fire far more often
44
+ // than the display refreshes, so coalesce to at most one raycast per
45
+ // animation frame instead of one per event.
46
+ this._hoverRafId = null;
47
+ this._queueHoverUpdate = () => {
48
+ if (this._hoverRafId !== null) return;
49
+ this._hoverRafId = requestAnimationFrame(() => {
50
+ this._hoverRafId = null;
51
+ this.handleHover();
52
+ });
53
+ };
54
+
55
+ this.initializeEventListeners();
56
+ }
57
+
58
+ initializeEventListeners() {
59
+ // Use passive event listeners where possible for better performance
60
+ const passiveOptions = { passive: true };
61
+
62
+ window.addEventListener("mousemove", this.onMouseMove, passiveOptions);
63
+ window.addEventListener("click", this.onClick);
64
+ window.addEventListener("keyup", this.onKeyUp);
65
+
66
+ const renderer = this.app.scene && this.app.scene.renderer;
67
+ if (!renderer) {
68
+ console.warn("InteractionController: renderer not found during initialization.");
69
+ return;
70
+ }
71
+ const canvas = renderer.domElement;
72
+ if (!canvas) return;
73
+
74
+ canvas.addEventListener("mousemove", this._queueHoverUpdate, passiveOptions);
75
+ canvas.addEventListener("mousedown", this.onMouseDown);
76
+ canvas.addEventListener("mousemove", this.onMouseDrag, passiveOptions);
77
+ canvas.addEventListener("mouseup", this.onMouseUp);
78
+ }
79
+
80
+ cleanup() {
81
+ // Remove event listeners when cleaning up
82
+ window.removeEventListener("mousemove", this.onMouseMove);
83
+ window.removeEventListener("click", this.onClick);
84
+ window.removeEventListener("keyup", this.onKeyUp);
85
+
86
+ const renderer = this.app.scene && this.app.scene.renderer;
87
+ const canvas = renderer ? renderer.domElement : null;
88
+ if (canvas) {
89
+ canvas.removeEventListener("mousemove", this._queueHoverUpdate);
90
+ canvas.removeEventListener("mousedown", this.onMouseDown);
91
+ canvas.removeEventListener("mousemove", this.onMouseDrag);
92
+ canvas.removeEventListener("mouseup", this.onMouseUp);
93
+ }
94
+
95
+ if (this._hoverRafId !== null) {
96
+ cancelAnimationFrame(this._hoverRafId);
97
+ this._hoverRafId = null;
98
+ }
99
+
100
+ this.clearSelectionBox();
101
+
102
+ if (this.probeSphere && this.app.scene) {
103
+ this.app.scene.removeObject3D(this.probeSphere);
104
+ this.probeSphere.geometry.dispose();
105
+ this.probeSphere.material.dispose();
106
+ this.probeSphere = null;
107
+ }
108
+ }
109
+
110
+ getSelectionMode(event) {
111
+ for (const [mode, config] of Object.entries(SELECTION_CONFIG)) {
112
+ if (event[`${config.key}Key`]) {
113
+ return config;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ onMouseMove(e) {
120
+ // Calculate mouse position in normalized device coordinates (-1 to +1)
121
+ this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
122
+ this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
123
+ }
124
+
125
+ onClick(e) {
126
+ if (!this.app.scene || !this.app.scene.camera) return;
127
+
128
+ // Prevent click if mouse was dragged (e.g. rotating camera)
129
+ if (this.lastMouseDown) {
130
+ const dx = e.clientX - this.lastMouseDown.x;
131
+ const dy = e.clientY - this.lastMouseDown.y;
132
+ this.lastMouseDown = null; // Consume the mousedown
133
+ if (dx * dx + dy * dy > 25) {
134
+ return; // 5px movement threshold
135
+ }
136
+ }
137
+
138
+ this.raycaster.setFromCamera(this.mouse, this.app.scene.camera);
139
+ const intersects = this.raycaster.intersectObjects(
140
+ Object.values(this.app.bodies).flatMap((body) =>
141
+ body.children.filter((child) => child.isMesh)
142
+ ), // Filter only meshes
143
+ true // Enable recursive raycasting
144
+ );
145
+
146
+ if (intersects.length > 0) {
147
+ this.selectedObject = intersects[0].object;
148
+ this.hideTerrainTooltip();
149
+ return;
150
+ }
151
+
152
+ // Raycast against terrain
153
+ if (this.app.uiState && this.app.uiState.terrainProbe && this.app.terrain && this.app.terrain.group) {
154
+ const terrainIntersects = this.raycaster.intersectObject(this.app.terrain.group, true);
155
+ const surfaceIntersect = terrainIntersects.find(i => i.object.name === "surface");
156
+ if (surfaceIntersect) {
157
+ this.showTerrainTooltip(e, surfaceIntersect);
158
+ return;
159
+ }
160
+ }
161
+
162
+ this.hideTerrainTooltip();
163
+ }
164
+
165
+ showTerrainTooltip(e, intersect) {
166
+ const point = intersect.point;
167
+ let batchIndex = 0;
168
+ let current = intersect.object;
169
+ while (current) {
170
+ if (current.name && current.name.startsWith("batch")) {
171
+ batchIndex = parseInt(current.name.replace("batch", ""));
172
+ break;
173
+ }
174
+ current = current.parent;
175
+ }
176
+
177
+ const props = this.app.terrain.getPropertiesAt(point.x, point.y, batchIndex);
178
+ if (!props) return;
179
+
180
+ let tooltip = document.getElementById("terrain-tooltip");
181
+ if (!tooltip) {
182
+ tooltip = document.createElement("div");
183
+ tooltip.id = "terrain-tooltip";
184
+ Object.assign(tooltip.style, {
185
+ position: "absolute",
186
+ background: "rgba(0, 0, 0, 0.8)",
187
+ color: "white",
188
+ padding: "8px",
189
+ borderRadius: "4px",
190
+ pointerEvents: "none",
191
+ zIndex: "1000",
192
+ fontSize: "12px",
193
+ fontFamily: "monospace",
194
+ whiteSpace: "pre"
195
+ });
196
+ document.body.appendChild(tooltip);
197
+ }
198
+
199
+ tooltip.style.left = `${e.clientX + 10}px`;
200
+ tooltip.style.top = `${e.clientY + 10}px`;
201
+ tooltip.style.display = "block";
202
+
203
+ let text = `Batch: ${batchIndex}\nX: ${point.x.toFixed(3)}, Y: ${point.y.toFixed(3)}\nHeight: ${props.height.toFixed(3)}`;
204
+ if (props.friction !== undefined) text += `\nFriction: ${props.friction.toFixed(3)}`;
205
+ if (props.stiffness !== undefined) text += `\nStiffness: ${props.stiffness.toExponential(2)}`;
206
+
207
+ tooltip.innerText = text;
208
+
209
+ if (this.probeSphere) {
210
+ this.probeSphere.position.copy(point);
211
+ this.probeSphere.visible = true;
212
+ }
213
+ }
214
+
215
+ hideTerrainTooltip() {
216
+ const tooltip = document.getElementById("terrain-tooltip");
217
+ if (tooltip) {
218
+ tooltip.style.display = "none";
219
+ }
220
+ if (this.probeSphere) {
221
+ this.probeSphere.visible = false;
222
+ }
223
+ }
224
+
225
+ onMouseDown(e) {
226
+ this.lastMouseDown = { x: e.clientX, y: e.clientY };
227
+
228
+ this.currentSelectionMode = this.getSelectionMode(e);
229
+ if (!this.currentSelectionMode) return;
230
+
231
+ e.preventDefault(); // Prevent default only when needed
232
+ this.isDragging = true;
233
+ this.startPoint = {
234
+ x: e.clientX,
235
+ y: e.clientY,
236
+ };
237
+
238
+ this.initSelectionBox(e.clientX, e.clientY);
239
+ }
240
+
241
+ onMouseDrag(e) {
242
+ if (!this.isDragging || !this.currentSelectionMode) {
243
+ if (this.selectionBox) {
244
+ this.clearSelectionBox();
245
+ }
246
+ return;
247
+ }
248
+
249
+ this.updateSelectionBox(e.clientX, e.clientY);
250
+ }
251
+
252
+ onMouseUp(e) {
253
+ if (!this.isDragging) return;
254
+
255
+ this.isDragging = false;
256
+ if (e.ctrlKey) {
257
+ this.selectObjectsInBox();
258
+ }
259
+ this.clearSelectionBox();
260
+ }
261
+
262
+ onKeyUp(e) {
263
+ for (const config of Object.values(SELECTION_CONFIG)) {
264
+ if (e.key === config.key) {
265
+ this.isDragging = false;
266
+ this.currentSelectionMode = null;
267
+ this.clearSelectionBox();
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ initSelectionBox(x, y) {
273
+ this.clearSelectionBox(); // Clear any existing selection box
274
+
275
+ const overlay = document.createElement("div");
276
+ Object.assign(overlay.style, {
277
+ position: "fixed", // Using fixed instead of absolute
278
+ border: "1px dashed #00ff00",
279
+ backgroundColor: "rgba(0, 255, 0, 0.1)",
280
+ pointerEvents: "none",
281
+ left: `${x}px`,
282
+ top: `${y}px`,
283
+ width: "0px",
284
+ height: "0px",
285
+ zIndex: "1000",
286
+ });
287
+
288
+ document.body.appendChild(overlay);
289
+ this.selectionBox = overlay;
290
+ }
291
+
292
+ updateSelectionBox(currentX, currentY) {
293
+ if (!this.selectionBox) return;
294
+
295
+ const width = currentX - this.startPoint.x;
296
+ const height = currentY - this.startPoint.y;
297
+
298
+ const left = width < 0 ? currentX : this.startPoint.x;
299
+ const top = height < 0 ? currentY : this.startPoint.y;
300
+ const absWidth = Math.abs(width);
301
+ const absHeight = Math.abs(height);
302
+
303
+ Object.assign(this.selectionBox.style, {
304
+ left: `${left}px`,
305
+ top: `${top}px`,
306
+ width: `${absWidth}px`,
307
+ height: `${absHeight}px`,
308
+ });
309
+ }
310
+
311
+ clearSelectionBox() {
312
+ if (this.selectionBox) {
313
+ this.selectionBox.remove();
314
+ this.selectionBox = null;
315
+ }
316
+ }
317
+
318
+ handleHover() {
319
+ if (!this.app.scene || !this.app.scene.camera) return;
320
+ this.raycaster.setFromCamera(this.mouse, this.app.scene.camera);
321
+ const intersectables = this.getIntersectableObjects();
322
+ const intersects = this.raycaster.intersectObjects(intersectables, true);
323
+
324
+ // Handle hover state
325
+ const newHovered = intersects[0]?.object ?? null;
326
+ if (this.hoveredObject !== newHovered) {
327
+ if (this.hoveredObject) {
328
+ this.onObjectUnhover(this.hoveredObject);
329
+ }
330
+ if (newHovered) {
331
+ this.onObjectHover(newHovered);
332
+ }
333
+ this.hoveredObject = newHovered;
334
+ }
335
+ }
336
+
337
+ onObjectHover(object) {
338
+ object.scale.setScalar(1.2);
339
+ }
340
+
341
+ onObjectUnhover(object) {
342
+ object.scale.setScalar(1.0);
343
+ }
344
+
345
+ getIntersectableObjects() {
346
+ if (!this.currentSelectionMode) return [];
347
+
348
+ const modeKey = this.currentSelectionMode.objects;
349
+ if (this._intersectableCache && this._intersectableCacheMode === modeKey) {
350
+ return this._intersectableCache;
351
+ }
352
+
353
+ const objects = this.app[modeKey];
354
+ let result;
355
+ if (modeKey === "bodies") {
356
+ result = Object.values(objects)
357
+ .flatMap((body) => body.children)
358
+ .filter((child) => child.isMesh);
359
+ } else {
360
+ result = Object.values(objects);
361
+ }
362
+ this._intersectableCache = result;
363
+ this._intersectableCacheMode = modeKey;
364
+ return result;
365
+ }
366
+
367
+ invalidateIntersectableCache() {
368
+ this._intersectableCache = null;
369
+ this._intersectableCacheMode = null;
370
+ }
371
+
372
+ selectObjectsInBox() {
373
+ if (!this.currentSelectionMode || !this.selectionBox) return;
374
+
375
+ const camera = this.app.scene && this.app.scene.camera;
376
+ const renderer = this.app.scene && this.app.scene.renderer;
377
+ if (!camera || !renderer || !renderer.domElement) return;
378
+
379
+ const rect = renderer.domElement.getBoundingClientRect();
380
+
381
+ // Get the selection box coordinates
382
+ const boxLeft = parseInt(this.selectionBox.style.left);
383
+ const boxTop = parseInt(this.selectionBox.style.top);
384
+ const boxWidth = parseInt(this.selectionBox.style.width);
385
+ const boxHeight = parseInt(this.selectionBox.style.height);
386
+
387
+ // Function to check if a point is inside the selection box
388
+ const isPointInSelectionBox = (point) => {
389
+ // Convert 3D point to screen coordinates (reuse _projPoint to avoid allocation)
390
+ this._projPoint.copy(point).project(camera);
391
+
392
+ // Convert to pixel coordinates
393
+ const x = ((this._projPoint.x + 1) * rect.width) / 2 + rect.left;
394
+ const y = ((-this._projPoint.y + 1) * rect.height) / 2 + rect.top;
395
+
396
+ return (
397
+ x >= boxLeft &&
398
+ x <= boxLeft + boxWidth &&
399
+ y >= boxTop &&
400
+ y <= boxTop + boxHeight
401
+ );
402
+ };
403
+
404
+ // Clear previous selection
405
+ const selectionSet = this.app[this.currentSelectionMode.set];
406
+ selectionSet.forEach((obj) => {
407
+ if (obj.material) {
408
+ obj.material.color.setHex(obj.originalColor || 0xffffff);
409
+ }
410
+ });
411
+ selectionSet.clear();
412
+
413
+ // Select objects (reuse bbox/center to avoid per-object allocations)
414
+ const selectableObjects = this.getIntersectableObjects();
415
+ const bbox = new THREE.Box3();
416
+ const center = new THREE.Vector3();
417
+ selectableObjects.forEach((object) => {
418
+ bbox.setFromObject(object);
419
+ bbox.getCenter(center);
420
+
421
+ if (isPointInSelectionBox(center)) {
422
+ if (!object.hasOwnProperty("originalColor")) {
423
+ object.originalColor = object.material.color.getHex();
424
+ }
425
+ object.material.color.setHex(0xff0000);
426
+ selectionSet.add(object);
427
+ }
428
+ });
429
+
430
+ console.log(
431
+ `Selected ${selectionSet.size} ${this.currentSelectionMode.objects}`
432
+ );
433
+ }
434
+
435
+ deselectAll() {
436
+ for (const config of Object.values(SELECTION_CONFIG)) {
437
+ const selectionSet = this.app[config.set];
438
+ selectionSet.forEach((obj) => {
439
+ if (obj.material && obj.hasOwnProperty("originalColor")) {
440
+ obj.material.color.setHex(obj.originalColor);
441
+ }
442
+ });
443
+ selectionSet.clear();
444
+ }
445
+ }
446
+ }