第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);
}

三步流程:

  1. 丢弃微碎片:长度低于 max(1e-3 mm, 1e-4 × 视图尺度) 的段直接删除;
  2. 合并共线:同可见性、彼此接近(连接容差 = 1e-3 的 4 倍)、方向余弦 ≥ 0.99 的两段,若共享端点或端点极近,则合并延长(只允许”延长”式合并,避免改变几何);
  3. 消除重叠:同一直线、同一可见性、区间有重叠的段,取区间并集。

清理后剩下的线段 + 识别出的圆弧,就是最终交付给工程图落图的”干净线条”。

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 章的测试一节也收录了消隐管线的性能与跨平台验证方法。本章为进阶专题,可独立阅读。


← 上一章 目录