读取显示stl和点云文件,鼠标拖动 任意角度旋转
背景:可能由于之前在做 读取显示stl
文件和点云文件的项目时候,在各大博客网站留下了些许的踪迹。最近这一两天打开csdn
的消息栏,突然发现有私信我。询问我读取stl
文件的相关东西,瞬间我就觉的,这个我会呀,我可以去帮助别人阿。于是就有了这篇博客,希望可以帮助到更多的人。
今天要讲所有内容所涉及到的代码,全部来自我之前与学长做的一个项目,是一个dcm
文件处理的项目,专业一点的全名叫作 乳腺癌放疗补偿物自动生成软件。我负责的是读取生成的点云和stl
文件,将其显示出来,使鼠标拖动可任意方向、任意角度旋转,滑动滚轮可放大缩小,并将它打包成一个可以发布的软件。所以我今天要分享的内容都围绕我负责的这一部分,同时我会将,除解析dcm
文件原码外的所有原码全部分享出来。(其实也就屏蔽了下面“打开读取dcm
文件” 按钮的功能而已)让大家只要配置好环境就可以运行。
运行环境:VS2017
+ Qt5.14.0
项目地址:
先来看一下实现效果
显示stl文件
显示点云
1、读取点云
讲道理
读取点云相对来说,是比较容易的,因为点云数据很规则。如下图,都是一个数值挨着一个数值,连续的三个数值表示一个点。所以你只需要将它一 一读取,三个为一组放入一个表示点的数据结构中,就OK了。这比较容易,不是我们重点要去讨论的问题。我们重点要去讨论的问题是 “如何将所有的数值都转换到我们想要的范围之内?”
那么,为什么要进行这样的转换呢?那是因为我们所要读取的点云数据的值没有一个约定俗成的数值范围,不同的点云文件,其中数值的范围是不一样的,要么值很大,大到上千 上万,要么值很小,小到小数点后 7位 8位。如果没有一个约定好的数值显示范围,那么在opengl
环境中显示出来的物体,要么很小,要么很大,这不是我们想要的。我们想要的是,无论点云数据的数值有多大,有多小,在我们运行软件时,都能在视口的合适位置,看到合适大小的物体。因此我们要去做一个 从任意范围数值到 我们约定的一个标准范围 的映射,除此之外,还要将摄像机放置在合适的位置,这个后面再说。我们先来讨论范围的问题。默认情况下opengl
环境的可视范围是 -1.0 - 1.0,
所以我们只要把所有的数值都映射到这个范围来就可以了。根据实际情况,你也可以将你所有的点都映射到 -0.5 - 0.5
、-0.6 - 0.6
等这些范围内。(在我的项目中,我将所有的点都映射到-0.5 - 0.5
的范围内)。但是 注意了,注意了,在进行映射之前,我们还要干一件非常重要的事情。(下一段啦)
回看一下咱们上面展示的旋转效果,是不是以物体的中心点为旋转中心进行旋转的。要实现这样的效果我们就必须让物体的中心点 与 opengl
环境的旋转中心重合。opengl
环境中的旋转中心,我们可以自由定义。但是在大多数情况下,为了让旋转算法更简单,更容易实现,我们一般取旋转中心为世界空间的坐标原点。咱们读入的点云数据都在物体的局部空间内,且当只有一个物体对象的时候,默认物体对象的世界坐标为世界空间的坐标原点,也就是说物体对象的局部空间的坐标原点和世界空间的坐标原点是重合的。因此,我们只要将所有读取的数据,做一个 从读入物体数据的中心点 到 物体局部空间的坐标原点( 0,0,0 点)的映射即可。
再来说一下上面提到的摄像机位置的问题。首先它肯定在世界空间坐标轴的z轴上,指向z轴负半轴,因为经过上面的讨论,我们肯定会让物体的局部空间坐标原点,跟世界空间的坐标原点重合。剩下的只需要把它放在z轴正半轴合适的位置就可,什么位置最合适没有一个标准的答案,推荐取物体z轴坐标可取到的最大值的6倍。假如我们将所有的点都映射到了-0.5-0.5的范围之内,那么就取 6*0.5 为摄像机的z轴位置,摄像机最后的位置坐标为(0,0,3)。
说了这么多,想毕你已经迫不及待的要看到代码是怎么实现了吧,那咱们就上代码吧~
上代码
/*PointCloud.h*/
class PointCloud
{
public:
PointCloud();
~PointCloud();
QVector3D maxCoordinate;// 由所有点的 x轴 y轴 z轴 数据的最大值,组成的点
QVector3D minCoordinate;// 由所有点的 x轴 y轴 z轴 数据的最小值,组成的点
vector<QVector3D> pointData;// 点云数据
float getFactor();
public:
bool read(const char* path);//从文件中读取点云数据,并对数据映射到合适的范围之内
void handlePointDate(vector<QVector3D> data);// 将数据映射到合适的范围之内
void getMaxCoordinate();// 计算出 maxCoordinate
void getMinCoordinate();// 计算出 minCoordinate
void getCenterPoint(QVector3D &vec); // 获取中心点
};
/*PointCloud.cpp*/
//从文件中读取点云数据,并对数据映射到合适的范围之内
bool PointCloud::read(const char* path)
{
// 读取数据 存储到 pointData中
fstream readTexData(path);
if (!readTexData)
{
return false;
}
QVector3D data;
float x, y, z;
while (readTexData >> x >> y >> z)
{
data.setX(x);
data.setY(y);
data.setZ(z);
pointData.push_back(data);
}
readTexData.close();
// end 读取数据 存储到 pointData中
// 设置 最大 最小值点,为获取 数据的中点做准备
getMaxCoordinate();
getMinCoordinate();
// 获取中心点 并 完成 所有点的中心点 到 0,0,0 点的映射
QVector3D centerPoint;
getCenterPoint( centerPoint );
for (int i = 0; i < pointData.size(); i++)
{
pointData[i].setX( pointData[i].x() - centerPoint.x() );
pointData[i].setY( pointData[i].y() - centerPoint.y() );
pointData[i].setZ( pointData[i].z() - centerPoint.z() );
}
// end 获取中心点 并 完成 所有点的中心点 到 0,0,0 点的映射
//重置 最大 最小值点坐标
getMaxCoordinate();
getMinCoordinate();
// 将所有点的坐标值 都映射到 -0.5 - 0.5 的范围内
float factor = getFactor();//获取变换因子
for (int i = 0; i < pointData.size(); i++)
{
pointData[i].setX(pointData[i].x() * factor);
pointData[i].setY(pointData[i].y() * factor);
pointData[i].setZ(pointData[i].z() * factor);
}
// 将所有点的坐标值 都映射到 -0.5 - 0.5 的范围内
// 重置 最大 最小值点坐标
getMaxCoordinate();
getMinCoordinate();
return true;
}
// 获取中心点
void PointCloud::getCenterPoint(QVector3D &vec)
{
if (0 == pointData.size())
{
return;
}
//取重心
/*float xSum = 0;
float ySum = 0;
float zSum = 0;
for (int i = 0; i < pointData.size(); i++)
{
xSum += pointData[i].x();
ySum += pointData[i].y();
zSum += pointData[i].z();
}
vec.setX(xSum / pointData.size());
vec.setY(ySum / pointData.size());
vec.setZ(zSum / pointData.size());*/
//取中心, 因为人的眼睛 习惯上 会把物体的中心当做中心点,而不是 重心,取重心的话,旋转效果可能会很怪异
vec.setX((maxCoordinate.x() + minCoordinate.x()) / 2);
vec.setY((maxCoordinate.y() + minCoordinate.y()) / 2);
vec.setZ((maxCoordinate.z() + minCoordinate.z()) / 2);
}
float PointCloud::getFactor()
{
// 由于在使用这个函数之前,已经将所有点映射到了 中心点 为 (0,0,0) 的位置
// 这里的 maxCoordinate.x()的绝对值 是等于 minCoordinate() 的绝对值的,
// 所以这里我们只考虑,maxCoordinate的坐标 就可以了
float max = 0;
if (max <= maxCoordinate.x())
{
max = maxCoordinate.x();
}
if (max <= maxCoordinate.y())
{
max = maxCoordinate.y();
}
if (max <= maxCoordinate.z())
{
max = maxCoordinate.z();
}
return 0.5 / max; // 根据实际情况 可改为 1.0\0.6\0.7 等
}
// 计算出 maxCoordinate
void PointCloud::getMaxCoordinate()
{
if (0 == pointData.size())
{
return;
}
QVector3D vec;
vec.setX(pointData[0].x());
vec.setY(pointData[0].y());
vec.setZ(pointData[0].z());
for (int i = 0; i < pointData.size(); i++)
{
if (vec.x() < pointData[i].x())
{
vec.setX(pointData[i].x());
}
if (vec.y() < pointData[i].y())
{
vec.setY(pointData[i].y());
}
if (vec.z() < pointData[i].z())
{
vec.setZ(pointData[i].z());
}
}
maxCoordinate = vec;
}
// 计算出 minCoordinate
void PointCloud::getMinCoordinate()
{
if (0 == pointData.size())
{
return;
}
QVector3D vec;
vec.setX(pointData[0].x());
vec.setY(pointData[0].y());
vec.setZ(pointData[0].z());
for (int i = 0; i < pointData.size(); i++)
{
if (vec.x() > pointData[i].x())
{
vec.setX(pointData[i].x());
}
if (vec.y() > pointData[i].y())
{
vec.setY(pointData[i].y());
}
if (vec.z() > pointData[i].z())
{
vec.setZ(pointData[i].z());
}
}
minCoordinate = vec;
}
你可能发现了,在上面 获取中心点的函数 getCenterPoint()
中,我注释掉了获取 重心点的 代码。那为什么要去取中心为旋转中心,而不取重心呢?那是因为,我们人,总是习惯性的想象或认为,旋转的三维物体是绕着它的几何中心旋转的,或者说这样的旋转效果,不需要我们大脑费太大劲,就可以在脑中构想出来。所以我们取中心为旋转中心,进行旋转的话,会让人看着更自然,更舒服。如果是以重心为旋转中心的话,旋转效果有时候会变的非常怪异。比如,你读入了一个 马儿 的点云数据,这个马的马头部分存在这个大量密集的点,假设占 所有点的80% 以上。在这样的情况下,如果你以重心为旋转中心的话,旋转的时候,就会绕着马头旋转,这样的效果你会看的这舒服吗?因此、我们取 中心而不取重心。
2、读取stl
简单来说stl
文件是一种以记录三角片面信息来描述三维模型的文件,每个三角片面都由三个点和一个法向量组成,通常作为CAD
以及3D
打印的常用交换格式。常见的stl
文件主要有二进制和ASCLL两种格式。
我对ascll的格式的文件读取不是很了解,也没有实际在项目使用过。为了避免误导大家,所以下面我只对二进制stl的格式和实现代码做了讲述。对于ascll格式的stl
,我给大家找了两篇相关的博客供大家参考:
https://blog.csdn.net/just0kk/article/details/53967846
https://blog.csdn.net/qq_27133869/article/details/105310737
文件格式
二进制stl
文件用固定的字节数来给出三角面片的几何信息。如下:
【80】80个字节的文件头,用于存贮文件名
【4】 4 个字节的整数,来描述模型的三角面片数量(小端存储)
//一个三角面片占用固定的50个字节(小端存储),依次是:
【12】3个4字节浮点数(法向量) // 要正确的显示出stl文件,这个法向量是必不可少的
【12】3个4字节浮点数(1个顶点的坐标)
【12】3个4字节浮点数(2个顶点的坐标)
【12】3个4字节浮点数(3个顶点的坐标)个
【2】三角面片的最后2个字节用来描述三角面片的属性信息。
所以一个完整二进制stl
文件的大小为三角形面片数乘以 50再加上84个字节。
读取思路
我们的思路是这样的:
- 1、先将文件中的所有字节内容全读出来,存入一个缓存中;
- 2、对缓存中的字节数据进行处理:直接跳到第81个字节,读取三角片面的个数,迭代读取每个 三角片面的法向量 和 三顶点的坐标,分别存储到 法向量的容器 和 顶点坐标容器中。
- 由于我项目的开发环境是
QT + vs
,所以存储三维坐标点,使用的是QVector3D
。如果你不是QT的环境,可以自己写一个存储点的数据结构;
- 由于我项目的开发环境是
- 3、和点云数据一样,完成所有数据数值 到 标准范围的映射。(在上一部分已经做了详细的讲述,这里就不在赘述了);
- 4、渲染;
代码实现
bool ReadSTLFile::ReadFile(const char *cfilename)
{
FILE * pFile;
long lSize;
char* buffer;
size_t result;
/* 若要一个byte不漏地读入整个文件,只能采用二进制方式打开 */
fopen_s(&pFile, cfilename, "rb");
if (pFile == NULL)
{
return false;
}
/* 获取文件大小 */
fseek(pFile, 0, SEEK_END);
lSize = ftell(pFile);
rewind(pFile);
/* 分配内存存储整个文件 */
buffer = (char*)malloc(sizeof(char)*lSize);
if (buffer == NULL)
{
fputs("Memory error", stderr);
exit(2);
}
/* 将文件拷贝到buffer中 */
result = fread(buffer, 1, lSize, pFile);
if (result != lSize)
{
fputs("Reading error", stderr);
exit(3);
}
/* 结束演示,关闭文件并释放内存 */
fclose(pFile);
ios::sync_with_stdio(false);
ReadBinary(buffer);
ios::sync_with_stdio(true);
free(buffer);
return true;
}
bool ReadSTLFile::ReadBinary(const char *buffer)
{
const char* p = buffer;
char name[80];
int i, j;
memcpy(name, p, 80); // 读取80个字节的文件名
p += 80;
unTriangles = cpyint(p); // 读取4字节 表示的三角片面的数量
const char *tempP;
// 循环读取所有三角片面的 法向量信息 和 顶点信息
// QVector3D 是 qt 中用来存储三维坐标的一个 数据结构
for (i = 0; i < unTriangles; i++)
{
//读取三角片面的向量信息
vectorList.push_back(QVector3D(cpyfloat(p), cpyfloat(p), cpyfloat(p)));
for (j = 0; j < 3; j++)//读取三顶点
{
pointList.push_back(QVector3D(cpyfloat(p), cpyfloat(p), cpyfloat(p)));
}
p += 2;//跳过尾部标志
}
return true;
}
// 读取四个字节的数据,转为一个int类型的整数
int ReadSTLFile::cpyint(const char*& p)
{
int cpy;
char byte[4];
for (int i = 0; i < 4; i++)
{
byte[i] = *p;
p++;
}
cpy = *(int*)byte;
return cpy;
}
// 读取四个字节的数据,转为一个float类型的浮点型数据
float ReadSTLFile::cpyfloat(const char*& p)
{
float cpy;
char byte[4];
for (int i = 0; i < 4; i++)
{
byte[i] = *p;
p++;
}
cpy = *(float*)byte;
return cpy;
}
注意点
1、在使用渲染方法时
- 要使用
GL_TRIANGLES
渲染方式,且第三个参数传递的是 所有点的数量(包括法向量),而不是三角片面的数量;
glDrawArrays(GL_TRIANGLES, 0, vertices.size());
2、打光要 打全局光。因为在opengl的环境中,你不进行设置,它就没有漫反射。当你只在某个方向设置光源时,物体表面照射不到光的地方,就没有阴影(不存在黑白灰),你就看不到物体的棱角。也就是说,你要保证物体的每个角落,都有光可以照射到。
我采用的是 前后 都打平行光的方式。
- 当我只 设置 一个方向的平行光时,效果是这样的:
3、实现旋转
实现旋转的算法有好几种,有欧拉旋转,有四元数旋转。欧拉旋转存在 万向死锁的问题,四元数虽然它不会导致万向死锁,但是它比较复杂,不好理解(我个人是这么认为的)。那么,有没有一种即不会导致万向死锁,实现起来也比较简单的旋转算法呢?答案是肯定的,下面听我给你娓娓道来。
万向死锁不懂,看这里:https://www.bilibili.com/video/BV1Cx411C7ED?p=13
首先明确一下我们要实现的效果:按下鼠标之后,任意拖动鼠标,鼠标向那个方向拖动,物体就向那个方向转动,拖动的距离越长,物体旋转的角度就越大。
在OpenGL中,存在三种基本的变换:平移变换、缩放变换、旋转变换,它们分别对应,平移矩阵、缩放矩阵、和旋转矩阵。当然我们这里重点要讨论的是旋转矩阵。使用旋转矩阵就可以对物体进行旋转操作了。这个旋转矩阵计算起来相当复杂,牵扯到很多线性代数的知识,不过不用担心,OpenGL已经帮我们封装好了一个很好用的计算旋转矩阵的函数rotate()
,我们只需要提供旋转轴向量,和旋转角度,它就可以帮我们计算出旋转矩阵了。
对于旋转轴:旋转轴就是过旋转中心,垂直我们拖动鼠标方向的一条直线。通过我们小学二年级学过的平面几何知识 ,很容易就可以计算出来。如下图:
对于旋转角度:根据我们想要实现的效果,旋转角度和鼠标拖动的距离之间是存在线性关系的。只要获取鼠标的拖动距离,再通过一个合适的映射,就可以得到我们想要的旋转角度了。
剩下的直接看代码吧
// model: 每次渲染时 使用的变换矩阵
// modelUse: 存储上一次释放鼠标时 的model值
// modelSave: 实时跟model 保持同步,用于在鼠标按下时,更新modelUse
void MyGLWidget::mouseMoveEvent(QMouseEvent * event)// 获取鼠标移动
{
QPoint p_ab = event->pos();
translate_point(p_ab); // 坐标系转换
QPoint sub_point = p_ab - press_position;
if (event->buttons() & Qt::LeftButton)
{
model.setToIdentity();// 转化成单位矩阵
GLfloat angle_now = qSqrt(qPow(sub_point.x(), 2) + qPow(sub_point.y(), 2)) / 5;
model.rotate(angle_now, -sub_point.y(), sub_point.x(), 0.0);
model = model * modelUse;
// 对旋转进行叠加
modelSave.setToIdentity();// 转化成单位矩阵,
modelSave.rotate(angle_now, -sub_point.y(), sub_point.x(), 0.0);
modelSave = modelSave * modelUse;
}
}
void MyGLWidget::mousePressEvent(QMouseEvent * event)
{
//单击
QPoint p_ab = event->pos();
// 如果是鼠标左键按下
if (event->button() == Qt::LeftButton)
{
modelUse = modelSave;
setPressPosition(p_ab);
}
}
void MyGLWidget::setPressPosition(QPoint p_ab)
{
translate_point(p_ab);
press_position = p_ab;
}
void MyGLWidget::translate_point(QPoint &p_ab)
{
// this->width() 表示视口的宽度,由于视口宽高一样,
// 所以点(this->width(), this->withd() )就是视口的中心点,即旋转中心。
int x = p_ab.x() - this->width() / 2;
int y = -(p_ab.y() - this->width() / 2);
p_ab.setX(x);
p_ab.setY(y);
}
- 对于上面 stranslate_point() 函数的作用,我简单说明一下:因为qt的鼠标事件获取的点所在的坐标轴,原点在左上角,而且y轴方向朝下,如下:
而我们计算旋转轴时所用的坐标轴,原点在旋转中心,也就是视口中心,y轴方向朝上,如下:
所以就要对qt事件获取的坐标,进行变换,让其存在于原点为视口中心,y轴方向朝上的坐标系中。