功能概览:
- 单独通过threejs绘制的视角立方体,一共具有22个面。
- 视角立方体可以和三维空间里的任意一个模型进行绑定,也可以不和任意模型进行绑定。
- 视角立方体会和三维空间的相机进行同步,不管是转动视角立方体或者转动三维空间都会引起另一个旋转位置的变化。
- 点击视角立方体的任意一个面,视角立方体都会平滑的旋转过渡到另一个位置,使其被点的面正向朝向屏幕,三维空间也会进行平滑的旋转过渡。
- 提供与此功能相对应的一些api,比如销毁视角立方体,比如移动视角立方体在屏幕的二维空间的位置。
核心逻辑
立方体绘制
在3d建模软件maya绘制22面的立方体,并且导出obj文件,这个文件包含了点、面等信息
js
const cubePoint = {
vertices: [
-0.33333, -0.5, 0.33333, -0.33333, -0.33333, 0.5, -0.5, -0.33333, 0.33333, 0.5, -0.33333, 0.33333, 0.33333,
-0.33333, 0.5, 0.33333, -0.5, 0.33333, -0.5, 0.33333, 0.33333, -0.33333, 0.33333, 0.5, -0.33333, 0.5, 0.33333,
0.33333, 0.5, 0.33333, 0.33333, 0.33333, 0.5, 0.5, 0.33333, 0.33333, -0.5, 0.33333, -0.33333, -0.33333, 0.5,
-0.33333, -0.33333, 0.33333, -0.5, 0.33333, 0.33333, -0.5, 0.33333, 0.5, -0.33333, 0.5, 0.33333, -0.33333, -0.5,
-0.33333, -0.33333, -0.33333, -0.33333, -0.5, -0.33333, -0.5, -0.33333, 0.33333, -0.5, -0.33333, 0.33333, -0.33333,
-0.5, 0.5, -0.33333, -0.33333,
],
faces: [
[1, 3, 19, 21],
[2, 1, 6, 5],
[3, 2, 8, 7],
[4, 6, 22, 24],
[5, 4, 12, 11],
[7, 9, 14, 13],
[9, 8, 11, 10],
[10, 12, 18, 17],
[13, 15, 20, 19],
[15, 14, 17, 16],
[16, 18, 24, 23],
[21, 20, 23, 22],
[2, 5, 11, 8],
[9, 10, 17, 14],
[15, 16, 23, 20],
[21, 22, 6, 1],
[4, 24, 18, 12],
[19, 3, 7, 13],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10, 11, 12],
[13, 14, 15],
[16, 17, 18],
[19, 20, 21],
[22, 23, 24],
],
};
通过threejs根据上述信息绘制立方体
js
const { faces, vertices } = cubePoint;
faces.forEach((f, index) => {
if (f.length > 3) {
const vs: Array<THREE.Vector3> = [];
f.forEach((v) => {
const i = (v - 1) * 3;
const vertex = new THREE.Vector3(vertices[i], vertices[i + 1], vertices[i + 2]);
vs.push(vertex);
});
const meshGeometry = new ConvexGeometry(vs);
const meshMaterial = new THREE.MeshLambertMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
side: THREE.FrontSide,
});
const mesh = new THREE.Mesh(meshGeometry, meshMaterial);
group0.add(mesh);
mesh.name = index + 'group0';
} else {
const geometry = new THREE.BufferGeometry();
const vs: Array<number> = [];
f.forEach((v) => {
const i = (v - 1) * 3;
vs.push(vertices[i], vertices[i + 1], vertices[i + 2]);
});
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vs), 3));
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.7 });
const mesh = new THREE.Mesh(geometry, material);
group0.add(mesh);
mesh.name = index + 'group0';
}
});
给立方体的六个大面绘制贴图
js
for (let index = 0; index < 6; index++) {
const geometry = new THREE.PlaneGeometry(0.67, 0.67);
const material = new THREE.MeshBasicMaterial({
map: getTexture(index),
side: THREE.FrontSide,
opacity: 0.5,
transparent: true,
});
const mesh = new THREE.Mesh(geometry, material);
const plane = textConfig[index].plane;
mesh.translateOnAxis(plane.normal, plane.constant);
const quaternion = new THREE.Quaternion();
quaternion.setFromUnitVectors(new Vector3(0, 0, 1), plane.normal);
mesh.applyQuaternion(quaternion);
mesh.name = index + 'group1';
group1.add(mesh);
}
js
const textConfig: { [key: string]: { text: string; view: string; plane: Plane } } = {
0: { text: '前', view: 'homeView', plane: new Plane(new Vector3(0, 0, 1), len) },
1: { text: '后', view: 'backView', plane: new Plane(new Vector3(0, 0, -1), len) },
2: { text: '左', view: 'leftView', plane: new Plane(new Vector3(-1, 0, 0), len) },
3: { text: '右', view: 'rightView', plane: new Plane(new Vector3(1, 0, 0), len) },
4: { text: '下', view: 'bottomView', plane: new Plane(new Vector3(0, -1, 0), len) },
5: { text: '上', view: 'topView', plane: new Plane(new Vector3(0, 1, 0), len) },
};
const getTexture = (index: number) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const width = 40;
const height = 40;
canvas.width = width;
canvas.height = height;
const scale = 2;
ctx.scale(scale, scale);
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, 40 / scale, 40 / scale);
ctx.fillStyle = '#0F0F0F';
ctx.textAlign = 'center';
ctx.translate(0, (40 - 40 / scale) / (2 * scale) - scale);
ctx.fillText(textConfig[index].text, 20 / 2, 20 / 2);
const url = canvas.toDataURL('image/png');
const texture = new THREE.TextureLoader().load(url);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
return texture;
};
三轴箭头绘制
js
const createAxesGroup = () => {
const axesGroup = new Group();
const axesY = new Group();
const axesX = new Group();
const axesZ = new Group();
// 坐标轴:圆柱体和圆锥组成一条轴
const cylinder = new CylinderGeometry(0.015, 0.015, 0.7, 16);
const arrow = new CylinderGeometry(0, 0.035, 0.1);
const sphere = new SphereGeometry(0.03);
const d = 0.4;
// 材质:RGB对应XYZ
const red = new MeshStandardMaterial({ color: 0xff0000, transparent: true, opacity: 0.6 });
const green = new MeshStandardMaterial({ color: 0x00ff00, transparent: true, opacity: 0.6 });
const blue = new MeshStandardMaterial({ color: 0x0000ff, transparent: true, opacity: 0.6 });
const gold = new MeshStandardMaterial({ color: 'gold', transparent: true, opacity: 0.6 });
const origin = new Mesh(sphere, gold);
origin.position.set(-d, -d, -d);
const y_line = new Mesh(cylinder, green);
const y_arrow = new Mesh(arrow, green);
y_arrow.position.y += 0.7;
y_line.position.y += 0.35;
axesY.position.set(-d, -d, -d);
axesY.add(y_arrow, y_line);
const x_line = new Mesh(cylinder, red);
const x_arrow = new Mesh(arrow, red);
x_arrow.position.y += 0.7;
x_line.position.y += 0.35;
axesX.add(x_line, x_arrow);
axesX.position.set(-d, -d, -d);
axesX.rotation.z = -Math.PI / 2;
const z_line = new Mesh(cylinder, blue);
const z_arrow = new Mesh(arrow, blue);
z_arrow.position.y += 0.7;
z_line.position.y += 0.35;
axesZ.add(z_line, z_arrow);
axesZ.position.set(-d, -d, -d);
axesZ.rotation.x = Math.PI / 2;
axesGroup.add(axesY, axesX, axesZ, origin);
return {
axesGroup,
};
};
事件处理
点击立方体任意一个面的时候触发的事件
js
self.current.pointerClick = (e: MouseEvent) => {
if (self.current.mouseIsMove) return;
const mouse = new THREE.Vector2();
mouse.x = ((e.clientX - self.current.lefts) / width) * 2 - 1;
mouse.y = -((e.clientY - self.current.tops) / height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects && intersects[0]) {
const name = intersects[0]?.object?.name;
let viewType = '';
if (name.includes('group1')) {
viewType = textConfig[name.split('group1')[0]].view;
} else if (name.includes('group0')) {
viewType = 'ISO' + name.split('group0')[0];
}
if (viewType && viewTypes[viewType]) {
const { heading, pitch } = viewTypes[viewType];
const x = self.current.rotateX;
const y = self.current.rotateY;
const startQuaternion = new THREE.Quaternion().setFromEuler(
new THREE.Euler(cesiumMath.toRadians(-x), cesiumMath.toRadians(y), 0, 'XYZ')
);
const endQuaternion = new THREE.Quaternion().setFromEuler(
new THREE.Euler(cesiumMath.toRadians(-pitch), cesiumMath.toRadians(heading), 0, 'XYZ')
);
const startCameraQuaternion = Quaternion.fromHeadingPitchRoll(
new HeadingPitchRoll(viewer.camera.heading, viewer.camera.pitch, viewer.camera.roll)
);
const endHeadingPitchRoll = new HeadingPitchRoll(
cesiumMath.toRadians(heading),
cesiumMath.toRadians(pitch == -87 ? -88.5 : pitch),
0
);
const endCameraQuaternion = Quaternion.fromHeadingPitchRoll(endHeadingPitchRoll);
self.current.temporaryCameraCenterPosition = self.current.isCameraCenterPosition
? self.current.cameraCenterPosition
: getCameraCenterPosition();
moveAnimation({
moveFn: ({ currentValues }) => {
const quaternion = new THREE.Quaternion().slerpQuaternions(
startQuaternion,
endQuaternion,
currentValues[0]
);
const headingPitchRoll = HeadingPitchRoll.fromQuaternion(
Quaternion.slerp(startCameraQuaternion, endCameraQuaternion, currentValues[0], new Quaternion())
);
self.current.group0!.setRotationFromQuaternion(quaternion);
self.current.group1!.setRotationFromQuaternion(quaternion);
viewer.camera.lookAt(
self.current.temporaryCameraCenterPosition!,
new HeadingPitchRange(headingPitchRoll.heading, headingPitchRoll.pitch, self.current.scalar)
);
},
animationTime: 0.5,
moveKey: 'cubeAnimation',
easingType: 'easeInOutQuad',
changeValues: [1],
callback: () => {
self.current.rotateY = heading;
self.current.rotateX = pitch;
viewer.camera.lookAt(
self.current.temporaryCameraCenterPosition!,
new HeadingPitchRange(endHeadingPitchRoll.heading, endHeadingPitchRoll.pitch, self.current.scalar)
);
self.current.group0!.setRotationFromQuaternion(endQuaternion);
self.current.group1!.setRotationFromQuaternion(endQuaternion);
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
},
});
}
}
};
点击后能自然平滑的旋转到指定角度的核心方法是调用了Quaternion.slerp
进行了四元数插值算法,计算了开始的旋转姿态startQuaternion
、startCameraQuaternion
,结束的旋转姿态endQuaternion
、endCameraQuaternion
,再通过自己封装的moveAnimation
动画函数,一边设置立方体的姿态一边设置相机的姿态。
硬编码的每一个面的欧拉角的姿态表示
js
const viewTypes: { [key: string]: { heading: number; pitch: number } } = {
// 主视图
homeView: {
heading: 0,
pitch: 0,
},
// 俯视图
topView: {
heading: 0,
pitch: -87,
},
// 左视图
leftView: {
heading: 90,
pitch: 0,
},
// 右视图
rightView: {
heading: 270,
pitch: 0,
},
// 仰视图
bottomView: {
heading: 0,
pitch: 87,
},
// 后视图
backView: {
heading: 180,
pitch: 0,
},
// 上侧三角iso视图
ISO21: {
heading: 315,
pitch: -45,
},
ISO20: {
heading: 45,
pitch: -45,
},
ISO22: {
heading: 135,
pitch: -45,
},
ISO23: {
heading: 225,
pitch: -45,
},
// 下侧三角iso视图
ISO19: {
heading: 315,
pitch: 45,
},
ISO18: {
heading: 45,
pitch: 45,
},
ISO24: {
heading: 135,
pitch: 45,
},
ISO25: {
heading: 225,
pitch: 45,
},
// 上侧横条iso视图
ISO6: {
heading: 0,
pitch: -45,
},
ISO5: {
heading: 90,
pitch: -45,
},
ISO9: {
heading: 180,
pitch: -45,
},
ISO7: {
heading: 270,
pitch: -45,
},
// 中侧横条iso视图
ISO4: {
heading: 315,
pitch: 0,
},
ISO2: {
heading: 45,
pitch: 0,
},
ISO8: {
heading: 135,
pitch: 0,
},
ISO10: {
heading: 225,
pitch: 0,
},
// 下侧横条iso视图
ISO1: {
heading: 0,
pitch: 45,
},
ISO0: {
heading: 90,
pitch: 45,
},
ISO11: {
heading: 180,
pitch: 45,
},
ISO3: {
heading: 270,
pitch: 45,
},
};
移动立方体触发的事件
js
self.current.pointermove = (e: MouseEvent) => {
const mouse = new THREE.Vector2();
mouse.x = ((e.clientX - self.current.lefts) / width) * 2 - 1;
mouse.y = -((e.clientY - self.current.tops) / height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects && intersects[0]) {
const name = intersects[0]?.object?.name;
let meshNames = '';
if (name.includes('group1')) {
meshNames = group1ToGroup0[name.split('group1')[0]] + '';
} else if (name.includes('group0')) {
meshNames = name.split('group0')[0];
}
if (meshNames) {
if (!changeMesh[meshNames]) {
const mesh = group0.getObjectByName(meshNames + 'group0') as THREE.Mesh;
if (mesh) {
(mesh.material as THREE.MeshLambertMaterial).color = new THREE.Color(0x4d79ff);
changeMesh[meshNames] = true;
}
}
forEach(changeMesh, (value, meshName) => {
if (value && meshName != meshNames) {
const mesh = group0.getObjectByName(meshName + 'group0') as THREE.Mesh;
if (mesh) {
(mesh.material as THREE.MeshLambertMaterial).color = new THREE.Color(0xffffff);
changeMesh[meshName] = false;
}
}
});
}
} else {
forEach(changeMesh, (value, meshName) => {
if (value) {
const mesh = group0.getObjectByName(meshName + 'group0') as THREE.Mesh;
if (mesh) {
(mesh.material as THREE.MeshLambertMaterial).color = new THREE.Color(0xffffff);
changeMesh[meshName] = false;
}
}
});
}
};