Files
GDevelop/Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.ts
D8H 361f6b3a87 Add experimental "navmesh-based" pathfinding behavior for 2D and 3D (#8943)
- This pathfinding works in 2D and 3D is more flexible than the "grid-based" existing pathfinding.
- For 3D models, you can optionally choose, like for 3D physics, to follow the exact mesh of the 3D model for pathfinding - allowing characters and objects to move on or around the 3D model naturally.
- It handles "crowds" of characters, which can all go to a destination avoiding each others.
2026-08-11 11:11:44 +02:00

1318 lines
43 KiB
TypeScript

namespace gdjs {
export interface RuntimeInstanceContainer {
navMeshObstaclesManager: gdjs.NavMeshObstaclesManager;
}
const cubePositions = [
0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5,
-0.5, -0.5, -0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5, 0.5,
];
const cubeIndices = [
0, 1, 2, 1, 3, 2, 4, 5, 6, 5, 7, 6, 4, 6, 2, 6, 8, 2, 7, 5, 1, 5, 3, 1, 6,
7, 8, 7, 1, 8, 2, 3, 4, 3, 5, 4,
];
const isModel3D = (
object: gdjs.RuntimeObject
): object is gdjs.Model3DRuntimeObject => {
//@ts-ignore We are checking if the methods are present.
return object._modelResourceName;
};
/**
* NavMeshObstaclesManager manages the common objects shared by objects
* having a pathfinding behavior: In particular, the obstacles behaviors are
* required to declare themselves (see
* `NavMeshObstaclesManager.addObstacle`) to the manager of their
* associated container (see `NavMeshObstaclesManager.getManager`).
* @category Behaviors > NavMesh pathfinding
*/
export class NavMeshObstaclesManager {
obstacles = new Set<NavMeshObstacleRuntimeBehavior>();
characters = new Set<NavMeshCharacterRuntimeBehavior>();
is3D = false;
navMesh: RecastNav.NavMesh | null = null;
private navMeshConfig: Partial<RecastNav.SoloNavMeshGeneratorConfig> = {
cs: 10,
ch: 10,
walkableSlopeAngle: 50,
walkableHeight: 15,
detailSampleMaxError: 50,
walkableClimb: 2,
walkableRadius: 1,
};
cellDepth: float = 10;
walkableRadius: float = -1;
walkableDepth: float = 150;
stairHeightMax: float = 20;
speedScaleY: float = 1;
inverseSpeedScaleY: float = 1;
timeSinceLastNavMeshLastRebuild: float = 1;
isNavMeshDirty = true;
isFirstFrame = true;
crowd: RecastNav.Crowd | null = null;
hasStepped = false;
debuggerRenderer: gdjs.NavMeshDebuggerRenderer | null = null;
static readonly cellHeightFor2D = 5;
constructor(instanceContainer: gdjs.RuntimeInstanceContainer, sharedData) {
if (!sharedData) {
// It can happens when there is no object with the character behavior.
return;
}
this.navMeshConfig.cs = sharedData.cellSize;
this.cellDepth = sharedData.cellDepth;
this.navMeshConfig.detailSampleMaxError = sharedData.cellDepth * 5;
this.navMeshConfig.walkableSlopeAngle = sharedData.slopeMaxAngle;
this.stairHeightMax = sharedData.stairHeightMax;
this.walkableDepth = sharedData.walkableDepth;
this.walkableRadius = sharedData.walkableRadius;
this.speedScaleY =
sharedData.speedScaleY > 0 ? sharedData.speedScaleY : 1;
this.inverseSpeedScaleY = 1 / this.speedScaleY;
}
/**
* Get the obstacles manager of an instance container.
*/
static getManager(
instanceContainer: gdjs.RuntimeInstanceContainer
): gdjs.NavMeshObstaclesManager {
if (!instanceContainer.navMeshObstaclesManager) {
// Create the shared manager if necessary.
const initialData =
instanceContainer.getInitialSharedDataForBehavior('NavMeshCharacter');
instanceContainer.navMeshObstaclesManager =
new gdjs.NavMeshObstaclesManager(instanceContainer, initialData);
}
return instanceContainer.navMeshObstaclesManager;
}
step(timeDelta: float): void {
if (this.hasStepped) {
return;
}
this.hasStepped = true;
this.timeSinceLastNavMeshLastRebuild += timeDelta;
// Don't try to build the nav mesh before the events get a chance
// to build the level.
if (!this.isFirstFrame) {
this.rebuildNavMeshIfNeeded();
}
this.isFirstFrame = false;
if (!this.crowd) {
return;
}
for (const character of this.characters) {
character.teleportAgentToObjectIfNeeded();
}
this.crowd.update(timeDelta); //1 / 60, timeDelta, 8);
}
invalidateNavMesh(): void {
this.isNavMeshDirty = true;
}
rebuildNavMeshIfNeeded(): void {
if (!this.isNavMeshDirty || this.timeSinceLastNavMeshLastRebuild < 1) {
return;
}
this.timeSinceLastNavMeshLastRebuild = 0;
this.isNavMeshDirty = false;
const positions: Array<float> = [];
const indices: Array<integer> = [];
for (const obstacle of this.obstacles) {
const object = obstacle.owner;
if (gdjs.Base3DHandler.is3D(object)) {
if (isModel3D(object) && obstacle._shape === 'Mesh') {
this.addMeshFor(object, obstacle, positions, indices);
} else {
this.addBoxFor(object, positions, indices);
}
} else if (!this.is3D) {
this.addPolygonsFor(object, positions, indices);
}
}
if (!this.is3D && positions.length > 0) {
this.addGroundFor2D(positions, indices);
this.navMeshConfig.walkableClimb = 0.1;
this.navMeshConfig.walkableHeight = 2;
this.navMeshConfig.ch = gdjs.NavMeshObstaclesManager.cellHeightFor2D;
} else {
const walkableClimbMin =
this.navMeshConfig.walkableSlopeAngle &&
this.navMeshConfig.walkableSlopeAngle > 40
? 2
: 1;
this.navMeshConfig.walkableClimb = Math.max(
walkableClimbMin,
this.stairHeightMax / this.cellDepth
);
this.navMeshConfig.walkableHeight = this.walkableDepth / this.cellDepth;
this.navMeshConfig.ch = this.cellDepth;
}
let characterRadiusMax = 0;
for (const character of this.characters) {
characterRadiusMax = Math.max(
characterRadiusMax,
character.getRadius()
);
}
this.navMeshConfig.walkableRadius = this.navMeshConfig.cs
? (this.walkableRadius < 0 ? characterRadiusMax : this.walkableRadius) /
this.navMeshConfig.cs
: 0;
const result = RecastNav.generateSoloNavMesh(
positions,
indices,
this.navMeshConfig
);
if (result.success) {
const oldNavMesh = this.navMesh;
const oldCrowd = this.crowd;
this.navMesh = result.navMesh;
this.crowd = new RecastNav.Crowd(this.navMesh, {
maxAgents: this.characters.size + 100,
maxAgentRadius: characterRadiusMax,
});
for (const character of this.characters) {
this.buildCharacterAgent(character);
}
if (this.debuggerRenderer) {
this.debuggerRenderer.renderFor3D();
}
if (oldNavMesh) {
oldNavMesh.destroy();
}
if (oldCrowd) {
oldCrowd.destroy();
}
}
}
setDebugDrawEnabled(
instanceContainer: gdjs.RuntimeInstanceContainer,
enableDebugDraw: boolean
): void {
if (!this.debuggerRenderer) {
this.debuggerRenderer = new gdjs.NavMeshDebuggerRenderer(this);
}
this.debuggerRenderer.setEnabled(instanceContainer, enableDebugDraw);
}
private addGroundFor2D(
positions: Array<float>,
indices: Array<integer>
): void {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let index = 0; index + 2 < positions.length; index += 3) {
const x = positions[index];
const y = positions[index + 2];
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
const width = maxX - minX;
const height = (maxY - minY) * this.inverseSpeedScaleY;
const centerX = (maxX + minX) / 2;
const centerY = ((maxY + minY) / 2) * this.inverseSpeedScaleY;
const indicesOffset = Math.round(positions.length / 3);
for (let index = 0; index + 2 < cubePositions.length; index = index + 3) {
let x = cubePositions[index];
let y = cubePositions[index + 2];
let z = cubePositions[index + 1];
x *= width;
y *= height;
z *= gdjs.NavMeshObstaclesManager.cellHeightFor2D - 1;
x += centerX;
y += centerY;
z -= gdjs.NavMeshObstaclesManager.cellHeightFor2D / 2;
// Y is the top for Recast
positions.push(x, z, y);
}
for (const vertexIndex of cubeIndices) {
indices.push(vertexIndex + indicesOffset);
}
}
private addPolygonsFor(
object: gdjs.RuntimeObject,
positions: Array<float>,
indices: Array<integer>
): void {
const vertexFlags: Array<boolean> = [];
for (const hitBox of object.getHitBoxes()) {
let indicesOffset = Math.round(positions.length / 3);
const vertices: Array<Point> = hitBox.vertices.map(([x, y]) => ({
x,
y,
}));
if (
getSignedAreaX2(
vertices[0].x,
vertices[0].y,
vertices[1].x,
vertices[1].y,
vertices[2].x,
vertices[2].y
) > 0
) {
vertices.reverse();
}
const depthMax = gdjs.NavMeshObstaclesManager.cellHeightFor2D - 1;
const depthMin = -1;
triangulate(
vertices,
vertexFlags,
(p1: Point, p2: Point, p3: Point) => {
// Top
positions.push(p1.x, depthMax, p1.y * this.inverseSpeedScaleY);
positions.push(p2.x, depthMax, p2.y * this.inverseSpeedScaleY);
positions.push(p3.x, depthMax, p3.y * this.inverseSpeedScaleY);
indices.push(
indicesOffset + 0,
indicesOffset + 1,
indicesOffset + 2
);
// Bottom
positions.push(p1.x, depthMin, p1.y * this.inverseSpeedScaleY);
positions.push(p2.x, depthMin, p2.y * this.inverseSpeedScaleY);
positions.push(p3.x, depthMin, p3.y * this.inverseSpeedScaleY);
indices.push(
indicesOffset + 3,
indicesOffset + 5,
indicesOffset + 4
);
indicesOffset += 6;
}
);
for (let index = 0; index < vertices.length; index++) {
const vertex = vertices[index];
// Side
positions.push(
vertex.x,
depthMax,
vertex.y * this.inverseSpeedScaleY
);
positions.push(
vertex.x,
depthMin,
vertex.y * this.inverseSpeedScaleY
);
indices.push(indicesOffset + 0, indicesOffset + 1, indicesOffset + 2);
indices.push(indicesOffset + 3, indicesOffset + 2, indicesOffset + 1);
indicesOffset += 2;
}
const vertex = vertices[0];
positions.push(vertex.x, depthMax, vertex.y * this.inverseSpeedScaleY);
positions.push(vertex.x, depthMin, vertex.y * this.inverseSpeedScaleY);
}
}
private addBoxFor(
object: gdjs.AbstractRuntimeObject3D,
positions: Array<float>,
indices: Array<integer>
): void {
const point = new THREE.Vector3();
const euler = new THREE.Euler();
euler.order = 'ZYX';
const indicesOffset = Math.round(positions.length / 3);
for (let index = 0; index + 2 < cubePositions.length; index = index + 3) {
let x = cubePositions[index];
let y = cubePositions[index + 2];
let z = cubePositions[index + 1];
x *= object.getWidth();
y *= object.getHeight();
z *= object.getDepth();
point.set(x, y, z);
euler.set(
gdjs.toRad(object.getRotationX()),
gdjs.toRad(object.getRotationY()),
gdjs.toRad(object.getAngle())
);
point.applyEuler(euler);
x = object.getCenterXInScene() + point.x;
y = object.getCenterYInScene() + point.y;
z = object.getCenterZInScene() + point.z;
// Y is the top for Recast
positions.push(x, z, y);
}
for (const vertexIndex of cubeIndices) {
indices.push(vertexIndex + indicesOffset);
}
}
private addMeshFor(
model3DRuntimeObject: gdjs.Model3DRuntimeObject,
obstacle: NavMeshObstacleRuntimeBehavior,
positions: Array<float>,
indices: Array<integer>
): void {
const originalModel = model3DRuntimeObject
.getInstanceContainer()
.getGame()
.getModel3DManager()
.getModel(
obstacle._meshShapeResourceName ||
model3DRuntimeObject._modelResourceName ||
''
);
const modelInCube = new THREE.Group();
modelInCube.rotation.order = 'ZYX';
const root = THREE_ADDONS.SkeletonUtils.clone(originalModel.scene);
modelInCube.add(root);
const data = model3DRuntimeObject._data.content;
model3DRuntimeObject._renderer.stretchModelIntoUnitaryCube(
modelInCube,
data.rotationX,
data.rotationY,
data.rotationZ
);
const threeObject = new THREE.Group();
threeObject.rotation.order = 'ZYX';
threeObject.add(modelInCube);
const object = model3DRuntimeObject;
const width = object.getWidth();
const height = object.getHeight();
const depth = object.getDepth();
threeObject.scale.set(
object.isFlippedX() ? -width : width,
object.isFlippedY() ? -height : height,
object.isFlippedZ() ? -depth : depth
);
threeObject.position.set(
object.getCenterXInScene(),
object.getCenterYInScene(),
object.getCenterZInScene()
);
threeObject.rotation.set(
gdjs.toRad(object.getRotationX()),
gdjs.toRad(object.getRotationY()),
gdjs.toRad(object.getAngle())
);
threeObject.updateMatrixWorld();
// For indexed triangles
const vector3 = new THREE.Vector3();
threeObject.traverse((object3d) => {
const mesh = object3d as THREE.Mesh;
if (!mesh.isMesh) {
return;
}
const indicesOffset = Math.round(positions.length / 3);
const positionAttribute = mesh.geometry.getAttribute('position');
object3d.getWorldScale(vector3);
// Negate it because we swap Y and Z.
const shouldTrianglesBeFlipped = !(
vector3.x * vector3.y * vector3.z <
0
);
const index = mesh.geometry.getIndex();
if (index) {
for (let i = 0; i < positionAttribute.count; i++) {
vector3.fromBufferAttribute(positionAttribute, i);
object3d.localToWorld(vector3);
positions.push(vector3.x, vector3.z, vector3.y);
}
for (let i = 0; i < index.count; i += 3) {
indices.push(
indicesOffset + index.getX(shouldTrianglesBeFlipped ? i + 1 : i),
indicesOffset + index.getX(shouldTrianglesBeFlipped ? i : i + 1),
indicesOffset + index.getX(i + 2)
);
}
} else {
for (let i = 0; i < positionAttribute.count; i += 3) {
vector3.fromBufferAttribute(positionAttribute, i);
object3d.localToWorld(vector3);
positions.push(vector3.x, vector3.z, vector3.y);
vector3.fromBufferAttribute(positionAttribute, i + 1);
object3d.localToWorld(vector3);
positions.push(vector3.x, vector3.z, vector3.y);
vector3.fromBufferAttribute(positionAttribute, i + 2);
object3d.localToWorld(vector3);
positions.push(vector3.x, vector3.z, vector3.y);
indices.push(
indicesOffset + (shouldTrianglesBeFlipped ? i + 1 : i),
indicesOffset + (shouldTrianglesBeFlipped ? i : i + 1),
indicesOffset + i + 2
);
}
}
});
}
buildCharacterAgent(character: NavMeshCharacterRuntimeBehavior): void {
if (!this.navMesh || !this.crowd) {
return;
}
const owner = character.owner;
const navMeshQuery = new RecastNav.NavMeshQuery(this.navMesh);
const { success: hasFoundOrigin, point: origin } =
navMeshQuery.findClosestPoint(
{
x: owner.getX(),
y: gdjs.Base3DHandler.is3D(owner) ? owner.getZ() : 0,
z: this.is3D
? owner.getY()
: owner.getY() * this.inverseSpeedScaleY,
},
{
halfExtents: {
x: 100,
y: this.is3D ? 100 : gdjs.NavMeshObstaclesManager.cellHeightFor2D,
z: 100,
},
}
);
navMeshQuery.destroy();
if (!hasFoundOrigin) {
character._agent = null;
return;
}
character._crowdAgentParams.radius = character.getRadius();
character._crowdAgentParams.height = gdjs.Base3DHandler.is3D(owner)
? owner.getDepth()
: 1;
const agent = this.crowd
? this.crowd.addAgent(origin, character._crowdAgentParams)
: null;
const oldAgent = character._agent;
character._agent = agent;
if (agent) {
if (character.pathFound() && !character.destinationReached()) {
character.moveTo(
character.getDestinationX(),
character.getDestinationY(),
character.getDestinationZ()
);
}
if (oldAgent) {
agent.raw.set_vel(0, oldAgent.raw.get_vel(0));
agent.raw.set_vel(1, oldAgent.raw.get_vel(1));
agent.raw.set_vel(2, oldAgent.raw.get_vel(2));
}
}
}
/**
* Add a obstacle to the list of existing obstacles.
*/
addObstacle(obstacle: NavMeshObstacleRuntimeBehavior): void {
this.obstacles.add(obstacle);
if (gdjs.Base3DHandler.is3D(obstacle.owner)) {
this.is3D = true;
}
this.invalidateNavMesh();
}
/**
* Remove a obstacle from the list of existing obstacles. Be sure that the obstacle was
* added before.
*/
removeObstacle(obstacle: NavMeshObstacleRuntimeBehavior): void {
this.obstacles.delete(obstacle);
this.invalidateNavMesh();
}
/**
* Add a character to the list of existing characters.
*/
addCharacter(character: NavMeshCharacterRuntimeBehavior): void {
if (this.characters.has(character)) {
return;
}
this.characters.add(character);
if (gdjs.Base3DHandler.is3D(character.owner)) {
this.is3D = true;
}
this.buildCharacterAgent(character);
}
/**
* Remove a character from the list of existing characters.
*/
removeCharacter(character: NavMeshCharacterRuntimeBehavior): void {
this.characters.delete(character);
if (this.crowd && character._agent) {
this.crowd.removeAgent(character._agent);
character._agent = null;
}
}
}
/** @category Behaviors > NavMesh pathfinding */
export namespace NavMeshObstaclesManager {
/**
* Enable or disable the debug draw.
* @param instanceContainer The current container.
* @param enableDebugDraw true to enable the debug draw, false to disable it.
*/
export const enableDebugDraw = function (
instanceContainer: gdjs.RuntimeInstanceContainer,
enableDebugDraw: boolean
): void {
if (enableDebugDraw) {
// Make sure the debug layer will be displayed.
// We never set it back to false because users might have enabled hit-box
// debug draw with the other action.
instanceContainer._debugDrawEnabled = true;
}
const manager =
gdjs.NavMeshObstaclesManager.getManager(instanceContainer);
manager.setDebugDrawEnabled(instanceContainer, enableDebugDraw);
};
}
/**
* NavMeshObstacleRuntimeBehavior represents a behavior allowing objects to be
* considered as a obstacle by objects having Character Behavior.
* @category Behaviors > NavMesh pathfinding
*/
export class NavMeshObstacleRuntimeBehavior extends gdjs.RuntimeBehavior {
_shape: string;
_meshShapeResourceName: string;
_oldX: float = 0;
_oldY: float = 0;
_oldZ: float = 0;
_oldWidth: float = 0;
_oldHeight: float = 0;
_oldDepth: float = 0;
_oldRotationX: float = 0;
_oldRotationY: float = 0;
_oldRotationZ: float = 0;
_manager: NavMeshObstaclesManager;
_registeredInManager: boolean = false;
constructor(
instanceContainer: gdjs.RuntimeInstanceContainer,
behaviorData,
owner: gdjs.RuntimeObject
) {
super(instanceContainer, behaviorData, owner);
this._shape = behaviorData.shape;
this._meshShapeResourceName = behaviorData.meshShapeResourceName;
this._manager = NavMeshObstaclesManager.getManager(instanceContainer);
//Note that we can't use getX(), getWidth()... of owner here:
//The owner is not yet fully constructed.
}
override applyBehaviorOverriding(behaviorData): boolean {
if (behaviorData.shape !== undefined) {
this._shape = behaviorData.shape;
this._manager.invalidateNavMesh();
}
if (behaviorData.meshShapeResourceName !== undefined) {
this._meshShapeResourceName = behaviorData.meshShapeResourceName;
this._manager.invalidateNavMesh();
}
return true;
}
override onDestroy() {
if (this._registeredInManager) {
this._manager.removeObstacle(this);
}
}
override doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) {
if (!this.activated() && this._registeredInManager) {
this._manager.removeObstacle(this);
this._registeredInManager = false;
} else {
if (this.activated() && !this._registeredInManager) {
this._manager.addObstacle(this);
this._registeredInManager = true;
}
}
const newX = this.owner.getX();
const newY = this.owner.getY();
const newWidth = this.owner.getWidth();
const newHeight = this.owner.getHeight();
const newRotationZ = this.owner.getAngle();
let newZ = 0;
let newDepth = 0;
let newRotationX = 0;
let newRotationY = 0;
if (gdjs.Base3DHandler.is3D(this.owner)) {
newZ = this.owner.getZ();
newDepth = this.owner.getDepth();
newRotationX = this.owner.getRotationX();
newRotationY = this.owner.getRotationY();
}
if (
this._oldX !== newX ||
this._oldY !== newY ||
this._oldZ !== newZ ||
this._oldWidth !== newWidth ||
this._oldHeight !== newHeight ||
this._oldDepth !== newDepth ||
this._oldRotationX !== newRotationX ||
this._oldRotationY !== newRotationY ||
this._oldRotationZ !== newRotationZ
) {
if (this._registeredInManager) {
this._manager.invalidateNavMesh();
}
this._oldX = newX;
this._oldY = newY;
this._oldZ = newZ;
this._oldWidth = newWidth;
this._oldHeight = newHeight;
this._oldDepth = newDepth;
this._oldRotationX = newRotationX;
this._oldRotationY = newRotationY;
this._oldRotationZ = newRotationZ;
}
}
override doStepPostEvents(
instanceContainer: gdjs.RuntimeInstanceContainer
) {}
override onActivate() {
if (this._registeredInManager) {
return;
}
this._manager.addObstacle(this);
this._registeredInManager = true;
}
override onDeActivate() {
if (!this._registeredInManager) {
return;
}
this._manager.removeObstacle(this);
this._registeredInManager = false;
}
}
gdjs.registerBehavior(
'NavMeshPathfinding::NavMeshObstacleBehavior',
gdjs.NavMeshObstacleRuntimeBehavior
);
// The following code is inspired from Critterai
// https://github.com/stevefsp/critterai
interface Point {
x: number;
y: number;
}
/**
* Attempts to triangulate a polygon.
*
* @param vertices the polygon to be triangulate.
* The content is manipulated during the operation
* and it will be left in an undefined state at the end of
* the operation.
* @param vertexFlags only used internally
* @param outTriangles is called for each triangle derived
* from the original polygon.
*/
function triangulate(
vertices: Array<Point>,
vertexFlags: Array<boolean>,
outTriangles: (p1: Point, p2: Point, p3: Point) => void
): void {
// Terminology, concepts and such:
//
// This algorithm loops around the edges of a polygon looking for
// new internal edges to add that will partition the polygon into a
// new valid triangle internal to the starting polygon. During each
// iteration the shortest potential new edge is selected to form that
// iteration's new triangle.
//
// Triangles will only be formed if a single new edge will create
// a triangle. Two new edges will never be added during a single
// iteration. This means that the triangulated portions of the
// original polygon will only contain triangles and the only
// non-triangle polygon will exist in the untriangulated portion
// of the original polygon.
//
// "Partition edge" refers to a potential new edge that will form a
// new valid triangle.
//
// "Center" vertex refers to the vertex in a potential new triangle
// which, if the triangle is formed, will be external to the
// remaining untriangulated portion of the polygon. Since it
// is now external to the polygon, it can't be used to form any
// new triangles.
//
// Some documentation refers to "iPlus2" even though the variable is
// not in scope or does not exist for that section of code. For
// documentation purposes, iPlus2 refers to the 2nd vertex after the
// primary vertex.
// E.g.: i, iPlus1, and iPlus2.
//
// Visualizations: http://www.critterai.org/projects/nmgen_study/polygen.html#triangulation
// Loop through all vertices, flagging all indices that represent
// a center vertex of a valid new triangle.
vertexFlags.length = vertices.length;
for (let i = 0; i < vertices.length; i++) {
const iPlus1 = (i + 1) % vertices.length;
const iPlus2 = (i + 2) % vertices.length;
// A triangle formed by i, iPlus1, and iPlus2 will result
// in a valid internal triangle.
// Flag the center vertex (iPlus1) to indicate a valid triangle
// location.
vertexFlags[iPlus1] = isValidPartition(i, iPlus2, vertices);
}
// Loop through the vertices creating triangles. When there is only a
// single triangle left, the operation is complete.
//
// When a valid triangle is formed, remove its center vertex. So for
// each loop, a single vertex will be removed.
//
// At the start of each iteration the indices list is in the following
// state:
// - Represents a simple polygon representing the un-triangulated
// portion of the original polygon.
// - All valid center vertices are flagged.
while (vertices.length > 3) {
// Find the shortest new valid edge.
// NOTE: i and iPlus1 are defined in two different scopes in
// this section. So be careful.
// Loop through all indices in the remaining polygon.
let minLengthSq = Number.MAX_VALUE;
let minLengthSqVertexIndex = -1;
for (let i = 0; i < vertices.length; i++) {
if (vertexFlags[(i + 1) % vertices.length]) {
// Indices i, iPlus1, and iPlus2 are known to form a
// valid triangle.
const vert = vertices[i];
const vertPlus2 = vertices[(i + 2) % vertices.length];
// Determine the length of the partition edge.
// (i -> iPlus2)
const deltaX = vertPlus2.x - vert.x;
const deltaY = vertPlus2.y - vert.y;
const lengthSq = deltaX * deltaX + deltaY * deltaY;
if (lengthSq < minLengthSq) {
minLengthSq = lengthSq;
minLengthSqVertexIndex = i;
}
}
}
if (minLengthSqVertexIndex === -1)
// Could not find a new triangle. Triangulation failed.
// This happens if there are three or more vertices
// left, but none of them are flagged as being a
// potential center vertex.
return;
let i = minLengthSqVertexIndex;
let iPlus1 = (i + 1) % vertices.length;
// Add the new triangle to the output.
outTriangles(
vertices[i],
vertices[iPlus1],
vertices[(i + 2) % vertices.length]
);
// iPlus1, the "center" vert in the new triangle, is now external
// to the untriangulated portion of the polygon. Remove it from
// the vertices list since it cannot be a member of any new
// triangles.
vertices.splice(iPlus1, 1);
vertexFlags.splice(iPlus1, 1);
if (iPlus1 === 0 || iPlus1 >= vertices.length) {
// The vertex removal has invalidated iPlus1 and/or i. So
// force a wrap, fixing the indices so they reference the
// correct indices again. This only occurs when the new
// triangle is formed across the wrap location of the polygon.
// Case 1: i = 14, iPlus1 = 15, iPlus2 = 0
// Case 2: i = 15, iPlus1 = 0, iPlus2 = 1;
i = vertices.length - 1;
iPlus1 = 0;
}
// At this point i and iPlus1 refer to the two indices from a
// successful triangulation that will be part of another new
// triangle. We now need to re-check these indices to see if they
// can now be the center index in a potential new partition.
vertexFlags[i] = isValidPartition(
(i - 1 + vertices.length) % vertices.length,
iPlus1,
vertices
);
vertexFlags[iPlus1] = isValidPartition(
i,
(i + 2) % vertices.length,
vertices
);
}
// Only 3 vertices remain.
// Add their triangle to the output list.
outTriangles(vertices[0], vertices[1], vertices[2]);
}
/**
* Check if the line segment formed by vertex A and vertex B will
* form a valid partition of the polygon.
*
* I.e. the line segment AB is internal to the polygon and will not
* cross existing line segments.
*
* Assumptions:
* - The vertices arguments define a valid simple polygon
* with vertices wrapped clockwise.
* - indexA != indexB
*
* Behavior is undefined if the arguments to not meet these
* assumptions
*
* @param indexA the index of the vertex that will form the segment AB.
* @param indexB the index of the vertex that will form the segment AB.
* @param vertices a polygon wrapped clockwise.
* @return true if the line segment formed by vertex A and vertex B will
* form a valid partition of the polygon.
*/
function isValidPartition(
indexA: integer,
indexB: integer,
vertices: Point[]
): boolean {
// First check whether the segment AB lies within the internal
// angle formed at A (this is the faster check).
// If it does, then perform the more costly check.
return (
liesWithinInternalAngle(indexA, indexB, vertices) &&
!hasIllegalEdgeIntersection(indexA, indexB, vertices)
);
}
/**
* Check if vertex B lies within the internal angle of the polygon
* at vertex A.
*
* Vertex B does not have to be within the polygon border. It just has
* be be within the area encompassed by the internal angle formed at
* vertex A.
*
* This operation is a fast way of determining whether a line segment
* can possibly form a valid polygon partition. If this test returns
* FALSE, then more expensive checks can be skipped.
*
* Visualizations: http://www.critterai.org/projects/nmgen_study/polygen.html#anglecheck
*
* Special case:
* FALSE is returned if vertex B lies directly on either of the rays
* cast from vertex A along its associated polygon edges. So the test
* on vertex B is exclusive of the polygon edges.
*
* Assumptions:
* - The vertices and indices arguments define a valid simple polygon
* with vertices wrapped clockwise.
* -indexA != indexB
*
* Behavior is undefined if the arguments to not meet these
* assumptions
*
* @param indexA the index of the vertex that will form the segment AB.
* @param indexB the index of the vertex that will form the segment AB.
* @param vertices a polygon wrapped clockwise.
* @return true if vertex B lies within the internal angle of
* the polygon at vertex A.
*/
function liesWithinInternalAngle(
indexA: integer,
indexB: integer,
vertices: Point[]
): boolean {
// Get pointers to the main vertices being tested.
const vertexA = vertices[indexA];
const vertexB = vertices[indexB];
// Get pointers to the vertices just before and just after vertA.
const vertexAMinus =
vertices[(indexA - 1 + vertices.length) % vertices.length];
const vertexAPlus = vertices[(indexA + 1) % vertices.length];
// First, find which of the two angles formed by the line segments
// AMinus->A->APlus is internal to (pointing towards) the polygon.
// Then test to see if B lies within the area formed by that angle.
// TRUE if A is left of or on line AMinus->APlus
if (
isLeftOrCollinear(
vertexA.x,
vertexA.y,
vertexAMinus.x,
vertexAMinus.y,
vertexAPlus.x,
vertexAPlus.y
)
)
// The angle internal to the polygon is <= 180 degrees
// (non-reflex angle).
// Test to see if B lies within this angle.
return (
isLeft(
// TRUE if B is left of line A->AMinus
vertexB.x,
vertexB.y,
vertexA.x,
vertexA.y,
vertexAMinus.x,
vertexAMinus.y
) &&
// TRUE if B is right of line A->APlus
isRight(
vertexB.x,
vertexB.y,
vertexA.x,
vertexA.y,
vertexAPlus.x,
vertexAPlus.y
)
);
// The angle internal to the polygon is > 180 degrees (reflex angle).
// Test to see if B lies within the external (<= 180 degree) angle and
// flip the result. (If B lies within the external angle, it can't
// lie within the internal angle)
return !(
// TRUE if B is left of or on line A->APlus
(
isLeftOrCollinear(
vertexB.x,
vertexB.y,
vertexA.x,
vertexA.y,
vertexAPlus.x,
vertexAPlus.y
) &&
// TRUE if B is right of or on line A->AMinus
isRightOrCollinear(
vertexB.x,
vertexB.y,
vertexA.x,
vertexA.y,
vertexAMinus.x,
vertexAMinus.y
)
)
);
}
/**
* Check if point P is to the left of line AB when looking
* from A to B.
* @param px The x-value of the point to test.
* @param py The y-value of the point to test.
* @param ax The x-value of the point (ax, ay) that is point A on line AB.
* @param ay The y-value of the point (ax, ay) that is point A on line AB.
* @param bx The x-value of the point (bx, by) that is point B on line AB.
* @param by The y-value of the point (bx, by) that is point B on line AB.
* @return TRUE if point P is to the left of line AB when looking
* from A to B.
*/
function isLeft(
px: integer,
py: integer,
ax: integer,
ay: integer,
bx: integer,
by: integer
): boolean {
return getSignedAreaX2(ax, ay, px, py, bx, by) < 0;
}
/**
* Check if point P is to the left of line AB when looking
* from A to B or is collinear with line AB.
* @param px The x-value of the point to test.
* @param py The y-value of the point to test.
* @param ax The x-value of the point (ax, ay) that is point A on line AB.
* @param ay The y-value of the point (ax, ay) that is point A on line AB.
* @param bx The x-value of the point (bx, by) that is point B on line AB.
* @param by The y-value of the point (bx, by) that is point B on line AB.
* @return TRUE if point P is to the left of line AB when looking
* from A to B, or is collinear with line AB.
*/
function isLeftOrCollinear(
px: integer,
py: integer,
ax: integer,
ay: integer,
bx: integer,
by: integer
): boolean {
return getSignedAreaX2(ax, ay, px, py, bx, by) <= 0;
}
/**
* Check if point P is to the right of line AB when looking
* from A to B.
* @param px The x-value of the point to test.
* @param py The y-value of the point to test.
* @param ax The x-value of the point (ax, ay) that is point A on line AB.
* @param ay The y-value of the point (ax, ay) that is point A on line AB.
* @param bx The x-value of the point (bx, by) that is point B on line AB.
* @param by The y-value of the point (bx, by) that is point B on line AB.
* @return TRUE if point P is to the right of line AB when looking
* from A to B.
*/
function isRight(
px: integer,
py: integer,
ax: integer,
ay: integer,
bx: integer,
by: integer
): boolean {
return getSignedAreaX2(ax, ay, px, py, bx, by) > 0;
}
/**
* Check if point P is to the right of or on line AB when looking
* from A to B.
* @param px The x-value of the point to test.
* @param py The y-value of the point to test.
* @param ax The x-value of the point (ax, ay) that is point A on line AB.
* @param ay The y-value of the point (ax, ay) that is point A on line AB.
* @param bx The x-value of the point (bx, by) that is point B on line AB.
* @param by The y-value of the point (bx, by) that is point B on line AB.
* @return TRUE if point P is to the right of or on line AB when looking
* from A to B.
*/
function isRightOrCollinear(
px: integer,
py: integer,
ax: integer,
ay: integer,
bx: integer,
by: integer
): boolean {
return getSignedAreaX2(ax, ay, px, py, bx, by) >= 0;
}
/**
* The absolute value of the returned value is two times the area of the
* triangle defined by points (A, B, C).
*
* A positive value indicates:
* - Counterclockwise wrapping of the points.
* - Point B lies to the right of line AC, looking from A to C.
*
* A negative value indicates:
* - Clockwise wrapping of the points.<
* - Point B lies to the left of line AC, looking from A to C.
*
* A value of zero indicates that all points are collinear or
* represent the same point.
*
* This is a fast operation.
*
* @param ax The x-value for point (ax, ay) for vertex A of the triangle.
* @param ay The y-value for point (ax, ay) for vertex A of the triangle.
* @param bx The x-value for point (bx, by) for vertex B of the triangle.
* @param by The y-value for point (bx, by) for vertex B of the triangle.
* @param cx The x-value for point (cx, cy) for vertex C of the triangle.
* @param cy The y-value for point (cx, cy) for vertex C of the triangle.
* @return The signed value of two times the area of the triangle defined
* by the points (A, B, C).
*/
function getSignedAreaX2(
ax: integer,
ay: integer,
bx: integer,
by: integer,
cx: integer,
cy: integer
): integer {
// References:
// http://softsurfer.com/Archive/algorithm_0101/algorithm_0101.htm#Modern%20Triangles
// http://mathworld.wolfram.com/TriangleArea.html (Search for "signed")
return (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
}
/**
* Check if the line segment AB intersects any edges not already
* connected to one of the two vertices.
*
* Assumptions:
* - The vertices and indices arguments define a valid simple polygon
* with vertices wrapped clockwise.
* - indexA != indexB
*
* Behavior is undefined if the arguments to not meet these
* assumptions
*
* @param indexA the index of the vertex that will form the segment AB.
* @param indexB the index of the vertex that will form the segment AB.
* @param vertices a polygon wrapped clockwise.
* @return true if the line segment AB intersects any edges not already
* connected to one of the two vertices.
*/
function hasIllegalEdgeIntersection(
indexA: integer,
indexB: integer,
vertices: Point[]
): boolean {
// Get pointers to the primary vertices being tested.
const vertexA = vertices[indexA];
const vertexB = vertices[indexB];
// Loop through the polygon edges.
for (
let edgeBeginIndex = 0;
edgeBeginIndex < vertices.length;
edgeBeginIndex++
) {
const edgeEndIndex = (edgeBeginIndex + 1) % vertices.length;
if (
edgeBeginIndex === indexA ||
edgeBeginIndex === indexB ||
edgeEndIndex === indexA ||
edgeEndIndex === indexB
) {
continue;
}
// Neither of the test indices are endpoints of this edge.
// Get this edge's vertices.
const edgeBegin = vertices[edgeBeginIndex];
const edgeEnd = vertices[edgeEndIndex];
if (
(edgeBegin.x === vertexA.x && edgeBegin.y === vertexA.y) ||
(edgeBegin.x === vertexB.x && edgeBegin.y === vertexB.y) ||
(edgeEnd.x === vertexA.x && edgeEnd.y === vertexA.y) ||
(edgeEnd.x === vertexB.x && edgeEnd.y === vertexB.y)
) {
// One of the test vertices is co-located
// with one of the endpoints of this edge (this is a
// test of the actual position of the vertices rather than
// simply the index check performed earlier).
// Skip this edge.
continue;
}
// This edge is not connected to either of the test vertices.
// If line segment AB intersects with this edge, then the
// intersection is illegal.
// I.e. New edges cannot cross existing edges.
if (
segmentsIntersect(
vertexA.x,
vertexA.y,
vertexB.x,
vertexB.y,
edgeBegin.x,
edgeBegin.y,
edgeEnd.x,
edgeEnd.y
)
) {
return true;
}
}
return false;
}
/**
* Returns TRUE if line segment AB intersects with line segment CD in any
* manner. Either collinear or at a single point.
* @param ax The x-value for point (ax, ay) in line segment AB.
* @param ay The y-value for point (ax, ay) in line segment AB.
* @param bx The x-value for point (bx, by) in line segment AB.
* @param by The y-value for point (bx, by) in line segment AB.
* @param cx The x-value for point (cx, cy) in line segment CD.
* @param cy The y-value for point (cx, cy) in line segment CD.
* @param dx The x-value for point (dx, dy) in line segment CD.
* @param dy The y-value for point (dx, dy) in line segment CD.
* @return TRUE if line segment AB intersects with line segment CD in any
* manner.
*/
function segmentsIntersect(
ax: integer,
ay: integer,
bx: integer,
by: integer,
cx: integer,
cy: integer,
dx: integer,
dy: integer
): boolean {
// This is modified 2D line-line intersection/segment-segment
// intersection test.
const deltaABx = bx - ax;
const deltaABy = by - ay;
const deltaCAx = ax - cx;
const deltaCAy = ay - cy;
const deltaCDx = dx - cx;
const deltaCDy = dy - cy;
const numerator = deltaCAy * deltaCDx - deltaCAx * deltaCDy;
const denominator = deltaABx * deltaCDy - deltaABy * deltaCDx;
// Perform early exit tests.
if (denominator === 0 && numerator !== 0) {
// If numerator is zero, then the lines are colinear.
// Since it isn't, then the lines must be parallel.
return false;
}
// Lines intersect. But do the segments intersect?
// Forcing float division on both of these via casting of the
// denominator.
const factorAB = numerator / denominator;
const factorCD = (deltaCAy * deltaABx - deltaCAx * deltaABy) / denominator;
// Determine the type of intersection
if (
factorAB >= 0.0 &&
factorAB <= 1.0 &&
factorCD >= 0.0 &&
factorCD <= 1.0
) {
return true; // The two segments intersect.
}
// The lines intersect, but segments to not.
return false;
}
}