第16章:三视图投影与隐藏线消除
到这里,教程讲的都是”把实体造出来”。但工程场景里还有另一半需求:把三维实体画成二维工程图——国标第一角画法的三视图(主视、俯视、左视),带隐藏线消除(可见边画实线、被遮挡边画虚线),甚至把投影出的折线还原成圆弧表达。这就是 OpenCSG.NET 的三视图投影子系统(View* 系列文件)——库中最新、规模最大的一块功能。本章按”数据准备 → 拓扑分析 → 投影消隐 → 圆弧识别 → 清理收尾”的管线顺序完整拆解它;它是独立于主线(第 1~15 章:建模 → 导出 → 测试)之外的进阶专题,可以单独阅读。
16.1 子系统全景
View* 模块共有 7 个源文件,按职责分三层:
| 层次 | 文件 | 职责 |
|---|---|---|
| 几何基元 | ViewGeometry.cs |
ViewPoint/ViewVector/ViewPoint2/ViewBounds2(三维点/向量/二维点/二维包络) |
| 网格输入 | ViewMesh.cs |
ViewTriangle(三角面)+ ViewMeshReader(STL 读回,第 12 章讲过) |
| 网格输入 | ViewMeshTopology.cs |
顶点焊接、邻接边表、特征边分类(轮廓/锐边/边界) |
| 契约 | ViewProjectorContracts.cs |
ViewDirection/ViewDirections、ViewSegment/IProjectedSegment、ViewProjection、OrthographicViews |
| 核心 | ViewProjector.cs |
消隐投影器:特征边提取 + Z-buffer 可见性切分 |
| 核心 | ViewArcFitter.cs |
把折线段链还原成圆弧/整圆(Kåsa 最小二乘圆拟合) |
| 核心 | ViewSegmentCleanup.cs |
线段清理:丢弃碎片、合并共线、消除重叠 |
整条管线的输入是三角网格(ViewTriangle 列表),输出是每个视图的可见/隐藏线段 + 识别出的圆弧 + 二维包络。注意口径:投影用的网格是Y-up 的 CSG 坐标(直接用 CsgMesh 风格的三角化,不做 Y↔Z 交换——与第 12 章导出 STL 的口径不同);单位仍与模型一致(mm)。
16.2 ViewGeometry:三个轻量几何基元
子系统自带一套极简几何类型,不依赖主库的 Vector3D(保持模块边界清晰):
public readonly struct ViewPoint { double X, Y, Z; } // 三维点
public readonly struct ViewVector { double X, Y, Z; } // 三维向量
public readonly struct ViewPoint2 { double X, Y; } // 二维点(图面坐标)
public readonly struct ViewBounds2 // 二维包络
{
public double MinX, MinY, MaxX, MaxY;
public double Width, Height;
public static ViewBounds2 Empty { get; } // (0,0,0,0) 哨兵
public static ViewBounds2 Of(IEnumerable<ViewPoint2> points);
public ViewBounds2 Offset(double dx, double dy);
public ViewBounds2 Union(ViewBounds2 other);
}
运算层面,ViewVector 提供 Length、Dot、Cross(含点叉点、向量叉向量两种重载)、Normalized() 与数乘;ViewPoint 支持加减。都是纯值类型,没有隐藏状态。
一个约定:
ViewBounds2.Empty是(0,0,0,0)的哨兵值,不区分”占位为零”与”无几何”。新代码里投影结果的包络由ViewProjection.Bounds统一计算(见 16.5.5),不会裸用哨兵。
16.3 ViewTriangle 与 ViewMeshTopology:从三角面到特征边
16.3.1 ViewTriangle:法向由绕序决定
ViewTriangle(ViewMesh.cs)是三角面的最小封装:
public readonly struct ViewTriangle
{
public ViewPoint A, B, C;
public ViewVector Normal { get; } // 右手法则重算并归一化;退化三角形返回零向量
public ViewPoint this[int index] { get; } // 0/1/2 索引访问
}
和 StlWriter.NormalOf 同一哲学:法向永远由绕序重算,不信任外部传入。
16.3.2 ViewMeshTopology:焊接 + 邻接 + 特征边
消隐的前提是知道”哪些边是真正的工程特征边”,而三角网格里的边大多是细分产物。ViewMeshTopology(ViewMeshTopology.cs)完成三件事:
public sealed class ViewMeshTopology
{
public const double SharpAngleDegrees = 25.0; // 锐边判定阈值(二面角 > 25°)
public ViewMeshTopology(IReadOnlyList<ViewTriangle> triangles);
public IReadOnlyList<ViewTriangle> Triangles { get; }
public IReadOnlyList<ViewPoint> Points { get; } // 焊接后的去重顶点
public IReadOnlyList<ViewVector> Normals { get; } // 逐面法向
public IReadOnlyList<ViewEdge> Edges { get; } // 去重后的邻接边表
public readonly struct ViewEdge
{
public int P0, P1; // 端点索引
public int TriA, TriB; // 邻接三角形;TriB = −1 表示边界边(只被一个三角形引用)
public bool IsSharp; // 二面角 > 25°
}
}
- 顶点焊接:用量化栅格 + 精确邻域比较(容差
1e-6)把重复顶点去重——三角网格里同一个空间顶点会出现几十次(每个相邻三角形各一份),不焊接就无法建立邻接关系。 - 边表构建:把每个三角形的三条边按”端点索引对”归并,得到去重后的边表与每条边的两个邻接三角形。
- 特征标记:
IsSharp按相邻两面的二面角判定(> 25° 即锐边);TriB == -1即边界边。
16.4 ViewDirection:投影方向的契约
投影方向由 ViewDirection(ViewProjectorContracts.cs)表达——一个观察方向 + 两个图面取轴:
public sealed class ViewDirection
{
public string Name { get; } // "主视"/"俯视"/"左视"
public ViewVector Look { get; } // 观察方向(深度轴);相机在 −Look 侧
public ViewVector Right { get; } // 映射到图面水平轴的模型轴
public ViewVector Up { get; } // 映射到图面垂直轴的模型轴
}
16.4.1 防镜像不变量
ViewDirection 有一条关键不变量:
Right × Up == −Look
也就是说 (Right, Up, −Look) 构成右手正交基,投影行列式为 +1——图面不会镜像。若取成左手基(Right × Up == +Look),俯视图和左视图会整个镜像,非对称零件会画反。契约要求调用方保证这一点(构造函数不主动校验),做自定义视图方向时务必自检。
16.4.2 国标第一角三视图
ViewDirections 提供三个标准方向与三视图组合:
ViewDirections.Front // 主视:Look = +X(观察者在 −X 侧朝 +X 看),Right = −Y,Up = +Z
ViewDirections.Top // 俯视:Look = −Z(从上往下看), Right = −Y,Up = +X
ViewDirections.Left // 左视:Look = −Y(观察者在 +Y 侧朝 −Y 看),Right = −X,Up = +Z
ViewDirections.OrthographicThree // 顺序固定:{ Front, Top, Left }
这是 GB/T 4458.1 第一角画法的口径:俯视图画在主视图正下方(”长对正”——两图水平轴同为 −Y),左视图画在主视图正右方(”高平齐”——两图垂直轴同为 +Z)。三个方向都满足防镜像不变量。
口径自检练习:
Top的Right × Up = (0,−1,0) × (1,0,0) = (0,0,1),而−Look = (0,0,1)——成立。若有人把Top的 Up 写成 −X,乘积就变成+Look(左手基),俯视图立刻镜像——验证不变量是排查”图面左右颠倒”故障的第一手段。
16.5 ViewProjector:消隐投影核心
ViewProjector(ViewProjector.cs)是子系统的算法核心。构造时传入三角网格,之后可以按任意方向投影:
public sealed class ViewProjector
{
public const int RasterResolution = 900; // 深度栅格分辨率(屏幕长边方向)
public const int MaxEdgeSamples = 4000; // 单条候选边最大采样数
public const double DepthBiasFactor = 1e-3; // 深度容差系数(防自遮挡)
public ViewProjector(IReadOnlyList<ViewTriangle> triangles);
public int TriangleCount { get; }
public IReadOnlyList<ViewProjection> Project(IReadOnlyList<ViewDirection>? directions = null);
public ViewProjection ProjectView(ViewDirection view);
}
Project() 不传方向时默认投影 ViewDirections.OrthographicThree——一次调用得到三张视图。完整的单视图管线分五个阶段:
16.5.1 ① 正对相机判定
对每个三角形,用法向与视线方向的点积判断是否正对相机:
frontFacing[i] = normals[i].Dot(look) < 0; // 外法向与视线相反 = 朝向观察者
16.5.2 ② 特征边提取
每个视图只保留三类边的候选集:
- 轮廓边(silhouette):相邻两面一正对、一背对相机——实体轮廓在此翻转,这是”外轮廓线”的来源;
- 锐边(sharp):二面角 >
SharpAngleDegrees(25°)——零件的可见棱线; - 边界边(boundary):只被一个三角形引用(
TriB == -1)。
曲面细分产生的近似共面”缝”(二面角极小)被自动剔除,因此图面上是工程轮廓而不是密集的网格线团。
这里还有一处精巧的缺陷修复(仓库登记为 DEFECT-010):CSG 求值会把大面按小特征切成多片,留下一些只被单侧引用但实际共面的”分解缝”边(相邻边与它共线且共面、法向差 ≤ 25°)。若不作处理,薄板叠加小凸台这类构造会画出贯穿全长的幻线。ViewProjector 在构造时对每条边界边分类(ClassifyBoundarySpans),把这类假边界按分解缝剔除子区间;真正的 T 形交界(覆盖边不共面)保留。
16.5.3 ③ Z-buffer 消隐
把正对相机的三角形光栅化到规则深度栅格(900 格长边),每格存最小深度(离相机最近的面片深度):
- 候选边沿长度采样(上限 4000 个采样点),每个采样点与栅格深度比较;
- 深度差值在
DepthBiasFactor(深度跨度的 1e-3)偏置之内视为可见——避免自遮挡(Z-fighting); - 逐段切分后得到一串可见/隐藏线段(
ViewSegment)。
一个工程细节:栅格按每个视图独立构建,没有跨视图状态,更省内存也便于并行。自遮挡的判定偏置还有一层”逐格深度上界”处理(登记为 DEFECT-005):斜面在栅格化时深度取值有量化误差,逐格用”本格内真实深度的上界”作 slack,避免斜面上误判出点状隐藏段。
16.5.4 ④ 圆弧识别
消隐后的线段碎片进入 ViewArcFitter(在清理之前——先识别、后清理,顺序很重要,见 16.6)。
16.5.5 ⑤ 清理收尾
最后 ViewSegmentCleanup.Clean 做碎片清理(16.7),组装成 ViewProjection:
public sealed class ViewProjection
{
public ViewDirection Direction { get; }
public IReadOnlyList<ViewSegment> Segments { get; } // 可见段 + 隐藏段
public IReadOnlyList<ViewArc> Arcs { get; } // 识别出的圆弧/整圆(不再出现在 Segments 里)
public ViewBounds2 Bounds { get; } // 视图包络(含弧极值)
public int VisibleCount { get; }
public int HiddenCount { get; }
public int ArcCount { get; }
}
Bounds 的计算有一处值得学习的细节(登记为 DEFECT-009):被圆弧吸收的折线早已从 Segments 里移除,若包络只看线段,全弧视图(比如正对圆柱面看到的整圆)会退化成 (0,0,0,0) 哨兵,与”无几何”不可区分。现在的实现把弧极值并入包络:整圆取全圆外接盒;开口弧取两端点 + 扫掠范围内的 0°/90°/180°/270° 轴极值点。而且用的是精确弧盒而非”保守全圆盒”——拟合出的浅弧半径可能远大于几何尺度,全圆盒会把包络撑到几何之外,破坏落图定位。
16.6 ViewArcFitter:把折线还原成圆弧
消隐输出的是折线(曲面被细分成折线段),但工程图要的是圆弧。ViewArcFitter(ViewArcFitter.cs)负责从折线段链里识别圆弧:
public readonly struct ViewArc
{
public ViewPoint2 Center { get; }
public double Radius { get; }
public double StartAngle { get; } // 弧度
public double EndAngle { get; }
public bool IsVisible { get; } // 可见(实线)或隐藏(虚线)
public bool IsClosed { get; } // 是否整圆
public double SweepDegrees { get; } // 扫掠角度(度)
}
public sealed class ViewCurves<T> where T : IProjectedSegment
{
public IReadOnlyList<ViewArc> Arcs { get; }
public IReadOnlyList<T> Segments { get; } // 未被识别为弧的剩余段
}
public static class ViewArcFitter
{
public static ViewCurves<T> Fit<T>(IReadOnlyList<T> segments,
double tolerance = 0.05, int minSegments = 3, double? joinTolerance = null);
public static IReadOnlyList<ViewArc> MergeConcyclic(IReadOnlyList<ViewArc> arcs);
public static bool DiagnosticsEnabled { get; set; } // [ThreadStatic]
public static string LastDiagnostics { get; } // [ThreadStatic]
}
16.6.1 判定门槛
拟合算法的核心是 Kåsa 最小二乘圆拟合(对折线点集解线性方程组得圆心与半径),配套一组阈值守卫:
| 常量 | 值 | 含义 |
|---|---|---|
DefaultTolerance |
0.05 mm | 点集到拟合圆的偏差上限 |
DefaultMinSegments |
3 | 至少几段才尝试拟合 |
MinRadius / MinRadiusFraction |
0.5 mm / 1e-3 | 半径绝对/相对下限 |
MaxRadius / MaxRadiusToExtent |
1e6 / 5.0× | 半径绝对/相对上限(相对视图尺度) |
MinSweepDegrees |
10° | 扫掠角下限(太小不识别为弧) |
MaxClosingGapDegrees |
25° | 开口弧两端闭合缺口的容差 |
MinJoinTolerance |
1e-3 mm | 链式连接容差 |
拟合成功后被识别为弧的折线段从输出中移除(转入 Arcs),落图时写成 ARC/CIRCLE 实体。MergeConcyclic 把同圆同向的相邻弧段合并(相对容差 1e-3、合并缺口 1°)——比如一个圆柱面两侧的可见/隐藏弧,最终可以合并成更完整的圆弧。
16.6.2 三处硬仗
圆弧识别看似”拟合一下就行”,但真正工程化要过三道关(仓库分别登记为缺陷并全部修复):
- 链的取向(DEFECT-004):折线链有正向和反向两种接续方式,若不携带取向信息,一个球面会破碎成上千段”伪弧碎片”。现在链成员用
(Index, Reversed)显式携带取向,球面 1482 段碎片被正确识别为 3 个整圆。 - 伪巨弧(DEFECT-012):一段几乎笔直的边,用最小二乘也能拟合出”半径巨大、偏差达标”的浅弧——C 型钢样例里出现过 R≈279 的假弧”鼓包” 9.2 mm。现在的实现加了角步长离群守卫:当某一段的转角超过平均步长的 2.2 倍且超过次大值的 1.8 倍时拒绝拟合。真弧的角步长均匀,直边转角的”突变步”会被这个守卫拦下。
- 诊断状态线程隔离(DEFECT-001):
DiagnosticsEnabled与LastDiagnostics都是[ThreadStatic]——并行测试曾因全局静态互相串扰而误报。
16.7 ViewSegmentCleanup:线段清理
最后一道工序是 ViewSegmentCleanup.Clean,把消隐与拟合留下的碎片整理成”落图友好”的线段集:
public static class ViewSegmentCleanup
{
public const double MinAbsoluteLength = 1e-3; // 绝对长度下限(mm)
public const double MinRelativeLength = 1e-4; // 相对视图尺度下限
public static IReadOnlyList<ViewSegment> Clean(IReadOnlyList<ViewSegment> segments, ViewBounds2 bounds);
}
三步流程:
- 丢弃微碎片:长度低于
max(1e-3 mm, 1e-4 × 视图尺度)的段直接删除; - 合并共线:同可见性、彼此接近(连接容差 = 1e-3 的 4 倍)、方向余弦 ≥ 0.99 的两段,若共享端点或端点极近,则合并延长(只允许”延长”式合并,避免改变几何);
- 消除重叠:同一直线、同一可见性、区间有重叠的段,取区间并集。
清理后剩下的线段 + 识别出的圆弧,就是最终交付给工程图落图的”干净线条”。
16.8 完整示例:一次三视图投影
把管线串起来——从声明式建模到三视图投影与统计:
using Csg;
using static Csg.CsgNodes;
// 1) 建模:一个 10×20×30 的长方体(声明式)
var node = Box(0, 0, 0, 10, 20, 30);
var solid = CsgEvaluator.Evaluate(node);
// 2) 三角化(Y-up CSG 口径;注意不是 CsgMesh 的 Z-up 导出口径)
var triangles = MeshBridge.ToViewTriangles(solid); // 见下方说明
// 3) 投影
var projector = new ViewProjector(triangles);
IReadOnlyList<ViewProjection> views = projector.Project(); // 默认三视图
// 4) 消费结果
foreach (var view in views)
{
Console.WriteLine($"{view.Direction.Name}: " +
$"可见 {view.VisibleCount} 段, 隐藏 {view.HiddenCount} 段, " +
$"圆弧 {view.ArcCount} 个, 包络 {view.Bounds.Width}×{view.Bounds.Height}");
}
关于
MeshBridge:库内部(src/)没有内置Solid → ViewTriangle的适配器——测试基础设施(TestKit)中有一个ViewBridge,做同样的转换:把Solid的凸多边形扇形三角化(与CsgMesh.TrianglesOf相同的三角化口径,但不做 Z-up 交换)。如果你在自己的项目里使用投影管线,照下列思路自建即可:遍历solid.Polygons,对每个多边形从首顶点做扇形三角化,把每个三角形的三个顶点包成ViewTriangle。仓库的规格文档中记录了这一设计决定(是否把适配器提升进src/仍待定)。
举一个可以自测的例子:Box(0, 0, 0, sizeX: 10, sizeY: 20, sizeZ: 30)(居中长方体)投影后,借助三视图的取轴关系可以精确预测每个视图的包络——主视(Right = −Y、Up = +Z)为 30×20(对应测试中 Box10x20x30 的主视图),俯视(Right = −Y、Up = +X)为 30×10,左视(Right = −X、Up = +Z)为 10×20。仓库测试正是这样钉住轴映射口径的。另一个可自测的经典场景是遮挡:小盒子放在大盒子后面,投影后应出现隐藏段(HiddenCount > 0)——这是消隐正确性的第一块试金石。
16.9 性能与工程口径
- 复杂度:特征边提取与栅格化对三角形数量近似线性(边表构建 O(n),栅格填充 O(投影面积));深度栅格是 900×900 的定长开销,对超大模型构成下限成本。
- 性能预算:仓库的 SystemTests 中有一组性能测试(
BoundaryProjectionTest等)对ViewProjector.Project、ViewArcFitter.Fit、ViewSegmentCleanup.Clean的耗时设预算门禁(第 15 章会展开)。 - 误判护栏:消隐、圆弧识别与清理都有明确的容差口径(本节表格里的常量)——如果投影结果与预期不符,先查容差量级:模型远小于 1e-3 或远大于 1e6 时,这些阈值都需要重新评估。
- 跨平台确定性:
Math.Sin/Cos/Atan2在 Windows 与 Linux 上有 ≤1 ULP 的差异,经曲面/旋转几何放大后会让投影输出的数字文本出现字节级差异——仓库为此把字节级基线钉在 Windows,非 Windows 平台走语义校验(第 15 章展开)。自己串 CI 时要注意这一点。
16.10 本章小结
- 三视图子系统是库中的投影 + 消隐管线:三角网格 → 特征边 → Z-buffer 消隐 → 圆弧识别 → 清理,产出每视图的可见/隐藏线段与圆弧。
ViewDirections提供国标第一角(GB/T 4458.1)的主/俯/左三视图,防镜像不变量是Right × Up == −Look。ViewMeshTopology做顶点焊接(1e-6)与特征边分类(轮廓/锐边 25°/边界,含分解缝剔除)。ViewProjector用 900 格深度栅格 + 4000 采样/边 + 1e-3 深度偏置完成消隐,正对判定为n·Look < 0。ViewArcFitter用 Kåsa 拟合 + 多重阈值把折线还原成圆弧(ARC/CIRCLE),关键防御是取向携带、角步长离群守卫与线程隔离。ViewSegmentCleanup三步清理(丢碎片、并共线、消重叠);ViewBounds2与弧极值的精确并入保证包络可靠。
本章的三视图内容与第 12 章(网格导出)、第 13 章(内核剖析)、第 14 章(实战)构成”成型 → 导出 → 出图”的完整交付链;第 15 章的测试一节也收录了消隐管线的性能与跨平台验证方法。本章为进阶专题,可独立阅读。