1. 从一段文字到三维模型:text-to-cad 到底在解决什么问题
第一次听到 “text-to-cad” 这个词,很多人脑子里浮现的画面可能是:对着电脑敲一句话,屏幕上就自动长出一个三维零件。这个直觉方向没错,但真正落到工程实践里,它要解决的问题比“生成一个模型”要具体得多,也琐碎得多。
我做了几年 CAD 相关的自动化工具,最深的感受是:建模本身不难,难的是把“意图”翻译成“几何”。一个工程师脑子里想的是“一块 100×60×10 的底板,四角开 M6 沉头孔,孔中心距边缘 12mm”,但 CAD 软件要的是草图、约束、拉伸、孔特征、坐标系、单位制这一整套结构化数据。text-to-cad 的核心价值,就是在这两者之间架一座桥——把自然语言或半结构化的文本描述,转成 CAD 能直接消费的格式,比如STEP、URDF,或者干脆用Python脚本驱动建模内核生成实体。
它适合谁?三类人最该关注。第一类是做机械设计自动化的工程师,手里有大量参数化零件,每次改尺寸都要手动重画,想用脚本批量生成;第二类是机器人方向的开发者,需要把设计好的零件导出成 URDF 做仿真,但 CAD 和 URDF 之间的转换总是丢信息;第三类是想入门参数化建模的 Python 玩家,会写点代码但不懂 CAD 内核,想找个能上手的切入点。
这篇文章我不打算讲空泛的概念,而是把 text-to-cad 这条链路拆开:文本怎么解析、几何怎么生成、STEP 和 URDF 各自扮演什么角色、Python 在中间怎么串起来,以及我在实操中踩过的那些坑。看完你应该能自己搭一个最小可用的原型,而不是停留在“听起来很酷”的阶段。
2. 整体设计思路:为什么是“文本 → 中间表示 → 几何”这条链路
2.1 直接让大模型吐 STEP 文件,为什么行不通
很多人第一反应是:现在大模型这么强,直接让它输出 STEP 文件不就行了?我试过,结论是基本不可用。原因很实在:STEP 是一种严格的 ISO 标准格式,文件里每一行都有固定的语法结构,比如CARTESIAN_POINT、ADVANCED_FACE、CLOSED_SHELL这些实体,它们之间有复杂的引用关系(用#123这样的编号互相指向)。大模型生成这种长程依赖的结构化文本,稍微长一点就会出现引用错乱、编号重复、拓扑不闭合的问题,最后导进 CAD 软件直接报错。
更关键的是,STEP 描述的是“结果几何”,不是“建模过程”。你拿到一个 STEP 文件,里面只有最终的曲面和边,没有“这是一个拉伸特征”“这个孔是阵列出来的”这类信息。而文本描述里恰恰充满了过程性意图——“先画底板,再打孔,孔沿 X 方向阵列 4 个”。把过程性意图直接压成结果几何,中间丢的信息太多,改一个参数就得全部重来。
所以正确的思路是引入一个中间表示层。文本先被解析成结构化的参数和操作序列,再由一个确定性的建模内核去执行这些操作,最后导出成目标格式。这样每一步都是可控的、可调试的、可复现的。
2.2 中间表示该长什么样
中间表示(Intermediate Representation,IR)是整条链路的心脏。我的做法是用一个 JSON 结构来描述,大致分三层:
- 全局参数层:单位制、坐标系、全局变量。比如
{"units": "mm", "vars": {"plate_w": 100, "plate_h": 60}}。 - 特征操作层:一个有序的操作列表,每个操作是一个原子建模动作,比如
sketch_rect、extrude、hole、fillet。 - 约束与引用层:操作之间怎么互相引用,比如某个孔的定位依赖底板的边。
用 JSON 而不是直接写 Python 代码,好处是解析和执理解耦。文本解析模块只负责产出 JSON,建模模块只负责消费 JSON。哪天你想换个建模内核,只要改消费端,解析端不用动。而且 JSON 天然可序列化,方便存数据库、做版本对比、走人工审核。
2.3 建模内核怎么选
中间表示要落地成真实几何,必须有个建模内核。市面上主流的选择有这么几类:
| 内核/库 | 语言 | 特点 | 适合场景 |
|---|---|---|---|
| OpenCASCADE | C++,有 Python 绑定 | 工业级,功能全,STEP 支持好 | 严肃的工程建模 |
| CadQuery | Python | 基于 OCCT,语法友好 | 快速原型、参数化零件 |
| build123d | Python | CadQuery 的继任者,API 更现代 | 新项目首选 |
| FreeCAD 脚本 | Python | 有完整 GUI,可脚本化 | 需要人工介入的流程 |
| trimesh / numpy-stl | Python | 只处理网格,不处理 B-rep | 3D 打印、可视化 |
如果你要导出 STEP,必须用支持 B-rep(边界表示)的内核,因为 STEP 本质上是 B-rep 格式。trimesh 这类网格库只能导出 STL,导不出真正的 STEP。这一点我踩过坑:早期用网格库拼了个模型,想导 STEP,结果只能导出一个空壳或者报错,因为网格没有精确的曲面定义。
我的推荐是CadQuery 或 build123d。它们底层都是 OpenCASCADE,导出的 STEP 是货真价实的 B-rep,同时 Python API 写起来比直接调 OCCT 舒服太多。下面这条链路就是我实际在用的:
文本描述 → 解析器(规则/LLM)→ JSON 中间表示 → CadQuery 脚本 → STEP / URDF3. 核心细节拆解:文本解析、几何生成与格式导出
3.1 文本解析:规则优先,LLM 兜底
文本解析这一步,我的经验是不要一上来就全交给大模型。原因很简单:工程描述里大量是重复的、模式化的表达,用正则和关键词匹配就能搞定,又快又稳又便宜。真正需要 LLM 的是那些模糊的、口语化的、带隐含约束的描述。
我一般把解析分成两档:
第一档:结构化模板匹配。如果输入是“底板 100x60x10,四角 M6 沉头孔,边距 12”,这种其实有固定套路。用正则抓出尺寸、孔型、数量、位置关系,直接映射成 JSON。这一档覆盖了我日常 70% 的需求,而且零延迟、零成本。
第二档:LLM 解析。输入是“做一个大概巴掌大的安装板,四个角固定,中间留个走线孔”,这种就得靠 LLM 理解“巴掌大”约等于 80-100mm,“四个角固定”约等于四角开孔,“走线孔”约等于中心通孔。这时候给 LLM 的 prompt 里要带上目标 JSON schema,让它按格式输出,再用 JSON Schema 校验一遍。
提示:LLM 解析出来的尺寸一定要做合理性校验。我遇到过模型把“M6 孔”解析成直径 6 米的情况,单位没对齐。所有数值都要过一遍范围检查,超出物理常识的直接打回。
解析器输出后,我会做一个语义校验层:检查孔是否落在实体内部、边距是否小于板宽的一半、阵列数量是否为正整数。这些校验能在建模之前就拦掉大部分错误,比等到内核报错再去查要高效得多。
3.2 几何生成:用 CadQuery 把 JSON 变成实体
拿到 JSON 之后,几何生成就是确定性的活了。以 CadQuery 为例,一个“带四角沉头孔的底板”大概长这样:
import cadquery as cq def build_plate(params): w = params["width"] h = params["height"] t = params["thickness"] margin = params["hole_margin"] d = params["hole_dia"] # 先建底板 plate = cq.Workplane("XY").box(w, h, t) # 四角孔的位置 x = w / 2 - margin y = h / 2 - margin points = [(-x, -y), (x, -y), (x, y), (-x, y)] # 打沉头孔 plate = ( plate.faces(">Z").workplane() .pushPoints(points) .cboreHole(d, d * 1.8, d * 0.5) ) return plate result = build_plate({ "width": 100, "height": 60, "thickness": 10, "hole_margin": 12, "hole_dia": 6 }) cq.exporters.export(result, "plate.step")这段代码里有几个细节值得说。faces(">Z")是选朝上的那个面作为工作平面,>Z是 CadQuery 的选择器语法,表示 Z 方向最大的面。cboreHole是沉头孔,三个参数分别是孔径、沉头直径、沉头深度。这些参数如果从 JSON 来,就实现了完全的数据驱动。
参数计算过程要写清楚。比如沉头直径一般取孔径的 1.6-2.0 倍,沉头深度取孔径的 0.5 倍左右,这是沉头螺钉的标准比例。如果你不确定,查一下对应螺钉规格的国标,别拍脑袋。我在项目里会把这些比例做成配置项,不同螺钉标准用不同系数。
3.3 STEP 导出:B-rep 的坑与单位制
导出 STEP 看起来就一行export,但坑不少。最常见的是单位制问题。OpenCASCADE 内部默认单位是毫米,但有些库在导入导出时会做单位换算,导致模型放大或缩小 1000 倍。我的做法是:在 JSON 里显式声明单位,导出前统一转成毫米,导出后再用工具检查一遍包围盒尺寸是否符合预期。
第二个坑是精度设置。STEP 导出时可以指定公差,公差太大会让曲面变得粗糙,太小会让文件体积暴涨。对于普通机械零件,我一般用默认公差就够了;如果是配合面、需要后续做装配的,会把公差调小一档。这个没有万能值,得根据零件用途定。
第三个坑是坐标系。CadQuery 默认在 XY 平面建模,Z 向上。但有些下游软件(比如某些仿真工具)习惯 Z 向前或者 Y 向上。导出前想清楚下游要什么坐标系,必要时在导出前做一次旋转。我吃过这个亏:模型导进仿真软件后方向不对,排查了半天才发现是坐标系约定不一致。
3.4 URDF 导出:从 CAD 到机器人描述
URDF(Unified Robot Description Format)是机器人领域描述连杆和关节的 XML 格式。它和 STEP 的定位完全不同:STEP 描述的是几何形状,URDF 描述的是运动学结构——哪个是连杆、哪个是关节、关节怎么转、惯性参数是多少。
从 CAD 到 URDF,核心工作是把零件按运动关系分组,并计算每个连杆的惯性参数。流程大致是:
- 把每个零件导出成独立的 STL 或 DAE 网格(URDF 的 visual 和 collision 通常用网格)。
- 确定关节的旋转轴和原点位置,这需要从 CAD 的装配关系里提取。
- 计算每个连杆的质量、质心、惯性张量。质量可以从材料密度和体积算,惯性张量如果形状复杂,用网格近似计算。
# 用 trimesh 计算网格的惯性参数 import trimesh mesh = trimesh.load("link1.stl") mesh.density = 7850 # 钢的密度 kg/m^3 print("质量:", mesh.mass) print("质心:", mesh.center_mass) print("惯性张量:\n", mesh.moment_inertia)这里有个单位陷阱:URDF 的标准单位是米和千克,而 CAD 通常是毫米。导出网格时如果不做缩放,URDF 里的模型会大 1000 倍,仿真直接飞出去。我一般会在导出前把网格缩放到米制,或者在 URDF 的<mesh>标签里用scale属性统一缩放。
注意:URDF 的 collision 网格建议用简化后的凸包或包围盒,不要直接用高精度网格。高精度网格做碰撞检测会非常慢,仿真根本跑不动。visual 用精细网格,collision 用简化网格,这是标准做法。
4. 实操过程:搭一个最小可用的 text-to-cad 原型
4.1 环境准备与依赖安装
先把环境搭起来。我习惯用虚拟环境,避免污染系统 Python:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install cadquery trimesh numpy lxmlCadQuery 的安装稍微有点讲究,它依赖 OCCT,不同平台装法不一样。Linux 上一般pip install cadquery就能搞定;Windows 上如果报错,建议用 conda 装,conda install -c conda-forge cadquery更稳。装完之后跑一句import cadquery验证一下,没报错就说明 OCCT 绑定正常。
如果你还要处理 URDF,lxml用来生成和解析 XML 比标准库的xml.etree顺手,尤其是需要格式化输出的时候。
4.2 写一个规则解析器
先实现第一档的规则解析。我写了个简单的函数,用正则抓尺寸和孔信息:
import re def parse_plate(text): result = {} # 抓 100x60x10 这种尺寸 m = re.search(r'(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)', text) if m: result["width"] = float(m.group(1)) result["height"] = float(m.group(2)) result["thickness"] = float(m.group(3)) # 抓 M6 这种螺纹孔 m = re.search(r'M(\d+)', text) if m: result["hole_dia"] = float(m.group(1)) # 抓边距 m = re.search(r'边距\s*(\d+)', text) if m: result["hole_margin"] = float(m.group(1)) return result print(parse_plate("底板 100x60x10,四角 M6 沉头孔,边距 12")) # {'width': 100.0, 'height': 60.0, 'thickness': 10.0, 'hole_dia': 6.0, 'hole_margin': 12.0}这个解析器很粗糙,但已经能覆盖不少场景。实际项目里我会把正则写成配置表,不同零件类型对应不同的解析规则,方便扩展。
4.3 从 JSON 到 STEP 的完整流程
把解析和建模串起来,加上校验和导出:
import cadquery as cq import json def validate(params): assert params["width"] > 0 and params["height"] > 0 assert params["hole_margin"] < params["width"] / 2 assert params["hole_margin"] < params["height"] / 2 assert params["hole_dia"] > 0 return True def build_and_export(params, out_path): validate(params) w, h, t = params["width"], params["height"], params["thickness"] margin, d = params["hole_margin"], params["hole_dia"] plate = cq.Workplane("XY").box(w, h, t) x = w / 2 - margin y = h / 2 - margin points = [(-x, -y), (x, -y), (x, y), (-x, y)] plate = ( plate.faces(">Z").workplane() .pushPoints(points) .cboreHole(d, d * 1.8, d * 0.5) ) cq.exporters.export(plate, out_path) return out_path params = parse_plate("底板 100x60x10,四角 M6 沉头孔,边距 12") build_and_export(params, "output.step")跑完这一步,output.step就是一个可以直接拖进任意 CAD 软件打开的真实 B-rep 模型。你可以用 FreeCAD 或者在线 STEP 查看器验证一下,尺寸和孔位应该都对得上。
4.4 导出 URDF 的实操记录
假设这个底板是某个机器人的一个连杆,我要把它变成 URDF 的一部分。先导出网格:
cq.exporters.export(plate, "link1.stl")然后用 trimesh 算惯性参数,再拼 URDF:
import trimesh from lxml import etree mesh = trimesh.load("link1.stl") mesh.density = 2700 # 铝合金 kg/m^3 mass = mesh.mass com = mesh.center_mass / 1000.0 # mm 转 m inertia = mesh.moment_inertia / 1e9 # 换算到 kg·m^2 robot = etree.Element("robot", name="my_robot") link = etree.SubElement(robot, "link", name="link1") inertial = etree.SubElement(link, "inertial") etree.SubElement(inertial, "mass", value=str(mass)) origin = etree.SubElement(inertial, "origin", xyz=f"{com[0]} {com[1]} {com[2]}") etree.SubElement(inertial, "inertia", ixx=str(inertia[0][0]), ixy=str(inertia[0][1]), ixz=str(inertia[0][2]), iyy=str(inertia[1][1]), iyz=str(inertia[1][2]), izz=str(inertia[2][2])) visual = etree.SubElement(link, "visual") geom = etree.SubElement(visual, "geometry") etree.SubElement(geom, "mesh", filename="link1.stl", scale="0.001 0.001 0.001") tree = etree.ElementTree(robot) tree.write("robot.urdf", pretty_print=True, xml_declaration=True)注意scale="0.001 0.001 0.001"这一行,它把毫米网格在 URDF 里缩放到米制,省得我提前改网格。惯性张量的单位换算要特别小心,trimesh 算出来的单位取决于你输入的网格单位,如果网格是毫米,惯性张量的单位是 kg·mm²,转成 kg·m² 要除以 10 的 6 次方。我上面写的是除以 1e9,那是假设网格已经是米制的情况,实际用的时候一定要先确认网格单位。
5. 常见问题与排查技巧实录
5.1 建模阶段的典型报错
问题一:cboreHole报错说找不到面。这通常是因为faces(">Z")选到了多个面或者没选到面。如果模型有多个朝上的面,选择器会返回多个,workplane()就不知道该用哪个。解决办法是用faces(">Z").first()明确取第一个,或者用更精确的选择器比如faces(cq.selectors.AreaNthSelector(0))选面积最大的面。
问题二:导出的 STEP 打开是空的。九成是建模时实体没闭合,或者布尔运算失败产生了无效实体。CadQuery 里可以用plate.val().isValid()检查实体有效性,导出前加一道校验。如果无效,通常是孔的位置超出了实体边界,或者沉头深度大于板厚。
问题三:模型尺寸差 1000 倍。单位制问题,前面说过。检查你的输入单位、内核单位、导出单位是否一致。我的习惯是全程用毫米,只在导出 URDF 时做一次缩放。
5.2 URDF 导入仿真环境的坑
热词里有个“urdf导入coppeliasim”,这个我踩过。CoppeliaSim 对 URDF 的解析比较严格,常见问题有:
- 网格路径不对:URDF 里的
filename是相对路径,CoppeliaSim 的工作目录如果和 URDF 不在同一层,就找不到网格。解决办法是用绝对路径,或者把网格和 URDF 放同一目录。 - 惯性参数不合理:如果惯性张量太小或者为零,仿真里物体会乱飞。检查质量是否为正、惯性张量是否满足三角不等式(ixx + iyy >= izz 这类)。
- 关节轴方向反了:URDF 的关节轴是单位向量,方向搞反了运动就反了。导入后先在仿真里手动转一下关节,确认方向对不对。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| STEP 打开为空 | 实体无效/布尔失败 | val().isValid() | 检查孔位、沉头深度 |
| 尺寸差 1000 倍 | 单位制不一致 | 检查包围盒 | 统一用毫米,导出时缩放 |
| URDF 网格丢失 | 路径错误 | 看仿真日志 | 用绝对路径或同目录 |
| 仿真物体乱飞 | 惯性参数异常 | 检查质量和张量 | 重新计算,满足三角不等式 |
| 孔位偏移 | 坐标系约定不同 | 对比 CAD 和导出结果 | 导出前统一坐标系 |
| 解析尺寸离谱 | LLM 单位误判 | 范围校验 | 加物理常识检查 |
5.4 几条独家避坑心得
心得一:永远先做最小闭环。别一上来就搞复杂的多零件装配。先用一个最简单的方块跑通“文本→JSON→STEP”全流程,确认每个环节都通了,再往上加特征。我见过太多人卡在环境配置上就放弃了。
心得二:把中间 JSON 存下来。每次生成的 JSON 都存一份,出问题的时候可以直接拿 JSON 复现,不用重新解析文本。而且 JSON 可以人工修改,相当于给自动化流程留了个手动干预的口子。
心得三:参数校验比建模本身更重要。建模内核报的错往往很晦涩,但如果你在建模前就把参数校验做好,大部分错误根本不会发生。我现在的校验层比建模代码还长,但值得。
心得四:URDF 的 collision 一定要简化。这个前面提过,但值得再强调。用高精度网格做碰撞,仿真速度会慢到无法忍受。用凸包或者几个包围盒拼一下,效果够用,速度快几十倍。
心得五:版本锁定。CadQuery 和 OCCT 的版本兼容性偶尔会出问题,升级后老代码跑不了。生产环境一定要锁版本,pip freeze存一份依赖清单。
6. 这条链路还能怎么扩展
跑通基础流程之后,我实际项目里做了几个扩展,效果不错,分享给你。
扩展一:批量生成。把参数做成 CSV,每行一个零件,循环调用建模函数,一次生成几百个 STEP。这在做系列化零件的时候特别省事,改一个参数就能重出一整套。
扩展二:加一个 Web 界面。用 FastAPI 包一层,前端传文本,后端返回 STEP 下载链接。团队里不懂代码的同事也能用,输入一句话就能拿到模型。
扩展三:接 LLM 做模糊解析。前面说的第二档解析,用 LLM 处理口语化描述。关键是 prompt 里要带 JSON schema,并且输出后做严格校验。我实测下来,加上校验层之后,LLM 解析的可用率能到八成以上。
扩展四:URDF 自动装配。如果零件多,手动写 URDF 很痛苦。可以做一个映射表,把 CAD 里的装配关系和关节定义对应起来,自动生成完整的 URDF 树。这个稍微复杂点,但一旦做好,机器人模型迭代的速度会快很多。
我个人在实际操作中的体会是,text-to-cad 这条链路的价值不在于“全自动”,而在于把重复劳动自动化,把人的精力留给真正需要判断的地方。参数校验、异常处理、格式转换这些活交给代码,工程师专注在设计和决策上,这才是它真正好用的地方。