molstar
Version:
A comprehensive macromolecular library.
203 lines (202 loc) • 6.99 kB
JavaScript
"use strict";
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.convexHull = convexHull;
const vec3_1 = require("../linear-algebra/3d/vec3.js");
/**
* Incremental 3D convex hull for small point sets (typically 4–12 points).
*
* Returns triangle indices with outward-facing normals.
* Degenerate cases (coplanar/collinear points) are handled gracefully:
* - < 4 points or all coplanar → returns empty hull (no triangles)
* - Duplicate points are tolerated; the resulting hull is still valid
*/
const EPSILON = 1e-8;
const _ab = (0, vec3_1.Vec3)();
const _ac = (0, vec3_1.Vec3)();
const _normal = (0, vec3_1.Vec3)();
const _ap = (0, vec3_1.Vec3)();
/**
* Compute 3D convex hull of a set of points.
* @param positions Array of Vec3 positions
* @returns Triangle indices with outward-facing normals, or empty if degenerate
*/
function convexHull(positions) {
const n = positions.length;
if (n < 4) {
return { indices: [] };
}
// Find initial tetrahedron
const tet = findInitialTetrahedron(positions);
if (!tet) {
return { indices: [] };
}
const [i0, i1, i2, i3] = tet;
// Faces are stored as triplets of indices into `positions`.
// Each face's vertices must be ordered so the outward normal = cross(v1-v0, v2-v0).
// We orient the initial tetrahedron so all faces point outward.
// Check orientation of first face relative to 4th point
vec3_1.Vec3.sub(_ab, positions[i1], positions[i0]);
vec3_1.Vec3.sub(_ac, positions[i2], positions[i0]);
vec3_1.Vec3.cross(_normal, _ab, _ac);
vec3_1.Vec3.sub(_ap, positions[i3], positions[i0]);
const dot = vec3_1.Vec3.dot(_normal, _ap);
// Faces of tetrahedron. If the 4th point is on the positive side of face (i0,i1,i2),
// we need to flip that face.
let faces;
if (dot > 0) {
// i3 is on positive side of (i0,i1,i2) → flip first face
faces = [
[i0, i2, i1],
[i0, i1, i3],
[i1, i2, i3],
[i0, i3, i2],
];
}
else {
faces = [
[i0, i1, i2],
[i0, i3, i1],
[i1, i3, i2],
[i0, i2, i3],
];
}
// Incrementally add each remaining point
for (let pi = 0; pi < n; ++pi) {
if (pi === i0 || pi === i1 || pi === i2 || pi === i3)
continue;
const p = positions[pi];
// Find visible faces (point is on positive side of face)
const visible = [];
let anyVisible = false;
for (let fi = 0; fi < faces.length; fi++) {
const face = faces[fi];
if (isFaceVisibleFrom(positions, face, p)) {
visible.push(true);
anyVisible = true;
}
else {
visible.push(false);
}
}
if (!anyVisible)
continue; // Point is inside current hull
// Find horizon edges: edges shared between a visible and non-visible face.
// An edge [a,b] in a visible face that also appears as [b,a] in a non-visible face is a horizon edge.
const horizon = [];
for (let fi = 0; fi < faces.length; fi++) {
if (!visible[fi])
continue;
const face = faces[fi];
for (let ei = 0; ei < 3; ei++) {
const a = face[ei];
const b = face[(ei + 1) % 3];
// Check if the reverse edge (b,a) belongs to a non-visible face
let isHorizon = false;
for (let fj = 0; fj < faces.length; fj++) {
if (visible[fj])
continue;
const other = faces[fj];
for (let ej = 0; ej < 3; ej++) {
if (other[ej] === b && other[(ej + 1) % 3] === a) {
isHorizon = true;
break;
}
}
if (isHorizon)
break;
}
if (isHorizon) {
horizon.push([a, b]);
}
}
}
// Remove visible faces
const newFaces = [];
for (let fi = 0; fi < faces.length; fi++) {
if (!visible[fi]) {
newFaces.push(faces[fi]);
}
}
// Create new faces from horizon edges to the new point
for (const [a, b] of horizon) {
newFaces.push([a, b, pi]);
}
faces = newFaces;
}
// Flatten faces into indices
const indices = [];
for (const face of faces) {
indices.push(face[0], face[1], face[2]);
}
return { indices };
}
function isFaceVisibleFrom(positions, face, point) {
const a = positions[face[0]];
const b = positions[face[1]];
const c = positions[face[2]];
vec3_1.Vec3.sub(_ab, b, a);
vec3_1.Vec3.sub(_ac, c, a);
vec3_1.Vec3.cross(_normal, _ab, _ac);
vec3_1.Vec3.sub(_ap, point, a);
return vec3_1.Vec3.dot(_normal, _ap) > EPSILON;
}
/**
* Find 4 non-coplanar points for the initial tetrahedron.
* Returns indices [i0, i1, i2, i3] or null if all points are coplanar.
*/
function findInitialTetrahedron(positions) {
const n = positions.length;
// Find two distinct points
const i0 = 0;
let i1 = -1;
for (let i = 1; i < n; i++) {
if (vec3_1.Vec3.distance(positions[i0], positions[i]) > EPSILON) {
i1 = i;
break;
}
}
if (i1 < 0)
return null;
// Find a third point not collinear with the first two
let i2 = -1;
let maxArea = 0;
for (let i = 0; i < n; i++) {
if (i === i0 || i === i1)
continue;
vec3_1.Vec3.sub(_ab, positions[i1], positions[i0]);
vec3_1.Vec3.sub(_ac, positions[i], positions[i0]);
vec3_1.Vec3.cross(_normal, _ab, _ac);
const area = vec3_1.Vec3.magnitude(_normal);
if (area > maxArea) {
maxArea = area;
i2 = i;
}
}
if (i2 < 0 || maxArea < EPSILON)
return null;
// Find a fourth point not coplanar with the first three
let i3 = -1;
let maxVol = 0;
vec3_1.Vec3.sub(_ab, positions[i1], positions[i0]);
vec3_1.Vec3.sub(_ac, positions[i2], positions[i0]);
vec3_1.Vec3.cross(_normal, _ab, _ac);
vec3_1.Vec3.normalize(_normal, _normal);
for (let i = 0; i < n; i++) {
if (i === i0 || i === i1 || i === i2)
continue;
vec3_1.Vec3.sub(_ap, positions[i], positions[i0]);
const vol = Math.abs(vec3_1.Vec3.dot(_normal, _ap));
if (vol > maxVol) {
maxVol = vol;
i3 = i;
}
}
if (i3 < 0 || maxVol < EPSILON)
return null;
return [i0, i1, i2, i3];
}