Skip to content

Commit 32fbe60

Browse files
committed
feat: 题目8 请在UI子包中,根据提供的机器人模型文件
1 parent 62f061a commit 32fbe60

File tree

9 files changed

+507
-3
lines changed

9 files changed

+507
-3
lines changed
7.99 MB
Binary file not shown.

apps/admin/src/router/routes/compo.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ const CompoRoute: RouteObject = {
104104
key: 'flowEditor',
105105
},
106106
},
107+
{
108+
path: 'robot-viewer',
109+
name: 'RobotViewer',
110+
element: LazyLoad(lazy(() => import('@/views/compo/robot-viewer'))),
111+
meta: {
112+
title: '机器人查看器',
113+
key: 'robotViewer',
114+
},
115+
},
107116
],
108117
};
109118

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Card, Col, Row, Typography } from 'antd';
2+
import { createStyles } from 'antd-style';
3+
import React from 'react';
4+
import { RobotViewer } from 'ui';
5+
6+
import { Page } from '@/components/Page';
7+
8+
const { Text } = Typography;
9+
10+
const useStyles = createStyles(({ token }) => ({
11+
container: {
12+
height: 'calc(100vh - 140px)',
13+
minHeight: 600,
14+
},
15+
viewerContainer: {
16+
height: '100%',
17+
borderRadius: token.borderRadiusLG,
18+
overflow: 'hidden',
19+
},
20+
controlPanel: {
21+
height: '100%',
22+
padding: token.paddingLG,
23+
},
24+
infoCard: {
25+
marginBottom: token.marginMD,
26+
},
27+
}));
28+
29+
const RobotDemo: React.FC = () => {
30+
const { styles } = useStyles();
31+
const [modelLoaded, setModelLoaded] = React.useState(false);
32+
33+
const handleModelLoad = (model: any) => {
34+
console.log('模型加载完成:', model);
35+
setModelLoaded(true);
36+
};
37+
38+
return (
39+
<Page plugin={{ name: '机器人模型查看器', desc: '使用 Three.js 加载和展示 FBX 机器人模型', url: '' }}>
40+
<Row gutter={16} className={styles.container}>
41+
<Col span={18}>
42+
<Card className={styles.viewerContainer} bodyStyle={{ height: '100%', padding: 0 }}>
43+
<RobotViewer onModelLoad={handleModelLoad} />
44+
</Card>
45+
</Col>
46+
<Col span={6}>
47+
<Card className={styles.controlPanel} title='控制面板'>
48+
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
49+
<Card size='small' className={styles.infoCard}>
50+
<Text type='secondary'>模型信息</Text>
51+
<div style={{ marginTop: 8 }}>
52+
<Text>• 格式: FBX</Text>
53+
<br />
54+
<Text>• 状态: {modelLoaded ? '已加载' : '加载中...'}</Text>
55+
<br />
56+
<Text>• 渲染引擎: Three.js</Text>
57+
</div>
58+
</Card>
59+
60+
<Card size='small' className={styles.infoCard}>
61+
<Text type='secondary'>操作说明</Text>
62+
<div style={{ marginTop: 8 }}>
63+
<Text>• 左键拖拽: 旋转视角</Text>
64+
<br />
65+
<Text>• 右键拖拽: 平移视角</Text>
66+
<br />
67+
<Text>• 滚轮: 缩放</Text>
68+
<br />
69+
<Text>• 重置视角: 点击按钮</Text>
70+
</div>
71+
</Card>
72+
</div>
73+
</Card>
74+
</Col>
75+
</Row>
76+
</Page>
77+
);
78+
};
79+
80+
export default RobotDemo;

packages/three/src/three-core/ThreeApplication.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,13 @@ export class ThreeApplication {
104104
this.containerElement = undefined;
105105
}
106106
}
107+
108+
export * from './camera/Camera';
109+
export * from './camera/CameraControl';
110+
export * from './event-emitter/EventEmitter';
111+
export * from './light/Light';
112+
export * from './renderer/Renderer';
113+
export * from './resource-loader/ResourceLoader';
114+
export * from './scene/Scene';
115+
export * from './sizes/Sizes';
116+
export * from './ticker/Ticker';

packages/three/src/three-core/camera/Camera.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import type { Sizes } from '../sizes/Sizes';
55
export class Camera {
66
setting = {
77
fov: 45,
8-
near: 0.1,
8+
near: 1,
99
far: 1000,
1010
position: {
1111
x: 0,
1212
y: 0,
13-
z: 5,
13+
z: 100,
1414
},
1515
};
1616

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { EventEmitter, Resource, ResourceData, ThreeApplication } from '@gbeata/three';
2+
import { Box3, Color, Material, Mesh, MeshStandardMaterial, Raycaster, Vector2, Vector3 } from 'three';
3+
import { GLTF } from 'three/examples/jsm/Addons';
4+
5+
interface RobotEvents {
6+
partSelect: (part: Mesh | null) => void;
7+
}
8+
9+
export class Robot extends EventEmitter<RobotEvents> {
10+
private robotScene: GLTF['scene'] | null = null;
11+
private selectedPart: Mesh | null = null;
12+
private originalEmissive = new Color(0x000000);
13+
private originalOpacity = 1;
14+
private raycaster = new Raycaster();
15+
private mouse = new Vector2();
16+
17+
constructor(private app: ThreeApplication) {
18+
super();
19+
this.bindEvents();
20+
}
21+
22+
private onModelLoadEnd = (resource: Resource, data: ResourceData) => {
23+
if (resource.name === 'robot') {
24+
this.robotScene = data as GLTF['scene'];
25+
this.app.scene.instance.add(this.robotScene);
26+
27+
const box = new Box3().setFromObject(this.robotScene);
28+
const center = box.getCenter(new Vector3());
29+
const size = box.getSize(new Vector3());
30+
const maxDim = Math.max(size.x, size.y, size.z);
31+
const fov = this.app.camera.instance.fov * (Math.PI / 180);
32+
const cameraZ = maxDim / (2 * Math.tan(fov / 2));
33+
34+
this.app.camera.instance.position.set(center.x, center.y, center.z + cameraZ);
35+
this.app.camera.instance.lookAt(center);
36+
this.app.camera.instance.far = maxDim * 10;
37+
this.app.camera.instance.updateProjectionMatrix();
38+
39+
this.app.cameraControl.instance.target.copy(center);
40+
}
41+
};
42+
43+
private onMouseDown = (event: MouseEvent) => {
44+
if (!this.robotScene) return;
45+
46+
const canvas = this.app.renderer.instance.domElement;
47+
const rect = canvas.getBoundingClientRect();
48+
49+
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
50+
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
51+
52+
this.raycaster.setFromCamera(this.mouse, this.app.camera.instance);
53+
const intersects = this.raycaster.intersectObjects(this.robotScene.children, true);
54+
55+
if (intersects.length > 0) {
56+
const mesh = intersects[0].object as Mesh;
57+
this.selectPart(mesh);
58+
} else {
59+
this.deselectPart();
60+
}
61+
};
62+
63+
private selectPart(mesh: Mesh) {
64+
if (this.selectedPart === mesh) return;
65+
66+
this.deselectPart();
67+
68+
this.selectedPart = mesh;
69+
70+
if (mesh.material) {
71+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
72+
materials.forEach((material: Material) => {
73+
if (this.isMeshStandardMaterial(material)) {
74+
this.originalEmissive = material.emissive.clone();
75+
}
76+
this.originalOpacity = material.transparent ? material.opacity : 1;
77+
});
78+
79+
this.highlightPart(mesh);
80+
}
81+
82+
this.emit('partSelect', mesh);
83+
}
84+
85+
private isMeshStandardMaterial(material: Material): material is MeshStandardMaterial {
86+
return material.type === 'MeshStandardMaterial';
87+
}
88+
89+
private highlightPart(mesh: Mesh) {
90+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
91+
materials.forEach((material: Material) => {
92+
if (this.isMeshStandardMaterial(material)) {
93+
material.emissive = new Color(0x00ff00);
94+
material.emissiveIntensity = 0.3;
95+
}
96+
});
97+
}
98+
99+
private unhighlightPart(mesh: Mesh) {
100+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
101+
materials.forEach((material: Material) => {
102+
if (this.isMeshStandardMaterial(material)) {
103+
material.emissive = this.originalEmissive;
104+
material.emissiveIntensity = 0;
105+
}
106+
});
107+
}
108+
109+
private deselectPart() {
110+
if (!this.selectedPart) return;
111+
112+
if (this.selectedPart.material) {
113+
this.unhighlightPart(this.selectedPart);
114+
}
115+
116+
this.selectedPart = null;
117+
this.emit('partSelect', null);
118+
}
119+
120+
setPartScale(scale: number) {
121+
if (!this.selectedPart) return;
122+
this.selectedPart.scale.setScalar(scale);
123+
}
124+
125+
setPartOpacity(opacity: number) {
126+
if (!this.selectedPart) return;
127+
const materials = Array.isArray(this.selectedPart.material) ? this.selectedPart.material : [this.selectedPart.material];
128+
129+
materials.forEach((material: Material) => {
130+
material.transparent = true;
131+
material.opacity = opacity;
132+
material.depthWrite = opacity >= 1;
133+
material.needsUpdate = true;
134+
});
135+
}
136+
137+
private bindEvents() {
138+
this.app.resourceLoader.on('fileLoadEnd', this.onModelLoadEnd);
139+
this.app.renderer.instance.domElement.addEventListener('mousedown', this.onMouseDown);
140+
}
141+
142+
private unbindEvents() {
143+
this.app.resourceLoader.off('fileLoadEnd', this.onModelLoadEnd);
144+
this.app.renderer.instance.domElement.removeEventListener('mousedown', this.onMouseDown);
145+
}
146+
147+
destroy() {
148+
this.unbindEvents();
149+
this.clear();
150+
this.app.destroy();
151+
}
152+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Slider } from 'antd';
2+
import { createStyles } from 'antd-style';
3+
import React from 'react';
4+
5+
const useStyles = createStyles(({ token }) => ({
6+
panel: {
7+
position: 'absolute',
8+
top: '10px',
9+
left: '10px',
10+
zIndex: 1000,
11+
backgroundColor: token.colorBgContainer,
12+
padding: '16px',
13+
borderRadius: token.borderRadius,
14+
boxShadow: token.boxShadow,
15+
width: '280px',
16+
maxHeight: 'calc(100% - 20px)',
17+
overflowY: 'auto',
18+
},
19+
title: {
20+
fontSize: '14px',
21+
fontWeight: 600,
22+
marginBottom: '12px',
23+
color: token.colorText,
24+
},
25+
emptyState: {
26+
fontSize: '12px',
27+
color: token.colorTextSecondary,
28+
textAlign: 'center',
29+
padding: '20px 0',
30+
},
31+
controlGroup: {
32+
marginBottom: '16px',
33+
},
34+
label: {
35+
fontSize: '12px',
36+
color: token.colorTextSecondary,
37+
marginBottom: '8px',
38+
display: 'block',
39+
},
40+
slider: {
41+
width: '100%',
42+
},
43+
valueDisplay: {
44+
fontSize: '12px',
45+
color: token.colorText,
46+
textAlign: 'right',
47+
marginTop: '4px',
48+
},
49+
}));
50+
51+
interface RobotConfigPanelProps {
52+
selectedPartName?: string;
53+
scale: number;
54+
opacity: number;
55+
onScaleChange: (value: number) => void;
56+
onOpacityChange: (value: number) => void;
57+
}
58+
59+
const RobotConfigPanel: React.FC<RobotConfigPanelProps> = ({
60+
selectedPartName,
61+
scale,
62+
opacity,
63+
onScaleChange,
64+
onOpacityChange,
65+
}) => {
66+
const { styles } = useStyles();
67+
68+
return (
69+
<div className={styles.panel}>
70+
<div className={styles.title}>零部件参数配置</div>
71+
{!selectedPartName ? (
72+
<div className={styles.emptyState}>请点击选择机器人零部件</div>
73+
) : (
74+
<>
75+
<div className={styles.controlGroup}>
76+
<span className={styles.label}>选中零部件: {selectedPartName}</span>
77+
</div>
78+
<div className={styles.controlGroup}>
79+
<span className={styles.label}>大小缩放</span>
80+
<Slider
81+
className={styles.slider}
82+
min={0.1}
83+
max={3}
84+
step={0.1}
85+
value={scale}
86+
onChange={onScaleChange}
87+
/>
88+
<div className={styles.valueDisplay}>{scale.toFixed(1)}x</div>
89+
</div>
90+
<div className={styles.controlGroup}>
91+
<span className={styles.label}>透明度</span>
92+
<Slider
93+
className={styles.slider}
94+
min={0}
95+
max={1}
96+
step={0.05}
97+
value={opacity}
98+
onChange={onOpacityChange}
99+
/>
100+
<div className={styles.valueDisplay}>{Math.round(opacity * 100)}%</div>
101+
</div>
102+
</>
103+
)}
104+
</div>
105+
);
106+
};
107+
108+
export default RobotConfigPanel;

0 commit comments

Comments
 (0)