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元素。应用无法启动。');
}
});