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>