ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

text-to-cad 实战:从文本描述到 STEP 与 URDF 的完整链路

text-to-cad 实战:从文本描述到 STEP 与 URDF 的完整链路

1. 从一段文字到三维实体:text-to-cad 到底在解决什么问题

第一次听到 "text-to-cad" 这个词,很多人脑子里浮现的画面大概是:对着电脑敲一句"给我画一个法兰盘",然后屏幕上就自动长出一个三维模型。这个想象不算离谱,但真正落地的时候,它解决的问题比"自动画图"要具体得多,也琐碎得多。

我接触这个方向是从一个很实际的需求开始的:手头有一批参数化的机械零件,尺寸规格经常变,每次改动都要在 CAD 软件里手动调草图、改约束、重新拉伸,改一个零件十分钟,改五十个零件就是大半天。更麻烦的是,这些零件还要拿去做运动仿真,需要导出成 URDF 给仿真环境用,手动导出的过程又是一堆重复劳动。于是我开始琢磨,能不能用一段结构化的文本描述,直接把模型生成出来,顺带把 STEP 和 URDF 一起吐出来。

text-to-cad 的核心,就是把"自然语言或结构化文本"翻译成"CAD 能理解的几何与拓扑描述",再驱动几何内核生成实体,最后导出成通用格式。它不是一个单一工具,而是一条链路:文本解析 → 参数提取 → 几何构建 → 格式导出。关键词里出现的 CAD、STEP、URDF、Python,正好对应了这条链路的四个关键环节——CAD 是目标领域,STEP 是通用的三维交换格式,URDF 是机器人仿真用的描述格式,Python 则是把这一切串起来的胶水语言。

这篇文章适合谁看?如果你是会一点 Python、又经常和 CAD 打交道的人,比如做机械设计、机器人仿真、参数化建模、批量出图的工程师,那这篇内容基本就是为你写的。如果你完全没碰过 CAD,但想理解"文本生成三维模型"这件事的技术轮廓,也能看懂大半。我会把每一步为什么这么做、坑在哪里、怎么绕过去都讲清楚,尽量让你看完能自己动手跑一遍。

需要先说明一点:text-to-cad 目前没有哪个方案能做到"随便说句话就出完美模型"。自然语言的歧义太大,"一个圆角"到底是 R2 还是 R5,机器猜不出来。所以真正可用的方案,几乎都是"结构化文本 + 参数"的路子,而不是纯自由文本。这一点想通了,后面的技术选型就顺了。

2. 文本描述怎么变成几何:解析层的设计取舍

2.1 为什么纯自然语言解析在工程上不靠谱

先说一个反直觉的结论:在 text-to-cad 这条链路上,最不该花大力气的地方,恰恰是"自然语言理解"。我一开始也想着搞个模型来解析"画一个直径 50、厚 10 的圆盘",后来发现纯属给自己找麻烦。

原因很直接。工程模型对尺寸是零容错的,直径 50 和直径 50.0 在语义上一样,但在几何内核里必须是一个确定的值。自然语言里"大概五十"、"五十左右"这种表述,人一看就懂,机器一解析就抓瞎。更别说"倒个角"这种话,连人都不确定是哪个边、多大半径。与其让模型去猜,不如把输入约束成结构化格式,让解析变成确定性的字符串处理。

所以我的做法是:定义一个轻量的文本 DSL(领域特定语言),用键值对加层级缩进来描述模型。比如一个带孔的法兰盘,输入长这样:

part flange type: cylinder diameter: 120 height: 15 hole: count: 6 diameter: 10 pitch_diameter: 90 fillet: radius: 2 edges: top_outer

这种格式的好处是解析逻辑极其简单,用 Python 的正则或者configparser就能搞定,不需要任何模型推理,速度快、结果稳定、可复现。你可能会问,那这不就是配置文件吗,跟"text-to-cad"有什么关系?关系在于,这个文本是可以由上层再生成的——你可以让一个语言模型把用户的自由描述转成这个 DSL,但 DSL 到几何这一段必须是确定性的。把不确定的部分和确定的部分切开,是整个系统稳定的关键。

2.2 参数提取阶段最容易忽略的单位与坐标系问题

解析出参数只是第一步,真正让新手翻车的是单位和坐标系。CAD 内核里长度单位可以是毫米、厘米、米,不同内核默认值还不一样。我踩过的坑是:文本里写diameter: 120,心里想的是毫米,结果导出 STEP 之后在另一个软件里打开变成了 120 米,模型大得看不见。

解决办法是在 DSL 里强制声明单位,并且在解析层做统一换算,所有内部计算一律用毫米,导出时再按目标格式转换。别小看这一条,STEP 文件本身对单位是有约定的,URDF 则默认用米,这两个格式之间来回倒,单位不统一必然出错。

坐标系的问题更隐蔽。CAD 建模通常以 Z 轴为拉伸方向,而 URDF 里机器人关节的旋转轴、连杆的朝向有自己的一套约定。如果你生成的模型要直接进仿真,坐标原点放在哪里、Z 轴朝哪,必须提前想清楚。我的经验是:建模阶段统一用"底面中心为原点、Z 轴向上"的约定,导出 URDF 时再统一做一次坐标变换,这样建模逻辑最干净。

2.3 用 Python 做解析层的具体实现思路

解析层用 Python 写是最省事的,标准库就够。核心就是一个递归下降的小解析器,把缩进层级转成嵌套字典。下面是我常用的一个简化版本:

import re def parse_dsl(text): lines = [l for l in text.splitlines() if l.strip() and not l.strip().startswith('#')] root = {} stack = [(-1, root)] for line in lines: indent = len(line) - len(line.lstrip()) key, _, value = line.strip().partition(':') key = key.strip() value = value.strip() while stack and stack[-1][0] >= indent: stack.pop() parent = stack[-1][1] if value: parent[key] = coerce(value) else: parent[key] = {} stack.append((indent, parent[key])) return root def coerce(v): try: return int(v) except ValueError: pass try: return float(v) except ValueError: return v

这段代码不长,但覆盖了绝大多数参数化模型的描述需求。coerce函数负责把字符串转成数字,转不了就保留字符串,这样type: cylinder和diameter: 120能自动区分。实测下来,几百行的模型描述解析耗时在毫秒级,完全不是瓶颈。

提示:解析层一定要写单元测试。参数解析错了,后面几何全错,而且错误往往在导出后才暴露,排查成本极高。我一般会针对每个字段写一个最小用例,改解析逻辑时先跑测试。

3. 几何构建:选对内核比写对代码更重要

3.1 为什么我最终选了 CadQuery 而不是直接调 OpenCASCADE

几何构建这一步,本质是调用一个几何内核,把参数变成实体。可选的路子有几条:直接调 OpenCASCADE 的 Python 绑定、用 FreeCAD 的脚本接口、用 CadQuery,或者用 build123d。我前后试过三种,最后稳定在 CadQuery 上。

直接调 OpenCASCADE 最灵活,但 API 极其底层,画一个带孔的圆柱要写几十行,还得自己管理拓扑对象,稍不注意就内存泄漏或者产生无效实体。FreeCAD 的脚本接口功能全,但它的 Python 环境依赖整个 FreeCAD,部署起来重,而且版本之间 API 变动不小。CadQuery 的好处是它在 OpenCASCADE 之上做了一层声明式的封装,写起来接近"描述几何"而不是"操作内核",同时它导出的 STEP 质量很稳。

用 CadQuery 画上面那个法兰盘,代码大概是这样:

import cadquery as cq result = ( cq.Workplane("XY") .circle(60) .extrude(15) .faces(">Z") .workplane() .polarArray(45, 0, 360, 6) .hole(10) .edges("|Z") .fillet(2) ) cq.exporters.export(result, "flange.step")

十几行就把模型建出来并导出 STEP 了。polarArray做环形阵列,hole直接打孔,fillet倒角,这些都是声明式的,读起来就是几何本身。这就是我选它的核心理由:代码即模型描述,改起来直观,出错概率低。

3.2 参数化建模里"特征顺序"为什么不能随便调

用 CadQuery 这类工具,一个很容易被忽视的点是特征的操作顺序。上面那段代码里,先拉伸、再打孔、最后倒角,顺序换一下结果可能完全不同。如果先倒角再打孔,孔可能会切掉倒角的一部分,或者倒角在孔的位置产生自交,几何内核直接报错。

这不是 CadQuery 的问题,而是所有基于边界表示(BRep)的建模都有的特性。BRep 记录的是实体的面、边、顶点以及它们之间的拓扑关系,每一步操作都会重建这套关系。顺序不对,中间结果的拓扑就不对,后续操作自然出问题。

我的经验是遵循一个固定的特征顺序:基础形体 → 布尔运算(加/减)→ 细节特征(倒角、圆角)→ 阵列。阵列放最后是因为阵列会复制特征,如果被复制的特征本身有问题,阵列会放大问题。另外,倒角和圆角尽量放在布尔运算之后,因为布尔运算产生的边往往更"干净",倒角成功率高。

3.3 从参数到实体的容错设计:无效几何怎么提前拦住

几何内核最让人头疼的地方是:它不会告诉你"这个参数不合理",它只会在某个操作上直接抛异常,而且异常信息经常看不懂。比如孔径大于外径,打孔的时候可能不报错,但生成的是一个空实体或者自交实体,导出 STEP 之后在别的软件里打开才发现是坏的。

我的做法是在几何构建之前加一层参数校验,把能提前判断的约束都检查掉。比如:

校验项判断逻辑处理方式
孔径小于外径hole.diameter < outer.diameter直接报错,提示参数冲突
阵列不重叠pitch_diameter * π / count > hole.diameter警告,可能产生干涉
倒角半径合理fillet.radius < min(壁厚) / 2报错,倒角会切穿
高度为正height > 0报错

这层校验用纯 Python 写,不依赖几何内核,跑得飞快。别嫌麻烦,它能帮你挡掉八成以上的低级错误,而且报错信息是你自己写的,比内核的异常友好得多。

注意:参数校验只能挡住"逻辑上明显不合理"的情况,像自交、退化面这类几何层面的问题,还是得靠内核。所以构建完之后最好做一次有效性检查,CadQuery 里可以用result.val().isValid()来判断。

4. 导出 STEP 与 URDF:两种格式,两套心思

4.1 STEP 导出看着简单,单位和小数位是重灾区

STEP 是三维模型的通用交换格式,几乎所有 CAD 软件都认。CadQuery 导出 STEP 就一行cq.exporters.export(result, "xxx.step"),看起来毫无技术含量,但坑都在细节里。

第一个坑是单位。前面提过,STEP 文件内部有单位声明,CadQuery 默认按毫米导出,但如果你从别的来源导入模型再导出,单位可能就乱了。我遇到过导入一个英寸制的模型,导出后尺寸差了 25.4 倍。解决办法是导出前显式确认模型的单位,必要时做缩放。

第二个坑是小数位。STEP 是文本格式,坐标值的小数位直接影响文件大小和精度。默认精度通常够用,但如果你做的是精密零件,需要确认导出精度满足要求。反过来,如果模型很大很复杂,适当降低精度能显著减小文件体积。

第三个坑是坐标系朝向。不同 CAD 软件对"哪个轴朝上"的约定不一样,有的 Z 朝上,有的 Y 朝上。导出时如果不做处理,模型在对方软件里可能是躺着的。这个没有通用解,只能针对目标软件调整。

4.2 URDF 不是模型格式,别把它当 STEP 用

很多人第一次接触 URDF 会误以为它是另一种三维模型格式,其实不是。URDF 描述的是机器人的运动学结构:有哪些连杆(link)、连杆之间怎么连接(joint)、每个连杆的惯性参数、碰撞体、视觉模型长什么样。它本身不包含几何,几何是通过引用外部网格文件(通常是 STL 或 DAE)来提供的。

所以从 CAD 到 URDF,实际要做两件事:一是把模型导出成网格文件(STL/DAE),二是在 URDF 里引用这个网格并定义好连杆和关节。关键词里出现的"urdf 导入 coppeliasim",说的就是把这个 URDF 连同网格一起导入仿真环境。

一个最小的 URDF 片段长这样:

<robot name="flange_assembly"> <link name="base_link"> <visual> <geometry> <mesh filename="flange.stl" scale="0.001 0.001 0.001"/> </geometry> </visual> <collision> <geometry> <mesh filename="flange.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>

注意那个scale="0.001 0.001 0.001"。STL 通常按毫米导出,而 URDF 和大多数仿真环境按米计算,所以这里要缩小一千倍。这个 scale 是新手最容易漏的地方,漏了之后模型在仿真里会大得离谱,或者小得看不见。

4.3 惯性参数:最容易被糊弄、也最容易出问题的地方

URDF 里的惯性参数(质量和转动惯量)是很多人直接填 1 或者随便估一个的地方。如果你的仿真只是看看运动学,问题不大;但一旦涉及动力学,惯性参数错了,仿真结果就是错的,而且错得很隐蔽——模型能动,但力矩、加速度全不对。

正确的做法是从 CAD 模型里直接算。CadQuery 和 OpenCASCADE 都能计算实体的体积和质心,配合材料密度就能得到质量和转动惯量。下面是一个计算思路:

solid = result.val() volume = solid.Volume() density = 7.85e-6 # 钢,单位 kg/mm^3 mass = volume * density center = solid.Center() # 转动惯量需要绕质心计算,OpenCASCADE 提供相关接口

这里单位要特别小心。体积是立方毫米,密度用 kg/mm³,算出来才是千克。转动惯量的单位是 kg·mm²,而 URDF 要求 kg·m²,所以还要乘 1e-6。我见过太多人在这里单位搞错,导致仿真里零件轻得像纸或者重得像铅。

提示:如果模型形状复杂,手算转动惯量不现实,一定要用内核的计算接口。CadQuery 底层是 OpenCASCADE,可以通过BRepGProp相关方法拿到惯性张量,虽然 API 稍微绕一点,但结果可靠。

5. 把整条链路串起来:一个可复现的实操流程

5.1 环境准备:Python 版本和依赖的坑

先说环境。CadQuery 对 Python 版本有要求,目前稳定支持 3.8 到 3.11,太新的版本可能装不上。关键词里"python 3.8"、"python 安装"这些搜索词,说明不少人在这一步卡住。我的建议是直接用 conda 建一个独立环境,别在系统 Python 里折腾:

conda create -n text2cad python=3.10 conda activate text2cad conda install -c conda-forge cadquery

用 conda 而不是 pip 装 CadQuery,是因为它依赖 OpenCASCADE 的原生库,conda 能自动处理这些二进制依赖,pip 装经常在编译环节失败。这一步省下来的时间,够你写好几个模型了。

装完之后验证一下:

import cadquery as cq print(cq.__version__) box = cq.Workplane("XY").box(10, 10, 10) print(box.val().Volume()) # 应该是 1000

体积是 1000 就说明环境没问题。如果报错找不到 OCCT 相关的库,八成是 conda 环境没激活或者装错了 channel。

5.2 完整流程:从文本文件到 STEP 和 URDF

把前面几节串起来,一个完整的流程是这样的:

  1. 读取文本描述文件,用解析器转成参数字典
  2. 参数校验,拦截明显不合理的输入
  3. 根据type字段分发到对应的建模函数,用 CadQuery 构建实体
  4. 导出 STEP 文件
  5. 导出 STL 网格,同时生成 URDF 文件,填好惯性参数和 scale
  6. 可选:把 URDF 和网格打包,导入仿真环境验证

第 3 步的分发逻辑很关键,它决定了你的系统能支持多少种模型。我的做法是用一个字典把类型名映射到建模函数:

BUILDERS = { "cylinder": build_cylinder, "box": build_box, "flange": build_flange, } def build(params): ptype = params.get("type") if ptype not in BUILDERS: raise ValueError(f"不支持的模型类型: {ptype}") return BUILDERS[ptype](params)

这样加新模型类型只需要写一个函数并注册进去,不用改主流程。实测下来,支持十几种常见零件类型,代码量也就几百行。

5.3 批量生成时怎么避免内存和性能问题

单个模型生成很快,但如果你要批量生成几百个模型,问题就来了。CadQuery 的实体对象占用内存不小,如果全部留在内存里最后一起导出,很容易爆。我踩过一次坑,生成到两百多个的时候进程直接被系统杀掉。

解决办法是生成一个导出一个,及时释放:

for params in all_params: result = build(params) cq.exporters.export(result, f"{params['name']}.step") del result # 及时释放

del之后最好再手动触发一次垃圾回收,尤其是模型复杂的时候。另外,如果批量任务很大,可以考虑用多进程并行,但要注意 CadQuery 的线程安全性,多线程容易出问题,多进程更稳。

注意:并行生成时,每个进程要独立初始化几何内核环境,不要在主进程建好模型再传给子进程,那样会序列化失败。让子进程自己读参数、自己建模、自己导出,主进程只负责调度。

6. 实操中踩过的坑和几条硬经验

6.1 模型能生成但导不进仿真,问题往往在网格质量

有一次我生成的模型在 CAD 里看着好好的,导出 STL 之后导入仿真环境,碰撞检测各种穿透,零件像幽灵一样互相穿过。排查了半天,问题出在 STL 的网格精度上。STL 是三角网格,曲面被离散成三角形,如果离散精度太低,圆柱看起来像多边形棱柱,碰撞体自然不准。

CadQuery 导出 STL 时可以指定精度参数:

cq.exporters.export(result, "part.stl", tolerance=0.01, angularTolerance=0.1)

tolerance控制线性偏差,angularTolerance控制角度偏差,值越小网格越细、文件越大。我的经验是,做碰撞检测的模型,tolerance给到 0.01 毫米级别比较稳,视觉模型可以放宽到 0.1。别一味追求精细,网格太密会让仿真变慢,得不偿失。

6.2 中文路径和文件名引发的玄学问题

这个坑很隐蔽,但确实存在。几何内核和某些导出库对非 ASCII 路径的处理不完善,模型文件放在中文目录下,导出时可能报错或者生成空文件。我遇到过导出 STEP 成功但文件是 0 字节的情况,换成英文路径就好了。

所以我的建议是:整个工作目录用纯英文命名,文件名也用英文加数字,别用中文和空格。如果非要保留中文名,至少在导出时用临时英文路径,导出完再重命名。这个习惯能帮你省掉很多莫名其妙的排查时间。

6.3 版本升级导致的 API 变动怎么应对

CadQuery 从 1.x 到 2.x 有一些 API 变动,比如导出接口从exporters.export的某些参数调整过。如果你在网上抄的代码跑不通,先检查版本。我的做法是在项目里锁定依赖版本,用requirements.txt或者 conda 的environment.yml固定住,别让环境自己升级。

cadquery==2.4.0

锁定版本之后,升级变成一个有意识的行为,而不是某天突然发现代码跑不了了。升级前先在测试环境跑一遍全部用例,确认没问题再切生产。

6.4 关于"免费源码"和"插件"的一点提醒

搜索热词里有一堆"免费 python 源码大全"、"盘扣 cad 插件免费版"之类的词,我理解大家想省事的心情,但在这条链路上,抄来的代码往往水土不服。text-to-cad 的难点不在某一段代码,而在整条链路的衔接和参数约定。别人的代码可能用的是英寸、Y 轴朝上、不同的内核版本,直接拿来用,光对齐这些约定就够你喝一壶。

我的建议是:核心的解析、建模、导出三段自己写,参考别人的思路可以,但别整段复制。这三段加起来也就几百行,自己写一遍,后面改起来心里有底。真正值得复用的是几何内核和 CadQuery 这类成熟库,而不是别人拼凑的业务代码。

7. 这套方案还能往哪些方向延伸

把基础链路跑通之后,能扩展的方向其实不少。一个自然的延伸是接一个大语言模型做前端,让用户用自然语言描述,模型负责转成前面说的 DSL,再由确定性链路生成模型。这样既保留了自然语言的便利,又避免了直接让模型生成几何的不稳定。关键在于 DSL 这一层要设计得足够表达力强,能覆盖你目标领域的常见零件。

另一个方向是和参数化设计结合。很多零件族(比如一系列不同规格的法兰、齿轮、支架)有共同的拓扑结构,只是尺寸不同。用 text-to-cad 的思路,可以把整个零件族用一套模板加参数表描述,批量生成所有规格,顺带把 STEP、URDF、图纸一起出。这在做产品系列或者仿真参数扫描的时候特别有用。

再往深了走,可以接入优化循环。给定目标性能(比如重量最轻、刚度最大),让优化算法调整参数,每轮调用一次建模和仿真,自动迭代。这时候 text-to-cad 就成了整个优化流程里的一个确定性模块,输入参数、输出模型,稳定可靠,这正是它最大的价值所在。

我个人在实际操作中的体会是:text-to-cad 这件事,技术门槛没有想象中高,难的是把工程细节抠干净——单位、坐标系、特征顺序、网格精度、惯性参数,每一个都是小问题,但每一个都能让整条链路崩掉。把这些细节一个个踩平之后,你会发现从一段文本到一个能进仿真的模型,真的可以做到几秒钟完成,而且结果可复现。这种确定性带来的效率提升,比任何花哨的自动化都实在。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进