3d子部件高亮选中

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>
相关推荐
阿图灵21 分钟前
LangGraph 实战 03:Workflows 与 Agents——六种工作流模式与智能体实战(附 6 个可运行示例)
java·前端·javascript·工作流·ai agent·智能体·langgraph
zzzll111127 分钟前
深入理解 Java HashMap:从原理到实战
java·开发语言·哈希算法
猫猫不是喵喵.35 分钟前
Java开发高频面试题汇总(通俗易懂版)
java·开发语言·java高频面试
k4m7v2pz41 分钟前
用 rust-verb-shell 重写进程管理:从 game.sh 到 .rvs 的迁移实录
开发语言·后端·rust
2603_9658966244 分钟前
JavaScript 对象零基础详解|属性、方法、遍历、增删改查
开发语言·javascript·ecmascript
dear_bi_MyOnly1 小时前
数组字符串深度解析:从入门到卡牌实战
开发语言·c++·学习
正在走向自律1 小时前
从 Python 基础到大模型落地:读《深入浅出 Python 人工智能》,吃透 AI 工程化 CRUD 实战
开发语言·人工智能·python·机器学习·知识脉络
用户283209679371 小时前
从进程线程到 async/await:JS Event-loop事件循环机制底层解析
前端·javascript
被摘下的星星1 小时前
Java 中 .length、.length() 以及 .size() 的区分
java·开发语言