three教学 3d资产拼接源代码

pinjie.html

拼接后还需要偏移量,不然3d打印Bambu Studio拆分成零件还是独立物体。

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>GLB 模型拼接 - Three.js 0.162.0 增强版</title>
<style>
    body { margin: 0; overflow: hidden; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
    canvas { display: block; }
    #info {
        position: absolute;
        top: 20px;
        left: 20px;
        background: rgba(0,0,0,0.75);
        color: white;
        padding: 12px 20px;
        border-radius: 8px;
        backdrop-filter: blur(8px);
        pointer-events: none;
        z-index: 10;
        font-size: 14px;
        box-shadow: 0 2px 10px rgba(0,0,0,0.3);
        border-left: 4px solid #4caf50;
        font-family: monospace;
    }
    #controls-panel {
        position: absolute;
        bottom: 20px;
        left: 20px;
        right: 20px;
        background: rgba(30,30,40,0.95);
        backdrop-filter: blur(8px);
        border-radius: 12px;
        padding: 15px 20px;
        display: flex;
        flex-wrap: wrap;
        gap: 12px;
        justify-content: space-between;
        align-items: center;
        z-index: 20;
        pointer-events: auto;
        border: 1px solid rgba(255,255,255,0.2);
        box-shadow: 0 4px 15px rgba(0,0,0,0.3);
        color: #eee;
    }
    .btn-group {
        display: flex;
        gap: 12px;
        flex-wrap: wrap;
        align-items: center;
    }
    button {
        background: #3a6ea5;
        border: none;
        color: white;
        padding: 8px 18px;
        border-radius: 40px;
        cursor: pointer;
        font-weight: bold;
        font-size: 14px;
        transition: all 0.2s ease;
        box-shadow: 0 1px 3px rgba(0,0,0,0.3);
        letter-spacing: 1px;
    }
    button:hover {
        background: #2c4e7a;
        transform: scale(1.02);
    }
    button.active {
        background: #ff9800;
        color: #1e1e2a;
        box-shadow: 0 0 8px rgba(255,152,0,0.5);
    }
    button.primary {
        background: #4caf50;
        color: white;
    }
    button.primary:hover {
        background: #3e8e41;
    }
    button.warning {
        background: #ff9800;
        color: #1e1e2a;
    }
    button.danger {
        background: #f44336;
    }
    button.danger:hover {
        background: #d32f2f;
    }
    button.merge-btn {
        background: #9c27b0;
        font-size: 16px;
        padding: 10px 24px;
    }
    button.merge-btn:hover {
        background: #7b1fa2;
    }
    .file-label {
        background: #9c27b0;
        padding: 8px 18px;
        border-radius: 40px;
        cursor: pointer;
        font-weight: bold;
        font-size: 14px;
        transition: 0.2s;
        display: inline-block;
    }
    .file-label:hover {
        background: #7b1fa2;
    }
    .file-label.upper {
        background: #e91e63;
    }
    .file-label.upper:hover {
        background: #c2185b;
    }
    .file-label.lower {
        background: #2196f3;
    }
    .file-label.lower:hover {
        background: #1976d2;
    }
    input[type="file"] {
        display: none;
    }
    .status {
        background: #000000aa;
        padding: 5px 12px;
        border-radius: 20px;
        font-size: 12px;
        font-family: monospace;
        max-width: 400px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .model-indicator {
        padding: 4px 10px;
        border-radius: 20px;
        font-size: 11px;
        font-family: monospace;
        font-weight: bold;
    }
    .model-indicator.upper-loaded { background: #e91e63; color: white; }
    .model-indicator.lower-loaded { background: #2196f3; color: white; }
    .model-indicator.not-loaded { background: #555; color: #999; }
    .model-indicator.selected { box-shadow: 0 0 8px rgba(255,255,255,0.6); }

    @media (max-width: 700px) {
        .btn-group { gap: 6px; }
        button, .file-label { padding: 5px 12px; font-size: 12px; }
        #controls-panel { flex-direction: column; align-items: stretch; bottom: 10px; left: 10px; right: 10px; }
        .status { white-space: normal; max-width: none; text-align: center; }
    }
    .loading-overlay {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(0,0,0,0.9);
        color: white;
        padding: 20px 40px;
        border-radius: 12px;
        z-index: 100;
        display: none;
        font-size: 16px;
        pointer-events: none;
        font-family: monospace;
        text-align: center;
    }
    .gap-slider {
        display: flex;
        align-items: center;
        gap: 8px;
        background: rgba(0,0,0,0.3);
        padding: 4px 12px;
        border-radius: 20px;
    }
    .gap-slider label { font-size: 12px; color: #aaa; }
    .gap-slider input { width: 100px; }
    .gap-value { font-size: 12px; color: #4caf50; font-weight: bold; min-width: 50px; }
</style>
</head>
<body>
<div id="info">
    🔗 双模型拼接 | 移动/旋转/缩放 | 合并导出
</div>
<div id="controls-panel">
    <div class="btn-group">
        <span class="model-indicator not-loaded" id="upperIndicator">⬆ 上半部: 未加载</span>
        <label class="file-label upper" for="upperFile">🔴 加载上半部</label>
        <input type="file" id="upperFile" accept=".glb,.gltf,.GLB,.GLTF">

        <span class="model-indicator not-loaded" id="lowerIndicator">⬇ 下半部: 未加载</span>
        <label class="file-label lower" for="lowerFile">🔵 加载下半部</label>
        <input type="file" id="lowerFile" accept=".glb,.gltf,.GLB,.GLTF">
    </div>
    <div class="btn-group">
        <button id="selectUpperBtn">⬆ 选上半部</button>
        <button id="selectLowerBtn">⬇ 选下半部</button>
        <button id="selectBothBtn">🔗 选整体</button>
    </div>
    <div class="btn-group">
        <button id="modeTranslate" class="active">↔ 移动</button>
        <button id="modeRotate">🔄 旋转</button>
        <button id="modeScale">📐 缩放</button>
    </div>
    <div class="btn-group">
        <div class="gap-slider">
            <label>📏 间隙</label>
            <input type="range" id="gapSlider" min="0" max="2" step="0.01" value="0.05">
            <span class="gap-value" id="gapValue">0.05</span>
        </div>
        <button id="alignBtn" class="primary">🔗 自动拼接</button>
        <button id="mergeBtn" class="merge-btn">🧩 合并成一个</button>
        <button id="exportBtn" class="warning">📦 导出GLB</button>
        <button id="resetViewBtn">🎥 重置</button>
    </div>
    <div class="status" id="loadStatus">
        ⚡ 就绪 | 加载模型 → 调整 → 合并导出
    </div>
</div>
<div class="loading-overlay" id="loadingOverlay">
    <div>⏳ 正在处理...</div>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.162.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.162.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";

// --- 场景 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.FogExp2(0x1a1a2e, 0.006);

const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000);
camera.position.set(5, 3, 6);

const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

// OrbitControls
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.target.set(0, 0.5, 0);
orbitControls.update();

// TransformControls
const transformControls = new TransformControls(camera, renderer.domElement);
transformControls.addEventListener('dragging-changed', (event) => {
    orbitControls.enabled = !event.value;
});
scene.add(transformControls);

// 光照
scene.add(new THREE.AmbientLight(0x404060, 0.8));
const mainLight = new THREE.DirectionalLight(0xffffff, 1.5);
mainLight.position.set(3, 6, 4);
mainLight.castShadow = true;
mainLight.shadow.mapSize.set(2048, 2048);
mainLight.shadow.camera.near = 0.1;
mainLight.shadow.camera.far = 50;
mainLight.shadow.bias = -0.0001;
scene.add(mainLight);
scene.add(new THREE.PointLight(0x6688cc, 0.6, 20, 2));
scene.add(new THREE.PointLight(0xff9966, 0.4, 20, 2));
scene.add(new THREE.PointLight(0x8866ff, 0.5, 20, 2));

// 辅助
const grid = new THREE.GridHelper(8, 20, 0x88aaff, 0x335588);
grid.position.y = -1;
scene.add(grid);

const jointPlaneGeo = new THREE.PlaneGeometry(4, 4);
const jointPlaneMat = new THREE.MeshPhongMaterial({
    color: 0x4caf50, side: THREE.DoubleSide,
    transparent: true, opacity: 0.3, emissive: 0x1a3a1a
});
const jointPlane = new THREE.Mesh(jointPlaneGeo, jointPlaneMat);
jointPlane.rotation.x = -Math.PI / 2;
scene.add(jointPlane);

// --- 状态 ---
let upperModel = null;
let lowerModel = null;
let upperBounds = null;
let lowerBounds = null;
let currentGap = 0.05;
let selectedTarget = 'both'; // 'upper' | 'lower' | 'both'
let mergedGroup = null;

// --- 工具 ---
function disposeModel(model) {
    if (!model) return;
    model.traverse(c => {
        if (c.isMesh) {
            c.geometry?.dispose();
            if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
            else c.material?.dispose();
        }
    });
    model.parent?.remove(model);
}

function getBounds(model) {
    const box = new THREE.Box3().setFromObject(model);
    return {
        box, min: box.min.clone(), max: box.max.clone(),
        center: box.getCenter(new THREE.Vector3()),
        size: box.getSize(new THREE.Vector3())
    };
}

function setTransformTarget(target) {
    selectedTarget = target;

    // 更新按钮状态
    document.getElementById('selectUpperBtn').classList.toggle('active', target === 'upper');
    document.getElementById('selectLowerBtn').classList.toggle('active', target === 'lower');
    document.getElementById('selectBothBtn').classList.toggle('active', target === 'both');

    // 更新指示器
    document.getElementById('upperIndicator').classList.toggle('selected', target === 'upper');
    document.getElementById('lowerIndicator').classList.toggle('selected', target === 'lower');

    // 附加 TransformControls
    if (target === 'upper' && upperModel) {
        transformControls.attach(upperModel);
    } else if (target === 'lower' && lowerModel) {
        transformControls.attach(lowerModel);
    } else if (target === 'both') {
        // 如果有合并的组,附加到组;否则分离
        if (mergedGroup) {
            transformControls.attach(mergedGroup);
        } else {
            transformControls.detach();
            // 两个都选时,分别操作
            if (upperModel && lowerModel) {
                // 创建一个临时父节点
                const tempParent = new THREE.Group();
                const upperWorldPos = upperModel.position.clone();
                const lowerWorldPos = lowerModel.position.clone();
                scene.remove(upperModel);
                scene.remove(lowerModel);
                tempParent.add(upperModel);
                tempParent.add(lowerModel);
                upperModel.position.copy(upperWorldPos);
                lowerModel.position.copy(lowerWorldPos);
                scene.add(tempParent);
                mergedGroup = tempParent;
                transformControls.attach(mergedGroup);
            }
        }
    }
}

function updateIndicators() {
    const upEl = document.getElementById('upperIndicator');
    const lowEl = document.getElementById('lowerIndicator');
    if (upperModel) {
        upEl.textContent = '⬆ 上半部: 已加载';
        upEl.className = 'model-indicator upper-loaded';
    }
    if (lowerModel) {
        lowEl.textContent = '⬇ 下半部: 已加载';
        lowEl.className = 'model-indicator lower-loaded';
    }
    if (selectedTarget === 'upper') upEl.classList.add('selected');
    if (selectedTarget === 'lower') lowEl.classList.add('selected');
}

// --- 自动拼接 ---
function autoAlign() {
    if (!upperModel || !lowerModel) {
        alert("请先加载上下两个模型");
        return;
    }

    // 如果有合并组,先解散
    if (mergedGroup) {
        while (mergedGroup.children.length) {
            scene.add(mergedGroup.children[0]);
        }
        scene.remove(mergedGroup);
        mergedGroup = null;
    }

    upperBounds = getBounds(upperModel);
    lowerBounds = getBounds(lowerModel);

    // 居中对齐 X/Z
    const cx = (upperBounds.center.x + lowerBounds.center.x) / 2;
    const cz = (upperBounds.center.z + lowerBounds.center.z) / 2;

    // 上半部底面在 y=0
    upperModel.position.set(
        upperModel.position.x - upperBounds.center.x,
        -upperBounds.min.y,
        upperModel.position.z - upperBounds.center.z
    );

    // 下半部顶面在 y=-gap
    lowerModel.position.set(
        lowerModel.position.x - lowerBounds.center.x,
        -lowerBounds.max.y - currentGap,
        lowerModel.position.z - lowerBounds.center.z
    );

    upperBounds = getBounds(upperModel);
    lowerBounds = getBounds(lowerModel);

    // 更新平面
    jointPlane.position.y = -currentGap / 2;
    const allBounds = new THREE.Box3();
    allBounds.expandByObject(upperModel);
    allBounds.expandByObject(lowerModel);
    const size = allBounds.getSize(new THREE.Vector3());
    jointPlane.scale.set(Math.max(size.x, size.z) * 1.5 / 4, Math.max(size.x, size.z) * 1.5 / 4, 1);

    // 相机
    const center = allBounds.getCenter(new THREE.Vector3());
    const maxDim = Math.max(size.x, size.y, size.z);
    orbitControls.target.copy(center);
    camera.position.set(center.x + maxDim * 1.2, center.y + maxDim * 0.8, center.z + maxDim * 1.5);
    orbitControls.update();

    document.getElementById('loadStatus').innerHTML =
        `✅ 拼接完成 | 上:${upperBounds.size.x.toFixed(1)}x${upperBounds.size.y.toFixed(1)} | 下:${lowerBounds.size.x.toFixed(1)}x${lowerBounds.size.y.toFixed(1)} | 间隙:${currentGap.toFixed(2)}`;
}

// --- 合并成一个 ---
function mergeIntoOne() {
    if (!upperModel || !lowerModel) {
        alert("请先加载两个模型");
        return;
    }

    // 解散合并组
    if (mergedGroup) {
        while (mergedGroup.children.length) {
            scene.add(mergedGroup.children[0]);
        }
        scene.remove(mergedGroup);
        mergedGroup = null;
    }

    // 创建新合并组
    const combinedGroup = new THREE.Group();
    combinedGroup.name = "CombinedModel";

    // 克隆上半部
    const upperClone = upperModel.clone(true);
    upperClone.position.copy(upperModel.position);
    upperClone.quaternion.copy(upperModel.quaternion);
    upperClone.scale.copy(upperModel.scale);
    upperClone.traverse(c => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } });

    // 克隆下半部
    const lowerClone = lowerModel.clone(true);
    lowerClone.position.copy(lowerModel.position);
    lowerClone.quaternion.copy(lowerModel.quaternion);
    lowerClone.scale.copy(lowerModel.scale);
    lowerClone.traverse(c => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } });

    combinedGroup.add(upperClone);
    combinedGroup.add(lowerClone);

    // 隐藏原始模型
    upperModel.visible = false;
    lowerModel.visible = false;

    // 添加到场景
    scene.add(combinedGroup);
    mergedGroup = combinedGroup;

    // 附加TransformControls
    transformControls.attach(mergedGroup);
    selectedTarget = 'both';
    setTransformTarget('both');

    document.getElementById('loadStatus').innerHTML =
        "🧩 已合并为一个物体!可用移动/旋转/缩放整体调整,点击导出GLB保存";
}

// --- 导出 ---
function exportGLB() {
    const target = mergedGroup || (upperModel && lowerModel ? (() => {
        // 临时合并导出
        const temp = new THREE.Group();
        temp.add(upperModel.clone(true));
        temp.add(lowerModel.clone(true));
        return temp;
    })() : null);

    if (!target && upperModel) {
        // 只有一个模型
        exportSingle(upperModel);
        return;
    }
    if (!target && lowerModel) {
        exportSingle(lowerModel);
        return;
    }
    if (!target) {
        alert("没有模型可导出");
        return;
    }

    document.getElementById('loadingOverlay').style.display = 'flex';

    const exporter = new GLTFExporter();
    exporter.parse(target, (result) => {
        const blob = result instanceof ArrayBuffer
            ? new Blob([result], {type: 'application/octet-stream'})
            : new Blob([JSON.stringify(result)], {type: 'application/json'});
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `combined_model_${Date.now()}.glb`;
        a.click();
        document.getElementById('loadingOverlay').style.display = 'none';
        document.getElementById('loadStatus').innerHTML = "📦 导出成功!";
    }, (err) => {
        alert("导出失败: " + err);
        document.getElementById('loadingOverlay').style.display = 'none';
    }, { binary: true, onlyVisible: false, trs: true });
}

function exportSingle(model) {
    document.getElementById('loadingOverlay').style.display = 'flex';
    const exporter = new GLTFExporter();
    exporter.parse(model, (result) => {
        const blob = result instanceof ArrayBuffer
            ? new Blob([result], {type: 'application/octet-stream'})
            : new Blob([JSON.stringify(result)], {type: 'application/json'});
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `model_${Date.now()}.glb`;
        a.click();
        document.getElementById('loadingOverlay').style.display = 'none';
    }, { binary: true, trs: true });
}

// --- 加载 ---
async function loadModel(file, type) {
    return new Promise((resolve, reject) => {
        const loader = new GLTFLoader();
        const url = URL.createObjectURL(file);
        loader.load(url, (gltf) => {
            URL.revokeObjectURL(url);
            gltf.scene.traverse(c => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } });
            resolve(gltf.scene);
        }, undefined, reject);
    });
}

// --- 事件 ---
document.getElementById('upperFile').addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    document.getElementById('loadingOverlay').style.display = 'flex';
    try {
        if (upperModel) { disposeModel(upperModel); upperModel = null; }
        if (mergedGroup) { disposeModel(mergedGroup); mergedGroup = null; }
        upperModel = await loadModel(file, 'upper');
        scene.add(upperModel);
        updateIndicators();
        if (upperModel && lowerModel) autoAlign();
        else setTransformTarget('upper');
        document.getElementById('loadStatus').innerHTML = `✅ 上半部: ${file.name}`;
    } catch(err) {
        document.getElementById('loadStatus').innerHTML = `❌ ${err.message}`;
    }
    document.getElementById('loadingOverlay').style.display = 'none';
    e.target.value = '';
});

document.getElementById('lowerFile').addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    document.getElementById('loadingOverlay').style.display = 'flex';
    try {
        if (lowerModel) { disposeModel(lowerModel); lowerModel = null; }
        if (mergedGroup) { disposeModel(mergedGroup); mergedGroup = null; }
        lowerModel = await loadModel(file, 'lower');
        scene.add(lowerModel);
        updateIndicators();
        if (upperModel && lowerModel) autoAlign();
        else setTransformTarget('lower');
        document.getElementById('loadStatus').innerHTML = `✅ 下半部: ${file.name}`;
    } catch(err) {
        document.getElementById('loadStatus').innerHTML = `❌ ${err.message}`;
    }
    document.getElementById('loadingOverlay').style.display = 'none';
    e.target.value = '';
});

document.getElementById('selectUpperBtn').addEventListener('click', () => setTransformTarget('upper'));
document.getElementById('selectLowerBtn').addEventListener('click', () => setTransformTarget('lower'));
document.getElementById('selectBothBtn').addEventListener('click', () => {
    if (mergedGroup) {
        setTransformTarget('both');
    } else if (upperModel && lowerModel) {
        // 自动创建合并组用于操作
        const tempGroup = new THREE.Group();
        const upPos = upperModel.position.clone();
        const lowPos = lowerModel.position.clone();
        const upQuat = upperModel.quaternion.clone();
        const lowQuat = lowerModel.quaternion.clone();
        scene.remove(upperModel);
        scene.remove(lowerModel);
        tempGroup.add(upperModel);
        tempGroup.add(lowerModel);
        upperModel.position.copy(upPos);
        lowerModel.position.copy(lowPos);
        upperModel.quaternion.copy(upQuat);
        lowerModel.quaternion.copy(lowQuat);
        scene.add(tempGroup);
        mergedGroup = tempGroup;
        setTransformTarget('both');
    }
});

document.getElementById('modeTranslate').addEventListener('click', () => {
    transformControls.setMode('translate');
    document.getElementById('modeTranslate').classList.add('active');
    document.getElementById('modeRotate').classList.remove('active');
    document.getElementById('modeScale').classList.remove('active');
});

document.getElementById('modeRotate').addEventListener('click', () => {
    transformControls.setMode('rotate');
    document.getElementById('modeTranslate').classList.remove('active');
    document.getElementById('modeRotate').classList.add('active');
    document.getElementById('modeScale').classList.remove('active');
});

document.getElementById('modeScale').addEventListener('click', () => {
    transformControls.setMode('scale');
    document.getElementById('modeTranslate').classList.remove('active');
    document.getElementById('modeRotate').classList.remove('active');
    document.getElementById('modeScale').classList.add('active');
});

document.getElementById('gapSlider').addEventListener('input', (e) => {
    currentGap = parseFloat(e.target.value);
    document.getElementById('gapValue').textContent = currentGap.toFixed(2);
    if (upperModel && lowerModel) {
        lowerBounds = getBounds(lowerModel);
        const lowerHeight = lowerBounds.size.y;
        lowerModel.position.y = -lowerHeight / 2 - currentGap;
        jointPlane.position.y = -currentGap / 2;
    }
});

document.getElementById('alignBtn').addEventListener('click', autoAlign);
document.getElementById('mergeBtn').addEventListener('click', mergeIntoOne);
document.getElementById('exportBtn').addEventListener('click', exportGLB);

document.getElementById('resetViewBtn').addEventListener('click', () => {
    const target = mergedGroup || (() => {
        const g = new THREE.Group();
        if (upperModel) g.add(upperModel);
        if (lowerModel) g.add(lowerModel);
        return g;
    })();
    const box = new THREE.Box3().setFromObject(target);
    const center = box.getCenter(new THREE.Vector3());
    const size = box.getSize(new THREE.Vector3());
    const maxDim = Math.max(size.x, size.y, size.z);
    orbitControls.target.copy(center);
    camera.position.set(center.x + maxDim*1.2, center.y + maxDim*0.8, center.z + maxDim*1.5);
    orbitControls.update();
});

// 键盘快捷键
window.addEventListener('keydown', (e) => {
    switch(e.key.toLowerCase()) {
        case 'w': transformControls.setMode('translate'); break;
        case 'e': transformControls.setMode('rotate'); break;
        case 'r': transformControls.setMode('scale'); break;
        case 'g': if (upperModel && lowerModel) mergeIntoOne(); break;
        case 'escape': transformControls.detach(); break;
    }
});

window.addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
});

// --- 动画 ---
function animate() {
    requestAnimationFrame(animate);
    orbitControls.update();
    const t = Date.now() * 0.002;
    jointPlane.material.opacity = 0.25 + Math.sin(t) * 0.1;
    renderer.render(scene, camera);
}
animate();

console.log("✅ 增强版拼接系统 | 移动W 旋转E 缩放R 合并G");
console.log("📦 支持 GLB/GLTF | 合并导出功能已就绪");

</script>
</body>
</html>
相关推荐
程序猿阿伟2 小时前
《Chrome标签组搭建多任务高效浏览指南》
前端·chrome
2601_958352902 小时前
双麦 DSP 音频模块实战:一文梳理 A-68 在全行业场景的声学解决方案与落地要点
前端·嵌入式硬件·音视频·语音识别·降噪消回音·音频处理模块
智码看视界3 小时前
老梁聊全栈:JavaScript 原型链深入探索对象继承的奥秘
前端·javascript·ecmascript
布朗克1683 小时前
39 Spring Boot Web实战
前端·spring boot·后端·实战
纽格立科技3 小时前
DRM 发射端链路图(上)
前端·人工智能·车载系统·信息与通信·传媒
云水一下3 小时前
Vue.js从零到精通系列(七):高级特性实战——Teleport、异步组件、自定义指令与TypeScript深度结合
前端·vue.js·typescript
qq4356947013 小时前
Vue05
前端·vue.js
qq_422152573 小时前
PDF 解密工具怎么选?2026 年文档密码移除方案与注意事项
java·前端·pdf
YHHLAI4 小时前
前端工程化调用 AI 多模态生图模型:Qwen Image Demo 实战
前端·人工智能