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

  • Post author:
  • Post category:python




用户接口

我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,

GLUT

允许我们在键盘或鼠标事件上注册对应的回调函数。

新建

interaction.py

文件,用户接口在

Interaction

类中实现。

导入需要的库



  1. from


    collections


    import


    defaultdict



  2. from


    OpenGL.GLUT


    import


    glutGet, glutKeyboardFunc, glutMotionFunc, glutMouseFunc, glutPassiveMotionFunc, \

  3. glutPostRedisplay, glutSpecialFunc



  4. from


    OpenGL.GLUT


    import


    GLUT_LEFT_BUTTON, GLUT_RIGHT_BUTTON, GLUT_MIDDLE_BUTTON, \

  5. GLUT_WINDOW_HEIGHT, GLUT_WINDOW_WIDTH, \

  6. GLUT_DOWN, GLUT_KEY_UP, GLUT_KEY_DOWN, GLUT_KEY_LEFT, GLUT_KEY_RIGHT



  7. import


    trackball

初始化

Interaction

类,注册

glut

的事件回调函数。





  1. class








    Interaction






    (object)




    :





  2. def








    __init__






    (self)




    :



  3. “”” 处理用户接口 “””



  4. #被按下的键

  5. self.pressed =


    None



  6. #轨迹球,会在之后进行说明

  7. self.trackball = trackball.Trackball(theta =


    -25


    , distance=


    15


    )



  8. #当前鼠标位置

  9. self.mouse_loc =


    None



  10. #回调函数词典

  11. self.callbacks = defaultdict(list)

  12. self.register()





  13. def








    register






    (self)




    :



  14. “”” 注册glut的事件回调函数 “””

  15. glutMouseFunc(self.handle_mouse_button)

  16. glutMotionFunc(self.handle_mouse_move)

  17. glutKeyboardFunc(self.handle_keystroke)

  18. glutSpecialFunc(self.handle_keystroke)

回调函数的实现:





  1. def








    handle_mouse_button






    (self, button, mode, x, y)




    :



  2. “”” 当鼠标按键被点击或者释放的时候调用 “””

  3. xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)

  4. y = ySize – y


    # OpenGL原点在窗口左下角,窗口原点在左上角,所以需要这种转换。

  5. self.mouse_loc = (x, y)



  6. if


    mode == GLUT_DOWN:



  7. #鼠标按键按下的时候

  8. self.pressed = button



  9. if


    button == GLUT_RIGHT_BUTTON:



  10. pass



  11. elif


    button == GLUT_LEFT_BUTTON:

  12. self.trigger(


    ‘pick’


    , x, y)



  13. else


    :


    # 鼠标按键被释放的时候

  14. self.pressed =


    None



  15. #标记当前窗口需要重新绘制

  16. glutPostRedisplay()





  17. def








    handle_mouse_move






    (self, x, screen_y)




    :



  18. “”” 鼠标移动时调用 “””

  19. xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)

  20. y = ySize – screen_y



  21. if


    self.pressed


    is




    not




    None


    :

  22. dx = x – self.mouse_loc[


    0


    ]

  23. dy = y – self.mouse_loc[


    1


    ]



  24. if


    self.pressed == GLUT_RIGHT_BUTTON


    and


    self.trackball


    is




    not




    None


    :



  25. # 变化场景的角度

  26. self.trackball.drag_to(self.mouse_loc[


    0


    ], self.mouse_loc[


    1


    ], dx, dy)



  27. elif


    self.pressed == GLUT_LEFT_BUTTON:

  28. self.trigger(


    ‘move’


    , x, y)



  29. elif


    self.pressed == GLUT_MIDDLE_BUTTON:

  30. self.translate(dx/


    60.0


    , dy/


    60.0


    ,


    0


    )



  31. else


    :



  32. pass

  33. glutPostRedisplay()

  34. self.mouse_loc = (x, y)





  35. def








    handle_keystroke






    (self, key, x, screen_y)




    :



  36. “”” 键盘输入时调用 “””

  37. xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)

  38. y = ySize – screen_y



  39. if


    key ==


    ‘s’


    :

  40. self.trigger(


    ‘place’


    ,


    ‘sphere’


    , x, y)



  41. elif


    key ==


    ‘c’


    :

  42. self.trigger(


    ‘place’


    ,


    ‘cube’


    , x, y)



  43. elif


    key == GLUT_KEY_UP:

  44. self.trigger(


    ‘scale’


    , up=


    True


    )



  45. elif


    key == GLUT_KEY_DOWN:

  46. self.trigger(


    ‘scale’


    , up=


    False


    )



  47. elif


    key == GLUT_KEY_LEFT:

  48. self.trigger(


    ‘rotate_color’


    , forward=


    True


    )



  49. elif


    key == GLUT_KEY_RIGHT:

  50. self.trigger(


    ‘rotate_color’


    , forward=


    False


    )

  51. glutPostRedisplay()



内部回调

针对用户行为会调用

self.trigger

方法,它的第一个参数指明行为期望的效果,后续参数为该效果的参数,

trigger

的实现如下:





  1. def








    trigger






    (self, name, *args, **kwargs)




    :



  2. for


    func


    in


    self.callbacks[name]:

  3. func(*args, **kwargs)

从代码可以看出

trigger

会取得

callbacks

词典下该效果对应的所有方法逐一调用。

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





  1. def








    register_callback






    (self, name, func)




    :

  2. self.callbacks[name].append(func)

还记得

Viewer

中未实现的

self.init_interaction()

吗,我们就是在这里注册回调函数的,下面补完

init_interaction

.



  1. from


    interaction


    import


    Interaction





  2. class








    Viewer






    (object)




    :





  3. def








    init_interaction






    (self)




    :

  4. self.interaction = Interaction()

  5. self.interaction.register_callback(


    ‘pick’


    , self.pick)

  6. self.interaction.register_callback(


    ‘move’


    , self.move)

  7. self.interaction.register_callback(


    ‘place’


    , self.place)

  8. self.interaction.register_callback(


    ‘rotate_color’


    , self.rotate_color)

  9. self.interaction.register_callback(


    ‘scale’


    , self.scale)





  10. def








    pick






    (self, x, y)




    :



  11. “”” 鼠标选中一个节点 “””



  12. pass





  13. def








    move






    (self, x, y)




    :



  14. “”” 移动当前选中的节点 “””



  15. pass





  16. def








    place






    (self, shape, x, y)




    :



  17. “”” 在鼠标的位置上新放置一个节点 “””



  18. pass





  19. def








    rotate_color






    (self, forward)




    :



  20. “”” 更改选中节点的颜色 “””



  21. pass





  22. def








    scale






    (self, up)




    :



  23. “”” 改变选中节点的大小 “””



  24. 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

矩阵





  1. class








    Viewer






    (object)




    :





  2. def








    render






    (self)




    :

  3. self.init_view()

  4. glEnable(GL_LIGHTING)

  5. glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)



  6. # 将ModelView矩阵设为轨迹球的旋转矩阵

  7. glMatrixMode(GL_MODELVIEW)

  8. glPushMatrix()

  9. glLoadIdentity()

  10. glMultMatrixf(self.interaction.trackball.matrix)



  11. # 存储ModelView矩阵与其逆矩阵之后做坐标系转换用

  12. currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))

  13. self.modelView = numpy.transpose(currentModelView)

  14. self.inverseModelView = inv(numpy.transpose(currentModelView))

  15. self.scene.render()

  16. glDisable(GL_LIGHTING)

  17. glCallList(G_OBJ_PLANE)

  18. glPopMatrix()

  19. glFlush()

运行代码:

此处输入图片的描述

右键拖动查看效果:

此处输入图片的描述

选择场景中的对象

既然要操作场景中的对象,那么必然得先能够选中对象,要怎么才能选中呢?想象你有一只指哪打哪的激光笔,当激光与对象相交时就相当于选中了对象。

我们如何判定激光穿透了对象呢?

想要真正实现对复杂形状物体进行选择判定是非常考验算法和性能的,所以在这里我们简化问题,对对象使用包围盒(axis-aligned bounding box, 简称AABB),包围盒可以想象成一个为对象量身定做的盒子,你刚刚好能将模型放进去。这样做的好处就是对于不同形状的对象你都可以使用同一段代码处理选中判定,并能保证较好的性能。

新建

aabb.py

,编写包围盒类:



  1. from


    OpenGL.GL


    import


    glCallList, glMatrixMode, glPolygonMode, glPopMatrix, glPushMatrix, glTranslated, \

  2. GL_FILL, GL_FRONT_AND_BACK, GL_LINE, GL_MODELVIEW



  3. from


    primitive


    import


    G_OBJ_CUBE



  4. import


    numpy



  5. import


    math



  6. #判断误差

  7. EPSILON =


    0.000001





  8. class








    AABB






    (object)




    :





  9. def








    __init__






    (self, center, size)




    :

  10. self.center = numpy.array(center)

  11. self.size = numpy.array(size)





  12. def








    scale






    (self, scale)




    :

  13. self.size *= scale





  14. def








    ray_hit






    (self, origin, direction, modelmatrix)




    :



  15. “”” 返回真则表示激光射中了包盒


  16. 参数说明: origin, distance -> 激光源点与方向


  17. modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 “””

  18. aabb_min = self.center – self.size

  19. aabb_max = self.center + self.size

  20. tmin =


    0.0

  21. tmax =


    100000.0

  22. obb_pos_worldspace = numpy.array([modelmatrix[


    0


    ,


    3


    ], modelmatrix[


    1


    ,


    3


    ], modelmatrix[


    2


    ,


    3


    ]])

  23. delta = (obb_pos_worldspace – origin)



  24. # test intersection with 2 planes perpendicular to OBB’s x-axis

  25. xaxis = numpy.array((modelmatrix[


    0


    ,


    0


    ], modelmatrix[


    0


    ,


    1


    ], modelmatrix[


    0


    ,


    2


    ]))

  26. e = numpy.dot(xaxis, delta)

  27. f = numpy.dot(direction, xaxis)



  28. if


    math.fabs(f) >


    0.0


    + EPSILON:

  29. t1 = (e + aabb_min[


    0


    ])/f

  30. t2 = (e + aabb_max[


    0


    ])/f



  31. if


    t1 > t2:

  32. t1, t2 = t2, t1



  33. if


    t2 < tmax:

  34. tmax = t2



  35. if


    t1 > tmin:

  36. tmin = t1



  37. if


    tmax < tmin:



  38. return


    (


    False


    ,


    0


    )



  39. else


    :



  40. if


    (-e + aabb_min[


    0


    ] >


    0.0


    + EPSILON)


    or


    (-e+aabb_max[


    0


    ] <


    0.0


    – EPSILON):



  41. return




    False


    ,


    0

  42. yaxis = numpy.array((modelmatrix[


    1


    ,


    0


    ], modelmatrix[


    1


    ,


    1


    ], modelmatrix[


    1


    ,


    2


    ]))

  43. e = numpy.dot(yaxis, delta)

  44. f = numpy.dot(direction, yaxis)



  45. # intersection in y



  46. if


    math.fabs(f) >


    0.0


    + EPSILON:

  47. t1 = (e + aabb_min[


    1


    ])/f

  48. t2 = (e + aabb_max[


    1


    ])/f



  49. if


    t1 > t2:

  50. t1, t2 = t2, t1



  51. if


    t2 < tmax:

  52. tmax = t2



  53. if


    t1 > tmin:

  54. tmin = t1



  55. if


    tmax < tmin:



  56. return


    (


    False


    ,


    0


    )



  57. else


    :



  58. if


    (-e + aabb_min[


    1


    ] >


    0.0


    + EPSILON)


    or


    (-e+aabb_max[


    1


    ] <


    0.0


    – EPSILON):



  59. return




    False


    ,


    0



  60. # intersection in z

  61. zaxis = numpy.array((modelmatrix[


    2


    ,


    0


    ], modelmatrix[


    2


    ,


    1


    ], modelmatrix[


    2


    ,


    2


    ]))

  62. e = numpy.dot(zaxis, delta)

  63. f = numpy.dot(direction, zaxis)



  64. if


    math.fabs(f) >


    0.0


    + EPSILON:

  65. t1 = (e + aabb_min[


    2


    ])/f

  66. t2 = (e + aabb_max[


    2


    ])/f



  67. if


    t1 > t2:

  68. t1, t2 = t2, t1



  69. if


    t2 < tmax:

  70. tmax = t2



  71. if


    t1 > tmin:

  72. tmin = t1



  73. if


    tmax < tmin:



  74. return


    (


    False


    ,


    0


    )



  75. else


    :



  76. if


    (-e + aabb_min[


    2


    ] >


    0.0


    + EPSILON)


    or


    (-e+aabb_max[


    2


    ] <


    0.0


    – EPSILON):



  77. return




    False


    ,


    0



  78. return




    True


    , tmin





  79. def








    render






    (self)




    :



  80. “”” 渲染显示包围盒,可在调试的时候使用 “””

  81. glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)

  82. glMatrixMode(GL_MODELVIEW)

  83. glPushMatrix()

  84. glTranslated(self.center[


    0


    ], self.center[


    1


    ], self.center[


    2


    ])

  85. glCallList(G_OBJ_CUBE)

  86. glPopMatrix()

  87. glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)

更新

Node

类与

Scene

类,加入与选中节点有关的内容

更新

Node

类:



  1. from


    aabb


    import


    AABB





  2. class








    Node






    (object)




    :





  3. def








    __init__






    (self)




    :

  4. self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)

  5. self.aabb = AABB([


    0.0


    ,


    0.0


    ,


    0.0


    ], [


    0.5


    ,


    0.5


    ,


    0.5


    ])

  6. self.translation_matrix = numpy.identity(


    4


    )

  7. self.scaling_matrix = numpy.identity(


    4


    )

  8. self.selected =


    False





  9. def








    render






    (self)




    :

  10. glPushMatrix()

  11. glMultMatrixf(numpy.transpose(self.translation_matrix))

  12. glMultMatrixf(self.scaling_matrix)

  13. cur_color = color.COLORS[self.color_index]

  14. glColor3f(cur_color[


    0


    ], cur_color[


    1


    ], cur_color[


    2


    ])



  15. if


    self.selected:


    # 选中的对象会发光

  16. glMaterialfv(GL_FRONT, GL_EMISSION, [


    0.3


    ,


    0.3


    ,


    0.3


    ])

  17. self.render_self()



  18. if


    self.selected:

  19. glMaterialfv(GL_FRONT, GL_EMISSION, [


    0.0


    ,


    0.0


    ,


    0.0


    ])

  20. glPopMatrix()





  21. def








    select






    (self, select=None)




    :



  22. if


    select


    is




    not




    None


    :

  23. self.selected = select



  24. else


    :

  25. self.selected =


    not


    self.selected

更新

Scene

类:





  1. class








    Scene






    (object)




    :





  2. def








    __init__






    (self)




    :

  3. self.node_list = list()

  4. self.selected_node =


    None



Viewer

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

pick

函数



  1. # class Viewer





  2. def








    get_ray






    (self, x, y)




    :



  3. “””


  4. 返回光源和激光方向


  5. “””

  6. self.init_view()

  7. glMatrixMode(GL_MODELVIEW)

  8. glLoadIdentity()



  9. # 得到激光的起始点

  10. start = numpy.array(gluUnProject(x, y,


    0.001


    ))

  11. end = numpy.array(gluUnProject(x, y,


    0.999


    ))



  12. # 得到激光的方向

  13. direction = end – start

  14. direction = direction / norm(direction)



  15. return


    (start, direction)





  16. def








    pick






    (self, x, y)




    :



  17. “”” 是否被选中以及哪一个被选中交由Scene下的pick处理 “””

  18. start, direction = self.get_ray(x, y)

  19. self.scene.pick(start, direction, self.modelView)

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



  1. # Scene 下实现





  2. def








    pick






    (self, start, direction, mat)




    :



  3. “””


  4. 参数中的mat为当前ModelView的逆矩阵,作用是计算激光在局部(对象)坐标系中的坐标


  5. “””



  6. import


    sys



  7. if


    self.selected_node


    is




    not




    None


    :

  8. self.selected_node.select(


    False


    )

  9. self.selected_node =


    None



  10. # 找出激光击中的最近的节点。

  11. mindist = sys.maxint

  12. closest_node =


    None



  13. for


    node


    in


    self.node_list:

  14. hit, distance = node.pick(start, direction, mat)



  15. if


    hit


    and


    distance < mindist:

  16. mindist, closest_node = distance, node



  17. # 如果找到了,选中它



  18. if


    closest_node


    is




    not




    None


    :

  19. closest_node.select()

  20. closest_node.depth = mindist

  21. closest_node.selected_loc = start + direction * mindist

  22. self.selected_node = closest_node



  23. # Node下的实现





  24. def








    pick






    (self, start, direction, mat)




    :



  25. # 将modelview矩阵乘上节点的变换矩阵

  26. newmat = numpy.dot(

  27. numpy.dot(mat, self.translation_matrix),

  28. numpy.linalg.inv(self.scaling_matrix)

  29. )

  30. results = self.aabb.ray_hit(start, direction, newmat)



  31. return


    results

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

此处输入图片的描述

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

此处输入图片的描述

操作场景中的对象

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

加入新对象的代码如下:



  1. # Viewer下的实现





  2. def








    place






    (self, shape, x, y)




    :

  3. start, direction = self.get_ray(x, y)

  4. self.scene.place(shape, start, direction, self.inverseModelView)



  5. # Scene下的实现



  6. import


    numpy



  7. from


    node


    import


    Sphere, Cube, SnowFigure





  8. def








    place






    (self, shape, start, direction, inv_modelview)




    :

  9. new_node =


    None



  10. if


    shape ==


    ‘sphere’


    : new_node = Sphere()



  11. elif


    shape ==


    ‘cube’


    : new_node = Cube()



  12. elif


    shape ==


    ‘figure’


    : new_node = SnowFigure()

  13. self.add_node(new_node)



  14. # 得到在摄像机坐标系中的坐标

  15. translation = (start + direction * self.PLACE_DEPTH)



  16. # 转换到世界坐标系

  17. pre_tran = numpy.array([translation[


    0


    ], translation[


    1


    ], translation[


    2


    ],


    1


    ])

  18. translation = inv_modelview.dot(pre_tran)

  19. new_node.translate(translation[


    0


    ], translation[


    1


    ], translation[


    2


    ])

效果如下,按C键创建立方体,按S键创建球体。

此处输入图片的描述

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



  1. # Viewer下的实现





  2. def








    move






    (self, x, y)




    :

  3. start, direction = self.get_ray(x, y)

  4. self.scene.move_selected(start, direction, self.inverseModelView)



  5. # Scene下的实现





  6. def








    move_selected






    (self, start, direction, inv_modelview)




    :



  7. if


    self.selected_node


    is




    None


    :


    return



  8. # 找到选中节点的坐标与深度(距离)

  9. node = self.selected_node

  10. depth = node.depth

  11. oldloc = node.selected_loc



  12. # 新坐标的深度保持不变

  13. newloc = (start + direction * depth)



  14. # 得到世界坐标系中的移动坐标差

  15. translation = newloc – oldloc

  16. pre_tran = numpy.array([translation[


    0


    ], translation[


    1


    ], translation[


    2


    ],


    0


    ])

  17. translation = inv_modelview.dot(pre_tran)



  18. # 节点做平移变换

  19. node.translate(translation[


    0


    ], translation[


    1


    ], translation[


    2


    ])

  20. node.selected_loc = newloc

移动了一下立方体:

此处输入图片的描述



五、一些探索

到这里我们就已经实现了一个简单的3D建模工具了,想一下这个程序还能在什么地方进行改进,或是增加一些新的功能?比如说:

  • 编写新的节点类,支持三角形网格能够组合成任意形状。
  • 增加一个撤销栈,支持撤销命令功能。
  • 能够保存/加载3d设计,比如保存为 DXF 3D 文件格式
  • 改进程序,选中目标更精准。

你也可以从开源的3d建模软件汲取灵感,学习他人的技巧,比如参考三维动画制作软件

Blender

的建模部分,或是三维建模工具

OpenSCAD



六、参考资料与延伸阅读