首页 / 资讯中心 / 文章详情

Python+CADQuery实战:自然语言生成STEP与URDF模型

Python+CADQuery实战:自然语言生成STEP与URDF模型 ★ FEATURED ARTICLE
1. 项目缘起与核心思路拆解1.1 这个项目到底在解决什么问题做机械设计或者机器人仿真的朋友大概率都经历过这样的场景脑子里已经想清楚了一个零件的形状甚至用文字都能描述得明明白白——“一个长宽高分别是80、60、40毫米的长方体底座上面开四个M6的沉头孔孔距60乘40”但真要打开CAD软件把这个东西画出来从启动软件到建草图到拉伸到打孔没个十几分钟下不来。如果一天要生成几十个类似的变体零件这种重复劳动能把人逼疯。text-to-cad这个项目瞄准的就是这个痛点用自然语言描述直接生成CAD模型文件。你输入一段文字它输出一个标准的STEP文件或者URDF文件可以直接丢进任意主流CAD软件里打开编辑也可以直接导入到CoppeliaSim这类机器人仿真环境里做运动学验证。核心价值在于把“想法到模型”的路径从“人操作软件”变成“文字驱动生成”省掉中间所有手工建模的环节。这个项目适合谁呢我梳理了一下大概三类人最需要第一类是做机器人仿真的工程师经常需要快速生成大量测试用的连杆、底座、法兰盘模型导入URDF做运动学仿真第二类是做参数化设计的机械工程师产品系列有大量结构相似但尺寸不同的零件想用文字批量生成第三类是对CAD二次开发感兴趣的程序员想了解怎么用Python操作几何内核把代码和CAD打通。1.2 为什么选Python加STEP这条技术路线技术选型这件事我踩过的坑不少。最早我想的是直接用FreeCAD的Python API来建模因为FreeCAD本身开源免费API也算完善。但实际用下来发现两个问题一是FreeCAD的API文档质量参差不齐很多函数没有说明得翻源码二是FreeCAD的几何内核是OpenCASCADE虽然功能强大但Python绑定层有时候会有内存泄漏批量生成几百个模型之后进程就卡死了。后来我转向了直接用Python的cadquery库。cadquery是对OpenCASCADE的Python封装API设计比FreeCAD原生API干净得多而且专门为脚本化建模优化过。它的核心思路是链式调用先定义一个工作平面然后在上面画草图再拉伸、切割、倒角每一步都返回一个新的Workplane对象可以一直链下去。这种风格写起来很顺手代码可读性也高。输出格式选STEP而不是STL或者OBJ原因很直接STEP是B-rep边界表示格式保留完整的几何拓扑信息圆柱面就是圆柱面孔就是孔导入到SolidWorks或者中望CAD里还能继续编辑特征。STL是三角网格导进去就是一坨三角面片想改个孔径都得重新建模。URDF格式则是专门给机器人仿真用的它本身不包含几何数据而是引用外部网格文件所以生成URDF的时候需要同时输出STL或者DAE格式的网格文件。1.3 整体架构长什么样整个项目的架构其实不复杂核心就三层。最上面是自然语言解析层负责把用户输入的中文或者英文描述拆解成结构化的参数。中间是几何生成层用cadquery根据解析出来的参数构建3D模型。最下面是文件输出层把生成的模型导出成STEP或者URDF格式。自然语言解析这块我一开始想用正则表达式硬匹配比如检测到“长方体”就提取长宽高数字。但实际测试下来发现用户的表达方式太灵活了“一个80x60x40的方块”和“长80宽60高40的矩形体”和“80毫米长、60毫米宽、40毫米厚的底板”说的是同一个东西正则根本覆盖不过来。后来改用了一个轻量级的规则引擎加同义词映射的方案先把输入文本分词然后查同义词表把“方块”“矩形体”“底板”都映射到“box”这个标准类型再从文本中提取所有数字和单位按位置关系分配给长宽高。这个方案对常见表达方式的覆盖率能到90%以上剩下的边缘情况再手动补规则就行。几何生成层是项目的核心。cadquery的建模逻辑和传统CAD软件不太一样它更像是“函数式建模”你不是在画图而是在描述一个形状的构造过程。比如要生成一个带孔的板子代码是这样的先创建一个长方体然后在顶面选一个位置挖一个圆柱体出来。每一步操作都是不可变的返回一个新的对象。这种模式的好处是代码即文档看代码就知道模型长什么样。文件输出层相对简单cadquery原生支持导出STEP和STLURDF需要自己拼XML。URDF的结构不复杂就是定义link和joint每个link引用一个网格文件joint定义父子关系和运动类型。需要注意的是URDF里的单位是米而CAD建模通常用毫米导出的时候要记得除以1000。2. 核心细节解析与实操要点2.1 环境搭建Python版本和依赖库的选择Python版本我建议用3.8到3.10之间的太老的版本cadquery不支持太新的版本有些依赖库还没跟上。我实测下来3.9最稳3.10也没问题3.11在某些系统上装cadquery的依赖会报编译错误。安装cadquery最省事的方式是用conda因为cadquery依赖OpenCASCADE的二进制库pip安装有时候会找不到预编译的wheel。命令是conda install -c conda-forge cadqueryconda-forge频道有维护好的包装完直接能用。如果非要用pip那就先装cadquery-ocp这个包它是OpenCASCADE的Python绑定然后再装cadquery本身。除了cadquery还需要装几个辅助库。numpy用来做数值计算比如计算孔位坐标的时候会用到。lxml用来生成URDF的XML文件比标准库的xml.etree好用。regex库处理中文分词的时侯比re模块更灵活支持Unicode属性匹配。注意cadquery在Windows上安装的时候如果之前装过FreeCAD可能会因为OpenCASCADE的DLL冲突导致导入失败。解决办法是在虚拟环境里装或者把FreeCAD的安装目录从PATH里临时移除。2.2 自然语言解析的规则设计解析层的核心是一张同义词映射表。我把常见的几何体类型和修饰词都整理了一遍比如“长方体”“方块”“矩形体”“底板”“基座”都映射到box“圆柱”“圆棒”“轴”“销”都映射到cylinder“孔”“洞”“开口”都映射到hole。修饰词方面“沉头”“ countersink”映射到沉头孔“通孔”“through”映射到贯穿孔“盲孔”“blind”映射到不贯穿的孔。数字提取用的是正则表达式\d(\.\d)?匹配所有整数和小数。单位识别单独处理检测到“毫米”“mm”就按毫米算检测到“厘米”“cm”就乘以10检测到“米”“m”就乘以1000。默认单位是毫米因为机械设计里毫米最常用。参数分配的逻辑是这样的先找到几何体类型关键词的位置然后看这个关键词前面和后面各有哪些数字。对于长方体通常前面三个数字分别是长宽高或者用“长X宽Y高Z”的格式明确标注。对于圆柱体通常两个数字是直径和高度或者半径和高度。如果数字数量对不上就按默认值填充比如长方体缺一个维度就默认和第一个维度相同。这里有个坑要注意中文里“80乘60乘40”和“80x60x40”和“806040”是一个意思但正则匹配的时候要分别处理。我的做法是先把所有乘号统一替换成x然后再用x分割提取数字。另外“80毫米长”和“长80毫米”语序不同但意思一样所以不能依赖位置得靠单位词和维度词的位置关系来判断。2.3 几何建模的关键参数与操作用cadquery建一个带孔的长方体底座代码大概长这样import cadquery as cq # 定义参数 length 80.0 # 长 width 60.0 # 宽 height 40.0 # 高 hole_dia 6.0 # 孔径 hole_positions [(20, 15), (60, 15), (20, 45), (60, 45)] # 孔位坐标 # 创建底座 base cq.Workplane(XY).box(length, width, height) # 在顶面打孔 base base.faces(Z).workplane().pushPoints(hole_positions).hole(hole_dia) # 导出STEP cq.exporters.export(base, base.step)这段代码的逻辑很清晰先在XY平面上创建一个长方体然后选中Z方向最高的那个面也就是顶面在这个面上创建一个工作平面把孔位坐标推上去然后调用hole方法打孔。hole方法默认是贯穿孔如果要打盲孔需要指定深度参数。孔位坐标的计算有个细节cadquery的坐标系原点在长方体的几何中心所以孔位坐标是相对于中心的偏移量。如果你习惯从角落开始算坐标需要先做一个平移变换。我一般会在代码里加一个辅助函数把“从角落算”的坐标转换成“从中心算”的坐标这样更符合机械设计的习惯。倒角和圆角也是常用操作。edges(|Z).fillet(2.0)表示对所有平行于Z轴的边做半径2毫米的圆角。edges(Z).chamfer(1.0)表示对顶面的边做1毫米的倒角。这些操作要放在打孔之前做否则孔边缘的边也会被选中结果就不是你想要的了。2.4 URDF导出的格式要求与注意事项URDF文件的结构是XML格式根节点是robot里面包含若干link和joint。每个link可以包含视觉几何visual和碰撞几何collision两者可以引用同一个网格文件也可以分开。joint定义两个link之间的连接关系包括类型revolute、prismatic、fixed等、父子link名称、旋转轴或平移轴、运动范围限制。导出URDF的时候有几个容易出错的地方。第一是单位问题前面说过URDF用米所以所有尺寸要除以1000。第二是网格文件的路径URDF里引用网格用的是相对路径或者package://格式的路径如果路径写错了导入CoppeliaSim的时候会找不到模型。我一般把网格文件和URDF放在同一个目录下然后用相对路径引用这样最不容易出错。第三是惯性矩阵的计算。URDF里每个link可以定义惯性属性包括质量、质心位置和惯性张量。如果只是做运动学仿真惯性属性可以省略或者随便填。但如果做动力学仿真惯性矩阵必须准确否则仿真结果没有意义。cadquery本身不提供惯性计算功能需要自己根据几何形状和密度手动计算或者用trimesh这类库从网格文件反推。提示导入CoppeliaSim的时候如果URDF里的网格文件是STL格式CoppeliaSim会自动做凸包分解用于碰撞检测。如果模型有凹面结构碰撞检测会不准确。解决办法是单独指定碰撞几何用简单的凸体代替复杂的视觉几何。3. 实操过程与核心环节实现3.1 从零搭建项目目录结构我习惯的项目目录结构是这样的text-to-cad/ ├── src/ │ ├── parser/ │ │ ├── __init__.py │ │ ├── tokenizer.py # 分词和同义词映射 │ │ └── extractor.py # 参数提取和分配 │ ├── modeler/ │ │ ├── __init__.py │ │ ├── primitives.py # 基本几何体生成 │ │ └── features.py # 孔、倒角、圆角等特征 │ ├── exporter/ │ │ ├── __init__.py │ │ ├── step_exporter.py # STEP导出 │ │ └── urdf_exporter.py # URDF导出 │ └── main.py # 入口脚本 ├── tests/ │ ├── test_parser.py │ └── test_modeler.py ├── output/ # 生成的模型文件 ├── requirements.txt └── README.md这个结构的好处是职责分离清晰。parser只负责文本到参数的转换modeler只负责参数到几何的转换exporter只负责几何到文件的转换。每个模块可以单独测试改一个模块不会影响其他模块。requirements.txt里至少要包含这些cadquery2.2 numpy1.21 lxml4.6 regex2021.0如果要做单元测试再加个pytest。如果要做命令行工具加个click或者argparse。3.2 解析器的完整实现流程解析器的入口函数接收一个字符串返回一个字典。字典的结构是这样的{ shape_type: box, dimensions: {length: 80.0, width: 60.0, height: 40.0}, features: [ {type: hole, diameter: 6.0, positions: [(20,15), (60,15), (20,45), (60,45)], through: True} ], fillets: [{edge_selector: |Z, radius: 2.0}], chamfers: [] }第一步是分词。中文分词用jieba或者regex的\p{Han}匹配连续汉字英文和数字用\w匹配。分词之后得到一个token列表比如“一个80x60x40的长方体底座上面开四个M6的沉头孔”会分成[一个, 80, x, 60, x, 40, 的, 长方体, 底座, 上面, 开, 四个, M6, 的, 沉头孔]。第二步是类型识别。遍历token列表查同义词表找到第一个匹配的几何体类型。如果同时匹配到多个类型比如“长方体底座”同时匹配到“长方体”和“底座”优先取更具体的那个。同义词表用字典实现key是各种说法value是标准类型名。第三步是参数提取。找到类型关键词的位置之后看它前面和后面各有哪些数字token。对于长方体取最近的三个数字作为长宽高。如果数字不够三个缺的用默认值补。对于圆柱体取最近的两个数字作为直径和高度。这里要注意“M6”这种带字母的数字它表示螺纹规格不是尺寸参数提取的时候要排除掉。第四步是特征识别。查找“孔”“洞”“开口”等关键词然后看它前面有没有“沉头”“通孔”“盲孔”等修饰词后面有没有数字表示孔径或数量。孔位坐标如果文本里没给就按均匀分布自动计算。比如“四个孔”就默认在四个角各打一个边距取板厚的1.5倍或者孔径的2倍取较大的那个。3.3 建模器的参数化实现建模器的核心是一个build_model函数接收解析器输出的参数字典返回一个cadquery的Workplane对象。这个函数的实现逻辑是def build_model(params): shape_type params[shape_type] dims params[dimensions] if shape_type box: model cq.Workplane(XY).box(dims[length], dims[width], dims[height]) elif shape_type cylinder: model cq.Workplane(XY).circle(dims[diameter]/2).extrude(dims[height]) else: raise ValueError(f不支持的几何体类型: {shape_type}) # 应用特征 for feature in params.get(features, []): if feature[type] hole: model apply_holes(model, feature) # 应用倒角和圆角 for fillet in params.get(fillets, []): model model.edges(fillet[edge_selector]).fillet(fillet[radius]) return modelapply_holes函数的实现要注意几个细节。第一是工作平面的选择打孔要在正确的面上操作。对于长方体如果孔在顶面就选faces(Z)如果在侧面就选faces(X)或者faces(Y)。第二是孔位坐标的转换如果用户给的是从角落算的坐标要先减去半长半宽转换到中心坐标系。第三是孔的深度贯穿孔用hole()方法盲孔用hole(depth)方法。倒角和圆角的顺序也有讲究。一般先做大的圆角再做小的倒角因为倒角会改变边的拓扑结构如果先倒角再选边做圆角可能选不到想要的边。我的经验是先做所有圆角再做所有倒角最后打孔。打孔放最后是因为孔会切断边如果先打孔再选边做圆角孔边缘的边也会被选中结果就是孔口也被圆角了这通常不是你想要的效果。3.4 导出STEP和URDF的完整代码STEP导出很简单一行代码cq.exporters.export(model, output/part.step)如果要控制精度可以加参数cq.exporters.export(model, output/part.step, tolerance0.001, angularTolerance0.1)tolerance是线性公差单位是毫米0.001表示1微米。angularTolerance是角度公差单位是弧度0.1表示大约5.7度。这两个值越小导出的模型越精细但文件也越大。一般机械零件用0.01和0.5就够了。URDF导出要自己拼XML。先定义link和joint的结构from lxml import etree def export_urdf(model, name, mass1.0): robot etree.Element(robot, namename) # 导出网格文件 mesh_path f{name}.stl cq.exporters.export(model, foutput/{mesh_path}) # 创建link link etree.SubElement(robot, link, namef{name}_link) visual etree.SubElement(link, visual) geometry etree.SubElement(visual, geometry) mesh etree.SubElement(geometry, mesh, filenamemesh_path) # 创建joint如果是单个零件用fixed joint连接到base joint etree.SubElement(robot, joint, namef{name}_joint, typefixed) parent etree.SubElement(joint, parent, linkbase_link) child etree.SubElement(joint, child, linkf{name}_link) # 写入文件 tree etree.ElementTree(robot) tree.write(foutput/{name}.urdf, pretty_printTrue, xml_declarationTrue)这段代码生成的是一个最简单的URDF只有一个link和一个fixed joint。如果要生成多连杆的机器人模型需要循环创建多个link和joint并且正确设置joint的origin和axis。注意CoppeliaSim导入URDF的时候如果URDF里引用的网格文件路径是相对路径CoppeliaSim会相对于URDF文件所在目录去找网格文件。所以网格文件和URDF必须放在同一个目录下或者用绝对路径。我一般用相对路径然后把所有文件打包到一个文件夹里这样迁移的时候不会丢文件。3.5 批量生成的性能优化如果要批量生成几百个模型性能是个问题。cadquery的建模操作是CPU密集型的单个简单零件大概需要0.5到2秒。如果串行生成100个零件就是一两分钟。优化思路有两个一是用多进程并行二是复用已经创建的对象。多进程并行用multiprocessing.Pool就行把参数列表分成几份每个进程处理一份。注意cadquery的对象不能跨进程传递所以每个进程要独立导入cadquery独立创建模型独立导出文件。进程数不要超过CPU核心数一般设成核心数减一留一个核心给系统调度。复用对象这个思路更巧妙一些。如果一批零件的结构完全相同只是尺寸不同可以先创建一个模板模型然后通过缩放或者参数修改来生成变体。但cadquery不支持直接修改已有对象的参数所以这个思路实现起来比较麻烦。更实际的做法是把公共的建模步骤封装成函数减少重复代码但性能提升有限。我实测下来用8核的机器并行生成100个简单零件总耗时大概15秒平均每个0.15秒比串行快了将近10倍。如果零件更复杂比如带很多孔和倒角并行加速比会更高因为每个零件的计算量更大了。4. 常见问题与排查技巧实录4.1 安装和导入阶段的典型报错问题一ImportError: DLL load failed while importing OCP这是Windows上最常见的问题原因是OpenCASCADE的DLL文件没有被正确加载。解决办法有三个一是用conda安装而不是pipconda会自动处理DLL依赖二是把OpenCASCADE的bin目录加到系统PATH里三是用os.add_dll_directory()在Python代码里手动指定DLL目录。问题二ModuleNotFoundError: No module named cadquery这个通常是虚拟环境没激活或者装到了错误的Python版本下。检查方法是pip list | grep cadquery看有没有装which python看当前用的是哪个Python。如果确认装了但还是找不到可能是PYTHONPATH的问题试试python -c import sys; print(sys.path)看路径对不对。问题三ValueError: Cannot convert to a shape这个错误通常出现在建模阶段原因是传给cadquery的参数类型不对。比如把字符串传给了需要浮点数的参数或者把None传给了必填参数。排查方法是打印出参数字典逐个检查类型和值。我一般会在建模函数入口加一个参数校验确保所有尺寸都是正数所有坐标都是数值类型。4.2 建模阶段的常见几何错误问题一打孔打到了错误的位置这个问题的根源通常是坐标系理解错了。cadquery的box方法创建的长方体原点在几何中心不是角落。如果你按角落坐标给孔位孔就会偏。解决办法是在打孔之前先做一个平移把工作平面的原点移到角落。代码是model model.translate((length/2, width/2, 0))这样后续的坐标就都是从角落算了。问题二倒角之后模型变成空壳这个通常是因为倒角半径太大了超过了相邻边的长度。比如一个10毫米厚的板你倒一个6毫米的角两边加起来12毫米超过了板厚几何内核就处理不了了。解决办法是减小倒角半径或者先检查一下最小边长度确保倒角半径不超过最小边的一半。问题三导出的STEP文件打开是空的这个一般是导出路径的问题。cadquery的export函数如果路径不存在不会自动创建目录而是静默失败。解决办法是在导出之前用os.makedirs(os.path.dirname(path), exist_okTrue)确保目录存在。另外路径里不要有中文和空格有些CAD软件对中文路径支持不好。4.3 URDF导入CoppeliaSim的排查清单URDF导入CoppeliaSim失败的原因很多我整理了一个排查清单按顺序检查检查项常见问题解决办法文件路径网格文件路径错误确保URDF和网格文件在同一目录用相对路径引用单位尺寸单位不是米所有尺寸除以1000URDF标准单位是米XML格式标签未闭合或属性拼写错误用xmllint验证XML格式惯性矩阵惯性张量为零或负定检查质量和惯性矩阵确保正定joint类型不支持的joint类型只使用revolute、prismatic、fixed、continuous网格格式STL文件有非流形边用MeshLab修复网格或者换DAE格式提示CoppeliaSim导入URDF之后如果模型显示为白色没有纹理这是正常的因为URDF里没有定义材质。可以在CoppeliaSim里手动给模型赋材质或者用DAE格式的网格文件DAE可以包含材质信息。4.4 批量生成时的内存管理技巧批量生成模型的时候内存泄漏是个隐蔽的问题。cadquery的Workplane对象在每次操作后都会创建新对象旧对象如果没被垃圾回收就会一直占内存。生成几百个模型之后内存可能涨到几个G。解决办法是在每次循环结束后手动触发垃圾回收import gc for params in param_list: model build_model(params) export_step(model, foutput/part_{i}.step) del model gc.collect()del model删除引用gc.collect()强制回收。实测下来加了这两行之后内存占用稳定在200M左右不加的话会一直涨。另一个技巧是不要在循环里累积结果。比如不要把所有模型对象存到一个列表里最后统一导出而是一个一个生成、导出、释放。这样内存峰值最低。4.5 自然语言解析的边界情况处理解析器最怕的是用户输入模糊或者有歧义。比如“一个80x60x40的板上面打几个孔”这里“几个”没有具体数字解析器就不知道打几个。我的处理策略是如果数量词缺失默认打4个孔按矩形均匀分布。如果孔径缺失默认取板厚的四分之一但不小于3毫米。还有一种情况是用户输入了矛盾的信息比如“一个直径50高度100的圆柱长80宽60”。这时候解析器会同时匹配到圆柱和长方体两个类型。我的处理策略是取第一个匹配到的类型后面的忽略同时在日志里输出一个警告提示用户输入有歧义。单位混用也是常见问题比如“长80毫米宽6厘米”。解析器需要分别处理每个数字的单位不能统一按一个单位算。我的做法是在提取数字的时候同时提取它后面的单位词然后分别转换。如果数字后面没有单位词就沿用前一个数字的单位如果前面也没有就用默认单位毫米。4.6 模型精度与文件大小的平衡STEP文件的精度由导出时的tolerance参数控制。tolerance越小曲面越平滑但文件越大。我做过一个测试一个带圆角的圆柱体tolerance从0.1降到0.001文件大小从50KB涨到了2MB涨了40倍但视觉上看不出明显区别。我的经验值是做视觉展示用0.05做3D打印用0.01做精密装配用0.001。如果只是导入仿真软件做运动学验证0.1都够用因为仿真软件显示的时候本身就会做网格简化。STL文件的精度由网格密度控制cadquery导出STL的时候可以指定tolerance和angularTolerance。STL文件通常比STEP大因为它是三角网格曲面需要很多三角形来逼近。一个STEP文件50KB的零件导出成STL可能变成5MB。所以如果不需要网格格式尽量用STEP。URDF里引用的网格文件建议用STL而不是DAE因为STL更通用所有仿真软件都支持。DAE虽然能带材质但不同软件对DAE的支持程度不一样有时候会出问题。4.7 实际项目中的经验教训做了几个实际项目之后我总结了几个教训。第一个是不要过度依赖自然语言解析对于复杂的零件最好的方式还是提供一个结构化的参数接口让用户填表格或者写JSON自然语言只作为辅助输入方式。自然语言解析的准确率很难做到100%对于关键尺寸让用户确认一遍比事后返工划算得多。第二个是版本控制很重要。生成的STEP文件是二进制格式git没法做diff所以每次生成的模型最好同时保存一份参数JSON这样版本对比的时候看JSON就行了。我一般会在输出目录里同时保存part.step和part.jsonJSON里记录生成这个模型用的所有参数。第三个是测试用例要覆盖边界情况。我写了一个测试脚本自动生成各种极端参数的模型比如长宽高都是0.1毫米的微小零件或者长宽高都是10000毫米的巨大零件看会不会报错。这些边界情况在实际项目中真的会遇到提前测出来比事后调试强。第四个是文档要写清楚支持的自然语言表达方式。用户不知道你的解析器能识别哪些词就会乱输入。我在README里列了一个支持的说法列表比如“长方体”“方块”“矩形体”都支持“圆柱”“圆棒”“轴”都支持。用户看了就知道该怎么描述解析成功率也高了。
阅读完成 · 觉得有帮助?
咨询建站