Merge remote-tracking branch 'origin/lxl-dev' into lxl-dev

This commit is contained in:
lixiaolong 2026-08-04 15:35:54 +08:00
commit b49de73aa7
5 changed files with 203 additions and 744 deletions

View File

@ -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'
}, },

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -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 viewerContainer = ref(null); const normalizePublicPath = (path) => {
const isLoading = ref(true); let normalized = path.trim().replace(/\\/g, '/')
const loadingMessage = ref('初始化...'); normalized = normalized.replace(/^[a-zA-Z]:.*?\/public\//, '/')
normalized = normalized.replace(/^\.?\/?public\//, '/')
let scene, camera, renderer, controls; return normalized.startsWith('/') ? normalized : `/${normalized}`
let robotGroup = null;
const jointMap = new Map();
const animationId = ref(null);
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 modelUrl = computed(() => normalizePublicPath(
const cleanMeshPath = (path) => { props.filePath && props.fileName
if (!path) return ''; ? `${props.filePath.replace(/[\\/]?$/, '/')}${props.fileName}`
path = path.replace(/^file:\/\//, ''); : props.modelPath
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) => { const modelDirectory = computed(() => modelUrl.value.slice(0, modelUrl.value.lastIndexOf('/') + 1))
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) => { const viewerContainer = ref(null);
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 let scene, camera, renderer, controls;
const rpyToQuaternion = (rpy) => {
const euler = new THREE.Euler(rpy.roll, rpy.pitch, rpy.yaw, 'ZYX');
return new THREE.Quaternion().setFromEuler(euler);
};
// ============ STL ============ const animationId = ref(null);
const loadSTLWithRetry = (url, group, visualPos, visualQuat) => { const jointControls = reactive([]);
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 loadRobot = async () => {
try {
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();
loadingMessage.value = '解析XACRO...';
const parser = new XacroParser();
const robotDoc = parser.parse(xacroContent);
loadingMessage.value = '构建机器人模型...';
robotGroup = await parseAndBuildRobot(robotDoc);
if (robotGroup) {
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;
for (const [name, joint] of jointMap) {
jointControls.push({
name,
displayName: joint.displayName,
value: 0,
min: joint.min,
max: joint.max
});
}
fitCamera();
}
isLoading.value = false;
loadingMessage.value = '加载完成';
} catch (error) {
console.error('加载机器人失败:', error);
isLoading.value = false;
loadingMessage.value = `加载失败: ${error.message}`;
} }
}; };
const fitCamera = () => { const fileMap = new Map()
if (!robotGroup) return; const resolveReferencedUrl = (reference, parentUrl) => {
const box = new THREE.Box3().setFromObject(robotGroup); const normalized = reference.replace(/\\/g, '/')
const center = box.getCenter(new THREE.Vector3()); const rosPath = normalized.match(/^(?:file:\/\/)?\$\(find\s+[^)]+\)\/(.+)$/)
const size = box.getSize(new THREE.Vector3()); if (rosPath) return `${modelDirectory.value}${rosPath[1]}`
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim === 0) return; const packagePath = normalized.match(/^package:\/\/[^/]+\/(.+)$/)
const distance = maxDim / (2 * Math.tan((camera.fov * Math.PI) / 360)); if (packagePath) return `${modelDirectory.value}${packagePath[1]}`
const direction = new THREE.Vector3(1, 1, 1).normalize();
camera.position.copy(center).add(direction.multiplyScalar(distance * 1.5)); if (/^(?:https?:|blob:|data:)/i.test(normalized)) return normalized
controls.target.copy(center); return new URL(normalized, new URL(parentUrl, window.location.origin)).pathname
controls.update(); }
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 response = await fetch(modelUrl.value);
if (!response.ok) throw new Error(`加载模型失败:${modelUrl.value} (HTTP ${response.status})`);
const content = await response.text();
const fileName = modelUrl.value.split('/').pop()
const lowerCaseFileName = fileName.toLowerCase()
let model
if (lowerCaseFileName.endsWith('.xacro')) {
const file = fileMap.get(modelUrl.value) || new File([content], fileName, { type: 'text/xml' })
model = await XacroAdapter.parse(content, file.name, fileMap, file);
} else if (lowerCaseFileName.endsWith('.urdf')) {
model = await parseUrdf(content)
} else {
throw new Error(`不支持的模型格式:${fileName},仅支持 .xacro 和 .urdf`)
}
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
});
}
}; };
// ============ ============ // ============ ============
@ -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 {
width: 100%;
height: 100%;
canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
}
} }
.viewer-container {
width: 100%;
height: 100%;
}
.viewer-container canvas { </style>
display: block;
width: 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>