1. 从一句话到三维模型:text-to-cad 到底在解决什么问题
第一次听到 text-to-cad 这个词,很多人脑子里冒出来的画面大概是:对着电脑敲一句“给我画个齿轮”,然后屏幕上就自动出现一个可以旋转、可以导出、可以拿去加工的三维模型。这个想象不算离谱,但真正落地的时候,它解决的问题比“自动画图”要具体得多,也有意思得多。
text-to-cad 本质上是一条从自然语言描述到 CAD 几何模型的自动化链路。你输入的不是坐标、不是草图约束、不是拉伸参数,而是一段人话,比如“一个外径 60mm、内径 20mm、厚度 10mm 的圆环,中心开四个直径 5mm 的均布孔”。系统需要理解这段话里的几何语义,把它翻译成结构化的建模指令,再驱动几何内核生成实体,最后导出成 STEP、STL 或者 URDF 这类下游能直接消费的格式。它服务的人群其实很明确:一类是经常要做参数化建模、但不想每次都手动点草图的人;另一类是做机器人仿真、需要批量生成 URDF 模型的工程师;还有一类是想把 CAD 能力嵌进自己 Python 工具链里的开发者。
我之所以对这个方向感兴趣,是因为它踩中了一个真实的痛点。传统 CAD 建模的交互成本很高,画一个简单零件,你得选基准面、画草图、标尺寸、加约束、拉伸、倒角,一套流程下来十几步。如果只是做一次,那无所谓;但如果你要生成一百个尺寸略有差异的零件,或者要把建模逻辑接进一个自动化流程,手动操作就完全不可接受了。text-to-cad 的价值就在于把这套流程变成可编程、可批量、可复现的。它不是一个要取代 CAD 工程师的东西,而是一个把重复劳动自动化掉的工具。
这篇文章我会从整体设计思路讲起,然后拆到几何解析、Python 建模、STEP 与 URDF 导出这几个核心环节,再给出一套可以直接抄的实操流程,最后把我踩过的坑和排查经验整理出来。不管你是刚接触 Python 建模的新手,还是已经在做参数化设计的老手,应该都能从里面找到能直接用的东西。
2. 整体设计与技术选型:为什么是 Python 加几何内核
2.1 核心链路的四个阶段拆解
把 text-to-cad 拆开看,它其实是一条四段式的流水线,每一段都有明确的输入和输出,段与段之间靠结构化数据衔接。
第一阶段是语义解析。输入是自然语言,输出是结构化的参数字典。比如“外径 60、内径 20、厚 10 的圆环”会被解析成{"type": "ring", "outer_d": 60, "inner_d": 20, "thickness": 10}。这一步现在主流做法是用大语言模型做意图识别和槽位抽取,让它输出 JSON。为什么用 JSON 而不是直接让它写建模代码?因为 JSON 是可校验、可复现的中间层,模型偶尔抽风输出个语法错误的代码你很难兜底,但 JSON 你可以做 schema 校验,字段缺了、类型错了都能拦住。
第二阶段是几何构建。输入是参数字典,输出是内存里的三维实体对象。这一步必须依赖一个靠谱的几何内核,因为你要处理布尔运算、倒角、抽壳这些真正的几何操作,纯靠手算顶点坐标是不现实的。
第三阶段是格式导出。把内存实体写成 STEP、STL、URDF 等文件。STEP 是通用交换格式,STL 是网格格式,URDF 是机器人描述格式,三者用途完全不同,后面会细讲。
第四阶段是校验与反馈。检查生成的模型是否封闭、体积是否合理、有没有自相交,把结果反馈给用户或者写进日志。
提示:这四个阶段一定要解耦。我见过有人把解析和建模写在一个函数里,结果换个几何类型就要改一大片代码,维护成本极高。分层的意义在于,解析层可以独立替换(今天用这个模型,明天换那个),建模层可以独立测试。
2.2 为什么选 Python 而不是别的语言
选 Python 做这条链路,理由很实在。第一,几何内核的 Python 绑定最成熟,CadQuery、build123d 这些库把 OpenCASCADE 的能力封装得很好,你不需要碰 C++ 就能做实体建模。第二,Python 在数据处理和 AI 生态上无可替代,解析自然语言、调模型、做参数校验,全都在一个语言里搞定,不用跨语言传数据。第三,脚本化程度高,一个.py文件就是一套可复现的建模流程,扔进 CI 里跑批处理毫无压力。
对比一下其他选择:直接用 CAD 软件的二次开发接口(比如某些软件的脚本 API),问题是绑定死、跨平台差、批处理麻烦;用纯数学库手搓几何,问题是布尔运算和曲面处理会让你怀疑人生。所以 Python 加成熟几何内核,是当前性价比最高的组合。
2.3 几何内核选型:CadQuery 与 build123d 的取舍
几何内核这块,绕不开 OpenCASCADE(简称 OCCT),它是工业级的开源几何内核,STEP 读写、布尔运算、倒角抽壳都靠它。但直接用 OCCT 的 Python 绑定太底层了,所以有了更高层的封装库。
| 库 | 定位 | 优势 | 适合场景 |
|---|---|---|---|
| CadQuery | 脚本化建模 | 文档全、社区大、链式 API 顺手 | 参数化零件、批量生成 |
| build123d | 新一代封装 | API 更 Pythonic、上下文管理器友好 | 复杂装配、新项目 |
| 直接调 OCCT | 底层控制 | 灵活度最高 | 特殊几何、性能敏感 |
我个人的选择是:新项目优先 build123d,因为它的上下文管理写法更接近人的建模直觉;如果团队里已经有人用 CadQuery,那就继续用,没必要为了新而新。两者底层都是 OCCT,导出的 STEP 质量没有本质差别。
2.4 输出格式的定位差异
很多人搞不清 STEP、STL、URDF 该在什么时候用,这里一次性说清楚。
STEP 是精确边界表示格式,它记录的是数学曲面和实体的拓扑关系,文件里存的是“这是一个半径 30 的圆柱面”,而不是一堆三角面片。所以 STEP 可以无损地再次导入 CAD 软件继续编辑,是做交换和存档的首选。
STL 是三角网格格式,它把模型表面离散成一堆三角形。优点是几乎所有 3D 软件和 3D 打印机都认,缺点是精度有限,圆会变成多边形,而且丢了拓扑信息,没法直接参数化编辑。
URDF 是机器人描述格式,它描述的不是单个零件,而是由多个连杆(link)和关节(joint)组成的运动学树。它引用 STL 或 STEP 作为几何外观,同时定义每个关节的类型、轴向、限位。做机器人仿真、导入 CoppeliaSim 这类平台时,URDF 才是正确的交付物。
注意:如果你的目标是机器人仿真,光导出 STEP 是不够的,必须把几何和运动学结构一起组织成 URDF,否则导入仿真环境后模型是一堆散件,动不起来。
3. 核心细节解析:从自然语言到几何参数
3.1 自然语言解析的关键:把模糊描述变成确定参数
自然语言解析这一步,难点不在于“听懂”,而在于“补全”。用户说“一个厚一点的圆盘”,这个“厚一点”是没法直接建模的,你必须要么追问,要么给一个合理的默认值。我的做法是定义一套参数 schema,每个几何类型有哪些必填字段、哪些可选字段、默认值是多少,全部写死。
以圆环为例,schema 大概长这样:
RING_SCHEMA = { "type": "ring", "required": ["outer_d", "inner_d", "thickness"], "optional": {"fillet": 0.0, "hole_count": 0, "hole_d": 0.0}, "defaults": {"unit": "mm"} }解析出来的 JSON 先过一遍 schema 校验,缺必填字段就报错让用户补,可选字段缺失就填默认值。这样做的好处是,建模层拿到的永远是完整、类型正确的参数,不用在建模代码里到处写if xxx is None。
单位处理是个容易被忽略的坑。用户可能说“直径 6 厘米”,也可能说“直径 60”,你必须统一到毫米。我的做法是在解析层做单位归一化,识别到“厘米”“米”“英寸”就换算成毫米,建模层只认毫米。这一步不做,后面尺寸错十倍你都发现不了。
3.2 参数校验:把错误拦在建模之前
参数校验不是可选项,是必须项。我见过太多因为参数不合理导致建模直接崩溃的情况,比如内径大于外径、厚度为负数、孔径大于零件尺寸。这些错误如果等到几何内核报错,堆栈信息往往很难看懂,不如在建模前自己拦。
校验规则我一般分三类。第一类是数值范围,比如直径必须大于 0,厚度必须大于 0。第二类是逻辑关系,比如内径必须小于外径,孔的数量必须是正整数。第三类是工艺合理性,比如壁厚不能小于某个值,否则实际加工不出来。第三类偏经验,可以给警告而不是直接报错。
def validate_ring(params): if params["outer_d"] <= 0: raise ValueError("外径必须大于 0") if params["inner_d"] >= params["outer_d"]: raise ValueError("内径必须小于外径") if params["thickness"] <= 0: raise ValueError("厚度必须大于 0") wall = (params["outer_d"] - params["inner_d"]) / 2 if wall < 1.0: print(f"警告:壁厚仅 {wall}mm,实际加工可能偏薄")3.3 几何构建的核心操作与顺序
几何构建这块,操作顺序非常关键,顺序错了结果就完全不对。以带孔的圆环为例,正确的顺序是:先做外圆柱,再减内圆柱得到圆环,最后减掉均布的小孔。为什么孔要最后做?因为如果你先打孔再做布尔减,孔的位置基准可能会因为后续操作发生偏移,而且先做主体再打孔,布尔运算的稳定性更好。
均布孔的位置计算是个小数学题。假设孔数量为 n,分布半径为 r,第 i 个孔的中心坐标是:
import math def hole_positions(count, radius): positions = [] for i in range(count): angle = 2 * math.pi * i / count x = radius * math.cos(angle) y = radius * math.sin(angle) positions.append((x, y)) return positions这里有个细节:分布半径 r 应该是外径和内径的中间值,也就是(outer_d + inner_d) / 4,这样孔才落在圆环的实体区域中间。如果直接用外径算,孔会跑到边缘外面去。
3.4 布尔运算的稳定性经验
布尔运算是几何建模里最容易出问题的地方。两个实体如果刚好共面、共边,布尔运算就可能产生退化面或者失败。我的经验是,做布尔减的时候,让被减的实体稍微“穿透”一点,不要刚好贴合。比如你要在一个 10mm 厚的板上打一个通孔,孔的深度设成 12mm 而不是 10mm,让它穿出去,这样布尔运算更稳。
另一个经验是,尽量用简单实体做布尔,避免用已经做过多次布尔运算的复杂实体再去做运算,误差会累积。如果必须做,中间结果可以先导出 STEP 再重新导入,相当于“重置”一下几何数据。
4. 实操过程:用 Python 生成模型并导出 STEP 与 URDF
4.1 环境准备与依赖安装
先把环境搭起来。Python 建议用 3.9 以上版本,太老的版本有些库装不上。依赖主要就是几何库和数值库。
pip install cadquery pip install numpy如果你用 build123d,就换成pip install build123d。numpy 主要是做坐标计算用的,均布孔位置、矩阵变换都靠它。装完之后跑一句import cadquery确认没报错,如果报错大概率是 OCCT 的动态库没加载上,这种情况在 Linux 上比较常见,需要装一下系统级的依赖。
提示:Windows 上装 cadquery 有时候会遇到编译问题,建议直接用 conda 装,
conda install -c conda-forge cadquery,省去编译的麻烦。
4.2 完整建模脚本:从参数到 STEP
下面是一个完整的圆环建模脚本,从参数字典到导出 STEP,可以直接跑。
import cadquery as cq import math def build_ring(params): outer_r = params["outer_d"] / 2 inner_r = params["inner_d"] / 2 thickness = params["thickness"] # 外圆柱 result = cq.Workplane("XY").circle(outer_r).extrude(thickness) # 减内圆柱,深度多给一点保证穿透 result = result.faces(">Z").workplane().circle(inner_r).cutThruAll() # 均布孔 hole_count = params.get("hole_count", 0) hole_d = params.get("hole_d", 0) if hole_count > 0 and hole_d > 0: mid_r = (outer_r + inner_r) / 2 positions = [] for i in range(hole_count): angle = 2 * math.pi * i / hole_count positions.append((mid_r * math.cos(angle), mid_r * math.sin(angle))) result = ( result.faces(">Z").workplane() .pushPoints(positions) .circle(hole_d / 2) .cutThruAll() ) return result params = { "outer_d": 60.0, "inner_d": 20.0, "thickness": 10.0, "hole_count": 4, "hole_d": 5.0, } model = build_ring(params) cq.exporters.export(model, "ring.step") print("STEP 导出完成")这段代码里几个关键点值得说。cutThruAll()是穿透切除,比手动指定深度更省心。pushPoints()一次性传入所有孔位,比循环打孔效率高,而且布尔运算只做一次,稳定性更好。导出用cq.exporters.export,根据文件后缀自动判断格式,写.step就是 STEP,写.stl就是 STL。
4.3 导出 STL 用于仿真与 3D 打印
STL 导出更简单,但有个精度参数要注意。
cq.exporters.export(model, "ring.stl", tolerance=0.01, angularTolerance=0.1)tolerance是线性偏差,值越小网格越密、文件越大。angularTolerance是角度偏差,控制圆弧的离散精度。做 3D 打印的话,0.01mm 的线性偏差足够了;做机器人仿真的话,可以放宽到 0.05mm,减小文件体积,加快仿真加载速度。这两个参数不设的话会用默认值,有时候圆看起来会有点棱角,调小一点就顺滑了。
4.4 构建 URDF:把几何变成机器人模型
URDF 这块是很多人卡住的地方。URDF 描述的是连杆和关节的树状结构,每个连杆可以引用一个网格文件作为外观。一个最简单的单连杆 URDF 长这样:
<?xml version="1.0"?> <robot name="ring_robot"> <link name="base_link"> <visual> <geometry> <mesh filename="ring.stl" scale="0.001 0.001 0.001"/> </geometry> </visual> <collision> <geometry> <mesh filename="ring.stl" scale="0.001 0.001 0.001"/> </geometry> </collision> <inertial> <mass value="0.5"/> <inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/> </inertial> </link> </robot>这里有个大坑:单位。URDF 默认单位是米,而 CAD 建模通常用毫米,所以 mesh 的 scale 要设成 0.001,把毫米转成米。这个 scale 不设或者设错,导入仿真环境后模型要么大得离谱,要么小得看不见。我一开始就栽在这上面,模型导进去只有一粒米那么大,找了半天才发现是单位问题。
visual是外观,collision是碰撞体,inertial是惯性参数。做仿真的话这三个都要有,尤其是惯性参数,没有的话物理引擎算出来的动力学是错的。惯性参数可以简化估算,质量按体积乘密度算,转动惯量用近似公式,精度要求不高的话够用。
4.5 多连杆装配与关节定义
如果模型不止一个零件,就要定义关节把它们连起来。关节类型主要有revolute(旋转)、prismatic(滑动)、fixed(固定)。一个旋转关节的定义:
<joint name="joint1" type="revolute"> <parent link="base_link"/> <child link="arm_link"/> <origin xyz="0 0 0.05" rpy="0 0 0"/> <axis xyz="0 0 1"/> <limit lower="-1.57" upper="1.57" effort="10" velocity="1.0"/> </joint>origin定义关节在父连杆坐标系里的位置和姿态,axis是旋转轴,limit是运动限位。这几个参数必须和实际几何对应上,否则仿真里模型会以奇怪的方式运动。我的做法是先在 CAD 里量好各零件的相对位置,再填到 origin 里,不要凭感觉写。
5. 常见问题与排查技巧实录
5.1 建模阶段的典型报错与处理
几何建模的报错信息往往很晦涩,我整理了几个高频问题和对应处理方式。
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 布尔运算失败 | 实体共面或退化 | 让切除实体穿透,避免刚好贴合 |
| 导出 STEP 后打开是空 | 实体不是封闭体 | 检查是否所有面都闭合,用isValid()校验 |
| 圆看起来有棱角 | 离散精度不够 | 调小 tolerance 和 angularTolerance |
| 尺寸差十倍 | 单位没统一 | 解析层统一换算成毫米 |
| 孔位置偏移 | 分布半径算错 | 用内外径中间值算分布半径 |
isValid()这个校验很值得加,导出前跑一遍,能提前发现很多问题。
if not model.val().isValid(): raise RuntimeError("生成的实体无效,请检查参数")5.2 URDF 导入仿真环境的常见坑
URDF 导入 CoppeliaSim 这类环境时,最常见的三个问题是:模型不可见、模型位置不对、模型动不了。
模型不可见,九成是单位问题,检查 mesh 的 scale 是不是 0.001。模型位置不对,检查 joint 的 origin 和 link 的坐标系原点是否一致,很多时候是建模时零件不在原点,导入后就偏了。模型动不了,检查 joint 类型和 axis 是否正确,fixed 类型的关节是不会动的,如果你想要它动,得改成 revolute 或 prismatic。
还有一个隐蔽的坑:mesh 文件路径。URDF 里引用的 mesh 路径可以是相对路径也可以是绝对路径,相对路径是相对于 URDF 文件所在目录。如果你把 URDF 和 STL 放在不同目录,路径写错就加载不出来。我的习惯是把 URDF 和所有 mesh 放同一个目录,用相对路径引用,整个文件夹一起拷贝,不会出问题。
5.3 批量生成的性能与稳定性经验
做批量生成的时候,性能和稳定性都要考虑。性能上,几何内核的布尔运算比较吃 CPU,如果一次要生成几百个模型,建议用多进程并行,每个进程独立处理一批,避免单进程串行太慢。稳定性上,批量任务里只要有一个模型参数异常导致崩溃,整个批次就挂了,所以每个模型都要包在 try-except 里,失败的记录下来跳过,不要让它影响其他模型。
import traceback results = [] for params in param_list: try: model = build_ring(params) if model.val().isValid(): cq.exporters.export(model, f"out/{params['id']}.step") results.append((params["id"], "ok")) else: results.append((params["id"], "invalid")) except Exception as e: results.append((params["id"], f"error: {e}")) traceback.print_exc()这样跑完你能拿到一份完整的成功失败清单,哪几个失败了、失败原因是什么,一目了然,方便针对性修复。
5.4 参数化设计的几个实用心得
最后分享几个参数化设计上的心得。第一,参数命名要语义化,用outer_d而不是d1,过两个月你自己都忘了 d1 是什么。第二,默认值要合理,用户不填的时候给一个能跑通的默认值,比直接报错体验好得多。第三,保留中间产物,建模过程中的草图、中间实体可以导出留档,出问题的时候方便回溯。第四,版本化你的参数 schema,schema 改了要记下来,不然老参数配新代码会出各种诡异问题。
我个人在实际操作中的体会是,text-to-cad 这条链路真正难的不是让模型“画出来”,而是让它在各种边界情况下都“画得对、画得稳”。自然语言是模糊的,几何是精确的,中间这层翻译和校验做扎实了,整个系统才可靠。如果你也在做类似的东西,建议先把参数 schema 和校验规则定清楚,再往上接语言解析,这样地基稳,后面加功能才不会塌。