从零把 CAD 件建出来并送 CNC:用 cadquery+OCC 出真 STEP、用 fontTools+skia 把汉字变成光滑解析曲面、 用 Blender 无头渲染预览,以及嘉立创免费打样的设计期量化红线(槽宽>1.1 / 壁厚>1.0 / 吸附面≥35% / 禁雕刻)。 **只管"建"**——修复已有的 STEP(窄槽、薄壁、尖薄末端)请用 `cnc-dfm-repair`。 Triggers: "CNC", "STEP", "STL", "cad", "CAD", "三维模型", "建模", "3D 打印", "3D打印", "嘉立创", "JLC", "免费打样", "机械加工", "铣", "雕刻", "纪念币", "纪念章", "铭牌", "吊牌", "改字体", "字体轮廓", "文字浮雕", "CAD 导出", "solidworks", "freecad", "cadquery".
从零建 CAD 件并送 CNC
先分清你要做的是哪件事
| 任务 | 用什么 |
|---|---|
| 从零建模(改字形、回转体、浮雕、导出 STEP) | 这份 |
| 修一个已有 STEP 让它过 DFM(窄槽/薄壁/尖尾) | cnc-dfm-repair |
| 量一个已有 STEP 的槽宽/壁厚、验证改了没改坏 | cnc-dfm-repair(面-面精确距离 + 编辑掩膜差分) |
别重复造轮子。 本项目里我为了"找窄槽在哪"自己写了三套几何度量,
三套都被证伪(非单调、报出负槽宽、同一字在两个字号下自相矛盾),最后管用的是
cnc-dfm-repair 那套——对真实 BRep 直接量面-面精确距离(BRepExtrema_DistShapeShape),
配合 DFM 回报反推:原始槽宽 g₀ = DFM 报的槽宽 − 2×削细量。
本机工具链
| 工具 | 用途 |
|---|---|
| cadquery 2.8(含 OCC) | 建体、文字、导出 STEP。要真 STEP 必须用它按解析曲面重建 |
| OCP | cadquery 没暴露的底层:布尔、建面建环、Bezier、精确距离、ShapeFix |
| fontTools | 从 .otf 取汉字的精确贝塞尔轮廓(RecordingPen) |
| skia-python | 路径布尔/偏移(skia.Op、getFillPath) |
| gmsh | STEP → STL(自带 OCCT),喂渲染和测量 |
| Blender 4.5.3 | 无头渲染。没有 STEP 导入器,只吃 STL/OBJ |
Python 3.12。跑脚本一律 python -I(隔离模式,避免临时目录里的同名文件被 import)。
关键判断:网格不能转 STEP
STL 是三角面片、STEP 是数学曲面。硬把 7 万三角面转成 STEP → 7 万个微小平面, Ø80 的圆变 200 边形,CAM 读进去要么报错要么生成几万条碎刀路。 用户说"要 STEP"=要能送加工的 CAD 文件,不是要转格式 → 用 cadquery 重建。
命令行出 STEP 的骨架见 references/cadquery-patterns.md。
汉字 → 光滑解析曲面(这条链是本技能的核心)
为什么要走这条路:cq.Workplane.text() 用的是 OCC 自带的字体轮廓,
出来的是一堆碎面(「黎」119 个曲面),而且会带退化微边——
拉伸后就是 0.001mm² 的微壁,DFM 判「碎面」。
把轮廓换成字体原生的贝塞尔之后,「黎」的侧壁从几百个 PLANE 变成 86 个光滑 EXTRUSION。
pip install skia-python fonttools
fontTools 取轮廓 → skia 做路径运算 → OCC Geom_BezierCurve 建边 → 拉伸
完整可用实现:references/thin-glyph.py。要点:
pen = RecordingPen()
font.getGlyphSet()[glyphName].draw(pen) # moveTo/lineTo/curveTo/qCurveTo/closePath
heights = [p[1] for op, args in pen.value for p in args if p is not None]
scale = target_height / (max(heights) - min(heights)) # 见下面"基线"那条
槽宽和壁厚:第一选择是平移笔画
两条硬线(槽宽 >1.1mm、壁厚 >1.0mm)不要靠削细或补料去凑——那是拿形状换合规。
不要用圆柱(或胶囊体)去改笔画,这条是硬规则:圆柱与笔画的交线是圆弧,正面看就是个圆疙瘩。
正确做法是只平移,不改形。
关键实测:保持 thin=0(字体原始字重)时,"细于 1.0mm 的残留"在六个测试字上全部是 0.000mm²。
薄壁问题根本不存在,它完全是「削细」自己造出来的;补料又是在补自己造的伤。
平移只改位置——槽宽按平移量线性变宽,笔画粗细一点没变,一条杠杆同时满足两条线。
字形是多个独立部件时(glyph_solid 返回 Compound),按部件索引平移:
NUDGE="莺:0:0:-1.05;高:0:0:-0.20|2:0:0.30" # 字:块:dx:dy,"|" 分隔同字的多个块
做法:先用 BRepExtrema_DistShapeShape 量每对部件的天然间距,把小于 1.2 的补到 1.2 左右
(留 0.1 余量)。平移会改变外包尺寸,必须重新核对字到凹壁、字到字、字到挂孔,
以及变高后是否顶到挂孔;必要时整体缩小 halfdiag 换空间。
其余手段只在平移解决不了时才用,代价明确:
thin=t:每边削 t,g=g₀+2t、w=w₀−2t对冲,而且制造薄壁(见上)。 同字号下g₀+w₀是常数,等比放大不腾余量。本项目曾削细到 0.65 硬凑槽宽,整批件都挂在薄壁上。fix_slot=1.1:closing 填窄缝,改形并把相邻部件焊成一坨。fix_wall=w:opening 残差再胀回去,把轮廓打成毛刺(一个字 9 个环 → 65–123 个环)。thicken=[(x,y)]:沿笔画自身轮廓局部膨胀,视觉上顺,但仍是加料;且薄点若夹在窄槽两侧, 补厚必然把槽挤死(实测 1.3mm → 0.70mm)。坐标为字形局部坐标。
一定会踩的 API 坑
-
枚举是
skia.Path.Verb,不是skia.Verb -
qCurveTo是 TrueType 式的连续二次曲线打包(n 个离曲线点 + 1 个在曲线点), 中间隐含的中点要自己补;全是离曲线点的闭合串要单独处理 -
Geom_BezierCurve遇到控制点重合的退化段会抛 ConstructionError → 重合点先合并, 剩两点就降级成直线 -
skia 的路径布尔在近相切处会整个失败(
RuntimeError: Failed to apply op), 而且同一形状换个尺寸就失败(浮点简并)。修法:按百万分之一微扰缩放重试几次, 全失败就退回未处理的轮廓(宁可字粗一点,别让整个构建崩掉) -
⚠️ 绝对不要按"环面积的正负"分外墙和洞。 skia 不保证整条路径一个绕向: 「黎」回来是 CCW,「君」整条回来是 CW。按符号分类会让「君」的每个环都成"洞", 只剩最大的那个当外墙 → 墨迹掉 72%、包围盒从 26mm 塌到 9.6mm。 正解:按包含关系嵌套分类——面积从大到小遍历,落进已有外环内的就是那个外环的洞
-
洞只属于一个外环:写
for i in outers: for j in holes: face.Add(hole)会把同一个洞 塞进每个外环,面全部非法。射线法判断归属,分给最小的包含它的外环 -
零面积碎片环要丢:skia 差集留下的 sliver 拉伸后是垃圾实体,还会吞掉加给它的洞。 下限用
max(1e-4×最大环, 0.01mm²) -
微段要在轮廓层并掉:
_edge()里合并重合控制点的阈值用 1e-3(不是 1e-9), 否则微米级重合点会拉伸成微壁。但别把这个阈值再放大(试过 1e-3 之外的全局调法, 整个实体散成 12 块) -
改了参数、输出逐字节没变 = 参数根本没接上。
add_material_for_rules曾把wall的位置传成slot, 于是「补壁」长期是空操作,而界面、日志、实体有效性全都正常。判别法极简:改一个参数后算字的面积或轮廓数, 一样就是没生效。这类「标准机制静默无效」比抛异常难查得多 -
不要用自制几何度量判断"薄"和"细"。 腐蚀到空、opening 残差面积这两个指标在本项目里都被证伪过 (同一个字在不同半径下结果非单调、还会报出物理上不可能的数值)。可信的只有平台判定和 「凹面底面轮廓间距」——后者见
cnc-dfm-repair/scripts/dish_slots.py
文字排版:基线
整套字符共用同一个缩放系数,按基线定位。
逐字按"字高"缩放会把句点炸成数字大小(句点的墨迹高度只有数字的 1/3)。
要实现"共用系数 + 不居中",_to_skia 需要 scale= 和 center=False 两个口子。
OCC 布尔:SetArguments / SetTools 不能混
这是本项目最贵的一个坑——症状是"字形并不进币体、静默失败", 当时误判成"OCC 吃不下去光滑曲面",差点去换内核。
# ✗ 全塞进 SetArguments → IsDone()=False,Shape() 是 Null TopoDS_Shape
op.SetArguments([body, p1, p2, ...])
# ✓ 被融体放 SetArguments,工具放 SetTools
op.SetArguments([body])
op.SetTools([p1, p2, ...])
校准法(references/fuse-calibration.py,跑一次就知道是"调用错"还是"几何错"):
拿一个体积能手算的最小算例,把"结果必须等于多少"先写死——
币体 25324 + 方块 920 − 重叠 800 = 25444,改调用方式后分毫不差。
教训:报 IsDone=False 时先怀疑自己的调用,再怀疑内核。
Shape.fuse() 在复杂输入上会静默返回错误结果(面数变少、体积变负),
必须每步打印 len(result.Solids()) 和 Volume() 盯着。
渲染:Blender 无头
见 references/blender-preview.md。要点:--background --factory-startup --python,
bpy.ops.wm.stl_import / stl_export(4.5 改名了),EEVEE Next。
四视角 + 局部特写 + 拼图(compose.py)。
closeup.py 的 SCALE 是正交视野的毫米数——传 2.0 等于看 2mm 的区域,会拍到一片空白。
数字和渲染,两边都得看
渲染图会骗人:布尔静默失败后埋在实心内部的字、面片化的轮廓,渲染都画得像正常的。
但反过来同样成立——有些错数值一个都抓不到。 那个"绕向分类"bug 让两个字只剩轮廓线, 而实体合法、体积为正、无开边、无碎面全绿。是用户看渲染图一眼指出来的。
数值全绿不等于做对了。
嘉立创免费打样:设计期就要对的量化红线
| 检测项 | 口径 | 设计时怎么满足 |
|---|---|---|
| 窄槽 | 槽宽 >1.1mm | 字缝、字与字之间、浮雕与壁之间全是槽,都要过线 |
| 薄壁 | 壁厚 >1.0mm | 笔画本身、补料、细桥 |
| 底部装夹面 | 可吸附面积 ≥包围盒 35% | 真空吸盘,见下 |
| 内圆角 | 直径 ≥1.0mm | 圆体字天然满足;花瓣/波浪的 V 形凹口必然收窄,要么加圆角要么整体收细 |
| Logo | 不支持雕刻 | 文字一律做凸起 |
尺寸 2×2×2 ~ 200×200×15mm;材料仅 6061 铝;上下两面都能加工(限的是侧面特征)。
吸附面:唯一的杠杆是加宽圆环
判据是"和最低面齐平的接触面积":
- 凹面底比外圈环低(哪怕只低 0.5mm)→ 碰不到吸盘,不算
- 凸起浮雕高出环面 → 变成接触点把平面顶起来 → 该面失效
- 字顶受"与外圈齐平"约束也不能再高
- 包围盒是下料尺寸(Ø80 的件按 90×90 = 8100mm² 算),不是零件尺寸
能贴住吸盘的只有「外圈环 + 与外圈齐平的字顶」,所以唯一的杠杆就是环面有多宽 → 缩小凹面半径(DISH_R)。 实测:DISH_R 34.3→28.0 把环面从 1100 提到 2350mm²,正面接触 24.6%→37.7%、反面 22.5%→35.5%。 代价是凹面窄了、字必须缩小塞得进去。
别想别的招:把凹面做浅来"蒙混"(被明确否掉);反面留空虽然最省事(整块平面 62%), 但反面一旦要有字,那块完整平面就没了。
日期 / 装饰不要放在圆环上
圆环上任何凸起都会把接触面顶掉——实测日期凸 0.9mm 时正面最高层只剩 45mm²(环面 1930mm² 全悬空)。 装饰排在凹面里,字顶与环面齐平,既不碰吸盘又计入接触面积。
排布:字与字之间也算"槽"
不只字内部的笔画缝是槽——字与字之间的空隙、浮雕与外壁之间的空隙,全是槽,都要过线。
别用外接框间距当真实间距。 fit_r(外接圆半径)是按墨迹包围盒算出来的,
框就是墨迹的边界,一点余量都没有。本项目翻过车:按框算「黎」底边 −3.6、
「君婷」顶边 −4.1,间距 0.5mm,还把这数字写进了注释,却拿它跟"看着有缝"比、
没跟线比 → 被判窄槽。
改完必须实测,而且量真实几何(BRepExtrema_DistShapeShape),别量重建出来的轮廓。
视觉
- 圆体字的笔画末端是圆的 → 内圆角天然过;方黑体不一定
- 花瓣/波浪等造型的 V 形凹口底部必然收窄,几乎一定踩窄槽
- 把造型做成一条光滑闭合曲线(周期插值 B 样条)而不是"若干小圆拼花瓣"—— 后者圆与圆之间会留 <1.1mm 的窄缝,直接是窄槽;前者每朵花只有 3 个面 (1 个光滑侧壁 + 顶底),零碎面
工作流
- 先分清"建"还是"修"——修的话去
cnc-dfm-repair - 找参考把尺寸量清楚(STEP 的
CARTESIAN_POINT直接正则扫即可,不用 CAD 库) - 用 cadquery 按解析曲面重建,别指望格式转换
- 汉字走 fontTools → skia → OCC 那条链,别用
Workplane.text() - 每步量体积/包围盒/开边
- 无头 Blender 渲染给用户看
- 送 CNC 前把上面的红线对着改;上传后以 DFM 橙点为准,不要推理
参考
references/thin-glyph.py— 汉字 → 光滑 BREP 的完整实现(fontTools → skia → OCC), 含thin削细、fix_slot/fix_wall补料、thicken_at定点补厚references/fuse-calibration.py— 布尔失败时的最小算例校准,跑一次定性references/suction-area.py— 量吸附面。自动认厚度轴,按高度层汇总共面平面references/face-audit.py— 面类型分布 + 碎面清单(带中心坐标)references/cadquery-patterns.md— 旋转体、文字、圆环阵列、导出references/blender-preview.md— 无头渲染与多视图拼图
修复 / 检测 / 验证已有 STEP → 用 cnc-dfm-repair 技能,第一道是
scripts/dish_slots.py(凹面底面轮廓间距 + 退化边检查,实测与平台判定一致),
再往下是 scripts/step_audit.py(面-面精确间距 + 编辑掩膜布尔差分)和该项目的实测记录。