1. 从一段文字到三维模型:text-to-cad 到底在解决什么问题
第一次听到 text-to-cad 这个词,我脑子里蹦出来的画面是:对着电脑敲一句“给我画一个长宽高 100×60×30 毫米、四角带 M4 沉头孔的法兰底座”,然后屏幕上直接出现一个可以旋转查看、能导出 STEP 文件送进加工中心的三维实体。这个画面在几年前还属于科幻范畴,但现在已经有一批工具和方案在往这个方向靠了。
text-to-cad,直译就是“文本转 CAD”。它的核心逻辑是:把自然语言描述或者结构化的文本参数,转换成计算机辅助设计软件能够识别的几何模型数据,最终输出成 STEP、GLB、STL 这类通用格式。它解决的是一个非常具体的痛点——大量非专业建模人员(比如产品经理、硬件创业者、机械专业的学生、做创客项目的开发者)脑子里有明确的结构想法,但卡在不会用 SolidWorks、Fusion 360、中望 CAD 这些工具上。传统路径是:想清楚需求 → 找会建模的人 → 反复沟通 → 改图 → 出图,周期长、成本高、沟通损耗大。text-to-cad 想砍掉中间环节,让“描述即建模”成为可能。
这篇文章适合谁看?如果你是做硬件产品原型的、搞机械设计的、玩 3D 打印的、或者单纯对“用代码和文字生成三维模型”这件事好奇的开发者,那接下来的内容应该对你有用。我会从整体设计思路讲到具体实现路径,把 STEP、GLB、STL 这几种格式的取舍讲清楚,再给出一套可以跟着操作的流程,最后把我踩过的坑和排查经验整理出来。全文基于我自己的实践和行业里常见的做法来写,不保证是唯一解,但保证是能跑通的方案。
2. 整体设计思路:为什么不是“一句话直接出图”那么简单
2.1 文本到几何的映射难点在哪里
很多人对 text-to-cad 的期待是“输入一句话,输出一个精确的工程图”。这个期待本身没问题,但实现起来有几个硬骨头要啃。
第一个难点是语义到几何的映射不确定性。你说“一个圆柱”,直径多少?高度多少?是实心还是空心?这些参数在自然语言里经常是缺失的。人类工程师听到“圆柱”会默认追问参数,但机器不行。所以 text-to-cad 系统要么要求用户提供结构化参数,要么用大语言模型去“猜”一个合理默认值,再让用户确认。我试过几种方案,最稳的还是“半结构化输入”——用类似 JSON 或者 YAML 的格式把关键尺寸写清楚,自然语言只用来描述拓扑关系和特征类型。
第二个难点是几何内核的精度和鲁棒性。CAD 模型不是随便画个网格就完事了,它需要精确的边界表示(B-rep),需要支持布尔运算、倒角、抽壳这些操作。OpenCASCADE 是目前开源方案里最成熟的内核,Python 的 cadquery 和 build123d 都是基于它封装的。但 OpenCASCADE 的布尔运算在极端情况下会失败,比如两个面几乎共面的时候,或者倒角半径大于相邻边长度的时候。这些坑在纯文本生成场景下更容易触发,因为用户不会像在 GUI 里那样实时看到预览并调整。
第三个难点是输出格式的适配。STEP 是精确的 B-rep 格式,适合后续在 CAD 软件里继续编辑和出工程图;STL 是三角网格,适合 3D 打印和渲染,但丢失了特征信息;GLB 是 glTF 的二进制版本,适合网页展示和 AR/VR 场景。text-to-cad 系统需要根据下游用途选择输出格式,有时候甚至要同时输出多种格式。
2.2 为什么选择“代码化建模”作为中间层
直接让大语言模型输出 STEP 文件是不现实的,因为 STEP 的语法极其冗长且对拓扑一致性要求极高。目前业界比较务实的做法是:用代码作为中间表示。具体来说,就是让模型生成 Python 代码(调用 cadquery 或 build123d 这类库),然后执行代码得到几何体,再导出成目标格式。
这个设计的好处很明显。第一,代码是可读、可审查、可修改的,用户看到生成的代码能理解模型是怎么构建的,不满意可以直接改参数。第二,代码执行环境是确定的,同样的代码跑出来一定是同样的几何体,不会像直接生成网格那样每次都有随机性。第三,代码可以版本控制,一个 text-to-cad 项目本质上就是一堆参数化脚本的集合,用 Git 管理起来非常方便。
我自己的项目里就是走这条路:用户输入一段描述,后端调用大语言模型生成 cadquery 脚本,然后在沙箱里执行脚本,捕获几何体,导出 STEP 和 STL,最后把文件链接返回给前端。整个链路跑通之后,从输入文字到拿到可下载的模型文件,大概在 10 到 30 秒之间,取决于模型复杂度和 API 响应速度。
2.3 格式选型:STEP、GLB、STL 各自的位置
这三种格式在 text-to-cad 流程里扮演不同角色,不能混为一谈。
| 格式 | 类型 | 典型用途 | 优点 | 缺点 |
|---|---|---|---|---|
| STEP | B-rep 精确几何 | 后续 CAD 编辑、CNC 加工、出工程图 | 精度高、保留特征、通用性强 | 文件较大、解析复杂 |
| STL | 三角网格 | 3D 打印、快速渲染 | 格式简单、兼容性好 | 丢失特征、精度受网格密度影响 |
| GLB | 二进制 glTF | 网页展示、AR/VR、游戏引擎 | 加载快、支持材质和动画 | 非精确几何、不适合加工 |
我的建议是:主输出用 STEP,辅输出用 STL 和 GLB。STEP 作为“源文件”保存,STL 给 3D 打印用,GLB 给前端预览用。这样一套数据可以覆盖从设计到展示到制造的全链路。
3. 核心细节解析:从文本到几何的关键环节
3.1 输入解析:怎么把一句话变成结构化参数
输入解析是整个流程的第一道关卡,也是最容易被低估的环节。我见过太多项目在这里翻车——用户说“画一个盒子”,系统生成一个 1×1×1 毫米的立方体,用户直接懵了。
我的做法是设计一个两阶段解析流程。第一阶段用大语言模型做意图识别和参数抽取,输出一个结构化的 JSON 对象。比如用户输入“一个 100×60×30 的铝制法兰底座,四角有 M4 沉头孔,孔中心距边缘 10 毫米”,模型应该输出类似这样的结构:
{ "shape_type": "flange_base", "dimensions": { "length": 100, "width": 60, "height": 30, "unit": "mm" }, "features": [ { "type": "counterbore_hole", "count": 4, "position": "corners", "edge_offset": 10, "thread": "M4", "counterbore_diameter": 8, "counterbore_depth": 4 } ], "material": "aluminum" }第二阶段是参数校验和默认值填充。如果用户没给单位,默认用毫米;如果没给沉头孔深度,根据 M4 标准查表填一个合理值;如果长宽高比例异常(比如长度是宽度的 100 倍),给用户一个警告提示确认。这一步看起来简单,但能避免大量“生成出来完全不能用”的情况。
注意:不要指望大语言模型一次就能抽取出完美参数。我的经验是,在 prompt 里明确要求模型“对于缺失的参数,标注为 null 并给出建议值”,然后在后端做二次处理。这样比让模型瞎猜要可靠得多。
3.2 代码生成:cadquery 还是 build123d
目前 Python 生态里做参数化 CAD 生成,主流选择是 cadquery 和 build123d。两者都基于 OpenCASCADE,但 API 风格不同。
cadquery 用的是链式调用风格,代码写起来比较紧凑:
import cadquery as cq result = ( cq.Workplane("XY") .box(100, 60, 30) .faces(">Z") .workplane() .rect(80, 40, forConstruction=True) .vertices() .cskHole(4.5, 8, 90) )build123d 更偏向上下文管理器和面向对象风格,可读性更好一些:
from build123d import * with BuildPart() as flange: Box(100, 60, 30) with Locations(flange.faces().sort_by(Axis.Z)[-1]): with GridLocations(80, 40, 2, 2): CounterBoreHole(2.25, 4, 4)我最终选了 cadquery,原因是它的社区更大、文档更全、大语言模型对它的代码生成准确率更高。实测下来,同样的 prompt,cadquery 生成的代码一次通过率比 build123d 高大概 20 个百分点。这个差距在批量生成场景下非常关键。
3.3 几何内核的坑:布尔运算失败与倒角溢出
OpenCASCADE 虽然强大,但在自动化场景下有几个高频翻车点。
布尔运算失败是最常见的。当你对两个几乎共面的实体做差集运算时,内核可能因为浮点精度问题无法确定面的归属,直接抛异常。我的应对策略是:在代码生成阶段就加入“安全间隙”逻辑。比如要在底板上挖一个槽,槽的深度比板厚小 0.1 毫米,避免切穿导致零厚度面。这个 0.1 毫米的间隙在视觉上几乎看不出来,但能大幅降低布尔运算失败率。
倒角溢出是另一个高频问题。如果你对一个 10 毫米厚的板边缘做 15 毫米的倒角,内核会直接报错。解决方案是在生成倒角代码之前,先计算相邻边的最小长度,如果倒角半径超过这个长度的一半,就自动缩小到安全值,并在返回结果里标注“倒角半径已自动调整”。
抽壳失败也经常遇到。抽壳操作要求实体有明确的内外方向,如果实体本身有自相交或者非流形边,抽壳会直接失败。我的做法是在抽壳之前先跑一遍isValid()检查,不合法就先做修复或者简化。
实操心得:在沙箱里执行生成的代码时,一定要设置超时和内存限制。我遇到过生成的代码里有个死循环,直接把服务器内存吃满的情况。后来加了 30 秒超时和 2GB 内存上限,稳多了。
4. 实操过程:搭一套能跑的 text-to-cad 流水线
4.1 环境准备与依赖安装
先说一下我的环境配置,这套配置在 Ubuntu 22.04 和 macOS 上都跑通过。
# 创建虚拟环境 python -m venv text2cad-env source text2cad-env/bin/activate # 安装核心依赖 pip install cadquery==2.4.0 pip install ocp==7.7.2 pip install openai==1.12.0 pip install fastapi==0.109.0 pip install uvicorn==0.27.0 pip install trimesh==4.0.0 pip install numpy==1.26.0cadquery 的安装在不同平台上差异很大。Linux 上通常可以直接 pip 安装,Windows 上建议用 conda 装,macOS 上如果遇到 OCP 编译问题,可以用conda install -c conda-forge cadquery来解决。我试过在 Windows 上纯 pip 装 cadquery,折腾了两个小时才搞定,后来换 conda 五分钟就完事了。
4.2 大语言模型调用与代码生成
我用的是 OpenAI 的 API,但换成任何兼容 OpenAI 接口的模型都可以。关键是 prompt 的设计。下面是我实际用的 system prompt 的简化版:
SYSTEM_PROMPT = """你是一个 CAD 代码生成助手。根据用户的描述,生成 cadquery Python 代码。 要求: 1. 代码必须完整可执行,包含必要的 import 2. 最终结果赋值给变量 `result` 3. 所有尺寸单位默认为毫米 4. 如果用户未指定某个参数,使用合理的工程默认值 5. 对于可能失败的布尔运算,加入安全间隙 6. 代码中不要包含任何文件读写操作 7. 只输出代码,不要输出解释文字 示例输出格式: ```python import cadquery as cq result = cq.Workplane("XY").box(100, 60, 30)"""
调用的时候,把用户输入和 system prompt 一起发给模型,拿到返回的代码字符串,然后做一次语法检查(用 `ast.parse`),通过之后再送进沙箱执行。 ### 4.3 沙箱执行与几何体导出 沙箱执行是整个流程里最需要小心的地方。我用的方案是 `subprocess` 加资源限制: ```python import subprocess import tempfile import os def execute_cadquery_code(code: str, timeout: int = 30): with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(code) f.write("\n\nimport cadquery as cq\n") f.write("cq.exporters.export(result, '/tmp/output.step')\n") f.write("cq.exporters.export(result, '/tmp/output.stl')\n") script_path = f.name try: proc = subprocess.run( ['python', script_path], timeout=timeout, capture_output=True, text=True, env={**os.environ, 'PYTHONPATH': ''} ) if proc.returncode != 0: return None, proc.stderr return '/tmp/output.step', None except subprocess.TimeoutExpired: return None, "执行超时" finally: os.unlink(script_path)导出 STEP 用cq.exporters.export(result, 'output.step'),导出 STL 用同样的函数但换扩展名。GLB 的导出稍微麻烦一点,需要先用 trimesh 加载 STL,再转成 GLB:
import trimesh mesh = trimesh.load('/tmp/output.stl') mesh.export('/tmp/output.glb')4.4 前端预览与文件下载
前端我用的是 three.js 加载 GLB 文件做预览。整个交互流程是:用户在输入框里写描述,点生成,前端显示 loading,后端返回 GLB 的 URL,three.js 加载并渲染,同时提供 STEP 和 STL 的下载链接。
这里有个细节要注意:GLB 文件要设置正确的 MIME 类型(model/gltf-binary),否则某些浏览器会当成普通二进制文件下载而不是渲染。另外 three.js 的 GLTFLoader 需要配合 DRACOLoader 来解压压缩过的 GLB,如果后端用了 Draco 压缩的话。
5. 常见问题与排查技巧实录
5.1 生成代码执行报错怎么办
这是最高频的问题,没有之一。我整理了一个排查顺序表,按这个顺序走能解决 90% 以上的报错。
| 报错类型 | 典型信息 | 排查方向 | 解决方案 |
|---|---|---|---|
| 语法错误 | SyntaxError | 检查生成的代码是否有拼写错误 | 重新生成或手动修正 |
| 导入错误 | ModuleNotFoundError | 检查依赖是否安装 | 补装缺失的包 |
| 几何运算失败 | Standard_Failure | 布尔运算或倒角溢出 | 加入安全间隙或缩小倒角 |
| 超时 | TimeoutExpired | 代码有死循环或模型过于复杂 | 优化代码或增加超时时间 |
| 内存溢出 | MemoryError | 网格密度过高或实体过多 | 降低网格精度或简化模型 |
我遇到最多的是几何运算失败。有一次生成一个带 20 个孔的板,代码跑出来报BRep_API: command not done。排查后发现是其中两个孔的位置重叠了,导致布尔运算无法确定材料去除区域。解决方案是在生成孔之前先做碰撞检测,如果两个孔的中心距小于孔径之和,就自动调整位置或者合并成一个孔。
5.2 导出的 STEP 在 CAD 软件里打不开
这个问题通常有两个原因。一是 STEP 文件的版本不兼容,有些老版本 CAD 软件只支持 AP203 而不支持 AP214。cadquery 默认导出 AP214,可以在导出时指定:
cq.exporters.export(result, 'output.step', exportType='STEP', opt={'write_pcurves': False})二是模型本身有几何缺陷,比如非流形边或者自相交面。这种情况下需要先用result.val().isValid()检查,如果不合法,用result.val().fix()尝试修复。
5.3 STL 文件太大或太小
STL 的网格密度直接影响文件大小和打印质量。cadquery 导出 STL 时可以指定公差参数:
cq.exporters.export(result, 'output.stl', tolerance=0.01, angularTolerance=0.1)tolerance是线性公差,单位毫米,越小网格越密;angularTolerance是角度公差,单位弧度。我的经验值是:普通 3D 打印用tolerance=0.05就够了,精细模型用0.01,快速预览用0.2。文件大小和公差大致成平方关系,公差缩小 10 倍,文件大小增加约 100 倍,所以要按需选择。
5.4 大语言模型生成的代码“看起来对但跑不通”
这是最让人头疼的情况。代码语法没问题,逻辑看起来也合理,但执行就是报错。常见原因有几个:模型用了不存在的 API 方法、参数顺序搞错了、或者对 cadquery 的坐标系理解有偏差。
我的应对策略是维护一个常用代码片段库,在 prompt 里把高频操作的示例代码放进去,让模型参考。比如画孔、倒角、抽壳、阵列这些操作,各给一个标准示例。实测下来,加了示例之后代码一次通过率从 60% 左右提升到了 85% 以上。
避坑技巧:在 prompt 里明确告诉模型“不要使用
cq.Workplane的shell方法做抽壳,改用faces().shell()”,因为前者在某些版本里有 bug。这种细节只有踩过坑才知道。
6. 进阶方向:从单次生成到参数化模板库
跑通基础流程之后,我发现一个更有价值的模式:把高频生成的模型沉淀成参数化模板。比如法兰、支架、齿轮、外壳这些常见零件,与其每次都让模型重新生成代码,不如预先写好参数化脚本,用户只需要填参数就行。
这样做的好处是生成速度从 30 秒降到 1 秒以内,而且质量稳定可控。我的做法是用 Jinja2 模板引擎,把 cadquery 代码里的可变部分抽成变量:
from jinja2 import Template TEMPLATE = """ import cadquery as cq result = ( cq.Workplane("XY") .box({{ length }}, {{ width }}, {{ height }}) .faces(">Z").workplane() .rect({{ length - 2 * edge_offset }}, {{ width - 2 * edge_offset }}, forConstruction=True) .vertices() .cskHole({{ hole_diameter }}, {{ cb_diameter }}, {{ cb_angle }}) ) """ template = Template(TEMPLATE) code = template.render(length=100, width=60, height=30, edge_offset=10, hole_diameter=4.5, cb_diameter=8, cb_angle=90)这个模式特别适合批量生成场景。我有个做创客教育的朋友,用这套方案给学生批量生成不同尺寸的机器人底盘模型,每个学生填自己的参数,系统秒出 STEP 文件,直接送 3D 打印机。以前他手动建模要花一整天,现在十分钟搞定。
另一个方向是多轮对话式建模。用户第一轮说“画一个盒子”,系统生成一个基础盒子;第二轮说“在顶面加四个孔”,系统在已有模型基础上继续操作。这需要维护一个会话状态,把之前的几何体保存下来,每次新指令都在这个几何体上做增量修改。技术实现上可以用 cadquery 的Workplane对象序列化,或者把每一步的操作记录成代码片段,最后拼接执行。
7. 我在这条路上踩过的几个坑
第一个坑是低估了单位问题。早期版本没做单位校验,用户输入“画一个 10 厘米的立方体”,模型生成的是 10 毫米,差了 10 倍。后来加了单位识别和转换逻辑,支持毫米、厘米、米、英寸的自动换算,才把这个坑填上。
第二个坑是没有做几何有效性检查。生成的模型直接导出,结果有些模型在 CAD 软件里打开是空心的或者有破面。后来在导出之前强制跑isValid()和fix(),问题少了很多。
第三个坑是忽略了并发场景。单用户测试的时候一切正常,上线之后多个用户同时生成,沙箱目录冲突、临时文件互相覆盖,各种诡异问题。后来给每个请求分配独立的临时目录,用 UUID 做前缀,才彻底解决。
第四个坑是对 STL 网格质量的预期管理。用户看到 STL 预览觉得“表面不够光滑”,其实是网格公差设太大了。后来在前端加了网格精度选项,让用户自己选“快速预览”还是“精细输出”,投诉少了一大半。
这套东西我断断续续搞了大半年,从最初只能生成简单方块,到现在能处理带孔、带槽、带倒角的复杂零件,中间踩的坑比写过的代码还多。但每次看到用户输入一段文字、几秒钟后拿到一个可以直接打印的模型文件,那种“这事成了”的感觉还是挺爽的。如果你也在搞类似的东西,建议先从最简单的立方体开始,把整条链路跑通,再逐步加复杂度。别一上来就想生成齿轮箱,那样容易卡在某个环节出不来。