Files

427 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loadThreeJS } from './three-loader.js';
import { AZIMUTH_MAP, ELEVATION_MAP, DISTANCE_MAP, AZIMUTHS, ELEVATIONS } from './constants.js';
import { ModelLoader } from './model-loader.js';
import { t } from './i18n.js';
// 模型位置偏移配置
const MODEL_OFFSET = {
x: 0, // 左右偏移:负数向左,正数向右
y: -40, // 垂直偏移:负数向下,正数向上
z: -40, // 前后偏移:负数向前,正数向后
scale: 1 // 大小缩放:1为默认大小,大于1放大,小于1缩小
};
export class AnglesSelector3DWidget {
constructor(node, container) {
this.node = node;
this.container = container;
this.selectedPoints = [];
this.pointMeshes = [];
this.gridLines = { 0: [], 1: [], 2: [] }; // 按距离层分组的网格线
this.layerVisible = { 0: true, 1: true, 2: true }; // 层可见性
this.scene = null;
this.camera = null;
this.renderer = null;
this.THREE = null;
this.animationId = null;
this.spherical = null;
this.raycaster = null;
this.mouse = null;
}
async init() {
try {
this.THREE = await loadThreeJS();
if (!this.THREE) return;
this.raycaster = new this.THREE.Raycaster();
this.raycaster.params.Sphere = { threshold: 8 };
this.mouse = new this.THREE.Vector2();
this.setupScene();
await this.createFigure();
this.createGridSpheres();
this.createPoints();
this.setupControls();
this.setupRaycaster();
this.animate();
this.setupResizeObserver();
} catch (e) {
console.error("AnglesSelector3D: Init error", e);
}
}
setupResizeObserver() {
const cc = this.container.querySelector('.canvas-container');
if (!cc) return;
// 使用 ResizeObserver 监听容器大小变化
const ro = new ResizeObserver(() => this.handleResize());
ro.observe(cc);
ro.observe(this.container);
// 定期检查大小变化(备用方案)
this.resizeInterval = setInterval(() => this.handleResize(), 500);
}
handleResize() {
if (!this.renderer || !this.camera) return;
const cc = this.container.querySelector('.canvas-container');
if (!cc) return;
const w = cc.clientWidth || 400;
const h = cc.clientHeight || 320;
// 只在尺寸真正变化时更新
if (this.lastWidth === w && this.lastHeight === h) return;
this.lastWidth = w;
this.lastHeight = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
setupScene() {
const THREE = this.THREE;
const cc = this.container.querySelector('.canvas-container');
const w = cc?.clientWidth || 400, h = cc?.clientHeight || 320;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1a1a2e);
this.camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 1000);
this.camera.position.set(0, 150, 350); // 正面视角:X=0(居中), Y=150(略高), Z=350(前方)
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
if (cc) cc.appendChild(this.renderer.domElement);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(100, 100, 100);
this.scene.add(dl);
this.spherical = new THREE.Spherical();
this.spherical.setFromVector3(this.camera.position);
}
async createFigure() {
const THREE = this.THREE;
const modelLoader = new ModelLoader(THREE);
// 尝试加载 GLB 模型
const modelPath = new URL('./models/toon_cat_free.glb', import.meta.url).href;
try {
const model = await modelLoader.loadModel(modelPath);
// 调整模型大小和位置
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// 缩放到合适大小(约120单位高,原来的3倍),并应用自定义缩放
const scale = (120 / size.y) * MODEL_OFFSET.scale;
model.scale.set(scale, scale, scale);
// 应用偏移量(在文件头配置)
model.position.set(
-center.x * scale + MODEL_OFFSET.x,
-center.y * scale + MODEL_OFFSET.y,
-center.z * scale + MODEL_OFFSET.z
);
console.log('Model bounds:', { size, center, scale, position: model.position });
this.scene.add(model);
console.log('GLB model loaded successfully');
} catch (error) {
console.warn('Failed to load GLB model, using fallback:', error);
// 加载失败时使用备用方案
const figure = modelLoader.createSimpleHumanoid(THREE);
this.scene.add(figure);
}
}
createGridSpheres() {
const THREE = this.THREE;
let di = 0;
for (const { radius, color } of Object.values(DISTANCE_MAP)) {
for (const elev of ELEVATIONS) {
const er = (elev * Math.PI) / 180;
const rr = radius * Math.cos(er), y = radius * Math.sin(er);
const curve = new THREE.EllipseCurve(0, 0, rr, rr, 0, 2 * Math.PI, false, 0);
const pts = curve.getPoints(64).map(p => new THREE.Vector3(p.x, y, p.y));
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.3 }));
line.userData.distanceLayer = di;
this.scene.add(line);
this.gridLines[di].push(line);
}
for (const azim of AZIMUTHS) {
const ar = (azim * Math.PI) / 180, pts = [];
for (let e = -90; e <= 90; e += 5) {
const er = (e * Math.PI) / 180;
pts.push(new THREE.Vector3(radius * Math.cos(er) * Math.sin(ar), radius * Math.sin(er), radius * Math.cos(er) * Math.cos(ar)));
}
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.3 }));
line.userData.distanceLayer = di;
this.scene.add(line);
this.gridLines[di].push(line);
}
di++;
}
// 在最外层添加方位标签
this.createViewLabels();
}
createViewLabels() {
const THREE = this.THREE;
const outerRadius = DISTANCE_MAP[2].radius + 30; // 最外层半径 + 偏移
const labels = [
{ text: t('front'), azim: 0, color: '#FF0000' },
{ text: t('right'), azim: 90, color: '#00FF00' },
{ text: t('back'), azim: 180, color: '#0088FF' },
{ text: t('left'), azim: 270, color: '#FFFF00' },
{ text: t('frontRight'), azim: 45, color: '#FF8800' },
{ text: t('backRight'), azim: 135, color: '#00FFAA' },
{ text: t('backLeft'), azim: 225, color: '#00DDFF' },
{ text: t('frontLeft'), azim: 315, color: '#FF00FF' }
];
for (const label of labels) {
const ar = (label.azim * Math.PI) / 180;
const x = outerRadius * Math.sin(ar);
const z = outerRadius * Math.cos(ar);
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = label.color;
ctx.font = 'bold 32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label.text, 64, 32);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(canvas),
transparent: true
}));
sprite.position.set(x, 0, z);
sprite.scale.set(30, 15, 1);
this.scene.add(sprite);
}
// 添加上下标签
const verticalLabels = [
{ text: t('top'), y: outerRadius, color: '#00FFFF' },
{ text: t('bottom'), y: -outerRadius, color: '#FF66FF' }
];
for (const label of verticalLabels) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = label.color;
ctx.font = 'bold 32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label.text, 64, 32);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(canvas),
transparent: true
}));
sprite.position.set(0, label.y, 0);
sprite.scale.set(30, 15, 1);
this.scene.add(sprite);
}
}
createPoints() {
const THREE = this.THREE;
this.pointMeshes = [];
let di = 0;
for (const { radius, color } of Object.values(DISTANCE_MAP)) {
for (const azim of AZIMUTHS) {
for (const elev of ELEVATIONS) {
const ar = (azim * Math.PI) / 180, er = (elev * Math.PI) / 180;
const m = new THREE.Mesh(new THREE.SphereGeometry(7, 16, 16), new THREE.MeshPhongMaterial({ color, transparent: true, opacity: 0.7, emissive: color, emissiveIntensity: 0.2 }));
m.position.set(radius * Math.cos(er) * Math.sin(ar), radius * Math.sin(er), radius * Math.cos(er) * Math.cos(ar));
m.userData = { azimuth: azim, elevation: elev, distance: di, baseColor: color, selected: false };
this.scene.add(m);
this.pointMeshes.push(m);
}
}
di++;
}
}
toggleLayerVisibility(layerIndex) {
this.layerVisible[layerIndex] = !this.layerVisible[layerIndex];
const visible = this.layerVisible[layerIndex];
// 切换网格线可见性
for (const line of this.gridLines[layerIndex]) {
line.visible = visible;
}
// 切换点可见性
for (const m of this.pointMeshes) {
if (m.userData.distance === layerIndex) {
m.visible = visible;
}
}
return visible;
}
setupControls() {
let drag = false, prev = { x: 0, y: 0 };
const cv = this.renderer.domElement;
cv.addEventListener('mousedown', e => { if (e.button === 0) { drag = true; prev = { x: e.clientX, y: e.clientY }; } });
cv.addEventListener('mousemove', e => {
if (!drag) return;
this.spherical.theta -= (e.clientX - prev.x) * 0.01;
this.spherical.phi -= (e.clientY - prev.y) * 0.01;
this.spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, this.spherical.phi));
this.camera.position.setFromSpherical(this.spherical);
this.camera.lookAt(0, 0, 0);
prev = { x: e.clientX, y: e.clientY };
});
cv.addEventListener('mouseup', () => drag = false);
cv.addEventListener('mouseleave', () => drag = false);
cv.addEventListener('wheel', e => {
e.preventDefault();
this.spherical.radius = Math.max(150, Math.min(600, this.spherical.radius + e.deltaY * 0.5));
this.camera.position.setFromSpherical(this.spherical);
this.camera.lookAt(0, 0, 0);
}, { passive: false });
cv.addEventListener('dblclick', () => {
this.spherical.set(350, Math.PI / 3, 0); // 双击重置到正面视角
this.camera.position.setFromSpherical(this.spherical);
this.camera.lookAt(0, 0, 0);
});
}
setupRaycaster() {
const cv = this.renderer.domElement;
let hovered = null;
cv.addEventListener('mousemove', e => {
const r = cv.getBoundingClientRect();
this.mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1;
this.mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
// 只检测可见的点
const visiblePoints = this.pointMeshes.filter(m => m.visible);
const hits = this.raycaster.intersectObjects(visiblePoints);
if (hovered && !hovered.userData.selected) { hovered.scale.set(1, 1, 1); hovered.material.opacity = 0.7; hovered.material.emissiveIntensity = 0.2; }
if (hits.length > 0) {
hovered = hits[0].object;
if (!hovered.userData.selected) { hovered.scale.set(1.3, 1.3, 1.3); hovered.material.opacity = 1; hovered.material.emissiveIntensity = 0.5; }
cv.style.cursor = 'pointer';
} else { hovered = null; cv.style.cursor = 'grab'; }
});
cv.addEventListener('click', e => {
const r = cv.getBoundingClientRect();
this.mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1;
this.mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const visiblePoints = this.pointMeshes.filter(m => m.visible);
const hits = this.raycaster.intersectObjects(visiblePoints);
if (hits.length > 0) this.togglePoint(hits[0].object);
});
}
togglePoint(mesh) {
const ud = mesh.userData;
if (ud.selected) {
ud.selected = false;
mesh.material.color.setHex(ud.baseColor);
mesh.material.emissive.setHex(ud.baseColor);
mesh.material.opacity = 0.7;
mesh.material.emissiveIntensity = 0.2;
mesh.scale.set(1, 1, 1);
this.selectedPoints = this.selectedPoints.filter(p => !(p.azimuth === ud.azimuth && p.elevation === ud.elevation && p.distance === ud.distance));
} else {
ud.selected = true;
mesh.material.color.setHex(0xFFFFFF);
mesh.material.emissive.setHex(0xFFFFFF);
mesh.material.opacity = 1;
mesh.material.emissiveIntensity = 0.5;
mesh.scale.set(1.3, 1.3, 1.3);
this.selectedPoints.push({ azimuth: ud.azimuth, elevation: ud.elevation, distance: ud.distance });
}
this.updateNodeValue();
this.updateSelectionDisplay();
}
updateNodeValue() {
// 查找 selected_points widget 并更新值
const w = this.node.widgets?.find(w => w.name === "selected_points");
if (w) {
w.value = JSON.stringify(this.selectedPoints);
console.log('Updated selected_points:', w.value);
} else {
console.warn('selected_points widget not found');
}
}
updateSelectionDisplay() {
const d = this.container.querySelector('.selection-display');
if (!d) return;
if (this.selectedPoints.length === 0) {
d.innerHTML = `<div class="no-selection">${t('noSelection')}</div>`;
return;
}
let h = `<div class="selection-count">${t('selectedCount', { n: this.selectedPoints.length })}</div><div class="selection-list">`;
for (const p of this.selectedPoints) {
const c = DISTANCE_MAP[p.distance].color.toString(16).padStart(6, '0');
h += `<div class="selection-item" style="border-left:3px solid #${c}"><span>${AZIMUTH_MAP[p.azimuth]}, ${ELEVATION_MAP[p.elevation]}, ${DISTANCE_MAP[p.distance].name}</span><button class="remove-btn" data-a="${p.azimuth}" data-e="${p.elevation}" data-d="${p.distance}">×</button></div>`;
}
d.innerHTML = h + '</div>';
d.querySelectorAll('.remove-btn').forEach(b => {
b.onclick = e => {
const m = this.pointMeshes.find(m => m.userData.azimuth === +e.target.dataset.a && m.userData.elevation === +e.target.dataset.e && m.userData.distance === +e.target.dataset.d);
if (m) this.togglePoint(m);
};
});
}
clearAll() {
for (const m of this.pointMeshes) {
if (m.userData.selected) {
m.userData.selected = false;
m.material.color.setHex(m.userData.baseColor);
m.material.emissive.setHex(m.userData.baseColor);
m.material.opacity = 0.7;
m.material.emissiveIntensity = 0.2;
m.scale.set(1, 1, 1);
}
}
this.selectedPoints = [];
this.updateNodeValue();
this.updateSelectionDisplay();
}
selectLayer(di) {
if (!this.layerVisible[di]) return; // 隐藏的层不能全选
for (const m of this.pointMeshes) if (m.userData.distance === di && !m.userData.selected) this.togglePoint(m);
}
animate() {
if (!this.renderer) return;
this.animationId = requestAnimationFrame(() => this.animate());
this.renderer.render(this.scene, this.camera);
}
destroy() {
if (this.animationId) cancelAnimationFrame(this.animationId);
if (this.resizeInterval) clearInterval(this.resizeInterval);
if (this.renderer) { this.renderer.dispose(); this.renderer.domElement?.remove(); }
}
}