很多刚接触 FreeCAD 二次开发的朋友,一上来就找“画齿轮的脚本”“做阵列的宏”,结果摸索半天发现 API 文档零零散散。我自己的经验是,与其在功能脚本里打转,不如先啃下最底层的一块骨头:Expression Framework(表达式框架)。它藏在src/App目录下,表面看只是给属性写写公式,实际上是 FreeCAD 参数化体系的数据动脉——草稿里的约束、表格里的别名、模型之间跨文档的引用,最后全都汇聚到这个框架里求值。这篇东西我会按源码拆解的顺序,从词法解析讲到求值器,再讲单位系统和实战调试,尽量做到让没翻过 C++ 源码的人也能跟上。
1. 模块定位:为什么 Expression Framework 值得先拆
1.1 它藏在哪,由哪些文件组成
FreeCAD 的源码目录里,Expression Framework 的核心文件集中在src/App下,没有独立成库,但它是一个相当完整的子模块。你打开Expression.h会看到一堆类名,Expression.cpp是主实现,另外还有ExpressionParser.y和ExpressionLexer.l这两个文件——一个是 bison 语法规则,一个是 flex 词法规则。初次接触的人容易被“.y”和“.l”后缀唬住,其实把它们理解为“一部字典”和“一套句法规则”就好:.l负责把字符串切成 token(数字、变量名、运算符、单位字符串),.y负责把这些 token 按照优先级组成一棵树。树的节点类型就是Expression.h里那些类。
除了这三个核心文件,还有ExpressionParser.h/cpp、ExpressionLexer.h/cpp,它们是由工具自动生成的代码。改语法时不要去碰生成文件,改.y和.l即可。这种“手写核心逻辑 + 工具生成解析代码”的组合,在桌面级开源项目里很常见,好处是语法演进快,代价是你得忍受生成代码的可读性极差。
1.2 为什么说它是参数化体系的神经中枢
FreeCAD 的文档里对可见参数化描述得不多,但实际建模时你一定会遇到这种现象:你想让一个 Pad 的高度等于另一个零件的长度,或者想让某个草图的约束角度等于某个 Spreadsheet 单元格的值。如果没有表达式系统,你只能记下数值、手动改、再手动检查,模型一变就全乱。有了它,任何属性都可以绑定一个公式,公式里可以引用同一个文档里任意对象的任意属性,甚至引用另一个文档里的对象。
很多人说“FreeCAD 没有齿轮工具”,其实更准确的说法是“FreeCAD 把造齿轮所需的所有参数化底层都暴露给了你”。齿轮是迭代设计出来的:模数、齿数、压力角建立数学关系,每个量都可以寄存在 Spreadsheet 里,然后草图和特征全部引用这些别名。做到了这一点,你就拥有了一台比固定齿轮工具更灵活的“参数化引擎”。Expression Framework 就是这个引擎的点火装置。
1.3 整体数据流:从属性编辑器到求值结果
我先把整个流程在脑海里画成一条线,方便后面对照源码:
- 用户在属性视图点击 fx 按钮,输入一个字符串,比如
Spreadsheet1.A1 * 2 mm; - FreeCAD 把字符串存到对象的 ExpressionBinding 里,并不立刻求值;
- 当对象被 recompute(重算)时,框架取出表达式字符串,交给词法/语法分析器生成 AST;
- AST 交给 ExpressionEvaluator 求值,过程中解析出对其它对象的引用,读取它们的属性;
- 求出的值带量纲(Quantity),做单位转换后写回目标属性。
这条线里最容易被忽略的是“表达式不会立刻求值”这一点。很多刚接触源码的人搜ExpressionFramework,看到一堆类名就放弃,其实只要抓住“存储字符串 -> 生成树 -> 遍历求值 -> 回写属性”这四个环节,整个框架就清楚了。
2. 词法与语法:表达式是怎样被“读懂”的
2.1 从 Token 到 AST 的两段式流程
拿一句表达式举例:sqrt(Spreadsheet.B2) + 3 mm。Lexer 做的第一件事是把字符串切开:sqrt是函数名,(是左括号,Spreadsheet.B2是变量路径,)是右括号,+是运算符,3是数字,mm是单位。这些被切出来的块就是 token。你可以在ExpressionLexer.l里看到每个 token 对应什么正则模式,例如数字和单位的组合会被解析成一个带Quantity的 token,而不是先拆成裸数字再拼单位。这个设计很关键,它让“单位”从一开始就是表达式的第一公民,而不是附加品。
Parser 拿到 token 流之后,依据ExpressionParser.y里的文法开始推导。3 mm这一整段会被当成一个单位表达式,而不是简单的数字加乘法;如果写3mm但后续又出现+ 5 deg,文法树里就会同时存在两个不同类型的量纲,求值阶段才决定抛不抛异常。
2.2 从.y文件看表达式语法结构
ExpressionParser.y里的内容本质上是一套 BNF 文法。你不需要会写 bison,只要会读规则即可。核心规则大致长这样:
expression: logicalOrExpression | expression '?' expression ':' expression // 三元条件 ; logicalOrExpression: logicalAndExpression | logicalOrExpression '||' logicalAndExpression ;运算符优先级是通过规则嵌套实现的:越靠底层的基础表达式优先级越高。所以1 + 2 * 3会先解析出“2*3”这个节点,再和“1+”组合。函数调用、括号、中括号索引也都作为独立规则存在,尤其是obj.Constraint[3].Value这种路径,词法里会把它切成一串点分 token,语法里再按“属性访问”递归挂到树上。
这块值得记住的心得是:FreeCAD 的表达式语法并不是一套从头设计的语言,它刻意向 Python 表达式靠拢。你看ExpressionParser.y里的名字——NumberExpression、StringExpression、VariableExpression、FunctionExpression、ConditionalExpression、RangeExpression——这些都是从编译原理教科书里走出来的经典节点类型。理解了这个,后面看Expression.cpp的求值器就轻松了。
2.3 单位是如何在语法层被“邀请”进来的
一般编程语言里,单位只是一个后缀,比如“mm”就是变量名。但在 CAD 场景里,1 mm + 2 cm必须是合法的,而且等于21 mm。FreeCAD 的做法是:在语法层面把“数字 + 单位字符串”合并成一个UnitExpression节点,这个节点内部持有Base::Quantity对象。Base::Quantity由数值和单位向量组成,比如长度和角度的单位向量就完全不同。
想看清楚单位怎么被邀请进来,去ExpressionLexer.l里找数字和单位的正则,再看.y里对unit expression的处理。运算时,节点的处理函数会调用Quantity的加减乘除方法,量纲会被自动传播。如果你写10 mm * 10 mm,求值结果是一个面积量纲的 Quantity,可以直接赋给一个面积属性,不需要你手动标注单位。这个设计极大地提高了表达式可读性,代价是调试时必须时刻注意量纲是否匹配。
3. 求值器核心:从 AST 到最终数值的最后一公里
3.1 ExpressionEvaluator 的工作流
表达式解析生成 AST 之后,真正干活的类是ExpressionEvaluator。它的职责简单说就是:给定一棵 AST、一个“作用域”对象,递归地计算出结果。你去看Expression.cpp,会发现每个表达式节点都有一个类似evaluate()或eval()的方法,ExpressionEvaluator只是个调度器,它根据节点类型把任务分发下去。比如遇到NumberExpression,直接返回数字常量;遇到BinaryExpression,先递归算左子树和右子树,再根据运算符号做加减乘除。
这里有个加分细节:很多节点的求值需要“上下文”,比如引用一个对象属性时,要知道这个对象在内存里的实际句柄。所以ExpressionEvaluator持有或关联一个ExpressionBinding,这个绑定关系把表达式里的名字映射到实际的 C++ 对象指针和属性名。打个比方,表达式里的Box.Height是“句子里的人名”,ExpressionBinding就是“对着名字在通讯录里找到本人”的动作;之后的evaluate()才相当于“跟本人问出数据”。
3.2 变量路径解析:一个点分路径是怎么被一步步拆开的
以Pad002.Sketch.Constraints[3].Value为例。求值器先看到最左边的Pad002,这通常是当前文档里的一个对象名。它先在绑定的对象图里找到名为Pad002的文档对象,然后进入它的属性表,找到Sketch属性——这个属性本身是一个链接,指向另一个对象Sketch。接下来走.Constraints,这会触发对Sketch的某个动态属性调用,返回一个约束列表,再用[3]做索引,最后读取Value。
在源码里,这一步对应着PropertyLink、PropertyFloat、PropertyInteger等属性类型的读取。实现上,FreeCAD 会把对象包装成Py::Object,通过 Python 绑定层去读取属性。这听着绕,但你可以理解成:C++ 世界玩不转“运行时字符串属性查找”,干脆借助 Python 的动态性,把属性读取变成了getattr(py_object, path)式的调用。这样做既有性能开销,又换来了巨大的灵活性——用户在表达式里能访问的属性范围,和 Python 脚本能访问的基本一致。
3.3 作用域与 Python 桥接:表达式能“看到”哪些东西
作用域决定了一个表达式里直接写裸名(比如Spreadsheet1)时,这个名字从哪里来。FreeCAD 的做法是:以“文档 + 对象”为边界,注入一组命名变量。当前文档的对象的裸名可以直接用,跨文档引用则需要更长的路径。ExpressionBinding中会维护一个映射表,把对象名映射到对象句柄;别名(Alias)也被注册进去,所以你在表达式里写Length,实际可能是某个 Spreadsheet 单元格的别名。
这里有一个重要的历史背景:早期的 FreeCAD 表达式引擎直接调用 Python 的eval(),快到不可思议但也极不安全——表达式可以调用任意 Python 函数,文档打开一条恶意 URL 就可能被读写文件。现在这个框架已经改成“原生 AST 求值为主,Python 桥接为辅”。你去看 Evaluation 相关代码,会发现它先把表达式转成 C++ 节点树,再判断哪些节点需要与 Python 对象交互。这个改造一方面提高了速度(省去反复进出 Python 解释器),另一方面堵住了安全口子。对开发者而言,这意味着你不能在表达式里随便调用os.system了,但可以调 FreeCAD 内置的那组数学和几何函数。
4. 表达式与单位系统:FreeCAD 的真正的杀手锏
4.1 一套贯穿全局的量纲体系
CAD 参数化里最怕什么?最怕“20”这种魔术数字。20是毫米还是英寸?是角度还是长度?FreeCAD 的Base::Unit设计彻底避免了这种歧义。它内部用整数指数表示 7 个基本量纲:长度、质量、时间、电流、温度、发光强度、物质的量。你可以想象每一个 Quantity 都带着一个“量纲向量”。10 mm的量纲向量在长度轴上是 1,其它轴上是 0;5 s的量纲向量在时间轴上是 1。加减法要求两边的量纲向量完全一致,乘法相当于向量相加,除法相当于向量相减。这就是你在表达式里写10 mm + 5 deg会报错的原因——角度与长度在量纲向量上压根不是同一维。
4.2 量纲不匹配时,框架为什么“故意”报错
刚开始用表达式的人都会遇到一次:10 mm + 5 deg为什么会报错?我当时也很烦。后来意识到这套设计恰恰是参数化的底线。如果框架允许你稀里糊涂地把角度和长度加在一起,到了三维模型里就会生成一个扭曲的实体,而且你根本找不到错在哪儿。FreeCAD 宁可让文档显示Invalid expression,也不愿算出“一个既不是角度也不是长度的魔法数”。同理,sqrt(Quantity)在数学上没问题,但框架会检查结果量纲:如果对面积量纲求平方根,会返回长度量纲;如果对角度量纲求平方根,直接拒绝,因为几何上无意义。
我把量纲匹配的几种情形整理成下表:
| 表达式 | 结果 | 是否允许 |
|---|---|---|
10 mm + 5 cm | 60 mm(自动转换) | 允许,长度+长度 |
10 mm + 5 deg | 报错 | 不允许,长度+角度 |
10 mm * 5 mm | 面积量纲 Quantity | 允许,量纲相乘 |
sin(30 deg) | 实数 0.5 | 允许,角度作为三角函数参数 |
sin(30 mm) | 报错 | 不允许,长度不能作为角度函数参数 |
后面两种情形在源码里对应“函数签名里的量纲约束”。每个内置函数不光标记参数个数,还会标记参数期望的量纲类型。这点看着繁琐,但用久了你会发现,它让表达式本身变成了一种“可计算文档”。
4.3 函数扩展点:让表达式支持新函数
如果你的二次开发需要让表达式支持一个自定义函数,比如myGearModule(z, angle),该怎么做?源码层面有两条路。第一条是在 C++ 层注册一个新的FunctionExpression处理函数:在Expression.cpp里的函数表里增加条目,描述函数名、参数个数、量纲要求以及实际的计算回调。这条路最严谨,但要重新编译 FreeCAD,发布给你的用户还需要替换整个程序,适合深度定制。
第二条路是所谓“代理函数”:利用 FreeCAD 自带的 Python 模块,在表达式系统查找函数时,把未识别的函数名转发到已注册的 Python 函数。FreeCAD 提供了一套接口,你可以把模块里的某个普通函数注册为表达式可调用函数。这条路的优点是无需重新编译,缺点是没有量的约束检查,你必须自己在 Python 函数里验证量纲是否合法。我用过第二条路做齿轮参数计算,整体体验是:小范围工具完全够用,但生产级插件最好还是走 C++ 带量纲签名的方式,用户一旦输入错单位,C++ 层能给出比 Python 更规范的报错。
5. 应用场景与扩展:Spreadsheet、约束、跨文档
5.1 三大经典应用场景的源码视角
- Spreadsheet:你可以建一个电子表格,给单元格写别名(Alias),然后在别处用
Spreadsheet1.AliasName来引用。这一步看着普通,但在源码里对应着 Spreadsheet 对象把别名注册进 ExpressionBinding 的动作。别名机制的价值在于:它把“单元格坐标”这种实现细节抽象成了语义化的变量名,代码审查和模型维护都轻松得多。 - Sketch 约束:草图约束里的数值,比如线段长度、角度,也可以写表达式。源码里约束对象提供了一组带索引的属性,比如
Constraints[0].Value,表达式可以指向这些属性。这样你可以用一个全局参数驱动多个约束,而不必逐个改草图。 - 属性面板 fx 按钮:这是最直接的入口。
Box.Height处点 fx,输入Spreadsheet1.Thickness * 2,回车后属性值会立刻显示计算结果。源码上,这个操作实际是调用了对象的setExpression()方法,签名里传入了属性路径和表达式字符串。这个方法才是把表达式真正“存”进对象的地方,UI 只是它的壳。
5.2 跨文档引用:#语法背后的解析链路
跨文档引用是很多人容易忽略的功能,也是 Expression Framework 里做得比较精致的地方。语法形态大致是<<另外一份文档的文件名>>#Object.Property。<<>>里放文档名,#后面放对象路径。Lexer 看到<<和>>就切换到“文件名模式”,不再把空格当成分隔符,所以文件名里带空格也没问题。
实现链条上是这样工作的:解析阶段,DocumentLinkExpression把<<文件名>>解析成一个指向外部文档的引用;求值阶段,ExpressionEvaluator发现这个节点后,会去外部文档的对象容器里查找对象名。这个“先跳文档,再找对象,再找属性”的过程就是跨文档参数化联动的核心。实际体验上,我建议只在文档结构非常稳定的场景使用它,比如标准件库。如果文档移动了路径或改了文件名,所有跨文档引用都会断掉。
5.3 给表达式框架加自定义函数的完整路径
以“给表达式加一个齿轮模数计算函数”为例,我第一次走通这套扩展后,理解了为什么说 FreeCAD 适合做 CAD 自动化平台。
- 第一步:在
Expression.cpp的函数注册表附近添加一个函数类,派生自某个内部函数基类,重写它的数量与量纲检查逻辑; - 第二步:实现求值回调。回调里拿到参数列表,可能是
Quantity类型,也可能是整数或字符串,按业务逻辑计算出返回Quantity; - 第三步:注册函数名。这一步一般涉及修改
ExpressionParser.y,不过 FreeCAD 做了优化,较新版本里你可以通过接口在运行时注册而不必重写词法规则; - 第四步:在单位检查器里声明参数和返回值量纲。拿齿轮举例,参数是“齿数”(无量纲)和“模数”(长度量纲),返回值是“分度圆直径”(长度量纲)。
整个过程最容易被卡住的是第三步,因为直接改.y后需要重新生成解析器。如果你不想碰 bison,可以优先研究一下运行时注册函数表这套 API,它能让你用很短的开发时间把函数接入表达式语法。这个模式的精髓在于:你不用为每一个业务功能重写一套表达式语言,而是复用一套成熟的语言框架,只插入你的计算逻辑。
6. 调试与避坑:我在源码和实战里踩过的坑
6.1 调试工具与工作流
看这种底层框架的源码,调试工具比阅读更重要。我的三板斧是:
- Python Console 直接调 API。
obj.setExpression('Height', 'Spreadsheet1.A1 * 2')、obj.getExpression('Height')这两对命令能让你在不启动图形界面的情况下测试表达式。我是先建一个临时文档,塞一个 Box 和一个 Spreadsheet,然后在 Console 里反复改表达式、触发 recompute,用.Value查看结果,排错效率极高。 - gdb 打断点。如果你改过
.y或者.cpp,想知道表达式是在哪一步挂掉的,就在ExpressionEvaluator::eval()和Expression::eval()打断点。每次 recompute 都会走进来,配合bt可以看到完整路径。 - 临时表达式验证量纲。如果你怀疑是量纲不匹配,先在 Python Console 里用
App.Units.Quantity('10 mm') + App.Units.Quantity('5 cm')手动算一遍。这一步能快速判断问题出在量纲乘法还是单位转换上。
6.2 常见错误速查表
| 症状 | 原因 | 解决办法 |
|---|---|---|
| 表达式保存后属性不更新 | 对象没有被标记为需要重算 | 调用obj.touch()或触发文档 recompute |
| 明明引用了属性却提示找不到 | 属性路径大小写不匹配 | FreeCAD 属性名下标的引用是大小写敏感的,核对属性视图里的准确名称 |
| 删除了 Spreadsheet 后一堆公式变红 | 表达式里硬引用已删除对象 | 不要硬删被引用对象,先清除或改写所有引用表达式 |
Cyclic reference错误 | A 引用 B,B 又直接或间接引用 A | 检查表达式关系链,把循环拆成单向依赖 |
Units mismatch报错 | 相加的两项量纲不一致 | 统一单位,或显式使用convert()类函数 |
结果返回nan或极大值 | 索引越界或参数为负值 | 检查中括号索引、函数签名负数是否合法 |
6.3 性能与缓存经验
表达式框架不是每次视图刷新就重算的,它挂在 recompute 机制上。但如果你在大型装配里用 Spreadsheet 做了上千条公式,每次拓扑变化都可能触发大批重算。我遇到过一个 200 个零件的装备模型,修改一个全局参数后重算耗时十几秒。用 gdb 一追,发现大量时间都花在“解析表达式字符串 -> 生成 AST -> 求值”的重复劳动上。
FreeCAD 对表达式有一定的缓存机制,但它不会缓存 AST。一个比较实用的优化思路是:把复杂的复合表达式拆成多个中间 Spreadsheet 单元格。比如把“齿轮节圆直径 = 模数 × 齿数”单独存一格,再用其它公式引用这个中间量。这样既能缩短单个表达式的解析时间,也让引用关系更直观。另一个技巧是尽量把常量写在名称里,不要让表达式去引用“那个值等于 5 的格子”——因为每次改动那个格子,整条依赖链都会重算。
我个人在实际操作中还发现,批量修改表达式的最佳路径不是用 UI,而是写一个 Python 脚本遍历对象、调用setExpression()。UI 会让你一个个编辑,脚本却能把“修改 20 个零件的 60 条公式”压缩成一秒内完成。这时候你会真正感受到:Expression Framework 不只是给用户在属性面板里写字用的,它是一个可以编程操控的数据流引擎。
结尾:几个绕不开的体会
如果你问我在 FreeCAD 源码里翻 Expression Framework 的最大收获,我的答案不是看懂了某个数据结构,而是理解了“为什么 FreeCAD 敢说自己是一个开源 CAD 平台”。一个纯粹依赖硬编码参数功能的 CAD,加一个齿轮工具只是多一个按钮;而一个把表达式、单位、对象属性全部打通成数据流的 CAD,任何设计规则都可以变成一段公式。开发者的价值不再体现在“添加固定功能”,而是体现在“把领域知识翻译成参数化逻辑”。
最后分享一个我在实践里养成的习惯:写任何自定义属性绑定之前,先花十分钟在源码里查一遍Property*的类型和setExpression()的签名变化。这个框架在版本迭代中改过不少东西,在较新版本里,表达式求值性能和单位检查比老版本严格得多。你只有站在最新源码上看清楚了,才能避免把建立在旧行为上的脚本带到生产环境里。希望这篇拆解能让你少走一些我当时绕过的弯路。