Merge remote-tracking branch 'origin/lxl-dev' into lxl-dev
This commit is contained in:
commit
b49de73aa7
@ -390,9 +390,6 @@ function handler(params) {
|
|||||||
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
|
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
|
||||||
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0.5, disabled: true },
|
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0.5, disabled: true },
|
||||||
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.1, input: 1, disabled: true },
|
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.1, input: 1, disabled: true },
|
||||||
{ name: "blendRadius", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0, required: false, disabled: true },
|
|
||||||
{ name: "jointVelocityLimits", type: "input", input: "", required: false, disabled: true },
|
|
||||||
{ name: "asynchronous", type: "input", input: false, componentType: 'select', selectOptions: booleanOptions(), required: false, disabled: true }
|
|
||||||
],
|
],
|
||||||
outputType: 'json'
|
outputType: 'json'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -149,9 +149,7 @@
|
|||||||
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
||||||
<UrdfViewer
|
<UrdfViewer
|
||||||
ref="viewerRef"
|
ref="viewerRef"
|
||||||
base-path="/inspection/elfin10/"
|
modelPath="/inspection/elfin10/elfin10.urdf.xacro"
|
||||||
model-color="#cbcbcb"
|
|
||||||
:show-controls="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
||||||
|
|||||||
@ -1,240 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="elfin-viewer">
|
|
||||||
<div ref="viewerContainer" class="viewer-container">
|
|
||||||
<!-- Three.js 渲染 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="loading-overlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>正在加载机器人模型...</p>
|
|
||||||
<p class="loading-detail">{{ loadingMessage }}</p>
|
|
||||||
</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 props = defineProps({
|
|
||||||
basePath: { type: String, default: '/inspection/elfin10' },
|
|
||||||
modelColor: { type: String, default: '#cbcbcb' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewerContainer = ref(null);
|
|
||||||
const isLoading = ref(true);
|
|
||||||
const loadingMessage = ref('初始化...');
|
|
||||||
|
|
||||||
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('#000d2e');
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'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 () => {
|
|
||||||
isLoading.value = true;
|
|
||||||
loadingMessage.value = '加载XACRO文件...';
|
|
||||||
const xacroUrl = `${props.basePath}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;
|
|
||||||
isLoading.value = false;
|
|
||||||
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 scoped>
|
|
||||||
.elfin-viewer {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0d0d1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-container canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@ -95,7 +95,7 @@
|
|||||||
<MapCanvas :robotList="robotList" ref="mapCanvasRef" />
|
<MapCanvas :robotList="robotList" ref="mapCanvasRef" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
||||||
<UrdfViewer ref="urdfViewerRef" base-path="/inspection/elfin10/" />
|
<UrdfViewer ref="urdfViewerRef" modelPath="/inspection/elfin10/elfin10.urdf.xacro" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +115,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, nextTick } from "vue";
|
import { onMounted, nextTick } from "vue";
|
||||||
import SvgIcon from "@/components/SvgIcon";
|
import SvgIcon from "@/components/SvgIcon";
|
||||||
import UrdfViewer from './UrdfView.vue'
|
import UrdfViewer from '../UrdfView.vue'
|
||||||
import DirectionControl from './DirectionControl.vue'
|
import DirectionControl from './DirectionControl.vue'
|
||||||
import RoboticArm from "./RoboticArm.vue";
|
import RoboticArm from "./RoboticArm.vue";
|
||||||
import { getRobotList } from '@/api/inspection/robot'
|
import { getRobotList } from '@/api/inspection/robot'
|
||||||
|
|||||||
@ -3,501 +3,230 @@
|
|||||||
<div ref="viewerContainer" class="viewer-container">
|
<div ref="viewerContainer" class="viewer-container">
|
||||||
<!-- Three.js 渲染 -->
|
<!-- Three.js 渲染 -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoading" class="loading-overlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>正在加载机器人模型...</p>
|
|
||||||
<p class="loading-detail">{{ loadingMessage }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-panel" v-if="!isLoading && jointControls.length > 0">
|
|
||||||
<h4>关节控制</h4>
|
|
||||||
<div class="joint-list">
|
|
||||||
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
|
|
||||||
<label :for="joint.name">{{ joint.displayName }}</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
:id="joint.name"
|
|
||||||
:min="joint.min"
|
|
||||||
:max="joint.max"
|
|
||||||
:step="0.01"
|
|
||||||
v-model.number="joint.value"
|
|
||||||
@input="updateJoint(joint.name, joint.value)"
|
|
||||||
/>
|
|
||||||
<span class="angle">{{ (joint.value * 180 / Math.PI).toFixed(1) }}°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="resetJoints" class="reset-btn">重置关节</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
|
import { computed, ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
import URDFLoader from 'urdf-loader';
|
||||||
|
|
||||||
|
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
|
||||||
|
import { URDFAdapter } from '@/adapters/URDFAdapter.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
basePath: { type: String, default: '/inspection/elfin10' },
|
modelPath: {
|
||||||
modelColor: { type: String, default: '#cbcbcb' },
|
type: String,
|
||||||
showControls: { type: Boolean, default: true }
|
default: '/inspection/elfin10/elfin10.urdf.xacro'
|
||||||
});
|
},
|
||||||
|
filePath: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizePublicPath = (path) => {
|
||||||
|
let normalized = path.trim().replace(/\\/g, '/')
|
||||||
|
normalized = normalized.replace(/^[a-zA-Z]:.*?\/public\//, '/')
|
||||||
|
normalized = normalized.replace(/^\.?\/?public\//, '/')
|
||||||
|
return normalized.startsWith('/') ? normalized : `/${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelUrl = computed(() => normalizePublicPath(
|
||||||
|
props.filePath && props.fileName
|
||||||
|
? `${props.filePath.replace(/[\\/]?$/, '/')}${props.fileName}`
|
||||||
|
: props.modelPath
|
||||||
|
))
|
||||||
|
|
||||||
|
const modelDirectory = computed(() => modelUrl.value.slice(0, modelUrl.value.lastIndexOf('/') + 1))
|
||||||
|
|
||||||
const viewerContainer = ref(null);
|
const viewerContainer = ref(null);
|
||||||
const isLoading = ref(true);
|
|
||||||
const loadingMessage = ref('初始化...');
|
|
||||||
|
|
||||||
let scene, camera, renderer, controls;
|
let scene, camera, renderer, controls;
|
||||||
let robotGroup = null;
|
|
||||||
const jointMap = new Map();
|
|
||||||
const animationId = ref(null);
|
const animationId = ref(null);
|
||||||
const jointControls = reactive([]);
|
const jointControls = reactive([]);
|
||||||
|
|
||||||
// ============ XACRO 解析器 ============
|
|
||||||
class XacroParser {
|
|
||||||
constructor() {
|
|
||||||
this.variables = new Map();
|
|
||||||
this.macros = new Map();
|
|
||||||
this.includes = [];
|
|
||||||
this.robotXml = null;
|
|
||||||
}
|
|
||||||
parse(xacroContent) {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const xmlDoc = parser.parseFromString(xacroContent, 'text/xml');
|
|
||||||
const properties = xmlDoc.getElementsByTagName('xacro:property');
|
|
||||||
for (const prop of properties) {
|
|
||||||
const name = prop.getAttribute('name');
|
|
||||||
const value = prop.getAttribute('value');
|
|
||||||
if (name && value) this.variables.set(name, this.evaluateExpression(value));
|
|
||||||
}
|
|
||||||
const includes = xmlDoc.getElementsByTagName('xacro:include');
|
|
||||||
for (const inc of includes) {
|
|
||||||
const filename = inc.getAttribute('filename');
|
|
||||||
if (filename) this.includes.push(filename);
|
|
||||||
}
|
|
||||||
const macros = xmlDoc.getElementsByTagName('xacro:macro');
|
|
||||||
for (const macro of macros) {
|
|
||||||
const name = macro.getAttribute('name');
|
|
||||||
const params = macro.getAttribute('params') || '';
|
|
||||||
if (name) {
|
|
||||||
this.macros.set(name, {
|
|
||||||
params: params.split(/\s+/).filter(p => p),
|
|
||||||
content: macro.innerHTML
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.robotXml = this.processRobotXml(xmlDoc);
|
|
||||||
return this.robotXml;
|
|
||||||
}
|
|
||||||
processRobotXml(xmlDoc) {
|
|
||||||
const doc = xmlDoc.cloneNode(true);
|
|
||||||
const allElements = Array.from(doc.getElementsByTagName('*'));
|
|
||||||
for (const elem of allElements) {
|
|
||||||
if (elem.tagName.toLowerCase().startsWith('xacro:')) {
|
|
||||||
elem.parentNode?.removeChild(elem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const remaining = Array.from(doc.getElementsByTagName('*'));
|
|
||||||
for (const elem of remaining) {
|
|
||||||
for (const attr of elem.attributes) {
|
|
||||||
if (attr.value?.includes('${')) attr.value = this.replaceVariables(attr.value);
|
|
||||||
}
|
|
||||||
if (elem.textContent?.includes('${')) elem.textContent = this.replaceVariables(elem.textContent);
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
replaceVariables(text) {
|
|
||||||
return text.replace(/\$\{([^}]+)\}/g, (match, expr) => this.evaluateExpression(expr));
|
|
||||||
}
|
|
||||||
evaluateExpression(expr) {
|
|
||||||
expr = expr.trim();
|
|
||||||
if (!expr) return '0';
|
|
||||||
try {
|
|
||||||
for (const [key, value] of this.variables) {
|
|
||||||
if (key.length === 0) continue;
|
|
||||||
const regex = new RegExp('\\b' + this.escapeRegex(key) + '\\b', 'g');
|
|
||||||
expr = expr.replace(regex, '(' + value + ')');
|
|
||||||
}
|
|
||||||
expr = expr.replace(/\bPI\b/g, '(' + Math.PI + ')');
|
|
||||||
expr = expr.replace(/\bpi\b/g, '(' + Math.PI + ')');
|
|
||||||
const sanitized = expr.replace(/\s+/g, '');
|
|
||||||
if (/^[\d+\-*/().eE]+$/.test(sanitized)) {
|
|
||||||
const result = Function('"use strict"; return (' + sanitized + ')')();
|
|
||||||
if (typeof result === 'number' && isFinite(result)) return result.toString();
|
|
||||||
}
|
|
||||||
console.warn('表达式求值失败,返回原值:', expr);
|
|
||||||
return expr;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('表达式求值异常:', expr, e);
|
|
||||||
return expr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
escapeRegex(str) {
|
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
getRobotXml() { return this.robotXml; }
|
|
||||||
getIncludes() { return this.includes; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 工具函数 ============
|
|
||||||
const cleanMeshPath = (path) => {
|
|
||||||
if (!path) return '';
|
|
||||||
path = path.replace(/^file:\/\//, '');
|
|
||||||
path = path.replace(/^package:\/\/[^/]+\//, '');
|
|
||||||
const findMatch = path.match(/\$\(find\s+([^)]+)\)/);
|
|
||||||
if (findMatch) path = path.replace(/\$\(find\s+[^)]+\)/, '');
|
|
||||||
path = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseXYZ = (str) => {
|
|
||||||
if (!str) return { x: 0, y: 0, z: 0 };
|
|
||||||
const [x, y, z] = str.trim().split(/\s+/).map(Number);
|
|
||||||
return { x: x || 0, y: y || 0, z: z || 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseRPY = (str) => {
|
|
||||||
if (!str) return { roll: 0, pitch: 0, yaw: 0 };
|
|
||||||
const [r, p, y] = str.trim().split(/\s+/).map(Number);
|
|
||||||
return { roll: r || 0, pitch: p || 0, yaw: y || 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ★ 正确转换 RPY → 四元数(URDF 固定轴 XYZ = Three.js 局部轴 ZYX)
|
|
||||||
const rpyToQuaternion = (rpy) => {
|
|
||||||
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
|
||||||
return new THREE.Quaternion().setFromEuler(euler);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ STL 加载 ============
|
|
||||||
const loadSTLWithRetry = (url, group, visualPos, visualQuat) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const loader = new STLLoader();
|
|
||||||
const loadMesh = (attemptUrl) => {
|
|
||||||
loader.load(
|
|
||||||
attemptUrl,
|
|
||||||
(geometry) => {
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
const material = new THREE.MeshStandardMaterial({
|
|
||||||
color: props.modelColor,
|
|
||||||
roughness: 0.4,
|
|
||||||
metalness: 0.3,
|
|
||||||
});
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.position.set(visualPos.x, visualPos.y, visualPos.z);
|
|
||||||
mesh.quaternion.copy(visualQuat); // 使用四元数
|
|
||||||
mesh.castShadow = true;
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
while (group.children.length > 0) {
|
|
||||||
const child = group.children[0];
|
|
||||||
if (child.isMesh) {
|
|
||||||
child.geometry?.dispose();
|
|
||||||
child.material?.dispose();
|
|
||||||
}
|
|
||||||
group.remove(child);
|
|
||||||
}
|
|
||||||
group.add(mesh);
|
|
||||||
resolve(mesh);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
(error) => {
|
|
||||||
console.warn(`加载STL失败: ${attemptUrl}`, error);
|
|
||||||
if (!attemptUrl.startsWith('/') && !attemptUrl.startsWith('http')) {
|
|
||||||
loadMesh('/' + attemptUrl);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
loadMesh(url);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ URDF 解析与构建(严格修复) ============
|
|
||||||
const parseAndBuildRobot = async (urdfDoc) => {
|
|
||||||
const links = urdfDoc.getElementsByTagName('link');
|
|
||||||
const jointsRaw = urdfDoc.getElementsByTagName('joint');
|
|
||||||
const joints = sortJointsTopologically(jointsRaw, links);
|
|
||||||
const linkGroupMap = new Map();
|
|
||||||
const mainGroup = new THREE.Group();
|
|
||||||
mainGroup.name = 'robot';
|
|
||||||
|
|
||||||
loadingMessage.value = '创建链接结构...';
|
|
||||||
for (const link of links) {
|
|
||||||
const name = link.getAttribute('name');
|
|
||||||
const group = new THREE.Group();
|
|
||||||
group.name = name;
|
|
||||||
const visual = link.getElementsByTagName('visual')[0];
|
|
||||||
if (visual) {
|
|
||||||
const origin = visual.getElementsByTagName('origin')[0];
|
|
||||||
const geometry = visual.getElementsByTagName('geometry')[0];
|
|
||||||
const meshTag = geometry?.getElementsByTagName('mesh')[0];
|
|
||||||
if (meshTag) {
|
|
||||||
let meshPath = cleanMeshPath(meshTag.getAttribute('filename'));
|
|
||||||
let visualPos = { x: 0, y: 0, z: 0 };
|
|
||||||
let visualQuat = new THREE.Quaternion();
|
|
||||||
if (origin) {
|
|
||||||
const rawXyz = parseXYZ(origin.getAttribute('xyz') || '0 0 0');
|
|
||||||
const rawRpy = parseRPY(origin.getAttribute('rpy') || '0 0 0');
|
|
||||||
visualPos = rawXyz;
|
|
||||||
visualQuat = rpyToQuaternion(rawRpy); // ★ 使用正确顺序
|
|
||||||
}
|
|
||||||
group.userData = {
|
|
||||||
meshPath: `${props.basePath}${meshPath}`,
|
|
||||||
visualPos,
|
|
||||||
visualQuat
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
linkGroupMap.set(name, group);
|
|
||||||
mainGroup.add(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingMessage.value = '加载3D模型文件...';
|
|
||||||
const loadPromises = [];
|
|
||||||
for (const [name, group] of linkGroupMap) {
|
|
||||||
if (group.userData.meshPath) {
|
|
||||||
loadPromises.push(
|
|
||||||
loadSTLWithRetry(group.userData.meshPath, group, group.userData.visualPos, group.userData.visualQuat)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.allSettled(loadPromises);
|
|
||||||
|
|
||||||
loadingMessage.value = '构建关节结构...';
|
|
||||||
const jointDataList = [];
|
|
||||||
for (const joint of joints) {
|
|
||||||
const type = joint.getAttribute('type');
|
|
||||||
const name = joint.getAttribute('name');
|
|
||||||
const parent = joint.getElementsByTagName('parent')[0]?.getAttribute('link');
|
|
||||||
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
|
||||||
const origin = joint.getElementsByTagName('origin')[0];
|
|
||||||
let xyz = { x: 0, y: 0, z: 0 };
|
|
||||||
let rpy = { roll: 0, pitch: 0, yaw: 0 };
|
|
||||||
if (origin) {
|
|
||||||
xyz = parseXYZ(origin.getAttribute('xyz') || '0 0 0');
|
|
||||||
rpy = parseRPY(origin.getAttribute('rpy') || '0 0 0');
|
|
||||||
}
|
|
||||||
const axis = joint.getElementsByTagName('axis')[0];
|
|
||||||
const axisVec = parseXYZ(axis?.getAttribute('xyz') || '0 0 1');
|
|
||||||
const limit = joint.getElementsByTagName('limit')[0];
|
|
||||||
const min = limit ? parseFloat(limit.getAttribute('lower')) : -Math.PI;
|
|
||||||
const max = limit ? parseFloat(limit.getAttribute('upper')) : Math.PI;
|
|
||||||
jointDataList.push({
|
|
||||||
name, type, parent, child, xyz, rpy,
|
|
||||||
axis: new THREE.Vector3(axisVec.x, axisVec.y, axisVec.z),
|
|
||||||
min, max
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建层级(Euler 顺序 ZYX)
|
|
||||||
for (const jointData of jointDataList) {
|
|
||||||
const { name, type, parent, child, xyz, rpy, axis, min, max } = jointData;
|
|
||||||
const parentGroup = linkGroupMap.get(parent);
|
|
||||||
const childGroup = linkGroupMap.get(child);
|
|
||||||
if (!parentGroup || !childGroup) {
|
|
||||||
console.warn(`关节 ${name} 的父或子链接不存在`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainGroup.children.includes(childGroup)) mainGroup.remove(childGroup);
|
|
||||||
if (parentGroup.children.includes(childGroup)) parentGroup.remove(childGroup);
|
|
||||||
|
|
||||||
if (type === 'revolute' || type === 'continuous') {
|
|
||||||
const varGroup = new THREE.Group();
|
|
||||||
varGroup.name = name + '_var';
|
|
||||||
|
|
||||||
const originGroup = new THREE.Group();
|
|
||||||
originGroup.name = name + '_origin';
|
|
||||||
originGroup.position.set(xyz.x, xyz.y, xyz.z);
|
|
||||||
// ★ 使用正确顺序
|
|
||||||
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
|
||||||
originGroup.quaternion.setFromEuler(euler);
|
|
||||||
|
|
||||||
originGroup.add(childGroup);
|
|
||||||
varGroup.add(originGroup);
|
|
||||||
jointMap.set(name, {
|
|
||||||
name,
|
|
||||||
group: varGroup,
|
|
||||||
axis: axis.clone().normalize(), // 仍为 Z-up 轴,稍后在 loadRobot 中统一变换
|
|
||||||
min, max,
|
|
||||||
value: 0,
|
|
||||||
displayName: name.replace(/_/g, ' ').replace('elfin ', '')
|
|
||||||
});
|
|
||||||
parentGroup.add(varGroup);
|
|
||||||
} else {
|
|
||||||
// 固定关节
|
|
||||||
childGroup.position.set(xyz.x, xyz.y, xyz.z);
|
|
||||||
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
|
|
||||||
childGroup.quaternion.setFromEuler(euler);
|
|
||||||
parentGroup.add(childGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mainGroup;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 拓扑排序 ============
|
|
||||||
const sortJointsTopologically = (joints, links) => {
|
|
||||||
const childToJoint = new Map();
|
|
||||||
const allLinkNames = new Set();
|
|
||||||
for (const link of links) allLinkNames.add(link.getAttribute('name'));
|
|
||||||
const jointArray = Array.from(joints);
|
|
||||||
for (const joint of jointArray) {
|
|
||||||
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
|
||||||
if (child) childToJoint.set(child, joint);
|
|
||||||
}
|
|
||||||
const childLinks = new Set(childToJoint.keys());
|
|
||||||
const rootLinks = [...allLinkNames].filter(name => !childLinks.has(name));
|
|
||||||
const sorted = [];
|
|
||||||
const queue = [...rootLinks];
|
|
||||||
const visited = new Set();
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const linkName = queue.shift();
|
|
||||||
for (const joint of jointArray) {
|
|
||||||
const parent = joint.getElementsByTagName('parent')[0]?.getAttribute('link');
|
|
||||||
const child = joint.getElementsByTagName('child')[0]?.getAttribute('link');
|
|
||||||
if (parent === linkName && !visited.has(joint.getAttribute('name'))) {
|
|
||||||
visited.add(joint.getAttribute('name'));
|
|
||||||
sorted.push(joint);
|
|
||||||
if (child) queue.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const joint of jointArray) {
|
|
||||||
if (!visited.has(joint.getAttribute('name'))) sorted.push(joint);
|
|
||||||
}
|
|
||||||
return sorted;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 场景初始化 ============
|
// ============ 场景初始化 ============
|
||||||
const initScene = () => {
|
const initScene = () => {
|
||||||
const container = viewerContainer.value;
|
const container = viewerContainer.value;
|
||||||
const width = container.clientWidth || 800;
|
const width = container.clientWidth || 800;
|
||||||
const height = container.clientHeight || 600;
|
const height = container.clientHeight || 600;
|
||||||
|
|
||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0x1a1a2e);
|
scene.background = new THREE.Color('#050a14');
|
||||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
|
||||||
camera.position.set(2, 2, 3);
|
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 = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
renderer.setSize(width, height);
|
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);
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
controls = new OrbitControls(camera, renderer.domElement);
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = 0.1;
|
controls.dampingFactor = 0.1;
|
||||||
controls.target.set(0, 0.5, 0);
|
controls.target.set(0, 0.5, 0);
|
||||||
controls.update();
|
controls.update();
|
||||||
|
|
||||||
const ambient = new THREE.AmbientLight(0x404060, 0.6);
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6)
|
||||||
scene.add(ambient);
|
scene.add(ambient);
|
||||||
const main = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
||||||
main.position.set(5, 10, 7);
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||||
main.castShadow = true;
|
directionalLight.position.set(5, 10, 7);
|
||||||
scene.add(main);
|
directionalLight.castShadow = true;
|
||||||
const fill = new THREE.DirectionalLight(0x8888ff, 0.4);
|
scene.add(directionalLight);
|
||||||
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) => {
|
const updateJoint = (jointName, angle) => {
|
||||||
const joint = jointMap.get(jointName);
|
if (!robotInstance) return;
|
||||||
if (!joint) return;
|
|
||||||
const clampedAngle = Math.max(joint.min, Math.min(joint.max, angle));
|
|
||||||
joint.value = clampedAngle;
|
|
||||||
|
|
||||||
joint.group.quaternion.identity();
|
const joint = robotInstance.joints[jointName];
|
||||||
const axis = joint.axis.clone().normalize();
|
|
||||||
joint.group.quaternion.setFromAxisAngle(axis, clampedAngle); // 正负根据实际情况调整
|
|
||||||
|
|
||||||
const control = jointControls.find(j => j.name === jointName);
|
if (!joint) {
|
||||||
if (control) control.value = clampedAngle;
|
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 = () => {
|
const resetJoints = () => {
|
||||||
for (const joint of jointControls) updateJoint(joint.name, 0);
|
for (const joint of jointControls) {
|
||||||
|
joint.value = 0;
|
||||||
|
updateJoint(joint.name, 0);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ 加载机器人(轴变换加入) ============
|
const fileMap = new Map()
|
||||||
|
const resolveReferencedUrl = (reference, parentUrl) => {
|
||||||
|
const normalized = reference.replace(/\\/g, '/')
|
||||||
|
const rosPath = normalized.match(/^(?:file:\/\/)?\$\(find\s+[^)]+\)\/(.+)$/)
|
||||||
|
if (rosPath) return `${modelDirectory.value}${rosPath[1]}`
|
||||||
|
|
||||||
|
const packagePath = normalized.match(/^package:\/\/[^/]+\/(.+)$/)
|
||||||
|
if (packagePath) return `${modelDirectory.value}${packagePath[1]}`
|
||||||
|
|
||||||
|
if (/^(?:https?:|blob:|data:)/i.test(normalized)) return normalized
|
||||||
|
return new URL(normalized, new URL(parentUrl, window.location.origin)).pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
fileMap.clear()
|
||||||
|
if (!modelUrl.value.toLowerCase().endsWith('.xacro')) return
|
||||||
|
|
||||||
|
const pendingUrls = [modelUrl.value]
|
||||||
|
const visitedUrls = new Set()
|
||||||
|
while (pendingUrls.length) {
|
||||||
|
const url = pendingUrls.shift()
|
||||||
|
if (visitedUrls.has(url)) continue
|
||||||
|
visitedUrls.add(url)
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`加载模型依赖失败:${url} (HTTP ${response.status})`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const fileName = url.split('/').pop()
|
||||||
|
const file = new File([blob], fileName, { type: blob.type })
|
||||||
|
fileMap.set(url, file)
|
||||||
|
|
||||||
|
if (!/\.(?:xacro|urdf|xml)$/i.test(fileName)) continue
|
||||||
|
const content = await blob.text()
|
||||||
|
const references = [...content.matchAll(/\b(?:filename|url)=["']([^"']+)["']/gi)]
|
||||||
|
references.forEach(([, reference]) => {
|
||||||
|
const referencedUrl = resolveReferencedUrl(reference, url)
|
||||||
|
if (!visitedUrls.has(referencedUrl)) pendingUrls.push(referencedUrl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentModel = ref()
|
||||||
|
let robotInstance = null; // 保存 URDFRobot 对象
|
||||||
|
|
||||||
|
const parseUrdf = async (urdfContent) => {
|
||||||
|
const loader = new URDFLoader()
|
||||||
|
loader.parseCollision = true
|
||||||
|
|
||||||
|
const packageNames = [...urdfContent.matchAll(/package:\/\/([^/]+)/g)].map(match => match[1])
|
||||||
|
loader.packages = Object.fromEntries(
|
||||||
|
[...new Set(packageNames)].map(packageName => [packageName, `${modelDirectory.value}${packageName}`])
|
||||||
|
)
|
||||||
|
|
||||||
|
const robot = loader.parse(urdfContent, modelDirectory.value)
|
||||||
|
return URDFAdapter.convert(robot, urdfContent)
|
||||||
|
}
|
||||||
|
|
||||||
const loadRobot = async () => {
|
const loadRobot = async () => {
|
||||||
try {
|
const response = await fetch(modelUrl.value);
|
||||||
isLoading.value = true;
|
if (!response.ok) throw new Error(`加载模型失败:${modelUrl.value} (HTTP ${response.status})`);
|
||||||
loadingMessage.value = '加载XACRO文件...';
|
const content = await response.text();
|
||||||
const xacroUrl = `${props.basePath}elfin10.urdf.xacro`;
|
const fileName = modelUrl.value.split('/').pop()
|
||||||
const response = await fetch(xacroUrl);
|
const lowerCaseFileName = fileName.toLowerCase()
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
let model
|
||||||
const xacroContent = await response.text();
|
if (lowerCaseFileName.endsWith('.xacro')) {
|
||||||
loadingMessage.value = '解析XACRO...';
|
const file = fileMap.get(modelUrl.value) || new File([content], fileName, { type: 'text/xml' })
|
||||||
const parser = new XacroParser();
|
model = await XacroAdapter.parse(content, file.name, fileMap, file);
|
||||||
const robotDoc = parser.parse(xacroContent);
|
} else if (lowerCaseFileName.endsWith('.urdf')) {
|
||||||
loadingMessage.value = '构建机器人模型...';
|
model = await parseUrdf(content)
|
||||||
robotGroup = await parseAndBuildRobot(robotDoc);
|
} else {
|
||||||
if (robotGroup) {
|
throw new Error(`不支持的模型格式:${fileName},仅支持 .xacro 和 .urdf`)
|
||||||
scene.add(robotGroup);
|
|
||||||
// ★ 全局旋转:Z-up → Y-up
|
|
||||||
robotGroup.rotation.x = -Math.PI / 2;
|
|
||||||
|
|
||||||
// ★ 关键:将所有关节轴从 Z-up 坐标系变换到 Y-up 坐标系
|
|
||||||
const globalQuat = new THREE.Quaternion().setFromEuler(
|
|
||||||
new THREE.Euler(-Math.PI / 2, 0, 0, 'XYZ')
|
|
||||||
);
|
|
||||||
const globalQuat1 = new THREE.Quaternion().setFromEuler(
|
|
||||||
new THREE.Euler(-Math.PI / 2, 0, 0, 'ZXY')
|
|
||||||
);
|
|
||||||
for (const joint of jointMap.values()) {
|
|
||||||
if (['joint2', 'joint6'].includes(joint.displayName)) {
|
|
||||||
joint.axis.applyQuaternion(globalQuat).normalize();
|
|
||||||
}
|
|
||||||
if (['joint3', 'joint4', 'joint5'].includes(joint.displayName)) {
|
|
||||||
joint.axis.applyQuaternion(globalQuat1).normalize();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jointControls.length = 0;
|
const robot = model.threeObject;
|
||||||
for (const [name, joint] of jointMap) {
|
|
||||||
|
// 将机器人添加到场景
|
||||||
|
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({
|
jointControls.push({
|
||||||
name,
|
name,
|
||||||
displayName: joint.displayName,
|
min: lower,
|
||||||
value: 0,
|
max: upper,
|
||||||
min: joint.min,
|
value: 0, // 因为 joint.angle 初始为 0
|
||||||
max: joint.max
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
fitCamera();
|
|
||||||
}
|
|
||||||
isLoading.value = false;
|
|
||||||
loadingMessage.value = '加载完成';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载机器人失败:', error);
|
|
||||||
isLoading.value = false;
|
|
||||||
loadingMessage.value = `加载失败: ${error.message}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fitCamera = () => {
|
|
||||||
if (!robotGroup) return;
|
|
||||||
const box = new THREE.Box3().setFromObject(robotGroup);
|
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
|
||||||
const size = box.getSize(new THREE.Vector3());
|
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
|
||||||
if (maxDim === 0) return;
|
|
||||||
const distance = maxDim / (2 * Math.tan((camera.fov * Math.PI) / 360));
|
|
||||||
const direction = new THREE.Vector3(1, 1, 1).normalize();
|
|
||||||
camera.position.copy(center).add(direction.multiplyScalar(distance * 1.5));
|
|
||||||
controls.target.copy(center);
|
|
||||||
controls.update();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ 动画循环 ============
|
// ============ 动画循环 ============
|
||||||
@ -510,6 +239,7 @@ const animate = () => {
|
|||||||
// ============ 生命周期 ============
|
// ============ 生命周期 ============
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await loadFiles()
|
||||||
initScene();
|
initScene();
|
||||||
await loadRobot();
|
await loadRobot();
|
||||||
animate();
|
animate();
|
||||||
@ -533,56 +263,30 @@ const onResize = () => {
|
|||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ updateJoint, resetJoints, jointControls, jointMap });
|
defineExpose({ updateJoint, resetJoints, jointControls });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.elfin-viewer {
|
.elfin-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0d0d1a;
|
background: #050a14;
|
||||||
}
|
|
||||||
.viewer-container {
|
.viewer-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
|
||||||
.viewer-container canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
.control-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: rgba(20, 20, 40, 0.85);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #ccc;
|
|
||||||
max-height: 80%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
.joint-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.joint-item label {
|
|
||||||
width: 120px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.joint-item input[type=range] {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.angle {
|
|
||||||
width: 50px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.reset-btn {
|
|
||||||
margin-top: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user