znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第03章:几何系统(上)——向量、曲线与多边形

Elements 的几何系统是整个库的核心支柱。所有建筑元素——墙、梁、柱、楼板——最终都需要精确的几何描述才能在三维空间中存在。Elements 没有依赖第三方几何内核(如 OpenCASCADE 或 Parasolid),而是从零构建了一个完整的、适合 AEC 领域的计算几何库。这个几何库涵盖从基础向量运算到复杂多边形布尔运算的全套能力。

本章聚焦于几何系统的基础层:精度约定、向量系统、曲线类层次结构,以及多边形体系。下一章(第04章)将继续深入到轮廓变换与实体建模。

3.1 右手坐标系与精度约定

3.1.1 右手坐标系

Elements 采用标准的右手坐标系:+X 向右,+Y 向前,+Z 向上。这与大多数 BIM 工具(Revit、Rhino)的默认坐标系一致。在坐标系确立之后,所有旋转变换遵循右手定则:从旋转轴的正方向看向原点,逆时针为正向旋转。

// 右手定则验证:X 叉乘 Y 必须等于 Z
var x = Vector3.XAxis;  // (1, 0, 0)
var y = Vector3.YAxis;  // (0, 1, 0)
var z = x.Cross(y);     // (0, 0, 1) == ZAxis
Console.WriteLine(z.IsAlmostEqualTo(Vector3.ZAxis));  // True

3.1.2 浮点精度常量 Vector3.EPSILON

所有几何计算的核心容差由 Vector3.EPSILON 定义,值为 1e-5(0.00001)。这个值的选择经过了权衡:足够大以吸收大多数浮点累积误差,又足够小以保证建筑尺度的精度(毫米级)。

// Vector3.EPSILON 的定义(源码:Vector3.cs:17)
public const double EPSILON = 1e-5;

此外还有角度比较专用的容差 COS_ANGLE_EPSILON,对应 0.001° 的余弦值(约 0.99999999984769128),用于快速判断两条方向向量是否平行:

// 角度容差(源码:Vector3.cs:22)
public const double COS_ANGLE_EPSILON = 0.99999999984769128;

3.1.3 IsAlmostEqualTo 容差比较方法

Elements 中几乎所有的”相等”判断都使用容差比较而非精确比较。核心方法是 Vector3.IsAlmostEqualTo,它通过欧氏距离的平方来判断两点是否在容差范围内:

// 源码:Vector3.cs:763-772
public bool IsAlmostEqualTo(Vector3 v, double tolerance = Vector3.EPSILON)
{
    if ((this.X - v.X) * (this.X - v.X)
      + (this.Y - v.Y) * (this.Y - v.Y)
      + (this.Z - v.Z) * (this.Z - v.Z) < (tolerance * tolerance))
    {
        return true;
    }
    return false;
}

注意这里使用的是 tolerance * tolerance 作为距离平方的阈值,避免了开方运算,提高了性能。

除了 Vector3.IsAlmostEqualTo,还有一个配套的ApproximatelyEquals 扩展方法,用于比较两个浮点数:

// 双精度容差比较
double a = 1.0 / 3.0;
double b = 0.33333;
if (a.ApproximatelyEquals(b, 1e-4))
{
    Console.WriteLine("近似相等");
}

3.1.4 跨平台浮点精度问题

不同平台(x86、x64、ARM)和不同 .NET 运行时对浮点运算的中间精度处理存在差异。x86 架构使用 80 位扩展精度寄存器,而 x64 使用 SSE 指令的 64 位精度。Elements 采用如下策略应对:

  1. 统一使用 double(64位浮点),不使用 float,保证最小精度一致性
  2. 容差比较无处不在:从不使用 == 直接比较浮点数,所有几何判断都通过 EPSILONApproximatelyEquals 进行
  3. 距离平方比较:尽量用平方距离代替开方后的距离,既提高性能也减少精度损失
  4. 构造时 NaN/Infinity 验证Vector3 构造函数会检查 NaN 和 Infinity 值,防止无效数据进入系统
// 源码:Vector3.cs:40-51
public Vector3(double @x, double @y, double @z)
{
    if (!Validator.DisableValidationOnConstruction)
    {
        if (Double.IsNaN(x) || Double.IsNaN(y) || Double.IsNaN(z))
            throw new ArgumentOutOfRangeException("...One or more of the components was NaN.");
        if (Double.IsInfinity(x) || Double.IsInfinity(y) || Double.IsInfinity(z))
            throw new ArgumentOutOfRangeException("...One or more of the components was infinity.");
    }
    this.X = @x;
    this.Y = @y;
    this.Z = @z;
}

3.2 Vector3:三维向量的完整功能

Vector3 是 Elements 中使用频率最高的类型。它既表示三维空间中的点(位置),也表示方向向量。在 Elements 中,点和向量使用同一个类型,由使用上下文决定其语义。

3.2.1 基本结构

Vector3 是一个 struct(值类型),包含三个 double 分量 X、Y、Z。作为结构体,它避免了频繁分配堆内存,在大量几何运算中性能优势明显。

public partial struct Vector3 : IComparable<Vector3>, IEquatable<Vector3>
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Z { get; set; }
}

它实现了 IEquatable<Vector3>,并重载了 ==!= 运算符,使得所有相等判断都走容差比较路径:

// 运算符重载直接调用 IsAlmostEqualTo
public static bool operator ==(Vector3 a, Vector3 b) => a.Equals(b);
public override bool Equals(object other)
{
    if (!(other is Vector3)) return false;
    return this.IsAlmostEqualTo((Vector3)other);
}

3.2.2 静态常量:坐标轴与原点

Elements 提供了一组全局静态常量来快速引用常用方向:

public static Vector3 XAxis   => new Vector3(1, 0, 0);   // +X 方向
public static Vector3 YAxis   => new Vector3(0, 1, 0);   // +Y 方向
public static Vector3 ZAxis   => new Vector3(0, 0, 1);   // +Z 方向
public static Vector3 Origin  => new Vector3();            // 原点 (0,0,0)
public static Vector3 Min     => new Vector3(double.MinValue, ...);  // 最小值
public static Vector3 Max     => new Vector3(double.MaxValue, ...);  // 最大值

3.2.3 构造与隐式转换

Vector3 提供了多种构造方式,并支持从元组(tuple)隐式转换,极大提高了代码可读性:

// 标准构造函数
var a = new Vector3(10, 20, 30);
var b = new Vector3(5, 5);          // Z 默认为 0

// 从元组隐式转换——Elements 独有的语法糖
Vector3 c = (10, 20, 30);           // 三元素元组
Vector3 d = (5.5, 3.2);             // 二元素元组,Z=0
Vector3 e = (1, 2, 3);              // 三元素整数元组

// 复制构造
var f = new Vector3(a);

这种隐式转换使得使用 Polygon 或 Polyline 时非常方便:

var polyline = new Polyline((0,0,0), (10,0,0), (10,10,0), (0,10,0));

3.2.4 向量运算:加减乘除与运算符重载

Vector3 重载了所有基本算术运算符,使得向量运算代码自然直观:

Vector3 p1 = (10, 20, 5);
Vector3 p2 = (5, 10, 0);

Vector3 sum = p1 + p2;        // (15, 30, 5)
Vector3 diff = p1 - p2;       // (5, 10, 5)
Vector3 scaled = p1 * 3.0;    // (30, 60, 15)
Vector3 halved = p1 / 2.0;    // (5, 10, 2.5)
Vector3 negated = -p1;        // (-10, -20, -5)

3.2.5 Dot(点积)与 Cross(叉积)

这两个方法是三维几何计算的基石:

Vector3 v1 = (1, 0, 0);
Vector3 v2 = (0, 1, 0);

double dot = v1.Dot(v2);          // 0(垂直时为 0)
Vector3 cross = v1.Cross(v2);     // (0, 0, 1)(右手定则:X × Y = Z)

// 点积判断方向关系
double projection = v1.Dot((0.707, 0.707, 0)); // ≈ 0.707

// 叉积有重载,可以直接传三个分量
Vector3 cross2 = v1.Cross(0, 1, 0);  // 等同于 v1.Cross(v2)

3.2.6 长度、单位化与零向量判断

Vector3 v = (3, 4, 0);
double length = v.Length();          // 5(欧氏距离)
double lengthSq = v.LengthSquared(); // 25(避免开方,性能更好)

Vector3 unit = v.Unitized();         // (0.6, 0.8, 0),保持方向不变
bool isUnit = unit.IsUnitized();     // True(长度约等于 1)

// 零向量判断——所有分量均 < EPSILON
Vector3 zero = (0, 0, 0);
bool isZero = zero.IsZero();         // True

// 缩放
Vector3 doubled = v.Scale(2);        // (6, 8, 0)

3.2.7 AngleTo:角度计算

AngleTo 返回两个向量之间的夹角(0° ~ 180°),单位为度:

Vector3 a = (1, 0, 0);
Vector3 b = (0, 1, 0);

double angle1 = a.AngleTo(b);  // 90°
double angle2 = a.AngleTo((-1, 0, 0)); // 180°

// PlaneAngleTo:在指定平面内计算 0°~360° 的有向角度(逆时针)
double angle3 = a.PlaneAngleTo(b);  // 90°(在 XY 平面内)

// 可以指定法向量为任意方向
double angle4 = a.PlaneAngleTo(b, Vector3.ZAxis); // 等同于上面

AngleTo 内部实现中处理了边界情况:当 dot 接近 1 时直接返回 0,避免 Acos(1) 的微小误差;当 dot 接近 -1 时返回 π。

3.2.8 DistanceTo:多种距离计算

DistanceTo 有多个重载,覆盖了点到点、点到线段、点到射线、点到平面、点到折线、点到多边形的各种距离计算需求:

Vector3 point = (5, 5, 0);

// 点到点的距离
double dist1 = point.DistanceTo(new Vector3(10, 10, 0));  // ≈ 7.07

// 点到线段的距离
var line = new Line((0, 0, 0), (10, 0, 0));
double dist2 = point.DistanceTo(line);                       // 5

// 点到线段的距离,同时获取最近点
double dist3 = point.DistanceTo(line, out Vector3 closest);  // 5, closest=(5,0,0)

// 点到射线的距离
var ray = new Ray(Vector3.Origin, Vector3.XAxis);
double dist4 = point.DistanceTo(ray);                        // 5

// 点到平面的距离(有正负号区别)
var plane = new Plane(Vector3.Origin, Vector3.ZAxis);
double dist5 = point.DistanceTo(plane);                      // 0(点在 XY 平面上)

// 点到折线的最近距离
var polyline = new Polyline((0,0,0), (10,0,0), (10,10,0));
double dist6 = point.DistanceTo(polyline);                   // 找到最短的线段距离

// 点到多边形的距离(考虑内部点的情况)
var polygon = new Polygon((0,0,0), (10,0,0), (10,10,0), (0,10,0));
double dist7 = point.DistanceTo(polygon, out Vector3 closestOnPoly);  // 点在内部时为投影距离

点到线段距离的内部实现(Vector3.cs:476-497)使用了投影参数 λ 的方法:将点到起点的向量投影到线段方向上,根据 λ < 0、0 ≤ λ ≤ 1、λ > 1 三种情况分别处理。

3.2.9 AtNEqualSpacesAlongLine:沿线段均匀采样

这是一个静态工具方法,沿给定线段创建 n 个等距采样点:

var line = new Line((0, 0, 0), (100, 0, 0));

// 沿线创建 4 个内部采样点,不含端点
var pts = Vector3.AtNEqualSpacesAlongLine(line, 4, includeEnds: false);
// pts: [(20,0,0), (40,0,0), (60,0,0), (80,0,0)]

// 包含端点
var ptsWithEnds = Vector3.AtNEqualSpacesAlongLine(line, 3, includeEnds: true);
// pts: [(0,0,0), (25,0,0), (50,0,0), (75,0,0), (100,0,0)]

源码实现直接通过步进长度值来计算各点位置:

// 源码:Vector3.cs:141-157
public static IList<Vector3> AtNEqualSpacesAlongLine(Line line, int n, bool includeEnds = false)
{
    var l = line.Length();
    var div = l / (double)(n + 1);
    var pts = new List<Vector3>();
    for (var t = 0.0; t <= l; t += div)
    {
        var pt = line.PointAt(t);
        if ((t == 0.0 && !includeEnds) || (t == l && !includeEnds))
            continue;
        pts.Add(pt);
    }
    return pts;
}

3.2.10 其他实用方法

Vector3 a = (1, 2, 3);
Vector3 b = (4, 5, 6);

// 中点/均值
Vector3 mid = a.Average(b);            // (2.5, 3.5, 4.5)

// 投影:将 a 投影到 b 上
Vector3 proj = a.ProjectOnto(b);       // a 在 b 上的投影向量

// 投影到平面
Vector3 onPlane = a.Project(plane);

// 求反
Vector3 neg = a.Negate();              // (-1, -2, -3)

// 判断平行
bool parallel = Vector3.XAxis.IsParallelTo((2, 0, 0));  // True

// 判断 NaN
bool nan = new Vector3(double.NaN, 0, 0).IsNaN();       // True

// 转换为数组
double[] arr = a.ToArray();            // [1.0, 2.0, 3.0]

// 字符串表示(默认保留 4 位小数)
Console.WriteLine(a.ToString());       // "X:1.0000, Y:2.0000, Z:3.0000"

3.3 曲线类体系

Elements 的曲线系统采用双重抽象基类的分层架构:最顶层是 Curve(全部曲线的抽象),中间层是 BoundedCurve(有界曲线)和 TrimmedCurve<TBasis>(裁剪曲线),底层是具体曲线类型。

3.3.1 类层次结构总览

Curve (abstract)                        ← 顶层抽象基类
├── InfiniteLine                        ← 无限直线
├── Circle                              ← 圆(完整)
├── Ellipse                             ← 椭圆(完整)
├── BoundedCurve (abstract)             ← 有界曲线
│   ├── Bezier                          ← 贝塞尔曲线
│   ├── IndexedPolycurve                ← 索引多段曲线
│   │   └── Polyline                    ← 折线
│   │       └── Polygon                 ← 封闭多边形
│   ├── EllipticalArc                   ← 椭圆弧
│   └── TrimmedCurve<TBasis> (abstract) ← 裁剪曲线
│       ├── Line : TrimmedCurve<InfiniteLine>    ← 线段
│       └── Arc : TrimmedCurve<Circle>           ← 圆弧

核心设计模式TrimmedCurve<TBasis> 泛型类表示”从基曲线(Basis Curve)上截取一段”的裁剪曲线。例如 Line 继承 TrimmedCurve<InfiniteLine>,表示 Line 是从一条无限直线(InfiniteLine)上截取的有限线段;Arc 继承 TrimmedCurve<Circle>,表示 Arc 是从一个完整圆上截取的圆弧段。

3.3.2 Curve:抽象基类

Curve 定义所有曲线的公共契约(源码 Curve.cs:12-46):

public abstract partial class Curve : ICurve, ITransformable<Curve>
{
    // 在参数 u 处求点
    public abstract Vector3 PointAt(double u);

    // 在参数 u 处求变换(Z 轴沿曲线切线方向)
    public abstract Transform TransformAt(double u);

    // 返回变换后的曲线副本
    public abstract Curve Transformed(Transform transform);

    // 沿曲线从起点出发指定距离,返回对应的参数
    public abstract double ParameterAtDistanceFromParameter(double distance, double start);

    // 求与另一曲线的交点
    public abstract bool Intersects(ICurve curve, out List<Vector3> results);
}

Intersects 方法是 Elements 2.1 版大幅扩展的功能,采用双分派(double dispatch)模式:每个具体曲线类型的 Intersects 方法内部通过 switch 判断另一曲线的类型,将调用路由到最适合的求交算法。

3.3.3 BoundedCurve:有界曲线

BoundedCurveCurve 基础上增加了”有起点和终点”的语义(源码 BoundedCurve.cs):

public abstract class BoundedCurve : Curve, IBoundedCurve
{
    public virtual Vector3 Start { get; protected set; }    // 起点
    public virtual Vector3 End { get; protected set; }      // 终点
    public virtual Domain1d Domain => new Domain1d(0, Length());  // 参数域
    public const double DefaultMinimumChordLength = 0.1;     // 默认弦长

    public abstract BBox3 Bounds();                          // 包围盒
    public abstract double Length();                         // 总长度
    public abstract double ArcLength(double start, double end); // 指定参数间的弧长
    public virtual Vector3 Mid() => PointAt(this.Domain.Mid());  // 中点

    // 转换为折线近似(用于渲染和三角剖分)
    public virtual Polyline ToPolyline(int divisions = 10) { ... }

    // 归一化参数访问(u ∈ [0, 1])
    public Vector3 PointAtNormalized(double u) { ... }
    public Transform TransformAtNormalized(double u) { ... }
}

Domain 属性定义曲线的参数范围。对于 Line,参数域为 [0, Length](从起点沿线的距离);对于 Bezier,参数域为 [0, 1](归一化)。

3.3.4 Line:线段

Line 继承自 TrimmedCurve<InfiniteLine>,表示一条有起点和终点的直线段。其 BasisCurve 是一条 InfiniteLine,参数域为 [0, Length]Domain.Min 为起点到 BasisCurve 原点沿方向的距离)。

// 默认构造函数:沿 X 轴的单位长度线段
var defaultLine = new Line();  // Start=(0,0,0), End=(1,0,0)

// 标准构造
var l1 = new Line((0, 0, 0), (10, 0, 0));

// 从起点沿方向指定长度
var l2 = new Line((0, 5, 0), Vector3.XAxis, 20);  // 从(0,5,0)沿 X 方向 20 单位

// 从 InfiniteLine 裁剪
var infLine = new InfiniteLine((0, 0, 0), Vector3.XAxis);
var l3 = new Line(infLine);  // 自动从 BasisCurve.Domain 确定起止点

核心方法

var line = new Line((0, 0, 0), (10, 0, 0));

// 方向(单位向量)
Vector3 dir = line.Direction();   // (1, 0, 0)

// 长度
double len = line.Length();       // 10

// 某参数处的点(参数 = 沿线段距离)
Vector3 p = line.PointAt(3.5);    // (3.5, 0, 0)

// 线段中点
Vector3 mid = line.Mid();         // (5, 0, 0)

// 端点信息
Vector3 s = line.Start;           // (0, 0, 0)
Vector3 e = line.End;             // (10, 0, 0)

// 反转方向
Line rev = line.Reversed();       // Start=(10,0,0), End=(0,0,0)

// 偏移(保持方向,垂直于 Z 轴方向偏移)
Line offset = line.Offset(2.0, flip: false);

// 加厚为多边形(线段→矩形面)
Polygon thick = line.Thicken(1.0); // 1单位宽的矩形条

// 等分线段
var segments = line.DivideIntoEqualSegments(5); // 分为 5 段,返回 5 条 Line
var byLength = line.DivideByLength(3.0);        // 按长度 3 划分

// 包围盒
BBox3 bbox = line.Bounds();

3.3.5 InfiniteLine:无限直线

InfiniteLine 直接继承自 Curve,没有起点和终点。参数化从 -∞(Origin 之前)到 +∞(Origin 之后),参数为 0 时对应 Origin 点。

var infLine = new InfiniteLine((5, 0, 0), Vector3.YAxis);

// 在参数 t 处求点
Vector3 p0 = infLine.PointAt(0);   // (5, 0, 0) —— Origin
Vector3 p1 = infLine.PointAt(3);   // (5, 3, 0)
Vector3 p2 = infLine.PointAt(-2);  // (5, -2, 0)

// 判断某点是否在线上
bool onLine = infLine.ParameterAt((5, 10, 0), out double t);  // true, t=10

3.3.6 Arc:圆弧

Arc 继承自 TrimmedCurve<Circle>,从完整圆上截取一个角度区间形成圆弧。圆弧沿逆时针方向从 StartAngle 扫到 EndAngle

// 在 XY 平面,圆心在原点,半径 5,从 0° 到 90° 逆时针
var arc1 = new Arc(Vector3.Origin, 5.0, 0, 90);

// 圆心在 (10,0,0),半径 3,从 45° 到 180°
var arc2 = new Arc((10, 0, 0), 3.0, 45, 180);

// 从 Circle 裁剪
var circle = new Circle((0, 0, 0), 5.0);
var arc3 = new Arc(circle, 0, Math.PI);  // 半圆弧,参数为弧度

// 通过三点创建
var arc4 = Arc.ByThreePoints((0,0,0), (5,5,0), (10,0,0)); // 过三点的弧

// 两直线间的倒角弧(Fillet)
var a = new Line((0,0,0), (10,0,0));
var b = new Line((10,0,0), (10,10,0));
var fillet = Arc.Fillet(a, b, 2.0);  // 半径 2 的倒角弧

// 核心属性
double radius = arc1.Radius;        // 5
Vector3 center = arc1.Center;       // (0,0,0)
Vector3 start = arc1.Start;         // (5,0,0)(0°处)
Vector3 end = arc1.End;             // (0,5,0)(90°处)
Vector3 mid = arc1.Mid();           // 中点(45°处)
double sAngle = arc1.StartAngle;    // 0
double eAngle = arc1.EndAngle;      // 90

// 弧长
double arcLen = arc1.Length();      // (π/2) * 5 ≈ 7.854

// 互补弧(360° 减去当前弧)
Arc complement = arc1.Complement(); // 从 90° 到 360°

// 反转方向
Arc reversed = arc1.Reversed();     // 从 90° 到 0°

内部机制:如果用户提供的 endAngle < startAngle(比如 270°→90° 表示扫过 180°),内部会调用 EnsureIncreasingAngleDomain 将参数翻转并对底层圆做 180° 旋转,保证参数域始终递增。

3.3.7 Circle:圆

Circle 直接继承 Curve,参数化范围 0 → 2π。它通过一个 Transform 描述其在空间中的位置和朝向(圆心 = Transform.Origin,法向量 = Transform.ZAxis)。

// XY 平面原点处、默认半径 1
var c1 = new Circle();                     // Center=(0,0,0), Radius=1

// 指定圆心和半径
var c2 = new Circle((10, 0, 5), 3.0);      // Center=(10,0,5), Radius=3

// 指定 Transform(可用于任意朝向的圆)
var transform = new Transform((5,5,0), Vector3.YAxis); // 在 Y 轴方向上的圆
var c3 = new Circle(transform, 2.0);

// 求点(参数 u 为弧度,从 +X 方向逆时针)
Vector3 p0 = c2.PointAt(0);                 // (13, 0, 5)
Vector3 p90 = c2.PointAt(Math.PI / 2);      // (10, 3, 5)

// 在参数 u 处的变换(用于沿曲线放置截面)
Transform t = c2.TransformAt(Math.PI / 4);

// 隐式转换为 Arc(一个完整的 360° 弧)
Arc fullArc = c2;  // implicit operator

3.3.8 Ellipse:椭圆

Ellipse 直接继承 Curve,结构与 Circle 类似但区分长轴(MajorAxis)和短轴(MinorAxis)。

// 默认:长轴=2(X方向),短轴=1(Y方向)
var e1 = new Ellipse();

// 指定中心和轴长
var e2 = new Ellipse((0, 0, 0), majorAxis: 5.0, minorAxis: 3.0);

// 指定 Transform 和轴长
var e3 = new Ellipse(new Transform((5,0,0)), 4.0, 2.0);

// 求点
Vector3 p0 = e2.PointAt(0);               // (5, 0, 0)
Vector3 pHalfPi = e2.PointAt(Math.PI / 2); // (0, 3, 0)

// 近似周长(Ramanujan 公式)
double circ = e2.Circumference();

// 判断点是否在椭圆上
bool onEllipse = e2.ParameterAt((3, 2.4, 0), out double t);

椭圆的弧长计算使用数值积分(辛普森法),在 ArcLength 方法中实现。

3.3.9 Bezier:贝塞尔曲线

Bezier 继承 BoundedCurve,支持任意阶的贝塞尔曲线(至少 3 个控制点)。参数化范围 0 → 1。

// 三次贝塞尔曲线(4 个控制点通常称为 cubic Bezier)
var bezier = new Bezier(new List<Vector3>
{
    (0, 0, 0),    // P0: 起点
    (3, 5, 0),    // P1: 控制点
    (7, 5, 0),    // P2: 控制点
    (10, 0, 0)    // P3: 终点
});

// 更高阶贝塞尔
var bezier5 = new Bezier(new List<Vector3>
{
    (0,0,0), (2,3,0), (5,4,0), (7,3,0), (10,0,0)
});

// 求点(u ∈ [0, 1])
Vector3 mid = bezier.PointAt(0.5);

// 曲线长度(分段线性近似)
double len = bezier.Length();

// Frenet 标架:切线、法线、副法线
Vector3 tangent = bezier.TangentAt(0.3);
Vector3 normal = bezier.NormalAt(0.3);
Vector3 binormal = bezier.BinormalAt(0.3);

// 速度与加速度向量
Vector3 vel = bezier.VelocityAt(0.5);
Vector3 accel = bezier.AccelerationAt(0.5);

// Frametype: Frenet 或 RoadLike
var roadBezier = new Bezier(controlPoints, FrameType.RoadLike);

Bezier 实现了完整的伯恩斯坦多项式计算:PointAt 方法通过伯恩斯坦基函数和组合数(二项式系数)来计算曲线上任意参数位置的点。

3.3.10 Polyline:折线

Polyline 继承 IndexedPolycurveBoundedCurve,表示由连续线段组成的开放折线。参数化范围 0 → n−1(n 为顶点数量),每个整数段对应一条线段。

// 从顶点列表创建
var polyline = new Polyline(new List<Vector3>
{
    (0, 0, 0),
    (10, 0, 0),
    (10, 10, 0),
    (0, 10, 0)
});

// 便捷构造函数(params 形式)
var pl = new Polyline((0,0,0), (10,0,0), (10,10,0));

// 获取线段
Line[] segs = polyline.Segments();  // 3 条线段

// 长度(所有线段之和)
double len = polyline.Length();

// 某参数处的点
Vector3 p = polyline.PointAt(1.5);  // 在第1段中点

// 反转
Polyline rev = polyline.Reversed();

// 偏移(产生一个闭合 Polygon)
Polygon[] offsets = polyline.Offset(2.0, EndType.Butt);

// 单侧偏移
Polygon[] sideOffsets = polyline.OffsetOnSide(1.0, flip: false);

// 平面
Plane plane = polyline.Plane();

// 获取子段
Polyline sub = polyline.GetSubsegment((5,0,0), (10,5,0));

// 判断线段相交
bool intersects = polyline.Intersects(someLine, out List<Vector3> intersections);

// 在指定点处拆分
polyline.Split(new List<Vector3> { (5, 0, 0), (10, 5, 0) });

3.4 曲线的切分与求交

Elements 2.1 版对求交系统进行了大幅扩展。每个曲线类型都实现了大量的 Intersects 重载,覆盖了各种曲线组合。

3.4.1 求交系统的设计

所有求交操作的统一入口是基类的抽象方法:

public abstract bool Intersects(ICurve curve, out List<Vector3> results);

具体实现采用访问者模式(Visitor Pattern)的简化版——在每个具体曲线类型的 Intersects(ICurve, ...) 方法中,通过 switch 针对不同的曲线类型调用不同的专用算法。

Circle.Intersects 为例(源码 Circle.cs:191-206):

public override bool Intersects(ICurve curve, out List<Vector3> results)
{
    switch (curve)
    {
        case BoundedCurve boundedCurve:
            return boundedCurve.Intersects(this, out results);  // 委托给有界曲线
        case InfiniteLine line:
            return Intersects(line, out results);               // 圆与无限直线
        case Circle circle:
            return Intersects(circle, out results);              // 圆与圆
        case Ellipse ellipse:
            return Intersects(ellipse, out results);             // 圆与椭圆
        default:
            throw new NotImplementedException();
    }
}

TrimmedCurve<TBasis>.Intersects 的方法更加复杂(源码 TrimmedCurve.cs:28-51),覆盖了 Line、Arc、EllipticalArc、InfiniteLine、Circle、Ellipse、IndexedPolycurve、Bezier 等所有常见组合。

3.4.2 线段与篮框求交

Line.Intersects(BBox3 box, ...) 使用经典的 slab test 算法,分别计算线段与包围盒在 X、Y、Z 三个轴向上的进入/离开参数 t,然后求交集:

var line = new Line((0, 0, 0), (10, 10, 0));
var bbox = new BBox3((2, 2, -1), (8, 8, 1));

bool hits = line.Intersects(bbox, out List<Vector3> results);
// results: [ (2,2,0), (8,8,0) ] —— 线段穿过包围盒的两个交点

3.4.3 线段与线段求交(3D)

Line.Intersects(Line, ...) 支持有限线段和无限直线两种模式:

var line1 = new Line((0, 0, 0), (10, 0, 0));
var line2 = new Line((5, -5, 0), (5, 5, 0));

// 有限线段求交
bool hit = line1.Intersects(line2, out Vector3 pt);  // true, pt=(5,0,0)

// 无限直线求交
bool hitInf = line1.Intersects(line2, out Vector3 pt2, infinite: true);

// 线段与 BBox 求交(无限模式)
bool hitBox = line1.Intersects(bbox, out List<Vector3> results, infinite: true);

3D 线段求交的算法(源码 Line.cs:318-370)使用了一个巧妙的几何方法:将两条线段视为两条 3D 直线,通过构造包含第一条线与第二条线端点(或反过来)的平面来判断它们是否共面且相交。不是简单的参数方程求解,而是通过平面交线的方法,避免了平行或异面情况下的数值问题。

3.4.4 圆与圆的求交

两个圆的求交(Circle.Intersects(Circle, ...))分两种情况:

  1. 共面圆:通过构造三角形(已知两圆半径和圆心距),用余弦定理求交点
  2. 非共面圆:先求两圆所在平面的交线,再分别求该交线与两圆的交点,取同时在两圆上的点
var c1 = new Circle((0, 0, 0), 5.0);
var c2 = new Circle((6, 0, 0), 5.0);  // 两圆相交

bool hit = c1.Intersects(c2, out List<Vector3> results);
// results: 两个交点

3.4.5 曲线与 Bezier 的求交

对于 Bezier 曲线,Elements 采用了迭代逼近 + 细分算法来处理各种求交场景:

  • Bezier.Intersects(InfiniteLine, ...):迭代寻找 Bezier 上到直线距离为 0 的点
  • Bezier.Intersects(Circle, ...):迭代寻找 Bezier 上到圆心的距离等于半径的点
  • Bezier.Intersects(Ellipse, ...):迭代寻找 Bezier 上满足椭圆方程的点
  • Bezier.Intersects(Bezier, ...):使用递归包围盒细分算法——将两条 Bezier 各自递归细分为更小的曲线段,丢弃包围盒不重叠的段,直到包围盒小于容差时取中心点为交点
var b1 = new Bezier(new List<Vector3> { (0,0,0), (2,5,0), (8,5,0), (10,0,0) });
var b2 = new Bezier(new List<Vector3> { (0,5,0), (3,-2,0), (7,-2,0), (10,5,0) });

bool hit = b1.Intersects(b2, out List<Vector3> results);
// results: 两条三次贝塞尔在 XY 平面交点的迭代近似解

3.4.6 椭圆与直线/圆的求交

Ellipse.Intersects(InfiniteLine, ...) 的处理很优雅:将直线变换到椭圆的局部坐标系,然后解一个一元二次方程来求交点。

Ellipse.Intersects(Circle, ...) 在共面时使用迭代逼近;在多平面时分别处理。对于椭圆与椭圆的求交,采用了同样的策略——迭代逼近搜索满足两个椭圆方程的参数。

3.5 Polygon:布尔运算集合的多边形

Polygon 继承自 Polyline,要求顶点共面且封闭(最后一个顶点隐含连接回第一个顶点)。它是 Elements 中进行面域运算的核心类型。

3.5.1 构造与验证

// 标准构造
var rect = new Polygon(new List<Vector3>
{
    (0, 0, 0),
    (10, 0, 0),
    (10, 5, 0),
    (0, 5, 0)
});

// 便捷构造(params 形式)
var triangle = new Polygon((0,0,0), (3,0,0), (1.5, 2.5, 0));

// 构造时自动验证:
// 1. 顶点共面检查——非共面顶点会抛出 ArgumentException
// 2. 移除连续重复顶点
// 3. 删除重叠边上的冗余顶点
// 4. 自相交检查
// 5. 至少需要 3 个顶点

ValidateVertices() 方法(源码 Polygon.cs:72-89)在构造时自动调用,确保多边形的几何正确性。

3.5.2 布尔运算(Union、Intersection、Difference、XOR)

Elements 的多边形布尔运算底层使用 Clipper 库(ClipperLib)。Clipper 是一个经过广泛验证的 2D 多边形布尔运算库(基于 Vatti 算法),Elements 通过整数缩放的方式将浮点多边形转换为 Clipper 能处理的整数坐标:

// ToClipperPath 的核心转换(源码 PolygonExtensions.cs:20-28)
internal static List<IntPoint> ToClipperPath(this Polygon p, double tolerance = Vector3.EPSILON)
{
    var scale = Math.Round(1.0 / tolerance);
    var path = new List<IntPoint>();
    foreach (var v in p.Vertices)
    {
        path.Add(new IntPoint(
            Math.Round(v.X * scale),
            Math.Round(v.Y * scale)));
    }
    return path;
}

缩放因子为 1 / EPSILON ≈ 100000,这意味着所有坐标被放大 10 万倍后转为整数参与 Clipper 运算,运算完成后再缩回来。

四个布尔运算

var a = Polygon.Rectangle((0, 0), (10, 10));
var b = Polygon.Rectangle((5, 5), (15, 15));

// 交集 (Intersection)
var intersection = a.Intersection(b);
// 返回两个多边形重叠的部分:一个 5×5 的正方形

// 并集 (Union)
var union = a.Union(b);
// 返回两个多边形合并为一个 L 形

// 差集 (Difference) — a 减去 b
var diff = a.Difference(b);
// 从矩形 a 中挖掉与 b 重叠的部分

// 对称差 (XOR)
var xor = Polygon.XOR(new[] { a }, new[] { b });
// 返回两个多边形不重叠的部分

// 静态多集合版本:
var allUnion = Polygon.UnionAll(polygonList);
var setDiff = Polygon.Difference(setA, setB);
var setIntersection = Polygon.Intersection(setA, setB);
var setXor = Polygon.XOR(setA, setB);

布尔运算支持 VoidTreatment 参数。IgnoreInternalVoids 将所有多边形视为实心(NonZero 填充规则),PreserveInternalVoids 保留内部的孔洞(EvenOdd 填充规则)。

3.5.3 Contains:点在多边形内的判断

var rect = Polygon.Rectangle((0, 0), (10, 10));

// 简单判断
bool inside = rect.Contains((5, 5, 0));   // true

// 带包含状态详情
rect.Contains((10, 5, 0), out Containment c);
// c == Containment.CoincidesAtEdge —— 点恰好在边缘上

rect.Contains((0, 0, 0), out Containment c2);
// c2 == Containment.CoincidesAtVertex —— 点在顶点上

rect.Contains((-1, 5, 0), out Containment c3);
// c3 == Containment.Outside —— 在多边形外部

内部实现使用绕组数(winding number)算法(源码 Polygon.cs:586-629),支持 3D 空间中的任意朝向多边形——通过投影变换到局部 XY 平面后进行 2D 判断。

3.5.4 面积与法向量

var rect = Polygon.Rectangle((0, 0), (10, 5));

double area = rect.Area();          // 50(绝对值)
double signedArea = rect.Area(signed: true);  // 正=逆时针, 负=顺时针

Vector3 normal = rect.Normal();     // (0, 0, 1) —— 根据 Newell 法计算的单位法向量

bool cw = rect.IsClockWise();       // 判断多边形环绕方向

Area() 使用鞋带公式(Shoelace formula),对于 3D 朝向的多边形,先变换到局部 XY 平面再计算。

3.5.5 Offset:多边形偏移

var rect = Polygon.Rectangle((0, 0), (10, 10));

// 向外偏移 2 单位(产生一个带圆角的更大矩形)
Polygon[] expanded = rect.Offset(2.0);

// 向内偏移(收缩)
Polygon[] shrunk = rect.Offset(-1.5);

Offset 底层同样使用 Clipper 库的 ClipperOffset,支持不同的端点类型(EndType):Butt(平头)、Square(方形延伸)、ClosedPolygon(封闭多边形)。

3.5.6 其他多边形操作

var rect = Polygon.Rectangle((0, 0), (10, 10));

// 形状中心(质心,考虑面积的加权中心)
Vector3 centroid = rect.Centroid();    // (5, 5, 0)

// 顶点平均值中心
Vector3 center = rect.Center();        // (5, 5, 0)

// 变换
var moved = rect.TransformedPolygon(new Transform((0, 0, 10)));

// 反转环绕方向
var rev = rect.Reversed();

// 投影到平面
var proj = rect.Project(new Plane(Vector3.Origin, Vector3.YAxis));

// 去除共线顶点
var cleaned = rect.CollinearPointsRemoved();

// 倒角(所有角)
var filleted = rect.Fillet(1.0);  // 所有角用半径 1 的圆弧替换

// 多边形是否覆盖/包含/触碰其他多边形
bool covers = outer.Covers(inner);
bool disjoint = outer.Disjoint(inner);
bool touches = outer.Touches(inner);

// 确定一个内部点(用于标签放置等)
Vector3 internalPt = rect.PointInternal();

// 与平面求交
bool hits = rect.Intersects(somePlane, out List<Vector3> intersections);

// 用平面裁剪多边形
List<Polygon> trimmed = rect.Trimmed(cutPlane);

3.5.7 静态工厂方法

Elements 提供了多个静态工厂方法来创建常用多边形:

// 矩形(两点定义)
var rect = Polygon.Rectangle((0, 0), (10, 5));

// 矩形(含 Z 坐标)
var rect3D = Polygon.Rectangle((0, 0, 3), (10, 5, 3));

// N 边形(正多边形)
var hexagon = Polygon.NGon(6, radius: 5.0);  // 正六边形,外接圆半径 5

// L 形
var lShape = Polygon.L(10, 8, 2);  // 宽10高8厚2的 L 形

// 圆形 → 多边形(见 3.6 节)

3.6 从圆形曲线转换为 Polygon

当需要将圆、椭圆、圆弧等曲线类型用于三角剖分(Tessellation)或布尔运算(它们只能处理多边形)时,就需要 ToPolygon 方法。

3.6.1 Circle.ToPolygon

var circle = new Circle((0, 0, 0), 5.0);

// 默认 10 段(正十边形近似)
var decagon = circle.ToPolygon();

// 指定分段数
var poly36 = circle.ToPolygon(divisions: 36);  // 36段近似——更平滑

// 源码实现(Circle.cs:81-95)
public Polygon ToPolygon(int divisions = 10)
{
    var pts = new List<Vector3>();
    var twoPi = Math.PI * 2;
    var step = twoPi / divisions;
    // 使用 EPSILON 确保最后一帧不会恰好走到 2π
    for (var t = 0.0; t < twoPi - Vector3.EPSILON; t += step)
    {
        pts.Add(this.PointAt(t));
    }
    return new Polygon(pts, true);  // disableValidation=true,避免自相交误报
}

3.6.2 Ellipse 的 ToPolygon

Ellipse 没有直接的 ToPolygon 方法,但可以借助 BoundedCurve.ToPolyline 然后闭合:

var ellipse = new Ellipse((0, 0, 0), 5.0, 3.0);

// ToPolyline 返回开放折线,需要手动闭合
var polyline = ellipse.ToPolyline(divisions: 24);
var polygon = new Polygon(polyline.Vertices);

// 或者通过 EllipticalArc 完整闭合

BoundedCurve.ToPolyline 的源码逻辑(BoundedCurve.cs:92-108)是在参数域内均匀采样,然后用这些采样点构造折线。对于圆和椭圆,采样点不均匀分布在弧长上,而是均匀分布在角度参数上——这意味着在曲率更大的区域(椭圆短轴端点附近)采样会更密,这是合理的近似策略。

3.6.3 Arc 的 ToPolygon

Arc 也继承自 BoundedCurve,所以可以使用 ToPolyline

var arc = new Arc(Vector3.Origin, 5.0, 0, 180);  // 半圆弧
var pl = arc.ToPolyline(divisions: 20);

// 如果要封闭为扇形,可以手动添加圆心
var fanVerts = new List<Vector3> { arc.Center };
fanVerts.AddRange(pl.Vertices);
var sector = new Polygon(fanVerts);

3.6.4 应用场景

ToPolygon(或 ToPolyline)的结果主要用于:

  1. 三角剖分:Elements 的 Tessellation 模块只能处理多边形面,需要通过 ToPolygon 将曲线转化为多边形后再送入三角剖分器
  2. 布尔运算:Clipper 只能处理 2D 多边形,圆的布尔运算需要先多边形化
  3. 渲染:GPU 只能处理直线段,曲线显示依赖多边形近似

选择合适的分段数是关键:分段太少会导致可见的棱角,太多则会增加不必要的三角形数量。对于建筑应用,建议的分段数经验值:

曲线类型 建议分段数 说明
小半径弧 (R < 0.5m) 8~12 视觉上已经足够平滑
中半径弧 (0.5m < R < 5m) 16~24 常规建筑弧形元素
大半径弧 (R > 5m) 32~48 大型弧形幕墙、屋顶
完整圆 24~48 按建筑尺度调整
椭圆 32~64 长轴方向的曲率变化大,需要更多分段

← 上一章 目录 下一章 →