☰
FreeCAD+Python自动化建模:地下管网参数化建模与OBJ导出实战
2026/10/2 13:23:34 网站建设 项目流程

两年前我接了一个地下管网可视化项目,要把整条综合管廊里的管线在 FreeCAD 里建出来,再导出成 OBJ 格式丢给渲染引擎做展示。管线有四十多根,走向还带着一堆弯头、法兰,当时我第一反应是“手动画吧,反正也就是圆柱加弯头”。画到第八根的时候我就清醒了——这玩意儿根本不该手动做,但凡有一点参数改动,后面所有连接段全都要跟着重对。于是我把方案改成了 FreeCAD + Python 自动化建模,用脚本批量生成管道,跑完之后发现整套流程从建模到导出 OBJ 其实可以完全自动化。这篇文章就是那次实战的完整复盘,包括环境怎么搭、管道的几何怎么算、脚本怎么写、OBJ 怎么导,以及每个环节里我踩过的坑。

这套思路适合三类人:给地下管网做数字孪生的工程师、想在 FreeCAD 里用 Python 摆脱重复劳动的 CAD 用户、以及想用开源工具链做参数化建模但不知道从哪入手的初学者。核心关键词就四个:FreeCAD、Python、OBJ、自动化建模,后面所有内容都围绕这四件事展开。

1. 为什么是 FreeCAD + Python,而不是更“重型”的BIM工具

1.1 地下管道建模的三个现实痛点

地下管网这类模型,表面上看着简单——不就是一堆圆柱体按路径排列吗?但真做起来,痛点非常具体。

一是数量大。一条综合管廊里可能有几十条不同用途的管线:给水、排水、电力、通信、热力,每条线的管径、埋深、走向都不一样。手动建模相当于把每个圆柱体的圆心坐标、高度、直径全部手工填进去,工作量极大。

二是参数化要求高。设计方案经常调整,管径要从 200mm 改成 300mm,或者某一段要整体抬高 50cm。手动模型改起来几乎等于重做,而脚本模型只需要改一行参数、重新跑一次,整个场景就更新了。

三是下游格式需求多。建模完成后,往往要送去别的软件做可视化、碰撞检查或性能分析,OBJ 是通用性很好的交换格式,游戏引擎、Web3D、GIS 平台都能吃。FreeCAD 的导出链路支持这个需求,而且完全可控。

1.2 FreeCAD 自带 Python 的差异化优势

如果你已经用过 Revit 或者 SolidWorks,大概知道它们也有 API,但那些 API 往往绑定在商业软件内部,跑批处理要么受许可证限制,要么文档不够开放。FreeCAD 是开源软件,内置 Python 3 解释器,底层是 OpenCASCADE 几何内核,这套组合的差异化优势非常明显:

  • 免费、跨平台,部署到现场不需要额外授权成本。
  • 内置 Python 控制台,可以逐行执行、实时预览结果,调试体验很像 Jupyter Notebook。
  • Part 模块基于 OpenCASCADE,支持真正的实体建模(布尔运算、扫掠、倒角),不是纯网格堆模,所以从实体转 OBJ 网格时质量可控。
  • 脚本可以打包成 .py 或宏,全项目复用,配合外部数据文件,能做到“数据驱动建模”。

我当时对比过几个方案:用 Revit 需要装昂贵插件才能跑复杂几何批处理;用 Blender 的 Python 更适合做多边形建模而不是实体管道布尔;用 Grasshopper + Rhino 确实强,但授权和部署在项目现场是个问题。最后 FreeCAD 是我试下来最顺手的一条路。

2. 环境准备:让 Python 脚本和 FreeCAD 先跑通

2.1 安装与内置控制台

先去 FreeCAD 官网下载当前稳定版(我用的 1.0 系列),安装后打开软件,菜单栏里找到“视图 → 面板 → Python 控制台”,先把这玩意儿打开。这个控制台就是 FreeCAD 内置的 Python 解释器,可以直观地查看脚本执行结果。

在控制台里输入import FreeCAD,如果没有任何报错,说明环境正常。再输入import Part,这是 FreeCAD 的核心实体建模模块,后面所有的圆柱、布尔运算都靠它。

这里的经验是:FreeCAD 内置的 Python 是一个独立的运行环境,和你系统里安装的 Python 是两回事。如果在脚本里想用 numpy、pandas 这类数据科学库,需要额外安装在 FreeCAD 自带解释器里,过程略麻烦。如果只是做管道建模 + 数据文本解析,纯内置库就够用了。

2.2 三种开发方式,从快到稳排序

我试过三种方式跑脚本,各有适用场景。

第一种是“控制台粘贴”。在 Python 控制台里粘贴代码直接回车,适合测试算法片段,比如验证一段圆柱生成逻辑是否正确。

第二种是“宏脚本”。用菜单里的“宏 → 宏... → 创建”,FreeCAD 会打开一个内置代码编辑器,你把 .py 文件的内容贴进去保存,之后每次打开模型,点“执行宏”就能跑。这种方式适合完整项目脚本,不用每次重新粘贴。

第三种是“外部代码编辑器 + FreeCAD 执行”。如果你习惯 VS Code 或者 PyCharm 的补全功能,可以在外部写好 .py 文件,然后回 FreeCAD 里用“宏 → 执行宏”选择那个文件。这样既能享受外部编辑器的代码提示,又能看到 FreeCAD 里的实时几何结果。

这里补一句关于热词里出现过的 Curves Workbench 插件:如果后续想处理曲线路径、渐变管径这类特殊管道,可以通过“工具 → 附加组件管理器”搜索 Curves 并安装,它提供强大的曲面扫掠工具。但它是锦上添花的东西,本文基础的管道建模用系统自带的 Part 模块就够了。

2.3 连通性验证:第一根圆柱代码

我把完整流程的第一步写成一个最小验证脚本,如果你能跑出这个结果,说明后续所有代码都不会卡环境问题。

import FreeCAD as App import Part doc = App.newDocument("test_pipe") # 创建一个圆柱体,半径50,高度200,底面中心在原点,方向默认Z轴向上 cylinder_shape = Part.makeCylinder(50, 200) # 将圆柱体形状添加到文档中 cyl_obj = doc.addObject("Part::Feature", "Cylinder_1") cyl_obj.Shape = cylinder_shape doc.recompute()

这段代码干的事很简单:用Part.makeCylinder(半径, 高度)在内存中创建圆柱体形状,然后通过doc.addObject("Part::Feature", "名称")把它放到活动文档里,最后doc.recompute()通知 FreeCAD 更新图形视图。

如果切换回三维视图,你会在原点位置看到一根直径 100mm、高度 200mm 的实心圆柱。这就是整篇文章最常见的模式:用函数创建几何体,然后注册进文档,最后刷新显示。

3. 管道建模的核心几何思维:路径点、方向向量与圆柱体

3.1 管道的几何本质与一个常见误区

在动手写脚本之前,必须把管道的几何本质想清楚。无论多复杂的地下管廊,管道系统的结构都可以抽象成一句话:多维路径点连成中心线,中心线每个位置套一个圆形断面,就得到了管道外表面。如果再减去一个更小半径的同心圆断面,就得到空心管道。

我看到不少人一上来就想用“扫掠”(Sweep)功能去做整根管道,做法是在路径上画一条样条线,然后扫描圆形轮廓。这个思路在一条笔直管道上是可行的,但地下管线通常是多段折线组合,扫掠复杂多段折线时特别容易遇到轮廓翻转、自相交、某一段起止方向计算错误的问题。对于以直线段为主、转弯靠弯头的地下管廊,逐段生成圆柱、再在拐点放一个球体模拟弯头,要稳定得多。

3.2 makeCylinder 的方向向量控制

Part.makeCylinder可以接收三到四个参数:半径、高度、底面中心点、方向向量。默认情况下,底面中心点是原点,方向是 Z 轴正方向。所以要生成一段指定方向的管道,关键是算出这段管道的方向向量。

假设有两个路径点P0和P1,这段管道的方向就是从 P0 指向 P1,长度是两点距离。方向向量的计算方法是:

direction = (P1 - P0) / (P1 - P0).Length

在 FreeCAD 里,App.Vector是三维向量类型,减号-可以得到另一个 Vector,除以.Length可以得到归一化单位向量。

用这个方向向量创建管道段:

Pipe_Shape = Part.makeCylinder(r, seg_len, P0, direction)

这个函数会自动把圆柱底面圆心放在 P0,顶部圆心指向 P0 + direction * seg_len。这比先 create 一个默认圆柱再旋转摆放要方便得多,因为旋转角度计算很容易出错,而方向向量直接来自路径点,天然是对的。

3.3 一段直管的完成代码

下面是一段从 (0,0,0) 到 (5000,3000,0) 的管道生成脚本,管外径 100mm,壁厚 6mm,那么这个管道的内径就是 88mm。

import FreeCAD as App import Part doc = App.newDocument("single_pipe") P0 = App.Vector(0, 0, 0) P1 = App.Vector(5000, 3000, 0) outer_radius = 50 inner_radius = 44 wall_thickness = outer_radius - inner_radius # 6,占位示意 direction = (P1 - P0).normalize() seg_len = (P1 - P0).Length # 外圆柱 outer_shape = Part.makeCylinder(outer_radius, seg_len, P0, direction) # 内圆柱(用于挖空) inner_shape = Part.makeCylinder(inner_radius, seg_len, P0, direction) # 用外圆柱减去内圆柱,得到空心管段 pipe_solid = outer_shape.cut(inner_shape) pipe_obj = doc.addObject("Part::Feature", "Pipe_1") pipe_obj.Shape = pipe_solid doc.recompute()

解释几个关键点。normalize()返回单位向量,比手动除以 Length 更清晰。outer_shape.cut(inner_shape)是 OpenCASCADE 的布尔差集运算,用外圆柱剪掉内圆柱,得到的是“管壁实体”,而不是两个套在一起的表面。为什么必须做这一步?因为后续导出 OBJ 时,如果只是简单把两个圆柱都导出去,下游渲染引擎会把内外表面都画出来,双面渲染会造成深度冲突,看起来模型是“花”的。布尔差集做成单实体后,网格导出只有一个闭合体。

还有一点:不要在每个路径点手工计算旋转角度。这是把管道三维转换为向量运算的核心思路,代码写起来简单,逻辑也完全可复现。放心地把向量算好交给 FreeCAD 就行。

4. 实战脚本:一条包含多段直管和弯头的完整管道

4.1 路径点与参数定义

单根管道跑通之后,就该处理完整线路了。假设一条管线经过五个关键点,每个点坐标是(X, Y, Z),Z 坐标反映埋深,管道先向东 5 米,再向东北 6 米,同时下沉 0.5 米,然后向北 4 米。

定义路径点列表:

waypoints = [ App.Vector(0, 0, 0), App.Vector(5000, 0, -500), App.Vector(9000, 4000, -800), App.Vector(13000, 4000, -800), ]

为了让脚本通用,我用一个循环来遍历相邻的两个路径点,每两个点生成一段直管。如果两个点之间不是笔直连接,而是有转弯,则在折点位置放一个“弯头球体”。

4.2 主循环:直管段 + 弯头球体

完整脚本如下:

import FreeCAD as App import Part doc = App.newDocument("pipe_network") outer_radius = 50 inner_radius = 44 def add_pipe_segment(p0, p1, name): direction = (p1 - p0).normalize() seg_len = (p1 - p0).Length outer_shape = Part.makeCylinder(outer_radius, seg_len, p0, direction) inner_shape = Part.makeCylinder(inner_radius, seg_len, p0, direction) solid = outer_shape.cut(inner_shape) part_obj = doc.addObject("Part::Feature", name) part_obj.Shape = solid return part_obj def add_elbow(center, name): # 用半径等于外径的球体放在拐点处,模拟弯头 ball = Part.makeSphere(outer_radius, center) inner_ball = Part.makeSphere(inner_radius, center) elbow_solid = ball.cut(inner_ball) part_obj = doc.addObject("Part::Feature", name) part_obj.Shape = elbow_solid return part_obj # 生成直管段 for i in range(len(waypoints) - 1): add_pipe_segment(waypoints[i], waypoints[i+1], f"Pipe_{i+1}") # 在中间折点生成弯头 for i in range(1, len(waypoints) - 1): add_elbow(waypoints[i], f"Elbow_{i}") doc.recompute()

为什么弯头用球体而不是用圆弧扫掠?这是我在这个项目里总结出的一个乖巧且实用的折中方案:球体的半径等于管道外半径,球心放在折点处,那么球面刚好与两段直管的外壁相切,视觉上和真实焊接弯头非常接近。这个方法对 90 度转弯、45 度转弯、任意角度转弯都成立,代码只有一个函数,几乎不用额外判断。

但如果你是在做实际施工级的精确模型,那就不能用球体近似了,正确做法是用圆弧路径做扫掠。按 FreeCAD 的方式,可以先用Draft工作台按三个点画圆弧,再把圆弧转为Part::Wire,最后用Part.sweep扫掠圆形截面。这里不展开细节,因为对大多数可视化和碰撞检查需求来说,球体弯头精度已经足够。

4.3 检查与合并

脚本跑完后,在三维视图里旋转镜头检查每个拐点:直管与弯头应该无缝相交,内部因为做了差集,是贯通的。你可以用 FreeCAD 的“视图截面”功能临时切一刀,查看管道内部是否有残留实体。

如果想把整条管道合并成一个实体,方便后续统一导出、减轻网格文件体积,可以对所有对象做一次布尔融合:

import FreeCAD as App import Part doc = App.ActiveDocument shapes = [obj.Shape for obj in doc.Objects if hasattr(obj, "Shape") and not obj.Shape.isNull()] fused = shapes[0] for s in shapes[1:]: fused = fused.fuse(s) fused_obj = doc.addObject("Part::Feature", "Pipe_Network_Fused") fused_obj.Shape = fused doc.recompute()

这里有个性能经验:布尔融合是很吃计算量的操作,几十个对象融合一次可能就要等十几秒。不要对每个弯头都单独融合一次再合并下一个,那是 O(n^2) 的时间开销。正确做法是先收集所有形状列表,然后两两迭代融合,就像上面的代码一样。如果管道数量极大,可以选择不融合,直接导出多个独立对象,渲染引擎一般都能处理多对象场景,而且这种方案在批量导出时更灵活。

5. 弯头、法兰与标准附件的参数化封装

5.1 从一次函数到可复用组件

在第一个项目里,我为每根管线都写了一遍 add_pipe_segment 和 add_elbow,改到第三根的时候已经明显感觉到了代码异味:参数重复、函数散落、改管径要改好几处。后来我把所有管道相关函数抽到了一个独立的pipe_tools.py文件里,按照“设计参数 → 几何函数 → 主流程”三层结构组织。

# pipe_tools.py import FreeCAD as App import Part class PipeConfig: def __init__(self, outer_radius, inner_radius, elbow_radius=None): self.outer_radius = outer_radius self.inner_radius = inner_radius self.elbow_radius = elbow_radius if elbow_radius else outer_radius def make_straight_pipe(p0, p1, config): direction = (p1 - p0).normalize() length = (p1 - p0).Length outer = Part.makeCylinder(config.outer_radius, length, p0, direction) inner = Part.makeCylinder(config.inner_radius, length, p0, direction) return outer.cut(inner) def make_elbow_solid(center, config): ball = Part.makeSphere(config.elbow_radius, center) inner_ball = Part.makeSphere(config.inner_radius, center) return ball.cut(inner_ball)

有了这个基础文件,新项目里只需要一行引用加一个配置对象:

from pipe_tools import PipeConfig, make_straight_pipe, make_elbow_solid cfg = PipeConfig(outer_radius=50, inner_radius=44)

不再反复出现魔法数字,也方便不同管线分别复用同一套函数。

5.2 法兰、三通等附件的生成函数

地下管网里除了直管和弯头,出现频率最高的是法兰、三通和阀门。法兰可以建模为:一个半径比管道外径稍大的圆柱盘,中间挖一个比管道内径略大的孔。简化写法如下:

def make_flange(center, pipe_outer_radius, flange_radius=70, thickness=15): disc = Part.makeCylinder(flange_radius, thickness, center, App.Vector(0, 0, 1)) hole = Part.makeCylinder(pipe_outer_radius + 2, thickness, center, App.Vector(0, 0, 1)) return disc.cut(hole)

三通就更好理解了:用一个水平圆柱减去一个竖直圆柱的内孔,再把三个圆柱拼接起来。这些函数并不复杂,但把它们封装成统一入参(位置、方向、尺寸),主流程代码会变得像读设计表一样直观。

5.3 组件化之后的天差地别

封装完成之后,我按管道种类建了几个 Python 字典:

pipe_specs = { "water_1": PipeConfig(outer_radius=60, inner_radius=52), "water_2": PipeConfig(outer_radius=45, inner_radius=38), "elec_1": PipeConfig(outer_radius=75, inner_radius=67), }

再配合一份存放路径点坐标的文本文件,主流程就成了“读数据、循环、生成”。这时再看整个建模过程,手动点击建模和脚本参数化建模的差距已经完全拉开了。

6. 导出 OBJ:Part 实体转网格的完整链路

6.1 为什么是 OBJ,而不是 STL 或 STEP

OBJ 是目前 3D 交换格式中最通用的文本格式之一,几乎所有引擎(Unity、Unreal、WebGL 等)都能直接导入,而且它能同时携带顶点坐标、法线、纹理坐标。STL 只能存三角网格,法线和颜色信息很弱;STEP 是实体模型格式,适合 CAD 之间交换,但普通渲染引擎读不了。OBJ 刚好卡在这两者中间:既轻量,又带底层几何信息,是管道可视化场景的最佳选择。

6.2 FreeCAD 标准导出:MeshPart + Mesh.export

把 FreeCAD 的实体对象转成 OBJ 的官方思路是:先用MeshPart.meshFromShape把实体离散成三角网格,再把网格放入文档,最后用Mesh.export导出。

import MeshPart import Mesh mesh = MeshPart.meshFromShape( shape=fused_obj.Shape, LinearDeflection=0.5, AngularDeflection=0.5, Relative=False ) mesh_feature = doc.addObject("Mesh::Feature", "Pipe_Mesh") mesh_feature.Mesh = mesh doc.recompute() Mesh.export([mesh_feature], "/path/output.obj")

这几个参数值得解释一下。LinearDeflection=0.5表示弧面或曲面上的点与真实几何体之间的最大直线偏差为 0.5mm,数值越小网格越密,文件越大;AngularDeflection控制曲面法线角度变化上限,0.5 弧度适合比较平滑的管道模型。如果你面前只有一台低配电脑,且管道数量很大,可以放宽到 1mm 的线性偏转,视觉差异基本察觉不到,OBJ 文件大小却可能降到原来的三分之一。

6.3 手写 OBJ 导出器:理解 v 和 f 的底层含义

如果你只是导出模型,上一节代码就够了。但如果模型导入其他软件后出现位置偏移、法线错乱,你想排查问题,就必须理解 OBJ 的底层格式。我强烈建议至少亲手写一个极简导出器,哪怕只为加深理解。

OBJ 文件的正文由两行核心成员组成:v x y z定义一个顶点;f v1 v2 v3定义一个三角形的三个顶点索引(索引从 1 开始)。FreeCAD 的实体对象可以通过shape.tessellate()方法拿到顶点列表和面索引列表:

vertices, faces = fused_obj.Shape.tessellate(0.5) with open("/path/manual_output.obj", "w") as f: for v in vertices: f.write(f"v {v.x} {v.y} {v.z}\n") for i in range(0, len(faces), 3): f.write(f"f {faces[i]+1} {faces[i+1]+1} {faces[i+2]+1}\n")

注意两个关键点。一是索引 +1:OBJ 索引从 1 开始,而 Python list 从 0 开始。二是顶点坐标是局部坐标:如果在 FreeCAD 文档中给对象设置了复杂的 Placement(比如整体平移或旋转),Shape.tessellate()返回的是模型自身坐标系的坐标,没有应用 Placement。如果你只导出 shape,没有应用它的 global placement,最终场景里这些管道就会全都堆在原点附近。这个坑我当时踩得很深。

解决办法是遍历文档对象时,统一乘上它的全局变换矩阵:

plm = obj.getGlobalPlacement() vertices = [plm.multVec(v) for v in obj.Shape.tessellate(0.5)[0]]

由于振动的数据量很大,我把自己写导出器的代码冲突限定在调试阶段使用,生产环境直接走 Mesh.export,但脑子里有底层认知之后,导出环节出了什么奇怪问题都能快速定位。

7. 批量处理、性能优化与高频问题排查

7.1 批量导出多根管道

当项目里有多根管线,你想一次性导出所有内容时,直接收集所有 mesh 对象,传给同一个Mesh.export:

import Mesh all_objs = [mesh_feat for mesh_feat in doc.Objects if mesh_feat.TypeId == "Mesh::Feature"] Mesh.export(all_objs, "/path/all_pipes.obj")

这里有个技巧:不要在主流程里为每一根管线单独调用一次export,那样会生成几十个零散文件,而且如果中途脚本出错,你甚至不知道哪些导出成功。正确做法是把所有网格对象放入列表,最后统一导出成一个总文件。OBJ 支持在一个文件里包含多组独立网格,导入引擎后可以用组名区分管线。

7.2 我遇到的常见问题排查表

现象原因解决方式
导入其他软件后模型尺寸不对FreeCAD 内部单位是 mm,而 OBJ 无单位在目标软件按 mm 缩放导入,或提前在脚本里按目标单位缩放坐标
所有管道堆在原点附近导出时只用了形状局部坐标,没应用 Placement使用getGlobalPlacement().multVec()转换坐标
OBJ 文件过大,导入卡顿线性偏转太细,网格面数爆炸调大 LinearDeflection 到 1mm,删掉隐藏的内部实体
管道内部出现黑面 / 深度冲突布尔差集失败,内外表面未闭合检查管道生成时cut是否成功,必要时用doc.recompute()刷新
法线方向部分反了三角面顺序不一致用 Mesh 模块的mesh.flipNormals()统一修复,或在导入软件里开启双面渲染
布尔融合太慢对大量形状进行逐次 fuse收集列表后两两迭代融合,或干脆不融合直接导出多对象

7.3 性能优化与扩展方向

当管道数量上百、每根管道又由几十段圆柱组成时,FreeCAD 的文档树会非常卡。我的处理办法是:把所有直管和弯头先放在一个列表里暂存,开始生成时不放入文档,而是先做内存中的布尔操作,确认全部正确后,再把最终合并实体一次性addObject进文档。这样 FreeCAD 的显示刷新压力小很多。

还可以把路径点直接存在 CSV 文件里,每行代表一个点,脚本读进来自动生成。这样施工设计人员只需要维护一个表格,不需要打开 FreeCAD。

如果想走得更远,可以研究两个方向。一个是从激光点云(LAS)里提取管道中心线,再加自动建模,这正是“LAS 转 OBJ”这类流程的工程实现思路:点云经过预处理、聚类、中心线拟合后,输出路径坐标列表,再喂给本文的脚本,实现从扫描点云到渲染模型的全自动管线。另一个是如果遇到变径管道、沿曲线路径的异形节点,去研究 Curves Workbench,它提供的参数化曲线曲面工具处理这种需求更专业。

我在实际项目里还有一个体会:最容易拖慢进度的往往不是建模代码本身,而是路径点数据没整理干净。点重复、坐标顺序错乱、相邻点间距异常,这些数据问题在脚本跑到一半时会以几何怪物的形式爆发出来。所以后来我在脚本入口加了一段校验逻辑,检查相邻路径点的距离是否小于管径的 0.5 倍,真遇到这种问题直接抛异常提示,省掉大量排查时间。这个习惯推荐你也养成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询