Python实现3D建模工具(下)

2024-05-09 08:04:32 浏览数 (2)

#Python实现3D建模工具 ###用户接口

我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,GLUT允许我们在键盘或鼠标事件上注册对应的回调函数。

新建interaction.py文件,用户接口在Interaction类中实现。

导入需要的库

代码语言:javascript复制
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的事件回调函数。

代码语言:javascript复制
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)

回调函数的实现:

代码语言:javascript复制
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的实现如下:

代码语言:javascript复制
def trigger(self, name, *args, **kwargs):
    for func in self.callbacks[name]:
        func(*args, **kwargs)

从代码可以看出trigger会取得callbacks词典下该效果对应的所有方法逐一调用。

那么如何将方法添加进callbacks呢?我们需要实现一个注册回调函数的方法:

代码语言:javascript复制
def register_callback(self, name, func):
    self.callbacks[name].append(func)

还记得Viewer中未实现的self.init_interaction()吗,我们就是在这里注册回调函数的,下面补完init_interaction.

代码语言:javascript复制
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

pickmove 等函数的说明如下表所示

回调函数

参数

说明

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文件,并将其置于工作目录下:

代码语言:javascript复制
$ wget  http://labfile.oss.aliyuncs.com/courses/561/trackball.py

drag_to方法实现与轨迹球的交互,它会比对之前的鼠标位置和移动后的鼠标位置来更新旋转矩阵。

代码语言:javascript复制
self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)

得到的旋转矩阵保存在viewer的trackball.matrix中。

更新viewer.py下的ModelView矩阵

代码语言:javascript复制
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,编写包围盒类:

代码语言:javascript复制
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类:

代码语言:javascript复制
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类:

代码语言:javascript复制
class Scene(object):
    def __init__(self):
        self.node_list = list()
        self.selected_node = None

Viewer类中实现通过鼠标位置获取激光的函数以及pick函数

代码语言:javascript复制
    # 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)

为了确定是哪个对象被选中,我们会遍历场景下的所有对象,检查激光是否与该对象相交,取离摄像机最近的对象为选中对象。

代码语言:javascript复制
# 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

运行代码(蓝立方体被选中):

检测包围盒也有其缺点,如下图所示,我们希望能点中球背后的立方体,然而却选中了立方体前的球体,因为我们的激光射中了球体的包围盒。为了效率我们牺牲了这部分功能。在性能,代码复杂度与功能准确度之间之间进行衡量与抉择是在计算机图形学与软件工程中常常会遇见的。

####操作场景中的对象

对对象的操作主要包括在场景中加入新对象, 移动对象、改变对象的颜色与改变对象的大小。因为这部分的实现较为简单,所以仅实现加入新对象与移动对象的操作.

加入新对象的代码如下:

代码语言:javascript复制
# 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键创建球体。

移动目标对象的代码如下:

代码语言:javascript复制
# 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。

##六、参考资料与延伸阅读

0 人点赞