237 lines
6.7 KiB
Vue
237 lines
6.7 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="elfin-viewer">
|
|||
|
|
<div ref="viewerContainer" class="viewer-container">
|
|||
|
|
<!-- Three.js 渲染 -->
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
|
|||
|
|
import * as THREE from 'three';
|
|||
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|||
|
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
|||
|
|
|
|||
|
|
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
|
|||
|
|
|
|||
|
|
const viewerContainer = ref(null);
|
|||
|
|
|
|||
|
|
let scene, camera, renderer, controls;
|
|||
|
|
|
|||
|
|
const animationId = ref(null);
|
|||
|
|
const jointControls = reactive([]);
|
|||
|
|
|
|||
|
|
// ============ 场景初始化 ============
|
|||
|
|
const initScene = () => {
|
|||
|
|
const container = viewerContainer.value;
|
|||
|
|
const width = container.clientWidth || 800;
|
|||
|
|
const height = container.clientHeight || 600;
|
|||
|
|
scene = new THREE.Scene();
|
|||
|
|
scene.background = new THREE.Color('#060a12');
|
|||
|
|
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
|||
|
|
camera.position.set(2, 2, 3);
|
|||
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|||
|
|
renderer.setSize(width, height);
|
|||
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|||
|
|
renderer.shadowMap.enabled = true;
|
|||
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|||
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|||
|
|
renderer.toneMappingExposure = 1.2;
|
|||
|
|
container.appendChild(renderer.domElement);
|
|||
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|||
|
|
controls.enableDamping = true;
|
|||
|
|
controls.dampingFactor = 0.1;
|
|||
|
|
controls.target.set(0, 0.5, 0);
|
|||
|
|
controls.update();
|
|||
|
|
|
|||
|
|
const ambient = new THREE.AmbientLight(0x404060, 0.6);
|
|||
|
|
scene.add(ambient);
|
|||
|
|
const main = new THREE.DirectionalLight(0xffffff, 1.0);
|
|||
|
|
main.position.set(5, 10, 7);
|
|||
|
|
main.castShadow = true;
|
|||
|
|
scene.add(main);
|
|||
|
|
const fill = new THREE.DirectionalLight(0x8888ff, 0.4);
|
|||
|
|
fill.position.set(-5, 0, 5);
|
|||
|
|
scene.add(fill);
|
|||
|
|
const rim = new THREE.DirectionalLight(0xffffff, 0.3);
|
|||
|
|
rim.position.set(0, -5, 5);
|
|||
|
|
scene.add(rim);
|
|||
|
|
// const grid = new THREE.GridHelper(3, 20, 0x444466, 0x333355);
|
|||
|
|
// grid.position.y = -0.5;
|
|||
|
|
// scene.add(grid);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
// ============ 关节控制(无需修改) ============
|
|||
|
|
const updateJoint = (jointName, angle) => {
|
|||
|
|
if (!robotInstance) return;
|
|||
|
|
|
|||
|
|
const joint = robotInstance.joints[jointName];
|
|||
|
|
|
|||
|
|
if (!joint) {
|
|||
|
|
console.warn(`未找到关节: ${jointName}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 使用 setJointValue 触发变换矩阵更新
|
|||
|
|
if (joint._jointType === 'revolute' || joint._jointType === 'continuous') {
|
|||
|
|
joint.setJointValue(angle);
|
|||
|
|
} else if (joint._jointType === 'prismatic') {
|
|||
|
|
joint.setJointValue(angle);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 同步滑块显示值
|
|||
|
|
const control = jointControls.find(c => c.name === jointName);
|
|||
|
|
if (control) control.value = angle;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetJoints = () => {
|
|||
|
|
for (const joint of jointControls) {
|
|||
|
|
joint.value = 0;
|
|||
|
|
updateJoint(joint.name, 0);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fileMap = new Map()
|
|||
|
|
const loadFiles = async () => {
|
|||
|
|
const fileNames = [
|
|||
|
|
'elfin10.urdf.xacro',
|
|||
|
|
'elfin10_gazebo.xacro',
|
|||
|
|
'elfin10_transmission.xacro',
|
|||
|
|
'meshes/elfin10/elfin_base.STL',
|
|||
|
|
'meshes/elfin10/elfin_end_link.STL',
|
|||
|
|
'meshes/elfin10/elfin_link1.STL',
|
|||
|
|
'meshes/elfin10/elfin_link2.STL',
|
|||
|
|
'meshes/elfin10/elfin_link3.STL',
|
|||
|
|
'meshes/elfin10/elfin_link4.STL',
|
|||
|
|
'meshes/elfin10/elfin_link5.STL',
|
|||
|
|
'meshes/elfin10/elfin_link6.STL'
|
|||
|
|
]
|
|||
|
|
const fetchPromises = fileNames.map(async (name) => {
|
|||
|
|
const url = `/inspection/elfin10/${name}`
|
|||
|
|
const response = await fetch(url)
|
|||
|
|
const blob = await response.blob()
|
|||
|
|
return new File([blob], name, { type: blob.type })
|
|||
|
|
})
|
|||
|
|
const fileList = await Promise.all(fetchPromises)
|
|||
|
|
fileList.forEach(item => {
|
|||
|
|
fileMap.set('elfin10/' + item.name, item);
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const currentModel = ref()
|
|||
|
|
let robotInstance = null; // 保存 URDFRobot 对象
|
|||
|
|
|
|||
|
|
const loadRobot = async () => {
|
|||
|
|
const xacroUrl = `/inspection/elfin10/elfin10.urdf.xacro`;
|
|||
|
|
|
|||
|
|
const response = await fetch(xacroUrl);
|
|||
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|||
|
|
const xacroContent = await response.text();
|
|||
|
|
const file = fileMap.get('elfin10/elfin10.urdf.xacro')
|
|||
|
|
const model = await XacroAdapter.parse(xacroContent, file.name, fileMap, file);
|
|||
|
|
|
|||
|
|
const robot = model.threeObject; // 这就是 URDFRobot
|
|||
|
|
|
|||
|
|
|
|||
|
|
// 将机器人添加到场景
|
|||
|
|
scene.add(robot); // ⚠️ 注意:是 robot.scene,不是 robot
|
|||
|
|
robot.rotation.x = -Math.PI / 2;
|
|||
|
|
currentModel.value = model
|
|||
|
|
|
|||
|
|
robotInstance = robot;
|
|||
|
|
|
|||
|
|
// 获取关节条目数组
|
|||
|
|
let jointEntries = [];
|
|||
|
|
if (robot.joints instanceof Map) {
|
|||
|
|
jointEntries = [...robot.joints.entries()];
|
|||
|
|
} else if (typeof robot.joints === 'object' && robot.joints !== null) {
|
|||
|
|
jointEntries = Object.entries(robot.joints);
|
|||
|
|
} else if (Array.isArray(robot.joints)) {
|
|||
|
|
jointEntries = robot.joints.map((j, i) => [i, j]); // 索引作为名称
|
|||
|
|
} else {
|
|||
|
|
console.error('未知的 joints 结构:', robot.joints);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 填充关节控制列表
|
|||
|
|
for (const [name, joint] of jointEntries) {
|
|||
|
|
if (joint._jointType === 'fixed') continue;
|
|||
|
|
const limits = joint.limits || {};
|
|||
|
|
let lower = limits.lower !== undefined ? limits.lower : -Math.PI;
|
|||
|
|
let upper = limits.upper !== undefined ? limits.upper : Math.PI;
|
|||
|
|
// 如果是连续关节,范围设为 -PI ~ PI
|
|||
|
|
if (joint._jointType === 'continuous') {
|
|||
|
|
lower = -Math.PI;
|
|||
|
|
upper = Math.PI;
|
|||
|
|
}
|
|||
|
|
// 初始角度一般为 0
|
|||
|
|
jointControls.push({
|
|||
|
|
name,
|
|||
|
|
min: lower,
|
|||
|
|
max: upper,
|
|||
|
|
value: 0, // 因为 joint.angle 初始为 0
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ============ 动画循环 ============
|
|||
|
|
const animate = () => {
|
|||
|
|
animationId.value = requestAnimationFrame(animate);
|
|||
|
|
controls?.update();
|
|||
|
|
renderer?.render(scene, camera);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ============ 生命周期 ============
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await nextTick();
|
|||
|
|
await loadFiles()
|
|||
|
|
initScene();
|
|||
|
|
await loadRobot();
|
|||
|
|
animate();
|
|||
|
|
window.addEventListener('resize', onResize);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
if (animationId.value) cancelAnimationFrame(animationId.value);
|
|||
|
|
window.removeEventListener('resize', onResize);
|
|||
|
|
renderer?.dispose();
|
|||
|
|
controls?.dispose();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const onResize = () => {
|
|||
|
|
const container = viewerContainer.value;
|
|||
|
|
if (!container || !camera || !renderer) return;
|
|||
|
|
const width = container.clientWidth;
|
|||
|
|
const height = container.clientHeight;
|
|||
|
|
camera.aspect = width / height;
|
|||
|
|
camera.updateProjectionMatrix();
|
|||
|
|
renderer.setSize(width, height);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
defineExpose({ updateJoint, resetJoints, jointControls });
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="scss" scoped>
|
|||
|
|
.elfin-viewer {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: #0d0d1a;
|
|||
|
|
|
|||
|
|
.viewer-container {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
|
|||
|
|
canvas {
|
|||
|
|
display: block;
|
|||
|
|
width: 100% !important;
|
|||
|
|
height: 100% !important;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
</style>
|