This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
/**
* A special class of Polygon which implements a limited angle of emission for a Point Source.
* The shape is defined by a point origin, radius, angle, and rotation.
* The shape is further customized by a configurable density which informs the approximation.
* An optional secondary externalRadius can be provided which adds supplementary visibility outside the primary angle.
*/
class LimitedAnglePolygon extends PIXI.Polygon {
constructor(origin, {radius, angle=360, rotation=0, density, externalRadius=0} = {}) {
super([]);
/**
* The origin point of the Polygon
* @type {Point}
*/
this.origin = origin;
/**
* The radius of the emitted cone.
* @type {number}
*/
this.radius = radius;
/**
* The angle of the Polygon in degrees.
* @type {number}
*/
this.angle = angle;
/**
* The direction of rotation at the center of the emitted angle in degrees.
* @type {number}
*/
this.rotation = rotation;
/**
* The density of rays which approximate the cone, defined as rays per PI.
* @type {number}
*/
this.density = density ?? PIXI.Circle.approximateVertexDensity(this.radius);
/**
* An optional "external radius" which is included in the polygon for the supplementary area outside the cone.
* @type {number}
*/
this.externalRadius = externalRadius;
/**
* The angle of the left (counter-clockwise) edge of the emitted cone in radians.
* @type {number}
*/
this.aMin = Math.normalizeRadians(Math.toRadians(this.rotation + 90 - (this.angle / 2)));
/**
* The angle of the right (clockwise) edge of the emitted cone in radians.
* @type {number}
*/
this.aMax = this.aMin + Math.toRadians(this.angle);
// Generate polygon points
this.#generatePoints();
}
/**
* The bounding box of the circle defined by the externalRadius, if any
* @type {PIXI.Rectangle}
*/
externalBounds;
/* -------------------------------------------- */
/**
* Generate the points of the LimitedAnglePolygon using the provided configuration parameters.
*/
#generatePoints() {
const {x, y} = this.origin;
// Construct polygon points for the primary angle
const primaryAngle = this.aMax - this.aMin;
const nPrimary = Math.ceil((primaryAngle * this.density) / (2 * Math.PI));
const dPrimary = primaryAngle / nPrimary;
for ( let i=0; i<=nPrimary; i++ ) {
const pad = Ray.fromAngle(x, y, this.aMin + (i * dPrimary), this.radius);
this.points.push(pad.B.x, pad.B.y);
}
// Add secondary angle
if ( this.externalRadius ) {
const secondaryAngle = (2 * Math.PI) - primaryAngle;
const nSecondary = Math.ceil((secondaryAngle * this.density) / (2 * Math.PI));
const dSecondary = secondaryAngle / nSecondary;
for ( let i=0; i<=nSecondary; i++ ) {
const pad = Ray.fromAngle(x, y, this.aMax + (i * dSecondary), this.externalRadius);
this.points.push(pad.B.x, pad.B.y);
}
this.externalBounds = (new PIXI.Circle(x, y, this.externalRadius)).getBounds();
}
// No secondary angle
else {
this.points.unshift(x, y);
this.points.push(x, y);
}
}
/* -------------------------------------------- */
/**
* Restrict the edges which should be included in a PointSourcePolygon based on this specialized shape.
* We use two tests to jointly keep or reject edges.
* 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
* 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
* @param {Point} a The first edge vertex
* @param {Point} b The second edge vertex
* @returns {boolean} Should the edge be included in the PointSourcePolygon computation?
* @internal
*/
_includeEdge(a, b) {
// 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
if ( this.externalBounds?.lineSegmentIntersects(a, b, {inside: true}) ) return true;
// 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
const roundPoint = p => ({x: Math.round(p.x), y: Math.round(p.y)});
const rMin = Ray.fromAngle(this.origin.x, this.origin.y, this.aMin, this.radius);
roundPoint(rMin.B);
const rMax = Ray.fromAngle(this.origin.x, this.origin.y, this.aMax, this.radius);
roundPoint(rMax.B);
// If either vertex is inside, keep the edge
if ( LimitedAnglePolygon.pointBetweenRays(a, rMin, rMax, this.angle) ) return true;
if ( LimitedAnglePolygon.pointBetweenRays(b, rMin, rMax, this.angle) ) return true;
// If both vertices are outside, test whether the edge collides with one (either) of the limiting rays
if ( foundry.utils.lineSegmentIntersects(rMin.A, rMin.B, a, b) ) return true;
if ( foundry.utils.lineSegmentIntersects(rMax.A, rMax.B, a, b) ) return true;
// Otherwise, the edge can be discarded
return false;
}
/* -------------------------------------------- */
/**
* Test whether a vertex lies between two boundary rays.
* If the angle is greater than 180, test for points between rMax and rMin (inverse).
* Otherwise, keep vertices that are between the rays directly.
* @param {Point} point The candidate point
* @param {PolygonRay} rMin The counter-clockwise bounding ray
* @param {PolygonRay} rMax The clockwise bounding ray
* @param {number} angle The angle being tested, in degrees
* @returns {boolean} Is the vertex between the two rays?
*/
static pointBetweenRays(point, rMin, rMax, angle) {
const ccw = foundry.utils.orient2dFast;
if ( angle > 180 ) {
const outside = (ccw(rMax.A, rMax.B, point) <= 0) && (ccw(rMin.A, rMin.B, point) >= 0);
return !outside;
}
return (ccw(rMin.A, rMin.B, point) <= 0) && (ccw(rMax.A, rMax.B, point) >= 0);
}
}

View File

@@ -0,0 +1,446 @@
// noinspection TypeScriptUMDGlobal
/**
* A helper class used to construct triangulated polygon meshes
* Allow to add padding and a specific depth value.
* @param {number[]|PIXI.Polygon} poly Closed polygon to be processed and converted to a mesh
* (array of points or PIXI Polygon)
* @param {object|{}} options Various options : normalizing, offsetting, add depth, ...
*/
class PolygonMesher {
constructor(poly, options = {}) {
this.options = {...this.constructor._defaultOptions, ...options};
const {normalize, x, y, radius, scale, offset} = this.options;
// Creating the scaled values
this.#scaled.sradius = radius * scale;
this.#scaled.sx = x * scale;
this.#scaled.sy = y * scale;
this.#scaled.soffset = offset * scale;
// Computing required number of pass (minimum 1)
this.#nbPass = Math.ceil(Math.abs(offset) / 3);
// Get points from poly param
const points = poly instanceof PIXI.Polygon ? poly.points : poly;
if ( !Array.isArray(points) ) {
throw new Error("You must provide a PIXI.Polygon or an array of vertices to the PolygonMesher constructor");
}
// Correcting normalize option if necessary. We can't normalize with a radius of 0.
if ( normalize && (radius === 0) ) this.options.normalize = false;
// Creating the mesh vertices
this.#computePolygonMesh(points);
}
/**
* Default options values
* @type {Record<string,boolean|number>}
*/
static _defaultOptions = {
offset: 0, // The position value in pixels
normalize: false, // Should the vertices be normalized?
x: 0, // The x origin
y: 0, // The y origin
radius: 0, // The radius
depthOuter: 0, // The depth value on the outer polygon
depthInner: 1, // The depth value on the inner(s) polygon(s)
scale: 10e8, // Constant multiplier to avoid floating point imprecision with ClipperLib
miterLimit: 7, // Distance of the miter limit, when sharp angles are cut during offsetting.
interleaved: false // Should the vertex data be interleaved into one VBO?
};
/* -------------------------------------------- */
/**
* Polygon mesh vertices
* @type {number[]}
*/
vertices = [];
/**
* Polygon mesh indices
* @type {number[]}
*/
indices = [];
/**
* Contains options to apply during the meshing process
* @type {Record<string,boolean|number>}
*/
options = {};
/**
* Contains some options values scaled by the constant factor
* @type {Record<string,number>}
* @private
*/
#scaled = {};
/**
* Polygon mesh geometry
* @type {PIXI.Geometry}
* @private
*/
#geometry = null;
/**
* Contain the polygon tree node object, containing the main forms and its holes and sub-polygons
* @type {{poly: number[], nPoly: number[], children: object[]}}
* @private
*/
#polygonNodeTree = null;
/**
* Contains the the number of offset passes required to compute the polygon
* @type {number}
* @private
*/
#nbPass;
/* -------------------------------------------- */
/* Polygon Mesher static helper methods */
/* -------------------------------------------- */
/**
* Convert a flat points array into a 2 dimensional ClipperLib path
* @param {number[]|PIXI.Polygon} poly PIXI.Polygon or points flat array.
* @param {number} [dimension=2] Dimension.
* @returns {number[]|undefined} The clipper lib path.
*/
static getClipperPathFromPoints(poly, dimension = 2) {
poly = poly instanceof PIXI.Polygon ? poly.points : poly;
// If points is not an array or if its dimension is 1, 0 or negative, it can't be translated to a path.
if ( !Array.isArray(poly) || dimension < 2 ) {
throw new Error("You must provide valid coordinates to create a path.");
}
const path = new ClipperLib.Path();
if ( poly.length <= 1 ) return path; // Returning an empty path if we have zero or one point.
for ( let i = 0; i < poly.length; i += dimension ) {
path.push(new ClipperLib.IntPoint(poly[i], poly[i + 1]));
}
return path;
}
/* -------------------------------------------- */
/* Polygon Mesher Methods */
/* -------------------------------------------- */
/**
* Create the polygon mesh
* @param {number[]} points
* @private
*/
#computePolygonMesh(points) {
if ( !points || points.length < 6 ) return;
this.#updateVertices(points);
this.#updatePolygonNodeTree();
}
/* -------------------------------------------- */
/**
* Update vertices and add depth
* @param {number[]} vertices
* @private
*/
#updateVertices(vertices) {
const {offset, depthOuter, scale} = this.options;
const z = (offset === 0 ? 1.0 : depthOuter);
for ( let i = 0; i < vertices.length; i += 2 ) {
const x = Math.round(vertices[i] * scale);
const y = Math.round(vertices[i + 1] * scale);
this.vertices.push(x, y, z);
}
}
/* -------------------------------------------- */
/**
* Create the polygon by generating the edges and the interior of the polygon if an offset != 0,
* and just activate a fast triangulation if offset = 0
* @private
*/
#updatePolygonNodeTree() {
// Initializing the polygon node tree
this.#polygonNodeTree = {poly: this.vertices, nPoly: this.#normalize(this.vertices), children: []};
// Computing offset only if necessary
if ( this.options.offset === 0 ) return this.#polygonNodeTree.fastTriangulation = true;
// Creating the offsetter ClipperLib object, and adding our polygon path to it.
const offsetter = new ClipperLib.ClipperOffset(this.options.miterLimit);
// Launching the offset computation
return this.#createOffsetPolygon(offsetter, this.#polygonNodeTree);
}
/* -------------------------------------------- */
/**
* Recursively create offset polygons in successive passes
* @param {ClipperLib.ClipperOffset} offsetter ClipperLib offsetter
* @param {object} node A polygon node object to offset
* @param {number} [pass=0] The pass number (initialized with 0 for the first call)
*/
#createOffsetPolygon(offsetter, node, pass = 0) {
// Time to stop recursion on this node branch?
if ( pass >= this.#nbPass ) return;
const path = PolygonMesher.getClipperPathFromPoints(node.poly, 3); // Converting polygon points to ClipperLib path
const passOffset = Math.round(this.#scaled.soffset / this.#nbPass); // Mapping the offset for this path
const depth = Math.mix(this.options.depthOuter, this.options.depthInner, (pass + 1) / this.#nbPass); // Computing depth according to the actual pass and maximum number of pass (linear interpolation)
// Executing the offset
const paths = new ClipperLib.Paths();
offsetter.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
offsetter.Execute(paths, passOffset);
offsetter.Clear();
// Verifying if we have pathes. If it's not the case, the area is too small to generate pathes with this offset.
// It's time to stop recursion on this node branch.
if ( !paths.length ) return;
// Incrementing the number of pass to know when recursive offset should stop
pass++;
// Creating offsets for children
for ( const path of paths ) {
const flat = this.#flattenVertices(path, depth);
const child = { poly: flat, nPoly: this.#normalize(flat), children: []};
node.children.push(child);
this.#createOffsetPolygon(offsetter, child, pass);
}
}
/* -------------------------------------------- */
/**
* Flatten a ClipperLib path to array of numbers
* @param {ClipperLib.IntPoint[]} path path to convert
* @param {number} depth depth to add to the flattened vertices
* @returns {number[]} flattened array of points
* @private
*/
#flattenVertices(path, depth) {
const flattened = [];
for ( const point of path ) {
flattened.push(point.X, point.Y, depth);
}
return flattened;
}
/* -------------------------------------------- */
/**
* Normalize polygon coordinates and put result into nPoly property.
* @param {number[]} poly the poly to normalize
* @returns {number[]} the normalized poly array
* @private
*/
#normalize(poly) {
if ( !this.options.normalize ) return [];
// Compute the normalized vertex
const {sx, sy, sradius} = this.#scaled;
const nPoly = [];
for ( let i = 0; i < poly.length; i+=3 ) {
const x = (poly[i] - sx) / sradius;
const y = (poly[i+1] - sy) / sradius;
nPoly.push(x, y, poly[i+2]);
}
return nPoly;
}
/* -------------------------------------------- */
/**
* Execute the triangulation to create indices
* @param {PIXI.Geometry} geometry A geometry to update
* @returns {PIXI.Geometry} The resulting geometry
*/
triangulate(geometry) {
this.#geometry = geometry;
// Can we draw at least one triangle (counting z now)? If not, update or create an empty geometry
if ( this.vertices.length < 9 ) return this.#emptyGeometry();
// Triangulate the mesh and create indices
if ( this.#polygonNodeTree.fastTriangulation ) this.#triangulateFast();
else this.#triangulateTree();
// Update the geometry
return this.#updateGeometry();
}
/* -------------------------------------------- */
/**
* Fast triangulation of the polygon node tree
* @private
*/
#triangulateFast() {
this.indices = PIXI.utils.earcut(this.vertices, null, 3);
if ( this.options.normalize ) {
this.vertices = this.#polygonNodeTree.nPoly;
}
}
/* -------------------------------------------- */
/**
* Recursive triangulation of the polygon node tree
* @private
*/
#triangulateTree() {
this.vertices = [];
this.indices = this.#triangulateNode(this.#polygonNodeTree);
}
/* -------------------------------------------- */
/**
* Triangulate a node and its children recursively to compose a mesh with multiple levels of depth
* @param {object} node The polygon node tree to triangulate
* @param {number[]} [indices=[]] An optional array to receive indices (used for recursivity)
* @returns {number[]} An array of indices, result of the triangulation
*/
#triangulateNode(node, indices = []) {
const {normalize} = this.options;
const vert = [];
const polyLength = node.poly.length / 3;
const hasChildren = !!node.children.length;
vert.push(...node.poly);
// If the node is the outer hull (beginning polygon), it has a position of 0 into the vertices array.
if ( !node.position ) {
node.position = 0;
this.vertices.push(...(normalize ? node.nPoly : node.poly));
}
// If the polygon has no children, it is an interior polygon triangulated in the fast way. Returning here.
if ( !hasChildren ) {
indices.push(...(PIXI.utils.earcut(vert, null, 3).map(v => v + node.position)));
return indices;
}
let holePosition = polyLength;
let holes = [];
let holeGroupPosition = 0;
for ( const nodeChild of node.children ) {
holes.push(holePosition);
nodeChild.position = (this.vertices.length / 3);
if ( !holeGroupPosition ) holeGroupPosition = nodeChild.position; // The position of the holes as a contiguous group.
holePosition += (nodeChild.poly.length / 3);
vert.push(...nodeChild.poly);
this.vertices.push(...(normalize ? nodeChild.nPoly : nodeChild.poly));
}
// We need to shift the result of the indices, to match indices as it is saved in the vertices.
// We are using earcutEdges to enforce links between the outer and inner(s) polygons.
const holeGroupShift = holeGroupPosition - polyLength;
indices.push(...(earcut.earcutEdges(vert, holes).map(v => {
if ( v < polyLength ) return v + node.position;
else return v + holeGroupShift;
})));
// Triangulating children
for ( const nodeChild of node.children ) {
this.#triangulateNode(nodeChild, indices);
}
return indices;
}
/* -------------------------------------------- */
/**
* Updating or creating the PIXI.Geometry that will be used by the mesh
* @private
*/
#updateGeometry() {
const {interleaved, normalize, scale} = this.options;
// Unscale non normalized vertices
if ( !normalize ) {
for ( let i = 0; i < this.vertices.length; i+=3 ) {
this.vertices[i] /= scale;
this.vertices[i+1] /= scale;
}
}
// If VBO shouldn't be interleaved, we create a separate array for vertices and depth
let vertices; let depth;
if ( !interleaved ) {
vertices = [];
depth = [];
for ( let i = 0; i < this.vertices.length; i+=3 ) {
vertices.push(this.vertices[i], this.vertices[i+1]);
depth.push(this.vertices[i+2]);
}
}
else vertices = this.vertices;
if ( this.#geometry ) {
const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
vertBuffer.update(new Float32Array(vertices));
const indicesBuffer = this.#geometry.getIndex();
indicesBuffer.update(new Uint16Array(this.indices));
if ( !interleaved ) {
const depthBuffer = this.#geometry.getBuffer("aDepthValue");
depthBuffer.update(new Float32Array(depth));
}
}
else this.#geometry = this.#createGeometry(vertices, depth);
return this.#geometry;
}
/* -------------------------------------------- */
/**
* Empty the geometry, or if geometry is null, create an empty geometry.
* @private
*/
#emptyGeometry() {
const {interleaved} = this.options;
// Empty the current geometry if it exists
if ( this.#geometry ) {
const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
vertBuffer.update(new Float32Array([0, 0]));
const indicesBuffer = this.#geometry.getIndex();
indicesBuffer.update(new Uint16Array([0, 0]));
if ( !interleaved ) {
const depthBuffer = this.#geometry.getBuffer("aDepthValue");
depthBuffer.update(new Float32Array([0]));
}
}
// Create an empty geometry otherwise
else if ( interleaved ) {
// Interleaved version
return new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0, 0], 3).addIndex([0, 0]);
}
else {
this.#geometry = new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0], 2)
.addAttribute("aTextureCoord", [0, 0, 0, 1, 1, 1, 1, 0], 2)
.addAttribute("aDepthValue", [0], 1)
.addIndex([0, 0]);
}
return this.#geometry;
}
/* -------------------------------------------- */
/**
* Create a new Geometry from provided buffers
* @param {number[]} vertices provided vertices array (interleaved or not)
* @param {number[]} [depth=undefined] provided depth array
* @param {number[]} [indices=this.indices] provided indices array
* @returns {PIXI.Geometry} the new PIXI.Geometry constructed from the provided buffers
*/
#createGeometry(vertices, depth=undefined, indices=this.indices) {
if ( this.options.interleaved ) {
return new PIXI.Geometry().addAttribute("aVertexPosition", vertices, 3).addIndex(indices);
}
if ( !depth ) throw new Error("You must provide a separate depth buffer when the data is not interleaved.");
return new PIXI.Geometry()
.addAttribute("aVertexPosition", vertices, 2)
.addAttribute("aTextureCoord", [0, 0, 1, 0, 1, 1, 0, 1], 2)
.addAttribute("aDepthValue", depth, 1)
.addIndex(indices);
}
}

View File

@@ -0,0 +1,37 @@
/**
* An extension of the default PIXI.Text object which forces double resolution.
* At default resolution Text often looks blurry or fuzzy.
*/
class PreciseText extends PIXI.Text {
constructor(...args) {
super(...args);
this._autoResolution = false;
this._resolution = 2;
}
/**
* Prepare a TextStyle object which merges the canvas defaults with user-provided options
* @param {object} [options={}] Additional options merged with the default TextStyle
* @param {number} [options.anchor] A text anchor point from CONST.TEXT_ANCHOR_POINTS
* @returns {PIXI.TextStyle} The prepared TextStyle
*/
static getTextStyle({anchor, ...options}={}) {
const style = CONFIG.canvasTextStyle.clone();
for ( let [k, v] of Object.entries(options) ) {
if ( v !== undefined ) style[k] = v;
}
// Positioning
if ( !("align" in options) ) {
if ( anchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
else if ( anchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";
}
// Adaptive Stroke
if ( !("stroke" in options) ) {
const fill = Color.from(style.fill);
style.stroke = fill.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
}
return style;
}
}

View File

@@ -0,0 +1,248 @@
/**
* @typedef {Object} RayIntersection
* @property {number} x The x-coordinate of intersection
* @property {number} y The y-coordinate of intersection
* @property {number} t0 The proximity to the Ray origin, as a ratio of distance
* @property {number} t1 The proximity to the Ray destination, as a ratio of distance
*/
/**
* A ray for the purposes of computing sight and collision
* Given points A[x,y] and B[x,y]
*
* Slope-Intercept form:
* y = a + bx
* y = A.y + ((B.y - A.Y) / (B.x - A.x))x
*
* Parametric form:
* R(t) = (1-t)A + tB
*
* @param {Point} A The origin of the Ray
* @param {Point} B The destination of the Ray
*/
class Ray {
constructor(A, B) {
/**
* The origin point, {x, y}
* @type {Point}
*/
this.A = A;
/**
* The destination point, {x, y}
* @type {Point}
*/
this.B = B;
/**
* The origin y-coordinate
* @type {number}
*/
this.y0 = A.y;
/**
* The origin x-coordinate
* @type {number}
*/
this.x0 = A.x;
/**
* The horizontal distance of the ray, x1 - x0
* @type {number}
*/
this.dx = B.x - A.x;
/**
* The vertical distance of the ray, y1 - y0
* @type {number}
*/
this.dy = B.y - A.y;
/**
* The slope of the ray, dy over dx
* @type {number}
*/
this.slope = this.dy / this.dx;
}
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/**
* The cached angle, computed lazily in Ray#angle
* @type {number}
* @private
*/
_angle = undefined;
/**
* The cached distance, computed lazily in Ray#distance
* @type {number}
* @private
*/
_distance = undefined;
/* -------------------------------------------- */
/**
* The normalized angle of the ray in radians on the range (-PI, PI).
* The angle is computed lazily (only if required) and cached.
* @type {number}
*/
get angle() {
if ( this._angle === undefined ) this._angle = Math.atan2(this.dy, this.dx);
return this._angle;
}
set angle(value) {
this._angle = Number(value);
}
/* -------------------------------------------- */
/**
* A normalized bounding rectangle that encompasses the Ray
* @type {PIXI.Rectangle}
*/
get bounds() {
return new PIXI.Rectangle(this.A.x, this.A.y, this.dx, this.dy).normalize();
}
/* -------------------------------------------- */
/**
* The distance (length) of the Ray in pixels.
* The distance is computed lazily (only if required) and cached.
* @type {number}
*/
get distance() {
if ( this._distance === undefined ) this._distance = Math.hypot(this.dx, this.dy);
return this._distance;
}
set distance(value) {
this._distance = Number(value);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* A factory method to construct a Ray from an origin point, an angle, and a distance
* @param {number} x The origin x-coordinate
* @param {number} y The origin y-coordinate
* @param {number} radians The ray angle in radians
* @param {number} distance The distance of the ray in pixels
* @returns {Ray} The constructed Ray instance
*/
static fromAngle(x, y, radians, distance) {
const dx = Math.cos(radians);
const dy = Math.sin(radians);
const ray = this.fromArrays([x, y], [x + (dx * distance), y + (dy * distance)]);
ray._angle = Math.normalizeRadians(radians); // Store the angle, cheaper to compute here
ray._distance = distance; // Store the distance, cheaper to compute here
return ray;
}
/* -------------------------------------------- */
/**
* A factory method to construct a Ray from points in array format.
* @param {number[]} A The origin point [x,y]
* @param {number[]} B The destination point [x,y]
* @returns {Ray} The constructed Ray instance
*/
static fromArrays(A, B) {
return new this({x: A[0], y: A[1]}, {x: B[0], y: B[1]});
}
/* -------------------------------------------- */
/**
* Project the Array by some proportion of it's initial distance.
* Return the coordinates of that point along the path.
* @param {number} t The distance along the Ray
* @returns {Object} The coordinates of the projected point
*/
project(t) {
return {
x: this.A.x + (t * this.dx),
y: this.A.y + (t * this.dy)
};
}
/* -------------------------------------------- */
/**
* Create a Ray by projecting a certain distance towards a known point.
* @param {Point} origin The origin of the Ray
* @param {Point} point The point towards which to project
* @param {number} distance The distance of projection
* @returns {Ray}
*/
static towardsPoint(origin, point, distance) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const t = distance / Math.hypot(dx, dy);
return new this(origin, {
x: origin.x + (t * dx),
y: origin.y + (t * dy)
});
}
/* -------------------------------------------- */
/**
* Create a Ray by projecting a certain squared-distance towards a known point.
* @param {Point} origin The origin of the Ray
* @param {Point} point The point towards which to project
* @param {number} distance2 The squared distance of projection
* @returns {Ray}
*/
static towardsPointSquared(origin, point, distance2) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const t = Math.sqrt(distance2 / (Math.pow(dx, 2) + Math.pow(dy, 2)));
return new this(origin, {
x: origin.x + (t * dx),
y: origin.y + (t * dy)
});
}
/* -------------------------------------------- */
/**
* Reverse the direction of the Ray, returning a second Ray
* @returns {Ray}
*/
reverse() {
const r = new Ray(this.B, this.A);
r._distance = this._distance;
r._angle = Math.PI - this._angle;
return r;
}
/* -------------------------------------------- */
/**
* Create a new ray which uses the same origin point, but a slightly offset angle and distance
* @param {number} offset An offset in radians which modifies the angle of the original Ray
* @param {number} [distance] A distance the new ray should project, otherwise uses the same distance.
* @return {Ray} A new Ray with an offset angle
*/
shiftAngle(offset, distance) {
return this.constructor.fromAngle(this.x0, this.y0, this.angle + offset, distance || this.distance);
}
/* -------------------------------------------- */
/**
* Find the point I[x,y] and distance t* on ray R(t) which intersects another ray
* @see foundry.utils.lineLineIntersection
*/
intersectSegment(coords) {
return foundry.utils.lineSegmentIntersection(this.A, this.B, {x: coords[0], y: coords[1]}, {x: coords[2], y: coords[3]});
}
}

View File

@@ -0,0 +1,531 @@
/**
* @typedef {"light"|"sight"|"sound"|"move"|"universal"} PointSourcePolygonType
*/
/**
* @typedef {Object} PointSourcePolygonConfig
* @property {PointSourcePolygonType} type The type of polygon being computed
* @property {number} [angle=360] The angle of emission, if limited
* @property {number} [density] The desired density of padding rays, a number per PI
* @property {number} [radius] A limited radius of the resulting polygon
* @property {number} [rotation] The direction of facing, required if the angle is limited
* @property {number} [wallDirectionMode] Customize how wall direction of one-way walls is applied
* @property {boolean} [useThreshold=false] Compute the polygon with threshold wall constraints applied
* @property {boolean} [includeDarkness=false] Include edges coming from darkness sources
* @property {number} [priority] Priority when it comes to ignore edges from darkness sources
* @property {boolean} [debug] Display debugging visualization and logging for the polygon
* @property {PointSource} [source] The object (if any) that spawned this polygon.
* @property {Array<PIXI.Rectangle|PIXI.Circle|PIXI.Polygon>} [boundaryShapes] Limiting polygon boundary shapes
* @property {Readonly<boolean>} [useInnerBounds] Does this polygon use the Scene inner or outer bounding rectangle
* @property {Readonly<boolean>} [hasLimitedRadius] Does this polygon have a limited radius?
* @property {Readonly<boolean>} [hasLimitedAngle] Does this polygon have a limited angle?
* @property {Readonly<PIXI.Rectangle>} [boundingBox] The computed bounding box for the polygon
*/
/**
* An extension of the default PIXI.Polygon which is used to represent the line of sight for a point source.
* @extends {PIXI.Polygon}
*/
class PointSourcePolygon extends PIXI.Polygon {
/**
* Customize how wall direction of one-way walls is applied
* @enum {number}
*/
static WALL_DIRECTION_MODES = Object.freeze({
NORMAL: 0,
REVERSED: 1,
BOTH: 2
});
/**
* The rectangular bounds of this polygon
* @type {PIXI.Rectangle}
*/
bounds = new PIXI.Rectangle(0, 0, 0, 0);
/**
* The origin point of the source polygon.
* @type {Point}
*/
origin;
/**
* The configuration of this polygon.
* @type {PointSourcePolygonConfig}
*/
config = {};
/* -------------------------------------------- */
/**
* An indicator for whether this polygon is constrained by some boundary shape?
* @type {boolean}
*/
get isConstrained() {
return this.config.boundaryShapes.length > 0;
}
/* -------------------------------------------- */
/**
* Benchmark the performance of polygon computation for this source
* @param {number} iterations The number of test iterations to perform
* @param {Point} origin The origin point to benchmark
* @param {PointSourcePolygonConfig} config The polygon configuration to benchmark
*/
static benchmark(iterations, origin, config) {
const f = () => this.create(foundry.utils.deepClone(origin), foundry.utils.deepClone(config));
Object.defineProperty(f, "name", {value: `${this.name}.construct`, configurable: true});
return foundry.utils.benchmark(f, iterations);
}
/* -------------------------------------------- */
/**
* Compute the polygon given a point origin and radius
* @param {Point} origin The origin source point
* @param {PointSourcePolygonConfig} [config={}] Configuration options which customize the polygon computation
* @returns {PointSourcePolygon} The computed polygon instance
*/
static create(origin, config={}) {
const poly = new this();
poly.initialize(origin, config);
poly.compute();
return this.applyThresholdAttenuation(poly);
}
/* -------------------------------------------- */
/**
* Create a clone of this polygon.
* This overrides the default PIXI.Polygon#clone behavior.
* @override
* @returns {PointSourcePolygon} A cloned instance
*/
clone() {
const poly = new this.constructor([...this.points]);
poly.config = foundry.utils.deepClone(this.config);
poly.origin = {...this.origin};
poly.bounds = this.bounds.clone();
return poly;
}
/* -------------------------------------------- */
/* Polygon Computation */
/* -------------------------------------------- */
/**
* Compute the polygon using the origin and configuration options.
* @returns {PointSourcePolygon} The computed polygon
*/
compute() {
let t0 = performance.now();
const {x, y} = this.origin;
const {width, height} = canvas.dimensions;
const {angle, debug, radius} = this.config;
if ( !(x >= 0 && x <= width && y >= 0 && y <= height) ) {
console.warn("The polygon cannot be computed because its origin is out of the scene bounds.");
this.points.length = 0;
this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
return this;
}
// Skip zero-angle or zero-radius polygons
if ( (radius === 0) || (angle === 0) ) {
this.points.length = 0;
this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
return this;
}
// Clear the polygon bounds
this.bounds = undefined;
// Delegate computation to the implementation
this._compute();
// Cache the new polygon bounds
this.bounds = this.getBounds();
// Debugging and performance metrics
if ( debug ) {
let t1 = performance.now();
console.log(`Created ${this.constructor.name} in ${Math.round(t1 - t0)}ms`);
this.visualize();
}
return this;
}
/**
* Perform the implementation-specific computation
* @protected
*/
_compute() {
throw new Error("Each subclass of PointSourcePolygon must define its own _compute method");
}
/* -------------------------------------------- */
/**
* Customize the provided configuration object for this polygon type.
* @param {Point} origin The provided polygon origin
* @param {PointSourcePolygonConfig} config The provided configuration object
*/
initialize(origin, config) {
// Polygon origin
const o = this.origin = {x: Math.round(origin.x), y: Math.round(origin.y)};
// Configure radius
const cfg = this.config = config;
const maxR = canvas.dimensions.maxR;
cfg.radius = Math.min(cfg.radius ?? maxR, maxR);
cfg.hasLimitedRadius = (cfg.radius > 0) && (cfg.radius < maxR);
cfg.density = cfg.density ?? PIXI.Circle.approximateVertexDensity(cfg.radius);
// Configure angle
cfg.angle = cfg.angle ?? 360;
cfg.rotation = cfg.rotation ?? 0;
cfg.hasLimitedAngle = cfg.angle !== 360;
// Determine whether to use inner or outer bounds
const sceneRect = canvas.dimensions.sceneRect;
cfg.useInnerBounds ??= (cfg.type === "sight")
&& (o.x >= sceneRect.left && o.x <= sceneRect.right && o.y >= sceneRect.top && o.y <= sceneRect.bottom);
// Customize wall direction
cfg.wallDirectionMode ??= PointSourcePolygon.WALL_DIRECTION_MODES.NORMAL;
// Configure threshold
cfg.useThreshold ??= false;
// Configure darkness inclusion
cfg.includeDarkness ??= false;
// Boundary Shapes
cfg.boundaryShapes ||= [];
if ( cfg.hasLimitedAngle ) this.#configureLimitedAngle();
else if ( cfg.hasLimitedRadius ) this.#configureLimitedRadius();
if ( CONFIG.debug.polygons ) cfg.debug = true;
}
/* -------------------------------------------- */
/**
* Configure a limited angle and rotation into a triangular polygon boundary shape.
*/
#configureLimitedAngle() {
this.config.boundaryShapes.push(new LimitedAnglePolygon(this.origin, this.config));
}
/* -------------------------------------------- */
/**
* Configure a provided limited radius as a circular polygon boundary shape.
*/
#configureLimitedRadius() {
this.config.boundaryShapes.push(new PIXI.Circle(this.origin.x, this.origin.y, this.config.radius));
}
/* -------------------------------------------- */
/**
* Apply a constraining boundary shape to an existing PointSourcePolygon.
* Return a new instance of the polygon with the constraint applied.
* The new instance is only a "shallow clone", as it shares references to component properties with the original.
* @param {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon} constraint The constraining boundary shape
* @param {object} [intersectionOptions] Options passed to the shape intersection method
* @returns {PointSourcePolygon} A new constrained polygon
*/
applyConstraint(constraint, intersectionOptions={}) {
// Enhance polygon configuration data using knowledge of the constraint
const poly = this.clone();
poly.config.boundaryShapes.push(constraint);
if ( (constraint instanceof PIXI.Circle) && (constraint.x === this.origin.x) && (constraint.y === this.origin.y) ) {
if ( poly.config.radius <= constraint.radius ) return poly;
poly.config.radius = constraint.radius;
poly.config.density = intersectionOptions.density ??= PIXI.Circle.approximateVertexDensity(constraint.radius);
if ( constraint.radius === 0 ) {
poly.points.length = 0;
poly.bounds.x = poly.bounds.y = poly.bounds.width = poly.bounds.height = 0;
return poly;
}
}
if ( !poly.points.length ) return poly;
// Apply the constraint and return the constrained polygon
const c = constraint.intersectPolygon(poly, intersectionOptions);
poly.points = c.points;
poly.bounds = poly.getBounds();
return poly;
}
/* -------------------------------------------- */
/** @inheritDoc */
contains(x, y) {
return this.bounds.contains(x, y) && super.contains(x, y);
}
/* -------------------------------------------- */
/* Polygon Boundary Constraints */
/* -------------------------------------------- */
/**
* Constrain polygon points by applying boundary shapes.
* @protected
*/
_constrainBoundaryShapes() {
const {density, boundaryShapes} = this.config;
if ( (this.points.length < 6) || !boundaryShapes.length ) return;
let constrained = this;
const intersectionOptions = {density, scalingFactor: 100};
for ( const c of boundaryShapes ) {
constrained = c.intersectPolygon(constrained, intersectionOptions);
}
this.points = constrained.points;
}
/* -------------------------------------------- */
/* Collision Testing */
/* -------------------------------------------- */
/**
* Test whether a Ray between the origin and destination points would collide with a boundary of this Polygon.
* A valid wall restriction type is compulsory and must be passed into the config options.
* @param {Point} origin An origin point
* @param {Point} destination A destination point
* @param {PointSourcePolygonConfig} config The configuration that defines a certain Polygon type
* @param {"any"|"all"|"closest"} [config.mode] The collision mode to test: "any", "all", or "closest"
* @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision result depends on the mode of the test:
* * any: returns a boolean for whether any collision occurred
* * all: returns a sorted array of PolygonVertex instances
* * closest: returns a PolygonVertex instance or null
*/
static testCollision(origin, destination, {mode="all", ...config}={}) {
if ( !CONST.WALL_RESTRICTION_TYPES.includes(config.type) ) {
throw new Error("A valid wall restriction type is required for testCollision.");
}
const poly = new this();
const ray = new Ray(origin, destination);
config.boundaryShapes ||= [];
config.boundaryShapes.push(ray.bounds);
poly.initialize(origin, config);
return poly._testCollision(ray, mode);
}
/* -------------------------------------------- */
/**
* Determine the set of collisions which occurs for a Ray.
* @param {Ray} ray The Ray to test
* @param {string} mode The collision mode being tested
* @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result
* @protected
* @abstract
*/
_testCollision(ray, mode) {
throw new Error(`The ${this.constructor.name} class must implement the _testCollision method`);
}
/* -------------------------------------------- */
/* Visualization and Debugging */
/* -------------------------------------------- */
/**
* Visualize the polygon, displaying its computed area and applied boundary shapes.
* @returns {PIXI.Graphics|undefined} The rendered debugging shape
*/
visualize() {
if ( !this.points.length ) return;
let dg = canvas.controls.debug;
dg.clear();
for ( const constraint of this.config.boundaryShapes ) {
dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xAAFF00).drawShape(constraint).endFill();
}
dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xFFAA99, 0.25).drawShape(this).endFill();
return dg;
}
/* -------------------------------------------- */
/**
* Determine if the shape is a complete circle.
* The config object must have an angle and a radius properties.
*/
isCompleteCircle() {
const { radius, angle, density } = this.config;
if ( radius === 0 ) return true;
if ( angle < 360 || (this.points.length !== (density * 2)) ) return false;
const shapeArea = Math.abs(this.signedArea());
const circleArea = (0.5 * density * Math.sin(2 * Math.PI / density)) * (radius ** 2);
return circleArea.almostEqual(shapeArea, 1e-5);
}
/* -------------------------------------------- */
/* Threshold Polygons */
/* -------------------------------------------- */
/**
* Augment a PointSourcePolygon by adding additional coverage for shapes permitted by threshold walls.
* @param {PointSourcePolygon} polygon The computed polygon
* @returns {PointSourcePolygon} The augmented polygon
*/
static applyThresholdAttenuation(polygon) {
const config = polygon.config;
if ( !config.useThreshold ) return polygon;
// Identify threshold walls and confirm whether threshold augmentation is required
const {nAttenuated, edges} = PointSourcePolygon.#getThresholdEdges(polygon.origin, config);
if ( !nAttenuated ) return polygon;
// Create attenuation shapes for all threshold walls
const attenuationShapes = PointSourcePolygon.#createThresholdShapes(polygon, edges);
if ( !attenuationShapes.length ) return polygon;
// Compute a second polygon which does not enforce threshold walls
const noThresholdPolygon = new this();
noThresholdPolygon.initialize(polygon.origin, {...config, useThreshold: false});
noThresholdPolygon.compute();
// Combine the unrestricted polygon with the attenuation shapes
const combined = PointSourcePolygon.#combineThresholdShapes(noThresholdPolygon, attenuationShapes);
polygon.points = combined.points;
polygon.bounds = polygon.getBounds();
return polygon;
}
/* -------------------------------------------- */
/**
* Identify edges in the Scene which include an active threshold.
* @param {Point} origin
* @param {object} config
* @returns {{edges: Edge[], nAttenuated: number}}
*/
static #getThresholdEdges(origin, config) {
let nAttenuated = 0;
const edges = [];
for ( const edge of canvas.edges.values() ) {
if ( edge.applyThreshold(config.type, origin, config.externalRadius) ) {
edges.push(edge);
nAttenuated += edge.threshold.attenuation;
}
}
return {edges, nAttenuated};
}
/* -------------------------------------------- */
/**
* @typedef {ClipperPoint[]} ClipperPoints
*/
/**
* For each threshold wall that this source passes through construct a shape representing the attenuated source.
* The attenuated shape is a circle with a radius modified by origin proximity to the threshold wall.
* Intersect the attenuated shape against the LOS with threshold walls considered.
* The result is the LOS for the attenuated light source.
* @param {PointSourcePolygon} thresholdPolygon The computed polygon with thresholds applied
* @param {Edge[]} edges The identified array of threshold walls
* @returns {ClipperPoints[]} The resulting array of intersected threshold shapes
*/
static #createThresholdShapes(thresholdPolygon, edges) {
const cps = thresholdPolygon.toClipperPoints();
const origin = thresholdPolygon.origin;
const {radius, externalRadius, type} = thresholdPolygon.config;
const shapes = [];
// Iterate over threshold walls
for ( const edge of edges ) {
let thresholdShape;
// Create attenuated shape
if ( edge.threshold.attenuation ) {
const r = PointSourcePolygon.#calculateThresholdAttenuation(edge, origin, radius, externalRadius, type);
if ( !r.outside ) continue;
thresholdShape = new PIXI.Circle(origin.x, origin.y, r.inside + r.outside);
}
// No attenuation, use the full circle
else thresholdShape = new PIXI.Circle(origin.x, origin.y, radius);
// Intersect each shape against the LOS
const ix = thresholdShape.intersectClipper(cps, {convertSolution: false});
if ( ix.length && ix[0].length > 2 ) shapes.push(ix[0]);
}
return shapes;
}
/* -------------------------------------------- */
/**
* Calculate the attenuation of the source as it passes through the threshold wall.
* The distance of perception through the threshold wall depends on proximity of the source from the wall.
* @param {Edge} edge The Edge for which this threshold applies
* @param {Point} origin Origin point on the canvas for this source
* @param {number} radius Radius to use for this source, before considering attenuation
* @param {number} externalRadius The external radius of the source
* @param {string} type Sense type for the source
* @returns {{inside: number, outside: number}} The inside and outside portions of the radius
*/
static #calculateThresholdAttenuation(edge, origin, radius, externalRadius, type) {
const d = edge.threshold?.[type];
if ( !d ) return { inside: radius, outside: radius };
const proximity = edge[type] === CONST.WALL_SENSE_TYPES.PROXIMITY;
// Find the closest point on the threshold wall to the source.
// Calculate the proportion of the source radius that is "inside" and "outside" the threshold wall.
const pt = foundry.utils.closestPointToSegment(origin, edge.a, edge.b);
const inside = Math.hypot(pt.x - origin.x, pt.y - origin.y);
const outside = radius - inside;
if ( (outside < 0) || outside.almostEqual(0) ) return { inside, outside: 0 };
// Attenuate the radius outside the threshold wall based on source proximity to the wall.
const sourceDistance = proximity ? Math.max(inside - externalRadius, 0) : (inside + externalRadius);
const percentDistance = sourceDistance / d;
const pInv = proximity ? 1 - percentDistance : Math.min(1, percentDistance - 1);
const a = (pInv / (2 * (1 - pInv))) * CONFIG.Wall.thresholdAttenuationMultiplier;
return { inside, outside: Math.min(a * d, outside) };
}
/* -------------------------------------------- */
/**
* Union the attenuated shape-LOS intersections with the closed LOS.
* The portion of the light sources "inside" the threshold walls are not modified from their default radius or shape.
* Clipper can union everything at once. Use a positive fill to avoid checkerboard; fill any overlap.
* @param {PointSourcePolygon} los The LOS polygon with threshold walls inactive
* @param {ClipperPoints[]} shapes Attenuation shapes for threshold walls
* @returns {PIXI.Polygon} The combined LOS polygon with threshold shapes
*/
static #combineThresholdShapes(los, shapes) {
const c = new ClipperLib.Clipper();
const combined = [];
const cPaths = [los.toClipperPoints(), ...shapes];
c.AddPaths(cPaths, ClipperLib.PolyType.ptSubject, true);
const p = ClipperLib.PolyFillType.pftPositive;
c.Execute(ClipperLib.ClipType.ctUnion, combined, p, p);
return PIXI.Polygon.fromClipperPoints(combined.length ? combined[0] : []);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/** @ignore */
get rays() {
foundry.utils.logCompatibilityWarning("You are referencing PointSourcePolygon#rays which is no longer a required "
+ "property of that interface. If your subclass uses the rays property it should be explicitly defined by the "
+ "subclass which requires it.", {since: 11, until: 13});
return this.#rays;
}
set rays(rays) {
this.#rays = rays;
}
/** @deprecated since v11 */
#rays = [];
}