230 lines
6.0 KiB
Vue
230 lines
6.0 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'
|
|
import { fileNameMap } from './config'
|
|
|
|
const props = defineProps({
|
|
filePath: {
|
|
type: String,
|
|
default: '/inspection/elfin10/'
|
|
},
|
|
fileName: {
|
|
type: String,
|
|
default: 'elfin10.urdf.xacro'
|
|
}
|
|
})
|
|
|
|
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(35, width / height, 0.1, 1000);
|
|
camera.position.set(0, 0, 4);
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
renderer.setSize(width, height);
|
|
|
|
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(0xffffff, 0.6)
|
|
scene.add(ambient);
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
directionalLight.position.set(5, 10, 7);
|
|
directionalLight.castShadow = true;
|
|
scene.add(directionalLight);
|
|
};
|
|
|
|
// ============ 关节控制(无需修改) ============
|
|
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 = fileNameMap[props.filePath]
|
|
const fetchPromises = fileNames.map(async (name) => {
|
|
const url = `${props.filePath}${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(props.filePath + item.name, item);
|
|
})
|
|
}
|
|
|
|
const currentModel = ref()
|
|
let robotInstance = null; // 保存 URDFRobot 对象
|
|
|
|
const loadRobot = async () => {
|
|
const xacroUrl = `${props.filePath}${props.fileName}`;
|
|
|
|
const response = await fetch(xacroUrl);
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const xacroContent = await response.text();
|
|
const file = fileMap.get(xacroUrl)
|
|
let model
|
|
if (props.fileName.split('.').pop() === 'xacro') {
|
|
model = await XacroAdapter.parse(xacroContent, file.name, fileMap, file);
|
|
}
|
|
|
|
const robot = model.threeObject;
|
|
|
|
// 将机器人添加到场景
|
|
scene.add(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> |