Initial
This commit is contained in:
161
resources/app/client/pixi/core/shapes/limited-angle-polygon.js
Normal file
161
resources/app/client/pixi/core/shapes/limited-angle-polygon.js
Normal 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);
|
||||
}
|
||||
}
|
||||
446
resources/app/client/pixi/core/shapes/polygon-mesher.js
Normal file
446
resources/app/client/pixi/core/shapes/polygon-mesher.js
Normal 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);
|
||||
}
|
||||
}
|
||||
37
resources/app/client/pixi/core/shapes/precise-text.js
Normal file
37
resources/app/client/pixi/core/shapes/precise-text.js
Normal 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;
|
||||
}
|
||||
}
|
||||
248
resources/app/client/pixi/core/shapes/ray.js
Normal file
248
resources/app/client/pixi/core/shapes/ray.js
Normal 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]});
|
||||
}
|
||||
}
|
||||
531
resources/app/client/pixi/core/shapes/source-polygon.js
Normal file
531
resources/app/client/pixi/core/shapes/source-polygon.js
Normal 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 = [];
|
||||
}
|
||||
Reference in New Issue
Block a user