凸包算法

1-凸包简介

凸包 (Convex Hull) :能包裹一组点集的最小的凸向包围体。

以二维点集为例,其凸包可以想象为在所有点外侧拉紧橡皮筋形成的轮廓。

常见的凸包算法:

算法 时间复杂度 特点 适用场景
蛮力法 O(n3) 枚举所有边判断,理论教学,不实用 仅学习原理
Graham 扫描 O(nlogn) 极角排序 + 栈 经典入门算法
Andrew's Monotone Chain 单调链算法 O(nlogn) 坐标排序,实现最简单、稳定 工程首选
Jarvis 步进(礼品包裹法) O(nh),h为凸包顶点数 类似包礼物,h 小时很快 稀疏凸包场景
QuickHull 期望 O(nlogn),最坏O(n2) 分治,类似快速排序 大规模点云

图形学开发中优先使用Andrew's Monotone Chain 算法,代码简洁、数值稳定性好、不易出现极角精度问题。

接下来我们会详细说一下计算二维凸包和三维凸包的常用方法。

2-二维凸包

接下来我们会用Andrew's Monotone Chain 算法计算二维凸包。

2-1-计算原理

已知:二维顶点集合A到G

求:二维顶点集合的凸包

解:

1.排序。

按照顶点的x 位置的大小排序,若x 相同,就按照y 值的大小排序。

2.按顺序计算下凸包,即ADG。

计算原理是用按照排序,从前向后,用相邻的3个顶点构建2条向量进行叉乘运算,用叉乘结果判断是否满足凸包条件。

比如:AB叉乘AC,若结果小于0,把点B扔掉,然后用AC叉乘AD,......;否则,保留点B,然后用BC叉乘BD,......。

3.按顺序计算上凸包,即GFBA。

原理同上。

4.将下凸包和上凸包合在一起,就是二维顶点集合的凸包。

2-2-代码实现

1.随机生成二维点集。

ini 复制代码
function randomPoints(count: number): Point2[] {
  const out: Point2[] = [];
  const span = 9;
  for (let i = 0; i < count; i++) {
    out.push({id: nextId++,
      x: (Math.random() * 2 - 1) * span,
      y: (Math.random() * 2 - 1) * span,
    });
  }
  return out;
}
const points = randomPoints(10);

2.计算二维点集的凸包。

ini 复制代码
const convexHull=monotoneChain(points)

monotoneChain 是计算二维凸包的方法,代码如下:

ini 复制代码
export type Point2 = { x: number; y: number };

function cross(o: Point2, a: Point2, b: Point2): number {
  return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}

function compareXY(a: Point2, b: Point2): number {
  if (a.x !== b.x) return a.x - b.x;
  return a.y - b.y;
}

function pointKey(p: Point2): string {
  return `${p.x},${p.y}`;
}

function dedupeSorted(points: Point2[]): Point2[] {
  const out: Point2[] = [];
  const seen = new Set<string>();
  for (const p of points) {
    const key = pointKey(p);
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(p);
  }
  return out;
}

/** Andrew 单调链:排序 O(n log n),建链 O(n) */
export function monotoneChain(input: Point2[]): Point2[] {
  if (input.length === 0) return [];

  // 1. 按 x(再按 y)排序并去重
  const sorted = dedupeSorted([...input].sort(compareXY));
  if (sorted.length <= 2) return [...sorted];

  // 2. 下凸包:左 → 右,保持严格左转
  const lower: Point2[] = [];
  for (const p of sorted) {
    while (lower.length >= 2) {
      const o = lower[lower.length - 2]!;
      const a = lower[lower.length - 1]!;
      if (cross(o, a, p) <= 0) lower.pop();
      else break;
    }
    lower.push(p);
  }

  // 3. 上凸包:右 → 左;跳过下链内部点,保留两端
  const upper: Point2[] = [];
  const lowerInterior = new Set(lower.slice(1, -1));

  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i]!;
    if (lowerInterior.has(p)) continue;

    while (upper.length >= 2) {
      const o = upper[upper.length - 2]!;
      const a = upper[upper.length - 1]!;
      if (cross(o, a, p) <= 0) upper.pop();
      else break;
    }
    upper.push(p);
  }

  // 4. 合并(去掉重复端点);全共线则退化为最左 ↔ 最右
  const result = [...lower.slice(0, -1), ...upper.slice(0, -1)];
  return result.length >= 2
    ? result
    : [sorted[0]!, sorted[sorted.length - 1]!];
}

解释一下上面的while 逻辑。

while 的作用是在将新点压入栈前,不断丢掉会破坏凸包的点。

以下图为例,判断下凸包。

ABC 是下凸包,所以ABC可以被压入栈;

BCD 不是下凸包,所以C 舍弃,但D 在压入栈前,还得再往前判断是否存在破坏凸包的点。

ABD 不是下凸包,所以B舍弃,此时压入栈的就是AD。

3-三维凸包

接下来我们会用增量构造的方法计算凸包。

增量构造(beneath-beyond):从四面体出发,逐点扩展为多面的凸包。

3-1-增量构造的实现逻辑

1.已知随机三维点。

2.在三维点中找到体积最大的四面体。

3.遍历四面体之外的其它顶点,若顶点在四面体中,跳过;否则,找到四面体中朝向此顶点的面。

在上图中,红色的顶点可以理解为我们的眼睛,我们可以看见的面就是可见面,否是就是不可见面。

4.在四面体中,找到可见面和不可见面的边界线。

边界线满足的条件是:一段边界线有2个相邻面,一个面可见,一个面不可见。

5.删除可见面。将边界线连向红色顶点,构建新的三角面,与之前的不可见面构成新的几何体。

6.重复步骤3,遍历完所有顶点,便可以增量构造的方式得到三维凸包。

3-2-三维凸包的代码实现

1.为顶点标注id

ini 复制代码
/** 空间点;id 用于可视化追踪,不参与几何计算。 */
export type Point3 = {
  x: number;
  y: number;
  z: number;
  id: number;
};
/** 当前输入点集 */
let points: Point3[] = [];
/** 点 id 自增计数,保证可视化可稳定追踪每个点 */
let nextId = 1;

function randomPoints(count: number): Point3[] {
  const out: Point3[] = [];
  const span = 6;
  for (let i = 0; i < count; i++) {
    out.push({
      id: nextId++,
      x: (Math.random() * 2 - 1) * span,
      y: (Math.random() * 2 - 1) * span,
      z: (Math.random() * 2 - 1) * span,
    });
  }
  return out;
}

const points = randomPoints(12);

上面的顶点是随机生成的,在实际工作中,它是取自某个模型的。

2.计算顶点集合的凸包

ini 复制代码
const faceList=incrementalHull(points)

incrementalHull 就是用增量构造的方式计算凸包的方法,其整体代码如下:

ini 复制代码
/**
 * 3D 凸包增量构造(beneath-beyond)
 * 去重 → 最大体积不共面四点建初始四面体 → 逐点:可见面 → 地平线 → 删面 → 三角化
 */

export type Point3 = {
    x: number;
    y: number;
    z: number;
    id: number;
};

/** 三角面:三点按外法向右手定则排序 */
export type Face3 = {
    a: Point3;
    b: Point3;
    c: Point3;
};

const EPS = 1e-9;

type Vec3 = [number, number, number];

function sub(a: Point3, b: Point3): Vec3 {
    return [a.x - b.x, a.y - b.y, a.z - b.z];
}

function cross3(u: Vec3, v: Vec3): Vec3 {
    return [
        u[1] * v[2] - u[2] * v[1],
        u[2] * v[0] - u[0] * v[2],
        u[0] * v[1] - u[1] * v[0],
    ];
}

function dot(u: Vec3, v: Vec3): number {
    return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
}

function len2(u: Vec3): number {
    return dot(u, u);
}

/** ((b-a)×(c-a))·(p-a);外法向朝外时 >0 表示 p 在外侧 */
export function orient(a: Point3, b: Point3, c: Point3, p: Point3): number {
    return dot(cross3(sub(b, a), sub(c, a)), sub(p, a));
}

function pointKey3(p: Point3): string {
    return `${p.x},${p.y},${p.z}`;
}

function faceKey(f: Face3): string {
    const ids = [f.a.id, f.b.id, f.c.id].sort((x, y) => x - y);
    return ids.join('-');
}

function dedupe(points: Point3[]): Point3[] {
    const out: Point3[] = [];
    const seen = new Set<string>();
    for (const p of points) {
        const key = pointKey3(p);
        if (seen.has(key)) continue;
        seen.add(key);
        out.push(p);
    }
    return out;
}

function centroidOfPoints(pts: Point3[]): Point3 {
    let x = 0;
    let y = 0;
    let z = 0;
    for (const p of pts) {
        x += p.x;
        y += p.y;
        z += p.z;
    }
    const n = pts.length || 1;
    return { id: -1, x: x / n, y: y / n, z: z / n };
}

function uniqueVertices(faces: Face3[]): Point3[] {
    const map = new Map<number, Point3>();
    for (const f of faces) {
        map.set(f.a.id, f.a);
        map.set(f.b.id, f.b);
        map.set(f.c.id, f.c);
    }
    return [...map.values()];
}

function orientFaceOutward(face: Face3, interior: Point3): Face3 {
    if (orient(face.a, face.b, face.c, interior) > 0) {
        return { a: face.a, b: face.c, c: face.b };
    }
    return face;
}

function orientAllOutward(faces: Face3[], interior: Point3): Face3[] {
    return faces.map((f) => orientFaceOutward(f, interior));
}

/** 选取体积最大的不共面四点 */
function findInitialTetra(
    points: Point3[],
): { tetra: [Point3, Point3, Point3, Point3]; rest: Point3[] } | null {
    if (points.length < 4) return null;

    let i0 = 0;
    let i1 = 1;
    let bestD = -1;
    for (let i = 0; i < points.length; i++) {
        for (let j = i + 1; j < points.length; j++) {
            const d = len2(sub(points[i]!, points[j]!));
            if (d > bestD) {
                bestD = d;
                i0 = i;
                i1 = j;
            }
        }
    }
    if (bestD <= EPS) return null;

    const a = points[i0]!;
    const b = points[i1]!;
    const ab = sub(b, a);

    let i2 = -1;
    let bestArea = -1;
    for (let i = 0; i < points.length; i++) {
        if (i === i0 || i === i1) continue;
        const area = len2(cross3(ab, sub(points[i]!, a)));
        if (area > bestArea) {
            bestArea = area;
            i2 = i;
        }
    }
    if (i2 < 0 || bestArea <= EPS) return null;

    const c = points[i2]!;

    let i3 = -1;
    let bestVol = -1;
    for (let i = 0; i < points.length; i++) {
        if (i === i0 || i === i1 || i === i2) continue;
        const vol = Math.abs(orient(a, b, c, points[i]!));
        if (vol > bestVol) {
            bestVol = vol;
            i3 = i;
        }
    }
    if (i3 < 0 || bestVol <= EPS) return null;

    const used = new Set([i0, i1, i2, i3]);
    const tetra: [Point3, Point3, Point3, Point3] = [
        points[i0]!,
        points[i1]!,
        points[i2]!,
        points[i3]!,
    ];
    const rest = points.filter((_, i) => !used.has(i));
    return { tetra, rest };
}

function makeTetraFaces(
    p0: Point3,
    p1: Point3,
    p2: Point3,
    p3: Point3,
): Face3[] {
    const interior = centroidOfPoints([p0, p1, p2, p3]);
    const raw: Face3[] = [
        { a: p0, b: p1, c: p2 },
        { a: p0, b: p2, c: p3 },
        { a: p0, b: p3, c: p1 },
        { a: p1, b: p3, c: p2 },
    ];
    return orientAllOutward(raw, interior);
}

function edgeKey(u: Point3, v: Point3): string {
    return u.id < v.id ? `${u.id}_${v.id}` : `${v.id}_${u.id}`;
}

type HorizonEdge = { from: Point3; to: Point3 };

/** 地平线:恰连接一个可见面与一个不可见面的边 */
function findHorizon(faces: Face3[], visible: Face3[]): HorizonEdge[] {
    const visibleSet = new Set(visible.map(faceKey));

    type EdgeRec = {
        from: Point3;
        to: Point3;
        visible: boolean;
    };

    const byEdge = new Map<string, EdgeRec[]>();

    for (const f of faces) {
        const vis = visibleSet.has(faceKey(f));
        const directed: Array<[Point3, Point3]> = [
            [f.a, f.b],
            [f.b, f.c],
            [f.c, f.a],
        ];
        for (const [from, to] of directed) {
            const key = edgeKey(from, to);
            const list = byEdge.get(key) ?? [];
            list.push({ from, to, visible: vis });
            byEdge.set(key, list);
        }
    }

    const horizon: HorizonEdge[] = [];
    for (const recs of byEdge.values()) {
        if (recs.length !== 2) continue;
        const [r0, r1] = recs;
        if (r0!.visible === r1!.visible) continue;
        const visRec = r0!.visible ? r0! : r1!;
        horizon.push({ from: visRec.from, to: visRec.to });
    }
    return horizon;
}

function facesFromHorizon(
    horizon: HorizonEdge[],
    eye: Point3,
    interior: Point3,
): Face3[] {
    return horizon.map((e) =>
        orientFaceOutward({ a: e.from, b: e.to, c: eye }, interior),
    );
}

/** 校验:所有顶点应在每个面的内侧或面上 */
export function validateHull(faces: Face3[], eps = 1e-6): boolean {
    if (faces.length < 4) return faces.length === 0;
    const verts = uniqueVertices(faces);
    const interior = centroidOfPoints(verts);
    for (const f of faces) {
        if (orient(f.a, f.b, f.c, interior) > eps) return false;
    }
    for (const f of faces) {
        for (const p of verts) {
            if (p.id === f.a.id || p.id === f.b.id || p.id === f.c.id) continue;
            if (orient(f.a, f.b, f.c, p) > eps) return false;
        }
    }
    return true;
}

/** 增量构造三维凸包,返回三角面集 */
export function incrementalHull(input: Point3[]): Face3[] {
    if (input.length === 0) return [];

    const points = dedupe(input);
    if (points.length < 4) return [];

    const initial = findInitialTetra(points);
    if (!initial) return [];

    const [p0, p1, p2, p3] = initial.tetra;
    let faces = makeTetraFaces(p0, p1, p2, p3);

    for (const eye of initial.rest) {
        const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
        if (visible.length === 0) continue;

        const horizon = findHorizon(faces, visible);
        if (horizon.length < 3) continue;

        const visibleKeys = new Set(visible.map(faceKey));
        const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
        const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
        const added = facesFromHorizon(horizon, eye, interior);
        faces = [...kept, ...added];
    }

    faces = orientAllOutward(faces, centroidOfPoints(uniqueVertices(faces)));
    return faces;
}

incrementalHull 方法涉及的代码有点多,咱们详细说一下。

4-incrementalHull 代码详解

4-1-顶点去重,检验顶点数量

对于位置一样的顶点,只取一个。

若去重后的顶点数量小于4个,返回\[\]。

ini 复制代码
if (input.length === 0) return [];

const points = dedupe(input);
if (points.length < 4) return [];

dedupe 是顶点去重方法。

javascript 复制代码
function pointKey3(p: Point3): string {
	return `${p.x},${p.y},${p.z}`;
}
function dedupe(points: Point3[]): Point3[] {
    const out: Point3[] = [];
    const seen = new Set<string>();
    for (const p of points) {
      const key = pointKey3(p);
      if (seen.has(key)) continue;
      seen.add(key);
      out.push(p);
    }
    return out;
}

此方法将顶点位置变成字符串,然后做为Set 值,判断点位是否相同。

3.若去重后的顶点数量小于4个,返回\[\]。

ini 复制代码
if (points.length < 4) return [];

4-2-获取顶点中最大的四面体

ini 复制代码
const initial = findInitialTetra(points);
if (!initial) return [];

findInitialTetra 方法就是获取最大的四面体的方法。

ini 复制代码
function findInitialTetra(
    points: Point3[],
): { tetra: [Point3, Point3, Point3, Point3]; rest: Point3[] } | null {
    if (points.length < 4) return null;

    let i0 = 0;
    let i1 = 1;
    let bestD = -1;
    for (let i = 0; i < points.length; i++) {
        for (let j = i + 1; j < points.length; j++) {
            const d = len2(sub(points[i]!, points[j]!));
            if (d > bestD) {
                bestD = d;
                i0 = i;
                i1 = j;
            }
        }
    }
    if (bestD <= EPS) return null;

    const a = points[i0]!;
    const b = points[i1]!;
    const ab = sub(b, a);

    let i2 = -1;
    let bestArea = -1;
    for (let i = 0; i < points.length; i++) {
        if (i === i0 || i === i1) continue;
        const area = len2(cross3(ab, sub(points[i]!, a)));
        if (area > bestArea) {
            bestArea = area;
            i2 = i;
        }
    }
    if (i2 < 0 || bestArea <= EPS) return null;

    const c = points[i2]!;

    let i3 = -1;
    let bestVol = -1;
    for (let i = 0; i < points.length; i++) {
        if (i === i0 || i === i1 || i === i2) continue;
        const vol = Math.abs(orient(a, b, c, points[i]!));
        if (vol > bestVol) {
            bestVol = vol;
            i3 = i;
        }
    }
    if (i3 < 0 || bestVol <= EPS) return null;

    const used = new Set([i0, i1, i2, i3]);
    const tetra: [Point3, Point3, Point3, Point3] = [
        points[i0]!,
        points[i1]!,
        points[i2]!,
        points[i3]!,
    ];
    const rest = points.filter((_, i) => !used.has(i));
    return { tetra, rest };
}

详细解释一下findInitialTetra 方法的计算步骤。

1.找到距离最远的2个点,即a,b

ini 复制代码
let i0 = 0;
let i1 = 1;
let bestD = -1;
for (let i = 0; i < points.length; i++) {
    for (let j = i + 1; j < points.length; j++) {
        const d = len2(sub(points[i]!, points[j]!));
        if (d > bestD) {
            bestD = d;
            i0 = i;
            i1 = j;
        }
    }
}
if (bestD <= EPS) return null;

const a = points[i0]!;
const b = points[i1]!;
const ab = sub(b, a);

sub 是向量减法。

perl 复制代码
function sub(a: Point3, b: Point3): Vec3 {
    return [a.x - b.x, a.y - b.y, a.z - b.z];
}

len2 是利用相同向量的点积计算向量长度的平方。

scss 复制代码
function len2(u: Vec3): number {
    return dot(u, u);
}
function dot(u: Vec3, v: Vec3): number {
    return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
}

2.找到与ab 构成最大三角形的点,即c。

ini 复制代码
let i2 = -1;
let bestArea = -1;
for (let i = 0; i < points.length; i++) {
    if (i === i0 || i === i1) continue;
    const area = len2(cross3(ab, sub(points[i]!, a)));
    if (area > bestArea) {
        bestArea = area;
        i2 = i;
    }
}
if (i2 < 0 || bestArea <= EPS) return null;

const c = points[i2]!;

三角形的面积可以用三角形两边叉乘后的长度计算。

perl 复制代码
const area = len2(cross3(ab, sub(points[i]!, a)));

cross3 是三维向量的叉乘方法。

less 复制代码
function cross3(u: Vec3, v: Vec3): Vec3 {
    return [        u[1] * v[2] - u[2] * v[1],
        u[2] * v[0] - u[0] * v[2],
        u[0] * v[1] - u[1] * v[0],
    ];
}

3.找到与abc 构成最大四面体的点。

ini 复制代码
let i3 = -1;
let bestVol = -1;
for (let i = 0; i < points.length; i++) {
    if (i === i0 || i === i1 || i === i2) continue;
    const vol = Math.abs(orient(a, b, c, points[i]!));
    if (vol > bestVol) {
        bestVol = vol;
        i3 = i;
    }
}
if (i3 < 0 || bestVol <= EPS) return null;

四面体的体积是四面体三条相邻边的行列式的绝对值的一半。

css 复制代码
const vol = Math.abs(orient(a, b, c, points[i]!));

上面的代码没有取半,是因为这只是做对比,不取半也行。

orient 是行列式的计算方法,其几何概念是用三条向量计算以其为边平行六面体的有向体积。

less 复制代码
/** ((b-a)×(c-a))·(p-a);外法向朝外时 >0 表示 p 在外侧 */
export function orient(a: Point3, b: Point3, c: Point3, p: Point3): number {
    return dot(cross3(sub(b, a), sub(c, a)), sub(p, a));
}

4.返回最大四面体的顶点,以及顶点集合中的其它顶点。

ini 复制代码
const used = new Set([i0, i1, i2, i3]);
const tetra: [Point3, Point3, Point3, Point3] = [
    points[i0]!,
    points[i1]!,
    points[i2]!,
    points[i3]!,
];
const rest = points.filter((_, i) => !used.has(i));
return { tetra, rest };

4-3-根据四面体的顶点生成法线朝外的三角面

ini 复制代码
const [p0, p1, p2, p3] = initial.tetra;
let faces = makeTetraFaces(p0, p1, p2, p3);

makeTetraFaces 是根据四面体顶点生成法线朝外的三角面的方法。

yaml 复制代码
function makeTetraFaces(
    p0: Point3,
    p1: Point3,
    p2: Point3,
    p3: Point3,
): Face3[] {
    const interior = centroidOfPoints([p0, p1, p2, p3]);
    const raw: Face3[] = [
        { a: p0, b: p1, c: p2 },
        { a: p0, b: p2, c: p3 },
        { a: p0, b: p3, c: p1 },
        { a: p1, b: p3, c: p2 },
    ];
    return orientAllOutward(raw, interior);
}

1.用centroidOfPoints 方法获取四面体的质心。

ini 复制代码
function centroidOfPoints(pts: Point3[]): Point3 {
    let x = 0;
    let y = 0;
    let z = 0;
    for (const p of pts) {
        x += p.x;
        y += p.y;
        z += p.z;
    }
    const n = pts.length || 1;
    return { id: -1, x: x / n, y: y / n, z: z / n };
}

2.构建三角面

yaml 复制代码
const raw: Face3[] = [
    { a: p0, b: p1, c: p2 },
    { a: p0, b: p2, c: p3 },
    { a: p0, b: p3, c: p1 },
    { a: p1, b: p3, c: p2 },
];

此时三角面的法线并不一定是朝外的。

3.用orientAllOutward(raw, interior) 方法矫正所有三角面的法线朝向。

javascript 复制代码
function orientFaceOutward(face: Face3, interior: Point3): Face3 {
    if (orient(face.a, face.b, face.c, interior) > 0) {
        return { a: face.a, b: face.c, c: face.b };
    }
    return face;
}

function orientAllOutward(faces: Face3[], interior: Point3): Face3[] {
    return faces.map((f) => orientFaceOutward(f, interior));
}

在orientFaceOutward 方法里可以看到,若质心与三角形构成的行列式大于0,则表示三角形的法线朝内了。此时,需要将三角面中的任意2点置换一下。

4-4-增量构造多面体

遍历四面体之外的其它顶点,让每个顶点与四面体增量构造成多面体。

ini 复制代码
for (const eye of initial.rest) {
    const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
    if (visible.length === 0) continue;

    const horizon = findHorizon(faces, visible);
    if (horizon.length < 3) continue;

    const visibleKeys = new Set(visible.map(faceKey));
    const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
    const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
    const added = facesFromHorizon(horizon, eye, interior);
    faces = [...kept, ...added];
}

eye 是四面体之外的其它顶点。之所以叫eye ,是因为它要看向四面体,将四面体分成可见面和不可见面。

1.寻找可见面。

ini 复制代码
const visible = faces.filter((f) => orient(f.a, f.b, f.c, eye) > EPS);
if (visible.length === 0) continue;

可见面的判断原理就是eye 与三角面构成的行列式的有向性。

若没有找到可见面,则说明eye 在四面体中,要continue,继续遍历其它的点。

2.寻找可见面和不可见面的边界线。

ini 复制代码
const horizon = findHorizon(faces, visible);
if (horizon.length < 3) continue;

findHorizon 是寻找边界线的方法。

ini 复制代码
function findHorizon(faces: Face3[], visible: Face3[]): HorizonEdge[] {
    const visibleSet = new Set(visible.map(faceKey));

    type EdgeRec = {
        from: Point3;
        to: Point3;
        visible: boolean;
    };

    const byEdge = new Map<string, EdgeRec[]>();

    for (const f of faces) {
        const vis = visibleSet.has(faceKey(f));
        const directed: Array<[Point3, Point3]> = [
            [f.a, f.b],
            [f.b, f.c],
            [f.c, f.a],
        ];
        for (const [from, to] of directed) {
            const key = edgeKey(from, to);
            const list = byEdge.get(key) ?? [];
            list.push({ from, to, visible: vis });
            byEdge.set(key, list);
        }
    }

    const horizon: HorizonEdge[] = [];
    for (const recs of byEdge.values()) {
        if (recs.length !== 2) continue;
        const [r0, r1] = recs;
        if (r0!.visible === r1!.visible) continue;
        const visRec = r0!.visible ? r0! : r1!;
        horizon.push({ from: visRec.from, to: visRec.to });
    }
    return horizon;
}

解释一下findHorizon 方法的逻辑。

① 生成可见面的key集合

go 复制代码
const visibleSet = new Set(visible.map(faceKey));

面的key 是按照顶点的位置排序算出的。所以只要面的顶点位置一样,其key 就一样。

javascript 复制代码
function faceKey(f: Face3): string {
    const ids = [f.a.id, f.b.id, f.c.id].sort((x, y) => x - y);
    return ids.join('-');
}

②收集多面体中每条边的数据。

ini 复制代码
const byEdge = new Map<string, EdgeRec[]>();
for (const f of faces) {
    const vis = visibleSet.has(faceKey(f));
    const directed: Array<[Point3, Point3]> = [
        [f.a, f.b],
        [f.b, f.c],
        [f.c, f.a],
    ];
    for (const [from, to] of directed) {
        const key = edgeKey(from, to);
        const list = byEdge.get(key) ?? [];
        list.push({ from, to, visible: vis });
        byEdge.set(key, list);
    }
}

在上面的代码中,先遍历多面体的面,获取每个面的可见性,并声明面的边集合;然后遍历边集合,以边为key,向byEdge 中写入集合数据。

byEdge 的数据结构:

css 复制代码
{
	edge1 key:[
		{from, to, visible:相邻面A 的可见性},
		{from, to, visible:相邻面B 的可见性}
	],
	edge2 key:[
		{from, to, visible:相邻面C 的可见性},
		{from, to, visible:相邻面D 的可见性}
	],
	...
}

edgeKey 方法可以获取边的唯一key。

javascript 复制代码
function edgeKey(u: Point3, v: Point3): string {
    return u.id < v.id ? `${u.id}_${v.id}` : `${v.id}_${u.id}`;
}

edgeKey 是根据顶点id 做的排序,所以不同面的公共边的key 值相同。

byEdge 可以记录每条边的相邻面的可见性。

③ 根据byEdge 数据找到可见面和不可见面的边界线。

一段边界线有2个相邻面,一个面可见,一个面不可见。

ini 复制代码
const horizon: HorizonEdge[] = [];
for (const recs of byEdge.values()) {
    if (recs.length !== 2) continue;
    const [r0, r1] = recs;
    if (r0!.visible === r1!.visible) continue;
    const visRec = r0!.visible ? r0! : r1!;
    horizon.push({ from: visRec.from, to: visRec.to });
}
return horizon;

3.根据eye 和边界线构成新的面。

ini 复制代码
const visibleKeys = new Set(visible.map(faceKey));
const kept = faces.filter((f) => !visibleKeys.has(faceKey(f)));
const interior = centroidOfPoints([...uniqueVertices(kept), eye]);
const added = facesFromHorizon(horizon, eye, interior);

visibleKeys 是可见面的key 集合。

根据visibleKeys 从faces 中剔除可见面,剩下的就是不可见面kept。

根据不可见面和eye 可以算出新的凸多面体的质心interior。

使用facesFromHorizon方法,根据质心、边界线和eye 生成新的法线朝外的三角面added。

less 复制代码
function facesFromHorizon(
    horizon: HorizonEdge[],
    eye: Point3,
    interior: Point3,
): Face3[] {
    return horizon.map((e) =>
        orientFaceOutward({ a: e.from, b: e.to, c: eye }, interior),
    );
}

新的三角面就是遍历边界线horizon,连接eye生成的。当然,orientFaceOutward 方法还会根据interior 使面的法线朝外。

4.将不可见面和新面合成新的多面体。

ini 复制代码
faces = [...kept, ...added];

4-5-确保法线朝外

为了避免数据误差,可以在再统一执行一遍orientAllOutward 方法,确保法线朝外。

ini 复制代码
faces = orientAllOutward(faces, centroidOfPoints(uniqueVertices(faces)));
return faces

总结

这一章我们使用Andrew's Monotone Chain 算法计算了二维凸包,使用增量构造的方法计算了三维凸包。

这两种凸包算法都是非常经典、常用的,还有许多其它的凸包算法,我就不再一一赘述了,大家可以按需拓展。

在实际工作中,凸包算法的用处很多,比如简化模型,制作模型的包围体,用于碰撞检测等。

相关推荐
徐礼昭|商派软件市场负责人1 小时前
AI Agent 蜂群协作与经验基因化:从单体执行到自组织系统的工程路径
大数据·人工智能·算法·智能体·多agent
厚皮龙1 小时前
相机标定_角点检测_重投影误差_总结
人工智能
u0103055271 小时前
智能素材归档检索Agent实战方案腾讯元气6G参赛
人工智能
大模型momo1 小时前
AI 编程工程化:从 Prompt 到 Harness
人工智能·prompt·编程·agent
小白说大模型1 小时前
Python 工程化设计模式:AI 项目中的异步流水线与策略模式实战
人工智能·python·设计模式
来者皆善1 小时前
【位RAW图像处理五】任意位深位图像的中值模糊快速实现及其应用。
图像处理·人工智能·算法
happyprince1 小时前
第三观 · 深刻不忘观 —— AngelSpec 哲学与升华
人工智能·算法
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(二十八):顶栏统一战争——从1个页面异常到31个页面全量对齐
数据库·人工智能·uni-app
Kevinten101 小时前
OpenOctopus:领域原生 AI 家庭助手系统,Summon 功能将实体转化为智能 Agent
人工智能·ai·开源·开发工具·智能体