文生 3D 的路子是把提示词丢进去,等它吐出一坨网格。网格能看,但改不动:想把支架的孔距从 50 mm 调到 60 mm,你只能重新生成一次,再赌一次它长得像不像。这跟文生图是同一个毛病——结果是一张成品,不是一个能调的参数。
sand3d 换了个思路。它不生成网格,它让模型写一段 Python 程序——程序跑在 Blender 的 bpy 之上,用 extrude hole fillet joint 这一套动词描述零件怎么造。程序几百个 token 就写完了,人读得懂,改一个数字整台机器跟着变,还能直接进 Git。
这个仓库 11 月 3 日才建,到 11 月 9 日已经 1,398 star,Apache-2.0,最新版 1.6.3。全套 API 塞在一个 prompt 里——我数了一遍,3,643 token。我在 16GB 的 M4 MacBook 上把它从 pip install 一路跑到关节姿态和 URDF 导出,下面是我实际量到的东西。项目主页在 sandflow.ai/sand3d,代码在 GitHub。
它的词汇表长什么样
import sand3d as s3d 之后所有动词挂在同一个平面上,没有类别词。官方 README 里特意写了一句:没有 chair() table(),一个类别就是一段用 sand3d 写的程序。
词表分工挺清楚:16 个 profile(rect circle slot polygon spline text…)、15 个实体与路径(extrude revolve sweep loft helix tube…)、15 个编辑(cut join hole pocket fillet chamfer shell dent…),再加框架与定位、关节、装配、材料和几种工艺件(gear knurl thread)。
关键设计在于「按名字取,不按坐标算」。边有名字:top bottom rim seam、被切掉那个工具的名字、plate.top。位置也用方向词:bbox(ob, "+X") 是这个包围盒 +X 那一面的中心,"+X-Y" 是棱的中点,"+X-Y+Z" 是角。这就是它能省 token 的真正原因——模型不用推导一通三角函数去算那个孔该落在哪,它只要说「落在 +X 面中间,往 Y 挪 20」。
装它:一条命令,附带 245 MB 的 Blender
环境是 macOS 26.5.2 / Apple M4 / 16GB,Python 3.13.12。
python3 -m venv venv && source venv/bin/activate
pip install sand3d实测耗时 3 分 55 秒,其中绝大部分是 bpy 那个轮子——bpy-5.2.3-cp313-cp313-macosx_11_0_arm64.whl,245.2 MB,1.5 MB/s 拉了三分半。装完是 sand3d 1.6.3 + bpy 5.2.3 + numpy,没有 GPU 依赖。
版本对不上会直接装不上,这点 README 写得很实在:bpy 没有 Python 3.12 的轮子。3.13 配 bpy 5.2 LTS,3.11 配 4.5 LTS,Intel Mac 从 5.2 起也没有轮子,得退回 3.11。你如果用的是系统自带的 3.12,先换解释器,别在依赖上耗时间。
第一个零件,和一个能对上的体积
官方 quickstart 的支架是这样,八行:
import sand3d as s3d
def bracket(w=80, d=40, t=6, hole_d=6.6, pitch=50):
plate = s3d.extrude("plate", s3d.rect(w, d, r=4), t) # 80×40 的圆角矩形,拉起 6 mm
s3d.hole(plate, hole_d, on=s3d.grid(2, 1, pitch, 0)) # 两个孔,靠 grid 布点
s3d.fillet(plate, 1, edges="top") # 只给顶边倒 R1
return plate
s3d.export(bracket(), "bracket.glb")代码本身 156 个 token(o200k,去掉注释和 docstring),构建 0.06 秒,导出 0.2 秒。1220 个三角面。
导出这件事我不太信「文件生成了」这四个字,所以每次都拿 trimesh 反查一遍几何:
import trimesh
m = trimesh.load("bracket.glb", force="mesh")
print(f"tris={len(m.faces)} watertight={m.is_watertight}")
print(f"extents_mm={[round(float(x)*1000, 1) for x in m.extents]}")
print(f"volume_mm3={m.volume * 1e9:.0f}")读取结果 80.0 × 6.0 × 40.0 mm,体积 18,647 mm³。我按参数手算了一遍:80×40×6 = 19,200,两个 φ6.6 通孔各占 π×3.3²×6 ≈ 205,减 410 得 18,790,顶边 R1 圆角再削掉约 143,落在 18,647。程序和几何对得上,这个数我认。
「3,600 token」和「省 4 倍」,两条我都核了
这种「塞进一个 prompt」的说法,第一件该做的事是拿 tokenizer 数一遍,而不是信 README 的整数。
我把仓库里的 docs/api.md 丢进 tiktoken,cl100k_base 和 o200k_base 数出来都是 3,643。官方说「about 3,600」,这个差距在四舍五入范围内,成立。
第二条是「同一零件比 LLM 写的 Blender Python 省 4.0× token」。官方的测法写在一张单独的表里:o200k,只算代码不算注释,30 个来自 Zero-To-CAD 的零件,Blender Python 那侧由 LLM 从 CAD 源文件反写。我把它那张明细抄下来自己重算:
- 全 30 个样本,比值中位数 4.04×,范围 2.0–15.1;
- 官方表里 30 个 Blender Python 程序只有 9 个「建出了正确零件」,只取这 9 个,中位数 4.21×;
- 两侧 token 中位数分别是 235 与 978。
我原本怀疑「4 倍」是靠一堆写错的程序拉出来的——写错往往写得更短。数据不支持这个怀疑,只留正确的样本,倍数反而更高一点。这条宣传我给它记成「成立」。
真正值得看的是那 9/30。Blender 那侧是 LLM 写的,三十分之二十一跑出来的不是那个零件。我自己手写了一份等价实现做对照,547 个 token(3.5×),跑完退出码 0、GLB 正常生成、Blender 一路 「Applied modifier was not first」 的提示也没拦它。但模型只有 44 个三角面、体积 17,076 mm³,跟我预期差 8.4%。最后那个 modifier_add(type="BEVEL") 我没设宽度,默认值是 0.1——单位是米,也就是 100 mm,一刀把边全削了。
「能导出」和「是对的零件」是两件事。这就是那 21/30 的来源,也是我把 trimesh 那三行打印写进流程的原因。这个套路我上个月拆视频的时候用过一次——先造一个自己知道答案的靶子,再去量工具,比看它自己的报告可靠得多,那次的做法记在视频取证那篇里。
让免费模型自己写一个
光跑官方例子说明不了什么,得看模型拿到这 3,643 token 之后能不能独立干活。
我把 api.md 放进工作区,加一段环境说明(venv 路径、导出目录),任务给的是:一块 100×70×6 的底板、四角 R3,一块 90×60×5 的支撑臂立在底板的 −Y 边,两者用 hinge 关节连起来,绕 +X 轴 0–75 度,两个零件都要有名字。
模型用的是 openrouter/nvidia/nemotron-3.5-lightning:free,$0。opencode 那批 -free 模型这次全都撞了日额度墙,OpenRouter 这条还能走——这条绕法我在改 AI 味界面那次摸出来的,这次照样管用。
它一次成稿,自己跑了脚本、自己 ls 确认文件、自己删掉中间产物,然后汇报「exit code 0,5720 bytes,base 和 arm_plate 都在」。看着挺像那么回事。
但我按它的程序重新核了一遍几何(每个姿态单开一个进程,原因后面讲):
| 姿态 | 包围盒 (mm) | 三角面 | 体积 (mm³) |
|---|---|---|---|
| 静止 | 100 × 60 × 75 | 120 | 68,952 |
| 37° | 100 × 50.9 × 90.2 | 120 | 68,952 |
| 75° | 100 × 20.4 × 97.4 | 120 | 68,952 |
底板 100×70×6、臂 90×60×5 立在 −Y 边,静止时整体 100 宽 × 60 高 × 75 深——70 的板深加上 5 mm 臂厚,正好。转到 75 度,高度塌到 20.4、深度涨到 97.4,臂在绕着边缘往下放。三个姿态体积一模一样,转关节不改几何,这点也对。
URDF 里关节是 <joint name="hinge" type="revolute">,<axis xyz="1 0 0"/>,<limit lower="0" upper="1.30899694"/>——1.309 弧度就是 75 度。它没抄错。
然后是问题所在。我要的是一个「把手机托住」的支架,但 +X 正转 75 度是把臂朝底板外侧放平,75 度时它已经躺到板外去了,没有任何一面能靠住手机。要的是反向:臂朝底板内侧仰起来。这个轴是我在任务里写死的,模型一个字没质疑,照着写、照着验、照着汇报完工。
模型能把几何写对,不会把设计写对。 它检查的是「程序跑不跑、文件在不在、参数有没有写进代码」,它不检查「这东西装上之后能不能用」。规格写错了,它会很高效地帮你把错的做出来。
我后来自己把轴改成 -X 重跑,把两个姿态都渲出来对比:+X 那版臂是朝底板外侧倒下去的,-X 那版臂才盖上底板、往内侧仰。同一个程序、同一个零件,只有一个字符的区别。
五个坑,报错原文我都留着
一、场景不清,零件会改名。 同一个进程里连着 build 两次,我一开始以为「逐位确定性」是假的:两次 GLB 的 sha256 不一样,95,928 变成 95,996 字节。拆开 JSON 才看清,多出来的 68 字节全是名字——dial 变 dial.001、glass 变 glass.001、ring 变 ring.001。bpy 里同名对象会加后缀,几何一个字节没动。
在这套 DSL 里名字不是装饰,它是语义句柄:材质、关节、装配都靠名字找件。名字带脏后缀,导出的 URDF link 名跟着坏。每次 build 之前先 s3d.empty_scene(),这两个文件就完全一致了——STL 和 GLB 都是:
import hashlib
def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()
s3d.empty_scene(); s3d.export(washer(), "a.stl")
s3d.empty_scene(); s3d.export(washer(), "b.stl")
s3d.empty_scene(); s3d.export(washer(), "a.glb")
s3d.empty_scene(); s3d.export(washer(), "b.glb")
print(sha("a.stl") == sha("b.stl"), sha("a.glb") == sha("b.glb")) # True True二、清完场景,旧对象全部作废。 反过来也成立:empty_scene() 之后还拿着之前的对象去导出,直接 ValueError: export: an object given was removed from the scene。我第一版核验脚本就是这么挂的,量同一个装配体的三个姿态,第二次就报这个。想在一个脚本里量多个姿态,要么每个姿态重新 import 一遍模块,要么一个进程只量一次。
三、at 不接返回值,静默不动。 s3d.at(b, on=a.face("+Z")) 返回一个新的摆放结果,原地调用不报错,也不生效。我试了一下:一块 40×40×5 的板,一个 10×10×40 的立柱,at 没接返回值,导出的装配体里立柱老老实实待在原点,穿在板子中间,退出码 0,没有任何警告。
四、单位是毫米,写错不报错。 默认 mm,我手滑按米写了 s3d.box("small", 0.08, 0.04, 0.006),导出得到 0.08 mm 的东西。不报错,不提示。
顺便说清导出后的单位到底怎么落地的,因为这个我查了 GLB 的二进制:顶点坐标本身还是毫米(accessor 的 min/max 是 −20 到 20),节点上挂一个 0.001 的 scale 折成米。按 glTF 规范读节点变换的引擎拿到 0.04 m,是对的;但任何直接读顶点数据、不走节点变换的下游工具,读到的会是 1000 倍的数。
五、别写成链式调用。 这套 API 是动词式的,编辑是 s3d.fillet(ob, 1),不是 ob.fillet(1)。我照着常见几何库的直觉写 s3d.rect(80, 40, r=4).extrude("p", 6),AttributeError: 'Profile' object has no attribute 'extrude'。同理 s3d.box(...).fillet(3) 报 'Object' object has no attribute 'fillet'。
这条不算坑,而是一个我很喜欢的细节:工具被布尔运算吃掉之后再引用,报错会告诉你怎么办。
ValueError: join: 't' was used up by cut(a, ...) on line 1;
a tool is consumed by the boolean. To keep it, pass s3d.copy(t) to cut instead.它知道自己在第几行被谁吃掉的,还给了修法。模型读这种报错能自己纠回来,这比任何文档都有用。
关节和 URDF 是这套东西最实用的部分
上面那台洗衣机 54 行:柜体、滚筒、门、洗涤剂抽屉、旋钮,四个关节——门铰链 0–143 度、滚筒无限转、抽屉直线 0–200 mm、旋钮无限转。washer_open() 把门开 92 度、抽屉拉 120 mm、旋钮转 30 度。
导 URDF 出来是五条 link、三种关节类型:
links: washing_machine, dial, door, drawer, drum
joint: door_hinge revolute axis 0 -1 0
joint: drum_spin continuous axis 0 0 1
joint: drawer_slide prismatic axis 0 0 1
joint: dial_turn continuous axis 0 0 1有 URDF 就能直接进 ROS / PyBullet / MuJoCo 那套。做机器人夹爪、做桌面机械臂的连杆、做仿真环境的家具,用这个比手写 URDF 快得多——URDF 里那些 origin 和 axis 是最容易写错的东西。
有一处要提前知道:装配体的根 link 用装配体的名字,不用零件的名字。洗衣机里那块柜体,本来定义叫 cabinet,URDF 里变成 washing_machine;我给模型那个手机支架,base 变成了 phone_stand。你后续按名字找底座(加碰撞、换材质、贴传感器)会找不到,得记着根 link 就是那个固定件。
顺便一个实操建议:要判断能不能打印,拿 STL 去判,别拿 GLB。 同一批三个模型,STL 的 is_watertight 全是 True,GLB 全是 False,而两者体积一模一样(18,647 / 7,628 / 27,238 mm³)。差在 glTF 会按法线把顶点拆开,拓扑上不再闭合,但几何没破。这是格式差异,不是模型坏了。
什么时候别用它
有机造型。 它的词表里 16 个 profile、15 个实体,全是工程几何的路子:拉伸、旋转、扫掠、放样、布尔、倒角。没有雕模,没有细分曲面,也没有贴图 UV——材质只有颜色和透明度。做脸、做布料、做手办,这不是它的场景。
要直接出成品的精密件。 壁厚够不够、公差给多少、螺丝孔会不会滑丝,它不会替你验。它能保证「程序描述的几何和你想要的一致」,保证不了「这个件能加工出来」。
已经有 CAD 源文件的件。 这套东西是从零描述形状,不是从 STEP/IGES 反推特征。你手上要是有现成的参数化模型,改那个比用这个重写一遍省事。
要照片级渲染的场合。 bpy 在,渲染能做,但那是 Blender 的活。sand3d 负责把零件造出来,剩下的交给 Blender。
我的用法
这东西真正的分水岭不在「能不能生成 3D」,在「生成的东西能不能改」。
同一天我还在看代码知识图谱(graphify 那篇里我把一个 Flask 仓库拆成了两千多个节点),思路其实一样:给模型一套有名字的结构,比让它自由生成更可控。sand3d 是同一个道理的 3D 版本——它给模型的不是想象力,是一套带名字的动词表。
如果你想试试,最小的路径就三步:装好 bpy,把 api.md 塞进系统提示,然后从改造那个 bracket 开始——把 pitch=50 改成 60、把 hole_d 改成 8,看体积跟着怎么变。想让它做复杂装配,先看官方那份 guidelines 里的八条规则,尤其是「先写骨架再写细节」和「每个零件先定位置再定关节」。
至于「4 倍省 token」值不值——真正的账不在写那一次,在改的那十次。我今天把它参数从 850 改成 700,整台洗衣机所有零件自动跟着变,那一秒钟省下的是重新生成的等待和重新核对的心力。
参考来源
- 仓库与许可:github.com/Sandflow-ai/sand3d(Apache-2.0,v1.6.3,2026-11-03 创建)
- token 测法与逐零件明细:sand3d: fewer tokens for the same object
- API 全文(我数 token 用的就是这份):sand3d docs/api.md、在线版 API 页
- 数据集(五个,CC BY 4.0):Hugging Face · Sandflow
实测环境:macOS 26.5.2 / Apple M4 / 16GB,Python 3.13.12 + sand3d 1.6.3 + bpy 5.2.3,trimesh 5.2.0 与 tiktoken 用于反查;AI 环节为 opencode 2.1.4 + openrouter/nvidia/nemotron-3.5-lightning:free。Windows 与 Linux 未实测;尺寸公差、真实 3D 打印、五个数据集的实际内容未实测。