Python实现3D建模工具(下)
用户接口
我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,GLUT
允许我们在键盘或鼠标事件上注册对应的回调函数。
新建interaction.py
文件,用户接口在Interaction
类中实现。
导入需要的库
- from collections import defaultdict
- from OpenGL.GLUT import glutGet, glutKeyboardFunc, glutMotionFunc, glutMouseFunc, glutPassiveMotionFunc, \
- glutPostRedisplay, glutSpecialFunc
- from OpenGL.GLUT import GLUT_LEFT_BUTTON, GLUT_RIGHT_BUTTON, GLUT_MIDDLE_BUTTON, \
- GLUT_WINDOW_HEIGHT, GLUT_WINDOW_WIDTH, \
- GLUT_DOWN, GLUT_KEY_UP, GLUT_KEY_DOWN, GLUT_KEY_LEFT, GLUT_KEY_RIGHT
- import trackball
初始化Interaction
类,注册glut
的事件回调函数。
- class Interaction(object):
- def __init__(self):
- """ 处理用户接口 """
- #被按下的键
- self.pressed = None
- #轨迹球,会在之后进行说明
- self.trackball = trackball.Trackball(theta = -25, distance= 15)
- #当前鼠标位置
- self.mouse_loc = None
- #回调函数词典
- self.callbacks = defaultdict(list)
- self.register()
- def register(self):
- """ 注册glut的事件回调函数 """
- glutMouseFunc(self.handle_mouse_button)
- glutMotionFunc(self.handle_mouse_move)
- glutKeyboardFunc(self.handle_keystroke)
- glutSpecialFunc(self.handle_keystroke)
回调函数的实现:
- def handle_mouse_button(self, button, mode, x, y):
- """ 当鼠标按键被点击或者释放的时候调用 """
- xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
- y = ySize - y # OpenGL原点在窗口左下角,窗口原点在左上角,所以需要这种转换。
- self.mouse_loc = (x, y)
- if mode == GLUT_DOWN:
- #鼠标按键按下的时候
- self.pressed = button
- if button == GLUT_RIGHT_BUTTON:
- pass
- elif button == GLUT_LEFT_BUTTON:
- self.trigger( 'pick', x, y)
- else: # 鼠标按键被释放的时候
- self.pressed = None
- #标记当前窗口需要重新绘制
- glutPostRedisplay()
- def handle_mouse_move(self, x, screen_y):
- """ 鼠标移动时调用 """
- xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
- y = ySize - screen_y
- if self.pressed is not None:
- dx = x - self.mouse_loc[ 0]
- dy = y - self.mouse_loc[ 1]
- if self.pressed == GLUT_RIGHT_BUTTON and self.trackball is not None:
- # 变化场景的角度
- self.trackball.drag_to(self.mouse_loc[ 0], self.mouse_loc[ 1], dx, dy)
- elif self.pressed == GLUT_LEFT_BUTTON:
- self.trigger( 'move', x, y)
- elif self.pressed == GLUT_MIDDLE_BUTTON:
- self.translate(dx/ 60.0, dy/ 60.0, 0)
- else:
- pass
- glutPostRedisplay()
- self.mouse_loc = (x, y)
- def handle_keystroke(self, key, x, screen_y):
- """ 键盘输入时调用 """
- xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
- y = ySize - screen_y
- if key == 's':
- self.trigger( 'place', 'sphere', x, y)
- elif key == 'c':
- self.trigger( 'place', 'cube', x, y)
- elif key == GLUT_KEY_UP:
- self.trigger( 'scale', up= True)
- elif key == GLUT_KEY_DOWN:
- self.trigger( 'scale', up= False)
- elif key == GLUT_KEY_LEFT:
- self.trigger( 'rotate_color', forward= True)
- elif key == GLUT_KEY_RIGHT:
- self.trigger( 'rotate_color', forward= False)
- glutPostRedisplay()
内部回调
针对用户行为会调用self.trigger
方法,它的第一个参数指明行为期望的效果,后续参数为该效果的参数,trigger
的实现如下:
- def trigger(self, name, *args, **kwargs):
- for func in self.callbacks[name]:
- func(*args, **kwargs)
从代码可以看出trigger
会取得callbacks
词典下该效果对应的所有方法逐一调用。
那么如何将方法添加进callbacks呢?我们需要实现一个注册回调函数的方法:
- def register_callback(self, name, func):
- self.callbacks[name].append(func)
还记得Viewer
中未实现的self.init_interaction()
吗,我们就是在这里注册回调函数的,下面补完init_interaction
.
- from interaction import Interaction
- ...
- class Viewer(object):
- ...
- def init_interaction(self):
- self.interaction = Interaction()
- self.interaction.register_callback( 'pick', self.pick)
- self.interaction.register_callback( 'move', self.move)
- self.interaction.register_callback( 'place', self.place)
- self.interaction.register_callback( 'rotate_color', self.rotate_color)
- self.interaction.register_callback( 'scale', self.scale)
- def pick(self, x, y):
- """ 鼠标选中一个节点 """
- pass
- def move(self, x, y):
- """ 移动当前选中的节点 """
- pass
- def place(self, shape, x, y):
- """ 在鼠标的位置上新放置一个节点 """
- pass
- def rotate_color(self, forward):
- """ 更改选中节点的颜色 """
- pass
- def scale(self, up):
- """ 改变选中节点的大小 """
- pass
pick
、move
等函数的说明如下表所示
回调函数 | 参数 | 说明 |
---|---|---|
pick
|
x:number, y:number | 鼠标选中一个节点 |
move
|
x:number, y:number | 移动当前选中的节点 |
place
|
shape:string, x:number, y:number | 在鼠标的位置上新放置一个节点 |
rotate_color
|
forward:boolean | 更改选中节点的颜色 |
scale
|
up:boolean | 改变选中节点的大小 |
我们将在之后实现这些函数。
Interaction
类抽象出了应用层级别的用户输入接口,这意味着当我们希望将glut
更换为别的工具库的时候,只要照着抽象出来的接口重新实现一遍底层工具的调用就行了,也就是说仅需改动Interaction
类内的代码,实现了模块与模块之间的低耦合。
这个简单的回调系统已满足了我们的项目所需。在真实的生产环境中,用户接口对象常常是动态生成和销毁的,所以真实生产中还需要实现解除注册的方法,我们这里就不用啦。
与场景交互
旋转场景
在这个项目中摄像机是固定的,我们主要靠移动场景来观察不同角度下的3d模型。摄像机固定在距离原点15个单位的位置,面对世界坐标系的原点。感观上是这样,但其实这种说法不准确,真实情况是在世界坐标系里摄像机是在原点的,但在摄像机坐标系中,摄像机后退了15个单位,这就等价于前者说的那种情况了。
使用轨迹球
我们使用轨迹球算法来完成场景的旋转,旋转的方法理解起来很简单,想象一个可以向任意角度围绕球心旋转的地球仪,你的视线是不变的,但是通过你的手在拨这个球,你可以想看哪里拨哪里。在我们的项目中,这个拨球的手就是鼠标右键,你点着右键拖动就能实现这个旋转场景的效果了。
想要更多的理解轨迹球可以参考OpenGL Wiki,在这个项目中,我们使用Glumpy中轨迹球的实现。
下载trackball.py
文件,并将其置于工作目录下:
$ wget http://labfile.oss.aliyuncs.com/courses/561/trackball.py
drag_to
方法实现与轨迹球的交互,它会比对之前的鼠标位置和移动后的鼠标位置来更新旋转矩阵。
self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
得到的旋转矩阵保存在viewer的trackball.matrix
中。
更新viewer.py
下的ModelView
矩阵
- class Viewer(object):
- ...
- def render(self):
- self.init_view()
- glEnable(GL_LIGHTING)
- glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
- # 将ModelView矩阵设为轨迹球的旋转矩阵
- glMatrixMode(GL_MODELVIEW)
- glPushMatrix()
- glLoadIdentity()
- glMultMatrixf(self.interaction.trackball.matrix)
- # 存储ModelView矩阵与其逆矩阵之后做坐标系转换用
- currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))
- self.modelView = numpy.transpose(currentModelView)
- self.inverseModelView = inv(numpy.transpose(currentModelView))
- self.scene.render()
- glDisable(GL_LIGHTING)
- glCallList(G_OBJ_PLANE)
- glPopMatrix()
- glFlush()
运行代码:
右键拖动查看效果:
选择场景中的对象
既然要操作场景中的对象,那么必然得先能够选中对象,要怎么才能选中呢?想象你有一只指哪打哪的激光笔,当激光与对象相交时就相当于选中了对象。
我们如何判定激光穿透了对象呢?
想要真正实现对复杂形状物体进行选择判定是非常考验算法和性能的,所以在这里我们简化问题,对对象使用包围盒(axis-aligned bounding box, 简称AABB),包围盒可以想象成一个为对象量身定做的盒子,你刚刚好能将模型放进去。这样做的好处就是对于不同形状的对象你都可以使用同一段代码处理选中判定,并能保证较好的性能。
新建aabb.py
,编写包围盒类:
- from OpenGL.GL import glCallList, glMatrixMode, glPolygonMode, glPopMatrix, glPushMatrix, glTranslated, \
- GL_FILL, GL_FRONT_AND_BACK, GL_LINE, GL_MODELVIEW
- from primitive import G_OBJ_CUBE
- import numpy
- import math
- #判断误差
- EPSILON = 0.000001
- class AABB(object):
- def __init__(self, center, size):
- self.center = numpy.array(center)
- self.size = numpy.array(size)
- def scale(self, scale):
- self.size *= scale
- def ray_hit(self, origin, direction, modelmatrix):
- """ 返回真则表示激光射中了包盒
- 参数说明: origin, distance -> 激光源点与方向
- modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 """
- aabb_min = self.center - self.size
- aabb_max = self.center + self.size
- tmin = 0.0
- tmax = 100000.0
- obb_pos_worldspace = numpy.array([modelmatrix[ 0, 3], modelmatrix[ 1, 3], modelmatrix[ 2, 3]])
- delta = (obb_pos_worldspace - origin)
- # test intersection with 2 planes perpendicular to OBB's x-axis
- xaxis = numpy.array((modelmatrix[ 0, 0], modelmatrix[ 0, 1], modelmatrix[ 0, 2]))
- e = numpy.dot(xaxis, delta)
- f = numpy.dot(direction, xaxis)
- if math.fabs(f) > 0.0 + EPSILON:
- t1 = (e + aabb_min[ 0])/f
- t2 = (e + aabb_max[ 0])/f
- if t1 > t2:
- t1, t2 = t2, t1
- if t2 < tmax:
- tmax = t2
- if t1 > tmin:
- tmin = t1
- if tmax < tmin:
- return ( False, 0)
- else:
- if (-e + aabb_min[ 0] > 0.0 + EPSILON) or (-e+aabb_max[ 0] < 0.0 - EPSILON):
- return False, 0
- yaxis = numpy.array((modelmatrix[ 1, 0], modelmatrix[ 1, 1], modelmatrix[ 1, 2]))
- e = numpy.dot(yaxis, delta)
- f = numpy.dot(direction, yaxis)
- # intersection in y
- if math.fabs(f) > 0.0 + EPSILON:
- t1 = (e + aabb_min[ 1])/f
- t2 = (e + aabb_max[ 1])/f
- if t1 > t2:
- t1, t2 = t2, t1
- if t2 < tmax:
- tmax = t2
- if t1 > tmin:
- tmin = t1
- if tmax < tmin:
- return ( False, 0)
- else:
- if (-e + aabb_min[ 1] > 0.0 + EPSILON) or (-e+aabb_max[ 1] < 0.0 - EPSILON):
- return False, 0
- # intersection in z
- zaxis = numpy.array((modelmatrix[ 2, 0], modelmatrix[ 2, 1], modelmatrix[ 2, 2]))
- e = numpy.dot(zaxis, delta)
- f = numpy.dot(direction, zaxis)
- if math.fabs(f) > 0.0 + EPSILON:
- t1 = (e + aabb_min[ 2])/f
- t2 = (e + aabb_max[ 2])/f
- if t1 > t2:
- t1, t2 = t2, t1
- if t2 < tmax:
- tmax = t2
- if t1 > tmin:
- tmin = t1
- if tmax < tmin:
- return ( False, 0)
- else:
- if (-e + aabb_min[ 2] > 0.0 + EPSILON) or (-e+aabb_max[ 2] < 0.0 - EPSILON):
- return False, 0
- return True, tmin
- def render(self):
- """ 渲染显示包围盒,可在调试的时候使用 """
- glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
- glMatrixMode(GL_MODELVIEW)
- glPushMatrix()
- glTranslated(self.center[ 0], self.center[ 1], self.center[ 2])
- glCallList(G_OBJ_CUBE)
- glPopMatrix()
- glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
更新Node
类与Scene
类,加入与选中节点有关的内容
更新Node
类:
- from aabb import AABB
- ...
- class Node(object):
- def __init__(self):
- self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
- self.aabb = AABB([ 0.0, 0.0, 0.0], [ 0.5, 0.5, 0.5])
- self.translation_matrix = numpy.identity( 4)
- self.scaling_matrix = numpy.identity( 4)
- self.selected = False
- ...
- def render(self):
- glPushMatrix()
- glMultMatrixf(numpy.transpose(self.translation_matrix))
- glMultMatrixf(self.scaling_matrix)
- cur_color = color.COLORS[self.color_index]
- glColor3f(cur_color[ 0], cur_color[ 1], cur_color[ 2])
- if self.selected: # 选中的对象会发光
- glMaterialfv(GL_FRONT, GL_EMISSION, [ 0.3, 0.3, 0.3])
- self.render_self()
- if self.selected:
- glMaterialfv(GL_FRONT, GL_EMISSION, [ 0.0, 0.0, 0.0])
- glPopMatrix()
- def select(self, select=None):
- if select is not None:
- self.selected = select
- else:
- self.selected = not self.selected
更新Scene
类:
- class Scene(object):
- def __init__(self):
- self.node_list = list()
- self.selected_node = None
在Viewer
类中实现通过鼠标位置获取激光的函数以及pick
函数
- # class Viewer
- def get_ray(self, x, y):
- """
- 返回光源和激光方向
- """
- self.init_view()
- glMatrixMode(GL_MODELVIEW)
- glLoadIdentity()
- # 得到激光的起始点
- start = numpy.array(gluUnProject(x, y, 0.001))
- end = numpy.array(gluUnProject(x, y, 0.999))
- # 得到激光的方向
- direction = end - start
- direction = direction / norm(direction)
- return (start, direction)
- def pick(self, x, y):
- """ 是否被选中以及哪一个被选中交由Scene下的pick处理 """
- start, direction = self.get_ray(x, y)
- self.scene.pick(start, direction, self.modelView)
为了确定是哪个对象被选中,我们会遍历场景下的所有对象,检查激光是否与该对象相交,取离摄像机最近的对象为选中对象。
- # Scene 下实现
- def pick(self, start, direction, mat):
- """
- 参数中的mat为当前ModelView的逆矩阵,作用是计算激光在局部(对象)坐标系中的坐标
- """
- import sys
- if self.selected_node is not None:
- self.selected_node.select( False)
- self.selected_node = None
- # 找出激光击中的最近的节点。
- mindist = sys.maxint
- closest_node = None
- for node in self.node_list:
- hit, distance = node.pick(start, direction, mat)
- if hit and distance < mindist:
- mindist, closest_node = distance, node
- # 如果找到了,选中它
- if closest_node is not None:
- closest_node.select()
- closest_node.depth = mindist
- closest_node.selected_loc = start + direction * mindist
- self.selected_node = closest_node
- # Node下的实现
- def pick(self, start, direction, mat):
- # 将modelview矩阵乘上节点的变换矩阵
- newmat = numpy.dot(
- numpy.dot(mat, self.translation_matrix),
- numpy.linalg.inv(self.scaling_matrix)
- )
- results = self.aabb.ray_hit(start, direction, newmat)
- return results
运行代码(蓝立方体被选中):
检测包围盒也有其缺点,如下图所示,我们希望能点中球背后的立方体,然而却选中了立方体前的球体,因为我们的激光射中了球体的包围盒。为了效率我们牺牲了这部分功能。在性能,代码复杂度与功能准确度之间之间进行衡量与抉择是在计算机图形学与软件工程中常常会遇见的。
操作场景中的对象
对对象的操作主要包括在场景中加入新对象, 移动对象、改变对象的颜色与改变对象的大小。因为这部分的实现较为简单,所以仅实现加入新对象与移动对象的操作.
加入新对象的代码如下:
- # Viewer下的实现
- def place(self, shape, x, y):
- start, direction = self.get_ray(x, y)
- self.scene.place(shape, start, direction, self.inverseModelView)
- # Scene下的实现
- import numpy
- from node import Sphere, Cube, SnowFigure
- ...
- def place(self, shape, start, direction, inv_modelview):
- new_node = None
- if shape == 'sphere': new_node = Sphere()
- elif shape == 'cube': new_node = Cube()
- elif shape == 'figure': new_node = SnowFigure()
- self.add_node(new_node)
- # 得到在摄像机坐标系中的坐标
- translation = (start + direction * self.PLACE_DEPTH)
- # 转换到世界坐标系
- pre_tran = numpy.array([translation[ 0], translation[ 1], translation[ 2], 1])
- translation = inv_modelview.dot(pre_tran)
- new_node.translate(translation[ 0], translation[ 1], translation[ 2])
效果如下,按C键创建立方体,按S键创建球体。
移动目标对象的代码如下:
- # Viewer下的实现
- def move(self, x, y):
- start, direction = self.get_ray(x, y)
- self.scene.move_selected(start, direction, self.inverseModelView)
- # Scene下的实现
- def move_selected(self, start, direction, inv_modelview):
- if self.selected_node is None: return
- # 找到选中节点的坐标与深度(距离)
- node = self.selected_node
- depth = node.depth
- oldloc = node.selected_loc
- # 新坐标的深度保持不变
- newloc = (start + direction * depth)
- # 得到世界坐标系中的移动坐标差
- translation = newloc - oldloc
- pre_tran = numpy.array([translation[ 0], translation[ 1], translation[ 2], 0])
- translation = inv_modelview.dot(pre_tran)
- # 节点做平移变换
- node.translate(translation[ 0], translation[ 1], translation[ 2])
- node.selected_loc = newloc
移动了一下立方体:
五、一些探索
到这里我们就已经实现了一个简单的3D建模工具了,想一下这个程序还能在什么地方进行改进,或是增加一些新的功能?比如说:
- 编写新的节点类,支持三角形网格能够组合成任意形状。
- 增加一个撤销栈,支持撤销命令功能。
- 能够保存/加载3d设计,比如保存为 DXF 3D 文件格式
- 改进程序,选中目标更精准。
你也可以从开源的3d建模软件汲取灵感,学习他人的技巧,比如参考三维动画制作软件Blender的建模部分,或是三维建模工具OpenSCAD。
六、参考资料与延伸阅读
- A 3D Modeller
- A 3D Modeller 源代码
- Real Time Rendering
- OpenGL学习脚印: 坐标变换过程(vertex transformation)
- OpenGL学习脚印: 坐标和变换的数学基础(math-coordinates and transformations)
Python实现3D建模工具(下)相关推荐
- python实现3d建模工具_3D One 2.5引爆新思维,用趣味编程来实现3D建模!
3D One2.5正式版终于和大家见面啦!新版本全新推出趣味编程,让3D模型也能通过编程逻辑来完成,丰富你的创新想象力.这给有计划开展编程和3D设计课的学校提供了支持,在2018年高中新课标提出的加强 ...
- 【3D建模工具】上海道宁与McNeel为您提供强大的专业3D造型软件
Rhino可以对 NURBS曲线.曲面.实体. 细分几何图形 (SubD).点云和多边形网格 进行创建.编辑.分析.记录. 渲染.动画制作与转换 只要硬件条件允许 不受复杂度.阶数与尺寸大小的限制 R ...
- 在线3d倍数计算机,免费的在线3D建模工具
免费的在线3D建模工具 最近C4D已成为主流的设计软件,很受追捧,由于它能实现酷炫的立体场景效果,而且效果非常出彩,而且还能实现立体动效.在电商.运营方面应用非常广泛. 看着大神们的酷炫作品,都摩拳擦 ...
- 推荐一个3D建模工具集
3D建模工具集, 收录一下几个工具集: 数字孪生编辑器 基于WebGL技术,依托丰富的模型资产库,通过拖拽式的操作,方便用户高效便捷的搭建三维数字孪生场景,配合twin服务平台,实现孪 ...
- Maya和Blender先学习哪个更好?这两个3D建模工具的主要区别
Maya 是由 Alias Systems Corporation 开发的 3D 计算机图形应用程序,目前由 Auto desk, Inc 拥有.它可以在 Windows.Mac OS 和 Linux ...
- Dust3D - 3D 建模工具
Dust3D 是一个 开源建模工具,可以用它快速创建 Low Poly 模型,用于游戏制作.动画制作以及 3D 打印模型制作,既适合有经验的模型师创建雕刻前的基础模型,也适合像作者这样的新手(会一点点 ...
- linux系统设计软件,FreeCAD:Linux 下的 3D 建模和设计软件
FreeCAD 是一个基于 OpenCasCade 的跨平台机械工程和产品设计工具.作为参数化 3D 建模工具,它可以与 PLM.CAx.CAE.MCAD 和 CAD 协同工作,并且可以使用大量高级扩 ...
- python 3D 可视化工具应用 - Open3D (一)
python 版本 3D 可视化工具应用 -Open3D(一) open3d对于python版本的3d工具非常好用,且支持windows和ubuntu系统. 相关项目地址:https://gitcod ...
- 下面哪个不是python常用的开发工具_Python程序员常用的IDE和其它开发工具
概述 "工欲善其事,必先利其器",如果说编程是程序员的手艺,那么IDE就是程序员的吃饭家伙了. IDE的全称是Integration Development Environment ...
最新文章
- python 编程实例 1
- java性能分析 linux,linux 系统性能分析
- Hadoop 之父趣事:用儿子的大象玩偶为大数据项目命名
- Oracle 11.2 安装Oracle 11.1的HR schoma
- Python按行读取文件、写文件
- 论大型信息系统集成项目的成本管理
- CentOS添加一个新的硬盘、添加分区到挂载的过程
- boost::gil模块实现打包像素格式的测试程序
- linux下mysql安装笔记
- RMAN-06026报错解决方法
- php分页教程,php 如何分页 教程
- 个人信息管理系统代码_Thymeleaf+SpringBoot+Mybatis实现的易游网旅游信息管理系统...
- 计算机在平面设计中的作用,比例设计在平面设计中的作用与意义
- 【20171005】Luogu P1164 小A点菜
- 转载 — 问题解决:Unlink of file '.git/objects/pack/pack-***.pack' failed. Should I try again? (y/n) y
- axios 注册拦截器 cdn引用_CDN加速原理
- 获得系统异常的详细信息
- Linux shell脚本详解及实战(三)——shell脚本循环
- python3 爬妹子图片网站实战 代码简明易懂
- 华硕aura完全卸载_更快,更信仰,华硕RTX 2070电竞显卡
热门文章
- 2022 RoboCom 世界机器人开发者大赛-高职组 国赛(RC-v3 智能护理中心统计)
- 迅雷/旋风地址转换原理分析(转)
- android radiobutton 监听事件,Android View系列---RadioGroup与RadioButton
- win11开机启动显示自动修复。根本原因是最近提供的引导二进制文件已损坏
- 程序员自我修养》系统调用与API
- SpringBoot自定义注解
- 传统文化术语翻译整理
- DreamSceneSeven打造炫酷的动态桌面
- element-ui组件Popover 弹出框,el-popover样式、定位以及二次确认弹出框自动关闭 问题
- MHA 高可用配置(故障切换)(理论详解+实验步骤)