qie_one_json3.html
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>多部件3D查看器 - 爆炸视图 & 颜色分组导出</title>
<style>
/* ===== 全局重置 ===== */
* { box-sizing: border-box; }
body {
margin: 0;
overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
background-color: #1a1a2e;
color: #fff;
}
#info {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 10px 18px;
border-radius: 10px;
backdrop-filter: blur(8px);
pointer-events: none;
z-index: 10;
font-size: 14px;
border-left: 4px solid #FFD700;
user-select: none;
}
#controls-panel {
position: absolute;
bottom: 30px;
left: 24px;
background: #323232;
border-radius: 6px;
color: #dfdfdf;
padding: 8px 12px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
z-index: 20;
font-family: monospace;
flex-wrap: wrap;
}
#controls-panel .group {
display: flex;
gap: 6px;
align-items: center;
}
#controls-panel .group+.group {
border-left: 1px solid rgba(255, 255, 255, 0.1);
padding-left: 12px;
}
button {
background: rgba(255, 255, 255, 0.08);
border: none;
color: #fff;
padding: 5px 14px;
border-radius: 20px;
cursor: pointer;
transition: 0.2s;
white-space: nowrap;
font-size: 12px;
}
button:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.04);
}
button.active {
background: rgba(255, 215, 0, 0.25);
color: #FFD700;
}
.reset-btn { background: #27ae60; }
.reset-btn:hover { background: #2ecc71; }
.print-btn { background: #8e44ad; }
.print-btn:hover { background: #9b59b6; }
#parts-panel {
position: absolute;
top: 20px;
right: 20px;
width: 280px;
max-height: calc(100vh - 120px);
background: rgba(30, 30, 50, 0.9);
backdrop-filter: blur(12px);
border-radius: 12px;
padding: 14px 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
z-index: 15;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
#parts-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
#parts-panel .panel-header h3 {
margin: 0;
font-weight: 500;
font-size: 16px;
color: #ddd;
}
#parts-panel .panel-header .count {
font-size: 12px;
color: #888;
}
#parts-list {
flex: 1;
overflow-y: auto;
margin: 8px -4px 0 -4px;
padding: 0 4px;
max-height: calc(100vh - 220px);
}
#parts-list::-webkit-scrollbar {
width: 4px;
}
#parts-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
.part-item {
display: flex;
align-items: center;
padding: 6px 8px;
margin-bottom: 4px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid transparent;
transition: 0.15s;
cursor: default;
gap: 6px;
flex-wrap: wrap;
}
.part-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.part-item.active {
background: rgba(255, 215, 0, 0.12);
border-left-color: #FFD700;
}
.part-item .name {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 40px;
color: #ccc;
}
/* 颜色下拉 */
.color-selector {
position: relative;
flex-shrink: 0;
user-select: none;
font-size: 12px;
}
.color-selector .trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 4px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: 0.2s;
min-width: 60px;
height: 28px;
}
.color-selector .trigger:hover {
background: rgba(255, 255, 255, 0.16);
}
.color-selector .trigger .swatch {
width: 20px;
height: 20px;
border-radius: 2px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.color-selector .trigger .label {
font-size: 11px;
color: #ddd;
white-space: nowrap;
max-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
}
.color-selector .trigger .arrow {
font-size: 10px;
color: #999;
margin-left: 2px;
transition: transform 0.2s;
}
.color-selector .trigger .arrow.open {
transform: rotate(180deg);
}
.color-selector .dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
background: #2d2d44;
border-radius: 8px;
padding: 4px 0;
min-width: 120px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
display: none;
max-height: 200px;
overflow-y: auto;
}
.color-selector .dropdown.open {
display: block;
}
.color-selector .dropdown .option {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
cursor: pointer;
transition: 0.1s;
}
.color-selector .dropdown .option:hover {
background: rgba(255, 255, 255, 0.08);
}
.color-selector .dropdown .option .swatch {
width: 20px;
height: 20px;
border-radius: 2px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.color-selector .dropdown .option .label {
font-size: 12px;
color: #ddd;
}
.color-selector .dropdown .option .check {
margin-left: auto;
color: #FFD700;
font-size: 14px;
display: none;
}
.color-selector .dropdown .option.active .check {
display: inline;
}
.part-item input[type="range"] {
width: 50px;
accent-color: #FFD700;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.part-item .size-label {
font-size: 10px;
color: #999;
min-width: 20px;
text-align: center;
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
flex-direction: column;
}
.loading-spinner {
width: 44px;
height: 44px;
border: 4px solid #f3f3f3;
border-top: 4px solid #FFD700;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: #fff;
margin-top: 16px;
font-size: 14px;
}
.status-tip {
position: absolute;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.55);
color: #FFD700;
padding: 4px 18px;
border-radius: 30px;
font-size: 11px;
pointer-events: none;
z-index: 10;
opacity: 0.7;
white-space: nowrap;
}
@media (max-width: 800px) {
#parts-panel { width: 220px; right: 10px; top: 10px; padding: 10px; }
.color-selector .trigger .label { max-width: 28px; font-size: 10px; }
.color-selector .trigger { min-width: 44px; height: 24px; padding: 1px 6px 1px 3px; }
.color-selector .trigger .swatch { width: 16px; height: 16px; }
.color-selector .dropdown .option .swatch { width: 16px; height: 16px; }
.color-selector .dropdown { min-width: 90px; }
}
@media (max-width: 500px) {
#parts-panel { width: 170px; right: 6px; top: 8px; padding: 6px; }
.part-item { padding: 4px 4px; gap: 4px; }
.part-item input[type="range"] { width: 28px; }
.part-item .size-label { font-size: 8px; min-width: 14px; }
#controls-panel { flex-wrap: wrap; gap: 4px; }
.color-selector .trigger .label { display: none; }
.color-selector .trigger { min-width: 32px; padding: 1px 4px; }
.color-selector .trigger .swatch { width: 18px; height: 18px; }
.color-selector .dropdown { min-width: 80px; left: -10px; }
}
</style>
</head>
<body>
<div id="info">🧩 多部件查看器 · 点击部件/列表项选中 · 可调颜色/大小</div>
<div id="status-tip" class="status-tip">🖱 拖拽旋转 · 滚轮缩放 · 右键平移</div>
<div id="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">加载中...</div>
</div>
<div id="controls-panel">
<div class="group">
<button id="mode-translate" class="active">移动</button>
<button id="mode-rotate">旋转</button>
<button id="mode-scale">缩放</button>
</div>
<div class="group">
<button id="reset-btn" class="reset-btn">重置视图</button>
<button id="explode-btn">💥 爆炸/收起</button>
</div>
<div class="group">
<button id="print-btn" class="print-btn">🖨️ 按颜色导出GLB</button>
</div>
</div>
<div id="parts-panel">
<div class="panel-header">
<h3>部件列表</h3>
<span class="count" id="part-count">0</span>
</div>
<div id="parts-list"></div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.128.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
// ============================================================
// 预设颜色
// ============================================================
const PRESET_COLORS = [
{ name: '红色', value: '#e74c3c' },
{ name: '金黄色', value: '#FFD700' },
{ name: '蓝色', value: '#3498db' },
{ name: '黑白', value: '#7f8c8d' },
{ name: '绿色', value: '#2ecc71' },
{ name: '紫色', value: '#9b59b6' },
{ name: '橙色', value: '#e67e22' },
{ name: '青色', value: '#00cec9' },
{ name: '粉色', value: '#fd79a8' },
];
function hexToColorName(hex) {
const str = '#' + hex.toString(16).padStart(6, '0');
const found = PRESET_COLORS.find(c => c.value === str);
return found ? found.name : '自定义';
}
function hexToColorStr(hex) {
return '#' + hex.toString(16).padStart(6, '0');
}
function colorStrToHex(str) {
return parseInt(str.slice(1), 16);
}
// ============================================================
// 场景 / 相机 / 渲染器
// ============================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(6, 4, 8);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ============================================================
// 灯光
// ============================================================
const ambient = new THREE.AmbientLight(0x8899bb, 0.5);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x88aaff, 0x443366, 0.8);
scene.add(hemi);
const dirLight = new THREE.DirectionalLight(0xffeedd, 1.6);
dirLight.position.set(2, 6, 3);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0x4488ff, 0.4);
fillLight.position.set(-3, 1, -2);
scene.add(fillLight);
// ============================================================
// 环境贴图
// ============================================================
const pmrem = new THREE.PMREMGenerator(renderer);
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 256;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, '#2a2a4a');
grad.addColorStop(0.5, '#3d3d6b');
grad.addColorStop(1, '#1a1a2e');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 256);
const envTexture = pmrem.fromEquirectangular(new THREE.CanvasTexture(canvas)).texture;
scene.environment = envTexture;
// ============================================================
// 地面网格 & 控制器
// ============================================================
const gridHelper = new THREE.GridHelper(20, 20, 0x6688cc, 0x334466);
gridHelper.position.y = -0.01;
scene.add(gridHelper);
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.target.set(0, 0, 0);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.08;
orbitControls.minDistance = 0.5;
orbitControls.maxDistance = 50;
orbitControls.update();
const transformControls = new TransformControls(camera, renderer.domElement);
transformControls.addEventListener('dragging-changed', (e) => {
orbitControls.enabled = !e.value;
});
scene.add(transformControls.getHelper());
// ============================================================
// 模型管理
// ============================================================
let objectGroup = null;
let partMeshes = [];
let partData = [];
let selectedIndex = -1;
// 包围盒高亮(金黄色)
let bboxHelper = null;
const loader = new GLTFLoader();
function disposeObject(obj) {
obj.traverse((child) => {
if (child.isMesh) {
child.geometry?.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material?.dispose();
}
}
});
}
function clearModel() {
if (objectGroup) {
scene.remove(objectGroup);
disposeObject(objectGroup);
objectGroup = null;
}
// 清除包围盒
removeBBox();
partMeshes = [];
partData = [];
selectedIndex = -1;
transformControls.detach();
document.getElementById('parts-list').innerHTML = '';
document.getElementById('part-count').textContent = '0';
}
// ---------- 3D包围盒高亮方案(金黄色) ----------
function addBBox(mesh) {
removeBBox();
if (!mesh) return;
// 计算世界空间中的包围盒
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// 金黄色线框
const boxGeo = new THREE.BoxGeometry(size.x, size.y, size.z);
const edges = new THREE.EdgesGeometry(boxGeo);
const mat = new THREE.LineBasicMaterial({
color: 0xFFD700,
transparent: true,
opacity: 0.95,
linewidth: 1,
});
const line = new THREE.LineSegments(edges, mat);
line.position.copy(center);
scene.add(line);
bboxHelper = line;
// 半透明填充(金黄色,更醒目)
const fillMat = new THREE.MeshBasicMaterial({
color: 0xFFD700,
transparent: true,
opacity: 0.10,
depthWrite: false,
side: THREE.DoubleSide,
});
const fillMesh = new THREE.Mesh(boxGeo.clone(), fillMat);
fillMesh.position.copy(center);
scene.add(fillMesh);
bboxHelper.userData.fillMesh = fillMesh;
// 释放临时几何体
boxGeo.dispose();
edges.dispose();
}
function removeBBox() {
if (bboxHelper) {
scene.remove(bboxHelper);
bboxHelper.geometry.dispose();
bboxHelper.material.dispose();
// 清理关联的填充mesh
if (bboxHelper.userData.fillMesh) {
const fill = bboxHelper.userData.fillMesh;
scene.remove(fill);
fill.geometry.dispose();
fill.material.dispose();
}
bboxHelper = null;
}
}
// 更新包围盒位置(当部件发生变换时)
function updateBBox() {
if (!bboxHelper || selectedIndex < 0 || selectedIndex >= partMeshes.length) return;
const mesh = partMeshes[selectedIndex];
const box = new THREE.Box3().setFromObject(mesh);
const center = box.getCenter(new THREE.Vector3());
bboxHelper.position.copy(center);
if (bboxHelper.userData.fillMesh) {
bboxHelper.userData.fillMesh.position.copy(center);
}
}
// ---------- 加载模型 ----------
function loadModelFromGLTF(gltf) {
clearModel();
const model = gltf.scene;
const meshes = [];
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
const geo = child.geometry.clone();
const mat = Array.isArray(child.material) ?
child.material.map((m) => m.clone()) :
child.material.clone();
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(child.position);
mesh.quaternion.copy(child.quaternion);
mesh.scale.copy(child.scale);
mesh.name = child.name || '部件';
meshes.push(mesh);
}
});
if (meshes.length === 0) {
alert('模型中没有网格');
return;
}
objectGroup = new THREE.Group();
scene.add(objectGroup);
meshes.forEach((m) => objectGroup.add(m));
partMeshes = meshes;
const box = new THREE.Box3().setFromObject(objectGroup);
const center = box.getCenter(new THREE.Vector3());
objectGroup.position.sub(center);
objectGroup.updateMatrixWorld();
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 2.2 + 2;
camera.position.set(dist * 0.8, dist * 0.6, dist);
orbitControls.target.set(0, 0, 0);
orbitControls.update();
partData = partMeshes.map((mesh, idx) => {
const color = mesh.material.color.getHex();
return {
mesh: mesh,
name: mesh.name || `部件${idx+1}`,
color: color,
scale: 1.0,
originalPos: mesh.position.clone(),
};
});
selectedIndex = 0;
transformControls.attach(partMeshes[0]);
explodeParts(0.6);
renderPartList();
updatePartCount();
highlightSelected();
}
// ---------- 爆炸 / 收起 ----------
let explodeFactor = 0.6;
let isExploded = true;
function explodeParts(factor) {
if (!objectGroup || partMeshes.length === 0) return;
const groupCenter = new THREE.Vector3(0, 0, 0);
const dirs = partMeshes.map((mesh) => {
const worldPos = new THREE.Vector3();
mesh.getWorldPosition(worldPos);
const dir = worldPos.clone().sub(groupCenter);
if (dir.length() < 0.001) dir.set(0, 1, 0);
return dir.normalize();
});
let avgDist = 0;
const centers = partMeshes.map((mesh) => {
const wp = new THREE.Vector3();
mesh.getWorldPosition(wp);
return wp;
});
centers.forEach((c) => avgDist += c.length());
avgDist /= centers.length || 1;
const baseOffset = Math.max(0.5, avgDist * 0.4);
partMeshes.forEach((mesh, i) => {
const origPos = partData[i].originalPos.clone();
const offset = dirs[i].clone().multiplyScalar(factor * baseOffset);
mesh.position.copy(origPos.clone().add(offset));
mesh.updateMatrixWorld();
});
explodeFactor = factor;
isExploded = factor > 0.05;
// 更新包围盒位置
updateBBox();
}
function toggleExplode() {
if (!objectGroup || partMeshes.length === 0) return;
if (isExploded) {
explodeParts(0);
document.getElementById('explode-btn').textContent = '💥 爆炸';
} else {
explodeParts(0.6);
document.getElementById('explode-btn').textContent = '🔽 收起';
}
isExploded = !isExploded;
}
// ---------- 部件列表渲染 ----------
function renderPartList() {
const list = document.getElementById('parts-list');
list.innerHTML = '';
partData.forEach((data, idx) => {
const div = document.createElement('div');
div.className = 'part-item' + (idx === selectedIndex ? ' active' : '');
div.dataset.index = idx;
const nameSpan = document.createElement('span');
nameSpan.className = 'name';
nameSpan.textContent = data.name;
div.appendChild(nameSpan);
// 颜色下拉
const selector = document.createElement('div');
selector.className = 'color-selector';
selector.dataset.index = idx;
const trigger = document.createElement('div');
trigger.className = 'trigger';
const colorHex = data.color;
const colorStr = hexToColorStr(colorHex);
const colorName = hexToColorName(colorHex);
const swatch = document.createElement('span');
swatch.className = 'swatch';
swatch.style.backgroundColor = colorStr;
trigger.appendChild(swatch);
const label = document.createElement('span');
label.className = 'label';
label.textContent = colorName;
trigger.appendChild(label);
const arrow = document.createElement('span');
arrow.className = 'arrow';
arrow.textContent = '▾';
trigger.appendChild(arrow);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown';
PRESET_COLORS.forEach((pc) => {
const opt = document.createElement('div');
opt.className = 'option';
if (pc.value === colorStr) opt.classList.add('active');
opt.dataset.color = pc.value;
const optSwatch = document.createElement('span');
optSwatch.className = 'swatch';
optSwatch.style.backgroundColor = pc.value;
opt.appendChild(optSwatch);
const optLabel = document.createElement('span');
optLabel.className = 'label';
optLabel.textContent = pc.name;
opt.appendChild(optLabel);
const check = document.createElement('span');
check.className = 'check';
check.textContent = '✓';
opt.appendChild(check);
opt.addEventListener('click', (e) => {
e.stopPropagation();
const newColorStr = opt.dataset.color;
const newHex = colorStrToHex(newColorStr);
setPartColor(idx, newHex);
updateColorSelectorUI(selector, newHex);
dropdown.classList.remove('open');
arrow.classList.remove('open');
});
dropdown.appendChild(opt);
});
selector.appendChild(trigger);
selector.appendChild(dropdown);
trigger.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.color-selector .dropdown.open').forEach((d) => {
if (d !== dropdown) {
d.classList.remove('open');
d.parentElement.querySelector('.arrow')?.classList.remove('open');
}
});
dropdown.classList.toggle('open');
arrow.classList.toggle('open');
});
div.appendChild(selector);
// 大小滑块
const sizeSlider = document.createElement('input');
sizeSlider.type = 'range';
sizeSlider.min = '0.3';
sizeSlider.max = '2.0';
sizeSlider.step = '0.01';
sizeSlider.value = data.scale;
sizeSlider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
setPartScale(idx, val);
});
div.appendChild(sizeSlider);
const sizeLabel = document.createElement('span');
sizeLabel.className = 'size-label';
sizeLabel.textContent = data.scale.toFixed(2);
div.appendChild(sizeLabel);
div.addEventListener('click', (e) => {
if (e.target.closest('.color-selector')) return;
selectPart(idx);
});
list.appendChild(div);
});
}
function updateColorSelectorUI(selector, hex) {
const colorStr = hexToColorStr(hex);
const colorName = hexToColorName(hex);
const swatch = selector.querySelector('.trigger .swatch');
if (swatch) swatch.style.backgroundColor = colorStr;
const label = selector.querySelector('.trigger .label');
if (label) label.textContent = colorName;
const options = selector.querySelectorAll('.dropdown .option');
options.forEach((opt) => {
opt.classList.toggle('active', opt.dataset.color === colorStr);
});
}
function updatePartCount() {
document.getElementById('part-count').textContent = partData.length;
}
// ---------- 高亮列表和3D ----------
function highlightSelected() {
// 列表高亮(金黄色)
const items = document.querySelectorAll('.part-item');
items.forEach((el, idx) => {
el.classList.toggle('active', idx === selectedIndex);
});
// 3D 包围盒高亮(金黄色,不改变颜色)
if (selectedIndex >= 0 && selectedIndex < partMeshes.length) {
addBBox(partMeshes[selectedIndex]);
} else {
removeBBox();
}
}
// ---------- 选中部件 ----------
function selectPart(index) {
if (index < 0 || index >= partMeshes.length) return;
if (selectedIndex === index) return;
selectedIndex = index;
transformControls.attach(partMeshes[index]);
highlightSelected();
const items = document.querySelectorAll('.part-item');
if (items[index]) {
items[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
// ---------- 修改部件属性 ----------
function setPartColor(index, hexColor) {
if (index < 0 || index >= partData.length) return;
const data = partData[index];
data.color = hexColor;
const mesh = data.mesh;
const color = new THREE.Color(hexColor);
if (Array.isArray(mesh.material)) {
mesh.material.forEach((m) => m.color.copy(color));
} else {
mesh.material.color.copy(color);
}
const items = document.querySelectorAll('.part-item');
if (items[index]) {
const selector = items[index].querySelector('.color-selector');
if (selector) updateColorSelectorUI(selector, hexColor);
}
}
function setPartScale(index, scaleVal) {
if (index < 0 || index >= partData.length) return;
const data = partData[index];
data.scale = scaleVal;
data.mesh.scale.set(scaleVal, scaleVal, scaleVal);
// 更新包围盒
updateBBox();
const items = document.querySelectorAll('.part-item');
if (items[index]) {
const label = items[index].querySelector('.size-label');
if (label) label.textContent = scaleVal.toFixed(2);
const slider = items[index].querySelector('input[type="range"]');
if (slider) slider.value = scaleVal;
}
}
// ---------- 按颜色导出GLB ----------
async function exportByColor() {
if (!objectGroup || partMeshes.length === 0) {
alert('没有部件可导出');
return;
}
const colorMap = new Map();
partData.forEach((data, idx) => {
const hex = data.color;
if (!colorMap.has(hex)) colorMap.set(hex, []);
colorMap.get(hex).push(idx);
});
if (colorMap.size === 0) {
alert('没有有效颜色');
return;
}
const exporter = new GLTFExporter();
let exportedCount = 0;
for (const [hex, indices] of colorMap.entries()) {
const group = new THREE.Group();
indices.forEach((idx) => {
const mesh = partMeshes[idx];
const clone = mesh.clone();
clone.position.copy(mesh.position);
clone.quaternion.copy(mesh.quaternion);
clone.scale.copy(mesh.scale);
const color = new THREE.Color(hex);
if (Array.isArray(clone.material)) {
clone.material.forEach((m) => m.color.copy(color));
} else {
clone.material.color.copy(color);
}
group.add(clone);
});
const colorHexStr = '#' + hex.toString(16).padStart(6, '0');
const fileName = `group_${colorHexStr}_${Date.now()}.glb`;
try {
const result = await new Promise((resolve, reject) => {
exporter.parse(
group,
(r) => resolve(r),
(err) => reject(err),
{ binary: true, onlyVisible: true, trs: true }
);
});
const blob = result instanceof ArrayBuffer ?
new Blob([result], { type: 'application/octet-stream' }) :
new Blob([JSON.stringify(result)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
link.click();
URL.revokeObjectURL(link.href);
exportedCount++;
} catch (err) {
console.error(`导出颜色组 ${colorHexStr} 失败:`, err);
alert(`导出颜色组 ${colorHexStr} 失败: ${err.message}`);
}
}
alert(`成功导出 ${exportedCount} 个GLB文件(共 ${colorMap.size} 种颜色)`);
}
// ============================================================
// 导入 & URL加载
// ============================================================
async function importObject(fileOrUrl) {
let url;
let isFile = false;
if (typeof fileOrUrl === 'string') {
url = fileOrUrl;
} else {
url = URL.createObjectURL(fileOrUrl);
isFile = true;
}
const overlay = document.getElementById('loading-overlay');
overlay.style.display = 'flex';
try {
const gltf = await new Promise((resolve, reject) => {
loader.load(url, resolve, undefined, reject);
});
loadModelFromGLTF(gltf);
} catch (e) {
alert('加载失败: ' + e.message);
console.error(e);
} finally {
overlay.style.display = 'none';
if (isFile) URL.revokeObjectURL(url);
}
}
function loadFromURL() {
const params = new URLSearchParams(window.location.search);
let filename = params.get('model');
if (!filename) {
const path = window.location.pathname;
const match = path.match(/\/view\/([^/?]+)(?:\.glb)?/);
if (match) {
filename = match[1];
if (!filename.endsWith('.glb')) filename += '.glb';
importObject(`/glbs/${filename}`);
return;
}
return;
}
if (filename.startsWith('http://') || filename.startsWith('https://')) {
importObject(filename);
} else {
if (!filename.endsWith('.glb')) filename += '.glb';
importObject(`/glbs/${filename}`);
}
}
// ============================================================
// UI事件绑定
// ============================================================
document.getElementById('mode-translate').addEventListener('click', () => {
transformControls.setMode('translate');
document.querySelectorAll('.group button').forEach((b) => b.classList.remove('active'));
document.getElementById('mode-translate').classList.add('active');
});
document.getElementById('mode-rotate').addEventListener('click', () => {
transformControls.setMode('rotate');
document.querySelectorAll('.group button').forEach((b) => b.classList.remove('active'));
document.getElementById('mode-rotate').classList.add('active');
});
document.getElementById('mode-scale').addEventListener('click', () => {
transformControls.setMode('scale');
document.querySelectorAll('.group button').forEach((b) => b.classList.remove('active'));
document.getElementById('mode-scale').classList.add('active');
});
document.getElementById('reset-btn').addEventListener('click', () => {
if (objectGroup) {
objectGroup.position.set(0, 0, 0);
objectGroup.quaternion.identity();
objectGroup.scale.set(1, 1, 1);
orbitControls.target.set(0, 0, 0);
orbitControls.update();
if (partMeshes.length > 0) {
transformControls.attach(partMeshes[0]);
}
} else {
camera.position.set(6, 4, 8);
orbitControls.target.set(0, 0, 0);
orbitControls.update();
}
});
document.getElementById('explode-btn').addEventListener('click', toggleExplode);
document.getElementById('print-btn').addEventListener('click', exportByColor);
document.addEventListener('click', () => {
document.querySelectorAll('.color-selector .dropdown.open').forEach((d) => {
d.classList.remove('open');
d.parentElement.querySelector('.arrow')?.classList.remove('open');
});
});
// ============================================================
// 点击场景选择
// ============================================================
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let isDragging = false;
let mouseDownPos = { x: 0, y: 0 };
renderer.domElement.addEventListener('pointerdown', (e) => {
mouseDownPos.x = e.clientX;
mouseDownPos.y = e.clientY;
isDragging = false;
});
renderer.domElement.addEventListener('pointermove', (e) => {
const dx = e.clientX - mouseDownPos.x;
const dy = e.clientY - mouseDownPos.y;
if (Math.sqrt(dx * dx + dy * dy) > 4) isDragging = true;
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (isDragging) return;
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const targets = [];
if (objectGroup) {
objectGroup.traverse((child) => {
if (child.isMesh) targets.push(child);
});
}
const hits = raycaster.intersectObjects(targets, false);
if (hits.length > 0) {
let hit = hits[0].object;
while (hit && !hit.isMesh) hit = hit.parent;
if (hit && hit.isMesh) {
const idx = partMeshes.indexOf(hit);
if (idx !== -1) {
selectPart(idx);
return;
}
}
}
if (partMeshes.length > 0) {
selectPart(0);
} else {
transformControls.detach();
}
});
// ============================================================
// 窗口 & 渲染
// ============================================================
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
function animate() {
requestAnimationFrame(animate);
orbitControls.update();
renderer.render(scene, camera);
}
// ============================================================
// 启动
// ============================================================
loadFromURL();
animate();
console.log('🧩 多部件查看器已启动 (金黄色3D包围盒高亮方案,不改变颜色)');
</script>
</body>
</html>