1. 项目缘起与核心思路拆解
1.1 为什么我会盯上 text-to-cad 这个方向
最早接触 text-to-cad 这个概念,是在做一批机械臂仿真项目的时候。当时团队里几个做算法的同事对 URDF 的关节参数、连杆坐标系一窍不通,每次改一个连杆长度都要找我手动调 XML。我就想,能不能让他们用自然语言描述需求,我这边自动生成对应的 CAD 模型和 URDF 文件?这个念头就是 text-to-cad 的雏形。
简单说,text-to-cad 就是让程序理解一段文字描述,然后自动输出可用的三维模型文件。输入可能是“一个长 100mm、宽 60mm、高 30mm 的带圆角长方体,四角各有一个直径 8mm 的通孔”,输出则是 STEP、STL 或者 URDF 这类 CAD 和机器人领域通用的格式。它解决的核心问题是:把非专业人员的几何意图,翻译成工程软件能读懂的精确数据。
这个方向适合谁?我总结了三类人:一是做机器人仿真的工程师,需要批量生成 URDF 做运动学验证;二是做参数化设计的从业者,想用脚本替代重复的建模操作;三是有 Python 基础、想入门 CAD 自动化的开发者。哪怕你之前只会用 CAD 画图、没写过几行代码,只要理解了几何参数和坐标系的关系,也能跟着做下来。
1.2 技术路线选型:为什么是 Python + STEP + URDF
市面上做 text-to-cad 有几条路。一条是直接调用商业 CAD 软件的 API,比如某些二次开发接口,优点是精度高、功能全,缺点是依赖软件安装、授权成本高,而且批量处理时容易被卡。另一条是用纯几何内核,比如 OpenCASCADE,通过 Python 绑定来操作,这条路更轻量、更可控。
我最终选的是Python + CadQuery + STEP + URDF这套组合。CadQuery 是基于 OpenCASCADE 的 Python 参数化建模库,写几行代码就能生成实体,导出 STEP 是它的原生能力。STEP 是中性格式,几乎所有 CAD 软件都能打开,方便后续人工微调。URDF 则是机器人领域的标准描述格式,用来做仿真和可视化。
为什么不用 STL 作为主要输出?STL 只有三角面片,没有拓扑信息,改一个孔的位置就得重新生成整个网格,而且精度受网格密度影响。STEP 保留完整的 B-rep 边界表示,可以精确修改特征,这对参数化迭代至关重要。至于 URDF,它本身不存几何,而是引用 STL 或 STEP 文件并定义关节和连杆关系,所以我的流程是:文字 → Python 脚本 → STEP 实体 → 导出 STL 供 URDF 引用。
提示:如果你只是做视觉展示,STL 够用;但只要涉及尺寸修改、装配配合、运动学仿真,STEP 是绕不开的。
1.3 整体架构:从一句话到可用的模型文件
整个系统的数据流我画过很多遍,核心就四层。第一层是输入解析层,把自然语言里的尺寸、形状、位置关系抽出来,转成结构化的字典。第二层是几何构建层,用 CadQuery 的 API 把字典变成实体。第三层是格式导出层,根据目标输出 STEP、STL 或 URDF。第四层是校验与反馈层,检查模型是否闭合、尺寸是否合理、URDF 关节是否连通。
这四层里,最容易被低估的是第一层。很多人以为直接正则匹配数字就行,但实际文本里“长 100 宽 60”和“100x60 的板”表达完全不同。我的做法是先用规则提取候选参数,再用一个轻量模板匹配来确认语义角色。比如“孔”这个字出现时,后面的直径和位置描述就归到孔特征上。这套逻辑不依赖大模型,纯 Python 就能跑,胜在稳定、可调试。
第二层的难点在于特征顺序。CAD 建模是有先后依赖的,先做主体再打孔,和先打孔再做主体,结果可能完全不同。我的经验是:先做最大轮廓的拉伸体,再做切除特征,最后做圆角和倒角。圆角一定要放最后,否则后续切除会破坏圆角面,导致重建失败。
2. 核心细节解析与实操要点
2.1 文本解析:把“人话”拆成几何参数
文本解析这块,我踩过的坑最多。最开始我用正则表达式抓所有数字,结果“M8 的孔”里的 8 被当成孔径,“4 个孔”里的 4 被当成数量,但“长 100”里的 100 是长度。数字本身没有语义,必须结合上下文。
我的解决方案是建立一个关键词-参数映射表。比如“长”“长度”“L”映射到 length,“宽”“宽度”“W”映射到 width,“高”“厚度”“H”映射到 height,“直径”“孔径”“D”映射到 diameter,“半径”“R”映射到 radius。然后按句子切分,每个分句里找关键词和它最近的数字。这个方法对“长 100mm 宽 60mm”这种规整表达准确率很高。
对于更复杂的描述,比如“四角各有一个直径 8mm 的通孔,孔中心距边缘 10mm”,我会用位置词 + 特征词 + 尺寸词的三元组来解析。“四角”是位置,“孔”是特征,“直径 8mm”是尺寸,“距边缘 10mm”是定位约束。解析出来的结构大概是这样:
{ "feature": "hole", "count": 4, "position": "corners", "diameter": 8.0, "offset_from_edge": 10.0, "through": True }这个字典直接喂给几何构建层,每个键对应一个建模操作。关键点在于:解析层不负责判断能不能做,只负责提取;构建层才做可行性检查。这样职责分离,调试时能快速定位是解析错了还是建模错了。
注意:单位一定要统一。我遇到过文本里写“10cm”但解析成 10mm 的情况,模型直接小了十倍。建议在解析层最后加一个单位归一化步骤,所有长度统一转成毫米。
2.2 几何构建:CadQuery 的常用操作与避坑
CadQuery 的 API 设计很直观,核心就几个操作:Workplane创建工作平面,box画长方体,cylinder画圆柱,hole打孔,fillet倒圆角,chamfer倒角,cut做布尔减,union做布尔加。但实际用起来,有几个细节不注意就会翻车。
第一个坑是工作平面的选择。默认的Workplane("XY")是在 XY 平面上建模,Z 轴朝上。如果你要做一个竖直的圆柱,得先Workplane("XZ")再画圆拉伸。我建议在代码里显式指定平面,不要依赖默认值,否则换一个模型就乱了。
第二个坑是布尔运算的顺序。CadQuery 的cut是链式操作,box.cut(cylinder)表示从长方体里挖掉圆柱。但如果你先fillet再cut,圆角面可能被切掉一部分,导致后续fillet报错。我的固定顺序是:主体 → 切除 → 圆角 → 倒角。圆角放最后,且半径不要超过相邻边最短长度的一半,否则会自交。
第三个坑是孔的位置计算。以四角孔为例,如果板长 100、宽 60、孔距边缘 10,那么四个孔的中心坐标是(10,10)、(90,10)、(10,50)、(90,50)。这个计算看起来简单,但如果你用rect的角点做参考,坐标系原点在中心还是角点,结果完全不同。我的做法是统一用角点为原点,所有坐标从角点算起,这样最不容易错。
import cadquery as cq # 主体:100x60x30 的长方体,角点为原点 result = cq.Workplane("XY").box(100, 60, 30, centered=False) # 四角孔,直径 8,距边缘 10 hole_positions = [(10, 10), (90, 10), (10, 50), (90, 50)] result = result.faces(">Z").workplane().pushPoints(hole_positions).hole(8) # 圆角,半径 3 result = result.edges("|Z").fillet(3) # 导出 STEP cq.exporters.export(result, "bracket.step")这段代码我用了很多次,基本覆盖了板类零件的常见需求。centered=False是关键,它让长方体以角点为原点,而不是中心。faces(">Z")选中顶面,workplane()在顶面上建立工作平面,pushPoints批量放置孔中心,hole(8)打直径 8 的通孔。最后edges("|Z")选中所有平行于 Z 轴的边,fillet(3)倒半径 3 的圆角。
2.3 URDF 生成:从 STEP 到可仿真的机器人模型
URDF 的结构比 STEP 复杂,因为它描述的是连杆和关节的树状关系。一个典型的 URDF 包含<link>和<joint>两类元素。<link>定义连杆的几何、惯性、碰撞属性,<joint>定义父子连杆之间的运动关系,比如旋转关节、平移关节、固定关节。
从 text-to-cad 的角度,文字描述里通常包含“底座”“大臂”“小臂”“末端”这类连杆名称,以及“旋转”“俯仰”“伸缩”这类关节类型。我的解析策略是:先识别连杆,再识别关节,最后按运动链顺序组装。比如“一个底座,上面接一个可旋转的大臂,大臂末端接一个可俯仰的小臂”,解析出来就是三个 link 和两个 joint。
URDF 里的几何引用有两种方式:一种是直接写<box>、<cylinder>、<sphere>这类基本形状,另一种是引用外部 mesh 文件。我选的是导出 STL 再引用,因为 STL 通用性好,而且 CadQuery 导出 STL 很方便。每个 link 的<visual>和<collision>都指向同一个 STL,<inertial>则根据几何体积和密度估算。
<link name="base_link"> <visual> <geometry> <mesh filename="base.stl" scale="0.001 0.001 0.001"/> </geometry> </visual> <collision> <geometry> <mesh filename="base.stl" scale="0.001 0.001 0.001"/> </geometry> </collision> <inertial> <mass value="1.5"/> <inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/> </inertial> </link>注意scale="0.001 0.001 0.001",因为 STL 默认单位是毫米,而 URDF 默认单位是米,所以要把毫米转成米。这个细节不注意,模型在仿真里会大一千倍。惯性矩阵我一般用简化估算,质量取体积乘以密度,惯性矩用长方体公式近似。如果对动力学精度要求高,建议用 CAD 软件算好再填。
提示:URDF 的关节原点定义在父子连杆的坐标系变换上,写错了模型会飞。我的经验是先在 RViz 里可视化,确认每个 link 的位置和朝向,再调 joint 的
<origin>。
3. 实操过程与核心环节实现
3.1 环境搭建:Python 与 CadQuery 的安装
环境这块,我强烈建议用conda而不是 pip 直接装 CadQuery。因为 CadQuery 依赖 OpenCASCADE 的二进制库,pip 装的时候经常编译失败,尤其是 Windows 上。conda 有预编译的包,一条命令搞定。
conda create -n text2cad python=3.10 conda activate text2cad conda install -c conda-forge cadqueryPython 版本我选 3.10,因为 3.11 和 3.12 有些依赖还没跟上,3.10 最稳。装完之后验证一下:
import cadquery as cq print(cq.__version__)能打印出版本号就说明装好了。如果报ImportError,大概率是 OpenCASCADE 的 DLL 没加载,检查 conda 环境是否激活,或者重装ocp包。
对于 URDF 的生成,我不依赖额外的库,直接用 Python 的字符串模板拼 XML。因为 URDF 结构固定,用xml.etree.ElementTree反而绕。拼好之后用urdf_parser_py做校验,确保没有语法错误。
pip install urdf-parser-py这个库能解析 URDF 并检查 link 和 joint 的引用是否完整,避免出现“关节引用了不存在的连杆”这种低级错误。
3.2 完整流程:从一句话到 STEP 和 URDF
我拿一个实际例子走一遍。输入文本是:“一个 100x60x30 的底板,四角有直径 8 的通孔,孔距边缘 10,上面中间有一个直径 30 高 50 的圆柱,圆柱顶部有一个直径 10 深 20 的盲孔。”
第一步,解析。我写了一个parse_description函数,输出结构化字典:
{ "base": {"length": 100, "width": 60, "height": 30}, "holes": {"count": 4, "diameter": 8, "offset": 10, "through": True}, "boss": {"diameter": 30, "height": 50, "position": "center_top"}, "pocket": {"diameter": 10, "depth": 20, "position": "boss_top"} }第二步,构建。按“底板 → 四角孔 → 圆柱凸台 → 顶部盲孔 → 圆角”的顺序建模:
import cadquery as cq # 底板 model = cq.Workplane("XY").box(100, 60, 30, centered=False) # 四角孔 holes = [(10, 10), (90, 10), (10, 50), (90, 50)] model = model.faces(">Z").workplane().pushPoints(holes).hole(8) # 圆柱凸台,位于底板顶面中心 model = model.faces(">Z").workplane().center(50, 30).circle(15).extrude(50) # 顶部盲孔 model = model.faces(">Z").workplane().circle(5).cutBlind(-20) # 圆角 model = model.edges("|Z").fillet(3) # 导出 cq.exporters.export(model, "part.step") cq.exporters.export(model, "part.stl")这里有几个细节。center(50, 30)是把工作平面原点移到 (50,30),也就是底板顶面的中心。circle(15)画半径 15 的圆,对应直径 30。extrude(50)向上拉伸 50。cutBlind(-20)是向下切 20 深,负号表示方向。最后edges("|Z")选竖直边倒圆角。
第三步,生成 URDF。这个例子只有一个零件,所以 URDF 里只有一个 link,没有 joint:
<robot name="part"> <link name="part_link"> <visual> <geometry> <mesh filename="part.stl" scale="0.001 0.001 0.001"/> </geometry> </visual> <collision> <geometry> <mesh filename="part.stl" scale="0.001 0.001 0.001"/> </geometry> </collision> <inertial> <mass value="0.8"/> <inertia ixx="0.0005" ixy="0" ixz="0" iyy="0.0008" iyz="0" izz="0.001"/> </inertial> </link> </robot>质量 0.8kg 是按铝合金密度 2700kg/m³ 估算的,体积大概 300cm³,换算下来 0.81kg。惯性矩用长方体近似,实际值可以用 CAD 软件算,但仿真里差不太多。
3.3 参数计算:尺寸、位置与公差的确定
text-to-cad 里最考验人的不是写代码,而是把模糊描述转成精确参数。比如“孔距边缘 10”,这个 10 是孔中心到边缘的距离,还是孔边到边缘的距离?工程上默认是中心距,但文本里不会说这么细。我的做法是在解析层加一个默认规则表,明确每种描述的默认解释,并在输出时打印出来让用户确认。
再比如“直径 8 的通孔”,通孔意味着深度等于板厚,但如果板厚是 30,孔深就是 30。如果文本说“深 20”,那就是盲孔,深度 20。这些逻辑要在构建层做判断,不能靠解析层猜。
公差方面,text-to-cad 生成的模型是名义尺寸,不带公差。如果后续要加工,得在 STEP 里手动加公差标注,或者导出图纸时标注。我的建议是在文本里显式写公差,比如“直径 8H7”,解析层识别 H7 后,在 STEP 的 PMI 里加上公差信息。CadQuery 对 PMI 支持有限,复杂公差还是得靠人工。
注意:圆角半径不要大于相邻边最短长度的一半。比如板厚 30,圆角半径最多 14,超过就会自交报错。我一般取 1/3 到 1/2 之间,既美观又安全。
4. 常见问题与排查技巧实录
4.1 建模失败:布尔运算报错的排查思路
布尔运算报错是最高频的问题,典型报错是BRep_API: command not done。这个错误信息很笼统,实际原因可能有好几种。我总结了一个排查顺序:
| 排查项 | 可能原因 | 解决方法 |
|---|---|---|
| 实体是否闭合 | 拉伸时轮廓不闭合 | 检查circle、rect是否首尾相连 |
| 布尔对象是否相交 | 切除体完全在主体外 | 打印两者的包围盒,确认有重叠 |
| 圆角半径是否过大 | 半径超过相邻边一半 | 减小半径或去掉圆角 |
| 工作平面是否正确 | 平面方向导致拉伸反向 | 显式指定Workplane("XY") |
| 单位是否一致 | 毫米和米混用 | 统一转成毫米 |
我遇到最多的是圆角半径过大。有一次做一个 10mm 厚的板,圆角设了 6mm,直接报错。改成 4mm 就好了。所以圆角参数一定要做范围检查,在代码里加断言:
assert fillet_radius <= min(length, width, height) / 2, "圆角半径过大"4.2 URDF 导入仿真环境后的典型异常
URDF 导入 CoppeliaSim 或者 RViz 后,常见异常有三个。第一个是模型不可见,原因通常是 mesh 路径不对或者 scale 不对。URDF 里的filename是相对路径,相对于 URDF 文件所在目录。如果 STL 放在子目录里,要写meshes/part.stl。scale 如果是 0.001,模型会缩小一千倍,看起来就像消失了。
第二个是关节运动方向反了。这通常是<axis>写错了。URDF 默认关节轴是(1,0,0),如果实际应该绕 Z 轴转,就得写<axis xyz="0 0 1"/>。我建议在 RViz 里用 Joint State Publisher 手动拖一下,确认方向对了再写死。
第三个是模型抖动或穿透。这是碰撞体和视觉体不一致导致的。<collision>用的 mesh 如果比<visual>大,仿真时就会提前碰撞。我的做法是 collision 用简化几何,比如长方体用<box>,圆柱用<cylinder>,只有复杂形状才用 mesh。
提示:URDF 里所有长度单位是米,角度是弧度。从 CAD 导出的 STL 是毫米,所以 scale 必须是 0.001。这个转换我写死在导出函数里,避免每次手动改。
4.3 批量生成时的性能优化经验
text-to-cad 的价值在于批量。我做过一个项目,要生成 200 个不同尺寸的支架,每个都要 STEP 和 URDF。如果串行跑,每个模型大概 3 秒,200 个就是 10 分钟。优化后降到 2 分钟,主要做了三件事。
第一,复用 Workplane 对象。CadQuery 的Workplane创建有开销,如果每次从零开始,浪费很多时间。我的做法是把公共部分(比如底板)先建好,然后copy一份再改参数。CadQuery 的val()返回底层Shape,可以深拷贝。
第二,并行导出。STEP 导出是 IO 密集型,用concurrent.futures.ThreadPoolExecutor开 4 个线程并行写文件,速度提升明显。注意 CadQuery 的建模部分不能并行,因为 OpenCASCADE 不是线程安全的,但导出可以。
第三,延迟生成 URDF。URDF 依赖 STL,而 STL 是从 STEP 导出的。我的流程是先生成所有 STEP,再批量转 STL,最后批量拼 URDF。这样避免了每个模型都走一遍完整流程,减少了重复的几何计算。
from concurrent.futures import ThreadPoolExecutor def export_step(model, path): cq.exporters.export(model, path) with ThreadPoolExecutor(max_workers=4) as executor: futures = [executor.submit(export_step, m, f"part_{i}.step") for i, m in enumerate(models)] for f in futures: f.result()这段代码我实测下来很稳,200 个模型从 10 分钟降到 2 分半。注意线程数不要开太多,4 到 8 个就够了,太多反而因为磁盘 IO 竞争变慢。
4.4 文本解析的边界情况与兜底策略
文本解析再完善,也会遇到奇葩描述。比如“做一个大概这么大的板”,没有具体数字;“孔的位置你看着办”,没有约束;“要好看一点”,没有量化标准。这些情况我的策略是给默认值 + 显式警告。
默认值我定了一套:板类零件默认 100x60x10,孔默认直径 6 距边缘 10,圆角默认 2。如果文本里没提到某个参数,就用默认值,并在日志里打印“未指定长度,使用默认值 100mm”。这样用户能看到哪些是猜的,方便修正。
对于“好看一点”这种主观描述,我直接忽略,因为无法量化。如果用户坚持,我会在输出后加一句“圆角半径 2mm,如需调整请指定具体数值”。text-to-cad 的边界是:只处理可量化的几何描述,不处理美学和功能需求。这一点想清楚,就不会陷入无休止的规则堆砌。
还有一个边界是多零件装配。如果文本描述的是“一个底座和一个盖子,用四个 M3 螺丝固定”,这就涉及装配关系和标准件。我的做法是拆成两个独立零件分别生成,螺丝用标准件库引用,装配关系在 URDF 里用 fixed joint 表达。这样既简单又实用,不需要在 text-to-cad 里做复杂的装配约束求解。
5. 工具链扩展与后续迭代方向
5.1 从 STEP 到工程图的自动化尝试
STEP 是三维模型,但很多加工场景需要二维工程图。我试过用 CadQuery 的exporters.export直接导 DXF,但只能导出投影轮廓,没有尺寸标注。后来换了一条路:用 FreeCAD 的命令行模式打开 STEP,调用它的 TechDraw 模块生成工程图,再导出 PDF。
freecadcmd generate_drawing.pygenerate_drawing.py里用 FreeCAD 的 Python API 创建页面、插入视图、添加尺寸。这套流程能跑通,但速度慢,每个模型要 10 秒左右。如果批量大,建议只对关键零件生成工程图,其余用 STEP 直接看。
5.2 接入大模型做语义解析的可行性
纯规则解析的瓶颈很明显:只能处理固定句式,换个说法就失效。我试过用大模型做语义解析,把文本转成 JSON,效果确实好很多。比如“四角打孔”和“四个角各开一个洞”,规则要写两条,大模型一次就懂。
但大模型有两个问题。一是不确定性,同样的输入两次输出可能不同,这对批量生成是致命的。二是成本,200 个模型调 200 次 API,费用不低。我的折中方案是:规则解析为主,大模型兜底。规则能解析的走规则,解析不了的才调大模型,并且把大模型的输出缓存下来,下次遇到相同描述直接读缓存。
def parse_with_fallback(text): result = rule_based_parse(text) if result.is_complete(): return result cache_key = hash(text) if cache_key in cache: return cache[cache_key] result = llm_parse(text) cache[cache_key] = result return result这套逻辑我用了半年,规则覆盖了 80% 的常见描述,剩下 20% 走大模型,成本和稳定性都能接受。
5.3 版本管理与模型追溯的实践
批量生成模型后,最大的问题是追溯。三个月后客户问“那个 100x60 的支架是哪一版”,如果没记录就抓瞎。我的做法是每次生成都写一个 JSON 日志,记录输入文本、解析结果、生成时间、文件哈希。
{ "input": "100x60x30 底板,四角孔", "parsed": {"length": 100, "width": 60, "height": 30}, "timestamp": "2025-01-15T10:30:00", "step_hash": "a1b2c3d4", "urdf_hash": "e5f6g7h8" }日志按项目分目录存,配合 Git 做版本控制。STEP 和 URDF 文件也进 Git,虽然二进制文件 diff 不友好,但至少能追溯哪个版本对应哪个输入。如果文件太大,用 Git LFS。这套流程看起来笨,但实际用起来很踏实,出了问题能快速定位。
提示:文件哈希用 SHA256,不要用 MD5,避免碰撞。哈希值存在日志里,比对时直接看哈希,不用打开文件。
5.4 我踩过的三个印象最深的坑
第一个坑是单位混乱。早期我没做单位归一化,文本里“10cm”被当成 10mm,生成的模型小了十倍,装配时才发现。后来我在解析层加了单位识别,cm 乘 10,m 乘 1000,inch 乘 25.4,统一转毫米。这个改动花了半小时,省了后面无数麻烦。
第二个坑是圆角顺序。有一次我先倒圆角再打孔,结果孔打在了圆角面上,布尔运算直接失败。后来我把圆角固定放在最后一步,再也没出过这个问题。这个经验我写进了代码注释,提醒自己不要改顺序。
第三个坑是URDF 的 scale。第一次导出 URDF 导入 CoppeliaSim,模型小得看不见,我以为文件坏了,查了半天才发现是 scale 没设。STL 是毫米,URDF 是米,差一千倍。现在我在导出函数里硬编码scale="0.001 0.001 0.001",再也不用手动改。
这三个坑都不复杂,但每一个都让我停了半天。写出来是希望后来的人少走弯路。text-to-cad 这个方向,技术门槛不高,难的是细节的积累和流程的打磨。把每一个边界情况处理好,系统才真正可用。