1. 从一段文字到三维模型:text-to-cad 到底在解决什么问题
第一次听到 “text-to-cad” 这个说法,很多人脑子里浮现的可能是“对着软件说一句话,模型就自己长出来了”。这个理解方向没错,但细节上差得挺远。我做了几年参数化建模和工业设计相关的工具链,也帮不少团队搭过从需求描述到模型文件的自动化流程,可以很负责任地说:text-to-cad 的本质,是把自然语言或结构化文本,翻译成 CAD 能识别的几何描述,再输出成 STEP、GLB、STL 这类通用格式。它不是魔法,而是一条“文本 → 参数/特征 → 几何内核 → 文件”的流水线。
这条流水线能干什么?举几个我实际接触过的场景。做非标设备的朋友,客户在微信里发一段话:“一个 200×150×80 的铝型材盒子,壁厚 5 毫米,前面开一个 120×60 的方孔,四角倒 R5 圆角。”以前得打开软件手动拉伸、开孔、倒角,现在把这段话丢进 text-to-cad 流程,几十秒就能拿到一个 STEP 文件,直接进装配体。做电商展示的团队,需要把一批产品快速生成 GLB 放到网页里做 3D 预览,靠人工建模根本来不及,用文本批量驱动就快得多。还有做 3D 打印的玩家,脑子里有个简单结构,懒得学建模软件,用文字描述生成 STL 直接切片,省事。
所以这篇文章适合谁看?三类人。第一类是有编程基础、想把 CAD 建模自动化的工程师,你们关心的是怎么把文本解析成几何、用什么内核、怎么导出文件。第二类是产品经理或设计管理者,你们想知道这套东西的边界在哪、能替代多少人工、成本怎么算。第三类是对 AI 加 CAD 感兴趣的爱好者,想自己动手搭一个最小可用版本。下面我会从整体设计思路讲起,一路拆到代码级实现、格式导出、踩坑排查,尽量让每一层都能落地。
需要先明确一个前提:text-to-cad 不是要取代 CAD 软件,而是给 CAD 加一个“文本入口”。几何内核该用 OpenCASCADE 还是别的,照样用;渲染该用 Three.js 还是别的,照样用。文本只是最前面那一层“翻译官”。理解这一点,后面的技术选型就不会跑偏。
2. 整体架构怎么搭:文本到几何的四层拆解
2.1 为什么不能一步到位,非要分层
我见过不少初学者的第一版实现,思路特别直接:拿一个大模型,输入“画一个法兰盘”,让它直接吐出一段 STEP 文件的文本内容。结果几乎全是乱码或者语法错误。原因很简单,STEP 文件是 ISO 10303 标准定义的严格语法,里面有实体编号、坐标系、拓扑关系,大模型对这类结构化文本的生成能力非常不稳定,尤其是涉及数值精度和引用关系的时候。
所以靠谱的做法是分层。我总结下来是四层:文本理解层、参数与特征层、几何构建层、格式导出层。每一层只干一件事,层与层之间用明确的数据结构传递。这样做的好处是,哪一层出问题就修哪一层,不会牵一发动全身。比如文本理解错了,改提示词或加规则;几何构建报错,查内核调用;导出格式不对,换导出器。如果全揉在一起,调试起来就是灾难。
这四层里,文本理解层负责把“一个边长 50 的立方体”变成{shape: "box", length: 50, width: 50, height: 50}这样的结构化参数。参数与特征层负责把这些参数映射成建模操作序列,比如“先拉伸一个矩形,再在顶面开孔”。几何构建层调用几何内核真正生成 B-rep 或网格数据。格式导出层把内存里的几何写成 STEP、GLB 或 STL 文件。下面逐层说。
2.2 文本理解层:规则、模板还是大模型
这一层是 text-to-cad 里最“AI”的部分,但我不建议一上来就全交给大模型。实际项目里,混合方案最稳:用大模型做意图识别和槽位填充,用规则做数值校验和单位换算。
具体来说,先定义一套“建模意图”的枚举,比如create_box、create_cylinder、create_hole、create_fillet、create_extrude。然后让大模型把用户输入映射到这些意图上,并抽取参数。比如输入“直径 30、高 100 的圆柱”,模型输出{intent: "create_cylinder", diameter: 30, height: 100, unit: "mm"}。这一步用提示词工程就能做得不错,关键是给模型明确的输出格式约束,最好用 JSON Schema 强制。
但大模型有个毛病:数值容易瞎编,单位容易漏。所以拿到结构化结果后,必须过一遍规则校验。比如直径不能为负,高度不能为零,单位缺失时默认毫米。我一般会写一个校验函数,把非法参数直接拦下来,返回错误信息让用户补充。这一步能挡掉至少三成的脏输入。
提示:如果你的场景里文本描述比较固定,比如都是“长×宽×高”这种格式,其实可以不用大模型,直接用正则加模板匹配,速度和稳定性都更好。大模型适合处理自由表达,比如“帮我搞个差不多巴掌大的方块”。
2.3 参数与特征层:把“一句话”翻译成“操作序列”
这一层是很多人忽略的,但它决定了你的系统能不能处理复杂模型。简单形状一步就能建,但真实零件往往是多步操作的叠加。比如“一个带四个安装孔的底板”,其实是“拉伸底板 → 在四角打孔”两步。
我的做法是定义一个中间表示,我管它叫Feature Graph,本质上是一个有向无环图。每个节点是一个建模特征,节点之间的边表示依赖关系。比如底板节点是父节点,四个孔节点是子节点,孔的位置依赖底板的尺寸。这样即使后面要改底板大小,孔的位置也能跟着重算。
这个图还有个好处:可以序列化成 JSON,存数据库或者传给前端做可视化编辑。用户看到的不只是最终模型,还能看到“哦,原来是由这几步组成的”,改起来也方便。我实测下来,用 Feature Graph 管理十步以内的建模流程,逻辑清晰,扩展也容易。
2.4 几何构建层:选对内核少走一半弯路
几何内核的选择直接决定你能建多复杂的模型。目前主流的有三类:B-rep 内核(如 OpenCASCADE)、网格内核(如基于三角面的自研或 Trimesh)、隐式几何(如基于 SDF 的方案)。
如果你要输出 STEP,那基本只能用 B-rep 内核,因为 STEP 本质上是 B-rep 的交换格式。OpenCASCADE 是开源里最成熟的选择,Python 有pythonocc-core绑定,功能全但学习曲线陡。如果你只输出 STL 或 GLB,网格内核就够了,Trimesh 这类库上手快,处理三角面很顺手。隐式几何适合做晶格、拓扑优化这类复杂结构,但导出 STEP 比较麻烦。
我的建议是:先明确输出格式,再选内核。要 STEP 就上 OpenCASCADE,要 STL/GLB 且形状简单就用 Trimesh,别为了“以后可能用得上”去硬啃复杂内核,时间成本不划算。
2.5 格式导出层:STEP、GLB、STL 各有各的脾气
三种格式的定位完全不同,导出时要注意的点也不一样。
| 格式 | 本质 | 适用场景 | 导出注意点 |
|---|---|---|---|
| STEP | B-rep 边界表示 | 工业交换、后续编辑 | 需要内核支持,精度高,文件较大 |
| GLB | 二进制 glTF,含网格与材质 | 网页 3D 预览、AR | 需要三角化,注意法线和 UV |
| STL | 纯三角面片 | 3D 打印、快速成型 | 只有几何无材质,注意单位和水密性 |
STEP 导出最讲究,因为它是给下游 CAD 软件读的,拓扑必须闭合,否则对方打开会报错。GLB 导出要处理材质和光照,不然网页里看着灰扑扑的。STL 导出最怕模型不水密,切片软件会直接拒绝。这些坑我在第 4 节会详细说。
3. 核心细节与实操要点:从提示词到文件落盘
3.1 提示词怎么写才能让模型稳定输出参数
大模型输出结构化参数,提示词是成败关键。我试过很多版本,最后稳定下来的模板大概长这样:
你是一个 CAD 参数抽取器。用户会用自然语言描述一个三维形状。 请把描述转换成如下 JSON 格式,不要输出任何其他内容: { "intent": "create_box | create_cylinder | create_sphere | create_hole | create_fillet", "params": { ... }, "unit": "mm | cm | m | inch" } 如果某个参数缺失,用 null 表示。如果描述无法识别,intent 返回 "unknown"。关键点有三个。第一,明确枚举 intent,不要让模型自由发挥,否则它会造出make_cube、create_cuboid这种同义词,后面解析很麻烦。第二,强制 JSON 输出,可以用 API 的 JSON mode,或者在提示词里强调“不要输出任何其他内容”。第三,缺失参数用 null,不要让它猜,猜出来的数值往往离谱。
实测下来,这套提示词对“长宽高”“直径高度”“半径”这类常见描述识别率很高。但遇到“差不多”“大概”“稍微大一点”这种模糊词,模型会懵。我的处理是:模糊词直接返回unknown,让用户重新描述。宁可多问一句,也不要生成一个错误模型。
3.2 单位换算:一个被严重低估的坑
单位问题在 text-to-cad 里特别容易出事。用户说“一个 10 厘米的方块”,模型可能输出{length: 10, unit: "cm"},但几何内核默认单位往往是毫米。如果你不换算,直接拿 10 去建模,出来的就是 10 毫米,小了十倍。
我的做法是在参数层统一换算成毫米,因为 STEP 和 STL 在工程领域默认都是毫米。换算表很简单:
- 1 m = 1000 mm
- 1 cm = 10 mm
- 1 inch = 25.4 mm
- 1 ft = 304.8 mm
换算函数写成一个字典映射,拿到 unit 就乘对应系数。如果 unit 是 null,默认按毫米处理,但在返回结果里标注“单位未指定,已按毫米处理”,让用户知道。这个提示很重要,我遇到过用户以为是厘米,结果打印出来小得可怜。
注意:GLB 在网页渲染时,很多引擎默认单位是米。如果你从毫米直接导出 GLB,模型在场景里会大得离谱。我的做法是导出 GLB 时统一除以 1000,转成米。这个细节不处理,前端同事会来找你麻烦。
3.3 特征顺序不能乱:依赖关系要显式管理
多特征建模时,顺序错了结果就全错。比如先打孔再倒角,和先倒角再打孔,出来的形状可能不一样。更麻烦的是,如果孔的位置依赖倒角后的边,顺序错了孔就悬空了。
Feature Graph 在这里就派上用场。每个特征节点记录自己的输入依赖,构建时按拓扑排序执行。比如:
features = [ {"id": "base", "type": "box", "params": {...}, "deps": []}, {"id": "hole1", "type": "hole", "params": {...}, "deps": ["base"]}, {"id": "fillet1", "type": "fillet", "params": {...}, "deps": ["base"]}, ]执行时先找deps为空的节点,执行完把它从依赖里移除,再找下一批。这样即使特征列表顺序打乱,执行顺序也是对的。我实测下来,这套机制能处理几十个特征的复杂装配,逻辑清晰,调试也方便。
3.4 数值精度:别让浮点误差毁了一个模型
几何内核里大量使用浮点数,精度问题很常见。比如两个面理论上应该重合,但因为浮点误差差了 1e-9,内核就认为它们不相交,布尔运算直接失败。这种问题在 STEP 导出时尤其明显,因为 STEP 对拓扑一致性要求很高。
我的经验是:在参数层就把数值规整到合理精度。比如长度保留 3 位小数,角度保留 2 位小数。建模时用内核提供的容差参数,OpenCASCADE 里可以设置Precision::Confusion(),默认是 1e-7,一般够用。如果布尔运算老失败,可以适当放大容差,但别太大,否则小特征会被吞掉。
还有一个技巧:避免在建模过程中做极端的数值运算。比如两个几乎平行的面做布尔差,结果会非常不稳定。遇到这种情况,宁可调整设计,让面之间有明显夹角,也不要硬算。
4. 完整实操流程:手把手搭一个最小可用版本
4.1 环境准备与依赖安装
我以 Python 为例,搭一个能输出 STL 和 GLB 的最小版本。STEP 因为依赖 OpenCASCADE,安装稍麻烦,放在后面说。
先建虚拟环境,装基础依赖:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install trimesh numpy shapely pip install openai # 如果用大模型做文本理解Trimesh 负责几何构建和 STL/GLB 导出,numpy 做数值计算,shapely 处理二维轮廓。如果要用大模型,装 openai 或对应厂商的 SDK。
如果要输出 STEP,需要装pythonocc-core。这个库在 conda 里装最省事:
conda install -c conda-forge pythonocc-corepip 装 pythonocc-core 经常编译失败,我踩过好几次坑,建议直接用 conda。
4.2 文本解析模块实现
先写一个解析函数,把用户输入转成结构化参数。这里我用一个简化的规则版,方便演示,实际项目可以替换成大模型调用。
import re def parse_text(text): text = text.strip().lower() # 匹配立方体:长 50 宽 30 高 20 box_match = re.search(r'长\s*(\d+\.?\d*)\s*宽\s*(\d+\.?\d*)\s*高\s*(\d+\.?\d*)', text) if box_match: return { "intent": "create_box", "params": { "length": float(box_match.group(1)), "width": float(box_match.group(2)), "height": float(box_match.group(3)) }, "unit": "mm" } # 匹配圆柱:直径 30 高 100 cyl_match = re.search(r'直径\s*(\d+\.?\d*)\s*高\s*(\d+\.?\d*)', text) if cyl_match: return { "intent": "create_cylinder", "params": { "diameter": float(cyl_match.group(1)), "height": float(cyl_match.group(2)) }, "unit": "mm" } return {"intent": "unknown", "params": {}, "unit": "mm"}这个版本能处理固定格式的输入。如果要处理自由表达,把parse_text换成大模型调用,提示词用 3.1 节那套。实测下来,规则版速度快、零成本,适合输入格式固定的场景;大模型版灵活,但每次调用有延迟和费用。
4.3 几何构建:用 Trimesh 生成模型
拿到结构化参数后,用 Trimesh 构建几何。Trimesh 的 API 很直观,创建基本体就几行代码。
import trimesh import numpy as np def build_geometry(parsed): intent = parsed["intent"] params = parsed["params"] unit = parsed.get("unit", "mm") # 单位换算成毫米 scale = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "inch": 25.4}.get(unit, 1.0) if intent == "create_box": mesh = trimesh.creation.box(extents=[ params["length"] * scale, params["width"] * scale, params["height"] * scale ]) return mesh elif intent == "create_cylinder": mesh = trimesh.creation.cylinder( radius=params["diameter"] * scale / 2, height=params["height"] * scale ) return mesh else: raise ValueError(f"无法识别的意图: {intent}")这里注意extents是三个方向的尺寸,Trimesh 的 box 默认以原点为中心。如果你希望模型底面在 z=0 平面上,需要额外平移:
mesh.apply_translation([0, 0, params["height"] * scale / 2])这个细节在 3D 打印时很重要,因为切片软件通常期望模型坐在打印床上,而不是悬空。
4.4 打孔与倒角:布尔运算实操
带孔的形状需要布尔运算。Trimesh 支持布尔差集,但依赖manifold3d或blender后端。我推荐装manifold3d,速度快且稳定。
pip install manifold3d打孔示例:在一个底板上开四个角孔。
def build_plate_with_holes(length, width, thickness, hole_d, hole_offset): base = trimesh.creation.box(extents=[length, width, thickness]) base.apply_translation([0, 0, thickness / 2]) holes = [] for sx in [-1, 1]: for sy in [-1, 1]: hole = trimesh.creation.cylinder( radius=hole_d / 2, height=thickness * 2 ) hole.apply_translation([ sx * (length / 2 - hole_offset), sy * (width / 2 - hole_offset), thickness / 2 ]) holes.append(hole) result = base for hole in holes: result = result.difference(hole) return result布尔运算的顺序有讲究。我一般先做所有加法(合并),再做所有减法(打孔),最后做倒角。因为倒角对拓扑敏感,放在最后能减少失败概率。如果先倒角再打孔,孔可能会切到倒角面,产生复杂拓扑,内核容易出错。
4.5 导出 STL、GLB 与 STEP
STL 和 GLB 导出很简单:
# 导出 STL mesh.export("output.stl") # 导出 GLB,注意单位转成米 mesh_m = mesh.copy() mesh_m.apply_scale(0.001) mesh_m.export("output.glb")STL 导出前建议检查水密性:
if not mesh.is_watertight: print("警告:模型不水密,切片可能失败") mesh.fill_holes()fill_holes能补一些小洞,但不是万能的。如果模型本身拓扑有问题,最好回到建模步骤修。
STEP 导出需要 OpenCASCADE。用 pythonocc-core 的话,流程是先把 Trimesh 的网格转成 B-rep,再写 STEP。但网格转 B-rep 本身就有精度损失,更好的做法是直接用 OpenCASCADE 从头建模。如果你的项目必须输出 STEP,我建议几何构建层直接用 OpenCASCADE,不要经过 Trimesh 中转。
from OCC.Core.STEPControl import STEPControl_Writer, STEPControl_AsIs from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox # 直接用 OCC 建一个盒子 box = BRepPrimAPI_MakeBox(50, 30, 20).Shape() writer = STEPControl_Writer() writer.Transfer(box, STEPControl_AsIs) writer.Write("output.step")这段代码输出一个 50×30×20 的盒子到 STEP 文件。实际项目里,你需要把 Feature Graph 的每个节点翻译成对应的 OCC 调用,再用布尔运算组合。工作量比 Trimesh 大,但 STEP 的兼容性和可编辑性是网格格式比不了的。
5. 常见问题与排查技巧实录
5.1 模型导出后打不开或显示异常
这是最高频的问题,表现是 STEP 在 CAD 软件里打开报错,或者 GLB 在网页里显示破面。原因通常有三类。
第一类是拓扑不闭合。STL 和 STEP 都要求模型是封闭实体,如果有开口边,下游软件会拒绝。排查方法是检查mesh.is_watertight,STEP 则用 OCC 的BRepCheck_Analyzer检查。修复办法是回到建模步骤,确保所有布尔运算都成功,没有残留面片。
第二类是法线方向错误。GLB 渲染时如果法线朝内,模型看起来是黑的或者透明。Trimesh 里可以用mesh.fix_normals()统一法线方向。STEP 一般不受影响,因为 B-rep 有明确的内外定义。
第三类是单位不一致。前面说过,GLB 默认米,STL 和 STEP 默认毫米。如果导出时没换算,模型大小会差 1000 倍。排查方法是导入后量一下尺寸,和预期对比。
| 现象 | 可能原因 | 排查方法 | 修复手段 |
|---|---|---|---|
| STEP 打开报错 | 拓扑不闭合 | BRepCheck_Analyzer | 重做布尔运算 |
| GLB 显示黑色 | 法线朝内 | 检查法线方向 | fix_normals |
| 模型尺寸差 1000 倍 | 单位未换算 | 量尺寸对比 | 导出时缩放 |
| STL 切片失败 | 模型不水密 | is_watertight | fill_holes 或重建 |
5.2 布尔运算失败的几种典型情况
布尔运算是几何建模里最容易翻车的地方。我总结了几种典型失败场景。
共面问题:两个面完全重合时做布尔差,内核不知道保留哪个面,结果可能出错。解决办法是让两个面稍微错开,比如差 0.001 毫米。这个偏移肉眼看不出来,但能避开数值退化。
薄壁问题:如果两个面之间的距离小于内核容差,会被当成一个面。比如壁厚 0.001 毫米的管子,在默认容差下可能直接消失。解决办法是放大容差,或者调整设计让壁厚大于容差。
自相交问题:如果输入的网格本身有自相交,布尔运算会失败。Trimesh 里可以用mesh.is_self_intersecting检查,用mesh.fix_self_intersections()修复。但修复不一定成功,最好从源头保证输入干净。
实操心得:布尔运算失败时,先别急着改代码,把两个操作数分别导出看看,确认它们本身是合法的。很多时候问题出在输入,而不是运算本身。
5.3 大模型输出不稳定的应对策略
用大模型做文本理解,最头疼的是输出不稳定。同一个输入,这次对下次错。我的应对策略有三条。
第一,降低温度参数。调用大模型时把 temperature 设成 0 或 0.1,输出会稳定很多。虽然不能保证 100% 一致,但比默认值好得多。
第二,加输出校验和重试。拿到 JSON 后先校验字段是否完整、数值是否合法。如果不合法,把错误信息拼回提示词,让模型重新生成。我一般重试两次,还不成功就返回错误让用户重新描述。
第三,关键参数用规则兜底。比如单位,如果模型没输出,直接默认毫米,不要让它猜。数值范围也可以校验,比如长度超过 10000 毫米就提示用户确认,防止单位搞错。
5.4 性能优化:批量生成时怎么提速
单次生成几秒可以接受,但批量生成几百个模型时,性能就成了问题。我做过一个批量生成 500 个 GLB 的任务,优化前后差距很大。
优化点一:复用几何内核实例。OpenCASCADE 的初始化和销毁开销不小,如果每次生成都新建实例,浪费很多时间。我的做法是全局维护一个内核上下文,所有生成任务共用。
优化点二:并行化。几何构建是 CPU 密集型,可以用多进程并行。Python 的multiprocessing或者concurrent.futures.ProcessPoolExecutor都行。注意 Trimesh 和 OCC 的对象不一定能跨进程传递,最好在每个进程里独立构建,只把文件路径传回来。
优化点三:缓存文本解析结果。如果同一段文本被多次请求,把解析结果缓存起来,省掉大模型调用。用functools.lru_cache或者 Redis 都行。
实测下来,这三条做完,批量生成速度能提升三到五倍。具体数字取决于模型复杂度和硬件,但方向是明确的。
5.5 常见问题速查表
| 问题 | 可能原因 | 快速排查 | 解决方向 |
|---|---|---|---|
| 文本解析返回 unknown | 描述超出枚举范围 | 看输入是否含模糊词 | 扩展 intent 或让用户重述 |
| 单位换算错误 | unit 字段缺失或错误 | 检查解析结果 | 加默认值和校验 |
| 布尔运算报错 | 共面/薄壁/自相交 | 分别导出操作数检查 | 加偏移或修复输入 |
| STEP 导出失败 | 内核未正确初始化 | 看异常堆栈 | 检查 OCC 环境 |
| GLB 在网页里太大 | 单位未转米 | 量模型尺寸 | 导出时缩放 0.001 |
| 批量生成慢 | 未并行/未缓存 | 看 CPU 利用率 | 多进程加缓存 |
6. 几个我踩过的坑和最后的小技巧
第一个坑是过度依赖大模型做数值计算。我早期版本让大模型直接算孔的位置,比如“四个角各留 10 毫米边距”,模型有时候算对有时候算错。后来改成让模型只输出“边距 10 毫米”,位置由代码算,就再也没错过。数值计算交给代码,语义理解交给模型,这条分工原则帮我省了很多事。
第二个坑是忽略 STL 的水密性检查。有次批量生成 200 个模型,直接打包发给打印厂,结果一半打不出来。后来加了is_watertight检查,不通过的自动fill_holes,通过率提到 95% 以上。剩下 5% 是复杂拓扑,只能人工介入。这个检查成本很低,但收益很大,强烈建议加上。
第三个坑是STEP 导出的坐标系问题。OpenCASCADE 默认坐标系和很多 CAD 软件不一致,导出的模型在对方软件里可能是躺着的。解决办法是在导出前统一做一次坐标变换,把 Z 轴朝上。这个细节文档里很少提,但实际对接时经常遇到。
最后分享一个小技巧:给每个生成的模型附带一份元数据 JSON,记录原始文本、解析参数、单位、生成时间、内核版本。这样出问题时能快速追溯,也方便后续做数据分析和模型迭代。我现在的项目里,每个输出文件旁边都有一个同名.json,排查效率高很多。
这套 text-to-cad 的流程,我从最初的手忙脚乱到现在能稳定跑批量任务,花了大概半年时间迭代。核心体会就是:别追求一步到位,先把最小闭环跑通,再逐层优化。文本解析不准就先上规则,几何构建报错就先做简单形状,导出格式不对就一个个试。每解决一个问题,系统就稳一分。等你把 STEP、GLB、STL 三条导出链路都跑顺了,再回头看,会发现最难的不是技术,而是把每一层的边界划清楚,让它们各司其职。