leonsimon23 commited on
Commit
9d608a0
·
verified ·
1 Parent(s): edde16b

Update main.js

Browse files
Files changed (1) hide show
  1. main.js +342 -453
main.js CHANGED
@@ -1,489 +1,378 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>中药饮片AI三维鉴别实训系统</title>
7
- <style>
8
- /* 基础样式 */
9
- :root {
10
- --primary-color: #4a90e2;
11
- --light-bg: #f0f2f5;
12
- --panel-bg: #ffffff;
13
- --text-color: #333333;
14
- --border-color: #e9ecef;
15
- }
16
-
17
- * {
18
- box-sizing: border-box;
19
- }
20
-
21
- body {
22
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
23
- margin: 0;
24
- background-color: var(--light-bg);
25
- height: 100vh;
26
- overflow: hidden;
27
- display: flex;
28
- flex-direction: column;
29
- }
30
 
31
- /* 顶部栏 */
32
- .top-bar {
33
- background-color: var(--primary-color);
34
- color: white;
35
- padding: 12px 20px;
36
- display: flex;
37
- justify-content: space-between;
38
- align-items: center;
39
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
40
- z-index: 100;
41
- flex-shrink: 0;
42
- }
43
-
44
- .top-bar h1 {
45
- margin: 0;
46
- font-size: 1.4em;
47
- font-weight: 600;
48
- }
49
-
50
- .mode-switcher button {
51
- background-color: transparent;
52
- border: 1px solid white;
53
- color: white;
54
- padding: 8px 16px;
55
- margin-left: 10px;
56
- border-radius: 5px;
57
- cursor: pointer;
58
- transition: all 0.3s;
59
- font-size: 14px;
60
- }
61
 
62
- .mode-switcher button.active, .mode-switcher button:hover {
63
- background-color: white;
64
- color: var(--primary-color);
65
- }
66
 
67
- /* 主内容区 */
68
- .main-content {
69
- display: flex;
70
- flex: 1;
71
- overflow: hidden;
72
- min-height: 0;
73
- }
74
 
75
- /* 侧边栏 */
76
- #sidebar {
77
- width: 220px;
78
- background-color: var(--panel-bg);
79
- padding: 16px;
80
- box-shadow: 2px 0 5px rgba(0,0,0,0.1);
81
- overflow-y: auto;
82
- flex-shrink: 0;
83
- }
84
-
85
- #sidebar h2 {
86
- margin: 0 0 16px 0;
87
- font-size: 1.1em;
88
- color: var(--text-color);
89
- }
90
-
91
- #herb-list {
92
- list-style: none;
93
- padding: 0;
94
- margin: 0;
95
- }
96
-
97
- #herb-list li {
98
- padding: 10px 12px;
99
- cursor: pointer;
100
- border-radius: 6px;
101
- margin-bottom: 6px;
102
- transition: all 0.2s;
103
- font-size: 14px;
104
- }
105
-
106
- #herb-list li:hover {
107
- background-color: #eaf2fb;
108
- }
109
-
110
- #herb-list li.active {
111
- background-color: var(--primary-color);
112
- color: white;
113
- font-weight: 500;
114
- }
115
 
116
- /* 3D 视窗容器 */
117
- #viewer-container {
118
- flex: 1;
119
- position: relative;
120
- min-width: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
 
 
 
 
 
 
122
 
123
- #c {
124
- display: block;
125
- width: 100%;
126
- height: 100%;
127
- }
128
 
129
- /* 右侧信息面板 - 重新设计 */
130
- #info-panel {
131
- width: 350px;
132
- background-color: var(--panel-bg);
133
- box-shadow: -2px 0 5px rgba(0,0,0,0.1);
134
- flex-shrink: 0;
135
- display: flex;
136
- flex-direction: column;
137
- overflow: hidden;
138
- }
139
 
140
- #learn-panel {
141
- height: 100%;
142
- display: flex;
143
- flex-direction: column;
144
- overflow: hidden;
 
 
 
 
 
 
145
  }
 
146
 
147
- /* 图片区域 - 固定高度 */
148
- .herb-image-section {
149
- flex-shrink: 0;
150
- padding: 20px 20px 0 20px;
151
- background-color: #fafbfc;
152
- border-bottom: 1px solid var(--border-color);
153
- }
154
 
155
- .herb-image-container {
156
- width: 100%;
157
- height: 200px;
158
- display: flex;
159
- justify-content: center;
160
- align-items: center;
161
- background-color: white;
162
- border-radius: 8px;
163
- border: 1px solid var(--border-color);
164
- overflow: hidden;
165
- margin-bottom: 15px;
166
- }
 
 
 
 
 
 
167
 
168
- #herb-image {
169
- max-width: 100%;
170
- max-height: 100%;
171
- width: auto;
172
- height: auto;
173
- object-fit: contain;
174
- border-radius: 6px;
175
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- /* 信息内容区域 - 可滚动 */
178
- .info-content {
179
- flex: 1;
180
- overflow-y: auto;
181
- padding: 20px;
182
- }
183
 
184
- .info-section h3 {
185
- margin: 0 0 20px 0;
186
- font-size: 1.3em;
187
- color: var(--primary-color);
188
- border-bottom: 2px solid var(--primary-color);
189
- padding-bottom: 10px;
190
- font-weight: 600;
191
- }
192
 
193
- .info-item {
194
- margin-bottom: 18px;
195
- }
 
 
 
 
 
196
 
197
- .info-item h4 {
198
- margin: 0 0 8px 0;
199
- font-size: 14px;
200
- color: #555;
201
- font-weight: 600;
202
- }
 
 
 
203
 
204
- .info-item p {
205
- margin: 0;
206
- line-height: 1.6;
207
- font-size: 13px;
208
- color: #666;
 
 
 
 
 
 
 
 
 
209
  }
 
 
 
 
 
 
 
 
210
 
211
- /* 加载覆盖层 */
212
- #loading-overlay {
213
- position: absolute;
214
- top: 0;
215
- left: 0;
216
- width: 100%;
217
- height: 100%;
218
- background-color: rgba(0, 0, 0, 0.7);
219
- display: flex;
220
- justify-content: center;
221
- align-items: center;
222
- z-index: 20;
223
- transition: opacity 0.5s;
224
- pointer-events: none;
225
- opacity: 0;
226
- }
227
-
228
- #loading-overlay.visible {
229
- opacity: 1;
230
- pointer-events: auto;
231
- }
232
-
233
- .loading-box {
234
- text-align: center;
235
- color: white;
236
- background-color: rgba(40, 40, 40, 0.9);
237
- padding: 30px 40px;
238
- border-radius: 10px;
239
- }
240
-
241
- .loading-title {
242
- font-size: 1.2em;
243
- margin-bottom: 15px;
244
- }
245
-
246
- .progress-bar-container {
247
- width: 250px;
248
- height: 8px;
249
- background-color: #555;
250
- border-radius: 4px;
251
- overflow: hidden;
252
- margin: 0 auto;
253
- }
254
-
255
- #progress-bar {
256
- width: 0%;
257
- height: 100%;
258
- background-color: var(--primary-color);
259
- border-radius: 4px;
260
- transition: width 0.2s ease-in-out;
261
- }
262
-
263
- #progress-text {
264
- margin-top: 15px;
265
- font-size: 1em;
266
- font-family: monospace;
267
- }
268
 
269
- /* 缩放控制按钮 */
270
- .zoom-controls {
271
- position: absolute;
272
- bottom: 20px;
273
- right: 20px;
274
- z-index: 10;
275
- display: flex;
276
- flex-direction: column;
277
- }
278
-
279
- .zoom-controls button {
280
- width: 40px;
281
- height: 40px;
282
- font-size: 18px;
283
- font-weight: bold;
284
- border: none;
285
- border-radius: 50%;
286
- background-color: rgba(0, 0, 0, 0.6);
287
- color: white;
288
- cursor: pointer;
289
- margin-top: 8px;
290
- transition: all 0.3s;
291
- display: flex;
292
- align-items: center;
293
- justify-content: center;
294
- }
295
-
296
- .zoom-controls button:hover {
297
- background-color: rgba(0, 0, 0, 0.8);
298
- transform: scale(1.1);
299
  }
 
300
 
301
- /* 特征点弹窗 */
302
- #feature-popup {
303
- position: absolute;
304
- top: 20px;
305
- left: 20px;
306
- background: rgba(0,0,0,0.85);
307
- color: white;
308
- padding: 20px;
309
- border-radius: 8px;
310
- max-width: 300px;
311
- z-index: 100;
312
- border: 1px solid rgba(255,255,255,0.2);
313
- font-size: 14px;
314
- line-height: 1.5;
315
- transition: all 0.3s;
316
- transform: scale(0.95);
317
- }
318
-
319
- #feature-popup.hidden {
320
- opacity: 0;
321
- pointer-events: none;
322
- transform: scale(0.9);
323
- }
324
-
325
- #feature-popup-content strong {
326
- color: #ffcc00;
327
- display: block;
328
- margin-bottom: 8px;
329
- }
330
-
331
- #feature-popup-close {
332
- display: block;
333
- width: 100%;
334
- margin-top: 15px;
335
- padding: 8px 12px;
336
- cursor: pointer;
337
- border: none;
338
- background: #555;
339
- color: white;
340
- border-radius: 5px;
341
- transition: background-color 0.3s;
342
- }
343
-
344
- #feature-popup-close:hover {
345
- background: #666;
346
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
-
349
-
350
- #feature-popup-close:hover {
351
- background: #666;
 
 
 
 
 
352
  }
 
353
 
354
-
 
 
 
 
355
 
356
- /* 响应式设计 */
357
- @media (max-width: 1400px) {
358
- #info-panel {
359
- width: 320px;
360
- }
361
- .herb-image-container {
362
- height: 180px;
363
- }
364
- }
365
 
366
- @media (max-width: 1200px) {
367
- #sidebar {
368
- width: 200px;
369
- }
370
- #info-panel {
371
- width: 300px;
372
- }
373
- .herb-image-container {
374
- height: 160px;
375
- }
376
- }
377
 
378
- @media (max-width: 768px) {
379
- #sidebar {
380
- width: 180px;
381
- }
382
- #info-panel {
383
- width: 280px;
384
- }
385
- .herb-image-container {
386
- height: 140px;
387
- }
388
- }
389
 
390
- /* 自定义滚动条样式 */
391
- ::-webkit-scrollbar {
392
- width: 6px;
393
- }
394
 
395
- ::-webkit-scrollbar-track {
396
- background: #f1f1f1;
397
- border-radius: 3px;
398
- }
399
 
400
- ::-webkit-scrollbar-thumb {
401
- background: #ccc;
402
- border-radius: 3px;
403
- }
 
404
 
405
- ::-webkit-scrollbar-thumb:hover {
406
- background: #999;
407
- }
408
- </style>
409
- </head>
410
- <body>
411
- <div class="top-bar">
412
- <h1>中药饮片AI三维鉴别实训系统</h1>
413
- <div class="mode-switcher">
414
- <button id="btn-learn-mode" class="active">三维沉浸式学习</button>
415
- <button id="btn-qa-mode">AI导师教学</button>
416
- <button id="btn-exam-mode">AI主考测验</button>
417
- </div>
418
- </div>
419
 
420
- <div class="main-content">
421
- <div id="sidebar">
422
- <h2>饮片目录</h2>
423
- <ul id="herb-list"></ul>
424
- </div>
425
-
426
- <div id="viewer-container">
427
- <canvas id="c"></canvas>
428
- <div id="loading-overlay">
429
- <div class="loading-box">
430
- <div class="loading-title">正在加载模型...</div>
431
- <div class="progress-bar-container">
432
- <div id="progress-bar"></div>
433
- </div>
434
- <div id="progress-text">0%</div>
435
- </div>
436
- </div>
437
- <div class="zoom-controls">
438
- <button id="zoom-in-btn">+</button>
439
- <button id="zoom-out-btn">-</button>
440
- </div>
441
- <div id="feature-popup" class="hidden">
442
- <div id="feature-popup-content"></div>
443
- <button id="feature-popup-close">关闭</button>
444
- </div>
445
- </div>
446
-
447
- <div id="info-panel">
448
- <div id="learn-panel">
449
- <div class="herb-image-section">
450
- <div class="herb-image-container">
451
- <img id="herb-image" src="" alt="中药饮片图片">
452
- </div>
453
- </div>
454
- <div class="info-content">
455
- <div class="info-section">
456
- <h3 id="herb-name"></h3>
457
- <div class="info-item">
458
- <h4>【来源】</h4>
459
- <p id="herb-source"></p>
460
- </div>
461
- <div class="info-item">
462
- <h4>【性味归经】</h4>
463
- <p id="herb-taste"></p>
464
- </div>
465
- <div class="info-item">
466
- <h4>【功效】</h4>
467
- <p id="herb-effect"></p>
468
- </div>
469
- <div class="info-item">
470
- <h4>【性状鉴别要点】</h4>
471
- <p id="herb-identification"></p>
472
- </div>
473
- </div>
474
- </div>
475
- </div>
476
- </div>
477
- </div>
478
-
479
- <script type="importmap">
480
- {
481
- "imports": {
482
- "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
483
- "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
484
- }
485
  }
486
- </script>
487
- <script type="module" src="main.js"></script>
488
- </body>
489
- </html>
 
1
+ import * as THREE from 'three';
2
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
+ import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
4
+ import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ class HerbalApp {
7
+ constructor(canvas) {
8
+ // --- 1. 核心属性 ---
9
+ this.canvas = canvas;
10
+ this.renderer = null;
11
+ this.scene = null;
12
+ this.camera = null;
13
+ this.controls = null;
14
+ this.raycaster = new THREE.Raycaster();
15
+ this.pointer = new THREE.Vector2();
16
+
17
+ // --- 2. 状态管理 ---
18
+ this.isInteractive = true;
19
+ this.isLoadingModel = false;
20
+ this.isPopupVisible = false;
21
+ this.currentModel = null;
22
+ this.featurePointObjects = [];
23
+ this.currentIntersected = null;
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ this.pointerMoveThrottle = 50;
26
+ this.lastPointerMoveTime = 0;
 
 
27
 
28
+ this.featurePointGeometry = new THREE.SphereGeometry(0.3, 16, 16);
29
+ this.featurePointMaterialTemplate = new THREE.MeshStandardMaterial({
30
+ color: 0xffcc00,
31
+ emissive: 0x000000,
32
+ metalness: 0.2,
33
+ roughness: 0.8,
34
+ });
35
 
36
+ // --- 3. DOM 元素 ---
37
+ this.dom = {
38
+ loadingOverlay: document.getElementById('loading-overlay'),
39
+ progressBar: document.getElementById('progress-bar'),
40
+ progressText: document.getElementById('progress-text'),
41
+ herbImage: document.getElementById('herb-image'),
42
+ herbName: document.getElementById('herb-name'),
43
+ herbSource: document.getElementById('herb-source'),
44
+ herbTaste: document.getElementById('herb-taste'),
45
+ herbEffect: document.getElementById('herb-effect'),
46
+ herbIdentification: document.getElementById('herb-identification'),
47
+ featurePopup: document.getElementById('feature-popup'),
48
+ featurePopupContent: document.getElementById('feature-popup-content'),
49
+ featurePopupCloseBtn: document.getElementById('feature-popup-close'),
50
+ herbList: document.getElementById('herb-list'),
51
+ zoomInBtn: document.getElementById('zoom-in-btn'),
52
+ zoomOutBtn: document.getElementById('zoom-out-btn')
53
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ // --- 4. 数据中心 ---
56
+ this.herbsData = [
57
+ {
58
+ id: 'biejia', name: '鳖甲', path: './assets/biejia/', objFile: 'biejia.obj', mtlFile: 'biejia.mtl',
59
+ image: './assets/biejia/biejia-photo.jpg',
60
+ details: { source: '鳖科动物鳖的背甲。', taste: '咸,寒。归肝、肾经。', effect: '滋阴潜阳,软坚散结,退热除蒸。', identification: '呈椭圆形或卵圆形,背面隆起,具不显明的三条棱线。外表面黑褐色或墨绿色,有细网状皱纹和"十三块"甲片对称的"人"字形及"介"字形纹理。内表面乳白色,中间有突起的脊椎骨。质坚硬。'},
61
+ featurePoints: [
62
+ { position: new THREE.Vector3(0, 4.5, 1.5), description: '<strong>颈盾:</strong> 位于最前端中央,是鳖甲十三个主要甲片之一。' },
63
+ { position: new THREE.Vector3(1.5, 3.5, -1.5), description: '<strong>肋盾:</strong> 左右对称,构成背甲的主要部分,可见清晰的生长纹。' },
64
+ { position: new THREE.Vector3(0, 0.5, -4.5), description: '<strong>臀盾:</strong> 位于背甲后端,形状较小,保护尾部区域。' }
65
+ ]
66
+ },
67
+ {
68
+ id: 'wanglingzhi', name: '醋五灵脂', path: './assets/wanglingzhi/', objFile: 'wanglingzhi.obj', mtlFile: 'wanglingzhi.mtl',
69
+ image: './assets/wanglingzhi/wanglingzhi-photo.jpg',
70
+ details: { source: '鼯鼠科动物复齿鼯鼠的干燥粪便,经醋炮制而成。', taste: '甘、温。归肝经。', effect: '活血止痛,化瘀止血。', identification: '呈不规则的块状,大小不一。表面黑褐色、红棕色或灰褐色,凹凸不平,有的具光泽。质硬,断面不平坦,可见长椭圆形或纤维状的植物残渣。具醋香气,味微苦。'},
71
+ featurePoints: [
72
+ { position: new THREE.Vector3(1, 2, 1), description: '<strong>光泽表面:</strong> 部分区域因醋制而呈现特有的光泽感,是鉴别要点之一。' },
73
+ { position: new THREE.Vector3(-2, -1, 0), description: '<strong>不规则断面:</strong> 质地坚硬,断面凹凸不平,可见内部结构。' }
74
+ ]
75
+ },
76
+ {
77
+ id: 'jiangcan', name: '僵蚕', path: './assets/jiangcan/', objFile: 'jiangchan-xiao.obj', mtlFile: 'jiangchan-xiao.mtl',
78
+ image: './assets/jiangcan/jiangchan-photo.jpg',
79
+ details: { source: '蚕蛾科昆虫家蚕4~5龄的幼虫,在未吐丝前,因感染白僵菌而僵死的干燥体。', taste: '辛、咸,平。归肝、肺、胃经。', effect: '息风止痉,祛风止痛,化痰散结。', identification: '多呈圆柱形,略弯曲,长2~5cm。表面灰白色,被有白色粉霜状的气生菌丝和分生孢子。头部较圆,足8对,体节明显。质硬而脆,易折断,断面平坦,外层白色,中间有棕色或亮棕色的丝腺环。气微腥,味微咸。'},
80
+ featurePoints: [
81
+ { position: new THREE.Vector3(0, 0, 2.5), description: '<strong>头部:</strong> 较圆,色泽与体部略有不同,是区分头尾的关键。' },
82
+ { position: new THREE.Vector3(0, 0, 0), description: '<strong>体节:</strong> 环节明显,是蚕体结构的重要特征。' },
83
+ { position: new THREE.Vector3(0.5, -0.5, -1), description: '<strong>胸足与腹足:</strong> 共8对,排列于身体腹侧,略突出。' }
84
+ ]
85
+ }
86
+ ];
87
+ }
88
+
89
+ init() {
90
+ this._setupScene();
91
+ this._setupLights();
92
+ this._setupCameraAndControls();
93
+ this._setupEventListeners();
94
+ this._createHerbListUI();
95
+ this.animate();
96
+ if (this.herbsData.length > 0) {
97
+ this.switchHerb(this.herbsData[0].id);
98
  }
99
+ }
100
+
101
+ async switchHerb(herbId) {
102
+ if (this.isLoadingModel) return;
103
+ const herbData = this.herbsData.find(h => h.id === herbId);
104
+ if (!herbData) return;
105
 
106
+ this._updateInfoPanel(herbData);
107
+ this._updateActiveHerbInList(herbId);
 
 
 
108
 
109
+ this.isLoadingModel = true;
110
+ if (this.isPopupVisible) this.closePopup();
111
+ this._updateInteractionState();
112
+ this._showLoadingOverlay();
 
 
 
 
 
 
113
 
114
+ try {
115
+ const object = await this._loadModelWithProgress(herbData);
116
+ this._cleanupScene();
117
+ this._processLoadedModel(object, herbData);
118
+ } catch (error) {
119
+ console.error(`加载药材 ${herbData.name} 失败:`, error);
120
+ this._cleanupScene();
121
+ } finally {
122
+ this.isLoadingModel = false;
123
+ this._updateInteractionState();
124
+ this._hideLoadingOverlay();
125
  }
126
+ }
127
 
128
+ _loadModelWithProgress(herb) {
129
+ return new Promise((resolve, reject) => {
130
+ const mtlLoader = new MTLLoader();
131
+ mtlLoader.setResourcePath(herb.path);
 
 
 
132
 
133
+ mtlLoader.load(herb.path + herb.mtlFile, (materials) => {
134
+ materials.preload();
135
+ const objLoader = new OBJLoader();
136
+ objLoader.setMaterials(materials);
137
+ objLoader.load(herb.path + herb.objFile, resolve, (xhr) => {
138
+ if (xhr.lengthComputable) {
139
+ const percent = 30 + (xhr.loaded / xhr.total) * 70;
140
+ this._updateLoadingProgress(Math.round(percent));
141
+ }
142
+ }, reject);
143
+ }, (xhr) => {
144
+ if (xhr.lengthComputable) {
145
+ const percent = (xhr.loaded / xhr.total) * 30;
146
+ this._updateLoadingProgress(Math.round(percent));
147
+ }
148
+ }, reject);
149
+ });
150
+ }
151
 
152
+ _cleanupScene() {
153
+ if (this.currentModel) {
154
+ this.scene.remove(this.currentModel);
155
+ this.currentModel.traverse((child) => {
156
+ if (child.isMesh) {
157
+ child.geometry?.dispose();
158
+ if (Array.isArray(child.material)) {
159
+ child.material.forEach(material => material.dispose());
160
+ } else if (child.material) {
161
+ child.material.dispose();
162
+ }
163
+ }
164
+ });
165
+ this.currentModel = null;
166
+ }
167
+ this.featurePointObjects.forEach(fp => {
168
+ fp.geometry?.dispose();
169
+ fp.material?.dispose();
170
+ this.scene.remove(fp);
171
+ });
172
+ this.featurePointObjects = [];
173
+ }
174
+
175
+ _updateInteractionState() {
176
+ const shouldBeInteractive = !this.isLoadingModel && !this.isPopupVisible;
177
+ if (this.isInteractive === shouldBeInteractive) return;
178
+ this.isInteractive = shouldBeInteractive;
179
+ this.controls.enabled = this.isInteractive;
180
+ if (!this.isInteractive) this._clearHoverState();
181
+ }
182
 
183
+ _setupScene() {
184
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, canvas: this.canvas, powerPreference: 'high-performance' });
185
+ this.scene = new THREE.Scene();
186
+ this.scene.background = new THREE.Color(0x333344);
187
+ }
 
188
 
189
+ _setupLights() {
190
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.8));
191
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
192
+ dirLight.position.set(10, 15, 10);
193
+ this.scene.add(dirLight);
194
+ }
 
 
195
 
196
+ _setupCameraAndControls() {
197
+ this.camera = new THREE.PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight, 0.1, 1000);
198
+ this.camera.position.set(0, 10, 30);
199
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
200
+ this.controls.enableDamping = true;
201
+ this.controls.dampingFactor = 0.05;
202
+ this.controls.target.set(0, 5, 0);
203
+ }
204
 
205
+ _setupEventListeners() {
206
+ this.renderer.domElement.addEventListener('pointermove', this._onPointerMove.bind(this));
207
+ this.renderer.domElement.addEventListener('click', this._onPointerClick.bind(this));
208
+ this.dom.featurePopupCloseBtn.addEventListener('click', () => this.closePopup());
209
+ this.dom.featurePopup.addEventListener('click', (event) => { if (event.target === this.dom.featurePopup) this.closePopup(); });
210
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && this.isPopupVisible) this.closePopup(); });
211
+ this.dom.zoomInBtn.addEventListener('click', () => this._zoom(0.8));
212
+ this.dom.zoomOutBtn.addEventListener('click', () => this._zoom(1.2));
213
+ }
214
 
215
+ _onPointerMove(event) {
216
+ if (!this.isInteractive) return;
217
+ const now = performance.now();
218
+ if (now - this.lastPointerMoveTime < this.pointerMoveThrottle) return;
219
+ this.lastPointerMoveTime = now;
220
+ const viewerRect = this.renderer.domElement.getBoundingClientRect();
221
+ this.pointer.x = ((event.clientX - viewerRect.left) / viewerRect.width) * 2 - 1;
222
+ this.pointer.y = -((event.clientY - viewerRect.top) / viewerRect.height) * 2 + 1;
223
+ this._updateHoverInteraction();
224
+ }
225
+
226
+ _onPointerClick() {
227
+ if (this.isInteractive && this.currentIntersected) {
228
+ this.showFeaturePointInfo(this.currentIntersected.userData.description);
229
  }
230
+ }
231
+
232
+ showFeaturePointInfo(description) {
233
+ this.dom.featurePopupContent.innerHTML = description;
234
+ this.dom.featurePopup.classList.remove('hidden');
235
+ this.isPopupVisible = true;
236
+ this._updateInteractionState();
237
+ }
238
 
239
+ closePopup() {
240
+ if (!this.isPopupVisible) return;
241
+ this.dom.featurePopup.classList.add('hidden');
242
+ this.isPopupVisible = false;
243
+ this._updateInteractionState();
244
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
+ animate() {
247
+ requestAnimationFrame(this.animate.bind(this));
248
+ this._resizeRendererToDisplaySize();
249
+ this.controls.update();
250
+ this.renderer.render(this.scene, this.camera);
251
+ }
252
+
253
+ _updateHoverInteraction() {
254
+ this.raycaster.setFromCamera(this.pointer, this.camera);
255
+ const intersects = this.raycaster.intersectObjects(this.featurePointObjects);
256
+ if (intersects.length > 0) {
257
+ const intersectedObject = intersects[0].object;
258
+ if (this.currentIntersected !== intersectedObject) {
259
+ this._clearHoverState();
260
+ this.currentIntersected = intersectedObject;
261
+ this.currentIntersected.material.emissive.setHex(0xffff00);
262
+ }
263
+ this.renderer.domElement.style.cursor = 'pointer';
264
+ } else {
265
+ this._clearHoverState();
 
 
 
 
 
 
 
 
 
 
266
  }
267
+ }
268
 
269
+ _clearHoverState() {
270
+ if (this.currentIntersected) {
271
+ this.currentIntersected.material.emissive.setHex(0x000000);
272
+ this.currentIntersected = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  }
274
+ this.renderer.domElement.style.cursor = 'auto';
275
+ }
276
+
277
+ _createFeaturePoints(featurePoints, scale) {
278
+ if (!featurePoints || featurePoints.length === 0) return;
279
+ featurePoints.forEach((pointData, index) => {
280
+ const material = this.featurePointMaterialTemplate.clone();
281
+ const sphere = new THREE.Mesh(this.featurePointGeometry, material);
282
+ sphere.position.copy(pointData.position).multiplyScalar(scale);
283
+ sphere.userData = { description: pointData.description, isFeaturePoint: true, index };
284
+ this.scene.add(sphere);
285
+ this.featurePointObjects.push(sphere);
286
+ });
287
+ }
288
+
289
+ _processLoadedModel(object, herb) {
290
+ this._clearHoverState();
291
+ const box = new THREE.Box3().setFromObject(object);
292
+ const center = box.getCenter(new THREE.Vector3());
293
+ const size = box.getSize(new THREE.Vector3());
294
+ object.position.sub(center);
295
+ const maxDim = Math.max(size.x, size.y, size.z);
296
+ const scale = maxDim > 0 ? 15 / maxDim : 1;
297
+ object.scale.set(scale, scale, scale);
298
+ this.scene.add(object);
299
+ this.currentModel = object;
300
+ this._createFeaturePoints(herb.featurePoints, scale);
301
+ this._resetCameraPosition();
302
+ }
303
+
304
+ _createHerbListUI() {
305
+ this.herbsData.forEach(herb => {
306
+ const li = document.createElement('li');
307
+ li.textContent = herb.name;
308
+ li.dataset.id = herb.id;
309
+ li.addEventListener('click', () => this.switchHerb(herb.id));
310
+ this.dom.herbList.appendChild(li);
311
+ });
312
+ }
313
 
314
+ _resizeRendererToDisplaySize() {
315
+ const canvas = this.renderer.domElement;
316
+ const viewerRect = canvas.getBoundingClientRect();
317
+ const width = Math.round(viewerRect.width);
318
+ const height = Math.round(viewerRect.height);
319
+ if (canvas.width !== width || canvas.height !== height) {
320
+ this.renderer.setSize(width, height, false);
321
+ this.camera.aspect = width / height;
322
+ this.camera.updateProjectionMatrix();
323
  }
324
+ }
325
 
326
+ _resetCameraPosition() {
327
+ this.camera.position.set(0, 10, 30);
328
+ this.controls.target.set(0, 5, 0);
329
+ this.controls.update();
330
+ }
331
 
332
+ _updateInfoPanel(herb) {
333
+ this.dom.herbImage.src = herb.image || '';
334
+ this.dom.herbImage.alt = herb.name;
335
+ this.dom.herbName.textContent = herb.name;
336
+ this.dom.herbSource.textContent = herb.details.source || '暂无';
337
+ this.dom.herbTaste.textContent = herb.details.taste || '暂无';
338
+ this.dom.herbEffect.textContent = herb.details.effect || '暂无';
339
+ this.dom.herbIdentification.textContent = herb.details.identification || '暂无';
340
+ }
341
 
342
+ _updateActiveHerbInList(herbId) {
343
+ this.dom.herbList.querySelector('li.active')?.classList.remove('active');
344
+ this.dom.herbList.querySelector(`li[data-id='${herbId}']`)?.classList.add('active');
345
+ }
 
 
 
 
 
 
 
346
 
347
+ _zoom(factor) {
348
+ this.camera.position.sub(this.controls.target).multiplyScalar(factor).add(this.controls.target);
349
+ }
 
 
 
 
 
 
 
 
350
 
351
+ _showLoadingOverlay() {
352
+ this.dom.loadingOverlay.classList.add('visible');
353
+ this._updateLoadingProgress(0);
354
+ }
355
 
356
+ _hideLoadingOverlay() {
357
+ this.dom.loadingOverlay.classList.remove('visible');
358
+ }
 
359
 
360
+ _updateLoadingProgress(percent) {
361
+ this.dom.progressBar.style.width = percent + '%';
362
+ this.dom.progressText.textContent = percent + '%';
363
+ }
364
+ }
365
 
366
+ // --- 启动应用 (已修改) ---
367
+ // 等待整个HTML文档加载并解析完毕后,再执行初始化代码
368
+ document.addEventListener('DOMContentLoaded', () => {
369
+ const canvas = document.querySelector('#c');
 
 
 
 
 
 
 
 
 
 
370
 
371
+ // 添加一个安全检查,确保canvas元素存在
372
+ if (canvas) {
373
+ const app = new HerbalApp(canvas);
374
+ app.init();
375
+ } else {
376
+ console.error('错误:在HTML中没有找到ID为 "c" 的canvas元素。应用无法启动。');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  }
378
+ });