text-to-cad 实战:从自然语言到 Python 参数化建模与 STEP/URDF 导出
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 与 URDF4.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, tolerance0.01, angularTolerance0.1)tolerance是线性偏差值越小网格越密、文件越大。angularTolerance是角度偏差控制圆弧的离散精度。做 3D 打印的话0.01mm 的线性偏差足够了做机器人仿真的话可以放宽到 0.05mm减小文件体积加快仿真加载速度。这两个参数不设的话会用默认值有时候圆看起来会有点棱角调小一点就顺滑了。4.4 构建 URDF把几何变成机器人模型URDF 这块是很多人卡住的地方。URDF 描述的是连杆和关节的树状结构每个连杆可以引用一个网格文件作为外观。一个最简单的单连杆 URDF 长这样?xml version1.0? robot namering_robot link namebase_link visual geometry mesh filenamering.stl scale0.001 0.001 0.001/ /geometry /visual collision geometry mesh filenamering.stl scale0.001 0.001 0.001/ /geometry /collision inertial mass value0.5/ inertia ixx0.001 ixy0 ixz0 iyy0.001 iyz0 izz0.001/ /inertial /link /robot这里有个大坑单位。URDF 默认单位是米而 CAD 建模通常用毫米所以 mesh 的 scale 要设成 0.001把毫米转成米。这个 scale 不设或者设错导入仿真环境后模型要么大得离谱要么小得看不见。我一开始就栽在这上面模型导进去只有一粒米那么大找了半天才发现是单位问题。visual是外观collision是碰撞体inertial是惯性参数。做仿真的话这三个都要有尤其是惯性参数没有的话物理引擎算出来的动力学是错的。惯性参数可以简化估算质量按体积乘密度算转动惯量用近似公式精度要求不高的话够用。4.5 多连杆装配与关节定义如果模型不止一个零件就要定义关节把它们连起来。关节类型主要有revolute旋转、prismatic滑动、fixed固定。一个旋转关节的定义joint namejoint1 typerevolute parent linkbase_link/ child linkarm_link/ origin xyz0 0 0.05 rpy0 0 0/ axis xyz0 0 1/ limit lower-1.57 upper1.57 effort10 velocity1.0/ /jointorigin定义关节在父连杆坐标系里的位置和姿态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, fout/{params[id]}.step) results.append((params[id], ok)) else: results.append((params[id], invalid)) except Exception as e: results.append((params[id], ferror: {e})) traceback.print_exc()这样跑完你能拿到一份完整的成功失败清单哪几个失败了、失败原因是什么一目了然方便针对性修复。5.4 参数化设计的几个实用心得最后分享几个参数化设计上的心得。第一参数命名要语义化用outer_d而不是d1过两个月你自己都忘了 d1 是什么。第二默认值要合理用户不填的时候给一个能跑通的默认值比直接报错体验好得多。第三保留中间产物建模过程中的草图、中间实体可以导出留档出问题的时候方便回溯。第四版本化你的参数 schemaschema 改了要记下来不然老参数配新代码会出各种诡异问题。我个人在实际操作中的体会是text-to-cad 这条链路真正难的不是让模型“画出来”而是让它在各种边界情况下都“画得对、画得稳”。自然语言是模糊的几何是精确的中间这层翻译和校验做扎实了整个系统才可靠。如果你也在做类似的东西建议先把参数 schema 和校验规则定清楚再往上接语言解析这样地基稳后面加功能才不会塌。