面向机械工程师的硬核编程指南-全-

面向机械工程师的硬核编程指南(全)

原文:zh.annas-archive.org/md5/4bc8a89bcd4861f45c3a48db743d929f

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

图片

学会编写代码能让你解决复杂问题。通过利用现代 CPU 的能力,它们每秒可以执行数十亿次操作,我们可以快速且准确地解决困难的问题。

本书介绍了如何使用 Python 解决工程问题。我们将学习如何编写几何原语,这些原语将作为更复杂操作的基础,如何从文件中读取和写入数据,如何创建矢量图像和动画序列来展示结果,以及如何解决大规模线性方程组。最后,我们将把这些知识结合起来,构建一个解决桁架结构问题的应用程序。

本书适合谁阅读

本书面向工程学学生、已毕业的工程师,或任何希望学习如何编写应用程序来解决工程问题的技术背景人士。

数学和力学的基础是必需的。我们将使用线性代数、二维几何和物理学的概念。我们还将使用一些材料力学和数值方法的知识,这些是许多工程学科的常见内容。为了让更多读者能从本书中获益,我们不会深入探讨这些主题。本书中学到的技术以后可以用来解决涉及更复杂概念的问题。

为了跟上本书的内容,你需要具备一定的编码技能和基本的 Python 知识。本书不是一本编程入门书;市面上有很多其他优秀的书籍可以覆盖这些内容。如果你正在寻找这样的书,我推荐 Eric Matthes 的 Python Crash Course(No Starch Press,2019 年)。网上也有很多很棒的资源,我最喜欢的是 https://realpython.com。官方的 Python 网站也有很多很好的教程和文档: www.python.org/about/gettingstarted/

我们将编写大量代码,因此强烈建议你在阅读时随身携带计算机,并输入和测试本书中的所有代码。

你将学到的内容

在本书中,我们将探讨编写健壮应用程序的技术,确保它们能够正确且快速地解决工程问题。为了确保正确性,我们将使用自动化测试来测试我们的代码。你构建的每个应用程序都应该通过自动化测试进行充分测试,正如我们在本书中所讨论的那样。

工程应用通常需要输入一些数据,因此我们还将学习如何从文件中读取程序的输入,并使用正则表达式进行解析。

工程应用通常需要解决大型方程组,因此我们将介绍如何编写数值方法来进行这些复杂的计算。我们将重点讲解线性方程组,但相同的技术也可以轻松应用于编写非线性方程的数值算法。

最后,工程应用需要产生结果。我们将学习如何将文本写入文件,以便稍后检查。我们还将学习如何生成漂亮的矢量图和动画序列,以展示程序的结果。正如他们所说,一张图胜过千言万语:看着一张清晰的图表,描述了最相关的解决方案值,能让程序的价值大大提升。

为了说明这些概念,我们将通过构建一个解决二维桁架结构的应用程序来结束本书。这款应用程序将包含你构建工程应用所需的一切知识。在构建这个应用程序时获得的知识可以轻松转化为编写其他类型工程应用的能力。

关于本书

在本节中,我们将解释三件事:本书标题背后的含义、选择 Python 的原因,以及目录内容。

“Hardcore”是什么意思?

本书标题中的Hardcore一词指的是我们将自己编写所有代码,只依赖 Python 标准库(Python 自带的库);我们不会使用任何第三方库来解方程或绘制矢量图形。

你可能会想,为什么呢?如果已经有人写了能为我们做这些事情的代码,为什么不直接使用它呢?难道我们不是在重新发明轮子吗?

这是一本关于学习的书,而要学习,你需要亲自去做。除非你重新发明轮子,否则你永远不会理解轮子的原理。一旦你的软件技能扎实,写了成千上万行代码,并参与了大量项目,你就能很好地判断哪些外部库适合你的需求,以及如何利用它们。但是,如果你从一开始就使用这些库,你会习惯使用它们,并理所当然地接受这些解决方案。始终问问自己,这个库的代码是如何解决我的问题的,这一点很重要。

就像其他任何事情一样,编码需要练习。如果你想在编程上做得好,你需要写很多代码;没有捷径可走。如果你是为了赚钱写软件,或者希望尽快将一个想法推向市场,那么可以使用现有的库。但是,如果你是在学习并希望在编写代码的艺术上变得熟练,就不要使用库。自己编写代码。

为什么选择 Python?

Python 是最受欢迎的编程语言之一。根据 Stack Overflow 2020 年的开发者调查(insights.stackoverflow.com/survey/2020),Python 是今天第三受欢迎的语言,有 66.7%的用户愿意在未来继续使用它,仅次于 TypeScript 和 Rust(参见图 1)。

Image

图 1:2020 年最受欢迎的编程语言(来源:Stack Overflow 调查)

这项调查将 Python 排在了“最受欢迎”语言的首位:30% 的受访开发者表示,他们虽然目前没有使用 Python,但有兴趣学习它(见 图 2)。

Image

图 2:2020 年最受欢迎语言(来源:Stack Overflow 调查)

这些结果并不令人惊讶;Python 是一门非常多才多艺且富有生产力的语言。用 Python 编写代码是一件愉快的事,它的标准库也非常完善:几乎任何你想做的事情,Python 都能提供相应的帮助。

我们在本书中使用 Python,不仅因为它的流行,还因为它易于使用且功能多样。Python 的一个优点是,如果你正在阅读本书且之前没有接触过这门语言,你也不需要太长时间就能上手。它是一门相对容易学习的语言,互联网上有大量的教程和课程可以帮助你。

Python 通常并不被视为一门快速的语言,实际上,Python 的执行速度并不是其强项。下面的 图 3 显示了用 Python 和 Go(Google 开发的一门非常快速的语言)编写的相同三个程序的执行时间(单位:秒)对比。在所有情况下,Python 的执行时间都远远长于 Go。

Image

图 3:Python 性能基准(来源:benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/python3-go.html)

那么,我们不关心速度吗?我们当然关心,但在本书中,我们更关心的是开发时间和开发体验。Python 拥有许多能让编码变得愉快的结构;例如,像过滤或映射集合这样的操作,Python 可以通过列表推导式轻松实现,而在 Go 中,你需要使用传统的 for 循环来做这些操作。对于我们将要编写的几乎每个程序,执行时间都不会成为问题,因为我们会获得足够好的结果。如果你在应用中遇到速度问题,本书中学到的技能将有助于你转向其他更快的语言。

在我们开始学习之前,先快速浏览一下你将在本书中遇到的主题。

概览

本书将涵盖大量内容。每一章都在上一章的基础上展开,因此你需要确保按照顺序阅读本书,并完成每一章提供的代码练习。

本书包括以下章节:

第一部分:基础

第一章:Python 简介 介绍了一些中级 Python 主题,这些内容将在本书中贯穿始终。我们将讨论如何将代码分割成模块和包,如何使用 Python 的集合,以及如何运行 Python 脚本和导入模块。

第二章:两种 Python 编程范式 介绍函数式编程和面向对象编程范式,并探索在这些风格下编写代码的技巧。

第三章:命令行 教你如何使用命令行运行程序和执行其他简单任务,如创建文件。

第二部分:二维几何

第四章:点与向量 讲解最基础但至关重要的几何基本元素:点和向量。全书其余内容都依赖于这两种基本元素的实现,因此我们还将学习自动化测试,以确保我们的实现没有 bug。

第五章:直线与线段 将直线和线段这两个几何基本元素加入我们的几何工具箱。我们将学习如何检查两条线段或直线是否相交,以及如何计算交点。

第六章:多边形 将矩形、圆形和一般多边形加入我们的几何工具箱。

第七章:仿射变换 讲解仿射变换,这是一种有趣的代数构造,我们将利用它来生成美丽的图像和动画。

第三部分:图形与仿真

第八章:绘制矢量图像 介绍了可缩放矢量图形(SVG)图像格式。我们将编写自己的库,使用几何基本元素生成这些图像。

第九章:通过三点构建圆 综合前几章的知识,构建我们的第一个应用程序,找到通过三个给定点的圆,并将结果绘制成矢量图像。

第十章:图形用户界面与画布 讲解了 Tkinter 包的基础知识,Tkinter 用于在 Python 中构建用户界面。我们将大部分时间用于学习如何使用 Canvas 小部件,它用于在屏幕上绘制图像。

第十一章:动画、仿真与时间循环 带领你通过绘制 Tkinter 的 Canvas 中的动画过程。我们将探索工程仿真和视频游戏引擎中使用的时间循环概念,用于将场景渲染到屏幕上。

第十二章:动画化仿射变换 创建一个应用程序,动画化仿射变换对某些几何基本元素的影响。

第四部分:方程组

第十三章:矩阵与向量 介绍了向量和矩阵构造,并讲解如何编码这些基本元素,这对我们处理方程组时非常有用。

第十四章:线性方程组 讲解了如何实现数值方法来求解大规模线性方程组。我们将一起实现 Cholesky 分解法;该算法将用于求解本书最后部分将出现的方程组。

第 V 部分:桁架结构

第十五章:结构模型 回顾了我们将在本书这一部分使用的基本材料力学概念。我们还将编写类来表示桁架结构。通过这个桁架结构模型,我们将构建一个完整的结构分析应用程序。

第十六章:结构解析 利用上一章构建的模型,我们将讲解所有计算内容,以找到结构的位移、变形和应力。

第十七章:从文件读取输入 讲解了文件读取和解析的实现,以便我们的桁架分析应用能够依赖存储为纯文本的数据。

第十八章:生成 SVG 图像和文本文件 讨论了基于结构解生成 SVG 图像图表的过程。在这里,我们将使用我们自己的 SVG 包来绘制图表,图表中将包含所有相关细节,例如变形结构的几何形状以及每根杆件旁边的应力标签。

第十九章:组装我们的应用程序 解释了如何将前几章构建的各个部分组合起来,构建完整的桁架分析应用。

设置您的开发环境

本书将使用 Python 3,并提供在开发环境程序 PyCharm 中工作的指导,PyCharm 让我们可以高效地进行工作。代码已在 Python 3.6 到 3.9 版本中测试过,但它很可能在未来的 Python 版本中也能正常工作。让我们下载本书随附的代码,安装最新的 Python 3 解释器,并设置 PyCharm。

下载书籍代码

本书的所有代码都可以在 GitHub 上找到,地址是 https://github.com/angelsolaorbaiceta/Mechanics。再次提醒,虽然我强烈建议你自己编写所有代码,但将其作为参考是个好主意。

如果你熟悉 Git 和 GitHub,可能希望克隆该代码库。此外,我建议你不时从仓库中拉取更新,因为我可能会在项目中添加新功能或修复 bug。

如果你不熟悉 Git 版本控制系统或 GitHub,最好的选择是下载一份代码副本。你可以通过点击 Clone 按钮并选择 下载 ZIP 选项来完成此操作(参见 图 4)。

图片

图 4:从 GitHub 下载代码

解压项目并将其放置在你选择的目录中。如你所见,我使用 README 文件README.md)记录了项目中的每个包和子包。这些文件通常出现在软件项目中,它们解释并记录了项目的特性,并包含有关如何编译或运行代码的说明。README 文件是你打开软件项目时首先要阅读的内容,因为它们描述了如何配置项目并使代码运行。

注意

README 文件是使用 Markdown 格式编写的。如果你想了解更多关于这种格式的内容,可以阅读这里的介绍: www.markdownguide.org/

GitHub 上的 Mechanics 项目包含的代码比我们在本书中涵盖的要多。我们不想让本书太长,因此未能涵盖项目中包含的所有内容。

例如,在第十四章《线性方程》中,我们讨论了解决线性方程组的数值方法,并详细解释了 Cholesky 分解。项目中还有一些其他的数值方法,例如共轭梯度法,由于时间限制我们无法在书中涵盖;这些代码可以供你分析和使用。项目中还有很多自动化测试,我们为了简洁省略了它们;在编写自己的代码时可以参考这些测试。

是时候安装 Python 了。

安装 Python

你可以从 https://www.python.org/downloads/ 下载适用于 macOS、Linux 和 Windows 的 Python。对于 Windows 和 macOS,你需要下载安装程序并运行它。

Linux 通常自带 Python。你可以通过在终端中使用以下命令检查你电脑上安装的版本:

$ python3 -V
Python 3.8.2

要在 Linux 计算机上安装 Python 版本,你可以使用 os 包管理器。对于使用 apt 包管理器的 Ubuntu 用户,命令如下:

$ sudo apt install python3.8

对于 Fedora 用户,使用 dnf 包管理器,命令如下:

$ sudo dnf install python38

如果你使用的是其他 Linux 发行版,快速的 Google 搜索应该能帮你找到使用包管理器安装 Python 的说明。

重要的是,你下载的是 Python 3 的版本,比如 3.9,这是在写作时的当前版本。任何版本高于 3.6(包含 3.6)都会有效。

注意

Python 2 和 3 不兼容;针对 Python 3 编写的代码很可能无法在 Python 2 解释器上运行。语言以一种不向后兼容的方式发展,版本 3 中的一些特性在版本 2 中不可用。

安装和配置 PyCharm

在我们开发代码时,我们需要使用 集成开发环境(简称 IDE),这是一种配备有帮助我们更高效写代码的功能的程序。IDE 通常提供自动补全功能,让你在输入时知道有哪些可用选项,并且还提供构建、调试和测试工具。花时间学习你所选择的 IDE 的主要功能是值得的:它会在开发阶段让你更加高效。

本书将使用 PyCharm,这是 JetBrains 创建的一个强大 IDE,JetBrains 不仅开发了市场上一些最优秀的 IDE,还开发了自己的编程语言 Kotlin。如果你已经有一定的 Python 经验,并且更喜欢使用其他 IDE,比如 Visual Studio Code,也可以使用,但你需要根据你的 IDE 文档自行解决一些问题。如果你之前没有太多 IDE 使用经验,我建议你坚持使用 PyCharm,这样你可以跟着本书一起学习。

要下载 PyCharm,请访问https://www.jetbrains.com/pycharm/,然后点击 Download 按钮(见 图 5)。

图片

图 5:下载 PyCharm IDE

PyCharm 可用于 Linux、macOS 和 Windows。它有两个版本:专业版和社区版。你可以免费下载社区版。按照安装步骤将 PyCharm 安装到你的计算机上。

打开 Mechanics 项目

让我们使用 PyCharm 设置你之前下载的 Mechanics 项目,这样你就可以玩一下并将其代码作为参考。

打开 PyCharm,在欢迎界面点击 Open 选项。找到你之前下载或从 GitHub 克隆的 Mechanics 项目文件夹并选择它。PyCharm 应该会打开该项目并为其配置 Python 解释器,使用你计算机上安装的 Python 版本。

每个 PyCharm 项目都需要设置 Python 解释器。由于你的计算机上可能安装了多个不同版本的 Python,且你可能选择了自定义安装位置,因此你需要告诉 PyCharm 使用哪个 Python 版本来解释项目的代码,并且需要指明在系统中哪里可以找到 Python 解释器。对于 Windows 和 Linux 用户,打开菜单并选择 FileSettings。对于 macOS 用户,选择 PyCharmPreferences。在 Settings/Preferences 窗口中,点击左侧栏的 Project: Mechanics 部分展开它,然后选择 Python Interpreter(见 图 6)。

图片

图 6:设置项目的 Python 解释器

在窗口右侧,点击 Python 解释器字段旁边的下拉箭头,从下拉菜单中选择你在计算机上安装的 Python 二进制版本。如果你按照之前的说明操作,Python 应该已安装到 PyCharm 可以找到的默认目录中,因此解释器应该出现在列表中。如果你将 Python 安装在其他地方,则需要告诉 PyCharm 安装目录。

注意

如果在设置项目的解释器时遇到任何问题,请查看 PyCharm 的官方文档: www.jetbrains.com/help/pycharm/configuring-python-interpreter.html。该链接包含了详细的操作说明。

现在你已经打开了 Mechanics 项目,项目应该已经设置好了。双击 PyCharm 中的 README.md 文件打开。默认情况下,当你在 PyCharm 中打开一个 Markdown 文件时,会显示分屏视图:左侧是 Markdown 原始文件,右侧是文件的渲染版本。参见 图 7。

这个 README.md 文件解释了项目的基本结构。随意浏览预览中的链接;给自己一些时间阅读每个包中的 README 文件。这将帮助你了解我们将在本书中一起完成的工作量。

Image

图 7:带有 PyCharm 分屏视图的 README.md 文件

创建自己的 Mechanics 项目

现在你已经设置好了下载的 Mechanics 项目作为参考,让我们创建一个新的空项目,在其中编写代码。如果你有打开的项目,关闭它(选择 文件关闭项目)。你应该会看到欢迎页面,如 图 8 所示。

Image

图 8:PyCharm 欢迎界面

在欢迎页面选择 创建新项目。系统会要求你为项目命名:使用 Mechanics。然后,在解释器选项中,选择 现有解释器(而不是默认的 New environment using)(参见 图 9)。找到你在引言中下载的 Python 版本并点击 创建

Image

图 9:PyCharm,创建新项目

你应该已经创建了一个新的空项目,准备好编写代码了。让我们快速浏览一下 PyCharm 的主要功能。

PyCharm 介绍

本节绝不是 PyCharm 使用的全面指南。要获取更完整的 IDE 概述,请阅读 https://www.jetbrains.com/help/pycharm 上的文档。官方文档完整且包含最新功能。

PyCharm 是一个强大的 IDE,它的 Community(免费)版本即使如此,也包含了大量功能;它让使用 Python 的过程变得非常愉悦。它的用户界面(UI)可以分为四个主要部分(请参见图 10)。

导航栏 窗口顶部是导航栏。导航栏的左侧是当前打开文件的面包屑导航。右侧有运行和调试程序的按钮,以及显示当前运行配置的下拉列表(我们将在本书稍后介绍运行配置)。

项目工具窗口 这是你的项目的目录结构,包含所有的包和文件。

编辑器 这是你编写代码的地方。

终端 PyCharm 配备了两个终端:系统终端和 Python 终端。我们将在全书中使用这两者。我们将在第三章中介绍这些内容。

Image

图 10:PyCharm 用户界面

PyCharm 还在 UI 的右下角包含了项目的 Python 解释器。你可以从这里更改解释器的版本,从你系统中安装的版本列表中进行选择。

创建包和文件

我们可以在项目中使用项目工具窗口创建新的 Python 包(我们将在第一章中介绍包的内容)。要创建一个新包,请打开项目工具窗口,右键点击你希望创建新包的文件夹或包;在出现的菜单中选择 新建Python 包。同样,选择 新建Python 文件 来创建 Python 文件。你可以在图 11 中看到这些选项。

你还可以通过选择 新建目录 来创建常规目录,通过 新建文件 创建各种类型的文件,后者会让你自己选择文件的扩展名。常规目录和 Python 包的区别在于,后者包含一个名为 init.py 的文件,该文件告诉 Python 解释器将该目录视为包含 Python 代码的包。你将在第一章中进一步了解此内容。

Image

图 11:PyCharm 新建包或文件

创建运行配置

运行配置 就是告诉 PyCharm 我们希望如何运行我们的项目(或其一部分)。我们可以保存这个配置,以便多次使用。配置好运行配置后,我们只需按一个按钮就能执行应用程序,而不需要在 shell 中写命令,这样就避免了复制粘贴参数、输入文件名等麻烦。

运行配置可以包含关于应用程序入口点、要重定向到标准输入的文件、必须设置的环境变量以及传递给程序的参数等信息。运行配置是开发中的便利工具;正如我们将在下一节看到的那样,它们还允许我们轻松调试 Python 代码。您可以在此处找到有关运行配置的官方文档:https://www.jetbrains.com/help/pycharm/run-debug-configuration.html

让我们自己创建一个运行配置来积累一些实践经验。首先,让我们创建一个新的空项目。

创建测试项目

要从菜单创建新项目,请选择 文件新建项目。在创建项目对话框中,为项目命名为 RunConfig,选择 现有解释器 选项,然后点击 创建

在这个新的空项目中,在项目工具窗口中右键单击 RunConfig 空目录,然后选择 新建Python 文件。命名为 fibonacci。打开文件并输入以下代码:

def fibonacci(n):
    if n < 3:
        return 1

    return fibonacci(n - 1) + fibonacci(n - 2)

fib_30 = fibonacci(30)
print(f'the 30th Fibonacci number is {fib_30}')

我们编写了一个使用递归算法计算第 n 个斐波那契数的函数,然后用它来计算并打印第 30 个数。让我们创建一个新的运行配置来执行此脚本。

创建新的运行配置

要创建新的运行配置,请从菜单选择 运行编辑配置;对话框中应该出现 图 12。

图片

图 12:运行/调试配置对话框

如您所见,我们可以使用几个模板来创建新的运行配置。每个模板定义了帮助我们轻松创建正确类型配置的参数。在本书中,我们只会使用 Python 模板。此模板定义了一个运行配置,用于运行和调试 Python 文件。

在对话框中,点击左上角的 + 按钮,从可用模板列表中选择 Python(见图 13)。

图片

图 13:创建新的 Python 运行配置

选择配置模板后,对话框的右侧将显示我们需要为此配置提供的参数以运行我们的代码。我们只需要填写其中两个参数:配置名称和脚本路径。

找到对话框顶部的名称字段,并输入 fibonacci。然后找到配置部分下的脚本路径字段,并单击其右侧的文件夹图标。单击此图标后,文件对话框应该会打开,位于项目根文件夹内,正好是我们添加了 fibonacci.py 文件的地方。选择此文件作为脚本路径。您的新配置对话框应该类似于 图 14。点击 确定

图片

图 14:运行配置参数

你已经成功创建了一个运行配置。让我们使用它。

使用运行配置

在导航栏的右侧,找到运行配置选择器。图 15 展示了这个选择器。

图片

图 15:运行配置选择器

在下拉列表中,选择你刚刚创建的运行配置,然后点击绿色播放按钮执行它。你应该会在 IDE 的终端看到以下消息:

the 30th Fibonacci number is 832040

Process finished with exit code 0

你也可以通过菜单来启动运行配置,选择运行运行‘fibonacci’

我们已成功使用运行配置启动了我们的fibonacci.py 脚本。现在让我们使用它来学习如何调试 Python 代码。

调试 Python 代码

当我们的程序出现异常而我们不知道原因时,可以调试程序。要调试程序,我们可以逐行执行它,一次一步,检查变量的值。

在调试脚本之前,我们稍微修改一下斐波那契函数。假设该函数的用户抱怨它在处理大数字时太慢。例如,他们说在计算第 50 个斐波那契数时,必须等待几分钟:

# this will fry your CPU... be prepared to wait
>>> fibonacci(50)

经过仔细分析,我们意识到目前实现的斐波那契函数可以通过缓存已计算的斐波那契数来改进,这样可以避免反复计算。为了加速执行,我们决定将已计算的数存储在字典中。请像这样修改你的代码:

cache = {}

def fibonacci(n):
    if n < 3:
        return 1

    if n in cache:
        return cache[n]

    cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
    return cache[n]

fib_30 = fibonacci(30)
print(f'the 30th Fibonacci number is {fib_30}')

在我们开始调试之前,尝试再次运行脚本,确保它仍然能够产生预期的结果。你可以进一步尝试计算第 50 个数字:这次它将在毫秒内完成计算。如下所示:

--snip--

fib_50 = fibonacci(50)
print(f'the 50th Fibonacci number is {fib_50}')

结果如下:

the 50th Fibonacci number is 12586269025

Process finished with exit code 0

现在让我们在调用函数的那一行停止执行:

fib_50 = fibonacci(50)

为此,我们需要设置一个断点,在此断点处,Python 解释器会停止执行。你可以通过两种方式设置断点:要么点击编辑器中你想停止的位置,稍微靠近行号右侧(在图 16 中可以看到出现的点),要么点击行中的任何位置,然后从菜单中选择运行切换断点行断点

如果你成功添加了断点,你应该会看到一个点,像图 16 中那样。

图片

图 16:在代码中设置断点

要在调试模式下启动斐波那契运行配置,除了点击绿色播放按钮外,你还可以点击红色的调试按钮(参见图 15)或从菜单选择运行调试‘fibonacci’

PyCharm 启动我们的脚本并检查断点;一旦找到一个断点,它会在执行该行之前停止执行。你的 IDE 应该在设置断点的那一行暂停了执行,并显示调试器控制项,如图 17 所示。

图片

图 17:PyCharm 的调试器

调试器的顶部有一个控制条,用来控制程序的执行(参见图 18)。有一些图标,但我们主要关注前两个:步过(Step over)和步入(Step into)。选择“步过”选项时,我们可以执行当前行并跳到下一行。而“步入”选项则进入当前行的函数体。稍后我们会更详细地了解这两个选项。

调试器的右侧有一个变量面板,我们可以检查程序的当前状态:所有现有变量的值。例如,我们可以看到图 17 中的缓存变量,它目前是一个空字典。

图片

图 18:调试器执行控制

现在让我们点击调试器中执行控制部分的“步入”图标。执行进入fibonacci函数体并在它的第一条指令处停止(参见图 19)。

图片

图 19:进入 fibonacci 函数

调试器的变量面板现在显示了n变量及其当前值 50。这个值也出现在fibonacci函数定义旁边,正如你在图 19 中看到的那样(两个位置都用箭头标出)。

调试器的左侧显示了帧面板。此面板包含程序的堆栈帧。每当执行一个函数时,都会将一个新的帧推入堆栈,其中包含该函数的局部变量及一些其他信息。你可以通过点击一个帧来回溯时间,查看该函数被调用之前程序的状态。例如,你可以点击、fibonacci.py:15 堆栈帧来回到fibonacci函数被调用之前。要回到当前执行点,只需点击最顶部的堆栈帧,这里是fibonacci,位置是 fibonacci.py:5。

尝试继续使用“步过”(Step over)和“步入”(Step into)控制来调试程序。确保观察cachen变量,它们的值会发生变化。实验完成后,要停止调试会话,你可以选择执行程序中的所有指令直到完成,或者点击调试器中的停止按钮。你也可以从菜单中选择运行停止‘fibonacci’,或者点击调试器左侧的红色方块图标。

让我们再做最后一个调试练习。再次以调试模式运行程序;当执行在断点处停止时,点击“逐步跳过”图标。检查“变量”面板中的缓存变量。如你所见,缓存现在已填充所有从 3 到 50 的斐波那契数。你可以展开字典查看其中的所有值,如图 20 所示。

Image

图 20:调试器变量

你还可以使用调试器的控制台与程序的当前状态进行交互(见图 21)。在调试器视图中,点击“控制台”标签,它位于“调试器”标签旁边。在这个控制台中,你可以与当前程序的状态进行交互,做一些事情,例如检查一个给定的斐波那契数是否已经被缓存:

>>> 12 in cache
True

Image

图 21:调试器控制台

总结

在本引言章节中,我们已经了解了本书的内容以及你需要的前置条件,以便跟随并最大限度地利用它。我们还安装了 Python 并配置了环境,以便在全书中有效工作。

上一节是对 PyCharm 及其强大调试工具的简要介绍,但正如你想象的那样,我们才刚刚触及表面。要了解更多关于 PyCharm 调试的信息,可以快速浏览官方文档:https://www.jetbrains.com/help/pycharm/debugging-code.html

现在,让我们开始学习 Python。

第一部分:基础**

第一章:简短的 Python 入门

Image

在本章中,我们将查看一些我们将在全书中使用的 Python 特性。这不是 Python 的介绍;我假设你已经具备了基本的语言理解。如果你没有基础,有很多优秀的书籍和在线教程可以帮助你入门。

我们将首先探讨如何将 Python 代码拆分成包,并将这些包导入到我们的程序中。我们将学习如何文档化 Python 代码,以及如何使用 Python 查阅这些文档。接着,我们将回顾元组、列表、集合和字典,这些是最常用的 Python 集合类型。

Python 包和模块

合理大小的软件项目通常包含大量源文件,也称为模块。一组相关的 Python 模块被称为。让我们通过讨论这两个概念:模块和包,来开始我们对 Python 的探讨。

模块

一个 Python 模块是一个包含 Python 代码的文件,该代码旨在被其他 Python 模块或脚本导入。另一方面,脚本是一个旨在被执行的 Python 文件。

Python 模块允许我们在文件之间共享代码,从而避免重复编写相同的代码。

每个 Python 文件都可以访问一个名为 name 的全局变量。这个变量可以有两个可能的值:

  • 模块的名称,即文件名,去掉 .py 扩展名

  • 字符串 'main'

Python 根据文件是被其他模块导入还是作为脚本运行来确定 name 的值。当模块被导入到另一个模块或脚本中时,name 被设置为模块的名称。如果我们将模块作为脚本运行,例如,

$ python3 my_module.py

然后,name 的值会被设置为 'main'。这现在可能显得有些抽象,但我们将在本章稍后解释为什么我们关心 name 这个全局变量。如我们所见,知道一个给定的模块是作为脚本执行还是被导入是一个重要的信息,我们需要考虑。

随着我们为项目编写越来越多的 Python 模块,将它们按功能分组是有意义的。这些模块组被称为

一个 是一个包含 Python 模块和一个特殊文件的目录,该文件的名称必须是 init.py。Python 解释器会将任何包含 init.py 文件的文件夹理解为一个包。

例如,像这样的文件夹结构:

geom2d

|- init.py

|- point.py

|- vector.py

是一个名为geom2d的 Python 包,包含两个文件或模块:point.pyvector.py

每当从包中导入内容时,init.py 文件都会被执行。这意味着init.py 文件可以包含 Python 代码,通常是初始化代码。然而,大多数时候,这个init.py 文件是空的。

运行文件

当 Python 导入一个文件时,它会读取该文件的内容。如果该文件只包含函数和数据,Python 只会加载这些定义,但不会实际执行代码。然而,如果文件中有顶层指令或函数调用,Python 会在导入过程中执行它们——这是我们通常不希望发生的。

之前,我们看到当文件被运行时(与导入不同),Python 会将__name__全局变量设置为字符串’main’。我们可以利用这一点来确保只有在文件被运行时才执行主要逻辑,而在文件被导入时则不执行:

if __name__ == '__main__':
    # only executes if file is run, not imported

我们将这种模式称为“if name is main”模式,并且在本书中我们将使用这一模式来编写应用程序。

请记住,当文件被导入时,Python 会将__name__变量设置为该模块的名称。

导入代码

假设你有一些 Python 代码,你希望在多个文件中使用它。一种方法是每次需要使用它时都复制粘贴代码。这不仅会显得繁琐和无聊,而且想象一下如果你改变了代码的某些功能:你需要打开每个粘贴了代码的文件,并以相同的方式进行修改。正如你能想象的,这并不是一种高效的软件编写方式。

幸运的是,Python 提供了一个强大的代码共享系统:导入模块。当module_b导入module_a时,module_b可以访问module_a中编写的代码。这让我们可以在一个地方编写算法,然后在多个文件中共享这些代码。让我们看一个示例,使用我们将在本书下一部分编写的两个模块。

假设我们有两个模块:point.pyvector.py。这两个模块位于我们之前看到的包中:

geom2d

|- init.py

|- point.py

|- vector.py

第一个模块名为point.py,它定义了几何原始类型 Point,第二个模块vector.py定义了另一个几何原始类型 Vector。图 1-1 展示了这两个模块。每个模块分为两个部分:一部分是灰色的,表示该模块从其他地方导入的代码;另一部分是白色的,表示模块本身定义的代码。

Image

图 1-1:两个 Python 模块

现在,假设我们需要我们的point.py模块实现一些使用 Vector 的功能(比如,按给定的向量移动一个点)。我们可以通过 Python 的 import 命令访问 vector.py 中的 Vector 代码。图 1-2 展示了这一过程,它将 Vector 代码引入到point.py模块的“导入”部分,使其在整个模块中都可以使用。

Image

图 1-2:从 vector.py 导入 Vector 类

在图 1-2 中,我们使用了以下 Python 命令:

    from vector import Vector

这个命令仅从vector.py中引入了 Vector 类。我们并没有引入vector.py中定义的其他任何内容。

正如你将在下一部分看到的那样,导入模块有几种方式。

不同的导入形式

为了理解我们可以如何导入模块和模块中的名称,我们来使用我们力学项目中的两个包。

Mechanics

|- geom2d

|    |- init.py

|    |- point.py

|    |- vector.py

|

|- eqs

|    |- init.py

|    |- matrix.py

|    |- vector.py

在这个例子中,我们将使用geom2deqs两个包,每个包内都有两个文件或模块。每个模块定义一个类,类的名称与模块名称相同,只是首字母大写。例如,point.py模块定义了 Point 类,vector.py定义了 Vector 类,matrix.py定义了 Matrix 类。图 1-3 展示了这个包的结构。

Image

图 1-3:来自我们力学项目的两个包及其部分模块

通过在心中建立这个目录结构,让我们分析几个场景。

从同一包中导入模块

如果我们在包geom2d中的模块point.py中,并且想要导入整个vector.py模块,我们可以使用以下代码:

import vector

现在我们可以像下面这样使用vector.py模块的内容:

v = vector.Vector(1, 2)

需要注意的是,由于我们导入了整个模块,而不是其中的单独实体,因此我们必须使用模块名称来引用模块定义的实体。如果我们想使用不同的名称来引用该模块,我们可以为其起别名:

import vector as vec

然后我们可以像这样使用它:

v = vec.Vector(1, 2)

我们还可以只导入模块中的特定名称,而不是导入整个模块。正如你之前看到的,语法如下:

from vector import Vector

使用这个导入,我们可以改为执行以下操作:

v = Vector(1, 2)

在这种情况下,我们还可以为导入的名称起别名:

from vector import Vector as Vec

当我们别名一个导入的名称时,我们只是将它重命名为其他名字。在这种情况下,我们现在可以这样写:

v = Vec(1, 2)
从不同包中导入模块

如果我们想从不同的包中导入matrix.py模块内的point.py模块,我们可以做如下操作:

import geom.point

或者等效地

from geom import point

这样,我们就可以在matrix.py中使用整个point.py模块:

p = point.Point(1, 2)

再次强调,我们可以选择为导入的模块起别名:

import geom.point as pt

或者等效地

from geom import point as pt

无论哪种方式,我们都可以像这样使用 pt:

p = pt.Point(1, 2)

我们也可以从模块中导入名称,而不是导入整个模块,方法如下:

from geom.point import Point

p = Point(1, 2)

如之前所示,我们可以使用别名:

from geom.point import Point as Pt

p = Pt(1, 2)
相对导入

最后,我们有相对导入。相对导入是指使用以文件当前所在位置为起点的路径来引用模块。

我们使用一个点(.)来引用同一包中的模块或包,使用两个点(..)来引用父目录。

根据我们之前的示例,我们可以通过相对导入的方式,从matrix.py中导入point.py模块:

from ..geom.point import Point

p = Point(1, 2)

在这种情况下,路径..geom.point 的意思是:从当前目录移动到父目录,然后寻找point.py模块。

使用文档字符串文档化代码

当我们编写其他开发者会使用的代码时,良好的实践是进行文档化。这些文档应包括如何使用我们的代码、代码做出了哪些假设以及每个函数的功能。

Python 使用 文档字符串 来记录代码。这些文档字符串被定义在三重引号(""")之间,并出现在它们所记录的函数、类或模块的第一条语句中。

你可能已经注意到,你之前下载的 Mechanics 项目的代码如何使用这些文档字符串。例如,如果你打开 matrix.py 文件,Matrix 类的方法就是这样进行文档化的:

def set_data(self, data: [float]):
    """
    Sets the given list of 'float' numbers as the values of
    the matrix.

    The matrix is filled with the passed in numbers from left
    to right and from top to bottom.
    The length of the passed in list has to be equal to the
    number of values in the matrix: rows x columns.

    If the size of the list doesn't match the matrix number
    of elements, an error is raised.

    :param data: [float] with the values
    :return: this Matrix
    """
    if len(data) != self.__cols_count * self.__rows_count:
        raise ValueError('Cannot set data: size mismatch')

    for row in range(self.__rows_count):
        offset = self.__cols_count * row
        for col in range(self.__cols_count):
            self.__data[row][col] = data[offset + col]

    return self

如果你在使用这段代码时遇到问题,Python 提供了 help 全局函数;如果你将 help 应用于模块、函数、类或方法,它将返回该代码的文档字符串。例如,我们可以在 Python 解释器控制台中这样获取 set_data 方法的文档:

>>> from eqs.matrix import Matrix
>>> help(Matrix.set_data)

Help on function set_data in module eqs.matrix:
set_data(self, data: [<class 'float'>])
    Sets the given list of 'float' numbers as the values of
    the matrix.

    The matrix is filled with the passed in numbers from left
    to right and from top to bottom.
    The length of the passed in list has to be equal to the
    number of values in the matrix: rows x columns.

    If the size of the list doesn't match the matrix number
    of elements, an error is raised.

    :param data: [float] with the values
    :return: this Matrix

有一些自动化工具,比如 Sphinx(https://www.sphinx-doc.org/),可以使用项目中的文档字符串生成 HTML、PDF 或纯文本的文档报告。你可以将这些文档与代码一起分发,方便其他开发者开始学习你编写的代码。

在本书中我们不会编写文档字符串,因为它们占用的空间比较大。但它们应该都包含在你下载的代码中,你可以在那里查看。

Python 中的集合

我们的程序经常需要处理一系列项目,有时这些集合非常庞大。我们希望以便捷的方式存储这些项目。有时我们会关心某个集合是否包含特定项目,而有时我们需要知道项目的顺序;我们也可能希望有一种快速查找给定项目的方法,或许是找到一个满足特定条件的项目。

正如你所看到的,处理集合项的方式有很多种。事实证明,选择正确的数据存储方式对我们的程序性能至关重要。每种集合都有其适用的场景;知道在每个特定情境下使用哪种类型的集合是每个软件开发者应当掌握的重要技能。

Python 提供了四种主要的集合:集合、元组、列表和字典。接下来我们将解释每种集合如何存储元素以及如何使用它们。

集合

集合是一个无序的独特元素集合。当我们需要快速确定某个元素是否存在于集合中时,集合非常有用。

要在 Python 中创建一个集合,我们可以使用 set 函数:

>>> s1 = set([1, 2, 3])

我们也可以使用字面量语法:

>>> s1 = {1, 2, 3}

请注意,当使用字面量语法时,我们通过花括号({})来定义集合。

我们可以使用全局的 len 函数来获取集合中包含的元素数量:

>>> len(s1)
3

检查某个元素是否存在于集合中是一个快速操作,可以使用 in 运算符来完成:

>>> 2 in s1
True

>>> 5 in s1
False

我们可以使用 add 方法向集合中添加新元素:

>>> s1.add(4)
# the set is now {1, 2, 3, 4}

如果我们尝试添加一个已经存在的元素,什么也不会发生,因为集合不允许重复元素:

>>> s1.add(3)
# the set is still {1, 2, 3, 4}

我们可以使用 remove 方法从集合中移除一个元素:

>>> s1.add(3)
>>> s1.remove(1)
# the set is now {2, 3, 4}

我们可以使用熟悉的数学操作处理集合。例如,我们可以计算两个集合的差集,即包含第一个集合中不在第二个集合中的元素的集合:

>>> s1 = set([1, 2, 3])
>>> s2 = set([3, 4])
>>> s1.difference(s2)
{1, 2}

我们还可以计算两个集合的并集,即包含出现在两个集合中的所有元素的集合:

>>> s1 = set([1, 2, 3])
>>> s2 = set([3, 4])
>>> s1.union(s2)
{1, 2, 3, 4}

我们可以遍历集合,但遍历的顺序是不确定的:

>>> for element in s1:
...     print(element)
...
3
1
2

元组

元组是不可变且有序的元素序列。不可变意味着,一旦创建,元组无法以任何方式更改。元组中的元素是通过它们所占的索引来引用的,索引从零开始。在 Python 中,计数总是从零开始。

当我们在代码中传递有序数据的集合时,元组是一个不错的选择,因为它们不会被任何方式改变。例如,在如下代码中:

>>> names = ('Anne', 'Emma')
>>> some_function(names)

你可以确信,names元组不会被某个函数(如 some_function)以任何方式修改。相反,如果你决定使用一个集合,比如:

>>> names = set('Anne', 'Emma')
>>> some_function(names)

什么也不会阻止 some_function 向传入的names集合中添加或移除元素,因此你需要检查函数的代码,了解代码是否会改变这些元素。

注意

无论如何,正如我们稍后看到的,函数不应该修改它们的参数,所以我们在本书中写的函数将永远不会修改它们的输入参数。尽管如此,你可能会使用由其他开发者编写的函数,而这些开发者没有遵循同样的规则,因此你需要检查这些函数是否有这种副作用。

元组是用圆括号定义的,元组中的元素是用逗号分隔的。这里有一个元组,使用字面量语法定义,包含了我的名字和年龄:

>>> me = ('Angel', 31)

如果我们想创建一个只有一个元素的元组,我们需要在元素后写一个逗号:

>>> name = ('Angel',)

还可以通过 tuple 函数创建元组,传入一个包含元素的列表:

>>> me = tuple(['Angel', 31])

我们可以使用 len 全局函数获取元组中元素的数量:

>>> len(count)
2

我们还可以使用元组的 count 方法计算某个值在元组中出现的次数:

>>> me.count('Angel')
1

>>> me.count(50)
0

>>> ('hey', 'hey', 'hey').count('hey')
3

我们可以使用 index 方法获取某个项第一次出现的索引:

>>> family = ('Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel', 'Alvaro')
>>> family.index('Alvaro')
1

在这个例子中,我们要查找字符串'Alvaro'的索引,它出现了两次:分别在索引 1 和索引 5。index 方法返回第一次出现的索引,在这个例子中是 1。

可以使用 in 运算符检查某个元素是否存在于元组中:

>>> 'Isabel' in family
True

>>> 'Elena' in family
False

元组可以被数字乘以,这是一个特殊的操作,会生成一个新元组,原始元素会按照乘数重复:

>>> ('ruby', 'ruby') * 4
('ruby', 'ruby', 'ruby', 'ruby', 'ruby', 'ruby', 'ruby', 'ruby')

>>> ('we', 'found', 'love', 'in', 'a', 'hopeless', 'place') * 16
('we', 'found', 'love', 'in', 'a', 'hopeless', 'place', 'we', 'found', ...

我们可以使用 for 循环遍历元组的值:

>>> for city in ('San Francisco', 'Barcelona', 'Pamplona'):
...     print(f'{city} is a beautiful city')
...
San Francisco is a beautiful city
Barcelona is a beautiful city
Pamplona is a beautiful city

使用 Python 的内建 enumerate 函数,我们可以遍历元组中的项及其索引:

>>> cities = ('Pamplona', 'San Francisco', 'Barcelona')
>>> for index, city in enumerate(cities):
...     print(f'{city} is #{index + 1} in my favorite cities list')
...
Pamplona is #1 in my favorite cities list
San Francisco is #2 in my favorite cities list
Barcelona is #3 in my favorite cities list

列表

列表 是一个有序的非唯一元素集合,通过它们的索引进行引用。列表非常适合需要按顺序保存元素并且我们知道它们出现位置的情况。

列表和元组相似,唯一的区别是元组是不可变的;列表中的元素可以移动,可以添加和删除元素。如果你确定一个大型集合中的元素不会被修改,使用元组而不是列表;元组的操作比列表的操作更快。如果 Python 知道集合中的元素不会改变,它可以做一些优化。

在 Python 中创建一个列表,我们可以使用 list 函数:

>>> l1 = list(['a', 'b', 'c'])

或者我们可以使用字面量语法:

>>> l1 = ['a', 'b', 'c']

注意使用方括号([])的方式。

我们可以使用 len 函数来检查列表中元素的数量:

>>> len(l1)
3

列表元素可以通过索引访问(第一个元素的索引是零):

>>> l1[1]
'b'

我们还可以替换列表中的现有元素:

>>> l1[1] = 'm'
# the list is now ['a', 'm', 'c']

小心不要使用列表中不存在的索引;这会引发 IndexError:

>>> l1[35] = 'x'
Traceback (most recent call last):
  File "<input>", line 1, in <module>
IndexError: list assignment index out of range

可以使用 append 方法将项目追加到列表的末尾:

>>> l1.append('d')
# the list is now ['a', 'm', 'c', 'd']

列表可以被迭代,且迭代的顺序是有保证的:

>>> for element in l1:
...     print(element)
...
a
m
c
d

很多时候,我们不仅对元素本身感兴趣,还对它在列表中的索引感兴趣。在这种情况下,我们可以使用 enumerate 函数,它返回一个包含索引和元素的元组:

>>> for index, element in enumerate(l1):
...     print(f'{index} -> {element}')
...
0 -> a
1 -> m
2 -> c
3 -> d

可以通过从另一个列表中取连续元素来创建新列表。这个过程叫做 切片。切片是一个重要的话题,需要单独的一节来讲解。

切片列表

切片列表看起来有点像使用方括号进行索引,只是我们使用两个由冒号分隔的索引:[ : ]。以下是一个例子:

>>> a = [1, 2, 3, 4]
>>> b = a[1:3]
# list b is [2, 3]

在前面的例子中,我们有一个包含值 [1, 2, 3, 4] 的列表 a。我们通过切片原始列表,创建了一个新列表 b,从索引 1(包含)开始,到索引 3(不包含)结束。

注意

不要忘记,Python 中的切片总是包括起始索引的元素,并且排除结束索引的元素。

图 1-4 展示了这一过程。

图片

图 1-4:切片一个列表

切片操作符中的起始和结束索引是可选的,因为它们有默认值。默认情况下,起始索引被赋值为列表中的第一个索引,始终为零。结束索引被赋值为列表中的最后一个索引加一,这等于 len(the_list)。

>>> a = [1, 2, 3, 4]

# these two are equivalent:
>>> b_1 = a[0:4]
>>> b_2 = a[:]

在这个例子中,b_1 和 b_2 列表都是原始 a 列表的副本。我们所说的副本意味着它们是不同的列表;你可以安全地修改 b_1 或 b_2,而列表 a 保持不变。你可以通过以下方式来验证:

>>> a = [1, 2, 3, 4]
>>> b = a[:]
>>> b[0] = 55

>>> print('list a:', a)
list a: [1, 2, 3, 4]

>>> print('list b:', b)
list b: [55, 2, 3, 4]

负索引是你可以使用的另一个技巧。负索引是从列表的末尾开始计算并向列表的开头移动的索引。负索引可以像正索引一样用于切片操作,唯一的区别是:负索引从 -1 开始,而不是从 -0 开始。例如,我们可以通过以下方式切片列表,获取它的最后两个值:

>>> a = [1, 2, 3, 4]
>>> b = a[-2:]
# list b is [3, 4]

在这里,我们正在创建一个新列表,从倒数第二个位置开始,一直到列表的最后一个元素。图 1-5 展示了这一点。

切片列表是 Python 中的一项多功能操作。

Image

图 1-5:使用负索引切片列表

字典

一个 字典 是由键值对组成的集合。字典中的值与它们的键相关联;我们通过键从字典中检索元素。在字典中查找值的速度非常快。

当我们想存储由某些键引用的元素时,字典非常有用。例如,如果我们想存储关于兄弟姐妹的信息,并且希望能通过兄弟姐妹的名字来检索这些信息,我们可以使用字典。我们将在接下来的代码中查看这一点。

在 Python 中创建字典,你可以使用 dict 函数,

>>> colors = dict([('stoke', 'red'), ('fill', 'orange')])

或者使用字面量语法,

>>> colors = {'stoke': 'red', 'fill': 'orange'}

dict 函数期望传入一个包含元组的列表。这些元组应包含两个值:第一个值作为键,第二个值作为值。创建字典的字面量版本要简洁得多,并且在两种情况下,最终生成的字典是一样的。

和列表一样,我们可以使用方括号访问字典中的值。然而,这次我们在方括号中使用的是值的键,而不是索引:

>>> colors['stroke']
red

你可以使用任何不可变的对象作为字典中的键。记住,元组是不可变的,而列表则不是。数字、字符串和布尔值也是不可变的,因此可以用作字典键。

让我们创建一个字典,键是元组:

>>> ages = {('Angel', 'Sola'): 31, ('Jen', 'Gil'): 30}

在这个例子中,我们将年龄映射到由名字和姓氏组成的键(一个元组)。如果我们想知道 Jen 的年龄,我们可以通过使用其键在字典中获取相应的值:

>>> age = ages[('Jen', 'Gil')]
>>> print(f'she is {age} years old')
she is 30 years old

当我们查找一个字典中不存在的键时,会发生什么?

>>> age = ages[('Steve', 'Perry')]
Traceback (most recent call last):
  File "<input>", line 1, in <module>
KeyError: ('Steve', 'Perry')

我们会得到一个错误。我们可以在获取字典中的值之前,使用 in 操作符检查键是否存在:

>>> ('Steve', 'Perry') in ages
False

我们也可以获得一个类似集合的视图,包含字典中的所有键:

>>> ages.keys()
dict_keys([('Angel', 'Sola'), ('Jen', 'Gil')])

我们可以对值做同样的操作:

>>> ages.values()
dict_values([31, 30])

我们可以使用 in 操作符检查字典中键和值的存在情况:

>>> ('Jen', 'Gil') in ages.keys()
True

>>> 45 in ages.values()
False

字典可以通过几种方式进行遍历。假设我们有以下的年龄字典:

>>> ages = {'Angel': 31, 'Jen': 30}

我们可以使用 for 循环遍历字典的键:

>>> for name in ages.keys():
...     print(f'we have the age for {name}')
...
we have the age for Angel
we have the age for Jen

我们可以对值做同样的操作:

>>> for age in ages.values():
...     print(f'someone is {age} years old')
...
someone is 31 years old
someone is 30 years old

我们也可以对键值元组做同样的操作:

>>> for name, age in ages.items():
...     print(f'{name} is {age} years old')
...
Angel is 31 years old
Jen is 30 years old

目前为止,这就是我们需要了解的 Python 集合内容。接下来让我们继续 Python 之旅,学习如何解构集合。

解构

解构拆解 是一种技术,它允许我们将集合中的值分配给变量。让我们看一些例子。

假设我们有一个包含某个人信息的元组,其中包括她的姓名和最喜欢的饮料:

>>> anne_info = ('Anne', 'grape juice')

假设我们想将这两条信息分配给两个单独的变量。我们可以这样分开:

>>> name = anne_info[0]
>>> beverage = anne_info[1]

这样做完全可以,但我们可以使用解构语法以更优雅的方式来实现。为了将元组中的两个字符串解构到两个变量中,我们需要在赋值的左侧使用另一个元组,包含变量名:

>>> (name, beverage) = anne_info

>>> name
'Anne'

>>> beverage
>>> 'grape juice'

我们也可以解构列表。例如,如果我们有一个列表,包含另一个人的类似信息,如

>>> emma_info = ['Emma', 'hot chocolate']

我们可以使用左侧的列表解构出姓名和最喜欢的饮料:

>>> [name, beverage] = emma_info

>>> name
'Emma'

>>> beverage
'hot chocolate'

左侧的元组或列表必须与右侧的大小匹配,但有时我们并不关心解构出来的所有值。在这种情况下,可以在不想获取对应值的位置使用下划线。例如,

[a, _, c] = [1, 2, 3]

将值 1 分配给变量 a,将值 3 分配给变量 c,但会丢弃值 2。

这是一种帮助我们编写更简洁代码的技巧。

总结

本章介绍了一些中级和高级的 Python 技巧,这些技巧我们将在整本书中使用。我们了解了 Python 程序是由模块组成,这些模块打包成包,并且如何从代码的其他部分导入这些模块。

我们还解释了“if name is main”模式,它用于避免在文件被导入时执行部分代码。

然后,我们简要介绍了四种基本的 Python 集合:元组、列表、集合和字典。我们还学习了如何解构或拆解这些集合。

现在让我们换个话题,谈谈几种编程范式。

第二章:两种 Python 编程范式

图片

现在我们已经探讨了一些 Python 编程语言的主题,接下来让我们了解可以用来编写代码的两种主要范式。在本章的第二部分中,我们将讨论函数式编程和面向对象编程范式,并探讨每种范式带来的好处。最后,我们将简要介绍类型提示。让我们开始吧。

函数式编程

函数式编程是一种编程范式,意味着它是一种我们可以选择遵循的编写代码的风格。要说“我们正在编写函数式风格的代码”,我们必须遵循一些简单的规则,来定义什么是函数式编程。

函数式编程范式的核心元素是纯函数和数据的不可变性。我们将在接下来的章节中详细分析这些概念。

不是所有的编程语言都能很好地支持函数式风格的编程。例如,像 C 这样的语言对其支持并不好。另一方面,也有一些语言,比如 Haskell,完全是函数式的,意味着你只能编写函数式风格的代码。按设计,Python 不是一门函数式语言,但它确实支持函数式编程风格。

让我们来了解一下纯函数。

纯函数

让我们快速回顾一下 Python 函数的语法:

def function_name(parameters):
    <function body>

函数的定义以 def 关键字开始,后面是函数名和括号中的输入参数。冒号(:)标志着函数头的结束。函数体中的代码需要缩进一级。

在函数式编程范式中,函数类似于数学中函数的概念:将某些输入映射到某些输出。如果一个函数是的,我们就说它是纯函数:

  • 对于相同的一组输入,它始终返回相同的输出。

  • 它没有副作用。

副作用 是指函数体外的某些东西被函数改变了。当函数修改输入时,也会发生副作用,因为纯函数永远不会修改其输入。例如,以下函数就是纯函数:

def make_vector_between(p, q):
    u = q['x'] - p['x']
    v = q['y'] - p['y']

    return {'u': u, 'v': v}

给定相同的输入点 p 和 q,输出始终是相同的向量,且函数体外的任何内容都没有被修改。相比之下,以下代码是 make_vector 的不纯版本:

last_point = {'x': 10, 'y': 20}

def make_vector(q):
    u = q['x'] - last_point['x']
    v = q['y'] - last_point['y']
    new_vector = {'u': u, 'v': v}
    last_point = q

    return new_vector

上面的代码片段使用了 last_point 的共享状态,每次调用 make_vector 时都会改变这个状态。这种变化是函数的副作用。返回的向量依赖于 last_point 的共享状态,因此该函数对于相同的输入点不会始终返回相同的向量。

不可变性

如你在之前的例子中所见,函数式编程的一个关键特点是不可变性。如果某个事物随时间不会改变,我们就称它为不可变的。如果我们决定使用函数式编程风格编写代码,那么我们就要坚定地避免数据变更,并通过纯函数来建模我们的程序。

让我们来看一个例子。假设我们使用字典在平面上定义了一个点和一个向量:

point = {'x': 5, 'y': 2}
vector = {'u': 10, 'v': 20}

如果我们想计算通过向量位移现有点得到的新点,可以通过创建一个新点的函数来以函数式的方式实现。下面是一个例子:

def displaced_point(point, vector):
    x = point['x'] + vector['u']
    y = point['y'] + vector['v']

    return {'x': x, 'y': y}

这个函数是纯粹的:给定相同的点和向量输入,结果的位移点始终相同,而且函数体内没有任何被变更的内容,甚至函数参数也没有被改变。

如果我们运行这个函数,并传入之前定义的点和向量,我们将得到如下结果:

>>> displaced_point(point, vector)
{'x': 15, 'y': 22}

# let's check the state of point (shouldn't have been mutated)
>>> point
{'x': 5, 'y': 2}

相反,解决这个问题的一种非函数式方式可能会涉及使用如下的函数来修改原始点:

def displace_point_in_place(point, vector):
    point['x'] += vector['u']
    point['y'] += vector['v']

这个函数会修改它接收到的点,这违反了函数式编程风格的一个关键规则。

请注意函数名中使用了 in_place。这是一个常见的命名约定,表示变化将通过修改原始对象来发生。在本书中,我们将遵循这一命名约定。

现在让我们看看如何使用这个 displace_point_in_place 函数:

>>> displace_point_in_place(point, vector)
# nothing gets returned from the function, so let's check the point

>>> point
{'x': 15, 'y': 22}
# the original point has been mutated!

正如你所见,这个函数没有返回任何值,这是一个迹象,表明该函数不是纯函数,因为要进行某种有用的操作,它必须修改某些内容。在这种情况下,这个“某些内容”就是我们的点,它的坐标已经被更新。

函数式编程的一大优势是,通过尊重数据结构的不变性,我们避免了意外的副作用。当你修改一个对象时,你可能并不清楚代码中所有引用该对象的地方。如果代码中的其他部分依赖于该对象的状态,可能会出现你未察觉的副作用。因此,在对象被修改后,程序的行为可能会与预期不同。这类错误极难追踪,可能需要数小时的调试。

如果我们最小化项目中的变更次数,我们将使其更加可靠,减少错误的发生。

现在让我们来看一下在函数式编程中占据核心地位的一类特殊函数:lambda 函数。

Lambda 函数

回到 20 世纪 30 年代,一位名叫阿隆佐·丘奇的数学家发明了 lambda 演算,这是关于函数以及函数如何应用于其参数的理论。Lambda 演算是函数式编程的核心。

在 Python 中,lambda 函数,或者称为lambda,是一个匿名的、通常是短小的一行函数。我们会发现,lambda 在将函数作为参数传递给其他函数时非常有用。

我们在 Python 中定义一个 lambda 函数,使用 lambda 关键字,后跟参数(用逗号分隔),冒号和函数的表达式体:

    lambda <arg1>, <arg2>, ...: <expression body>

表达式的结果就是返回值。

求两个数之和的 lambda 函数可以写成如下形式:

>>> sum = lambda x, y: x + y
>>> sum(1, 2)
3

这等同于常规的 Python 函数:

>>> def sum(x, y):
...     return x + y
...
>>> sum(1, 2)
3

Lambda 函数将在接下来的章节中出现;我们将看到它们如何在多个场景中使用。我们最常使用 lambda 函数的地方是作为 filter、map 和 reduce 函数的参数,正如我们在“Filter、Map 和 Reduce”中讨论的那样,见 第 29 页。

高阶函数

高阶函数是指一个函数,它要么接收一个(或多个)函数作为输入参数,要么返回一个函数作为结果。

让我们来看两个情况的例子。

函数作为函数参数

假设我们想写一个函数,使其能够运行另一个函数指定的次数。我们可以这样实现:

>>> def repeat_fn(fn, times):
...     for _ in range(times):
...         fn()
...

>>> def say_hi():
...     print('Hi there!')
...

>>> repeat_fn(say_hi, 5)
Hi there!
Hi there!
Hi there!
Hi there!
Hi there!

如你所见,repeat_fn 函数的第一个参数是另一个函数,它会根据第二个参数指定的次数执行。然后,我们定义了另一个函数来简单地打印字符串“Hi there!”到屏幕上:say_hi。调用 repeat_fn 函数并传递 say_hi 的结果就是这五次问候。

我们可以使用匿名 lambda 函数来重写前面的例子:

>>> def repeat_fn(fn, times):
...     for _ in range(times):
...         fn()
...

>>> repeat_fn(lambda: print("Hello!"), 5)
Hello!
Hello!
Hello!
Hello!
Hello!

这让我们不必定义一个命名函数来打印信息。

函数作为函数返回值

让我们看一个返回另一个函数的例子。假设我们想定义一些验证函数,用于验证给定的字符串是否包含某个字符序列。我们可以写一个名为 make_contains_validator 的函数,它接收一个字符序列并返回一个函数来验证包含该序列的字符串:

>>> def make_contains_validator(sequence):
...     return lambda string: sequence in string

我们可以使用这个函数来生成验证函数,例如以下的这个,

>>> validate_contains_at = make_contains_validator('@')

这个可以用来检查传入的字符串是否包含 @ 字符:

>>> validate_contains_at('foo@bar.com')
True
>>> validate_contains_at('not this one')
False

高阶函数是一个非常有用的工具,我们将在本书中多次使用它。

函数嵌套在其他函数内

我们在本书中还将使用的另一种便捷技巧是将函数定义在另一个函数内。我们可能会这么做的两个主要原因是:首先,它让内部函数可以访问外部函数内的所有内容,而无需将这些信息作为参数传递;其次,内部函数可能定义了一些我们不想暴露给外部的逻辑。

可以使用常规语法在一个函数内部定义另一个函数。让我们来看一个例子:

def outer_fn(a, b):
    c = a + b

    def inner_fn():
        # we have access to a, b and c here
        print(a, b, c)

    inner_fn()

在这里,inner_fn 函数是在 outer_fn 函数内部定义的,因此它不能从外部访问,只能在其函数体内访问。inner_fn 函数可以访问 outer_fn 内定义的所有内容,包括函数参数。

在函数内部定义子函数在函数逻辑变得复杂并且可以分解成更小任务时非常有用。当然,我们也可以将函数拆分成多个在同一层级定义的小函数。在这种情况下,为了表明这些子函数并非用于外部导入和使用,我们将遵循 Python 的标准,命名这些函数时以两个下划线开头:

def public_fn():
    # this function can be imported

def __private_fn():
    # this function should only be accessed from inside the module

请注意,Python 没有访问修饰符(如 public、private 等);因此,模块顶部编写的所有代码,即 Python 文件中的代码,都可以被导入并使用。

请记住,两个下划线只是我们需要遵循的一种约定。实际上并没有什么可以阻止我们导入并使用这段代码。如果我们导入了一个以两个下划线开头的函数,我们必须理解,该函数并不是由其作者编写供外部使用的,如果我们调用这个函数,可能会得到意想不到的结果。通过在调用它们的函数内部定义子函数,我们可以防止这种情况发生。

过滤、映射与归约

在函数式编程中,我们从不修改集合的元素,而是总是创建一个新的集合,以反映操作对该集合的更改。有三个操作构成了函数式编程的基石,可以完成我们所能想到的对集合的任何修改:过滤、映射和归约。

过滤

filter 操作接受一个集合,并创建一个新的集合,其中可能会排除某些项。项是根据 谓词函数 进行过滤的,谓词函数是一个接受一个参数并根据该参数是否通过给定测试返回 True 或 False 的函数。

图 2-1 说明了过滤操作。

Image

图 2-1:过滤集合

图 2-1 显示了一个由四个元素组成的源集合:A、B、C 和 D。集合下方是一个框,表示谓词函数,用于确定保留哪些元素,丢弃哪些元素。集合中的每个元素都会传递给谓词函数,只有通过测试的元素才会被包含在结果集合中。

在 Python 中,有两种方法可以过滤集合:使用全局 filter 函数,或者如果集合是列表的话,使用列表推导式。我们在这里专注于 filter 函数;列表推导式将在下一节中介绍。Python 的 filter 函数接收一个函数(谓词)和一个集合作为参数:

    filter(<predicate_fn>, <collection>)

让我们编写一个谓词 lambda 函数来测试一个数字是否是偶数:

lambda n: n % 2 == 0

现在让我们使用我们的 lambda 函数来过滤一组数字,并获取一个只包含偶数的新集合:

>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8]
>>> evens = filter(lambda n: n % 2 == 0, numbers)
>>> list(evens)
[2, 4, 6, 8]

需要注意的一点是,filter 函数并不返回列表,而是返回一个迭代器。迭代器允许逐项遍历一个集合。如果你想了解更多关于 Python 迭代器及其底层工作原理的信息,请参考https://docs.python.org/3/library/stdtypes.html#typeiterhttps://docs.python.org/3/glossary.html#term-iterator

我们可以使用之前看到的 list 函数消耗所有的迭代器值并将它们放入一个列表中。我们也可以使用 for 循环消耗迭代器:

>>> for number in evens:
...     print(number)
...
2
4
6
8
映射(Map)

map操作通过将源集合中的每个项目传递给一个函数并将结果存储在一个新集合中来创建一个新集合。新集合的大小与源集合相同。

图 2-2 展示了 map 操作。

Image

图 2-2:映射一个集合

我们将由项 A、B、C 和 D 组成的源集合传入一个映射函数,这个映射函数在图 2-2 中的矩形框内进行说明;映射的结果存储在一个新集合中。

我们可以通过全局 map 函数映射一个集合,或者如果我们有一个列表,也可以使用列表推导式。稍后我们会讨论列表推导式;现在,让我们先学习如何使用 map 函数映射集合。

map 全局函数接收两个参数:一个映射函数和一个源集合:

    map(<mapping_fn>, <collection>)

这就是我们如何将一个名字列表映射到其长度的方式:

>>> names = ['Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel']
>>> lengths = map(lambda name: len(name), names)
>>> list(lengths)
[5, 6, 4, 4, 6]

和 filter 函数一样,map 函数返回一个迭代器,可以通过 list 函数将其转换为列表。在之前的示例中,生成的列表包含了 names 列表中每个名字的字母数:Angel有五个字母,Alvaro有六个字母,依此类推。我们将每个名字映射成一个表示其长度的数字。

归约(Reduce)

reduce操作是最复杂的,但同时也是三者中最灵活的。它创建一个新集合,新集合中的项数可能比原始集合少,可能更多,或者与原始集合相同。为了构建这个新集合,首先将归约函数应用于第一个和第二个元素。然后,它将归约函数应用于第三个元素第一次应用的结果。接着,它将归约函数应用于第四个元素和第二次应用的结果。这样,结果逐步累积。这里可以通过一张图来帮助理解,看看图 2-3。

Image

图 2-3:归约一个集合

本示例中的归约函数将集合中的每个元素(A、B、C 和 D)连接成一个单一元素:ABCD。

归约函数接收两个参数:累积结果和集合中的一个项:

    reducer_fn(<accumulated_result>, <item>)

该函数期望在处理完新项后返回累积结果。

Python 没有提供全局的 reduce 函数,但有一个名为 functools 的包,它包含一些有用的操作,用于处理高阶函数,包括一个 reduce 函数。这个函数不会返回一个迭代器,而是直接返回结果集合或项。这个函数的签名如下:

    reduce(<reducer_fn>, <collection>)

让我们通过一个例子来进行说明:

>>> from functools import reduce

>>> letters = ['A', 'B', 'C', 'D']

>>> reduce(lambda result, letter: result + letter, letters)
'ABCD'

在这个例子中,reduce 函数返回了一个单一的项:’ABCD’,它是将集合中每个字母连接起来的结果。为了开始归约过程,reduce 函数取了前两个字母,AB,并将它们连接成 AB。对于这个第一步,Python 将集合的初始项 (A) 作为累积结果,并将归约器应用于它和第二项。然后,它移到第三个字母 C,并将其与当前的累积结果 AB 连接,从而生成新的结果:ABC。最后一步对 D 字母做同样的操作,生成结果 ABCD

当累积结果和集合中的项具有不同类型时会发生什么?在这种情况下,我们不能将第一个项作为累积结果,因此 reduce 函数希望我们提供第三个参数,作为起始的累积结果:

    reduce(<reducer_fn>, <collection>, <start_result>)

例如,假设我们有之前的名字集合,我们想要将其归约为这些名字的总长度。在这种情况下,累积结果是数字类型,而集合中的项是字符串;我们不能将第一个项作为累积长度。如果我们忘记给 reduce 提供起始结果,Python 会通过引发错误提醒我们:

>>> reduce(lambda total_length, name: total_length + len(name), names)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 1, in <lambda>
TypeError: can only concatenate str (not "int") to str

对于这种情况,我们应该传递 0 作为初始的累积长度:

>>> reduce(lambda total_length, name: total_length + len(name), names, 0)
25

这里有一个有趣的注释:如果累积结果和集合中的项具有不同类型,你总是可以将 mapreduce 结合起来,以获得相同的结果。例如,在前面的练习中,我们也可以这样做:

>>> from functools import reduce

>>> names = ['Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel']
>>> lengths = map(lambda name: len(name), names)
>>> reduce(lambda total_length, length: total_length + length, lengths)
25

在这段代码中,我们首先将名字列表映射为名字长度的列表:lengths。然后,我们将 lengths 列表归约,求出所有值的总和,不需要提供起始值。

当使用常见操作(如两个数字的和或两个字符串的连接)归约项时,我们不需要自己编写 lambda 函数;我们可以简单地将现有的 Python 函数传递给 reduce 函数。例如,当对数字进行归约时,Python 提供了一个有用的模块,名为 operator.py。这个模块定义了用于数字操作的函数等。使用这个模块,我们可以简化之前的例子,如下所示:

>>> from functools import reduce
>>> import operator

>>> names = ['Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel']
>>> lengths = map(lambda name: len(name), names)
>>> reduce(operator.add, lengths)
25

这段代码更简洁、更易读,因此我们将在本书中优先使用这种形式。

operator.add 函数由 Python 定义如下:

def add(a, b):
    "Same as a + b."
    return a + b

如你所见,这个函数等同于我们定义的用于求和两个数字的 lambda 函数。我们将在全书中看到更多由 Python 定义的可以与 reduce 一起使用的函数示例。

到目前为止,我们所有的示例都将集合缩减为单一值,但 reduce 操作可以做更多事情。事实上,filter 和 map 操作都是 reduce 操作的特例。我们可以仅使用 reduce 操作来筛选和映射一个集合。但这不是我们此处要分析的内容;如果你有兴趣,可以自行尝试理解。

让我们看一个例子,假设我们想要基于名字列表创建一个新集合,每个项都是之前所有名字与当前名字用连字符 (-) 连接起来的结果。我们期望得到的结果应该是这样的:

['Angel', 'Angel-Alvaro', 'Angel-Alvaro-Mery', ...]

我们可以使用以下代码来实现:

>>> from functools import reduce

>>> names = ['Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel']
>>> def compute_next_name(names, name):
...     if len(names) < 1:
...         return name
...     return names[-1] + '-' + name
...
>>> reduce(
...    lambda result, name: result + [compute_next_name(result, name)],
...    names,
...    [])
['Angel', 'Angel-Alvaro', 'Angel-Alvaro-Mery', 'Angel-Alvaro-Mery-Paul', ...]

在这里,我们使用 compute_next_name 来确定序列中的下一个项。reduce 中使用的 lambda 函数将累积结果(即已连接的名字列表)与一个新列表(由新项组成)进行连接。由于列表中每项的类型(字符串)与结果(字符串列表)不同,因此需要提供初始解决方案——一个空列表。

如你所见,reduce 操作非常灵活多变。

列表推导式

如前所述,我们可以使用列表推导式在 Python 中筛选和映射列表。处理列表时,通常偏爱这种形式,因为它的语法更加简洁和易读,优于 filter 和 map 函数。

映射项的列表推导式结构如下:

    [<expression> for <item> in <list>]

它有两部分:

  • for in 是在 中迭代项目的 for 循环。

  • 是一个映射表达式,用来将 映射为其他内容。

让我们重复一下之前做的练习,这次我们使用列表推导式将名字列表映射到每个名字的长度列表:

>>> names = ['Angel', 'Alvaro', 'Mery', 'Paul', 'Isabel']
>>> [len(name) for name in names]
[5, 6, 4, 4, 6]

我希望你明白为什么 Python 程序员更偏爱列表推导式而非 map 函数;这个例子几乎就像是普通英语:“每个名字的长度,在 names 中的每个名字”。在这个例子中,for name in names 迭代原始列表中的名字,并使用每个名字的长度 (len(name)) 作为结果。

要使用列表推导式筛选列表,我们可以在推导式末尾添加一个 if 子句:

    [<expression> for <item> in <list> if <condition>]

如果我们想要,例如,筛选一个名字列表,这次只保留以 A 开头的名字,我们可以写出以下列表推导式:

>>> [name for name in names if name.startswith('A')]
['Angel', 'Alvaro']

从这个例子中注意两点:映射表达式是名字本身(一个身份映射,相当于没有映射),并且筛选使用了字符串的 startswith 方法。此方法只有在字符串具有给定的前缀时才会返回 True。

我们可以在同一个列表推导式中同时进行过滤和映射。例如,假设我们想从名字列表中筛选出那些字母超过五个的名字,然后构建一个新列表,其中的元素是原始名字及其长度的元组。我们可以轻松实现:

>>> [(name, len(name)) for name in names if len(name) < 6]
[('Angel', 5), ('Mery', 4), ('Paul', 4)]

为了便于比较,让我们看看如果我们选择使用 filtermap 函数,会是什么样子:

>>> names_with_length = map(lambda name: (name, len(name)), names)
>>> result = filter(lambda name_length: name_length[1] < 6, names_with_length)
>>> list(result)
[('Angel', 5), ('Mery', 4), ('Paul', 4)]

如你所见,结果是相同的,但列表推导式版本更简单且更具可读性。更容易阅读的代码也更容易维护,因此,列表推导式将成为我们过滤和映射列表的首选方式。

现在,让我们将注意力转向本章将要探讨的第二个范式:面向对象编程。

面向对象编程

在前面的部分,我们讨论了函数式编程和一些函数式编程模式。现在我们将学习另一种编程范式:面向对象范式。就像函数对函数式编程的重要性一样,面向对象编程中的对象也扮演着类似的角色。那么,首先,我们来看看:什么是对象?

我们可以通过多种方式来描述对象是什么。我将偏离面向对象编程理论中的标准学术定义,尝试一种不太传统的解释。

从实际的角度来看,我们可以把对象看作是某一特定领域的专家。我们可以向它们提问,它们会给我们信息;或者我们可以请求它们为我们做一些事情,它们就会执行这些任务。我们的提问或请求可能涉及复杂的操作,但这些专家会将复杂性隐藏起来,以便我们无需担心细节——我们只关心任务能否完成。

举个例子,想象一下去看牙医。当你去看牙医时,你不需要了解任何牙科知识。你依赖牙医的专业技能来修复你的蛀牙。你也可以向牙医询问关于牙齿的问题,牙医会用你能理解的语言回答你,隐藏了牙科的真正复杂性。在这个例子中,牙医就是你依赖的对象,用来处理与牙科相关的任务或查询。

要向对象请求某些操作,我们会调用对象的方法。方法是属于某个对象的函数,并且能够访问该对象的内部数据。对象本身有一些内存,用来存储通常对外部世界隐藏的数据,尽管该对象可能决定以属性的形式公开其中的一部分数据。

注意

方法是属于类的函数:它是类定义的一部分。它需要在定义它的类的实例上被调用(执行)。与此相对,函数不属于任何类;它是独立工作的。

在 Python 的术语中,对象中的任何函数或变量都被称为属性。无论是属性还是方法,它们都是属性。在本章和本书的其余部分,我们将使用这些等价的术语。

现在让我们实际操作一下,看看如何在 Python 中定义和使用对象。

定义了对象的构造方式以及它们具有什么特征和知识。有些人喜欢将类比作蓝图;它们是对象包含什么信息以及可以做什么的通用描述。对象和类是相关但不同的;如果类是蓝图,那么对象就是完成的建筑。

我们使用保留的 class 关键字在 Python 中定义一个新类。按照惯例,类名以大写字母开头,并且每个新单词的首字母也大写(这种情况通常称为Pascal 命名法)。让我们创建一个模拟咖啡机的类:

class CoffeeMachine:
    def __init__(self):
        self.__coffees_brewed = 0

在这个列表中,我们定义了一个表示咖啡机的新类。我们可以使用这个类生成新的咖啡机对象,这个过程称为实例化。当我们实例化一个类时,我们创建了该类的一个新对象。实例化一个类的方法是调用其名称,仿佛它是一个返回实例化对象的函数:

>>> machine = CoffeeMachine()

现在我们有了机器对象,其功能由咖啡机类定义(目前还为空,但我们将在接下来的部分中完成它)。当一个类被实例化时,它的 init 函数会被调用。在这个 init 函数内部,我们可以执行一次性的初始化任务。例如,这里我们添加了酿造咖啡的数量并将其设置为零:

def __init__(self):
    self.__coffees_brewed = 0

注意 __coffees_brewed 前面的两个下划线。如果你还记得我们之前讨论的访问级别,Python 中默认所有内容对外部可见。双下划线命名规则用于表示某些内容是私有的,外部不应该直接访问它。

# Don't do this!
>>> machine.__coffees_brewed
0

在这种情况下,我们不希望外部世界访问 __coffees_brewed;否则他们就可以随意更改酿造的咖啡数量!

# Don't do this!
>>> machine.__coffees_brewed = 5469
>>> machine.__coffees_brewed
5469

那么,如果我们无法访问 __coffees_brewed,我们如何知道我们的机器已经酿造了多少杯咖啡呢?答案是属性。属性是类的只读属性。然而,在讨论属性之前,我们需要先了解一些语法。

self

如果你看一下前面的例子,你会发现我们频繁使用一个名为 self 的变量。我们本可以为这个变量使用任何其他名字,但按照惯例,使用 self。正如你之前看到的,我们将其传递给类内每个函数的定义,包括初始化函数。多亏了这个第一个参数 self,我们能够访问类中定义的任何内容。例如,在 init 函数中,我们将 __coffees_brewed 变量附加到 self 上;从那时起,这个变量就存在于对象中了。

变量 self 需要作为每个函数定义中的第一个参数,但在我们调用这些函数时,不需要将其作为第一个参数传递给类的实例。例如,要实例化 CoffeeMachine 类,我们写了以下代码:

>>> machine = CoffeeMachine()

初始化器在没有参数的情况下被调用(这里没有 self)。如果你仔细想想,在这种情况下我们怎么可能把初始化器作为 self 传递呢,毕竟我们还没有初始化对象?事实证明,Python 为我们处理了这个问题:我们永远不需要将 self 传递给初始化器或任何对象的方法或属性。

self引用是不同属性如何访问类中其他定义的方式。例如,在我们稍后编写的brew_coffee方法中,我们会用self来访问__coffees_brewed计数:

def brew_coffee(self):
    # we need 'self' here to access the class' __coffees_brewed count
    self.__coffees_brewed += 1

理解了self后,我们可以继续了解属性。

类属性

对象的属性是一个只读属性,用于返回一些数据。对象的属性通过点表示法来访问:object.property。以我们的咖啡机为例,我们可以添加一个coffees_brewed属性(表示咖啡机已经煮过的咖啡数量),代码如下:

class CoffeeMachine:
    def __init__(self):
        self.__coffees_brewed = 0

    @property
    def coffees_brewed(self):
        return self.__coffees_brewed

然后我们可以这样访问它:

>>> machine = CoffeeMachine()
>>> machine.coffees_brewed
0

属性通过使用@property装饰器来定义为函数:

@property
def coffees_brewed(self):
    return self.__coffees_brewed

属性不应接受任何参数(除了习惯性的 self),并且它们应该返回某些内容。一个不返回任何内容或期望参数的属性在概念上是错误的:属性应仅仅是我们请求对象提供的只读数据。

我们提到过,@property是一个装饰器的例子。Python 装饰器允许我们修改函数的行为。@property修改了类函数,使其可以像类的属性一样被使用。在本书中我们不会使用其他装饰器,因此这里不做详细介绍,但如果你感兴趣,建议你阅读相关资料。

属性帮助我们获取有关对象的信息。例如,如果我们想知道某个CoffeeMachine实例是否至少煮过一杯咖啡,我们可以添加一个如下的属性:

class CoffeeMachine:
    def __init__(self):
        self.__coffees_brewed

    @property
    def has_brewed(self):
        return self.__coffees_brewed > 0

    --snip--

我们现在可以询问CoffeeMachine类的实例是否已经煮过咖啡:

>>> machine.has_brewed
False

这台机器还没有准备任何咖啡,那么我们怎么能让一个CoffeeMachine实例为我们煮咖啡呢?我们使用方法。

类方法

属性让我们了解关于对象的信息:它们回答我们的查询。为了请求对象为我们执行某些任务,我们使用方法。方法不过是属于类的一个函数,它能够访问类中定义的属性。在我们的CoffeeMachine类示例中,我们可以写一个方法来请求它煮些咖啡:

class CoffeeMachine:
    def __init__(self):
        self.__coffees_brewed = 0

    @property
    def coffees_brewed(self):
        return self.__coffees_brewed

    @property
    def has_brewed(self):
        return self.__coffees_brewed > 0

    def brew_coffee(self):
        self.__coffees_brewed += 1

方法将 self 作为第一个参数,这使得它们能够访问类中定义的所有内容。如前所述,在调用对象的方法时,我们永远不需要自己传递 self;Python 会自动为我们处理。

注意

请注意,属性就像是带有@property装饰器的方法。属性和方法都期望self作为它们的第一个参数。当调用方法时,我们使用括号并可选择传递参数,但属性是通过不带括号的方式来访问的。

我们可以在类的实例上调用brew_coffee方法:

>>> machine = CoffeeMachine()
>>> machine.brew_coffee()

现在我们已经煮好了第一杯咖啡,我们可以向实例提问如下:

>>> machine.coffees_brewed
1
>>> machine.has_brewed
True

正如你所看到的,方法必须在类的特定实例(对象)上调用。这个对象将响应请求。因此,函数是直接调用的,没有特定的接收者,像

a_function()

方法必须在对象上调用,像

machine.brew_coffee()

对象只能响应在创建它们的类中定义的方法。如果在对象上调用了一个方法(或任何属性),但该方法没有在类中定义,则会引发 AttributeError。让我们试试看。尽管我们从未给咖啡机提供过如何泡茶的指令,仍然让它尝试泡茶:

>>> machine.brew_tea()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: 'CoffeeMachine' object has no attribute 'brew_tea'

好吧,我们的对象抱怨了:我们从未告诉它我们希望它知道如何准备茶。以下是它抱怨的关键:

'CoffeeMachine' 对象没有 'brew_tea' 属性

经验教训:永远不要要求一个对象做它没有被教过的事情;它会崩溃并导致程序失败。

方法可以接受任意数量的参数,在我们的类中,这些参数必须在第一个强制性参数:self 之后定义。例如,让我们为我们的 CoffeeMachine 类添加一个方法,允许我们用指定量的水来填充它。

class CoffeeMachine:

    def __init__(self):
        self.__coffees_brewed = 0
        self.__liters_of_water = 0

    def fill_water_tank(self, liters):
        self.__liters_of_water += liters

我们可以通过调用新方法来填充咖啡机实例:

>>> machine = CoffeeMachine()
>>> machine.fill_water_tank(5)

在我们继续之前,关于方法要知道的最后一件事是它们动态分发的强大特性。当在对象上调用方法时,Python 会检查该对象是否响应该方法,但是,关键是,Python 不关心对象的类,只要该类定义了请求的方法。

我们可以利用这个特性定义不同的对象,这些对象可以响应相同的方法(这里的相同方法指的是相同的名称和参数),并且可以互换使用。例如,我们可以定义一个新的、更现代的咖啡生产者实体:

class CoffeeHipster:
    def __init__(self, skill_level):
        self.__skill_level = skill_level

    def brew_coffee(self):
        # depending on the __skill_level, this method
        # may take a long time to complete.
        # But apparently the result will be worth it?
        --snip--

现在我们可以写一个函数,期望一个咖啡生产者(任何定义了 brew_cofee() 方法的对象),并对其进行操作:

def keep_programmer_awake(programmer, coffee_producer):
    while programmer.wants_to_sleep:
        # give the coder some wakey juice
        coffee_producer.brew_coffee()
        --snip--

这个函数适用于 CoffeeMachine 和 CoffeeHipster 的实例:

>>> machine = CoffeeMachine()
>>> hipster = CoffeeHipster()
>>> programmer = SleepyProgrammer('Angel')

# works!
>>> keep_programmer_awake(programmer, machine)

# also works!
>>> keep_programmer_awake(programmer, hipster)

为了使此技术有效,我们需要确保方法具有相同的签名,即它们的名称相同,并且期望完全相同的参数和相同的名称。

魔法方法

我们的类可以定义一些特殊的方法,这些方法被称为魔法方法双下划线方法(简称dunder 方法)。这些方法通常不会直接由我们调用,但 Python 会在幕后使用它们,正如我们将在以下示例中看到的那样。

我们已经使用过一个这样的函数:init,它作为初始化函数在实例化对象时使用。这个 init 方法定义了当类的新实例被创建时执行的代码。

魔法方法的一个显著用途(我们将在本书中广泛使用)是重载运算符。通过一个例子来看这个问题。假设我们实现了一个类来表示复数:

class ComplexNum:
    def __init__(self, re, im):
        self.__re = re
        self.__im = im

    @property
    def real(self):
        return self.__re

    @property
    def imaginary(self):
        return self.__im

那么我们该如何实现 ComplexNum 实例的加法操作呢?一个选项是定义一个名为 plus 的方法:

class ComplexNum:

    --snip--

    def plus(self, addend):
        return ComplexNum(
            self.__re + addend.__re,
            self.__im + addend.__im
        )

我们可以这样使用:

>>> c1 = ComplexNum(2, 3)
>>> c2 = ComplexNum(5, 7)

>>> c1.plus(c2)
# the result is: 7 + 10i

这样写也可以,但如果我们能像对待其他数字一样使用 + 运算符,那就更好了:

>>> c1 + c2

Python 包含了一个魔法方法,add;如果我们实现这个方法,就可以像之前一样使用 + 运算符,Python 会在后台调用这个 add 方法。因此,如果我们将我们的加法方法重命名为 add,就可以使用 + 运算符自动添加 ComplexNum 实例:

class ComplexNum:

    --snip--

    def __add__(self, addend):
        return ComplexNum(
            self.__re + addend.__re,
            self.__im + addend.__im
        )

我们可以在类中实现更多魔法方法来执行减法、除法、比较等操作。你可以参考 表 4-1 和 第 70 页 中的内容,查看我们可以通过魔法方法实现的操作。例如,使用 - 运算符对两个复数进行减法操作,实际上只需要实现 sub 方法:

class ComplexNum:

    --snip--

    def __sub__(self, subtrahend):
        return ComplexNum(
            self.__re - subtrahend.__re,
            self.__im - subtrahend.__im
        )

现在我们可以使用 - 运算符:

>>> c1 - c2
# yields: -3 - 4i

那么如何用 == 运算符比较两个实例的相等性呢?只需要实现 eq 魔法方法:

class ComplexNum:

    --snip--

    def __eq__(self, other):
        return (self.__re == other.__re) and (self.__im == other.__im)

现在我们可以轻松比较复数了:

>>> c1 == c2
False

在本书中,我们会使用一些魔法方法;它们能大大提高代码的可读性。

现在我们换个话题,来学习类型提示。

类型提示

Python 的 类型提示 是我们在编写代码时可以用来确保不输入错误方法名或属性名的小帮助。

比如,我们可以使用前一节中实现的复数类:

class ComplexNum:

    def __init__(self, re, im):
        self.__re = re
        self.__im = im

    @property
    def real(self):
        return self.__re

    @property
    def imaginary(self):
        return self.__im

假设我们写了一个函数,接受一个 ComplexNum 实例作为参数,并且我们想要提取复数的虚部,但我们有点困倦,不小心写了以下代码:

def defrangulate(complex):
    --snip--
    im = complex.imaginry

你注意到拼写错误了吗?由于我们对复数参数没有任何了解,IDE 也无法给出任何视觉提示。就 IDE 来看,imaginery 是一个完全有效的属性名,直到我们运行程序并传递一个复数,才会报错。

Python 是一种动态类型语言:它在运行时使用类型信息。例如,它会检查一个对象在运行时是否响应某个方法,如果没有,就会抛出错误:

AttributeError: 'ComplexNum' 对象没有属性 'imaginry'

有点遗憾,不是吗?在这种情况下,我们知道这个函数只接受 ComplexNum 类的实例,所以如果我们的 IDE 能提醒我们关于属性名的错误输入就好了。事实上,我们可以通过类型提示来实现这一点。

在函数或方法定义中,类型提示位于参数名后面,用冒号分隔:

def defrangulate(complex: ComplexNum):
    --snip--
    im = complex.imaginry
    -------------^-------
    'ComplexNum' object has no attribute 'imaginry'

如你所见,IDE 已经提示我们 ComplexNum 类没有名为 imaginry 的属性。

除了我们使用类定义的类型外,我们还可以使用 Python 的内建类型作为类型提示。例如,复数初始化器期望两个浮点数,可以这样写:

class ComplexNum:
    def __init__(self, re: float, im: float):
        self.__re = re
        self.__im = im

现在,如果我们尝试使用错误的参数类型实例化类,我们的集成开发环境(IDE)会提醒我们:

i = ComplexNumber('one', 'two')
------------------^------------
Expected type 'float', got 'str' instead.

我们可以使用 float 来表示浮点数,int 来表示整数,str 来表示字符串。

这些类型提示在开发过程中对我们有所帮助,但在运行时没有任何影响。我们将在本书中的许多地方使用类型提示:它们不需要额外时间来添加,而且能为我们提供一些额外的安全性。

总结

本章讨论了两种编程范式:函数式编程和面向对象编程。当然,这两者都是庞大的话题,关于它们可以,且已经,写成整本书。我们仅仅触及了表面。

我们还讨论了魔法方法和类型提示,这是本书中我们将广泛使用的两种技术。

在下一章,我们将讨论命令行。之后,我们将开始编写代码。

第三章:命令行

图片

命令行接口 让我们可以直接向计算机发出指令。在命令行中,我们可以运行程序、搜索文件、创建和删除目录、连接互联网等。除两个例外外,本书中创建的所有应用程序都设计为从命令行执行。本章将简要介绍命令行接口的基础知识。如果您已经知道如何使用命令行,可以跳过本章。

Unix 和 Windows

每个操作系统都有不同的命令行界面(CLI),但它们的目的都是相似的:直接向操作系统发出指令。Linux 和 macOS 都基于 Unix,因此它们共享一个通用的语法,并使用相似的命令行处理器,这些程序可以解释您以纯文本形式发出的指令,并将其转换成计算机可以执行的语言。有几种 Unix 命令行处理器,bash、bourne 和 zsh 就是其中的几个例子。

这些系统中的命令行应用程序通常称为 shell终端提示符。苹果的 macOS 自带有 bash shell,但最近它将 bash 替换为 zsh,zsh 可以说是更现代且功能更强大。我们不会过于关注这些 shell 版本之间的差异;在我们的使用场景中,它们可以互换使用。

Windows 有自己的命令行系统,且与 macOS 或 Linux 使用不同的语法。幸运的是,由于大多数开发者更熟悉类似 Unix 的 shell,Windows 决定允许其用户安装 Linux 子系统。在下一部分,我们将讨论如何安装 Windows 子系统 Linux(WSL)支持,以便您在使用 Windows 计算机跟随本书时使用。

查找您的 Shell

如果您是 Linux 或 macOS 用户,您无需安装任何额外的软件:您的系统自带有一个终端。您可以在应用程序目录中找到它。

如果您是 Windows 用户,您的系统也有命令行,但我们将不使用它;我们将安装 WSL(Windows Subsystem for Linux)。这个系统将让您访问一个可以用于跟随本书内容的 shell。让我们看看如何在您的计算机上安装它。如果您不是 Windows 用户,可以跳过这一部分。

安装 Windows 子系统 Linux

Windows 子系统 Linux,简称 WSL,是在 Windows 操作系统内安装的 Linux 操作系统。WSL 将允许您访问 Linux 的主要工具,包括 shell。

由于安装说明通常会随着时间的推移而变化,如果您在以下步骤中遇到任何问题,请参考官方文档。您可以在https://docs.microsoft.com/windows/wsl找到官方文档,那里还会有详细的信息和逐步的安装指南。

截至本文编写时,要安装 Linux 子系统,你首先需要在你的机器上启用 WSL 可选功能。为此,打开 PowerShell 应用程序以管理员身份运行,然后执行以下命令:

PS C:\> dism.exe /online /enable-feature
    /featurename:Microsoft-Windows-Subsystem-Linux
    /all /norestart

请注意,你应该将此命令写在一行上;我不得不换行,因为它在书籍的印刷版本中无法容纳。它可能需要几秒钟才能完成。一旦命令执行完毕,重启你的机器。

当你的机器完全重启后,你可以开始安装任何你选择的 Linux 发行版(也叫做distro)。如果你没有特别偏好的 Linux 发行版,我建议你安装 Ubuntu;它既可靠又对开发者友好。

要安装 Linux 子系统,打开 Microsoft Store 并搜索 Ubuntu(或你选择的发行版)。在本书中,我将使用 Ubuntu 的 20 LTS 版本。运行 Linux 子系统的安装程序;一旦安装过程完成,打开它。

当你第一次打开 Linux 子系统时,它需要执行一些安装步骤,这可能需要几分钟。正如你所看到的,这个安装包括 Linux 操作系统和一个与其通信的 shell,但不包括图形界面。shell 会提示你创建一个新的用户名和密码。如果你在安装和配置系统时遇到困难,不要犹豫,阅读文档。

初探 Shell

当你打开 shell 时,它会显示如下内容:

angel@MacBook ~ %

你可能会看到一些不同的字符出现在最后,但第一部分是通过 @ 符号分隔的已登录用户和机器名:

<user>@<machine> ~ %

在本书的其余部分,我们将使用美元符号($)表示 shell,并且不再显示用户和机器的名称:

$

现在你知道如何打开 shell,我们来看看一些有用的命令。

文件和目录

让我们尝试第一个命令:pwd(即 print working directory 的缩写)。在 shell 中输入 pwd 并按回车键。这条命令会显示当前目录的路径,也就是 shell 当前所在的目录:

$ pwd
/Users/angel

在这种情况下,shell 告诉我们当前的工作目录是angel,它位于Users目录内。

使用 whoami 命令,我们还可以让 shell 告诉我们当前登录的用户:

$ whoami
angel

然后,我们可以使用 ls 命令列出当前目录中的内容:

$ ls
Desktop               Downloads             Music             PycharmProjects
Applications          Developer             Library           Pictures
Documents             Git                   Movies            Public

移动目录

我们可以使用 cd 命令后跟我们想要切换到的目录名称来切换目录:

$ cd Documents
$ pwd
/Users/angel/Documents

要回到上一级目录,即父目录,我们使用两个点:

$ cd ..
$ pwd
/Users/angel

在这两个 cd 命令的示例中,我们使用相对路径切换了目录。相对路径是从当前目录开始的路径。例如,如果我们想使用相对路径切换目录,我们只需要提供如下的路径:

$ cd Documents/Video
$ pwd
/Users/angel/Documents/Video

我们可以使用一个点(.)来表示当前目录。所以,下面是切换到 Documents/Video 目录的另一种方法:

$ cd ./Documents/Video
$ pwd
/Users/angel/Documents/Video

我们也可以使用 绝对路径 来更改目录,绝对路径是相对于根目录的路径。根目录的名称就是一个斜杠字符(/)。让我们尝试使用绝对路径切换到根目录:

$ cd /
$ pwd
/

现在让我们回到我们的主目录。主目录也有一个特殊的快捷方式名称,即波浪号(~):

$ cd ~
$ pwd
/Users/angel

创建文件和目录

我们可以使用 mkdir 命令创建新目录,后面跟上我们想要创建的目录名称:

$ mkdir tmp/mechanics

在这里,我们刚刚在工作目录中创建了一个名为 tmp 的新目录,里面有一个名为 mechanics 的新目录。我们本可以通过两步完成相同的操作,首先创建 tmp 目录,

$ mkdir tmp

然后进入 tmp 目录(cd tmp),再创建 mechanics 目录,

$ mkdir mechanics

两种情况的结果是一样的。

让我们进入那个新目录:

$ cd tmp/mechanics

要创建一个新文件,我们可以使用 touch 命令,后面跟上文件名:

$ touch file.txt
$ ls
file.txt

我们可以使用输入重定向将一些文本写入文件,我们将在下一节中对此做进一步解释:

$ echo write me to the file > file.txt

这个命令比我们之前看到的命令稍微复杂一些,它有两个部分。左侧的部分,在 > 符号的左边,使用 echo 命令将“write me”输出到文件。我们可以单独运行这个命令来看看它的效果:

$ echo write me to the file
write me to the file

如我们所见,echo 命令只是简单地打印我们传递给它的内容。使用 > 符号时,我们可以将输出目标从标准输出(shell)重定向到文件,这样消息就会写入文件,而不是输出到 shell。

为了证明我们已经删除了文件,让我们使用 cat 命令读取文件内容:

$ cat file.txt
write me to the file

cat 命令会打印文件的内容。该命令是 concatenate(连接)的缩写,它会连接传递给它的文件内容。实际上,我们可以将 cat 命令传递给同一个文件两次,以查看连接的结果:

$ cat file.txt file.txt
write me to the file
write me to the file

现在让我们删除刚刚创建的文件和目录。

删除文件和目录

要删除文件,我们使用 rm 命令:

$ rm file.txt

文件现在永远消失了:在使用命令行时没有垃圾桶或其他安全机制。我们在删除文件或目录时需要格外小心。

让我们回到上两级目录,离开 tmp/mechanics 文件夹:

$ cd ../..
$ pwd
/Users/angel

如果一个目录是空的,我们可以使用 -d 命令行选项将其删除。命令行选项是我们可以传递给命令的参数,用来修改其行为。命令行选项有两种形式:一种是一个短横线后跟一个或多个小写字母,如 -f;另一种是双短横线后跟一个单词或复合词,如 --file 或 --file-name。

删除一个空目录的操作如下:

$ rm -d tmp
rm: tmp: Directory not empty

如你所见,shell 返回了一个错误消息,因为我们的 tmp 目录不是空的(它有一个子目录)。如果我们想要删除一个目录及其所有子目录,我们可以使用 -r 选项:

$ rm -r tmp

如果目录或任何子目录中包含文件,前面的命令会失败。此命令在我们想删除不包含文件的目录时非常有用,因为如果遇到文件,该命令将不会删除任何东西,以确保安全。要删除包含文件的目录,我们可以使用 -rf 选项:

$ rm -rf tmp

你需要对 rm -rf 命令保持 极其小心。这个命令可能会造成一些不可恢复的严重损害。

命令总结

表 3-1 总结了我们在本节中探讨的命令。

表 3-1: 文件和目录的 shell 命令

命令 描述
whoami 显示有效用户 ID
pwd 返回当前工作目录的名称
ls 列出目录内容
cd 更改目录
mkdir 创建新目录
echo 将参数写入标准输出
cat 拼接并打印文件内容
rm 删除文件
rm -d 删除空目录
rm -r 删除目录及其中的子目录
rm -rf 删除目录及文件(递归)

使用 Windows 子系统运行 Linux

现在我们了解了基本的命令来在机器的目录之间移动,接下来让我们看看在使用 Windows 子系统运行 Linux 时的一些具体情况。

查找 C: 驱动器

每次你打开 Linux 子系统时,shell 的工作目录将设置为 Linux 子系统的主目录。你可以使用 pwd 命令查看当前目录:

$ pwd
/home/angel

WSL 有自己独立的目录结构,与计算机本身的目录结构不同。不过,由于你将在 Windows 机器上编写本书的代码,你需要一种访问 C: 驱动器的方式。WSL 提供了一个简单的访问 C: 驱动器的方法。

你的本地驱动器会被挂载到 Linux 子系统中的一个名为 /mnt 的目录内。让我们进入 /mnt 目录,然后列出其内容:

$ cd /mnt
$ ls
c    d

使用绝对路径(以 / 开头)来导航到 /mnt 是非常重要的。ls 命令列出了我的两个驱动器:C: 和 D:。要打开其中一个,只需更改目录:

$ cd c

现在,WSL 的工作目录就是你的 C: 驱动器。你可以找到你的 Users 主目录,或者任何你用来编写代码的文件夹:

$ cd Users/angel
确保 Python 安装(Ubuntu)

Ubuntu 已预装 Python 3 版本。你可以从 shell 中检查已安装的版本:

$ python3 --version
Python 3.8.2

你可以使用 Ubuntu 的 apt 命令行工具将 Python 更新到最新版本。首先,你需要更新 apt 软件包列表,以确保它们与最新版本的软件保持同步。你需要以 超级用户 身份运行此命令。你可以通过在命令前加上 sudo(即 superuser do)来实现这一点。运行任何超级用户命令时,你需要提供密码:

$ sudo apt update
[sudo] password for angel: <write your password here>

当你输入密码时,你不会在终端看到任何内容。输入时,提示符将保持为空,主要出于安全原因。一旦包列表更新完成,你就可以升级 Python 的版本:

$ sudo apt upgrade python3

现在你可以确保 Ubuntu 上有最新的 Python 版本 3 的稳定版本。你已经准备好学习如何运行 Python 脚本了。

运行 Python 脚本

使用命令行运行 Python 文件是一个简单的过程:

$ python3 <filename.py>

使用 Python 版本 3 的解释器非常重要,因为我们将使用一些仅在此版本中可用的功能。由于 Python 版本 2 和 3 可以安装在同一台机器上,因此版本 3 的解释器名称会以 3 结尾。

让我们创建一个 Python 文件并执行它。在你的终端中,使用以下命令创建一个新的 Python 文件:

$ touch script.py

这将会在终端的工作目录中创建一个新文件 script.py。用 PyCharm 或你选择的编辑器打开文件,并输入一个打印语句:

print('hello, World!')

确保保存文件。让我们检查一下我们的script.py文件是否正确写入:

$ cat script.py
print('hello, World!')

最后,让我们从命令行执行我们的 Python 脚本:

$ python3 script.py
hello, World!

正如预期的那样,我们的程序给出了一个问候,World!问候。

向脚本传递参数

命令行程序可以接受参数。让我们尝试一下,在我们的 Python 脚本中接受一个参数来个性化问候。打开script.py文件并修改它,使其包含以下内容:

import sys

name = sys.argv[1] if len(sys.argv) > 1 else 'unknown'
print(f'Hello, {name}')

Python 的 sys.argv 是传递给执行脚本的参数列表。列表的第一个项始终是执行程序的名称,在本例中是script.py。因此,我们首先需要检查参数列表是否包含多个项,以确定是否将名称作为参数传递给程序。如果我们检测到用户传递了参数,就将其用作我们要问候的人的名字,但如果没有传递参数,则默认名字为未知。

现在我们可以运行没有参数的程序,得到一个非个人化的问候:

$ python3 script.py
Hello, unknown!

我们还可以传递一个名字给脚本,以获得一个更加个性化的问候:

$ python3 script.py Jenny
Hello, Jenny!

标准输入和输出

在终端中执行的程序可以读取和写入数据。当一个程序(比如我们之前的script.py)打印内容时,它作为输出出现在终端中。我们之前的程序输出了像 Hello, Jenny! 这样的字符串,然后显示在终端屏幕上。终端的屏幕通常被称为标准输出

将输出重定向到文件

之前,我们通过使用 > 字符将 echo 命令的输出重定向到文件中。

在终端中试试这个:

$ python3 script.py Jenny > greeting.txt
$ cat greeting.txt
Hello, Jenny!

这次,script.py 程序的结果没有打印到终端屏幕上,而是写入了一个新文件 greeting.txt

使用 > 字符,我们可以将程序的输出重定向到一个新文件。如果目标文件已存在,它会被覆盖。我们还可以使用 >> 字符将内容追加到现有文件,而不是创建一个新文件:

$ python3 script.py Angel >> greeting.txt
$ cat greeting.txt
Hello, Jenny!
Hello, Angel!

这是一个非常有用的技巧,我们将在全书中使用它将程序的结果写入外部文件。

从文件重定向输入

就像我们可以重定向 shell 的标准输出一样,我们也可以重定向 shell 的输入。让我们创建一个新的脚本。与其从程序的参数中读取名字,不如提示用户输入他们的名字。首先,创建一个新的空文件:

$ touch script2.py

打开文件并输入以下代码:

print("What's your name?")
name = input()
print('Hello there, {name}')

如果我们现在运行我们的新脚本,它会提示我们输入名字:

$ python3 script2.py
What's your name?
Angel
Hello there, Angel

这个程序从标准输入读取名字,也就是从 shell 读取。我们必须在 shell 中输入名字并按回车键,程序才能读取它。我们可以使用 < 字符将输入从文件重定向到我们的程序。在这种情况下,程序会读取文件的内容,而不是从 shell 中读取。

让我们在一个新文件中写下一个名字:

$ echo Mary > name.txt

现在,让我们将输入重定向为从这个文件读取到我们的程序:

$ python3 script2.py < name.txt
What's your name?
Hello there, Mary

这一次,当程序提示输入名字时,shell 会读取 name.txt 文件的内容,而不需要我们自己输入任何东西。

本书中我们将编写的应用程序会使用输入重定向将输入文件的内容读入到 Python 程序中。

使用 PyCharm 的 Python 控制台

正如我们在本书的介绍部分所看到的,PyCharm 附带了两个控制台:一个是 Python 控制台,另一个是你系统的 shell。前者特别有趣,因为它允许我们直接运行 Python 代码并检查所有加载的符号。你可以通过点击底部栏的 Python 控制台按钮或通过菜单选择 View ▸ Tool Windows ▸ Python Console 来打开 PyCharm 的 Python 控制台。

如图 3-1 所示,Python 控制台被分为两个窗格:左侧窗格是你编写 Python 代码的控制台,右侧窗格列出了你已定义的所有变量。让我们做一个实际练习来学习它是如何工作的。

Image

图 3-1:PyCharm Python 控制台

在 Python 提示符下,输入以下内容:

>>> names = ['Angel', 'Alvaro', 'Mary', 'Paul', 'Isabel']

现在右侧窗格中列出了你可以探索的符号列表(见图 3-2)。你可以展开名字符号,检查列表中的项目。

Image

图 3-2:声明一个名字列表

现在让我们写一个函数,过滤一个字符串列表,只保留那些长度小于给定值的字符串。请在控制台中输入以下内容(注意代码中的缩进用三个点表示):

>>> def filter_list_shorter_than(lst, length):
...     return [item for item in lst if len(item) < length]
...

>>> filter_list_shorter_than(names, 5)
['Mary', 'Paul']

如果你想保存过滤后的列表的引用,你可以将结果保存到一个变量中:

>>> result = filter_list_shorter_than(names, 5)

现在你可以使用 Python 控制台的右侧窗格来查看结果列表。

你也可以从控制台导入 Python 模块。你可以从自己的项目或标准库中导入模块。例如,如果你之前在 PyCharm 中打开了 Mechanics 项目,你可以导入 Point 类。

>>> from geom2d import Point
>>> p = Point(10, 15)

从标准库导入模块同样简单。例如,要从 json 模块导入 JSONDecoder 类,可以使用以下代码:

>>> from json import JSONDecoder

有时候,我们可能需要重新加载控制台,以便清除所有导入的模块和已定义的变量。这是个好主意,因为你导入的模块和定义的变量可能会与新写的代码产生交互。我们可以通过点击控制台左上方的重新加载按钮来重新加载 Python 控制台(见图 3-3)。

Image

图 3-3:重新加载控制台

慢慢探索 PyCharm 的 Python 控制台,因为你会发现它在本书中非常有用;我们会经常通过在其中运行快速实验来测试代码。

总结

在这一短小的章节中,我们介绍了使用 bash/zsh 命令行的基础知识。通过这个终端,我们可以向计算机发出命令,并从这里执行 Python 脚本。我们还探索了标准输入和输出重定向,这是我们在全书中会广泛使用的技术。

不再浪费时间,让我们开始创建我们的 Mechanics 项目吧。让乐趣开始!

第二部分

二维几何**

第四章:点和向量

Image

点和向量是几何学的基础。在本书中,我们将它们作为我们的基本元素,是构建其余几何库的基石。为了让我们的几何库可用,必须确保我们实现的点和向量代码没有错误。代码中的一个 bug 不仅会导致库函数出错,还可能传播到我们在其上构建的其他库中,给我们带来各种错误的计算。

在本章中,我们有两个主要任务。首先,我们需要实现类来表示点和向量。然后,我们需要通过单元测试来确保我们的代码没有 bug,这是我们在本书中将反复进行的过程。但在做这两件事之前,我们需要实现一些有用的方法。

比较数字

在表示实数时,计算机并不具有无限精度。大多数计算机使用浮点数来存储这些值,而浮点数无法表示每一个有理数,更不用说无理数了。因此,在比较浮点数时,你必须指定一个容差:一个数字 ϵ,它小到足够可以满足

|a – b| < ϵ

其中 ab 是你想要比较的两个数字。

容差的数量级需要与问题的大小和你所需的精度相一致。例如,在处理行星轨道长度时,使用 1E^(–20)毫米的容差就没有太大意义,因为这些长度的数量级是数百万公里。同样,在处理原子距离时,使用 1E^(–2)厘米的容差也是毫无意义的。

在我们开始编写我们的基本元素之前,我们需要一种方法来判断两个浮点数在给定的容差 ϵ 下是否可以被认为是相等的。但我们不能依赖计算机来比较浮点数,因为百位小数上的不同数字在逻辑上被认为是完全不同的数字。因此,我们将从本章开始,编写一个函数来比较两个数字,使用给定的容差。对于我们的几何计算,我们将使用默认的容差 1E^(–10),这是大多数计算中可接受的精度水平。

打开你的项目,在 IDE 中右键点击项目根文件夹,选择新建Python 包。将其命名为geom2d,然后点击确定。这将是我们所有几何代码的包。

注意

因为包名已经确定了包内的内容是二维的,所以我们在命名文件和类时不会重复这个信息。在包内,我们会使用像 point segment 这样的名称,而不是 point2d segment2d。如果我们要创建一个三维几何包, geom3d,我们仍然会使用 point segment,只是它们会有不同的三维实现。

通过右键单击geom2d包文件夹并选择新建Python 文件来创建一个新文件。命名为nums,保持类型下拉框不变,然后点击确定

创建好文件后,让我们实现第一个比较函数。清单 4-1 中包含了我们的函数代码。

import math

def are_close_enough(a, b, tolerance=1e-10):
    return math.fabs(a - b) < tolerance

清单 4-1:比较数字

首先,我们导入了math模块,这是 Python 标准库的一部分,包含了有用的数学函数。我们的函数接受两个数字 a 和 b,以及一个可选的容差参数,如果未提供其他值,默认容差为 1E^(-10)。最后,我们使用 math 库的 fabs 函数来检查 a 和 b 之间的差值的绝对值是否小于容差,并返回相应的布尔值。

实际上,我们会发现有两个特定的值需要进行比较:零和一。为了避免重复编写类似的代码,

are_close_enough(num, 1.0, 1e-5)

are_close_enough(num, 0.0, 1e-5)

让我们将它们作为函数实现。接着上一个函数,添加清单 4-2 中的代码。

--snip--

def is_close_to_zero(a, tolerance=1e-10):
    return are_close_enough(a, 0.0, tolerance)

def is_close_to_one(a, tolerance=1e-10):
    return are_close_enough(a, 1.0, tolerance)

清单 4-2:将数字与零或一进行比较

像清单 4-2 中的函数并非严格必要,但它们非常方便,并使代码更具可读性。

点类

根据欧几里得《几何原本》的第一卷,点是“没有部分的东西”。换句话说,点是没有宽度、长度或深度的实体。它只是空间中的一个位置,是肉眼无法看到的。点是所有欧几里得几何的基础,他所有著作中的其他内容都是建立在这个简单概念上的。因此,我们的几何库也将基于这一强大的原始概念。

一个点由两个数字xy组成。这些是它的坐标,有时也称为投影。图 4-1 描绘了一个点P及其在欧几里得平面中的坐标。

Image

图 4-1:平面中的点 P

让我们实现一个表示二维点的类。如之前所述,我们将通过右键单击geom2d包文件夹并选择新建Python 文件来创建一个新文件。命名为point,然后点击确定。在文件中输入清单 4-3 中的代码。

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

清单 4-3:我们的点类

坐标被传递给初始化方法(init)并作为类的属性存储。

在编写完初始化器后,让我们实现一些功能。

计算点之间的距离

为了计算两点PQ之间的距离d(P, Q),我们使用方程 4.1。

Image

在这里,P[x]和P[y]是P的坐标,而Q[x]和Q[y]是Q的坐标。我们可以在图 4-2 中看到这个图形。

Image

图 4-2:点 P 和 Q 之间的距离

我们可以通过两种方式实现距离计算。我们可以在点 p 上调用方法,计算到另一个点 q 的距离,例如 p.distance_to(q)。我们也可以将相同的计算实现为一个函数,将两个点作为参数传入:distance_between(p, q)。前者是面向对象的风格,后者是函数式的。由于我们在这里做的是面向对象编程,所以我们将选择前者。

清单 4-4 包含实现我们类中 方程 4.1 的代码。

import math

class Point:
    --snip--

    def distance_to(self, other):
        delta_x = other.x - self.x
        delta_y = other.y - self.y
        return math.sqrt(delta_x ** 2 + delta_y ** 2)

清单 4-4:计算两点之间的距离

首先,我们需要导入 math 模块,它将许多有用的数学操作加载到我们的类中。我们定义了一个 distance_to 方法,使用 self 和 other 作为参数:self 是当前的点,other 是我们要计算距离的点。然后我们计算这两个坐标之间的距离(或 delta),并使用幂运算符(**)将两个 delta 的平方相加,并返回它们和的平方根。

现在让我们测试一下。打开 IDE 中的 Python 控制台,尝试以下操作:

>>> from geom2d.point import Point
>>> p = Point(1, 3)
>>> q = Point(2, 4)
>>> p.distance_to(q)
1.4142135623730951

激动人心!我们已经迈出了构建几何库的第一步——欧几里得会为我们感到骄傲。你可以使用计算器尝试相同的操作,看看我们的实现是否得到了正确的结果。本章稍后我们将自动化测试,检查 distance 方法是否得到了正确的结果。

在我们打开控制台并加载 p 和 q 后,尝试以下操作:

>>> p
<geom2d.point.Point object at 0x10f8a2588>

>>> p.__dict__
{'x': 1, 'y': 3}

评估点 p 会返回一个字符串,告诉我们 p 是一个在内存位置 0x10f8a2588 的 Point 类对象。注意,你获得的内存地址可能与你的不同。如果不了解计算机内存中的所有内容(并且无法读取十六进制),这种描述帮助不大。你还可以检查任何类的 dict 属性,以获取该类持有的所有属性的字典。这会给你更多关于实例的信息。在本章稍后的部分,我们将实现一个特殊的方法,帮助打印一个更简洁的对象描述,例如(2, 5)。

现在让我们将注意力集中在为 Point 类重载 + 和 – 操作符上。

加法和减法操作符

我们需要的下一个基本操作是加法和减法,这些操作我们也将在向量中实现。我们会经常使用这些基本方法,既可以单独使用,也可以用来构建更复杂的方法。我们可以像普通方法那样实现它们,通过 p.plus(q) 和 p.minus(q) 来调用,但我们可以做得更好。Python 允许我们重载 + 和 – 操作符(正如我们在“魔法方法”中所学到的,在第 43 页),这样我们就可以写成 p + q 和 p - q,Python 就知道要正确地加法和减法。重载操作符使得像这样的代码更加易读和易懂。

在 Python 中重载运算符需要实现一个对应于运算符的特定名称的方法。然后,当 Python 遇到该运算符时,它会用你定义的方法替代并调用它。对于 + 运算符,方法名是 add,而对于 - 运算符,方法名是 sub。表 4-1 包含了我们可以在类中重载的常见运算符。

表 4-1: Python 可重载的运算符

运算符 方法名称 描述
+ add(self, other) 加法
- sub(self, other) 减法
* mul(self, other) 乘法
/ truediv(self, other) 除法
% mod(self, other) 模运算
== eq(self, other) 等式
!= ne(self, other) 不等式
< lt(self, other) 小于
<= le(self, other) 小于或等于
> gt(self, other) 大于
>= ge(self, other) 大于或等于

让我们将加法和减法操作实现为方法。在 Point 类内部,在 distance_to 方法之后,添加 列表 4-5 中的代码。

class Point:
    --snip--

   def __add__(self, other):
       return Point(
           self.x + other.x,
           self.y + other.y
       )

   def __sub__(self, other):
       return Point(
           self.x - other.x,
           self.y - other.y
       )

列表 4-5:点的加法和减法

方法 add 创建并返回一个新的点,其投影是两个参数投影的和。从代数角度来看,这个操作没有太大意义,但我们以后可能会发现它很有用。方法 sub 做的是同样的事情,只不过结果的投影是输入点投影的差。两个点相减 P – Q 结果是一个从 QP 的向量,但我们还没有为向量创建类。我们将在下一节重构这段代码,使其返回一个向量实例。

让我们实现下一个主要的基础构件:向量。

向量类

与点类似,欧几里得平面中的 向量 由两个数字组成,称为坐标,它们编码了大小和方向。例如,向量 ⟨3, 5⟩ 可以理解为沿水平方向正向移动 3 个单位,沿垂直方向正向移动 5 个单位所得到的位移。图 4-3 展示了欧几里得平面中的向量 Image

Image

图 4-3:平面中的向量 Image

许多物理量是矢量的:它们需要大小和方向才能完全定义。例如,速度、加速度和力都是矢量量。由于向量如此常见,我们来创建一个类来表示它们。

右键点击 geom2d 包文件夹,选择 新建Python 文件。将文件命名为 vector 并点击 确定。然后输入 列表 4-6 中的代码。

class Vector:
    def __init__(self, u, v):
        self.u = u
        self.v = v

列表 4-6:向量类

Vector 类的实现类似于 Point 类。坐标被命名为 u 和 v,而不是 x 和 y。这只是一个惯例,避免无意中混淆点和向量。

在继续之前,让我们重构 Point 类的 sub 方法,使其返回一个 Vector。回想一下,两个点相减P - Q会得到一个从Q指向P的向量。修改你的point.py文件,使其现在与清单 4-7 中的代码相匹配。

import math

from geom2d.vector import Vector

class Point:
    --snip--

    def __sub__(self, other):
        return Vector(
            self.x - other.x,
            self.y - other.y
        )

清单 4-7: 重构 Point 类的 sub 方法

我们将在“向量工厂”一节中详细讨论这个操作,见第 89 页,我们将使用这个操作来创建向量。

现在让我们为 Vector 类实现一些有用的方法。

加法和减法运算符

和点一样,向量的加法和减法是常见操作。例如,我们可以通过对表示两个力的向量求和来得到两个力的和(力是向量量)。

init 方法之后,输入清单 4-8 中的代码。

class Vector:
    --snip--

    def __add__(self, other):
        return Vector(
            self.u + other.u,
            self.v + other.v
        )

    def __sub__(self, other):
        return Vector(
            self.u - other.u,
            self.v - other.v
        )

清单 4-8: 向量的加法和减法

addsub 方法中,我们创建了一个新的 Vector 实例来保存加法或减法的投影结果。

图 4-4 展示了两个向量的加法和减法操作,ImageImage。注意,减去ImageImage可以理解为Image加上–Image

Image

图 4-4: 两个向量的和:Image + Image,和两个向量的差:ImageImage

现在你可能会想知道我们是否会对其他运算符做相同的处理。加法和减法在点和向量的世界中很容易理解,但像 mul 运算符(用于重载乘法操作)就没有那么简单了。乘法到底是点积、叉积,还是向量缩放操作,这一点不太明确。我们不会使用单一的运算符,而是简单地将这些操作实现为具有描述性名称的方法:scaled_by、dot 和 cross。

我们将从缩放开始。

缩放向量

缩放一个向量Image,你需要用一个叫做标量的量k来乘它,标量会拉伸或缩小这个向量。从数学上讲,标量乘法看起来像方程 4.2:

Image

让我们在 Vector 类中创建一个缩放方法。在 sub 方法下输入清单 4-9 中的代码。

class Vector:
    --snip--

   def scaled_by(self, factor):
       return Vector(factor * self.u, factor * self.v)

清单 4-9: 缩放一个向量

在前面的代码中,我们简单地返回一个新的 Vector,其中的 u 和 v 属性被传入的标量因子乘以。

位移点

使用缩放方法,我们可以实现另一个操作:将点 P 通过给定的向量 Image 位移 k 次。从数学上讲,这看起来像方程 4.3。

Image

从图形上看,效果如图 4-5 所示。

Image

图 4-5:通过向量 Image 将点 P 位移给定次数 k(本例中为 2)

让我们在 Point 类中以编程方式实现它,因为位移对象是点(列表 4-10)。

class Point:
    --snip--

   def displaced(self, vector: Vector, times=1):
       scaled_vec = vector.scaled_by(times)
       return Point(
           self.x + scaled_vec.u,
           self.y + scaled_vec.v
       )

列表 4-10:通过向量 Image 将点 P 位移给定次数 k

该方法接收两个参数:一个向量 vector 和一个标量 times。向量将根据 times 进行缩放,产生最终的位移。例如,一个向量 ⟨3, 5⟩,如果 times = 2,则结果为 ⟨6, 10⟩。请注意,参数 times 默认值为 1,因为通常传入的向量已经具有所需的长度。返回的点是源点坐标与位移向量坐标之和。

让我们在 Python shell 中尝试移动一个点。重新启动控制台,以避免先前导入的 Point 和 Vector 类干扰,然后输入以下内容:

>>> from geom2d.point import Point
>>> from geom2d.vector import Vector

>>> p = Point(2, 3)
>>> v = Vector(10, 20)
>>> p_prime = p.displaced(v, 2)
>>> p_prime.__dict__
{'x': 22, 'y': 43}

你可以使用计算器来验证数学运算是否如预期工作。

向量范数

向量的 范数 是它的长度。单位范数 是长度恰好为一个单位的范数。单位范数的向量对于定义方向非常有用;因此,我们通常需要知道一个向量是否具有单位范数(即它是否是 规范的)。我们还经常需要 归一化 一个向量:保持其方向,但将其缩放至长度为 1。二维向量的范数由方程 4.4 给出。

Image

让我们实现一个返回 Vector 范数的属性,并实现另一个检查向量是否规范的属性。两者都包含在列表 4-11 中。

import math

from geom2d import nums

class Vector:
    --snip--

    @property
    def norm(self):
        return math.sqrt(self.u ** 2 + self.v ** 2)

    @property
    def is_normal(self):
        return nums.is_close_to_one(self.norm)

列表 4-11:向量的范数

从范数属性获得的值完全符合方程 4.4 的定义。为了知道一个向量的范数是否为 1,我们使用数值比较方法is_close_to_one,并传入该向量的范数。

我们将实现两个重要操作:一个方法将一个向量 Image 归一化,得到一个方向相同但长度为单位长度的向量 û,另一个方法将一个向量缩放到给定的长度。通过方程 4.5 可以获得向量的归一化版本,我们称其为 单位向量单位向量

Image

这样计算出来的向量将具有长度 1。将该向量与标量 k 相乘,得到的向量是 图片[k],它与原向量方向相同,但长度为标量的值,如 方程 4.6 所示。

图片

在 列表 4-12 中,我们将把这些方程转化为代码。

class Vector:
    --snip--

   def normalized(self):
       return self.scaled_by(1.0 / self.norm)

   def with_length(self, length):
       return self.normalized().scaled_by(length)

列表 4-12:单位长度或指定长度的向量

要归一化一个向量,我们通过它的范数的倒数对其进行缩放(这相当于将向量的长度除以它的范数)。当我们想要将向量缩放到指定长度时,我们只需归一化向量,然后按所需长度进行缩放。

不可变设计

你现在可能已经意识到,我们从未直接修改任何对象的属性,而是创建并返回一个新的 Point 或 Vector 实例。例如,要归一化一个向量,我们可以使用 列表 4-13 中的代码。

def normalize(self):
   norm = self.norm
    self.x = self.x / norm
    self.y = self.y / norm

列表 4-13:就地归一化一个向量

调用该方法会导致 就地归一化,即当前对象属性的变更。就地归一化速度更快且占用更少内存,但也更容易出错。当程序中的其他部分没有预料到这种变化时,程序很容易错误地修改正在使用的对象。找到这种类型的 bug 非常棘手,需要大量的调试。此外,使用不可变数据的程序更容易理解和推理,因为你不需要跟踪对象随时间如何变化其状态。

看一下以下代码,它实现了与之前类似的归一化方法,但其中包含了一个微妙的错误。在这种情况下,归一化将产生错误的结果。你能找出原因吗?

def normalize(self):
    self.x = self.x / self.norm
    self.y = self.y / self.norm

这是一个棘手的例子。在第一行通过改变 self.x 属性,第二次调用获取 self.norm 属性时将使用更新后的 self.x 值。第一次和第二次调用 self.norm 的结果不同,这就是我们必须将 self.norm 的值存储在变量中的原因。

当对象的数据量较小时,最好完全避免变更。你的程序如果并发执行,也会表现得更正确,代码也更容易理解。将可变性减少到最低限度会使代码更加健壮;正如本书中所展示的,我们将尽可能遵循这一原则。

命名约定

注意方法的命名约定。修改对象状态的方法命名如下:

normalize    就地归一化向量

scale_by    就地缩放向量

创建新对象作为结果的方法命名如下:

normalized    返回一个新的归一化向量

scaled_by    返回一个新的缩放向量

接下来,我们将在我们的 Vector 类中实现点积和叉积。这些简单的运算将为一些有用的操作打开大门,比如计算两个向量之间的角度或测试是否垂直。

点积

两个向量 ImageImage 之间的 点积 会得到一个标量值,这是衡量这两个向量方向差异的度量。在二维空间中,若 θ 为两个向量之间的角度,该点积由方程 4.7 给出。

Image

为了理解点积在两个操作数向量的相对方向不同的情况下可能具有的不同值,让我们看一下图 4-6。该图描绘了一个参考向量 Image 和三个其他向量:ImageImageImage。一条垂直于 Image 的线将空间分成两个半平面。向量 Image 位于该线上,因此 ImageImage 之间的角度 θ 为 90°,由于 cos(90^°) = 0,因此 Image · Image = 0。垂直向量的点积为零。向量 Image 恰好与 Image 在同一半平面上;因此,! Image · Image > 0。最后,! Image 位于 Image 的对侧半平面上;因此,! Image · Image < 0。

Image

图 4-6:与 Image 相关的向量方向会产生不同的点积。

根据方程 4.7,实现点积是直接的。在 Vector 类内部,输入列表 4-14 中的代码。

class Vector:
    --snip--

   def dot(self, other):
       return (self.u * other.u) + (self.v * other.v)

列表 4-14:点积

在我们继续讨论叉积之前,先停下来分析它的一个应用:获取向量在给定方向上的投影。

投影向量

当参与点积运算的其中一个向量是单位向量时,运算结果是一个向量在另一个向量上的投影长度。为了理解这一点,参考方程 4.7。给定一个向量 Image 和一个单位向量 Image,其点积为:

Image

其中Image · cosθ正好是ImageImage方向上的投影。这在计算沿某一方向的投影时非常有用,例如,我们可以用它来获取一个桁架构件上力的轴向分量,如图 4-7 所示。在这种情况下,我们只需做Image来计算轴向分量Image

Image

图 4-7:力Image在桁架构件轴向方向Image上的投影

让我们将此操作实现为一个新方法。在你的类中输入清单 4-15 中的代码。

class Vector:
    --snip--

   def projection_over(self, direction):
       return self.dot(direction.normalized())

清单 4-15:一个向量在另一个向量上的投影

请注意,方向参数可能不是单位向量。为了确保我们的公式有效,我们将其标准化。

叉积

两个三维向量的叉积会产生一个新的向量,该向量垂直于包含其他两个向量的平面。操作数的顺序很重要,它决定了结果向量的方向。你可以通过右手定则来确定叉积的方向。请注意,因此这个乘积是非交换的:Image 图 4-8 说明了这一现象。

Image

图 4-8:叉积是非交换的。

在三维空间中,叉积可以使用方程 4.8 进行计算。

Image

在二维空间中,每个向量都包含在同一平面内;因此,每个叉积都会生成一个垂直于该平面的向量。从之前的表达式中很容易观察到这一点,只需注意u[z] = v[z] = 0:

Image

在二维应用中,叉积被认为会产生一个标量值,它是前面表达式中结果向量的 z 坐标。你可以把这个坐标看作是结果向量的长度。由于 x 和 y 坐标为零,z 坐标给出的大小就是我们需要保留的值。给定θ为向量ImageImage之间的夹角,可以通过应用方程 4.9 来获得二维中的叉积操作。

Image

让我们实现叉积。在清单 4-16 中输入代码。

class Vector:
    --snip--

   def cross(self, other):
       return (self.u * other.v) - (self.v * other.u)

清单 4-16:叉积

在二维中,叉积的一个重要应用是确定角度的旋转方向。从图 4-8 可以看到,Image × Image > 0,因为从ImageImage描述了一个正的(逆时针)角度。相反,从ImageImage描述了一个负角度,导致负的叉积Image × Image < 0。最后,注意平行向量的叉积为零,这很容易理解,因为 sin 0 = 0。让我们更仔细地看一下这个事实,并编写我们类中的方法,判断两个向量是否平行或垂直。

平行和垂直向量

使用点积和叉积,可以很容易地测试两个向量是否平行或垂直。列表 4-17 包含了这些操作的代码。

class Vector:
    --snip--

   def is_parallel_to(self, other):
       return nums.is_close_to_zero(
           self.cross(other)
       )

   def is_perpendicular_to(self, other):
       return nums.is_close_to_zero(
           self.dot(other)
       )

列表 4-17:检查向量是否平行或垂直

检查两个向量是否平行非常简单,只需要检查它们的叉积是否为零。同样,检查两个向量是否垂直也很简单,只需要检查它们的点积是否为零。注意,我们使用函数 is_close_to_zero 来解决计算中的浮动点数比较问题。

向量之间的角度

计算两个向量之间的角度可以通过点积表达式来完成:

Image

将一边的点积项除以另一边的范数乘积,并取该表达式余弦的倒数,我们得到方程 4.10:

Image

这个表达式只计算角度的大小;如果我们想知道方向,则需要利用叉积。角度的符号可以通过以下方式得到:

Image

其中 sgn 是符号函数,定义如下:

Image

要理解为什么使用方程 4.10 只能得到角度的大小,我们需要记住余弦函数的一个重要特性。从基本几何中回忆一下,单位向量的角度余弦恰好是其水平投影的值。通过检查图 4-9 中的单位圆可以看到,两个具有相反角度的向量(角度和为零)会分配相同的余弦值。换句话说,cos α = cos (–α),这意味着一旦一个角度通过余弦函数处理,它的符号就永远丢失了。因此,无法从计算出的点积值中确定角度的符号。

Image

图 4-9:对角的余弦相等。

对于我们的许多应用,我们需要角度的大小和符号;借助叉积,我们可以将这些信息带回来。让我们创建两个新方法,一个返回角度的绝对值(用于那些只需要大小的情况),另一个返回包含符号的角度值。在你的 Vector 类中输入清单 4-18 中的代码。

class Vector:
    --snip--

   def angle_value_to(self, other):
       dot_product = self.dot(other)
       norm_product = self.norm * other.norm
       return math.acos(dot_product / norm_product)

   def angle_to(self, other):
       value = self.angle_value_to(other)
       cross_product = self.cross(other)
       return math.copysign(value, cross_product)

清单 4-18:计算两个向量之间的角度

第一个方法 angle_value_to 计算 self 和 other 之间的角度,使用方程 4.10。我们首先获得点积值,并将其除以模的乘积。然后,角度就是结果的反余弦值。第二个方法 angle_to 返回带有符号的角度值,这个符号来自叉积。Python 中的 math.copysign(x, y)函数返回 x 的大小,并使用 y 的符号。

让我们在控制台中尝试这两种方法。重新加载并输入以下内容:

>>> from geom2d.vector import Vector
>>> u = Vector(1, 0)
>>> v = Vector(1, 1)

>>> v.angle_value_to(u)
0.7853981633974484 # result in radians

>>> v.angle_to(u)
-0.7853981633974484 # result in radians

仅供参考,角度值 0.78539...是π/4 弧度(45^°)。

现在假设我们有一个向量,并且希望通过旋转原始向量一定角度来创建一个新向量。

旋转向量

想象一下,在棒子受外力作用的情况下,正如我们在图 4-7 中看到的那样,我们还希望知道外力在与棒子垂直方向上的投影!Image,这就是力的剪切分量。为了找到力的投影,我们首先需要找出一个垂直于棒子方向的向量û,这个向量通过将其旋转π/2 弧度获得,如图 4-10 所示。

Image

图 4-10:旋转棒的方向向量 π/2 弧度

旋转保持原始向量的长度不变,因为旋转是一种尊重长度的变换。假设α是我们希望向量旋转的角度,我们可以使用方程 4.11:

Image

这在 Python 中变成清单 4-19 中的代码。

class Vector:
    --snip--

   def rotated_radians(self, radians):
       cos = math.cos(radians)
       sin = math.sin(radians)
       return Vector(
           self.u * cos - self.v * sin,
           self.u * sin + self.v * cos
       )

清单 4-19:旋转向量

rotated_radians 函数返回一个新向量,这是通过给定的弧度数旋转原始向量得到的结果。遵循我们的不可变性原则,我们从不修改源向量;相反,我们返回一个应用了旋转的新向量。

有一个角度,π/2 弧度(90^°),它对于旋转向量非常有用。使用π/2 弧度,我们得到一个与原始向量垂直的新向量。为了避免一遍又一遍地写 v.rotated_radians(math.pi / 2),我们可以在 Vector 类中定义一个新方法。知道 cos (π/2) = 0,sin (π/2) = 1,方程 4.11 中的角度简化为以下形式:

Image

我们将这个方法命名为 perpendicular。在 Python 中,它看起来像清单 4-20。

class Vector:
    --snip--

   def perpendicular(self):
       return Vector(-self.v, self.u)

清单 4-20:获取垂直向量

我们经常用来旋转的另一个角度是 π 弧度(180^°)。旋转一个向量 π 弧度会得到一个共线但方向相反的向量。这时,cos (π) = –1 且 sin (π) = 0。现在,方程 4.11 中的角度看起来是这样的:

Image

让我们称这个方法为 opposite。在 Python 中,它看起来像 列表 4-21。

class Vector:
    --snip--

   def opposite(self):
       return Vector(-self.u, -self.v)

列表 4-21:获取相反向量

这两个方法——垂直和相反——其实并没有增加我们之前没有的任何东西;我们本可以直接使用 rotated_radians。不过,它们非常方便,我们将经常使用它们。

正弦和余弦

为了将向量量投影到 x 轴和 y 轴上,我们使用向量角度的正弦或余弦值,如 图 4-11 所示。

我们将使用这些来计算 第五部分中桁架结构杆件在全局坐标系下的刚度矩阵。杆件的刚度矩阵是相对于一个参考框架计算的,该框架的 x 轴与杆件的导线方向一致,但我们需要将这个矩阵投影到全局的 x 轴和 y 轴方向上,以建立结构的全局方程组。

如果向量类没有提供这两个属性,使用该类的客户端可以获取它的角度值,然后计算它的正弦或余弦。尽管这是完全可以接受的,但它需要先计算角度,然后再进行一次额外的正弦或余弦操作。但正如你所知道的,我们可以通过它们的数学定义更高效地计算正弦和余弦值。

Image

图 4-11:向量投影

假设我们有一个向量 Image,其范数为 ∥Image∥,它的投影分别为 uv。可以通过以下方式计算正弦和余弦:

Image

让我们将这些实现为向量类的属性。输入代码到 列表 4-22。

class Vector:
    --snip--

    @property
    def sine(self):
        return self.v / self.norm

    @property
    def cosine(self):
        return self.u / self.norm

列表 4-22:向量的方向正弦和余弦

鉴于前面的表达式,实施起来是直接的。让我们通过添加最后的细节来完成我们的点类和向量类。

完成我们的类

我们的点类和向量类看起来不错,但它们缺少一些小细节。如果我们比较它们的两个实例,Python 可能无法确定它们是否相等;我们稍后会解决这个问题。而且,正如你记得的,Python 会在控制台打印对象实例,附带类名和内存地址,但这对我们来说不太有用;我们也会在这里修复这个问题。

检查相等性

尝试在 shell 中输入以下内容(不要忘记重新加载)。

  >>> from geom2d.point import Point
  >>> p = Point(1, 0)
  >>> p == p
➊  True

  >>> q = Point(1, 0)
  >>> p == q
➋  False

我敢打赌 ➊ 并没有让你感到惊讶:一个 Point 与它自己是相等的。那么 ➋ 呢?你是不是皱了皱眉?我们在比较两个具有相同坐标的点,但 Python 说它们是不同的。难道 (1, 0) 不应该等于 (1, 0) 吗?它应该是相等的,但首先我们需要教 Python 如何比较两个给定的类实例。默认情况下,Python 认为两个类的实例相等,前提是它们实际上是同一个实例,也就是说,它们位于同一内存区域。为了更明确一些,请在控制台上写下这个:

>>> p
<geom2d.point.Point object at 0x10baa3f60>

>>> q
<geom2d.point.Point object at 0x10c63b438>

Python 将实例 p 视为位于内存地址 0x10baa3f60 的实例,实例 q 位于 0x10c63b438。别忘了,你实例的内存地址与这些会有所不同。我们必须指示 Python 通过检查投影是否足够接近,来将我们的 Point 实例视为相同。你还记得 表 4-1 吗?通过实现一个名为 eq(self, other) 的方法,你实际上是在重载 == 运算符。让我们为 Point 和 Vector 类都实现这一点。

列表 4-23 包含了 Point 类的代码(别忘了导入 nums)。

import math

from geom2d import nums

class Point:
   --snip--

   def __eq__(self, other):
      if self is other:
           return True

       if not isinstance(other, Point):
           return False

       return nums.are_close_enough(self.x, other.x) and \
              nums.are_close_enough(self.y, other.y)

列表 4-23:Point 类相等性实现

列表 4-24 包含了 Vector 类的代码。

import math

from geom2d import nums

class Vector:
   --snip--

   def __eq__(self, other):
      if self is other:
           return True

       if not isinstance(other, Vector):
           return False

       return nums.are_close_enough(self.u, other.u) and \
              nums.are_close_enough(self.v, other.v)

列表 4-24:实现向量相等性

正如你所看到的,无论哪种情况,思路都是一样的:将坐标与另一个给定实例进行比较。但在此之前,我们要做两个重要的检查。第一个是检查我们是否在比较同一个实例与它自己,在这种情况下我们不需要进一步的比较,因此直接返回 True。第二个检查是检查 other 是否不是该类的实例。由于 Python 允许我们比较任何两个对象,我们可能会将一个 Vector 实例与一个字符串进行比较。例如,如果我们检测到这种比较来自不同类的实例,我们就返回 False,比较结束。你会在全书中看到这种比较模式,因为我们所有实现了 eq 的类都会使用这种方法。

为了确保我们没有犯错,让我们重复实验一次。别忘了重新加载控制台以导入代码的最新版本,并输入以下代码:

>>> from geom2d.point import Point
>>> p = Point(1, 0)
>>> p == p
True

>>> q = Point(1, 0)
>>> p == q
True

就这样!现在我们的 Point 和 Vector 类的比较终于按预期工作了。

字符串表示

正如你在控制台中评估类实例时看到的,输出并不太有帮助:

>>> from geom2d.vector import Vector
>>> v = Vector(2, 3)
>>> v
<geom2d.vector.Vector object at 0x10c63b438>

如果我们尝试使用 str 函数将实例转换为其字符串表示,我们会得到相同的结果:

>>> str(p)
'<geom2d.vector.Vector object at 0x10c63b438>'

当将 Vector 实例的字符串表示打印到控制台时,我们会发现如下内容更有用:

>>> str(p)
'(2, 5) with norm 5.385164807134504'

该消息包含了坐标值和范数值的信息。Python 中的 str() 函数将类的实例转换为其字符串表示形式。此函数首先检查传入的参数是否实现了 str 方法。如果实现了,它会调用该方法并返回结果。如果没有实现,它则直接返回默认的字符串表示形式,在我们的案例中就是那个没有用的内存位置。

让我们在我们的类中实现 str 方法。进入 Point 类并输入 Listing 4-25。

class Point:
   --snip--

   def __str__(self):
       return f'({self.x}, {self.y})'

Listing 4-25: 为 Point 类重写字符串表示

然后在 Vector 类中输入 Listing 4-26。

class Vector:
   --snip--

   def __str__(self):
       return f'({self.u}, {self.v}) with norm {self.norm}'

Listing 4-26: 为 Vector 类重写字符串表示

我们使用 f-strings(f’’)将实例属性包含在字符串中。属性插入在大括号之间,Python 会调用它们的 str 方法来获取它们的字符串表示并连接结果。例如,你可以将 f-string 看作是,

f'({self.x}, {self.y})'

被 Python 翻译成类似这样的内容:

"(" + str(self.x) + ", " + str(self.y) + ")"

现在,在我们的类的实例上使用 str() 时,会打印出更好的描述。让我们重新加载 Python shell 并再试一次:

>>> from geom2d.vector import Vector
>>> v = Vector(2, 3)
>>> str(v)
'(2, 3) with norm 3.605551275463989'

好多了,不是吗?

向量工厂

工厂函数 只是构建对象的一个函数。工厂函数是初始化需要一些计算的对象的好选择。初始化函数理想情况下应该仅设置类的属性,避免任何计算;为此我们将使用工厂函数。

工厂函数同样有助于提高代码的可读性。例如,如果你想从点 P 到另一个点 Q 创建一个向量,代码

    make_vector_between(p, q)

比起这段代码,阅读起来好多了:

    Vector(q.x - p.x, q.y - p.y)

不仅如此,后者很可能会被写多次,这应该告诉你,某个算法需要被抽象为一个独立的概念。在这个特殊的情况下,算法是用来在两个有序点之间创建向量的公式(见 Equation 4.12)。

注意

缺少抽象是一个常见的问题。它发生在一个表示具体概念的算法没有被恰当地封装到其自己的函数或类中,并且没有一个描述性的名称。其主要风险在于,当抽象没有很好地封装时,我们的大脑理解代码的速度变慢,且相同的算法会被复制粘贴到多个地方,导致难以维护。

geom2d 中创建一个新文件,命名为 vectors,并输入 Listing 4-27 中的代码。

from geom2d.point import Point
from geom2d.vector import Vector

def make_vector_between(p: Point, q: Point):
    return q - p

def make_versor(u: float, v: float):
    return Vector(u, v).normalized()

def make_versor_between(p: Point, q: Point):
    return make_vector_between(p, q).normalized()

Listing 4-27: 向量工厂函数

这个文件定义了几个函数,它们的目的是创建向量。我们定义的第一个函数 make_vector_between,用于创建从点 p 到点 q 的向量。我们利用 Point 类的 sub 实现来创建这两个点之间的向量。这是一种便捷的创建向量的方法,数学上如方程 4.12 所示。

Image

接下来,我们有一个名为 make_versor 的函数,它创建单位长度的单位向量单位向量常用于表示方向或朝向,因此我们希望能有一个方便的方式来创建它们。请注意,单位向量通常在其上方有一个小帽符号,例如û,表示它们的长度是单位长度。

最后,我们有一个 make_versor_between 函数,用于创建两个点之间的单位向量,它会重用 make_vector_between 函数来返回标准化后的结果。得到的单位向量也可以通过方程 4.13 来计算。

Image

单元测试

到目前为止,我们已经在 Point 和 Vector 类上实现了一些方法,并在控制台手动测试过其中的一些方法,但现在我们面临一些重大问题:我们如何说服别人相信我们的代码始终按预期工作?我们如何确保自己写的代码始终有效?我们如何确保在修改现有代码或添加新代码时不会破坏任何功能?

经常需要回到你很久以前写的一段代码中去修复一个 bug。当你想要修改这段代码时,问题就出现了,因为你不知道这个修改是否会破坏已经正常工作的部分。事实上,你可能根本不了解所有代码的功能是什么,因此你可能会不小心修改了不该修改的部分,从而破坏了其他功能。这种现象发生得非常频繁,以至于它有了自己的名字:回归

在控制台手动测试代码既累人又无聊,这样做你很可能测试不到所有需要测试的内容。而且,它不是一个可重复的过程:你会忘记已经执行了哪些测试,或者如果其他人需要运行这些测试,他们得弄清楚应该测试什么,怎么测试。但是,我们真的需要确保我们的修改不会破坏任何东西。如果代码不能按预期工作,那它就完全没有用处。

让我们的生活变得更加轻松的是一种我们可以执行的自动化测试,它只需要几毫秒就能运行,并输出明确的结果,指出是否发生了错误,发生在哪里,为什么会出错。这就是单元测试的基本思想,它是任何一位认真开发者必不可少的活动。在没有伴随良好的单元测试来证明代码质量之前,代码不能算完成。我认为这部分开发工作至关重要,所以我想在书中早期就介绍它,并广泛使用它。为我们的代码编写自动化的单元测试是一个简单的过程,实际上没有任何理由不这么做。

为你的代码创建单元测试很简单:创建一个新文件,并在其中添加一个新类,类中包含测试目标小部分的方法。每个测试用例都有一个 断言 函数,用来确保在给定一组输入的情况下得到特定的结果。当断言成功时,测试被视为通过;否则,测试失败。当执行测试类时(如我们接下来所见),方法会被执行,且其断言会被检查。

如果这还不太清楚,别担心;我们将在本书中大量使用单元测试,你将能完全理解它。

测试距离

我们为 Point 编写的第一个方法是 distance_to,所以让我们从这里开始我们的单元测试之旅。在 geom2d 包中,创建一个名为 point_test.py 的新文件。你的项目结构应该如下所示:

力学

|- geom2d

|    |- init.py

|    |- nums.py

|    |- point.py

|    |- point_test.py

|    |- vector.py

|    |- vectors.py

point_test.py 中,输入来自 清单 4-28 的代码。

  import unittest

  from geom2d.point import Point

➊ class TestPoint(unittest.TestCase):

   ➋ def test_distance_to(self):
          p = Point(1, 2)
          q = Point(4, 6)
          expected = 5
          actual = p.distance_to(q)

       ➌ self.assertAlmostEqual(expected, actual)

清单 4-28:点之间的距离测试

我们首先导入 Python 附带的 unittest 模块。这个模块为我们提供了编写和执行单元测试所需的大部分基础设施。导入 Point 类后,我们定义了 TestPoint 类,它继承自 unittest.TestCase ➊。TestCase 类定义了一套很好的断言方法,继承它后,我们可以在类中使用这些方法。

接下来是 test_distance_to 方法 ➋。方法名必须以 test_ 开头,这一点非常重要,因为这就是类用来发现哪些方法是需要执行的测试的方法。你可以在类中定义其他方法,但只要它们的名称没有以 test 开头,它们就不会作为测试被执行。在测试中,我们创建了两个已知相距 5 个单位的点,并断言它们的距离 p.distance_to(q) 接近这个值。

注意

unittest 模块的命名可能会让人困惑。即使测试本身实际上是类中的方法,UnitTest 这个名字仍然被用于类。我们扩展 UnitTest 类,只是为了将相关的测试用例分组。

断言方法 assertAlmostEqual ➌(定义在我们继承的类中:unittest.TestCase)用于检查浮动小数的相等性,允许给定的容忍度,该容忍度以比较的小数位数表示。默认检查的小数位数为 7,在本次测试中,我们将遵循默认值(因为没有提供其他值)。记住,当进行浮动小数比较时,必须使用容忍度,或者在此情况下,使用给定的小数位数(见第 4 页的“比较数字”)。

有几种方法可以运行测试。让我们探索如何从 PyCharm 和控制台运行测试。

从 PyCharm 运行测试

如果你查看 PyCharm 中的测试文件,你会看到类和方法定义左边有一个绿色的播放按钮。类按钮会执行其中的所有测试(到目前为止我们只有一个),而方法旁边的按钮只会运行那个测试。点击类按钮;从菜单中选择运行‘Unittest for point’。运行窗格会出现在 IDE 的下部,执行测试的结果会显示出来。如果你做对了,你应该看到以下内容:

--snip--

Ran 1 test in 0.001s

OK

Process finished with exit code 0

现在让我们学习如何从控制台运行相同的测试。

从控制台运行测试

除 PyCharm 外,其他 IDE 可能有自己运行测试的方式。但无论你使用什么 IDE,你总是可以从控制台运行测试。打开控制台或命令行,确保你在Mechanics项目目录中。然后运行以下命令:

$ python3 -m unittest geom2d/point_test.py

你应该看到以下结果:

Ran 1 tests in 0.000s

OK

我们将通过本书的 IDE 运行大部分测试,但如果你更喜欢,也可以从控制台运行它们。

断言错误

让我们看看如果断言检测到错误结果会发生什么。在point_test.py文件中,改变距离的预期值:

expected = 567

这个断言期望点(1, 2)和(4, 6)之间相距 567 单位,这完全是错误的。现在通过点击类旁边的绿色播放按钮再次执行测试。你应该看到以下结果:

Ran 1 test in 0.006s

FAILED (failures=1)

Failure
Traceback (most recent call last):
  --snip--
  File ".../geom2d/tests/point_test.py", line 14, in test_distance_to
    self.assertAlmostEqual(expected, actual)
  --snip--

AssertionError: 567 != 5.0 within 7 places (562.0 difference)

最有价值的信息出现在最后一条消息中。它告诉我们发生了断言错误;也就是说,当它发现 5.0 而预期是 567 时,断言失败了。它在比较中使用了 7 位小数,仍然发现了 562 的差异。

在这个断言错误之前是回溯,即 Python 执行路径,直到发生错误。如消息所示,离失败越近的调用出现在列表的最后。正如你所看到的,测试执行在文件point_test.py中失败(这不奇怪),发生在第 14 行(你可能会看到不同的行号),在名为 test_distance_to 的测试中。这个信息在你修改现有代码并运行测试时将非常宝贵,因为它能告诉你到底是哪里出错了。这些测试失败信息会提供非常精确的细节。

别忘了把我们的单元测试恢复到最初的样子,并确保它仍然能成功运行。

测试向量的加法和减法操作

为了确保+和-操作对于向量正常工作(对于 Point 类的测试留作练习),我们使用以下测试用例:

Image

Image

geom2d包中创建一个新文件,用于测试 Vector 类。命名为vector_test并输入清单 4-29 中的代码。

import unittest

from geom2d.vector import Vector

class TestVector(unittest.TestCase):
    u = Vector(1, 2)
    v = Vector(4, 6)

    def test_plus(self):
        expected = Vector(5, 8)
        actual = self.u + self.v
        self.assertEqual(expected, actual)

    def test_minus(self):
        expected = Vector(-3, -4)
        actual = self.u - self.v
        self.assertEqual(expected, actual)

清单 4-29:加法和减法操作的测试

使用类定义左侧的绿色播放按钮运行所有测试。如果你一切正确,两个新的测试应该会成功。太好了!我们的操作已经正确实现。好的一点是,如果实现中有 bug,这些测试会指出问题出现的地方和原因。

值得注意的是,这一次我们使用了断言方法 assertEqual,它在底层使用==操作符比较两个参数。如果我们没有在 Vector 类中重载该操作符,即使结果正确,测试也会失败。试试看:注释掉 Vector 类中的 eq(self, other)方法定义(通过在行首加上#字符),然后重新运行测试。

你会看到最后两个测试失败,错误信息可能类似如下:

<geom2d.vector.Vector object at 0x10fd8d198> !=
<geom2d.vector.Vector object at 0x10fd8d240>

Expected :<geom2d.vector.Vector object at 0x10fd8d240>
Actual   :<geom2d.vector.Vector object at 0x10fd8d198>

熟悉吗?那是 Python 假设来自类的两个对象只有在它们是同一个实际对象并且在同一内存位置时才会相等。我们的 eq 操作符重载向 Python 解释了何时两个对象应该被视为相同。不要忘记取消注释该方法。

测试向量积操作

让我们为点积和叉积添加两个新的测试用例,使用测试类中定义的相同两个向量:

Image

Image

在代码中,这看起来像列表 4-30。

import unittest

from geom2d.vector import Vector

class TestVector(unittest.TestCase):

    --snip--

    def test_dot_product(self):
        expected = 16
        actual = self.u.dot(self.v)
        self.assertAlmostEqual(expected, actual)

    def test_cross_product(self):
        expected = -2
        actual = self.u.cross(self.v)
        self.assertAlmostEqual(expected, actual)

列表 4-30:测试向量的点积和叉积

运行所有测试用例,确保新测试也通过。注意,由于我们再次在比较数字,我们使用断言方法 assertAlmostEqual。

测试向量平行性和垂直性

接下来我们将测试 is_parallel_to 和 is_perpendicular_to 方法。由于我们在检查布尔表达式,我们希望有两个测试,一个检查两个向量是否平行(正向测试),一个检查它们是否不平行(反向测试)。对于正向测试,我们将依赖于一个事实:一个向量总是平行于它自身。将列表 4-31 中的代码输入到 TestVector 中。

import unittest

from geom2d.vector import Vector

class TestVector(unittest.TestCase):

    --snip--

    def test_are_parallel(self):
        self.assertTrue(self.u.is_parallel_to(self.u))

    def test_are_not_parallel(self):
        self.assertFalse(self.u.is_parallel_to(self.v))

列表 4-31:测试向量平行性

在这个列表中有两个新的断言方法,比较有趣:assertTrue,用来检查给定的表达式是否求值为 True;assertFalse,用来检查给定的表达式是否求值为 False。

我们将遵循相同的模式来检查垂直性。在最后两个测试后,将这两个测试输入到列表 4-32 中。

import unittest

from geom2d.vector import Vector

class TestVector(unittest.TestCase):

    --snip--

    def test_are_perpendicular(self):
        perp = Vector(-2, 1)
        self.assertTrue(self.u.is_perpendicular_to(perp))

    def test_are_not_perpendicular(self):
        self.assertFalse(self.u.is_perpendicular_to(self.v))

列表 4-32:测试向量垂直性

运行 TestVector 类中的所有测试,确保它们成功。恭喜你!你已经编写了你的第一个单元测试。这些测试将确保我们的几何类中的方法按预期工作。此外,如果你找到了更好的实现方式来优化我们覆盖测试的某个方法,只需运行该方法的测试,确保它仍然按预期工作。测试还有助于文档化你代码的预期行为。如果你在某个时刻需要提醒自己,了解你编写的代码在特定情况下应该做什么,单元测试应该能够帮到你。

编写好的测试并不是一件简单的事情。通过写很多测试才能变得熟练,但我们可以遵循一些准则,帮助我们写出更好的测试。让我们来看一下三条简单的规则,它们能让我们的测试更加坚韧。

单元测试的三条黄金法则

我们已经覆盖了点(Point)和向量(Vector)类的一小部分方法的测试。现在,你已经掌握了所需的知识,试着测试我们在点(Point)和向量(Vector)类中编写的所有方法。我会把这个任务留给你作为练习,但如果你需要帮助,可以查看书中提供的代码:它包含了大量的单元测试。寻找我们没有测试的方法,并编写你认为需要的测试,确保它们能正常工作。我鼓励你尝试,但如果你仍然觉得单元测试对你来说很陌生,别担心,我们会在本书的其他章节中编写单元测试。

正如前面提到的,我相信编写单元测试是编程的重要组成部分,对于没有覆盖单元测试的软件,应该被视为不好的实践。此外,为开源社区编写代码时,需要良好的单元测试。你必须给社区一个理由,让他们相信你做的事情真的有效。通过自动化测试来证明这一点,总是一个不错的方法,因为几乎不可能有人会花时间考虑如何测试你的代码,然后打开控制台手动逐一尝试。

随着实践的积累,你会在编写可靠的单元测试方面变得越来越好。现在,我想给你一些基本规则,帮助你入门。不要期望现在就能完全理解它们的含义,但随着你阅读书中的内容,可以时不时回过头来看看这一节。

规则 1:一个失败的原因

单元测试应该只有一个失败的原因。这听起来很简单,但在很多情况下,测试对象(你正在测试的内容)是复杂的,由多个组件协同工作。

如果测试失败的原因只有一个,那么找到代码中的 bug 就非常简单。想象一下相反的情况:一个测试可能会因为五个不同的原因而失败。当这个测试失败时,你会发现自己花费太多时间在阅读错误信息和调试代码上,试图理解这次失败的具体原因。

一些开发者和测试专家(测试本身就是一个职业,我曾从事这个工作多年)认为每个测试应该只有一个断言。从实用主义的角度来看,有时多个断言并不会造成太大危害,但如果只用一个,那会更好。

让我们分析一个特定的案例。以我们为检查两个向量是否垂直而编写的测试为例。如果不是

def test_are_perpendicular(self):
    perp = Vector(-2, 1)
    self.assertTrue(self.u.is_perpendicular_to(perp))

我们已经编写了

def test_are_perpendicular(self):
    perp = u.perpendicular()
    self.assertTrue(self.u.is_perpendicular_to(perp))

那么,测试可能会因为is_perpendicular_to方法中的错误,或因为我们用来计算垂直向量的perpendicular实现有问题而失败,查看这之间的区别吗?

规则 2:受控环境

我们使用fixture这个词来指代测试运行的环境。环境包括围绕测试的所有数据和测试对象本身的状态,所有这些都可能改变测试的结果。这个规则规定你应该完全控制你的测试运行的环境。测试的输入和预期输出应该始终预先知道。测试中发生的一切都应该是可确定的;也就是说,不应有任何随机性或依赖于你无法控制的事物:例如日期、时间、操作系统、未由测试设置的机器环境变量等。

如果你的测试看起来随机失败,那么它们是没有用的,你应该将它们删除。人们很快就会习惯于随机失败的测试并开始忽视它们。问题在于,当他们也忽视因为代码中的漏洞而失败的测试时,问题就会出现。

规则 3:测试独立性

测试不应依赖于其他测试。每个测试应独立运行,绝不依赖于其他测试所设置的环境。

这样做至少有三个原因。首先,你希望独立运行或调试测试。其次,许多测试框架不保证测试执行的顺序。最后,阅读和理解不依赖其他测试环境的测试要简单得多。

让我们通过清单 4-33 中的TestSwitch类来说明这一点。

class TestSwitch(unittest.TestCase):

   switch = Switch()

   def test_switch_on(self):
    self.switch.on()
    self.assertTrue(self.switch.is_on())

   def test_switch_off(self):
    # Last test should have switched on
    self.switch.toggle()
    self.assertTrue(self.switch.is_off())

清单 4-33:测试依赖于另一个测试

看到test_switch_off是如何依赖于test_switch_on的吗?通过使用名为toggle的方法,如果测试顺序不同,而在这个测试执行时开关的状态是关闭,我们可能会得到错误的结果。

永远不要依赖测试执行顺序;那会导致问题。测试应该始终独立运行:无论执行顺序如何,它们的工作方式应该是相同的。

总结

在本章中,我们创建了两个重要的类:Point 和 Vector。我们 geom2d 库的其余部分将建立在这些简单但强大的抽象概念之上。我们通过实现特殊方法 eq,教会了 Python 如何判断两个给定的 Point 或 Vector 实例是否在逻辑上相等,并通过 str 提供了更好的文本表示。我们通过单元测试覆盖了这些类中的一些方法,并鼓励你自行扩展测试覆盖率。学习编写优秀单元测试的最佳方式是通过实践。在下一章中,我们将向 geom2d 中添加两个新的几何抽象:线和线段。这些提供了一个新的维度,可以用来构建更复杂的形状。

第五章:线与线段

Image

一个点和一个方向描述了一条无限的直线,没有起点或终点。两个不同的点限定了一条线段,它具有有限的长度,但包含无限个点。在本章中,我们将重点关注这两种基本元素——线段和直线。我们将借助前一章中实现的点和向量来实现这两者。

我们还将花一些时间理解和实现两个算法:一个用于计算离线段最近的点,另一个用于计算线段交点。这些算法使用了一些几何学中的重要概念,这些概念将作为更复杂问题的基础。我们将花时间实现这些操作,以确保我们理解它们,所以准备好你的 Python IDE,拿出纸笔——以老式方式画图会很有帮助。

线段类

在平面上任意两点之间都存在一条唯一的线段,它是一条有限长度、包含无限个点的直线。图 5-1 描绘了两点* S E *之间的线段。

Image

图 5-1:S 点和 E 点之间的线段

让我们从创建一个名为 Segment 的类开始,类中有两个属性:起点S和终点E。到目前为止,我们的项目结构如下所示:

机械学

|- geom2d

|    |- init.py

|    |- nums.py

|    |- point.py

|    |- point_test.py

|    |- vector.py

|    |- vector_test.py

|    |- vectors.py

右键点击geom2d包,选择新建Python 文件,命名为segment,然后点击确定。PyCharm 会为你自动添加.py扩展名,但如果你使用的是其他 IDE,可能需要手动添加。在文件中,输入如列表 5-1 所写的类。

from geom2d.point import Point

class Segment:
    def __init__(self, start: Point, end: Point):
        self.start = start
        self.end = end

列表 5-1:线段初始化

我们首先从geom2d.point模块导入 Point 类。然后,我们定义了 Segment 类,并为其创建了一个初始化函数,该函数接受两个点:起点和终点。这些点将存储在相应的属性中。

请注意,我们对参数进行了类型标注;更具体地说,我们表示它们必须是 Point 类型。这些是我们在第二章中看到的类型提示,主要是为了让 IDE 为我们提供一些上下文帮助。如果 IDE 知道起点和终点都是 Point 类的对象,它会检测我们是否在尝试使用该类没有实现的属性。但需要注意的是,这不会阻止我们在运行时传递错误的参数类型。事实上,如果你在控制台尝试以下代码:

>>> from geom2d.segment import Segment
>>> s = Segment("foo", "bar")

>>> s.start
'foo'

你应该会看到,Python 允许我们传递字符串而不是 Points 而不报错,因为类型提示在 Python 解释器运行时会被忽略。

线段的方向

线段的一个重要属性是它的方向,定义为从起点S到终点E的向量。如果我们将其称为Image,我们可以通过方程式 5.1 来计算它。

Image

方向向量的归一化得到方向单位向量,这在许多与线段的操作中也常常使用。方向向量是一个与线段长度相同并与其平行的向量,方向从起点指向终点。方向单位向量是方向向量的归一化版本,即一个具有相同方向但单位长度的向量。

方向单位向量 Image,给定一个长度为l的线段,计算公式如公式 5.2 所示。

Image

注意

当我们说线段的方向时,大多数时候我们指的是方向单位向量 Image,但有时我们也会用这个短语来指代方向向量 Image。如果是这种情况,我们会明确说明。所以,如果没有特别说明,假设我们指的是方向单位向量。

让我们将它们作为类的属性进行实现。将清单 5-2 中的代码输入到你的segment.py文件中。

from geom2d.point import Point
from geom2d.vectors import make_vector_between, make_versor_between

class Segment:
    --snip--

    @property
    def direction_vector(self):
        return make_vector_between(self.start, self.end)

    @property
    def direction_versor(self):
        return make_versor_between(self.start, self.end)

清单 5-2:计算线段的方向向量和方向单位向量

由于我们使用了在vectors.py中定义的 make_vector_between 和 make_versor_between 工厂函数,这两个属性的实现非常简单。我们只是创建一个起点和终点之间的向量或单位向量。

现在,线段的方向和它的垂直方向一样重要。我们可能会使用这个垂直方向,例如计算与直线碰撞的粒子速度方向,这条直线可能代表一堵墙或地面,就像图 5-2 中的情况。

Image

图 5-2:使用法线方向计算碰撞角度

将方向单位向量 Image 旋转 π/4 弧度(90^°)得到线段的法线单位向量。使用 Vector 的 perpendicular 属性计算这个单位向量非常简单。将新的属性输入到清单 5-3 中,放在 Segment 类中。

class Segment:
    --snip--

    @property
    def normal_versor(self):
        return self.direction_versor.perpendicular()

清单 5-3:计算垂直于线段方向的向量

我们添加的这个新属性通过链式调用两个属性:direction_versor 和 perpendicular。我们首先调用 self 的 direction_versor 来获取线段的方向单位向量。结果是一个 Vector 实例,接着我们调用 perpendicular 方法,它返回一个垂直于线段方向的单位向量。

我们本可以将方向单位向量存储在一个新变量中,然后对该变量调用垂直方法:

def normal_versor(self):
    d = self.direction_versor
    return d.perpendicular()

在这种情况下,d 变量并没有增加代码的可读性,并且由于我们只使用它一次,我们可以将两个方法链式调用并返回结果。你会在我们的代码中经常看到这种模式。

你可以在图 5-3 中看到我们刚刚实现的概念的可视化表示。左侧的线段显示了方向向量Image,其起点为S(起点),终点为E(终点)。右侧的线段显示了方向向量的标准化版本Image及其垂直对应物Image,分别是方向向量和法向量。

Image

图 5-3:线段方向向量(左)以及方向和法向量(右)

在这一节中我们将跳过编写单元测试,但这并不意味着你不应该写单元测试。从现在开始,我不会为我们编写的每个方法都写测试,只会挑选一些方法,以便保持专注并推进内容。但你可以写单元测试来测试这些未测试的方法,这对你是一个很好的练习。你可以参考书中附带的Mechanics项目中的测试。

线段的长度

线段的另一个重要属性是它的长度,即其端点之间的距离。

计算长度

我们可以通过至少两种方式计算该段的长度:我们可以计算点SE之间的距离,或者计算方向向量的长度Image

我们将使用第一种方法,如 Listing 5-4 所示,但如果你愿意,也可以实现第二种方法。结果应该是相同的。

class Segment:
    --snip--

    @property
    def length(self):
        return self.start.distance_to(self.end)

Listing 5-4:计算线段的长度

再次注意,使用我们之前实现的方法使得这个计算变得轻松。此时,你的segment.py文件应该看起来像 Listing 5-5。

from geom2d.point import Point
from geom2d.vectors import make_vector_between, make_versor_between

class Segment:
    def __init__(self, start: Point, end: Point):
        self.start = start
        self.end = end

    @property
    def direction_vector(self):
        return make_vector_between(self.start, self.end)

    @property
    def direction_versor(self):
        return make_versor_between(self.start, self.end)

    @property
    def normal_versor(self):
        return self.direction_versor.perpendicular()

    @property
    def length(self):
        return self.start.distance_to(self.end)

Listing 5-5:Segment 类

让我们测试一下刚写的方法。

单元测试长度

为了确保我们在实现长度属性时没有犯错,让我们编写一个单元测试。首先创建一个新的测试文件。右键点击geom2d包,选择新建Python 文件,命名为segment_test.py,然后点击确定。然后输入 Listing 5-6 中的代码。

import math
import unittest

from geom2d.point import Point
from geom2d.segment import Segment

class TestSegment(unittest.TestCase):

    start = Point(400, 0)
    end = Point(0, 400)
    segment = Segment(start, end)

    def test_length(self):
        expected = 400 * math.sqrt(2)
        actual = self.segment.length
        self.assertAlmostEqual(expected, actual)

Listing 5-6:测试线段的长度属性

我们导入了unittestmath模块以及 Segment 和 Point 类。接着,我们定义了两个点:起点为(400, 0),终点为(0, 400)。利用这两个点,我们创建了 segment,它是我们的测试对象。按照好单元测试的规则 1,一个测试应该仅因一个原因失败,我们的预期结果直接表示为Image,该结果来自Image。这里的诱惑是写下如下内容:

expected = self.start.distance_to(self.end)

然而,这会违反规则 1,因为测试可能因为多个原因失败。此外,在这种情况下,预期值和实际值将使用相同的方法进行计算:distance_to。这破坏了测试与它应该测试的代码之间的独立性。

通过点击 TestSegment 类定义左侧的绿色播放按钮,并选择运行‘线段单元测试’来运行测试。你也可以通过控制台运行:

$ python3 -m unittest geom2d/segment_test.py

测试距离属性可能看起来有些愚蠢,因为它唯一的作用就是调用已经测试过的 distance_to 方法。即使是这样简单的实现,我们也可能会犯错误,比如尝试用同一个点计算两次距离:

self.start.distance_to(self.start)

正如你可能从自己的经验中知道的那样,我们开发者经常会犯这样的错误。

t 参数和中点

我们之前说过,线段的起点 E 和终点 S 之间有无数个点。我们该如何获取这些点呢?通常使用一个从 0 到 1(包括 1)的参数来获取线段上的每个点。我们将这个参数称为 t,并按照方程 5.3 中的定义进行定义。

Image

所有线段上的点都可以通过变化 t 的值来获得。对于 t = 0,我们得到线段的起点 S。同样,对于 t = 1,我们得到终点 E。为了计算给定 t 值的任何中点 P,我们可以使用方程 5.4。

Image

通过意识到前面表达式中的向量正是方程 5.1 中定义的方向向量,我们可以将表达式简化为方程 5.5 中的形式。

Image

我们可以轻松地通过 Point 类的 displaced 方法实现方程 5.5。将清单 5-7 中的 point_at 方法代码添加到你的 Segment 类文件中(segment.py)。

class Segment:
    --snip--

   def point_at(self, t: float):
      return self.start.displaced(self.direction_vector, t)

清单 5-7: 使用参数 t 从线段获取点

通过将起始点沿着方向向量* t 方向平移t*倍(其中 0.0 ≤ t ≤ 1.0),我们可以得到线段上的任何一点。我们来实现一个属性,直接得到线段的中点,也就是当 t = 0.5 时的点(见图 5-4)。

Image

图 5-4: 线段的中点

这是我们经常计算的特殊点,因此我们希望能够方便地获取它。将清单 5-8 中的代码添加进去。

class Segment:
    --snip--

    @property
   def middle(self):
       return self.point_at(0.5)

清单 5-8: 线段的中点

验证 t 值

你可能已经意识到,在 point_at 方法中,我们并没有检查传入的 t 值是否在方程 5.3 给出的预期范围内。我们可以传入一个错误的 t 值,它依然可以正常工作,返回超出线段的点。例如,如果我们传入 t = 1.5 的值,我们将得到图 5-5 中所示的点。

Image

图 5-5: t = 1.5 时线段外的点

如果不验证 t 值,这个方法会悄悄地失败,返回一个点,用户可能会误以为它位于线段的端点之间。我们所说的 悄悄失败,是指结果在概念上是错误的,但方法仍然愉快地为我们计算出这个结果,并没有任何警告或提示,表示可能存在错误。

稳健的软件 快速失败,意味着一旦检测到错误条件,程序就会出现故障并退出,如果可能的话,附带一条提供全面错误信息的消息。

这听起来可能有些可怕,但它帮助很大。假设我们允许用户向我们的 point_at(t) 方法传递一个错误的 t 值。现在,假设用户没有注意到,传入了一个像 739928393839... 这样的 t 值。你可以想象,从这个值得到的点将远离应该包含它的线段。这样的值不会导致我们的程序崩溃,程序会继续执行。我们可能直到几分钟后的某次计算才会发现得到了这样一个值,这时一切都失败了。在我们发现错误之前调试这些发生的事情可能需要几个小时(或者根据代码的复杂性和错误传播的范围,甚至几天)。如果我们能够立刻检测到错误的值,那就简单多了。也许我们可以像这样告诉用户:

Oops! We were expecting the value of 't' to be in the  range [0, 1],
but you gave us a value of '739928393839'.

这个消息非常清晰。它告诉用户程序因错误必须退出。如果程序继续执行,错误可能会变得更严重。好的一点是,用户有机会分析错误值的来源,并采取措施防止其再次发生。

注意

在这里我们使用“用户”这个词,指的是任何使用我们代码的人,而不是我们编写的应用程序的最终用户。这包括你自己,因为你将经常是自己代码的用户。

由于需要为 t 参数定义一堆功能,我们最好为它创建一个模块。此时,你的项目结构应该是这样的:

Mechanics

|- geom2d

| |- init.py

| |- nums.py

| |- point.py

| |- point_test.py

| |- segment.py

| |- segment_test.py

| |- vector.py

| |- vector_test.py

| |- vectors.py

geom2d 包内创建一个新文件,命名为 tparam.py。在其中输入 Listing 5-9 中的代码。

MIN = 0.0
MIDDLE = 0.5
MAX = 1.0

def make(value: float):
    if value < MIN:
        return MIN

    if value > MAX:
        return MAX

    return value

def ensure_valid(t):
    if not is_valid(t):
        raise TParamError(t)

def is_valid(t):
    return False if t < MIN or t > MAX else True

class TParamError(Exception):
    def __init__(self, t):
        self.t = t

    def __str__(self):
        return f'Expected t to be in [0, 1] but was {self.t}'

Listing 5-9: 验证 t 参数值

我们首先定义三个有用的常量。MIN 是 t 可以取的最小值。MIDDLE 是 (MIN + MAX) / 2 的值。最后,MAX 是 t 可以取的最大值。

这些值将会被多次使用,因此我们没有在每个地方写 魔法数字(硬编码的数字,没有解释它们的含义),而是给它们命名以便理解它们代表的意义。

一旦我们定义了这些值,我们定义了一个函数 make 来创建一个具有有效值的参数。接着是 ensure_valid 函数,它通过另一个方法 is_valid 来检查 t 是否小于或大于范围限制。如果 t 的值超出了有效范围,将会引发异常。TParam Error 是 Python 异常的实现。这是一个用户自定义的异常,我们为其提供了格式良好的信息。在 TParam Error 的初始化器中,我们传递了出错的 t 值,在特殊方法 __str__ 中,我们返回实际的错误信息。回想一下,一个类可以定义 __str__ 方法来提供实例的文本(字符串)表示形式,当该方法被调用时。

要查看它如何打印信息,请在控制台尝试以下操作:

>>> from geom2d import tparam
>>> tparam.ensure_valid(10.5)
Traceback (most recent call last):
  --snip--
geom2d.tparam.TParamError: Expected t to be in [0, 1] but was 10.5

错误信息清晰明了:

Expected t to be in [0, 1] but was 10.5

让我们在 Segment 类的 point_at 方法中使用这个验证。首先,在你的 segment.py 文件中导入模块:

from geom2d import tparam

回到 segment.py,重构 point_at(t),包括验证,如 列表 5-10 中所示。

def point_at(self, t: float):
    tparam.ensure_valid(t)
    return self.start.displaced(self.direction_vector, t)

列表 5-10:在 segment 的 point_at 方法中验证 t 的值

然后按照 列表 5-11 中的示例,重构中点属性,去除 0.5 的魔法数字。

@property
def middle(self):
    return self.point_at(tparam.MIDDLE)

列表 5-11:从中点计算中去除魔法数字

如果你跟着做,你的 segment.py 文件应该如下所示:列表 5-12。

from geom2d import tparam
from geom2d.point import Point
from geom2d.vectors import make_vector_between, make_versor_between

class Segment:
    def __init__(self, start: Point, end: Point):
        self.start = start
        self.end = end

    @property
    def direction_vector(self):
        return make_vector_between(self.start, self.end)

    @property
    def direction_versor(self):
        return make_versor_between(self.start, self.end)

    @property
    def normal_versor(self):
        return self.direction_versor.perpendicular()

    @property
    def length(self):
        return self.start.distance_to(self.end)

    def point_at(self, t: float):
        tparam.ensure_valid(t)
        return self.start.displaced(self.direction_vector, t)

    @property
    def middle(self):
        return self.point_at(tparam.MIDDLE)

列表 5-12:Segment 类

在我们的 Segment 类完成后,让我们编写一些测试。

单元测试 Segment 点

由于我们将把 point_at 作为更复杂计算的一部分,我们真的很想确保它能正常工作,因此让我们从一个测试开始,断言如果它传入一个错误的 t 值,实际上会引发异常。这为我们提供了一个学习新断言方法 assertRaises 的机会。

segment_test.py 文件中,首先导入 tparam 模块:

from geom2d import tparam

然后编写 列表 5-13 中的测试。

class TestSegment(unittest.TestCase):

    start = Point(400, 0)
    end = Point(0, 400)
    segment = Segment(start, end)

    --snip--

    def test_point_at_wrong_t(self):
        self.assertRaises(
         ➊ tparam.TParamError,
         ➋ self.segment.point_at,
         ➌ 56.7
        )

列表 5-13:测试 t 的错误值

这个断言比我们之前看到的那些稍微复杂一点。我们传递了三个参数。第一个是预期引发的异常(TParamError) ➊。第二个,我们传递了预期引发异常的方法 ➋。最后,我们传递了要传递给前面方法的参数(此例中为 point_at),作为以逗号分隔的参数 ➌。

这个断言可以理解为:

断言方法 'point_at' 来自实例 'self.segment'

引发类型为 'tparam.TParamError' 的异常

当调用参数为 '56.7' 时

如果 point_at 方法接受多个参数,你可以将它们作为 assertRaises 的参数。现在,让我们包含来自 列表 5-14 的两个测试用例。

class TestSegment(unittest.TestCase):

    start = Point(400, 0)
    end = Point(0, 400)
    segment = Segment(start, end)

    --snip--

    def test_point_at(self):
        t = tparam.make(0.25)
        expected = Point(300, 100)
        actual = self.segment.point_at(t)
        self.assertEqual(expected, actual)

    def test_middle_point(self):
        expected = Point(200, 200)
        actual = self.segment.middle
        self.assertEqual(expected, actual)

列表 5-14:测试 point_at 方法

在第一个测试案例中,我们确保对于有效的 t 值(在此情况下为 0.25),可以得到预期的中点。使用 方程 5.4,可以按以下方式计算该点:

Image

第二个测试是中间属性,它计算 t = 0.5 时的点。拿一支笔和一些纸,确保点 (200, 200) 在我们的测试中。然后运行 segment_test.py 文件中的所有测试,以确保它们都通过。你可以通过以下方式在控制台运行:

$  python3 -m unittest geom2d/segment_test.py

最近点

现在假设我们想知道线段上离外部点最近的点是什么。如果外部点与线段不对齐,即通过该点的垂线不与线段相交,那么最近的点必须是两个端点之一:SE。另一方面,如果外部点与线段对齐,则垂直线与线段的交点就是最近的点。图 5-6 展示了这一点。

Image

图 5-6:线段的最近点

在图中,点 SA′ 是距离 A 最近的点,点 EB′ 是距离 B 最近的点,C′ 是距离 C 最近的点。接下来让我们看看如何实现这个过程。

算法

在第四章中使用 projection_over 方法的帮助下,我们可以轻松找到最近点。我们将 P 作为外部点,l 作为线段的长度,并且使用图 5-7 中的各个点、线段和向量。

Image

图 5-7:计算线段最近点的算法辅助向量

算法如下:

  1. 计算一个向量Image,它从线段的 S 指向外部点 P

  2. 计算Image在线段方向单位向量Image上的投影。

  3. 根据投影的值,称之为 v[s]。最近的点 P′ 可以通过方程 5.6 计算得出。

Image

如果投影 v[s] 的值为负,则投影位于线段的 S 侧之外,因此最近的点是 S。对于大于 l 的值,线段方向上的投影长度大于线段本身。因此,结果是端点 E。对于闭区间 [0,l] 中的任何 v[s] 值,我们通过在Image方向上移动 S v[s] 倍来获得该点。图 5-7 展示了外部点 P 与线段对齐的最后一种情况。

此操作的代码见清单 5-15。

class Segment:
    --snip--

   def closest_point_to(self, p: Point):
       v = make_vector_between(self.start, p)
       d = self.direction_versor
       vs = v.projection_over(d)

       if vs < 0:
           return self.start

       if vs > self.length:
           return self.end

       return self.start.displaced(d, vs)

清单 5-15:线段的最近点

我们首先计算向量 图片。然后得到 v[s]:这是向量 图片 在段的方向单位向量 图片 上的投影。如果 v[s] 小于零,我们返回起点。如果大于段的长度,我们返回终点;否则,我们计算起点的位移,得到段上的结果点。

单元测试最近点

让我们测试之前定义的三种不同情况,即 v[s] < 0,v[s] > l 和 0 < v[s] < l。清单 5-16 显示了测试的代码。

class TestSegment(unittest.TestCase):

    start = Point(400, 0)
    end = Point(0, 400)
    segment = Segment(start, end)

    --snip--

    def test_closest_point_is_start(self):
        p = Point(500, 20)
        expected = self.start
        actual = self.segment.closest_point_to(p)
        self.assertEqual(expected, actual)

    def test_closest_point_is_end(self):
        p = Point(20, 500)
        expected = self.end
        actual = self.segment.closest_point_to(p)
        self.assertEqual(expected, actual)

    def test_closest_point_is_middle(self):
        p = Point(250, 250)
        expected = Point(200, 200)
        actual = self.segment.closest_point_to(p)
        self.assertEqual(expected, actual)

清单 5-16:测试段的最近点

为了更好地理解这些测试,手动绘制段和每个外部点可能是一个不错的练习,这样你可以看出预期结果为什么会有这些值。你的绘图应该类似于图 5-8。此外,尝试手动解决这三种情况可能会帮助你更好地理解算法。

图片

图 5-8:该段的最近点及其测试案例

别忘了运行所有的测试并确保它们都通过。你可以通过控制台如下操作:

$ python3 -m unittest geom2d/segment_test.py

点到点的距离

现在我们已经知道了段与外部点之间最近的点,我们可以轻松地计算出它与该段的距离。输入方法见清单 5-17。

class Segment:
    --snip--

   def distance_to(self, p: Point):
       return p.distance_to(
           self.closest_point_to(p)
       )

清单 5-17:计算点到段的距离

正如你在代码中看到的,段与任何给定外部点之间的距离就是该点与段上最接近它的点之间的距离。很简单,不是吗?

段交点

现在我们进入有趣的部分。我们如何测试两段是否相交?如果它们相交,我们如何计算交点?请参考图 5-9 中的情况。

图片

图 5-9:可能的段交点情况

左列的两个案例没有交点,但它们之间有区别。在第一个案例中,段的方向向量是平行的 (图片 × 图片 = 0)。因此,很容易知道它们不会相交。在另一个案例中,如果我们用无限长的直线代替段,它们会有交点。交点可能远离段的位置,但仍然会有交点。如我们在接下来的方程中所看到的,我们需要像处理直线那样计算交点,然后确保该点位于两个段内部。

在右上角的情况中,两个线段重叠,因此有不止一个交点——准确地说,是无限多个交点。对于我们的分析,我们将定义两种可能的情况:线段要么有交点,要么根本不相交(我们不会考虑右上角的情况)。我们会忽略重叠的情况,因为我们的应用中不需要它,而且我们希望代码更加简化。

重叠的线段

如果我们包括线段重叠的情况,交点函数的返回对象可能是点或线段。返回不同对象类型的函数很难操作。一旦得到结果,我们还需要检查返回的对象类型,并采取相应的操作。可以如下实现:

result = seg_a.intersection_with(seg_b)

if type(result) is Point:
    # intersection is a point
elif type(result) is Segment:
    # intersection is a segment
else:
    # no intersection

但是这段代码比较混乱。有更好的方式来处理这个逻辑,不过我们不讨论这些,因为对于我们来说,要么有交点,要么根本没有交点。这样会让我们的代码更加简洁,易于操作。

让我们看一下这个算法。

算法

让我们找出像图 5-9 右下角那种情况的交点。假设我们有两条线段:

  • 线段 1,起点 S[1] 和终点 E[1]

  • 线段 2,起点 S[2] 和终点 E[2]

我们可以计算线段 1 上的每个点,记为 P[1],使用如下表达式:

Image

其中 t[1] 是从 0 到 1 的参数,Image 是该线段的方向向量(不是单位向量)。同样,线段 2 如下:

Image

为了找出交点,我们必须寻找一对 t[1] 和 t[2] 的值,使得 P1 = P2:

Image

如果这两条线段相交,将这两个t参数值代入各自的线段表达式应能得到相同的点,即交点 P。让我们将表达式重写为向量形式:

Image

我们可以使用这种形式来得到一个标量系统,包含两个方程和两个未知数,t[1] 和 t[2]:

Image

我不打算详细介绍这些细节,直接给你结果,不过自己解这个系统来求解 t[1] 和 t[2] 可能是一个不错的练习。最终的 t 参数表达式如方程 5.7 和 5.8 所示。

ImageImage

这里,ΔS[x] = S[2x] – S[1x],ΔS[y] = S[2y] – S[1y],并且 Image。请注意,如果线段平行,这些公式将产生 的结果 Image。我们不能尝试除以零,因为那会在我们的 Python 代码中引发异常,所以我们需要在尝试计算 t[1] 和 t[2] 的值之前先检测这种情况。

对于线段不平行的情况,当计算出这两个值后,我们有两种可能的结果:

  • t[1] 和 t[2] 都在范围 [0, 1] 内。交点属于两个线段。

  • t[1] 和 t[2] 中的一个或两个超出了范围 [0, 1]。交点位于至少一个线段之外。

现在我们准备将逻辑实现为算法。在你的 segment.py 文件中,按照 列表 5-18 中所示实现 intersection_with 方法。

class Segment:
    --snip--

    def intersection_with(self, other):
        d1, d2 = self.direction_vector, other.direction_vector

        if d1.is_parallel_to(d2):
            return None

        cross_prod = d1.cross(d2)
        delta = other.start - self.start
        t1 = (delta.u * d2.v - delta.v * d2.u) / cross_prod
        t2 = (delta.u * d1.v - delta.v * d1.u) / cross_prod

        if tparam.is_valid(t1) and tparam.is_valid(t2):
            return self.point_at(t1)
        else:
            return None

列表 5-18:两个线段的交点

我们首先通过使用 Python 的多重赋值将两个线段的方向向量存储到变量 d1 和 d2 中。通过多重赋值,可以一次为多个变量赋值。接着,我们检查方向是否平行,如果平行,则返回 None。如果发现线段不平行,我们计算Image 和 ΔS并将其存储在变量 cross_prod 和 delta 中。借助这些值,我们再计算 t[1] 和 t[2]。如果这些值在其有效范围内,我们通过在当前线段对象(self)上调用 point_at 返回交点。请确保理解我们也可以使用 t[2] 来计算 P 并在另一个上调用 point_at,结果会是一样的。

注意

与其他语言(如 Java 或 C# 中的 null)类似,None* 应谨慎使用。它应该用于那些拥有类似空值且是完全有效结果的情况。例如,在我们的intersection_with方法中,None 代表没有交点的情况。*

线段交点单元测试

随着我们书籍内容的推进以及代码变得更加复杂,测试这些代码片段将变得更加复杂。我们刚刚写的用于计算线段交点的方法有几个分支或路径可以执行。为了尽可能全面地进行单元测试,让我们列出我们希望覆盖的每一种情况(参见 表 5-1)。

表 5-1: 线段交点算法结果

线段方向 t[1] t[2] 交点结果
Image None
Image 超出范围 超出范围 None
Image 在范围内 超出范围 None
Image 超出范围 在范围内 None
Image 在范围内 在范围内 Image

我们将为 表 5-1 中的第一个和最后一个情况编写单元测试;其他三个留给你作为练习。在 segment_test.py 文件中,将 列表 5-19 中的测试包含到 TestSegment 类中。

class TestSegment(unittest.TestCase):

    start = Point(400, 0)
    end = Point(0, 400)
    segment = Segment(start, end)

    --snip--

    def test_parallel_segments_no_intersection(self):
        other = Segment(Point(200, 0), Point(0, 200))
        actual = self.segment.intersection_with(other)
        self.assertIsNone(actual)

    def test_segments_intersection(self):
        other = Segment(Point(0, 0), Point(400, 400))
        expected = Point(200, 200)
        actual = self.segment.intersection_with(other)
        self.assertEqual(expected, actual)

列表 5-19:测试线段交点

在第一个测试中,我们构造了一个平行线段,并通过断言 assertIsNone 来验证这两个线段的交点为 None,assertIsNone 会检查传入的值是否为 None。在第二个测试中,我们构造了一个垂直于第一个线段的线段,并在交点 (200, 200) 与第一个线段相交,断言我们得到了该点作为结果。你可以通过点击 IDE 中的绿色播放按钮,或者在控制台中如下运行文件中的所有测试:

$ python3 -m unittest geom2d/segment_test.py

你能想出其他三种情况所需的线段吗?

相等性和字符串表示

就像我们在 Point 和 Vector 类中所做的那样,我们想要重载 == 运算符,以便 Python 能够理解具有相同起点和终点的两个线段在逻辑上是相等的,并且我们希望实现一个 __str__ 方法,以便我们可以获得该线段的漂亮字符串表示。在 segment.py 文件中输入代码,列表 5-20。

class Segment:
    --snip--

   def __eq__(self, other):
       if self is other:
           return True

       if not isinstance(other, Segment):
           return False

       return self.start == other.start \
              and self.end == other.end

   def __str__(self):
       return f'segment from {self.start} to {self.end}'

列表 5-20:线段的相等性和字符串表示

一旦我们开发了 Line 类,我们将添加最后一个属性。如果你跟随代码,您的 Segment 类应该类似于 列表 5-21。

from geom2d import tparam
from geom2d.point import Point
from geom2d.vectors import make_vector_between, make_versor_between

class Segment:
    def __init__(self, start: Point, end: Point):
        self.start = start
        self.end = end

    @property
    def direction_vector(self):
        return make_vector_between(self.start, self.end)

    @property
    def direction_versor(self):
        return make_versor_between(self.start, self.end)

    @property
    def normal_versor(self):
        return self.direction_versor.perpendicular()

    @property
    def length(self):
        return self.start.distance_to(self.end)

    def point_at(self, t: float):
        tparam.ensure_valid(t)
        return self.start.displaced(self.direction_vector, t)

    @property
    def middle(self):
        return self.point_at(tparam.MIDDLE)

    def closest_point_to(self, p: Point):
        v = make_vector_between(self.start, p)
        d = self.direction_versor
        vs = v.projection_over(d)

        if vs < 0:
            return self.start

        if vs > self.length:
            return self.end

        return self.start.displaced(d, vs)

    def distance_to(self, p: Point):
        return p.distance_to(
            self.closest_point_to(p)
        )

    def intersection_with(self, other):
        d1, d2 = self.direction_vector, other.direction_vector

        if d1.is_parallel_to(d2):
            return None

        cross_prod = d1.cross(d2)
        delta = other.start - self.start
        t1 = (delta.u * d2.v - delta.v * d2.u) / cross_prod
        t2 = (delta.u * d1.v - delta.v * d1.u) / cross_prod

        if tparam.is_valid(t1) and tparam.is_valid(t2):
            return self.point_at(t1)
        else:
            return None

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Segment):
            return False

        return self.start == other.start \
               and self.end == other.end

    def __str__(self):
        return f'segment from {self.start} to {self.end}'

列表 5-21:线段类

Line 类

一条无限长的线可以通过一个基准点 B 和一个方向向量 图像 来描述,正如 图 5-10 所示。

图像

图 5-10:带有基准点 B 和方向向量的线 图像

线是有用的辅助原始数据;通过它们我们可以构建更复杂的几何体和操作。例如,线的一个常见用途是找到两条不平行方向的交点。你将在下一章看到,利用线的交点,像通过三点构建一个圆这样的操作变得轻而易举。

让我们创建一个新的 Line 类,包含这两个属性:基准点和方向。在 geom2d 包中,添加一个名为 line.py 的新文件,并在其中输入 列表 5-22 中的代码。

from geom2d.point import Point
from geom2d.vector import Vector

class Line:
    def __init__(self, base: Point, direction: Vector):
        self.base = base
        self.direction = direction

列表 5-22:线初始化

初始化器根据传入的相应参数值设置我们的基准点和方向属性。像之前一样,我们已经为基准点和方向参数添加了类型注解,以便我们的 IDE 能警告我们潜在的错误。

现在让我们提供两个方法,检查一条线是否平行或垂直于另一条线(列表 5-23)。

class Line:
   --snip--

   def is_parallel_to(self, other):
       return self.direction.is_parallel_to(other.direction)

   def is_perpendicular_to(self, other):
       return self.direction.is_perpendicular_to(other.direction)

列表 5-23:检查线条是否平行或垂直

我们没有为 Segment 实现这些方法,因为我们关心的是线段的无限点及其在平面上的位置;而在这里,我们处理的是方向。处理方向需要了解它们的相对位置:它们是平行的吗?它们是垂直的吗?

对于线,问题通常是它们相对于其他线的位置;而对于线段,问题通常是它们自身的位置。

要检查两条直线是否平行,我们可以简单地访问它们的方向属性,并像这样使用它们的方法:

d1 = line_one.direction
d2 = line_two.direction
d1.is_parallel_to(d2)

这确实是可能的,但通常不被认为是最佳做法。常有一种指导原则,称为最小知识原则德梅特法则,它指出:“你应该只与你的直接朋友交谈。”在这种情况下,由于我们正在处理直线,直线是我们的直接朋友。直线的基准点和方向向量不是我们的直接朋友;因此,我们不应该向它们索取信息。如果我们需要它们的某些内容,我们必须要求我们的直接朋友——包含这些属性的直线——为我们做。

所以,我们应该如何检查两条直线是否平行或垂直呢:

line_one.is_parallel_to(line_two)

让我们再增加两个方法,用于创建与现有直线垂直或平行并通过某个点的新直线。在你的文件中,输入示例 5-24 中的代码。

from geom2d.point import Point
from geom2d.vector import Vector

class Line:
    --snip--

    def perpendicular_through(self, point: Point):
        return Line(point, self.direction.perpendicular())

    def parallel_through(self, point: Point):
        return Line(point, self.direction)

示例 5-24:创建垂直和平行的直线

方法 perpendicular_through 接收一个点作为参数,并返回一条新直线,该直线使用该基准点和与原始直线垂直的方向向量。类似地,parallel_through 构造一条新的直线,该直线具有给定的基准点,但使用与原始直线相同的方向向量。

直线交点

本章早些时候已详细解释了计算两条线段交点的一般算法。该算法基于线段的起点和方向向量,但可以通过使用直线的基准点而不是线段的起点,扩展为适用于直线。值得注意的是,在直线的情况下,参数t[1]和t[2]不再局限于[0, 1]范围;它们可以从– ∞

如果我们将方程 5.7 和 5.8 重写为直线方程,我们得到方程 5.9 和 5.10。

ImageImage

在这种情况下,ΔB[x] = B[2x] – B[1x],ΔB[y] = B[2y] – B[1y]。为了使这些公式得出正确的值,请回忆一下Image。由于t值不再受限制,因此无需计算t[1]和t[2]并检查它们是否落在[0, 1]范围内。计算其中一个值就足以得到交点。让我们选择方程 5.9 来计算t[1]。有了t[1],我们可以按如下方式确定实际的交点:

Image

在你的 Line 类中实现 intersection_with 方法,如示例 5-25 所示。

from geom2d.point import Point
from geom2d.vector import Vector
from geom2d.vectors import make_vector_between

class Line:
   --snip--

   def intersection_with(self, other):
       if self.is_parallel_to(other):
           return None

       d1, d2 = self.direction, other.direction
       cross_prod = d1.cross(d2)
       delta = make_vector_between(self.base, other.base)
       t1 = (delta.u * d2.v - delta.v * d2.u) / cross_prod

       return self.base.displaced(d1, t1)

示例 5-25:计算两条直线的交点

代码与 Segment 中的算法类似,但更简单一些。为了检查平行性,我们使用 self 方法,而不是使用方向。由于我们在 Line 类中实现了 is_parallel_to 方法,使用它更合适(并且使代码更加易读!)。

单元测试线段交点

让我们确保我们修改后的算法有效。创建一个新的文件 line_test.py 并输入 列表 5-26 中的 Line 类测试代码。

import unittest

from geom2d.line import Line
from geom2d.point import Point
from geom2d.vector import Vector

class TestLine(unittest.TestCase):

    def test_parallel_lines_no_intersection(self):
        l1 = Line(Point(0, 0), Vector(1, 1))
        l2 = Line(Point(10, 10), Vector(1, 1))
        self.assertIsNone(l1.intersection_with(l2))

    def test_lines_intersection(self):
        l1 = Line(Point(50, 0), Vector(0, 1))
        l2 = Line(Point(0, 30), Vector(1, 0))
        actual = l1.intersection_with(l2)
        expected = Point(50, 30)
        self.assertEqual(expected, actual)

列表 5-26:测试线段交点

在第一个测试中,test_parallel_lines_no_intersection,我们创建了两条平行线,它们有不同的基点,但方向向量相同。然后我们断言 intersection_with 返回 None。第二个测试 test_lines_intersection,创建了两条线,第一条在 x = 50 处垂直,第二条在 y = 30 处水平;因此,它们的交点是 (50, 30)。

通过点击类定义旁边的绿色播放按钮来运行测试。你应该在控制台中看到如下内容:

Ran 2 tests in 0.001s

OK

Process finished with exit code 0

你也可以通过控制台运行测试:

$  python3 -m unittest geom2d/line_test.py

列表 5-27 包含了我们为 Line 类编写的所有代码。

from geom2d.point import Point
from geom2d.vector import Vector
from geom2d.vectors import make_vector_between

class Line:
    def __init__(self, base: Point, direction: Vector):
        self.base = base
        self.direction = direction

    def is_parallel_to(self, other):
        return self.direction.is_parallel_to(other.direction)

    def is_perpendicular_to(self, other):
        return self.direction.is_perpendicular_to(other.direction)

    def perpendicular_through(self, point: Point):
        return Line(point, self.direction.perpendicular())

    def parallel_through(self, point: Point):
        return Line(point, self.direction)

    def intersection_with(self, other):
        if self.is_parallel_to(other):
            return None

        d1, d2 = self.direction, other.direction
        cross_prod = d1.cross(d2)
        delta = make_vector_between(self.base, other.base)
        t1 = (delta.u * d2.v - delta.v * d2.u) / cross_prod

        return self.base.displaced(d1, t1)

列表 5-27:Line 类

Segment 的平分线

现在我们有了 Segment 和 Line,我们可以在 Segment 中实现一个新的属性:它的 平分线。这个属性是穿过 Segment 中点 M 且与之垂直的线。图 5-11 展示了这一概念。

图片

图 5-11:Segment 的平分线

计算 Segment 的平分线很简单,因为我们已经可以访问到 Segment 的中点和法向量(别忘了导入 Line 类),如 列表 5-28 所示。

from geom2d import tparam
from geom2d.line import Line
from geom2d.point import Point
from geom2d.vectors import make_vector_between, make_versor_between

class Segment:
    --snip--

    @property
   def bisector(self):
       return Line(self.middle, self.normal_versor)

列表 5-28:Segment 的平分线

在下一章,我们将使用 Segment 的平分线来创建一个通过三点的圆——这是 CAD 软件中常用的一种求圆方法。在本书的第三部分,我们将创建一个程序,计算通过三点的圆并绘制一个美丽的图像,图中会标注圆心和半径。

总结

在本章中,我们使用了 Point 和 Vector 类来创建两个新的原始类型:Segment 和 Line。它们都有一个定义好的方向,且都表示一组无限对齐的点,但 Segment 是在两个点之间有限制的,而 Line 没有尽头。

我们还实现了一种方法,通过一个参数 t 来获取 Segment 中的无限点,t 的取值范围为 [0, 1]。对 Line 来说没有必要做相同的操作,因为我们通常不关心构成它的点。

然后,我们创建了两个算法:我们在 Segment 类中添加了一个方法,用于查找其与外部点的最近点。尽管我们没有在 Line 类中实现该方法,但我们本来可以实现它。我们利用这个方法来计算点到线段的距离。我们还实现了一个算法,用于计算两个线段和两条直线的交点。这些交点的结果是一个点,或者返回值为 None。最后,我们使用 Line 类表示线段的平分线。

这些线性原语对于构建更复杂的几何图形——多边形,将证明是无价的,这也是我们下一章的主题。

第六章:多边形

Image

我们的下一个原始元素,多边形,建立在点和线段的基础上。多边形可以用来描述碰撞的几何图形、需要重新绘制的屏幕部分、物体边界等等。事实证明,这些原始元素在图像处理时非常有用,因为你可以利用它们来判断图像的不同部分是否重叠。在动态仿真中,它们有助于确定两个物体何时碰撞。在图形密集型应用的用户界面中,你可以使用简单的多边形来轻松判断用户的鼠标是否悬停在可能被选中的实体上。

在本章中,我们将实现三种原始元素:通过顶点定义的通用多边形;通过中心点和半径定义的圆;以及通过原点、宽度和高度定义的矩形。由于在某些应用中仅使用通用多边形可能更为方便,因此圆和矩形都会实现一个方法,将它们转换为通用多边形。我们还将编写一些其他算法,包括一个判断多边形是否与另一个同类多边形重叠的算法,以及一个测试多边形是否包含给定点的算法。

Polygon 类

多边形是一个二维图形,通过至少三个有序且不重合的顶点定义,这些顶点连接形成一个闭合的多边形链条。每个连接都是一个线段,从一个顶点到下一个顶点,其中最后一个顶点连接回第一个顶点。给定顶点[V[1], V[2], …, V[n]],每个线段定义为[(V[1] → V[2]),(V[2] → V[3]), …, (V[n] → V[1])],被称为(参见 Figure 6-1)。

Image

Figure 6-1: 通过顶点定义的多边形

此时,你的geom2d包应该是这样的:

力学

|- geom2d

|    |- init.py

|    |- line.py

|    |- line_test.py

|    |- nums.py

|    |- point.py

|    |- point_test.py

|    |- segment.py

|    |- segment_test.py

|    |- vector.py

|    |- vector_test.py

|    |- vectors.py

创建一个类来表示通过顶点定义的多边形,顶点作为一系列点(Point 类的实例)。在geom2d包内创建一个新文件,命名为polygon.py,并输入 Listing 6-1 中的代码。

from geom2d.point import Point

class Polygon:
    def __init__(self, vertices: [Point]):
        if len(vertices) < 3:
            raise ValueError('Need 3 or more vertices')

        self.vertices = vertices

Listing 6-1: 多边形初始化

首先我们从 geom2d.point 导入 Point 类。然后我们定义 Polygon 类,创建一个初始化函数,接受一个按多边形链条顺序排列的点的序列;相连的顶点应在序列中相邻。如果列表包含少于三个点,我们将抛出一个 ValueError 类型的异常。还记得快速失败策略吗?我们希望在检测到任何不合逻辑并可能引发问题的情况时尽早失败,例如一个包含少于三个顶点的多边形。

注意

根据 Python 的文档,当“操作或函数接收到一个类型正确但值不合适的参数,并且该情况没有更精确的异常描述”时,应该引发 ValueError

一个是指在多边形的顶点序列中,从一个顶点到下一个顶点的线段。多边形的边一起构成了它的周长。为了闭合多边形链条,最后一个顶点需要与第一个顶点相连。因此,生成多边形的边需要配对顶点序列。这听起来像是一个通用操作,我们可以将其应用于任何对象序列,而不仅仅是顶点,因此我们希望将其实现为一个独立的模块。

在接下来的章节中,你需要对 Python 的列表推导式有较好的理解。你可以参考第 35 页的“列表推导式”部分以复习相关知识。

配对顶点

给定一个项目列表(无论是什么类型),

[A, B, C]

配对算法应该创建一个新的列表,其中每个元素是原始位置的项与下一个项的元组,包括将最后一个元素与第一个元素配对,如下所示:

[(A, B), (B, C), (C, A)]

让我们在 Python 项目中新建一个包来编写这段代码。在geom2d同级目录下创建一个新包,命名为utils。在这个包中,我们将保存一些可能会被项目其他模块复用的小型通用逻辑。你的项目文件夹结构应该如下所示:

机械学

|- geom2d

|    |- init.py

|    |- line.py

|    | ...

|- utils

|    |- init.py

许多软件项目最终都会有一个utils包或模块,里面聚集了各种不相关的算法。虽然这种做法很方便,但最终会导致项目维护困难,且难以持续发展。utils包是为那些没有足够大到可以单独成为一个包,但仍然被项目内多个部分复用的代码块所设计的。当utils中的相关代码开始增多时,最好将其迁移到专门的包中。例如,如果我们的配对逻辑开始变得更加复杂,涉及到各种不同的情况和集合类型,我们可以将其移动到一个名为pairs的新包中。但目前情况并非如此,因此我们将保持简单。

在该包中创建一个新文件,命名为pairs.py,并在其中包含列出 6-2 中的函数。

def make_round_pairs(sequence):
    length = len(sequence)
    return [
     ➊ (sequence[i], sequence[(i + 1) % length])
     ➋ for i in range(length)
    ]

列出 6-2:配对列表元素

该函数使用列表推导式从一个值的范围内创建一个新列表,起始值为 0,直到长度➋。对于每个值,它会创建一个包含两个元素➊的元组:原始列表中索引为 i 的元素和索引为 i + 1 的下一个元素。当我们达到索引 i = length 时,i + 1 在序列中会越界,所以我们希望通过模运算符将其包裹回到索引 0,以便最后一个元素和第一个元素也能配对。我们使用模运算符(%)来实现这一点,它返回将一个数字除以另一个数字的余数。巧妙之处在于,n % m对于每个n < m都会返回n,并且当n = m时返回 0。

为了更好地理解模运算,请在终端中尝试以下操作:

>>> [n % 4 for n in range(5)]
[0, 1, 2, 3, 0]

看看当n = 4 时结果是 0,而对于其他所有值,结果是n本身?尝试增加范围参数:

>>> [n % 4 for n in range(7)]
[0, 1, 2, 3, 0, 1, 2]

在模 4 运算中,数字永远不会超过 3。一旦达到这个数字,下一个数字就会重新回到 0。

注意

如果你想了解更多关于这种“包裹”现象的知识,请搜索模算术。它在现代密码学中被广泛应用,并且具有一些非常有趣的属性。

我们现在准备实现一个方法,用于生成我们的 Polygon 类的边。

生成边

一旦顶点正确配对,编写生成边的代码就变得简单:我们只需要为每对顶点创建一个 Segment 实例。为了计算它们,首先在你的文件polygon.py中添加以下导入:

from geom2d.segment import Segment
from utils.pairs import make_round_pairs

然后,输入列表 6-3 中的方法。

from geom2d.point import Point
from geom2d.segment import Segment
from utils.pairs import make_round_pairs

class Polygon:
    --snip--

    def sides(self):
        vertex_pairs = make_round_pairs(self.vertices)
        return [
            Segment(pair[0], pair[1])
            for pair in vertex_pairs
        ]

列表 6-3:计算多边形的边

使用 make_round_pairs 函数,我们将顶点配对,使得每个顶点对元组都包含一条线段的起始和结束点。然后,使用列表推导式,将这些元组映射为线段。

测试边

让我们为边属性创建一个单元测试。创建一个新的文件polygon_test.py,并将其放在geom2d包中,然后输入 TestPolygon 类的代码(见列表 6-4)。

import unittest

from geom2d.point import Point
from geom2d.polygon import Polygon
from geom2d.segment import Segment

class TestPolygon(unittest.TestCase):
    vertices = [
        Point(0, 0),
        Point(30, 0),
        Point(0, 30),
    ]
    polygon = Polygon(vertices)

    def test_sides(self):
        expected = [
            Segment(self.vertices[0], self.vertices[1]),
            Segment(self.vertices[1], self.vertices[2]),
            Segment(self.vertices[2], self.vertices[0])
        ]
        actual = self.polygon.sides()
        self.assertEqual(expected, actual)

列表 6-4:测试多边形的边

在测试类中,我们创建了一个顶点列表——(0, 0)、(30, 0)和(0, 30)——它们构成了一个三角形。我们使用这些点作为测试对象 polygon 的顶点。图 6-2 展示了该多边形。为了确保边正确计算,我们使用原始顶点按正确顺序配对来构建预期边的列表。

Image

图 6-2:测试中使用的多边形

由于我们在 Segment 类中重载了==运算符(通过实现特殊方法 eq),所以相等比较会按预期工作。如果我们没有这么做,即使是由相同的端点界定的线段,相等断言也会认为它们不同,从而导致测试失败。

使用以下命令运行测试,确保它成功。

$ python3 -m unittest geom2d/polygon_test.py

如果一切顺利,你应该会看到以下输出:

Ran 1 tests in 0.000s

OK

质心

多边形中的一个重要点是它的质心,即所有顶点位置的算术平均值。假设 n 是顶点的数量,质心 可以使用方程 6.1 来表示。

图片

这里,x[i] 和 y[i] 是顶点 i 的坐标。

实现质心

让我们实现质心属性。为此,我们首先需要在多边形类的顶部导入以下内容:

import operator
from functools import reduce

导入后,将列表 6-5 中的代码添加到 sides 方法下方。

import operator
from functools import reduce

from geom2d.point import Point
from geom2d.segment import Segment
from utils.pairs import make_round_pairs

class Polygon:
    --snip--

    @property
    def centroid(self):
     ➊ vtx_count = len(self.vertices)
     ➋ vtx_sum = reduce(operator.add, self.vertices)
     ➌ return Point(
           vtx_sum.x / vtx_count,
           vtx_sum.y / vtx_count
       )

列表 6-5:计算多边形的质心

我们首先将顶点列表的长度存储在变量 vtx_count 中 ➊。然后,我们通过将它们相加来归约顶点列表,得到一个称为 vtx_sum 的结果点 ➋。你可能想阅读第 29 页中的《过滤、映射和归约》部分,以回顾 reduce 函数以及我们如何使用操作符。请注意,operator.add 操作符可以在 reduce 函数中使用,因为我们的 Point 类重载了 + 操作符。

我们最后做的事是通过将 vtx_sum 的每个投影除以 vtx_count 来构造结果点 ➌。

测试质心

让我们编写一个单元测试,确保质心被正确计算。在你的文件 polygon_test.py 中,输入列表 6-6 中的代码。

class TestPolygon(unittest.TestCase):
   --snip--

   def test_centroid(self):
       expected = Point(10, 10)
       actual = self.polygon.centroid
       self.assertEqual(expected, actual)

列表 6-6:测试多边形的质心中心

使用方程 6.1,我们可以手动计算质心,以查看 (10, 10) 中的投影是如何得到的。知道我们的测试多边形的顶点是 (0, 0),(30, 0) 和 (0, 30),我们得到:

图片

你可以在图 6-3 中直观地查看这个内容。

图片

图 6-3:测试多边形的质心

运行文件 polygon_test.py 中的所有测试,确保一切按预期工作。你可以在终端中使用以下命令来运行它们:

$ python3 -m unittest geom2d/polygon_test.py

如果两个测试都通过,你应该会看到以下输出:

Ran 2 tests in 0.000s

OK

在继续之前,让我们先做一件事。记住,为了计算质心,我们将顶点列表简化成如下形式,

vtx_sum = reduce(operator.add, self.vertices)

我们之前说过,使用 operator.add 进行的这种归约工作是因为我们的 Point 类重载了 + 操作符?让我们看看如果没有重载这个操作符会发生什么。打开 point.py,并注释掉 __add__ 方法:

class Point:
    --snip--

    # def __add__(self, other):
    #     return Point(
    #         self.x + other.x,
    #         self.y + other.y
    #     )

再次运行测试。这一次你会在终端看到一个错误:

======================================================
ERROR: test_centroid (geom2d.polygon_test.TestPolygon)
------------------------------------------------------
Traceback (most recent call last):
  --snip--
    vtx_sum = reduce(operator.add, self.vertices)
TypeError: unsupported operand type(s) for +: 'Point' and 'Point'

-------------------------------------------------------
Ran 2 tests in 0.020s

这个带有消息(不支持的操作数类型...)的 TypeError 对错误描述非常清楚。如果两个 Point 实例没有实现 __add__ 方法,它们不能相加。取消注释我们为实验注释掉的 __add__ 方法,然后重新运行测试,确保一切恢复如初。

包含 Point

现在出现了一个有趣的问题:我们如何确定一个给定的点是否在多边形内部?一种广泛使用的程序是 射线投射算法,它计算从点出发的射线在任何方向上与多边形的几条边交叉的次数。交点数为偶数(包括零)意味着点在多边形外部,而交点数为奇数则意味着点在内部。看看 图 6-4。

左侧的图展示了一个复杂的多边形和位于外部的点 P。从该点发出的每条射线在任何方向上都与零条或偶数条边相交。右侧的案例展示了点 P 在多边形内部。这时,射线始终与奇数条边相交。

Image

图 6-4:射线投射算法

另一个常用的算法,也是我们将使用的算法,是 绕数算法。这个算法通过对从待测点到多边形顶点的向量之间的角度求和来工作。它的工作方式是这样的:要知道一个点 P 是否在具有顶点 V[1]、V[2]、…、V[n] 的多边形内部,我们的算法如下:

  1. 创建一个从 P 到每个顶点的向量:

    Image:从 P 到顶点 V[1] 的向量

    Image:从 P 到顶点 V[2] 的向量

    . . .

    Image:从 P 到顶点 V[n] 的向量

  2. 计算每个向量 Image 到下一个向量 Image 的角度,绕回并计算最后一个向量与第一个向量之间的角度:

    Image:从 ImageImage 的角度

    Image:从 ImageImage 的角度

    . . .

    Image:从 ImageImage 的角度

  3. 将在前一步计算的所有角度相加。

  4. 如果角度为 2π,则点 P 在多边形内部;如果为 0,则在外部。

查看 图 6-5,更好地理解这个算法是如何工作的。当点在多边形内部时,可以很容易看出角度之和是 2π

尽管我们也可以实现 射线投射算法,但我选择了 绕数算法,因为它很好地利用了我们在本书中创建的三个关键函数:make_vector_between 工厂函数、make_round_pairs 和 Vector 类中的 angle_to 方法。让我们来实现它。

Image

图 6-5:测试一个多边形是否包含一个点

实现绕数算法

我们需要导入几个模块。你在文件顶部导入的内容应该像这样:polygon.py

import math
import operator
from functools import reduce

from geom2d.nums import are_close_enough
from geom2d.point import Point
from geom2d.vectors import make_vector_between
from geom2d.segment import Segment
from utils.pairs import make_round_pairs

一旦你导入了所有内容,输入代码 列表 6-7,作为 Polygon 类的一个新方法。

import math
import operator
from functools import reduce

from geom2d.nums import are_close_enough
from geom2d.point import Point
from geom2d.vectors import make_vector_between
from geom2d.segment import Segment
from utils.pairs import make_round_pairs

class Polygon:
    --snip--

   def contains_point(self, point: Point):
    ➊ vecs = [make_vector_between(point, vertex)
                for vertex in self.vertices]
    ➋ paired_vecs = make_round_pairs(vecs)
    ➌ angle_sum = reduce(
           operator.add,
        ➍ [v1.angle_to(v2) for v1, v2 in paired_vecs]
       )

    ➎ return are_close_enough(angle_sum, 2 * math.pi)

列表 6-7:Polygon contains_point 算法

我们首先通过列表推导式计算出向量列表 ➊,它将多边形的每个顶点映射为一个从点到顶点的向量。然后,使用 make_round_pairs,我们将向量配对并将结果存储在 paired_vecs ➋ 中。

我们使用另一个列表推导式将成对的向量映射到它们所形成的角度 ➍。然后我们通过将计算出的每个角度 ➌ 相加来简化结果列表,最后我们检查计算出的角度总和(angle_sum)是否足够接近 2π ➎,如果是,则该点在多边形内。我们认为角度的其他任何值都表示该点在多边形外。

测试 contains_point

让我们通过在文件 polygon_test.py 中添加两个单元测试来确保这个算法能正常工作(见 列表 6-8)。

class TestPolygon(unittest.TestCase):
   --snip--

   def test_doesnt_contain_point(self):
       point = Point(15, 20)
       self.assertFalse(self.polygon.contains_point(point))

   def test_contains_point(self):
       point = Point(15, 10)
       self.assertTrue(self.polygon.contains_point(point))

列表 6-8:测试多边形是否包含某点

你可以通过 IDE 中的绿色播放按钮或从终端运行测试:

$ python3 -m unittest geom2d/polygon_test.py

在第一个测试中,我们选择一个已知在三角形外的点,并验证它实际上在外面。第二个测试验证点 (15, 10) 在三角形内。

测试边缘情况

让我们再试一个测试,看看会发生什么。那多边形的顶点呢?它们被认为在多边形内还是外?这是我们所说的 边缘情况,这种情况需要在代码中进行特殊处理。

输入看似无害的测试代码 列表 6-9,并运行文件 rect_test.py 中的所有测试。

class TestPolygon(unittest.TestCase):
   --snip--

   def test_contains_vertex(self):
       self.assertTrue(
           self.polygon.contains_point(self.vertices[0])
       )

列表 6-9:测试多边形是否包含其一个顶点

运行测试后的输出如下:

Error
Traceback (most recent call last):
  --snip--
  File ".../geom2d/polygon.py", line 36, in <listcomp>
    [v1.angle_to(v2) for v1, v2 in paired_vecs]
  File ".../geom2d/vector.py", line 69, in angle_to
    value = self.angle_value_to(other)
  File ".../geom2d/vector.py", line 66, in angle_value_to
    return math.acos(dot_product / norm_product)
ZeroDivisionError: float division by zero

哎呀!我们肯定做错了什么。你能通过阅读堆栈跟踪猜出是什么吗?从最后一行开始,我们找到了源头:ZeroDivisionError。显然我们在 angle_value_to 方法中尝试了除以零。具体来说,我们在这一行做了除法:

return math.acos(dot_product / norm_product)

这意味着 norm_product 为零;因此,用来计算角度的至少一个向量的模长为 0。往上查看堆栈跟踪,我们找到了出错前使用角度方法的代码行:

[v1.angle_to(v2) for v1, v2 in paired_vecs]

所以,看来当我们尝试计算两个向量之间的角度时,其中一个向量的长度为 0。这个从点 P(这次是多边形的一个顶点)到它自身的向量显然是一个零向量。

为了处理这个特殊的边缘情况,我们可以将顶点视为在多边形内作为一种约定。在 contains_point 方法的开始部分,我们检查传入的点是否为多边形的一个顶点,如果是,则直接返回 True。修改该方法以适应这个新条件(见 列表 6-10)。

class Polygon:
    --snip--

    def contains_point(self, point: Point):
        if point in self.vertices:
            return True

        vecs = [make_vector_between(point, vertex)
                for vertex in self.vertices]
        paired_vecs = make_round_pairs(vecs)
        angle_sum = reduce(
            operator.add,
            [v1.angle_to(v2) for v1, v2 in paired_vecs]
        )

        return are_close_enough(angle_sum, 2 * math.pi)

列表 6-10:修正后的算法,检查点是否在多边形内

如你所见,处理边界情况需要一些个性化的代码块。现在,运行所有测试,确保它们都成功:

$ python3 -m unittest geom2d/polygon_test.py

此次输出应如下所示:

Ran 5 tests in 0.001s

OK

多边形工厂

实际上,我们通常需要根据一个包含顶点坐标的数字列表来构建多边形。例如,在从文本文件读取多边形时我们就会用到这种方式,稍后我们将在第十二章看到。在做这件事之前,我们首先需要将这些数字配对,并将它们映射为 Point 类的实例。

例如,列表

[0, 0, 50, 0, 0, 50]

可以用于定义一个三角形的三个顶点:

[(0, 0), (50, 0), (0, 50)]

让我们实现一个工厂函数,根据一系列浮动点数来创建多边形。创建一个名为 polygons.py 的新文件。我们项目的结构目前如下所示:

机械学

|- geom2d

|    |- init.py

|    |- line.py

|    |- line_test.py

|    |- nums.py

|    |- point.py

|    |- point_test.py

|    |- polygon.py

|    |- polygon_test.py

|    |- polygons.py

|    |- segment.py

|    |- segment_test.py

|    |- vector.py

|    |- vector_test.py

|    |- vectors.py

|- utils

|    |- init.py

|    |- pairs.py

在新文件中,输入代码内容,见清单 6-11。

from geom2d import Point, Polygon

def make_polygon_from_coords(coords: [float]):
    if len(coords) % 2 != 0:
        raise ValueError('Need an even number of coordinates')

    indices = range(0, len(coords), 2)
    return Polygon(
        [Point(coords[i], coords[i + 1]) for i in indices]
    )

清单 6-11:多边形工厂函数

函数 make_polygon_from_coords 接受一个坐标列表,并首先检查其是否包含偶数个坐标(否则无法配对)。如果坐标列表的长度除以 2 没有余数,我们就得到了偶数个坐标。

如果发现坐标的数量不均匀,我们将抛出一个 ValueError。如果数量均匀,我们接着构建一个包含索引的列表,在这些索引位置我们将在 coords 列表中找到顶点的 x 坐标。我们通过一个从 0 到 len(coords)(不包括上限)的步长为 2 的范围来实现这一点。

为了更好地理解我们是如何做到这一点的,请在 Python 的 shell 中尝试以下操作:

>>> list(range(0, 10, 2))
[0, 2, 4, 6, 8]

使用这些索引,我们可以通过列表推导式轻松地获得顶点的列表。回想一下,Python 的 range 函数返回的是一个半开区间,不包含上限,这就是为什么我们在结果列表中没有得到数字 10 的原因。列表推导式将每个索引映射为一个 Point 类的实例。我们通过将这个列表传递给多边形的构造函数来创建多边形。正如你从代码中看到的,x 坐标是输入列表中每个索引 i 处的数字,而 y 坐标则是它右边的数字,也就是 i + 1。

处理完这些后,让我们看看如何比较多边形的相等性。

多边形相等性

为了确保我们可以检查多边形是否相等,接下来在 Polygon 类中实现 eq 方法(参见清单 6-12)。

class Polygon:
    --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Polygon):
            return False

        return self.vertices == other.vertices

清单 6-12:多边形相等性

我们首先检查传入的 other 是否与 self 为同一个实例,如果是,返回 True。其次,如果 other 不是 Polygon 的实例,我们就没有太多可以比较的地方;我们已经知道它们不可能相等。

由于 Point 已经实现了 eq 方法,我们只需要比较两个多边形的顶点列表,前提是之前的两个检查都没有返回结果。Python 将检查两个列表是否包含相同的顶点,并且顺序相同。列表是有序集合,因此在检查相等性时,顺序非常重要。尝试在 shell 中进行以下实验:

>>> l1 = [1, 2, 3]
>>> l2 = [3, 2, 1]
>>> l3 = [3, 2, 1]
>>> l1 == l2
False

>>> l2 == l3
true

即使 l1 和 l2 包含相同的数字,Python 也认为它们是不同的,因为它们的顺序不同(别忘了,对于列表和元组,顺序很重要)。相反,l2 和 l3 的顺序相同,因此被认为是相等的。多边形由一组有序的顶点组成:相同顶点集的不同排列将导致不同的多边形。这就是为什么我们使用列表作为集合,因为在列表中顺序是关键因素。

如果你跟随操作,你的 polygon.py 文件应该像 列表 6-13 中那样。

import math
import operator
from functools import reduce

from geom2d.nums import are_close_enough
from geom2d.point import Point
from geom2d.vectors import make_vector_between
from geom2d.segment import Segment
from utils.pairs import make_round_pairs

class Polygon:
    def __init__(self, vertices: [Point]):
        if len(vertices) < 3:
            raise ValueError('Need 3 or more vertices')
        self.vertices = vertices

    def sides(self):
        vertex_pairs = make_round_pairs(self.vertices)
        return [Segment(pair[0], pair[1]) for pair in vertex_pairs]

    @property
    def centroid(self):
        vtx_count = len(self.vertices)
        vtx_sum = reduce(operator.add, self.vertices)
        return Point(
            vtx_sum.x / vtx_count,
            vtx_sum.y / vtx_count
        )

    def contains_point(self, point: Point):
        if point in self.vertices:
            return True

        vecs = [make_vector_between(point, vertex)
                for vertex in self.vertices]
        paired_vecs = make_round_pairs(vecs)
        angle_sum = reduce(
            operator.add,
            [v1.angle_to(v2) for v1, v2 in paired_vecs]
        )

        return are_close_enough(angle_sum, 2 * math.pi)

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Polygon):
            return False

        return self.vertices == other.vertices

列表 6-13:多边形类

现在让我们来看一下圆形。

圆形类

圆形是平面上距离一个固定点(称为 中心)给定距离(即 半径)的所有点的集合。因此,圆形由其中心 C 的位置和半径 r 的值定义(见 图 6-6)。

图片

图 6-6:一个由中心点 C 和半径 r 定义的圆形

正如你可能还记得的那样,圆形的面积计算公式如下:

A = π ⋅ r²

一个圆的周长计算公式如下:

l[c] = 2π ⋅ r

geom2d 包中创建一个名为 circle.py 的新文件。在文件中输入 列表 6-14 中的代码。

import math

from geom2d.point import Point

class Circle:
    def __init__(self, center: Point, radius: float):
        self.center = center
        self.radius = radius

    @property
    def area(self):
        return math.pi * self.radius ** 2

    @property
    def circumference(self):
        return 2 * math.pi * self.radius

列表 6-14:圆形类初始化

太好了!我们现在有了一个表示圆形的类,它具有中心和半径这两个属性。我们还定义了名为面积(area)和周长(circumference)的属性。

注意

为了保持章节长度的合理性,我们将不再包括更多的单元测试部分。附带的代码确实包含了单元测试,我鼓励你自己动手编写它们。

包含点

测试一个点 P 是否在一个通用多边形内部需要一些步骤,但在圆形的情况下,逻辑非常简单。我们计算从中心 C 到点 P 的距离:d(C,P)。如果这个距离小于半径,即 d(C,P) < r,那么该点在圆内。如果 d(C,P) 大于 r,则该点距离中心比半径远,因此在圆外。进入 Circle 类,在 列表 6-15 中输入代码。

class Circle:
   --snip--

   def contains_point(self, point: Point):
       return point.distance_to(self.center) < self.radius

列表 6-15:检查一个圆形是否包含某个点

你能想出一些测试用例来确保 contains_point 方法没有 bug 吗?

从圆形到多边形

在第七章中,我们将通过旋转、缩放和倾斜来变换多边形的几何形状。经过这些变换后,圆形可能不再是圆形,结果的数学表示可能会变得复杂。

由于使用特定几何类来处理所有可能的形状会很繁琐,而我们的通用多边形无论形状如何都能正常工作,为什么不尝试用足够边数的多边形来逼近圆形呢?

要将圆形转换为多边形,需要选择一个划分数,比如 n。整个 2π 角度被划分为 n 个子角度 θ = 2π/n。从角度 0 开始,每次增加 θ,我们可以计算出 n 个圆周上的点,这些点将成为内接圆形的多边形的顶点。我们可以使用方程 6.2 计算给定角度 α 的顶点 V

Image

其中 C 是圆心,r 是半径。图 6-7 显示了选择 n = 8 的结果,这将圆形转换为一个八边形,顶点为 V[1]、V[2]、……、V[8]。

Image

图 6-7:将圆形转换为多边形

还要注意,当 n 的值较小时,所得的多边形与圆形的近似效果较差。例如,在图 6-8 中,n 分别选择为 3、4 和 5。正如你所看到的,内接多边形看起来只和它们所近似的圆形相似。通常我们会选择 n 的值在 30 到 200 之间,以得到一个可接受的结果。

Image

图 6-8:将圆形转换为多边形时的划分数

在 Circle 类中实现 to_polygon 方法,参考清单 6-16。

import math

from geom2d.point import Point
from geom2d.polygon import Polygon

class Circle:
   --snip--

   def to_polygon(self, divisions: int):
    ➊ angle_delta = 2 * math.pi / divisions
      return Polygon(
       ➋ [self.__point_at_angle(angle_delta * i)
          for i in range(divisions)]
    )

   def __point_at_angle(self, angle: float):
    ➌ return Point(
          self.center.x + self.radius * math.cos(angle),
          self.center.y + self.radius * math.sin(angle)
      )

清单 6-16:从圆形创建多边形

这次我们将算法分成了两部分:主要逻辑由 to_polygon 方法处理,另一个私有方法 __point_at_angle 用于根据角度返回圆周上的点 ➌。这样的点是根据方程 6.2 计算的。

to_polygon 方法首先计算给定划分数 ➊ 的角度增量(或角度增量)。然后,使用列表推导式,它将范围 0,n) 中的每个整数映射到圆周上对应增量角度的位置 ➋。这个点的列表将作为多边形初始化时的顶点。注意,我们如何通过将当前数字与角度增量相乘,将范围 [0, n) 转换为角度。

相等性和字符串表示

让我们在 Circle 类中实现相等性比较和字符串表示方法。请在[清单 6-17 中输入代码。

import math

from geom2d.nums import are_close_enough
from geom2d.point import Point
from geom2d.polygon import Polygon

class Circle:
   --snip--

   def __eq__(self, other):
       if self is other:
            return True

        if not isinstance(other, Circle):
            return False

       return self.center == other.center \
              and are_close_enough(self.radius, other.radius)

   def __str__(self):
       return f'circle c = {self.center}, r = {self.radius}'

清单 6-17:圆形相等性和字符串表示

如果你跟着做的话,你的 circle.py 文件应该和清单 6-18 一样。

import math

from geom2d.nums import are_close_enough
from geom2d.point import Point
from geom2d.polygon import Polygon

class Circle:
    def __init__(self, center: Point, radius: float):
        self.center = center
        self.radius = radius

    @property
    def area(self):
        return math.pi * self.radius ** 2

    @property
    def circumference(self):
        return 2 * math.pi * self.radius

    def contains_point(self, point: Point):
        return point.distance_to(self.center) < self.radius

    def to_polygon(self, divisions: int):
        angle_delta = 2 * math.pi / divisions
        return Polygon(
            [self.__point_at_angle(angle_delta * i)
             for i in range(divisions)]
        )

    def __point_at_angle(self, angle: float):
        return Point(
            self.center.x + self.radius * math.cos(angle),
            self.center.y + self.radius * math.sin(angle)
        )

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Circle):
            return False

        return self.center == other.center \
               and are_close_enough(self.radius, other.radius)

    def __str__(self):
        return f'circle c = {self.center}, r = {self.radius}'

清单 6-18:Circle 类

圆形工厂

我们通常从圆心和半径来构建圆,但也有几种不同的构建方式。在这一节中,我们将探讨其中一种:通过三点生成一个圆。我们这样做主要是为了趣味,但这也能让我们感受到我们正在构建的几何原始体的强大。

假设我们给定了三个不共线的点,分别是ABC。正如你在图 6-9 中看到的,你可以找到一个通过这三个点的圆。

图片

图 6-9:通过三点定义圆

为了解决这个问题,我们需要找到圆的中心和半径,后者比较简单,因为如果我们知道圆心的位置,任何一个三点到圆心的距离都能得到半径。所以,问题归结为寻找一个通过给定点的圆的圆心。我们可以通过以下方式来找到它:

  1. 计算从AB的线段,我们称之为seg[1]。

  2. 计算从BC的线段,我们称之为seg[2]。

  3. 找到seg[1]和seg[2]的角平分线的交点。

交点O是圆的中心(见图 6-10)。如前所述,求圆的半径非常简单,只需测量OABC之间的距离即可。

图片

图 6-10:通过三点定义的圆的圆心和半径

我们准备实现逻辑了。在geom2d包中创建一个新文件,并命名为circles.py。在该文件中输入清单 6-19 中的代码。

from geom2d import Point
from geom2d.circle import Circle
from geom2d.segment import Segment

def make_circle_from_points(a: Point, b: Point, c: Point):
    chord_one_bisec = Segment(a, b).bisector
    chord_two_bisec = Segment(b, c).bisector
    center = chord_one_bisec.intersection_with(chord_two_bisec)
    radius = center.distance_to(a)

    return Circle(center, radius)

清单 6-19:由三点生成圆

注意

回顾一下,圆弦是一个线段,其端点位于圆周上,并穿过圆。

如果你被要求简化这个函数,你能做到吗?每一行代码都清楚地告诉你它在做什么;你可以一行一行地阅读,并将其与算法描述进行匹配。自解释的代码清楚地表达了其意图,这种代码通常被称为清晰代码,它在软件行业中是一个备受推崇的概念,甚至有几本书专门讨论这一话题。我最喜欢的两本书包括[6]和[1],如果你想编写真正易读的代码,我也推荐你阅读它们。

矩形类

本章中我们将实现的最后一个几何原始体是矩形,但它不是任何类型的矩形——它是那种边始终水平和垂直的矩形。旋转矩形可以通过本章前面提到的多边形原始体来表示。这个看似限制性的规则背后有其原因,主要与该原始体通常用于什么用途有关。

这种矩形通常用于二维图形应用程序,应用场景包括以下几种:

  • 表示屏幕上需要重新绘制的部分

  • 确定屏幕上需要绘制某物的位置

  • 确定必须绘制的几何形状的大小

  • 测试两个对象是否可能发生碰撞

  • 测试鼠标光标是否位于屏幕的某个区域上

一个矩形可以通过一个点(称为原点)和一个大小来定义,大小又有两个属性:宽度和高度(见图 6-11)。按照约定,原点位于矩形的左下角,假设坐标系的 y 轴指向上方。

Image

图 6-11:由原点 O、宽度 w 和高度 h 定义的矩形

让我们从一个类开始,来表示大小。在 geom2d 包中,创建一个名为 size.py 的新文件,包含列表 6-20 中的定义。

from geom2d.nums import are_close_enough

class Size:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Size):
            return False

        return are_close_enough(self.width, other.width) \
               and are_close_enough(self.height, other.height)

列表 6-20:Size 类

使用这种大小的表示法,让我们来创建 Rect 的初始定义。创建一个名为 rect.py 的新文件,并输入列表 6-21 中的代码。

from geom2d.point import Point
from geom2d.size import Size

class Rect:
    def __init__(self, origin: Point, size: Size):
        self.origin = origin
        self.size = size

    @property
    def left(self):
        return self.origin.x

    @property
    def right(self):
        return self.origin.x + self.size.width

    @property
    def bottom(self):
        return self.origin.y

    @property
    def top(self):
        return self.origin.y + self.size.height

    @property
    def area(self):
        return self.size.width * self.size.height

    @property
    def perimeter(self):
        return 2 * self.size.width + 2 * self.size.height

列表 6-21:Rect 类

该类存储了一个 Point 实例表示原点,以及一个 Size 实例表示它的宽度和长度。我们在类中定义了一些有趣的属性,即:

left 矩形最左边的 x 坐标

right 矩形最右边的 x 坐标

bottom 矩形最底部边缘的 y 坐标

top 矩形最顶部边缘的 y 坐标

area 矩形的面积

perimeter 矩形的周长

让我们在 shell 中创建一个矩形:

>>> from geom2d.point import Point
>>> from geom2d.size import Size
>>> from geom2d.rect import Rect

>>> origin = Point(10, 20)
>>> size = Size(100, 150)
>>> rect = Rect(origin, size)

让我们检查一下它的一些属性:

>>> rect.right
110

>>> rect.area
15000

>>> rect.perimeter
500

包含点

下一个逻辑步骤是实现一个方法来测试一个点是否在矩形内。为了测试一个点 P 是否位于矩形内部,我们将使用以下两个条件:

Image

多亏了我们为类添加的属性,这变得轻而易举(见列表 6-22)。

class Rect:
   --snip--

   def contains_point(self, point: Point):
       return self.left < point.x < self.right \
              and self.bottom < point.y < self.top

列表 6-22:测试矩形是否包含一个点

请注意 Python 中复合不等式的优美语法,

left < point.x < right

而在其他大多数编程语言中,这通常需要用两个不同的条件来表示:

left < point.x && point.x < right

交集

假设我们有两个矩形,并且我们想知道它们是否重叠。由于 Rect 表示的矩形边缘始终是水平和垂直的,因此这个问题简化了很多。测试两个矩形是否重叠,实际上就是测试它们在 x 轴和 y 轴上的投影是否重叠。我们所说的投影,是指它们在轴线上的阴影。每个阴影是一个区间,从矩形原点的值所在的位置开始,长度要么是它的宽度,要么是它的高度(见图 6-12)。

Image

图 6-12:矩形的投影

例如,图 6-12 中的水平轴阴影可以表示为以下区间,

(O[x],O[x] + w)

其中O是原点,w是矩形的宽度。同样,垂直投影或阴影将是:

(O[y], O[y] + h)

其中h是这次的高度。请注意,O[x] + w的结果正是我们在 Rect 类中定义的正确属性,而O[y] + h则是顶部。

图 6-13 则描绘了两个矩形,它们的垂直投影重叠,但水平方向投影不重叠。因此,矩形并未发生重叠。

Image

图 6-13:两个不相交的矩形

图 6-14 则描绘了两个矩形,它们的垂直和水平方向投影发生了重叠。如你所见,这种布局确实产生了一个重叠区域,并以灰色标示出来。我们可以观察到,重叠的矩形总是会产生矩形形状的重叠区域。

Image

图 6-14:两个相交的矩形

使用前面图示中的术语,我们可以通过开区间来定义该条件,开区间是指端点被排除的区间。如果两个矩形重叠,则

Image

其中∩是交集二进制运算符。

开区间

现在我们已经将问题简化为计算区间之间的交集,让我们创建一个新的 OpenInterval 类来实现这个逻辑。请注意,在 Rect 类中编写算法的实现来查找两个区间的交集在概念上是错误的。每个类只应包含与其领域相关的逻辑,而区间交集显然与矩形无关。一个矩形不应知道如何计算两个区间的交集;这不属于它的领域。如果它需要计算交集,就像在我们的例子中一样,它应该将任务委托给该领域的专家:OpenRange。

如果你遵循这个简单的指南,你的代码将更容易推理和扩展。代码中的每一条知识应该恰好存在于它应在的位置,而且仅在该位置。软件的最大敌人之一就是知识重复,即同一条知识(如果你喜欢,可以称之为算法)出现在多个地方。当核心逻辑需要更改时,你需要记得在所有地方进行修改。相信我,问题比它听起来要严重得多。

注意

大多数作者使用“代码重复”的说法,但我更倾向于称之为“知识重复”。这个词语的选择是有意的,因为我注意到一些开发者倾向于误解这个概念,可能是因为“代码”这个词过于泛泛。应该避免重复的是由代码表达的知识。

geom2d中创建一个名为open_interval.py的新文件,并在其中定义如清单 6-23 所示的 OpenInterval 类。

class OpenInterval:
    def __init__(self, start: float, end: float):
        if start > end:
            raise ValueError('start should be smaller than end')
        self.start = start
        self.end = end

    @property
    def length(self):
     ➊ return self.end - self.start

    def contains(self, value):
     ➋ return self.start < value < self.end

清单 6-23:OpenInterval 类

OpenInterval 是通过起始和结束属性创建的。我们确保起始值小于结束值;否则,我们会引发一个 ValueError 异常。回想一下我们的快速失败约定;我们不希望有构造不当的区间存在。接下来,我们将区间的长度定义为一个属性 ➊,并定义一个方法来测试给定的值是否在区间范围内 ➋。

现在让我们加入另外两种方法:一种用于检查区间是否重叠,另一种用于实际计算重叠的结果(见清单 6-24)。

from geom2d.nums import are_close_enough

class OpenInterval:
    --snip--

    def overlaps_interval(self, other):
     ➊ if are_close_enough(self.start, other.start) and \
               are_close_enough(self.end, other.end):
           return True

     ➋ return self.contains(other.start) \
               or self.contains(other.end) \
               or other.contains(self.start) \
               or other.contains(self.end)

    def compute_overlap_with(self, other):
     ➌ if not self.overlaps_interval(other):
            return None

     ➍ return OpenInterval(
            max(self.start, other.start),
            min(self.end, other.end)
        )

清单 6-24: 开区间重叠

第一种方法 overlaps_interval 返回一个布尔值,如果区间与传入的另一个区间重叠,则返回 True。为此,我们首先检查两个区间的起始和结束值是否相同 ➊,如果相同则返回 True。然后检查四个端点是否有一个包含在另一个区间内 ➋。如果你对这段逻辑感到困惑,可以拿一支笔和一些纸,画出所有可能的两个重叠区间的组合(我已经为你在图 6-15 中画出了这些组合,排除了两个区间的起始和结束值相同的情况)。

Image

图 6-15: 区间位置的可能情况

第二种方法 compute_overlap_with 开始时会确保实际上存在重叠,如果没有重叠则返回 None ➌。重叠部分是一个新的区间,起始值为两个起始值中的最大值,结束值为两个结束值中的最小值 ➍。

我鼓励你为这个重叠逻辑编写单元测试。这是一个极好的机会来提升你的测试技能。重叠区间有很多组合,尽量覆盖所有情况。

计算交集

OpenInterval 的帮助下,矩形交集问题变得容易解决。返回到 rect.py,并导入 OpenInterval 类:

from geom2d.open_interval import OpenInterval

现在,在 contains_point 方法下面,输入清单 6-25 中的代码。

from geom2d.open_interval import OpenInterval
from geom2d.point import Point
from geom2d.size import Size

class Rect:
    --snip--

    def intersection_with(self, other):
     ➊ h_overlap = self.__horizontal_overlap_with(other)
        if h_overlap is None:
            return None

     ➋ v_overlap = self.__vertical_overlap_with(other)
        if v_overlap is None:
            return None

     ➌ return Rect(
            Point(h_overlap.start, v_overlap.start),
            Size(h_overlap.length, v_overlap.length)
       )

清单 6-25: 两个矩形的交集

有两个私有的辅助方法计算水平和垂直重叠;我们稍后会详细了解这两个方法。方法首先计算 selfother 之间的水平重叠 ➊。如果发现没有重叠,则返回 None,意味着矩形没有交集。垂直重叠也采用相同的步骤 ➋。只有当两个重叠都不为 None 时,即水平和垂直投影都重叠,我们才会进入最后的返回步骤,其中计算结果矩形 ➌。我们怎么找到这个矩形的原点和大小呢?很简单:原点坐标是水平和垂直重叠区间的起始值,宽度是水平重叠的长度,高度是垂直重叠的长度。

所以,唯一缺少的部分是实现查找水平和垂直区间重叠的私有方法(如果存在)。该代码在 清单 6-26 中。

class Rect:
   --snip--

   def __horizontal_overlap_with(self, other):
       self_interval = OpenInterval(self.left, self.right)
       other_interval = OpenInterval(other.left, other.right)

       return self_interval.compute_overlap_with(other_interval)

   def __vertical_overlap_with(self, other):
       self_interval = OpenInterval(self.bottom, self.top)
       other_interval = OpenInterval(other.bottom, other.top)

       return self_interval.compute_overlap_with(other_interval)

清单 6-26:交集私有方法

现在让我们看看如何基于矩形构建一个通用多边形。

转换为多边形

与圆形类似,应用仿射变换到矩形上可能会得到一些非矩形的形状。事实上,在进行通用仿射变换后,矩形会变成一个平行四边形,如 图 6-16 所示,这些形状无法用我们的 Rect 类来描述。

图片

图 6-16:仿射变换后的矩形

从矩形创建多边形的方法很简单,因为此类多边形的顶点就是矩形的四个角。在 Rect 类中,添加 清单 6-27 中的方法。别忘了导入 Polygon 类。

from geom2d.open_interval import OpenInterval
from geom2d.point import Point
from geom2d.polygon import Polygon
from geom2d.size import Size

class Rect:
   --snip--

   def to_polygon(self):
       return Polygon([
           self.origin,
           Point(self.right, self.bottom),
           Point(self.right, self.top),
           Point(self.left, self.top)
       ])

清单 6-27:从矩形创建多边形

不用多说,顶点应该按顺序给出,顺时针或逆时针,但无论如何要尊重顺序。顶点的顺序很容易搞错,导致边界交叉。为了确保这种情况永远不会发生,我们应该写一个测试,作为练习留给你。

相等性

你已经是实现 eq 方法的专家了,是吗?清单 6-28 显示了实现代码。

class Rect:
   --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Rect):
            return False

        return self.origin == other.origin \
               and self.size == other.size

清单 6-28:矩形相等性

唯一需要注意的是,我们能够直接使用 == 比较尺寸,因为我们在 Size 类上也实现了 eq 方法。

请注意,像 \textit{eq} 这样在 Rect 中实现(例如 are_close_enough(self.size.width, other.size.width) ...)并不理想。记得德梅特法则吗?这些知识属于 Size 类,并且应该仅在该类中实现。

作为参考, 清单 6-29 显示了你的 rect.py 文件应该是什么样子的。

from geom2d.open_interval import OpenInterval
from geom2d.point import Point
from geom2d.polygon import Polygon
from geom2d.size import Size

class Rect:

    def __init__(self, origin: Point, size: Size):
        self.origin = origin
        self.size = size

    @property
    def left(self):
        return self.origin.x

    @property
    def right(self):
        return self.origin.x + self.size.width

    @property
    def bottom(self):
        return self.origin.y

    @property
    def top(self):
        return self.origin.y + self.size.height

    @property
    def area(self):
        return self.size.width * self.size.height

    @property
    def perimeter(self):
        return 2 * self.size.width + 2 * self.size.height

    def contains_point(self, point: Point):
        return self.left < point.x < self.right \
               and self.bottom < point.y < self.top

    def intersection_with(self, other):
        h_overlap = self.__horizontal_overlap_with(other)
        if h_overlap is None:
            return None

        v_overlap = self.__vertical_overlap_with(other)
        if v_overlap is None:
            return None

        return Rect(
            Point(h_overlap.start, v_overlap.start),
            Size(h_overlap.length, v_overlap.length)
        )

    def __horizontal_overlap_with(self, other):
        self_interval = OpenInterval(self.left, self.right)
        other_interval = OpenInterval(other.left, other.right)

        return self_interval.compute_overlap_with(other_interval)

    def __vertical_overlap_with(self, other):
        self_interval = OpenInterval(self.bottom, self.top)
        other_interval = OpenInterval(other.bottom, other.top)

        return self_interval.compute_overlap_with(other_interval)

    def to_polygon(self):
        return Polygon([
            self.origin,
            Point(self.right, self.bottom),
            Point(self.right, self.top),
            Point(self.left, self.top)
        ])

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Rect):
            return False

        return self.origin == other.origin \
               and self.size == other.size

清单 6-29:Rect 类的实现

矩形工厂

我们经常使用矩形来近似一组几何图形的外部边界。例如,在本书的后续章节中,我们将生成图示,作为力学问题解决方案的一部分。为了将图示放入正确尺寸的图像中,我们将创建一个可以容纳所有内容的矩形。为此,我们将创建一个工厂函数,返回一个包含给定点列表的矩形。

例如,如果我们给定点列表 [A, B, C, D, E],那么矩形会像 图 6-17 中的左侧插图那样。我们还需要另一个工厂函数,做类似的事情,但也为矩形添加一些边距。

图片

图 6-17:包含点的矩形

geom2d 包中,创建一个新文件并命名为 rects.py。添加第一个工厂函数(见 清单 6-30)。

from geom2d.point import Point
from geom2d.rect import Rect
from geom2d.size import Size

def make_rect_containing(points: [Point]):
 ➊ if not points:
        raise ValueError('Expected at least one point')

    first_point = points[0]
 ➋ min_x, max_x = first_point.x, first_point.x
 ➌ min_y, max_y = first_point.y, first_point.y

    for point in points[1:]:
     ➍ min_x, max_x = min(min_x, point.x), max(max_x, point.x)
     ➎ min_y, max_y = min(min_y, point.y), max(max_y, point.y)

 ➏ return Rect(
        Point(min_x, min_y),
        Size(max_x - min_x, max_y - min_x)
    )

清单 6-30:创建包含点列表的矩形

第一步是检查列表 points 是否至少包含一个点 ➊。你可能会对语法感到惊讶;这里的技巧是,Python 在布尔上下文中将空列表视为 False。实际上,这是一个 Pythonic 的惯用法,用来检查列表是否为空。

接下来,我们需要寻找矩形的边界:最小和最大 x 轴和 y 轴的投影。四个变量存储这些值 ➋ ➌,它们被初始化为列表中第一个点的坐标。然后,我们遍历所有点,除了第一个点,因为它已经被用来初始化前述变量。为了避免经过第一个点,我们从索引 1 开始切片列表,直到列表的末尾:points[1:]。(你可以参考“列表”在第 15 页中有关切片列表的内容。)对于每个点,当前存储的最小和最大 x ➍ 以及 y ➎ 投影值会与当前值进行比较。

一旦我们得到这四个值,就可以使用最小的 x 和 y 投影来构建结果矩形 ➏,并计算每个最大值和最小值之间的差值作为矩形的大小。

现在,让我们实现一个类似的函数,并在点周围添加边距。在实现了 make_rect_containing 后,输入清单 6-31 中的代码。

--snip--

def make_rect_containing_with_margin(points: [Point], margin: float):
 ➊ rect = make_rect_containing(points)
    return Rect(
     ➋ Point(
            rect.origin.x - margin,
            rect.origin.y - margin
        ),
     ➌ Size(
            2 * margin + rect.size.width,
            2 * margin + rect.size.height
        )
    )

清单 6-31:创建包含点列表和给定边距的矩形

这个函数从之前的函数计算出的矩形 ➊ 开始。然后,通过将矩形的原点向左和向下移动边距的宽度 ➋,并将大小增加两倍边距的宽度 ➌,来计算新的矩形。请记住,边距是添加到左侧和右侧的,所以我们将它加到宽度上两次——高度也是如此。

还有一种我们可能想要构建矩形的方法:根据其中心和大小来创建矩形。这个实现很简单,正如你在清单 6-32 中看到的那样。

--snip--

def make_rect_centered(center: Point, width: float, height: float):
    origin = Point(
        center.x - width / 2,
        center.y - height / 2
    )
    return Rect(origin, Size(width, height))

清单 6-32:根据矩形的中心和大小创建矩形

通过这三种工厂方法,我们可以方便地创建矩形。我们将在后续章节中使用它们,因此我们希望确保它们能生成预期的矩形,并通过一些自动化单元测试进行验证。我会把这个作为一个练习留给你。你可以在与本书附带的源代码中的 rects_test.py 中找到我写的测试。

总结

本章开始时,我们实现了一个通用多边形,描述为至少由三个顶点组成的序列。我们编写了一个算法来配对对象序列,使得最后一个和第一个元素也能配对,并用这个逻辑生成多边形的边。我们还实现了绕数算法来检查多边形是否包含一个点。

本章中我们创建的第二个几何原始元素是圆形。正如你所看到的,检查一个点是否在圆内比检查它是否在普通多边形内要简单得多。我们想出了一个方法,利用给定的分割数或边数来构造一个逼近圆形几何的通用多边形。我们将在下一章中使用这个方法。

最后,我们实现了一个矩形。为了计算矩形之间的交集,我们需要一种方法来确定两个区间之间的重叠;因此,我们创建了一个开放区间的抽象来处理这个逻辑。

我们的几何库几乎完成了。我们已经具备了书中所需的所有基本元素;唯一缺少的就是一种变换它们的方法,这也是下一章的主题。

第七章:仿射变换

Image

如果我必须从本书中选择我最喜欢的主题,那就是仿射变换。仿射变换有某种奇异的美感,正如你在第十二章中看到我们为其制作动画时所见。

仿射变换对于二维图形应用至关重要;它们决定了如何在屏幕上平移、缩放和旋转所看到的内容。如果你使用过 AutoCAD,你应该已经习惯了对图纸的某一部分进行缩放,这就是通过仿射变换实现的。每当你在 Instagram 上缩放和旋转图片时,也是通过仿射变换来完成的。掌握这一主题对于编写任何涉及图形的软件都是必不可少的,尤其是那些允许用户与图形互动的软件。

仿射变换背后的数学原理非常简单,但其概念却异常强大。在本章结束时,你将拥有一个表示这些变换的类,并具备应用这些变换到几何原始图形的能力。我们还将学习如何组合变换以计算复合变换,并了解一些有用的变换,例如围绕某个具体点缩放图形的变换。

仿射变换

由于仿射变换应用于仿射空间,首先我们需要理解什么是仿射空间。你可以把仿射空间看作是一个向量空间,其中原点可以移动。在向量空间中使用的线性变换保持空间原点的位置,而在仿射空间中,由于我们不再关心固定的原点,平移是允许的。

那么,仿射变换是指两个仿射空间之间的一种映射,它保持点、直线和平面的不变。仿射变换后的点依然是点,直线仍然是直线,平面依然是平面。这些变换的一个有趣特性是,直线之间的平行性被保持。我们将在第十二章中通过动画展示仿射变换时看到这一点。在那个练习中,我们将看到原本平行的多边形的边在整个模拟过程中保持平行。

仿射变换类似于线性变换。唯一的区别是,后者保持原点不变;也就是说,点(0,0)不会移动。仿射变换则可以改变原点的位置。图 7-1 展示了线性变换和仿射变换。

Image

图 7-1:线性变换与仿射变换

每一对坐标轴x, y在图 7-1 中显示了变换前空间的状态;每一对x^′, y′则展示了变换后空间的状态。在线性变换的情况下,坐标原点*O*得以保留;而在仿射变换中,除了对坐标轴进行缩放和旋转外,还将原点*O*平移到了*O*′。

给定一个点P,我们可以使用以下表达式定义仿射变换

Image

其中,M是一个线性变换,Image是一个平移向量,而P^′是应用变换后的结果点。因此,仿射变换是线性变换M加上平移Image。这个表达式可以按照方程 7.1 所示写出所有项。

Image

线性变换矩阵M包含以下项目

s[x]    在 x 方向上的缩放

s[y]    在 y 方向上的缩放

sh[x]    在 x 方向上的剪切变换

sh[y]    在 y 方向上的剪切变换

平移Image的项为

t[x]    在 x 方向上的平移

t[y]    在 y 方向上的平移

方程 7.2 展示了使用所谓的增广矩阵的等效形式。

Image

这种版本通过扩展输入和输出向量的大小,并附加一个 1 来简化变换为一次矩阵乘法,这个 1 作为辅助值,在变换完成后可以丢弃。与前一种方法相比,这通常更受偏爱,因为它只需要一步,而不需要额外的加法。你可以观察到,在方程 7.1 和 7.2 中,结果坐标如方程 7.3 所示。

Image

来自方程 7.2 矩阵中的每个值,在变换过程中都有不同的贡献。图 7-2 展示了每个组件产生的变换效果。因此,通用的仿射变换是这些单位变换的组合。

Image

图 7-2:仿射变换的组件

有一种特殊的仿射变换,它将每个点映射到自身,即恒等变换

Image

如你所见,这是一个恒等矩阵:无论将这个矩阵乘以哪个点,结果都将保持不变。

仿射变换的例子

让我们来看几个仿射变换的实际例子。在本节中,请放下你的电脑,拿出笔和纸。如果你能手工完成使用仿射变换转换空间的运算,那么编码实现这些变换就会变得很简单。

例子 1:缩放

给定一个点 (2, 3),在应用水平缩放 2 和垂直缩放 5 后,结果会是什么点?

在这种情况下,仿射变换矩阵中的项除了s[x] = 2 和 s[y] = 5 之外,其他都是零。将这些值代入方程 7.2,我们得到如下结果:

Image

因此,结果点为 (4, 15)。图 7-3 展示了此变换对点的影响。

Image

图 7-3:缩放变换的示例

示例 2:缩放和平移

给定一个点 (2, 3),在应用水平缩放 2、垂直缩放 5 和平移 ⟨10, 15⟩ 后,结果会是什么点?

这个案例与前一个案例具有相同的缩放值,外加一个位移向量。我们将这些值代入仿射变换方程:

Image

这次,结果点为 (14, 30)。稍后我们将进一步探讨这一点,但值得注意的是,我们可以通过两个连续的仿射变换来实现相同的效果,第一个变换是缩放点,第二个变换是平移它:

Image

请注意,变换是从右到左应用的。在前面的案例中,首先是缩放,然后是平移。如果交换变换的顺序,结果会不同,我们可以通过分别在两个方向上相乘变换矩阵并比较结果来验证这一点。这将得到我们的原始矩阵:

Image

但是,交换顺序后得到的是:

Image

图 7-4 展示了先应用缩放然后应用平移的效果。

Image

图 7-4:缩放加平移

示例 3:垂直反射

反射可以通过使用具有负缩放值的仿射变换来实现。要将点 (2, 3) 在垂直方向上进行反射,可以使用 s[y] = –1:

Image

这会得到原始点的垂直反射:(2,–3)。图 7-5 表示了这个垂直反射。

Image

图 7-5:垂直反射的示例

示例 4:水平剪切

将水平剪切 sh[x] = 2 应用于一个矩形,矩形的左下角位于原点,宽度为 10 单位,高度为 5 单位,结果会怎样?

这次我们需要将相同的变换应用到矩形的四个顶点:(0, 0)、(10, 0)、(10, 5) 和 (0, 5)。仿射变换矩阵如下:

Image

使用方程 7.2 并用这个矩阵来变换顶点,得到以下结果:(0, 0)、(10, 0)、(20, 5) 和 (10, 5)。绘制出结果矩形,应该像图 7-6 那样。

Image

图 7-6:剪切变换示例

仿射变换类

不再赘述,让我们创建一个新的类来表示仿射变换。我们希望使用类来管理变换的缩放、平移和剪切值,这些值将作为类的内部状态,而无需在每次调用变换方法时都传递这些参数。如果我们使用函数来变换几何图形,那我们就需要将这些值作为参数传递给每个函数,但这将需要大量的参数。

geom2d 包中,创建一个名为 affine_transf.py 的新文件,并输入 列表 7-1 中的代码。

class AffineTransform:
    def __init__(self, sx=1, sy=1, tx=0, ty=0, shx=0, shy=0):
        self.sx = sx
        self.sy = sy
        self.tx = tx
        self.ty = ty
        self.shx = shx
        self.shy = shy

列表 7-1:AffineTransform 类

仿射变换存储了缩放值 s[x] 和 s[y]、平移值 t[x] 和 t[y],以及剪切值 sh[x] 和 sh[y]。所有值的默认值为零,除非缩放值,它们初始化为一,以防在初始化器中省略。这是为了方便,因为我们会创建许多变换,其中剪切或平移值为零。

有了这些值,我们已经可以实现一个方法,借助 公式 7.3 将变换应用于一个点。在 geom2d 包中输入 列表 7-2 中的代码。

from geom2d.point import Point

class AffineTransform:
   --snip--

   def apply_to_point(self, point: Point):
       return Point(
           (self.sx * point.x) + (self.shx * point.y) + self.tx,
           (self.shy * point.x) + (self.sy * point.y) + self.ty
       )

列表 7-2:将仿射变换应用于一个点

要将仿射变换应用于一个点,我们创建一个新的 Point,其中投影值是根据 公式 7.3 计算的。让我们使用几种不同的变换来测试此方法。

测试点的变换

geom2d 包中创建一个名为 affine_transf_test.py 的新文件,并输入 列表 7-3 中的代码。

import unittest

from geom2d.point import Point
from geom2d.affine_transf import AffineTransform

class TestAffineTransform(unittest.TestCase):
    point = Point(2, 3)
    scale = AffineTransform(2, 5)
    trans = AffineTransform(1, 1, 10, 15)
    shear = AffineTransform(1, 1, 0, 0, 3, 4)

 ➊ def test_scale_point(self):
        expected = Point(4, 15)
        actual = self.scale.apply_to_point(self.point)
        self.assertEqual(expected, actual)
 ➋ def test_translate_point(self):
        expected = Point(12, 18)
        actual = self.trans.apply_to_point(self.point)
        self.assertEqual(expected, actual)
 ➌ def test_shear_point(self):
        expected = Point(11, 11)
        actual = self.shear.apply_to_point(self.point)
        self.assertEqual(expected, actual)

列表 7-3:测试仿射变换应用

测试文件包含 TestAffineTransform 类,继承自 unittest.TestCase,和往常一样。在类内部,我们定义一个在所有测试中使用的点,以及三个仿射变换,即:

scale    缩放变换

trans    平移变换

shear    剪切变换

然后我们有第一个测试,确保缩放正确应用于点 ➊。第二个测试应用平移于点,并断言结果符合预期 ➋。第三个测试对剪切变换做同样的操作 ➌。运行测试。你可以从 shell 执行:

$ python3 -m unittest geom2d/affine_transf_test.py

这将产生如下结果:

Ran 3 tests in 0.001s

OK

太好了!现在我们确信仿射变换正确应用于点,我们来将逻辑扩展到其他更复杂的图形。

变换线段和多边形

我们可以利用实现的点变换方法来变换任何形状,只要它是通过点或向量定义的。下一步是实现线段的变换,因此在 apply_to _point 方法之后,输入 列表 7-4 中的方法。

from geom2d.segment import Segment
from geom2d.point import Point

class AffineTransform:
   --snip--

   def apply_to_segment(self, segment: Segment):
       return Segment(
           self.apply_to_point(segment.start),
           self.apply_to_point(segment.end)
       )

列表 7-4:将仿射变换应用于线段

这很简单,不是吗?要变换一个线段,我们只需使用前面的方法将两个端点都进行变换,创建一个新的线段。我们可以将类似的逻辑应用于多边形(见列表 7-5)。

from geom2d.polygon import Polygon
from geom2d.segment import Segment
from geom2d.point import Point

class AffineTransform:
   --snip--

   def apply_to_polygon(self, polygon: Polygon):
       return Polygon(
           [self.apply_to_point(v) for v in polygon.vertices]
       )

列表 7-5:将仿射变换应用于多边形

在这种情况下,我们返回一个新的多边形,其中所有的顶点都已经被变换。那矩形和圆形呢?思路类似,但有一个警告:在缩放、错切和旋转这些原始图形后,结果可能不再是矩形或圆形。这就是为什么在前一章中,我们为矩形和圆形提供了一个 to_polygon 方法,用于为这些原始图形创建一个通用的多边形表示。代码因此非常简单。请从列表 7-6 输入代码:

from geom2d.rect import Rect
from geom2d.circle import Circle
from geom2d.polygon import Polygon
from geom2d.segment import Segment
from geom2d.point import Point

class AffineTransform:
   --snip--

   def apply_to_rect(self, rect: Rect):
       return self.apply_to_polygon(
           rect.to_polygon()
       )

   def apply_to_circle(self, circle: Circle, divisions=30):
       return self.apply_to_polygon(
           circle.to_polygon(divisions)
       )

列表 7-6:将仿射变换应用于矩形和圆形

该过程包括获取矩形或圆形的多边形表示,并将其余过程委托给 apply_to_polygon。对于圆形,需要选择分割数,默认值为 30。即使应用的仿射变换是恒等变换(不会改变几何形状),这两个方法也会返回一个 Polygon 实例。一旦矩形或圆形经过仿射变换,它就会变成一个通用的多边形,无论是什么变换。

由于空间原因,我们在这里不会这么做,但你可以随时为这三个新方法添加单元测试。

连接变换

仿射变换的一个有趣性质是,任何复杂的变换都可以表示为一系列简单变换的组合。事实上,当你使用像 Sketch 或 Photoshop 这样的 2D 图形应用程序时,每一次画布的缩放或平移,都是当前变换与新的仿射变换的组合或连接,这决定了你在特定时刻在屏幕上看到的投影。

给定两个仿射变换 [T[1]] 和 [T[2]] 以及输入点 P,将 [T[1]] 应用到该点的结果如下:

P′ = [T[1]]P

然后,将第二个变换 [T[2]] 应用到先前的结果,我们得到:

P^″ = [T[2]]P^′

如果我们将第一个表达式中的 P^′ 代入第二个表达式,就得到应用这两次变换到输入点 P 的结果(方程 7.4),

Image

其中,[T[r]] 是等同于先应用[T[1]]再应用[T[2]]的仿射变换。注意,如果从左到右阅读,原始变换的顺序是如何反过来的?

[T[r]] = [T[2]][T[1]]

在前面的公式中,从左到右[T[2]]首先出现,但应用[T[r]]的效果相当于先应用[T[1]],再应用[T[2]]。我们需要小心顺序,因为矩阵乘法是不交换的。如果我们交换操作数的顺序,我们将得到一个不同的变换,这在之前的一个练习中已经证明过了。结果变换随后用矩阵的乘积表示(参见方程 7.5)。

Image

让我们为 AffineTransform 类提供一个方法,用方程 7.5 来串联仿射变换。我们将这个方法命名为 then(),接受参数 self 和 other。第一个参数 self 是变换[T[1]],而 other 是[T[2]]。在affine_transf.py中,类的末尾输入列表 7-7 中的代码。

class AffineTransform:
   --snip--

   def then(self, other):
       return AffineTransform(
           sx=other.sx * self.sx + other.shx * self.shy,
           sy=other.shy * self.shx + other.sy * self.sy,
           tx=other.sx * self.tx + other.shx * self.ty + other.tx,
           ty=other.shy * self.tx + other.sy * self.ty + other.ty,
           shx=other.sx * self.shx + other.shx * self.sy,
           shy=other.shy * self.sx + other.sy * self.shy
       )

列表 7-7:串联变换的方法

然后选择这个名称是为了确保完全清楚地表明 self 在 other 之前应用(即方法的参数)。

由于这是一个非常重要的方法,我们希望它能通过单元测试覆盖;这意味着我们需要一种方法来判断两个给定的仿射变换是否相等。让我们在 AffineTransform 中实现特殊的 eq 方法(参见列表 7-8)。

from geom2d.nums import are_close_enough
from geom2d.rect import Rect
from geom2d.circle import Circle
from geom2d.polygon import Polygon
from geom2d.segment import Segment
from geom2d.point import Point

class AffineTransform:
    --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, AffineTransform):
            return False

        return are_close_enough(self.sx, other.sx) \
               and are_close_enough(self.sy, other.sy) \
               and are_close_enough(self.tx, other.tx) \
               and are_close_enough(self.ty, other.ty) \
               and are_close_enough(self.shx, other.shx) \
               and are_close_enough(self.shy, other.shy)

列表 7-8:检查仿射变换相等性

测试变换的串联

现在让我们在affine_transf_test.py中输入两个新测试;它们都列在列表 7-9 中。

class TestAffineTransform(unittest.TestCase):
   --snip--

   def test_concatenate_scale_then_translate(self):
       expected = AffineTransform(2, 5, 10, 15)
       actual = self.scale.then(self.trans)
       self.assertEqual(expected, actual)

   def test_concatenate_translate_then_scale(self):
       expected = AffineTransform(2, 5, 20, 75)
       actual = self.trans.then(self.scale)
       self.assertEqual(expected, actual)

列表 7-9:测试仿射变换串联

正如你可能已经意识到的,这两个测试重复了我们在本章开头的某个练习中手动进行的操作。运行它们,以确保你正确实现了这些操作。

$ python3 -m unittest geom2d/affine_transf_test.py

由于 self 和 other 之间有很多加法和乘法运算,很容易把代码写错。如果测试未通过,那就意味着它们通过指出代码中的问题来完成它们的工作。返回你的实现并逐行确保你一切都做对了。

反转仿射变换

为了撤销一个变换或应用已知变换[T]的逆,我们希望能够计算一个变换[T[I]],使得

[T][T[I]] = [T[I]][T] = [I]

其中[I]是 3x3 的单位矩阵:

Image

这些变换对[T]和[T[I]]的一个有趣属性是它们相互抵消。例如,以下是将这些变换按顺序(无论顺序如何)作用于一个点P的结果:

T[I] = ([T[I]][T])P = [I]P = P

逆仿射变换之所以有趣的另一个原因是,它将屏幕上的点映射回我们的“模型空间”,即定义我们模型的仿射空间。直接变换用于计算几何图形如何投影到屏幕上,也就是说,模型中的每个点应该绘制到哪里——但反过来呢?要知道屏幕上某个给定点在模型中的位置,需要逆变换,即将“屏幕空间”转换为模型空间的变换。这在某些情况下很有用,例如当试图弄清楚屏幕上用户的鼠标指针是否映射到模型中的某个可选项时。

看一下图 7-7。这是我们的模型空间,其中只定义了一个三角形。为了将模型绘制到用户的屏幕上,我们必须应用一个仿射变换,将模型空间中的每个点投影到屏幕空间。现在,假设用户的鼠标位于屏幕上的P^′点,我们想知道该点是否位于我们的三角形内部。由于三角形是定义在模型空间中的几何图形,我们希望对屏幕上的该点应用逆变换:即将屏幕空间转换回模型空间的变换。回想一下,为了将我们的模型几何图形投影到屏幕上,我们应用了直接的仿射变换,因此要将几何图形映射回原始的模型空间,需要应用该变换的逆变换。通过将该点映射到我们的模型空间(P),我们可以进行计算,以确定P是否在三角形内部。

图片

图 7-7:模型和屏幕空间

你可以尝试自己计算逆仿射变换矩阵,这是一个很好的练习,但手动求逆矩阵是一个繁琐的任务,因此方程 7.6 展示了结果。

图片

使用方程 7.6 中的变换,计算逆变换只需要几行代码。在 AffineTransform 类中,之后输入清单 7-10 中的代码。

class AffineTransform:
    --snip--

   def inverse(self):
       denom = self.sx * self.sy - self.shx * self.shy
       return AffineTransform(
           sx=self.sy / denom,
           sy=self.sx / denom,
           tx=(self.ty * self.shx - self.sy * self.tx) / denom,
           ty=(self.tx * self.shy - self.sx * self.ty) / denom,
           shx=-self.shx / denom,
           shy=-self.shy / denom
       )

清单 7-10:逆仿射变换

让我们再添加一个测试,确保逆变换能够正确计算。在affine_transf_test.py中,向 TestAffineTransform 类中添加一个新方法,测试内容参见清单 7-11。

class TestAffineTransform(unittest.TestCase):
   --snip--

   def test_inverse(self):
       transf = AffineTransform(1, 2, 3, 4, 5, 6)
       expected = AffineTransform()
       actual = transf.then(transf.inverse())
       self.assertEqual(expected, actual)

清单 7-11:测试逆仿射变换

在这个测试中,我们创建了一个新的仿射变换 transf,并将所有值设置为非零值。然后,我们将连接 transf 及其逆变换的结果存储在实际结果中,如果你记得的话,如果逆变换正确构造,结果应该是单位矩阵。最后,我们将得到的结果与实际的单位矩阵进行比较。运行文件中的所有测试,确保它们都能成功通过。

$ python3 -m unittest geom2d/affine_transf_test.py

让我们试一个例子。我们将对一个点应用平移,然后对得到的点应用反向平移,最终应该得到原始点。在 Python shell 中,写下以下代码:

>>> from geom2d.affine_transf import AffineTransform
>>> from geom2d.point import Point
>>> trans = AffineTransform(tx=10, ty=20)
>>> original = Point(5, 7)

我们知道,如果我们将⟨10, 20⟩的平移应用到点(5, 7),则得到的点应该是(15, 27)。让我们来验证一下。

>>> translated = trans.apply_to_point(original)
>>> str(translated)
'(15, 27)'

使用 str 函数,我们可以获得平移后的点的字符串表示。现在,让我们对这个点应用反向平移变换。

>>> inverse = trans.inverse().apply_to_point(translated)
>>> str(inverse)
'(5.0, 7.0)'

对平移后的点应用反向变换,得到原始点,正如预期的那样。

缩放

每当你使用 AutoCAD 或 Illustrator 等图形应用程序进行缩放时,都会对几何模型应用一个缩放仿射变换,使得模型在屏幕上显示的大小与真实大小不同。建筑师绘制的建筑蓝图可能有几百米高,而这些蓝图需要适应几英寸宽的笔记本电脑屏幕。在计算机的内存中存储着具有真实尺寸的几何模型,但为了在屏幕上绘制它,需要应用一个缩放:一个缩放仿射变换。

为了获得这种仿射变换的视觉直觉,让我们看一下图 7-8。给定一个点P,我们可以想象有一个从原点出发的向量Image,其尖端在P上。对点P应用缩放S[x]和S[y],将其转换为点P^′,其向量Image的水平投影为S[x] ⋅ v[x],垂直投影为S[y] ⋅ v[y]。正如你所看到的,缩放是衡量点相对于其原始距离与原点之间的远近。事实上,纯粹的缩放变换并不会移动原点。绝对值小于 1 的缩放会将点拉近原点,而大于 1 的缩放则会把点推远原点。

Image

图 7-8:一个尺度仿射变换

这是有用的,但通常我们希望相对于原点以外的某个点应用缩放。例如,假设你正在使用 AutoCAD 并想放大图纸。如果缩放是相对于原点(假设它位于应用窗口的左下角)而不是围绕屏幕的中心或鼠标位置进行的,那么你会觉得图纸被移远了,如图 7-9 左图所示。

Image

图 7-9:围绕原点缩放(左)与围绕屏幕中心缩放(右)

你可能更习惯使用一个在屏幕中间某个点或甚至鼠标位置周围缩放绘图的功能,这种功能实际上在大多数情况下都会发生。许多图形设计程序都像这样工作,这样对用户来说更方便,但按照我们定义的纯缩放变换,它只能相对于原点发生。那么,如何在任意点周围进行缩放呢?好吧,现在我们知道了如何构造复合变换,实际上,获得这个变换就是轻而易举的事了。

注意

我花了相当长的时间才完全理解如何有效地使用仿射变换,以及如何从简单的变换中创建复合变换。我在尝试在我的软件 InkStructure 中实现一个正确的“放大”选项时遇到了很大的困难,这也是为什么原始版本在尝试放大绘图时感觉有些 bug,当时图形会随机移动到屏幕的其他位置。所以,当我说“轻而易举”时,我应该加上一些说明:只有在理解了仿射变换之后,它才变得简单。

让我们快速说明我们想解决的问题:我们想找到一个仿射变换,它相对于中心点 C 应用缩放 S[x] 和 S[y]。定义 O 为坐标系的原点,我们可以通过组合以下更简单的变换来构建这样的变换:

  1. [T[1]]: 移动 C 使其与原点 O 重合 (Image = Image = ⟨–C[x], –C[y]⟩)。

  2. [T[2]]: 使用缩放因子 S[x] 和 S[y] 进行缩放。

  3. [T[3]]: 将 C 移动回原来的位置 (Image = Image = ⟨C[x], C[y]⟩)。

由于缩放只能相对于原点应用,我们将整个空间移动,使得我们的点 C 恰好位于原点,然后应用缩放并将其移动回原来的位置。漂亮吧?因此,[T[r]] 可以通过方程 7.7 计算得出。

Image

让我们创建一个工厂函数来生成这些类型的变换。首先,创建一个名为 affine_transforms.py 的新文件;在其中输入列表 7-12 中的函数。

from geom2d.affine_transf import AffineTransform
from geom2d.point import Point

def make_scale(sx: float, sy: float , center=Point(0, 0)):
    return AffineTransform(
        sx=sx,
        sy=sy,
        tx=center.x * (1.0 - sx),
        ty=center.y * (1.0 - sy)
    )

列表 7-12:创建一个缩放变换

最好添加一些测试用例,检查该函数的行为。为了简洁起见,我会把这个留给你作为练习。

旋转

类似于缩放,旋转总是围绕原点进行。就像之前一样,通过使用巧妙的变换序列,我们可以围绕任何我们想要的点进行旋转。你可能在 Sketch、Illustrator 或类似应用程序中旋转过图形,在这种情况下,你已经习惯选择旋转中心,一个围绕它旋转的点,你可以使用方框控制柄,类似于图 7-10。

Image

图 7-10:围绕中心的旋转

旋转中心点可以移动,从而使旋转围绕不同的点进行。例如,将旋转中心移到边界框的左下角,旋转可能会像图 7-11 那样。

图片

图 7-11:围绕角落的旋转

让我们从学习如何围绕原点构建旋转仿射变换开始;这将作为构建围绕任何点旋转的更复杂变换的基础。方程 7.8 展示了如何围绕原点旋转点 θ 弧度。

图片

记住这一点后,让我们找到一个仿射变换,使得点围绕中心点 C 旋转 θ 弧度。以 O 作为坐标系的原点,变换是以下内容的组合:

  1. [T[1]]:将 C 平移到原点 O,使得旋转中心为 C (图片 = 图片 = ⟨–C[x], –C[y]⟩)。

  2. [T[2]]:旋转 θ 弧度。

  3. [T[3]]:将 C 平移回它原来的位置 (图片 = 图片 = ⟨C[x], C[y]⟩)。

这与之前的算法相同,但这次我们使用旋转而不是缩放。[T[r]]现在的计算方式如下:

图片

这得到了方程 7.9 中的仿射变换。

图片

让我们创建一个新的工厂函数,用于围绕中心点生成旋转。在affine_transforms.py中,在方程 7.9 的帮助下,实现新的函数,清单 7-13 中可以找到。

import math

from geom2d.affine_transf import AffineTransform
from geom2d.point import Point

--snip--

def make_rotation(radians: float, center=Point(0, 0)):
    cos = math.cos(radians)
    sin = math.sin(radians)
    one_minus_cos = 1.0 - cos

    return AffineTransform(
        sx=cos,
        sy=cos,
        tx=center.x * one_minus_cos + center.y * sin,
        ty=center.y * one_minus_cos - center.x * sin,
        shx=-sin,
        shy=sin
    )

清单 7-13:创建旋转变换

再次提醒,您需要至少编写一个单元测试,确保我们的实现没有漏洞。

让我们在 shell 中尝试一下:创建两个旋转,分别是π/4 弧度,一个围绕原点,另一个围绕点 (10, 10)。然后,我们将这两个旋转应用到点 (15, 15),看看它们在两种情况下的结果。重新加载 Python shell 并写入以下内容:

>>> from geom2d.affine_transforms import make_rotation
>>> from geom2d.point import Point
>>> import math
>>> point = Point(15, 15)

现在让我们尝试围绕原点进行旋转:

>>> rot_origin = make_rotation(math.pi / 4)
>>> str(rot_origin.apply_to_point(point))
'(1.7763568394002505e-15, 21.213203435596427)'

结果点的 x 坐标基本为零(注意指数 e-15),y 坐标为 21.2132...,这就是从原点到原始点的向量长度 图片

让我们尝试第二次旋转:

>>> rot_other = make_rotation(math.pi / 4, Point(10, 10))
>>> str(rot_other.apply_to_point(point))
'(10.000000000000002, 17.071067811865476)'

这次结果点是 (10, 17.071...)。为了帮助我们理解刚刚做的练习,图 7-12 展示了这两次旋转变换。

图片

图 7-12:围绕原点旋转(左)与围绕点 (10, 10) 旋转(右)的示例

插值变换

当你进行放大或缩小时,大多数图形程序不会一次性应用缩放,而是通常通过一个快速平滑的动画来展示缩放过程。这可以帮助用户更好地理解图形是如何被转换的。为了实现这一点,图形程序通常使用变换插值。在本书的后续部分,我们将为仿射变换制作动画,也就是说,我们将创建一种电影,通过每一帧逐步展示给定几何图形如何变化。每一帧动画将展示在应用部分仿射变换后的几何图形,这也是我们第一次使用插值的地方。

激励插值

在深入讨论插值变换的概念之前,先看一下图 7-13。

Image

图 7-13:动画中的仿射变换

在图中,最初位于窗口左下角的三角形经过一些位置(以较浅的灰色绘制)后,最终到达窗口的顶部中间。每个三角形表示我们在某一时刻看到的结果,是动画中的一个具体帧。

如果我们希望动画有n帧,其中n > 1,那么需要有n个仿射变换[T[0]],[T[1]],...,[T[n–1]],使得每一帧都是将相应变换应用到输入几何体后的结果。显然,最后一个变换[T[n–1]]必须是目标仿射变换,因为最后一帧应展示应用该变换后的几何图形。那么[T[0]]应该是什么呢?让我们思考一下。什么变换应用到输入几何体后能得到原本的几何体呢?嗯,只有一个这样的变换,它不会移动任何东西,那就是恒等变换。所以,我们的起始变换和结束变换如下所示:

Image

我们如何计算[T[1]],...,[T[n–2]]呢?其实很简单:我们可以通过插值法将每个起始值和结束值进行插值,从而得到所需的中间值。例如,从 0 到 5 进行线性插值,使用五个步骤会得到[0, 1, 2, 3, 4, 5]。注意,五个步骤产生六个值,所以要得到n帧,我们需要使用n–1步骤。

要从起始值v[s]插值到结束值v[e],我们可以使用任何一个通过这两个值的函数。直线(线性函数)是最简单的,得到的值是均匀间隔的。这就是线性插值。如果我们使用这种插值来生成动画的帧,结果将以恒定的速度从开始到结束移动(插值函数的斜率是恒定的),这在人眼中看起来不自然。为什么会这样呢?因为我们不习惯看到现实生活中的事物突然加速、以相同速度运动并突然停止。虽然这对于投射物或子弹可能没问题,但对于大多数现实生活中的运动物体来说就显得很奇怪。我们可以尝试一种看起来更自然的插值函数,比如在图 7-14 右侧图形中绘制的ease-in-out插值。

Image

图 7-14:两种插值函数

在一个 ease-in-out 函数中,开始和结束时的值变化较慢,这给人一种事物开始运动时加速,达到运动终点时缓慢减速的感觉。这个函数以更自然的方式定义了运动,跟随这种位置随时间变化的动画在人眼中看起来很美观。

要获得介于v[s]和v[e]之间的值,我们使用一个参数t,使得 0 ≤ t ≤ 1(见方程 7.10)。

Image

你可以很容易地观察到,方程 7.10 在t = 0 时给出v[s]的结果,在t = 1 时给出v[e]的结果。对于任何介于这两个值之间的t值,结果都会在这两个值之间变化。如果我们想得到一个从v[s]到v[e]的值序列,并且该序列呈线性分布,我们只需为t使用均匀间隔的值,例如[0, 0.25, 0.5, 0.75, 1]。

要生成 ease-in-out 分布的插值值,我们需要一个从 0 到 1 的不均匀间隔的t值序列,极值附近步长较小,中央附近步长较大。如果我们将t值表示为在水平线上从t = 0 到t = 1 的圆点,我们可以通过图 7-15 来直观了解均匀和 ease-in-out 值是如何分布的。

Image

图 7-15:插值 t 值

要构建一个按图 7-14 右侧图形分布的t值序列,我们可以将一系列均匀间隔的t值代入方程 7.11。

Image

这改变了它们的间隔,使得更多的值位于极值 0 和 1 附近,较少的值位于中间。

我们已经准备好所需的所有要素,开始动手吧!

实现插值

geom2d 中创建一个名为 interpolation.py 的新文件,并输入 列表 7-14 中的代码。

def uniform_t_sequence(steps: int):
    return [t / steps for t in range(steps + 1)]

def ease_in_out_t_sequence(steps: int):
    return [ease_in_out_t(t) for t in uniform_t_sequence(steps)]

def ease_in_out_t(t: float):
    return t ** 2 / (t ** 2 + (1 - t) ** 2)

列表 7-14:插值的 t 值

从底部开始,我们有函数 ease_in_out_t,它仅仅是 方程 7.11 的实现。第一个函数使用给定的步数构建一个均匀分布的 t 值序列,从而生成与步数加一相同数量的值。我们可以在 shell 中测试这一点。重新加载并尝试以下操作:

>>> from geom2d.interpolation import uniform_t_sequence
>>> uniform_t_sequence(10)
[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]

另一方面,函数 ease_in_out_t_sequence 创建遵循 ease-in-out 分布的序列。为此,它将 方程 7.11 应用到均匀序列的值上。我们也可以在 shell 中尝试一下:

>>> ease_in_out_t_sequence(10)
[0.0, 0.012195121951219514, 0.058823529411764705,
0.15517241379310345, 0.30769230769230776, 0.5,
0.6923076923076923, 0.8448275862068965,
0.9411764705882353, 0.9878048780487805, 1.0]

看看接近 0 和 1 的值是否更接近,而中间的值(接近 0.5)则相隔更远?太好了,现在我们只缺一个函数,用于给定 t 值时在两个值之间进行插值,就像 方程 7.10 定义的那样。在 interpolation.py 中添加 列表 7-15。

import geom2d.tparam as tparam

--snip--

def interpolate(vs: float, ve: float, t: float):
    tparam.ensure_valid(t)
    return vs + t * (ve - vs)

列表 7-15:给定 t 值时的插值

如果你还记得 第五章,当我们使用传入的 t 参数值进行操作时,我们需要检查它是否在预期的范围内,确保有效函数(ensure_valid)用于此。现在我们已准备好最后一步,希望你跟上了,因为这就是我们一直追求的仿射变换插值。打开你的文件 affine_transforms.py,在其中我们定义了创建几种特殊类型仿射变换的工厂函数,然后输入 列表 7-16 中的代码。

import math

from geom2d.affine_transf import AffineTransform
from geom2d.interpolation import ease_in_out_t_sequence, interpolate
from geom2d.point import Point

--snip--

def ease_in_out_interpolation(start, end, steps):
 ➊ t_seq = ease_in_out_t_sequence(steps)
 ➋ return [__interpolated(start, end, t) for t in t_seq]

def __interpolated(s: AffineTransform, e: AffineTransform, t):
 ➌ return AffineTransform(
        sx=interpolate(s.sx, e.sx, t),
        sy=interpolate(s.sy, e.sy, t),
        tx=interpolate(s.tx, e.tx, t),
        ty=interpolate(s.ty, e.ty, t),
        shx=interpolate(s.shx, e.shx, t),
        shy=interpolate(s.shy, e.shy, t)
    )

列表 7-16:插值的仿射变换序列

为了帮助生成插值的仿射变换序列,我们定义了一个私有函数 __interpolated,它接受两个变换和一个 t 值作为输入,返回该 t 值的插值 ➌。新变换的每个值都是对起始变换和结束变换的值进行插值的结果。然后,我们构建一个遵循 ease-in-out 分布的 t 值序列 ➊,每个 t 值都通过列表推导映射到相应的插值变换 ➋。

我们先将这个部分留到 第十二章,到时我们将使用插值的仿射变换序列来制作动画。如果这一章最后部分探讨的概念看起来有些抽象,不要担心。我们将在本书的下一部分构建动画运动的基础,到那时,这个插值的概念可能会变得更容易理解。

Geom2D 最后润色

我们的 geom2d 包已经经过测试,准备在本书剩余部分使用。我们使它变得稳健,但在本书这一部分结束之前,我们可以添加一些小的改进。

测试文件

我们首先要做的是将实现文件和测试文件分开,当前它们都在同一个文件夹中。这样做是为了让 geom2d 包文件夹看起来不那么杂乱,方便你更容易找到实现文件。在包内创建一个名为 tests 的新文件夹,然后选择所有测试文件(我们便捷地将它们命名为以 _test.py 结尾),并将它们拖动到该文件夹中。你的文件夹结构和文件应如下所示:

Mechanics

|- geom2d

|    |- tests

|    |    |- affine_transf_test.py

|    |    |- affine_transforms_test.py

|    |    |- circle_test.py

|    |    |- ...

|    |    |- vector_test.py

|    |- init.py

|    |- affine_transf.py

|    |- affine_transforms.py

|    |- circle.py

|    |- ...

|    |- vectors.py

运行所有测试

现在,所有测试文件都在同一个文件夹中,那如何一次性运行所有测试用例呢?可能是你更改了部分代码,想确保没有破坏任何功能,于是你决定运行包中的每个测试。按照我们之前的方法,这需要花费一些时间,因为你需要逐个打开测试文件,并点击每个类名称旁边的绿色播放按钮。其实有更好的方法!

打开 PyCharm 内的终端视图。如果看不到,可以从菜单中选择 ViewTool WindowsTerminal。默认情况下,终端会在项目的根目录下打开,这正是我们所需要的。在终端中,运行以下命令:

$ python3 -m unittest discover -s geom2d/tests/ -p '*_test.py'

该命令告诉 Python 查找并运行所有位于 geom2d/tests/ 中的单元测试,文件名符合 _test.py 模式,即所有以 _test.py 结尾的文件。运行该命令后,结果应类似于以下内容:

Ran 58 tests in 0.004s

OK

你可以将此命令保存在项目根目录下的 bash 文件中,这样你就可以随时执行它,而无需记住它。

包导入

最后,我们要做的是将所有模块包含在包的导出中,这样它们就可以像这样被加载:

from geom2d import Point, Polygon

将其与以下内容进行比较:

from geom2d.point import Point
from geom2d.polygon import Polygon

后者要求用户输入每个模块在 geom2d 中的路径,而前者则不需要:包内的所有内容都可以直接从包本身导入。采用这种导出模块的方式有两个优点:(1)它允许我们在不破坏用户导入的情况下改变模块的目录结构,(2)用户不需要知道每个模块在包内的具体位置,所有内容都可以从包本身直接导入。如你所见,这大大减少了使用包时的认知负担。

当 PyCharm 创建 geom2d 包时,它会在其中包含一个名为 init.py 的空文件。你能找到它吗?包中的文件名为此的文件会在包被导入时自动加载。我们可以利用它们来导入包内定义的内容。

注意

如果由于某种原因,文件 init.py 在你的 geom2d 包中不存在,直接创建它即可。也许你在 PyCharm 中将包创建为普通目录,因此 IDE 没有为你自动添加它。

所以,打开文件,它应该是空的,并导入我们定义的所有基本元素(见列表 7-17)。

from .point import Point
from .vector import Vector
from .vectors import *
from .circle import Circle
from .circles import *
from .interpolation import *
from .line import Line
from .nums import *
from .open_interval import OpenInterval
from .polygon import Polygon
from .rect import Rect
from .rects import *
from .segment import Segment
from .size import Size
from .tparam import *
from .affine_transf import *
from .affine_transforms import *

列表 7-17:geom2d 包的初始化文件

就是这样!要理解我们通过这个更改实现了什么,你可以在 Python 的 shell 中(不是我们刚才用来运行命令的 shell)尝试以下操作:

>>> from geom2d import Point, Size, Rect
>>> origin = Point(2, 3)
>>> size = Size(10, 15)
>>> rect = Rect(origin, size)

这将在后续章节中非常方便,因为我们可以直接从包中导入任何geom2d模块。

总结

在本章中,我们探讨了计算机图形学中的一个核心概念:仿射变换。它们允许我们通过缩放、旋转、平移和剪切来变换几何图形。

我们首先回顾了它们的数学定义,以及它们与线性变换的区别。结论是,仿射变换可以移动原点,而线性变换不能。仿射变换可以表示为线性变换与平移的组合,但我们看到了一个更方便的表示方法:增广矩阵。接下来,我们在 AffineTransform 类中实现了方法,用于变换我们的几何原始元素:点、线段和多边形。

然后,我们学习了如何将变换组合起来,以通过简单的变换实现复杂的变换。凭借这个强大的思想,我们能够构造出在几乎所有图形应用程序中都会用到的两个基本仿射变换:缩放和绕原点以外的点旋转。

最后,我们实现了一个函数,用于在两个仿射变换之间进行插值,从而生成一些中间变换,这些变换将很快用于制作动画。

第三部分:图形与模拟**

第八章:绘制矢量图像

图像

我们即将开始绘制由数学方程式描述的图像,这是一个既有趣又富有娱乐性的主题。我们称由几何原始元素构成的图像为矢量图像,与位图图像相对,后者有时也被称为光栅图像。矢量图像非常适合绘制工程问题的结果,这些问题通常以图表和简化的几何形式呈现。

在这一章中,我们将创建自己的 Python 包,能够从我们在第二部分中创建的几何原始元素(如点、线段、圆、 多边形等)生成 SVG 图像。在后面的章节中,当我们使用代码解决实际的力学问题时,这个包将帮助我们生成图形结果。

市面上有许多优秀的 SVG 包(例如 svgwrite),我们可以直接导入它们,但本书的重点是通过实践学习,所以除了 Python 标准库和我们自己的代码,我们不会使用任何外部库。

为了简洁起见,我们在这一章中不会编写单元测试,但如果你下载代码,你会看到我已经写好了单元测试,以确保一切正常工作。我鼓励你尝试为本章中的函数编写自己的单元测试,然后将其与我提供的代码进行比较。

本章将介绍一个强大的概念:模板化。在模板化时,我们有一段文本,称为模板,它可以通过填充不同的占位符来定制。这项技术在 web 开发中广泛使用,用于生成在浏览器中渲染的 HTML 文档。在这里,确实有很多优秀的模板库(如 jinja2mako),但我们希望了解它们背后的工作原理,因此我们将自己编写模板逻辑,而不是使用现成的库。

位图和矢量图像

图像有两种类型:位图矢量图。你可能以前见过位图图像:.jpeg.gif.png 都是位图图像格式的例子。位图是一种在像素网格上定义的图像,每个像素都会被赋予一个特定的颜色。这些图像在原始尺寸下看起来很漂亮,但如果你放大,可能会开始看到那些小方格——像素。

另一方面,矢量图像通过数学方程式定义其内容。这具有平滑缩放而不失真质量的优势。让我们来探索 .svg,这是最广泛使用的矢量图像格式,也是我们在本书中将使用的格式。

SVG 格式

SVG 代表可缩放矢量图形。其规范由万维网联盟 (W3C) 开发,并且是一个开放标准。我建议你打开 https://developer.mozilla.org/en-US/docs/Web/SVG,将其作为参考,查看更完整的描述和示例,这些内容可以补充书中所述的内容。如果你需要向你的 SVG 包添加新内容,这个页面将是你的好帮手。

让我们快速参考一下上面提到的 Mozilla 网站中的定义,它优美地描述了这些图像是如何定义的:

SVG 图像及其相关行为是通过 XML 文本文件定义的,这意味着它们可以被搜索、索引、脚本化和压缩。此外,这也意味着它们可以使用任何文本编辑器和绘图软件创建和编辑。

请注意,SVG 图像是以纯文本格式定义的,而大多数其他图像格式是以二进制编码的。这意味着我们可以轻松地自动化创建 SVG 图像,甚至检查现有图像的内容。

注意

本章假设你已经对 XML 格式有基本了解,但如果你没有,也不用担心;它非常容易学习。可以查看以下资源开始学习: www.w3schools.com/xml www.xmlfiles.com/xml

让我们尝试创建第一个 SVG 图像。打开你喜欢的纯文本编辑器,如 Sublime Text、Visual Studio Code、Atom,或者如果你喜欢,也可以使用 PyCharm,然后编写 清单 8-1。

<svg  width="500" height="500">
    <circle cx="200" cy="200" r="100" fill="#ff000077" />
    <circle cx="300" cy="200" r="100" fill="#00ff0077" />
    <circle cx="250" cy="300" r="100" fill="#0000ff77" />
</svg>

清单 8-1:多个圆形的 SVG 描述

请注意,你不应使用富文本编辑器(如 Word)创建 SVG 文件。这些富文本编辑器会在原始文件中添加自己的标记,破坏 SVG 格式。

一旦你复制了 清单 8-1 中的内容,保存文件为 circles.svg,并使用 Chrome 或 Firefox 打开它。信不信由你,浏览器是一些最好的 SVG 图像查看器。通过使用它们的 开发者工具,我们可以检查组成图像的不同部分,这在稍后构建更复杂的图像时将非常有用。你应该看到类似 图 8-1 的图像(屏幕上会有颜色,但书籍的印刷版是灰度的)。放大图像,你会看到它如何保持清晰。

图片

图 8-1:SVG 圆形示例

让我们分析一下 清单 8-1 中的代码。第一行最为晦涩,包含了 XML 命名空间(xmln)属性。

 width="500" height="500"

我们必须在每个 svg 开始标签中包含这个命名空间定义。width 和 height 属性确定图像的像素大小。SVG 属性 是影响特定元素如何呈现的修饰符。例如,width 和 height 属性决定了绘图的大小。

然后,在 svg 开始和结束标签之间是实际的绘制定义,在这种情况下是三个圆形:

<circle cx="200" cy="200" r="100" fill="#ff000077" />
<circle cx="300" cy="200" r="100" fill="#00ff0077" />
<circle cx="250" cy="300" r="100" fill="#0000ff77" />

正如你可能已经猜到的,cx 和 cy 对应的是圆心的坐标;r 是圆的半径。属性 fill 确定圆形的填充颜色,采用十六进制格式:#rrggbbaa,其中 rr 是红色值,gg 是绿色值,bb 是蓝色值,aa 是 alpha 或透明度值(参见 图 8-2)。

图片

图 8-2:十六进制颜色组件

例如,颜色 #ff000077 具有以下组成部分:

红色    ff,最大值(十进制中的 255)

绿色    00,最小值(十进制中的 0)

蓝色    00,最小值(十进制中的 0)

透明度    77,255 中的 119,相当于约 47% 的透明度

这种颜色是纯正的红色,并加入了一些透明度。

你可能没有意识到,但 SVG 图像的坐标原点位于左上角,y 轴指向下方。你可能不习惯这种垂直轴的方向,但别担心:通过使用我们的仿射变换之一,我们可以轻松地将空间变换,使 y 轴指向上方,正如你将在本章后面看到的那样。图 8-3 展示了我们创建的图像的几何形状和坐标布局。

图片

图 8-3:我们第一个 SVG 图像的几何形状

viewBox

我们可以为 svg 标签定义的一个有用属性是 viewBox。viewBox 是用户看到的图像的矩形部分。它由四个数字定义,

viewBox="x y w h"

其中 x 和 y 是矩形原点的坐标,w 和 h 是矩形的宽度和高度。

让我们给圆形图像添加一个 viewBox 来查看其效果(参见 列表 8-2)。

<svg 
    width="500"
    height="500"
    viewBox="100 100 300 300">
    <circle cx="200" cy="200" r="100" fill="#ff000077" />
    <circle cx="300" cy="200" r="100" fill="#00ff0077" />
    <circle cx="250" cy="300" r="100" fill="#0000ff77" />
</svg>

列表 8-2:SVG viewBox

保存我们在 列表 8-2 中所做的更改,并在浏览器中重新加载图像以查看变化。要理解发生了什么,查看 图 8-4。

我们定义了一个矩形,其原点在 (100, 100),宽度为 300,高度为 300:一个包含所有三个圆形且没有任何边距的矩形。注意,图像保持其 500x500 像素的大小,这是由 width 和 height 属性定义的。如果 viewBox 的大小与 SVG 本身的大小不同,内容会被缩放。

图片

*图 8-4:SVG 图像的 viewBox

因此,viewBox 是从无限画布中显示给用户的矩形部分。它是可选的,默认为由宽度和高度定义的矩形,原点在 (0, 0)。

空间变换

还记得第七章中讲解的仿射变换概念吗?SVG 图像使用它们来变换内容。属性 transform 可以用来定义仿射变换矩阵,如下所示:

transform="matrix(sx shy shx sy tx ty)"

矩阵项的顺序看起来可能一开始有些令人困惑,但对于 SVG 标准的制定者来说,这其实是有意义的。SVG 文档定义了仿射变换矩阵,如下所示:

图片

这些是 transform 属性的术语:

transform="matrix(a b c d e f)"

用我们更易理解的语言来表达,这些术语是 a = s[x],b = sh[y],c = sh[x],d = s[y],e = t[x],f = t[y]:

图片

让我们看看它如何运作。我们将通过将 sh[x] 设置为 1 来应用 x 方向上的剪切变换。记住,s[x] 和 s[y] 必须都为 1;否则,如果设置为零,图像将会塌缩成一条线或一个点,我们将看不到任何东西。清单 8-3 中已经包含了 transform 属性。

<svg 
    width="500"
    height="500"
    transform="matrix(1 0 1 1 0 0)"
    <circle cx="200" cy="200" r="100" fill="#ff000077" />
    <circle cx="300" cy="200" r="100" fill="#00ff0077" />
    <circle cx="250" cy="300" r="100" fill="#0000ff77" />
</svg>

清单 8-3:圆形图像中的剪切变换

记得删除 viewBox 属性,以免结果几何形状被裁剪。你应该看到类似图 8-5 的内容。

图片

图 8-5:变换后的圆形

那么如何反转 y 轴,使其朝上,就像我们习惯的那样?很简单!编辑 transform 矩阵如下:

transform="matrix(1 0 0 -1 0 0)"

你应该看到的结果几何形状在图 8-6 中有所概述。将其与图 8-1 进行比较。发生了什么?图像进行了垂直翻转。

图片

图 8-6:变换后的圆形,y 轴反转

现在你已经基本理解了如何创建 SVG 图像,让我们开始做一些 Python 编程。我们将在项目中创建一个包来绘制 SVG 图像。

svg 包

让我们为项目创建一个新的图形包,它将包含一个用于生成 SVG 图像的子包。稍后在本书中,我们将为其他类型的图形操作添加更多子包。右键点击 项目工具 窗口中的项目名称,选择 新建Python 包。将其命名为 graphic。你也可以自己创建一个新文件夹,但别忘了添加 init.py 文件,告诉 Python 这是一个包。

你应该将包放置在与 geom2d 同一级别,并且只包含一个 init.py 文件。你的项目目录结构应该如下所示:

机械学

|- geom2d

|    |- tests

|- graphic

|- utils

现在让我们添加 svg 子包:右键点击刚刚创建的包,再次选择 新建Python 包,但这次将其命名为 svg。现在我们可以开始添加代码了。

模板

模板是一个包含占位符的文档。通过为这些占位符赋值,我们可以生成文档的完整版本。例如,想想那些通过你名字来问候你的邮件营销活动。发送这些邮件的公司可能有一个像这样的模板。

你好,{{name}}!

这里有一些我们认为你可能喜欢的书籍推荐。

...

并且通过一个自动化过程,将 {{name}} 占位符替换为每个客户的名字,然后发送最终生成的邮件。

模板中的占位符也可以称为 变量。在渲染模板的过程中,变量会被赋值,从而生成最终的文档,文档中包含了所有已定义的内容。图 8-7 演示了使用两组不同值渲染相同模板的过程。模板中有变量 place-from、place-to、distance 和 units,我们为这些变量赋不同的值,生成相同模板的不同版本。

图片

图 8-7:模板渲染过程

使用模板是一种强大的技术,解决了需要生成任何形状和格式的文本的各种问题。例如,大多数 web 框架使用某种形式的模板来生成渲染后的 HTML 文档。我们将使用模板来生成我们的 SVG 图像。

使用 Python 字符串替换的示例

让我们在代码中做一个模板示例。打开 Python 的 shell,并输入以下模板字符串:

>>> template = 'Hello, my name is {{name}}'

现在,让我们通过将 {{name}} 变量替换为真实名字来创建一个问候语:

>>> template.replace('{{name}}', 'Angel')
'Hello, my name is Angel'

如你所见,我们可以使用 Python 的 replace 字符串方法来创建一个新的字符串,其中 {{name}} 已被 'Angel' 替换。由于 replace 返回一个新的实例,我们可以像这样链式调用:

>>> template.replace('{{name}}', 'Angel').replace('Hello', 'Hi there')
'Hi there, my name is Angel'

在这个示例中,我们首先将 {{name}} 变量替换为字符串 'Angel'。然后,我们在结果字符串上调用 replace 方法,将单词 'Hello' 替换为 'Hi there'。

请注意,我们可以使用 replace 方法替换任何我们想要的字符序列;无需让我们的替换目标出现在大括号中,就像我们之前用 {{name}} 那样。使用双大括号是一种约定,用于帮助我们快速识别模板中的变量。这个约定也有助于防止不必要的替换:我们的模板中不太可能包含两个大括号之间的任何内容,除非是我们的变量。

现在我们已经了解了如何在 Python 中使用模板字符串,让我们看看如何在单独的文件中定义模板,并将其加载到代码中的字符串中。

加载模板

为了避免混合 XML 和 Python 代码,我们希望将 SVG 标签的定义分离到各自的文件中。包含 XML 的文件需要有占位符,以便插入实际数据。例如,我们的圆形定义文件可能如下所示:

<circle cx="{{cx}}" cy="{{cy}}" r="{{r}}" />

这里我们使用了双大括号来放置占位符。我们将使用代码将此定义加载到一个字符串中,并将占位符替换为圆心的实际坐标和半径。

我们将创建几个模板,因此让我们在svg包内创建一个名为templates的文件夹,通过右键单击包名并选择新建目录。我们需要一个函数,它可以根据名称读取此文件夹中的模板并将其内容作为字符串返回。在svg包中,但不在templates文件夹内,创建一个名为read.py的新文件,并添加列表 8-4 中的代码。

from os import path

import pkg_resources as res

def read_template(file_name: str):
    file_path = path.join('templates', file_name)
    bytes_str = res.resource_string(__name__, file_path)
    return bytes_str.decode('UTF-8')

列表 8-4:读取模板文件的内容

让我们分解一下列表 8-4。函数中首先要做的事情是获取templates文件夹内文件的路径。我们通过使用 os.path 模块的 join 函数来实现这一点。此函数通过连接作为参数传递的各部分并使用适合操作系统的分隔符来计算路径。例如,基于 Unix 的操作系统使用/字符。

然后,使用pkg_resources模块中的 resource_string,我们将文件作为字节字符串读取。文件以字节序列存储到磁盘中,因此当我们使用 resource_string 函数读取它时,得到的是字节字符串。为了将其转换为 Unicode 字符字符串,我们需要对其进行解码。为此,字节字符串具有 decode 方法,该方法接受编码作为参数。

我们返回使用 UTF-8 编码解码字节字符串的结果。这将给我们一个字符串版本的模板,便于操作。

图像模板

我们要定义的最重要的模板是 SVG 图像的模板。在templates文件夹中创建一个新的文本文件,命名为img(不带扩展名;我们不需要扩展名),并在其中包含列表 8-5 中的定义。

<svg  version="1.1"
     width="{{width}}"
     height="{{height}}"
     viewBox="{{viewBox}}"
     transform="matrix({{transf}})">
    {{content}}
</svg>

列表 8-5:SVG 图像模板

该模板包括五个占位符,需要用实际的图像值进行替换。我们可以尝试使用之前定义的 read_template 函数,在 Python 的 shell 中加载模板:

>>> from graphic.svg.read import read_template
>>> read_template('img')
'<svg  version="1.1"\n  width="{{width}}"...'

svg目录中(但在templates文件夹外)创建一个新的文件image.py,并定义一个函数,该函数读取文件并进行替换。在你的image.py文件中,输入列表 8-6 中的代码。

from geom2d import AffineTransform, Rect, Point, Size
from graphic.svg.read import read_template

def svg_content(
        size: Size,
        primitives: [str],
        viewbox_rect=None,
        transform=None
):
 ➊ viewbox_rect = viewbox_rect or __default_viewbox_rect(size)
 ➋ transform = transform or AffineTransform()
 ➌ template = read_template('img') return template \
        .replace('{{width}}', str(size.width)) \
        .replace('{{height}}', str(size.height)) \
     ➍ .replace('{{content}}', '\n\t'.join(primitives)) \
     ➎ .replace('{{viewBox}}', __viewbox_from_rect(viewbox_rect)) \
     ➏ .replace('{{transf}}', __transf_matrix_vals(transform))

列表 8-6:SVG 图像

svg_content 函数有四个参数;最后两个参数 viewbox_rect 和 transform 的默认值为 None。我们可以使用“或”操作符,这样当 viewbox_rect 不是 None 时,它保持其值,否则将由 __default_viewbox_rect ➊(我们接下来将编写此函数)创建一个默认实例。我们对 transform ➋做同样的处理,使用默认值构造一个仿射变换。

然后,使用我们在前一节准备的函数,我们加载存储在templates/img中的模板 ➌。

最后一个也是最重要的步骤是将加载的模板字符串中的所有占位符替换为我们传入的值。

注意

Python 中字符串的一个优点,与大多数编程语言类似,是它们是不可变的;你不能直接修改字符串中的某个字符。相反,你需要创建一个包含所需更改的新字符串。这就是 replace 字符串方法的工作原理:它将给定的字符序列替换为另一个序列,并返回一个新的字符串作为结果。得益于这个特性,我们可以漂亮地链式调用多个 replace 来处理对 read_template* 方法的结果。*

{{width}} 和 {{height}} 占位符的替换非常直接;只需记住,传入的 size.width 和 size.height 属性是数字,因此我们需要使用 str 将它们转换为字符串表示。

primitives 参数包含了一系列字符串,这些字符串代表了图像的内容。我们需要将这些字符串收集到一个单独的字符串中。join 字符串方法将列表中的所有元素连接成一个单一的字符串,并以它被调用时的字符串作为分隔符。为了获取包含所有 primitives 的字符串,我们将使用 join ➍ 方法对列表进行操作,并以换行符和制表符(\n\t)作为分隔符。

对于 viewBox,我们需要将 Rect 实例转换为定义它的四个数字 ➎;这通过 __viewbox_from_rect 实现,我们稍后将定义它。transf ➏ 的处理方式也是如此。

让我们在 svg_content 后编写缺失的辅助函数。代码见 列表 8-7。

--snip--

def __default_viewbox_rect(size: Size):
    return Rect(Point(0, 0), size)

def __viewbox_from_rect(rect: Rect):
    x = rect.origin.x
    y = rect.origin.y
    width = rect.size.width
    height = rect.size.height

    return f'{x} {y} {width} {height}'

def __transf_matrix_vals(t: AffineTransform):
    return f'{t.sx} {t.shy} {t.shx} {t.sy} {t.tx} {t.ty}'

列表 8-7:SVG 图像辅助函数

第一个函数 (__default_viewbox_rect) 使用点 (0, 0) 作为原点和提供的尺寸来创建一个矩形用于 viewBox。正如它的名字所示,这个函数用于提供 viewbox _rect 参数的默认值,以防用户没有提供。

__viewbox_from_rect 函数返回一个格式化字符串,可以用作 SVG 定义中的 viewBox。最后一个函数 __transf_matrix_vals 做的事情类似:它将一个仿射变换转换为 SVG 期望的字符串格式。

很棒!我们现在有了一个函数,可以将 SVG 模板渲染为一个字符串。接下来,让我们看一下我们将添加到几乎所有图形元素中的一些属性。

属性

可以使用 属性 来修改 SVG 元素的外观。SVG 属性是按照 XML 属性语法定义的(别忘了 SVG 图像是按照 XML 格式定义的):

name="value"

例如,我们可以使用 stroke 属性来设置图形的描边颜色:

<circle cx="10" cy="15" r="40" stroke="green" />

请注意,在前面的例子中,圆形的中心坐标(cx 和 cy)以及半径(r)也作为属性定义在圆形的 SVG 元素中。

正如我们将看到的,许多 SVG 几何基本图形都有共享的属性,用于定义诸如笔触颜色、笔触宽度、填充颜色等。为了重用这部分逻辑,我们将其放在一个所有基本图形生成函数都会使用的文件中。由于这些属性定义较短,我们不会将它们包括在需要加载的模板中;相反,我们将在替换占位符的函数内部定义它们。

svg 目录中创建一个名为 attributes.py 的新文件。你的 graphic/svg 文件夹应该如下所示:

svg

|- templates

|    |- img

|- init.py

|- attributes.py

|- image.py

|- read.py

输入 列表 8-8 中的函数。

from geom2d.affine_transf import AffineTransform

def stroke_color(color: str):
    return f'stroke="{color}"'

def stroke_width(width: float):
    return f'stroke-width="{str(width)}"'

def fill_color(color: str):
    return f'fill="{color}"'

def fill_opacity(opacity: float):
    return f'fill-opacity="{str(opacity)}"'

def affine_transform(t: AffineTransform):
    values = f'{t.sx} {t.shy} {t.shx} {t.sy} {t.tx} {t.ty}'
    return f'transform="matrix({values})"'

def font_size(size: float):
    return f'font-size="{size}px"'

def font_family(font: str):
    return f'font-family="{font}"'

def attrs_to_str(attrs_list: [str]):
    return ' '.join(attrs_list)

列表 8-8:SVG 属性

所有函数都非常简单;它们接收一个值并返回一个包含 SVG 属性定义的字符串。我们使用单引号括起来返回的字符串,这样就可以在字符串中使用双引号,而无需转义它们。SVG 属性使用双引号定义,例如 stroke="blue"。

最后的函数接受一些属性,并将它们通过空格分隔连接成一个字符串。我们通过使用单个空格(’ ’)作为 join 函数的分隔符来实现这一点。为了完全理解这个如何工作,可以在 shell 中试一试:

>>> words = ['svg', 'is', 'a', 'nice', 'format']
>>> ' '.join(words)
'svg is a nice format'

SVG 基本图形

我们已经编写了 svg 包的基础;现在我们可以生成空白图像,这一过程涉及读取 img 模板并替换其变量。如果我们在 Python 的 Shell 中调用 svg_content 函数,

>>> from graphic.svg.image import svg_content
>>> from geom2d import Size
>>> svg_content(Size(200, 150), [])

我们将得到以下 SVG 内容:

<svg  version="1.1"
     width="200"
     height="150"
     viewBox="0 0 200 150"
     transform="matrix(1 0 0 1 0 0)">
</svg>

这是一个很好的开始,但谁希望有空白的图像呢?

在接下来的章节中,我们将创建一些基本的 SVG 基本图形,以便在 标签之间添加:线条、矩形、圆形、多边形和文本标签,等等。正如我们在整本书中将看到的那样,我们并不需要很多基本图形来绘制我们的工程图;仅凭直线、圆形和矩形就能做得相当好。

我们生成这些 SVG 基本图形的策略与生成 SVG 内容时使用的策略相同:我们将使用一个模板来定义带有变量的 SVG 代码,并在函数中替换这些变量。

线条

我们将在 svg 包中实现的第一个基本图形是线段,或者用 SVG 术语来说是直线。这可能有点不太合适,因为线段和直线是不同的概念。(回想一下,直线是无限的,而线段则不是;它们有有限的长度。)无论如何,我们在这里使用 SVG 术语,所以我们将在 templates 文件夹中创建一个名为 line 的新模板文件,并在其中添加 列表 8-9 中的代码:

<line x1="{{x1}}" y1="{{y1}}" x2="{{x2}}" y2="{{y2}}" {{attrs}}/>

列表 8-9:线条模板

线条的模板很简单。占位符定义了以下内容:

  • x1 和 y1,起点的坐标

  • x2 和 y2,终点的坐标

  • attrs,属性将被插入的位置

图 8-8 展示了使用 SVG 图像的默认坐标系统的线条及其属性。

图片

图 8-8:SVG 线条的例子

现在我们来创建一个函数,它读取模板并插入一个片段的值。我们需要一个新文件;让我们在svg文件夹中创建它,命名为primitives.py。输入清单 8-10 中的函数。

from geom2d import Segment
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')

def segment(seg: Segment, attributes=()):
    return __segment_template \
        .replace('{{x1}}', str(seg.start.x)) \
        .replace('{{y1}}', str(seg.start.y)) \
        .replace('{{x2}}', str(seg.end.x)) \
        .replace('{{y2}}', str(seg.end.y)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

清单 8-10:SVG 线

需要注意的一点是,参数属性的默认值是(),也就是一个空元组。我们也可以使用一个空列表[]作为参数的默认值,但这两者之间有一个重要的区别:元组是不可变的,而列表是可变的。函数的默认参数只会在文件加载到解释器时评估一次,因此,如果一个可变的默认参数被修改,那么对同一函数的所有后续调用都会得到修改后的默认值,而这正是我们想避免的。

在 shell 中,尝试以下代码来创建一个 SVG 线条,以查看结果并确保所有占位符都已正确替换。

>>> from geom2d import Segment, make_point
>>> from graphic import svg
>>> seg = Segment(make_point(1, 4), make_point(2, 5))
>>> attrs = [svg.attributes.stroke_color('#cacaca')]
>>> svg.primitives.segment(seg, attrs)
'<line x1="1" y1="4" x2="2" y2="5" stroke="#cacaca"/>'

这行位于 SVG 文件中的代码将像图 8-9 那样绘制。

图片

图 8-9:SVG 线

请记住,图中为了清晰起见添加了箭头和位置说明,但它们不会出现在图片本身中。

矩形

我们的下一个原语是矩形,因此在templates中创建一个名为rect的新文件(记住,我们的模板文件没有使用扩展名),文件内容如清单 8-11 所示:

<rect x="{{x}}" y="{{y}}"
      width="{{width}}" height="{{height}}"
      {{attrs}}/>

清单 8-11:矩形模板

你可以将模板写成一行;我们这里使用了多行,因为在印刷版中,代码无法放在一行内。定义矩形的属性是,按照预期,原点的坐标 x 和 y 以及由宽度和高度给定的大小。在primitives.py中,添加清单 8-12 中的函数。

from geom2d import Rect, Segment
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')

--snip--

def rectangle(rect: Rect, attributes=()):
    return __rect_template \
        .replace('{{x}}', str(rect.origin.x)) \
        .replace('{{y}}', str(rect.origin.y)) \
        .replace('{{width}}', str(rect.size.width)) \
        .replace('{{height}}', str(rect.size.height)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

清单 8-12:SVG 矩形

为了更好地理解定义 SVG 格式矩形的属性,可以查看图 8-10。该图使用了 SVG 的默认坐标系统:y 轴向下。这也是为什么矩形的原点是左上角。如果我们使用的是 y 轴向上的坐标系统,原点将是左下角。

图片

图 8-10:一个 SVG 矩形的例子

在 shell 中试试,就像我们对片段做的那样,检查所有占位符是否已正确替换:

>>> from geom2d import Rect, Point, Size
>>> from graphic.svg.primitives import rectangle
>>> r = Rect(Point(3, 4), Size(10, 20))
>>> rectangle(r)
'<rect x="3" y="4" width="10" height="20" />'

确保一切按预期工作是个好主意,因为在本书后续章节中,我们将使用这些简单的基本图形创建许多图表。单元测试是最佳选择,比在命令行中手动测试要好得多。如果你下载了本书的代码,你会发现所有这些基本图形渲染函数都已覆盖了测试。尝试自己编写测试,这样你可以习惯编写单元测试,然后将它们与我提供的测试进行对比。

圆形

我们将采用类似于矩形的方式来创建圆形。创建一个名为circle的模板文件(请参见清单 8-13)。

<circle cx="{{cx}}" cy="{{cy}}" r="{{r}}" {{attrs}}/>

清单 8-13: 圆形模板

然后将渲染圆形的函数添加到primitives.py中(请参见清单 8-14)。

from geom2d import Circle, Rect, Segment
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')

--snip--

def circle(circ: Circle, attributes=()):
    return __circle_template \
        .replace('{{cx}}', str(circ.center.x)) \
        .replace('{{cy}}', str(circ.center.y)) \
        .replace('{{r}}', str(circ.radius)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

清单 8-14: SVG 圆形

这里没有什么意外的!你可以查看图 8-11,查看我们用来定义 SVG 格式中圆形的属性。

图片

图 8-11: SVG 圆形示例

让我们在命令行中试试:

>>> from geom2d import Circle, Point
>>> from graphic.svg.primitives import circle
>>> c = Circle(Point(3, 4), 10)
>>> circle(c)
'<circle cx="3" cy="4" r="10" />'

多边形

多边形很容易定义;我们只需要提供按特定方式格式化的顶点坐标列表。创建一个名为polygon的模板文件,放在templates中(请参见清单 8-15)。

<polygon points="{{points}}" {{attrs}}/>

清单 8-15: 多边形模板

然后在primitives.py中包含清单 8-16 中的函数。

from geom2d import Circle, Rect, Segment, Polygon
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')
__polygon_template = read_template('polygon')

--snip--

def polygon(pol: Polygon, attributes=()):
    return __polygon_template \
        .replace('{{points}}', __format_points(pol.vertices)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

清单 8-16: SVG 多边形

占位符{{points}}将被应用 __format_points 函数处理顶点列表后的结果替换。让我们在primitives.py文件中编写这个函数(请参见清单 8-16):

--snip--

def __format_points(points: [Point]):
    return ' '.join([f'{p.x},{p.y}' for p in points])

清单 8-17: 格式化点

如你所见,顶点列表被转换为一个字符串,其中每个顶点通过空格分隔,

' '.join(...)

两个坐标,x 和 y,通过逗号分隔:

[f'\{p.x\},\{p.y\}' for p in points]

例如,一个顶点为 (1, 2),(5, 6) 和 (8, 9) 的多边形将会得到如下结果:

<polygon points="1, 2 5, 6 8, 9" />

多边形线

多边形线的定义与多边形相同,唯一的区别是最后一个顶点未与第一个顶点连接。创建一个名为polyline的模板文件,放在templates中(请参见清单 8-18)。

<polyline points="{{points}}" {{attrs}}/>

清单 8-18: 多边形线模板

将渲染函数包含在文件primitives.py中(请参见清单 8-19)。

from geom2d import Circle, Rect, Segment, Polygon
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')
__polygon_template = read_template('polygon')
__polyline_template = read_template('polyline')

--snip--

def polyline(points: [Point], attributes=()):
    return __polyline_template \
        .replace('{{points}}', __format_points(points)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

清单 8-19: SVG 多边形线

再次,这里没有什么惊讶的。图 8-12 展示了多边形和多边形线之间的区别。它们的定义是相同的,唯一的区别是最后一个连接顶点 (x[4], y[4]) 和 (x[1], y[1]) 的段,只有在多边形中才会出现。

图片

图 8-12: 一个 SVG 多边形和多边形线

让我们在命令行中试试多边形和多边形线,看看结果:

>>> from geom2d import Polygon, Point
>>> from graphic.svg.primitives import polygon, polyline
>>> points = [Point(1, 2), Point(3, 4), Point(5, 6)]

>>> polygon(Polygon(points))
'<polygon points="1, 2 3, 4 5, 6" />'

>>> polyline(points)
'<polyline points="1,2 3,4 5,6" />'

多边形和折线有相同的点序列,但在 SVG 图像中,多边形将连接第一个和最后一个顶点,而折线将保持开放。

文本

我们的图表将包含标题(就像第十八章中的结构分析结果图表),因此我们需要能够在图像中包含文本。创建一个新的模板文件,命名为text,并将其保存在templates文件夹中,代码见列表 8-20。

<text x="{{x}}" y="{{y}}" dx="{{dx}}" dy="{{dy}}" {{attrs}}>
    {{text}}
</text>

列表 8-20:文本模板

占位符{{text}}必须位于打开和关闭标签之间;这里将插入实际的文本。属性 x 和 y 定义了文本的位置;然后 dx 和 dy 用于偏移该原始位置。我们会发现这种偏移在某些情况下非常有用,例如当我们想在文本旁边添加一个点的坐标时。我们可以选择点本身的位置作为基准位置,然后偏移一定的量,这样文本和点的绘制就不会重叠。

primitives.py中添加列表 8-21 中显示的函数来渲染文本:

from geom2d import Circle, Rect, Segment, Polygon, Vector
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')
__polygon_template = read_template('polygon')
__polyline_template = read_template('polyline')
__text_template = read_template('text')

--snip--

def text(txt: str, pos: Point, disp: Vector, attrs_list=()):
    return __text_template \
        .replace('{{x}}', str(pos.x)) \
        .replace('{{y}}', str(pos.y)) \
        .replace('{{dx}}', str(disp.u)) \
        .replace('{{dy}}', str(disp.v)) \
        .replace('{{text}}', txt) \
        .replace('{{attrs}}', attrs_to_str(attrs_list))

列表 8-21:SVG 文本

让我们在 Shell 中试试:

>>> from geom2d import Point, Vector
>>> from graphic.svg.primitives import text
>>> text('Hello, SVG', Point(10, 15), Vector(5, 6))
'<text x="10" y="15" dx="5" dy="6" >\n    Hello, SVG\n</text>'

如果我们格式化结果字符串,结果如下所示:

<text x="10" y="15" dx="5" dy="6" >
    Hello, SVG
</text>

分组

我们通常希望将一组元素分组,以便可以为它们所有的元素添加一个共同的属性,比如仿射变换或填充颜色。这就是分组的用途。它们本身没有什么可渲染的内容,但它们以整洁的方式将一组原始元素分组在一起。在templates文件夹中创建一个group文件(见列表 8-22)。

<g {{attrs}}>
    {{content}}
</g>

列表 8-22:分组模板

为了渲染该分组,我们将在primitives.py文件中添加列表 8-23 中显示的函数。

from geom2d import Circle, Rect, Segment, Polygon, Vector
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')
__polygon_template = read_template('polygon')
__polyline_template = read_template('polyline')
__text_template = read_template('text')
__group_template = read_template('group')

--snip--

def group(primitives: [str], attributes=()):
    return __group_template \
        .replace('{{content}}', '\n'.join(primitives)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

列表 8-23:SVG 分组

这一次,所有作为序列传递的原始元素将被连接成一个由换行符(\n)分隔的字符串。这样做是为了让每个原始元素都被插入到新的一行中,这将使文件更易于阅读。

箭头

在本节中,我们将添加一个不同的原始元素,它不是通过加载和渲染模板来构建的,而是通过使用其他原始元素来创建:箭头。在第十八章中,当我们绘制结构图时,我们将使用箭头来表示力,因此现在正是实现箭头的好时机。

箭头由一条线段和一个位于其一端的小三角形构成,即箭头的头部(见图 8-13)。

图片

图 8-13:SVG 箭头

绘制箭头的线段非常简单:我们只需要一个线段。绘制箭头头部稍微复杂一些,因为它需要始终与线段对齐。通过一点基本几何知识,我们可以找出定义箭头头部的点。请查看图 8-14。

图片

图 8-14:箭头中的关键点

我们的箭头头部是由点C[1]、E(线段的终点)和C[2]定义的一个三角形。箭头的大小由长度和高度决定,我们将使用这些尺寸来定位C[1]和C[2]点。

该图使用三个向量来定位这两点。

Image    这是一个与线段方向向量相反方向的向量,长度与箭头相同。

Image    这是一个垂直于线段的向量,长度是箭头头部高度的一半。

Image    这与Image类似,但方向相反。

使用这些向量,我们现在可以按如下方式计算这些点:

Image

Image

毫不拖延,让我们编写绘制箭头的代码。在primitives.py中,输入列表 8-24 中的代码。

--snip--

def arrow(
        _segment: Segment,
        length: float,
        height: float,
        attributes=()
):
    director = _segment.direction_vector
 ➊ v_l = director.opposite().with_length(length)
 ➋ v_h1 = director.perpendicular().with_length(height / 2.0)
 ➌ v_h2 = v_h1.opposite()

    return group(
        [

         ➍ segment(_segment),
         ➎ polyline([
                _segment.end.displaced(v_l + v_h1),
                _segment.end,
                _segment.end.displaced(v_l + v_h2)
            ])
        ],
        attributes
    )

列表 8-24:SVG 箭头

我们定义了箭头函数,该函数接受线段、箭头的长度和高度以及 SVG 属性作为参数。请注意,_segment 参数以一个下划线开头,这是为了避免与文件中的 segment 函数发生冲突。

在这个函数中,我们首先将线段的方向向量存储在变量 director 中。然后,我们通过将 director 的相反向量按传入的长度➊进行缩放来计算Image向量。Image向量通过将 director 的垂直向量按箭头的半高度➋进行缩放得到。然后,Image就是它的相反向量➌。

该函数返回一个 SVG 组,包含箭头的线段➍和一个折线➎。这条折线使用我们之前讨论的三个点来定义箭头的头部。

第一个点C[1]是通过将线段的终点平移到向量ImageImage相加的结果来计算的。接下来是线段的终点。最后是C[2],它是通过将线段的终点平移到一个向量,该向量是将ImageImage相加的结果。

原始结果

我们向我们的primitives.py文件中添加了一些函数。如果你跟着做,你的文件应该和列表 8-25 类似。

from geom2d import Circle, Rect, Segment, Point, Polygon, Vector
from graphic.svg.attributes import attrs_to_str
from graphic.svg.read import read_template

__segment_template = read_template('line')
__rect_template = read_template('rect')
__circle_template = read_template('circle')
__polygon_template = read_template('polygon')
__polyline_template = read_template('polyline')
__text_template = read_template('text')
__group_template = read_template('group')

def segment(seg: Segment, attributes=()):
    return __segment_template \
        .replace('{{x1}}', str(seg.start.x)) \
        .replace('{{y1}}', str(seg.start.y)) \
        .replace('{{x2}}', str(seg.end.x)) \
        .replace('{{y2}}', str(seg.end.y)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def rectangle(rect: Rect, attributes=()):
    return __rect_template \
        .replace('{{x}}', str(rect.origin.x)) \
        .replace('{{y}}', str(rect.origin.y)) \
        .replace('{{width}}', str(rect.size.width)) \
        .replace('{{height}}', str(rect.size.height)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def circle(circ: Circle, attributes=()):
    return __circle_template \
        .replace('{{cx}}', str(circ.center.x)) \
        .replace('{{cy}}', str(circ.center.y)) \
        .replace('{{r}}', str(circ.radius)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def polygon(pol: Polygon, attributes=()):
    return __polygon_template \
        .replace('{{points}}', __format_points(pol.vertices)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def polyline(points: [Point], attributes=()):
    return __polyline_template \
        .replace('{{points}}', __format_points(points)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def text(txt: str, pos: Point, disp: Vector, attrs_list=()):
    return __text_template \
        .replace('{{x}}', str(pos.x)) \
        .replace('{{y}}', str(pos.y)) \
        .replace('{{dx}}', str(disp.u)) \
        .replace('{{dy}}', str(disp.v)) \
        .replace('{{text}}', txt) \
        .replace('{{attrs}}', attrs_to_str(attrs_list))

def group(primitives: [str], attributes=()):
    return __group_template \
        .replace('{{content}}', '\n\t'.join(primitives)) \
        .replace('{{attrs}}', attrs_to_str(attributes))

def arrow(
        _segment: Segment,
        length: float,
        height: float,
        attributes=()
):
    director = _segment.direction_vector
    v_l = director.opposite().with_length(length)
    v_h1 = director.perpendicular().with_length(height / 2.0)
    v_h2 = v_h1.opposite()

    return group(
        [
            segment(_segment),
            polyline([
                _segment.end.displaced(v_l + v_h1),
                _segment.end,
                _segment.end.displaced(v_l + v_h2)
            ])
        ],
        attributes
    )

def __format_points(points: [Point]):
    return ' '.join([f'{p.x},{p.y}' for p in points])

列表 8-25:SVG 原始结果

我们已经准备好开始绘制图像了。在下一章中,我们将使用我们的svg包绘制几何问题的结果。但首先,让我们提供一个方便的方式来导入该包的内容。

包导入

与我们在geom2d包中所做的类似,我们希望提供一个选项,通过一行导入语句将svg包中的所有内容导入:

from graphic import svg

我们需要做的唯一事情是将所有相关模块导入到svg包的init.py文件中:

from .attributes import *
from .image import svg_content
from .primitives import *

总结

图形在工程应用中至关重要。许多应用涉及创建由简单几何原语(如线段和矩形)构成的图表。我们在本书的第二部分中创建了一个几何包;在本章中,我们学习了如何将这些原语转换为矢量图像。

我们首先简要介绍了 SVG 格式,并展示了如何通过几行 XML 数据轻松创建 SVG 图像。接着,我们了解了模板,这是一种无扩展名的纯文本文件,通过占位符定义 SVG 结构。占位符的形式是{{name}},它们会通过代码替换为具体的数据。模板被广泛使用,并且有一些复杂的包用于渲染模板。我们的使用案例相对简单,因此我们使用 Python 字符串的replace方法进行了替换。

最后,我们创建了函数,以获取几何原语的 SVG 表示:线段、圆形、矩形和多边形。从现在开始,创建矢量图应该是直接的,我们将在下一章中证明这一点。

第九章:从三个点构建一个圆

Image

在本章中,我们将构建一个完整的命令行程序来解决一个著名的问题:找出通过三个给定点的圆。你可能在高中时用尺子和圆规通过图形方式解决过这个问题;你甚至可能已经通过数值方法解决过。此次,我们将使用计算机来为我们解决这个问题,并生成一个包含结果的 SVG 图像。我们已经在第六章实现了这个算法;在本章中,我们将把这个算法嵌入到一个应用程序中。

这是一个简单的问题,但非常适合理解如何编写一个完整的应用程序。我们将使用正则表达式从文件中读取三个输入点,正则表达式的内容我们将在本章稍后学习。我们还将读取一个配置文件,其中包含程序输出的颜色和大小值。

然后我们将构建模型:一组实现我们称之为领域逻辑的对象,也就是解决问题所需的知识。在本例中,模型由三个点以及创建通过这三个点的圆的工厂函数组成。感谢我们在第六章的前期工作,这应该不会太复杂。我们将通过图形方式展示结果,呈现一个包含输入点和结果圆的矢量图像。

这是我们第一个完整的命令行程序,它包含了工程应用的所有要素:从输入文件读取、解决问题和输出结果图。构建完这个程序后,你应该有信心构建你自己的程序。可能性是无限的!

应用程序架构

本书中我们一起构建的大多数命令行应用程序,可能还有你自己构建的许多其他程序,都将采用类似的架构。软件架构的概念指的是组成软件应用程序的各个组件的组织和设计。架构不仅涉及每个单独程序部分的设计,还包括各部分之间的通信和交互系统。

为了决定我们的应用程序应该由哪些组件组成,让我们思考一下我们的程序需要做什么。我们的应用程序通常将由三个主要阶段组成,每个阶段由不同的组件或架构构建模块执行:

输入解析 我们从传递给程序的文件中读取问题定义数据。这个阶段也可能包括读取外部配置文件,以调整程序的行为或输出。

问题解决 使用我们从输入定义数据中解析出的模型,我们找到问题的解决方案。

输出生成 我们将解决方案呈现给用户。根据需要的报告类型,我们可以选择生成图表、包含数据的文本文件、模拟结果,或它们的组合。解决问题本身固然重要,但生成易于理解且包含所有相关信息的输出,对于我们的程序能够发挥作用至关重要。

由于本章的问题相对简单,我们将把三个阶段分成三个文件:input.pymain.pyoutput.py。图 9-1 以图形化方式展示了我们应用程序的主要架构块。

图片

图 9-1:应用架构图

输入文件将包含三个点,应该具有以下格式,

x y
x y
x y

其中 x 和 y 是一个点的坐标,用空格分隔并且每个坐标在不同的行上。一个示例输入文件可能如下所示:

300 300
700 400
300 500

这个文件定义了三个点:A(300, 300)、B(700, 400) 和 C(300, 500)。我们会规定坐标的值需要是正整数。这使得解析逻辑稍微简单一些,因为数字中不会有小数点或负号,这有助于我们使用正则表达式入门,但别担心:我们将在第十二章中学习如何识别浮动点数字和负号。

使用纯文本文件作为我们程序的输入有一个很大的优势:我们可以手动编写它们。而且,我们可以轻松地检查和编辑它们。缺点是纯文本文件通常占用的空间比其二进制文件大,但这对我们来说不是问题。我们会选择创建和操作的便利性,而不是文件大小。只要记住,在处理纯文本文件时,务必使用纯文本编辑器,而不是富文本编辑器。富文本编辑器(如 Word)有自己的存储格式,其中包括比你实际写入的内容更多的信息,例如关于粗体、使用的字体类型或字体大小的信息。我们需要我们的输入文件只包含我们写入的内容。

设置

由于我们将在整本书中创建其他应用程序,让我们在 Python 项目的顶层创建一个新包(与 geom2dgraphicutils 包处于同一级别)。右键点击 Mechanics 文件夹,在菜单中选择 新建Python 包,命名为 apps,然后点击 确定

apps 中创建一个新包,这次命名为 circle_from_points

你的项目目录结构应该类似于以下内容:

机制

|- apps

|    |- circle_from_points

|- geom2d

|    |- tests

|- graphic

|    |- svg

让我们创建主文件。这是我们将在命令行中执行的文件来运行应用程序。在 circle_from_points 中创建一个名为 main.py 的文件。输入清单 9-1 中的代码。

if __name__ == '__main__':
   print('This is working')

清单 9-1:主文件

如果你还记得 第一章(在“运行文件”部分),我们使用的是“if name is main”模式来执行我们的主应用程序脚本。我们希望仅在检测到文件是单独运行时才执行这段代码,而不是在它被其他文件导入时执行。现在我们只会向终端输出一条消息,以确保我们的设置正常。

$ python3 apps/circle_from_points/main.py

这应当会输出到终端:

这在正常工作

注意

这次,我们的主文件没有定义任何可以被其他文件导入并使用的函数。但得益于“if name is main”模式,如果这个文件被导入(可能是误导入),那么不会导出任何内容,也不会执行任何代码。我们所有的“可运行”脚本都将使用这个模式。

我们需要一个包含三个点坐标的文件来测试我们的进度。在 circle_from_points 文件夹中创建一个新文件,命名为 test.txt。在其中输入以下坐标:

300 300
700 400
300 500

接下来,我们需要配置我们的 IDE,以便可以在其中本地测试应用程序。

创建运行配置

为了使用我们刚刚编写的 test.txt 文件中的数据来测试应用程序的代码,我们需要在 PyCharm 中创建一个称为 运行配置 的内容(请参阅第“创建运行配置”部分,第 liv 页以复习)。运行配置是一个便捷功能,它能节省我们开发时的时间。

注意

你可能需要参考在线文档以更好地理解运行配置: www.jetbrains.com/help/pycharm/run-debug-configuration.html。如果你使用的是除 PyCharm 之外的 IDE,请参考其文档。大多数 IDE 都有类似的运行配置概念,用于配置程序的测试运行。

要创建运行配置,首先确保通过选择 查看导航栏 来显示导航栏。从顶部菜单中选择 运行编辑配置。将打开 图 9-2 中所示的对话框。

Image

图 9-2:运行配置对话框

点击左上角的 + 图标,这将打开“添加新配置”下拉菜单,并选择 Python(见 图 9-3)。

Image

图 9-3:新的 Python 运行配置

运行配置表单应该出现在窗口的右侧。在顶部的名称字段中输入名称circle-three-points。这将是你用来引用该配置的名称。在“配置”选项卡中,你应该能看到脚本路径字段。这是我们main.py文件的路径:程序的入口点。点击字段中的文件夹图标并选择main.py。在配置选项卡的末尾,找到“执行”部分。选中从输入重定向复选框,然后在字段中点击文件夹图标,选择包含点定义的测试文件:test.txt。这样,运行配置将始终将test.txt传递给程序的标准输入。你的配置对话框应该类似于图 9-4。

图片

图 9-4:运行配置数据

我们需要做最后一件事。如果我们按照现在的配置执行运行,程序的输出将打印到终端(标准输出)。这没有问题,但因为我们要输出 SVG 代码,所以我们希望将标准输出重定向到一个具有 .svg 扩展名的文件。

转到配置右侧的日志标签。勾选将控制台输出保存到文件;然后点击文件夹图标,选择circle_from_points中的任何文件。选择文件后,只需将其名称更改为result.svg。或者,你可以复制并粘贴circle_from_points包的路径,然后在路径末尾添加result.svg文件的名称。你也可以创建一个空的result.svg文件,然后在这里选择它。无论你选择哪种方式,结果应该类似于图 9-5。

图片

图 9-5:将输出重定向到文件

一切就绪,点击确定。在导航栏中,你应该看到新创建的运行配置被选中(参见图 9-6)。点击右侧的绿色播放按钮。这将执行运行配置,最终会在一个名为result.svg的文件中写入消息“这是有效的”。

图片

图 9-6:导航栏中的运行配置

让我们快速回顾一下我们刚刚做了什么。我们在 PyCharm 中创建了一个配置,告诉它如何运行我们的项目。我们告知配置,main.py 是开始执行我们项目的入口点。然后,我们指定将包含测试数据的test.txt文件传递给程序的标准输入,并将程序的输出重定向到名为result.svg的文件。

为什么使用运行配置?

你可能会问,为什么我们要创建一个运行配置,而不是直接从命令行执行脚本?

这是一个很好的问题。我们使用运行配置有两个主要原因。第一个是,在开发过程中,我们会更加高效。我们无需在 shell 中输入命令来运行程序,也不必在必要时重定向标准输入和输出。此外,这个配置还允许我们调试程序,这在 shell 中会变得相当困难。如果你在代码中的某个地方设置了断点,你可以点击绿色播放按钮旁边像虫子一样的按钮,程序将在断点处停止。

第二个原因是,正如我们在本章后面将看到的,如果你试图从 shell 运行 main.py,一旦开始导入我们的包(例如 geom2d),它就会根本无法工作。是的,这有点令人惊讶,但我们将学习为什么会发生这种情况,更重要的是,如何修复它。

读取输入和配置文件

到目前为止,我们有了一个 main.py 文件和一个运行配置,使用标准输入将 test.txt 传递给它。现在我们对该文件的内容什么也不做,所以接下来的好步骤是读取文件的内容,并将每一行解析为 Point 类的实例。我们该如何做呢?我们需要使用正则表达式,一种强大的文本读取和信息提取技术。

在我们探索正则表达式之前,让我们在项目中创建一个新文件,用于读取输入和配置文件。同时,花些时间学习如何读取传递给我们程序标准输入的文件。

circle_from_points 中,创建一个名为 input.py 的新文件。你的 circle_from_points 目录应该如下所示:

circle_from_points

|- init.py

|- input.py

|- main.py

|- test.txt

让我们从小处开始,一步步来。在新创建的文件中输入清单 9-2 中的代码。

def parse_points():
    return (
        input(),
        input(),
        input(),
    )

清单 9-2:从输入文件读取行

parse_points 函数实际上还没有解析点……目前为止,它返回一个包含三个字符串的元组,每个字符串对应于从标准输入读取的一行。每一行都是通过 Python 的 input 函数读取的,该函数一次读取一行输入。让我们从主程序中调用 parse_points,看看它是如何读取测试文件内容的。返回到 main.py 并修改代码,使其与清单 9-3 匹配。

from apps.circle_from_points.input import parse_points

if __name__ == '__main__':
    (a, b, c) = parse_points()
    print(f'{a}\n{b}\n{c}')

清单 9-3:将点打印到 shell

你可能会想使用 Python 的相对导入,例如 from .input import parse_points,但是当从命令行运行进行导入的文件时,这样做不会正常工作。为了理解为什么会这样,来看一下 PEP 238 中的这段摘录:

相对导入使用模块的 name 属性来确定该模块在包层次结构中的位置。如果模块的名称不包含任何包信息(例如,它被设置为'main'),则相对导入将被解析为模块是顶级模块,无论该模块在文件系统中的实际位置如何。

在清单 9-3 中,我们首先从input.py模块导入 parse_points。在“if name is main”条件中,我们调用 parse_points 函数并将其输出赋值给元组(a, b, c),这将解构其元素为变量 a、b 和 c。

以下是实现相同结果的较不优雅的方法:

points = parse_points()
a = points[0]
b = points[1]
c = points[2]

但我们选择前者,它稍微干净一些。最后一行将 a、b 和 c 的内容打印到终端,每个打印在自己的行上。通过点击我们之前创建的运行配置旁边的绿色播放按钮来运行该应用程序。你应该会看到以下内容被打印到 IDE 的终端:

Input is being redirected from --snip--/test.txt
Console output is saving to: --snip--/result.svg
300 300
700 400
300 500

Process finished with exit code 0

前两行非常有趣。它们告诉我们,用于运行文件的配置正在从文件test.txt接收输入,并将输出写入文件result.svg。如果你打开result.svg,你应该能看到与test.txt中定义的三点相同,并且也与它们在终端中打印的方式相同。我们在这里取得了很好的进展!下一步是将这些用空格分隔的坐标转换为我们的 Point 类实例。为此,我们需要使用正则表达式。

正则表达式

正则表达式(简写为regex)在解释文本时是强大的构造。因为我们将在本书中创建的大多数应用程序的输入都将从纯文本文件中读取,所以我们希望了解正则表达式。

注意

如果你想了解更多关于正则表达式的信息,请查看这个超棒的互动教程: https://regexone.com

让我们快速回顾一下我们在这里要解决的问题:给定一个包含由空格分隔的两个整数的文本字符串,从字符串中提取它们,将它们转换为数字,并将它们作为 Point 类实例的坐标。正则表达式在这里如何帮助我们?

正则表达式是定义为字符串的模式。它用于在其他字符串中查找匹配项,并可选地提取它们的部分内容。

让我们试一个例子。请注意,正则表达式通过将它们写在两个斜杠字符之间来表示。假设我们正在寻找这个模式,

/重复 5 次/

并且我们有兴趣知道这个模式是否出现在以下任何句子中:

重复 5 次。

对于每个练习,重复 5 次。

对于那个特定的练习,重复 7 次。

让我们重复 3301 次。

/重复 5 次/ 正则表达式将自身与这些字符串进行比较,查找完全匹配文本 repeat 5 times,因此只会得到单一的粗体匹配:

重复 5 次。

对于每个练习,重复 5 次

对于该特定练习,重复 7 次。

让我们重复 3301 次。

这很好,但灵活性不足。第一句话没有匹配,因为第一个字母 R 是大写的,而我们的模式是小写的。我们可以调整模式,使其接受两者:

/[Rr]epeat 5 次/

这次的匹配结果如下:

重复 5 次

对于每个练习,重复 5 次

对于该特定练习,重复 7 次。

让我们重复 3301 次。

为了同时处理两种 r,我们引入了一个 字符集:在文本的某个位置接受的一系列字符,任何一个字符都被视为有效。字符集通过方括号定义。

使用正则表达式,我们可以做更多的事情。那句中的重复次数怎么办?我们能否使任何重复次数都被视为有效匹配?当然可以。如果我们修改模式为

/[Rr]epeat \d times/

我们将得到以下匹配:

重复 5 次

对于每个练习,重复 5 次

对于该特定练习,重复 7 次

让我们重复 3301 次。

模式 \d 匹配一个数字,0 到 9 之间的任何数字。那最后一句话呢?如果我们想匹配多个数字,我们需要为 \d 模式添加一个 量词。在这种情况下,最合适的量词是 +,它用于匹配一个或多个它量化的符号。模式

/[Rr]epeat \d+ times/

这将适用于任何重复次数,从而获得我们一直追求的完整匹配范围:

重复 5 次

对于每个练习,重复 5 次

对于该特定练习,重复 7 次

让我们 重复 3301 次

现在你开始理解正则表达式的基本概念了,让我们探索一些它们的基础概念。

字符集

正如我们所看到的,我们可以在方括号之间包含几个不同的字符,以使我们的正则表达式匹配其中任何一个。例如,我们可以使用

/[mbg]ore/

匹配 moreboregore。我们还可以包含像这样所有小写字母 az 的范围:

/[a-z]ore/

这将产生广泛的匹配,例如 morecore 和 explore。我们还可以包括大写字母的范围,

/[a-zA-Z]ore/

以包括像 MoreCore 这样的匹配。需要注意的是,连续的字符范围之间没有空格。如果你用空格分隔它们,集合会把空格也视为有效字符。

字符类

有一些特殊字符可以用来匹配常见的元素,如数字、空白符或单个字母。第一个是点号 (.),我们用它来匹配除换行符之外的任何内容。它匹配字母(包括大写和小写)、数字、标点符号和空白符。正如你所看到的,这是一个相当强大的 匹配器。例如,模式

/the end./

它将匹配 the end.the end?the end! 和更多情况。要匹配一个单独的点,我们需要使用反斜杠来转义点字符,

/the end./

这将只产生一个匹配项:the end.

我们已经学习过类 \d,它匹配一个数字。如果我们想匹配所有的数字字符,可以使用 \D(大写字母)。类似地,若要匹配字母,可以使用类 \w,而 \W 用于匹配非字母字符。最后,对于空白字符,我们使用 \s,而 \S 用于匹配任何非空白字符。

现在让我们将关于字符类的知识结合成一个正则表达式:

/code\s\w-\d\d/

这个正则表达式匹配像 code f-44code M-81code p-29 这样的字符串。

量词

量词修改了它们量化的符号的匹配次数。共有五个量词:

?     匹配零个或一个前面的符号

*     匹配零个或多个前面的符号

+     匹配一个或多个前面的符号

(n)     匹配前面符号的确切 n 个

(n,m)     匹配前面符号的 n 到 m 个

例如,

/o{2}m/

它将匹配 boom、zoom 或 kaboom。但是,如果我们使用

/o+m/

我们的匹配结果可以是以下任何一种:nomad,boooom,或 room。我们将在文中使用大多数这些量词,所以会有很多示例。

捕获分组

到目前为止,我们已经看到如何使用正则表达式匹配文本。但有时我们还想提取我们匹配到的文本。这时,分组就派上用场了。分组是在圆括号之间定义的。让我们尝试以下正则表达式,

/it takes (\d+) hours to go from (\w+) to (\w+)/

将其应用于句子“从巴塞罗那到庞普洛纳需要 4 小时”时,它会完全匹配并捕获以下组:

('4', 'Barcelona', 'Pamplona')

让我们在 Python 的 shell 中试一下。Python 的标准库包括一个强大的正则表达式包:re。打开你的 IDE 的 shell,尝试输入 示例 9-4 中的代码。

>>> import re
>>> pattern = r'it takes (\d+) hours to go from (\w+) to (\w+)'
>>> target = 'it takes 4 hours to go from Barcelona to Pamplona'
>>> matches = re.match(pattern, target)
>>> matches.groups()
('4', 'Barcelona', 'Pamplona')

示例 9-4:使用正则表达式捕获分组

我们使用原始字符串字面量来定义模式,格式为 r''。这些字符串将反斜杠(\)视为有效字符,而不是将其解释为转义序列。正则表达式需要反斜杠来定义其结构。

在 示例 9-4 中,结果存储在名为 matches 的变量中,我们可以调用 groups 方法来获取捕获的三个分组:4、Barcelona 和 Pamplona。

分组的一个好处是,它们可以被赋予一个名称,稍后我们可以用这个名称来检索匹配的值。例如,考虑以下模式:

/(?P\w+), but they call me (?P\w+)/

应用于像“我的名字是 Nelson,但他们叫我 Big Head”这样的句子时,它会捕获两个分组,我们可以通过名称来检索它们:

name = matches.group('name')
nick = matches.group('nick')

正如你所猜测的,赋予分组名称的语法如下,

(?P<name><regex>)

其中 name 是分配给该组的名称,regex 是实际用于匹配该组的模式。

正则表达式备忘单

表 9-1、表 9-2、表 9-3 和 表 9-4 总结了我们已经探索的概念,并可以作为本书其余部分的参考。

表 9-1: 正则表达式字符集

[abc] 匹配 ’a’ 或 ’b’ 或 ’c’
[^ab] 匹配除了 ’a’ 和 ’b’ 之外的所有字符
[a-z] 匹配从 ’a’ 到 ’z’ 之间的所有字符

表 9-2: 正则表达式字符类

\s 匹配空白字符
\S 匹配除空白字符之外的所有字符
\d 匹配数字
\D 匹配除了数字之外的所有字符
\w 匹配字母
\W 匹配除了字母之外的所有字符

表 9-3: 正则表达式量词

? 零个或一个
* 零个或多个
+ 一个或多个
精确匹配 n
匹配 n 到 m(包括 n 和 m)之间的字符

表 9-4: 正则表达式捕获组

(...) 捕获组位于圆括号之间
(?P...) 一个命名的捕获组

匹配点

我们已经知道了匹配由空格分隔的坐标定义的点所需的一切,并且可以通过名称捕获它们。因为坐标仅由整数定义,我们可以使用以下正则表达式:

/(?P\d+)\s(?P\d+)/

让我们将其分解。它有三个部分:

(?P\d+) 捕获一个名为 x 的组,该组由一个或多个数字组成

\s 匹配一个空格

(?P\d+) 捕获一个名为 y 的组,该组由一个或多个数字组成

让我们在应用程序的 input.py 文件中实现这个匹配模式。编辑我们编写的代码,使其看起来像 清单 9-5。

➊ import re

  from geom2d import Point

  def parse_points():
      return (
          __point_from_string(input()),
          __point_from_string(input()),
          __point_from_string(input()),
      )

  def __point_from_string(string: str):
   ➋ matches = re.match(r'(?P<x>\d+)\s(?P<y>\d+)', string)
      return Point(
       ➌ int(matches.group('x')),
       ➍ int(matches.group('y'))
      )

清单 9-5:解析点

我们首先导入 re ➊。然后,我们修改 parse_points 函数,将通过 input() 读取的行映射到 Point 实例。这一转换由私有的 __point_from_string 函数处理,该函数通过 re.match,查找传入字符串中与模式匹配的部分 ➋。

从匹配项中我们知道应该有两个组,分别命名为 x 和 y。因此,该函数创建并返回一个 Point 实例,其 x 坐标是通过解析名称为 x 的组所捕获的字符串,结果为一个整数 ➌。y 坐标以类似的方式处理,通过解析名称为 y 的组 ➍ 获取结果。

运行应用程序(使用 circle-three-points 配置),点击绿色播放按钮。你应该看到类似下面的内容打印到 shell 中:

Input is being redirected from --snip--/test.txt
Console output is saving to: --snip--/result.svg
(300, 300)
(700, 400)
(300, 500)

Process finished with exit code 0

恭喜!你刚刚从一个包含三行纯文本的文件中解析了三个点。从现在开始,我们将创建的所有命令行应用程序都可以期望从文件中获取输入数据,你已经知道如何使用强大的正则表达式解析和解释这些数据。

配置文件

我们的应用将生成一个漂亮的矢量图,展示输入的点和结果圆形。为此,我们将使用不同的颜色和线条粗细,帮助直观区分它们的各个部分。我们可以直接将这些颜色和大小值硬编码到代码中,但这不是一个好主意;如果我们将配置值与实际逻辑分开,我们的应用将更容易维护。因此,我们将把配置值存储在一个单独的 JSON 文件中。我们选择使用 JSON 格式,因为它非常容易转换为 Python 字典。

注意

当我们说某个东西是硬编码到代码中时,意思是没有办法改变它,除非修改程序的源代码。例如,配置值通常硬编码到主应用的逻辑中,无法改变,除非逐行阅读代码并可能需要重新编译应用程序。不要这样做。你需要编辑和重新编译现有代码的次数越少越好。始终将配置值从程序的逻辑中移到一个独立的文件中。

circle_from_points 中,右键点击包名并选择 新建文件,创建一个新文件。输入文件名 config.json,然后在其中编写 Listing 9-6 的内容。

{
  "input": {
    "stroke-color": "#4A90E2",
    "stroke-width": 2,
    "fill-color": "#ffffffbb",
    "label-size": 16,
    "font-family": "Helvetica"
  },
  "output": {
    "stroke-color": "#50E3C2",
    "stroke-width": 4,
    "fill-color": "#ffffff",
    "label-size": 14,
    "font-family": "Helvetica"
  }
}

Listing 9-6: 配置文件中的应用程序配置

这个文件是 JSON 格式的,这是一种广泛使用的格式。如果您对它不熟悉,可以在 www.json.org/ 阅读更多内容。它的结构类似于 Python 字典,以键值对的方式存储数据。幸运的是,Python 提供了一种简单的方法来读取 JSON 文件:标准库中包括了处理 JSON 数据的 json 包。

input.py 中,输入 Listing 9-7 中的函数(不要忘记导入)。

import json
import re

import pkg_resources as res

def read_config():
    config = res.resource_string(__name__, 'config.json')
    return json.loads(config)

--snip--

Listing 9-7: 读取配置文件

使用 pkg_resources 模块,这个过程变得轻而易举。使用 res.resource_string() 将 config.json 文件的内容读取为二进制字符串,并传递给 json.loads,这样就能得到解析后的 Python 字典,准备好供使用。我们很快就会用到这些值。

问题模型与解决方案

我们已经解析了问题的模型:我们的 Point 类的三个实例。利用这些,我们的应用现在应该计算出一个通过所有这些点的圆。我们之前的工作即将得到回报:我们已经有了完成此任务的代码(请参考 第 153 页的“圆形工厂”部分,了解更多)。

打开 main.py 并输入 Listing 9-8 中的代码。

from apps.circle_from_points.input import parse_points
from geom2d import make_circle_from_points

if __name__ == '__main__':
    (a, b, c) = parse_points()
    circle = make_circle_from_points(a, b, c)
    print(circle)

Listing 9-8: 计算通过三个点的圆

这很简单!我们从 geom2d 导入 make_circle_from_points,并将三个点:a、b 和 c 传递给它。为了确保圆形计算正确,我们打印出计算结果的圆形。运行应用程序,您应该期待结果圆形的以下字符串表示:

circle c = (487.5, 400.0), r = 212.5

如果你打开result.svg,它应该是这个内容。这个文件是我们将程序的输出重定向到的地方。程序中只缺少一件事:使用 SVG 格式绘制输出!

生成输出

现在问题已经解决,我们需要绘制一个包含圆形和输入点的 SVG。首先,在circle_from_points目录中创建一个新的文件,命名为output.py。你的circle_from_points目录应该像下面这样:

circle_from_points

|- init.py

|- input.py

|- main.py

|- output.py

|- test.txt

在其中,输入代码到清单 9-9。

from geom2d import Circle, Point

def draw_to_svg(points: [Point], circle: Circle, config):
    print("Almost there...")

清单 9-9:生成输出图像的第一步

我们定义了一个新函数 draw_to_svg,它接收一系列点(问题的输入点)、生成的圆形和配置字典。请注意,点序列的类型提示:[Point];它由 Point 类在方括号中声明。这样定义的序列类型提示既可以接受列表也可以接受元组。

目前,函数只是将一条消息打印到标准输出,但我们会一步一步地更新它,直到它最终绘制出所有内容。这样,你就可以继续给main.py最终定型。修改你的代码,使其看起来像清单 9-10。

from apps.circle_from_points.input import parse_points, read_config
from apps.circle_from_points.output import draw_to_svg
from geom2d import make_circle_from_points

if __name__ == '__main__':
    (a, b, c) = parse_points()
    circle = make_circle_from_points(a, b, c)
    draw_to_svg((a, b, c), circle, read_config())

清单 9-10:主文件

这段代码简洁明了。基本上有三行,分别读取输入、解决问题并绘制输出。我们的主文件已经设置好,现在让我们填写 draw_to_svg。

绘制输出圆形

我们将从绘制圆形开始。打开output.py并输入清单 9-11 中的代码。

from geom2d import make_rect_centered, Circle, Point, Vector
from graphic import svg

def draw_to_svg(points: [Point], circle: Circle, config):
 ➊ svg_output = output_to_svg(circle, config['output'])

 ➋ viewbox = make_viewbox(circle)
 ➌ svg_img = svg.svg_content(
        viewbox.size, svg_output, viewbox
    )

    print(svg_img)

def output_to_svg(circle: Circle, config):
 ➍ style = style_from_config(config)
 ➎ label_style = label_style_from_config(config)

    return [
     ➏ svg.circle(circle, style),
     ➐ svg.text(
            f'O {circle.center}',
            circle.center,
            Vector(0, 0),
            label_style
        ),
     ➑ svg.text(
            f'r = {circle.radius}',
            circle.center,
            Vector(0, 20),
            label_style
        )
    ]

清单 9-11:绘制生成的圆形

这看起来像是很多代码,但别担心,我们会逐步分析。首先,我们更新 draw_to_svg 函数。使用我们稍后在代码中定义的 output_to_svg 函数,我们为圆形创建 SVG 表示 ➊。请注意,我们传递给这个函数的是 config[’output’],即配置字典中的output部分。

然后,使用 make_viewbox,这是一个我们还未定义的函数,我们计算图像的 viewBox ➋。利用这个 viewBox、它的大小和 svg_output,我们生成图像 ➌ 并将其打印到标准输出。

现在让我们来看一下 output_to_svg。这个函数使用我们稍后将定义的另一个函数(style_from_config),将圆形的 SVG 属性存储在一个名为 style 的变量中 ➍。对于我们将用于文本的样式属性,也是如此,它们由 label_style_from_config 生成 ➎。该函数返回一个包含三个 SVG 原语的数组:圆形和两个标签。

圆形部分很简单;我们使用了我们预先编写的 circle 函数 ➏。接下来是标注,指示圆形的中心位置 ➐,该标注的原点位于圆心。最后是圆形半径信息的标签。这个标签位于圆形的中心,但稍微偏移⟨0, 20⟩,使其出现在之前标签的下方 ➑。

注意

你可能还记得我们曾说过,当通过向量 ⟨0, 20⟩* 移动标签时,它会出现在另一个标签下方。在向量的 y 坐标中使用正数应当导致标签向上移动,因此会将标签移到另一个标签的上方。但请记住,在 SVG 中,y 轴是向下的。我们本可以通过应用仿射变换来修正这一点,但我们现在不做这件事。*

要计算 viewBox,输入 清单 9-12 中的代码至 output_to_svg 函数下。

--snip--

def make_viewbox(circle: Circle):
    height = 2.5 * circle.radius
    width = 4 * circle.radius
    return make_rect_centered(circle.center, width, height)

清单 9-12:计算图像的 viewBox

这个函数计算定义图像可见部分的矩形。如果你需要复习一下,回到 第 207 页 的“viewBox”章节。为了构建这个矩形,我们使用了 make_rect_centered 工厂函数,这在现在需要一个包含圆形的矩形时非常方便。矩形的高度是圆形半径的 2.5 倍,也就是直径加上一些边距。宽度是半径的 4 倍(或直径的 2 倍),因为我们需要为接下来绘制的标签留出空间。我是通过反复试验得出了这些值,但你可以根据自己的实验调整它们。它们基本上只是为你的绘图添加了更多或更少的边距,仅此而已。

图 9-7 描述了我们正在绘制的 SVG 图像布局,供参考。

图像

图 9-7:SVG 输出布局

让我们实现生成 SVG 样式属性的函数。在你的文件 output.py 的末尾,输入 清单 9-13 中的代码。

--snip--

def style_from_config(config):
    return [
        svg.stroke_color(config['stroke-color']),
        svg.stroke_width(config['stroke-width']),
        svg.fill_color(config['fill-color'])
    ]

清单 9-13:从配置中创建样式

style_from_config 函数使用配置字典中的值创建一个 SVG 属性列表。让我们对标签的样式做同样的事情(参见 清单 9-14)。

--snip--

def label_style_from_config(config):
    return [
        svg.font_size(config['label-size']),
        svg.font_family(config['font-family']),
        svg.fill_color(config['stroke-color'])
    ]

清单 9-14:从配置中创建标签样式

就是这样!我们已经拥有绘制结果圆形所需的所有代码,并且使用了青色。现在运行应用程序,你应该会看到 shell 输出一些 SVG 代码,这些代码与文件 result.svg 中的内容相同。用你喜欢的浏览器打开这个文件,结果应该类似于 图 9-8。

图像

图 9-8:SVG 输出圆形

就这样!我们解决了第一个几何问题,并将结果绘制成了矢量图像。不是很激动人心吗?快去试试配置,改变输出颜色并重新运行应用程序。

绘制输入点

很高兴我们绘制了带有指示圆心位置和半径的标签的圆形,但生成的图像并没有包括关于生成圆形的输入点的信息。让我们将这些点也绘制出来,这样就能一眼看出生成该圆形所需的所有信息。

让我们创建一个新的函数,它类似于 output_to_svg,但生成代表输入点的 SVG 基本元素。我们也将这些点表示为圆形。在 output.py 中输入 列表 9-15 中的代码。

--snip--

def input_to_svg(points: [Point], point_radius: float, config):
    style = style_from_config(config)
    label_style = label_style_from_config(config)
 ➊ [a, b, c] = points
 ➋ disp = Vector(1.25 * point_radius, 0)

 ➌ return [
        svg.circle(Circle(a, point_radius), style),
        svg.circle(Circle(b, point_radius), style),
        svg.circle(Circle(c, point_radius), style),
        svg.text(f'A {a}', a, disp, label_style),
        svg.text(f'B {b}', b, disp, label_style),
        svg.text(f'C {c}', c, disp, label_style)
    ]

列表 9-15:绘制输入点

input_to_svg 函数接收一个列表,其中包含三个输入点、用于表示点的半径以及输入配置字典。

如你所见,我们将使用生成的圆形大小的一部分作为输入点的半径。这样无论生成的图像大小如何,它们看起来都能很好。使用固定的半径值可能会导致某些输入点的圆形非常小,几乎不可见,而其他的则可能是比生成的圆形还大的巨大圆形。

点和标签的样式是使用我们之前使用的相同函数计算的:style_from_config 和 label_style_from_config。序列中的点被解构为变量 a、b 和 c,以便我们可以方便地使用它们 ➊。

因为我们需要将标签稍微向右移动,以避免与圆形重叠,我们构造了一个位移向量 disp ➋。该函数返回带有标签的圆形数组 ➌。

现在更新函数 draw_to_svg,使其也包含生成图像中的三个点(参见 列表 9-16)。

def draw_to_svg(points: [Point], circle: Circle, config):
 ➊ pt_radius = circle.radius / 20
    svg_output = output_to_svg(circle, config['output'])
 ➋ svg_input = input_to_svg(points, pt_radius, config['input'])

    viewbox = make_viewbox(circle)
    svg_img = svg.svg_content(
     ➌ viewbox.size, svg_output + svg_input, viewbox
    )

    print(svg_img)

--snip--

列表 9-16:绘制到 SVG

如前所述,输入点的半径需要是生成的圆形半径的一部分,所以我们选择了其半径的五十分之一 ➊。如果你觉得生成的圆形太大或太小,可以更改这个值并进行实验,直到你对结果满意。这个值可以完全作为应用程序配置的一部分,但为了简单起见,我们将它保持为实现细节。

在计算出 pt_radius 后,我们像之前一样计算输出的 SVG 基本元素。然后,我们使用 input_to_svg 函数计算输入的 SVG 基本元素,并将结果存储在 svg_input ➋ 中。

创建 viewBox 后,我们通过将 svg_input 附加到 svg_output ➌ 来更新 SVG 图像的内容。重要的是,svg_input 要放在 svg_output 后面,因为图像元素是按顺序绘制的。如果你交换顺序,变成这样,

svg_input + svg_output

你会看到输入点的圆形位于大圆形的后面。

现在你可以运行应用程序,然后在浏览器中重新加载 result.svg 文件。结果应当如下所示:图 9-9。

图像

图 9-9:带有完整结果的 SVG

结果

供参考,列表 9-17 包含了 output.py 的完整版本。

from geom2d import make_rect_centered, Circle, Point, Vector
from graphic import svg

def draw_to_svg(points: [Point], circle: Circle, config):
    pt_radius = circle.radius / 20
    svg_output = output_to_svg(circle, config['output'])
    svg_input = input_to_svg(points, pt_radius, config['input'])

    viewbox = make_viewbox(circle)
    svg_img = svg.svg_content(
        viewbox.size, svg_output + svg_input, viewbox
    )

    print(svg_img)

def output_to_svg(circle: Circle, config):
    style = style_from_config(config)
    label_style = label_style_from_config(config)

    return [
        svg.circle(circle, style),
        svg.text(
            f'O {circle.center}',
            circle.center,
            Vector(0, 0),
            label_style
        ),
        svg.text(
            f'r = {circle.radius}',
            circle.center,
            Vector(0, 20),
            label_style
        )
    ]

def input_to_svg(points: [Point], point_radius: float, config):
    style = style_from_config(config)
    label_style = label_style_from_config(config)
    [a, b, c] = points
    disp = Vector(1.25 * point_radius, 0)

    return [
        svg.circle(Circle(a, point_radius), style),
        svg.circle(Circle(b, point_radius), style),
        svg.circle(Circle(c, point_radius), style),
        svg.text(f'A {a}', a, disp, label_style),
        svg.text(f'B {b}', b, disp, label_style),
        svg.text(f'C {c}', c, disp, label_style)
    ]

def style_from_config(config):
    return [
        svg.stroke_color(config['stroke-color']),
        svg.stroke_width(config['stroke-width']),
        svg.fill_color(config['fill-color'])
    ]

def label_style_from_config(config):
    return [
        svg.font_size(config['label-size']),
        svg.font_family(config['font-family']),
        svg.fill_color(config['stroke-color'])
    ]

def make_viewbox(circle: Circle):
    height = 2.5 * circle.radius
    width = 4 * circle.radius
    return make_rect_centered(circle.center, width, height)

列表 9-17:绘制 SVG 结果

翻转 Y 轴

如你所知,SVG 的 y 轴是向下的。例如,点 Cy = 500 处,在 y = 300 的 A 之下。这不一定不好,但可能与你习惯的方式相反。

给你一个挑战:修改output.py,使得生成的 SVG 图像使用仿射变换,使得 y 轴被翻转,从而指向上方。如果你需要提示,可以回顾一下第八章中的“空间变换”部分。

注意,如果你选择添加一个仿射变换,使得整个 SVG 图像的 y 轴被翻转,如下所示,

<svg --snip-- transform="matrix(1 0 0 -1 0 0)">
    --snip--
</svg>

所有文本标签也会垂直翻转,这使得它们无法读取。试着通过对所有标题添加仿射变换来解决这个问题,这样你就基本上翻转了它们两次。

这确实是个挑战,但对你来说是一个很好的练习。别担心,我们将在第 V 部分深入探讨这个问题。

分发我们的应用程序

这个消息已经在你的朋友之间传开了,他们都听说了你的成就:你开发了一个可以计算经过三点的圆并绘制出漂亮矢量图像的应用程序。他们知道这完全是你自己完成的,没有使用任何第三方库。他们都感到惊讶,“真是太厉害了,”你甚至听到他们这样说。他们也想试试,并且准备了一些输入文件来测试你的程序。你把代码分享给他们,他们因为懂 Python,打开了命令行并尝试执行你的main.py脚本,却发现出现了一个奇怪的错误,导致程序无法运行。

很遗憾,为了加载你的应用程序所使用的所有模块,PyCharm 做了一个小技巧,我们需要考虑到这一点。但别担心,我们会探讨为什么会出现这个错误并提供解决方案。你可以将这里学到的内容应用到本书中构建的任何应用程序,甚至是你自己编写的程序。

理解问题

让我们尝试从命令行运行我们最近创建的程序,不做任何更改,看看是否得到与 IDE 相同的结果。在 bash 命令行中(无论是在你的 IDE 中还是系统中),导航到应用程序的目录,

$ cd apps/circle_from_points

然后运行这个:

$ python3 main.py < test.txt

出乎意料的是,它没有起作用:

    Traceback (most recent call last):
      File "main.py", line 6, in <module>
        from apps.circle_from_points.input import --snip--
    ModuleNotFoundError: No module named 'apps'

这是我们得到的错误:

    ModuleNotFoundError: No module named 'apps'

这告诉我们 Python 在尝试导入 apps 模块时找不到它。但是如果是这样,为什么在使用 IDE 的运行配置时它能够正常运行呢?嗯,PyCharm 的运行配置在幕后做了一个技巧,而我们现在需要自己做这个技巧。

当脚本导入模块时,Python 会在特定的目录中查找它们。为了确切知道这些目录是什么,你可以在运行时查询它们:Python 将它们存储在 sys.path 中,这是一个包含 Python 在你的计算机上查找库的所有路径的列表。Python 还会将脚本本身的路径追加到这个列表中;这个路径被称为工作目录

我们遇到的问题是,sys.path 并没有将我们项目的父路径添加进来。这很不幸,因为它应该去这个地方查找我们的 geom2dgraphicapps 包。PyCharm 的运行配置之所以能正常工作,是因为它将这个路径添加到了 sys.path 中。让我们通过在主脚本中打印 sys.path 的内容来验证这一点,然后再使用运行配置运行它。打开 main.py 文件,在文件顶部添加以下内容:

import sys
print(sys.path)

--snip--

请注意,打印语句紧跟在导入 sys 后,位于其他导入之前。你可能会收到 PyCharm 的警告,认为按照 PEP-8 标准,这种做法在概念上是错误的——忽略这个警告。我们希望在 Python 尝试加载其他内容之前先打印这行,否则当脚本从 shell 中运行时,我们会遇到和之前一样的错误,永远无法打印 sys.path。如果你现在使用运行配置重新运行项目,得到的输出应该类似于以下内容:

/usr/local/bin/python3.7 --snip--/main.py
Input is being redirected from --snip--/test.txt
Console output is saving to: --snip--/result.svg

['--snip--/Mechanics/apps/circle_from_points',
'--snip--/Mechanics',
'--snip--/Python.framework/Versions/3.7/lib/python37.zip',
'--snip--/Python.framework/Versions/3.7/lib/python3.7',
'--snip--/Python.framework/Versions/3.7/lib/python3.7/lib-dynload',
'--snip--/Python/3.7/lib/python/site-packages',
'/usr/local/lib/python3.7/site-packages']

你能从 sys.path 列表中找到第二行(加粗部分)吗?这一行是解决我们程序中找不到包含模块问题的关键。现在我们从 shell 中运行脚本,看看该路径列表包含了什么。在 IDE 的 shell 中,导航到应用的目录并运行以下命令:

$ python3 main.py < test.txt

这次的输出如下:

['--snip--/Mechanics/apps/circle_from_points',
'--snip--/Python.framework/Versions/3.7/lib/python37.zip',
'--snip--/Python.framework/Versions/3.7/lib/python3.7',
'--snip--/Python.framework/Versions/3.7/lib/python3.7/lib-dynload',
'---snip--/Python/3.7/lib/python/site-packages',
'/usr/local/lib/python3.7/site-packages']

你能看到 Mechanics 目录没有作为搜索路径列在这里吗?如果这个目录没有被包括在内,Python 就无法在运行应用程序时从这个路径找到任何模块。

删除你在 main.py 中添加的那两行,让文件恢复到之前的样子,然后让我们来探索一些可能的解决方案。

寻找解决方案

问题很明确:Python 无法加载我们的库,因为它没有将父目录列为搜索路径。我们来看看如何解决这个问题。我们将提供两个选项,在决定哪一个最适合我们之前,先了解它们的优缺点。

添加到 sys.path

一种可能的解决方案是做 PyCharm 运行配置所做的事:在 Python 尝试导入任何内容之前,将我们项目的父目录添加到 sys.path。我们可以修改 main.py,使其看起来像 清单 9-18。

  import os
  import sys

➊ parent_path = os.path.normpath(os.path.join(os.getcwd(), '..', '..'))
➋ sys.path.append(parent_path)

  from apps.circle_from_points.input import parse_points, read_config
  from apps.circle_from_points.output import draw_to_svg
  from geom2d import make_circle_from_points

  if __name__ == '__main__':
      (a, b, c) = parse_points()
      circle = make_circle_from_points(a, b, c)
      draw_to_svg((a, b, c), circle, read_config())

清单 9-18:添加到 sys.path

我们首先导入 os 和 sys 模块。然后通过获取当前工作目录 (os.getcwd()) 并向上回溯两步('..', '..')来计算项目的父路径 ➊。

我们使用 os.path.normpath 函数来规范化路径,以确保路径中不包含表示目录树回溯的点。这个函数将像这样的路径:

/Documents/MechBook/code/Mechanics/../..

转换为以下内容:

/Documents/MechBook

这个路径在 Python 尝试从我们的项目中加载任何内容之前被添加到 sys.path ➋。如果你从 shell 中运行应用,应该不会再报错。

$ python3 main.py < test.txt

这个解决方案有效,但似乎有点尴尬,我们必须让用户进入apps/circle_from_points目录才能运行我们的脚本:如果我们能从项目的父目录运行程序会更方便。此外,我们添加到main.py中的那些行看起来有点难看,而且与解决通过三点画圆的问题无关。我们不希望将这些行添加到我们实现的每个应用中;那样会增加不必要的复杂性,我们希望避免。

让我们尝试一种不同的方法,不涉及修改主脚本中的代码:我们创建一个 bash 脚本,将正确的工作目录路径附加到 Python 脚本的执行中。首先撤销 Listing 9-18 中所做的操作,使得你的main.py文件看起来和 Listing 9-10 中的一样。

用脚本包装应用

在上一节中我们看到的,对于我们的main.py脚本运行所需的每个包,应该可以从工作目录或 sys.path 中列出的任何其他路径访问。

注意

记住,工作目录是执行文件(在本例中是main.py)所在的目录。

除了在 Python 代码中将路径附加到 sys.path 之外,我们还可以在环境变量PYTHONPATH 中包含路径。当运行 Python 脚本时,它会将 PYTHONPATH 中定义的所有路径包括在 sys.path 中。

因此,我们可以在项目的顶层创建一个 bash 脚本,设置 PYTHONPATH 中的正确路径,然后执行我们应用的main.py。记住,我们使用 bash 脚本来将一组命令行语句组合在一起,通过执行一个文件来运行它们(请回顾第三章以刷新记忆)。

在项目的顶层(与geom2dapps同一级别),创建一个名为cifpts.sh的新文件(“circle from points”的缩写)。在其中,写入 Listing 9-19 中的代码。

PYTHONPATH=$PWD python3 apps/circle_from_points/main.py

Listing 9-19:包装脚本

在这一行的第一件事是定义一个环境变量 PYTHONPATH,值设置为当前目录;当前目录存储在另一个 Unix 环境变量中:PWD。

然后,在同一行中,我们运行位于apps/circle_from_points中的main.py。将 PYTHONPATH 的定义与脚本运行放在同一行内,使环境变量仅限于脚本的执行。这意味着,一旦脚本执行完成,变量将不再存在。

让我们尝试从 shell 运行脚本,传递文件test.txt

$ bash cifpts.sh < apps/circle_from_points/test.txt

这应该会将 SVG 输出打印到 shell 中。我们甚至可以通过更改用户权限,使 bash 脚本看起来像一个可执行文件:

$ chmod +x cifpts.sh

这使我们能够进一步简化执行过程:

$ ./cifpts.sh < apps/circle_from_points/test.txt

记住,如果我们希望将结果写入文件而不是打印到 shell 中,输出需要重定向到文件:

$ ./cifpts.sh < apps/circle_from_points/test.txt > result.svg

这看起来更像是我们希望与朋友们分享的东西,所有朋友都渴望拥有一个可以计算通过任意三点的圆的脚本。

没有输入文件时运行应用程序

值得注意的是,尽管我们已经传递了一个包含三点坐标定义的文件,我们的代码只期待从标准输入中获取三行数据。这意味着我们不必创建一个文件来传递给脚本。我们只需要执行脚本并输入预期的数据。如果你在 shell 中尝试这个,

$ ./cifpts.sh > result.svg
$ 300 300
$ 700 400
$ 300 500

你将会在当前目录下得到一个名为result.svg的图像,图像中包含结果。如你所见,你可以直接从 shell 中向程序提供输入数据。

总结

在本章中,我们开发了我们的第一个应用程序:一个命令行工具,它读取一个文件,使用正则表达式解析文件,并生成一个漂亮的 SVG 向量图像。这个应用程序整合了我们在过去几章中学到的很多知识,并教会了我们正则表达式的使用。

我们还分析了一个问题:当从 shell 运行应用程序时,Python 无法找到我们的模块。我们了解到,这个问题发生是因为我们项目的根文件夹Mechanics不在 Python 用来解析导入的目录列表中。现在,你可以轻松地将你的Mechanics项目分发给你的朋友们,这样他们就可以使用我们将在本书中创建的应用程序,这些应用程序将方便地打包成顶级 bash 脚本。

第十章:图形用户界面与画布

图片

在我们深入仿真之前,我们需要了解图形用户界面(GUIs)的基础知识。这个话题非常广泛,我们只能略微触及皮毛,但我们会了解足够的信息来将我们的仿真展示给用户。

图形用户界面(GUIs)通常由一个父窗口(或多个窗口)组成,其中包含用户可以交互的控件,例如按钮或文本框。对于我们绘制仿真图形的目标,我们最感兴趣的控件是画布。在画布上,我们可以绘制几何图形原语,并且可以每秒重绘它们多次,这将帮助我们创造运动的感觉。

在这一章中,我们将介绍如何使用 Tkinter 布局图形用户界面,Tkinter 是 Python 标准库中自带的一个包。掌握这一点后,我们将实现一个类,方便我们将几何原语绘制到画布上。这个类还将包含一个仿射变换,作为其状态的一部分。我们将使用这个变换来影响所有原语的绘制方式,从而实现一些效果,例如将绘制内容垂直翻转,使得 y 轴指向上方。

Tkinter

Tkinter 是 Python 标准库中自带的一个包,用于构建图形用户界面。它提供了视觉组件,换句话说,就是控件,比如按钮、文本框和窗口。它还提供了画布,我们将用它来绘制仿真图形的框架。

Tkinter 是一个功能丰富的库;甚至有整本书专门讲解它(例如,参见[7])。我们只会讲解对于我们的目标而言需要的内容,但如果你喜欢创建 GUI,我建议你花些时间浏览 Tkinter 的在线文档;你可以学到很多东西,这些都能帮助你为程序构建精美的 GUI。

我们的第一个 GUI 程序

让我们在graphic文件夹中创建一个新包,将我们的仿真代码放入其中。右键单击graphic,选择新建Python 包,命名为simulation,然后点击确定。你项目中的文件夹结构应该如下所示:

Mechanics

|- apps

|    |- circle_from_points

|- geom2d

|    |- tests

|- graphic

|    |- simulation

|    |- svg

|- utils

现在,让我们创建第一个 GUI 程序来熟悉 Tkinter。在新创建的simulation文件夹中,添加一个名为hello_tkinter.py的新 Python 文件。输入清单 10-1 中的代码。

from tkinter import Tk

tk = Tk()
tk.title("Hello Tkinter")

tk.mainloop()

清单 10-1:Hello Tkinter

要执行文件中的代码,请在项目树面板中右键单击该文件,然后从弹出的菜单中选择运行 ‘hello_tkinter’。当你执行代码时,一个标题为“Hello Tkinter”的空窗口将会打开,如图 10-1 所示。

图片

图 10-1:空的 Tkinter 窗口

让我们回顾一下我们刚刚写的代码。我们首先从 tkinter 中导入 Tk 类。tk 变量保存了一个 Tk 实例,代表 Tkinter 程序中的主窗口。这个窗口在文档和在线示例中也被称为root

然后,我们将窗口的标题设置为 Hello Tkinter,并运行主循环。注意,主窗口不会在屏幕上显示,直到主循环开始。在图形界面程序中,主循环是一个无限循环:它在程序执行时一直运行;在运行过程中,它会收集窗口中的用户事件并做出反应。

图形用户界面与我们之前编写的其他程序不同,因为它们是事件驱动的。这意味着可以配置图形组件,在接收到期望的事件类型时运行某些代码。例如,我们可以告诉按钮在接收到点击事件时显示一条消息,也就是当它被点击时。反应事件的代码通常称为事件处理程序

让我们添加一个文本字段,供用户输入名字,并添加一个按钮,通过名字向用户打招呼。修改你的hello_tkinter.py文件,包含清单 10-2 中的代码。注意文件顶部新增的导入部分。

  from tkinter import Tk, Label, Entry, Button, StringVar

  tk = Tk()
  tk.title("Hello Tkinter")

➊ Label(tk, text='Enter your name:').grid(row=0, column=0)
➋ name = StringVar()
➌ Entry(tk, width=20, textvariable=name).grid(row=1, column=0)
➍ Button(tk, text='Greet me').grid(row=1, column=1)

  tk.mainloop()

清单 10-2:Hello Tkinter 小部件

为了添加标签“Enter your name:”,我们从 tkinter 实例化了 Label 类 ➊。我们将构造函数传递给程序主窗口(tk)的引用,并传递一个带有要显示文本的命名参数:text='Enter your name:’。在标签出现在窗口之前,我们需要告诉它在窗口中的位置。

在创建的 Label 实例上,我们调用带有命名参数 row 和 column 的 grid 方法。此方法将小部件放置在窗口中一个不可见的网格里,按照给定的行和列索引。网格中的单元格会根据内容自动调整大小。正如代码中所示,我们在每个小部件上调用此方法,以便将它们定位到窗口中。图 10-2 展示了我们 UI 的网格。还有其他方式可以在窗口中放置组件,但我们现在将使用这种方式,因为它足够灵活,能够轻松地排列组件。

Image

图 10-2:Tkinter 网格

Tkinter 中的输入字段被称为 Entry ➌。为了访问该字段的内容(即输入的文本),我们必须首先创建一个 StringVar 变量,称为 name ➋。这个变量通过 textvariable 参数传递给 Entry 组件。我们可以通过在实例上调用 get 来获取字段中写入的字符串,稍后我们将这样做。最后,我们创建一个按钮,文本为“Greet me” ➍;这个按钮点击时不会做任何事情(我们稍后会添加功能)。

运行该文件。现在你应该能看到一个标签、一个文本字段和一个按钮,如图 10-3 所示。

Image

图 10-3:一些 Tkinter 小部件

让我们通过为按钮点击事件添加一个事件处理程序来完成我们的程序,点击按钮后会弹出一个新对话框,显示问候信息。修改你的代码,使其看起来像清单 10-3。

  from tkinter import Tk, Label, Entry, Button, StringVar, messagebox

  tk = Tk()
  tk.title("Hello Tkinter")

➊ def greet_user():
      messagebox.showinfo(
         'Greetings',
         f'Hello, {name.get()}'
      )

  Label(tk, text='Enter your name:').grid(row=0, column=0)
  name = StringVar()
  Entry(tk, width=20, textvariable=name).grid(row=1, column=0)
  Button(
     tk,
     text='Greet me',
   ➋ command=greet_user
  ).grid(row=1, column=1)

tk.mainloop()

清单 10-3:问候用户的 Hello Tkinter

我们添加了一个名为 greet_user ➊ 的函数。这个函数会打开一个标题为“Greetings”的信息对话框,显示一条问候信息,内容是向用户在文本框中输入的名字打招呼。请注意,我们从 tkinter 导入了 messagebox 来调用 showinfo 函数,这个函数实际执行打开对话框的工作。为了将按钮点击事件与 greet_user 函数连接,我们需要通过参数 command 将其传递给 Button 的构造函数 ➋。

现在运行文件。别忘了每次想要执行新代码时,先关闭应用程序的窗口,然后重新运行程序。在文本框中输入你的名字并点击按钮。程序应该会弹出一个新对话框,显示个性化的问候信息,类似于图 10-4。

Image

图 10-4:我们的 Tkinter 问候程序

Tkinter 还能做很多事情,但在本书中我们不需要使用那么多功能。我们主要关注的是它的画布组件,接下来我们将深入探索这一部分。如果你想了解更多关于 Tkinter 的信息,网上有很多优秀的资源。你也可以参考之前提到的[7]。

画布

画布是一个可以绘制的区域。在 Tkinter 的数字世界中,情况也是如此。Tkinter 的画布组件由 tkinter 中的 Canvas 类表示。

让我们创建一个新的 Tkinter 应用程序,在其中尝试绘制画布内容。在 simulation 文件夹中,创建一个名为 hello_canvas.py 的新文件,并输入清单 10-4 中的代码。

from tkinter import Tk, Canvas

tk = Tk()
tk.title("Hello Canvas")

canvas = Canvas(tk, width=600, height=600)
canvas.grid(row=0, column=0)

tk.mainloop()

清单 10-4:Hello Canvas

这段代码创建了一个 Tkinter 应用程序,包含了一个主窗口和一个 600x600 像素的画布。如果你运行这个文件,应该会看到一个空的窗口,标题为“Hello Canvas”。画布已经存在,只是暂时还没有绘制任何内容。

绘制线条

我们从简单的开始,在画布上画一条线。只需要在创建画布和启动主循环之间,添加以下这一行:

--snip--

canvas.create_line(0, 0, 300, 300)

tk.mainloop()

传递给 create_line 的参数分别是起点的 x 和 y 坐标,以及终点的 x 和 y 坐标。

现在再次运行文件。应该会看到一条从屏幕左上角(0, 0)到屏幕中央(300, 300)的线段。正如你所猜测的那样,坐标原点在屏幕的左上角,y 轴朝下。稍后当我们进行动画模拟时,我们将使用仿射变换来修正这一点。

默认情况下,线条的宽度是 1 像素,颜色是黑色,但我们可以进行更改。试试以下代码:

canvas.create_line(
    0, 0, 300, 300,
    width=3,
    fill='#aa3355'
)

现在这条线变得更粗,而且呈现出红色。你的结果应该和图 10-5 类似。

Image

图 10-5:Tkinter 画布上的一条线

绘制椭圆

让我们使用与前一条线相同的颜色,在应用程序窗口的中央绘制一个圆形:

--snip--

canvas.create_oval(
    200, 200, 400, 400,
    width=3,
    outline='#aa3355'
)

tk.mainloop()

传递给 create_oval 的参数是包含椭圆的矩形左上角的 x 和 y 坐标,以及右下角的 x 和 y 坐标。接着是用于确定线条宽度和颜色的命名参数:widthoutline

如果你运行文件,你会看到一个位于窗口中心的圆形。让我们通过将它的宽度增加 100 像素,保持高度为 400 像素,将其变成一个正确的椭圆:

canvas.create_oval(
    200, 200, 500, 400,
    width=3,
    outline='#aa3355'
)

通过将右下角的 x 坐标从 400 改为 500,圆形变成了椭圆。现在应用程序有了一个同时包含线条和椭圆的画布,如 图 10-6 所示。

Image

图 10-6:添加到 Tkinter 画布上的椭圆

如果我们想为椭圆添加填充颜色,可以使用命名参数 fill='...'。以下是一个示例:

canvas.create_oval(
    200, 200, 500, 400,
    width=3,
    outline='#aa3355',
    fill='#cc3355',
)

不过有一个限制:Tkinter 不支持透明度,这意味着我们的填充和描边将完全不透明。Tkinter 不支持格式为 #rrggbbaa 的颜色,其中 aa 是 alpha(透明度)值。

绘制矩形

绘制矩形也相当简单。在文件中输入以下代码:

--snip--

canvas.create_rectangle(
    40, 400, 500, 500,
    width=3,
    outline='#aa3355'
)

tk.mainloop()

create_rectangle 的必需参数是矩形左上角的 x 和 y 坐标,以及右下角的 x 和 y 坐标。

运行文件,结果应该类似于 图 10-7。

Image

图 10-7:添加到 Tkinter 画布上的矩形

不错!结果图像变得越来越奇怪,但在画布上绘制图形是不是既简单又有趣?

绘制多边形

我们需要了解的最后一个几何原始图形是通用多边形。在你添加了绘制矩形的代码之后,写入以下内容:

--snip--

canvas.create_polygon(
    [40, 200, 300, 450, 600, 0],
    width=3,
    outline='#aa3355',
    fill=''
)

tk.mainloop()

create_polygon 的第一个参数是一个顶点坐标的列表。其余的是影响样式的命名参数。请注意,我们将空字符串传递给 fill 参数;默认情况下,多边形是填充的,但我们希望它只有轮廓。运行文件查看结果。它应该类似于 图 10-8。

Image

图 10-8:添加到 Tkinter 画布上的多边形

我们创建了一个顶点为 (40, 200)、(300, 450) 和 (600, 0) 的三角形。尝试添加填充颜色并查看结果。

绘制文本

它不是一个几何原始图形,但我们可能还需要在画布上绘制一些文本。使用 create_text 方法可以轻松做到这一点。将以下内容添加到 hello_canvas.py

--snip--

canvas.create_text(
    300, 520,
    text='This is a weird drawing',
    fill='#aa3355',
    font='Helvetica 20 bold'
)

tk.mainloop()

前两个参数是文本中心的 x 和 y 坐标。命名参数 text 是我们设置实际文本的地方;我们可以使用 font 来更改字体。最后运行文件一次,查看完整的绘图,如 图 10-9 所示。

如果我们可以绘制线条、圆形、矩形、通用多边形和文本,我们几乎可以绘制任何东西。我们也可以使用弧线和样条曲线,但我们将通过仅使用这些简单的原语来完成我们的仿真。

图片

图 10-9: 添加到我们的 Tkinter 画布上的文本

你的最终代码应该如下所示,参考清单 10-5。

from tkinter import Tk, Canvas

tk = Tk()
tk.title("Hello Canvas")

canvas = Canvas(tk, width=600, height=600)
canvas.grid(row=0, column=0)

canvas.create_line(
    0, 0, 300, 300,
    width=3,
    fill='#aa3355'
)
canvas.create_oval(
    200, 200, 500, 400,
    width=3,
    outline='#aa3355'
)
canvas.create_rectangle(
    40, 400, 500, 500,
    width=3,
    outline='#aa3355'
)
canvas.create_polygon(
    [40, 200, 300, 450, 600, 0],
    width=3,
    outline='#aa3355',
    fill=''
)
canvas.create_text(
    300, 520,
    text='This is a weird drawing',
    fill='#aa3355',
    font='Helvetica 20 bold'
)

tk.mainloop()

清单 10-5: 最终绘图代码

现在我们知道如何将简单的原语绘制到画布上,让我们想出一种方法,直接将我们的geom2d库的几何原语绘制到画布上。

绘制我们的几何原语

使用画布的 create_oval 方法绘制圆形非常简单。然而,这个方法并不方便;为了定义圆形,你需要传递定义一个矩形的两个顶点的坐标,该矩形内接圆形或椭圆。另一方面,我们的 Circle 类通过它的中心点和半径来定义,并且具有一些有用的方法,可以使用 AffineTransform 的实例进行变换。如果我们能像这样直接绘制我们的圆形,那就太好了:

circle = Circle(Point(2, 5), 10)
canvas.draw_circle(circle)

我们肯定希望与我们的几何原语一起工作。类似于我们在第八章中创建它们的 SVG 表示方式,我们需要一种方法将它们绘制到画布上。

计划如下:我们将为 Tkinter 的 Canvas 小部件创建一个包装类。我们将创建一个包含我们希望绘制的画布实例的类,但它的方法允许我们传递自己的几何原语。为了利用我们强大的仿射变换实现,我们将把变换与绘图关联,这样我们传递的所有原语将首先经过变换。

画布包装类

包装类就是一个包含另一个类(它所包装的类)实例的类,并用于提供与被包装类相似的功能,但具有不同的接口和一些附加的功能。这是一个简单却强大的概念。

在这种情况下,我们正在包装一个 Tkinter 画布。我们的画布包装类的目标是让我们以简单、干净的接口绘制我们的几何原语:我们希望方法能够直接接受我们的原语实例。这个包装类将帮助我们避免重复的任务,即将几何类的表示适配为 Tkinter 画布绘图方法所期望的输入。不仅如此,我们还会对我们绘制的所有内容应用仿射变换。图 10-10 描述了这个过程。

simulation包中,创建一个名为draw.py的新文件。输入清单 10-6 中的代码。

from tkinter import Canvas

from geom2d import AffineTransform

class CanvasDrawing:

    def __init__(self, canvas: Canvas, transform: AffineTransform):
        self.__canvas = canvas
        self.outline_color = '#aa3355'
        self.outline_width = 3
        self.fill_color = ''
        self.transform = transform

    def clear_drawing(self):
        self.__canvas.delete('all')

清单 10-6: 画布包装类

CanvasDrawing 类被定义为 Tkinter 画布的包装类。一个画布实例被传递给初始化函数并存储在一个私有变量 __canvas 中。将 __canvas 设为私有意味着我们不希望任何使用 CanvasDrawing 的人直接访问它。它现在属于包装类实例,应该仅通过其方法使用。

一个 AffineTransform 实例也被传递给初始化函数。我们将在绘制到 Tkinter 画布之前,将这个仿射变换应用到所有几何原件。变换存储在一个公共变量中:transform。这意味着我们允许 CanvasDrawing 实例的用户直接操作和编辑这个属性,它是实例状态的一部分。我们这样做是为了让改变绘制时应用的仿射变换变得简单,通过将 transform 属性重新赋值为一个不同的变换。

一个实例的状态定义了它的行为:如果状态发生变化,实例的行为也会随之变化。在这种情况下,很明显,如果属性 transform 被重新分配为一个不同的仿射变换,所有后续的绘图命令将会根据这个变换产生结果。

图 10-10 是一个表示我们画布包装类行为的图示。它将接收不同几何原件的绘制请求,应用仿射变换,并调用 Tkinter 的画布方法将其绘制到画布上。

图像

图 10-10:画布包装类

初始化函数中还定义了其他状态变量:outline_color,用于定义几何体轮廓的颜色,outline_width 用于轮廓的宽度,fill_color 用于填充几何体的颜色。这些在初始化函数中有默认值(在前一节示例中使用的那些),但它们也是公共的,实例的用户可以更改它们。如同之前一样,应该清楚这些属性是实例状态的一部分:如果我们编辑 outline_color,例如,所有后续的绘图都会使用该颜色作为轮廓色。

我们在这个类中只定义了一个方法:clear_drawing。这个方法将清除画布,在每次绘制每个帧之前使用。现在让我们聚焦于绘图命令。

绘制线段

让我们从最简单的几何原件开始绘制:线段。在 Canvas Drawing 类中,输入清单 10-7 中的方法。对于这段代码,你需要先更新geom2d的导入,包含 Segment 类。

from tkinter import Canvas

from geom2d import Segment, AffineTransform

class CanvasDrawing:
   --snip--

   def draw_segment(self, segment: Segment):
       segment_t = self.transform.apply_to_segment(segment)
       self.__canvas.create_line(
           segment_t.start.x,
           segment_t.start.y,
           segment_t.end.x,
           segment_t.end.y,
           fill=self.outline_color,
           width=self.outline_width
       )

清单 10-7:绘制线段

注意

注意我们是如何将 self.outline_color 值传递给 fill 参数的。这看起来像是一个错误,但不幸的是,Tkinter 选择了一个不好的名字。 fill 属性用于 create_line 命令中的笔触颜色。更好的名字应该是 outline 或者,更好的是, stroke-color。

draw_segment 方法做了两件事:首先,它使用当前仿射变换转换给定的段,并将结果存储在 segment_t 中。然后,它调用画布实例的 create_line 方法。对于轮廓颜色和宽度,我们使用实例的状态变量。

让我们继续讨论多边形、圆形和矩形。

绘制多边形

如果你还记得在第 179 页的“转换段和多边形”中提到的内容,一旦仿射变换应用到圆形或矩形上,结果将是一个通用的多边形。这意味着所有三个多边形都将使用画布的 create_polygon 方法进行绘制。

让我们创建一个私有方法,将多边形绘制到画布上,忽略仿射变换;这一部分将由每个公共绘图方法来处理。

在你的 CanvasDrawing 类中,输入清单 10-8 中的私有方法。

from functools import reduce
from tkinter import Canvas

from geom2d import Polygon, Segment, AffineTransform

class CanvasDrawing:
    --snip--

    def __draw_polygon(self, polygon: Polygon):
        vertices = reduce(
            list.__add__,
            [[v.x, v.y] for v in polygon.vertices]
        )

        self.__canvas.create_polygon(
            vertices,
            fill=self.fill_color,
            outline=self.outline_color,
            width=self.outline_width
        )

清单 10-8:将多边形绘制到画布上

对于这段代码,你需要添加以下导入:

from functools import reduce

并更新来自 geom2d 的导入:

from geom2d import Polygon, Segment, AffineTransform

__draw_polygon 方法首先准备多边形的顶点坐标,以符合画布小部件的 create_polygon 方法的期望。这是通过使用 Python 的列表 __add__ 方法来减少多个顶点坐标列表实现的,正如你回忆的那样,__add__ 方法重载了 + 操作符。

让我们分解一下。首先,通过列表推导式将多边形的顶点映射出来:

[[v.x, v.y] for v in polygon.vertices]

这将创建一个包含每个顶点的 x 和 y 坐标的列表。如果多边形的顶点是 (0, 10)、(10, 0) 和 (10, 10),之前展示的列表推导式将生成如下列表:

[[0, 10], [10, 0], [10, 10]]

这个列表接下来需要被 扁平化:内嵌列表中的所有值(数值坐标)必须连接成一个单一的列表。扁平化之前的列表的结果将如下所示:

[0, 10, 10, 0, 10, 10]

这是方法 create_polygon 所期望的顶点坐标列表。最终的扁平化步骤是通过 reduce 函数实现的;我们传递给它列表的 .__add__ 操作符,它生成一个新的列表,该列表由连接两个列表操作数的结果组成。要查看这个过程的实际效果,你可以在 Python 的交互式命令行中测试以下内容:

>>> [1, 2] + [3, 4]
[1, 2, 3, 4]

一旦顶点坐标列表准备好,将其绘制到画布上就变得简单了:我们只需将该列表传递给画布的 create_polygon 方法。现在最困难的部分已经完成,绘制多边形应该会更容易。将清单 10-9 中的代码输入到你的类中。

from functools import reduce
from tkinter import Canvas

from geom2d import Circle, Polygon, Segment, Rect, AffineTransform

class CanvasDrawing:
    --snip--

   def draw_circle(self, circle: Circle, divisions=30):
       self.__draw_polygon(
           self.transform.apply_to_circle(circle, divisions)
       )

   def draw_rectangle(self, rect: Rect):
       self.__draw_polygon(
           self.transform.apply_to_rect(rect)
       )

   def draw_polygon(self, polygon: Polygon):
       self.__draw_polygon(
           self.transform.apply_to_polygon(polygon)
       )

清单 10-9:绘制圆形、矩形和通用多边形

不要忘记添加来自 geom2d 的缺失导入:

from geom2d import Circle, Polygon, Segment, Rect, AffineTransform

在这三种方法中,过程是相同的:调用私有方法 __draw_polygon,并将当前仿射变换应用到几何形状后的结果传递给它。不要忘了,在圆形的情况下,我们需要将用来近似圆形的分割数传递给变换方法。

绘制箭头

现在让我们按照我们在第八章中为 SVG 图像使用的方法来绘制箭头。

箭头的头部将绘制在线段的端点E上,由两条线段在该端点成角度相交组成。为了提供一些灵活性,我们将使用二维来定义箭头的几何形状:长度和高度(参见图 10-11)。

如你在图 10-11(从第八章重复)中看到的,要绘制箭头的头部,我们需要弄清楚点C[1]和C[2]的位置。有了这两个点,我们就可以轻松绘制从C[1]到E以及从C[2]到E的线段。

Image

图 10-11:箭头中的关键点

为了找出这些点在平面中的位置,我们将计算三个向量:Image,它的长度与箭头头部相同,并且方向与线段的方向向量相反,另外两个是ImageImage,它们与线段垂直,且长度都等于箭头头部高度的一半。图 10-11 展示了这些向量。点C[1]可以通过创建一个E(线段的端点)的平移版本来计算,

Image

同样地,C[2]:

Image

让我们编写这个方法。在 CanvasDrawing 类中,输入清单 10-10 中的代码。

class CanvasDrawing:
    --snip--

    def draw_arrow(
            self,
            segment: Segment,
            length: float,
            height: float
    ):
        director = segment.direction_vector
        v_l = director.opposite().with_length(length)
        v_h1 = director.perpendicular().with_length(height / 2.0)
        v_h2 = v_h1.opposite()

        self.draw_segment(segment)
        self.draw_segment(
            Segment(
                segment.end,
             ➊ segment.end.displaced(v_l + v_h1)
            )
        )
        self.draw_segment(
            Segment(
                segment.end,
             ➋ segment.end.displaced(v_l + v_h2)
            )
        )

清单 10-10:绘制箭头

我们首先计算出需要的三个向量,利用之前的公式找出点C[1]和C[2]的位置。正如你所看到的,由于我们在 Vector 类中实现的方法,这一过程相当直接。例如,为了获得Image,我们使用线段方向向量的相反向量,并将其缩放到所需的长度。我们使用类似的操作来计算我们方程式中的其余元素。

然后我们有三个线段:基准线,即作为参数传递的线段;从EC[1] ➊ 的线段;以及从EC[2] ➋ 的线段。

作为参考,你的drawing.py文件应该如下所示:清单 10-11。

from functools import reduce
from tkinter import Canvas

from geom2d import Circle, Polygon, Segment, Rect, AffineTransform

class CanvasDrawing:

    def __init__(self, canvas: Canvas, transform: AffineTransform):
        self.__canvas = canvas
        self.outline_color = '#aa3355'
        self.outline_width = 3
        self.fill_color = ''
        self.transform = transform

    def clear_drawing(self):
        self.__canvas.delete('all')

    def draw_segment(self, segment: Segment):
        segment_t = self.transform.apply_to_segment(segment)
        self.__canvas.create_line(
            segment_t.start.x,
            segment_t.start.y,
            segment_t.end.x,
            segment_t.end.y,
            outline=self.outline_color,
            width=self.outline_width
        )

    def draw_circle(self, circle: Circle, divisions=30):
        self.__draw_polygon(
            self.transform.apply_to_circle(circle, divisions)
        )

    def draw_rectangle(self, rect: Rect):
        self.__draw_polygon(
            self.transform.apply_to_rect(rect)
        )

    def draw_polygon(self, polygon: Polygon):
        self.__draw_polygon(
            self.transform.apply_to_polygon(polygon)
        )

    def __draw_polygon(self, polygon: Polygon):
        vertices = reduce(
            list.__add__,
            [[v.x, v.y] for v in polygon.vertices]
        )

        self.__canvas.create_polygon(
            vertices,
            fill=self.fill_color,
            outline=self.outline_color,
            width=self.outline_width
        )

    def draw_arrow(
            self,
            segment: Segment,
            length: float,
            height: float
    ):
        director = segment.direction_vector
        v_l = director.opposite().with_length(length)
        v_h1 = director.perpendicular().with_length(height / 2.0)
        v_h2 = v_h1.opposite()

        self.draw_segment(segment)
        self.draw_segment(
            Segment(
                segment.end,
                segment.end.displaced(v_l + v_h1)
            )
        )
        self.draw_segment(
            Segment(
                segment.end,
                segment.end.displaced(v_l + v_h2)
            )
        )

清单 10-11:CanvasDrawing 类结果

现在我们有了一种方便的方式来绘制几何图形,但它们完全没有运动,我们需要运动来生成模拟。让这些几何图形栩栩如生的缺失成分是什么呢?这就是下一章的主题。事情变得越来越激动人心!

总结

在本章中,我们介绍了使用 Python 的 Tkinter 包创建图形用户界面的基础知识。我们学习了如何使用网格系统将小部件放置到主窗口中。我们还了解了如何让按钮响应点击事件,以及如何读取文本框的内容。最重要的是,我们学习了 Canvas 类及其方法,利用这些方法我们可以在画布上绘制简单的原始图形。

我们通过创建自己的类来结束这一章节,该类封装了 Tkinter 的画布,并允许我们直接绘制几何原始图形。这个类还包括一个仿射变换,在图形绘制之前应用于原始图形。该类具有定义笔触宽度和颜色以及填充颜色的属性。这些属性决定了我们用它绘制的几何图形的宽度和颜色。现在是时候让这些静态几何图形动起来了。

第十一章:动画、仿真与时间循环

Image

就像矢量图像用于可视化静态问题一样,动画帮助我们为动态问题建立直观的理解。单一的图像只能展示某一特定时刻的状态。当系统的属性随时间变化时,我们就需要动画来讲述完整的故事。

就像静态分析展示系统在某一时刻的状态一样,仿真展示了系统随时间的演变。动画是展示这种演变结果的好方法。工程师进行动态系统仿真有两个重要原因:它是巩固对这些系统理解的好方法,而且过程也相当有趣。

在本章中,我们将开始探索动画的迷人世界,从一些定义开始。然后我们将学习如何使图形在画布上移动。我们将使用 Tkinter 的画布,并且更重要的是,我们的 CanvasDrawing 包装类。

定义术语

让我们定义一下在本节中将使用的几个术语。

什么是动画?

动画是通过快速连续的图像生成的运动感知。因为计算机以极快的速度将这些图像绘制到屏幕上,所以我们的眼睛感知到了运动。

我们通过将图形绘制到画布上、清除画布,然后再绘制其他图形来制作动画。每个图形会在屏幕上停留极短的时间,这一帧图像被称为

以图 11-1 为例,它展示了动画的每一帧:一个三角形向右移动。

Image

图 11-1:三角形的动画帧

动画中的四个帧中,三角形的位置略有不同。如果我们将它们依次绘制到画布上,并清除之前的图形,三角形看起来就会移动。

很简单,不是吗?我们将在本章中构建我们的第一个动画,但首先让我们定义一下系统仿真这两个术语,因为它们将在我们的讨论中频繁出现。

什么是系统?

在我们的上下文中,系统一词指的是我们在动画中绘制到画布上的任何东西。它由一组物体组成,这些物体遵循某些物理定律并彼此相互作用。我们将使用这些定律推导出数学模型,通常是以微分方程组的形式。我们将使用数值方法求解这些方程,这些方法会在离散的时间点上给出描述系统的值。这些值可能是系统的位置或速度。

现在让我们来看一个系统的例子,并推导它的方程。假设我们有一个质量为m的物体,受一个关于时间的外力作用,Image。图 11-2 展示了一个自由体图。在这里,你可以看到施加的外力及其重力作用,其中Image是重力加速度矢量。

Image

图 11-2:受外力作用的物体

使用牛顿第二定律,并将物体的位置向量表示为Image,我们得到以下方程:

Image

求解加速度Image

Image

上述向量方程可以分解为其两个标量分量:

Image

这两个方程表示物体加速度是时间的函数。为了模拟这个简单系统,我们需要为动画的每一帧获得物体的加速度、速度和位置的新值。我们稍后会看到这意味着什么。

什么是仿真?

仿真是研究一个系统演化的过程,该系统的行为可以通过数学公式描述。仿真利用现代中央处理单元(CPU)的计算能力来理解在实际条件下给定系统的表现。

计算机仿真通常比实际实验更便宜且更易于设置,因此它们被广泛用于研究和预测许多工程设计的行为。

以我们在上一节中推导的系统为例。给定一个关于时间的外力表达式,如下所示:

Image

物体的质量假设为m = 5kg 时,加速度方程变为以下形式。

Image

这些标量方程给出了物体在每一时刻的加速度分量。由于这些方程简单,我们可以通过积分得到速度分量的表达式,

Image

其中[0]和[0]是初始速度的分量:即时间t = 0 时的速度。我们知道物体在每一时刻的速度。如果我们想要为物体的运动制作动画,就需要一个位置的表达式,这可以通过积分速度方程得到,

Image

其中X[0]和Y[0]是物体的初始位置分量。我们现在可以通过创建一系列时间值来生成动画,计算每个时间点的位置,然后在该位置上画一个矩形显示在屏幕上,从而理解物体在外力作用下如何运动。

与该例子中系统加速度如何随时间变化相关的微分方程是直接的,这使得我们能够通过积分得到解析解。我们通常无法为仿真中的系统获得解析解,因此我们倾向于使用数值方法。

解析解是精确解,而数值解是通过计算机算法寻找解的近似值。常见的数值方法,尽管不是最精确的,是欧拉法

实时绘制模拟意味着我们需要在每次绘制帧时都解算一次方程。例如,如果我们希望以每秒 50 帧(fps)的速率进行模拟,那么我们需要每秒解算方程 50 次并绘制 50 帧。

在 50 帧每秒(fps)的情况下,帧之间的时间为 20 毫秒。考虑到计算机需要一些时间来重绘当前帧,我们只剩下很少的时间来进行数学计算。

模拟也可以提前计算好,然后稍后回放。通过这种方式,解算方程的时间可以根据需要延长;只有当所有帧都准备好时,动画才会进行。

视频游戏引擎使用实时模拟,因为它们需要模拟玩家与世界互动的过程,这种互动无法提前确定。这些引擎通常以速度换取精度;它们的结果在物理上不完全准确,但从肉眼看起来是逼真的。

复杂的工程系统需要提前模拟,因为这些问题的控制方程非常复杂,需要更精确的解决方案。

什么是时间循环?

实时模拟发生在一个循环内,我们将其称为时间循环(time loop)或主循环(main loop)。这个循环每秒执行的次数与屏幕上绘制的帧数相同。以下是一些伪代码,展示时间循环可能的样子:

while current_time < end_time:
    solve_system_equations()
    draw_system()
    sleep(time_delta - time_taken)
    current_time += time_delta

为了使动画看起来更流畅,我们需要保持稳定的帧率。这意味着模拟的绘制阶段应该在时间上均匀分布地进行。(虽然这并非绝对必要,但有些技术可以根据处理器和 GPU 的吞吐量调整帧率,不过在本书中我们不会深入探讨这些技术。)

连续帧之间的时间差称为时间差(time delta)或δt;它与帧率(fps)成反比,通常以秒或毫秒为单位进行度量: Image。因此,时间循环中发生的所有事情都应该在一个时间差内完成。

循环的第一步是解算方程,找出系统在经过的时间差(delta)期间是如何演变的。然后,我们将系统的新配置绘制到屏幕上。我们需要测量当前循环中已经过去的时间,并将结果存储在time_taken变量中。

此时,程序会暂停或进入休眠状态,直到整个时间差已过。我们可以通过从time_delta中减去time_taken来计算需要休眠的时间。在结束循环之前的最后一步是将当前时间向前推进一个时间差;然后循环重新开始。图 11-3 展示了时间线和时间循环中的事件。

Image

图 11-3:时间循环事件

现在我们已经了解了这些定义,接下来让我们实现一个时间循环并开始动画制作。

我们的第一个动画

在本章开始时,我们解释了如何通过每秒绘制某些东西多次来实现运动的感觉。时间循环负责保持这些绘制的速率稳定。让我们实现我们的第一个时间循环。

设置

我们将从创建一个新文件开始,以便进行实验。在simulation包中创建一个新文件,并命名为hello_motion.py。输入 Listing 11-1 中的代码。

  import time
  from tkinter import Tk, Canvas

  tk = Tk()
  tk.title("Hello Motion")

  canvas = Canvas(tk, width=600, height=600)
  canvas.grid(row=0, column=0)

  frame_rate_s = 1.0 / 30.0
  frame_count = 1
  max_frames = 100

  def update_system():
      pass

  def redraw():
      pass

➊ while frame_count <= max_frames:
       update_start = time.time()
➋ update_system()
    ➌ redraw()
    ➍ tk.update()
       update_end = time.time()

    ➎ elapsed_s = update_end - update_start
       remaining_time_s = frame_rate_s - elapsed_s

       if remaining_time_s > 0:
        ➏ time.sleep(remaining_time_s)
      frame_count += 1

  tk.mainloop()

Listing 11-1: hello_motion.py 文件

在 Listing 11-1 中的代码中,我们首先创建一个 600 × 600 像素的画布,并将其添加到主窗口的网格中。然后,我们初始化一些变量:frame_rate_s保存两帧之间的时间,以秒为单位;frame_count是已经绘制的帧数;max_frames是我们将要绘制的总帧数。

注意

请注意,存储时间相关量的变量在其名称中包含了它们使用的单位信息。 s frame_rate_s elapsed_s 中表示秒。这是一种良好的实践,因为它帮助开发者理解代码使用的单位,而无需阅读注释或遍历所有代码。当你每天花费许多小时编写代码时,这些小细节最终会为你节省大量时间和精力。

然后是时间循环 ➊,它在每秒frame_rate_s的速度下执行max_frames次,至少在理论上是这样(稍后你会看到)。请注意,我们选择使用最大帧数来限制仿真,但我们也可以通过时间来限制,即在给定的时间过去后继续运行循环,就像我们在前面的伪代码中做的那样。这两种方法都可以正常工作。

在循环中,我们首先将当前时间存储在update_start中。系统更新和绘图完成后,我们再次存储时间,这次存储在update_end中。然后,通过从update_end中减去update_start来计算经过的时间,并将其存储在elapsed_s ➎中。我们使用这个值来计算循环需要睡眠多长时间,以保持帧率稳定,即从frame_rate_s中减去elapsed_s。该值存储在remaining_time_s中,如果它大于零,我们让循环休眠 ➏。

如果remaining_time_s小于零,说明循环比帧率要求的时间要长,意味着它无法跟上我们设定的节奏。如果这种情况经常发生,时间循环将变得不稳定,动画可能会显得卡顿,在这种情况下,最好减少帧率。

魔法发生在(或者更准确地说,将要发生在)update _system ➋ 和 redraw ➌ 中,我们在循环中调用它们以更新和重绘系统。这就是我们很快会写入绘图代码的地方。pass语句在 Python 中作为占位符使用:它不执行任何操作,但它允许我们例如拥有一个有效的函数体。

还调用了从主窗口 tk ➍ 更新,这告诉 Tkinter 运行主循环,直到所有待处理事件都被处理完。这是强制 Tkinter 查找可能触发用户界面小部件(包括我们的画布)变化的事件所必需的。

你现在可以运行文件了;你会看到一个空白窗口,表面上什么也没做,但它实际上正在运行最大帧数次数的循环。

添加帧计数标签

让我们在画布下方添加一个标签,显示当前绘制到画布上的帧以及总帧数。我们可以在 update 中更新它的值。首先,向 tkinter 导入中添加 Label:

from tkinter import Tk, Canvas, StringVar, Label

然后,在画布定义下方添加标签(Listing 11-2)。

label = StringVar()
label.set('Frame ? of ?')
Label(tk, textvariable=label).grid(row=1, column=0)

Listing 11-2: 向窗口添加标签

最后,通过设置标签的文本变量 label 的值,在 update 中更新标签的文本(Listing 11-3)。

def update():
    label.set(f'Frame {frame_count} of {max_frames}')

Listing 11-3: 更新标签文本

现在试着运行文件。画布仍然是空白的,但下面的标签现在显示了当前帧。你的程序应该像 Figure 11-4 那样:一个空白窗口,帧计数从 1 到 100。

Image

Figure 11-4: 帧计数标签

仅供参考,此阶段你的代码应该像 Listing 11-4 这样。

import time
from tkinter import Tk, Canvas, StringVar, Label

tk = Tk()
tk.title("Hello Motion")

canvas = Canvas(tk, width=600, height=600)
canvas.grid(row=0, column=0)

label = StringVar()
label.set('Frame ? of ?')
Label(tk, textvariable=label).grid(row=1, column=0)

frame_rate_s = 1.0 / 30.0
frame_count = 1
max_frames = 100

def update_system():
    pass

def redraw():
    label.set(f'Frame {frame_count} of {max_frames}')

while frame_count <= max_frames:
    update_start = time.time()
    update_system()
    redraw()
    tk.update()
    update_end = time.time()

    elapsed_s = update_end - update_start
    remaining_time_s = frame_rate_s - elapsed_s

    if remaining_time_s > 0:
        time.sleep(remaining_time_s)

    frame_count += 1

tk.mainloop()

Listing 11-4: 画布与标签

为了在画布上绘制任何内容,我们需要有一个系统。让我们先来看一下如何向我们的模拟中添加和更新一个系统。

更新系统

在这个示例中,我们将保持简单,绘制一个圆,其中心始终位于画布的中心点(300, 300)。它的半径将从零开始增长。当半径大于画布并不再可见时,我们将其重置为零。这将生成一个类似迷幻隧道的效果。

我们可以通过 Circle 类的一个实例来表示我们的“系统”。由于我们将把圆绘制到画布上,所以我们还需要创建一个 Canvas Drawing 的实例,并使用身份仿射变换。在变量 frame_rate_s、frame_count 和 max_frames 的定义下,添加以下内容:

transform = AffineTransform(sx=1, sy=1, tx=0, ty=0, shx=0, shy=0)
drawing = CanvasDrawing(canvas, transform)
circle = Circle(Point(300, 300), 0)

别忘了包含必要的导入:

from geom2d import Point, Circle, AffineTransform
from graphic.simulation.draw import CanvasDrawing

我们需要在 update_system 中每帧更新半径的值,这样当重新绘制时,圆就会以更新后的半径值绘制。在 update_system 中,输入 Listing 11-5 中的代码。

def update_system():
    circle.radius = (circle.radius + 15) % 450
    tk.update()

Listing 11-5: 更新圆的半径

半径的值通过将当前值加上 15 来更新。使用取模运算符(%),每当半径大于 450 时,值会回绕并重新归零。

注意

温馨提示:取模运算符 % 返回两个操作数相除后的余数。例如, 5 % 3 等于 2。

你可能已经意识到,我们修改了圆形的半径属性,而不是用新的半径值创建一个新的圆形;这是本书中第一次修改几何原件的属性。这样做的原因是,在模拟中,保持循环的吞吐量非常关键,而为每一帧创建一个新的系统实例会对性能产生较大的影响。

我们现在已经在每一帧中定义了系统:一个圆形,它的中心点始终保持在窗口的中心,而半径逐渐增大。让我们把它绘制到屏幕上吧!

创建运动

为了创建运动效果,画布必须在每一帧中都被清空,并且系统要重新绘制。在主循环中调用重绘之前,update_system已经更新了圆形。在重绘中,我们只需清除画布上已经绘制的内容,再次绘制圆形。使用清单 11-6 中的代码更新重绘。

def redraw():
    label.set(f'Frame {frame_count} of {max_frames}')
    drawing.clear_drawing()
    drawing.draw_circle(circle, 50)

清单 11-6:每帧重绘圆形

你可能已经等待了整个章节的这个重要时刻,现在可以执行文件了。你应该会看到一个圆形逐渐变大,直到从屏幕上消失,然后再重新开始。

仅供参考,此时你的hello_motion.py代码应该类似于清单 11-7。

import time
from tkinter import Tk, Canvas, StringVar, Label

from geom2d import Point, AffineTransform, Circle
from graphic.simulation import CanvasDrawing

tk = Tk()
tk.title("Hello Motion")

canvas = Canvas(tk, width=600, height=600)
canvas.grid(row=0, column=0)

label = StringVar()
label.set('Frame ? of ?')
Label(tk, textvariable=label).grid(row=1, column=0)

frame_rate_s = 1.0 / 30.0
frame_count = 1
max_frames = 100

transform = AffineTransform(sx=1, sy=1, tx=0, ty=0, shx=0, shy=0)
drawing = CanvasDrawing(canvas, transform)
circle = Circle(Point(300, 300), 0)

def update_system():
    circle.radius = (circle.radius + 15) % 450
    tk.update()

def redraw():
    label.set(f'Frame {frame_count} of {max_frames}')
    drawing.clear_drawing()
    drawing.draw_circle(circle, 50)

while frame_count <= max_frames:
    update_start = time.time()
    update_system()
    redraw()
    tk.update()
    update_end = time.time()

    elapsed_s = update_end - update_start
    remaining_time_s = frame_rate_s - elapsed_s

    if remaining_time_s > 0:
        time.sleep(remaining_time_s)

    frame_count += 1

tk.mainloop()

清单 11-7:结果模拟

注意,在绘制任何内容之前,重绘函数会清除画布。如果我们忘记这么做,你能猜到会发生什么吗?将那一行注释掉,然后运行模拟。

def redraw():
    label.set(f'Frame {frame_count} of {max_frames}')
    # drawing.clear_drawing()
    drawing.draw_circle(circle, 50)

所有绘制的圆形应保持在画布上,就像你在图 11-5 中看到的那样。

我们已经在画布上绘制了第一个动画,看起来很棒。然而,如果我们再写一个,我们就不得不复制并粘贴主循环的代码。为了避免这种不必要的重复,让我们将主循环代码移动到一个可以轻松重用的函数中。

图像

图 11-5:如果我们忘记清理画布,会是什么样子

抽象化主循环函数

我们刚刚编写的主循环包含了相当多的逻辑,这些逻辑在所有模拟中都是相同的。如果我们不断地复制和粘贴这些代码,不仅会违反编码规范,而且如果我们发现了改进之处或想要修改实现,我们就需要编辑所有模拟的代码。我们不希望重复知识:我们应该在一个地方定义主模拟循环的逻辑。

要实现一个通用版本的主循环,我们需要进行抽象化操作。让我们问自己以下几个问题,关于主循环的实现:其中有没有什么永远不会改变的部分,或者有没有什么特定于仿真的内容?while 循环、内部操作的顺序以及时间计算在每个仿真中都是相同的。相反,有三部分逻辑会因仿真不同而有所变化,即决定主循环是否继续的条件、更新操作和绘制操作。

如果我们将这些逻辑封装到仿真所实现的函数中,它们就可以被传递给我们的主循环抽象。我们实现的主循环只需要关注时序,即尽量保持帧率稳定。

simulation 包中创建一个名为 loop.py 的新文件。输入 示例 11-8 中的代码。

import time

def main_loop(
        update_fn,
        redraw_fn,
        should_continue_fn,
        frame_rate_s=0.03
):
    frame = 1
    time_s = 0
    last_elapsed_s = frame_rate_s

 ➊ while should_continue_fn(frame, time_s):
        update_start = time.time()
     ➋ update_fn(last_elapsed_s, time_s, frame)
     ➌ redraw_fn()
        update_end = time.time()

        elapsed_s = update_end - update_start
        remaining_time_s = frame_rate_s - elapsed_s

        if remaining_time_s > 0:
            time.sleep(remaining_time_s)
            last_elapsed_s = frame_rate_s
        else:
            last_elapsed_s = elapsed_s

        frame += 1
        time_s += last_elapsed_s

示例 11-8:仿真主循环函数

你应该首先注意到,main_loop 函数的三个参数也是函数:update_fn、redraw_fn 和 should_continue_fn。这些函数包含了特定于仿真的逻辑,因此我们的主循环只需按需调用它们。

注意

将函数作为参数传递给其他函数的内容已在第一章的第 27 页中介绍过。你可能想参考这一部分以便快速复习。

main_loop 函数首先声明三个变量:frame,表示当前帧的索引;time_s,表示总的经过时间;以及 last_elapsed_s,表示上一个帧完成所用的秒数。继续循环的条件现在被委托给 should_continue_fn 函数 ➊。只要该函数返回 true,循环将继续。它接受两个参数:帧数和总的经过时间(秒)。如果你还记得,我们的大多数仿真将由其中一个值限制,因此我们将它们传递给该函数,以便它能够获得判断循环是否应该继续运行所需的信息。

接下来,update_fn 函数 ➋ 用于更新仿真系统和用户界面。该函数接收三个参数:自上一个帧以来的时间间隔 last_elapsed_s;仿真总的经过时间 time_s;以及当前的帧数 frame。正如我们在后续章节中会看到的,当我们将物理引擎引入仿真时,自上一个帧以来的时间间隔将成为一个重要的数据。最后是 redraw_fn ➌,用于将系统绘制到屏幕上。

多亏了我们对仿真主循环的抽象,我们不再需要编写这部分逻辑。让我们尝试使用这种主循环定义来重构上一节的仿真。

重构我们的仿真

现在我们已经创建了主循环的抽象,让我们看看如何重构我们的仿真,来包含主循环函数。

创建一个新的文件,命名为 hello_motion_refactor.py,并输入列表 11-9 中的代码。你可能想要复制并粘贴 hello_motion.py 中的前几行,这些行定义了用户界面。请注意,为了让代码简短一些,我已从界面中移除了帧计数标签。

from tkinter import Tk, Canvas

from geom2d import Point, Circle, AffineTransform
from graphic.simulation.draw import CanvasDrawing
from graphic.simulation.loop import main_loop

tk = Tk()
tk.title("Hello Motion")

canvas = Canvas(tk, width=600, height=600)
canvas.grid(row=0, column=0)

max_frames = 100

transform = AffineTransform(sx=1, sy=1, tx=0, ty=0, shx=0, shy=0)
drawing = CanvasDrawing(canvas, transform)
circle = Circle(Point(300, 300), 0)

def update_system(time_delta_s, time_s, frame):
    circle.radius = (circle.radius + 15) % 450
    tk.update()

def redraw():
    drawing.clear_drawing()
    drawing.draw_circle(circle, 50)

def should_continue(frame, time_s):
    return frame <= max_frames

main_loop(update_system, redraw, should_continue)
tk.mainloop()

列表 11-9:重构后的 hello_motion.py 版本

如果我们查看代码的结尾部分,找到调用 main_loop 的位置。我们传入了之前定义的函数,唯一的区别是,现在这些函数必须声明适当的参数,以匹配 main_loop 函数所期望的参数。

这段代码更加简洁易懂。所有保持稳定帧率的逻辑已经被移到它自己的函数中,这样我们就能专注于模拟本身,而不需要处理那些细节。现在,让我们花点时间玩一下模拟的一些参数,了解它们是如何影响最终结果的。

玩转圆形分段

记住,CanvasDrawing 类在其状态中包括一个仿射变换,并且每个几何图形在绘制之前都会通过这个变换进行转换。还要记住,这也是为什么圆形会被转换成一个通用多边形,使用足够多的分段来近似圆周的原因。变换发生在绘制命令中;因此,必须传入分段数,否则会使用默认值 30。

回到列表 11-9 中的 redraw 函数,

def redraw():
    drawing.clear_drawing()
    drawing.draw_circle(circle, 50)

你可以看到我们使用了 50 个分段,但我们也可以使用其他任何数字。例如,我们可以尝试 10 个分段:

def redraw():
    drawing.clear_drawing()
    drawing.draw_circle(circle, 10)

重新运行文件。你能看到差异吗?如果你尝试使用 6 个分段,结果如何?图 11-6 显示了使用 50、10 和 6 个分段进行圆形模拟的结果。

图片

图 11-6:使用 50、10 和 6 个分段绘制的圆形

经过这个有趣的实验后,我们可以清楚地看到分段数量如何影响圆形的近似效果。现在让我们来实验一下用于在绘制到画布之前转换几何图形的仿射变换。

玩转仿射变换

在我们的模拟中应用的仿射变换是一个恒等变换:它保持点的位置不变。但我们可以使用这个变换做些不同的事,比如反转 y 轴,使其指向上方。例如,返回到 hello_motion_refactor.py,找到定义变换的那一行:

transform = AffineTransform(sx=1, sy=1, tx=0, ty=0, shx=0, shy=0)

然后,编辑它以反转 y 轴:

transform = AffineTransform(
    sx=1, sy=-1, tx=0, ty=0, shx=0, shy=0
)

再次运行模拟。你看到什么了?只是从画布顶部冒出来的一点边缘,对吧?发生的情况是,我们反转了 y 轴,但坐标原点仍然在左上角;因此,我们尝试绘制的圆形超出了窗口范围,如图 11-7 所示。

图片

图 11-7:y 轴翻转的模拟

我们可以通过将坐标原点平移到画布的左下角来轻松解决这个问题。由于画布的高度是 600 像素,我们可以将变换设置为如下:

transform = AffineTransform(
    sx=1, sy=-1, tx=0, ty=600, shx=0, shy=0
)

你可能会感到惊讶,垂直平移的值是 600 而不是-600,但请记住,在原始坐标系统中,y 方向是向下的,而这个仿射变换是基于该系统的。

如果你愿意,可以通过将两个更简单的变换连接起来,来更容易理解获取该变换的过程,第一个将原点向下平移 600 像素,第二个翻转 y 轴,

>>> t1 = AffineTransform(sx=1, sy=1, tx=0, ty=-600, shx=0, shy=0)
>>> t2 = AffineTransform(sx=1, sy=-1, tx=0, ty=0, shx=0, shy=0)
>>> t1.then(t2).__dict__
{'sx': 1, 'sy': -1, 'tx': 0, 'ty': 600, 'shx': 0, 'shy': 0}

这会产生相同的变换,正如你所看到的。

现在,让我们在水平方向上添加一些剪切,看看圆形如何变形。尝试以下变换值,

transform = AffineTransform(
    sx=1, sy=-1, tx=150, ty=600, shx=-0.5, shy=0
)

然后再次运行模拟。你应该看到一个类似于图 11-8 中的形状。

Image

图 11-8:使用水平剪切绘制的圆形

现在轮到你来调整这些值,看看是否能建立更好的直觉,了解动画、绘图和变换是如何工作的。你已经从零开始创建了一些美丽的东西,所以花时间去实验一下。试着用三角形或矩形替代圆形原语来更新几何图形。你可以通过移动几何图形而不是改变其大小来更新它。玩转仿射变换值,尝试推理出在实际运行模拟之前绘图应呈现的效果。利用这个练习来加深你对仿射变换的直觉。

清理模块

我们做两个小的重构来清理模块。首先,在simulation包中创建一个新的文件夹,命名为examples。我们将使用它来存放所有不属于模拟和绘图逻辑的文件,而是本章中编写的示例文件。所以,基本上将除了draw.pyloop.py之外的所有文件移到那里。你在simulation中的文件夹结构应如下所示:

simulation

|- examples

|    |- hello_canvas.py

|    |- hello_motion.py

|    |- ...

|

|- init.py

|- draw.py

|- loop.py

我们想做的第二件事是将 CanvasDrawing 类和 main_loop 函数添加到simulation包的默认导出中。打开simulation中的文件 init.py,并添加以下导入:

from .draw import CanvasDrawing
from .loop import main_loop

就这样!从现在开始,我们将能够使用更简洁的语法导入两者。

总结

在这一章中,我们学习了时间循环。时间循环在满足条件时不断执行,它的主要任务是保持帧率稳定。在这个循环中,有两件事发生:模拟系统的更新和屏幕的重绘。这些操作是定时的,当它们完成时,我们知道完成一个周期还剩下多少时间。

因为时间循环将在我们所有的模拟中出现,我们决定将其实现为一个函数。这个函数接收其他函数作为参数:一个用于更新系统,另一个用于将其绘制到屏幕上,最后一个用于决定模拟是否结束。

在下一章,我们将使用这个时间循环函数来动画仿射变换。

第十二章:动画仿射变换

Image

你刚刚学习了动画和 GUI 设计的基础知识。在本章中,我们将结合这两者,构建一个动画化仿射变换的应用程序。这将帮助你培养对这个可能令人困惑的主题的视觉直觉,并增强你的编程技能。

该应用程序将首先读取一个文本文件,定义仿射变换和需要变换的几何图形。然后,它将计算一个仿射变换的序列,从单位变换插值到给定的变换。这个序列中的每个变换将用于绘制动画的每一帧。

和我们在第九章中构建的圆形构建应用程序一样,我们将使用正则表达式从文本文件中读取原始数据。在这里,我们将使用一些更高级的正则表达式,我们会进行详细分析。本章会有很多代码。我们正在构建一个更大的应用程序,这是一个学习如何在代码中分配责任的绝佳机会。

和往常一样,我们将尽量保持架构和设计的简洁,解释我们所遇到的每个决策背后的理由。让我们开始吧!

应用程序架构和可见性图

要讨论这个应用程序的架构,我们将引入一种新的图表类型:可见性图。可见性图通过箭头显示应用程序的各个组件,指示程序的每个部分知道什么——换句话说,谁能看到谁。请看图 12-1 中的图示。

Image

图 12-1:我们的应用程序架构

图表的顶部是Main,即执行脚本。围绕它的圆圈表示它是应用程序的入口点。从Main出发有三条箭头,这意味着Main知道另外三个模块:ConfigInputSimulation。模块用矩形表示。

请注意,箭头是单向的。Main知道这些模块的存在,并依赖它们,但这些模块对Main的存在一无所知。这一点至关重要:我们希望尽量减少应用程序组件之间的相互了解。这确保了模块尽可能地解耦,也就是说,它们可以独立存在。

解耦设计的好处主要是简洁性,它使我们能够轻松地扩展和维护软件,并且具有可重用性。模块的依赖关系越少,越容易在其他地方使用它。

回到图 12-1 中的图示,我们说过Main使用了三个模块:ConfigInputSimulationConfig模块将负责加载存储在config.json中的应用程序配置——由箭头指示。

Input模块将读取用户提供的输入文件,并定义仿射变换和几何原始体。因此,这个模块将使用两个其他模块:Geometry,用于解析原始体,和Transformation,用于解析仿射变换。同样,箭头从Input指向其他两个模块,意味着这两个模块对Input一无所知:它们完全可以由其他模块使用。

最后,我们有仿真模块,它将负责执行实际的仿真。

注意

我不能过分强调解耦架构的重要性。应用程序应由小的子模块构成,这些子模块暴露简单明了、简洁的接口,并隐藏它们的内部工作方式。只有在这些模块拥有尽可能少的依赖关系时,它们才更容易维护。不遵循这一简单原则的应用程序往往注定失败,相信我,当你在一个模块中修复了一个小 bug,却导致另一个看似无关的模块出现问题时,你会感到绝望。

让我们继续并设置项目。

设置

apps文件夹中,创建一个名为aff_transf_motion的新 Python 包。在其中,添加以下树状结构中显示的所有文件。如果你通过右键点击apps并选择新建Python 包来创建新包,init.py会自动出现在目录中;IDE 为我们创建了它。如果你以其他方式创建包,请不要忘记添加这个文件。

apps

|- aff_transf_motion

|- init.py

|- config.json

|- config.py

|- input.py

|- main.py

|- parse_geom.py

|- parse_transform.py

|- simulation.py

|- test.txt

目前你所有的文件都是空的,但我们很快会用代码填充它们。

在我们进行之前,我们需要一个运行配置或 bash 脚本来在开发过程中运行项目,就像我们在第九章中做的那样。我们首先需要定义它将在main.py中执行的脚本。现在,我们将简单地打印一条信息到终端,以确保一切正常运行。打开文件并输入列表 12-1 中的代码。

if __name__ == '__main__':
   print('Ready!')

列表 12-1:主文件

现在让我们探讨执行项目的两种选择:运行配置和 bash 脚本。你不需要设置两者;可以选择最适合你的一个,跳过另一个。

创建运行配置

在菜单中选择运行编辑配置。点击左上角的+图标,选择Python来创建运行配置。命名为aff-transf-motion。与我们在第九章中做的类似,选择main.py作为脚本路径,aff_transform_motion作为工作目录。最后,勾选重定向输入自选项,选择test.txt。你的配置应该类似于图 12-2。

Image

图 12-2:运行配置

为了确保运行配置正确设置,请从运行配置导航栏中选择它,并点击绿色的播放按钮。终端应该显示消息“Ready!”。如果你在设置过程中遇到任何问题,参考第九章,我们在那里详细讲解了这个过程。

创建一个 Bash 脚本

要从命令行运行应用程序,我们将使用在第九章中探讨的技术:创建一个 Bash 脚本包装器,使用我们的项目根目录作为 Python 解析依赖项的工作区。在项目的根目录下(在 Mechanics 目录下)创建一个新文件:aff_motion.sh。在文件中,输入 Listing 12-2 中的代码。

#!/usr/bin/env bash
PYTHONPATH=$PWD python3 apps/aff_transf_motion/main.py

Listing 12-2: 执行项目的 Bash 脚本

使用这个 Bash 脚本,我们现在可以像这样从命令行执行应用程序:

$ bash ./aff_motion.sh < apps/aff_transf_motion/test.txt

我们可以让这个 Bash 脚本变得可执行:

$ chmod +x aff_motion.sh

然后像这样运行它:

$ ./aff_motion.sh < apps/aff_transf_motion/test.txt

读取配置文件

因为我们希望将配置值与代码分离,所以我们将它们保存在一个 JSON 文件中。这使得我们可以在不触碰代码的情况下更改应用程序的行为。打开 config.json 并输入 Listing 12-3 中的内容。

{
  "frames": 200,
  "axes": {
    "length": 100,
    "arrow-length": 20,
    "arrow-height": 15,
    "stroke-width": 2,
    "x-color": "#D53636",
    "y-color": "#33FF86"
  },
  "geometry": {
    "stroke-color": "#3F4783",
    "stroke-width": 3
  }
}

Listing 12-3: 配置 JSON 文件

该配置首先定义了用于仿真的帧数。接下来是坐标轴的尺寸和颜色,我们将绘制这些坐标轴来帮助我们可视化空间的变换。最后,我们为将要被变换的几何体设置配置值。这里我们定义了线条的颜色和宽度。

现在我们需要一种方法来读取这个配置的 JSON 文件,并将其内容转换成 Python 字典。让我们使用在第九章中使用的相同方法。在 config.py 中,输入 Listing 12-4 中的代码。

import json

import pkg_resources as res

def read_config():
    config = res.resource_string(__name__, 'config.json')
    return json.loads(config)

Listing 12-4: 读取配置文件

配置部分完成后,让我们将注意力转向读取和解析输入。

读取输入

我们希望用户传递给程序一个文件,文件包含仿射变换的定义和要变换的几何图形列表。让我们定义这些文件应该如何格式化。我们可以先读取仿射变换值,因为我们事先知道预期会有多少个值。由于几何图形的数量不固定,我们将它们放在最后。

格式化输入

这里有一个很好的格式化仿射变换值的方法:

sx <value>
sy <value>
shx <value>
shy <value>
tx <value>
ty <value>

这里每个值都在自己的行中定义,并且有一个标签指示它属于哪个术语。我们可以使用更简洁的格式,将所有这些值放在同一行中,像这样:

transformation: <value> <value> <value> <value> <value> <value>

但这样做的缺点是对用户来说不够清晰。值的顺序是什么?第三个数字是 x 方向的剪切还是 y 方向的平移?要回答这个问题,你需要打开源代码,看看这些值是如何解析的。我倾向于在输入大小不大的情况下,优先考虑清晰度而非简洁性,因此我们将坚持使用第一种方法。

那么,几何原始形状怎么样呢?对于每一种几何原始形状,我们将使用不同的四个字母的代码:例如,圆形使用 circ。该代码后面会跟一堆定义原始形状属性的数字。

对于圆形,定义将如下所示

circ <cx> <cy> <r>

其中 <cx> 和 <cy> 是中心点的坐标,<r> 是半径的值。

一个矩形看起来像

rect <ox> <oy> <w> <h>

其中 <ox> 和 <oy> 定义了原点的坐标,<w> 是宽度,<h> 是高度。

一个多边形看起来像

poly [<x1> <y1> <x2> <y2> <x3> <y3> ...]

其中 [<x> <y>] 表示一组 x 和 y 值,表示一个顶点的坐标。请记住,构建多边形的最小顶点数是三,因此这里需要至少六个值。

最后,一条线段的定义如下

segm <sx> <sy> <ex> <ey>

其中 <sx> 和 <sy> 是起点的坐标,<ex> 和 <ey> 是终点的坐标。

添加示例输入

让我们用一个示例输入填充 test.txt 文件。记住,我们在程序中将标准输入重定向到 test.txt,因此我们将用它来测试我们的代码。打开文件并输入清单 12-5 中的定义。

sx 1.2
sy 1.4
shx 2.0
shy 3.0
tx 50.0
ty 25.0

circ 150 40 20
rect 70 60 40 100
rect 100 90 40 100
poly 30 10 80 10 30 90
segm 10 20 200 240

清单 12-5:输入测试文件

该文件首先定义了一个仿射变换,如下所示:

Image

它还定义了一个圆形、两个矩形、一个多边形和一条线段。图 12-3 描述了在应用仿射变换之前,这些几何原始形状的大致布局。

Image

图 12-3:我们测试文件中的几何原始形状

现在 test.txt 包含了这些定义,接下来让我们编写读取和解析输入的代码大纲。打开 input.py 并输入清单 12-6 中的代码。

def read_input():
    transform = __read_transform()
    primitives = __read_primitives()
    return transform, primitives

def __read_transform():
    return None

def __read_primitives():
    return None

清单 12-6:解析输入文件起始点

我们首先定义一个函数 read_input,它将读取仿射变换和几何原始形状,并返回一个包含这两者的元组。为了完成这项工作,它将这两个任务委托给私有函数:__read_transform 和 __read_primitives。这些函数目前返回 None。我们将在接下来的两节中实现它们。

解析仿射变换

输入文件中的仿射变换将始终跨越六行,每个项占一行。我们可以通过要求项总是以相同的、预定义的顺序出现来简化解析过程。我们将重新检查每个项是否具有适当的名称标签,以确保用户按照正确的顺序编写项,但我们不会在正则表达式中包含这部分内容,这应该会简化一些事情。

我们首先需要一个正则表达式,它能够匹配变换组件中的浮点数。设计这个正则表达式时需要确保它也能匹配整数;小数部分应该是可选的。我们还希望接受负数。结合所有这些特征的正则表达式可能是这样的:

/-?\d+(.\d+)?/

这个正则表达式有三个部分。第一个 -? 匹配零次或一次的减号。第二个 \d+ 匹配小数分隔符之前的一个或多个数字:整数部分。最后是 (.\d+)?,它匹配零次或一次由点和一个或多个数字组成的序列。请注意,我们使用了?来处理可选组件。

使用前面展示的正则表达式,我们可以准备另一个正则表达式来匹配所有的项值:

/(?P-?\d+(.\d+)?)/

这定义了一个名为 val 的组,使用前面的表达式捕获项的值。

让我们打开parse_transform.py(此时为空),并实现读取和解析仿射变换项的逻辑。输入清单 12-7 中的代码。

import re

__TRANSF_VAL_RE = r'(?P<val>-?\d+(\.\d+)?)'

def parse_transform_term(term, line):
    __ensure_term_name(term, line)
    return __parse_transform_term(line)

def __ensure_term_name(name, line):
    if name not in line:
        raise ValueError(f'Expected {name} term')

def __parse_transform_term(line):
    matches = re.search(__TRANSF_VAL_RE, line)
    if not matches:
        raise ValueError('Couldn\'t read transform term')

    return float(matches.group('val'))

清单 12-7:解析仿射变换项

我们首先定义正则表达式来解析仿射变换项的值:__TRANSF_VAL_RE。然后是主函数:parse_transform_term,它有两个参数:要验证的项的名称和要解析的行。每个操作都由两个私有函数处理。

函数 __ensure_term_name 检查给定的名称是否出现在行中。如果没有,函数会引发一个 ValueError,并附上有用的消息,告诉用户哪个项无法正确解析。然后,__parse_transform_term 应用正则表达式 __TRANSF_VAL_RE 来匹配项的值。如果匹配成功,匹配的组 val 将被转换为浮动值并返回。如果字符串不符合正则表达式,则会引发错误。

现在,让我们在Input模块中使用这个解析函数(如图 12-1 所示)。打开你的input.py文件,并在顶部添加以下导入:

from apps.aff_transf_motion.parse_transform import parse_transform_term
from geom2d import AffineTransform

然后,按照清单 12-8 中的方式重构 __read_transform 函数。

--snip--

def __read_transform():
    return AffineTransform(
        sx=parse_transform_term('sx', input()),
        sy=parse_transform_term('sy', input()),
        shx=parse_transform_term('shx', input()),
        shy=parse_transform_term('shy', input()),
        tx=parse_transform_term('tx', input()),
        ty=parse_transform_term('ty', input())
    )

清单 12-8:解析仿射变换

我们可以通过编辑我们的main.py文件,将其内容与清单 12-9 进行匹配,轻松测试代码是否正常工作。

from apps.aff_transf_motion.input import read_input

if __name__ == '__main__':
    (transform, primitives) = read_input()
    print(transform)

清单 12-9:主文件:读取变换测试

如果你使用之前创建的运行配置或 bash 脚本运行程序,终端输出应该如下所示:

Input is being redirected from .../test.txt
(sx: 1.2, sy: 1.4, shx: 2.0, shy: 3.0, tx: 50.0, ty: 25.0)

Process finished with exit code 0

你需要确保我们在test.txt中定义的仿射变换的所有值都被正确解析。如果你还记得,这些值如下:

sx 1.2

sy 1.4

shx 2.0

shy 3.0

tx 50.0

ty 25.0

仔细检查程序输出是否与这些值匹配。如果一切正确,恭喜你!如果得到任何意外的值,请调试程序,直到找到问题并修复它。

解析几何图形

几何图形可以按任意顺序出现,而且数量不确定,因此我们需要一种不同的解析策略。我们需要解决两个独立的问题:我们需要从输入中读取不确定数量的行,然后确定每行对应的几何图形类型。让我们分别解决这两个问题,从第一个开始。

读取不确定数量的行

要读取不确定数量的行,我们可以不断从标准输入读取,直到引发 EOFError(文件结束错误),这意味着我们已读取完所有可用行。打开input.py并通过在列表 12-10 中输入代码来重构 __read_primitives。

--snip--

def __read_primitives():
    has_more_lines = True

    while has_more_lines:
        try:
            line = input()
            print('got line -->', line)

        except EOFError:
            has_more_lines = False

列表 12-10:从标准输入读取行

我们声明一个变量 has_more_lines 并将其赋值为 True。然后,在一个 while 循环中,只要变量保持 True,我们就尝试从标准输入中读取另一行。如果操作成功,我们将该行打印到输出中;否则,我们捕获 EOFError 并将 has_more_lines 设置为 False。

再次运行程序,确保输入文件中的所有行都由 __read_primitives 处理,并出现在终端输出中。程序的输出应包括以下行:

got line -->
got line --> circ 150 40 20
got line --> rect 70 60 40 100
got line --> rect 100 90 40 100
got line --> poly 30 10 80 10 30 90
got line --> segm 10 20 200 240

第一个问题已经解决:我们的input.py模块知道如何读取输入文件中的所有行。请注意,空行也由 __read_primitives 函数处理;我们将在下一节处理这个问题。现在我们知道如何读取这些行,让我们将注意力转向第二个问题:识别每一行的几何图形类型。

解析正确的图形

我们先从一个我们确定的事实开始:我们需要为程序理解的每个几何图形定义正则表达式。在本章前面,我们已经定义了我们预期的每种图形的输入格式。我们只需要将其转换为正则表达式。我们将接受整数或浮点数作为每个图形的属性值。我们之前已经看到过如何做到这一点。我们将称捕获属性值的正则表达式为 NUM_RE,并使用以下定义:

/\d+(.\d+)?/

使用这个正则表达式,我们可以为圆形定义正则表达式如下:

/circ (?PNUM_RE) (?PNUM_RE) (?PNUM_RE)/

这里我们包括了三个捕获组:cx, cy 和 r。这些组与我们为前一个圆的输入表示所定义的属性一致。类似地,一个矩形可以通过以下正则表达式进行匹配:

/rect (?PNUM_RE) (?PNUM_RE) (?PNUM_RE) (?PNUM_RE)/

一个匹配线段的正则表达式可以是:

/segm (?PNUM_RE) (?PNUM_RE) (?PNUM_RE) (?PNUM_RE)/

最后,对于多边形,我们使用了一种稍微不同的方法来简化它的解析过程,正如我们现在将看到的那样。以下是我们将使用的正则表达式:

/poly (?P[\d\s.]+)/

这个正则表达式匹配以poly开头,后跟一个空格和一串数字、空格或点(用作小数点分隔符)的字符串。通过它,我们将匹配多边形定义,具体如下:

poly 30 10 80.5 10 30 90.5

我们将其解析为由顶点(30, 10),(80.5, 10) 和 (30, 90.5) 定义的多边形。

让我们将这些定义包括到我们的parse_geom.py文件中,并添加一些我们需要的导入,以创建几何原语。将代码输入到列表 12-11 中。

import re

from geom2d import Circle, Point, Rect, Size, Segment
from geom2d import make_polygon_from_coords

__NUM_RE = r'\d+(\.\d+)?'

__CIRC_RE = rf'circ (?P<cx>{__NUM_RE}) (?P<cy>{__NUM_RE}) ' \
    rf'(?P<r>{__NUM_RE})'

__RECT_RE = rf'rect (?P<ox>{__NUM_RE}) (?P<oy>{__NUM_RE}) ' \
    rf'(?P<w>{__NUM_RE}) (?P<h>{__NUM_RE})'

__POLY_RE = rf'poly (?P<coords>[\d\s\.]+)'

__SEGM_RE = rf'segm (?P<sx>{__NUM_RE}) (?P<sy>{__NUM_RE}) ' \
    rf'(?P<ex>{__NUM_RE}) (?P<ey>{__NUM_RE})'

列表 12-11:几何原语,正则表达式定义

我们已经拥有了所有需要的正则表达式,所以我们的下一个目标是为每一行读取到的内容找到适当的原语。为了解决这个问题,我们将遵循“如果能<动词>,那么<动词>”的模式,在我们的情况下是“如果能解析,那么解析”。让我们看看这个是如何工作的。我们有一系列的解析函数,每个函数都期望接收到特定格式的字符串。如果它们试图从格式错误的字符串中解析几何原语,将会失败。因此,在使用这些函数之前,我们希望确保它们能够理解我们传递给它们的字符串。我们将为每个解析函数配备一个 can_parse 函数。这个第二个函数应该判断解析函数所期望的所有部分是否都存在于字符串中:即模式的“能解析”部分。

对于每一个几何原语,我们需要一对函数:一个用于确定给定的文本行是否可以解析为这个原语(即“能解析”部分),另一个则是实际进行解析的函数(即“然后解析”部分)。这个算法的代码如下:

if can_parse_circle(line):
    parse_circle(line)

elif can_parse_rect(line):
    parse_rect(line)

elif can_parse_polygon(line):
    parse_polygon(line)

elif can_parse_segment(line):
    parse_segment(line)

else:
    handle_unknown_line(line)

我们首先检查给定的行是否可以解析为一个圆形。如果测试通过,我们继续解析圆形;否则,我们继续进行下一个比较,重复这个模式。可能会出现没有一个比较通过的情况,最终进入最后的 else 语句;我们将在 handle_unknown_line 函数中处理这种情况。例如,考虑那些从输入文件读取的空行,它们将无法匹配任何已知的原语。我们可以有几种方法来处理这些问题行。例如,我们可以在终端中打印它们,并附带警告消息,告知用户程序无法理解某些行。为了简化起见,我们将忽略这些未知行。

现在让我们为每个原始数据实现“可以解析”和“解析”函数。在 parse_geom.py 中,在我们刚定义的正则表达式之后,输入 清单 12-12 中的代码。这段代码处理圆形的情况。

--snip--

def can_parse_circle(line):
    return re.match(__CIRC_RE, line)

def parse_circle(line):
    match = re.match(__CIRC_RE, line)
    return Circle(
        center=Point(
            float(match.group('cx')),
            float(match.group('cy'))
        ),
        radius=float(match.group('r'))
    )

清单 12-12:解析圆形

正如你所看到的,can_parse_circle 函数仅检查传入的行是否与圆形的正则表达式 __CIRC_RE 匹配。parse_circle 函数更进一步,假设该行与正则表达式匹配,它提取 cx 和 cy 组的值,即圆心的位置。它同样处理 r 组的值,即半径。

不要忘记,我们从正则表达式捕获组中提取的值始终是字符串。由于我们期望的是浮点数,因此需要使用 float 函数进行转换。

现在让我们为矩形的情况实现相同的函数。在你刚写的代码之后,输入 清单 12-13 中的代码。

--snip--

def can_parse_rect(line):
    return re.match(__RECT_RE, line)

def parse_rect(line):
    match = re.match(__RECT_RE, line)
    return Rect(
        origin=Point(
            float(match.group('ox')),
            float(match.group('oy'))
        ),
        size=Size(
            float(match.group('w')),
            float(match.group('h'))
        )
    )

清单 12-13:解析矩形

这里没有什么惊讶。我们应用了相同的程序,这次提取了名为 ox、oy、w 和 h 的组,它们定义了矩形的原点和大小。让我们对多边形的情况做同样的处理。在 清单 12-14 中输入代码。

--snip--

def can_parse_polygon(line):
    return re.match(__POLY_RE, line)

def parse_polygon(line):
    match = re.match(__POLY_RE, line)
    coords = [float(n) for n in match.group('coords').split(' ')]
    return make_polygon_from_coords(coords)

清单 12-14:解析多边形

在这种情况下,机制稍有不同。记住,我们在处理多边形时使用了略有不同的正则表达式。由于多边形由未知数量的顶点定义,因此用于匹配这些数字对的正则表达式必须更复杂。我们还需要使用列表推导式来正确解析坐标。

首先,名为 coords 的组捕获的字符串使用空格作为分隔符进行拆分。因此,数字字符串

'10 20 30 40 50 60'

将被转换为如下所示的字符串数组:

['10', '20', '30', '40', '50', '60']

然后每个字符串都将转换为浮点数:

[10.0, 20.0, 30.0, 40.0, 50.0, 60.0]

有了这个数字数组,我们可以轻松地使用工厂函数 make_polygon_from_coords 创建一个 Polygon 类的实例。别忘了在文件顶部添加导入:

from geom2d import make_polygon_from_coords

我们需要的最后一对“可以解析”和“解析”函数是针对线段的。在 清单 12-15 中输入代码。

--snip--

def can_parse_segment(line):
    return re.match(__SEGM_RE, line)

def parse_segment(line):
    match = re.match(__SEGM_RE, line)
    return Segment(
        start=Point(
            float(match.group('sx')),
            float(match.group('sy'))
        ),
        end=Point(
            float(match.group('ex')),
            float(match.group('ey'))
        )
    )

清单 12-15:解析线段

太棒了!我们现在有了需要的函数,可以应用我们的“如果可以解析则解析”的策略。打开 input.py 并导入这些函数:

from apps.aff_transf_motion.parse_geom import *

我们使用星号导入语法,将 parse_geom 模块中定义的所有函数导入,而无需写出所有函数的名称。现在让我们重构 __read_primitives 函数(清单 12-16)。

--snip--

def __read_primitives():
    prims = {'circs': [], 'rects': [], 'polys': [], 'segs': []}
    has_more_lines = True

    while has_more_lines:
        try:
            line = input()

            if can_parse_circle(line):
                prims['circs'].append(parse_circle(line))

            elif can_parse_rect(line):
                prims['rects'].append(parse_rect(line))

            elif can_parse_polygon(line):
                prims['polys'].append(parse_polygon(line))

            elif can_parse_segment(line):
                prims['segs'].append(parse_segment(line))

        except EOFError:
            has_more_lines = False

    return prims

清单 12-16:从输入中读取原始数据

我们开始定义一个名为 prims 的字典,其中包含每种几何原始图形的数组。字典中的每个数组都有一个名称:circs、rects、polys 和 segs。接下来是 while 循环,它遍历所有读取的行。我们没有将它们打印到控制台,而是添加了我们的解析函数,类似于之前在伪代码中所做的。每次解析到一个原始图形时,结果会被追加到 prims 字典中相应数组中。函数最后通过返回 prims 来结束。

清单 12-17 包含了input.py的最终结果。确保你的代码看起来类似。

from apps.aff_transf_motion.parse_geom import *
from apps.aff_transf_motion.parse_transform import parse_transform_term
from geom2d import AffineTransform

def read_input():
    transform = __read_transform()
    primitives = __read_primitives()
    return transform, primitives

def __read_transform():
    return AffineTransform(
        sx=parse_transform_term('sx', input()),
        sy=parse_transform_term('sy', input()),
        shx=parse_transform_term('shx', input()),
        shy=parse_transform_term('shy', input()),
        tx=parse_transform_term('tx', input()),
        ty=parse_transform_term('ty', input())
    )

def __read_primitives():
    prims = {'circs': [], 'rects': [], 'polys': [], 'segs': []}
    has_more_lines = True

    while has_more_lines:
        try:
            line = input()

            if can_parse_circle(line):
                prims['circs'].append(parse_circle(line))

            elif can_parse_rect(line):
                prims['rects'].append(parse_rect(line))

            elif can_parse_polygon(line):
                prims['polys'].append(parse_polygon(line))

            elif can_parse_segment(line):
                prims['segs'].append(parse_segment(line))

        except EOFError:
            has_more_lines = False

    return prims

清单 12-17:完整的输入读取代码

现在我们已经可以完全解析输入,让我们继续实现模拟功能。

运行模拟

一旦配置和输入完全读取并解析,它们将一起传递给我们稍后编写的模拟函数。该函数还会定义用户界面:一个画布来绘制形状,一个按钮来启动动画。图 12-4 展示了这些组件的布局方式。

模拟直到用户点击播放按钮才会开始。这样我们可以防止模拟过早开始;否则,用户可能会错过第一部分。此外,由于有按钮,我们还可以在不需要重新启动应用程序的情况下重新运行模拟。

图片

图 12-4:模拟的用户界面

构建用户界面

打开空的simulation.py并输入清单 12-18 中的代码。

from tkinter import Tk, Canvas, Button

def simulate(transform, primitives, config):
    # ---------- UI DEFINITION ---------- #
    tk = Tk()
    tk.title("Affine Transformations")

    canvas = Canvas(tk, width=800, height=800)
    canvas.grid(row=0, column=0)

    def start_simulation():
        tk.update()
        print('Starting Simulation...')

    Button(tk, text='Play', command=start_simulation) \
        .grid(row=1, column=0)

    # ---------- UPDATE, DRAW & CONTINUE ---------- #
    def update_system(time_delta_s, time_s, frame):
        pass

    def redraw():
        pass

    def should_continue(frame, time_s):
        pass

    # ---------- MAIN LOOP ---------- #
    redraw()
    tk.mainloop()

清单 12-18:模拟函数

我们定义了一个名为 simulate 的函数,它接收目标变换、几何原始图形和应用程序的配置。回想一下,配置的 JSON 文件包含了用于模拟的帧数,以及我们将在屏幕上绘制的所有内容的大小和颜色。由于该函数会稍长,我们为每个部分添加了三个头部注释,以便轻松定位:用户界面定义;更新、绘制和 should_continue 函数;以及主循环。

函数的第一部分构建了用户界面。我们实例化了 Tk 类,并向其中添加了 Canvas 和 Button。使用网格系统,我们将画布放在第一行(row=0),按钮放在第二行(row=1)。我们还创建了一个名为 start_simulation 的函数,当按钮被点击时该函数会执行。现在,这个函数的作用不大;它只是告诉 Tkinter 处理所有待处理的事件(tk.update())并向控制台打印一条消息。稍后我们将在这里添加模拟的更新逻辑。

然后我们定义了关键仿真函数的模板:update_system、redraw 和 should_continue。别忘了为每个函数声明适当的输入参数;否则,一旦我们把它们交给 main_loop 函数,Python 会报错。我们稍后会填充这些函数的内容。

最后,我们调用 redraw 来渲染几何图形的初始状态并启动 Tkinter 的主循环。为了测试我们的进展,让我们编辑main.py,使其显示用户界面。打开该文件并修改,使其看起来像清单 12-19 中的代码。

from apps.aff_transf_motion.config import read_config
from apps.aff_transf_motion.input import read_input
from apps.aff_transf_motion.simulation import simulate

if __name__ == '__main__':
    (transform, primitives) = read_input()
    config = read_config()
    simulate(transform, primitives, config)

清单 12-19:执行入口点

现在我们的main.py文件已经准备好。接下来,我们来编写仿真代码。

实现仿真逻辑

接下来我们进入仿真逻辑部分。如果你还记得第七章,为了绘制动画的不同帧,我们需要生成一系列插值仿射变换,从单位变换到我们从输入中解析出来的目标变换。如果你需要复习这个主题,可以参考第 192 页中的“插值变换”部分。得益于我们在第七章中实现的仿射变换插值函数 ease_in_out_interpolation,这段逻辑变得非常简单。在simulation.py文件中,进行清单 12-20 所示的更改。

from tkinter import Tk, Canvas, Button

from geom2d import affine_transforms as tf

def simulate(transform, primitives, config):
    # ---------- UI DEFINITION ---------- #
    --snip--

    # ---------- UPDATE, DRAW & CONTINUE ---------- #
    frames = config['frames']
    transform_seq = __make_transform_sequence(transform, frames)

    --snip--

def __make_transform_sequence(end_transform, frames):
    start_transform = tf.AffineTransform(sx=1, sy=1, tx=20, ty=20)
    return tf.ease_in_out_interpolation(
        start_transform, end_transform, frames
    )

清单 12-20:计算变换序列

我们需要做的第一件事是确定插值的步数。这就是帧数的数量,一个我们从配置中读取并存储在变量 frames 中的值。为了计算插值序列,我们在文件中定义了一个私有函数:__make_transform_sequence。该函数接受目标仿射变换和帧数,并以以下变换作为起点来计算序列:

图片

注意到在水平和垂直轴上各移动了 20 个像素。这个小偏移将坐标轴与画布的上边和左边分开。最终计算出的变换序列存储在 transform_seq 中。

现在让我们深入研究仿真的关键函数:update_system、redraw 和 should_continue。编辑simulation.py,使其与清单 12-21 中的代码相同。

from tkinter import Tk, Canvas, Button

from geom2d import affine_transforms as tf
from graphic.simulation import CanvasDrawing

def simulate(transform, primitives, config):
    # ---------- UI DEFINITION ---------- #
    --snip--

    # ---------- UPDATE, DRAW & CONTINUE ---------- #
    frames = config['frames']
    transform_seq = __make_transform_sequence(transform, frames)
  ➊ drawing = CanvasDrawing(canvas, transform_seq[0])

    def update_system(time_delta_s, time_s, frame):
     ➋ drawing.transform = transform_seq[frame - 1]
        tk.update()

  ➌ def redraw():
        drawing.clear_drawing()

        drawing.outline_width = config['geometry']['stroke-width']
        drawing.outline_color = config['geometry']['stroke-color']

        for circle in primitives['circs']:
            drawing.draw_circle(circle)

        for rect in primitives['rects']:
            drawing.draw_rectangle(rect)

        for polygon in primitives['polys']:
            drawing.draw_polygon(polygon)

        for segment in primitives['segs']:
            drawing.draw_segment(segment)

    def should_continue(frame, time_s):
     ➍ return frame <= frames

    # ---------- MAIN LOOP ---------- #
    redraw()
    tk.mainloop()

--snip--

清单 12-21:实现绘制和更新

在我们最近计算的变换序列之后,我们实例化了 CanvasDrawing 类,传入 Tkinter 画布和第一个仿射变换➊。注意,我们在文件顶部导入了这个类,并且变换序列中的第一个变换是几何图形的初始变换。

然后,我们实现 update_system 函数。该函数根据当前帧数 ➋ 更新绘图的变换,并调用 Tk 的 update 方法。为了计算用于获取相应变换的索引,我们从帧数中减去 1。请记住,帧是从 1 开始计数的,而 Python 列表的第一个索引是 0。重要的是要意识到,在这个特定的模拟中,并不是由几何原始图形组成的系统每帧都会更新,而是 CanvasDrawing 类的一个属性——仿射变换——会获得一个新的值。

接下来是 redraw 函数 ➌。它首先清空画布并设置我们绘制的形状的轮廓大小和颜色。这两个值来自配置文件。然后,它遍历字典中的所有基本图形,调用 CanvasDrawing 类中的相应绘制命令。得益于我们之前在该类中的工作,绘制到画布上变得如此简单。

最后是 should_continue 的实现,它仅仅是将当前帧数与动画的总帧数进行比较 ➍。

绘制坐标轴

我们快完成了!让我们添加一些代码来绘制 x 轴和 y 轴,并调用模拟的主循环(不要与 Tkinter 的 mainloop 函数混淆)。坐标轴将提供一个良好的视觉参考,帮助我们理解空间是如何变换的。请将这些更改包含在 清单 12-22 中。

from tkinter import Tk, Canvas, Button

from geom2d import affine_transforms as tf, Segment, Point
from graphic.simulation import CanvasDrawing, main_loop

def simulate(transform, primitives, config):
    # ---------- UI DEFINITION ---------- #
    --snip--

    def start_simulation():
        tk.update()
     ➊ main_loop(update_system, redraw, should_continue)

    Button(tk, text='Play', command=start_simulation) \
        .grid(row=1, column=0)

    # ---------- UPDATE, DRAW & CONTINUE ---------- #
    frames = config['frames']
    transform_seq = __make_transform_sequence(transform, frames)
    axis_length = config['axes']['length']
 ➋ x_axis = Segment(Point(0, 0), Point(axis_length, 0))
 ➌ y_axis = Segment(Point(0, 0), Point(0, axis_length))
    drawing = CanvasDrawing(canvas, transform_seq[0])

    def update_system(time_delta_s, time_s, frame):
        drawing.transform = transform_seq[frame - 1]
        tk.update()

    def redraw():
        drawing.clear_drawing()

        drawing.outline_width = config['axes']['stroke-width']
        drawing.outline_color = config['axes']['x-color']
     ➍ drawing.draw_arrow(
            x_axis,
            config['axes']['arrow-length'],
            config['axes']['arrow-height']
        )

        drawing.outline_color = config['axes']['y-color']
     ➎ drawing.draw_arrow(
            y_axis,
            config['axes']['arrow-length'],
            config['axes']['arrow-height']
        )

        --snip--

    def should_continue(frame, time_s):
        return frame <= frames
    # ---------- MAIN LOOP ---------- #
    redraw()
    tk.mainloop()

--snip--

清单 12-22:绘制坐标轴和主循环

首先是最重要的新增内容:调用 main_loop 函数 ➊。我们传入接下来定义的函数来处理更新、重绘和模拟的继续。确保在文件顶部导入 main_loop 函数。

接下来是定义 x_axis ➋ 和 y_axis ➌,它们都被定义为线段。每个长度都是我们从配置文件中读取的参数,并存储在 axis_length 中。为了绘制坐标轴,我们需要考虑到它们的笔触宽度和颜色与其他几何图形不同。我们已将这些属性的代码添加到了 redraw 函数中,就在调用 clear_drawing 之后。

在设置了相应的轮廓宽度和颜色之后,我们使用 CanvasDrawing 类的 draw_arrow 方法,将定义 x_axis 几何的线段和箭头的大小 ➍ 传递给它。箭头的大小同样来自配置文件。我们需要添加相同的代码来绘制 y_axis ➎,但这次只需更新笔触颜色:两轴使用相同的笔触宽度绘制。

好的,我们已经逐步编写了很多代码。仅供参考,清单 12-23 展示了最终的 simulation.py 文件。看一下并确保你已经理解了全部内容。

from tkinter import Tk, Canvas, Button

from geom2d import affine_transforms as tf, Segment, Point
from graphic.simulation import CanvasDrawing, main_loop

def simulate(transform, primitives, config):
    # ---------- UI DEFINITION ---------- #
    tk = Tk()
    tk.title("Affine Transformations")

    canvas = Canvas(tk, width=800, height=800)
    canvas.grid(row=0, column=0)

    def start_simulation():
        tk.update()
        main_loop(update_system, redraw, should_continue)

    Button(tk, text='Play', command=start_simulation) \
        .grid(row=1, column=0)

    # ---------- UPDATE, DRAW & CONTINUE ---------- #
    frames = config['frames']
    transform_seq = __make_transform_sequence(transform, frames)
    axis_length = config['axes']['length']
    x_axis = Segment(Point(0, 0), Point(axis_length, 0))
    y_axis = Segment(Point(0, 0), Point(0, axis_length))
    drawing = CanvasDrawing(canvas, transform_seq[0])

    def update_system(time_delta_s, time_s, frame):
        drawing.transform = transform_seq[frame - 1]
        tk.update()

    def redraw():
        drawing.clear_drawing()

        drawing.outline_width = config['axes']['stroke-width']
        drawing.outline_color = config['axes']['x-color']
        drawing.draw_arrow(
            x_axis,
            config['axes']['arrow-length'],
            config['axes']['arrow-height']
        )

        drawing.outline_color = config['axes']['y-color']
        drawing.draw_arrow(
            y_axis,
            config['axes']['arrow-length'],
            config['axes']['arrow-height']
        )

        drawing.outline_width = config['geometry']['stroke-width']
        drawing.outline_color = config['geometry']['stroke-color']

        for circle in primitives['circs']:
            drawing.draw_circle(circle)

        for rect in primitives['rects']:
            drawing.draw_rectangle(rect)

        for polygon in primitives['polys']:
            drawing.draw_polygon(polygon)

        for segment in primitives['segs']:
            drawing.draw_segment(segment)

    def should_continue(frame, time_s):
        return frame <= frames

    # ---------- MAIN LOOP ---------- #
    redraw()
    tk.mainloop()

def __make_transform_sequence(end_transform, frames):
    start_transform = tf.AffineTransform(sx=1, sy=1, tx=20, ty=20)
    return tf.ease_in_out_interpolation(
        start_transform, end_transform, frames
    )

清单 12-23:完整的模拟代码

最后!我们现在准备看到结果了,因此请使用运行配置或 bash 脚本执行该应用程序。一个显示几何原语的窗口应当出现,原语如输入文件中所定义(参见图 12-5 中的左侧图片)。还要注意我们绘制的 x 轴和 y 轴,它们以箭头的形式显示;你能发现我们给原点留出的 20 像素的间距吗?

现在点击播放并观察结果。模拟应该开始得较慢,然后逐渐加速,最后在结束时减速。我们通过使用“ease-in-out”插值方法实现了这一效果,使动画看起来平滑且逼真。

图片

图 12-5:仿射变换的模拟

现在是回顾第 192 页上“插值变换”部分并再次阅读的好时机。在看到“ease-in-out”效果的实际应用后,你可以建立起对方程 7.11(第 194 页)的直观理解,该方程定义了你刚刚看到的动画的节奏。

花点时间玩一下你的应用程序。改变一些参数,看看对模拟结果的影响。例如,尝试更改初始仿射变换的偏移量(平移组件 tx 和 ty)。在配置文件中调整线条宽度和颜色,编辑帧数。另一个有趣的练习是编辑仿射变换和输入文件test.txt中定义的几何图形。

总结

在本章中,我们开发了第二个应用程序,动画效果展示了仿射变换的效果。像之前一样,我们使用正则表达式解析输入,并利用我们的几何库进行复杂计算。这次的输出是一个动画,由于前几章的工作,这一实现变得非常简单。

本章结束了第三部分的内容。在这一部分中,我们学习了如何从几何原语创建 SVG 矢量图形和动画模拟,这是构建优质工程软件的关键技能。我们运用这些知识构建了两个简单的应用程序:一个用来确定通过三点的圆,另一个用于在仿射变换下进行几何原语动画。这些虽然是简单的应用,但它们展示了几何和视觉原语的强大功能。

在书的下一部分,我们将研究如何解方程组,这是任何工程应用中的另一个关键内容。这是我们力学包所需的最后一项工具。在探讨了这一主题后,书的其余部分将专注于使用我们自己编写的强大原语解决力学问题。

第四部分**

方程组

第十三章:矩阵和向量

Image

本书的这一部分将处理方程组的求解。我们可以方便地使用 矩阵形式 来表示一组方程,其中将未知系数存储在矩阵中,将自由项存储在向量中。

我们一直在与矩阵和向量进行仿射变换工作,但为了完整性起见,我们在这里定义它们。矩阵 是一个按行和列排列的二维数字数组。矩阵可以进行一些数学运算,包括加法、减法、乘法等。此上下文中的 向量 是一个只有一行或一列(通常为一列)的矩阵。

考虑以下方程组:

Image

我们可以方便地将其写成矩阵形式如下:

Image

注意方程中的系数是如何表示的,它们在 2(行)乘 3(列)的矩阵中。根据矩阵乘法规则,这些系数与未知数 xyz 相乘,得到我们的两个方程,每个方程的结果需要等于其对应的右边项,这些右边项存储在 ⟨1,–3⟩ 向量中。

现在可能不太明显,但矩阵,以及由此扩展的向量,将大大简化方程组的工作。然而,要使用它们,我们需要为矩阵和向量实现新的类。

新的 Vector 类将表示一个任意长度的单维数组(序列)中的数字。这种类型的向量不应与我们在第四章中实现的几何向量混淆,后者由两个坐标(uv)组成。我们新的 Vector 类的一个大小为 2 的实例看起来可能与几何向量相似,但它们是不同的:这些数字不一定表示定义方向的坐标。我们将不得不处理两个同名的类:Vector。如你所见,由于它们定义在不同的模块中,消除歧义应该不会有任何问题。

我们可以为这两个新类实现很多操作,但我们将务实,只实现下一章中求解方程组所需的操作。例如,尽管加法、减法或乘法是常见的,我们并不需要实现这些操作。

让我们首先实现两个简单的函数,帮助我们将新实例化的向量和矩阵填充为零。我们将在实例化向量或矩阵时使用这些函数。

列表工具

在内部,这个新的 Vector 类的实例将使用一个数字列表来存储数据。当实例化该类的一个实例时,我们希望将其内部列表填充为零。这样,未明确设置为其他值的值默认将为零。类似地,Matrix 类将其数据存储在一个列表的列表中。我们还希望矩阵中的每个位置都初始化为零。

utils包中创建一个新的 Python 文件,命名为lists.py,并在其中输入列表 13-1 中的代码。

 def list_of_zeros(length: int):
    return [0] * length

def list_of_list_of_zeros(rows: int, cols: int):
    return [list_of_zeros(cols) for _ in range(rows)]

列表 13-1:零列表

我们定义了两个函数。第一个函数,list_of_zeros,接受一个长度参数,并创建一个填充零的指定长度的列表。第二个函数,list_of_list_of_zeros,创建多个大小为 cols 的零列表,数量由参数 rows 指定。

对于[0] * length 这种有趣的语法可以这样理解:“创建一个由零组成的列表,长度为给定值。”可以在 Python 控制台中试试:

>>> [0] * 5
[0, 0, 0, 0, 0]

这是一种初始化包含相同重复值的列表的简洁方式。

list_of_list_of_zeros 函数使用列表推导创建一个大小为 rows 的列表,其中每个项目是一个大小为 cols 的零列表。在每次迭代中,索引并未使用,因此使用了下划线:

from _ in range(rows)

让我们在终端中尝试这个函数:

>>> from utils.lists import list_of_list_of_zeros
>>> list_of_list_of_zeros(2, 3)
[[0, 0, 0], [0, 0, 0]]

现在让我们设置一个新包,在其中添加新的 Matrix 和 Vector 类。

设置

现在让我们在项目中创建一个新包,里面添加 Vector 和 Matrix 的实现。这个包还将包含我们将在接下来的章节中实现的方程求解函数,通常也会包含我们编写的任何数学或方程求解算法。在项目的顶层创建一个新包,并命名为eqs。在其内部再添加一个包,并命名为tests。你项目的结构应该现在看起来像这样:

力学

|- apps

|    |- circle_from_points

|- eqs

|    |- tests

|- geom2d

|    |- tests

|- graphic

|    |- simulation

|    |- svg

|- utils

你应该已经添加了eqs目录及其tests子目录:

力学

| ...

|- eqs

|    |- tests

| ...

向量

正如我们在本章介绍中所看到的,eqs包中的向量将表示一个存储在列表中的数字序列。我们不会将其与geom2d包中的 Vector 实现混淆;它们共用一个名称,虽然这是不幸的,但记住它们是两个不同的(虽然有联系的)概念。这里的向量是一种特殊类型的矩阵;具体来说,它们是只有一行或一列的矩阵。例如,我们可以称一个向量为

Image

作为一个列向量,突出了它是一个只有一列的矩阵。同样地,我们称这样的向量为

[2 –1 3]

一个行向量,因为它只是一个只有一行的矩阵。

我们将矩阵和向量实现为两个独立的类(而不是使用 Matrix 类来表示两者),仅仅是为了提高可读性。例如,为了从矩阵中获取一个值,我们需要指明行和列的索引。而对于向量,我们只需要一个索引,因此使用 Matrix 类来存储向量是有意义的,但这将迫使我们在获取或设置值时传递两个索引,而从概念上讲,只需要一个索引即可。因此,在阅读像这样的代码时

m.value_at(2, 4)
v.value_at(3)

我们可以快速识别出 m 是一个矩阵,而 v 是一个向量。

实现向量类

我们将使用一个列表来存储向量的数据。我们不会让用户访问这个私有的数字列表,而是会在类中提供方法来操作向量。在eqs中创建一个新文件vector.py,并在清单 13-2 中输入代码。

from utils.lists import list_of_zeros

class Vector:

    def __init__(self, length: int):
        self.__length = length
        self.__data = list_of_zeros(length)

    @property
    def length(self):
        return self.__length

清单 13-2:向量类

当一个向量类的实例被初始化时,我们传入一个长度。这个长度保存在一个名为 __length 的类的私有属性中,并通过@property 装饰器作为属性暴露。这确保了向量类一旦被实例化,长度属性就不会被修改。回想一下,属性是只读的属性。

向量的数据存储在 __data 属性中,它是通过之前的 list_of_zeros 函数初始化的。

让我们实现设置向量中值的方法。在类中,输入清单 13-3 中的新代码。

class Vector:
   --snip--

   def set_value(self, value: float, index: int):
       self.__data[index] = value
       return self

   def add_to_value(self, amount: float, index: int):
       self.__data[index] += amount
       return self

   def set_data(self, data: [float]):
       if len(data) != self.__length:
           raise ValueError('Cannot set data: length mismatch')

       for i in range(self.__length):
           self.__data[i] = data[i]

       return self

清单 13-3:设置向量值

我们添加了三个新方法。第一个方法 set_value 是最简单的:它在指定的索引处设置一个值。注意,如果给定的索引大于或等于向量的长度,或者小于零,我们会引发通常称为越界错误的异常,即 IndexError。只要我们对 Python 如何处理这种情况满意,就不需要自己检查这个条件。同样需要注意的是,方法返回的是 self,也就是类的实例。我们将继续使用这个模式,在类中设置值时返回实例。这样我们就可以链接“设置”操作或执行类似的操作。

vec = Vector(5).set_value(3, 2)

而不是做这个不太漂亮的等效方法:

vec = Vector(5)
vec.set_value(3, 2)

我们定义的第二种方法是 add_to_value,它将给定的值加到向量中的一个值上。当你在本书的第五部分中处理结构时,这个方法会很方便,正如你将看到的那样。

最后,我们有 set_data 方法,它将源数据列表中的所有值设置到向量中。为此,它首先检查提供的列表是否与向量的长度相同;然后将每个值复制到私有列表 __data 中。

现在让我们实现一个方法,从向量中获取给定索引的值。在vector.py文件中,输入清单 13-4 中的代码。

class Vector:
   --snip--

   def value_at(self, index: int):
       return self.__data[index]

清单 13-4:获取向量值

我们快完成向量类的实现了。我们可以实现更多方法来执行如加法或减法向量等操作,但本书的目的不需要这些。我们唯一需要的一个方法是 eq,它可以用来检查两个向量实例是否相等。现在我们来实现它。在vector.py文件中添加以下导入:

from geom2d import are_close_enough

然后在清单 13-5 中输入新代码。

from geom2d import are_close_enough
from utils.lists import list_of_zeros

class Vector:
    --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Vector):
            return False

        if self.__length != other.__length:
            return False

        for i in range(self.length):
            if not are_close_enough(
                    self.value_at(i),
                    other.value_at(i)
            ):
                return False

        return True

清单 13-5:向量类相等性

我们首先检查是否正在比较同一个实例与它自己,在这种情况下,结果是 True,我们无需再比较其他内容。接着,如果传入的对象不是 Vector 类的实例,我们知道比较无法成功,因此返回 False。如果我们发现正在比较两个 Vector 类的实例,那么我们开始进行实际的检查。首先确保两个向量的长度相同(不同大小的向量无法相等)。如果长度检查成功,我们最后通过 our are_close_enough 函数逐一检查值是否相等。

当我们实现可能计算量大的 eq 方法时,重要的是先检查计算量较小的条件。例如,在这里,我们首先快速检查向量的长度,再进行每对值的相等性比较。相比需要进行 n 次比较(n 是向量的长度)的值比较,长度比较只需要一次比较。

我们完成的 Vector 类应与第 13-6 段中的示例类似。

from geom2d import are_close_enough
from utils.lists import list_of_zeros

class Vector:

    def __init__(self, length: int):
        self.__length = length
        self.__data = list_of_zeros(length)

    @property
    def length(self):
        return self.__length

    def set_value(self, value: float, index: int):
        self.__data[index] = value
        return self

    def add_to_value(self, amount: float, index: int):
        self.__data[index] += amount
        return self

    def set_data(self, data: [float]):
        if len(data) != self.__length:
            raise ValueError('Cannot set data: length mismatch')

        for i in range(self.__length):
            self.__data[i] = data[i]

        return self

    def value_at(self, index: int):
        return self.__data[index]

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Vector):
            return False

        if self.__length != other.__length:
            return False

        for i in range(self.length):
            if not are_close_enough(
                    self.value_at(i),
                    other.value_at(i)
            ):
                return False

        return True

第 13-6 段:向量类结果

因为这个类将作为解线性方程组的基础,所以我们不能容忍其实现中有任何错误:否则,解这些方程组将变得毫无意义。让我们添加一些测试,确保这个类没有漏洞。

测试向量类

在本章开始时,我们在 eqs 包内创建了一个 test 目录。在该目录内,创建一个名为 vector_test.py 的新文件,并输入第 13-7 段中的代码。

import unittest

from eqs.vector import Vector

class VectorTest(unittest.TestCase):

    def test_length(self):
        self.assertEqual(5, Vector(5).length)

    def test_unset_value_is_zero(self):
        vector = Vector(2)
        self.assertEqual(0.0, vector.value_at(0))
        self.assertEqual(0.0, vector.value_at(1))

    def test_set_get_value(self):
        value = 10.0
        vector = Vector(2).set_value(value, 1)
        self.assertEqual(0.0, vector.value_at(0))
        self.assertEqual(value, vector.value_at(1))

    def test_add_to_value(self):
        vector = Vector(2).set_data([1, 2]).add_to_value(10, 0)
        self.assertEqual(11, vector.value_at(0))
        self.assertEqual(2, vector.value_at(1))

第 13-7 段:向量类单元测试

这段代码定义了一个新的测试类 VectorTest,包含四个单元测试。运行所有测试,确保它们通过,并且我们的实现是正确的。你可以通过 Bash Shell 执行这些测试:

$ python3 -m unittest eqs/tests/vector_test.py

第一个测试 test_length 检查向量的长度属性是否返回正确的值。接下来是 test_unset_value_is_zero,它确保我们正确地初始化了向量,并将其填充为零。test_set_get_value 在索引 1 处设置值 10.0,并检查向量在请求索引 1 处的项时是否返回相同的值。我们还断言向量在索引 0 处返回零,以确保 set_value 不会修改任何它不应该修改的值。最后,我们有 test_add_to_value 来测试 add_to_value 方法。此测试初始化向量为 [1, 2],将 10 单位添加到索引 0 处的项,并断言该索引的值是否正确更新。

你可能已经注意到,test_set_get_value 测试可能会因为两种不同的原因失败:(1)vector 的 set_value 方法实现有误,或者(2)value_at 方法实现有误。大部分情况都是这样,你指出我们在这里破坏了良好测试的第一条规则是对的(参见 第 97 页的“三大单元测试黄金法则”)。但没有使用 value_at 方法的断言来测试 set_value 方法是很难的。我们本可以通过某种方式访问 vector 的私有 __data 来获取值,而不使用 value_at,但更倾向于通过类的公共 API 来测试,而不是访问它的实现细节。我们希望能够改变类的内部实现,而不改变它的行为,并且这不应该导致任何测试失败。如果我们依赖于类的内部结构来进行测试,那就会把测试与类的实现紧密耦合。

作为经验法则,类的私有实现应该始终对外界保密;只有类本身才应该了解它。这在面向对象术语中叫做 封装

我们的 Vector 类现在已经准备好并经过测试。让我们实现一个类来表示矩阵。

矩阵

矩阵为向量增加了一个额外的维度。矩阵是按行和列分布的数字数组。

eqs 目录下创建一个新的文件 matrix.py。输入 Matrix 类的初始定义,如 清单 13-8 所示。

from utils.lists import list_of_list_of_zeros

class Matrix:

    def __init__(self, rows_count: int, cols_count: int):
        self.__rows_count = rows_count
        self.__cols_count = cols_count
        self.__is_square = rows_count == cols_count
        self.__data = list_of_list_of_zeros(rows_count, cols_count)

    @property
    def rows_count(self):
        return self.__rows_count

    @property
    def cols_count(self):
        return self.__cols_count

    @property
    def is_square(self):
        return self.__is_square

清单 13-8:Matrix 类

Matrix 类通过行数和列数进行初始化。这些值作为类的私有属性保存:__rows_count 和 __cols_count。它们作为公共属性公开:rows_count 和 cols_count。一个矩阵如果行数和列数相同,则为方阵。我们也将其作为属性公开:is_square。最后,我们使用在本章开始时创建的函数,初始化私有属性 __data,给它一个零填充的二维列表。

设置值

让我们添加设置矩阵值的方法。在 Matrix 类中,输入 清单 13-9 中的两个方法。

class Matrix:
    --snip--

   def set_value(self, value: float, row: int, col: int):
       self.__data[row][col] = value
       return self

   def add_to_value(self, amount: float, row: int, col: int):
       self.__data[row][col] += amount
       return self

清单 13-9:设置矩阵的值

就像我们在 Vector 类中做的那样,我们实现了一个方法,用于根据位置(由行和列给定)在矩阵中设置值,并且实现了另一个方法,用于在现有值上加上给定的数值。遵循我们设置值时返回实例的约定,set_value 和 add_to_value 方法都返回 self。

如果我们能通过一个值列表来填充矩阵,那也会非常方便。所以在我们刚刚写的代码之后,输入 清单 13-10 中的方法。

class Matrix:
    --snip--

   def set_data(self, data: [float]):
    ➊ if len(data) != self.__cols_count * self.__rows_count:
           raise ValueError('Cannot set data: size mismatch')

       for row in range(self.__rows_count):
        ➋ offset = self.__cols_count * row
           for col in range(self.__cols_count):
            ➌ self.__data[row][col] = data[offset + col]

       return self

清单 13-10:设置矩阵的值

如你所见,使用列表中的值来设置矩阵数据并不像设置向量那样直接。我们需要执行一个检查,以确保数据适合矩阵:给定的数据应该具有与行数乘以列数 ➊ 相同的长度,也就是矩阵包含的总值数。如果不匹配,我们将抛出一个 ValueError。

然后,我们遍历矩阵的行索引。在偏移量变量中,我们存储输入列表中当前行数据的偏移量 ➋。对于索引为 0 的行,偏移量也是 0。对于索引为 1 的行,偏移量将是行的长度:即矩阵中的列数,依此类推。图 13-1 展示了这个偏移量。接下来,我们遍历列的索引,并从输入数据 ➌ 中设置 __data 中的每个值。

Image

图 13-1:从列表中设置矩阵数据

正如我们将在第 V 部分中看到的,当我们处理桁架结构时,计算结构方程组的一个步骤是考虑节点上的外部约束条件。我们稍后会详细讲解,但现在知道这一修改要求我们将矩阵的行和列设置为单位向量就足够了。例如,如果我们有以下矩阵,

Image

将行和列分别设置为索引 0 和 1 的单位向量,结果如下所示:

Image

让我们在 Matrix 类中写两个方法来完成这个操作。输入代码见列表 13-11。

class Matrix:
    --snip--

    def set_identity_row(self, row: int):
        for col in range(self.__cols_count):
            self.__data[row][col] = 1 if row == col else 0

        return self

    def set_identity_col(self, col: int):
        for row in range(self.__rows_count):
            self.__data[row][col] = 1 if row == col else 0

        return self

列表 13-11:设置单位行和列

我们实现了两个新方法:set_identity_rowset_identity_col。两者的实现类似:它们将行或列中的所有值设置为 0,除了主对角线上的位置,它被设置为 1。

在这段代码中,我们使用了紧凑的条件表达式:三元运算符。该运算符的语法如下:

<expression> if <condition> else <expression>

它根据条件值返回两个表达式中的一个。在这个特定的案例中,我们的条件是row == col,当行索引和列索引相等时,该条件为 True。

请注意,如果矩阵不是方阵,可能会出现设置某行或某列为单位向量时,它最终会被填充为全零的情况。例如,见图 13-2。我们有一个三行两列的矩阵,并将第三行(索引为 2 的行)设置为单位行。由于矩阵只有两列,值 1 将超出矩阵范围,位于不存在的第三列中。

Image

图 13-2:在非方阵中设置单位行

现在让我们添加两个方法来从矩阵中获取值。

获取值

我们需要实现value_at方法,以便在给定的行和列索引处获取一个值。我们还需要另一个方法value_transposed_at,它能像矩阵已转置一样从矩阵中获取一个值。提醒一下:矩阵[M]的转置是另一个矩阵[M]^′,其中[M]的行和列互换:

Image

我们将在第十四章中使用这种第二种方法来实现 Cholesky 分解算法,用于求解线性方程组。我们也可以在 Matrix 类中实现一个方法,返回一个新矩阵,该矩阵是通过转置当前矩阵得到的,然后从这个矩阵中提取值。这个方法确实是一个不错的选择,但由于表示线性方程组的矩阵通常非常大,将所有值复制到新矩阵中是一个计算开销很大的操作。能够像访问转置矩阵一样获取值,是我们在 Cholesky 实现中用到的性能优化。

matrix.py中,输入清单 13-12 中的代码。

class Matrix:
    --snip--

    def value_at(self, row: int, col: int):
        return self.__data[row][col]

    def value_transposed_at(self, row: int, col: int):
        return self.__data[col][row]

清单 13-12:获取矩阵值

首先实现value_at。该方法从私有数据存储中返回给定行和列索引处的值。接着是value_transposed_at。正如你所见,这个方法与value_at类似,唯一的区别是它不是直接从矩阵中获取值,而是模拟转置后的矩阵获取。

self.__data[row][col]

这次从矩阵中提取的值是

self.__data[col][row]

通过交换行和列索引,可以像访问转置矩阵一样获取该矩阵的值。这个方法稍后会带来显著的性能提升。

使用此方法时需要记住的一点是,我们传入的行索引不应大于列数,列索引也不应大于行数。由于我们是以矩阵转置的方式访问数据,实际的行数是原矩阵的列数,列数也是如此。

缩放值

让我们实现最后一个有用的方法:缩放矩阵。就像我们可以缩放向量一样,我们也可以通过将矩阵的所有值乘以标量来缩放矩阵。请在清单 13-13 中输入此方法。

class Matrix:
    --snip--

    def scale(self, factor: float):
        for i in range(self.__rows_count):
            for j in range(self.__cols_count):
                self.__data[i][j] *= factor

        return self

清单 13-13:缩放矩阵

该方法遍历所有的行列索引,并将每个位置存储的值乘以传入的因子。由于这是一个设置数据的方法,我们返回 self。

矩阵相等性

为了完成 Matrix 类的实现,让我们加入__eq__方法来比较矩阵是否相等。首先,在matrix.py的顶部添加以下导入:

from geom2d import are_close_enough

然后在清单 13-14 中输入__eq__方法的实现。

from geom2d import are_close_enough
from utils.lists import list_of_list_of_zeros

class Matrix:
    --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Matrix):
            return False

        if self.__rows_count != other.rows_count:
            return False

        if self.__cols_count != other.cols_count:
            return False

        for i in range(self.__rows_count):
            for j in range(self.__cols_count):
                if not are_close_enough(
                        self.__data[i][j],
                        other.__data[i][j]
                ):
                    return False

        return True

清单 13-14:矩阵类相等性

像往常一样,我们首先检查selfother的引用,因为如果我们正在将一个实例与它自己进行比较,就不需要再比较其他任何东西,比较可以安全地返回 True。然后,我们确保传入的对象是Matrix类的实例;否则,我们就没有太多可以比较的内容。

在开始逐一比较矩阵值之前,我们要确保矩阵的大小相同。如果检测到行数或列数不匹配,我们返回 False。

最后,如果所有前面的检查都没有返回值,我们比较两个矩阵的值。一旦找到一对不相等的值(根据我们的are_close_enough函数),我们返回 False。如果所有值都相等,我们退出 for 循环,并最终返回 True。

作为参考,你的matrix.py文件应该如下所示:清单 13-15。

from geom2d import are_close_enough
from utils.lists import list_of_list_of_zeros

class Matrix:

    def __init__(self, rows_count: int, cols_count: int):
        self.__rows_count = rows_count
        self.__cols_count = cols_count
        self.__is_square = rows_count == cols_count
        self.__data = list_of_list_of_zeros(rows_count, cols_count)

    @property
    def rows_count(self):
        return self.__rows_count

    @property
    def cols_count(self):
        return self.__cols_count

    @property
    def is_square(self):
        return self.__is_square

    def set_value(self, value: float, row: int, col: int):
        self.__data[row][col] = value
        return self

    def add_to_value(self, amount: float, row: int, col: int):
        self.__data[row][col] += amount
        return self

    def set_data(self, data: [float]):
        if len(data) != self.__cols_count * self.__rows_count:
            raise ValueError('Cannot set data: size mismatch')

        for row in range(self.__rows_count):
            offset = self.__cols_count * row
            for col in range(self.__cols_count):
                self.__data[row][col] = data[offset + col]

        return self

    def set_identity_row(self, row: int):
        for col in range(self.__cols_count):
            self.__data[row][col] = 1 if row == col else 0

        return self

    def set_identity_col(self, col: int):
        for row in range(self.__rows_count):
            self.__data[row][col] = 1 if row == col else 0

        return self

    def value_at(self, row: int, col: int):
        return self.__data[row][col]

    def value_transposed_at(self, row: int, col: int):
        return self.__data[col][row]

    def scale(self, factor: float):
        for i in range(self.__rows_count):
            for j in range(self.__cols_count):
                self.__data[i][j] *= factor

        return self

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, Matrix):
            return False

        if self.__rows_count != other.rows_count:
            return False

        if self.__cols_count != other.cols_count:
            return False

        for i in range(self.__rows_count):
            for j in range(self.__cols_count):
                if not are_close_enough(
                        self.__data[i][j],
                        other.__data[i][j]
                ):
                    return False

        return True

清单 13-15:矩阵类结果

我们的矩阵类差不多完成了!我们需要检查是否有 bug。在编写代码时,我们可能犯了一些小错误。一旦开始使用这个类来求解方程组,这些错误可能会引发问题。这类计算通常在工程应用中至关重要,因此我们不能容忍实现中的任何 bug。不过这对我们来说没问题。我们知道该怎么做:让我们添加一些自动化单元测试。

测试矩阵类

tests文件夹中,创建一个新的文件,命名为matrix_test.py。在清单 13-16 中输入测试的初始代码。

import unittest

from eqs.matrix import Matrix

class MatrixTest(unittest.TestCase):

    def test_is_square(self):
        self.assertTrue(
            Matrix(2, 2).is_square
        )

    def test_is_not_square(self):
        self.assertFalse(
            Matrix(2, 3).is_square
        )

清单 13-16:矩阵单元测试

在这个文件中,我们定义了一个新的测试类叫做MatrixTest,它继承自TestCase。我们为is_square属性创建了两个测试,一个检查矩阵是否是方阵,另一个检查矩阵是否不是方阵。运行测试,理想情况下它们都能通过,但如果没有,回到属性的实现中,确保实现正确。你可以使用以下命令从命令行运行测试:

$  python3 -m unittest eqs/tests/matrix_test.py

你应该得到类似下面的输出:

Ran 2 tests in 0.001s

OK

现在让我们检查设置或获取值的方法。在我们刚刚编写的两个测试之后,在清单 13-17 中输入测试。

class MatrixTest(unittest.TestCase):
    --snip--

    def test_unset_value_is_zero(self):
        matrix = Matrix(2, 2)
        self.assertEqual(0.0, matrix.value_at(0, 1))

    def test_set_get_value(self):
        value = 10.0
        matrix = Matrix(2, 2).set_value(value, 0, 1)
        self.assertEqual(value, matrix.value_at(0, 1))

    def test_add_to_value(self):
        expected = [1, 12, 3, 4]
        matrix = Matrix(2, 2) \
            .set_data([1, 2, 3, 4]) \
            .add_to_value(10, 0, 1)
        self.assert_matrix_has_data(matrix, expected)

清单 13-17:测试设置和获取值

第一个测试确保在实例化时,矩阵中尚未设置的值为零。接着,我们测试set_valuevalue_at方法,确保它们能够正确设置和获取矩阵值。最后,我们测试add_to_value方法,确保它能够将给定的数值加到已经设置的值上。

在这个最后的测试中,我们使用了一个不存在的断言方法:assert _matrix_has_data。我们需要在 MatrixTest 类中自己实现这个方法,并在需要确保矩阵中所有值符合预期时使用它。通过这样做,我们可以只用一个断言来检查矩阵中的值是否与作为第二个参数传入的列表中的值相同。在测试类的最后部分,输入 Listing 13-18 中显示的方法定义。

class MatrixTest(unittest.TestCase):
    --snip--

   def assert_matrix_has_data(self, matrix, data):
       for row in range(matrix.rows_count):
           offset = matrix.cols_count * row
           for col in range(matrix.cols_count):
               self.assertEqual(
                   data[offset + col],
                   matrix.value_at(row, col)
               )

Listing 13-18: 自定义矩阵值断言

这个断言方法与 Matrix 类中的 set_data 结构相同。这一次,我们不是设置值,而是使用 assertEqual 来测试是否相等。

我们必须注意,加入一个具有自己逻辑(此处为偏移计算)的断言方法,给测试引入了另一个失败的可能原因:断言方法本身实现错误。和往常一样,如果我们想要务实,就必须进行权衡。我们可以运用工程常识来分析利弊和替代方案。在这种情况下,拥有一个自定义的断言来检查矩阵值是值得的:它简化了矩阵值的断言,并使编写新测试和检查矩阵值变得轻松无忧。我们只需要特别确保断言方法中的逻辑是正确的。

现在让我们测试 set_data 方法。测试代码在 Listing 13-19 中。

class MatrixTest(unittest.TestCase):
    --snip--

   def test_set_data(self):
       data = [1, 2, 3, 4, 5, 6]
       matrix = Matrix(2, 3).set_data(data)
       self.assert_matrix_has_data(matrix, data)

Listing 13-19: 测试从列表设置数据

在这个测试中,我们使用了自定义的断言方法,使得测试变得非常简短和简洁。我们创建了一个有两行三列的矩阵,使用一个包含 1 到 6 之间数字的列表设置数据,然后断言这些数据是否已经正确地放置在各自的位置。

接下来,我们的测试应该是针对设置身份行和列的方法。请输入 Listing 13-20 中的测试。

class MatrixTest(unittest.TestCase):
    --snip--

    def test_set_identity_row(self):
        expected = [1, 0, 4, 5]
        matrix = Matrix(2, 2) \
            .set_data([2, 3, 4, 5]) \
            .set_identity_row(0)
        self.assert_matrix_has_data(matrix, expected)

    def test_set_identity_col(self):
        expected = [2, 0, 4, 1]
        matrix = Matrix(2, 2) \
            .set_data([2, 3, 4, 5]) \
            .set_identity_col(1)
        self.assert_matrix_has_data(matrix, expected)

Listing 13-20: 测试设置身份行和列

在这两个测试中,我们首先指定结果矩阵的预期值。然后,我们创建一个新的 2×2 矩阵,并将其值设置为介于 2 到 5 之间的数字列表。接着,我们设置身份行或身份列,并断言这些值是否符合预期。

我们避免在矩阵的任何初始值中使用 1:我们正在测试的方法会将矩阵中的某个值设置为 1。假设我们实现的 set_identity_row 方法错误地将矩阵中的一个值设置为 1,并且它选择将已经初始化为 1 的值再次设置为 1。如果是这样,我们的测试将无法检测到这个错误,因为无法判断这个 1 是我们在测试开始时自己设置的,还是 set_identity_row 方法设置的。通过不使用 1 作为输入值,我们避免了暴露测试中的这个问题。

我们在 Matrix 类中实现的最后一个需要测试的方法是:scale。测试代码见示例 13-21。

class MatrixTest(unittest.TestCase):
    --snip--

   def test_scale(self):
       expected = [2, 4, 6, 8, 10, 12]
       matrix = Matrix(2, 3) \
           .set_data([1, 2, 3, 4, 5, 6]) \
           .scale(2)
       self.assert_matrix_has_data(matrix, expected)

示例 13-21:测试缩放矩阵

该测试创建了一个 2×3 的矩阵,使用 1 到 6 的数字设置其数据,然后将所有数值都缩放为 2。通过自定义的 assert_matrix_has_data 断言,我们检查所有的数值是否正确缩放。确保运行测试类中的测试。在 shell 中,运行方式如下:

$ python3 -m unittest eqs/tests/matrix_test.py

你应该得到类似下面的输出:

Ran 9 tests in 0.001s

OK

总结

在本章中,我们实现了两个类,它们将帮助我们处理方程组:Vector(向量)和 Matrix(矩阵)。在下一章,我们将使用这两个类来表示我们将通过数值方法求解的方程组。

第十四章:线性方程

图片

许多工程问题需要求解线性方程组。这些方程出现在结构分析、电路、统计和优化问题中,仅举几例。实现解决这些普遍存在的方程组的算法是我们 力学 项目应对现实工程问题的关键。

本章我们将探讨 数值方法 的概念:使用计算机求解方程组的现有算法。我们将实现一种强大的方法来求解线性方程组:Cholesky 分解。当我们需要解决书中第五部分中的结构分析问题中的大型方程组时,我们将使用这种方法。

线性方程组

具有 n 个未知数 x[1]、x[2]、…、x[n] 的线性方程可以表示为方程 14.1 所示。

图片

在这里,m[1]、m[2]、…、m[n] 是方程的系数,即乘以每个未知数的已知数字,而 b 是一个已知数字,不与任何未知数相乘。我们将这个最后的数字 b 称为 自由项

如果未知数只是与标量相乘、相加或相减,则我们称该方程为 线性 方程。系数始终是已知量。另一种表示线性方程的方式如下,

图片

其中 m[i] 是系数,x[i] 是未知数,b 是自由项。

相比之下,非线性方程包括未知数带有指数(x³)、三角函数(sin(x))或多个未知数的乘积(x[1] ⋅ x[2])。这些方程比线性方程更难求解,因此我们将专注于线性方程。

线性方程组的形式如方程 14.2 所示。

图片

在这里,系数 m[i,j] 是乘以第 i 个方程中第 j 个未知数的项。这些方程组可以方便地表示为矩阵形式,如方程 14.3 所示。

图片

在这里,[M] 是系数矩阵,

图片

并且 [x] 和 [b] 是未知数和自由项列向量:

图片

方程 14.3 的解是一个由数字 x[1]、x[2]、…、x[n] 组成的集合,满足所有 n 个方程。手动求解大型方程组可能需要很长时间,但不用担心:有很多算法可以用计算机求解这样的方程组。

关于命名法的简要说明。我们将在方括号内使用大写字母表示矩阵:[M]。矩阵中的元素将使用与矩阵相同字母的小写形式命名,并且在下标中包含两个用逗号分隔的数字,表示它们在矩阵中的行列索引。例如,矩阵[M]中第 3 行第 5 列的数字将被称为m[3, 5]。列向量和行向量用方括号内的小写字母表示:[x]。请记住,列向量和行向量也是矩阵。

数值方法

数值方法是使用计算机的计算能力找到方程组近似解的算法。

有许多数值方法旨在求解线性、非线性和微分方程系统。然而,大多数数值方法仅限于求解特定类型的方程组。例如,Cholesky 分解仅适用于系数矩阵是对称且正定的线性方程组(稍后我们将了解这意味着什么)。如果我们需要求解一个非线性方程组,或者一个线性但系数矩阵非对称的方程组,Cholesky 分解将无法使用。

数值方法有两大类:直接法迭代法。直接法通过对原始系统进行代数修改来求解系统。另一方面,迭代法从方程组的近似解开始,通过逐步改进,直到解达到所需的精度为止。Cholesky 分解是一种直接数值方法。

数值方法是一个庞大的主题:已有专门的书籍讨论它。数值方法涉及许多技术细节,我们这里不会一一介绍。但这不是一本理论书籍;我们更关心实践,因此我们将实现一个算法,用于求解在接下来的结构分析应用中可能出现的方程组。在这种情况下,这意味着我们将处理具有对称、正定系数矩阵的线性方程组。

Cholesky 分解

Cholesky 分解是一种直接(非迭代)方法,用于求解线性方程组,前提是它们的[M](系数矩阵)是对称正定的。

对称矩阵[M]是指其等于其转置矩阵:[M] = [M]^′。这意味着矩阵中的数值相对于主对角线是对称的。在对称矩阵中,每一行包含与同一索引的列相同的值,反之亦然。注意,要成为对称矩阵,矩阵必须是方阵。以下是一个对称矩阵的例子:

Image

一个具有 n 行和列的方阵 [M] 被称为 正定矩阵,如果对于任何由 n 个实数(除了一个全零向量)构成的列向量 [x],方程 14.4 中的表达式成立。

Image

如果你找到一个不满足上述方程的非零向量 [x],那么矩阵 [M] 就不是正定的。

我们也可以说,如果一个矩阵是对称的,并且它的所有特征值都是正的,那么它是正定的。如果你记得获得矩阵特征值的过程,你可能会同意这既痛苦又有点无聊。无论如何,证明对于每个可能的 [x],[x]^′[M][x] > 0,或者获得矩阵的所有特征值并确保它们都为正,都是一个复杂的过程。

我们将跳过所有这些技术复杂性,不会演示我们将使用的矩阵是正定的。我们将应用 Cholesky 分解来解决一个众所周知的问题,该问题会产生一个对称正定矩阵的方程组:使用直接刚度法进行的桁架结构分析。如果你需要将这个算法应用到其他问题中,你首先需要确定为该问题导出的方程组是否具有 Cholesky 可以处理的矩阵。如果不是,也不用担心:你可以使用很多其他数值方法。

在我们一起实现 Cholesky 算法后,希望你能够自己实现其他数值方法。正如你所看到的,确保我们正确处理这些复杂算法的最强大工具就是单元测试。

LU 分解方法

Cholesky 是属于所谓 LU 分解或分解方法家族中的一种计算方法。给定方阵 [M] 的 LU 分解形式如 方程 14.5 所示。

Image

在这里,[L] 是一个 下三角矩阵,而 [U] 是一个 上三角矩阵。下三角矩阵是指所有非零值都位于主对角线及其下方。相反,上三角矩阵的非零值位于主对角线及其上方。以下是一个下三角矩阵和上三角矩阵的示例:

Image

每个 非奇异矩阵(具有逆矩阵的矩阵)总是可以进行 LU 分解。例如,前面的矩阵就是该矩阵的 LU 分解:

Image

你可以通过乘以以下内容来验证这一点:

Image

Cholesky 算法将为我们提供一个下三角矩阵和一个上三角矩阵。除了 Cholesky 之外,还有两种著名的方法可以用于获得任何非奇异矩阵的分解:Doolittle 算法和 Crout 算法。这些算法定义了计算下三角矩阵和上三角矩阵的l[i,j]和u[i,j]值所需的公式。这些方法的好处是它们适用于任何类型的矩阵,而不仅仅是对称正定矩阵。我们在这里不讨论这些方法,但我鼓励你了解它们,并尝试在我们的力学项目中自己实现其中之一。你可以在我们实现 Cholesky 算法之后,把它作为练习来尝试。

值得问一下,为什么不使用可以处理任何非奇异矩阵的 Doolittle 或 Crout 算法,而非使用更具限制性的 Cholesky 算法呢?对于对称正定矩阵,Cholesky 分解的速度大约是这些其他算法的两倍。由于我们将使用适用此方法的矩阵类型,我们希望能够从 Cholesky 方法所提供的执行速度中获益。

一旦我们获得了矩阵的LU分解,我们就可以通过两步解决方程组。假设我们的原始系统如下:

[M][x] = [b]

在对[M]进行分解后,我们得到了方程 14.6。

Image

如果我们取[U][x]的乘积并用一个新的未知向量[y]代替它,就可以从方程 14.6 中提取出两个系统:

Image

我们现在有了一个下三角矩阵系统,如方程 14.7 所示,

Image

并且有一个上三角矩阵系统,如方程 14.8 所示。

Image

通过首先求解方程 14.7,我们得到[y],然后将其代入方程 14.8,可以计算出未知向量[x]:即系统的解。 方程 14.7 和方程 14.8 都是具有三角矩阵的方程组,且可以通过前向和后向替代法轻松求解。

取这个系数矩阵是下三角矩阵的方程组:

Image

第一个未知数y[1]可以通过以下方式从第一个方程计算得出:

Image

从第二个方程我们得到了以下内容,

Image

这个系统可以求解,因为我们已经在前一步计算出了y[1]的值。我们对第三个方程做同样的处理:

Image

我们已经有了y[1]和y[2]的值,因此可以计算出y[3]的值。这个过程称为前向替代。使用前向替代法得到y^(第 i 项)解的公式见于方程 14.9(采用零基索引)。

Image

在一个系数矩阵为上三角矩阵的系统中,我们可以使用类似的替代过程,但这次从底部开始。这个过程叫做逆向替代。这时我们得到:

Image

从最后一个方程开始,我们可以计算x[3]:

Image

有了这个值,我们可以进入第二个方程,计算x[2]:

Image

最后,从系统中的第一个方程我们得到:

Image

对于逆向替代,计算x^(ith)项的公式由公式 14.10 描述,其中n是系统的大小。

Image

我们很快就需要在代码中实现这些公式。你会发现它比看起来简单。

理解 Cholesky

如我们所讨论的,Cholesky 分解是一种与对称正定矩阵配合使用的LU方法。由于这些特性,矩阵[M]可以分解成[L][U]形式,其中上三角矩阵是下三角矩阵的转置:[U] = [L]^′。这意味着我们只需要计算下三角矩阵[L]:[U]只是其转置。使用 Cholesky 方法,[M]矩阵的分解形式如公式 14.11 所示。

Image

所以,方程组现在看起来像公式 14.12。

Image

在这种情况下,我们通过将[L]^′[x]替换为[y],得到我们需要求解的两个方程组:

Image

如我们已经知道的,这个变换会产生一个下三角系统,我们将首先使用前向替代法来求解(见公式 14.13),

Image

然后,使用一个上三角系统,通过逆向替代法求解,得到解向量[x](见公式 14.14)。

Image

给定一个对称正定矩阵[M],我们可以使用公式 14.15 中的公式计算其 Cholesky 分解的下三角矩阵项,即l[i,j]项。

Image

公式 14.15 可能看起来令人畏惧,但实际上并没有那么复杂。最好的理解方式是通过手动做一个练习。拿起一支笔和一些纸,我们一起进行矩阵分解。

手动因式分解

给定对称正定矩阵

Image

让我们找到它的 Cholesky 分解,一个下三角矩阵[L],

Image

使得[M] = [L][L]^′。为了计算l[i,j]项,我们使用公式 14.15。别忘了,索引i表示矩阵的行,j表示矩阵的列。我们一步一步来。

步骤 1: i = 0,j = 0。由于 i = j,我们使用第一个公式:

Image

请注意,求和符号被划掉了,因为它没有产生任何项。这是因为求和的结束值 k = –1 小于开始值 k = 0。如你所知,为了让求和产生任何项,k(迭代变量)的结束值必须等于或大于开始值。

步骤 2: i = 1,j = 0。此时,ij,所以我们使用第二个公式:

Image

步骤 3: i = 1,j = 1。

Image

步骤 4: i = 2,j = 0。

Image

步骤 5: i = 2,j = 1。

Image

步骤 6: i = 2,j = 2。

Image

如果我们将所有计算出的 l[i,j] 值合并,得到的矩阵如下:

Image

这意味着原始系统的矩阵 [M] 可以如下因式分解:

Image

你可以进行矩阵乘法验证,L[L]^′ 的积实际上等于 M。为了完成这个练习,假设这个矩阵是一个方程组的系数矩阵,并使用前向和后向替代法来求解它。

手动解法

假设我们之前分解的矩阵 [L][L]^′ 形式是以下方程组的一部分:

Image

我们需要找到满足所有三个方程的 x[1]、x[2] 和 x[3] 的值。使用我们刚刚获得的 Cholesky 分解,我们可以将系统重写为如下形式:

Image

我们需要解的两个子系统中的第一个是 [L][y] = [b],这是通过将 [L]^′[x] 替换为新的未知向量 [y] 得到的:

Image

这就得到了第一个系统(下系统):

Image

我们必须使用 方程 14.9 中的前向替代公式来求解这个系统。

下系统:前向替代

让我们逐步应用 方程 14.9:

步骤 1: i = 0。

Image

步骤 2: i = 1。

Image

步骤 3: i = 2。

Image

因此,第一个系统的解如下:

Image

有了这个解,我们可以使用后向替代法计算 [x]:即我们的方程组的解。

上系统:后向替代

让我们使用 方程 14.10 逐步计算解向量。这一次,我们需要使用后向替代法来求解以下方程组:

Image

由于替代过程是反向的,我们必须从最后一行(i = 2)开始,一直到第一行(i = 0)。

步骤 1: i = 2。

Image

步骤 2: i = 1。

Image

步骤 3: i = 0。

Image

然后,初始系统的解如下:

Image

你可以通过检查等式是否成立来测试解是否正确:

Image

现在我们知道了 Cholesky 算法的工作原理,并且已经通过手算做了一个示例,让我们在代码中实现这个算法。

实现 Cholesky

首先在eqs包中创建一个名为cholesky.py的新文件。在该文件中,包含清单 14-1 中的 cholesky_solve 函数。

import math

from eqs.matrix import Matrix
from eqs.vector import Vector

def cholesky_solve(sys_mat: Matrix, sys_vec: Vector):
    validate_system(sys_mat, sys_vec)

    low_matrix = lower_matrix_decomposition(sys_mat)
    low_solution = solve_lower_sys(low_matrix, sys_vec)
    return solve_upper_sys(low_matrix, low_solution)

清单 14-1:Cholesky 分解算法

此函数接受矩阵和向量作为输入。这些是系统的系数矩阵和自由向量:来自方程组[M][x] = [b]中的[M]和[b]。返回的向量是[x],即通过 Cholesky 方法求得的系统解。

这个 cholesky_solve 函数定义了最高层的算法,包含三个主要步骤和一个输入系统的验证。我们还没有实现这些函数;我们稍后会实现。以下是算法的三个主要步骤:

lower_matrix_decomposition 通过应用方程 14.15 获得[L],即下三角矩阵。

solve_lower_sys 通过应用前向代入法(见方程 14.9)解第一个子系统,即下三角系统。

solve_upper_sys 通过应用回代法(见方程 14.10)解第二个子系统,即上三角系统。

从函数名称来看,很容易看出 cholesky_solve 中的代码在做什么。请注意,我们将函数拆分成了几个较小的函数。如果我们把所有用于 Cholesky 分解的代码都丢进 cholesky_solve 函数中,结果将是一个没有明显结构的长代码块。这段代码将非常难以理解。

一般来说,你需要将大的算法分解成较小的子算法,每个子算法都包含在一个具有描述性名称的小函数中。

请注意 cholesky_solve 使用的子函数的可见性。所有子函数都是公开的。这是为了能够单独对它们进行单元测试。分解算法稍显复杂,如果我们知道每个子部分都能正确执行其功能,处理起来会更安全。

验证系统

让我们实现一个函数,验证系统是否是方阵,并且列数是否等于向量的大小。输入清单 14-2 中的 validate_system 函数代码。

--snip--

def validate_system(sys_matrix: Matrix, sys_vector: Vector):
    if sys_matrix.cols_count != sys_vector.length:
        raise ValueError('Size mismatch between matrix and vector')

    if not sys_matrix.is_square:
        raise ValueError('System matrix must be square')

清单 14-2:系统验证

我们首先检查矩阵的列数是否与向量的长度相同。如果这个条件不满足,系统就无法解,因此我们会抛出一个错误。如果系统的矩阵不是方阵,情况也是如此。

我们并没有检查矩阵是否对称或正定;如果传递给我们函数的矩阵不满足这些条件,函数最终会因为除零错误或类似问题而失败。添加这些保护措施是一个不错的主意,至少要检查对称性,但检查矩阵是否正定可能会更具挑战性。对称性检查容易实现,但缺点是计算开销大。我鼓励你考虑如何进行这些检查,并可能将它们添加到你的代码中。

现在我们要做点反向操作。我们将从单元测试开始,而不是直接从代码本身开始。这样我们就能知道何时代码准备好:一旦测试通过。我们可以不断运行测试来检查我们编写的逻辑是否已经准备好;我们可以重构代码,直到它变得可读,并且有测试的安全网,当我们做错了什么时,测试会警告我们。这种在编写代码之前编写测试的技术称为测试驱动开发,简称 TDD。

我们将首先查看我们将在单元测试中使用的方程组。

测试用方程组

为了确保我们实现的所有逻辑没有错误,我们将为 Cholesky 算法中的每个子函数编写测试。我们还将实现一个测试,检查所有子函数是否能够协同工作以计算最终解。对于这些测试,我们希望使用一个我们事先知道解的方程组。

让我们使用以下大小为 4 的系统:

Image

对于该系统的矩阵[M],Cholesky [L][L]^′ 分解如下:

Image

下层系统的解,

Image

是以下向量:

Image

最终解,来自上层系统的求解,

Image

是以下向量:

Image

花时间检查所有这些数字,并确保你理解解算过程是一个好主意。一旦你掌握了这个过程的基本原理,我们就开始编码,从单元测试开始。

下三角矩阵分解

由于我们即将实现本书迄今为止最复杂的算法,首先让我们编写一个单元测试。我们会知道我们的分解逻辑是否正确实现,一旦测试通过。很可能我们需要调试代码,拥有一个测试会帮助我们。

创建一个新的文件用于我们的测试,cholesky_test.py,并将其放入eqs/tests目录下。然后在 Listing 14-3 中输入测试代码。

import unittest

from eqs.cholesky import lower_matrix_decomposition
from eqs.matrix import Matrix

class CholeskyTest(unittest.TestCase):
    sys_matrix = Matrix(4, 4).set_data([
        4, -2, 4, 2,
        -2, 10, -2, -7,
        4, -2, 8, 4,
        2, -7, 4, 7
    ])
    low_matrix = Matrix(4, 4).set_data([
        2, 0, 0, 0,
        -1, 3, 0, 0,
        2, 0, 2, 0,
        1, -2, 1, 1
    ])

    def test_lower_matrix_decomposition(self):
        actual = lower_matrix_decomposition(self.sys_matrix)
        self.assertEqual(self.low_matrix, actual)

Listing 14-3:测试下三角矩阵分解

这个测试定义了原始矩阵 sys_matrix 和期望的分解矩阵 low_matrix。使用一个我们尚未定义的函数 lower_matrix_decomposition,我们计算分解矩阵并将其与已知解进行比较。你的 IDE 应该会报错,提示你试图导入一个在eqs.cholesky模块中找不到的函数:

在'cholesky.py'中找不到引用'lower_matrix_decomposition'

让我们来实现这个函数。返回到 cholesky.py 文件,在 validate_system 之后,输入 Listing 14-4 中的代码。

--snip--

def lower_matrix_decomposition(sys_mat: Matrix):
    size = sys_mat.rows_count
    low_mat = Matrix(size, size)

    for i in range(size):
        sq_sum = 0

        for j in range(i + 1): 
         ➊ m_ij = sys_mat.value_at(i, j)

           if i == j:
               # main diagonal value
            ➋ diag_val = math.sqrt(m_ij - sq_sum)
            ➌ low_mat.set_value(diag_val, i, j)

           else:
               # value under main diagonal
               non_diag_sum = 0
            ➍ for k in range(j):
                   l_ik = low_mat.value_at(i, k)
                   l_jk = low_mat.value_at(j, k)
                   non_diag_sum += l_ik * l_jk

               l_jj = low_mat.value_at(j, j)
            ➎ non_diag_val = (m_ij - non_diag_sum) / l_jj
            ➏ sq_sum += non_diag_val * non_diag_val

            ➐ low_mat.set_value(non_diag_val, i, j)

    return low_mat

Listing 14-4:下三角矩阵分解

我们首先将系统的大小存储在一个名为 size 的变量中。大小是行数或列数——由于矩阵是方阵,因此二者无关。接着我们创建一个相同大小的新方阵 low_mat。回顾一下,我们的矩阵在实例化时是用零填充的。

该算法有两个主要的嵌套循环。这些循环遍历矩阵中主对角线及其下方的所有位置,也就是说,遍历所有m[i,j],其中ij

注意

不要忘记,Python 的 range(n) 函数生成的是从 0 到n - 1的序列,而不是n*。

在 j 循环内部,我们将系统矩阵在位置(i,j)的值存储在 m_ij ➊中。然后,我们通过 if else 语句区分是在主对角线(i == j)上,还是在其下方。回顾一下,计算分解矩阵主对角线上一个项的公式如下:

Image

我们使用这个表达式来计算值,将其存储在 diag_val ➋中,并设置在矩阵中 ➌。计算中我们使用了 m_ij 值和 sq_sum。后者在每次新的i(每一行)迭代时初始化为 0,并在主对角线下方的每个新值更新 ➏。

对于主对角线下方的情况(i > j,即 else 分支),计算l[i,j]项的公式如下:

Image

请注意,为了计算这个l[i,j]值,我们需要有l[j,j],这是来自前一行的一个值,因为i > j。我们计算的第一个项是l[i,k]l[j,k]的和,k从 0 到j - 1。步骤 ➍ 中的循环正是这样做的。在进入循环之前,我们将一个名为 non_diag_sum 的变量初始化为零。在循环内部,这个变量在每次循环中都会加上 l_ik 和 l_jk 的乘积。

计算出 non_diag_sum 后,我们就拥有了所需的一切。l[j,j]的值从 low_mat 中提取,并存储在变量 l_jj 中。然后,分解的值被计算出来并存储在变量 non_diag_val ➎中。这个值首先用于更新 sq_sum ➏,然后存储在分解矩阵 ➐中。

就这样。运行我们之前编写的测试,确保你的代码能够通过测试。如果没有通过,不用担心;事实上,第一次编写这个算法时,可能很难完全正确,但这正是我们先实现测试的原因。使用测试来调试代码,并仔细对比你编写的代码和本书中打印出的代码版本。你也可以参考书中附带的代码。

在 shell 中运行测试,使用以下命令:

$ python3 -m unittest eqs/tests/cholesky_test.py

我们已经通过 Cholesky 算法得到了 [L] 分解矩阵。现在让我们实现下三角和上三角系统的求解。

下三角系统求解

为了使用前向代换法求解下三角系统,我们需要实现方程 14.9 中的算法。为了方便起见,我们在这里重复公式:

Image

我们将采用与之前相同的方法,先编写测试,再编写主代码。在 cholesky_test.py 文件中,在清单 14-5 中输入新的测试。

import unittest

from eqs.cholesky import lower_matrix_decomposition, \
    solve_lower_sys
from eqs.matrix import Matrix
from eqs.vector import Vector

class CholeskyTest(unittest.TestCase):
    --snip--
  ➊ sys_vec = Vector(4).set_data([20, -16, 40, 28])
  ➋ low_solution = Vector(4).set_data([10, -2, 10, 4])

    def test_lower_matrix_decomposition(self):
        actual = lower_matrix_decomposition(self.sys_matrix)
        self.assertEqual(self.low_matrix, actual)

  ➌ def test_lower_system_resolution(self):
        actual = solve_lower_sys(self.low_matrix, self.sys_vec)
        self.assertEqual(self.low_solution, actual)

清单 14-5:测试下三角系统求解

我们首先从 eqs.vector 导入了 Vector 类。然后我们添加了两个新向量,这些向量是新测试所需的:sys_vec ➊,这是方程组的自由向量,以及 low_solution ➋,这是下三角系统的预期解。

在测试就位 ➌ 后,现在让我们实现缺失的 solve_lower_sys 函数。在 cholesky.py 文件中的分解函数后,输入清单 14-6 中的代码。

--snip--

def solve_lower_sys(low_mat: Matrix, vector: Vector):
    size = vector.length
    solution = Vector(size)

 ➊ for i in range(size):
        _sum = 0.0

     ➋ for j in range(i):
            l_ij = low_mat.value_at(i, j)
            y_j = solution.value_at(j)
            _sum += l_ij * y_j

        b_i = vector.value_at(i)
        l_ii = low_mat.value_at(i, i)
     ➌ solution_val = (b_i - _sum) / l_ii
        solution.set_value(solution_val, i)

    return solution

清单 14-6:求解下三角系统

我们做的第一件事是将系统的大小保存在一个变量 size 中,并创建一个该大小的解向量。主循环是 i 循环 ➊,它遍历 sys_vector 中的所有值。在循环中,我们首先将和初始化为零。j 循环 ➋ 遍历从 0 到 i – 1 的所有值,并在每次迭代时更新和。

得到方程的和部分后,我们可以计算出解值,并将其存储在 solution_val ➌ 中。然后我们在下一行设置解向量。

运行 cholesky_test.py 中的两个测试,确保都通过。第一个测试通过似乎是合理的:我们没有以任何方式修改分解函数,但最好运行文件中的所有测试,以防我们修改了不该修改的东西。我希望第二个测试对你来说也能通过,这样你就成功实现了新函数!否则,你需要调试你的代码。请花时间这样做,这是一个很好的练习。

从 shell 运行测试,使用以下命令:

$ python3 -m unittest eqs/tests/cholesky_test.py

现在让我们来处理上三角系统的求解。

上三角系统求解

使用回代法求解上三角系统可以通过方程 14.10 来实现。为了提醒你,公式如下:

Image

需要记住的一件重要事是,上三角矩阵 [U],其值为 u[i,j],是 Cholesky 下三角分解的转置:[L]^′。

我们再次从测试开始。打开你的 cholesky_test.py 文件,并在 清单 14-7 中输入新的测试。

import unittest

from eqs.cholesky import lower_matrix_decomposition, \
    solve_lower_sys, solve_upper_sys
from eqs.matrix import Matrix
from eqs.vector import Vector

class CholeskyTest(unittest.TestCase):
    --snip--
 ➊ solution = Vector(4).set_data([1, 2, 3, 4])

    def test_lower_matrix_decomposition(self):
        actual = lower_matrix_decomposition(self.sys_matrix)
        self.assertEqual(self.low_matrix, actual)

    def test_lower_system_resolution(self):
        actual = solve_lower_sys(self.low_matrix, self.sys_vec)
        self.assertEqual(self.low_solution, actual)

 ➋ def test_upper_system_resolution(self):
        actual = solve_upper_sys(
            self.low_matrix,
            self.low_solution
        )
        self.assertEqual(self.solution, actual)

清单 14-7:测试上三角系统的求解

在这个新测试中 ➋,我们调用 solve_upper_sys(尚未编写),传入分解后的矩阵 low_matrix 和下三角系统的解 low_solution。然后,我们断言返回的向量是我们期望的那个,即我们在测试数据中的 solution 变量中定义的向量 ➊。

我们现在准备实现最后一部分,以完成 Cholesky 方法:求解上三角系统。再次打开 cholesky.py 文件,并进入 清单 14-8 中的 solve_upper_sys 函数。

--snip--

def solve_upper_sys(up_matrix: Matrix, vector: Vector):
    size = vector.length
    last_index = size - 1
    solution = Vector(size)

 ➊ for i in range(last_index, -1, -1):
        _sum = 0.0

     ➋ for j in range(i + 1, size):
         ➌ u_ij = up_matrix.value_transposed_at(i, j)
            x_j = solution.value_at(j)
            _sum += u_ij * x_j

        y_i = vector.value_at(i)
     ➍ u_ii = up_matrix.value_transposed_at(i, i)
     ➎ solution_val = (y_i - _sum) / u_ii
        solution.set_value(solution_val, i)

    return solution

清单 14-8:求解上三角系统

该函数类似于之前的 solve_lower_sys 函数。我们首先初始化解向量 solution,大小与传入的 low_vector 相同。这一次,因为我们从最后一行开始迭代,所以我们将其索引保存在 last_index 变量中。

迭代所有行索引的循环从 last_index 一直到 -1(不包括) ➊。内部循环从 i + 1 到 size(同样不包括),计算 u[i,j]x[j] 的乘积之和 ➋。为了得到 u_ij,我们像访问转置矩阵一样访问下三角矩阵的值 ➌。多亏了这个巧妙的技巧,我们避免了转置 [L],这一过程计算开销很大。这就是我们在前一章中讨论的优化方法。

为了得到 公式 14.10 中的除数,我们再次使用 value_transposed_at 函数 ➍。有了这个值,我们就可以计算每一行的解 ➎,并将其存储在结果向量中。

运行文件中的所有测试,检查实现是否没有错误。仅供参考,清单 14-9 是完整的 cholesky.py 文件。

import math

from eqs.matrix import Matrix
from eqs.vector import Vector

def cholesky_solve(sys_mat: Matrix, sys_vec: Vector) -> Vector:
    validate_system(sys_mat, sys_vec)

    low_matrix = lower_matrix_decomposition(sys_mat)
    low_solution = solve_lower_sys(low_matrix, sys_vec)
    return solve_upper_sys(low_matrix, low_solution)

def validate_system(sys_matrix: Matrix, sys_vector: Vector):
    if sys_matrix.cols_count != sys_vector.length:
        raise ValueError('Size mismatch between matrix and vector')

    if not sys_matrix.is_square:
        raise ValueError('System matrix must be square')

def lower_matrix_decomposition(sys_mat: Matrix) -> Matrix:
    size = sys_mat.rows_count
    low_mat = Matrix(size, size)

    for i in range(size):
        sq_sum = 0

        for j in range(i + 1):
            m_ij = sys_mat.value_at(i, j)

            if i == j:
                # main diagonal value
                diag_val = math.sqrt(m_ij - sq_sum)
                low_mat.set_value(diag_val, i, j)

            else:
                # value under main diagonal
                non_diag_sum = 0
                for k in range(j):
                    l_ik = low_mat.value_at(i, k)
                    l_jk = low_mat.value_at(j, k)
                    non_diag_sum += l_ik * l_jk

                l_jj = low_mat.value_at(j, j)
                non_diag_val = (m_ij - non_diag_sum) / l_jj
                sq_sum += non_diag_val * non_diag_val

                low_mat.set_value(non_diag_val, i, j)

    return low_mat

def solve_lower_sys(low_mat: Matrix, vector: Vector):
    size = vector.length
    solution = Vector(size)

    for i in range(size):
        _sum = 0.0

        for j in range(i):
            l_ij = low_mat.value_at(i, j)
            y_j = solution.value_at(j)
            _sum += l_ij * y_j

        b_i = vector.value_at(i)
        l_ii = low_mat.value_at(i, i)
        solution_val = (b_i - _sum) / l_ii
        solution.set_value(solution_val, i)

    return solution

def solve_upper_sys(up_matrix: Matrix, vector: Vector):
    size = vector.length
    last_index = size - 1
    solution = Vector(size)

    for i in range(last_index, -1, -1):
        _sum = 0.0

        for j in range(i + 1, size):
            u_ij = up_matrix.value_transposed_at(i, j)
            x_j = solution.value_at(j)
            _sum += u_ij * x_j

        y_i = vector.value_at(i)
        u_ii = up_matrix.value_transposed_at(i, i)
        solution_val = (y_i - _sum) / u_ii
        solution.set_value(solution_val, i)

    return solution

清单 14-9:Cholesky 方法结果

参与 Cholesky 方法求解线性方程组的三个子函数已经分别经过测试:我们可以确保它们正常工作。难道这就意味着 cholesky_solve 函数本身没有 bug 吗?不一定。将这些经过充分测试的函数组合在一起时,我们可能仍会犯错。

检查 cholesky_solve 函数整体是否正常工作需要进行一次额外的测试。这是一个确保每个子函数在组合时能正常工作的重要测试,称为 集成测试

测试 Cholesky:集成测试

最后一次打开你的 cholesky_test.py 文件。让我们添加一个最终的测试(如 清单 14-10 所示)。

import unittest

from eqs.cholesky import lower_matrix_decomposition, \
    solve_lower_sys, solve_upper_sys, cholesky_solve
from eqs.matrix import Matrix
from eqs.vector import Vector

class CholeskyTest(unittest.TestCase):
    sys_matrix = Matrix(4, 4).set_data([
        4, -2, 4, 2,
        -2, 10, -2, -7,
        4, -2, 8, 4,
        2, -7, 4, 7
    ])
    low_matrix = Matrix(4, 4).set_data([
        2, 0, 0, 0,
        -1, 3, 0, 0,
        2, 0, 2, 0,
        1, -2, 1, 1
    ])
    sys_vec = Vector(4).set_data([20, -16, 40, 28])
    low_solution = Vector(4).set_data([10, -2, 10, 4])
    solution = Vector(4).set_data([1, 2, 3, 4])

    def test_lower_matrix_decomposition(self):
        actual = lower_matrix_decomposition(self.sys_matrix)
        self.assertEqual(self.low_matrix, actual)

    def test_lower_system_resolution(self):
        actual = solve_lower_sys(self.low_matrix, self.sys_vec)
        self.assertEqual(self.low_solution, actual)

    def test_upper_system_resolution(self):
        actual = solve_upper_sys(
            self.low_matrix,
            self.low_solution
        )
        self.assertEqual(self.solution, actual)

    def test_solve_system(self):
        actual = cholesky_solve(self.sys_matrix, self.sys_vec)
        self.assertEqual(self.solution, actual)

清单 14-10:测试 Cholesky 分解方法

列表 14-10 是生成的测试文件。我们加入了最后一个测试:test_solve_system。这个测试通过调用 cholesky_solve 来整体测试 Cholesky 算法。

运行文件中的所有测试。如果所有四个测试都通过了,那就说明你写的代码完全正确。你应该为自己在这一长章中跟随代码而感到自豪。恭喜你!

如果你想从命令行运行测试,请使用以下命令:

$ python3 -m unittest eqs/tests/cholesky_test.py

总结

在这一章中,我们讨论了数值方法,然后将讨论重点放在了解决线性方程组的算法上。特别地,我们分析了 Cholesky 分解方法。这个[L][U]分解算法适用于对称正定矩阵,且速度是其他[L][U]算法的两倍。

我们特别关注代码的可读性。为了使算法易于理解,我们将其拆分成更小的函数,每个函数都进行了单独测试。我们在编写主算法逻辑之前就开始编写测试,这种方法被称为测试驱动开发。我们加入了最后一个测试,集成了完整的线性方程组求解。

我们实现了一个强大的求解算法,并将在本书的第五部分中使用它。

第五部分**

桁架结构

第十五章:结构模型

Image

在本书的这一部分,我们将重点解决桁架结构问题。桁架结构用于支撑工业仓库的屋顶(见图 15-1)以及大跨度桥梁。这是一个实际的工程问题,作为构建一个应用程序的好例子,程序可以从文件中读取数据,基于这些数据构建模型,求解线性方程组,并通过图表呈现结果。

由于解决桁架结构问题是一个庞大的课题,我们将其分解成几章内容。第一章将为你粗略介绍材料力学的基础知识;这并不是从零开始解释概念,而是作为一种复习。当我们掌握了基础知识后,我们将实现两个类来模拟桁架结构:节点和杆件。正如我们在前面的章节中看到的,解决一个问题的第一步是拥有一组基本元素来表示解决方案中涉及的实体。

Image

图 15-1:仓库屋顶是桁架结构的一个典型例子。

解决结构问题

我们先来定义几个术语。结构是由一系列抗力构件组成,用于承受外部负荷的作用及其自身的重量。桁架结构是一种由两端通过销钉连接的杆件构成的结构,外部力仅作用于这些杆件连接的地方,即节点。

在解决结构问题时,我们最关心的两件事是:第一,结构中的杆件能否承受作用在其上的力并避免坍塌?第二,当结构在外力作用下变形时,其位移有多大?第一个问题显而易见:如果结构中的任何杆件断裂,结构可能会坍塌,造成灾难性的后果(例如:仓库屋顶或桥梁坍塌)。我们的分析应该确保这种情况永远不会发生。

第二个问题不那么显而易见,但同样重要。如果一个结构发生了足够明显的变形,哪怕结构本身是安全的,不会坍塌,周围或下面的人可能会感到不安。想象一下,如果你看到客厅的天花板明显弯曲,你会有什么感觉。将结构的变形限制在一定范围内对其使用者的舒适度有着重要影响。

我们要寻找的解决方案应该包括每根杆件上的应力大小,以及结构的整体位移。在下一章,我们将编写实际的解决方案代码;在这里,我们将定义解决方案模型。我们可以预计,解决方案模型将包括这两个量:每根杆件上的机械应力和节点位移。

然而,在我们能进行分析之前,我们需要深入研究结构分析的世界。准备好编写大量代码吧。我们将要解决一个重大的工程问题,因此我们辛苦工作的回报将是巨大的。

结构构件内力

让我们快速回顾一下弹性体如何对外力的作用做出反应。这是材料力学中通常会讲授的一个主题,是机械工程课程中的经典内容。如果你已经深入学习过这个主题,可以跳过这一部分,或者快速浏览一下作为复习。如果没有,这一部分就是为你准备的。你所掌握的力学知识应该足够理解本文的内容,但我们不可能详细覆盖所有内容。你可以参考[3],这是我个人非常喜欢的书籍之一。静力学相关的书籍也有一定程度的讲解,推荐你阅读[9]或[11]。

受外力作用的弹性体

让我们以 I 型梁作为弹性体的例子,并对其施加一个外部平衡力系统。这些力的总和为零: Image。图 15-2 显示了梁的样子。

Image

图 15-2:受外力作用的梁

当外部力作用于这个弹性体时,它的原子会反抗,以保持它们之间的相对距离。如果外部载荷试图分开这些原子,它们会尽量把彼此拉得更紧。如果它们被推在一起,它们会尽量避免靠得太近。这种“反抗”构成了内力:在体内存在的力,是对外力施加作用的反应。

为了研究这些力对物体的影响,我们可以以图 15-2 中的梁为例,虚拟地用一个平面将其切割,如同图 15-3 中所示。

Image

图 15-3:受外力作用的梁的横截面

让我们去掉梁的右侧部分,分析左侧部分的横截面发生了什么。由于整个梁在我们切割之前是静力平衡的,因此左侧部分也应当保持静力平衡。为了保持这个平衡,我们必须考虑右侧被去除的部分对左侧部分施加的内力分布。这些力的出现是因为左侧部分的原子已被从与右侧部分的邻近原子中分离开。拉紧它们的力需要加到切割的部分上,以保持原子的平衡状态不变。

这些力分布在整个切割面上,并在图 15-4 中表示。

Image

图 15-4:分析截面中的平衡

力在某一面积上的分布被称为 应力。应力的净效应可以用一个等效系统来代替,其中包括一个结果力 Image 和一个力矩 Image。这个等效力和力矩的每个分量对梁产生不同的效果。让我们来分解这些分量。

轴向力和剪切力

等效内力 Image 可以分解为两个力的等效系统,一个垂直于截面 Image,另一个平行于截面 Image(参见图 15-5)。

Image

图 15-5:梁截面内的等效内力

如果弹性体具有棱柱形状(其中一边远长于其他两边),并且我们切割与其直线方向垂直的截面,那么得到的法向力 Image 就被称为 轴向力。这个名称反映了该力与棱柱的主轴或直线方向对齐。棱柱体在结构分析中很常见;梁和柱就是很好的例子。

轴向力可以拉长或压缩物体。拉伸物体的轴向力称为 拉力,而压缩物体的轴向力则被称为 压力。图 15-6 展示了受到这些力作用的两个棱柱体。

Image

图 15-6:拉力与压力

剪切力 是作用在截面上的切向力(参见图 15-7),因此可以进一步分解为两个分量:ImageImage(参见图 15-5 右侧的示意图)。这两个分量具有相同的效果:它们试图剪切物体。 图 15-7 展示了剪切力作用在棱柱体上的效果。

Image

图 15-7:剪切力

总结来说,物体截面的等效内力可能具有一个法向分量,该分量可以拉长或压缩物体;它还可能具有一个切向分量,该分量使物体发生剪切。这是内力对物体产生变形的两种方式。

弯曲和扭转力矩

我们研究了由此产生的内力对给定截面的可能影响。那么,产生的力矩会有什么效果呢?如图 15-8 所示,产生的力矩 Image 可以分解为垂直于截面的力矩 Image 和与截面切线方向平行的力矩 Image

Image

图 15-8:梁截面内的等效内力矩

这些力矩以任意方式弯曲物体,但如果我们选择一个棱柱体并沿着其主轴方向切割(与我们之前对力的处理方式相同),那么我们得到的力矩会有可预测且明确的效果。垂直于表面的力矩,Image,会对棱柱体产生扭转(旋转)效应,因此被称为扭转力矩

再次强调,垂直于截面的力矩可以进一步分解为两个子分量:ImageImage(见图 15-8 右侧插图)。这两个力矩的效果相似:它们会使棱柱体发生弯曲,因此被称为弯曲力矩。图 15-9 展示了这一效果。

Image

图 15-9:弯矩

总结来说,物体横截面上的等效内部力矩可能包含一个会绕其主轴扭转的法向分量(即扭转力矩),也可能有两个切向力矩,它们倾向于弯曲棱柱体(即弯曲力矩)。

现在我们来详细分析棱柱形杆件在轴向力作用下的行为。然后,我们将看到如何利用一组这些抗力棱柱体,构建能够承受重载荷的结构。

拉力与压缩力

让我们将分析重点放在轴向力上:即沿着棱柱体抗力体轴线方向的力。如我们在下一节所见,我们解决的结构仅由受轴向力作用的棱柱形元素(杆件)组成。

胡克定律

实验已证明,在一定范围内,棱柱体杆件的伸长量与施加的轴向力成正比。这个线性关系被称为胡克定律。假设一根长度为l、截面为A的杆件,受一对外力作用,力的大小分别为Image和–Image,如图 15-10 所示。

Image

图 15-10:受轴向力作用的杆件

公式 15.1 给出了胡克定律。

Image

在这个公式中,

δ    是杆件的总伸长量。

F    是Image力的大小。

E    是比例常数或杨氏模量,它是材料特有的。

胡克定律指出,受一对外力作用的杆件的总伸长量δ,(1)与力的大小和杆件的长度成正比,并且(2)与其横截面和杨氏模量成反比。杆件越长或施加的力越大,伸长量就越大。相反,横截面值或杨氏模量越大,伸长量则越小。

回想一下,当力分布在一个面积上时,单位面积上的力强度被称为应力。应力通常用希腊字母σ表示(参见方程式 15.2)。

图片

按惯例,拉伸力的应力为正,压缩力的应力为负。应力在机械设计中是一个有用的量;它用于判断给定部件(例如结构或机器中的部件)在运行中是否会断裂。给定材料在失效前能承受的应力值已被充分研究。

我们定义应变为单位长度的伸长,是一个无量纲的量,表示为希腊字母ϵ(参见方程式 15.3)。

图片

使用应力和应变的方程,胡克定律可以从方程式 15.1 重新写成方程式 15.4 所示的形式。

图片

有趣的是,通过引入应力和应变,外部作用力(力)与其效果(伸长)之间的关系不再依赖于物体的面积或长度。我们实际上已从方程中去除了所有维度参数。方程式 15.4 中的比例常数(E)是杨氏模量,这是材料的一个特性。例如,对于结构钢,E约为 200 GPa,即 200 ⋅ 10⁹ Pa。因此,我们可以通过应用针对所用材料获得的实验结果来预测物体的机械行为。为此,我们使用应力-应变图,它绘制了给定材料的应力与应变的关系。

应力-应变图

应力-应变图绘制了给定材料的应力与应变的关系,并通过进行拉伸或压缩试验获得(更多详情请参见[3])。我们利用这些图表预测由相同材料制成的抗力体的行为。回想一下,自从我们引入了应力和应变这两个量后,胡克定律中的所有维度项都已经消失,这意味着一旦我们通过实验确定了材料在特定载荷下所经历的应力和应变,就可以将这些结果应用于任何由相同材料制成的物体,无论它们的形状或大小如何。

图 15-11 是结构钢的近似应力-应变图。注意,该图并非按比例绘制。

图片

图 15-11:结构钢的应力-应变图

该图有一个初始的线性区域,能够承受一个已知的应力值,称为比例极限,由点 A 表示。对于大于比例极限的应力值,应力-应变关系不再是线性的。比例极限通常在结构钢的 210 MPa 到 350 MPa 之间—比杨氏模量小三个数量级。这个区域由胡克定律建模,线性关系为σ = 。我们将在这里集中分析。

在点 A 之后,即比例极限,通过一个小的应力增量,我们到达了点 B,屈服应力屈服强度。在屈服应力之后,尽管应力不再增加,材料却会发生较大的伸长。这个现象被称为材料的屈服

在经历了明显的应变后,我们到达了点 C,材料似乎开始硬化。应力必须继续增加,直到达到点 D,这是结构钢能够承受的最大应力。我们将这个应力值称为极限应力极限强度。从这个点开始,材料会在应力值减小的情况下承受更大的应变。

点 E 是材料发生断裂的地方。材料在断裂前能承受的应变量可以称为断裂应变。这是完全的机械失效点,但如果你仔细想想,达到极限应力(点 D)后,材料很可能会发生断裂。极限应力通常作为材料在失效前能够承受的最大应力值。

现在我们对承受拉伸应力的承重体的反应有了很好的理解,让我们来看一下桁架结构。

平面桁架

结构类型有很多种,但我们将重点分析其中最简单的一种:平面桁架。

平面桁架结构是指位于一个平面内的结构,其承重体为仅受轴向力作用的杆件,并且其自重可以忽略。使其成为平面桁架的有两个条件。

  • 杆件的端部必须通过铰接销连接。

  • 外部载荷必须始终作用于节点。

节点是多个杆件端点相交的地方。节点将杆件端部通过无摩擦的连接连接在一起,这意味着杆件绕节点的旋转不受限制。

平面桁架由三角形构成:三根杆件在其端部铰接。三角形是最简单的刚性框架;连接成四边形或更多边形的杆件形成的是非刚性框架。图 15-12 展示了由四根杆件组成的平面桁架如何从原始位置移动,因此不被认为是刚性的。通过增加一根新的杆件并形成两个子三角形,结构变得刚性。

图像

图 15-12:多边形平面桁架示例

图 15-13 是一个平面桁架的示例。该结构由八个节点(N1、N2、...、N8)和十三根杆件组成。节点 1 和节点 5 有外部支撑或约束。节点 6、7 和 8 承受外部载荷。

Image

图 15-13:平面桁架结构

图 15-14 是通过对图 15-13 中描述的平面桁架进行结构分析得到的图示。它是通过我们将在本书这一部分构建的应用程序生成的。

Image

图 15-14:平面桁架结构解答图

在这个图示中,我们可以看到结构的变形几何形状,因为它已经被缩放到足够显眼。节点的位移通常非常小(大约是结构杆件尺寸的两个数量级),因此没有缩放的节点位移图示可能难以与原始几何形状区分开来。

你会注意到在图 15-14 中有很多信息。每根杆件都标注了它所承受的应力,尽管图中的标签字体较小,因此可能不容易阅读。正数表示拉伸应力,负数表示压缩应力。杆件也根据它们所承受的载荷以绿色或红色进行着色:绿色表示拉伸,红色表示压缩。由于本书是黑白印刷的,你无法分辨颜色,但一旦你开发完成应用程序,你将使用自己的代码生成图形,并能够探索其中的所有细节。

现在让我们研究一下组成平面桁架的杆件的机械响应。它们有一个我们之前提到的有趣特性:它们只会产生轴向应力。

双力构件

如我们之前讨论的,平面桁架杆件的端部是铰接的,载荷总是施加在节点上;因此,杆件仅承受轴向力。我们只能在杆件的端部施加外力,通过铰接接头与节点的接触。由于这些接头是无摩擦的,它们只能沿杆件的主方向传递力。

图 15-15 展示了外力如何通过节点传递到杆件上。这些力与杆件的主方向对齐,因此只会产生轴向应力。

Image

图 15-15:节点中力的传递

由于杆两端均有固定点并施加外力,因此它们受两个力的作用。为了保持平衡,身体需要这两个力共线,大小相等且方向相反。在杆(一个长的棱柱体)的情况下,这两个力必须沿着杆的主方向(见图 15-16)作用,因此只会产生轴向应力。我们将这些施加有两个共线力的杆称为 双力构件(见图 15-16)。

Image

图 15-16:一个双力构件

在图 15-16 中施加在杆上的力标记为 Image 和 –Image,表示这两个力的大小相等并且方向相反。在这种情况下,力会在杆上产生拉伸应力。

多亏了霍克定律,我们知道材料如何响应外部荷载的作用。我们还探讨了双力构件,并且看到平面桁架中的杆是双力构件。现在我们来推导一组方程,将这两个力与它们在这种双力构件上产生的位移关联起来。

全局坐标系中的刚度矩阵

回到方程 15.1 中霍克定律的原始公式,我们可以将力项隔离,得到以下结果:

Image

这里,术语 Image 是杆的比例常数,它将施加的力 F 与其产生的伸长 δ 相关联。这个术语也称为 刚度。如你所见,刚度依赖于杆的杨氏模量 (E),它是材料特性,和几何形状 (Al)。

现在看看图 15-17 中的杆。如果我们考虑一个局部坐标系,其 x 轴与杆的主方向对齐,则该杆具有两个 自由度(DOF),换句话说,它有两种不同的独立运动方式。这些是两个节点在局部 x 轴上的位移,分别用 ImageImage 表示。每个节点都有一个施加的力:F[1] 和 F[2]。

注意

命名法说明:我们将使用撇号来标记杆局部坐标系中参考的自由度(DOF)。例如,Image* 指的是节点 1 在杆局部坐标系中的 x 位移:(x′,y′)。相比之下,没有撇号的值,如 u[1],是指全球坐标系的参考:(x,y)。

Image

图 15-17:具有两个自由度的杆

使用之前的方程,我们可以将每个节点的力与位移 ImageImage 关联起来,如下所示:

Image

上述两个方程可以用矩阵表示法写出 (方程 15.5),

Image

其中,[k^′] 被称为杆件的局部刚度矩阵。这个刚度矩阵将杆件两节点的位移与施加在它们上的外力联系起来,所有内容都在杆件的局部参考系中。使用这个局部参考系,杆件只有两个自由度,即每个节点在局部 x 轴方向上的位移 (ImageImage)。

现在我们来考虑一个相对于全局坐标系旋转的杆件。以 图 15-18 为例。该杆件有自己的局部参考系 (x^′, y^′),它与全局参考系 (x, y) 形成一个角度 θ

Image

图 15-18:杆件的局部参考系

从全局参考系的角度来看,杆件的每个节点有两个自由度:每个节点可以在 x 和 y 方向上移动。在这个参考系下,四个自由度分别是 u[1]、v[1]、u[2] 和 v[2]。

为了将杆件的局部刚度矩阵 [k^′] 转换为全局的 [k] 刚度矩阵,我们必须应用一个变换矩阵。我们可以通过将局部位移 ImageImage 分解为它们的全局分量来找到这样一个矩阵。 图 15-19 展示了这个操作。

Image

图 15-19:局部位移投影

现在我们来找一个数学表达式,通过它来计算基于局部位移的全局位移:

Image

以矩阵形式写出,它的形式如下:

Image

其中,[L] 是变换矩阵。为了从局部的 [k^′] 计算全局刚度矩阵,我们可以使用以下方程(有关如何推导这个表达式的详细信息,请参见 [2] 或 [10])。

[k] = [L]′[*k*]′[L]

通过简化符号为 c = cosθs = sinθ,得到 方程 15.6。

Image

现在我们有一个方程组,它将施加在杆件节点上的外力与它们在全局坐标系中的位移联系起来(参见 方程 15.7)。

Image

现在我们利用这些知识开始在代码中构建我们的结构模型。

原始结构模型

在我们的力学项目中,创建一个新的 Python 包,命名为structures。在structures中,再创建一个包:model。在这里我们将定义构成结构模型的类。再在structures中创建一个名为solution的包。在这里,我们将定义模拟已解析结构的类。还需要在structures中创建一个tests文件夹,包含我们将要开发的单元测试。你项目的结构应当是这样的:

力学

|- 应用

|- eqs

|- geom2d

|- graphic

|- 结构

|    |- model

|    |   |- init.py

|    |- solution

|    |   |- init.py

|    |- tests

|    |   |- init.py

|    |- init.py

|- 工具

下一步是创建一个表示结构节点的类。

节点类

model中创建一个新文件,命名为node.py,并输入列表 15-1 中的代码。这是结构节点的基本定义。

import operator
from functools import reduce

from geom2d import Point, Vector

class StrNode:

    def __init__(
        self,
     ➊ _id: int,
        position: Point,
        loads=None,
        dx_constrained=False,
        dy_constrained=False
    ):
        self.id = _id
        self.position = position
     ➋ self.loads = loads or []
        self.dx_constrained = dx_constrained
        self.dy_constrained = dy_constrained

    @property
    def loads_count(self):
        return len(self.loads)

    @property
    def net_load(self):
     ➌ return reduce(
            operator.add,
            self.loads,
            Vector(0, 0)
        )

列表 15-1:结构节点类

在这个列表中,我们定义了新的类 StrNode。这个类定义了一个 id,用来标识它的每个实例。

请注意,传递给构造函数的参数使用了下划线:_id ➊。Python 已经定义了一个名为 id 的全局函数,因此如果我们将参数命名为 id(而不是使用下划线),我们就会在构造函数内覆盖这个全局 id 函数的定义。这意味着 id 在构造函数中不再指代 Python 的函数,而是指代我们传入的值。虽然我们在这个类的构造函数中并没有使用 Python 的 id 函数,但我们会尽量避免覆盖全局函数。

StrNode 还包含一个 Point 类的实例,用来确定节点的位置,并且包含一个默认值为 None 的加载列表。结构可能有很多节点没有外部加载,因此我们将加载参数设为可选(并提供默认值 None)。当加载参数为 None 时,我们将 self.loads 属性赋为空列表([]) ➋。

你可能会想知道或运算符在 ➋ 中是如何工作的:

self.loads = loads or []

或运算符返回其操作数中第一个“真实”的值或 None。看以下示例:

>>> 'Hello' or 'Good bye'
'Hello'

>>> None or 'Good bye'
'Good bye'

>>> False or True
True

>>> False or 'Hello'
'Hello'

>>> False or None
# nothing returned here

>>> False or None or 'Hi'
'Hi'

正如你可能已经猜到的,在布尔上下文中,None 被评估为“假”。

还有两个属性我们需要传递给构造函数,它们在构造函数中有默认值:dx_constrained 和 dy_constrained。这些属性决定了在 x 和 y 方向上的位移是否受到外部约束。我们将它们初始化为 False,这意味着节点不会受到外部约束,除非我们明确指出。

我们在类中定义了两个属性:loads_count 和 net_load。第一个属性 loads_count 只是返回 loads 列表的长度。

注意

如果你记得第五章中的德梅特法则,任何想要知道施加在节点上的载荷数量的外部人员,都应该能直接向 StrNode 类询问。但如果让 StrNode 返回载荷列表,再使用 len 函数来获取其长度,就会违反这一重要原则。

net_load 属性使用 reduce 来计算所有载荷的总和 ➌。注意,我们向 reduce 函数传递了第三个参数:Vector(0, 0)。这个第三个参数是归约的初始值。在载荷列表为空的完全有效的情况下,我们将返回这个初始值。否则,归约过程的第一步将把这个初始值与列表的第一个元素结合起来。如果没有提供初始值,减少载荷列表将引发以下错误:

TypeError: reduce() of empty sequence with no initial value

接下来,我们将添加一个方法来向节点的载荷列表中添加载荷;在列表 15-2 中输入该方法。

class StrNode:
   --snip--

   def add_load(self, load: Vector):
       self.loads.append(load)

列表 15-2:向节点添加载荷

最后,我们来实现 StrNode 类的相等性比较。类中有一些属性,但我们认为只有当两个节点在平面上的位置相同时,它们才是相等的。这个比较将认为重叠的节点是相等的,而不管它们的其他属性。

如果我们希望结构中的节点是真正唯一的,可以依赖于一个相等性比较,比较节点的所有属性,包括载荷列表和外部约束。在我们的情况下,我们只关心确保没有重叠的节点。如果我们在相等性检查中包括更多的字段,可能会发生两个重叠的节点(具有相同位置)被评估为不同的,因为它们有不同的载荷列表。这样我们就会允许两个重叠的节点在结构中共存。

在列表 15-3 中输入 eq 方法的实现。

class StrNode:
   --snip--

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, StrNode):
            return False

        return self.position == other.position

列表 15-3:节点相等性

我们的 StrNode 类现在准备好了!列表 15-4 包含了最终的 StrNode 类。

import operator
from functools import reduce

from geom2d import Point, Vector

class StrNode:

    def __init__(
            self,
            _id: int,
            position: Point,
            loads=None,
            dx_constrained=False,
            dy_constrained=False
    ):
        self.id = _id
        self.position = position
        self.loads = loads or []
        self.dx_constrained = dx_constrained
        self.dy_constrained = dy_constrained

    @property
    def loads_count(self):
        return len(self.loads)

    @property
    def net_load(self):
        return reduce(
            operator.add,
            self.loads,
            Vector(0, 0)
        )

    def add_load(self, load: Vector):
        self.loads.append(load)

    def __eq__(self, other):
        if self is other:
            return True

        if not isinstance(other, StrNode):
            return False

        return self.position == other.position

列表 15-4:节点类结果

现在,我们将实现一个类来表示结构杆件。

杆件类

结构杆件是由两个由 StrNode 类建模的节点之间定义的。杆件需要存储计算刚度矩阵所需的两个抗性属性的值(方程 15.6):杨氏模量和截面。

实现杆件类

model 中创建一个名为 bar.py 的新文件,并输入 StrBar 类的初始定义(列表 15-5)。

from geom2d import Segment
from .node import StrNode

class StrBar:

    def __init__(
            self,
            _id: int,
            start_node: StrNode,
            end_node: StrNode,
            cross_section: float,
            young_mod: float
    ):
        self.id = _id
        self.start_node = start_node
        self.end_node = end_node
        self.cross_section = cross_section
        self.young_mod = young_mod

    @property
    def geometry(self):
        return Segment(
            self.start_node.position,
            self.end_node.position
        )

    @property
    def length(self):
        return self.geometry.length

列表 15-5:结构杆件类

在此列表中,我们定义了 StrBar 类,包含五个属性:作为标识符的 ID,起始节点和终止节点,截面值,以及杨氏模量值。这些值传递给构造函数并存储在类中。

我们还使用 @property 装饰器定义了两个属性:geometry 和 length。杆件的几何形状是一个从起始节点位置到结束节点位置的线段,杆件的长度就是这个线段的长度。

我们需要实现的最后一件事是一个方法,用来计算杆件在全局坐标系中的刚度矩阵,正如方程 15.6 所定义的那样。在清单 15-6 中输入该方法。

from eqs import Matrix
from geom2d import Segment
from .node import StrNode

class StrBar:
    --snip--

    def global_stiffness_matrix(self) -> Matrix:
        direction = self.geometry.direction_vector
        eal = self.young_mod * self.cross_section / self.length
        c = direction.cosine
        s = direction.sine

        c2_eal = (c ** 2) * eal
        s2_eal = (s ** 2) * eal
        sc_eal = (s * c) * eal

        return Matrix(4, 4).set_data([
            c2_eal, sc_eal, -c2_eal, -sc_eal,
            sc_eal, s2_eal, -sc_eal, -s2_eal,
            -c2_eal, -sc_eal, c2_eal, sc_eal,
            -sc_eal, -s2_eal, sc_eal, s2_eal
        ])

清单 15-6:全局坐标系中的杆件刚度矩阵

别忘了导入 Matrix,如下所示:

from eqs import Matrix

我们添加了 global_stiffness_matrix 方法。该方法创建一个 4 × 4 的矩阵,并将其值设置为适当的刚度项,正如方程 15.6 中所给出的,并为方便起见在此重复:

Image

为了计算每个值,我们首先获取杆件几何形状的方向向量,并计算它的正弦和余弦。由于 [k] 中的每一项都乘以 Image,我们计算它并将结果存储在 eal 变量中。矩阵中的十六个项实际上只有三个不同的值需要计算。这些值分别存储在 c2_eal、s2_eal 和 sc_eal 中,后续会在 set_data 方法中引用。

测试 Bar 类

刚度矩阵的计算是我们结构分析问题的核心;如果这段代码有 bug,会导致完全错误的结果,比如,杆件出现巨大的变形。让我们添加一个单元测试,确保刚度矩阵中的所有项都能正确计算。我们首先需要在 structures/tests 目录中创建一个新的测试文件,命名为 bar_test.py。在文件中输入清单 15-7 中的代码。

import unittest
from math import sqrt

from eqs import Matrix
from geom2d import Point
from structures.model.node import StrNode
from structures.model.bar import StrBar

class BarTest(unittest.TestCase):
    section = sqrt(5)
    young = 5

    node_a = StrNode(1, Point(0, 0))
    node_b = StrNode(2, Point(2, 1))
    bar = StrBar(1, node_a, node_b, section, young)

    def test_global_stiffness_matrix(self):
        expected = Matrix(4, 4).set_data([
            4, 2, -4, -2,
            2, 1, -2, -1,
            -4, -2, 4, 2,
            -2, -1, 2, 1
        ])
        actual = self.bar.global_stiffness_matrix()
        self.assertEqual(expected, actual)

清单 15-7:测试杆件的刚度矩阵

在这个测试中,我们创建了一根节点位于 (0, 0) 和 (2, 1) 之间的杆件,截面如 Image,杨氏模量为 5。我们选择这些数字是为了让期望的刚度矩阵中的所有值都是整数,这样可以方便我们编写断言,特别是在这个情况下:ImageImage,和 Image

你可以通过点击绿色播放按钮或从终端运行测试。

$ python3 -m unittest structures/tests/bar_test.py

这应该会产生以下输出:

Ran 1 test in 0.000s

OK

你的 StrBar 类应该与清单 15-8 类似。

from eqs import Matrix
from geom2d import Segment
from .node import StrNode

class StrBar:

    def __init__(
            self,
            _id: int,
            start_node: StrNode,
            end_node: StrNode,
            cross_section: float,
            young_mod: float
    ):
        self.id = _id
        self.start_node = start_node
        self.end_node = end_node
        self.cross_section = cross_section
        self.young_mod = young_mod

    @property
    def geometry(self):
        return Segment(
            self.start_node.position,
            self.end_node.position
        )

    @property
    def length(self):
        return self.geometry.length

    def global_stiffness_matrix(self) -> Matrix:
        direction = self.geometry.direction_vector
        eal = self.young_mod * self.cross_section / self.length
        c = direction.cosine
        s = direction.sine

        c2_eal = (c ** 2) * eal
        s2_eal = (s ** 2) * eal
        sc_eal = (s * c) * eal

        return Matrix(4, 4).set_data([
            c2_eal, sc_eal, -c2_eal, -sc_eal,
            sc_eal, s2_eal, -sc_eal, -s2_eal,
            -c2_eal, -sc_eal, c2_eal, sc_eal,
            -sc_eal, -s2_eal, sc_eal, s2_eal
        ])

清单 15-8:Bar 类结果

我们需要最后一个类来将节点和杆件组合在一起:结构本身。

Structure 类

structures/model 中创建一个新的 Python 文件,命名为 structure.py,并输入 Structure 类的代码(清单 15-9)。

from functools import reduce

from .node import StrNode
from .bar import StrBar

class Structure:
    def __init__(self, nodes: [StrNode], bars: [StrBar]):
        self.__bars = bars
        self.__nodes = nodes

    @property
    def nodes_count(self):
        return len(self.__nodes)

    @property
    def bars_count(self):
        return len(self.__bars)

    @property
    def loads_count(self):
        return reduce(
            lambda count, node: count + node.loads_count,
            self.__nodes,
            0
        )

清单 15-9:Structure 类

这个类目前非常简单,但在后续章节中,我们将编写代码来组装结构的全局刚度矩阵,生成方程组,求解它,并创建解。现在,这个类仅存储传递给构造函数的节点列表和杆件列表,以及一些处理其包含项数量的计算。

loads_count 属性会计算每个节点的荷载总数。为了实现这一点,我们将一个 lambda 函数作为第一个参数传递给 reduce 函数。这个 lambda 函数有两个参数:当前的荷载计数和 self.__nodes 列表中的下一个节点。这个归约操作需要一个初始值(即第三个参数 0),我们将第一个节点的计数值加到它上面。如果没有这个初始值,归约操作将无法进行,因为 reduce 函数不知道 lambda 的第一个参数 count 在第一次迭代时的值。

现在我们已经有了完整的模型来定义结构!

从 Python Shell 创建结构

让我们尝试使用我们的模型类构建图 15-20 中的桁架结构。

图像

图 15-20:示例桁架结构

要定义结构,首先在 Python shell 中导入以下类:

>>> from geom2d import Point, Vector
>>> from structures.model.node import StrNode
>>> from structures.model.bar import StrBar
>>> from structures.model.structure import Structure

然后输入以下代码:

>>> node_one = StrNode(1, Point(0, 0), None, True, True)
>>> node_two = StrNode(2, Point(100, 0), None, False, True)
>>> node_three = StrNode(3, Point(100, 100), (Vector(50, -100)))

>>> bar_one = (1, node_one, node_two, 20, 20000000)
>>> bar_two = (2, node_two, node_three, 20, 20000000)
>>> bar_three = (3, node_three, node_one, 20, 20000000)

>>> structure = Structure(
    (node_one, node_two, node_three),
    (bar_one, bar_two, bar_three)
)

如你所见,在代码中创建桁架结构的模型非常简单。无论如何,我们最常见的做法是从外部定义文件加载模型,正如我们将在第十七章中学到的那样。然而,手动操作一个例子是理解我们模型类如何工作的一个很好的练习。

为了完成这一章,让我们创建结构解算模型:存储节点位移和杆件应力的类。

结构解算模型

我们将在下一章解决结构的解算问题,但在这里我们会准备类来存储解算值。现在,假设我们已经准备好了求解算法,需要使用解算类来存储解算的数据。

当我们解算一个结构时,首先获得节点在全局坐标系中的位移。通过结构节点的新位置,我们可以计算出其余的所有数据(如应变、应力和反作用力值)。我们需要一个新类来表示位移节点,这些节点类似于我们刚刚使用 StrNode 类定义的节点,只是多了一个位移向量。

这些节点位移会使结构的杆件发生拉伸或压缩。记住,杆件会产生应变和应力,这是它们对拉伸或压缩的机械反应。应变和应力值是结构解算中的重要数据,它们将决定结构是否能够承受施加的荷载。

我们还将创建一个新类来表示解算的杆件。这个类将引用位移后的节点,并计算应变和应力值。

解法节点

让我们创建一个表示结构解法中节点的类。在 structures/solution 包中,创建一个名为 node.py 的新文件,并输入列表 15-10 中的代码。

from geom2d import Vector
from structures.model.node import StrNode

class StrNodeSolution:
    def __init__(
            self,
            original_node: StrNode,
            global_disp: Vector
    ):
        self.__original_node = original_node
        self.global_disp = global_disp

    @property
 ➊ def id(self):
        return self.__original_node.id

    @property
 ➋ def original_pos(self):
        return self.__original_node.position

    @property
 ➌ def is_constrained(self):
        return self.__original_node.dx_constrained \
               or self.__original_node.dy_constrained

    @property
 ➍ def loads(self):
        return self.__original_node.loads

    @property
 ➎ def is_loaded(self):
       return self.__original_node.loads_count > 0

    @property
 ➏ def net_load(self):
        return self.__original_node.net_load

列表 15-10:解法节点类

这个代码段声明了 StrNodeSolution 类。如你所见,该类的构造函数接受原始节点及其在全局坐标系中的位移向量——这就是我们所需要的。原始节点被设置为类的私有属性 (__original_node),但它的一些属性被暴露出来。例如,id 属性 ➊ 仅返回原始节点的 ID,加载(loads)也是如此。

original_pos 属性 ➋ 返回原始节点的位置:即在应用作为结构解法一部分获得的位移之前的位置。这里的命名很重要,因为我们稍后将添加另一个属性,用于暴露节点在被位移后的新位置。

is_constrained 属性 ➌ 检查原始节点是否有任何自由度(在 x 或 y 方向上的位移)被外部约束。我们将使用此信息来确定是否需要为节点计算反作用力。反作用力是支撑或约束在节点上施加的外力。我们需要知道支撑所承受的力的大小,以便正确设计和确定支撑的尺寸。

最后,我们有三个与外部载荷相关的属性:loads ➍、is_loaded ➎ 和 net_load ➏。第一个属性仅返回原始节点的力的列表。我们将在绘制像图 15-14 那样的向量图时使用此信息。属性 is_loaded 让我们知道节点是否施加了任何载荷。这个属性在我们需要检查哪些解法节点上施加了载荷并将这些载荷绘制到结果图中时非常有用。属性 net_load 返回原始节点的净载荷,我们将使用它来计算节点的反作用力。

位移位置

让我们将位移位置作为一个属性。由于位移通常比结构的尺寸小几个数量级,我们希望包含一个方法来缩放位移向量,以便绘制出变形后的几何图形。这确保了我们能够在结果图中区分变形后的几何图形与原始几何图形。

将列表 15-11 中的代码输入到 StrNodeSolution 类中。

class StrNodeSolution:
   --snip--

   @property
   def displaced_pos(self):
       return self.original_pos.displaced(self.global_disp)

   def displaced_pos_scaled(self, scale=1):
       return self.original_pos.displaced(self.global_disp, scale)

列表 15-11:解法节点位移

displaced_pos 方法返回在应用全局位移向量后,原始节点的位置。displaced_pos_scaled 方法做类似的事情,但带有一个缩放值,允许我们增加位移的大小。

最终结果

如果你按照步骤操作,你的 StrNodeSolution 类应该与列表 15-12 中显示的一样。

from geom2d import Vector
from structures.model.node import StrNode

class StrNodeSolution:
    def __init__(
            self,
            original_node: StrNode,
            global_disp: Vector
    ):
        self.__original_node = original_node
        self.global_disp = global_disp

    @property
    def id(self):
        return self.__original_node.id

    @property
    def original_pos(self):
        return self.__original_node.position

    @property
    def is_constrained(self):
        return self.__original_node.dx_constrained \
               or self.__original_node.dy_constrained 

    @property
    def loads(self):
        return self.__original_node.loads

    @property
    def is_loaded(self):
        return self.__original_node.loads_count > 0

    @property
    def displaced_pos(self):
        return self.original_pos.displaced(self.global_disp)

    def displaced_position_scaled(self, scale=1):
        return self.original_pos.displaced(self.global_disp, scale)

列表 15-12:解法节点类结果

现在让我们实现杆的解类。

解杆

知道杆的节点位移是我们计算其应变和轴向应力所需的全部。我们将在开发 StrBarSolution 类时解释为什么如此。

structures/solution 目录下创建一个新文件,命名为 bar.py,并输入 Listing 15-13 中的代码。

from structures.model.bar import StrBar
from .node import StrNodeSolution

class StrBarSolution:
    def __init__(
            self,
            original_bar: StrBar,
            start_node: StrNodeSolution,
            end_node: StrNodeSolution
    ):
        if original_bar.start_node.id != start_node.id:
            raise ValueError('Wrong start node')

        if original_bar.end_node.id != end_node.id:
            raise ValueError('Wrong end node')

        self.__original_bar = original_bar
        self.start_node = start_node
        self.end_node = end_node

    @property
    def id(self):
        return self.__original_bar.id

    @property
    def cross_section(self):
        return self.__original_bar.cross_section

    @property
    def young_mod(self):
        return self.__original_bar.young_mod

Listing 15-13: 解杆类

StrBarSolution 类使用原始杆和两个解节点进行初始化。在构造函数中,我们通过将解节点的 ID 与原始杆节点的 ID 进行比较,检查是否传入了正确的解节点。如果检测到传入了错误的节点,我们将引发 ValueError,并终止程序执行。如果继续执行,结果将是错误的,因为解杆将与原始定义中未连接的节点相连。这将防止我们在构建结构的解类时犯错误。

该类还定义了 idcross_sectionyoung_mod 属性。这些属性只是返回原始杆的值。

伸长、应力与应变

现在让我们一步一步地计算应变和应力。应力可以通过应变推导出来(使用 Equation 15.4),所以我们从应变开始。应变是杆的每单位长度的伸长(见 Equation 15.3),因此我们需要找出这个伸长值。为此,我们首先要了解杆的原始几何形状和结果几何形状。输入 Listing 15-14 中显示的属性。

from geom2d import Segment
from structures.model.bar import StrBar
from .node import StrNodeSolution

class StrBarSolution:
   --snip--

   @property
   def original_geometry(self):
       return self.__original_bar.geometry

   @property
   def final_geometry(self):
       return Segment(
           self.start_node.displaced_pos,
           self.end_node.displaced_pos
       )

Listing 15-14: 解杆几何形状

原始几何形状已经是 StrBar 类中的一个属性。最终几何形状也是一个线段,这次是位移后的起始节点和结束节点之间的线段。需要理解的是,由于桁架结构的杆是二力构件,它们只受轴向力的作用。因此,杆的导线将始终保持为一条直线。Figure 15-21 描绘了原始杆和由于位移原始节点位置而产生的变形杆 ImageImage

Image

Figure 15-21: 杆的长度增量

假设原始杆的长度为 l[o],l[f] 是最终长度,则杆的伸长简单地为 Δl = l[f] – l[o]。如果杆被拉伸,则伸长值为正;如果杆被压缩,则为负。请注意,这与我们的应力符号约定一致:拉伸为正,压缩为负。输入 Listing 15-15 中的属性。

class StrBarSolution:
   --snip--

   @property
   def original_length(self):
       return self.original_geometry.length

   @property
   def final_length(self):
       return self.final_geometry.length

   @property
   def elongation(self):
       return self.final_length - self.original_length

Listing 15-15: 解杆长度

现在我们知道了杆的伸长,可以轻松地计算应变和应力。在 StrBarSolution 类中输入应变和应力属性,如 Listing 15-16 中所示。

class StrBarSolution:
   --snip--

   @property
   def strain(self):
       return self.elongation / self.original_length

   @property
   def stress(self):
       return self.young_mod * self.strain

列表 15-16:梁的应变与应力

最后!如你所见,所给的应变由方程 15.3 给出,它是梁的伸长与原始长度的商。通过应变值,我们可以通过与材料的杨氏模量简单相乘来获得应力。这就是方程 15.4 中表述的胡克定律。

内力

为了计算反作用力,我们将使用每个节点的静力平衡条件:一个节点的合力始终为零。在这个力的总和中,连接到该节点的每根梁都施加一个大小相等、方向相反的力(如图 15-23 所示)。这个内力是根据梁的应力乘以其截面计算得出的(见方程 15.2)。

我们需要梁每个节点内力的大小和方向,因为如果你还记得,为了使这个二力杆件处于平衡状态,两端的力需要大小相等且方向相反。让我们看看如何实现这一点。

输入代码:列表 15-17。

from geom2d import Segment, make_vector_between
from structures.model.bar import StrBar
from .node import StrNodeSolution

class StrBarSolution:
    --snip--

    @property
    def internal_force_value(self):
        return self.stress * self.cross_section

    def force_in_node(self, node: StrNodeSolution):
     ➊ if node is self.start_node:
            return make_vector_between(
                self.end_node.displaced_pos,
                self.start_node.displaced_pos
            ).with_length(
                self.internal_force_value
            )
     ➋ elif node is self.end_node:
            return make_vector_between(
                self.start_node.displaced_pos,
                self.end_node.displaced_pos
            ).with_length(
                self.internal_force_value
            )

        raise ValueError(
            f'Bar {self.id} does not know about node {node.id}'
        )

列表 15-17:梁的内力

在这段代码中,我们首先定义了 internal_force_value 属性,该属性表示根据方程 15.2 计算的内力的大小(正值或负值)。

接下来是 force_in_node 方法,它根据梁的起始或结束节点,返回该节点的力向量。无论是哪种情况,力向量的大小都是 internal_force_value。只有方向会根据传入的节点发生变化。

我们的符号约定是将拉力视为正值,压缩力视为负值。如果我们选择每个节点的内力方向为正,则力向量始终会朝着正确的方向。这是因为稍后我们会为其分配一个内力值,这个值对于压缩力来说是负的,正如你所知道的那样,将负值分配给我们的 Vector 实例之一会反转其方向。

回顾一下代码。如果传入的节点是起始节点 ➊,则力向量从结束节点的最终位置指向起始节点的位置。然后,结果向量会根据 internal_force_value 进行缩放。

相反,如果传入的节点是结束节点 ➋,力向量是相反的,但缩放部分保持不变。

最后,如果传入的节点不是这两个梁的节点之一,我们将引发错误。

梁有节点吗?

我们快完成梁解决方案类了;只需要再添加两个方法,我们的类就准备好了。第一个方法检查结构中的任何节点是否为梁的端节点之一。我们将使用此方法来绘制结果。输入方法:列表 15-18。

class StrBarSolution:
   --snip--

   def has_node(self, node: StrNodeSolution):
       return node is self.start_node or node is self.end_node

列表 15-18:梁有节点吗?

最后,我们需要一个方法来生成条形的最终几何图形,但要对位移应用缩放。

比例最终几何图形

如果你还记得,我们已经在 StrNodeSolution 类中实现了一个方法,它返回带有位移缩放的节点位置。让我们利用这个实现来构建表示变形条形几何图形的段,并对其应用缩放。输入列表 15-19 中的代码。

class StrBarSolution:
   --snip--

   def final_geometry_scaling_displacement(self, scale: float):
       return Segment(
           self.start_node.displaced_pos_scaled(scale),
           self.end_node.displaced_pos_scaled(scale)
       )

列表 15-19: 条形比例几何图形

final_geometry_scaling_displacement 方法返回一个段,其端点是条形节点在应用位移缩放后的位置。我们将在结果图上绘制这个段,以可视化原始条形如何从其原始位置发生位移。

同样,因为与结构本身的大小相比,位移相对较小,所以我们希望对节点位移进行缩放,以便清楚地看到结构在解图中如何变形。

最终结果

如果你跟着操作,那么你的 StrBarSolution 应该像列表 15-20 一样。

from geom2d import Segment, make_vector_between
from structures.model.bar import StrBar
from .node import StrNodeSolution

class StrBarSolution:
    def __init__(
            self,
            original_bar: StrBar,
            start_node: StrNodeSolution,
            end_node: StrNodeSolution
    ):
        if original_bar.start_node.id != start_node.id:
            raise ValueError('Wrong start node')

        if original_bar.end_node.id != end_node.id:
            raise ValueError('Wrong end node')

        self.__original_bar = original_bar
        self.start_node = start_node
        self.end_node = end_node

    @property
    def id(self):
        return self.__original_bar.id

    @property
    def cross_section(self):
        return self.__original_bar.cross_section

    @property
    def young_mod(self):
        return self.__original_bar.young_mod

    @property
    def original_geometry(self):
        return self.__original_bar.geometry

    @property
    def final_geometry(self):
        return Segment(
            self.start_node.displaced_pos,
            self.end_node.displaced_pos
        )

    @property
    def original_length(self):
        return self.original_geometry.length

    @property
    def final_length(self):
        return self.final_geometry.length

    @property
    def elongation(self):
        return self.final_length - self.original_length

    @property
    def strain(self):
        return self.elongation / self.original_length

    @property
    def stress(self):
        return self.young_mod * self.strain

    @property
    def internal_force_value(self):
        return self.stress * self.cross_section

    def force_in_node(self, node: StrNodeSolution):
        if node is self.start_node:
            return make_vector_between(
                self.end_node.displaced_pos,
                self.start_node.displaced_pos
            ).with_length(
                self.internal_force_value
            )
        elif node is self.end_node:
            return make_vector_between(
                self.start_node.displaced_pos,
                self.end_node.displaced_pos
            ).with_length(
                self.internal_force_value
            )

        raise ValueError(
            f'Bar {self.id} does not know about node {node.id}'
        )

    def has_node(self, node: StrNodeSolution):
        return node is self.start_node or node is self.end_node

    def final_geometry_scaling_displacement(self, scale: float):
        return Segment(
            self.start_node.displaced_position_scaled(scale),
            self.end_node.displaced_position_scaled(scale)
        )

列表 15-20: 解条形类结果

还有一个最后需要定义的类:结构解。

结构解

就像我们为原始结构模型定义了一个类一样,我们也想为结构的解定义一个类。这个类的目的是将解的节点和条形组合在一起。

structures/solution文件夹中创建一个名为structure.py的新文件。在文件中输入类的基本定义(列表 15-21)。

from .bar import StrBarSolution
from .node import StrNodeSolution

class StructureSolution:
    def __init__(
            self,
            nodes: [StrNodeSolution],
            bars: [StrBarSolution]
    ):
        self.nodes = nodes
        self.bars = bars

列表 15-21: 结构解类

StructureSolution 类通过节点和条形的列表初始化,这些节点和条形构成了解。这与原始结构的定义类似。但由于我们使用这个类来生成结果——报告和图表——所以我们需要一些额外的属性。

结构矩形边界

在绘制结构分析结果时,我们需要知道绘制完整结构所需的空间。了解整个结构的矩形边界将帮助我们稍后计算 SVG 图形的 viewBox。让我们先计算这些边界并添加一些边距(参见图 15-22),这样可以为绘制如表示荷载的箭头等元素留出额外的空间。

Image

图 15-22: 结构的边界

在类中,输入 bounds_rect 方法(列表 15-22)。

from geom2d import make_rect_containing_with_margin
from .bar import StrBarSolution
from .node import StrNodeSolution

class StructureSolution:
   --snip--

    def bounds_rect(self, margin: float, scale=1):
        d_pos = [
            node.displaced_pos_scaled(scale)
            for node in self.nodes
        ]
        return make_rect_containing_with_margin(d_pos, margin)

列表 15-22: 结构图形边界

我们首先导入 make_rect_containing_with_margin 函数。我们在本书的第二部分中实现了这个函数,它创建一个包含所有传入点的矩形原始图形,并带有一些边距。

我们编写的 bounds_rect 方法初始化了 d_pos 变量,作为一个包含所有结构节点位移位置的列表,并将其传递给函数,后者生成矩形。请注意,我们使用的是位移的缩放版本,以确保矩形边界包含所有将被绘制的节点位置。

节点反应力

最后,由于 StructureSolution 类能够访问结构的所有节点和杆件,它将负责计算每个节点的反应力。StrNodeSolution 类无法自行进行此计算,因为它无法访问与该节点相交的杆件列表。

那么,我们如何计算一个节点的反应力呢?假设我们有一个像图 15-23 中的节点。两个杆件,杆件 1 和杆件 2,在此节点相交,并且分别受内部力 ImageImage 的作用。同时,外部载荷 Image 也施加在该节点上。该节点受到外部约束,! Image 是我们要计算的反应力。

Image

图 15-23:节点中的反应力

从这些量中,只有 Image 是未知的。杆件的内部力 ImageImage 是通过我们在清单 15-17 中实现的 force_in_node 方法计算的,而外部载荷 Image 是题目陈述中给出的。

假设节点处于静态平衡状态,必须满足以下条件。

Image

你可能已经注意到,在这种情况下,杆件力的符号是负号。这些是节点从杆件的力中接收到的反应力,符合牛顿第三定律。如果一根杆件受到一对压缩力,它会将节点拉向自己。另一方面,如果一根杆件倾向于扩张,它会将节点推离自己。

我们可以轻松地从前面的方程中孤立出 Image

Image

或者以更通用的方式表示(方程式 15.8),

Image

其中 Image 是所有杆件力的总和,! Image 是施加在该节点上的所有外部载荷的总和(节点的净载荷)。

让我们在我们的类中实现这一点。输入代码到清单 15-23。

import operator
from functools import reduce

from geom2d import make_rect_containing_with_margin, Vector
from .bar import StrBarSolution
from .node import StrNodeSolution

class StructureSolution:
   --snip--

    def reaction_for_node(self, node: StrNodeSolution):
     ➊ if not node.is_constrained:
            return Vector(0, 0)

     ➋ forces = [
            bar.force_in_node(node)
            for bar in self.bars
            if bar.has_node(node)
        ]

        if node.is_loaded:
         ➌ forces.append(node.net_load.opposite())

     ➍ return reduce(operator.add, forces)

清单 15-23:节点反应力

我们定义了 reaction_for_node 方法,该方法根据给定的节点计算其反作用力。不要忘记,反作用力只存在于那些具有外部支撑或约束的节点。事实上,这就是我们首先检查的内容 ➊:如果节点没有约束,我们返回一个零向量(表示没有反作用力)。

第二步是搜索结构中与传入节点相连接的所有杆件,并获取它们在该节点的内力 ➋。我们使用列表推导式来实现这一点,遍历结构中的所有杆件,筛选出通过 bar.has_node(node) 测试的那些,最后将每个杆件映射到其在给定节点的内力。这就是方程 15.8 中的Image

接下来,如果节点有外部载荷,我们就将净外部载荷添加到力列表中 ➌。请注意,来自节点的净载荷在方程 15.8 中以负号出现,这也是为什么我们对其调用相反的方法。还要注意,我们不需要对这些载荷求和(正如方程 15.8 中的Image所示),因为 StrNodeSolution 类已经为我们完成了这项工作,并提供了净载荷。

最后,使用 reduce 函数和 operator.add 运算符 ➍ 将列表中的所有力求和。

最终结果

供您参考,清单 15-24 展示了完整的 StructureSolution 类实现。

import operator
from functools import reduce

from geom2d import make_rect_containing_with_margin, Vector
from .bar import StrBarSolution
from .node import StrNodeSolution

class StructureSolution:
    def __init__(
            self,
            nodes: [StrNodeSolution],
            bars: [StrBarSolution]
    ):
        self.nodes = nodes
        self.bars = bars

    def bounds_rect(self, margin: float, scale=1):
        d_pos = [
            node.displaced_pos_scaled(scale)
            for node in self.nodes
        ]
        return make_rect_containing_with_margin(d_pos, margin)

    def reaction_for_node(self, node: StrNodeSolution):
        if not node.is_constrained:
            return Vector(0, 0)

        forces = [
            bar.force_in_node(node)
            for bar in self.bars
            if bar.has_node(node)
        ]

        if node.is_loaded:
            forces.append(node.net_load.opposite())

        return reduce(operator.add, forces)

清单 15-24:结构解法类结果

进行单元测试非常重要,以确保我们没有犯任何错误。尽管如此,为了进行测试,我们需要了解一种高级测试技术:模拟。我们将在下一章深入探讨这个话题,所以我们会回到这个实现。

总结

我们在本章开始时回顾了一些材料力学的内容,比如弹性体在外力作用下所产生的内力。我们介绍了应力和应变的概念,这两者对于结构分析至关重要。我们特别关注了棱柱体中的轴向应力,因为这些应力在平面桁架结构中至关重要,而本书这一部分的重点就是平面桁架。

接下来,我们研究了平面桁架及其特点,并利用刚度矩阵的概念公式化了杆件上力与位移之间的关系。正如我们将在下一章中看到的,这些矩阵在结构求解中起着至关重要的作用。

最后,我们实现了结构的建模类:StrNode、StrBar 和 Structure。我们还实现了结构的求解类:StrNodeSolution、StrBarSolution 和 StructureSolution。这两组类分别表示原始设计的结构和结构求解,包括每根杆件的应力值和每个节点的位移。我们将在下一章中介绍如何从原始定义到求解的过程。

第十六章:结构求解

Image

在前一章中,我们定义了结构模型的类:StrNode、StrBar 和 Structure。我们还编写了结构求解的类:StrNodeSolution、StrBarSolution 和 StructureSolution。我们使用前面三个类来定义结构,使用后面三个类来建模解,包括节点的位移和杆件的应力与应变。问题是,我们如何从定义模型过渡到求解模型呢?

在本章中,我们将通过开发求解算法来回答这个问题,该算法是原始结构模型和求解结构模型之间的桥梁。我们将回顾结构的求解过程,其中我们基于每根杆件的矩阵组装结构的刚度矩阵 [k],并根据每个节点的负载组装负载向量 {Image}。解算 Image 方程组得到结构节点在全局坐标系中的位移:Image。为了求解这个方程组,我们将使用我们的 Cholesky 实现。

本章还将介绍一种高级单元测试技术:测试替身(test doubles)。测试替身帮助我们通过将代码中依赖的函数或类替换为“虚拟”的实现,从而隔离代码的某一部分,以便在测试时只测试代码的一部分。

结构求解

在前一章中,我们研究了与杆件的位移相关的各个自由度所施加的力的方程组。一个杆件有两个节点,每个节点有两个自由度:

u x 方向的位移

v y 方向的位移

这使得每根杆件总共有四个自由度:u[1]和v[1]对应节点 1,u[2]和v[2]对应节点 2。施加在节点上的力——我们称它们为 ImageImage——可以分别分解为两个分量。因此,Image 可以分解为 F[1x] 和 F[1y],同样适用于 Image(参见图 16-1)。

Image

图 16-1:杆件的自由度

这些力和节点位移之间的方程系统在“全局坐标系中的刚度矩阵”一节中已重复出现,见第 397 页:

Image

需要注意的是,这些力和位移,以及刚度矩阵 [k],都是基于全局坐标系的,正如图 16-1 左下角所示的那样。每根杆件都有自己的局部坐标系,正如你从图 15-18 中看到的那样,但为了建立结构的全局方程组,我们希望将力和位移参考到这个全局坐标系。

在继续之前,让我们简要了解一下刚度矩阵中每个术语的含义。

解释刚度矩阵项

刚度矩阵项将给定自由度的力与另一个自由度中产生的位移相关联。它们按一种明确定义的方式排列:

Image

例如,这里 Image 可以解读为“第一节点 x 方向的力 (Image) 与它在第二节点 y 方向上产生的位移 (Image) 之间的关系”。牢记这一点,我们可以辨识出一个模式。

每一行包含了将某一自由度的力与所有自由度的位移相关联的刚度项。例如,第一行包括将起始节点 x 轴方向的力 (Image) 与所有可能的位移相关联的项:ImageImageImageImage

每一列包含了将每个自由度中的力与给定自由度中位移相关联的刚度项。例如,第一列包括将每个自由度中的力—ImageImageImageImage—与起始节点在 x 轴方向的位移相关联的项。

记住这种刚度项的解释;我们稍后在组装结构的全局刚度矩阵时将使用这些知识。让我们继续修订解析过程,并逐步编写代码。

结构初始化

作为结构解析过程的一部分,我们希望将一些中间结果保存在结构类的私有属性中。在进入主算法之前,我们先初始化这些属性。

打开你的 model/structure.py 文件,编辑类,以便它包含我们在 init 方法中添加的新属性,正如列表 16-1 中所示。

  from functools import reduce

➊ from eqs import Matrix, Vector as EqVector
  from .node import StrNode
  from .bar import StrBar

  class Structure:
   ➋ __DOF_PER_NODE = 2

     def __init__(self, nodes: [StrNode], bars: [StrBar]):
        self.__bars = bars
        self.__nodes = nodes

      ➌ self.__dofs_dict = None
        self.__system_matrix: Matrix = None
        self.__system_vector: EqVector = None
        self.__global_displacements: EqVector = None

    --snip--

列表 16-1:初始化结构

我们需要从 eqs 包中添加两个新的导入,Matrix 和 Vector ➊。因为我们稍后还需要导入另一个 Vector 类,那个是在 geom2d 包中定义的,我们将 eqs 包中的 Vector 别名为 EqVector。请注意 Python 中的别名语法:

from <module> import <identifier> as <alias>

接下来,我们定义一个名为 __DOF_PER_NODE ➋ 的常量,其值为 2。我们将在代码中使用这个常量,而不是直接使用数字。这个清晰的名称应该能很好地提示该数字的实际含义。我们将在代码中避免使用魔法数字,即那些出现在代码中,但不清楚其代表什么含义的数字。命名良好的常量可以告诉读者该数字实际上代表什么。

最后,我们定义四个新的私有属性,并将它们都初始化为 None ➌。

__dofs_dict 一个字典,其中键是节点的 ID,值是分配给该节点的自由度编号的列表。稍后我们会解释这个是什么意思。

__system_matrix 结构全局方程组的刚度矩阵。

__system_vector 结构全局方程组的载荷向量。

__global_displacements 一个节点的全局位移列表,其中每个位移的索引与其自由度编号相同。

如果你还不完全理解这些新属性的含义,别担心;我们将在后续章节中详细解释每个属性。

主要结构求解算法

结构求解算法可以分为三个大步骤:

  1. 为每个自由度分配一个编号。

  2. 汇总并求解结构的方程组。

  3. 使用系统的结果向量来构建求解模型。

让我们试着快速理解这些步骤的含义;我们稍后会补充剩余的细节。第一步,给自由度编号,是一个为结构中的每个自由度分配一个唯一数字的过程。我们以图 16-2 中的结构为例。

Image

图 16-2:我们的示例结构

图 16-2 中的结构有三个节点(N1、N2 和 N3),每个节点有两个自由度。为自由度分配编号就像听起来那么简单:我们将每个自由度与一个独特的数字关联。表 16-1 展示了一个可能的自由度编号分配方案,使用了节点的自然顺序。

表 16-1:分配自由度编号

节点 自由度编号
N1 0, 1
N2 2, 3
N3 4, 5

如你所见,我们从零开始分配 DOF 编号。我们本可以选择任何其他的数字集合,包括从我们喜欢的任何数字开始的编号方案,但由于我们将使用这些数字来引用系统矩阵和向量中的位置,使用直接与索引相关联的数字会更方便。否则,我们就需要在 DOF 编号和系统中的索引之间进行映射。

在分配了 DOF 编号后,下一步是组装全局方程系统。这个系统与杆件的方程系统结构相同:Image。当我们解这个方程系统时,我们会得到所有自由度的全局位移。使用这些位移,我们可以利用第十五章中定义的类创建结构解模型。

让我们在Structure类(来自model包)中实现这个三步算法。请在列表 16-2 中输入新方法。

class Structure:
    --snip--

    def solve_structure(self):
        self.__assign_degrees_of_freedom()
        self.__solve_system_of_equations()
        return self.__make_structure_solution()

列表 16-2:结构解析

solve_structure方法将计算解并返回一个StructureSolution实例。该方法概述了我们刚刚描述的三步过程。虽然这三个私有方法还不存在,但我们将在接下来的部分逐一实现它们。

自由度编号

解析过程的第一步是为结构的每个自由度分配一个编号。请记住,每个节点有两个自由度,因此__assign_degrees_of_freedom方法会为结构的每个节点分配两个数字,并将它们保存在我们在列表 16-1 中初始化的__dofs_dict字典里。分配了 DOF 编号后,我们在图 16-2 中看到的结构现在应该像图 16-3 那样。

Image

图 16-3:我们结构节点的自由度,带有编号标签

让我们实现这个方法。请在列表 16-3 中输入代码。

class Structure:
    --snip--

    def __assign_degrees_of_freedom(self):
        self.__dofs_dict = {}
        for i, node in enumerate(self.__nodes):
            self.__dofs_dict[node.id] = (2 * i, 2 * i + 1)

列表 16-3:自由度分配

该方法首先初始化__dofs_dict属性,将其设置为空字典,以确保每次运行该方法时使用的是一个全新的字典。然后,我们遍历结构中所有节点的枚举(self.__nodes),将每个节点的 id 作为字典中的键,并将该节点的自由度(DOFs)作为一个包含两个数字的元组与之关联。

Python 中的enumerate函数返回我们传递给该函数的元素的可迭代序列及其索引。当我们的逻辑需要获取列表中项目的索引时,这个函数非常方便。在这里,我们使用节点的索引来计算它的 DOF 编号,对于给定的索引i,DOF 编号分别是2*i2*i + 1

因此,索引为 0 的第一个节点将获得自由度 0 和 1,索引为 1 的节点将获得自由度 2 和 3,以此类推。

对于一个有三个节点(编号为 1、2 和 3)的结构,其自由度字典可能如下所示:

dofs_dict = {
    1: (0, 1),
    2: (2, 3),
    3: (4, 5)
}

让我们继续下一步,这里是关键步骤。

组装和求解方程组

要找到结构各个节点的位移,我们需要组装并求解结构的整体 Image 方程组。这个方程组由各个杆件的单独方程组组合而成。就像每个杆件的 Image 方程组将其节点上的外力和位移联系在一起一样,结构的整体方程组则将结构中每个节点的力和位移联系在一起。

让我们更详细地分析一下,以便理解所有的细节。像往常一样,通过手动做一个小例子可以帮助我们更好地理解这个过程。

手动示例

在开始之前,关于术语的一个小提示:我们将用杆件所在节点的编号,并用箭头分隔,来标记每根杆件。所以,1 → 2 是从节点 1 到节点 2 的杆件。

Image

指的是杆件 1 → 2 的 Image 数量:E 代表杆件的材料杨氏模量,A 是杆件的截面积,l 是杆件的长度。

现在让我们来看一下 图 16-3 中的结构。这个结构有三个节点,三个杆件,并且节点 3 上施加了外部载荷。让我们使用之前定义的自由度编号来推导每个杆件的方程组(参见 图 16-4)。

Image

图 16-4:我们结构的节点和杆件,已标记

杆件 1 → 2 这个水平杆件从节点 1 到节点 2,其局部的 x 轴和 y 轴与全局坐标系对齐;因此,在这种情况下,θ = 0°,所以 cos0° = 1,sin0° = 0。杆件的方程组如下:

Image

如果你需要回顾这个方程组是如何推导出来的,请参考 第 397 页中的“全局坐标系中的刚度矩阵”章节。

杆件 1 → 3 这个从节点 1 到节点 3 的杆件与全局 x 轴形成 30°的角度,因此 ImageImage。杆件的方程组如下:

Image

杆件 2 → 3 这个垂直杆件从节点 2 到节点 3,与全局坐标系的 x 轴形成 30°角,因此 cos30° = 0,sin30° = 1。杆件的方程组如下:

Image

现在我们已经有了每个杆件的方程组,我们需要组装结构的全局方程组。结构总共有三个节点,每个节点有两个自由度,因此系统的大小为 3 × 2 = 6。在这个系统中,力和位移需要出现在它们对应的自由度编号所给定的位置。为了清楚说明这一点,让我们做一个表格,列出自由度编号以及与之相关的力和位移(表 16-2)。

表 16-2: 每个力和位移对应的自由度编号

自由度 (DOF) 相关力 相关位移
0 F[1x] = 0 u[1]
1 F[1y] = 0 v[1]
2 F[2x] = 0 u[2]
3 F[2y] = 0 v[2]
4 F[3x] u[3]
5 F[3y] v[3]

如果自由度编号能告诉我们每个力或位移项在方程组中应占的位置,那么我们可以这样开始构建系统:

Image

请注意,如果我们决定以不同的方式编号自由度,那么力和位移项的顺序会有所不同,但仍然是完全有效的。

在这个方程组中,我们还需要计算刚度项。一般的刚度项 k[ij] 将施加在 i^(th) 自由度上的力与 j^(th) 自由度上的位移相关联(这与我们之前在“解释刚度矩阵项”中看到的内容相同,见 第 429 页)。

正如你所想的那样,如果 i^(th) 和 j^(th) 自由度不属于同一节点,或者不通过杆件连接的节点之间,那么 k[ij] 刚度项将为零:在 i 自由度施加的力和 j 自由度的位移之间无法产生任何关系。在我们的示例结构中,所有节点都已连接,因此全局矩阵中不会出现零值(除了已经存在于杆件各自矩阵中的零值)。在大型结构中,如果一个节点只与少数几个节点相连,那么得到的刚度矩阵往往会有很多零值。

要计算每个 k[ij] 项,我们需要加上与 i^(th) 和 j^(th) 自由度相关的杆件刚度矩阵中的所有刚度值。例如,要计算 k[00],我们必须考虑杆件 1 → 2 和 1 → 3 的刚度,因为这些杆件在自由度 0 上施加的力与相同自由度的位移之间产生了刚度关系。为了简化 Image 项的符号表示,我们使用以下别名:

Image

这样,我们可以通过添加每个刚度项和荷载来组装系统的矩阵和向量:

Image

还有最后一步是让这个方程系统可解:应用外部约束条件,即将受约束的位移设为零。到目前为止,这个方程系统代表的是没有外部约束的结构,但有一些被强加为零的位移,我们必须将这些条件强制输入其解中。在这种情况下,节点 N1 的 x 和 y 位移都受到约束,这可以用数学方式表达如下:

u[1] = 0 和 v[1] = 0

N2 节点的 y 位移受到约束。因此,

v[2] = 0

要在我们的方程组中引入这些条件,以便它们出现在解中,我们必须将给定自由度(DOF)编号的行和列设置为系统矩阵中的单位矩阵,并将系统的力向量中的相应位置设为零。在这种情况下,位移 u[1]、v[1] 和 v[2] 分别分配了 0、1 和 3 的自由度编号;让我们将这些行和列设置为单位向量:

图像

受约束索引的力向量值已经是零(在这些自由度上没有施加力),但如果不是零,我们也必须将它们置为零。通过这个小的代数技巧,我们强制 u[1]、v[1] 和 v[2] 在系统的解中等于零。得到的系统矩阵是正定的;因此,我们在 第十四章 中实现的 Cholesky 数值方法是一个解决此系统的良好候选。

结构的方程组现在已经组装完毕,并准备求解。如果我们使用线性系统求解过程,如 Cholesky 分解,我们将获得位移的值。

现在我们理解了这个过程,让我们将其写成代码。

算法

在结构类中,输入方法 清单 16-4。该方法一步步定义了我们的求解算法。

from functools import reduce

from eqs import Matrix, Vector as EqVector, cholesky_solve
from .node import StrNode
from .bar import StrBar

class Structure:
    --snip--

    def __solve_system_of_equations(self):
        size = self.nodes_count * self.__DOF_PER_NODE
        self.__assemble_system_matrix(size)
        self.__assemble_system_vector(size)
        self.__apply_external_constraints()
        self.__global_displacements = cholesky_solve(
            self.__system_matrix,
            self.__system_vector
        )

清单 16-4:求解方程组

我们在 清单 16-2 中调用了 __solve_system_of_equations,但当时还没有定义它。现在这个完整的方法概述了组装和求解结构方程组的主要步骤。注意,我们使用了许多我们还没有定义的方法;我们将在后续章节中定义它们。

我们首先通过将结构中节点的数量与每个节点的自由度数相乘来计算系统的大小,这个值我们已经存储在类中的常量 __DOF_PER_NODE 中。

然后,我们使用稍后编写的两个私有方法 __assemble_system_matrix 和 __assemble_system_vector 来组装系统的矩阵和向量。

我们调用的下一个方法,__apply_external_constraints,应用那些强制约束位移为零的条件,类似于我们之前手动示例中做的。

最后一步使用我们最近计算的系统矩阵和力向量,通过 Cholesky 解算器函数:cholesky_solve 来求解该方程。此函数需要从 eqs 包中导入。我们得到的结果是全局坐标系下的位移向量。

组装系统矩阵

让我们编写 __assemble_system_matrix 方法。这可能是结构分析算法中最复杂的代码部分,但别担心,我会一步一步带你走。首先,输入代码到清单 16-5 中。

class Structure:
    --snip--

    def __assemble_system_matrix(self, size: int):
        matrix = Matrix(size, size)

        for bar in self.__bars:
         ➊ bar_matrix = bar.global_stiffness_matrix()
         ➋ dofs = self.__bar_dofs(bar)

           for row, row_dof in enumerate(dofs):
               for col, col_dof in enumerate(dofs):
                   matrix.add_to_value(
                    ➌ bar_matrix.value_at(row, col),
                      row_dof,
                      col_dof
                   )

     ➍ self.__system_matrix = matrix

    def __bar_dofs(self, bar: StrBar):
        start_dofs = self.__dofs_dict[bar.start_node.id]
        end_dofs = self.__dofs_dict[bar.end_node.id]
        return start_dofs + end_dofs

清单 16-5:组装方程组矩阵

我们首先创建一个新的矩阵实例,其行数和列数与传入的大小参数相同。然后,我们使用一个 for 循环遍历结构中的所有杆件。在循环中,我们对每个杆件调用 global_stiffness_matrix 方法,并将结果的刚度矩阵存储在 bar_matrix 变量中 ➊。

接下来,我们创建一个包含所有自由度编号的列表,这些自由度编号包含在杆件的节点中:dofs ➋。为了在 __assemble_system_matrix 方法中避免增加过多的噪声,我们实现了另一个私有方法:__bar_dofs。

这个 __bar_dofs 方法使用传入杆件节点的 id,从 __dofs_dict 中提取其自由度编号。在提取起始节点和结束节点的自由度编号后,我们通过连接两个自由度元组来创建一个新的元组。注意,我们可以使用 + 运算符来连接元组。

现在,我们有了一个包含给定杆件节点自由度编号的元组。回想一下,这给了我们杆件刚度项在结构方程组矩阵中的位置:自由度编号也是系统矩阵中的索引。在 __assemble_system_matrix 中,我们使用两个 for 循环遍历杆件刚度矩阵中的所有项。这些循环遍历矩阵的行和列,并将每个访问到的刚度值添加到结构的全局矩阵 ➌ 中。我们使用枚举中的索引来访问杆件的刚度矩阵,使用自由度编号来确定在结构矩阵中的位置。为了确保你理解这个过程,请查看图 16-5。

Image

图 16-5:组装刚度矩阵

在图中,我们选择了杆件 1 → 3,其第一个节点 N1 的自由度为 0 和 1,而第二个节点 N2 的自由度为 4 和 5。我们在杆件的刚度矩阵的边侧和顶部标注了自由度编号。矩阵中的刚度项将这些自由度关联起来。例如,k[21] 项位于与自由度 4 对应的行和与自由度 1 对应的列中;该项关联了在自由度 4 上施加的力与自由度 1 上的位移。这些自由度编号是结构刚度矩阵中的索引。例如,k[21] 项位于该矩阵的第 4 行和第 1 列。

在清单 16-5 的最后一步是将计算得到的矩阵赋值给实例的 __system_matrix 属性 ➍。

组装系统的向量

我们使用与刚度矩阵类似的过程来组装系统的外力向量。不同的是,这次我们不遍历结构的杆件,而是遍历节点:我们要收集每个节点上的外部力。

在你的文件中,输入新的私有方法到清单 16-6 中。

class Structure:
    --snip--

    def __assemble_system_vector(self, size: int):
        vector = EqVector(size)

        for node in self.__nodes:
            net_load = node.net_load
            (dof_x, dof_y) = self.__dofs_dict[node.id]

            vector.add_to_value(net_load.u, dof_x)
            vector.add_to_value(net_load.v, dof_y)

        self.__system_vector = vector

清单 16-6:组装方程系统向量

我们首先创建一个新的向量,其大小根据 size 参数来确定(别忘了,我们已经将这个类别名为 EqVector)。

接下来,我们有一个 for 循环,遍历每个节点。对于每个节点,我们将其净载荷保存到 net_load 变量中。然后,我们从 __dofs_dict 中提取节点的自由度编号到 dof_x 和 dof_y 变量中。请注意,我们正在将元组解构到这些变量中;如果需要复习解构,请查看第 20 页的“解构”部分。

然后,我们将每个净载荷分量加入到向量变量中:x 分量(net_load.u)放在 dof_x 指定的位置,y 分量(net_load.v)放在 dof_y 指定的位置。

最后,我们将计算得到的向量分配给实例的 __system_vector 属性。

应用外部约束

最后,我们需要将外部约束包含到结构的刚度矩阵和力向量中。这意味着我们希望那些外部约束的位移在最终解向量中为零;如果它们受到约束,就不能移动。为了实现这一点,我们可以使用之前探讨的代数技巧,即将相关自由度的行和列设置为刚度矩阵中的单位行列,并在力向量中设置为零。

这比说起来容易做,所以,事不宜迟,先看看代码长什么样。将代码输入到清单 16-7 中。

class Structure:
    --snip--

    def __apply_external_constraints(self):
        for node in self.__nodes:
         ➊ (dof_x, dof_y) = self.__dofs_dict[node.id]

         ➋ if node.dx_constrained:
                self.__system_matrix.set_identity_row(dof_x)
                self.__system_matrix.set_identity_col(dof_x)
                self.__system_vector.set_value(0, dof_x)

         ➌ if node.dy_constrained:
                self.__system_matrix.set_identity_row(dof_y)
                self.__system_matrix.set_identity_col(dof_y)
                self.__system_vector.set_value(0, dof_y)

清单 16-7:应用外部约束

为了检查现有的外部约束,我们遍历结构的每个节点。对于每个节点,我们将其自由度编号提取到 dof_x 和 dof_y 变量中➊。然后,我们检查该节点的 x 方向位移是否受到约束➋,如果是的话,我们需要做三件事:

  1. 将刚度矩阵中的 dof_x 行设置为单位矩阵。

  2. 将刚度矩阵中的 dof_x 列设置为单位矩阵。

  3. 将力向量 dof_x 的值设置为零。

对 y 方向的位移约束➌,我们也做同样的处理。

系统现在准备好求解了。一旦我们得到了系统的解,以位移向量的形式,我们就可以创建结构的解模型。

创建解

让我们快速回顾一下,提醒自己当前所处的阶段。我们已经编写了很多代码,分布在几个私有方法中。图 16-6 显示了解决结构问题所涉及的各个方法的层级结构。

图像

图 16-6:结构求解代码分解成层级结构

图中的节点是按执行顺序从左到右排列的方法。solve_structure 方法是定义主要算法的公共方法。如果你还记得,该方法由三个步骤组成,这些步骤被写成私有方法:

__assign_degrees_of_freedom

__solve_system_of_equations

__make_structure_solution

第二个私有方法,__solve_system_of_equations,是包含最多子方法的方法,如图中所示。

到目前为止,我们已经编写了除了 __make_structure_solution 方法之外的所有代码,__make_structure_solution 是 solve_structure 中的第三步,也是最后一步。现在我们来编写这个方法。它利用方程组的解(节点的全局位移)来构建结构解模型。

model/structure.py 文件中,输入清单 16-8 中的代码。

from functools import reduce

from eqs import Matrix, Vector as EqVector, cholesky_solve
from geom2d import Vector
from structures.solution.bar import StrBarSolution
from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution
from .bar import StrBar
from .node import StrNode

class Structure:
    --snip--

    def __make_structure_solution(self) -> StructureSolution:
        nodes = [
         ➊ self.__node_to_solution(node)
            for node in self.__nodes
        ]

     ➋ nodes_dict = {}
        for node in nodes:
            nodes_dict[node.id] = node
        bars = [
         ➌ StrBarSolution(
                bar,
                nodes_dict[bar.start_node.id],
                nodes_dict[bar.end_node.id]
            )
            for bar in self.__bars
        ]

     ➍ return StructureSolution(nodes, bars)

    def __node_to_solution(self, node: StrNode) -> StrNodeSolution:
     ➎ (dof_x, dof_y) = self.__dofs_dict[node.id]
     ➏ disp = Vector(
            self.__global_displacements.value_at(dof_x),
            self.__global_displacements.value_at(dof_y)
        )
     ➐ return StrNodeSolution(node, disp)

清单 16-8:创建解模型

我们需要做的第一件事是从 structures.solution 包中添加几个导入。我们还从 geom2d 包中导入了 Vector 类。

请注意我们如何为方法的返回对象添加类型提示。这些类型提示前面会有一个箭头(->),并位于方法或函数名称与冒号之间。

然后,使用列表推导,我们将每个原始的 __nodes 映射到节点解决方案模型 ➊。我们使用一个需要编写的私有方法:__node_to_solution。给定一个节点,该方法查找它的自由度编号 ➎,创建一个包含这两个自由度编号对应的位移的向量 ➏,并返回一个 StrNodeSolution 实例,使用原始节点和全局位移向量 ➐。

回到 __make_structure_solution,下一步是一个中间计算,它将简化结构解算条的构建。我们将创建一个解决方案节点的字典,其中键是节点的 id,值是节点本身 ➋。

nodes_dict 的帮助下,计算解决方案的杆模型变得更简单了。通过列表推导,我们将每个原始杆映射到一个 StrBarSolution 实例 ➌。为了实例化这个类,我们需要传递原始杆和两个解决方案节点;多亏了我们刚刚创建的字典,这就变得轻而易举。如果我们没有按 ID 创建节点字典,我们就需要在解决方案节点的列表中查找具有给定 ID 的节点。从性能上来看,这并不是理想的做法。对于每个杆,我们可能需要遍历整个节点列表两次。创建按 ID 查找节点的字典是更明智的选择;它允许常数时间内查找节点。这意味着,无论字典的大小如何,查找与键关联的值所花费的时间都是相同的。如果结构中有大量节点,这种改进可以显著减少执行时间。

最后,我们实例化 StructureSolution,并传递解决方案的节点和杆 ➍。

结果

解决结构问题需要写很多代码,因此我们最好将它们集中在一个列表中以便清晰展示。列表 16-9 是完整的 Structure 类代码,包含了 solve_structure 实现以及我们编写的每个私有方法。

from functools import reduce

from eqs import Matrix, Vector as EqVector, cholesky_solve
from geom2d import Vector
from structures.solution.bar import StrBarSolution
from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution
from .bar import StrBar
from .node import StrNode

class Structure:
    __DOF_PER_NODE = 2

    def __init__(self, nodes: [StrNode], bars: [StrBar]):
        self.__bars = bars
        self.__nodes = nodes

        self.__dofs_dict = None
        self.__system_matrix: Matrix = None
        self.__system_vector: EqVector = None
        self.__global_displacements: EqVector = None

    @property
    def nodes_count(self):
        return len(self.__nodes)

    @property
    def bars_count(self):
        return len(self.__bars)

    @property
    def loads_count(self):
        return reduce(
            lambda count, node: count + node.loads_count,
            self.__nodes,
            0
        )

    def solve_structure(self) -> StructureSolution:
        self.__assign_degrees_of_freedom()
        self.__solve_system_of_equations()
        return self.__make_structure_solution()

    def __assign_degrees_of_freedom(self):
        self.__dofs_dict = {}
        for i, node in enumerate(self.__nodes):
            self.__dofs_dict[node.id] = (2 * i, 2 * i + 1)

    def __solve_system_of_equations(self):
        size = self.nodes_count * self.__DOF_PER_NODE
        self.__assemble_system_matrix(size)
        self.__assemble_system_vector(size)
        self.__apply_external_constraints()
        self.__global_displacements = cholesky_solve(
            self.__system_matrix,
            self.__system_vector
        )

    def __assemble_system_matrix(self, size: int):
        matrix = Matrix(size, size)

        for bar in self.__bars:
            bar_matrix = bar.global_stiffness_matrix()
            dofs = self.__bar_dofs(bar)

            for row, row_dof in enumerate(dofs):
                for col, col_dof in enumerate(dofs):
                    matrix.add_to_value(
                        bar_matrix.value_at(row, col),
                        row_dof,
                        col_dof
                    )

        self.__system_matrix = matrix

    def __bar_dofs(self, bar: StrBar):
        start_dofs = self.__dofs_dict[bar.start_node.id]
        end_dofs = self.__dofs_dict[bar.end_node.id]
        return start_dofs + end_dofs

    def __assemble_system_vector(self, size: int):
        vector = EqVector(size)

        for node in self.__nodes:
            net_load = node.net_load
            (dof_x, dof_y) = self.__dofs_dict[node.id]

            vector.add_to_value(net_load.u, dof_x)
            vector.add_to_value(net_load.v, dof_y)

        self.__system_vector = vector

    def __apply_external_constraints(self):
        for node in self.__nodes:
            (dof_x, dof_y) = self.__dofs_dict[node.id]

            if node.dx_constrained:
                self.__system_matrix.set_identity_row(dof_x)
                self.__system_matrix.set_identity_col(dof_x)
                self.__system_vector.set_value(0, dof_x)

            if node.dy_constrained:
                self.__system_matrix.set_identity_row(dof_y)
                self.__system_matrix.set_identity_col(dof_y)
                self.__system_vector.set_value(0, dof_y)

    def __make_structure_solution(self) -> StructureSolution:
        nodes = [
            self.__node_to_solution(node)
            for node in self.__nodes
        ]

        nodes_dict = {}
        for node in nodes:
            nodes_dict[node.id] = node

        bars = [
            StrBarSolution(
                bar,
                nodes_dict[bar.start_node.id],
                nodes_dict[bar.end_node.id]
            )
            for bar in self.__bars
        ]

        return StructureSolution(nodes, bars)

    def __node_to_solution(self, node: StrNode) -> StrNodeSolution:
        (dof_x, dof_y) = self.__dofs_dict[node.id]
        disp = Vector(
            self.__global_displacements.value_at(dof_x),
            self.__global_displacements.value_at(dof_y)
        )
        return StrNodeSolution(node, disp)

列表 16-9:最终的 Structure 类

有了这段代码,唯一缺少的就是一些单元测试。我们需要确保我们刚刚编写的所有逻辑没有错误。但是我们在前两章编写的代码变得更加复杂,且需要多个不同类之间的交互才能正常工作。那么我们如何隔离我们想要测试的代码部分呢?

高级单元测试:测试替身

随着我们的类变得更加复杂,它们通常会依赖于其他类和外部函数。这时,单元测试就变得更加棘手。单元测试的目标是隔离我们想要测试的类或函数中的一小部分逻辑,以便测试失败时只有一个单一的原因。测试在将多个部分组合在一起时是否正常运行被称为集成测试。集成测试旨在测试系统的较大部分;通过集成测试,我们关注的是当系统的较小部分相互作用时,它们是否仍然有效。我们在这里不会进行集成测试,但我鼓励你自己尝试一下。

回到单元测试,让我们来看看上一章中的 StructureSolution 类。假设我们想要测试它的 bounds_rect 方法。

def bounds_rect(self, margin, scale=1):
    d_pos = [
        node.displaced_pos_scaled(scale)
        for node in self.nodes
    ]
    return make_rect_containing_with_margin(d_pos, margin)

该方法将大部分逻辑委托给 make_rect_containing_with_margin,并且还依赖于 StrNodeSolution 实例来正确计算它们的位移位置。如果我们按原样测试这个方法,我们将测试 make_rect_containing_with_margin 和 Node 类的 displaced_pos_scaled 方法。这两个方法应该已经在其他地方做过单元测试。测试可能会由于与 bounds_rect 逻辑无关的原因而失败。在这种情况下,我们将进行集成测试,但首先我们想通过单元测试确保我们的方法在独立运行时能够正常工作。

我们可以通过使用测试替身而不依赖其他类的实现来测试这种方法。

测试替身

一个测试替身替代测试中使用的真实实现。这个测试替身可以替代一个函数、一个完整的类,或者只是其中的一部分。为了进行单元测试,我们将替换所有没有被直接测试的代码部分,用测试替身代替。测试替身的具体功能取决于它是哪种类型的测试替身。测试替身有几种不同类型。

虚拟对象 这是最简单的测试替身。虚拟对象替代了一个需要存在但在测试中实际上从未使用的对象。这可能是函数的一个参数,例如。

假对象 一个假对象测试替身替代代码的某一部分;它有一个有效的实现,但采用一些简化措施或是大大简化。举个例子,如果我们有一个函数,用于读取文本文件并从中解析出结构模型。如果这个函数在另一个我们想要测试的代码部分中被使用,我们可以创建一个假版本,它假装读取文件,尽管实际上并没有读取文件,而是创建一个结构并返回它。

存根 一个存根替代代码的某一部分,并始终返回相同的值或以特定方式表现。例如,我们可以存根我们比较浮点数的 are_close_enough 函数,使其在给定测试中总是返回 False。

模拟对象 这是一个记录它被使用方式的测试替身,以便可以用来进行断言。模拟对象可能是最复杂且最具多功能性的测试替身。我们可以模拟整个对象,将它们传递给代码以替代真实实现,然后检查我们的代码如何与模拟对象交互,以确保发生了正确的交互。我们稍后会简要查看一个真实的模拟对象示例。

现在让我们探讨一下 Python 如何允许我们创建测试替身。我们将重点讨论模拟,因为模拟非常多功能,我们几乎可以在每个需要测试替身的情况下使用它们。

unittest.mock 包

Python 标准库中的 unittest 包包括了自己的 mock 机制,位于 unittest.mock 包中。你可以在docs.python.org/3/library/unittest.mock.html阅读该包的文档,我建议你这样做,因为它包含了详细的解释,帮助你理解如何最好地使用它。让我们快速了解一下如何使用 unittest.mock 包的主要功能。

Mock 对象

Mock 是 unittest.mock 包中的主要类。这个类的实例记录它们的每一次交互,并提供断言功能来检查这些交互。你可以在 mock 对象中调用任何你想调用的方法;如果该方法不存在,它将被创建,以便我们可以检查该方法被调用了多少次,或传入了什么参数。正如文档中所述,

Mock 是可调用的,并且当你访问它们时,会创建新 mock 作为新属性。访问同一属性将始终返回相同的 mock。Mock 记录你如何使用它们,从而允许你对代码对它们的操作进行断言。

让我们分解一下文档内容。Mock 类的实例是“可调用的”,意味着你可以像调用函数一样“调用”它。你在实例上做的这些调用都会被 mock 记录下来。这表明我们可以使用 Mock 实例来替代函数。

文档还指出,当你访问 mock 的属性时,mock 会“创建新 mock 作为新属性”。这意味着当你在一个 Mock 实例上调用方法时,如果该方法尚未存在,Python 将会为该方法创建一个新的 Mock,并将其作为实例的新属性添加。不要忘记 mock 是可调用的:你可以像调用方法一样调用这些属性,它们的交互将会被记录下来。

让我们通过 Python 的 shell 来查看一个快速示例,以便将这些概念变得更具体:

>>> from unittest.mock import Mock
>>> mock = Mock()
>>> mock()
<Mock name='mock()' id='4548720456'>

>>> mock.some_method('foo', 23)
<Mock name='mock.some_method()' id='4436512848'>

在这段代码中,我们创建了一个新的 Mock 类实例,并像函数一样调用它。我们还在 mock 上调用了一个名为 some_method 的方法,并传递了两个参数:字符串’foo’和数字 23。调用 some_method 没有副作用:它什么也不做,除了记录调用;这是因为 mock 方法默认没有实现。稍后我们将学习如何让 mock 方法返回某些内容或执行某种副作用,但现在请记住,默认情况下,mock 只会记录它们的使用。

如果我们从一个未配置返回任何内容或执行任何副作用的 Mock 对象中调用一个方法,默认情况下它将返回另一个 Mock 实例。这个实例作为属性存储在原始 mock 中。

我们可以询问这个 mock,some_method 是否被调用过,调用时使用了哪些参数:

>>> mock.some_method.assert_called()
>>> mock.some_method.assert_called_once()
>>> mock.some_method.assert_called_with('foo', 23)

所有三个调用都成功(不会引发断言错误),但是如果我们请求没有传递给 some_method 的参数,

>>> mock.some_method.assert_called_with('bar', 577)

我们会得到一个带有有用信息的 AssertionError,这会使测试失败并告诉我们原因:

Traceback (most recent call last):
--snip--
AssertionError: Expected call: some_method('bar', 577)
Actual call: some_method('foo', 123)

同样,如果我们请求一个从未被调用的方法的调用记录,

>>> mock.foo.assert_called()

我们也会遇到一个错误:

Traceback (most recent call last):
--snip--
AssertionError: Expected 'foo' to have been called.

不要忘记,mock 本身是一个可调用的对象,它会记录与其交互的所有信息。因此,下面的代码也会成功:

>>> mock.assert_called()
模拟类

一个常见的 mock 用例是创建一个给定类的 mock 实例。这些 mock 让我们检查它们模拟的类是如何被使用的,以及它上面调用了哪些方法;我们还可以使用 mock 提供返回值,以供测试中的模拟方法使用。

要模拟一个类,我们将其传递给 Mock 构造函数的 spec 参数。让我们为我们的 Vector 类创建一个 mock:

>>> from unittest.mock import Mock
>>> from geom2d import Vector
>>> vector_mock = Mock(spec=Vector)
>>> isinstance(vector_mock, Vector)
True

这个 mock 对象的 __class__ 属性被设置为 Vector,以便它看起来像一个真实的 Vector 实例。它甚至通过了 isinstance 测试!这个 mock 可以有效地用来替代一个真实的 VectorVector 类中的所有方法在这个测试替身中也都有定义。我们可以像通常一样调用它们:

>>> vector_mock.rotated_radians(0.25)
<Mock name='mock.rotated_radians()' id='4498122344'>

这次,rotated_radians 并没有返回我们期望的 Vector 新实例,而是返回了一个 Mock 实例。由于模拟类的方法没有实现,因此没有代码来执行旋转操作并返回结果向量。我们可以通过 mock 的 side_effectreturn_value 属性来编程 mock 方法返回预定义的值。

但在我们继续之前,还有一件事关于类 mock 很重要:如果我们尝试调用类中不存在的方法,我们将得到一个 AttributeError。新属性可以添加到通用 mock 上,但不能添加到类的 mock 上。代码

>>> vector_mock.defrangulate()

产生如下结果:

Traceback (most recent call last):
--snip--
AttributeError: Mock object has no attribute 'defrangulate'

这是好的:我们可以确保,如果我们的代码的某部分尝试调用原始类中不存在的方法,我们将得到一个错误。

现在让我们看看如何为 mock 添加一个存根实现,或者只是为 mock 设置一个预定义的返回值。

设置返回值和副作用

通过设置 mock 的 return_value,我们可以让它在被调用时返回某个值:

>>> vector_mock.rotated_radians.return_value = Vector(0, 0)
>>> vector_mock.rotated_radians(0.25)
<geom2d.vector.Vector object at 0x10bbaa4a8>

现在调用 rotated_radians 会返回一个 Vector 类的实例:正是我们编程让它返回的实例。从现在起,每次在 mock 上调用这个方法时,它都会返回相同的 Vector 实例。

模拟也可以在被调用时执行副作用。根据文档,side_effect

可以是一个在调用 mock 时被调用的函数,一个可迭代对象,或是一个将被抛出的异常(类或实例)。

首先让我们看看一个 mock 如何抛出异常。例如,如果我们需要 cosine 方法抛出一个 ValueError,我们可以这样做:

>>> vector_mock.cosine.side_effect = ValueError
>>> vector_mock.cosine()
Traceback (most recent call last):
--snip--
ValueError

请注意,我们将 ValueError 类本身设置为 side_effect,但正如文档所述,我们也可以使用一个具体的实例,像这样:

>>> vector_mock.cosine.side_effect = ValueError('Oops')
>>> vector_mock.cosine()
Traceback (most recent call last):
--snip--
ValueError: Oops

在这种情况下,每次调用 cosine 时,我们都会得到相同的 ValueError 实例。在前一个示例中,每次调用都会生成一个新的错误实例。

我们还可以为 mock 的 side_effect 属性分配一个函数。这个函数接收传递给 mock 函数的参数,并且可能返回一个值。例如,在我们的 Vector mock 中,我们可以决定让 scaled_by 方法返回传入的因子参数:

>>> vector_mock.scaled_by.side_effect = lambda factor: factor
>>> vector_mock.scaled_by(45)
45

在这个例子中,scaled_by 方法传入了 45 作为缩放因子,并且这个参数被转发到了定义为 mock 的 side_effect 属性的函数中。

这个函数可以执行自己的副作用,比如保存它接收到的参数或打印某些内容到终端。我们可以将这个函数与 return_value 一起使用。如果我们使用这个函数来执行副作用,但仍然希望返回在 return_value 属性中设置的内容,则该函数应返回 DEFAULT(在 unittest.mock 中定义)。

>>> from unittest.mock import DEFAULT
>>> def side_effect(factor):
...    print(f'mock called with factor: {factor}')
...    return DEFAULT

>>> vector_mock.scaled_by.side_effect = side_effect
>>> vector_mock.scaled_by.return_value = Vector(1, 2)

>>> vector_mock.scaled_by(2)
mock called with factor: 2
<geom2d.vector.Vector object at 0x10c4a7f28>

正如你所看到的,side_effect 函数被调用了,但由于它返回了默认值,因此调用 scaled_by 返回了我们设置为 return_value 的向量。

@patch 装饰器

mock 包包括一个 unittest.mock.patch 装饰器,我们可以在测试函数中使用它来模拟对象。@patch 装饰器能够模拟它所装饰的测试函数中实例化的对象。装饰器创建的 mock 会在函数返回后自动被清除,因此模拟只在函数上下文中有效。我们必须通过 ’package.module.name’ 格式传递给 @patch 装饰器我们想要模拟的目标(这是一个字符串,所以别忘了引号),其中 name 可以是类或函数的名称。装饰的函数将作为一个新参数接收被模拟的目标:

from unittest.mock import patch

@patch('geom2d.circles.make_circle_from_points')
def test_something(make_circle_mock):
    make_circle_mock(1, 2, 3)
    make_circle_mock.assert_called_with(1, 2, 3)

在这个测试中,我们正在替换 geom2d 包的 circles 模块中定义的 make_circle_from_points 函数。我们必须将模拟的函数 make_circle_mock 作为参数传递给这个函数。然后,在 test_something 函数的上下文中,我们可以引用被模拟的函数,并像对待其他 mock 一样断言它被调用了。

@patch 装饰器的主要用途是替换测试对象导入的函数或类。通过使用 patch,我们强制它们导入 mock,而不是实际的依赖项。

没有其他简单的方法可以模拟我们想要单元测试的模块的依赖项:如果模块导入了它们的依赖项,我们需要一种方法来替换 Python 导入机制中的依赖项。@patch 装饰器以优雅的方式为我们完成了这个任务。

现在,让我们将所有这些知识应用于隔离测试我们的代码:没有比在实际用例中使用测试替代品更好的学习方法了。如果你是第一次使用测试替代品,你可能会感到有点困惑;这完全正常。随着我们多次看到 mock 的使用,你将开始理解这些概念。

测试结构解决方案类

根据我们之前介绍的 StructureSolution 类中的 bounds_rect 方法的示例,接下来让我们看看如何进行测试。记住,我们想要测试的方法定义如下:

def bounds_rect(self, margin, scale=1):
    d_pos = [
        node.displaced_pos_scaled(scale)
        for node in self.nodes
    ]
    return make_rect_containing_with_margin(d_pos, margin)

该方法要求 StrNodeSolution 类能够正确计算其位移位置,使用缩放值,并且 make_rect_containing_with_margin 函数能够根据给定的边距返回正确的矩形。我们不需要测试这些行为;这些应该在其他地方已经完成。我们想要做的是用测试替代品替换它们的实际实现,这样它们就不会干扰我们的测试。

不再多说,让我们在 structures/tests 目录下创建一个名为 structure_solution_test.py 的新文件。在文件中输入测试设置代码,如 Listing 16-10 所示。

import unittest
from unittest.mock import patch, Mock

from geom2d import Point
from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution

class StructureSolutionTest(unittest.TestCase):

    p_one = Point(2, 3)
    p_two = Point(5, 1)

    def setUp(self):
        self.n_one = Mock(spec=StrNodeSolution)
        self.n_one.displaced_pos_scaled.return_value = self.p_one
        self.n_two = Mock(spec=StrNodeSolution)
        self.n_two.displaced_pos_scaled.return_value = self.p_two

Listing 16-10: 结构解类测试:设置

在这个测试设置中,我们定义了两个点:p_one 和 p_two;它们是我们在 setUp 方法中创建的模拟节点的位置。这个 setUp 方法会在每个测试之前由 unittest 框架执行,确保每个测试都有新的模拟节点;否则,模拟节点会继续记录,破坏测试之间的独立性。

我们定义了两个节点:n_one 和 n_two。然后,我们使用 StrNodeSolution 类实例化节点模拟,作为 spec 参数的值。每个节点模拟都将定义一个已定义点,作为其 displaced_pos_scaled 方法的返回值。

接下来,让我们编写第一个测试,确保两个节点在调用 displaced_pos_scaled 方法时,传入了正确的缩放参数值。输入代码,见 Listing 16-11。

class StructureSolutionTest(unittest.TestCase):
   --snip--

    def test_node_displaced_scaled_positions_called(self):
        solution = StructureSolution([self.n_one, self.n_two], [])
        solution.bounds_rect(margin=10, scale=4)

        self.n_one.displaced_pos_scaled.assert_called_once_with(4)
        self.n_two.displaced_pos_scaled.assert_called_once_with(4)

Listing 16-11: 结构解类测试:第一个测试

我们创建一个 StructureSolution 实例,列表中包含在 setUp 方法中定义的两个节点,并且没有条形图:我们不需要它们来测试 bounds_rect 方法,而且如果我们用空的 bars 列表实例化它,StructureSolution 也不会抱怨。如果 StructureSolution 类的初始化器抱怨传入了一个空的 bars 列表,那么这正是使用虚拟测试替代品的最佳情况:我们会向构造函数传入一个虚拟的 bars 列表。虚拟对象用于填充必需的参数,但它们不会实际做任何事情,也不会干扰测试。

一旦我们实例化了 StructureSolution,我们就调用 bounds_rect 方法,也就是我们的测试对象,并传入边距和缩放的值。最后,我们断言 displaced_pos_scaled 方法在两个节点上都被调用了一次,且传入了正确的缩放值。

这个测试确保我们使用节点的位移位置,并应用相应的缩放值来计算结构解的边界。想象一下,如果我们在实现该方法时错误地混淆了边距和缩放参数:

def bounds_rect(self, margin, scale=1):
    d_pos = [
        # wrong! used 'margin' instead of 'scale'
        node.displaced_pos_scaled(margin)
        for node in self.nodes
    ]
    # wrong! used 'scale' instead of 'margin'
    return make_rect_containing_with_margin(d_pos, scale)

我们的单元测试会警告我们:

Expected call: make_rect_containing_with_margin([
    <geom2d.point.Point object at 0x10575a630>,
    <geom2d.point.Point object at 0x10575a6a0>], 10)
Actual call: make_rect_containing_with_margin([
    <geom2d.point.Point object at 0x10575a630>,
    <geom2d.point.Point object at 0x10575a6a0>], 4)

恭喜!你已经写出了第一个使用测试替身的单元测试。现在让我们写一个第二个测试,确保正确使用计算矩形的函数。请在清单 16-12 中输入代码。

class StructureSolutionTest(unittest.TestCase):
   --snip--

    @patch('structures.solution.structure.make_rect_containing_with_margin')
    def test_make_rect_called(self, make_rect_mock):
        solution = StructureSolution([self.n_one, self.n_two], [])
        solution.bounds_rect(margin=10, scale=4)

        make_rect_mock.assert_called_once_with(
            [self.p_one, self.p_two],
            10
        )

清单 16-12:结构解决方案类测试:第二个测试

这个测试有点棘手,因为make_rect_containing_with_margin函数是由StructureSolution类导入的。为了让这个类导入我们的模拟对象而不是实际实现,我们必须修补函数的路径:’package.module.name’,在这个例子中,如下所示:

'structures.solution.structure.make_rect_containing_with_margin'

但是,等一下:make_rect_containing_with_margin不是在*geom2d*包中定义的吗?那为什么我们像在*structures.solution*包和*structure*模块中一样修补它呢?

@patch装饰器有一些规则来定义如何给定对象的路径以模拟它。在“在哪里修补”部分,文档中说明:

patch()通过(临时)将一个名称指向的对象替换为另一个对象来工作。可以有多个名称指向同一个对象,因此为了使修补工作,你必须确保修补测试系统中使用的名称。

基本原理是你修补对象被查找的地方,这不一定是定义它的地方。

第二段给了我们关键:对象必须在它们被查找的地方修补。在我们的测试中,我们要替换的函数是在structures.solution包中的*structure*模块中查找的。开始时这可能听起来有点复杂,但当你做几次后就会明白。

继续我们的测试,前两行和之前的一样:它们创建结构解决方案并调用被测试的函数。然后是断言,这个断言是对传递给测试函数的参数:make_rect_mock进行的。记住,@patch装饰器将修补后的实体传递给装饰的函数。我们断言模拟对象只被调用一次,参数是模拟节点返回的位置列表和边距的值。

你可以通过在 PyCharm 中点击测试类名称左边的绿色播放按钮来运行这些测试。或者,你也可以从命令行运行它们:

$ python3 -m unittest structures/tests/structure_solution_test.py

清单 16-13 展示了结果代码,供你参考。

import unittest
from unittest.mock import patch, Mock

from geom2d import Point
from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution

class StructureSolutionTest(unittest.TestCase):

    p_one = Point(2, 3)
    p_two = Point(5, 1)

    def setUp(self):
        self.n_one = Mock(spec=StrNodeSolution)
        self.n_one.displaced_pos_scaled.return_value = self.p_one
        self.n_two = Mock(spec=StrNodeSolution)
        self.n_two.displaced_pos_scaled.return_value = self.p_two

    def test_node_displaced_scaled_positions_called(self):
        solution = StructureSolution([self.n_one, self.n_two], [])
        solution.bounds_rect(margin=10, scale=4)

        self.n_one.displaced_pos_scaled.assert_called_once_with(4)
        self.n_two.displaced_pos_scaled.assert_called_once_with(4)

    @patch('structures.solution.structure.make_rect_containing_with_margin')
    def test_make_rect_called(self, make_rect_mock):
        solution = StructureSolution([self.n_one, self.n_two], [])
        solution.bounds_rect(margin=10, scale=4)

        make_rect_mock.assert_called_once_with(
            [self.p_one, self.p_two],
            10
        )

清单 16-13:结构解决方案类测试:结果

在继续之前,有一个重要的陷阱需要注意。如果你查看这两个测试,你可能会想删除重复的行,

solution = StructureSolution([self.n_one, self.n_two], [])
solution.bounds_rect(margin=10, scale=4)

通过将它们移到setUp中。这样做似乎是合理的,这样测试就不需要重复这些行,但如果你继续进行重构,你会发现第二个测试现在失败了。为什么?

答案与@patch 装饰器的工作原理有关。它需要装饰依赖被修补的函数,而在我们的案例中,make_rect_containing_with_margin 函数是在实例化 StructureSolution 类时被导入的。因此,至少对于第二个测试,必须在测试方法中实例化该类,并且该方法需要使用@patch 装饰器。

测试结构求解过程

现在让我们添加一些测试,确保结构求解过程得出正确的结果。对于这些测试,我们将在代码中定义图 16-7 中的结构。

图片

图 16-7:单元测试用的结构

structures/tests目录下创建一个名为structure_test.py的新文件。在文件中,输入列表 16-14 中的代码。

import unittest
from unittest.mock import patch

from eqs import Matrix
from geom2d import Point, Vector
from eqs.vector import Vector as EqVector
from structures.model.node import StrNode
from structures.model.bar import StrBar
from structures.model.structure import Structure

class StructureTest(unittest.TestCase):

    def setUp(self):
        section = 5
        young = 10
        load = Vector(500, -1000)

        self.n_1 = StrNode(1, Point(0, 0))
        self.n_2 = StrNode(2, Point(0, 200))
        self.n_3 = StrNode(3, Point(400, 200), [load])
        self.b_12 = StrBar(1, self.n_1, self.n_2, section, young)
        self.b_23 = StrBar(2, self.n_2, self.n_3, section, young)
        self.b_13 = StrBar(3, self.n_1, self.n_3, section, young)

     ➊ self.structure = Structure(
            [self.n_1, self.n_2, self.n_3],
            [self.b_12, self.b_23, self.b_13]
        )

    def test_nodes_count(self):
     ➋ self.assertEqual(3,  self.structure.nodes_count)

    def test_bars_count(self):
     ➌ self.assertEqual(3, self.structure.bars_count)

    def test_loads_count(self):
     ➍ self.assertEqual(1, self.structure.loads_count)

列表 16-14:结构求解测试

这个列表定义了 StructureTest 测试类。在每次测试之前调用的 setUp 方法中,我们定义了图 16-7 中的结构。结构有三个节点:n_1、n_2 和 n_3。最后一个节点 n_3 上施加了一个荷载。我们暂时没有向节点 1 和 2 添加外部约束;稍后我们会解释原因。然后,我们在刚定义的节点之间创建了杆件 b_12、b_23 和 b_13;我们使用了 5 和 10 作为截面面积和杨氏模量。通过这些节点和杆件,结构最终被实例化 ➊。

接下来是三个简单的测试。第一个确保结构能够计算出它有多少个节点 ➋。第二个做同样的事情,但作用于杆件 ➌。第三个也做相同的操作,这次是计算施加在结构上的荷载数量 ➍。

在求解结构的过程中,最复杂的操作之一是组装刚度矩阵,因此我们添加一个测试,检查在施加外部约束条件之前矩阵是否正确组装。由于我们尚未向结构中添加外部约束,传递给 cholesky_solve 函数的矩阵就是我们需要的系统矩阵。如果我们模拟 cholesky_solve 函数,传递给它的参数是系统的刚度矩阵和荷载向量,我们可以捕获这些参数来进行断言。通过模拟这个函数,我们的代码不会执行 Cholesky 方法的原始代码,这没问题,因为那部分逻辑不应该干扰我们的测试。在列表 16-15 中输入新的测试。

class StructureTest(unittest.TestCase):
    --snip--

 ➊ @patch('structures.model.structure.cholesky_solve')
    def test_assemble_system_matrix(self, cholesky_mock):
        eal3 = 0.1118033989
        c2_eal3 = .8 * eal3
        s2_eal3 = .2 * eal3
        cs_eal3 = .4 * eal3
     ➋ expected_mat = Matrix(6, 6).set_data([
            c2_eal3, cs_eal3, 0, 0, -c2_eal3, -cs_eal3,
            cs_eal3, .25 + s2_eal3, 0, -.25, -cs_eal3, -s2_eal3,
            0, 0, .125, 0, -.125, 0,
            0, -.25, 0, .25, 0, 0,
            -c2_eal3, -cs_eal3, -.125, 0, .125 + c2_eal3, cs_eal3,
            -cs_eal3, -s2_eal3, 0, 0, cs_eal3, s2_eal3
        ])

        self.structure.solve_structure()
     ➌ [actual_mat, _] = cholesky_mock.call_args[0]

     ➍ cholesky_mock.assert_called_once()
     ➎ self.assertEqual(expected_mat, actual_mat)

列表 16-15:系统刚度矩阵组装测试

我们首先希望模拟 cholesky_solve 函数,因此我们添加了一个@patch 装饰器,指定了查找该函数的路径:structures/model/structure 包中的cholesky_solve模块 ➊。注意,我们将 cholesky_mock 作为参数传递给测试方法。

接下来,我们定义预期结构的刚度矩阵:expected_mat。它是一个 6 × 6 的矩阵(三个节点,每个节点有两个自由度)。我已经手动做了数学计算并组装了矩阵;我建议你也这样做,以确保你理解这个过程。对于梁 1 → 3,定义了一些辅助变量:

  • eal3 是 Image 数值

  • c2_eal3 是 Image

  • s2_eal3 是 Image

  • cs_eal3 是 Image

梁 1 → 2 和梁 2 → 3 的刚度矩阵中的数值是直接的,因为它们的角度分别是 Image 和 0 弧度。在使用这三根梁的矩阵组装全局矩阵后,结果是 ➋。

要运行解析代码,我们必须调用 solve_structure 方法。在执行 solve 方法后,我们关心的是哪些参数传递给了 cholesky_mock 函数。Mocks 有一个属性 call_args,这是一个包含传递给每个 mock 调用的参数的列表。我们的 mock 函数只被调用了一次,所以我们需要的是第一次调用的参数。

我们解构了 cholesky_mock 的 call_args 中的第一个调用(call_args[0]),并只保留了第一个值,存入名为 actual_mat 的变量 ➌。正如你所看到的,左侧列表中的第二个元素([actual_mat, _])是一个下划线,这意味着右侧列表(cholesky_mock.call_args[0])中有一个值,但我们不需要保存它。

然后是两个断言。第一个断言检查 cholesky_mock 是否只被调用了一次 ➍,第二个断言则比较预期的刚度矩阵与实际传递给 cholesky_mock 解析函数的刚度矩阵 ➎。

在这个测试中,我们确保 Cholesky 解析函数接收到正确的、未应用外部约束条件的结构刚度矩阵。现在,让我们编写一个新的测试,加入这些约束,检查刚度矩阵是否被正确修改以包括这些约束。请在清单 16-16 中输入此测试。

class StructureTest(unittest.TestCase):
    --snip--

 ➊ @patch('structures.model.structure.cholesky_solve')
    def test_system_matrix_constraints(self, cholesky_mock):
     ➋ self._set_external_constraints()

        eal3 = 0.1118033989
        c2_eal3 = .8 * eal3
        s2_eal3 = .2 * eal3
        cs_eal3 = .4 * eal3
     ➌ expected_mat = Matrix(6, 6).set_data([
            1, 0, 0, 0, 0, 0,
            0, 1, 0, 0, 0, 0,
            0, 0, 1, 0, 0, 0,
            0, 0, 0, 1, 0, 0,
            0, 0, 0, 0, .125 + c2_eal3, cs_eal3,
            0, 0, 0, 0, cs_eal3, s2_eal3
        ])

        self.structure.solve_structure()
        [actual_mat, _] = cholesky_mock.call_args[0]

        cholesky_mock.assert_called_once()
     ➍ self.assertEqual(expected_mat, actual_mat)

清单 16-16:系统刚度矩阵约束测试

这个测试与之前的测试类似。cholesky_solve 函数以相同的方式被修补 ➊,并且新的 mock 参数 cholesky_mock 被传递到测试方法中。然后,我们调用一个私有方法,将外部约束添加到节点 1 和节点 2,正如它们在图 16-7 中所示 ➋。我们将在测试后编写此方法。

然后是预期矩阵的定义,这次应用了外部约束 ➌。除了主对角线上的项外,唯一非零的项是属于节点 3 的:自由度 4 和 5。因此,只有那些行和列索引中的项是非零的。

剩下的测试与之前完全相同:我们在结构实例上调用 solve_structure 方法。然后我们将从调用 cholesky_mock 中提取的矩阵参数保存到名为 actual_mat 的变量中。注意,我们使用了列表解包,其中第二个项,即系统的负载向量,通过使用下划线被忽略了。接着是检查 Cholesky 模拟函数是否仅被调用一次的断言,以及比较实际和预期系统矩阵的检查 ➍。

最后,我们需要编写 _set_external_constraints 函数,将外部约束应用到节点 1 和节点 2 上。在我们刚写完的方法之后,输入第 16-17 列表中的代码。

class StructureTest(unittest.TestCase):
    --snip--

    def _set_external_constraints(self):
        self.n_1.dx_constrained = True
        self.n_1.dy_constrained = True
        self.n_2.dx_constrained = True
        self.n_2.dy_constrained = True

第 16-17 列表:为节点设置外部约束

让我们再做一次最后的测试,检查负载向量组装过程。这个测试的思路是沿用之前两个测试的结构,但这次检查负载向量。输入第 16-18 列表中的测试。

class StructureTest(unittest.TestCase):
    --snip--

 ➊ @patch('structures.model.structure.cholesky_solve')
    def test_assemble_system_vector(self, cholesky_mock):
     ➋ expected_vec = EqVector(6).set_data([
            0, 0, 0, 0, 500, -1000
        ])

        self.structure.solve_structure()
     ➌ [_, actual_vec] = cholesky_mock.call_args[0]

     ➍ self.assertEqual(expected_vec, actual_vec)

第 16-18 列表:系统负载向量组装测试

我们像之前一样修补 cholesky_solve 函数 ➊。然后我们声明预期的负载向量 ➋,这次很简单,因为只有一个负载作用在节点 3 上。

剩下的测试与之前类似。主要的区别是这次我们解构了第一次调用 cholesky_mock ➌ 的第二个参数,即传入的向量,也就是我们的代码生成的负载向量。这次我们不再断言 mock 被调用了一次,正如我们在之前的两个测试中所做的那样;我们可以这么做,但这个条件已经测试过了,没有必要重复相同的断言。我们想要检查的是 actual_vec 是否等于 expected_vec ➍。

现在我们可以运行测试了。从 shell 中运行以下命令:

$ python3 -m unittest structures/tests/structure_test.py

如果所有测试都通过,应该会生成以下输出:

Ran 6 tests in 0.004s

OK

我们可以再写几个单元测试,但为了简洁起见,我们就不写了。不过,我建议你自己设计更多的测试,锻炼一下你的测试替代技能。

总结

在本章中,我们开发了结构的求解算法,这是一个复杂的逻辑,我们将其拆分到几个私有方法中。这个求解过程承担了组装结构的全局刚度矩阵和向量、应用外部约束并使用我们之前实现的 Cholesky 方法求解得到的方程组的重任。一旦得到节点的全局位移,它们就会用于构建结构解模型。在第十八章中,我们将看到如何为这个解模型生成图形结果。

我们还介绍了测试替身的概念,这是通过将代码的一个小部分与其协作对象隔离来编写良好单元测试的关键技术。测试替身有几种不同的类型;Python 的unittest实现基本上为我们提供了一个:mock。然而,这个 mock 实现非常灵活,甚至可以用作存根(stub)或间谍(spy)。我们通过使用这个类和@patch 装饰器,学习了如何用它们来测试我们最新的代码。

现在是时候集中精力从文本文件中读取和解析结构了,这样我们就能为我们的解析算法提供一些精细的结构定义。让我们开始吧!

第十七章:从文件读取输入

Image

我们开发的任何工程应用都需要一些数据输入。例如,要使用我们在上一章中开发的算法解决一个桁架结构问题,我们首先需要构建结构模型。每次想要解决结构问题时手动实例化类来构建模型是非常繁琐的;如果我们能简单地将一个符合给定且明确定义的方案的纯文本文件传递给应用程序,定义我们想要解决的结构,那将更加方便。在本章中,我们将为我们的应用程序配备一个文件解析器功能,它可以读取文本文件,解释文件内容,并构建应用程序内部使用的模型。

定义输入格式

为了使我们的应用程序正常工作,我们提供给它的文件必须具有明确定义的结构。文本文件必须包括节点的定义、施加到它们的载荷以及结构的杆件。让我们为这些部分决定一种格式。

节点格式

每个节点将在它自己的行中定义,遵循以下格式,

<node_id>: (<x_coord>, <y_coord>) (<external_constraints>)

其中

  • node_id 是赋予节点的 ID。

  • x_coord 是节点的 x 位置。

  • y_coord 是节点的 y 位置。

  • external_constraints 是一组约束运动。

这是一个例子:

1: (250, 400) (xy)

这定义了一个 ID 为 1 的节点,位置为 (250, 400),其 x 和 y 位移受到外部约束。

载荷格式

载荷将与其应用的节点分开定义,因此我们需要指明载荷应用的节点 ID。将节点和载荷定义在不同的行可以通过使用两个简单的正则表达式(一个用于节点,另一个用于载荷)来简化输入解析过程,而不必使用一个长且复杂的正则表达式。每个载荷将在单独的一行中定义。

我们将使用以下格式来定义载荷,

<node_id> -> (<Fx>, <Fy>)

其中

  • node_id 是载荷作用的节点。

  • Fx 是载荷的 x 分量。

  • Fy 是载荷的 y 分量。

这是一个例子:

3 -> (500, -1000)

这定义了一个载荷 ⟨500,–1000⟩,作用在 ID 为 3 的节点上。我们使用 -> 字符序列来分隔节点 ID 和载荷分量,而不是使用冒号,以便明确我们不是将 ID 分配给载荷本身,而是将载荷应用于具有该 ID 的节点。

杆件格式

杆件定义在两个节点之间,并具有截面和杨氏模量。与节点和载荷一样,每个杆件将定义在它自己的行中。我们可以为杆件设置以下格式,

<bar_id>: (<start_node_id> -> <end_node_id>) <A> <E>

其中

  • bar_id 是赋予杆件的 ID。

  • start_node_id 是起始节点的 ID。

  • end_node_id 是结束节点的 ID。

  • A 是横截面积。

  • E 是杨氏模量。

这是一个例子:

1: (1 -> 2) 30 20000000

这定义了一个位于节点 1 和 2 之间的杆件,横截面积为 30,杨氏模量为 20000000。这个杆件的 ID 设置为 1。

文件格式

现在我们已经为节点、荷载和杆件设计了格式,让我们看看如何将它们组合到一个文件中。我们正在寻找一种既容易手动编写又容易解析的文件结构。

一个有趣的想法是将文件分成几个部分,每个部分由一个标题开始:

<section_name>

每个部分应仅包含定义相同类型实体的行。

由于我们的结构定义文件将包含三种不同类型的实体——节点、荷载和杆件——因此它们需要三个不同的部分。例如,我们在上一章的单元测试中使用的结构,这里作为图 17-1 展示,将按如下方式定义:

图片

图 17-1:来自上一章单元测试的结构

nodes
1: (0, 0)     (xy)
2: (0, 200)   (xy)
3: (400, 200) ()

loads
3 -> (500, -1000)

bars
1: (1 -> 2) 5 10
2: (2 -> 3) 5 10
3: (1 -> 3) 5 10

现在我们已经为结构定义文件定义了格式,接下来需要开发一个解析器。一个解析器是一个组件(函数或类),它读取文本,解释它,并将其转换为数据结构或模型。在这种情况下,模型是我们的桁架结构类:Structure。我们将像在第九章中一样使用正则表达式。

查找正则表达式

如果我们事先知道结构,正则表达式是一种可靠的提取纯文本中所有需要信息的方法。我们将需要三种不同的正则表达式:一个用于节点,一个用于荷载,一个用于杆件。如果你需要复习正则表达式,请花点时间回顾一下第 9 页中的“正则表达式”部分。让我们设计这些正则表达式。

节点正则表达式

为了匹配我们格式中定义的节点,我们可以使用以下正则表达式:

/(?P\d+)\s:\s

((?P[\d\s.,-]+))\s*

((?P[xy]{0,2}))/

这是一个非常复杂的正则表达式。它被分成了几行,因为它太长,无法放入一行,但你可以想象它只是一个单行的正则表达式。让我们分解这个正则表达式的各个部分。

(?P\d+) 这匹配节点的 ID,一个由一位或多位数字组成的数字(\d+),并将其捕获为名为 id 的组。

\s:\s 这匹配 ID 后的冒号,并允许冒号前后有任意数量的空格(\s*)。

((?P[\d\s.,-]+)) 这匹配括号内的节点位置坐标,并将其捕获为名为 pos 的组。请注意,我们匹配的是括号内的整个表达式;它包括两个坐标和分隔它们的逗号。我们将在代码中分割这两个数字。我们这样做是为了避免让我们已经非常庞大的正则表达式变得更加可怕。将正则表达式与 Python 的字符串操作方法结合起来是一种强大的技术。

\s* 这匹配零个或多个空格,用于分隔坐标组和外部约束组。

((?P[xy]{0, 2})) 这一部分匹配括号内定义的外部约束,并将其捕获到名为 ec 的组中。括号内的内容限于字符集[xy],即字符“x”和“y”。字符数也有限制,允许的字符数为 0 到 2 之间({0, 2})。

我们很快会看到这个正则表达式的实际应用。图 17-2 可能会帮助你理解正则表达式中每个子部分的含义。

图片

图 17-2:节点正则表达式的可视化

让我们来看一下如何解析负载。

负载正则表达式

为了匹配使用我们定义的格式编写的负载,我们将使用以下正则表达式:

/(?P<node_id>\d+)\s->\s((?P[\d\s.,-]+))/

这个正则表达式并不像前一个那样可怕;让我们将其分解为多个子部分。

(?P<node_id>\d+) 这个表达式匹配节点 ID,并将其捕获到名为 node_id 的组中。

\s->\s 这个表达式匹配->字符序列及其周围的可选空格。

((?P[\d\s.,-]+)) 这个表达式匹配括号内的整个内容,其中定义了力向量分量。括号内允许的字符集是[\d\s.,-],包括数字、空格、点、逗号和减号。捕获的内容会存储在名为 vec 的捕获组中。

图 17-3 是正则表达式不同部分的分解。确保你理解它们的每一部分。

图片

图 17-3:负载正则表达式的可视化

最后,让我们来看看条形的正则表达式。

条形正则表达式

为了匹配使用我们之前定义的格式编写的条形,我们将使用以下正则表达式:

/(?P\d+)\s:\s

((?P<start_id>\d+)\s->\s(?P<end_id>\d+))\s*

(?P[\d.]+)\s+

(?P[\d.]+)/

这个正则表达式因为其长度也被拆分成了几行,但你可以想象它是写在一行中的。让我们逐步分析它:

(?P\d+) 这个表达式匹配分配给条形的 ID,并将其捕获到名为 id 的组中。

\s:\s 这个表达式匹配冒号字符及其周围的可选空白字符。

((?P<start_id>\d+)\s->\s(?P<end_id>\d+)) 这个表达式匹配由->字符序列分隔并且周围有可选空格的两个节点 ID。ID 会被捕获到名为 start_id 和 end_id 的组中。整个表达式需要出现在括号内。

\s* 这个表达式匹配最后一个括号与下一个值(即节)之间的可选空白字符。

(?P[\d.]+) 这个表达式捕获一个十进制数,并将其赋值给名为 sec 的组。

\s+ 这个正则表达式匹配括号和下一个值(杨氏模量)之间的必需空格。回想一下,在这种情况下,我们至少需要一个空格,否则无法知道截面值和杨氏模量值的起始和结束位置。

(?P[\d.]+) 这个捕获的是一个小数,并将其分配给名为 young 的组。

这是我们在本书中看到的最大和最复杂的正则表达式。图 17-4 应该帮助你识别它的各个部分。

图片

图 17-4:条形正则表达式可视化

现在我们已经有了正则表达式,让我们开始编写解析结构文件的代码。

设置

现在,我们的structures包包含以下子目录:

structures

|- 模型

|- 解决方案

|- 测试

让我们通过右键点击 structures 并选择 新建Python 包 来创建一个名为 parse 的新包文件夹。如果你是在 IDE 外部进行操作,别忘了在文件夹中创建一个空的 init.py 文件。我们的 structures 包目录应该如下所示:

structures

|- 模型

|- 解析

|- 解决方案

|- 测试

我们已经准备好开始实现代码了。我们将首先实现解析节点、载荷和条形的逻辑。每个逻辑将定义在自己的函数中,并包含单元测试。然后,我们将把这些内容整合到一个函数中,该函数读取整个文件的内容,将其拆分成行,并将每一行解析成正确的模型类。

解析节点

我们从节点开始。在 structures/parse 中,创建一个新文件,命名为 node_parse.py。在此文件中,输入清单 17-1 中的代码。

import re

from geom2d import Point
from structures.model.node import StrNode

__NODE_REGEX = r'(?P<id>\d+)\s*:\s*' \
               r'\((?P<pos>[\d\s\.,\-]+)\)\s*' \
               r'\((?P<ec>[xy]{0,2})\)'

def parse_node(node_str: str):
 ➊ match = re.match(__NODE_REGEX, node_str)
    if not match:
        raise ValueError(
            f'Cannot parse node from string: {node_str}'
        )

 ➋ _id = int(match.group('id'))
 ➌ [x, y] = [
        float(num)
        for num in match.group('pos').split(',')
    ]
 ➍ ext_const = match.group('ec')

 ➎ return StrNode(
        _id,
        Point(x, y),
        None,
        'x' in ext_const,
        'y' in ext_const
    )

清单 17-1:从字符串解析节点

我们从定义之前看到的正则表达式开始。它需要被拆分成多行,因为单行长度太长,但由于我们使用了续行反斜杠字符(\),Python 会将所有内容读取为单行。

接下来是 parse_node 函数,它接受一个字符串参数作为输入。这个字符串应该按照我们之前定义的节点格式进行格式化。我们在 node_str 字符串中查找与节点正则表达式 ➊ 的匹配项。如果没有匹配项,我们将引发 ValueError 错误,并附带错误字符串,这样更容易调试错误。

然后我们从名为 id 的捕获组中提取 ID,并将其存储在 _id 变量中 ➋。

接下来,我们解析 x 和 y 位置坐标:我们读取 pos 捕获组的内容,并使用逗号字符分割字符串。

match.group('pos').split(',')

这将返回表示节点位置的两个字符串。

使用列表推导式,我们将每个字符串映射为浮点数:

[x, y] = [
    float(num)
    for num in match.group('pos').split(',')
]

然后,我们将结果解构为变量 x 和 y ➌。

最后一个命名捕获组是 ec。它包含了外部约束的定义。我们读取其内容并将其存储在变量 ext_const ➍ 中。最后,我们创建节点实例,传递它所需的所有参数 ➎。我们传递 ID、位置点、None 作为负载(稍后会添加),以及外部约束。外部约束是通过检查约束字符串中是否包含字符 “x” 或 “y” 来添加的。为此,我们使用 Python 的 in 运算符,它检查给定的值是否存在于一个序列中。这里有一个示例:

>>> 'hardcore' in 'hardcore programming for mechanical engineers'
True

>>> 3 in [1, 2]
False

让我们使用一些单元测试来确保我们的代码能够正确解析节点。

测试节点解析器

让我们在 structures/tests 目录下创建一个新的测试文件,命名为 node_parse_test.py。在该文件中,输入 清单 17-2 中的代码。

import unittest

from geom2d import Point
from structures.parse.node_parse import parse_node

class NodeParseTest(unittest.TestCase):
 ➊ node_str = '1 : (25.0, 45.0)   (xy)'
 ➋ node = parse_node(node_str)

    def test_parse_id(self):
        self.assertEqual(1, self.node.id)

    def test_parse_position(self):
        expected = Point(25.0, 45.0)
        self.assertEqual(expected, self.node.position)

    def test_parse_dx_external_constraint(self):
        self.assertTrue(self.node.dx_constrained)

   def test_parse_dy_external_constraint(self):
        self.assertTrue(self.node.dy_constrained)

清单 17-2:测试节点解析

这个文件定义了一个新的测试类:NodeParseTest。我们已经定义了一个具有正确格式的字符串,以便我们可以测试是否能够解析其所有部分。这个字符串是 node_str ➊。我们编写了所有测试,以便与解析该字符串 ➋ 后得到的节点一起使用;我们这样做是为了避免在每个测试中重复相同的解析操作。

然后,我们有一个测试来确保结果节点中的 ID 被正确设置,另一个测试检查节点的位置,还有两个测试用来验证外部约束是否已被添加。

让我们运行测试,确保它们都通过了。你可以通过 IDE 或在 shell 中使用以下命令来完成:

$ python3 -m unittest structures/tests/node_parse_test.py

现在,让我们开始解析条形图。

解析条形图

structures/parse 目录下,创建一个名为 bar_parse.py 的新文件。在该文件中,输入 清单 17-3 中的代码。

import re
from structures.model.bar import StrBar

__BAR_REGEX = r'(?P<id>\d+)\s*:\s*' \
              r'\((?P<start_id>\d+)\s*->\s*(?P<end_id>\d+)\)\s*' \
              r'(?P<sec>[\d\.]+)\s+' \
              r'(?P<young>[\d\.]+)'

def parse_bar(bar_str: str, nodes_dict):
 ➊ match = re.match(__BAR_REGEX, bar_str)
    if not match:
        raise ValueError(
            f'Cannot parse bar from string: {bar_str}'
        )

 ➋ _id = int(match.group('id'))
 ➌ start_id = int(match.group('start_id'))
 ➍ end_id = int(match.group('end_id'))
 ➎ section = float(match.group('sec'))
 ➏ young_mod = float(match.group('young'))

 ➐ start_node = nodes_dict[start_id]
    if start_node is None:
        raise ValueError(f'Node with id: ${start_id} undefined')

    end_node = nodes_dict[end_id]
    if end_node is None:
        raise ValueError(f'Node with id: ${start_id} undefined')

 ➑ return StrBar(_id, start_node, end_node, section, young_mod)

清单 17-3:从字符串解析一个条形图

用于匹配条形图定义的正则表达式 (__BAR_REGEX) 有点长且复杂。确保小心输入它。我们稍后会编写一些单元测试,所以这里的任何错误将在测试中暴露出来。

我们编写了 parse_bar 函数,它接受两个参数:定义条形图的字符串和一个节点字典。在这个字典中,键是节点的 ID,值是节点本身。条形图需要引用其结束节点,因此必须先解析这些节点,然后传递给 parse_bar 函数。这对我们解析结构文件的方式增加了一个约束:节点应该首先出现。

与节点一样,我们首先将传入的字符串与我们的正则表达式 ➊ 进行匹配。如果没有匹配项,我们会引发一个 ValueError,并附上一个有帮助的消息,包含无法解析的字符串。

接下来,我们检索并解析捕获组:id 解析为整数 ➋,start_id ➌ 和 end_id ➍ 解析为整数,sec ➎ 和 young ➏ 解析为浮动数值。

然后,我们在节点字典中查找起始节点 ➐,如果未找到,则抛出错误:我们不能构建没有节点的条形。同样处理终止节点,然后在最后一行 ➑ 创建并返回条形实例,传入所有解析的值。

让我们测试一下这个代码。

测试条形解析器

为了测试条形的解析过程,在 structures/tests 中创建一个名为 bar_parse_test.py 的新文件。将新的测试代码输入到 列表 17-4。

import unittest

from structures.parse.bar_parse import parse_bar

class BarParseTest(unittest.TestCase):
 ➊ bar_str = '1: (3 -> 5) 25.0 20000000.0'
 ➋ nodes_dict = {
        3: 'Node 3',
        5: 'Node 5'
    }
 ➌ bar = parse_bar(bar_str, nodes_dict)

    def test_parse_id(self):
        self.assertEqual(1, self.bar.id)

    def test_parse_start_node(self):
        self.assertEqual('Node 3', self.bar.start_node)

    def test_parse_end_node_id(self):
        self.assertEqual('Node 5', self.bar.end_node)

    def test_parse_section(self):
        self.assertEqual(25.0, self.bar.cross_section)

    def test_parse_young_modulus(self):
        self.assertEqual(20000000.0, self.bar.young_mod)

列表 17-4:测试条形的解析

在这个测试中,我们使用条形的字符串表示法 ➊ 来定义一个条形。parse_bar 函数需要一个包含按 ID 分类的节点字典作为第二个参数;我们创建了一个虚拟字典(回想一下第 16 页 447 中的类型),称为 nodes_dict ➋。这个字典包含两个节点 ID,并将它们映射到一个字符串。我们的解析代码实际上并不会对节点做任何处理,甚至不会检查它们的类型;它只是简单地将它们添加到条形实例中。因此,在测试中,模拟节点的字符串就足够了。

同样,我们首先解析 ➌ 并将结果存储在 bar 变量中。然后我们创建五个测试,检查是否正确解析了 ID、起始节点、终止节点、截面和杨氏模量。

运行测试,确保它们都通过了。你可以从命令行进行测试:

$ python3 -m unittest structures/tests/bar_parse_test.py

最后,我们需要解析负载。

解析负载

我们现在编写一个函数来解析负载字符串,但我们不会在这里将负载应用到节点上。这将在稍后将所有部分结合时进行。

structures/parse 文件夹中创建一个名为 load_parse.py 的新文件。将代码输入到 列表 17-5。

import re

from geom2d import Vector

__LOAD_REGEX = r'(?P<node_id>\d+)\s*->\s*' \
               r'\((?P<vec>[\d\s\.,\-]+)\)'

def parse_load(load_str: str):
 ➊ match = re.match(__LOAD_REGEX, load_str)
    if not match:
        raise ValueError(
            f'Cannot parse load from string: "{load_str}"'
        )

 ➋ node_id = int(match.group('node_id'))
 ➌ [fx, fy] = [
        float(num)
        for num in match.group('vec').split(',')
    ]

 ➍ return node_id, Vector(fx, fy)

列表 17-5:从字符串中解析负载

在这个列表中,我们定义了匹配负载的正则表达式 __LOAD_REGEX。接着是 parse_load 函数,它首先在传入的字符串(load_str)中查找匹配项 ➊。如果字符串不匹配 __LOAD_REGEX,我们会抛出一个错误。

正则表达式定义了两个捕获组:node_idvec。第一个组是需要施加负载的节点的 ID。我们将第一个组的值转换为整数,并将其存储在 node_id 变量中 ➋。

为了提取力的分量,我们拆分由 vec 捕获组匹配到的值,然后解析每个部分,将其转换为浮动值,并使用解构赋值将分量提取到 fxfy 变量中 ➌。

最后,我们返回一个元组,包含节点 ID 和一个带有力的分量的向量 ➍。

让我们测试一下这个逻辑,确保它能正确解析负载。

测试负载解析器

structures/tests 文件夹中创建一个名为 load_parse_test.py 的新文件。将测试代码输入到 列表 17-6。

import unittest

from geom2d import Vector
from structures.parse.load_parse import parse_load

class LoadParseTest(unittest.TestCase):

    load_str = '1 -> (250.0, -3500.0)'
    (node_id, load) = parse_load(load_str)

    def test_parse_node_id(self):
        self.assertEqual(1, self.node_id)

    def test_parse_load_vector(self):
        expected = Vector(250.0, -3500.0)
        self.assertEqual(expected, self.load)

列表 17-6:测试负载的解析

这个测试定义了一个表示施加到 ID 为 1 的节点上的负载的字符串,负载的分量是 ⟨250.0,–3500.0⟩。该字符串存储在 load_str 变量中并传递给 parse_load 函数。

在第一次测试中,我们检查是否正确解析了节点 ID,节点 ID 作为元组的第一个值由函数返回。然后,我们检查是否正确解析了元组的第二个值,即向量。这两个简单的测试足以确保我们的函数能够正常工作。

从 IDE 或命令行运行测试:

$ python3 -m unittest structures/tests/load_parse_test.py

现在我们已经有了可以解析结构各个部分的函数,是时候将它们组合起来了。在接下来的章节中,我们将编写一个函数,读取结构定义文件的所有行,并生成相应的模型。

解析结构

我们的结构文件将每个实体定义在单独的行上,实体按部分分组出现。如果你还记得,我们为需要解析的三个不同实体定义了三个部分:节点、杆件和荷载。以下是之前的结构文件示例:

nodes
1: (0, 0)     (xy)
2: (0, 200)   (xy)
3: (400, 200) ()

loads
3 -> (500, -1000)

bars
1: (1 -> 2) 5 10
2: (2 -> 3) 5 10
3: (1 -> 3) 5 10

因为这些文件大多数是手动编写的,因此如果我们允许包含注释那就太好了:注释行会被解析机制忽略,但能向文件的阅读者解释一些内容,就像代码中的注释一样。

这里是一个示例:

# only node with a load applied
3: (400, 200) ()

我们将借用 Python 的语法,使用 # 符号标记注释的开始。注释必须单独占一行。

概览

由于我们需要编写一些函数,可能有一个带注解的结构解析过程图会更有帮助,可以标注出各个步骤对应的函数名。请看一下图 17-5。

Image

图 17-5:结构解析过程

在这个图示中,我们展示了解析过程的每个步骤。我们从一个结构文件开始,定义了以纯文本形式表示的结构,遵循我们的标准格式。

第一步是将文件内容读取到一个字符串中。我们将在第十九章的应用中实现这一部分。

第二步是将大的字符串拆分成多行。

第三步是将这些行解析成结构原始数据的字典。这一步由私有的 __parse_lines 函数处理。

第四步也是最后一步是将那些解析后的结构项聚合到一个结构实例中。

parse_structure_from_lines 函数是第 3 步和第 4 步的结合:它将一个定义行的列表转化为一个完整的结构。parse_structure 函数更进一步,将单一的字符串拆分成多行。

设置

structures/parse 目录中,创建一个名为 str_parse.py 的新文件。此时 structures 包应该是这样的:

structures

|- model

|    | ...

|- parse

|    |- init.py

|    |- bar_parse.py

|    |- load_parse.py

|    |- node_parse.py

|    |- str_parse.py

|- solution

|    | ...

|- tests

|    | ...

让我们从一个函数开始,该函数判断文件中的一行是否为空白或是注释。这个函数将告诉我们给定的行是否可以忽略,或者是否需要解析。

忽略空行和注释

str_parse.py中,输入 Listing 17-7 中的代码。

__COMMENT_INDICATOR = '#'

def __should_ignore_line(line: str):
    stripped = line.strip()
    return len(stripped) == 0 or \
           stripped.startswith(__COMMENT_INDICATOR)

Listing 17-7:确定需要忽略的行的函数

我们定义了一个常量__COMMENT_INDICATOR,它的值是#字符。如果我们想要更改注释的识别方式,只需编辑这一行。

接下来是__should_ignore_line函数。该函数接收一个字符串并去除两端的空白字符(换句话说,它会去除字符串的空白)。然后,如果该行的长度为零或以注释标识符开头,函数将返回 True 值,否则返回 False。

解析行

现在我们已经有了一种方法来过滤掉不需要解析的行,接下来看看需要解析的行。我们将定义一个接收字符串列表的函数,这些字符串代表行,并识别该行是部分头(“nodes”、“bars”或“loads”)还是实体。如果是部分头,函数将设置一个标志来跟踪当前正在读取的部分。函数的其余部分将负责使用相应的解析器解析每一行。

在文件str_parse.py中,输入 Listing 17-8 中的代码。

import re

from .bar_parse import parse_bar
from .load_parse import parse_load
from .node_parse import parse_node

__COMMENT_INDICATOR = '#'
__NODES_HEADER = 'nodes'
__LOADS_HEADER = 'loads'
__BARS_HEADER = 'bars'

def __parse_lines(lines: [str]):
 ➊ reading = ''
 ➋ result = {'nodes': {}, 'loads': [], 'bars': []}

    for i, line in enumerate(lines):
     ➌ if __should_ignore_line(line):
            continue

        # <--- header ---> #
     ➍ if re.match(__NODES_HEADER, line):
            reading = 'nodes'
        elif re.match(__BARS_HEADER, line):
            reading = 'bars'
        elif re.match(__LOADS_HEADER, line):
            reading = 'loads'

        # <--- definition ---> #
     ➎ elif reading == 'nodes':
            node = parse_node(line)
            result['nodes'][node.id] = node
        elif reading == 'bars':
            bar = parse_bar(line, result['nodes'])
            result['bars'].append(bar)
        elif reading == 'loads':
            load = parse_load(line)
            result['loads'].append(load)
        else:
            raise RuntimeError(
                f'Unknown error in line ${i}: ${line}'
            )

    return result

def __should_ignore_line(line: str):

    --snip--

Listing 17-8:解析行

我们首先添加了三个变量,分别是文件头的名称:__NODES_HEADER__LOADS_HEADER__BARS_HEADER。这些常量定义了各个部分的名称。

接下来是__parse_lines函数的定义,它接收一个参数:结构文件中的行列表。该函数声明了一个名为reading ➊的变量。此变量指示后续循环当前处于哪个结构部分。例如,当它的值为“bars”时,随后的行应该使用parse_bar函数进行解析,直到文件结束或遇到新的部分。

接下来是结果字典的定义。它通过三个键来初始化:'nodes'、'loads'和'bars'。我们将解析后的元素添加到此字典中,按照它们对应的键的集合进行存储。loadsbars存储在列表中,nodes则存储在字典中,键是它们的 ID。我们将节点映射到它们的键存储在字典中,因为在结构文件中,loadsbars都通过 ID 来引用节点;因此,当我们将它们关联时,通过 ID 查找会更方便。

接下来是一个循环,遍历行的枚举。回想一下,Python 的enumerate函数返回一个可迭代序列,其中包含原始对象以及它们的索引。我们只会在遇到错误时使用该索引,借助行号在错误信息中可以更容易地找到输入文件中的错误。我们对每一行的处理第一步是检查它是否为空行或是注释行 ➌,如果是这种情况,我们会使用continue语句跳过该行。

接下来,我们有一些 if-else 语句。它们的第一个块用于匹配头部行 ➍。当找到一个与三种可能的头部之一匹配的行时,我们将 reading 变量设置为该头部的值。后面的 if-else 语句评估 reading,以确定要解析的结构元素 ➎。如果 reading 的值为 'nodes',我们使用 parse_node 函数解析该行,并将结果存储在结果字典的 'nodes' 键下:

result['nodes'][node.id] = node

对于 bars 和 loads 也一样,但请记住,在它们的情况下,它们被存储在一个列表中:

result['bars'].append(bar)

函数随后返回结果字典。

我们实现了一个函数,读取一系列文本行,并将每一行转换为结构类实例(我们称之为解析)。这些实例代表结构中的节点、bars 和 loads。该函数返回一个将这些实例按类型分类的字典。接下来的步骤是使用这些解析后的对象来构造一个 Structure 实例。

拆分行并实例化结构

给定结构文件的内容作为字符串,我们希望将这个字符串拆分成行。我们将这些行传递给之前编写的 __parse_lines 函数,通过解析后的对象,我们可以构造一个 Structure 类的实例。

str_parse.py 文件中,在 __parse_lines 函数之前,输入 示例 17-9 中的代码。

import re

from structures.model.structure import Structure
from .bar_parse import parse_bar
from .load_parse import parse_load
from .node_parse import parse_node

__COMMENT_INDICATOR = '#'
__NODES_HEADER = 'nodes'
__LOADS_HEADER = 'loads'
__BARS_HEADER = 'bars'

def parse_structure(structure_string: str):
 ➊ lines = structure_string.split('\n')
    return parse_structure_from_lines(lines)

def parse_structure_from_lines(lines: [str]):
 ➋ parsed = __parse_lines(lines)
    nodes_dict = parsed['nodes']
    loads = parsed['loads']
    bars = parsed['bars']

 ➌__apply_loads_to_nodes(loads, nodes_dict)

   return Structure(
     ➍ list(nodes_dict.values()),
        bars
    )

def __apply_loads_to_nodes(loads, nodes):
 ➎ for node_id, load in loads:
        nodes[node_id].add_load(load)

--snip--

示例 17-9:拆分行

我们编写了三个新函数。第一个函数 parse_structure 将传入的字符串拆分成行 ➊,并将这些行传递给随后定义的 parse_structure_from_lines 函数。

第二个函数 parse_structure_from_lines 将这些行传递给 __parse_lines,并将结果保存在一个名为 parsed ➋ 的变量中。然后,它将结果字典中的内容提取到以下变量中:nodes_dict、loads 和 bars。

负载与它们应用的节点是分开定义的;因此,我们需要将每个负载添加到其相应的节点 ➌。为此,我们编写了另一个小函数:__apply_loads_to_nodes。回想一下,负载是使用以下格式定义的:

1 -> (500, -1000)

并且由我们的 parse_load 函数解析,作为一个由节点 ID 和负载组件(作为向量)组成的元组:

(1, Vector(500, -1000))

记住这一点对于理解 __apply_loads_to_nodes ➎ 中的循环非常重要。该循环遍历负载元组,在每次迭代中,它分别将节点 ID 和负载向量存储到 node_idload 变量中。因为我们的节点存储在一个字典中,其键是节点 ID,所以应用负载非常简单。

一旦加载操作应用到节点(在 parse_structure_from_lines 中),最后一步是返回一个 Structure 类的实例。该类的构造函数需要一个节点列表和一个条形码列表。条形码已经作为列表解析,但节点是以字典的形式存在。为了将字典的值转换为列表,我们只需要使用 Python 的 list 函数处理字典的值,这些值通过 values()方法提取 ➍。

到此为止,我们的解析逻辑已经准备好了!

结果

供您参考,列表 17-10 展示了str_parse.py的完整代码。

import re

from structures.model.structure import Structure
from .bar_parse import parse_bar
from .load_parse import parse_load
from .node_parse import parse_node

__COMMENT_INDICATOR = '#'
__NODES_HEADER = 'nodes'
__LOADS_HEADER = 'loads'
__BARS_HEADER = 'bars'

def parse_structure(structure_string: str):
    lines = structure_string.split('\n')
    return parse_structure_from_lines(lines)

def parse_structure_from_lines(lines: [str]):
    parsed = __parse_lines(lines)
    nodes_dict = parsed['nodes']
    loads = parsed['loads']
    bars = parsed['bars']

    __apply_loads_to_nodes(loads, nodes_dict)

    return Structure(
        list(nodes_dict.values()),
        bars
    )

def __apply_loads_to_nodes(loads, nodes):
    for node_id, load in loads:
        nodes[node_id].add_load(load)

def __parse_lines(lines: [str]):
    reading = ''
    result = {'nodes': {}, 'loads': [], 'bars': []}

    for i, line in enumerate(lines):
        if __should_ignore_line(line):
            continue

        # <--- header ---> #
        if re.match(__NODES_HEADER, line):
            reading = 'nodes'
        elif re.match(__BARS_HEADER, line):
            reading = 'bars'
        elif re.match(__LOADS_HEADER, line):
            reading = 'loads'

        # <--- definition ---> #
        elif reading == 'nodes':
            node = parse_node(line)
            result['nodes'][node.id] = node
        elif reading == 'bars':
            bar = parse_bar(line, result['nodes'])
            result['bars'].append(bar)
        elif reading == 'loads':
            load = parse_load(line)
            result['loads'].append(load)
        else:
            raise RuntimeError(
                f'Unknown error in line ${i}: ${line}'
            )

    return result

def __should_ignore_line(line: str):
    stripped = line.strip()
    return len(stripped) == 0 or \
           stripped.startswith(__COMMENT_INDICATOR)

列表 17-10:解析结构

在进入下一部分之前,打开init.py文件,位于parse目录中,并输入以下导入语句:

from .str_parse import parse_structure

这允许我们像这样导入 parse_structure 函数,

from structures.parse import parse_structure

而不是这个稍微长一点的版本:

from structures.parse.str_parse import parse_structure

让我们通过实现一些自动化测试来确保我们的解析函数正常工作。

测试结构解析器

为确保 parse_structure 函数按预期工作,我们现在将添加一些单元测试。首先,我们需要创建一个结构定义文件用于测试。在structures/tests目录中,创建一个新文件,命名为test_str.txt,其内容如下:

# Nodes
nodes
1: (0.0, 0.0)      (xy)
2: (200.0, 150.0)  ()
3: (400.0, 0.0)    (y)

# Loads
loads
2 -> (2500.0, -3500.0)

# Bars
bars
1: (1 -> 2) 25 20000000
2: (2 -> 3) 25 20000000
3: (1 -> 3) 25 20000000

我们已经添加了注释行和一些额外的空行;我们的函数应该忽略这些。创建一个新的测试文件:str_parse_test.py(列表 17-11)。

import unittest

import pkg_resources as res

from structures.parse import parse_structure

class StructureParseTest(unittest.TestCase):

    def setUp(self):
        str_bytes = res.resource_string(__name__, 'test_str.txt')
        str_string = str_bytes.decode("utf-8")
        self.structure = parse_structure(str_string)

列表 17-11:设置结构解析测试

该文件定义了一个新的测试类:StructureParseTest。在 setUp 方法中,我们使用 resource_string 函数将test_str.txt文件加载为字节。然后,我们将这些字节解码为 UTF-8 编码的 Python 字符串。最后,使用 parse_structure 解析结构字符串,并将结果存储在类的一个属性中:self.structure。

测试节点解析器

让我们添加一些测试用例,确保从test_str.txt文件解析的结构包含预期的节点。在 setUp 方法之后,输入第一个测试(列表 17-12)。

import unittest

import pkg_resources as res

from geom2d import Point
from structures.parse import parse_structure

class StructureParseTest(unittest.TestCase):
    --snip--

    def test_parse_nodes_count(self):
        self.assertEqual(3, self.structure.nodes_count)

    def test_parse_nodes(self):
     ➊ nodes = self.structure._Structure__nodes
        self.assertEqual(
            Point(0, 0),
            nodes[0].position
        )
        self.assertEqual(
            Point(200, 150),
            nodes[1].position
        )
        self.assertEqual(
            Point(400, 0),
            nodes[2].position
        )

    def test_parse_node_constraints(self):
        nodes = self.structure._Structure__nodes

        self.assertTrue(nodes[0].dx_constrained)
        self.assertTrue(nodes[0].dy_constrained)

        self.assertFalse(nodes[1].dx_constrained)
        self.assertFalse(nodes[1].dy_constrained)

        self.assertFalse(nodes[2].dx_constrained)
        self.assertTrue(nodes[2].dy_constrained)

列表 17-12:测试结构解析:节点

我们写了三个测试。第一个测试检查结构中是否有三个节点。下一个测试确保这三个节点的位置正确。

有一点值得注意。由于 __nodes 列表是 Structure 类的私有属性,Python 通过一个技巧来试图隐藏它。Python 会在私有属性的名称前加上一个下划线和类名。因此,__nodes 属性将被称为 _Structure__nodes,而不是我们预期的 __nodes。这就是为什么在我们的测试中,要通过这个名称来访问它 ➊。

第三个也是最后一个测试检查节点中的外部约束是否具有结构定义文件中定义的正确值。让我们运行测试。您可以在 IDE 中点击绿色播放按钮,或者使用终端:

$ python3 -m unittest structures/tests/str_parse_test.py

应该在终端中显示一条成功消息。

测试条形码解析器

现在,让我们测试杆件是否也被正确解析。在我们刚刚编写的测试用例之后,输入列表 17-13 中的测试用例。

class StructureParseTest(unittest.TestCase):
    --snip--

    def test_parse_bars_count(self):
        self.assertEqual(3, self.structure.bars_count)

    def test_parse_bars(self):
        bars = self.structure._Structure__bars

        self.assertEqual(1, bars[0].start_node.id)
        self.assertEqual(2, bars[0].end_node.id)

        self.assertEqual(2, bars[1].start_node.id)
        self.assertEqual(3, bars[1].end_node.id)

        self.assertEqual(1, bars[2].start_node.id)
        self.assertEqual(3, bars[2].end_node.id)

列表 17-13:测试结构解析:杆件

第一个测试断言结构中有三根杆件。第二个测试检查结构中的每根杆件是否与正确的节点 ID 关联。和以前一样,要访问私有的杆件列表,我们需要在属性名之前加上 _Structure:_Structure__bars。

我邀请你添加两个额外的测试,检查横截面和杨氏模量的值是否正确地解析到杆件中。由于篇幅原因,我们在此不包括这些测试。

再次运行测试类,以确保我们的新测试也通过。在终端中运行以下命令:

$ python3 -m unittest structures/tests/str_parse_test.py
测试荷载解析器

让我们添加最后两个测试,确保荷载被正确解析。输入列表 17-14 中的代码。

import unittest

import pkg_resources as res

from geom2d import Point, Vector
from structures.parse import parse_structure

class StructureParseTest(unittest.TestCase):
    --snip--

    def test_parse_loads_count(self):
        self.assertEqual(1, self.structure.loads_count)

    def test_apply_load_to_node(self):
        node = self.structure._Structure__nodes[1]
        self.assertEqual(
            Vector(2500, -3500),
            node.net_load
        )

列表 17-14:测试结构解析:荷载

在这两个最后的测试中,我们检查结构中的荷载数量是否为 1,并且它是否正确地应用到第二个节点。

让我们运行所有测试,确保所有测试都通过:

$ python3 -m unittest structures/tests/str_parse_test.py

如果你的代码实现良好,所有测试应该都通过,并且你应该在终端看到以下内容:

Ran 7 tests in 0.033s

OK
测试类结果

我们已经做了一些测试,因此列表 17-15 展示了结果测试类,供您参考。

import unittest

import pkg_resources as res

from geom2d import Point, Vector
from structures.parse import parse_structure

class StructureParseTest(unittest.TestCase):

    def setUp(self):
        str_bytes = res.resource_string(__name__, 'test_str.txt')
        str_string = str_bytes.decode("utf-8")
        self.structure = parse_structure(str_string)

    def test_parse_nodes_count(self):
        self.assertEqual(3, self.structure.nodes_count)

    def test_parse_nodes(self):
        nodes = self.structure._Structure__nodes
        self.assertEqual(
            Point(0, 0),
            nodes[0].position
        )
        self.assertEqual(
            Point(200, 150),
            nodes[1].position
        )
        self.assertEqual(
            Point(400, 0),
            nodes[2].position
        )

    def test_parse_node_constraints(self):
        nodes = self.structure._Structure__nodes

        self.assertTrue(nodes[0].dx_constrained)
        self.assertTrue(nodes[0].dy_constrained)

        self.assertFalse(nodes[1].dx_constrained)
        self.assertFalse(nodes[1].dy_constrained)

        self.assertFalse(nodes[2].dx_constrained)
        self.assertTrue(nodes[2].dy_constrained)

    def test_parse_bars_count(self):
        self.assertEqual(3, self.structure.bars_count)

    def test_parse_bars(self):
        bars = self.structure._Structure__bars

        self.assertEqual(1, bars[0].start_node.id)
        self.assertEqual(2, bars[0].end_node.id)

        self.assertEqual(2, bars[1].start_node.id)
        self.assertEqual(3, bars[1].end_node.id)

        self.assertEqual(1, bars[2].start_node.id)
        self.assertEqual(3, bars[2].end_node.id)

    def test_parse_loads_count(self):
        self.assertEqual(1, self.structure.loads_count)

    def test_apply_load_to_node(self):
        node = self.structure._Structure__nodes[1]
        self.assertEqual(
            Vector(2500, -3500),
            node.net_load
        )

列表 17-15:测试结构解析

我们的结构解析逻辑已经准备好并通过测试!

总结

在本章中,我们首先定义了结构文件的格式。这是一个简单的纯文本格式,可以手动编写。

然后,我们实现了函数,将结构文件中的每一行解析为相应的结构元素:节点、荷载和杆件。正则表达式是本次工作的明星;有了它们,解析结构化文本变得轻而易举。

最后,我们将所有内容合并到一个函数中,该函数将大字符串分割成各行,并决定每一行使用哪个解析器。我们将使用这个函数来读取结构文件,并创建我们的桁架解法应用程序将使用的结构模型。

现在是时候开始生成结构解法的输出图表了。这正是我们将在下一章中做的事情。

第十八章:生成 SVG 图像和文本文件

Image

当我们解决其中一个桁架结构时,我们会用解法值构建一个新模型。如果我们想查看每根杆件的应力或每个节点的位移,我们需要生成一些带有这些信息的输出。图表是一种很好的方式来显示工程计算的结果,但我们也可能需要一个包含详细值的文本文件。

在本章中,我们将为我们的结构分析应用编写一个模块,该模块生成包含所有相关数据的矢量图像和更简洁的结构解法文本表示。

设置

我们在structures中添加一个名为out的新包;这个包将包含所有解法输出代码。你的structures包目录现在应该如下所示:

structures

|- generation

|- model

|- out

|- parse

|- solution

|- tests

我们将首先实现一个从结构解法生成 SVG 图像的函数。让我们创建一个新的 Python 文件,命名为svg.py,并再创建一个名为config.json的文件,用于存储绘图的配置。你的out目录现在应该包含以下文件:

structures

|- out

|- init.py

|- config.json

|- svg.py

像往常一样,如果你没有使用 IDE,请记得包含一个init.py文件。

从结构解法到 SVG

当我们的输出代码完成后,它应该能够生成像图 18-1 中那样的图表。尽管在书籍的打印版中看不到,但压缩条是红色的,拉伸条是绿色的,外力是黄色的,而反应力用紫色表示。

Image

图 18-1:桁架结果图

这张图像是通过我们将在本章接下来一起编写的代码生成的。

配置文件

当你的代码准备好并且能够正常工作时,你可能希望调整图表的颜色和大小,直到得到令你满意的结果。我们希望能够自由地更改这些颜色,而不需要查看我们的代码,因此我们将它们移动到一个单独的配置文件中,就像我们在第九章和第十二章中已经做过的一样。事实上,我们想调整的任何参数都可以放在配置文件中。我们将在配置文件中包括节点的半径、节点的描边宽度、图像的边距等内容。

图 18-2 展示了我们希望能够配置的部分属性以及我们将赋给它们的值。颜色使用以 # 开头的十六进制值表示。

Image

图 18-2:输出配置值

打开我们刚才创建的config.json文件,并在清单 18-1 中输入配置值。

{
    "sizes": {
        "margin": 170,
        "node_radius": 5,
        "stroke": 4,
        "arrow": 14
    },
    "colors": {
        "node_stroke": "#354595",
        "back": "#FFFFFF",
        "traction": "#005005",
        "compression": "#BB2727",
        "original": "#D5DBF8",
        "load": "#FFC046",
        "reaction": "#4A0072"
    },
    "font": {
        "family": "sans-serif",
        "size": 14
    }
}

清单 18-1:我们输出图像的默认配置

这些配置值是我们在没有提供其他值时使用的默认值。你可以通过使用不同的颜色、大小或文本字体来个性化你的应用程序图表。

为此,我们需要一种方法,将配置 JSON 文件读取到我们的主svg.py脚本中。让我们编写一个函数来实现这一点。在svg.py中,输入清单 18-2 中的代码。

import json

import pkg_resources as res

def __read_config():
    config = res.resource_string(__name__, 'config.json')
    return json.loads(config)

清单 18-2:读取配置 JSON 文件

__read_config 函数使用来自 pkg_resources 包(Python 标准库中的一个包)中的 resource_string 来将我们的config.json文件加载为字符串。然后,我们使用 json.loads 将字符串解析为字典。稍后我们会使用这个函数。

现在,让我们看看如何允许用户将一些参数传递给应用程序,这些参数将修改结果图表的绘制方式。

设置

我们有一个配置,它包含决定图表外观的值。这些值由应用程序定义,用户无需担心它们。我们允许用户将一个配置字典传递给应用程序,使用它来覆盖默认配置中的值。

除了配置外,我们的应用程序还需要一些其他值来绘制给定结构的解答图。这些值包括绘制几何形状和载荷时使用的比例。例如。我们无法提前猜测这些值,因此我们需要用户将它们提供给应用程序。

我们将这些一次性使用的值称为设置。我们将向函数传递一个设置字典,但这些设置不会有默认值,因为没有合适的默认值可以在这里使用;它们完全依赖于正在计算的结构以及用户希望结果的外观。用户是否希望夸大变形?还是希望看到没有比例尺的变形,以便了解变形结构的实际外观?我们无法自行猜测这些,因此,我们将让应用程序的用户来决定这些值。

我们已将所有希望提供给用户的设置列出在表 18-1 中。

表 18-1: 输出设置

名称 类型 用途
scale 数字 改变结果绘图的比例
disp_scale 数字 改变节点位移的比例
load_scale 数字 改变载荷表示的比例
no_draw_original 布尔值 指定是否绘制原始几何形状

让我们编写一个函数,验证字典中是否包含所有这些设置的值。在你的svg.py文件中,输入清单 18-3 中的代码。

--snip--

__expected_settings = (
    # scale applied to the diagram
    'scale',
    # scale applied to the node displacements
    'disp_scale',
    # scale applied to the load vectors
    'load_scale',
    # boolean to decide whether to draw the original geometry
    'no_draw_original'
)

def __validate_settings(settings):
    for setting in __expected_settings:
        if setting not in settings:
            raise ValueError(f'"{setting}" missing in settings')

清单 18-3:验证设置字典

这个 __validate_settings 函数确保所有预期的设置都在设置字典中。如果缺少任何设置,它将向用户抛出带有信息的错误消息。现在,让我们编写生成 SVG 图像的函数。

解决方案绘制功能

svg.py文件中,在 __read_config 函数之前,输入清单 18-4 中的代码。

import json

import pkg_resources as res

from geom2d import AffineTransform
from graphic import svg
from structures.solution.structure import StructureSolution

def structure_solution_to_svg(
        result: StructureSolution,
        settings,
        _config=None,
):
    __validate_settings(settings)
    default_config = __read_config()

 ➊ config = {**default_config, **(_config or {})}

 ➋ viewbox = result.bounds_rect(
        config['sizes']['margin'],
        settings.scale
    )
    transform = AffineTransform(sx=1, sy=-1, tx=0, ty=0)

 ➌ return svg.svg_content(
        size=viewbox.size,
        primitives=[],
        viewbox_rect=viewbox,
        transform=transform

   )

--snip--

清单 18-4:结构解决方案到 SVG 函数

我们定义了 structure_solution_to_svg 函数,但它还没有绘制任何内容;它只是生成一个空的 SVG 图像。该函数接收三个参数:结构解决方案(一个 StructureSolution 类实例)、设置字典和配置字典。配置字典是可选的,因此我们为其提供了默认值 None。

在该函数中,我们首先使用前一节中编写的函数来验证传入的设置。如果验证失败,我们将抛出一个错误并停止执行该函数。

接下来,我们使用 __read_config 函数加载默认配置。

下一步是将传入的配置字典与默认字典合并➊。字典的合并使用 Python 的字典解包操作符:。如果 a 和 b 是字典,使用{a, **b}将创建一个包含 a 和 b 中所有条目的新字典。如果有一个键在两个字典中都存在,那么保留的是 b 中的版本,即第二个字典中的版本。因此,在我们的使用中,如果用户提供了配置值,这将覆盖默认值。我们将合并后的配置字典存储在 config 变量中。

注意

字典解包操作符在 Python 3.5 版本中添加。你可以在 PEP-448 中阅读更多内容: www.python.org/dev/peps/pep-0448。PEP 代表“Python 增强提案”。这是 Python 社区编写的文档,用于提议语言的新特性等。

接下来,我们使用结构解决方案的边界矩形➋来计算 SVG 图像的视图框。如果你还记得,StructureSolution 类的 bounds_rect 方法的第一个参数是边界的边距,第二个是缩放。我们从配置中获取边距值,从设置中获取缩放值。

然后,我们创建一个仿射变换,用来翻转图像的 y 轴,使其指向上方。

最后,我们使用来自 svg 包的 svg_content 创建并返回 SVG 图像➌。图像的大小由视图框的大小决定;目前,原语列表为空。在接下来的章节中,我们将用表示节点、杆件和载荷的 SVG 原语填充这个列表。首先,让我们看看标题。

标题

我们将在几个地方使用标题:标注杆件的应力、编号节点和给出力的坐标。定位这些标题会有点棘手,因为我们希望旋转它们,使其与它们所标注的元素对齐,正如你在图 18-3 中看到的那样。

图片

图 18-3:我们图中的标题

此外,由于我们对 SVG 图像应用了一个翻转 y 轴的仿射变换,因此我们添加的标题也会被翻转。如果我们不撤销这个翻转,它们将无法阅读。我们将通过缩放标题,使其 y 轴被翻转回去来修正这一点。

structures/out中创建一个新的 Python 文件,名为captions_svg.py。你的out目录应该如下所示:

out

|- init.py

|- captions_svg.py

|- svg.py

在这个新文件中,输入 Listing 18-5 中的代码。

from geom2d import Point, Vector, make_rotation, make_scale
from graphic import svg
from graphic.svg import attributes

def caption_to_svg(
        caption: str,
        position: Point,
        angle: float,
        color: str,
        config
):
 ➊ font = config['font']['family']
    size = config['font']['size']

    rotation = make_rotation(angle, position)
    scale = make_scale(1, -1, position)
 ➋ transform = rotation.then(scale)

 ➌ return svg.text(
        caption,
        position,
        Vector(0, 0),
        [
            attributes.fill_color(color),
            attributes.affine_transform(transform),
            attributes.font_family(font),
            attributes.font_size(size)
        ]
    )

Listing 18-5: 从标题到 SVG

我们实现了一个名为 caption_to_svg 的函数。这个函数有五个参数:标题文本、标题所在的点、旋转角度、颜色和配置字典。

我们将从配置字典中提取字体系列和字体大小。前两行将这些值分别保存到 font 和 size 变量中➊。

接下来,我们计算一个仿射变换来缩放和旋转标题。我们首先使用 make_rotation 函数生成旋转变换,然后使用 make_scale 函数生成缩放变换;最后,这些变换被合并成一个单一的变换➋。请注意,这两个变换都是相对于标题位置点进行的(见 Figure 18-4)。这一点非常关键。如果我们围绕全局原点(⟨0, 0⟩点)缩放和旋转标题,标题将在图形中出现在一个意想不到的位置。

Image

Figure 18-4: 标题旋转

最后,我们使用 svg.text 函数创建 SVG 文本元素,传递给它标题、中心点、零位移向量以及属性列表➌。在这些属性中,我们包括填充颜色、变换、字体系列和字体大小。

条形图

现在,让我们继续生成 SVG 代码,以绘制原始和变形后的条形几何形状。条形是直线,因此表示它们不会太复杂。在out目录下,创建一个名为bar_svg.py的新文件。你的out目录应该如下所示:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- svg.py

如我们所知,原始和变形后的条形几何形状都是直线。我们将首先编写一个辅助函数,生成表示条形的 SVG 段落,无论是在原始状态还是变形状态。在文件中,输入 Listing 18-6 中的代码。

from math import sqrt

from graphic import svg
from graphic.svg import attributes

def __bar_svg(geometry, color, cross_section):
 ➊ section_height = sqrt(cross_section)
 ➋ return svg.segment(
        geometry,
        [
            attributes.stroke_color(color),
            attributes.stroke_width(section_height)
        ]
    )

Listing 18-6: 单条形到 SVG

我们已经编写了 __bar_svg 函数,用来根据传入的几何形状生成一个 SVG 段落,该几何形状应为我们的 Segment 类的实例;我们还传入了要使用的颜色和条形的截面。

为什么我们需要横截面值?我们将使用一个大致代表条形横截面的线条厚度,这样横截面较大的条形将用较粗的线条绘制。图 18-5 展示了我们的近似:我们将线条厚度计算为横截面正方形一边的长度。

Image

图 18-5:根据横截面计算线条厚度

在 section_height 变量中,我们将条形的高度存储为其横截面为正方形的情况 ➊。这个值是通过条形横截面的平方根计算得出的。

最后,我们使用传入的几何图形返回一个 SVG 片段,并添加两个属性:描边颜色和我们计算出的线条厚度 ➋。

让我们继续并编写 bars_to_svg 函数的第一个版本。在你的文件中,在我们刚刚编写的 __bar_svg 函数之前,输入 列表 18-7 中的代码。

from math import sqrt

from graphic import svg
from graphic.svg import attributes
from structures.solution.bar import StrBarSolution

def bars_to_svg(bars: [StrBarSolution], settings, config):
    should_draw_original = not settings.no_draw_original
 ➊ original, final, stresses = [], [], []

    for bar in bars:
     ➋ if should_draw_original:
            original.append(original_bar_to_svg(bar))
     ➌ final.append(bar_to_svg(bar))
     ➍ stresses.append(bar_stress_to_svg(bar))

    # Ordering is important to preserve z-depth
 ➎ return original + final + stresses

def __bar_svg(geometry, color, cross_section):
    --snip--

列表 18-7:条形转 SVG

在这个列表中,我们只是概述了生成表示条形的 SVG 原语的主要算法。虽然有三个函数负责大部分工作,但我们还没有编写它们:original_bar_to_svg、bar_to_svg 和 bar_stress_to_svg。我们稍后会编写这些函数。

我们的 bars_to_svg 函数首先保存 no_draw_original 设置的取反值,保存在 should_draw_original 变量中。如果 should_draw_original 为真,我们的函数也会包括表示原始条形的片段。

接下来,我们声明三个空列表:original、final 和 stresses ➊。第一个列表 original 存储表示原始条形的片段;第二个列表 final 存储最终的解决方案条形;最后一个列表 stresses 存储应力标签。我们将把所有生成的 SVG 原语放入这些列表中。

然后我们开始迭代条形。对于每个条形,如果 should_draw_original 为真,我们将 original_bar_to_svg 的结果添加到 original 列表 ➋;original_bar_to_svg 是我们尚未编写的函数,它生成表示原始条形的 SVG 片段。我们将表示解决方案条形的 SVG 添加到 final 列表 ➌,将应力标签添加到 stresses 列表 ➍。

循环之后,三个列表将填充表示原始和解决方案结构条形的 SVG 原语。我们将这些列表连接并返回 ➎。正如代码中的注释所指出的,这里顺序很重要:最后出现在列表中的元素将绘制在其余元素之上。我们希望原始条形位于解决方案条形的下方,因此它们需要首先出现在列表中。你可以想象这些条形是按层分布的,如 图 18-6 所示。

Image

图 18-6:按层绘制条形 SVG

让我们编写生成 SVG 原语所用的三个函数。

绘制原始条形图

对于这些函数,我们将使用我们在“函数内部函数”章节中探讨的一种技术,位于第 28 页。我们将在 bars_to_svg 函数内部定义它们,这样它们就可以访问传递给 bars_to_svg 的参数。这样我们就不需要传递配置和设置字典。最终的内部函数将具有更短的参数列表,使它们更加简洁。由于这些函数实际上保持在 bars_to_svg 内部,因此只有宿主函数可以访问它们。

首先让我们编写 original_bar_to_svg 函数。在你的文件中,输入第 18-8 节中的缺失代码。

from math import sqrt

from graphic import svg
from graphic.svg import attributes
from structures.solution.bar import StrBarSolution

def bars_to_svg(bars: [StrBarSolution], settings, config):
    def original_bar_to_svg(_bar: StrBarSolution):
     ➊ color = config['colors']['original']
     ➋ return __bar_svg(
            _bar.original_geometry,
            color,
            _bar.cross_section
        )

    --snip--

    # Ordering is important to preserve z-depth
    return original + final + stresses

def __bar_svg(geometry, color, cross_section):
    --snip--

第 18-8 节:原始(非解法)条形到 SVG

我们已将 original_bar_to_svg 函数写在 bars_to_svg 函数的开头。这个函数只需要一个参数:来自解法结构的条形(类型为 StrBarSolution),该条形包含在其 original_geometry 属性中的原始条形。

首先,我们从配置字典中提取原始条形的颜色 ➊。然后,我们返回调用 __bar_svg 函数的结果,传入原始条形的几何形状、颜色和条形的横截面 ➋。

绘制解法条形

现在让我们编写代码来绘制解法条形。根据应力是压缩还是拉伸,它们将有不同的颜色。在 bars_to_svg 函数中,在我们刚刚编写的 original_bar_to_svg 函数之后,输入第 18-9 节中的缺失代码。

from math import sqrt

from graphic import svg
from graphic.svg import attributes
from structures.solution.bar import StrBarSolution

def bars_to_svg(bars: [StrBarSolution], settings, config):
    def original_bar_to_svg(_bar: StrBarSolution):
        --snip--

    def bar_to_svg(_bar: StrBarSolution):
        return __bar_svg(
         ➊ _bar.final_geometry_scaling_displacement(
                settings.disp_scale
            ),
         ➋ bar_color(_bar),
         ➌ _bar.cross_section
        )

    def bar_color(_bar: StrBarSolution):
        if _bar.stress >= 0:
            return config['colors']['traction']
        else:
            return config['colors']['compression']

    --snip--

    # Ordering is important to preserve z-depth
    return original + final + stresses

def __bar_svg(geometry, color, cross_section):
    --snip--

第 18-9 节:解法条形到 SVG

bar_to_svg 函数返回调用 __bar_svg 的结果,第一个参数是已位移的条形,使用我们在 StrBarSolution 类中实现的 final_geometry_scaling_displacement 方法计算 ➊。第二个参数是颜色,我们使用在代码中稍后实现的另一个函数 bar_color 计算 ➋。第三个也是最后一个参数是条形的横截面 ➌。

bar_color 函数根据条形应力的符号从配置字典中返回正确的颜色。请注意,再次强调,我们不需要将配置字典传递给此函数。因为我们处于 bars_to_svg 函数内部,已经可以访问它。

绘制应力标注

最后,我们需要绘制应力标注。这些标注在绘图中定位有点棘手,但我们在 caption_to_svg 函数中已经解决了最难的部分。

输入第 18-10 节中的缺失代码。

from math import sqrt

from geom2d import Vector
from graphic import svg
from graphic.svg import attributes
from structures.solution.bar import StrBarSolution
from .captions_svg import caption_to_svg

__I_VERSOR = Vector(1, 0)
__STRESS_DISP = 10
__DECIMAL_POS = 4

def bars_to_svg(bars: [StrBarSolution], settings, config):
    def original_bar_to_svg(_bar: StrBarSolution):
        --snip--

    def bar_to_svg(_bar: StrBarSolution):
        --snip--

    def bar_stress_to_svg(_bar: StrBarSolution):
     ➊ geometry = _bar.final_geometry_scaling_displacement(
           settings.disp_scale
        )
     normal = geometry.normal_versor
     ➋ position = geometry.middle.displaced(normal, __STRESS_DISP)
     ➌ angle = geometry.direction_versor.angle_to(__I_VERSOR)

     ➍ return caption_to_svg(
           f'σ = {round(_bar.stress, __DECIMAL_POS)}',
           position,
           angle,
           bar_color(_bar),
           config
        )

   def bar_color(_bar: StrBarSolution):
       --snip--

    --snip--

    # Ordering is important to preserve z-depth
    return original + final + stresses

def __bar_svg(geometry, color, cross_section):
    --snip--

第 18-10 节:条形应力到 SVG

我们从 geom2d 导入 Vector 和我们在本章前面实现的 caption_to_svg 函数。然后,我们声明三个常量:

  • __I_VERSOR 是表示水平方向的 î 向量。

  • __STRESS_DISP 是我们用来将标注与条形几何形状分开的距离。

  • __DECIMAL_POS 是我们用于格式化应力值的小数位数。

然后是 bar_stress_to_svg 函数的实现。在这个函数中,我们首先要做的事情是计算我们要添加标题的条形图的几何形状,且该几何形状与图纸本身的比例完全一致 ➊。我们希望标题与条形图的绘制图形对齐,因此我们需要其几何形状作为参考。

接下来,我们计算条形图的几何法线方向向量;我们需要这个方向来计算标题的位置。然后,我们通过将条形图的中点沿法线方向向量移动一个等于 __STRESS_DISP ➋的量,来计算标题的原点,即位置。此过程如 Figure 18-7 所示。

Image

Figure 18-7: 条形图标题的位置

我们还需要条形图与î方向向量之间的角度 ➌;这个角度将用于旋转标题,使其与条形图对齐。

现在我们已经有了中心点和旋转角度,我们只需调用 caption_to_svg 函数,并将这些值作为参数传递,返回结果 ➍。对于标题的文本,我们使用希腊字母σ(西格玛),通常用于表示机械应力,后面跟上条形图的应力值,四舍五入到四位小数。

最后,请注意标签颜色与条形图相同,因此我们从 bar_color 函数中获取它。

结果

在我们编写的所有代码之后,你的bar_svg.py文件应该像 Listing 18-11 所示。

from math import sqrt

from geom2d import Vector
from graphic import svg
from graphic.svg import attributes
from structures.solution.bar import StrBarSolution
from .captions_svg import caption_to_svg

__I_VERSOR = Vector(1, 0)
__STRESS_DISP = 10
__DECIMAL_POS = 4

def bars_to_svg(bars: [StrBarSolution], settings, config):
    def original_bar_to_svg(_bar: StrBarSolution):
        color = config['colors']['original']
        return __bar_svg(
            _bar.original_geometry,
            color,
            _bar.cross_section
        )

    def bar_to_svg(_bar: StrBarSolution):
        return __bar_svg(
            _bar.final_geometry_scaling_displacement(
                settings.disp_scale
            ),
            bar_color(_bar),
            _bar.cross_section
        )

    def bar_stress_to_svg(_bar: StrBarSolution):
        geometry = _bar.final_geometry_scaling_displacement(
            settings.disp_scale
        )
        normal = geometry.normal_versor
        position = geometry.middle.displaced(normal, __STRESS_DISP)
        angle = geometry.direction_versor.angle_to(__I_VERSOR)

        return caption_to_svg(
            f  ' = {round(_bar.stress, __DECIMAL_POS)}',
            position,
            angle,
            bar_color(_bar),
            config
        )

    def bar_color(_bar: StrBarSolution):
        if _bar.stress >= 0:
            return config['colors']['traction']
        else:
            return config['colors']['compression']

    should_draw_original = not settings.no_draw_original
    original, final, stresses = [], [], []

    for bar in bars:
        if should_draw_original:
            original.append(original_bar_to_svg(bar))
        final.append(bar_to_svg(bar))
        stresses.append(bar_stress_to_svg(bar))

    # Ordering is important to preserve z-depth
    return original + final + stresses

def __bar_svg(geometry, color, cross_section):
    section_height = sqrt(cross_section)
    return svg.segment(
        geometry,
        [
            attributes.stroke_color(color),
            attributes.stroke_width(section_height)
        ]
    )

Listing 18-11: Bar 转为 SVG 结果

确保你的代码与 Listing 18-11 相同,因为我们在本章不会编写单元测试。为我们的 SVG 生成函数编写测试是个不错的主意,因为这里有很多逻辑。然而,为了保持本章的合理长度,我们不会这样做。

现在是节点的部分。

节点

out目录下,创建一个名为node_svg.py的新文件:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- node_svg.py

|- svg.py

在此文件中,输入 Listing 18-12 中的代码。

from geom2d import Circle, Vector
from graphic import svg
from graphic.svg import attributes
from structures.solution.node import StrNodeSolution
from .captions_svg import caption_to_svg

def nodes_to_svg(nodes: [StrNodeSolution], settings, config):
 ➊ def node_to_svg(node: StrNodeSolution):
        radius = config['sizes']['node_radius']
        stroke_size = config['sizes']['stroke']
        stroke_color = config['colors']['node_stroke']
        fill_color = config['colors']['back']

     ➋ position = node.displaced_pos_scaled(settings.disp_scale)
     ➌ caption_pos = position.displaced(Vector(radius, radius))

        return svg.group([
         ➍ svg.circle(
                Circle(position, radius),
                [
                    attributes.stroke_width(stroke_size),
                    attributes.stroke_color(stroke_color),
                    attributes.fill_color(fill_color)
                ]
            ),
         ➎ caption_to_svg(
                f'{node.id}', caption_pos, 0, stroke_color, config

            )
        ])

 ➏ return [
        node_to_svg(node)
        for node in nodes
    ]

Listing 18-12: Node 转为 SVG

我们首先导入一些内容—确保你全部导入了。然后,我们定义 nodes_to_svg 函数,输入参数为 StrNodeSolution 实例列表以及 settings 和 config 字典。该函数将 nodes 列表中的每个节点映射到其 SVG 表示形式,这通过调用一个内部函数 node_to_svg ➏来实现。映射是通过列表推导完成的。

node_to_svg 内部函数操作单个节点,并且可以访问主函数的参数 ➊。它首先做的事情是将一些配置参数保存到变量中。

接下来,我们计算节点的位移位置 ➋以及标题的位置,该位置将是节点的 ID ➌。标题的位置是通过将节点的位置沿水平方向和垂直方向各移动一个等于其半径的量来获得的。Figure 18-8 展示了这一过程。

Image

图 18-8:节点标题定位

node_to_svg 函数返回一个 SVG 组,包含表示节点本身的圆形 ➍ 和标题 ➎。

我们的节点已经准备好了!让我们添加它们的外部反应力。

节点反应

我们还将在 SVG 图中包含外部约束节点的反应力。我们将以箭头和标题的形式表示这些,就像 图 18-9 一样。

Image

图 18-9:节点反应

由于我们将以相同的方式绘制外部荷载和反应力,让我们编写一个函数,绘制一个带有标题的箭头作为向量几何原语;这样我们就可以用于两种情况。

绘制向量

out 目录中,创建一个新文件,命名为 vector_svg.py。你的 out 目录应如下所示:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- node_svg.py

|- svg.py

|- vector_svg.py

在此文件中,输入 清单 18-13 中的代码。

from geom2d import Point, Vector, Segment
from graphic import svg
from graphic.svg import attributes
from .captions_svg import caption_to_svg

__I_VERSOR = Vector(1, 0)
__CAPTION_DISP = 10
__DECIMAL_POS = 2

def vector_to_svg(
        position: Point,
        vector: Vector,
        scale: float,
        color: str,
        config
):
 ➊ segment = Segment(
        position.displaced(vector, -scale),
        position
    )
 ➋ caption_origin = segment.start.displaced(
        segment.normal_versor,
        __CAPTION_DISP
    )

    def svg_arrow():
        pass

    def svg_caption():
        pass

 ➌ return svg.group([
        svg_arrow(),
        svg_caption()
    ])

清单 18-13:向量到 SVG

我们定义了三个常量:

  • __I_VERSOR 用于计算与水平方向的角度。

  • __CAPTION_DISP 是向量基准线和标题之间的间隔。

  • __DECIMAL_POS 用固定小数位数格式化向量坐标。

接下来是 vector_to_svg 函数,它具有以下参数:

  • position 是向量的基准点。

  • vector 是向量本身。

  • scale 应用于向量,用于缩短或延长它。

  • color 是描边和字体颜色。

  • config 是配置字典。

在函数中,我们创建一个段来表示向量的基准线 ➊。该段的起点是传入位置,由向量(也作为参数传递给函数)位移,并使用 -scale 的比例。我们希望向量的箭头位于原点;因此,该段的终点位于向量的相反方向。你可以在 图 18-10 中看到这种向量段端点配置。

Image

图 18-10:向量段的端点

我们还通过将段的起点按段的法向方向位移来计算标题的原点 ➋(见 图 18-11)。

Image

图 18-11:节点反应标题的位置

然后是两个我们还未实现的函数:svg_arrow 和 svg_caption。它们是绘制箭头和标题的函数。我们很快就会实现它们。

最后,我们返回一个包含 svg_arrow 和 svg_caption 函数结果的 SVG 组 ➌。

让我们实现这两个缺失的函数。输入 清单 18-14 中缺失的代码。

--snip--

def vector_to_svg(
        position: Point,
        vector: Vector,
        scale: float,
        color: str,
        config
):
    segment = Segment(
        position.displaced(vector, -scale),
        position
    )
    caption_origin = segment.start.displaced(
        segment.normal_versor,
        __CAPTION_DISP
    )

    def svg_arrow():
        width = config['sizes']['stroke']
        arrow_size = config['sizes']['arrow']

     ➊ return svg.arrow(
            segment,
            arrow_size,
            arrow_size,
            [
                attributes.stroke_color(color),
                attributes.stroke_width(width),
                attributes.fill_color('none')
            ]
        )

    def svg_caption():
     ➋ return caption_to_svg(
           vector.to_formatted_str(__DECIMAL_POS),
           caption_origin,
           vector.angle_to(__I_VERSOR),
           color,
           config
        )

    return svg.group([
        svg_arrow(),
        svg_caption()
    ])

清单 18-14:向量到 SVG

svg_arrow 函数首先将宽度和箭头大小配置值保存在变量中。然后,它返回我们的 SVG 箭头原语,传入段落、箭头宽度和长度的箭头大小,以及包括笔触颜色和宽度的属性列表 ➊。回顾一下,我们的 svg.arrow 函数绘制位于段落末端点的箭头。

svg_caption 函数返回调用 svg_caption 函数的结果,传入标题字符串、原点、旋转角度、颜色和配置字典 ➋。使用我们的 Vector 类的 to_formatted_str 方法计算正确格式的标题。这个方法尚未实现,所以让我们编写它来创建包含向量分量和范数的字符串。

打开 geom2d/vector.py 文件,并输入 列表 18-15 中的代码。

class Vector:
    --snip--

    def to_formatted_str(self, decimals: int):
        u = round(self.u, decimals)
        v = round(self.v, decimals)
        norm = round(self.norm, decimals)

        return f'({u}, {v}) with norm {norm}'

列表 18-15:向量到格式化字符串

我们还需要在 Point 类中定义一个类似的方法,用于格式化解的文本表示中的节点位置。打开 geom2d/point.py 并输入 列表 18-16 中的代码。

class Point:
    --snip--

    def to_formatted_str(self, decimals: int):
        x = round(self.x, decimals)
        y = round(self.y, decimals)

        return f'({x}, {y})'

列表 18-16:点到格式化字符串

现在我们已经实现了一种绘制带有坐标标题的向量的方法,让我们使用我们的实现来显示节点反应。

绘制反应力

out 目录中,创建一个名为 reaction_svg.py 的新文件。你的 out 目录应该如下所示:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- node_svg.py

|- reaction_svg.py

|- svg.py

|- vector_svg.py

在这个新创建的文件中,输入 列表 18-17 中的代码。

from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution
from .vector_svg import vector_to_svg

def node_reactions_to_svg(
        solution: StructureSolution,
        settings,
        config
):
    def reaction_svg(node: StrNodeSolution):
     ➊ position = node.displaced_pos_scaled(settings.disp_scale)
     ➋ reaction = solution.reaction_for_node(node)
     ➌ return vector_to_svg(
            position=position,
            vector=reaction,
            scale=settings.load_scale,
            color=config['colors']['reaction'],
            config=config
        )

 ➍ return [
        reaction_svg(node)
        for node in solution.nodes
        if node.is_constrained
    ]

列表 18-17:节点对 SVG 的反应

在这个文件中,我们定义了 node_reactions_to_svg。结构解中的每个外部约束节点都通过列表推导式映射到其 SVG 反应 ➍。

我们使用一个内部函数来生成每个解节点的 SVG 表示:reaction_svg。这个函数首先获取结果节点的位移位置(应用了 disp_scale) ➊。然后,它请求解结构中该节点的反应 ➋。通过这些信息,我们可以使用 vector_to_svg 函数生成反应向量的 SVG 表示 ➌。

载荷

我们在结果图像中最后要绘制的是施加在结构上的载荷。

out 目录中,创建一个名为 load_svg.py 的新文件。你的 out 目录应该如下所示:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- load_svg.py

|- node_svg.py

|- reaction_svg.py

|- svg.py

|- vector_svg.py

load_svg.py 中,输入 列表 18-18 中的代码。

from geom2d import Vector, Point
from graphic import svg
from structures.solution.node import StrNodeSolution
from .vector_svg import vector_to_svg

def loads_to_svg(nodes: [StrNodeSolution], settings, config):
    def svg_node_loads(node: StrNodeSolution):
     ➊ position = node.displaced_pos_scaled(settings.disp_scale)
     ➋ return svg.group(
            [
                svg_load(position, load)
                for load in node.loads
            ]
        )

    def svg_load(position: Point, load: Vector):
     ➌ return vector_to_svg(
            position=position,
            vector=load,
            scale=settings.load_scale,
            color=config['colors']['load'],
            config=config
        )

 ➍ return [
        svg_node_loads(node)
        for node in nodes
        if node.is_loaded
    ]

列表 18-18:载荷到 SVG

在这个文件中,我们定义了一个函数 loads_to_svg,接收三个参数:StrNodeSolution 列表以及 settings 和 config 字典。该函数依赖于两个内部函数:svg_node_loads 和 svg_load。我们使用列表推导将每个在传入的节点列表中有外部荷载的节点映射到其 SVG 表示 ➍。我们使用每个节点的 is_loaded 属性来过滤出外部加载的节点。

svg_node_loads 内部函数首先获取解算节点 ➊ 的位移位置,然后返回节点中所有荷载的 SVG 组 ➋。每个荷载都通过第二个内部函数:svg_load,映射到一个 SVG 向量。

svg_load 函数非常简单:它仅调用 vector_to_svg 函数,并传递适当的参数 ➌。

到这里,我们的所有 SVG 生成代码已经准备好!我们只需将它们整合在一起,就可以开始绘制结构解算图了。

将所有内容结合起来

现在,让我们打开 svg.py 文件,将我们编写的函数添加到 structure_solution_to_svg 函数中。按照 Listing 18-19 输入缺失的代码。

  import json

  import pkg_resources as res

  from geom2d import AffineTransform
  from graphic import svg
  from structures.solution.structure import StructureSolution
➊ from .bar_svg import bars_to_svg
  from .load_svg import loads_to_svg
  from .node_svg import nodes_to_svg
  from .reaction_svg import node_reactions_to_svg

  def structure_solution_to_svg(
          result: StructureSolution,
          settings,
          _config=None,
  ):
      __validate_settings(settings)
      default_config = __read_config()
      config = {**default_config, **(_config or {})}

      viewbox = result.bounds_rect(
          config['sizes']['margin'],
          settings.scale
      )
      transform = AffineTransform(sx=1, sy=-1, tx=0, ty=0)

    ➋ svg_bars = bars_to_svg(result.bars, settings, config)
       svg_nodes = nodes_to_svg(result.nodes, settings, config)
       svg_react = node_reactions_to_svg(result, settings, config)
       svg_loads = loads_to_svg(result.nodes, settings, config)

       return svg.svg_content(
           size=viewbox.size,
        ➌ primitives=svg_bars + svg_nodes + svg_react + svg_loads,
           viewbox_rect=viewbox,
           transform=transform

      )

--snip--

Listing 18-19: 结构解算到 SVG

首先,我们导入 bars_to_svg、loads_to_svg、nodes_to_svg 和 node_reactions_to_svg 函数 ➊。

然后,在 structure_solution_to_svg 函数内部,我们调用每个函数以生成相应的 SVG 代码 ➋。结果被存储在 svg_bars、svg_nodes、svg_react 和 svg_loads 中。这些结果被连接成一个列表,我们将其传递给 svg_content 函数 ➌。顺序很重要:列表末尾的 SVG 基元会出现在前面的基元之前。

最终结果

如果你已经跟随完成,你的 svg.py 文件应该与 Listing 18-20 类似。

import json

import pkg_resources as res

from geom2d import AffineTransform
from graphic import svg
from structures.solution.structure import StructureSolution
from .bar_svg import bars_to_svg
from .load_svg import loads_to_svg
from .node_svg import nodes_to_svg
from .reaction_svg import node_reactions_to_svg

def structure_solution_to_svg(
        result: StructureSolution,
        settings,
        _config=None,
):
    __validate_settings(settings)
    default_config = __read_config()

    config = {**default_config, **(_config or {})}

    viewbox = result.bounds_rect(
        config['sizes']['margin'],
        settings.scale
    )
    transform = AffineTransform(sx=1, sy=-1, tx=0, ty=0)

    svg_bars = bars_to_svg(result.bars, settings, config)
    svg_nodes = nodes_to_svg(result.nodes, settings, config)
    svg_react = node_reactions_to_svg(result, settings, config)
    svg_loads = loads_to_svg(result.nodes, settings, config)

    return svg.svg_content(
        size=viewbox.size,
        primitives=svg_bars + svg_nodes + svg_react + svg_loads,
        viewbox_rect=viewbox,
        transform=transform
    )

def __read_config():
    config = res.resource_string(__name__, 'config.json')
    return json.loads(config)

__expected_settings = (
    # scale applied to the diagram
    'scale',
    # scale applied to the node displacements
    'disp_scale',
    # scale applied to the load vectors
    'load_scale',
    # boolean to decide whether to draw the original geometry
    'no_draw_original'
)

def __validate_settings(settings):
    for setting in __expected_settings:
        if setting not in settings:
            raise ValueError(f'"{setting}" missing in settings')

Listing 18-20: 结构解算到 SVG

我们已经准备好了所有需要的内容,但在下一章开始使用之前,让我们准备一个解算的文本表示。

从结构解算到文本

通过视觉图示,我们可以更好地理解结构变形;因为我们根据条形图所受的应力对其进行着色,这也是查看哪些条形图被压缩、哪些被拉伸的好方法。同时,以文本格式研究数值结果可能更为简便,且我们可能希望它进行其他计算。这两种格式是互补的,我们的结构分析程序将同时输出这两者。

我们将使用以下格式将每个节点的位移写入文本文件:

NODE 25
    original position: (1400.0, 150.0)
    displacement: (0.1133, -0.933) with norm 0.9398
    displaced position: (1400.1133, 149.067)

如果节点有外部约束,我们还需要检查它的反应。在这种情况下,我们可以添加最后一行:

NODE 1
    original position: (0.0, 0.0)
    displacement: (0.0, 0.0) with norm 0.0
    displaced position: (0.0, 0.0)
    reaction: (-283.6981, 9906.9764) with norm 9911.0376

条形图将遵循以下格式:

[mathescape=true]
BAR 8 (25 → 9) : ⊕ TENSION
    Δl (elongation) = 0.0026
    ϵ  (strain)     = 1.045e-05
    σ  (stress)     = 209.0219

让我们编写一个生成结构解算的纯文本表示的函数。

结构解算的字符串

在我们编写生成纯文本表示的函数之前,先编写一个有用的辅助函数,该函数接受一个字符串列表并返回一个单一的字符串,将所有字符串通过“换行”字符连接在一起。

我们希望将每个结果值定义为独立的字符串,但我们实现的函数只能返回一个字符串,该字符串随后会被写入文件。

让我们为这个辅助函数创建一个新的文件。在你的utils包中,创建一个新的 Python 文件,命名为strings.py。该包现在应包含以下内容:

utils

|- init.py

|- lists.py

|- pairs.py

|- strings.py

在这个strings.py文件中,输入 Listing 18-21 中的函数。

def list_to_string(strings: [str]) -> str:
    return '\n'.join(strings)

Listing 18-21: 列表转字符串

这个list_to_string函数将一个字符串列表映射为一个单一的字符串,每个条目之间使用‘\n’(换行符)字符分隔。

现在让我们概述一下文本输出函数的逻辑。首先,在structures/out包内创建一个新的text.py文件,该包现在应包含以下文件:

out

|- init.py

|- bar_svg.py

|- captions_svg.py

|- load_svg.py

|- node_svg.py

|- reaction_svg.py

|- svg.py

|- text.py

|- vector_svg.py

在这个text.py文件中,输入 Listing 18-22 中的代码。

  from structures.solution.bar import StrBarSolution
  from structures.solution.node import StrNodeSolution
  from structures.solution.structure import StructureSolution
  from utils.strings import list_to_string

➊ __DECIMAL_POS = 4
  __SEPARATION = ['------------------------------------------', '\n']

  def structure_solution_to_string(result: StructureSolution):
   ➋ nodes_text = __nodes_to_string(result)
   ➌ bars_text = __bars_to_string(result.bars)
   ➍ return list_to_string(nodes_text + __SEPARATION + bars_text)

  def __nodes_to_string(result: StructureSolution):
      pass

  def __node_to_string(
          result: StructureSolution,
          node: StrNodeSolution
  ):
      pass

  def __bars_to_string(bars: [StrBarSolution]):
      pass

  def __bar_to_string(bar: StrBarSolution):
      pass

Listing 18-22: 结构解决方案转文本

在这个列表中,我们导入了StrBarSolutionStrNodeSolutionStructure Solution类,以及list_to_string函数。我们定义了两个常量,一个用于指定要用来格式化结果值的小数位数,__DECIMAL_POS ➊,另一个是分隔字符串列表__SEPARATION,用于在结果字符串中分隔不同的部分。

然后是主函数structure_solution_to_string。这个函数只接收一个参数:结构解决方案。它使用两个私有函数:一个用于转换节点的字符串表示 ➋,另一个用于转换条形 ➌。结果存储在nodes_textbars_text变量中,这两个列表通过__SEPARATION字符串连接,并传递给list_to_string ➍。

在这个主函数之后,我们定义了其余的私有函数,但它们尚未实现。现在让我们来实现它们。

节点

让我们从节点开始。将代码填入 Listing 18-23 中的__nodes_to_string__node_to_string函数。

--snip--

def __nodes_to_string(result: StructureSolution):
    return [
     ➊ __node_to_string(result, node)
        for node in result.nodes
    ]

def __node_to_string(
        result: StructureSolution,
        node: StrNodeSolution
):
 ➋ orig_pos = node.original_pos.to_formatted_str(__DECIMAL_POS)
    displacement = node.global_disp.to_formatted_str(__DECIMAL_POS)
    disp_pos = node.displaced_pos.to_formatted_str(__DECIMAL_POS)

 ➌ strings = [
        f'NODE {node.id}',
        f'\toriginal position: {orig_pos}',
        f'\tdisplacement: {displacement}',
        f'\tdisplaced position: {disp_pos}'
    ]

 ➍ if node.is_constrained:
        react = result.reaction_for_node(node)
        react_str = react.to_formatted_str(__DECIMAL_POS)
        strings.append(f'\treaction: {react_str}')

 ➎ return list_to_string(strings) + '\n'

--snip--

Listing 18-23: 节点转文本

第一个函数__nodes_to_string使用列表推导将结果中的每个节点映射到其文本表示,为此它使用了__node_to_string函数 ➊。此函数不仅需要节点本身,还需要整个结构对象作为参数。请记住,节点的反作用力是由结构解决方案实例计算的,而不是由节点本身计算的。

__node_to_string 函数首先获取节点原始位置 ➋、全局位移向量和位移位置的格式化字符串。我们使用 Point 和 Vector 类的 to_formatted_str 方法来处理点坐标的格式化。

接着,我们声明一个列表 strings ➌,将刚才获得的字符串放入其中。注意,除了第一个作为标题,其他字符串都以制表符(\t)字符开始。这样,我们就实现了之前定义的良好格式化:

NODE 2
    original position: (200.0, 0.0)
    displacement: (0.0063, -0.1828) with norm 0.1829
    displaced position: (200.0063, -0.1828)

接下来,如果节点受到外部约束 ➍,我们生成反作用力字符串。为此,我们首先使用结构解算类计算给定节点的反作用力,然后使用 to_formatted_str 方法格式化它,最后将其附加到字符串列表中。

最后一步是使用辅助函数 list_to_string 将得到的字符串列表转换为单个字符串,并在末尾附加换行符 ➎。

条形

现在,让我们来填充条形的函数。我们将使用一些 UTF-8 字符,使文本更具视觉效果。这些字符是可选的;你可以决定不在代码中添加它们,只使用标签。如果决定使用它们,我们将在“Unicode 字符”部分解释如何操作,详情见第 18 页。

输入清单 18-24 中的代码。

--snip--

def __bars_to_string(bars: [StrBarSolution]):
 ➊ return [__bar_to_string(bar) for bar in bars]

def __bar_to_string(bar: StrBarSolution):
 ➋ nodes_str = f'{bar.start_node.id} → {bar.end_node.id}'
    type_str = '⊕ TENSION' if bar.stress >= 0 else '⊖ COMPRESSION'
    elongation = round(bar.elongation, __DECIMAL_POS)
    strain = '{:.3e}'.format(bar.strain)
    stress = round(bar.stress, __DECIMAL_POS)

 ➌ return list_to_string([
        f'BAR {bar.id} ({nodes_str}) : {type_str}',
        f'\tΔl (elongation) = {elongation}',
        f'\tϵ  (strain)     = {strain}',
        f'\tσ  (stress)     = {stress}\n'
    ])

清单 18-24:条形转文本

__bars_to_string 函数使用列表推导式将列表中的每个条形映射到其文本表示形式 ➊。这个文本是由第二个函数 __bar_to_string 生成的。

在 __bar_to_string 中,我们首先准备一些字符串 ➋,然后使用 list_to_string 函数 ➌将它们连接返回,而 nodes_str 表示条形的节点 ID,用→字符分隔它们。

type_str 表示条形是受拉还是受压,取决于条形应力的符号。我们使用⊕符号来装饰 TENSION 文本,使用⊖来装饰 COMPRESSION 文本。这个细节使得结果在视觉上更为突出。

然后是延伸、应变和应力字符串。这些是条形结果值,格式化为具有 __DECIMAL_POS 小数位数。这里应变是个例外;我们不是进行四舍五入,而是希望使用科学计数法,并保留三位小数(’{:.3e}’)。应变通常是一个较小的值,比应力小几个数量级,因此如果我们尝试将其四舍五入到四位小数,比如 0.0000$,结果仍然是零。使用’{:.3e}’格式,我们会得到像 1.259e–05 这样的值。

在我们的工程应用中格式化值时,必须注意数量级。格式错误的值,如果失去了所需的精度,会导致应用程序无法使用。

Unicode 字符

我们在代码中使用的图标,→、Δϵ、⊕ 和 ⊖,都是 Unicode 字符。每个操作系统都有插入这些字符的方式。如果你进行一个简单的 Google 搜索,你应该能找到如何在你的操作系统中访问它们。例如,macOS 使用 CMD-CTRL-空格键组合打开符号对话框,这就是我在代码中插入这些符号的方式。

你也可以通过在 Python 字符串中插入这些字符的代码来使用它们,方法如下:

>>> '\u2295 is a Unicode symbol'
'⊕ is a Unicode symbol'

如果你选择这种替代方法,你需要用字符的代码替换列表中的字符。表 18-2 展示了我们使用的字符及其 Unicode 代码。

表 18-2: Unicode 字符

字符 Unicode 用途
\u2295 拉伸应力
\u2296 压缩应力
\u279c 分隔一个条形的节点 ID(1 →2)
Δ \u0394 长度增量(Δl
ϵ \u03f5 应变
σ \u03c3 应力

将所有内容整合起来

如果你跟着做,你的结果应该像 列表 18-25 所示。

from structures.solution.bar import StrBarSolution
from structures.solution.node import StrNodeSolution
from structures.solution.structure import StructureSolution
from utils.strings import list_to_string

__DECIMAL_POS = 4
__SEPARATION = ['------------------------------------------', '\n']

def structure_solution_to_string(result: StructureSolution):
    nodes_text = __nodes_to_string(result)
    bars_text = __bars_to_string(result.bars)
    return list_to_string(nodes_text + __SEPARATION + bars_text)

def __nodes_to_string(result: StructureSolution):
    return [
        __node_to_string(result, node)
        for node in result.nodes
    ]

def __node_to_string(
        result: StructureSolution,
        node: StrNodeSolution
):
    orig_pos = node.original_pos.to_formatted_str(__DECIMAL_POS)
    displacement = node.global_disp.to_formatted_str(__DECIMAL_POS)
    disp_pos = node.displaced_pos.to_formatted_str(__DECIMAL_POS)

    strings = [
        f'NODE {node.id}',
        f'\toriginal position: {orig_pos}',
        f'\tdisplacement: {displacement}',
        f'\tdisplaced position: {disp_pos}'
    ]

    if node.is_constrained:
        react = result.reaction_for_node(node)
        react_str = react.to_formatted_str(__DECIMAL_POS)
        strings.append(f'\treaction: {react_str}')

    return list_to_string(strings) + '\n'

def __bars_to_string(bars: [StrBarSolution]):
    return [__bar_to_string(bar) for bar in bars]

def __bar_to_string(bar: StrBarSolution):
    nodes_str = f'{bar.start_node.id} → {bar.end_node.id}'
    type_str = '⊕ TENSION' if bar.stress >= 0 else '⊖ COMPRESSION'
    elongation = round(bar.elongation, __DECIMAL_POS)
    strain = '{:.3e}'.format(bar.strain)
    stress = round(bar.stress, __DECIMAL_POS)

    return list_to_string([
        f'BAR {bar.id} ({nodes_str}) : {type_str}',
        f'\tΔl (elongation) = {elongation}',
        f'\tϵ  (strain)     = {strain}',
        f'\tσ  (stress)     = {stress}\n'
    ])

列表 18-25:结构解决方案转为文本

我们用不到 70 行代码编写了一个能够生成结构解决方案模型文本表示的函数。

总结

在本章中,我们实现了创建表示结构解决方案模型的矢量图的代码。我们将生成图形的过程拆分成若干部分,以便让代码更易管理,然后我们将其全部整合在 svg.py 文件中,特别是在 structure_solution_to_svg 函数中。

我们接着实现了一个函数,structure_solution_to_string,用于生成结构解决方案的纯文本表示。

现在我们已经准备好将我们的应用程序整合起来了。在最后一章,我们将做到这一点。

第十九章:组装我们的应用程序

Image

我们已经实现了所有的构件,现在是时候将它们组合成一个可以从命令行运行的应用程序了。我们将在本章编写的应用程序将解析输入文件为结构模型,使用结构类中的 solve_structure 方法来组装已解算的结构,然后利用我们在前一章实现的函数创建一个 SVG 图表和一个描述解决方案的文本文件。

概述

为了概览如何将不同的模块组装成最终的应用程序,让我们看看图 19-1。这张图说明了当我们的应用程序执行时的各个阶段。

Image

图 19-1:结构解析步骤

首先,我们的应用程序会提供一个文本文件来定义结构。这个文件是按照我们在第十七章中定义的规则格式化的。在第一步,我们将读取文件的内容并将其解析为一个基于我们结构类构建的模型。

一旦结构模型构建完成,结构类的 solve_structure 方法会进行分析并创建结构解决方案模型。如果你记得的话,StructureSolution 类是表示解决方案的顶级实体。

最后一步是将结果以图表形式保存(保存为 SVG 文件),并以文本报告的形式保存(保存为纯文本文件)。因此,我们程序的输出将是两个文件。

不过,在我们做任何事情之前,我们首先需要为应用程序设置一个新的目录。

设置

首先,让我们在 apps 目录中创建一个新的包。命名为 truss_structures。你的目录结构应如下所示:

apps

|- aff_transf_motion

|   |- ...

|- circle_from_points

|   |- ...

|- truss_structures

|    |- init.py

如果你将包文件夹创建为常规文件夹,别忘了包含一个空的 init.py 文件,以使其成为一个 Python 包。在该包中,现在让我们添加主文件。创建一个新的 Python 文件,命名为 main.py,并在其中简单地添加以下几行:

if __name__ == '__main__':
    print('Main')

你的 truss_structures 包现在应该包含两个文件:

truss_structures

|- init.py

|- main.py

在本章中,我们将不使用 IDE 中的运行配置;我们将依赖一个包装程序的 bash 脚本。让我们现在准备好这个脚本,以便在整章中使用它。在项目目录的最上层,即 Mechanics 文件夹中,创建一个新的 bash 文件并命名为 truss.sh。输入清单 19-1 中的代码。

#!/usr/bin/env bash
PYTHONPATH=$PWD python3 apps/truss_structures/main.py $@

清单 19-1:Bash 包装脚本

我们必须更改文件的权限,以使其可执行。可以从 Shell 运行以下命令:

$ chmod +x truss.sh

如果你从 Shell 运行这个脚本,

$ ./truss.sh

你应该看到 “Main” 被打印出来。我们已经准备好了一切,现在开始编码吧!

输入参数

我们的命令行应用程序将接受几个参数:绘图的总体比例、节点位移的比例、负载的比例,以及是否绘制原始几何图形(有关详细信息,请参见 表 18-1 和 第 497 页)。

我们这样将这些参数传递给我们的程序:

$ ./truss.sh --scale=1.25 --disp-scale=100 --load-scale=0.1 --no-draw-original

我们希望读取这些参数,解析它们的值,并在用户没有提供值时使用默认值。我们可以使用 Python 标准库中的一个实用工具:argparse 来实现。Argparse 还将为用户生成关于不同参数的帮助信息,并验证传入的值。

apps/truss_structures 包中创建一个新文件,命名为 arguments.py。你的 truss_structures 包现在应该是这样的:

truss_structures

|- init.py

|- arguments.py

|- main.py

输入 清单 19-2 中的代码。

import argparse

def parse_arguments():
 ➊ parser = argparse.ArgumentParser(
        description='Solves a truss structure'
    )

 ➋ parser.add_argument(
        '--scale',
        help='scale applied to the geometry (for plotting)',
        default=2,
        type=float
    )

 ➌ parser.add_argument(
        '--disp-scale',
        help='scale applied to the displacements (for plotting)',
        default=500,
        type=float
    )

 ➍ parser.add_argument(
        '--load-scale',
        help='scale applied to the loads (for plotting)',
        default=0.02,
        type=float
    )

 ➎ parser.add_argument(
        '--no-draw-original',
        help='Should draw the original geometry?',
        action='store_true'
    )

 ➏ return parser.parse_args()

清单 19-2:解析命令行参数

在这个文件中,我们定义了一个名为 parse_arguments 的函数。这个函数配置了 ArgumentParser ➊ 类的一个实例,用于识别我们的参数并解析它们。我们将程序的描述传递给构造函数。如果用户传入 --help 标志,则此描述会作为帮助信息显示,像这样:

$ ./truss.sh --help

这将为用户提供以下描述:

usage: main.py [--help] [--scale SCALE] [--disp-scale DISP_SCALE]
               [--load-scale LOAD_SCALE] [--no-draw-original]

Solves a truss structure

optional arguments:
  -h, --help            show this help message and exit
  --scale SCALE         scale applied to the geometry (for plotting)
  --disp-scale DISP_SCALE
                        scale applied to the displacements (for plotting)
  --load-scale LOAD_SCALE
                        scale applied to the loads (for plotting)
  --no-draw-original    Should draw the original geometry?

我们添加的第一个参数是 --scale ➋;我们为它提供一个帮助信息和一个默认值 2,并将其类型设置为浮动点数。

然后是 --disp-scale 参数 ➌,其默认值为 500。不要忘记,位移通常相较于杆件的大小要小,因此我们需要一个较大的比例才能更好地观察它们。每个结构解的位移量级不同,因此这个比例最好通过反复试验来调整。

接下来是 --load-scale 参数 ➍,其默认值为 0.02。这个比例会缩小负载,使它们适应绘图。

最后是 --no-draw-original 标志 ➎,它控制是否绘制原始结构的几何图形。如果参数中没有这个标志,我们会绘制原始几何图形,但会使用较浅的颜色,以便将重点放在解答图形上。这看起来像是 图 19-2。

Image

图 19-2:绘制原始几何图形(使用较浅的颜色)

--no-draw-original 标志与其他参数不同:它不需要关联的值;我们只关心标志是否出现在参数列表中。我们通过使用 add_argument 方法和 action 参数将此标志添加到解析器中。当这个参数出现在参数列表中时,将执行一个动作。在这种情况下,我们使用 'store_true' 动作,它会在标志存在时将 True 值保存在参数中,反之则为 False。argsparse 包中定义了几个动作,你可以在文档中查看。我们只需要 'store_true'。

最后一行返回了调用 parse_args 方法 ➏ 的结果。该方法从 sys.argv 中读取参数,sys.argv 是 Python 存储传递给程序的参数的地方,并按照我们之前定义的规则解析值。

结果是一个类似字典的结构,包含参数的值。如我们稍后将看到的,字典的键名与命令行参数相同,但去掉了前导的破折号(--),并且使用了下划线代替中间的破折号。例如,--load-scale 变成了 load_scale,这更符合 Python 的命名风格。此外,Python 中不允许使用破折号作为变量名。

现在,让我们编写生成应用程序输出文件的代码。

生成输出

我们在上一章准备了两个函数,分别生成 SVG 和文本的解表示。我们将在应用中使用这些函数,并将它们的结果写入外部文件。

首先,创建一个名为 output.py 的新文件。现在你的 truss_structures 包应该如下所示:

truss_structures

|- init.py

|- arguments.py

|- main.py

|- output.py

output.py 中,输入 示例 19-3 中的代码。

import os

from structures.out.svg import structure_solution_to_svg
from structures.out.text import structure_solution_to_string
from structures.solution.structure import StructureSolution

def save_solution_to_svg(solution: StructureSolution, arguments):
 ➊ solution_svg = structure_solution_to_svg(solution, arguments)
    __write_to_file('result.svg', solution_svg)

def save_solution_to_text(solution: StructureSolution):
 ➋ solution_text = structure_solution_to_string(solution)
    __write_to_file('result.txt', solution_text)

def __write_to_file(filename, content):
 ➌ file_path = os.path.join(os.getcwd(), filename)
 ➍ with open(file_path, 'w') as file:
        file.write(content)

示例 19-3:处理结构输出

我们定义了三个函数:一个用于将解保存为 SVG 图像文件(save_solution_to_svg),另一个将解保存为文本文件(save_solution_to_text),还有一个用于创建新文件并将其保存在当前工作目录中的函数(__write_to_file)。

save_solution_to_svg 函数调用了上一章的 structure_solution_to_svg 函数 ➊,并将生成的 SVG 字符串传递给 __write_to_file 函数。请注意,我们将命令行参数的字典传递给此函数;这些参数是我们用来生成 SVG 矢量图像的设置。为了使其正常工作,我们必须确保命令行参数使用与 structure_solution_to_svg 预期的设置相同的名称进行解析。SVG 图表创建后,我们使用 __write_to_file 在程序的工作目录中创建一个名为 result.svg 的文件。

save_solution_to_text 函数类似于 save_solution_to_svg:它使用 structure_solution_to_string 函数 ➋ 生成文本结果,然后将结果写入 result.txt 文件。

__write_to_file 中,我们首先通过将当前工作目录与文件名(文件名应已包括扩展名)连接来确定文件路径。然后,我们将文件路径存储在 file_path 变量 ➌ 中。最后,我们使用 with 块以写入模式('w')打开文件,这将创建文件(如果文件不存在),然后将传入的内容字符串写入文件 ➍。

我们差不多完成了!接下来我们只需要将输入、解析和输出连接起来。

主脚本

让我们回到main.py文件。打开它并输入列表 19-4 中的代码(你可以删除我们之前写的 print(’Main’)这一行)。

import sys
import time

import apps.truss_structures.output as out
from apps.truss_structures.arguments import parse_arguments
from structures.parse.str_parse import parse_structure_from_lines

if __name__ == '__main__':
 ➊ arguments = parse_arguments()
 ➋ lines = sys.stdin.readlines()

    start_time = time.time()

 ➌ structure = parse_structure_from_lines(lines)
 ➍ solution = structure.solve_structure()
    out.save_solution_to_svg(solution, arguments)
    out.save_solution_to_text(solution)

    end_time = time.time()
    elapsed_secs = end_time - start_time
 ➎ print(f'Took {round(elapsed_secs, 3)} seconds to solve')

列表 19-4:主脚本

在“if name is main”块中,我们解析从命令行传递给脚本的参数。为此,我们使用从arguments.py模块导入的 parse_arguments 函数 ➊。如果解析失败,因为缺少了必需的标志或类似的原因,执行会停止,并向用户发送一条有用的提示信息。

一旦解析了参数,我们就读取通过标准输入传递给程序的所有行,并将它们保存在 lines 变量中 ➋。

接下来,我们解析传入的行,使用我们在第十七章中开发的 parse_structure_from_lines 函数创建结构模型 ➌。一旦我们得到结构模型,就调用它的 solve_structure 方法来计算解决方案 ➍。

然后,我们调用前一节中编写的两个函数来生成输出文件:save_solution_to_svg 和 save_solution_to_text。

最后,我们计算程序运行所花费的时间,作为参考,并比较解决不同大小结构所需的时间。在开始解析和计算结构之前,我们将时间存储在 start_time 变量中。在生成输出文件后,我们将时间存储在 end_time 中。从 end_time 中减去 start_time,得到经过的秒数,也就是我们的应用程序花费的时间。我们在应用程序执行完成前,打印出这个结果时间(秒) ➎。

我相信你和我一样迫不及待地想试试我们的新应用。让我们手动编写一个结构文件并求解它。

尝试应用

我们来创建一个结构文件,试试这个应用。图 19-3 展示了桥梁中常见的四种桁架配置。我们将从这些标准设计中选择沃伦型桁架作为第一次测试。我们将手动编写一个文件,定义一个按照桁架中杆件配置的结构。

图片

图 19-3:桁架类型

apps/truss_structures目录中创建一个名为warren.txt的新文件。输入以下结构定义:

# Warren truss with 4 spans

nodes
# lower nodes
1: (0.0, 0.0) (xy)
2: (400.0, 0.0) ()
3: (800.0, 0.0) ()
4: (1200.0, 0.0) ()
5: (1600.0, 0.0) (y)
# upper nodes
6: (400.0, 300.0) ()
7: (800.0, 300.0) ()
8: (1200.0, 300.0) ()

loads
6 -> (2500.0, -5000.0)
7 -> (2500.0, -5000.0)
8 -> (2500.0, -5000.0)

bars
# horizontal bars
1: (1 -> 2) 20.0 20000000.0
2: (2 -> 3) 20.0 20000000.0
3: (3 -> 4) 20.0 20000000.0
4: (4 -> 5) 20.0 20000000.0
5: (6 -> 7) 20.0 20000000.0
6: (7 -> 8) 20.0 20000000.0
# vertical bars
7: (2 -> 6) 15.0 20000000.0
8: (3 -> 7) 15.0 20000000.0
9: (4 -> 8) 15.0 20000000.0
# diagonal bars
10: (1 -> 6) 30.0 20000000.0
11: (6 -> 3) 30.0 20000000.0
12: (3 -> 8) 30.0 20000000.0
13: (8 -> 5) 30.0 20000000.0

或者,为了避免自己写这些代码,你可以复制并粘贴书中提供的代码文件内容。图 19-4 可能帮助你直观地理解沃伦结构示例文件中节点和杆件的排列方式。

图片

图 19-4:沃伦桁架结构来测试我们的应用

现在是时候求解这个结构,并查看我们的应用程序产生的美丽结果了。从终端运行以下命令:

$ ./truss.sh --scale=1.25 --disp-scale=250 < apps/truss_structures/warren.txt

这应该会输出到终端:

Took 0.058 seconds to solve

在前一个命令中,我们执行了一个封装了我们代码的 bash 脚本,并传递了两个参数:一个 1.25 的全局绘图比例和 250 的位移比例。其他参数将使用默认值,回忆一下,它们分别是 0.02 的载荷比例和--no-draw-original 标志的值为 False。

两个新文件应该已经出现在你的项目中,与truss.sh bash 文件在同一层级:result.svgresult.txt。如果你打开第二个文件——解决方案的文本表示,你会看到类似于列表 19-5 的内容。

NODE 1
    original position: (0.0, 0.0)
    displacement: (0.0, 0.0) with norm 0.0
    displaced position: (0.0, 0.0)
    reaction: (-7513.0363, 6089.8571) with norm 9671.1981

--snip--

NODE 8
    original position: (1200.0, 300.0)
    displacement: (0.0185, -0.0693) with norm 0.0717
    displaced position: (1200.0185, 299.9307)

------------------------------------------

BAR 1 (1 → 2) : ⊕ TENSION
    Δl (elongation) = 0.0156
    ϵ  (strain)     = 3.908e-05
    σ  (stress)     = 781.5951

--snip--

BAR 13 (8 → 5) : ⊖ COMPRESSION
    Δl (elongation) = -0.0124
    ϵ  (strain)     = -2.473e-05
    σ  (stress)     = -494.5523

列表 19-5:沃伦桁架纯文本解决方案

纯文本解决方案报告有助于检查所有的解决方案值。例如,你可以检查节点 1 和节点 5 的反应(外部约束节点)。ID 为 1 的节点(节点 1)在水平和垂直方向上均受约束,其大约反作用力为图片 = ⟨–7513, 6090⟩。该节点的位移必然为零。ID 为 5 的节点(节点 5)仅在垂直方向上受约束,其位移向量为图片 = ⟨0.055, 0.0⟩。

现在看看每个桁架部分。你可以轻松识别出被压缩和被拉长的桁架,检查它们的伸长、应变和应力值。如果我们想分析在给定载荷下的结构,这份报告提供了所有所需的数据。

最精彩的部分在result.svg文件中。用你最喜欢的浏览器打开生成的图片。你的结果应该像图 19-5 那样。

图片

图 19-5:沃伦解法示意图

正如你在屏幕上看到的,如果桁架受压,它们会显示为红色;如果受拉,它们会显示为绿色。与桁架对齐的标签显示了它们的应力。原始几何图形以浅蓝色绘制在背景中,这使我们能够更好地可视化载荷如何使结构变形。

注意

你可以在 PyCharm 中查看 SVG 图像,但如果我们尝试在 IDE 中打开并可视化我们的图表,你会惊讶地发现它们是倒过来的。别慌张:你没有弄错。只是(从 2021.1 版本开始)PyCharm 不支持我们添加到 SVG 中的transform属性,正如你从前面记得的那样,我们需要它来翻转 y 轴。我建议你改用浏览器。

你能看到桁架线条粗细的区别吗?使用线条粗细来表示桁架的截面有助于我们识别出能够承受更大载荷的桁架。我们为桁架添加的应力标签使我们可以迅速检查每根桁架上的应力,从而提前获取最重要的信息之一。从我们的图表中,我们只需一眼就能收集到很多信息;这正是这种图形表示法的价值所在。

为了理解程序的参数作用,我们可以玩玩这些参数,看看能得到什么样的结果。

玩弄参数

让我们先检查一下,如果我们传递--no-draw-original 标志会发生什么。

$ ./truss.sh --scale=1.25 --disp-scale=250 --no-draw-original
  < apps/truss_structures/warren.txt

如果你在你喜欢的浏览器中打开result.svg图像,你应该能看到类似 Figure 19-6 的图像。

Image

Figure 19-6:沃伦解法图,没有原始几何形状

没有原始几何形状的情况下,我们可以看到变形后的结构更加简洁;同时,我们无法看到节点和杆件相对于原始位置的运动。

那么,使用更大的位移比例怎么样呢?让我们试试下面的操作:

$ ./truss.sh --scale=1.25 --disp-scale=500
  < apps/truss_structures/warren.txt

使用 500 的位移缩放值会夸大变形,以便我们能够清楚地看到它们。现在,图表应该像 Figure 19-7 一样。

Image

Figure 19-7:沃伦解法图,位移比例较大

我们还没有使用荷载图;我们一直在使用默认值 0.02。让我们尝试编辑这个值,看看它的效果:

$ /truss.sh --scale=1.25 --disp-scale=400 --load-scale=0.01
  < apps/truss_structures/warren.txt

如果我们使用荷载比例为 0.01,也就是我们目前使用的荷载比例的一半,你可以看到荷载向量的长度缩小了,正如 Figure 19-8 所示。

Image

Figure 19-8:沃伦解法图,荷载比例较小

如你所见,荷载比例对于正确的荷载向量可视化非常重要。较小的值会将向量缩小到几乎没有空间来妥善放置它们的标签。你可以尝试使用较大的荷载比例,比如 0.5。标签应该会从图表中消失。在这种情况下,我们绘制的向量非常长,以至于它们的中心点位于绘图范围外,因此,我们放置在起始点附近的荷载标题根本看不见。

解决大型结构

在代码与书籍一起分发的apps/truss_structures目录中,有一个文件,baltimore.txt,它定义了一个有 10 个跨度的巴尔的摩桁架结构。将此文件复制到你的项目中,放在同一文件夹里。或者,你也可以手动创建并编写该文件(Listing 19-6):

# Baltimore truss with 10 spans

nodes
# lower nodes
1: (0.0, 0.0) (xy)
2: (200.0, 0.0) ()
3: (400.0, 0.0) ()
4: (600.0, 0.0) ()
5: (800.0, 0.0) ()
6: (1000.0, 0.0) ()
7: (1200.0, 0.0) ()
8: (1400.0, 0.0) ()
9: (1600.0, 0.0) ()
10: (1800.0, 0.0) ()
11: (2000.0, 0.0) ()
12: (2200.0, 0.0) ()
13: (2400.0, 0.0) ()
14: (2600.0, 0.0) ()
15: (2800.0, 0.0) ()
16: (3000.0, 0.0) ()
17: (3200.0, 0.0) ()
18: (3400.0, 0.0) ()
19: (3600.0, 0.0) ()
20: (3800.0, 0.0) ()
21: (4000.0, 0.0) (y)
# middle nodes
22: (200.0, 150.0) ()
23: (600.0, 150.0) ()
24: (1000.0, 150.0) ()
25: (1400.0, 150.0) ()
26: (1800.0, 150.0) ()
27: (2200.0, 150.0) ()
28: (2600.0, 150.0) ()
29: (3000.0, 150.0) ()
30: (3400.0, 150.0) ()
31: (3800.0, 150.0) ()
# upper nodes
32: (400.0, 300.0) ()
33: (800.0, 300.0) ()
34: (1200.0, 300.0) ()
35: (1600.0, 300.0) ()
36: (2000.0, 300.0) ()
37: (2400.0, 300.0) ()
38: (2800.0, 300.0) ()
39: (3200.0, 300.0) ()
40: (3600.0, 300.0) ()

loads
1 -> (0.0, -500.0)
2 -> (0.0, -500.0)
--snip--
40 -> (0.0, -500.0)

bars
# zig-zag bars
1: (1 -> 22) 20.0 20000000.0
2: (22 -> 3) 20.0 20000000.0
3: (3 -> 23) 20.0 20000000.0
4: (23 -> 5) 20.0 20000000.0
5: (5 -> 24) 20.0 20000000.0
6: (24 -> 7) 20.0 20000000.0
7: (7 -> 25) 20.0 20000000.0
8: (25 -> 9) 20.0 20000000.0
9: (9 -> 26) 20.0 20000000.0
10: (26 -> 11) 20.0 20000000.0
11: (11 -> 27) 20.0 20000000.0
12: (27 -> 13) 20.0 20000000.0
13: (13 -> 28) 20.0 20000000.0
14: (28 -> 15) 20.0 20000000.0
15: (15 -> 29) 20.0 20000000.0
16: (29 -> 17) 20.0 20000000.0
17: (17 -> 30) 20.0 20000000.0
18: (30 -> 19) 20.0 20000000.0
19: (19 -> 31) 20.0 20000000.0
20: (31 -> 21) 20.0 20000000.0
# left diagonal bars
21: (32 -> 22) 20.0 20000000.0
22: (32 -> 23) 20.0 20000000.0
23: (33 -> 24) 20.0 20000000.0
24: (34 -> 25) 20.0 20000000.0
25: (35 -> 26) 20.0 20000000.0
# right diagonal bars
26: (37 -> 27) 20.0 20000000.0
27: (38 -> 28) 20.0 20000000.0
28: (39 -> 29) 20.0 20000000.0
29: (40 -> 30) 20.0 20000000.0
30: (40 -> 31) 20.0 20000000.0
# vertical bars
31: (2 -> 22) 20.0 20000000.0
32: (3 -> 32) 20.0 20000000.0
33: (4 -> 23) 20.0 20000000.0
34: (5 -> 33) 20.0 20000000.0
35: (6 -> 24) 20.0 20000000.0
36: (7 -> 34) 20.0 20000000.0
37: (8 -> 25) 20.0 20000000.0
38: (9 -> 35) 20.0 20000000.0
39: (10 -> 26) 20.0 20000000.0
40: (11 -> 36) 20.0 20000000.0
41: (12 -> 27) 20.0 20000000.0
42: (13 -> 37) 20.0 20000000.0
43: (14 -> 28) 20.0 20000000.0
44: (15 -> 38) 20.0 20000000.0
45: (16 -> 29) 20.0 20000000.0
46: (17 -> 39) 20.0 20000000.0
47: (18 -> 30) 20.0 20000000.0
48: (19 -> 40) 20.0 20000000.0
49: (20 -> 31) 20.0 20000000.0
# lower horizontal bars
50: (1 -> 2) 20.0 20000000.0
51: (2 -> 3) 20.0 20000000.0
52: (3 -> 4) 20.0 20000000.0
53: (4 -> 5) 20.0 20000000.0
54: (5 -> 6) 20.0 20000000.0
55: (6 -> 7) 20.0 20000000.0
56: (7 -> 8) 20.0 20000000.0
57: (8 -> 9) 20.0 20000000.0
58: (9 -> 10) 20.0 20000000.0
59: (10 -> 11) 20.0 20000000.0
60: (11 -> 12) 20.0 20000000.0
61: (12 -> 13) 20.0 20000000.0
62: (13 -> 14) 20.0 20000000.0
63: (14 -> 15) 20.0 20000000.0
64: (15 -> 16) 20.0 20000000.0
65: (16 -> 17) 20.0 20000000.0
66: (17 -> 18) 20.0 20000000.0
67: (18 -> 19) 20.0 20000000.0
68: (19 -> 20) 20.0 20000000.0
69: (20 -> 21) 20.0 20000000.0
# upper horizontal bars
70: (32 -> 33) 20.0 20000000.0
71: (33 -> 34) 20.0 20000000.0
72: (34 -> 35) 20.0 20000000.0
73: (35 -> 36) 20.0 20000000.0
74: (36 -> 37) 20.0 20000000.0
75: (37 -> 38) 20.0 20000000.0
76: (38 -> 39) 20.0 20000000.0
77: (39 -> 40) 20.0 20000000.0

Listing 19-6:巴尔的摩桁架结构定义

请注意,在这段代码中,我们对每个节点施加相同的荷载,但我们省略了一些荷载行。如果你手动编写代码,应该包括这些荷载定义行。

让我们将定义这个大型结构的文件传递给我们的程序:

$ ./truss.sh --scale=0.75 --disp-scale=100 --load-scale=0.2
  < apps/truss_structures/baltimore.txt

程序生成的输出应该类似于以下内容:

Took 0.106 seconds to solve

即使是具有 40 个节点和 77 条杆件的巴尔的摩类型,计算时间也是一秒钟的一小部分。如果你打开solution.svg文件,你会看到类似于 Figure 19-9 的内容。

Image

Figure 19-9:巴尔的摩解法图

现在你已经完成了这一部分,花些时间玩玩你的应用程序。尝试使用不同的结构和参数来检查结果。

总结

在本章中,我们将之前章节中构建的所有结构分析模块整合成一个命令行应用程序,用于求解桁架结构。我们的应用程序从标准输入读取结构文件,并生成两个结果文件:一个是表示解的矢量图,另一个是包含所有相关数值的纯文本报告。

这是本书第五部分的最后一章。这几章内容很密集,但我希望结果是值得的。我们为文件制定了一个定义结构的格式,编写了一个函数将其解析到我们的模型中,实施了解决方案算法以生成解决方案模型,编写了一种将此解决方案导出为图表和文本报告的方式,最后将这一切整合成一个最终应用程序。

我们选择了一个求解桁架结构的应用来举例说明编写工程应用程序的过程,但我们本可以选择任何其他主题——如热传导、流体动力学、梁分析等。过程和技巧是相同的。你所学到的知识应该能让你编写适用于任何工程领域的代码。

这也是本书的最后一章。我希望你喜欢学习如何构建工程应用、将它们拆分成模块,并且当然也学会如何测试它们。剩下的就是开始创建你自己的应用了。正如本书介绍中所提到的,成为专家的唯一途径就是通过实践:构建很多应用,从错误中学习,然后再继续构建。祝你好运!

posted @ 2025-11-30 19:37  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报