1. 从一段文字到三维模型:text-to-cad 到底在解决什么问题
第一次听到 text-to-cad 这个词,很多人脑子里浮现的画面大概是:对着电脑敲一句“给我画一个带四个轮子的小车”,然后屏幕上就自动出现一个可以旋转、可以导出、可以拿去打印的 3D 模型。这个想象方向没错,但真正落地的时候,它远没有这么“魔法”。我前后折腾了大概两个月,从最开始的纯文本生成网格,到后来能稳定输出 STEP 和 URDF,中间踩的坑足够写一本小册子。这篇文章就把我这一路的经验完整摊开,讲清楚 text-to-cad 的核心逻辑、技术选型、实操步骤,以及那些文档里不会写的坑。
先把概念说清楚。text-to-cad,字面意思就是“文本转 CAD”,它属于 AI 辅助设计这个大方向下的一个细分场景。和常见的“文生图”不同,CAD 模型对尺寸精度、拓扑结构、参数可编辑性的要求高得多。一张图生成得再好看,如果尺寸是错的、面是破的、没法在 SolidWorks 或 FreeCAD 里继续编辑,那对工程师来说就是废品。所以 text-to-cad 真正的难点不在于“生成”,而在于“生成得能用”。
它适合谁?我总结下来是三类人:第一类是机械、建筑、工业设计方向的工程师,想用自然语言快速出草图或概念模型,省掉重复建模的时间;第二类是做机器人仿真的人,需要批量生成 URDF 描述文件,把模型导入 CoppeliaSim、Gazebo 这类仿真环境;第三类是做 AI 应用开发的程序员,想在自己的产品里集成“文字生成模型”的能力。如果你属于这三类中的任何一类,下面的内容应该都能直接用上。
核心关键词里出现了 CAD、STEP、URDF、Python,这四个词基本勾勒出了整条技术链路:用 Python 做胶水语言,把自然语言解析成结构化的几何参数,再通过几何内核生成 CAD 实体,最后导出成 STEP(通用三维交换格式)或 URDF(机器人描述格式)。下面我按这个链路一层层拆。
2. 整体架构设计:为什么我最终选了“参数化”而不是“端到端生成”
2.1 两条技术路线的取舍
做 text-to-cad,摆在面前的无非两条路。第一条是端到端生成:训练一个模型,输入文本,直接输出网格文件(STL、OBJ)或者点云。第二条是参数化生成:让模型把文本解析成一组结构化的几何参数(长宽高、孔位、圆角半径等),再调用几何内核去“画”出模型。
我一开始走的是第一条路,用现成的文生 3D 模型跑了一批测试。结果很现实:生成的网格面数动辄几十万,拓扑乱七八糟,尺寸完全不可控,更致命的是没法导出 STEP。STEP 是 B-rep(边界表示)格式,要求模型是由精确的曲面和实体构成的,而神经网络生成的网格是三角面片,两者根本不是一个体系。你硬要转换,得到的也是一堆碎面,工程上没法用。
所以我转向了参数化路线。这条路的核心思想是:AI 只负责“理解语言”和“输出参数”,几何的精确性交给成熟的 CAD 内核。这样做的好处非常明显——尺寸可控、拓扑干净、天然支持 STEP 导出、生成的模型还能反向转成可编辑的特征树。代价是,你需要提前定义好一套“参数 schema”,也就是模型能理解哪些几何原语、哪些约束。
2.2 整体数据流
我把整条链路拆成四层,从下往上说。
最底层是几何内核层。我选的是 Python 生态里的 CadQuery,它底层是 OpenCASCADE(OCCT),这是工业界用了二十多年的几何内核,稳定性和精度都有保障。CadQuery 的好处是 API 是链式的,写起来像搭积木,而且原生支持 STEP 导出。
往上一层是参数解析层。这一层负责把大模型输出的 JSON 转成 CadQuery 能执行的调用。这里的关键是定义一个严格的 schema,比如一个“带孔板”的参数长这样:长度、宽度、厚度、孔径、孔位坐标数组、圆角半径。模型只要按这个格式吐 JSON,我就能稳定地把它变成几何体。
再往上是语言理解层。这一层用大语言模型做,负责把“给我做一个 100 毫米长、50 毫米宽、四角各有一个直径 5 毫米圆孔的板子”这种自然语言,转成上面那个 JSON。这里我用的是带 function calling 能力的模型,通过工具定义强制它输出结构化数据,比纯 prompt 靠谱得多。
最上面是交互与导出层,负责接收用户输入、展示预览、导出 STEP 或 URDF。预览我用的是 CadQuery 自带的导出 STL 再丢给前端渲染,导出 URDF 则需要额外做一步坐标系和关节的映射。
2.3 为什么这套架构更稳
有人会问,为什么不直接让模型输出 CadQuery 的 Python 代码?我试过,确实可行,但风险在于模型可能生成有语法错误或者逻辑错误的代码,执行起来直接崩。而输出 JSON 参数的话,我可以在解析层做严格的校验,参数超范围、类型不对、必填项缺失,都能在进入几何内核之前拦下来。把“生成”和“执行”解耦,是这套方案最关键的工程决策。
另外,参数化路线还有一个隐藏好处:可复现。同一个 JSON 输入,任何时候跑出来的模型都是一模一样的。端到端生成做不到这一点,每次采样结果都不同,这对工程场景是致命的。
3. 核心细节拆解:从自然语言到几何参数的完整映射
3.1 参数 schema 怎么设计才够用
schema 设计是整件事的地基。设计得太窄,稍微复杂点的需求就表达不了;设计得太宽,模型容易输出乱七八糟的东西。我的经验是,从最常见的几何原语出发,逐步扩展。
我最初只定义了三种原语:长方体、圆柱、带孔板。跑了一段时间发现不够用,又加了圆环、倒角块、阵列孔板。目前我的 schema 覆盖了大概十几种原语,基本能应付 80% 的概念设计需求。每种原语都有一组必填参数和一组可选参数,可选参数有默认值。
举个具体的例子,带孔板的 schema 我是这样定义的:
{ "type": "plate_with_holes", "length": 100.0, "width": 50.0, "thickness": 5.0, "corner_radius": 2.0, "holes": [ {"x": 10.0, "y": 10.0, "diameter": 5.0}, {"x": 90.0, "y": 10.0, "diameter": 5.0} ], "unit": "mm" }这里有几个细节值得说。第一,所有尺寸必须带单位,我强制用毫米,避免模型输出“5 厘米”这种需要二次换算的表述。第二,孔位用绝对坐标而不是相对坐标,虽然相对坐标更省 token,但绝对坐标不容易出错,尤其是孔多的时候。第三,圆角半径单独列出来,因为圆角在几何内核里是单独的操作,放在最后统一处理比每个边单独处理要稳。
3.2 大模型输出 JSON 的稳定性技巧
让大模型稳定输出符合 schema 的 JSON,是另一个大坑。我试过三种方案,最后选了 function calling。
第一种是纯 prompt,在系统提示里写清楚格式要求。问题是模型经常“自由发挥”,加一些 schema 里没有的字段,或者把数字写成字符串。第二种是 JSON mode,强制输出 JSON,但字段结构还是不受控。第三种是 function calling,把 schema 定义成工具的参数,模型必须按这个结构来填。实测下来,function calling 的字段准确率能到 95% 以上,剩下 5% 靠解析层的校验兜底。
还有一个技巧是给模型几个 few-shot 示例。我在工具定义里塞了三个典型例子,覆盖简单板、带孔板、圆柱阵列。模型看到例子之后,输出的格式明显更规范。这个投入产出比非常高,强烈建议加上。
3.3 几何内核调用的参数校验
参数从模型出来之后,不能直接丢给 CadQuery,必须先过一遍校验。我踩过的坑包括:模型输出了负数的厚度、孔位跑到板子外面、圆角半径大于板厚的一半。这些在几何内核里要么直接报错,要么生成一个破面模型。
我的校验规则分三层。第一层是类型和范围校验,所有尺寸必须为正数,圆角半径不能超过最小边的一半。第二层是几何一致性校验,孔位必须在板子范围内,孔之间不能重叠。第三层是可制造性校验,比如孔径不能小于某个阈值,否则实际加工做不出来。这三层校验加起来大概两百行代码,但省掉了我无数次的调试时间。
提示:校验失败的时候,不要直接把错误抛给用户,而是把错误信息回传给大模型,让它重新生成参数。我实测下来,模型在收到具体错误提示后,第二次生成的正确率能到 90% 以上。
4. 实操过程:手把手搭一个能跑的 text-to-cad 流水线
4.1 环境准备与依赖安装
先把环境搭起来。我用的是 Python 3.10,太新的版本有些库还没跟上,太老的版本 CadQuery 装不上。虚拟环境用 venv 就行,没必要上 conda。
python -m venv tcad-env source tcad-env/bin/activate # Windows 用 tcad-env\Scripts\activate pip install cadquery==2.4.0 pip install openai pip install pydanticCadQuery 的安装是这里最容易出问题的环节。它在 Windows 上依赖 OCCT 的预编译包,如果 pip 装不上,可以去它的官方文档找 conda 的安装方式。Linux 上一般没问题,但要注意系统里得有 OpenGL 相关的库,否则导出预览图的时候会报错。
Pydantic 用来做参数校验,比手写 if-else 优雅得多,而且错误信息很清晰。OpenAI 的库用来调大模型,如果你用别的模型,换成对应的 SDK 就行。
4.2 定义参数模型与校验逻辑
用 Pydantic 把 schema 定义成类,校验逻辑直接写在类里。这样模型输出的 JSON 一进来就能自动校验,省掉大量样板代码。
from pydantic import BaseModel, Field, field_validator from typing import List class Hole(BaseModel): x: float y: float diameter: float = Field(gt=0) class PlateWithHoles(BaseModel): type: str = "plate_with_holes" length: float = Field(gt=0) width: float = Field(gt=0) thickness: float = Field(gt=0) corner_radius: float = Field(ge=0) holes: List[Hole] = [] unit: str = "mm" @field_validator("corner_radius") def check_radius(cls, v, info): thickness = info.data.get("thickness", 0) if v > thickness / 2: raise ValueError("圆角半径不能超过板厚的一半") return v这段代码里,Field(gt=0)表示必须大于 0,field_validator用来做跨字段的校验。Pydantic 会在实例化的时候自动跑这些校验,任何一条不过就抛异常,我在外层捕获之后把错误信息回传给模型重新生成。
4.3 调用大模型生成参数
调用部分我用的是 function calling。把上面定义的 schema 转成工具描述,塞进请求里。
import openai import json tools = [{ "type": "function", "function": { "name": "create_plate_with_holes", "description": "创建一个带圆孔的长方形板", "parameters": PlateWithHoles.model_json_schema() } }] def generate_params(user_input: str) -> dict: response = openai.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "你是一个 CAD 参数生成助手,根据用户描述生成几何参数。"}, {"role": "user", "content": user_input} ], tools=tools, tool_choice={"type": "function", "function": {"name": "create_plate_with_holes"}} ) args = response.choices[0].message.tool_calls[0].function.arguments return json.loads(args)这里tool_choice强制模型必须调用这个工具,避免它跑偏去聊天。拿到参数之后,先过 Pydantic 校验,通过了再进几何生成。
4.4 用 CadQuery 生成几何体并导出 STEP
几何生成这一段是整条链路里最“实”的部分。CadQuery 的链式 API 写起来很直观,我以带孔板为例。
import cadquery as cq def build_plate(params: PlateWithHoles): # 先建基础板 plate = ( cq.Workplane("XY") .box(params.length, params.width, params.thickness) .edges("|Z") .fillet(params.corner_radius) ) # 再打孔 if params.holes: plate = ( plate.faces(">Z") .workplane() .pushPoints([(h.x - params.length/2, h.y - params.width/2) for h in params.holes]) .hole(5.0) # 这里简化处理,实际按每个孔的直径分别打 ) return plate result = build_plate(validated_params) cq.exporters.export(result, "output.step")注意几个细节。第一,CadQuery 的box是以原点为中心的,所以孔位坐标要做一次平移,把用户输入的左下角坐标系转成中心坐标系。第二,fillet操作要放在打孔之前,否则圆角会把孔的边缘也带上,结果不对。第三,hole方法一次只能打一种直径的孔,如果孔直径不同,要分组处理或者循环调用。
导出 STEP 就一行代码,CadQuery 会自动处理 B-rep 的转换。导出的文件可以直接在 SolidWorks、FreeCAD、中望 CAD 里打开,尺寸和拓扑都是干净的。
4.5 导出 URDF 用于机器人仿真
如果你的目标是机器人仿真,那还需要把模型转成 URDF。URDF 本质是一个 XML 文件,描述机器人的连杆、关节、坐标系和几何形状。text-to-cad 生成的模型可以作为连杆的视觉和碰撞几何。
def export_urdf(step_file: str, urdf_path: str, link_name: str = "base_link"): urdf_content = f'''<?xml version="1.0"?> <robot name="generated_robot"> <link name="{link_name}"> <visual> <geometry> <mesh filename="{step_file}" scale="1 1 1"/> </geometry> </visual> <collision> <geometry> <mesh filename="{step_file}" scale="1 1 1"/> </geometry> </collision> </link> </robot>''' with open(urdf_path, "w") as f: f.write(urdf_content)这里有个坑:URDF 对 mesh 格式的支持,不同仿真器不一样。CoppeliaSim 对 STL 支持最好,Gazebo 对 DAE 和 STL 都行,STEP 反而不被直接支持。所以实际用的时候,我一般会同时导出 STL 给 URDF 用,STEP 给 CAD 编辑用。另外,URDF 里的坐标系是右手系,单位是米,而 CAD 里我习惯用毫米,导出的时候要做一次单位换算,否则模型会大一千倍。
注意:URDF 的 mesh 路径建议用相对路径,并且和 URDF 文件放在同一个目录下,这样在不同机器之间迁移的时候不会因为绝对路径失效而加载失败。
5. 常见问题与排查技巧实录
5.1 几何生成阶段的典型报错
这一阶段的问题基本都出在参数和几何内核的交互上。我整理了一张速查表,覆盖了我遇到过的绝大多数情况。
| 报错信息 | 根本原因 | 解决方法 |
|---|---|---|
| BRep_API: command not done | 圆角半径过大或几何自相交 | 检查圆角半径是否超过最小边的一半 |
| Standard_Failure | 孔位超出板子范围 | 校验孔位坐标是否在边界内 |
| Null shape | 尺寸参数为 0 或负数 | 在 Pydantic 层加 gt=0 校验 |
| STEP 导出后模型为空 | 几何体没有闭合 | 检查布尔运算是否成功,加.clean() |
| 模型面数爆炸 | 圆角或孔的分辨率过高 | 降低fillet和hole的段数参数 |
这张表里的每一条我都是真金白银踩出来的。尤其是第一条,圆角半径过大导致几何内核直接罢工,报错信息还特别模糊,我查了大半天才定位到。
5.2 大模型输出不稳定的应对
模型输出不稳定主要体现在三个方面:字段缺失、类型错误、数值离谱。字段缺失靠 Pydantic 的必填校验能拦住,类型错误靠 Pydantic 的自动转换能救回来一部分,数值离谱就得靠范围校验。
我的经验是,把校验错误信息原样回传给模型,让它重试,比自己在代码里猜要高效得多。比如模型输出了thickness: -5,我把“厚度必须大于 0”这个错误回传,模型第二次基本都能改对。重试次数我设的是 3 次,超过 3 次就报错给用户,避免无限循环。
还有一个技巧是在系统提示里明确单位。我一开始没写,模型有时候输出英寸有时候输出毫米,导致模型尺寸忽大忽小。后来在提示里加了一句“所有尺寸单位统一为毫米”,这个问题就再没出现过。
5.3 导出格式的兼容性坑
STEP 的兼容性总体很好,但有一个坑:不同 CAD 软件对 STEP 版本的支持不一样。CadQuery 默认导出的是 AP214,这个版本兼容性最好。如果你导出 AP203,有些老版本的软件会读不出来。我一般显式指定版本:
cq.exporters.export(result, "output.step", exportType="STEP", opt={"write_pcurves": False})write_pcurves设成 False 可以避免一些软件读取时出现多余的曲线,文件也更小。
URDF 的坑主要在坐标系和单位上。前面提过单位换算,这里再强调一次:CAD 用毫米,URDF 用米,导出前一定要除以 1000。另外,URDF 的关节原点定义和 CAD 的装配坐标系不是一回事,如果你的模型有多个零件,需要额外定义关节的origin和axis,这部分建议参考 CoppeliaSim 的官方示例,照着改最快。
5.4 性能优化的几个实用手段
text-to-cad 的整条链路里,最慢的环节是大模型调用,其次是几何生成。大模型调用没法优化太多,但可以通过缓存减少重复请求。我把“用户输入 -> 参数 JSON”这个映射做了本地缓存,相同的输入直接命中缓存,省掉一次 API 调用。
几何生成这边,CadQuery 本身性能不错,但如果你要批量生成几百个模型,建议用多进程并行。CadQuery 是 CPU 密集型的,多进程比多线程有效。我用concurrent.futures.ProcessPoolExecutor跑批,8 核机器上速度大概能提升 5 倍。
还有一个细节是预览图的生成。CadQuery 导出 PNG 预览需要调用 OCCT 的渲染,这一步比较慢。我的做法是先生成低分辨率的预览,用户确认之后再生成高精度的。低分辨率预览用 200x200 就够了,生成时间能压到 1 秒以内。
6. 我在这条路上踩过的几个大坑
第一个坑是过早追求端到端。我一开始花了两周时间折腾文生 3D 模型,结果发现导出的网格根本没法用。如果重来一次,我会直接从参数化路线开始,省下这两周。
第二个坑是schema 设计得太随意。最开始我没用 Pydantic,手写校验逻辑,结果代码又长又容易漏。换成 Pydantic 之后,校验逻辑清晰了很多,而且错误信息直接能回传给模型。
第三个坑是忽略了单位统一。这个问题看起来小,但导致的 bug 特别隐蔽。模型尺寸大一千倍的时候,预览图里根本看不出来,直到导出 URDF 导入仿真器才发现。现在我所有涉及尺寸的地方都强制带单位,并且在入口处做一次统一换算。
第四个坑是没有做参数缓存。开发阶段反复测试同一个输入,每次都调 API,既慢又费钱。加了本地缓存之后,开发效率提升明显。
如果你也在做 text-to-cad 相关的东西,我的建议是:先把参数化这条路走通,哪怕只支持一种几何原语,也比端到端生成一堆没法用的网格强。等链路跑通了,再逐步扩展原语种类和复杂度。这个方向目前还在快速演进,工具链每个月都有新东西出来,保持关注,但别被新工具牵着走,把核心链路吃透才是最重要的。