一、背景与目的
在三维几何处理、计算机辅助设计(CAD)、3D打印及工程仿真中,经常需要将一个凸多面体的各个面沿法线方向向内或向外平移特定距离,从而生成缩小的型芯、间隙模型或分层结构。传统的"顶点缩放"或"顶点法向平均"方法无法保证每个平面等距移动,导致体积变化不符合理论预期。为此,本工程实现了一个基于半空间交集(Half-space Intersection) 的精确偏移算法,支持任意凸棱柱、凸棱台和凸棱锥,并允许为每个几何面(底面、顶面、各个侧面)独立设置不同的偏移距离。
二、核心功能
-
三种凸多面体快速生成
- 凸棱柱:底面和顶面形状相同,垂直拉伸。
- 凸棱台:底面与顶面为相似凸多边形(顶点数相同,大小可不同)。
- 凸棱锥:底面凸多边形 + 一个顶点(尖顶)。
支持任意顶点数的凸多边形(三角形、四边形、五边形......)。
-
分面独立偏移
- 对底面、顶面、侧面分别指定不同的向内缩进量(单位与模型尺度一致)。
- 偏移方向严格沿各平面的单位法线方向,数学上精确。
-
基于半空间的精确算法
- 将网格转换为不等式组
A x ≤ b(每个面对应一个半空间)。 - 对新截距
b' = b - offset求解所有顶点,通过凸包重建偏移后的多面体。 - 避免了传统顶点平移法的误差,体积变化符合几何理论值。
- 将网格转换为不等式组
-
详细的几何数据输出
- 原始体积、偏移后体积及体积差。
- 每个几何面(底面、顶面、侧面)的原始顶点与偏移后顶点坐标对比(自动逆时针排序)。
- 支持棱台四边形面的生成与输出(原始面与偏移后面之间的过渡四边形带)。
-
交互式三维可视化
- 使用
pyvista在同一场景中叠加显示原始网格(半透明青色)和偏移后网格(半透明红色)。 - 鼠标点击拾取坐标:左键单击任意网格表面,控制台立即输出该点的三维坐标;同时自动吸附到最近的顶点(距离阈值 0.1),确保多次点击同一顶点时坐标一致,并显示黄色小球标记。
- 支持鼠标拖拽旋转/缩放,便于多角度观察偏移效果。
- 使用
三、技术亮点
- 数学精确性:偏移基于平面平移而非顶点插值,适用于工程级别的间隙设计。
- 高灵活性:每个几何面独立控制偏移量,可实现非均匀缩进(例如底面缩进多、侧面缩进少)。
- 交互友好:鼠标点击即可获得任意位置坐标,辅助验证和调试。
- 跨平台 :纯 Python 实现,依赖库(
numpy,trimesh,pyvista,pypoman,scipy)均可通过pip安装。
四、应用场景
- 3D打印:生成带有均匀缩进的外壳或内芯模型。
- CAD 辅助设计:快速创建等距偏移的型芯、型腔。
- 计算机图形学教学:直观演示凸多面体沿法线平移后的几何变化。
- 工程分析:构建简化模型或检查间隙尺寸。
五、快速开始
bash
pip install numpy trimesh pyvista pypoman scipy
运行代码即可依次生成六棱柱、六棱台和六棱锥的偏移示例,并弹出交互可视化窗口。您只需修改主程序中的多边形顶点和偏移量字典,即可处理任意凸棱柱/台/锥。
六、代码
python
import numpy as np
import trimesh
import random
import pyvista as pv
import pypoman
from scipy.spatial import ConvexHull
# ================== 多面体创建函数 ==================
def create_prism(polygon_xy, z_bottom, z_top):
"""创建凸棱柱(底面与顶面相同,垂直拉伸)"""
return create_polyhedron(polygon_xy, top_polygon=polygon_xy, bottom_z=z_bottom, top_z=z_top)
def create_frustum(polygon_bottom, polygon_top, z_bottom, z_top):
"""创建凸棱台(底面与顶面为不同大小的相似凸多边形,顶点数相同)"""
return create_polyhedron(polygon_bottom, top_polygon=polygon_top, bottom_z=z_bottom, top_z=z_top)
def create_pyramid(polygon_base, apex, z_bottom):
"""创建凸棱锥(底面凸多边形 + 一个顶点)"""
return create_polyhedron(polygon_base, apex=apex, bottom_z=z_bottom)
def create_polyhedron(polygon_bottom, top_polygon=None, apex=None, bottom_z=0, top_z=1):
"""
通用凸多面体创建函数。
参数:
polygon_bottom : list of (x,y) 底面多边形顶点(逆时针)
top_polygon : list of (x,y) 顶面多边形顶点(可选,与底面对应)
apex : (x,y,z) 棱锥顶点(可选,若指定则忽略 top_polygon 和 top_z)
bottom_z : 底面Z坐标
top_z : 顶面Z坐标(棱柱/棱台使用)
返回:
mesh : trimesh.Trimesh 三角网格
tri_labels : list[str] 每个三角形面的类型标签
geom_faces_info: list[dict] 几何面信息(用于输出对比和偏移量分配)
"""
bottom_xy = np.asarray(polygon_bottom)
n = len(bottom_xy)
# 判断类型
if apex is not None:
# 棱锥:底面 + 一个顶点
apex = np.asarray(apex)
vertices = np.vstack([np.hstack([bottom_xy, np.full((n, 1), bottom_z)]), apex])
n_bottom = n
# 构建三角面
faces_tri = []
tri_labels = []
# 底面三角剖分
for i in range(1, n-1):
faces_tri.append([0, i, i+1])
tri_labels.append('bottom')
# 侧面:每个底面边与顶点构成三角形
for i in range(n):
i_next = (i+1) % n
faces_tri.append([i, i_next, n_bottom])
tri_labels.append('side')
# 几何面信息
geom_faces_info = []
# 底面
bottom_inds = list(range(n))
geom_faces_info.append({
'type': 'bottom',
'vertices': bottom_inds,
'plane': (np.array([0,0,-1]), -bottom_z),
'orig_coords': [vertices[i] for i in bottom_inds]
})
# 侧面:每个侧面一个三角形
for i in range(n):
i_next = (i+1) % n
side_inds = [i, i_next, n_bottom]
# 计算平面方程和法线(修正为外法线)
p0, p1, p2 = vertices[side_inds[0]], vertices[side_inds[1]], vertices[side_inds[2]]
normal = np.cross(p1-p0, p2-p0)
normal = normal / np.linalg.norm(normal)
# 确保法线指向外:检查底面中心附近点沿法线移动是否在外部
centroid = np.mean(bottom_xy, axis=0)
outward_dir = np.array([centroid[0], centroid[1], bottom_z]) - np.mean([p0,p1,p2], axis=0)
outward_dir[2] = 0 # 只关心水平方向
if np.dot(normal[:2], outward_dir[:2]) < 0:
normal = -normal
d = np.dot(normal, p0)
geom_faces_info.append({
'type': 'side',
'vertices': side_inds,
'plane': (normal, d),
'orig_coords': [vertices[idx] for idx in side_inds]
})
# 构建网格
faces_tri = np.array(faces_tri)
mesh = trimesh.Trimesh(vertices=vertices, faces=faces_tri, process=False)
mesh.fix_normals()
return mesh, tri_labels, geom_faces_info
elif top_polygon is not None:
# 棱柱或棱台:底面和顶面都有,且顶点数相同
top_xy = np.asarray(top_polygon)
if len(top_xy) != n:
raise ValueError("棱台顶面顶点数必须与底面相同")
bottom_verts = np.hstack([bottom_xy, np.full((n, 1), bottom_z)])
top_verts = np.hstack([top_xy, np.full((n, 1), top_z)])
vertices = np.vstack([bottom_verts, top_verts])
# 三角剖分
faces_tri = []
tri_labels = []
# 底面
for i in range(1, n-1):
faces_tri.append([0, i, i+1])
tri_labels.append('bottom')
# 顶面
for i in range(1, n-1):
faces_tri.append([n, n+i, n+i+1])
tri_labels.append('top')
# 侧面
for i in range(n):
i_next = (i+1) % n
v0, v1, v2, v3 = i, i_next, n+i_next, n+i
faces_tri.append([v0, v1, v2])
tri_labels.append('side')
faces_tri.append([v0, v2, v3])
tri_labels.append('side')
faces_tri = np.array(faces_tri)
mesh = trimesh.Trimesh(vertices=vertices, faces=faces_tri, process=False)
mesh.fix_normals()
# 几何面信息
geom_faces_info = []
# 底面
bottom_inds = list(range(n))
geom_faces_info.append({
'type': 'bottom',
'vertices': bottom_inds,
'plane': (np.array([0,0,-1]), -bottom_z),
'orig_coords': [vertices[i] for i in bottom_inds]
})
# 顶面
top_inds = list(range(n, 2*n))
geom_faces_info.append({
'type': 'top',
'vertices': top_inds,
'plane': (np.array([0,0,1]), top_z),
'orig_coords': [vertices[i] for i in top_inds]
})
# 侧面
for i in range(n):
i_next = (i+1) % n
side_inds = [i, i_next, n+i_next, n+i]
p0, p1, p2 = vertices[side_inds[0]], vertices[side_inds[1]], vertices[side_inds[2]]
normal = np.cross(p1-p0, p2-p0)
normal = normal / np.linalg.norm(normal)
# 外法线修正:指向多边形外侧
centroid_xy = np.mean(bottom_xy, axis=0)
mid_xy = (bottom_xy[i] + bottom_xy[i_next]) / 2
outward_2d = mid_xy - centroid_xy
outward_2d = outward_2d / np.linalg.norm(outward_2d)
if np.dot(normal[:2], outward_2d) < 0:
normal = -normal
d = np.dot(normal, p0)
geom_faces_info.append({
'type': 'side',
'vertices': side_inds,
'plane': (normal, d),
'orig_coords': [vertices[idx] for idx in side_inds]
})
return mesh, tri_labels, geom_faces_info
else:
raise ValueError("必须指定顶面多边形或棱锥顶点")
# ================== 偏移与辅助函数 ==================
def get_halfspace_representation(mesh):
"""从三角形网格提取半空间表示 A x <= b (法线向外)"""
vertices = mesh.vertices
faces = mesh.faces
normals = mesh.face_normals
A = []
b = []
for i, face in enumerate(faces):
p = vertices[face[0]]
n = normals[i]
d = np.dot(n, p)
A.append(n)
b.append(d)
return np.array(A), np.array(b)
def offset_polyhedron_variable(A, b, offsets_per_face):
"""对每个三角形面向内偏移指定距离"""
if len(offsets_per_face) != len(b):
raise ValueError("偏移量长度必须与三角形面数相等")
b_new = b - np.array(offsets_per_face)
vertices = pypoman.compute_polytope_vertices(A, b_new)
if len(vertices) == 0:
raise ValueError("偏移距离过大,多面体退化")
vertices = np.unique(vertices, axis=0)
hull = ConvexHull(vertices)
new_mesh = trimesh.Trimesh(vertices=vertices, faces=hull.simplices, process=False)
return new_mesh
def sort_polygon_ccw(points, normal=None):
"""将共面点集按逆时针排序"""
points = np.asarray(points)
if normal is None:
v1 = points[1] - points[0]
v2 = points[2] - points[0]
normal = np.cross(v1, v2)
normal = normal / np.linalg.norm(normal)
center = np.mean(points, axis=0)
if abs(normal[0]) < 0.9:
u = np.cross(normal, [1,0,0])
else:
u = np.cross(normal, [0,1,0])
u = u / np.linalg.norm(u)
v = np.cross(normal, u)
angles = [np.arctan2(np.dot(p-center, v), np.dot(p-center, u)) for p in points]
sorted_idx = np.argsort(angles)
return points[sorted_idx].tolist()
def get_plane_points(mesh, plane_normal, plane_d, eps=1e-5):
"""从网格中提取位于平面上的多边形点(逆时针)"""
vertices = mesh.vertices
distances = np.abs(np.dot(vertices, plane_normal) - plane_d)
on_plane = np.where(distances < eps)[0]
if len(on_plane) < 3:
return []
points = vertices[on_plane]
points = np.unique(points, axis=0)
return sort_polygon_ccw(points, plane_normal)
def visualize_offset(mesh_original, mesh_offset):
"""三维可视化,支持鼠标点击显示最近顶点的坐标"""
# 合并所有网格的顶点,方便点击时吸附
all_vertices = np.vstack([mesh_original.vertices, mesh_offset.vertices])
from scipy.spatial import KDTree
tree = KDTree(all_vertices)
plotter = pv.Plotter(window_size=[800, 600])
plotter.add_mesh(mesh_original, color='cyan', opacity=0.5, show_edges=True, label='原始网格')
plotter.add_mesh(mesh_offset, color='salmon', opacity=0.8, show_edges=True, label='偏移后网格')
def point_callback(point):
# 寻找离点击点最近的顶点(吸附距离阈值0.1)
dist, idx = tree.query(point, distance_upper_bound=0.1)
if dist < 0.1:
nearest_vertex = all_vertices[idx]
print(f"鼠标点击点坐标 -> 吸附到顶点: ({nearest_vertex[0]:.6f}, {nearest_vertex[1]:.6f}, {nearest_vertex[2]:.6f})")
plotter.add_mesh(pv.Sphere(radius=0.05, center=nearest_vertex), color='yellow', render=False)
else:
print(f"鼠标点击点坐标: ({point[0]:.6f}, {point[1]:.6f}, {point[2]:.6f}) (未吸附,不在顶点附近)")
plotter.add_mesh(pv.Sphere(radius=0.05, center=point), color='yellow', render=False)
plotter.render()
plotter.enable_point_picking(callback=point_callback, show_message=False, left_clicking=True)
plotter.add_legend()
plotter.add_axes()
plotter.view_isometric()
plotter.show()
# ================== 主程序示例 ==================
if __name__ == "__main__":
# 公共几何数据:六边形
np.random.seed(42)
angles = np.linspace(0, 2*np.pi, 6, endpoint=False)
radius = 1.0
hexagon = [(radius * np.cos(a), radius * np.sin(a)) for a in angles]
# 确保逆时针
area = 0.5 * sum((x1*y2 - x2*y1) for (x1,y1),(x2,y2) in zip(hexagon, hexagon[1:]+[hexagon[0]]))
if area < 0:
hexagon = hexagon[::-1]
# ========= 1. 棱柱(六棱柱) =========
print("===== 示例1:六棱柱 =====")
prism, tri_labels_prism, geom_faces_prism = create_prism(hexagon, z_bottom=0, z_top=2)
type_offset = {'bottom': 0.2, 'top': 0.1, 'side': 0.05}
offsets_tri_prism = [type_offset[label] for label in tri_labels_prism]
A_prism, b_prism = get_halfspace_representation(prism)
offset_mesh_prism = offset_polyhedron_variable(A_prism, b_prism, offsets_tri_prism)
print(f"原始体积: {prism.volume:.6f}, 偏移后体积: {offset_mesh_prism.volume:.6f}\n")
# ========= 2. 棱台(六棱台,顶面半径缩小一半) =========
print("===== 示例2:六棱台 =====")
top_hexagon = [(x*0.5, y*0.5) for x,y in hexagon]
frustum, tri_labels_frustum, geom_faces_frustum = create_frustum(hexagon, top_hexagon, z_bottom=0, z_top=2)
offsets_tri_frustum = [type_offset[label] for label in tri_labels_frustum]
A_frustum, b_frustum = get_halfspace_representation(frustum)
offset_mesh_frustum = offset_polyhedron_variable(A_frustum, b_frustum, offsets_tri_frustum)
print(f"原始体积: {frustum.volume:.6f}, 偏移后体积: {offset_mesh_frustum.volume:.6f}\n")
# ========= 3. 棱锥(六棱锥,顶点在 (0,0,2)) =========
print("===== 示例3:六棱锥 =====")
apex = (0, 0, 2)
pyramid, tri_labels_pyramid, geom_faces_pyramid = create_pyramid(hexagon, apex, 0)
type_offset_pyramid = {'bottom': 0.2, 'side': 0.05}
offsets_tri_pyramid = [type_offset_pyramid[label] for label in tri_labels_pyramid]
A_pyramid, b_pyramid = get_halfspace_representation(pyramid)
offset_mesh_pyramid = offset_polyhedron_variable(A_pyramid, b_pyramid, offsets_tri_pyramid)
print(f"原始体积: {pyramid.volume:.6f}, 偏移后体积: {offset_mesh_pyramid.volume:.6f}\n")
# 可视化(选择一个要查看的结果,例如棱柱)
visualize_offset(prism, offset_mesh_prism)
七、总结
ConvexOffset3D 提供了一个开箱即用、高度可定制的凸多面体精确偏移解决方案。它将复杂的半空间求交算法封装为简洁的 Python 函数,配合直观的 3D 可视化与交互拾取,是几何处理、教学演示和工程验证的得力工具。