import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { MTLLoader } from 'three/addons/loaders/MTLLoader.js'; class HerbalApp { constructor(canvas) { // --- 1. 核心属性 --- this.canvas = canvas; this.renderer = null; this.scene = null; this.camera = null; this.controls = null; this.raycaster = new THREE.Raycaster(); this.pointer = new THREE.Vector2(); // --- 2. 状态管理 --- this.isInteractive = true; this.isLoadingModel = false; this.isPopupVisible = false; this.currentModel = null; this.featurePointObjects = []; this.currentIntersected = null; this.pointerMoveThrottle = 50; this.lastPointerMoveTime = 0; this.featurePointGeometry = new THREE.SphereGeometry(0.3, 16, 16); this.featurePointMaterialTemplate = new THREE.MeshStandardMaterial({ color: 0xffcc00, emissive: 0x000000, metalness: 0.2, roughness: 0.8, }); // --- 3. DOM 元素 --- this.dom = { loadingOverlay: document.getElementById('loading-overlay'), progressBar: document.getElementById('progress-bar'), progressText: document.getElementById('progress-text'), herbImage: document.getElementById('herb-image'), herbName: document.getElementById('herb-name'), herbSource: document.getElementById('herb-source'), herbTaste: document.getElementById('herb-taste'), herbEffect: document.getElementById('herb-effect'), herbIdentification: document.getElementById('herb-identification'), featurePopup: document.getElementById('feature-popup'), featurePopupContent: document.getElementById('feature-popup-content'), featurePopupCloseBtn: document.getElementById('feature-popup-close'), herbList: document.getElementById('herb-list'), zoomInBtn: document.getElementById('zoom-in-btn'), zoomOutBtn: document.getElementById('zoom-out-btn') }; // --- 4. 数据中心 --- this.herbsData = [ { id: 'biejia', name: '鳖甲', path: './assets/biejia/', objFile: 'biejia.obj', mtlFile: 'biejia.mtl', image: './assets/biejia/biejia-photo.jpg', details: { source: '鳖科动物鳖的背甲。', taste: '咸,寒。归肝、肾经。', effect: '滋阴潜阳,软坚散结,退热除蒸。', identification: '呈椭圆形或卵圆形,背面隆起,具不显明的三条棱线。外表面黑褐色或墨绿色,有细网状皱纹和"十三块"甲片对称的"人"字形及"介"字形纹理。内表面乳白色,中间有突起的脊椎骨。质坚硬。'}, featurePoints: [ { position: new THREE.Vector3(0, 4.5, 1.5), description: '颈盾: 位于最前端中央,是鳖甲十三个主要甲片之一。' }, { position: new THREE.Vector3(1.5, 3.5, -1.5), description: '肋盾: 左右对称,构成背甲的主要部分,可见清晰的生长纹。' }, { position: new THREE.Vector3(0, 0.5, -4.5), description: '臀盾: 位于背甲后端,形状较小,保护尾部区域。' } ] }, { id: 'wanglingzhi', name: '醋五灵脂', path: './assets/wanglingzhi/', objFile: 'wanglingzhi.obj', mtlFile: 'wanglingzhi.mtl', image: './assets/wanglingzhi/wanglingzhi-photo.jpg', details: { source: '鼯鼠科动物复齿鼯鼠的干燥粪便,经醋炮制而成。', taste: '甘、温。归肝经。', effect: '活血止痛,化瘀止血。', identification: '呈不规则的块状,大小不一。表面黑褐色、红棕色或灰褐色,凹凸不平,有的具光泽。质硬,断面不平坦,可见长椭圆形或纤维状的植物残渣。具醋香气,味微苦。'}, featurePoints: [ { position: new THREE.Vector3(1, 2, 1), description: '光泽表面: 部分区域因醋制而呈现特有的光泽感,是鉴别要点之一。' }, { position: new THREE.Vector3(-2, -1, 0), description: '不规则断面: 质地坚硬,断面凹凸不平,可见内部结构。' } ] }, { id: 'jiangcan', name: '僵蚕', path: './assets/jiangcan/', objFile: 'jiangchan-xiao.obj', mtlFile: 'jiangchan-xiao.mtl', image: './assets/jiangcan/jiangchan-photo.jpg', details: { source: '蚕蛾科昆虫家蚕4~5龄的幼虫,在未吐丝前,因感染白僵菌而僵死的干燥体。', taste: '辛、咸,平。归肝、肺、胃经。', effect: '息风止痉,祛风止痛,化痰散结。', identification: '多呈圆柱形,略弯曲,长2~5cm。表面灰白色,被有白色粉霜状的气生菌丝和分生孢子。头部较圆,足8对,体节明显。质硬而脆,易折断,断面平坦,外层白色,中间有棕色或亮棕色的丝腺环。气微腥,味微咸。'}, featurePoints: [ { position: new THREE.Vector3(0, 0, 2.5), description: '头部: 较圆,色泽与体部略有不同,是区分头尾的关键。' }, { position: new THREE.Vector3(0, 0, 0), description: '体节: 环节明显,是蚕体结构的重要特征。' }, { position: new THREE.Vector3(0.5, -0.5, -1), description: '胸足与腹足: 共8对,排列于身体腹侧,略突出。' } ] } ]; } init() { this._setupScene(); this._setupLights(); this._setupCameraAndControls(); this._setupEventListeners(); this._createHerbListUI(); this.animate(); if (this.herbsData.length > 0) { this.switchHerb(this.herbsData[0].id); } } async switchHerb(herbId) { if (this.isLoadingModel) return; const herbData = this.herbsData.find(h => h.id === herbId); if (!herbData) return; this._updateInfoPanel(herbData); this._updateActiveHerbInList(herbId); this.isLoadingModel = true; if (this.isPopupVisible) this.closePopup(); this._updateInteractionState(); this._showLoadingOverlay(); try { const object = await this._loadModelWithProgress(herbData); this._cleanupScene(); this._processLoadedModel(object, herbData); } catch (error) { console.error(`加载药材 ${herbData.name} 失败:`, error); this._cleanupScene(); } finally { this.isLoadingModel = false; this._updateInteractionState(); this._hideLoadingOverlay(); } } _loadModelWithProgress(herb) { return new Promise((resolve, reject) => { const mtlLoader = new MTLLoader(); mtlLoader.setResourcePath(herb.path); mtlLoader.load(herb.path + herb.mtlFile, (materials) => { materials.preload(); const objLoader = new OBJLoader(); objLoader.setMaterials(materials); objLoader.load(herb.path + herb.objFile, resolve, (xhr) => { if (xhr.lengthComputable) { const percent = 30 + (xhr.loaded / xhr.total) * 70; this._updateLoadingProgress(Math.round(percent)); } }, reject); }, (xhr) => { if (xhr.lengthComputable) { const percent = (xhr.loaded / xhr.total) * 30; this._updateLoadingProgress(Math.round(percent)); } }, reject); }); } _cleanupScene() { if (this.currentModel) { this.scene.remove(this.currentModel); this.currentModel.traverse((child) => { if (child.isMesh) { child.geometry?.dispose(); if (Array.isArray(child.material)) { child.material.forEach(material => material.dispose()); } else if (child.material) { child.material.dispose(); } } }); this.currentModel = null; } this.featurePointObjects.forEach(fp => { fp.geometry?.dispose(); fp.material?.dispose(); this.scene.remove(fp); }); this.featurePointObjects = []; } _updateInteractionState() { const shouldBeInteractive = !this.isLoadingModel && !this.isPopupVisible; if (this.isInteractive === shouldBeInteractive) return; this.isInteractive = shouldBeInteractive; this.controls.enabled = this.isInteractive; if (!this.isInteractive) this._clearHoverState(); } _setupScene() { this.renderer = new THREE.WebGLRenderer({ antialias: true, canvas: this.canvas, powerPreference: 'high-performance' }); this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x333344); } _setupLights() { this.scene.add(new THREE.AmbientLight(0xffffff, 0.8)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); dirLight.position.set(10, 15, 10); this.scene.add(dirLight); } _setupCameraAndControls() { this.camera = new THREE.PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight, 0.1, 1000); this.camera.position.set(0, 10, 30); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; this.controls.target.set(0, 5, 0); } _setupEventListeners() { this.renderer.domElement.addEventListener('pointermove', this._onPointerMove.bind(this)); this.renderer.domElement.addEventListener('click', this._onPointerClick.bind(this)); this.dom.featurePopupCloseBtn.addEventListener('click', () => this.closePopup()); this.dom.featurePopup.addEventListener('click', (event) => { if (event.target === this.dom.featurePopup) this.closePopup(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && this.isPopupVisible) this.closePopup(); }); this.dom.zoomInBtn.addEventListener('click', () => this._zoom(0.8)); this.dom.zoomOutBtn.addEventListener('click', () => this._zoom(1.2)); } _onPointerMove(event) { if (!this.isInteractive) return; const now = performance.now(); if (now - this.lastPointerMoveTime < this.pointerMoveThrottle) return; this.lastPointerMoveTime = now; const viewerRect = this.renderer.domElement.getBoundingClientRect(); this.pointer.x = ((event.clientX - viewerRect.left) / viewerRect.width) * 2 - 1; this.pointer.y = -((event.clientY - viewerRect.top) / viewerRect.height) * 2 + 1; this._updateHoverInteraction(); } _onPointerClick() { if (this.isInteractive && this.currentIntersected) { this.showFeaturePointInfo(this.currentIntersected.userData.description); } } showFeaturePointInfo(description) { this.dom.featurePopupContent.innerHTML = description; this.dom.featurePopup.classList.remove('hidden'); this.isPopupVisible = true; this._updateInteractionState(); } closePopup() { if (!this.isPopupVisible) return; this.dom.featurePopup.classList.add('hidden'); this.isPopupVisible = false; this._updateInteractionState(); } animate() { requestAnimationFrame(this.animate.bind(this)); this._resizeRendererToDisplaySize(); this.controls.update(); this.renderer.render(this.scene, this.camera); } _updateHoverInteraction() { this.raycaster.setFromCamera(this.pointer, this.camera); const intersects = this.raycaster.intersectObjects(this.featurePointObjects); if (intersects.length > 0) { const intersectedObject = intersects[0].object; if (this.currentIntersected !== intersectedObject) { this._clearHoverState(); this.currentIntersected = intersectedObject; this.currentIntersected.material.emissive.setHex(0xffff00); } this.renderer.domElement.style.cursor = 'pointer'; } else { this._clearHoverState(); } } _clearHoverState() { if (this.currentIntersected) { this.currentIntersected.material.emissive.setHex(0x000000); this.currentIntersected = null; } this.renderer.domElement.style.cursor = 'auto'; } _createFeaturePoints(featurePoints, scale) { if (!featurePoints || featurePoints.length === 0) return; featurePoints.forEach((pointData, index) => { const material = this.featurePointMaterialTemplate.clone(); const sphere = new THREE.Mesh(this.featurePointGeometry, material); sphere.position.copy(pointData.position).multiplyScalar(scale); sphere.userData = { description: pointData.description, isFeaturePoint: true, index }; this.scene.add(sphere); this.featurePointObjects.push(sphere); }); } _processLoadedModel(object, herb) { this._clearHoverState(); const box = new THREE.Box3().setFromObject(object); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); object.position.sub(center); const maxDim = Math.max(size.x, size.y, size.z); const scale = maxDim > 0 ? 15 / maxDim : 1; object.scale.set(scale, scale, scale); this.scene.add(object); this.currentModel = object; this._createFeaturePoints(herb.featurePoints, scale); this._resetCameraPosition(); } _createHerbListUI() { this.herbsData.forEach(herb => { const li = document.createElement('li'); li.textContent = herb.name; li.dataset.id = herb.id; li.addEventListener('click', () => this.switchHerb(herb.id)); this.dom.herbList.appendChild(li); }); } _resizeRendererToDisplaySize() { const canvas = this.renderer.domElement; const viewerRect = canvas.getBoundingClientRect(); const width = Math.round(viewerRect.width); const height = Math.round(viewerRect.height); if (canvas.width !== width || canvas.height !== height) { this.renderer.setSize(width, height, false); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); } } _resetCameraPosition() { this.camera.position.set(0, 10, 30); this.controls.target.set(0, 5, 0); this.controls.update(); } _updateInfoPanel(herb) { this.dom.herbImage.src = herb.image || ''; this.dom.herbImage.alt = herb.name; this.dom.herbName.textContent = herb.name; this.dom.herbSource.textContent = herb.details.source || '暂无'; this.dom.herbTaste.textContent = herb.details.taste || '暂无'; this.dom.herbEffect.textContent = herb.details.effect || '暂无'; this.dom.herbIdentification.textContent = herb.details.identification || '暂无'; } _updateActiveHerbInList(herbId) { this.dom.herbList.querySelector('li.active')?.classList.remove('active'); this.dom.herbList.querySelector(`li[data-id='${herbId}']`)?.classList.add('active'); } _zoom(factor) { this.camera.position.sub(this.controls.target).multiplyScalar(factor).add(this.controls.target); } _showLoadingOverlay() { this.dom.loadingOverlay.classList.add('visible'); this._updateLoadingProgress(0); } _hideLoadingOverlay() { this.dom.loadingOverlay.classList.remove('visible'); } _updateLoadingProgress(percent) { this.dom.progressBar.style.width = percent + '%'; this.dom.progressText.textContent = percent + '%'; } } // --- 启动应用 (已修改) --- // 等待整个HTML文档加载并解析完毕后,再执行初始化代码 document.addEventListener('DOMContentLoaded', () => { const canvas = document.querySelector('#c'); // 添加一个安全检查,确保canvas元素存在 if (canvas) { const app = new HerbalApp(canvas); app.init(); } else { console.error('错误:在HTML中没有找到ID为 "c" 的canvas元素。应用无法启动。'); } });