pinjie.html拼接后还需要偏移量不然3d打印Bambu Studio拆分成零件还是独立物体。!DOCTYPE html html langzh head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno titleGLB 模型拼接 - 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[typefile] { 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 idinfo 双模型拼接 | 移动/旋转/缩放 | 合并导出 /div div idcontrols-panel div classbtn-group span classmodel-indicator not-loaded idupperIndicator⬆ 上半部: 未加载/span label classfile-label upper forupperFile 加载上半部/label input typefile idupperFile accept.glb,.gltf,.GLB,.GLTF span classmodel-indicator not-loaded idlowerIndicator⬇ 下半部: 未加载/span label classfile-label lower forlowerFile 加载下半部/label input typefile idlowerFile accept.glb,.gltf,.GLB,.GLTF /div div classbtn-group button idselectUpperBtn⬆ 选上半部/button button idselectLowerBtn⬇ 选下半部/button button idselectBothBtn 选整体/button /div div classbtn-group button idmodeTranslate classactive↔ 移动/button button idmodeRotate 旋转/button button idmodeScale 缩放/button /div div classbtn-group div classgap-slider label 间隙/label input typerange idgapSlider min0 max2 step0.01 value0.05 span classgap-value idgapValue0.05/span /div button idalignBtn classprimary 自动拼接/button button idmergeBtn classmerge-btn 合并成一个/button button idexportBtn classwarning 导出GLB/button button idresetViewBtn 重置/button /div div classstatus idloadStatus ⚡ 就绪 | 加载模型 → 调整 → 合并导出 /div /div div classloading-overlay idloadingOverlay div⏳ 正在处理.../div /div script typeimportmap { imports: { three: https://unpkg.com/three0.162.0/build/three.module.js, three/addons/: https://unpkg.com/three0.162.0/examples/jsm/ } } /script script typemodule 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; // 上半部底面在 y0 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