From 5de0d9d6ac0567ebe9c0cdb8f9fdeb172166d407 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 11 Jan 2024 14:16:33 -0800 Subject: [PATCH 01/25] WIP --- module.json | 4 + scripts/PriorityQueue.js | 120 +++++++ scripts/PriorityQueueArray.js | 104 ++++++ scripts/delaunator/ISC license.txt | 17 + scripts/delaunator/delaunator.min.js | 1 + scripts/pathfinding.js | 494 +++++++++++++++++++++++++++ 6 files changed, 740 insertions(+) create mode 100644 scripts/PriorityQueue.js create mode 100644 scripts/PriorityQueueArray.js create mode 100644 scripts/delaunator/ISC license.txt create mode 100644 scripts/delaunator/delaunator.min.js create mode 100644 scripts/pathfinding.js diff --git a/module.json b/module.json index aed2d1a..26d406a 100644 --- a/module.json +++ b/module.json @@ -26,6 +26,10 @@ } ] }, + "scripts": [ + "/scripts/delaunator/delaunator.min.js" + ], + "esmodules": [ "/scripts/module.js" ], diff --git a/scripts/PriorityQueue.js b/scripts/PriorityQueue.js new file mode 100644 index 0000000..466eb6a --- /dev/null +++ b/scripts/PriorityQueue.js @@ -0,0 +1,120 @@ +// Priority queue using a heap +// from https://www.digitalocean.com/community/tutorials/js-binary-heaps + +class Node { + constructor(val, priority) { + this.val = val; + this.priority = priority; + } +} + + +/** + * Priority queue using max heap. + * Highest priority item will be dequeued first. + */ +export class PriorityQueue { + constructor() { + this.values = []; + } + + get length() { return this.values.length; } + + + /** + * Convert a sorted array to a queue + */ + static fromArray(arr, priorityFn) { + const pq = new this(); + + pq.values = arr.map(elem => new Node(elem, priorityFn(elem))); + pq.values = radixSortObj(pq.values, "priority").reverse(); + + return pq; + } + + /** + * Add an object to the queue. + * @param {Object} val Object to store in the queue + * @param {number} priority Priority of the object to store + */ + enqueue(val, priority) { + let newNode = new Node(val, priority); + this.values.push(newNode); + let index = this.values.length - 1; + const current = this.values[index]; + + while(index > 0) { + let parentIndex = Math.floor((index - 1) / 2); + let parent = this.values[parentIndex]; + + if(parent.priority <= current.priority) { + this.values[parentIndex] = current; + this.values[index] = parent; + index = parentIndex; + } else break; + } + } + + /** + * Remove the highest-remaining-priority object from the queue. + * @return {Object|undefined} The highest-priority object stored. + * Undefined if queue is empty. + */ + dequeue() { + if(this.values.length < 2) return this.values.pop(); + + const max = this.values[0]; + const end = this.values.pop(); + this.values[0] = end; + + let index = 0; + const length = this.values.length; + const current = this.values[0]; + while(true) { + let leftChildIndex = 2 * index + 1; + let rightChildIndex = 2 * index + 2; + let leftChild, rightChild; + let swap = null; + + if(leftChildIndex < length) { + leftChild = this.values[leftChildIndex]; + if(leftChild.priority > current.priority) swap = leftChildIndex; + } + + if(rightChildIndex < length) { + rightChild = this.values[rightChildIndex]; + if((swap === null && rightChild.priority > current.priority) || + (swap !== null && rightChild.priority > leftChild.priority)) { + swap = rightChildIndex; + } + } + + if(swap === null) break; + this.values[index] = this.values[swap]; + this.values[swap] = current; + index = swap; + } + + return max; + } +} + +/* test +let tree = new PriorityQueue(); +tree.enqueue(3,2); +tree.enqueue(4, 5); +tree.enqueue(31, 1); +tree.enqueue(6, 3); +console.log(tree.dequeue()); // 4 +console.log(tree.dequeue()); // 6 +console.log(tree.dequeue()); // 3 +console.log(tree.dequeue()); // 31 + +// from an array +priorityFn = (a) => a; +arr = [1,3,2,10,5] +tree = PriorityQueue.fromArray(arr, priorityFn); +tree.dequeue() + +*/ \ No newline at end of file diff --git a/scripts/PriorityQueueArray.js b/scripts/PriorityQueueArray.js new file mode 100644 index 0000000..aa68bb3 --- /dev/null +++ b/scripts/PriorityQueueArray.js @@ -0,0 +1,104 @@ +// Very basic priority queue based on an array +// Allows for a custom comparator on which to sort and the option to switch the +// initial sort algorithm + +import { binaryFindIndex } from "./BinarySearch.js"; + +export class PriorityQueueArray { + + /** + * @param {Object[]} arr Array of objects to queue. Not copied. + * @param {Function} comparator How to organize the queue. Used for initial sort + * and to insert additional elements. + * Should sort the highest priority item last. + */ + constructor(arr, { comparator = (a, b) => a - b, + sort = (arr, cmp) => arr.sort(cmp) } = {}) { + + this.sort = sort; + this.comparator = comparator + this.data = arr; + this.sort(this.data, this.comparator); + } + + /** + * Length of the queue + * @type {number} + */ + get length() { return this.data.length; } + + /** + * Examine the highest priority item in the queue without removing it. + * @return {Object} + */ + get peek() { return this.data[this.data.length - 1]; } + + /** + * Retrieve the next element of the queue + * @return {Object} Highest priority item in queue. + */ + next() { return this.data.pop(); } + + + /** + * Insert an object in the array using a linear search, O(n), to locate the position. + * @param {Object} obj Object to insert + * @return {number} Index where the object was inserted. + */ + insert(obj) { + const idx = this.data.findIndex(elem => this._elemIsAfter(obj, elem)); + this._insertAt(obj, idx); + } + + /** + * Insert an object in the array using a binary search, O(log(n)). + * Requires that the array is strictly sorted according to the comparator function. + * @param {Object} obj Object to insert + */ + binaryInsert(obj) { + const idx = binaryFindIndex(this.data, elem => this._elemIsAfter(obj, elem)); + this._insertAt(obj, idx); + } + + /** + * Helper to insert an object at a specified index. Inserts at end if index is -1. + * @param {Object} obj Object to insert + * @param {number} idx Location to insert + */ + _insertAt(obj, idx) { +// ~idx ? (this.data = this.data.slice(0, idx).concat(obj, this.data.slice(idx))) : this.data.push(obj); + ~idx ? this.data.splice(idx, undefined, obj) : this.data.push(obj); + } + + /** + * Remove object + */ + remove(obj) { + const idx = this.data.findIndex(elem => this._elemIsAfter(obj, elem)); + this._removeAt(idx); + } + + /** + * Remove object using binary search + */ + binaryRemove(obj) { + const idx = binaryFindIndex(this.data, elem => this._elemIsAfter(obj, elem)); + this._removeAt(idx); + } + + /** + * Helper to remove an object at a specified index. + */ + _removeAt(idx) { + this.data.splice(idx, 1); + } + + /** + * Helper function transforming the comparator output to true/false; used by insert. + * @param {Object} obj Object to search for + * @param {Object} elem Element of the array + * @return {boolean} True if the element is after the segment in the ordered array. + */ + _elemIsAfter(obj, elem) { return this.comparator(obj, elem) < 0; } + +} \ No newline at end of file diff --git a/scripts/delaunator/ISC license.txt b/scripts/delaunator/ISC license.txt new file mode 100644 index 0000000..2d47b88 --- /dev/null +++ b/scripts/delaunator/ISC license.txt @@ -0,0 +1,17 @@ +https://github.com/mapbox/delaunator + +ISC License + +Copyright (c) 2021, Mapbox + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. \ No newline at end of file diff --git a/scripts/delaunator/delaunator.min.js b/scripts/delaunator/delaunator.min.js new file mode 100644 index 0000000..7a812a5 --- /dev/null +++ b/scripts/delaunator/delaunator.min.js @@ -0,0 +1 @@ +!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).Delaunator=i()}(this,(function(){"use strict";const t=134217729;function i(t,i,s,e,n){let h,r,l,o,a=i[0],f=e[0],c=0,u=0;f>a==f>-a?(h=a,a=i[++c]):(h=f,f=e[++u]);let _=0;if(ca==f>-a?(r=a+h,l=h-(r-a),a=i[++c]):(r=f+h,l=h-(r-f),f=e[++u]),h=r,0!==l&&(n[_++]=l);ca==f>-a?(r=h+a,o=r-h,l=h-(r-o)+(a-o),a=i[++c]):(r=h+f,o=r-h,l=h-(r-o)+(f-o),f=e[++u]),h=r,0!==l&&(n[_++]=l);for(;c0!=d>0)return g;const y=Math.abs(_+d);return Math.abs(g)>=33306690738754716e-32*y?g:-function(s,o,a,f,c,u,_){let d,g,y,w,b,A,k,M,p,x,S,T,z,U,m,K,L,v;const F=s-c,P=a-c,E=o-u,H=f-u;U=F*H,A=t*F,k=A-(A-F),M=F-k,A=t*H,p=A-(A-H),x=H-p,m=M*x-(U-k*p-M*p-k*x),K=E*P,A=t*E,k=A-(A-E),M=E-k,A=t*P,p=A-(A-P),x=P-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,e[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,e[1]=z-(S+b)+(b-K),v=T+S,b=v-T,e[2]=T-(v-b)+(S-b),e[3]=v;let I=function(t,i){let s=i[0];for(let e=1;e=N||-I>=N)return I;if(b=s-F,d=s-(F+b)+(b-c),b=a-P,y=a-(P+b)+(b-c),b=o-E,g=o-(E+b)+(b-u),b=f-H,w=f-(H+b)+(b-u),0===d&&0===g&&0===y&&0===w)return I;if(N=11093356479670487e-47*_+33306690738754706e-32*Math.abs(I),I+=F*w+H*d-(E*y+P*g),I>=N||-I>=N)return I;U=d*H,A=t*d,k=A-(A-d),M=d-k,A=t*H,p=A-(A-H),x=H-p,m=M*x-(U-k*p-M*p-k*x),K=g*P,A=t*g,k=A-(A-g),M=g-k,A=t*P,p=A-(A-P),x=P-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const j=i(4,e,4,l,n);U=F*w,A=t*F,k=A-(A-F),M=F-k,A=t*w,p=A-(A-w),x=w-p,m=M*x-(U-k*p-M*p-k*x),K=E*y,A=t*E,k=A-(A-E),M=E-k,A=t*y,p=A-(A-y),x=y-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const q=i(j,n,4,l,h);U=d*w,A=t*d,k=A-(A-d),M=d-k,A=t*w,p=A-(A-w),x=w-p,m=M*x-(U-k*p-M*p-k*x),K=g*y,A=t*g,k=A-(A-g),M=g-k,A=t*y,p=A-(A-y),x=y-p,L=M*x-(K-k*p-M*p-k*x),S=m-L,b=m-S,l[0]=m-(S+b)+(b-L),T=U+S,b=T-U,z=U-(T-b)+(S-b),S=z-K,b=z-S,l[1]=z-(S+b)+(b-K),v=T+S,b=v-T,l[2]=T-(v-b)+(S-b),l[3]=v;const D=i(q,h,4,l,r);return r[D-1]}(s,o,a,f,c,u,y)}const a=Math.pow(2,-52),f=new Uint32Array(512);class c{static from(t,i=w,s=b){const e=t.length,n=new Float64Array(2*e);for(let h=0;h>1;if(i>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const s=Math.max(2*i-5,0);this._triangles=new Uint32Array(3*s),this._halfedges=new Int32Array(3*s),this._hashSize=Math.ceil(Math.sqrt(i)),this._hullPrev=new Uint32Array(i),this._hullNext=new Uint32Array(i),this._hullTri=new Uint32Array(i),this._hullHash=new Int32Array(this._hashSize).fill(-1),this._ids=new Uint32Array(i),this._dists=new Float64Array(i),this.update()}update(){const{coords:t,_hullPrev:i,_hullNext:s,_hullTri:e,_hullHash:n}=this,h=t.length>>1;let r=1/0,l=1/0,f=-1/0,c=-1/0;for(let i=0;if&&(f=s),e>c&&(c=e),this._ids[i]=i}const _=(r+f)/2,y=(l+c)/2;let w,b,A,k=1/0;for(let i=0;i0&&(b=i,k=s)}let x=t[2*b],S=t[2*b+1],T=1/0;for(let i=0;ie&&(i[s++]=n,e=this._dists[n])}return this.hull=i.subarray(0,s),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(o(M,p,x,S,z,U)<0){const t=b,i=x,s=S;b=A,x=z,S=U,A=t,z=i,U=s}const m=function(t,i,s,e,n,h){const r=s-t,l=e-i,o=n-t,a=h-i,f=r*r+l*l,c=o*o+a*a,u=.5/(r*a-l*o);return{x:t+(a*f-l*c)*u,y:i+(r*c-o*f)*u}}(M,p,x,S,z,U);this._cx=m.x,this._cy=m.y;for(let i=0;i0&&Math.abs(c-h)<=a&&Math.abs(u-r)<=a)continue;if(h=c,r=u,f===w||f===b||f===A)continue;let _=0;for(let t=0,i=this._hashKey(c,u);t=0;)if(g=d,g===_){g=-1;break}if(-1===g)continue;let y=this._addTriangle(g,f,s[g],-1,-1,e[g]);e[f]=this._legalize(y+2),e[g]=y,K++;let k=s[g];for(;d=s[k],o(c,u,t[2*k],t[2*k+1],t[2*d],t[2*d+1])<0;)y=this._addTriangle(k,f,d,e[f],-1,e[k]),e[f]=this._legalize(y+2),s[k]=k,K--,k=d;if(g===_)for(;d=i[g],o(c,u,t[2*d],t[2*d+1],t[2*g],t[2*g+1])<0;)y=this._addTriangle(d,f,g,-1,e[g],e[d]),this._legalize(y+2),e[d]=y,s[g]=g,K--,g=d;this._hullStart=i[f]=g,s[g]=i[k]=f,s[f]=k,n[this._hashKey(c,u)]=f,n[this._hashKey(t[2*g],t[2*g+1])]=g}this.hull=new Uint32Array(K);for(let t=0,i=this._hullStart;t0?3-s:1+s)/4}(t-this._cx,i-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:i,_halfedges:s,coords:e}=this;let n=0,h=0;for(;;){const r=s[t],l=t-t%3;if(h=l+(t+2)%3,-1===r){if(0===n)break;t=f[--n];continue}const o=r-r%3,a=l+(t+1)%3,c=o+(r+2)%3,u=i[h],d=i[t],g=i[a],y=i[c];if(_(e[2*u],e[2*u+1],e[2*d],e[2*d+1],e[2*g],e[2*g+1],e[2*y],e[2*y+1])){i[t]=y,i[r]=u;const e=s[c];if(-1===e){let i=this._hullStart;do{if(this._hullTri[i]===c){this._hullTri[i]=t;break}i=this._hullPrev[i]}while(i!==this._hullStart)}this._link(t,e),this._link(r,s[h]),this._link(h,c);const l=o+(r+1)%3;n=s&&i[t[r]]>h;)t[r+1]=t[r--];t[r+1]=e}else{let n=s+1,h=e;y(t,s+e>>1,n),i[t[s]]>i[t[e]]&&y(t,s,e),i[t[n]]>i[t[e]]&&y(t,n,e),i[t[s]]>i[t[n]]&&y(t,s,n);const r=t[n],l=i[r];for(;;){do{n++}while(i[t[n]]l);if(h=h-s?(g(t,i,n,e),g(t,i,s,h-1)):(g(t,i,s,h-1),g(t,i,n,e))}}function y(t,i,s){const e=t[i];t[i]=t[s],t[s]=e}function w(t){return t[0]}function b(t){return t[1]}return c})); \ No newline at end of file diff --git a/scripts/pathfinding.js b/scripts/pathfinding.js new file mode 100644 index 0000000..7bfa490 --- /dev/null +++ b/scripts/pathfinding.js @@ -0,0 +1,494 @@ + +Draw = CONFIG.GeometryLib.Draw; + + +wall0 = canvas.placeables.walls[0] +wall1 = canvas.placeables.walls[1] + + + +/* Need to be able to identify points at open walls. +Types: + • +Open wall: ------ • + • + +Need three points to navigate around the wall from any position w/o running into the wall. + +Convex wall: ------ • + | + +No point on inside b/c it does not represent valid position. +Can use one point so long as it is exactly halfway on the outside (larger) angle + +180º wall: ----- ------ No point needed. + +Points are all 2d, so we can use PIXI.Point keys with a set. +*/ + + +/* Visibility Point +{number} key PIXI.Point key of the endpoint. +{PIXI.Point} dir Directional vector. Used +{PIXI.Point} loc coordinate of the endpoint +{Set} walls Walls linked to this endpoint +*/ + +let { Graph, GraphVertex, GraphEdge } = CONFIG.GeometryLib.Graph; + +Draw.clearDrawings() +visibilityPoints = new Map(); +calculateVisibilityPoints(); +drawVisibilityPoints() + + +/* Build graph +For each visibility point, scale it out by some distance based on the grid. +Center on the grid square. +Use a key and a map, making each GraphVertex a location. + +GraphEdge connects if there is visibility (no collisions) between the two vertices. +*/ + +// Probably need some sort of tracking so change to wall can modify the correct points. +scaledVisibilityPoints = new Set(); +spacer = 100; +for ( const [key, pts] of visibilityPoints.entries() ) { + const endpoint = PIXI.Point.invertKey(key); + for ( const pt of pts ) { + const scaledPoint = endpoint.add(pt.multiplyScalar(spacer)); + const center = canvas.grid.grid.getCenter(scaledPoint.x, scaledPoint.y); + scaledVisibilityPoints.add((new PIXI.Point(center[0], center[1])).key); + } +} + +// Visibility test to construct the edges. +graph = new Graph(); + +vertices = scaledVisibilityPoints.map(key => new GraphVertex(key)); +verticesArr = [...vertices]; +nVertices = verticesArr.length; +collisionCfg = { mode: "any", type: "move" } +for ( let i = 0; i < nVertices; i += 1 ) { + const iV = verticesArr[i]; + const iPt = PIXI.Point.invertKey(iV.value) + for ( let j = i + 1; j < nVertices; j += 1 ) { + const jV = verticesArr[j]; + const jPt = PIXI.Point.invertKey(jV.value); + // if ( CONFIG.Canvas.polygonBackends.move.testCollision(iPt, jPt, collisionCfg) ) continue; + + const distance = canvas.grid.grid.measureDistances([{ ray: new Ray(iPt, jPt) }])[0]; + graph.addEdgeVertices(iV, jV, distance); + } +} + +// For each vertex, keep the best N edges that don't have a collision. +// Store these edges in a new graph +trimmedGraph = new Graph(); +MAX_EDGES = 4; +for ( const vertex of graph.getAllVertices() ) { + const edges = vertex.edges.sort((a, b) => a.weight - b.weight); + let n = 1; + for ( const edge of edges ) { + if ( n > MAX_EDGES ) break; + const A = PIXI.Point.invertKey(edge.A.value); + const B = PIXI.Point.invertKey(edge.B.value); + if ( CONFIG.Canvas.polygonBackends.move.testCollision(A, B, collisionCfg) ) continue; + n += 1; + trimmedGraph.addEdge(edge); + } +} + + + +// Draw graph vertices +vertices = graph.getAllVertices(); +for ( const vertex of vertices ) { + Draw.point(PIXI.Point.invertKey(vertex.value), { color: Draw.COLORS.green }); +} + +// Draw the graph edges +edges = graph.getAllEdges(); +for ( const edge of edges ) { + const A = PIXI.Point.invertKey(edge.A.value); + const B = PIXI.Point.invertKey(edge.B.value); + Draw.segment({ A, B }) +} + +edges = trimmedGraph.getAllEdges(); +for ( const edge of edges ) { + const A = PIXI.Point.invertKey(edge.A.value); + const B = PIXI.Point.invertKey(edge.B.value); + Draw.segment({ A, B }, { color: Draw.COLORS.green }) +} + + + + + + + + + +function drawVisibilityPoints(spacer = 100) { + for ( const [key, pts] of visibilityPoints.entries() ) { + const endpoint = PIXI.Point.invertKey(key); + for ( const pt of pts ) { + Draw.point(endpoint.add(pt.multiplyScalar(spacer))); + } + } + +} + + +function calculateVisibilityPoints() { + const A = new PIXI.Point(); + const B = new PIXI.Point() + for (const wall of canvas.walls.placeables ) { + A.copyFrom(wall.vertices.a); + B.copyFrom(wall.vertices.b); + + // Determine the visibility point direction(s) for A and B + if ( !visibilityPoints.has(A.key) ) visibilityPoints.set(A.key, findVisibilityDirections(A, B)); + if ( !visibilityPoints.has(B.key) ) visibilityPoints.set(B.key, findVisibilityDirections(B, A)); + } +} + + + + + +let PI2 = Math.PI * 2; +function findVisibilityDirections(endpoint, other) { + const key = endpoint.key; + const linkedWalls = canvas.walls.placeables.filter(w => w.wallKeys.has(key)) + if ( !linkedWalls.length ) return []; // Shouldn't happen. + if ( linkedWalls.length < 2 ) { + // Open wall point. Needs three visibility points. + const dir = endpoint.subtract(other).normalize(); + return [ dir, new PIXI.Point(-dir.y, dir.x), new PIXI.Point(dir.y, -dir.x) ]; + } + + // Find the maximum angle between all the walls. + // Test each wall combination once. + let maxAngle = 0; + // let clockwise = true; + let aEndpoint; + let cEndpoint; + + const nWalls = linkedWalls.length; + for ( let i = 0; i < nWalls; i += 1 ) { + const iWall = linkedWalls[i]; + const a = iWall.vertices.a.key === key ? iWall.vertices.b : iWall.vertices.a; + for ( let j = i + 1; j < nWalls; j += 1 ) { + const jWall = linkedWalls[j]; + const c = jWall.vertices.a.key === key ? jWall.vertices.b : jWall.vertices.a; + const angleI = PIXI.Point.angleBetween(a, endpoint, c, { clockwiseAngle: true }); + if ( angleI.almostEqual(Math.PI) ) return []; // 180º, precludes any other direction from being > 180º. + + if ( angleI > Math.PI & angleI > maxAngle ) { + maxAngle = angleI; + //clockwise = true; + aEndpoint = a; + cEndpoint = c; + } else if ( (PI2 - angleI) > maxAngle ) { + maxAngle = PI2 - angleI; + //clockwise = false; + aEndpoint = a; + cEndpoint = c; + } + } + } + + // Calculate the direction for 1/2 of the maximum angle + const ab = endpoint.subtract(aEndpoint).normalize(); + const cb = endpoint.subtract(cEndpoint).normalize(); + return [ab.add(cb).multiplyScalar(0.5).normalize()]; + + // To test: + // res = ab.add(cb).multiplyScalar(0.5); + // Draw.point(endpoint.add(res.multiplyScalar(100))) +} + + + +// Alternative: Triangulate +/* +Take the 4 corners plus coordinates of each wall endpoint. +(TODO: Use wall edges to capture overlapping walls) + +Triangulate. + +Can traverse using the half-edge structure. + +Start in a triangle. For now, traverse between triangles at midpoints. +Triangle coords correspond to a wall. Each triangle edge may or may not block. +Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). +This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. + +Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. +Goal heuristic based on distance (modified by terrain?). +Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. + +Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. + +*/ + + +sceneRect = canvas.scene.dimensions.sceneRect; +TL = new PIXI.Point(sceneRect.left, sceneRect.top); +TR = new PIXI.Point(sceneRect.right, sceneRect.top); +BR = new PIXI.Point(sceneRect.right, sceneRect.bottom); +BL = new PIXI.Point(sceneRect.left, sceneRect.bottom); +endpointKeys = new Set([TL.key, TR.key, BR.key, BL.key]); + +for ( const wall of canvas.walls.placeables ) { + endpointKeys.add(wall.vertices.a.key); + endpointKeys.add(wall.vertices.b.key); +} + +coords = new Uint32Array(endpointKeys.size * 2); +let i = 0; +for ( const key of endpointKeys ) { + const pt = PIXI.Point.invertKey(key); + coords[i] = pt.x; + coords[i + 1] = pt.y; + i += 2; +} + +delaunay = new Delaunator(coords); + +// Draw each endpoint +for ( const key of endpointKeys ) { + const pt = PIXI.Point.invertKey(key); + Draw.point(pt, { color: Draw.COLORS.blue }) +} + +// Draw each triangle +triangles = []; +for (let i = 0; i < delaunay.triangles.length; i += 3) { + const j = delaunay.triangles[i] * 2; + const k = delaunay.triangles[i + 1] * 2; + const l = delaunay.triangles[i + 2] * 2; + triangles.push(new PIXI.Polygon( + delaunay.coords[j], delaunay.coords[j + 1], + delaunay.coords[k], delaunay.coords[k + 1], + delaunay.coords[l], delaunay.coords[l + 1] + )); +} + +for ( const tri of triangles ) Draw.shape(tri); + + +// Build set of border triangles +borderTriangles = new Map(); +for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { + const j = delaunay.triangles[i] * 2; + const k = delaunay.triangles[i + 1] * 2; + const l = delaunay.triangles[i + 2] * 2; + const tri = new BorderTriangle( + delaunay.coords[j], delaunay.coords[j + 1], + delaunay.coords[k], delaunay.coords[k + 1], + delaunay.coords[l], delaunay.coords[l + 1]); + borderTriangles.set(ii, tri); + tri.id = ii; +} + +// Set the half-edges +EDGE_NAMES = ["neighborAB", "neighborBC", "neighborCA"]; +let i = 0 +for ( i = 0; i < delaunay.halfedges.length; i += 1 ) { + const halfEdgeIndex = delaunay.halfedges[i]; + if ( !~halfEdgeIndex ) continue; + const triFrom = borderTriangles.get(Math.floor(i / 3)); + const triTo = borderTriangles.get(Math.floor(halfEdgeIndex / 3)); + + // Always a, b, c in order (b/c ccw) + const fromEdge = EDGE_NAMES[i % 3]; + const toEdge = EDGE_NAMES[halfEdgeIndex % 3]; + triFrom[fromEdge] = triTo; + triTo[toEdge] = triFrom; +} + + +borderTriangles.forEach(tri => tri.draw()); +borderTriangles.forEach(tri => tri.drawLinks()) + +// Map of wall keys corresponding to walls. +wallKeys = new Map(); +for ( const wall of canvas.walls.placeables ) { + wallKeys.set(wall.vertices.a.key, wall); + wallKeys.set(wall.vertices.b.key, wall); +} + +// Set the wall(s) for each triangle edge + + + +/* For the triangles, need: +√ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. + --> Used to find where a start/end point is located. + --> Needs to also handle when on a line. PIXI.Polygon contains returns true if on top or left but not right or bottom. +- Look up wall for each edge. +√ Link to adjacent triangle via half-edge +- Provide 2x corner + median pass-through points +*/ + +/** + * An edge that makes up the triangle-shaped polygon + */ +class BorderEdge { + /** @type {PIXI.Point} */ + a = new PIXI.Point(); + + /** @type {PIXI.Point} */ + b = new PIXI.Point(); + + /** @type {BorderTriangle} */ + cwTriangle; + + /** @type {BorderTriangle} */ + ccwTriangle; + + constructor(a, b) { + + } + + /** + * Link a triangle to this edge, replacing any previous triangle in that position. + */ + linkTriangle(triangle) { + const triEndpoints = new Set([triangle.a, triangle.b, triangle.c]); + + otherEndpoint = + + } + + + +} + +/** + * A triangle-shaped polygon. + * Assumed static---points cannot change. + * Note: delaunay triangles from Delaunator are oriented counterclockwise + */ +class BorderTriangle extends PIXI.Polygon { + /** @type {PIXI.Point} */ + a = new PIXI.Point(); + + /** @type {PIXI.Point} */ + b = new PIXI.Point(); + + /** @type {PIXI.Point} */ + c = new PIXI.Point(); + + /** @type {number} */ + id = -1; + + constructor(...args) { + super(...args); + if ( this.points.length !== 6 ) throw new Error("Border Triangle must have 6 coordinates"); + + + } + + /** + * Initialize properties based on the unchanging coordinates. + */ + _initialize() { + // Orient a --> b --> c counterclockwise + if ( this.isClockwise ) this.reverseOrientation(); + this.a.x = this.points[0]; + this.a.y = this.points[1]; + this.b.x = this.points[2]; + this.b.y = this.points[3]; + this.c.x = this.points[4]; + this.c.y = this.points[5]; + + // Get the + + } + + /** + * Calculate coordinate index in the Delaunay set. + */ + delaunayCoordinate(vertex = "a") { + switch ( vertex ) { + case "a": return BorderTriangle.delauney + } + } + + /** + * Contains method based on orientation. + * More inclusive than PIXI.Polygon.prototype.contains in that any point on the edge counts. + * @param {number} x X coordinate of point to test + * @param {number} y Y coordinate of point to test + * @returns {boolean} + * - False if not contained at all + * - + */ + containsPoint(pt) { + const orient2d = foundry.utils.orient2dFast; + return orient2d(a, b, pt) >= 0 + && orient2d(b, c, pt) >= 0 + && orient2d(c, a, pt) >= 0; + } + + /** + * Replace getBounds with static version. + * @returns {PIXI.Rectangle} + */ + + /** @type {PIXI.Rectangle} */ + #bounds; + + get bounds() { return this.#bounds || (this.#bounds = this._getBounds()); } + + getBounds() { return this.#bounds || (this.#bounds = this._getBounds()); } + + _getBounds() { + const xMinMax = Math.minMax(this.a.x, this.b.x, this.c.x); + const yMinMax = Math.minMax(this.a.y, this.b.y, this.c.y); + return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); + } + + /** + * Links to neighboring triangles. + * @type {BorderTriangle} + */ + neighborAB; + + neighborBC; + + neighborCA; + + /** + * Links to the wall(s) representing each edge + * @type {Set} + */ + wallsAB; + + wallsBC; + + wallsCA; + + /** + * Debug helper to draw the triangle. + */ + draw(opts = {}) { + Draw.shape(this, opts); + if ( ~this.id ) Draw.labelPoint(this.center, this.id.toString()); + } + + /* + * Draw links to other triangles. + */ + drawLinks() { + const center = this.center; + if ( this.neighborAB ) Draw.segment({ A: center, B: this.neighborAB.center }); + if ( this.neighborBC ) Draw.segment({ A: center, B: this.neighborBC.center }); + if ( this.neighborCA ) Draw.segment({ A: center, B: this.neighborCA.center }); + } +} + From 54483de446bba0176d1e0893cabfc014454b5207 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 13 Jan 2024 13:50:11 -0800 Subject: [PATCH 02/25] Add BorderTriangle, BorderEdge classes --- scripts/pathfinding/BorderTriangle.js | 331 ++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 scripts/pathfinding/BorderTriangle.js diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js new file mode 100644 index 0000000..cc05880 --- /dev/null +++ b/scripts/pathfinding/BorderTriangle.js @@ -0,0 +1,331 @@ +/* globals +canvas +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { Draw } from "./geometry/Draw.js"; + +/** + * An edge that makes up the triangle-shaped polygon + */ +export class BorderEdge { + /** @type {PIXI.Point} */ + a = new PIXI.Point(); + + /** @type {PIXI.Point} */ + b = new PIXI.Point(); + + /** @type {Set} */ + endpointKeys = new Set(); + + /** @type {BorderTriangle} */ + cwTriangle; + + /** @type {BorderTriangle} */ + ccwTriangle; + + /** @type {Wall} */ + wall; + + constructor(a, b) { + this.a.copyFrom(a); + this.b.copyFrom(b); + this.endpointKeys.add(this.a.key); + this.endpointKeys.add(this.b.key); + } + + /** @type {PIXI.Point} */ + #median; + + get median() { return this.#median || (this.#median = this.a.add(this.b).multiplyScalar(0.5)); } + + /** @type {number} */ + #length; + + get length() { return this.#length || (this.#length = this.b.subtract(this.a).magnitude()); } + + /** + * Get the other triangle for this edge. + * @param {BorderTriangle} + * @returns {BorderTriangle} + */ + otherTriangle(triangle) { return this.cwTriangle === triangle ? this.ccwTriangle : this.cwTriangle; } + + /** + * Remove the triangle link. + * @param {BorderTriangle} + */ + removeTriangle(triangle) { + if ( this.cwTriangle === triangle ) this.cwTriangle = undefined; + if ( this.ccwTriangle === triangle ) this.ccwTriangle = undefined; + } + + /** + * Provide valid destinations for this edge. + * Blocked walls are invalid. + * Typically returns 2 corner destinations plus the median destination. + * If the edge is less than 2 * spacer, no destinations are valid. + * @param {Point} center Test if wall blocks from perspective of this origin point. + * @param {number} [spacer] How much away from the corner to set the corner destinations. + * If the edge is less than 2 * spacer, it will be deemed invalid. + * Corner destinations are skipped if not more than spacer away from median. + * @returns {PIXI.Point[]} + */ + getValidDestinations(origin, spacer) { + spacer ??= canvas.grid.size * 0.5; + const length = this.length; + const destinations = []; + + // No destination if edge is smaller than 2x spacer. + if ( length < (spacer * 2) || this.wallBlocks(origin) ) return destinations; + destinations.push(this.median); + + // Skip corners if not at least spacer away from median. + if ( length < (spacer * 4) ) return destinations; + + const { a, b } = this; + const t = spacer / length; + destinations.push( + a.projectToward(b, t), + b.projectToward(a, t)); + return destinations; + } + + + /** + * Does this edge wall block from an origin somewhere else in the triangle? + * Tested "live" and not cached so door or wall orientation changes need not be tracked. + * @param {Point} origin Measure wall blocking from perspective of this origin point. + * @returns {boolean} + */ + wallBlocks(origin) { + const wall = this.wall; + if ( !wall ) return false; + if ( !wall.document.move || wall.isOpen ) return false; + + // Ignore one-directional walls which are facing away from the center + const side = wall.orientPoint(origin); + const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; + if ( wall.document.dir + && (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false; + return true; + } + + /** + * Link a triangle to this edge, replacing any previous triangle in that position. + */ + linkTriangle(triangle) { + const { a, b } = this; + if ( !triangle.endpointKeys.has(a.key) + || !triangle.endpointKeys.has(b.key) ) throw new Error("Triangle does not share this edge!"); + + const { a: aTri, b: bTri, c: cTri } = triangle.vertices; + const otherEndpoint = !this.endpointKeys.has(aTri.key) ? aTri + : !this.endpointKeys.has(bTri.key) ? bTri + : cTri; + const orient2d = foundry.utils.orient2dFast; + if ( orient2d(a, b, otherEndpoint) > 0 ) this.ccwTriangle = triangle; + else this.cwTriangle = triangle; + } + + /** + * For debugging. + * Draw this edge. + */ + draw(opts = {}) { + opts.color ??= this.wall ? Draw.COLORS.red : Draw.COLORS.blue; + Draw.segment({ A: this.a, B: this.b }, opts); + } +} + +/** + * A triangle-shaped polygon. + * Assumed static---points cannot change. + * Note: delaunay triangles from Delaunator are oriented counterclockwise + */ +export class BorderTriangle { + vertices = { + a: new PIXI.Point(), /** @type {PIXI.Point} */ + b: new PIXI.Point(), /** @type {PIXI.Point} */ + c: new PIXI.Point() /** @type {PIXI.Point} */ + }; + + edges = { + AB: undefined, /** @type {BorderEdge} */ + BC: undefined, /** @type {BorderEdge} */ + CA: undefined /** @type {BorderEdge} */ + }; + + /** @type {BorderEdge} */ + + /** @type {Set} */ + endpointKeys = new Set(); + + /** @type {number} */ + id = -1; + + /** + * @param {Point} a + * @param {Point} b + * @param {Point} c + */ + constructor(edgeAB, edgeBC, edgeCA) { + // Determine the shared endpoint for each. + let a = edgeCA.endpointKeys.has(edgeAB.a.key) ? edgeAB.a : edgeAB.b; + let b = edgeAB.endpointKeys.has(edgeBC.a.key) ? edgeBC.a : edgeBC.b; + let c = edgeBC.endpointKeys.has(edgeCA.a.key) ? edgeCA.a : edgeCA.b; + + const oABC = foundry.utils.orient2dFast(a, b, c); + if ( !oABC ) throw Error("BorderTriangle requires three non-collinear points."); + if ( oABC < 0 ) { + // Flip to ccw. + [a, b, c] = [c, b, a]; + [edgeAB, edgeCA] = [edgeCA, edgeAB]; + } + + this.vertices.a.copyFrom(a); + this.vertices.b.copyFrom(b); + this.vertices.c.copyFrom(c); + + this.edges.AB = edgeAB; + this.edges.BC = edgeBC; + this.edges.CA = edgeCA; + + Object.values(this.vertices).forEach(v => this.endpointKeys.add(v.key)); + Object.values(this.edges).forEach(e => e.linkTriangle(this)); + } + + /** + * Construct a BorderTriangle from three points. + * Creates three new edges. + * @param {Point} a First point of the triangle + * @param {Point} b Second point of the triangle + * @param {Point} c Third point of the triangle + * @returns {BorderTriangle} + */ + static fromPoints(a, b, c) { + return new this( + new BorderEdge(a, b), + new BorderEdge(b, c), + new BorderEdge(c, a) + ); + } + + /** @type {Point} */ + #center; + + get center() { return this.#center + || (this.#center = this.vertices.a.add(this.vertices.b).add(this.vertices.c).multiplyScalar(1/3)); } + + /** + * Contains method based on orientation. + * More inclusive than PIXI.Polygon.prototype.contains in that any point on the edge counts. + * @param {number} x X coordinate of point to test + * @param {number} y Y coordinate of point to test + * @returns {boolean} + */ + contains(pt) { + const orient2d = foundry.utils.orient2dFast; + const { a, b, c } = this.vertices; + return orient2d(a, b, pt) >= 0 + && orient2d(b, c, pt) >= 0 + && orient2d(c, a, pt) >= 0; + } + + /** @type {PIXI.Rectangle} */ + #bounds; + + get bounds() { return this.#bounds || (this.#bounds = this._getBounds()); } + + getBounds() { return this.bounds; } + + _getBounds() { + const { a, b, c } = this.vertices; + const xMinMax = Math.minMax(a.x, b.x, c.x); + const yMinMax = Math.minMax(a.y, b.y, c.y); + return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); + } + + /** + * Provide valid destinations given that you came from a specific neighbor. + * Blocked walls are invalid. + * Typically returns 2 corner destinations plus the median destination. + * @param {Point} entryPoint + * @param {BorderTriangle|null} priorTriangle + * @param {number} spacer How much away from the corner to set the corner destinations. + * If the edge is less than 2 * spacer, it will be deemed invalid. + * Corner destinations are skipped if not more than spacer away from median. + * @returns {object[]} Each element has properties describing the destination: + * - {BorderTriangle} triangle + * - {Point} entryPoint + * - {number} distance + */ + getValidDestinations(entryPoint, priorTriangle, spacer) { + spacer ??= canvas.grid.size * 0.5; + const destinations = []; + const center = this.center; + for ( const edge of Object.values(this.edges) ) { + const neighbor = edge.otherTriangle(this); + if ( priorTriangle && priorTriangle === neighbor ) continue; + const pts = edge.getValidDestinations(center, spacer); + pts.forEach(pt => { + destinations.push({ + entryPoint: pt, + triangle: neighbor, + + // TODO: Handle 3d distances. + // Probably use canvas.grid.measureDistances, passing a Ray3d. + // TODO: Handle terrain distance + distance: canvas.grid.measureDistance(center, pt), + }); + }) + } + return destinations; + } + + /** + * Replace an edge in this triangle. + * Used to link triangles by an edge. + * @param {string} edgeName "AB"|"BC"|"CA" + */ + setEdge(edgeName, newEdge) { + const oldEdge = this.edges[edgeName]; + if ( !oldEdge ) { + console.error(`No edge with name ${edgeName} found.`); + return; + } + + if ( !(newEdge instanceof BorderEdge) ) { + console.error("BorderTriangle requires BorderEdge to replace an edge."); + return; + } + + if ( !(oldEdge.endpointKeys.has(newEdge.a.key) && oldEdge.endpointKeys.has(newEdge.b.key)) ) { + console.error("BorderTriangle edge replacement must have the same endpoints. Try building a new triangle instead."); + return; + } + + oldEdge.removeTriangle(this); + this.edges[edgeName] = newEdge; + newEdge.linkTriangle(this); + } + + /** + * For debugging. Draw edges on the canvas. + */ + drawEdges() { Object.values(this.edges).forEach(e => e.draw()); } + + /* + * Draw links to other triangles. + */ + drawLinks() { + const center = this.center; + for ( const edge of Object.values(this.edges) ) { + if ( edge.otherTriangle(this) ) { + const color = edge.wallBlocks(center) ? Draw.COLORS.orange : Draw.COLORS.green; + Draw.segment({ A: center, B: edge.median }, { color }); + + } + } + } +} \ No newline at end of file From d65e9c5bee840497988f7627cd6d0b2c5e7da977 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 13 Jan 2024 13:50:59 -0800 Subject: [PATCH 03/25] Triangulation tests with walls --- scripts/pathfinding.js | 417 +++++++++++++++++++++++++++++++---------- 1 file changed, 321 insertions(+), 96 deletions(-) diff --git a/scripts/pathfinding.js b/scripts/pathfinding.js index 7bfa490..f973fd4 100644 --- a/scripts/pathfinding.js +++ b/scripts/pathfinding.js @@ -2,6 +2,7 @@ Draw = CONFIG.GeometryLib.Draw; + wall0 = canvas.placeables.walls[0] wall1 = canvas.placeables.walls[1] @@ -235,14 +236,9 @@ Ultimately traverse by choosing midpoint or points 1 grid square from each endpo */ -sceneRect = canvas.scene.dimensions.sceneRect; -TL = new PIXI.Point(sceneRect.left, sceneRect.top); -TR = new PIXI.Point(sceneRect.right, sceneRect.top); -BR = new PIXI.Point(sceneRect.right, sceneRect.bottom); -BL = new PIXI.Point(sceneRect.left, sceneRect.bottom); -endpointKeys = new Set([TL.key, TR.key, BR.key, BL.key]); -for ( const wall of canvas.walls.placeables ) { +endpointKeys = new Set(); +for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { endpointKeys.add(wall.vertices.a.key); endpointKeys.add(wall.vertices.b.key); } @@ -280,50 +276,113 @@ for (let i = 0; i < delaunay.triangles.length; i += 3) { for ( const tri of triangles ) Draw.shape(tri); -// Build set of border triangles -borderTriangles = new Map(); +// Build array of border triangles +borderTriangles = []; for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { const j = delaunay.triangles[i] * 2; const k = delaunay.triangles[i + 1] * 2; const l = delaunay.triangles[i + 2] * 2; - const tri = new BorderTriangle( - delaunay.coords[j], delaunay.coords[j + 1], - delaunay.coords[k], delaunay.coords[k + 1], - delaunay.coords[l], delaunay.coords[l + 1]); - borderTriangles.set(ii, tri); - tri.id = ii; + + const a = { x: delaunay.coords[j], y: delaunay.coords[j + 1] }; + const b = { x: delaunay.coords[k], y: delaunay.coords[k + 1] }; + const c = { x: delaunay.coords[l], y: delaunay.coords[l + 1] }; + const tri = BorderTriangle.fromPoints(a, b, c); + borderTriangles.push(tri); + tri.id = ii; // Mostly for debugging at this point. } // Set the half-edges -EDGE_NAMES = ["neighborAB", "neighborBC", "neighborCA"]; -let i = 0 -for ( i = 0; i < delaunay.halfedges.length; i += 1 ) { +EDGE_NAMES = ["AB", "BC", "CA"]; +triangleEdges = new Set(); +for ( let i = 0; i < delaunay.halfedges.length; i += 1 ) { const halfEdgeIndex = delaunay.halfedges[i]; if ( !~halfEdgeIndex ) continue; - const triFrom = borderTriangles.get(Math.floor(i / 3)); - const triTo = borderTriangles.get(Math.floor(halfEdgeIndex / 3)); + const triFrom = borderTriangles[Math.floor(i / 3)]; + const triTo = borderTriangles[Math.floor(halfEdgeIndex / 3)]; // Always a, b, c in order (b/c ccw) const fromEdge = EDGE_NAMES[i % 3]; const toEdge = EDGE_NAMES[halfEdgeIndex % 3]; - triFrom[fromEdge] = triTo; - triTo[toEdge] = triFrom; -} + // Need to pick one; keep the fromEdge + const edgeToKeep = triFrom.edges[fromEdge]; + triTo.setEdge(toEdge, edgeToKeep); -borderTriangles.forEach(tri => tri.draw()); -borderTriangles.forEach(tri => tri.drawLinks()) + // Track edge set to link walls. + triangleEdges.add(edgeToKeep); +} // Map of wall keys corresponding to walls. wallKeys = new Map(); -for ( const wall of canvas.walls.placeables ) { - wallKeys.set(wall.vertices.a.key, wall); - wallKeys.set(wall.vertices.b.key, wall); +for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { + const aKey = wall.vertices.a.key; + const bKey = wall.vertices.b.key; + if ( wallKeys.has(aKey) ) wallKeys.get(aKey).add(wall); + else wallKeys.set(aKey, new Set([wall])); + + if ( wallKeys.has(bKey) ) wallKeys.get(bKey).add(wall); + else wallKeys.set(bKey, new Set([wall])); } // Set the wall(s) for each triangle edge +nullSet = new Set(); +for ( const edge of triangleEdges.values() ) { + const aKey = edge.a.key; + const bKey = edge.b.key; + const aWalls = wallKeys.get(aKey) || nullSet; + const bWalls = wallKeys.get(bKey) || nullSet; + edge.wall = aWalls.intersection(bWalls).first(); // May be undefined. +} + +borderTriangles.forEach(tri => tri.drawEdges()); +borderTriangles.forEach(tri => tri.drawLinks()) + + +// Use Quadtree to locate starting triangle for a point. + +// quadtree.clear() +// quadtree.update({r: bounds, t: this}) +// quadtree.remove(this) +// quadtree.update(this) + + +quadtreeBT = new CanvasQuadtree() +borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) + + +token = _token +startPoint = _token.center; +endPoint = _token.center + +// Find the strat and end triangles +collisionTest = (o, _rect) => o.t.contains(startPoint); +startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); + +collisionTest = (o, _rect) => o.t.contains(endPoint); +endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); + +startTri.drawEdges(); +endTri.drawEdges(); + +// Locate valid destinations +destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); +destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +destinations.sort((a, b) => a.distance - b.distance); + + +// Pick direction, repeat. +chosenDestination = destinations[0]; +Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) +nextTri = chosenDestination.triangle; +destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); +destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +destinations.sort((a, b) => a.distance - b.distance); +function boundsForPoint(pt) { + return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); +} + /* For the triangles, need: √ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. @@ -344,28 +403,127 @@ class BorderEdge { /** @type {PIXI.Point} */ b = new PIXI.Point(); + /** @type {Set} */ + endpointKeys = new Set(); + /** @type {BorderTriangle} */ cwTriangle; /** @type {BorderTriangle} */ ccwTriangle; - constructor(a, b) { + /** @type {Wall} */ + wall; + constructor(a, b) { + this.a.copyFrom(a); + this.b.copyFrom(b); + this.endpointKeys.add(this.a.key); + this.endpointKeys.add(this.b.key); } + /** @type {PIXI.Point} */ + #median; + + get median() { return this.#median || (this.#median = this.a.add(this.b).multiplyScalar(0.5)); } + + /** @type {number} */ + #length; + + get length() { return this.#length || (this.#length = this.b.subtract(this.a).magnitude()); } + /** - * Link a triangle to this edge, replacing any previous triangle in that position. + * Get the other triangle for this edge. + * @param {BorderTriangle} + * @returns {BorderTriangle} */ - linkTriangle(triangle) { - const triEndpoints = new Set([triangle.a, triangle.b, triangle.c]); + otherTriangle(triangle) { return this.cwTriangle === triangle ? this.ccwTriangle : this.cwTriangle; } - otherEndpoint = + /** + * Remove the triangle link. + * @param {BorderTriangle} + */ + removeTriangle(triangle) { + if ( this.cwTriangle === triangle ) this.cwTriangle = undefined; + if ( this.ccwTriangle === triangle ) this.ccwTriangle = undefined; + } + /** + * Provide valid destinations for this edge. + * Blocked walls are invalid. + * Typically returns 2 corner destinations plus the median destination. + * If the edge is less than 2 * spacer, no destinations are valid. + * @param {Point} center Test if wall blocks from perspective of this origin point. + * @param {number} [spacer] How much away from the corner to set the corner destinations. + * If the edge is less than 2 * spacer, it will be deemed invalid. + * Corner destinations are skipped if not more than spacer away from median. + * @returns {PIXI.Point[]} + */ + getValidDestinations(origin, spacer) { + spacer ??= canvas.grid.size * 0.5; + const length = this.length; + const destinations = []; + + // No destination if edge is smaller than 2x spacer. + if ( length < (spacer * 2) || this.wallBlocks(origin) ) return destinations; + destinations.push(this.median); + + // Skip corners if not at least spacer away from median. + if ( length < (spacer * 4) ) return destinations; + + const { a, b } = this; + const t = spacer / length; + destinations.push( + a.projectToward(b, t), + b.projectToward(a, t)); + return destinations; } + /** + * Does this edge wall block from an origin somewhere else in the triangle? + * Tested "live" and not cached so door or wall orientation changes need not be tracked. + * @param {Point} origin Measure wall blocking from perspective of this origin point. + * @returns {boolean} + */ + wallBlocks(origin) { + const wall = this.wall; + if ( !wall ) return false; + if ( !wall.document.move || wall.isOpen ) return false; + + // Ignore one-directional walls which are facing away from the center + const side = wall.orientPoint(origin); + const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; + if ( wall.document.dir + && (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false; + return true; + } + /** + * Link a triangle to this edge, replacing any previous triangle in that position. + */ + linkTriangle(triangle) { + const { a, b } = this; + if ( !triangle.endpointKeys.has(a.key) + || !triangle.endpointKeys.has(b.key) ) throw new Error("Triangle does not share this edge!"); + + const { a: aTri, b: bTri, c: cTri } = triangle.vertices; + const otherEndpoint = !this.endpointKeys.has(aTri.key) ? aTri + : !this.endpointKeys.has(bTri.key) ? bTri + : cTri; + const orient2d = foundry.utils.orient2dFast; + if ( orient2d(a, b, otherEndpoint) > 0 ) this.ccwTriangle = triangle; + else this.cwTriangle = triangle; + } + + /** + * For debugging. + * Draw this edge. + */ + draw(opts = {}) { + opts.color ??= this.wall ? Draw.COLORS.red : Draw.COLORS.blue; + Draw.segment({ A: this.a, B: this.b }, opts); + } } /** @@ -373,122 +531,189 @@ class BorderEdge { * Assumed static---points cannot change. * Note: delaunay triangles from Delaunator are oriented counterclockwise */ -class BorderTriangle extends PIXI.Polygon { - /** @type {PIXI.Point} */ - a = new PIXI.Point(); +class BorderTriangle { + vertices = { + a: new PIXI.Point(), /** @type {PIXI.Point} */ + b: new PIXI.Point(), /** @type {PIXI.Point} */ + c: new PIXI.Point() /** @type {PIXI.Point} */ + }; - /** @type {PIXI.Point} */ - b = new PIXI.Point(); + edges = { + AB: undefined, /** @type {BorderEdge} */ + BC: undefined, /** @type {BorderEdge} */ + CA: undefined /** @type {BorderEdge} */ + }; - /** @type {PIXI.Point} */ - c = new PIXI.Point(); + /** @type {BorderEdge} */ + + /** @type {Set} */ + endpointKeys = new Set(); /** @type {number} */ id = -1; - constructor(...args) { - super(...args); - if ( this.points.length !== 6 ) throw new Error("Border Triangle must have 6 coordinates"); - - - } - /** - * Initialize properties based on the unchanging coordinates. + * @param {Point} a + * @param {Point} b + * @param {Point} c */ - _initialize() { - // Orient a --> b --> c counterclockwise - if ( this.isClockwise ) this.reverseOrientation(); - this.a.x = this.points[0]; - this.a.y = this.points[1]; - this.b.x = this.points[2]; - this.b.y = this.points[3]; - this.c.x = this.points[4]; - this.c.y = this.points[5]; - - // Get the + constructor(edgeAB, edgeBC, edgeCA) { + // Determine the shared endpoint for each. + let a = edgeCA.endpointKeys.has(edgeAB.a.key) ? edgeAB.a : edgeAB.b; + let b = edgeAB.endpointKeys.has(edgeBC.a.key) ? edgeBC.a : edgeBC.b; + let c = edgeBC.endpointKeys.has(edgeCA.a.key) ? edgeCA.a : edgeCA.b; + + const oABC = foundry.utils.orient2dFast(a, b, c); + if ( !oABC ) throw Error("BorderTriangle requires three non-collinear points."); + if ( oABC < 0 ) { + // Flip to ccw. + [a, b, c] = [c, b, a]; + [edgeAB, edgeCA] = [edgeCA, edgeAB]; + } + + this.vertices.a.copyFrom(a); + this.vertices.b.copyFrom(b); + this.vertices.c.copyFrom(c); + + this.edges.AB = edgeAB; + this.edges.BC = edgeBC; + this.edges.CA = edgeCA; + Object.values(this.vertices).forEach(v => this.endpointKeys.add(v.key)); + Object.values(this.edges).forEach(e => e.linkTriangle(this)); } /** - * Calculate coordinate index in the Delaunay set. + * Construct a BorderTriangle from three points. + * Creates three new edges. + * @param {Point} a First point of the triangle + * @param {Point} b Second point of the triangle + * @param {Point} c Third point of the triangle + * @returns {BorderTriangle} */ - delaunayCoordinate(vertex = "a") { - switch ( vertex ) { - case "a": return BorderTriangle.delauney - } + static fromPoints(a, b, c) { + return new this( + new BorderEdge(a, b), + new BorderEdge(b, c), + new BorderEdge(c, a) + ); } + /** @type {Point} */ + #center; + + get center() { return this.#center + || (this.#center = this.vertices.a.add(this.vertices.b).add(this.vertices.c).multiplyScalar(1/3)); } + /** * Contains method based on orientation. * More inclusive than PIXI.Polygon.prototype.contains in that any point on the edge counts. * @param {number} x X coordinate of point to test * @param {number} y Y coordinate of point to test * @returns {boolean} - * - False if not contained at all - * - */ - containsPoint(pt) { + contains(pt) { const orient2d = foundry.utils.orient2dFast; + const { a, b, c } = this.vertices; return orient2d(a, b, pt) >= 0 && orient2d(b, c, pt) >= 0 && orient2d(c, a, pt) >= 0; } - /** - * Replace getBounds with static version. - * @returns {PIXI.Rectangle} - */ - /** @type {PIXI.Rectangle} */ #bounds; get bounds() { return this.#bounds || (this.#bounds = this._getBounds()); } - getBounds() { return this.#bounds || (this.#bounds = this._getBounds()); } + getBounds() { return this.bounds; } _getBounds() { - const xMinMax = Math.minMax(this.a.x, this.b.x, this.c.x); - const yMinMax = Math.minMax(this.a.y, this.b.y, this.c.y); + const { a, b, c } = this.vertices; + const xMinMax = Math.minMax(a.x, b.x, c.x); + const yMinMax = Math.minMax(a.y, b.y, c.y); return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); } /** - * Links to neighboring triangles. - * @type {BorderTriangle} + * Provide valid destinations given that you came from a specific neighbor. + * Blocked walls are invalid. + * Typically returns 2 corner destinations plus the median destination. + * @param {Point} entryPoint + * @param {BorderTriangle|null} priorTriangle + * @param {number} spacer How much away from the corner to set the corner destinations. + * If the edge is less than 2 * spacer, it will be deemed invalid. + * Corner destinations are skipped if not more than spacer away from median. + * @returns {object[]} Each element has properties describing the destination: + * - {BorderTriangle} triangle + * - {Point} entryPoint + * - {number} distance */ - neighborAB; - - neighborBC; - - neighborCA; + getValidDestinations(entryPoint, priorTriangle, spacer) { + spacer ??= canvas.grid.size * 0.5; + const destinations = []; + const center = this.center; + for ( const edge of Object.values(this.edges) ) { + const neighbor = edge.otherTriangle(this); + if ( priorTriangle && priorTriangle === neighbor ) continue; + const pts = edge.getValidDestinations(center, spacer); + pts.forEach(pt => { + destinations.push({ + entryPoint: pt, + triangle: neighbor, + + // TODO: Handle 3d distances. + // Probably use canvas.grid.measureDistances, passing a Ray3d. + // TODO: Handle terrain distance + distance: canvas.grid.measureDistance(center, pt), + }); + }) + } + return destinations; + } /** - * Links to the wall(s) representing each edge - * @type {Set} + * Replace an edge in this triangle. + * Used to link triangles by an edge. + * @param {string} edgeName "AB"|"BC"|"CA" */ - wallsAB; + setEdge(edgeName, newEdge) { + const oldEdge = this.edges[edgeName]; + if ( !oldEdge ) { + console.error(`No edge with name ${edgeName} found.`); + return; + } + + if ( !(newEdge instanceof BorderEdge) ) { + console.error("BorderTriangle requires BorderEdge to replace an edge."); + return; + } - wallsBC; + if ( !(oldEdge.endpointKeys.has(newEdge.a.key) && oldEdge.endpointKeys.has(newEdge.b.key)) ) { + console.error("BorderTriangle edge replacement must have the same endpoints. Try building a new triangle instead."); + return; + } - wallsCA; + oldEdge.removeTriangle(this); + this.edges[edgeName] = newEdge; + newEdge.linkTriangle(this); + } /** - * Debug helper to draw the triangle. + * For debugging. Draw edges on the canvas. */ - draw(opts = {}) { - Draw.shape(this, opts); - if ( ~this.id ) Draw.labelPoint(this.center, this.id.toString()); - } + drawEdges() { Object.values(this.edges).forEach(e => e.draw()); } /* * Draw links to other triangles. */ drawLinks() { const center = this.center; - if ( this.neighborAB ) Draw.segment({ A: center, B: this.neighborAB.center }); - if ( this.neighborBC ) Draw.segment({ A: center, B: this.neighborBC.center }); - if ( this.neighborCA ) Draw.segment({ A: center, B: this.neighborCA.center }); + for ( const edge of Object.values(this.edges) ) { + if ( edge.otherTriangle(this) ) { + const color = edge.wallBlocks(center) ? Draw.COLORS.orange : Draw.COLORS.green; + Draw.segment({ A: center, B: edge.median }, { color }); + + } + } } } - From 414415a56853cb638c7602177ad8b90fc64bb26e Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 13 Jan 2024 13:51:36 -0800 Subject: [PATCH 04/25] Remove unneeded visibility graph --- scripts/pathfinding.js | 536 +---------------------------------------- 1 file changed, 3 insertions(+), 533 deletions(-) diff --git a/scripts/pathfinding.js b/scripts/pathfinding.js index f973fd4..da787da 100644 --- a/scripts/pathfinding.js +++ b/scripts/pathfinding.js @@ -3,217 +3,8 @@ Draw = CONFIG.GeometryLib.Draw; -wall0 = canvas.placeables.walls[0] -wall1 = canvas.placeables.walls[1] - - -/* Need to be able to identify points at open walls. -Types: - • -Open wall: ------ • - • - -Need three points to navigate around the wall from any position w/o running into the wall. - -Convex wall: ------ • - | - -No point on inside b/c it does not represent valid position. -Can use one point so long as it is exactly halfway on the outside (larger) angle - -180º wall: ----- ------ No point needed. - -Points are all 2d, so we can use PIXI.Point keys with a set. -*/ - - -/* Visibility Point -{number} key PIXI.Point key of the endpoint. -{PIXI.Point} dir Directional vector. Used -{PIXI.Point} loc coordinate of the endpoint -{Set} walls Walls linked to this endpoint -*/ - -let { Graph, GraphVertex, GraphEdge } = CONFIG.GeometryLib.Graph; - -Draw.clearDrawings() -visibilityPoints = new Map(); -calculateVisibilityPoints(); -drawVisibilityPoints() - - -/* Build graph -For each visibility point, scale it out by some distance based on the grid. -Center on the grid square. -Use a key and a map, making each GraphVertex a location. - -GraphEdge connects if there is visibility (no collisions) between the two vertices. -*/ - -// Probably need some sort of tracking so change to wall can modify the correct points. -scaledVisibilityPoints = new Set(); -spacer = 100; -for ( const [key, pts] of visibilityPoints.entries() ) { - const endpoint = PIXI.Point.invertKey(key); - for ( const pt of pts ) { - const scaledPoint = endpoint.add(pt.multiplyScalar(spacer)); - const center = canvas.grid.grid.getCenter(scaledPoint.x, scaledPoint.y); - scaledVisibilityPoints.add((new PIXI.Point(center[0], center[1])).key); - } -} - -// Visibility test to construct the edges. -graph = new Graph(); - -vertices = scaledVisibilityPoints.map(key => new GraphVertex(key)); -verticesArr = [...vertices]; -nVertices = verticesArr.length; -collisionCfg = { mode: "any", type: "move" } -for ( let i = 0; i < nVertices; i += 1 ) { - const iV = verticesArr[i]; - const iPt = PIXI.Point.invertKey(iV.value) - for ( let j = i + 1; j < nVertices; j += 1 ) { - const jV = verticesArr[j]; - const jPt = PIXI.Point.invertKey(jV.value); - // if ( CONFIG.Canvas.polygonBackends.move.testCollision(iPt, jPt, collisionCfg) ) continue; - - const distance = canvas.grid.grid.measureDistances([{ ray: new Ray(iPt, jPt) }])[0]; - graph.addEdgeVertices(iV, jV, distance); - } -} - -// For each vertex, keep the best N edges that don't have a collision. -// Store these edges in a new graph -trimmedGraph = new Graph(); -MAX_EDGES = 4; -for ( const vertex of graph.getAllVertices() ) { - const edges = vertex.edges.sort((a, b) => a.weight - b.weight); - let n = 1; - for ( const edge of edges ) { - if ( n > MAX_EDGES ) break; - const A = PIXI.Point.invertKey(edge.A.value); - const B = PIXI.Point.invertKey(edge.B.value); - if ( CONFIG.Canvas.polygonBackends.move.testCollision(A, B, collisionCfg) ) continue; - n += 1; - trimmedGraph.addEdge(edge); - } -} - - - -// Draw graph vertices -vertices = graph.getAllVertices(); -for ( const vertex of vertices ) { - Draw.point(PIXI.Point.invertKey(vertex.value), { color: Draw.COLORS.green }); -} - -// Draw the graph edges -edges = graph.getAllEdges(); -for ( const edge of edges ) { - const A = PIXI.Point.invertKey(edge.A.value); - const B = PIXI.Point.invertKey(edge.B.value); - Draw.segment({ A, B }) -} - -edges = trimmedGraph.getAllEdges(); -for ( const edge of edges ) { - const A = PIXI.Point.invertKey(edge.A.value); - const B = PIXI.Point.invertKey(edge.B.value); - Draw.segment({ A, B }, { color: Draw.COLORS.green }) -} - - - - - - - - - -function drawVisibilityPoints(spacer = 100) { - for ( const [key, pts] of visibilityPoints.entries() ) { - const endpoint = PIXI.Point.invertKey(key); - for ( const pt of pts ) { - Draw.point(endpoint.add(pt.multiplyScalar(spacer))); - } - } - -} - - -function calculateVisibilityPoints() { - const A = new PIXI.Point(); - const B = new PIXI.Point() - for (const wall of canvas.walls.placeables ) { - A.copyFrom(wall.vertices.a); - B.copyFrom(wall.vertices.b); - - // Determine the visibility point direction(s) for A and B - if ( !visibilityPoints.has(A.key) ) visibilityPoints.set(A.key, findVisibilityDirections(A, B)); - if ( !visibilityPoints.has(B.key) ) visibilityPoints.set(B.key, findVisibilityDirections(B, A)); - } -} - - - - - -let PI2 = Math.PI * 2; -function findVisibilityDirections(endpoint, other) { - const key = endpoint.key; - const linkedWalls = canvas.walls.placeables.filter(w => w.wallKeys.has(key)) - if ( !linkedWalls.length ) return []; // Shouldn't happen. - if ( linkedWalls.length < 2 ) { - // Open wall point. Needs three visibility points. - const dir = endpoint.subtract(other).normalize(); - return [ dir, new PIXI.Point(-dir.y, dir.x), new PIXI.Point(dir.y, -dir.x) ]; - } - - // Find the maximum angle between all the walls. - // Test each wall combination once. - let maxAngle = 0; - // let clockwise = true; - let aEndpoint; - let cEndpoint; - - const nWalls = linkedWalls.length; - for ( let i = 0; i < nWalls; i += 1 ) { - const iWall = linkedWalls[i]; - const a = iWall.vertices.a.key === key ? iWall.vertices.b : iWall.vertices.a; - for ( let j = i + 1; j < nWalls; j += 1 ) { - const jWall = linkedWalls[j]; - const c = jWall.vertices.a.key === key ? jWall.vertices.b : jWall.vertices.a; - const angleI = PIXI.Point.angleBetween(a, endpoint, c, { clockwiseAngle: true }); - if ( angleI.almostEqual(Math.PI) ) return []; // 180º, precludes any other direction from being > 180º. - - if ( angleI > Math.PI & angleI > maxAngle ) { - maxAngle = angleI; - //clockwise = true; - aEndpoint = a; - cEndpoint = c; - } else if ( (PI2 - angleI) > maxAngle ) { - maxAngle = PI2 - angleI; - //clockwise = false; - aEndpoint = a; - cEndpoint = c; - } - } - } - - // Calculate the direction for 1/2 of the maximum angle - const ab = endpoint.subtract(aEndpoint).normalize(); - const cb = endpoint.subtract(cEndpoint).normalize(); - return [ab.add(cb).multiplyScalar(0.5).normalize()]; - - // To test: - // res = ab.add(cb).multiplyScalar(0.5); - // Draw.point(endpoint.add(res.multiplyScalar(100))) -} - - - -// Alternative: Triangulate +// Triangulate /* Take the 4 corners plus coordinates of each wall endpoint. (TODO: Use wall edges to capture overlapping walls) @@ -393,327 +184,6 @@ function boundsForPoint(pt) { - Provide 2x corner + median pass-through points */ -/** - * An edge that makes up the triangle-shaped polygon - */ -class BorderEdge { - /** @type {PIXI.Point} */ - a = new PIXI.Point(); - - /** @type {PIXI.Point} */ - b = new PIXI.Point(); - - /** @type {Set} */ - endpointKeys = new Set(); - - /** @type {BorderTriangle} */ - cwTriangle; - - /** @type {BorderTriangle} */ - ccwTriangle; - - /** @type {Wall} */ - wall; - - constructor(a, b) { - this.a.copyFrom(a); - this.b.copyFrom(b); - this.endpointKeys.add(this.a.key); - this.endpointKeys.add(this.b.key); - } - - /** @type {PIXI.Point} */ - #median; - - get median() { return this.#median || (this.#median = this.a.add(this.b).multiplyScalar(0.5)); } - - /** @type {number} */ - #length; - - get length() { return this.#length || (this.#length = this.b.subtract(this.a).magnitude()); } - - /** - * Get the other triangle for this edge. - * @param {BorderTriangle} - * @returns {BorderTriangle} - */ - otherTriangle(triangle) { return this.cwTriangle === triangle ? this.ccwTriangle : this.cwTriangle; } - - /** - * Remove the triangle link. - * @param {BorderTriangle} - */ - removeTriangle(triangle) { - if ( this.cwTriangle === triangle ) this.cwTriangle = undefined; - if ( this.ccwTriangle === triangle ) this.ccwTriangle = undefined; - } - - /** - * Provide valid destinations for this edge. - * Blocked walls are invalid. - * Typically returns 2 corner destinations plus the median destination. - * If the edge is less than 2 * spacer, no destinations are valid. - * @param {Point} center Test if wall blocks from perspective of this origin point. - * @param {number} [spacer] How much away from the corner to set the corner destinations. - * If the edge is less than 2 * spacer, it will be deemed invalid. - * Corner destinations are skipped if not more than spacer away from median. - * @returns {PIXI.Point[]} - */ - getValidDestinations(origin, spacer) { - spacer ??= canvas.grid.size * 0.5; - const length = this.length; - const destinations = []; - - // No destination if edge is smaller than 2x spacer. - if ( length < (spacer * 2) || this.wallBlocks(origin) ) return destinations; - destinations.push(this.median); - - // Skip corners if not at least spacer away from median. - if ( length < (spacer * 4) ) return destinations; - - const { a, b } = this; - const t = spacer / length; - destinations.push( - a.projectToward(b, t), - b.projectToward(a, t)); - return destinations; - } - - - /** - * Does this edge wall block from an origin somewhere else in the triangle? - * Tested "live" and not cached so door or wall orientation changes need not be tracked. - * @param {Point} origin Measure wall blocking from perspective of this origin point. - * @returns {boolean} - */ - wallBlocks(origin) { - const wall = this.wall; - if ( !wall ) return false; - if ( !wall.document.move || wall.isOpen ) return false; - - // Ignore one-directional walls which are facing away from the center - const side = wall.orientPoint(origin); - const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; - if ( wall.document.dir - && (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false; - return true; - } - - /** - * Link a triangle to this edge, replacing any previous triangle in that position. - */ - linkTriangle(triangle) { - const { a, b } = this; - if ( !triangle.endpointKeys.has(a.key) - || !triangle.endpointKeys.has(b.key) ) throw new Error("Triangle does not share this edge!"); - - const { a: aTri, b: bTri, c: cTri } = triangle.vertices; - const otherEndpoint = !this.endpointKeys.has(aTri.key) ? aTri - : !this.endpointKeys.has(bTri.key) ? bTri - : cTri; - const orient2d = foundry.utils.orient2dFast; - if ( orient2d(a, b, otherEndpoint) > 0 ) this.ccwTriangle = triangle; - else this.cwTriangle = triangle; - } - - /** - * For debugging. - * Draw this edge. - */ - draw(opts = {}) { - opts.color ??= this.wall ? Draw.COLORS.red : Draw.COLORS.blue; - Draw.segment({ A: this.a, B: this.b }, opts); - } -} -/** - * A triangle-shaped polygon. - * Assumed static---points cannot change. - * Note: delaunay triangles from Delaunator are oriented counterclockwise - */ -class BorderTriangle { - vertices = { - a: new PIXI.Point(), /** @type {PIXI.Point} */ - b: new PIXI.Point(), /** @type {PIXI.Point} */ - c: new PIXI.Point() /** @type {PIXI.Point} */ - }; - - edges = { - AB: undefined, /** @type {BorderEdge} */ - BC: undefined, /** @type {BorderEdge} */ - CA: undefined /** @type {BorderEdge} */ - }; - - /** @type {BorderEdge} */ - - /** @type {Set} */ - endpointKeys = new Set(); - - /** @type {number} */ - id = -1; - - /** - * @param {Point} a - * @param {Point} b - * @param {Point} c - */ - constructor(edgeAB, edgeBC, edgeCA) { - // Determine the shared endpoint for each. - let a = edgeCA.endpointKeys.has(edgeAB.a.key) ? edgeAB.a : edgeAB.b; - let b = edgeAB.endpointKeys.has(edgeBC.a.key) ? edgeBC.a : edgeBC.b; - let c = edgeBC.endpointKeys.has(edgeCA.a.key) ? edgeCA.a : edgeCA.b; - - const oABC = foundry.utils.orient2dFast(a, b, c); - if ( !oABC ) throw Error("BorderTriangle requires three non-collinear points."); - if ( oABC < 0 ) { - // Flip to ccw. - [a, b, c] = [c, b, a]; - [edgeAB, edgeCA] = [edgeCA, edgeAB]; - } - - this.vertices.a.copyFrom(a); - this.vertices.b.copyFrom(b); - this.vertices.c.copyFrom(c); - - this.edges.AB = edgeAB; - this.edges.BC = edgeBC; - this.edges.CA = edgeCA; - - Object.values(this.vertices).forEach(v => this.endpointKeys.add(v.key)); - Object.values(this.edges).forEach(e => e.linkTriangle(this)); - } - - /** - * Construct a BorderTriangle from three points. - * Creates three new edges. - * @param {Point} a First point of the triangle - * @param {Point} b Second point of the triangle - * @param {Point} c Third point of the triangle - * @returns {BorderTriangle} - */ - static fromPoints(a, b, c) { - return new this( - new BorderEdge(a, b), - new BorderEdge(b, c), - new BorderEdge(c, a) - ); - } - - /** @type {Point} */ - #center; - - get center() { return this.#center - || (this.#center = this.vertices.a.add(this.vertices.b).add(this.vertices.c).multiplyScalar(1/3)); } - - /** - * Contains method based on orientation. - * More inclusive than PIXI.Polygon.prototype.contains in that any point on the edge counts. - * @param {number} x X coordinate of point to test - * @param {number} y Y coordinate of point to test - * @returns {boolean} - */ - contains(pt) { - const orient2d = foundry.utils.orient2dFast; - const { a, b, c } = this.vertices; - return orient2d(a, b, pt) >= 0 - && orient2d(b, c, pt) >= 0 - && orient2d(c, a, pt) >= 0; - } - - /** @type {PIXI.Rectangle} */ - #bounds; - - get bounds() { return this.#bounds || (this.#bounds = this._getBounds()); } - - getBounds() { return this.bounds; } - - _getBounds() { - const { a, b, c } = this.vertices; - const xMinMax = Math.minMax(a.x, b.x, c.x); - const yMinMax = Math.minMax(a.y, b.y, c.y); - return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); - } - - /** - * Provide valid destinations given that you came from a specific neighbor. - * Blocked walls are invalid. - * Typically returns 2 corner destinations plus the median destination. - * @param {Point} entryPoint - * @param {BorderTriangle|null} priorTriangle - * @param {number} spacer How much away from the corner to set the corner destinations. - * If the edge is less than 2 * spacer, it will be deemed invalid. - * Corner destinations are skipped if not more than spacer away from median. - * @returns {object[]} Each element has properties describing the destination: - * - {BorderTriangle} triangle - * - {Point} entryPoint - * - {number} distance - */ - getValidDestinations(entryPoint, priorTriangle, spacer) { - spacer ??= canvas.grid.size * 0.5; - const destinations = []; - const center = this.center; - for ( const edge of Object.values(this.edges) ) { - const neighbor = edge.otherTriangle(this); - if ( priorTriangle && priorTriangle === neighbor ) continue; - const pts = edge.getValidDestinations(center, spacer); - pts.forEach(pt => { - destinations.push({ - entryPoint: pt, - triangle: neighbor, - - // TODO: Handle 3d distances. - // Probably use canvas.grid.measureDistances, passing a Ray3d. - // TODO: Handle terrain distance - distance: canvas.grid.measureDistance(center, pt), - }); - }) - } - return destinations; - } - - /** - * Replace an edge in this triangle. - * Used to link triangles by an edge. - * @param {string} edgeName "AB"|"BC"|"CA" - */ - setEdge(edgeName, newEdge) { - const oldEdge = this.edges[edgeName]; - if ( !oldEdge ) { - console.error(`No edge with name ${edgeName} found.`); - return; - } - - if ( !(newEdge instanceof BorderEdge) ) { - console.error("BorderTriangle requires BorderEdge to replace an edge."); - return; - } - - if ( !(oldEdge.endpointKeys.has(newEdge.a.key) && oldEdge.endpointKeys.has(newEdge.b.key)) ) { - console.error("BorderTriangle edge replacement must have the same endpoints. Try building a new triangle instead."); - return; - } - - oldEdge.removeTriangle(this); - this.edges[edgeName] = newEdge; - newEdge.linkTriangle(this); - } - - /** - * For debugging. Draw edges on the canvas. - */ - drawEdges() { Object.values(this.edges).forEach(e => e.draw()); } - - /* - * Draw links to other triangles. - */ - drawLinks() { - const center = this.center; - for ( const edge of Object.values(this.edges) ) { - if ( edge.otherTriangle(this) ) { - const color = edge.wallBlocks(center) ? Draw.COLORS.orange : Draw.COLORS.green; - Draw.segment({ A: center, B: edge.median }, { color }); - - } - } - } -} + + From 2151641744b24b4f0c4e72ff5ba34c4ebf5c7d16 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 13 Jan 2024 15:20:19 -0800 Subject: [PATCH 05/25] Add Pathfinding class --- scripts/pathfinding.js | 189 -------------------- scripts/pathfinding/BorderTriangle.js | 2 + scripts/pathfinding/pathfinding.js | 243 ++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 189 deletions(-) delete mode 100644 scripts/pathfinding.js create mode 100644 scripts/pathfinding/pathfinding.js diff --git a/scripts/pathfinding.js b/scripts/pathfinding.js deleted file mode 100644 index da787da..0000000 --- a/scripts/pathfinding.js +++ /dev/null @@ -1,189 +0,0 @@ - -Draw = CONFIG.GeometryLib.Draw; - - - - -// Triangulate -/* -Take the 4 corners plus coordinates of each wall endpoint. -(TODO: Use wall edges to capture overlapping walls) - -Triangulate. - -Can traverse using the half-edge structure. - -Start in a triangle. For now, traverse between triangles at midpoints. -Triangle coords correspond to a wall. Each triangle edge may or may not block. -Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). -This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. - -Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. -Goal heuristic based on distance (modified by terrain?). -Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. - -Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. - -*/ - - - -endpointKeys = new Set(); -for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { - endpointKeys.add(wall.vertices.a.key); - endpointKeys.add(wall.vertices.b.key); -} - -coords = new Uint32Array(endpointKeys.size * 2); -let i = 0; -for ( const key of endpointKeys ) { - const pt = PIXI.Point.invertKey(key); - coords[i] = pt.x; - coords[i + 1] = pt.y; - i += 2; -} - -delaunay = new Delaunator(coords); - -// Draw each endpoint -for ( const key of endpointKeys ) { - const pt = PIXI.Point.invertKey(key); - Draw.point(pt, { color: Draw.COLORS.blue }) -} - -// Draw each triangle -triangles = []; -for (let i = 0; i < delaunay.triangles.length; i += 3) { - const j = delaunay.triangles[i] * 2; - const k = delaunay.triangles[i + 1] * 2; - const l = delaunay.triangles[i + 2] * 2; - triangles.push(new PIXI.Polygon( - delaunay.coords[j], delaunay.coords[j + 1], - delaunay.coords[k], delaunay.coords[k + 1], - delaunay.coords[l], delaunay.coords[l + 1] - )); -} - -for ( const tri of triangles ) Draw.shape(tri); - - -// Build array of border triangles -borderTriangles = []; -for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { - const j = delaunay.triangles[i] * 2; - const k = delaunay.triangles[i + 1] * 2; - const l = delaunay.triangles[i + 2] * 2; - - const a = { x: delaunay.coords[j], y: delaunay.coords[j + 1] }; - const b = { x: delaunay.coords[k], y: delaunay.coords[k + 1] }; - const c = { x: delaunay.coords[l], y: delaunay.coords[l + 1] }; - const tri = BorderTriangle.fromPoints(a, b, c); - borderTriangles.push(tri); - tri.id = ii; // Mostly for debugging at this point. -} - -// Set the half-edges -EDGE_NAMES = ["AB", "BC", "CA"]; -triangleEdges = new Set(); -for ( let i = 0; i < delaunay.halfedges.length; i += 1 ) { - const halfEdgeIndex = delaunay.halfedges[i]; - if ( !~halfEdgeIndex ) continue; - const triFrom = borderTriangles[Math.floor(i / 3)]; - const triTo = borderTriangles[Math.floor(halfEdgeIndex / 3)]; - - // Always a, b, c in order (b/c ccw) - const fromEdge = EDGE_NAMES[i % 3]; - const toEdge = EDGE_NAMES[halfEdgeIndex % 3]; - - // Need to pick one; keep the fromEdge - const edgeToKeep = triFrom.edges[fromEdge]; - triTo.setEdge(toEdge, edgeToKeep); - - // Track edge set to link walls. - triangleEdges.add(edgeToKeep); -} - -// Map of wall keys corresponding to walls. -wallKeys = new Map(); -for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { - const aKey = wall.vertices.a.key; - const bKey = wall.vertices.b.key; - if ( wallKeys.has(aKey) ) wallKeys.get(aKey).add(wall); - else wallKeys.set(aKey, new Set([wall])); - - if ( wallKeys.has(bKey) ) wallKeys.get(bKey).add(wall); - else wallKeys.set(bKey, new Set([wall])); -} - -// Set the wall(s) for each triangle edge -nullSet = new Set(); -for ( const edge of triangleEdges.values() ) { - const aKey = edge.a.key; - const bKey = edge.b.key; - const aWalls = wallKeys.get(aKey) || nullSet; - const bWalls = wallKeys.get(bKey) || nullSet; - edge.wall = aWalls.intersection(bWalls).first(); // May be undefined. -} - -borderTriangles.forEach(tri => tri.drawEdges()); -borderTriangles.forEach(tri => tri.drawLinks()) - - -// Use Quadtree to locate starting triangle for a point. - -// quadtree.clear() -// quadtree.update({r: bounds, t: this}) -// quadtree.remove(this) -// quadtree.update(this) - - -quadtreeBT = new CanvasQuadtree() -borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) - - -token = _token -startPoint = _token.center; -endPoint = _token.center - -// Find the strat and end triangles -collisionTest = (o, _rect) => o.t.contains(startPoint); -startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); - -collisionTest = (o, _rect) => o.t.contains(endPoint); -endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); - -startTri.drawEdges(); -endTri.drawEdges(); - -// Locate valid destinations -destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); -destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -destinations.sort((a, b) => a.distance - b.distance); - - -// Pick direction, repeat. -chosenDestination = destinations[0]; -Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) -nextTri = chosenDestination.triangle; -destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); -destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -destinations.sort((a, b) => a.distance - b.distance); - - -function boundsForPoint(pt) { - return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); -} - - -/* For the triangles, need: -√ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. - --> Used to find where a start/end point is located. - --> Needs to also handle when on a line. PIXI.Polygon contains returns true if on top or left but not right or bottom. -- Look up wall for each edge. -√ Link to adjacent triangle via half-edge -- Provide 2x corner + median pass-through points -*/ - - - - diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index cc05880..b4dfade 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -144,6 +144,8 @@ export class BorderEdge { * Note: delaunay triangles from Delaunator are oriented counterclockwise */ export class BorderTriangle { + static EDGE_NAMES = ["AB", "BC", "CA"]; + vertices = { a: new PIXI.Point(), /** @type {PIXI.Point} */ b: new PIXI.Point(), /** @type {PIXI.Point} */ diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js new file mode 100644 index 0000000..e0fddc9 --- /dev/null +++ b/scripts/pathfinding/pathfinding.js @@ -0,0 +1,243 @@ +/* globals +canvas +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { BorderTriangle, BorderEdge } from "./BorderTriangle.js"; + + +Draw = CONFIG.GeometryLib.Draw; + + + + +// Triangulate +/* +Take the 4 corners plus coordinates of each wall endpoint. +(TODO: Use wall edges to capture overlapping walls) + +Triangulate. + +Can traverse using the half-edge structure. + +Start in a triangle. For now, traverse between triangles at midpoints. +Triangle coords correspond to a wall. Each triangle edge may or may not block. +Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). +This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. + +Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. +Goal heuristic based on distance (modified by terrain?). +Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. + +Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. + +*/ + + +// Draw each endpoint +for ( const key of endpointKeys ) { + const pt = PIXI.Point.invertKey(key); + Draw.point(pt, { color: Draw.COLORS.blue }) +} + +// Draw each triangle +triangles = []; +for (let i = 0; i < delaunay.triangles.length; i += 3) { + const j = delaunay.triangles[i] * 2; + const k = delaunay.triangles[i + 1] * 2; + const l = delaunay.triangles[i + 2] * 2; + triangles.push(new PIXI.Polygon( + delaunay.coords[j], delaunay.coords[j + 1], + delaunay.coords[k], delaunay.coords[k + 1], + delaunay.coords[l], delaunay.coords[l + 1] + )); +} + +for ( const tri of triangles ) Draw.shape(tri); + + + + +borderTriangles.forEach(tri => tri.drawEdges()); +borderTriangles.forEach(tri => tri.drawLinks()) + + +// Use Quadtree to locate starting triangle for a point. + +// quadtree.clear() +// quadtree.update({r: bounds, t: this}) +// quadtree.remove(this) +// quadtree.update(this) + + +quadtreeBT = new CanvasQuadtree() +borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) + + +token = _token +startPoint = _token.center; +endPoint = _token.center + +// Find the strat and end triangles +collisionTest = (o, _rect) => o.t.contains(startPoint); +startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); + +collisionTest = (o, _rect) => o.t.contains(endPoint); +endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); + +startTri.drawEdges(); +endTri.drawEdges(); + +// Locate valid destinations +destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); +destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +destinations.sort((a, b) => a.distance - b.distance); + + +// Pick direction, repeat. +chosenDestination = destinations[0]; +Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) +nextTri = chosenDestination.triangle; +destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); +destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +destinations.sort((a, b) => a.distance - b.distance); + + +function boundsForPoint(pt) { + return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); +} + + +/* For the triangles, need: +√ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. + --> Used to find where a start/end point is located. + --> Needs to also handle when on a line. PIXI.Polygon contains returns true if on top or left but not right or bottom. +- Look up wall for each edge. +√ Link to adjacent triangle via half-edge +- Provide 2x corner + median pass-through points +*/ + +class Pathfinder { + /** @type {CanvasQuadTree} */ + static quadtree = new CanvasQuadtree(); + + /** @type {Set} */ + static endpointKeys = new Set(); + + /** @type {Delaunator} */ + static delaunay; + + /** @type {Map>} */ + static wallKeys = new Map(); + + /** @type {BorderTriangle[]} */ + static borderTriangles = []; + + /** @type {Set} */ + static triangleEdges = new Set(); + + /** + * Initialize properties used for pathfinding related to the scene walls. + */ + static initialize() { + this.initializeWalls(); + this.initializeDelauney(); + this.initializeTriangles(); + } + + /** + * Build a map of wall keys to walls. + * Each key points to a set of walls whose endpoint matches the key. + */ + static initializeWalls() { + const wallKeys = this.wallKeys; + for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { + const aKey = wall.vertices.a.key; + const bKey = wall.vertices.b.key; + if ( wallKeys.has(aKey) ) wallKeys.get(aKey).add(wall); + else wallKeys.set(aKey, new Set([wall])); + + if ( wallKeys.has(bKey) ) wallKeys.get(bKey).add(wall); + else wallKeys.set(bKey, new Set([wall])); + } + } + + /** + * Build a set of Delaunay triangles from the walls in the scene. + * TODO: Use wall segments instead of walls to handle overlapping walls. + */ + static initializeDelauney() { + const { delauney, endpointKeys } = this; + for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { + endpointKeys.add(wall.vertices.a.key); + endpointKeys.add(wall.vertices.b.key); + } + + coords = new Uint32Array(endpointKeys.size * 2); + let i = 0; + for ( const key of endpointKeys ) { + const pt = PIXI.Point.invertKey(key); + coords[i] = pt.x; + coords[i + 1] = pt.y; + i += 2; + } + + delaunay = new Delaunator(coords); + } + + /** + * Build the triangle objects used to represent the Delauney objects for pathfinding. + * Must first run initializeDelauney and initializeWalls. + */ + static initializeTriangles() { + const { borderTriangles, triangleEdges, delaunay, wallKeys } = this; + + // Build array of border triangles + for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { + const j = delaunay.triangles[i] * 2; + const k = delaunay.triangles[i + 1] * 2; + const l = delaunay.triangles[i + 2] * 2; + + const a = { x: delaunay.coords[j], y: delaunay.coords[j + 1] }; + const b = { x: delaunay.coords[k], y: delaunay.coords[k + 1] }; + const c = { x: delaunay.coords[l], y: delaunay.coords[l + 1] }; + const tri = BorderTriangle.fromPoints(a, b, c); + borderTriangles.push(tri); + tri.id = ii; // Mostly for debugging at this point. + } + + // Set the half-edges + const EDGE_NAMES = BorderTriangle.EDGE_NAMES; + for ( let i = 0; i < delaunay.halfedges.length; i += 1 ) { + const halfEdgeIndex = delaunay.halfedges[i]; + if ( !~halfEdgeIndex ) continue; + const triFrom = borderTriangles[Math.floor(i / 3)]; + const triTo = borderTriangles[Math.floor(halfEdgeIndex / 3)]; + + // Always a, b, c in order (b/c ccw) + const fromEdge = EDGE_NAMES[i % 3]; + const toEdge = EDGE_NAMES[halfEdgeIndex % 3]; + + // Need to pick one; keep the fromEdge + const edgeToKeep = triFrom.edges[fromEdge]; + triTo.setEdge(toEdge, edgeToKeep); + + // Track edge set to link walls. + triangleEdges.add(edgeToKeep); + } + + // Set the wall, if any, for each triangle edge + nullSet = new Set(); + for ( const edge of triangleEdges.values() ) { + const aKey = edge.a.key; + const bKey = edge.b.key; + const aWalls = wallKeys.get(aKey) || nullSet; + const bWalls = wallKeys.get(bKey) || nullSet; + edge.wall = aWalls.intersection(bWalls).first(); // May be undefined. + } + } + + +} + + From efa0344693a88c58251697ffcf12cf01c5369e38 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 14 Jan 2024 09:47:05 -0800 Subject: [PATCH 06/25] Add Priority Queue, Uniform Cost Search --- scripts/PriorityQueueArray.js | 104 ----- scripts/module.js | 20 +- scripts/pathfinding/BinarySearch.js | 484 ++++++++++++++++++++ scripts/pathfinding/BorderTriangle.js | 62 ++- scripts/{ => pathfinding}/PriorityQueue.js | 14 +- scripts/pathfinding/PriorityQueueArray.js | 73 +++ scripts/pathfinding/pathfinding.js | 498 ++++++++++++++++----- scripts/util.js | 7 + 8 files changed, 1031 insertions(+), 231 deletions(-) delete mode 100644 scripts/PriorityQueueArray.js create mode 100644 scripts/pathfinding/BinarySearch.js rename scripts/{ => pathfinding}/PriorityQueue.js (94%) create mode 100644 scripts/pathfinding/PriorityQueueArray.js diff --git a/scripts/PriorityQueueArray.js b/scripts/PriorityQueueArray.js deleted file mode 100644 index aa68bb3..0000000 --- a/scripts/PriorityQueueArray.js +++ /dev/null @@ -1,104 +0,0 @@ -// Very basic priority queue based on an array -// Allows for a custom comparator on which to sort and the option to switch the -// initial sort algorithm - -import { binaryFindIndex } from "./BinarySearch.js"; - -export class PriorityQueueArray { - - /** - * @param {Object[]} arr Array of objects to queue. Not copied. - * @param {Function} comparator How to organize the queue. Used for initial sort - * and to insert additional elements. - * Should sort the highest priority item last. - */ - constructor(arr, { comparator = (a, b) => a - b, - sort = (arr, cmp) => arr.sort(cmp) } = {}) { - - this.sort = sort; - this.comparator = comparator - this.data = arr; - this.sort(this.data, this.comparator); - } - - /** - * Length of the queue - * @type {number} - */ - get length() { return this.data.length; } - - /** - * Examine the highest priority item in the queue without removing it. - * @return {Object} - */ - get peek() { return this.data[this.data.length - 1]; } - - /** - * Retrieve the next element of the queue - * @return {Object} Highest priority item in queue. - */ - next() { return this.data.pop(); } - - - /** - * Insert an object in the array using a linear search, O(n), to locate the position. - * @param {Object} obj Object to insert - * @return {number} Index where the object was inserted. - */ - insert(obj) { - const idx = this.data.findIndex(elem => this._elemIsAfter(obj, elem)); - this._insertAt(obj, idx); - } - - /** - * Insert an object in the array using a binary search, O(log(n)). - * Requires that the array is strictly sorted according to the comparator function. - * @param {Object} obj Object to insert - */ - binaryInsert(obj) { - const idx = binaryFindIndex(this.data, elem => this._elemIsAfter(obj, elem)); - this._insertAt(obj, idx); - } - - /** - * Helper to insert an object at a specified index. Inserts at end if index is -1. - * @param {Object} obj Object to insert - * @param {number} idx Location to insert - */ - _insertAt(obj, idx) { -// ~idx ? (this.data = this.data.slice(0, idx).concat(obj, this.data.slice(idx))) : this.data.push(obj); - ~idx ? this.data.splice(idx, undefined, obj) : this.data.push(obj); - } - - /** - * Remove object - */ - remove(obj) { - const idx = this.data.findIndex(elem => this._elemIsAfter(obj, elem)); - this._removeAt(idx); - } - - /** - * Remove object using binary search - */ - binaryRemove(obj) { - const idx = binaryFindIndex(this.data, elem => this._elemIsAfter(obj, elem)); - this._removeAt(idx); - } - - /** - * Helper to remove an object at a specified index. - */ - _removeAt(idx) { - this.data.splice(idx, 1); - } - - /** - * Helper function transforming the comparator output to true/false; used by insert. - * @param {Object} obj Object to search for - * @param {Object} elem Element of the array - * @return {boolean} True if the element is after the segment in the ordered array. - */ - _elemIsAfter(obj, elem) { return this.comparator(obj, elem) < 0; } - -} \ No newline at end of file diff --git a/scripts/module.js b/scripts/module.js index ff22c8f..826b057 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -13,13 +13,31 @@ import { MODULE_ID } from "./const.js"; import { iterateGridUnderLine } from "./util.js"; import { registerGeometry } from "./geometry/registration.js"; +// Pathfinding +import { BorderTriangle, BorderEdge } from "./pathfinding/BorderTriangle.js"; +import { Pathfinder, BreadthFirstPathSearch, UniformCostPathSearch, AStarPathSearch } from "./pathfinding/pathfinding.js"; +import { PriorityQueueArray } from "./pathfinding/PriorityQueueArray.js"; +import { PriorityQueue } from "./pathfinding/PriorityQueue.js"; + Hooks.once("init", function() { // Cannot access localization until init. PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); registerGeometry(); game.modules.get(MODULE_ID).api = { iterateGridUnderLine, - PATCHER + PATCHER, + + // Pathfinding + pathfinding: { + BorderTriangle, + BorderEdge, + Pathfinder, + BreadthFirstPathSearch, + UniformCostPathSearch, + AStarPathSearch, + PriorityQueueArray, + PriorityQueue + } }; }); diff --git a/scripts/pathfinding/BinarySearch.js b/scripts/pathfinding/BinarySearch.js new file mode 100644 index 0000000..22c43e0 --- /dev/null +++ b/scripts/pathfinding/BinarySearch.js @@ -0,0 +1,484 @@ +/* Functions to binary search a sorted array. +Binary, Interpolate, and InterpolateBinary options. +For each, there are versions to work with an array of numbers and an array of objects +that can be scored. + +• Binary halves the search each time from the midpoint. O(log(n)) +• Interpolate halves the search from a point based on + where the target value is likely to be given min and max of the array + and assuming a uniform distribution. + - Best case: O(log(log(n))) + - Worse case: O(n). +• InterpolateBinary starts like Interpolate but then runs binary search + on the two halves of the array. This repeats for each subsequent iteration. + +If the array is close to uniformly distributed, like the output of +Math.random(), then Interpolate will likely be fastest. If relatively +uniform but might have jumps/gaps, then InterpolateBinary may do better. +If not uniform at all, Binary may be best. + +If normally distributed, Interpolation search likely very slow +but Binary and InterpolateBinary may perform similarly. + +Type of searching: +• indexOf: Comparable to Array.indexOf, for numeric arrays. +• indexOfCmp: Comparable to Array.indexOf, but takes the comparator used to sort the array + Used when the array elements are sorted objects. +• findIndex: Comparable to Array.findIndex. Finds the first element that is true + for a comparison function. Requires that once true, every subsequent + element in the sorted array is true. + +*/ + +/** + * Find the index of a value in a sorted array of numbers. + * Comparable to Array.indexOf but uses binary search. + * O(log(n)) time. + * @param {Number[]} arr Array to search. Must be sorted low to high. + * @param {Number} x Value to locate. + * @return {Number|-1} Index of the value or -1 if not found. + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * binaryIndexOf(arr, 2) + * arr.indexOf(2) + */ +export function binaryIndexOf(arr, x) { + let start = 0; + let end = arr.length - 1; + + // Iterate, halving the search each time. + while (start <= end) { + const mid = Math.floor((start + end) / 2); + if (arr[mid] === x) return mid; + + if (arr[mid] < x) { + start = mid + 1; + } else { + end = mid - 1; + } + } + return -1; +} + +/** + * Find the index of a value in a sorted array of objects. + * Comparable to Array.indexOf but uses binary search. + * O(log(n)) time. + * @param {Number[]} arr Array to search. Must be sorted low to high. + * @param {Number} obj Object to locate. + * @param {Function} cmpFn Comparison function to use. Typically should be function + * used to sort the array. Must return 0 if elem === obj. + * @return {number|-1} Index of the value or -1 if not found. + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * binaryIndexOfCmp(arr, 2, cmpNum) + * arr.indexOf(2) + */ +export function binaryIndexOfObject(arr, obj, cmpFn = (a, b) => a - b) { + let start = 0; + let end = arr.length - 1; + + // Iterate, halving the search each time. + while (start <= end) { + let mid = Math.floor((start + end) / 2); + let res = cmpFn(obj, arr[mid], mid); + if (!res) { return mid; } + + if (res > 0) { + start = mid + 1; + } else { + end = mid - 1; + } + } + return -1; +} + + +/** + * Find the first element that meets a condition in a sorted array, + * based on binary search of a sorted array. + * Comparable to Array.findIndex, but in O(log(n)) time. + * @param {Object[]} arr Array to search + * @param {Function} comparator Comparison function to call. + * Must return true or false, like with Array.findIndex. + * @return {number|-1} Index of the object or -1 if not found. + * + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * binaryFindIndex(arr, elem => elem > 3) + * arr.findIndex(elem => elem > 3) + * + * binaryFindIndex(arr, elem => cmpNum(3, elem) <= 0) + * arr.findIndex(elem => elem > cmpNum(3, elem) <= 0) + * + * binaryFindIndex(arr, elem => cmpNum(elem, 3) > 0) + * arr.findIndex(elem => cmpNum(elem, 3) > 0) + * + * or + * arr = Array.fromRange(100).map(elem => Math.random()) + * arr.sort((a, b) => a - b) + */ +export function binaryFindIndex(arr, comparator) { + let start = 0; + const ln = arr.length; + let end = ln - 1; + let mid = -1; + + // Need first index for which callbackFn returns true + // b/c the array is sorted, once the callbackFn is true for an index, + // it is assumed true for the rest. + // So, e.g, [F,F,F, T, T, T, T] + // Progressively check until we have no items left. + + // Iterate, halving the search each time we find a true value. + let last_true_index = -1; + while (start <= end) { + // Find the mid index. + mid = Math.floor((start + end) / 2); + + // Determine if this index returns true. + const res = comparator(arr[mid], mid); + + if (res) { + // If the previous is false, we are done. + if ((mid - 1) >= 0 && !comparator(arr[mid - 1], mid - 1)) { return mid; } + // If we found a true value, we can ignore everything after mid + last_true_index = mid; + end = mid - 1; + } else { + // If the next value is true, we are done. + if ((mid + 1) < ln && comparator(arr[mid + 1], mid + 1)) { return mid + 1; } + // Otherwise, the first true value might be after mid. + // (b/c it is sorted, it cannot be before.) + start = mid + 1; + } + } + + return last_true_index; +} + +/** + * Find the index of an object in a sorted array of numbers + * that is approximately uniformly distributed. + * Expected O(log(log(n))) but can take up to O(n). + * @param {Number[]} arr Array to search + * @param {Number} x Value to find. + * @return {number|-1} Index of the object found or -1 if not found. + * + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * interpolationIndexOf(arr, 2) + * arr.indexOf(2) + */ +export function interpolationIndexOf(arr, x) { + let start = 0; + let end = arr.length - 1; + let position = -1; + let delta = -1; + while (start <= end) { + const v_start = arr[start]; + const v_end = arr[end]; + if (x < v_start || x > v_end) { break; } + + delta = (x - v_start) / (v_end - v_start); + position = start + Math.floor((end - start) * delta); + const v_position = arr[position]; + + if (v_position === x) { + return position; + } + + if (v_position < x) { + start = position + 1; + } else { + end = position - 1; + } + } + + return -1; +} + + +/** + * Find the index of an object in a sorted array of numbers + * that is approximately uniformly distributed. + * Expected O(log(log(n))) but can take up to O(n). + * @param {Object[]} arr Array to search + * @param {Object} obj Object to find. + * @param {Function} valuationFn How to value each object in the array. + * Must be ordered comparable to the sort + * @return {number|-1} Index of the object found or -1 if not found. + * + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * interpolationIndexOf(arr, 2, cmpNum) + * arr.indexOf(2) + */ +export function interpolationIndexOfObject(arr, obj, valuationFn = a => a) { + let start = 0; + let end = arr.length - 1; + let position = -1; + let delta = -1; + const target = valuationFn(obj); + while (start <= end) { + const v_start = valuationFn(arr[start]); + const v_end = valuationFn(arr[end]); + if (target < v_start || target > v_end) { break; } + + delta = (target - v_start) / (v_end - v_start); + position = start + Math.floor((end - start) * delta); + const v_position = valuationFn(arr[position]); + + if (v_position === target) { return position; } + + if (v_position < target) { + start = position + 1; + } else { + end = position - 1; + } + } + + return -1; +} + + +/** + * Find the first element that meets a condition in a sorted array, + * where the values in the array are approximately uniformly distributed. + * Expected O(log(log(n))) but can take up to O(n). + * @param {Object[]} arr Array to search + * @param {Function} comparator Comparison function to call. + * Must return true or false, like with Array.findIndex. + * @return {number|-1} Index of the object found or -1 if not found. + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * interpolationFindIndexBeforeScalar(arr, elem => elem ) + */ +export function interpolationFindIndexBeforeScalar(arr, x) { + let start = 0; + let end = arr.length - 1; + + if (x > arr[end]) return end; + if (x < arr[0]) return -1; + + while (start <= end) { + const delta = (x - arr[start]) / (arr[end] - arr[start]); + const position = start + Math.floor((end - start) * delta); + + if (arr[position] === x) return position - 1; + + if (arr[position] < x) { + if (arr[position + 1] > x) return position; + start = position + 1; + } else { + if (arr[position - 1] < x) return position - 1; + end = position - 1; + } + } + return -1; +} + + +/** + * Find the index of an object that is less than but nearest value in a sorted array, + * where the values in the array are approximately uniformly distributed. + * Probably O(log(log(n))) but can take up to O(n). + * @param {Object[]} arr Array to search + * @param {Object} obj Object to find. + * @param {Function} valuationFn How to value each object in the array. + * Must be ordered comparable to the sort + * @return {number|-1} Index of the object found or -1 if not found. + * Example: + * cmpNum = (a, b) => a - b; + * arr = [0,1,2,3,4,5,6,7] + * arr.sort(cmpNum) + * interpolationFindIndexBeforeObj(arr, 2.5) + */ +export function interpolationFindIndexBeforeObject(arr, obj, valuationFn = a => a) { + let start = 0; + let end = arr.length - 1; + const x = valuationFn(obj); + + if (x > valuationFn(arr[end])) return end; + if (x < valuationFn(arr[0])) return -1; + + while (start <= end) { + const v_start = valuationFn(arr[start]); + const v_end = valuationFn(arr[end]); + + const delta = (x - v_start) / (v_end - v_start); + const position = start + Math.floor((end - start) * delta); + const v_position = valuationFn(arr[position]); + + if (v_position === x) return position - 1; + + if (v_position < x) { + if (valuationFn(arr[position + 1]) > x) return position; + start = position + 1; + } else { + if (valuationFn(arr[position - 1]) < x) return position - 1; + end = position - 1; + } + } + return -1; +} + +/** + * Find the index of a value in a sorted array of numbers. + * Comparable to Array.indexOf but uses interpolation and binary search together. + * Expected time of between O(log(n)) and O(log(log(n))). + * Implements https://www.sciencedirect.com/science/article/pii/S221509862100046X + * @param {Number[]} arr Array to search. Must be sorted low to high. + * @param {Number} x Value to locate + * @return {Number|-1} Index of the value or -1 if not found + */ +export function interpolateBinaryIndexOf(arr, x) { + let left = 0; + let right = arr.length - 1; + + if (x > arr[right] || x < arr[0]) { return -1; } + + while (left < right) { + if (x < arr[left] || x > arr[right]) { break; } + + const inter = left + Math.ceil((x - arr[left]) / (arr[right] - arr[left]) * (right - left)); + + if (x === arr[inter]) { + return inter; + } else if (x > arr[inter]) { + const mid = Math.floor((inter + right) / 2); + if (x <= arr[mid]) { + left = inter + 1; + right = mid; + } else { + left = mid + 1; + } + } else { + const mid = Math.floor((inter + left) / 2); + if (x >= arr[mid]) { + left = mid; + right = inter - 1; + } else { + right = mid - 1; + } + } + } + + if (x === arr[left]) { return left; } + return -1; +} + +/** + * Find the index of a value in a sorted array of numbers. + * Comparable to Array.indexOf but uses interpolation and binary search together. + * Expected time of between O(log(n)) and O(log(log(n))). + * Implements https://www.sciencedirect.com/science/article/pii/S221509862100046X + * @param {Number[]} arr Array to search. Must be sorted low to high. + * @param {Number} x Value to locate + * @return {Number|-1} Index of the value or -1 if not found + */ +export function interpolateBinaryFindIndexBeforeScalar(arr, x) { + let left = 0; + let right = arr.length - 1; + + if (x > arr[right]) { return right; } + if (x < arr[0]) { return -1; } + + while (left < right) { + if (x < arr[left] || x > arr[right]) { break; } + + const inter = left + Math.ceil((x - arr[left]) / (arr[right] - arr[left]) * (right - left)); + + if (x === arr[inter]) { + return inter - 1; + } else if (x > arr[inter]) { + const mid = Math.floor((inter + right) / 2); + if (x <= arr[mid]) { + left = inter + 1; + right = mid; + } else { + left = mid + 1; + } + } else { + const mid = Math.floor((inter + left) / 2); + if (x >= arr[mid]) { + left = mid; + right = inter - 1; + } else { + right = mid - 1; + } + } + } + + + if (x > arr[left - 1] && x < arr[left]) { return left - 1; } + if (x > arr[right] && x < arr[right + 1]) { return right; } + + return -1; +} + +/** + * Find the index of a value in a sorted array of objects that can be scored. + * Comparable to Array.indexOf but uses interpolation and binary search together. + * Expected time of between O(log(n)) and O(log(log(n))). + * Implements https://www.sciencedirect.com/science/article/pii/S221509862100046X + * @param {Number[]} arr Array to search. Must be sorted low to high. + * @param {Object} obj Object to find. + * @param {Function} valuationFn How to value each object in the array. + * Must be ordered comparable to the sort + * @return {Number|-1} Index of the value or -1 if not found + */ +export function interpolateBinaryFindIndexBeforeObject(arr, obj, valuationFn = a => a) { + let left = 0; + let right = arr.length - 1; + const x = valuationFn(obj); + + if (x > valuationFn(arr[right])) { return right; } + if (x < valuationFn(arr[0])) { return -1; } + + while (left < right) { + const v_left = valuationFn(arr[left]); + const v_right = valuationFn(arr[right]); + if (x < v_left || x > v_right) { break; } + + const inter = left + Math.ceil((x - v_left) / (v_right - v_left) * (right - left)); + const v_inter = valuationFn(arr[inter]); + + if (x === v_inter) { + return inter - 1; + } else if (x > v_inter) { + const mid = Math.floor((inter + right) / 2); + if (x <= valuationFn(arr[mid])) { + left = inter + 1; + right = mid; + } else { + left = mid + 1; + } + } else { + const mid = Math.floor((inter + left) / 2); + if (x >= valuationFn(arr[mid])) { + left = mid; + right = inter - 1; + } else { + right = mid - 1; + } + } + } + + if (x > valuationFn(arr[left - 1]) && x < valuationFn(arr[left])) { return left - 1; } + if (x > valuationFn(arr[right]) && x < valuationFn(arr[right + 1])) { return right; } + + return -1; +} diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index b4dfade..9188430 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -1,9 +1,13 @@ /* globals -canvas +canvas, +foundry, +PIXI, +PointSourcePolygon */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ -import { Draw } from "./geometry/Draw.js"; +import { Draw } from "../geometry/Draw.js"; + /** * An edge that makes up the triangle-shaped polygon @@ -105,9 +109,13 @@ export class BorderEdge { // Ignore one-directional walls which are facing away from the center const side = wall.orientPoint(origin); - const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; +// const wdm = PointSourcePolygon.WALL_DIRECTION_MODES; +// if ( wall.document.dir +// && (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false; + if ( wall.document.dir - && (wallDirectionMode === wdm.NORMAL) === (side === wall.document.dir) ) return false; + && side === wall.document.dir ) return false; + return true; } @@ -181,7 +189,7 @@ export class BorderTriangle { if ( !oABC ) throw Error("BorderTriangle requires three non-collinear points."); if ( oABC < 0 ) { // Flip to ccw. - [a, b, c] = [c, b, a]; + [a, c] = [c, a]; [edgeAB, edgeCA] = [edgeCA, edgeAB]; } @@ -250,37 +258,47 @@ export class BorderTriangle { /** * Provide valid destinations given that you came from a specific neighbor. - * Blocked walls are invalid. - * Typically returns 2 corner destinations plus the median destination. - * @param {Point} entryPoint + * Typically returns 2 corner destinations plus the median destination per edge. + * Invalid destinations for an edge: + * - blocked walls + * - no neighbor (edge on border of map) + * - edge length < 2 * spacer + * - edge shared with the prior triangle, if any + * + * Corner destination skipped if median --> corner < spacer + * + * @param {Point} priorEntryPoint * @param {BorderTriangle|null} priorTriangle * @param {number} spacer How much away from the corner to set the corner destinations. - * If the edge is less than 2 * spacer, it will be deemed invalid. - * Corner destinations are skipped if not more than spacer away from median. - * @returns {object[]} Each element has properties describing the destination: - * - {BorderTriangle} triangle + * @returns {object[]} Each element has properties describing the destination, conforming to pathfinding + * - {BorderTriangle} key * - {Point} entryPoint - * - {number} distance + * - {number} cost */ - getValidDestinations(entryPoint, priorTriangle, spacer) { + getValidDestinations(priorEntryPoint, priorTriangle, spacer) { spacer ??= canvas.grid.size * 0.5; const destinations = []; const center = this.center; for ( const edge of Object.values(this.edges) ) { - const neighbor = edge.otherTriangle(this); - if ( priorTriangle && priorTriangle === neighbor ) continue; + const key = edge.otherTriangle(this); // Neighbor + if ( !key ) continue; + if ( priorTriangle && priorTriangle === key ) continue; const pts = edge.getValidDestinations(center, spacer); - pts.forEach(pt => { + pts.forEach(entryPoint => { destinations.push({ - entryPoint: pt, - triangle: neighbor, + entryPoint, + key, + + // Debugging: + // priorEntryPoint, + // priorTriangle, // TODO: Handle 3d distances. // Probably use canvas.grid.measureDistances, passing a Ray3d. // TODO: Handle terrain distance - distance: canvas.grid.measureDistance(center, pt), + cost: canvas.grid.measureDistance(priorEntryPoint, entryPoint) }); - }) + }); } return destinations; } @@ -330,4 +348,4 @@ export class BorderTriangle { } } } -} \ No newline at end of file +} diff --git a/scripts/PriorityQueue.js b/scripts/pathfinding/PriorityQueue.js similarity index 94% rename from scripts/PriorityQueue.js rename to scripts/pathfinding/PriorityQueue.js index 466eb6a..40e7c45 100644 --- a/scripts/PriorityQueue.js +++ b/scripts/pathfinding/PriorityQueue.js @@ -2,6 +2,12 @@ // from https://www.digitalocean.com/community/tutorials/js-binary-heaps class Node { + /** @param {object} */ + val = {}; + + /** @param {number} */ + priority = -1; + constructor(val, priority) { this.val = val; this.priority = priority; @@ -14,12 +20,14 @@ class Node { * Highest priority item will be dequeued first. */ export class PriorityQueue { - constructor() { - this.values = []; - } + /** @param {Node[]} */ + values = []; get length() { return this.values.length; } + /** @type {number} */ + clear() { this.data.length = 0; } + /** * Convert a sorted array to a queue diff --git a/scripts/pathfinding/PriorityQueueArray.js b/scripts/pathfinding/PriorityQueueArray.js new file mode 100644 index 0000000..f82fac8 --- /dev/null +++ b/scripts/pathfinding/PriorityQueueArray.js @@ -0,0 +1,73 @@ +// Very basic priority queue based on an array +// Allows for a custom comparator on which to sort and the option to switch the +// initial sort algorithm + +import { binaryFindIndex } from "./BinarySearch.js"; +import { radixSortObj } from "../geometry/RadixSort.js"; + +/** + * For speed, this class adds a "_priority" object to each data object + * instead of using a Node class. + */ +export class PriorityQueueArray { + /** @param {} */ + data = []; + + /** @type {number} */ + get length() { return this.data.length; } + + /** @type {number} */ + clear() { this.data.length = 0; } + + /** + * Convert a sorted array to a queue + */ + static fromArray(arr, priorityFn) { + const pq = new this(); + pq.data = arr.map(elem => { + elem._priority = priorityFn(elem); + return elem; + }); + pq.data = radixSortObj(pq.data, "_priority").reverse(); + } + + /** + * Add an object to the queue + * @param {Object} val Object to store in the queue + * @param {number} priority Priority of the object to store + */ + enqueue(val, priority) { + val._priority = priority; + const idx = this.findPriorityIndex(val); + this._insertAt(val, idx); + } + + /** + * Remove the highest priority object from the queue + * @return {Object|undefined} + */ + dequeue() { return this.data.pop(); } + + /** + * Examine the highest priority item in the queue without removing it. + * @return {Object} + */ + get peek() { return this.data.at(-1); } + + /** + * Helper to insert an object at a specified index. Inserts at end if index is -1. + * @param {Object} obj Object to insert + * @param {number} idx Location to insert + */ + _insertAt(obj, idx) { + if ( ~idx ) this.data.splice(idx, undefined, obj); + else this.data.push(obj); + } + + /** + * Find the index of an object in this queue, or the index where the object would be. + * @param {object} object Object, with "_priority" property. + * @returns {number} + */ + findPriorityIndex(obj) { return binaryFindIndex(this.data, elem => (obj._priority - elem._priority) < 0); } +} diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index e0fddc9..777ebb2 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -1,112 +1,143 @@ /* globals -canvas +canvas, +CanvasQuadtree, +Delaunator, +PIXI */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ -import { BorderTriangle, BorderEdge } from "./BorderTriangle.js"; +import { BorderTriangle } from "./BorderTriangle.js"; +import { PriorityQueueArray } from "./PriorityQueueArray.js"; +// import { PriorityQueue } from "./PriorityQueue.js"; +import { boundsForPoint } from "../util.js"; +import { Draw } from "../geometry/Draw.js"; -Draw = CONFIG.GeometryLib.Draw; - - - - -// Triangulate -/* -Take the 4 corners plus coordinates of each wall endpoint. -(TODO: Use wall edges to capture overlapping walls) - -Triangulate. - -Can traverse using the half-edge structure. - -Start in a triangle. For now, traverse between triangles at midpoints. -Triangle coords correspond to a wall. Each triangle edge may or may not block. -Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). -This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. - -Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. -Goal heuristic based on distance (modified by terrain?). -Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. - -Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. - -*/ - - -// Draw each endpoint -for ( const key of endpointKeys ) { - const pt = PIXI.Point.invertKey(key); - Draw.point(pt, { color: Draw.COLORS.blue }) -} +/* Testing -// Draw each triangle -triangles = []; -for (let i = 0; i < delaunay.triangles.length; i += 3) { - const j = delaunay.triangles[i] * 2; - const k = delaunay.triangles[i + 1] * 2; - const l = delaunay.triangles[i + 2] * 2; - triangles.push(new PIXI.Polygon( - delaunay.coords[j], delaunay.coords[j + 1], - delaunay.coords[k], delaunay.coords[k + 1], - delaunay.coords[l], delaunay.coords[l + 1] - )); -} - -for ( const tri of triangles ) Draw.shape(tri); - - - - -borderTriangles.forEach(tri => tri.drawEdges()); -borderTriangles.forEach(tri => tri.drawLinks()) - - -// Use Quadtree to locate starting triangle for a point. - -// quadtree.clear() -// quadtree.update({r: bounds, t: this}) -// quadtree.remove(this) -// quadtree.update(this) - - -quadtreeBT = new CanvasQuadtree() -borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) +Draw = CONFIG.GeometryLib.Draw; +api = game.modules.get("elevationruler").api +Pathfinder = api.pathfinding.Pathfinder +Pathfinder.initialize() +endPoint = _token.center token = _token startPoint = _token.center; -endPoint = _token.center -// Find the strat and end triangles -collisionTest = (o, _rect) => o.t.contains(startPoint); -startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); +pf = new Pathfinder(token); +path = pf.breadthFirstPath(startPoint, endPoint) +pathPoints = pf.getPathPoints(path); +pf.drawPath(pathPoints, { color: Draw.COLORS.red }) -collisionTest = (o, _rect) => o.t.contains(endPoint); -endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); +path = pf.uniformCostPath(startPoint, endPoint) +pathPoints = pf.getPathPoints(path); +pf.drawPath(pathPoints, { color: Draw.COLORS.blue }) -startTri.drawEdges(); -endTri.drawEdges(); - -// Locate valid destinations -destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); -destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -destinations.sort((a, b) => a.distance - b.distance); - - -// Pick direction, repeat. -chosenDestination = destinations[0]; -Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) -nextTri = chosenDestination.triangle; -destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); -destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -destinations.sort((a, b) => a.distance - b.distance); - - -function boundsForPoint(pt) { - return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); -} +*/ +// Pathfinder.initialize(); +// +// Draw = CONFIG.GeometryLib.Draw; +// +// measureInitWalls = performance.measure("measureInitWalls", "Pathfinder|Initialize Walls", "Pathfinder|Initialize Delauney") +// measureInitDelaunay = performance.measure("measureInitDelaunay", "Pathfinder|Initialize Delauney", "Pathfinder|Initialize Triangles") +// measureInitTriangles = performance.measure("measureInitTriangles", "Pathfinder|Initialize Triangles", "Pathfinder|Finished Initialization") +// console.table([measureInitWalls, measureInitDelaunay,measureInitTriangles ]) +// +// +// +// // Triangulate +// /* +// Take the 4 corners plus coordinates of each wall endpoint. +// (TODO: Use wall edges to capture overlapping walls) +// +// Triangulate. +// +// Can traverse using the half-edge structure. +// +// Start in a triangle. For now, traverse between triangles at midpoints. +// Triangle coords correspond to a wall. Each triangle edge may or may not block. +// Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). +// This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. +// +// Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. +// Goal heuristic based on distance (modified by terrain?). +// Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. +// +// Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. +// +// */ +// +// +// // Draw each endpoint +// for ( const key of endpointKeys ) { +// const pt = PIXI.Point.invertKey(key); +// Draw.point(pt, { color: Draw.COLORS.blue }) +// } +// +// // Draw each triangle +// triangles = []; +// for (let i = 0; i < delaunay.triangles.length; i += 3) { +// const j = delaunay.triangles[i] * 2; +// const k = delaunay.triangles[i + 1] * 2; +// const l = delaunay.triangles[i + 2] * 2; +// triangles.push(new PIXI.Polygon( +// delaunay.coords[j], delaunay.coords[j + 1], +// delaunay.coords[k], delaunay.coords[k + 1], +// delaunay.coords[l], delaunay.coords[l + 1] +// )); +// } +// +// for ( const tri of triangles ) Draw.shape(tri); +// +// +// +// +// borderTriangles.forEach(tri => tri.drawEdges()); +// borderTriangles.forEach(tri => tri.drawLinks()) +// +// +// // Use Quadtree to locate starting triangle for a point. +// +// // quadtree.clear() +// // quadtree.update({r: bounds, t: this}) +// // quadtree.remove(this) +// // quadtree.update(this) +// +// +// quadtreeBT = new CanvasQuadtree() +// borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) +// +// +// token = _token +// startPoint = _token.center; +// endPoint = _token.center +// +// // Find the strat and end triangles +// collisionTest = (o, _rect) => o.t.contains(startPoint); +// startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); +// +// collisionTest = (o, _rect) => o.t.contains(endPoint); +// endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); +// +// startTri.drawEdges(); +// endTri.drawEdges(); +// +// // Locate valid destinations +// destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); +// destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +// destinations.sort((a, b) => a.distance - b.distance); +// +// +// // Pick direction, repeat. +// chosenDestination = destinations[0]; +// Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) +// nextTri = chosenDestination.triangle; +// destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); +// destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) +// destinations.sort((a, b) => a.distance - b.distance); +// /* For the triangles, need: √ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. @@ -117,7 +148,16 @@ function boundsForPoint(pt) { - Provide 2x corner + median pass-through points */ -class Pathfinder { +/** + * @typedef {object} PathNode + * @property {BorderTriangle} key The destination triangle + * @property {PIXI.Point} entryPoint The point on the edge of or within the destination triangle + * where the path will enter the triangle + * @property {number} cost Cost of the path from the last entryPoint to this entryPoint. + * @property {PathNode[]} neighbors Neighbors of this path node + */ + +export class Pathfinder { /** @type {CanvasQuadTree} */ static quadtree = new CanvasQuadtree(); @@ -140,9 +180,25 @@ class Pathfinder { * Initialize properties used for pathfinding related to the scene walls. */ static initialize() { + this.clear(); + + performance.mark("Pathfinder|Initialize Walls"); this.initializeWalls(); + + performance.mark("Pathfinder|Initialize Delauney"); this.initializeDelauney(); + + performance.mark("Pathfinder|Initialize Triangles"); this.initializeTriangles(); + + performance.mark("Pathfinder|Finished Initialization"); + } + + static clear() { + this.borderTriangles.length = 0; + this.triangleEdges.clear(); + this.wallKeys.clear(); + this.quadtree.clear(); } /** @@ -167,13 +223,13 @@ class Pathfinder { * TODO: Use wall segments instead of walls to handle overlapping walls. */ static initializeDelauney() { - const { delauney, endpointKeys } = this; + const endpointKeys = this.endpointKeys; for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { endpointKeys.add(wall.vertices.a.key); endpointKeys.add(wall.vertices.b.key); } - coords = new Uint32Array(endpointKeys.size * 2); + const coords = new Uint32Array(endpointKeys.size * 2); let i = 0; for ( const key of endpointKeys ) { const pt = PIXI.Point.invertKey(key); @@ -182,7 +238,7 @@ class Pathfinder { i += 2; } - delaunay = new Delaunator(coords); + this.delaunay = new Delaunator(coords); } /** @@ -190,9 +246,11 @@ class Pathfinder { * Must first run initializeDelauney and initializeWalls. */ static initializeTriangles() { - const { borderTriangles, triangleEdges, delaunay, wallKeys } = this; + const { borderTriangles, triangleEdges, delaunay, wallKeys, quadtree } = this; // Build array of border triangles + const nTriangles = delaunay.triangles.length / 3; + borderTriangles.length = nTriangles; for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { const j = delaunay.triangles[i] * 2; const k = delaunay.triangles[i + 1] * 2; @@ -202,8 +260,11 @@ class Pathfinder { const b = { x: delaunay.coords[k], y: delaunay.coords[k + 1] }; const c = { x: delaunay.coords[l], y: delaunay.coords[l + 1] }; const tri = BorderTriangle.fromPoints(a, b, c); - borderTriangles.push(tri); + borderTriangles[ii] = tri; tri.id = ii; // Mostly for debugging at this point. + + // Add to the quadtree + quadtree.insert({ r: tri.bounds, t: tri }); } // Set the half-edges @@ -227,7 +288,7 @@ class Pathfinder { } // Set the wall, if any, for each triangle edge - nullSet = new Set(); + const nullSet = new Set(); for ( const edge of triangleEdges.values() ) { const aKey = edge.a.key; const bKey = edge.b.key; @@ -237,7 +298,242 @@ class Pathfinder { } } + /** @type {Token} token */ + token; + + /** + * Optional token to associate with this path. + * Used for path spacing near obstacles. + * @param {Token} token + */ + constructor(token) { + this.token = token; + } + + /** @type {number} */ + _spacer = 0; + + get spacer() { + return this._spacer || (this.token.w * 0.5) || (canvas.dimensions.size * 0.5); + } + + /** + * Run a breadth first, early exit search of the border triangles. + * @param {Point} startPoint Start point for the graph + * @param {Point} endPoint End point for the graph + * @returns {Map} + */ + _breadthFirstPF; + + breadthFirstPath(startPoint, endPoint) { + const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); + if ( !this._breadthFirstPF ) { + this._breadthFirstPF = new BreadthFirstPathSearch(); + this._breadthFirstPF.getNeighbors = this.identifyDestinations.bind(this); + } + return this._breadthFirstPF.run(start, end); + } + + /** + * Run a uniform cost search (Dijkstra's). + * @param {Point} startPoint Start point for the graph + * @param {Point} endPoint End point for the graph + * @returns {Map} + */ + _uniformCostPF; + + uniformCostPath(startPoint, endPoint) { + const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); + if ( !this._uniformCostPF ) { + this._uniformCostPF = new UniformCostPathSearch(); + this._uniformCostPF.getNeighbors = this.identifyDestinations.bind(this); + } + return this._uniformCostPF.run(start, end); + } + + /** + * Locate start and end triangles for the start and end points and + * return the corresponding path nodes. + * @param {Point} startPoint Start point for the graph + * @param {Point} endPoint End point for the graph + * @returns {object} + * - {PathNode} start + * - {PathNode} end + */ + _initializeStartEndNodes(startPoint, endPoint) { + // Locate start and end triangles. + // TODO: Handle 3d + const quadtree = this.constructor.quadtree; + startPoint = PIXI.Point.fromObject(startPoint); + endPoint = PIXI.Point.fromObject(endPoint); + + let collisionTest = (o, _rect) => o.t.contains(startPoint); + const startTri = quadtree.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); + + collisionTest = (o, _rect) => o.t.contains(endPoint); + const endTri = quadtree.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); + + const start = { key: startTri, entryPoint: PIXI.Point.fromObject(startPoint) }; + const end = { key: endTri, entryPoint: endPoint }; + + return { start, end }; + } + + + /** + * Get destinations for a given pathfinding object, which is a triangle plus an entryPoint. + * @param {PathNode} pathObject + * @returns {PathNode[]} Array of destination nodes + */ + identifyDestinations(pathNode) { + return pathNode.key.getValidDestinations(pathNode.entryPoint, pathNode.key, this.spacer); + } + + /** + * Identify path points, in order from start to finish, for a cameFrom path map. + * @returns {PIXI.Point[]} + */ + getPathPoints(pathMap) { + let current = pathMap.goal; + const pts = [current.entryPoint]; + while ( current.key !== pathMap.start.key ) { + current = pathMap.get(current.key); + pts.push(current.entryPoint); + } + return pts; + } + + drawPath(pathPoints, opts) { + const nPts = pathPoints.length; + let prior = pathPoints[0]; + Draw.point(prior); + for ( let i = 1; i < nPts; i += 1 ) { + const curr = pathPoints[i]; + Draw.segment({A: prior, B: curr}, opts); + Draw.point(curr, opts); + prior = curr; + } + } } +// See https://www.redblobgames.com/pathfinding/a-star/introduction.html + + +/** + * Pathfinding objects used here must have the following properties: + * - {object} key Unique object, string, or number that can be used in a Set or as a Map key. + * - {number} cost For algorithms that measure value, the cost of this move. + * Objects can have any other properties needed. + */ +export class BreadthFirstPathSearch { + /** @type {PathNode[]} */ + frontier = []; + + /** @type {Map} */ + cameFrom = new Map(); // Path a -> b stored as cameFrom[b] = a + /** + * Run the breadth first search on the graph. + * Each object must have a unique key property used for comparison. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, frontier } = this; + frontier.unshift(start); + + while ( frontier.length ) { + const current = frontier.pop(); + if ( current.key === goal.key ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current) ) { + if ( !cameFrom.has(next.key) ) { + frontier.unshift(next); + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } + + /** + * Neighbor method that can be overriden to handle different objects. + * @param {PathNode} pathNode Object representing a graph node + * @returns {Array[PathNode]} Array of neighboring objects to the node. + */ + getNeighbors(pathNode) { return pathNode.neighbors; } + + /** + * Clear the path properties. + */ + clear() { + this.frontier.length = 0; + this.cameFrom.clear(); + } +} + +/** + * Dijkstra's Algorithm, or uniform cost path search. + */ +export class UniformCostPathSearch extends BreadthFirstPathSearch { + frontier = new PriorityQueueArray([], { comparator: (a, b) => a.cost - b.cost }); + + /** @type {Map} */ + costSoFar = new Map(); + + clear() { + this.frontier.clear(); + this.costSoFar.clear(); + this.cameFrom.clear(); + } + + /** + * Run the cost search on the graph. + * Each PathNode must have a unique key property used for comparison + * and cost property used to value cost so far. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, costSoFar, frontier } = this; + + frontier.enqueue(start, 0); + costSoFar.set(start.key, 0); + + while ( frontier.length ) { + const current = frontier.dequeue(); + if ( current.key === goal.key ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current) ) { + const newCost = costSoFar.get(current.key) + next.cost; + if ( !costSoFar.has(next.key) || newCost < costSoFar.get(next.key) ) { + frontier.enqueue(next, newCost); // Priority is newCost + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } +} + +export class AStarPathSearch { + /** @type {PriorityQueueArray} */ + frontier = new PriorityQueueArray([], { comparator: (a, b) => a.distance - b.distance }); + + /** @type {Map} */ + cameFrom = new Map(); // path a -> b stored as cameFrom[b] = a + +} diff --git a/scripts/util.js b/scripts/util.js index a10c30d..26efeb8 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -137,3 +137,10 @@ export function tokenIsSnapped(token) { return snappedX.almostEqual(x) && snappedY.almostEqual(y); } +/** + * Create a very small rectangle for a point to be used with Quadtree. + * @param {Point} pt + * @returns {PIXI.Rectangle} + */ +export function boundsForPoint(pt) { return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); } + From f8e1cf5da686c62b329463faf3a8d7f7faf4fca5 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sun, 14 Jan 2024 12:42:09 -0800 Subject: [PATCH 07/25] Working Uniform Cost Search, Breadth First Search --- scripts/pathfinding/PriorityQueueArray.js | 21 +++++++++++++-- scripts/pathfinding/pathfinding.js | 32 +++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/scripts/pathfinding/PriorityQueueArray.js b/scripts/pathfinding/PriorityQueueArray.js index f82fac8..7d180ed 100644 --- a/scripts/pathfinding/PriorityQueueArray.js +++ b/scripts/pathfinding/PriorityQueueArray.js @@ -10,9 +10,26 @@ import { radixSortObj } from "../geometry/RadixSort.js"; * instead of using a Node class. */ export class PriorityQueueArray { - /** @param {} */ + /** @param {object[]} */ data = []; + /** @param {function} */ + comparator = (elem, obj) => (obj._priority - elem._priority) < 0; + + /** + * @param {"high"|"low"|function} comparator What is the first element to leave the queue: + * - highest priority, + * - lowest priority, or + * - custom comparator method + */ + constructor(comparator = "high") { + switch ( comparator ) { + case "high": break; + case "low": this.comparator = (elem, obj) => (elem._priority - obj._priority) < 0; break; + default: this.comparator = comparator; + } + } + /** @type {number} */ get length() { return this.data.length; } @@ -69,5 +86,5 @@ export class PriorityQueueArray { * @param {object} object Object, with "_priority" property. * @returns {number} */ - findPriorityIndex(obj) { return binaryFindIndex(this.data, elem => (obj._priority - elem._priority) < 0); } + findPriorityIndex(obj) { return binaryFindIndex(this.data, elem => this.comparator(elem, obj)); } } diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index 777ebb2..891ae7c 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -18,7 +18,32 @@ import { Draw } from "../geometry/Draw.js"; Draw = CONFIG.GeometryLib.Draw; api = game.modules.get("elevationruler").api Pathfinder = api.pathfinding.Pathfinder +PriorityQueueArray = api.pathfinding.PriorityQueueArray; +PriorityQueue = api.pathfinding.PriorityQueue; + +// Test queue (PQ takes only objects, not strings or numbers) +pq = new PriorityQueueArray("high") +pq.enqueue({"D": 4}, 4) +pq.enqueue({"A": 1}, 1); +pq.enqueue({"C": 3}, 3); +pq.enqueue({"B": 2}, 2); +pq.data + +pq = new PriorityQueueArray("low") +pq.enqueue({"D": 4}, 4) +pq.enqueue({"A": 1}, 1); +pq.enqueue({"C": 3}, 3); +pq.enqueue({"B": 2}, 2); +pq.data + + + + + +// Test pathfinding Pathfinder.initialize() +Pathfinder.borderTriangles.forEach(tri => tri.drawEdges()); + endPoint = _token.center @@ -26,6 +51,7 @@ token = _token startPoint = _token.center; pf = new Pathfinder(token); + path = pf.breadthFirstPath(startPoint, endPoint) pathPoints = pf.getPathPoints(path); pf.drawPath(pathPoints, { color: Draw.COLORS.red }) @@ -483,7 +509,7 @@ export class BreadthFirstPathSearch { * Dijkstra's Algorithm, or uniform cost path search. */ export class UniformCostPathSearch extends BreadthFirstPathSearch { - frontier = new PriorityQueueArray([], { comparator: (a, b) => a.cost - b.cost }); + frontier = new PriorityQueueArray("low"); /** @type {Map} */ costSoFar = new Map(); @@ -508,6 +534,7 @@ export class UniformCostPathSearch extends BreadthFirstPathSearch { frontier.enqueue(start, 0); costSoFar.set(start.key, 0); + const MAX_COST = canvas.dimensions.maxR; while ( frontier.length ) { const current = frontier.dequeue(); @@ -515,8 +542,9 @@ export class UniformCostPathSearch extends BreadthFirstPathSearch { // Get each neighbor destination in turn. for ( const next of this.getNeighbors(current) ) { - const newCost = costSoFar.get(current.key) + next.cost; + const newCost = (costSoFar.get(current.key) ?? MAX_COST) + next.cost; if ( !costSoFar.has(next.key) || newCost < costSoFar.get(next.key) ) { + costSoFar.set(next.key, newCost); frontier.enqueue(next, newCost); // Priority is newCost cameFrom.set(next.key, current); } From 14e106719b92b447bad34dc8369941a3d9492179 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Mon, 15 Jan 2024 08:38:22 -0800 Subject: [PATCH 08/25] Working Astar, greedy Use the point keys as the key for the pathfinding. Special neighbor test when you reach the goal triangle, so it returns the end goal as a neighbor, with cost added as necessary. --- scripts/module.js | 4 +- scripts/pathfinding/BorderTriangle.js | 62 ++++--- scripts/pathfinding/algorithms.js | 246 ++++++++++++++++++++++++++ scripts/pathfinding/pathfinding.js | 241 +++++++++---------------- 4 files changed, 368 insertions(+), 185 deletions(-) create mode 100644 scripts/pathfinding/algorithms.js diff --git a/scripts/module.js b/scripts/module.js index 826b057..82378cd 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -15,7 +15,8 @@ import { registerGeometry } from "./geometry/registration.js"; // Pathfinding import { BorderTriangle, BorderEdge } from "./pathfinding/BorderTriangle.js"; -import { Pathfinder, BreadthFirstPathSearch, UniformCostPathSearch, AStarPathSearch } from "./pathfinding/pathfinding.js"; +import { Pathfinder } from "./pathfinding/pathfinding.js"; +import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./pathfinding/algorithms.js"; import { PriorityQueueArray } from "./pathfinding/PriorityQueueArray.js"; import { PriorityQueue } from "./pathfinding/PriorityQueue.js"; @@ -34,6 +35,7 @@ Hooks.once("init", function() { Pathfinder, BreadthFirstPathSearch, UniformCostPathSearch, + GreedyPathSearch, AStarPathSearch, PriorityQueueArray, PriorityQueue diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index 9188430..da4c983 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -1,8 +1,8 @@ /* globals canvas, +CONFIG, foundry, -PIXI, -PointSourcePolygon +PIXI */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -267,42 +267,58 @@ export class BorderTriangle { * * Corner destination skipped if median --> corner < spacer * - * @param {Point} priorEntryPoint - * @param {BorderTriangle|null} priorTriangle - * @param {number} spacer How much away from the corner to set the corner destinations. - * @returns {object[]} Each element has properties describing the destination, conforming to pathfinding - * - {BorderTriangle} key - * - {Point} entryPoint - * - {number} cost + * @param {BorderTriangle|null} priorTriangle Triangle that preceded this one along the path + * @param {number} spacer How far from the corner to set the corner destinations + * @returns {PathNode} Each element has properties describing the destination, conforming to pathfinding + * - {number} key + * - {PIXI.Point} entryPoint + * - {BorderTriangle} entryTriangle + * - {BorderTriangle} priorTriangle */ - getValidDestinations(priorEntryPoint, priorTriangle, spacer) { + getValidDestinations(priorTriangle, spacer) { spacer ??= canvas.grid.size * 0.5; const destinations = []; const center = this.center; for ( const edge of Object.values(this.edges) ) { - const key = edge.otherTriangle(this); // Neighbor - if ( !key ) continue; - if ( priorTriangle && priorTriangle === key ) continue; + const entryTriangle = edge.otherTriangle(this); // Neighbor + if ( !entryTriangle || priorTriangle && priorTriangle === entryTriangle ) continue; const pts = edge.getValidDestinations(center, spacer); pts.forEach(entryPoint => { destinations.push({ entryPoint, - key, - - // Debugging: - // priorEntryPoint, - // priorTriangle, - - // TODO: Handle 3d distances. - // Probably use canvas.grid.measureDistances, passing a Ray3d. - // TODO: Handle terrain distance - cost: canvas.grid.measureDistance(priorEntryPoint, entryPoint) + key: entryPoint.key, // Key needs to be unique for each point, + entryTriangle, // Needed to locate neighbors in the next iteration. + priorTriangle, // Needed to eliminate irrelevant neighbors in the next iteration. }); }); } return destinations; } + /** + * Retrieve destinations with cost calculation added. + * @param {BorderTriangle|null} priorTriangle Triangle that preceded this one along the path + * @param {number} spacer How far from the corner to set the corner destinations + * @param {Point} fromPoint Point to measure from, for cost + */ + getValidDestinationsWithCost(priorTriangle, spacer, fromPoint) { + const destinations = this.getValidDestinations(priorTriangle, spacer); + destinations.forEach(d => d.cost = this._calculateMovementCost(fromPoint, d.entryPoint)); + return destinations; + } + + /** + * Calculate the cost for a single path node from a given point. + * @param {PathNode} node + * @param {Point} fromPoint + * @returns {number} Cost value + */ + _calculateMovementCost(fromPoint, toPoint) { + // TODO: Handle 3d distance. Probably Ray3d with measureDistance or measureDistances. + // TODO: Handle terrain distance. + return CONFIG.GeometryLib.utils.gridUnitsToPixels(canvas.grid.measureDistance(fromPoint, toPoint)); + } + /** * Replace an edge in this triangle. * Used to link triangles by an edge. diff --git a/scripts/pathfinding/algorithms.js b/scripts/pathfinding/algorithms.js new file mode 100644 index 0000000..9304b4a --- /dev/null +++ b/scripts/pathfinding/algorithms.js @@ -0,0 +1,246 @@ +/* globals +canvas, +PIXI +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { Draw } from "../geometry/Draw.js"; +import { PriorityQueueArray } from "./PriorityQueueArray.js"; +// import { PriorityQueue } from "./PriorityQueue.js"; + +// See https://www.redblobgames.com/pathfinding/a-star/introduction.html + + +/** + * Pathfinding objects used here must have the following properties: + * - {object} key Unique object, string, or number that can be used in a Set or as a Map key. + * - {number} cost For algorithms that measure value, the cost of this move. + * Objects can have any other properties needed. + */ +export class BreadthFirstPathSearch { + /** @type {PathNode[]} */ + frontier = []; + + /** @type {Map} */ + cameFrom = new Map(); // Path a -> b stored as cameFrom[b] = a + + /** @type {boolean} */ + debug = false; + + /** + * Run the breadth first search on the graph. + * Each object must have a unique key property used for comparison. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, frontier } = this; + frontier.unshift(start); + + while ( frontier.length ) { + const current = frontier.pop(); + if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); + if ( this.goalReached(goal, current) ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current, goal) ) { + if ( !cameFrom.has(next.key) ) { + if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightgreen }); + frontier.unshift(next); + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } + + /** + * Goal testing method that can be overriden to handle different goals. + * @param {PathNode} goal Goal node + * @param {PathNode} current Current node + * @returns {boolean} True if goal has been reached and pathfinding should stop. + */ + goalReached(goal, current) { return goal.key === current.key; } + + /** + * Neighbor method that can be overriden to handle different objects. + * @param {PathNode} pathNode Object representing a graph node + * @param {PathNode} goal Goal node + * @returns {Array[PathNode]} Array of neighboring nodes to the provided node. + */ + getNeighbors(pathNode, _goal) { return pathNode.neighbors; } + + /** + * Clear the path properties. + */ + clear() { + this.frontier.length = 0; + this.cameFrom.clear(); + } +} + +/** + * Dijkstra's Algorithm, or uniform cost path search. + */ +export class UniformCostPathSearch extends BreadthFirstPathSearch { + /** @type {PriorityQueueArray} */ + frontier = new PriorityQueueArray("low"); + + /** @type {Map} */ + costSoFar = new Map(); + + clear() { + this.frontier.clear(); + this.costSoFar.clear(); + this.cameFrom.clear(); + } + + /** + * Run the cost search on the graph. + * Each PathNode must have a unique key property used for comparison + * and cost property used to value cost so far. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, costSoFar, frontier } = this; + + frontier.enqueue(start, 0); + costSoFar.set(start.key, 0); + const MAX_COST = canvas.dimensions.maxR; + + while ( frontier.length ) { + const current = frontier.dequeue(); + if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); + if ( this.goalReached(goal, current) ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current, goal) ) { + const newCost = (costSoFar.get(current.key) ?? MAX_COST) + next.cost; + if ( !costSoFar.has(next.key) || newCost < costSoFar.get(next.key) ) { + if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightgreen }); + costSoFar.set(next.key, newCost); + frontier.enqueue(next, newCost); // Priority is newCost + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } +} + +/** + * Greedy search + */ +export class GreedyPathSearch extends BreadthFirstPathSearch { + /** @type {PriorityQueueArray} */ + frontier = new PriorityQueueArray("low"); + + clear() { + this.frontier.clear(); + this.cameFrom.clear(); + } + + /** + * Heuristic that takes a goal node and a current node and returns a priority. + * Lower numbers are preferable. + * @param {PathNode} goal + * @param {PathNode} current + */ + heuristic = (goal, current) => PIXI.Point.distanceBetween(goal.entryPoint, current.entryPoint); + + /** + * Run the cost search on the graph. + * Each PathNode must have a unique key property used for comparison + * and cost property used to value cost so far. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, frontier } = this; + + frontier.enqueue(start, 0); + const MAX_COST = canvas.dimensions.maxR; + + while ( frontier.length ) { + const current = frontier.dequeue(); + if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); + if ( this.goalReached(goal, current) ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current, goal) ) { + if ( !cameFrom.has(next.key) ) { + if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightgreen }); + const priority = this.heuristic(goal, next) ?? MAX_COST; + frontier.enqueue(next, priority); + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } +} + +export class AStarPathSearch extends UniformCostPathSearch { + /** + * Heuristic that takes a goal node and a current node and returns a priority. + * Lower numbers are preferable. + * @param {PathNode} goal + * @param {PathNode} current + */ + heuristic = (goal, current) => PIXI.Point.distanceBetween(goal.entryPoint, current.entryPoint); + + /** + * Run the cost search on the graph. + * Each PathNode must have a unique key property used for comparison + * and cost property used to value cost so far. + * @param {PathNode} start Path node representing start + * @param {PathNode} goal Path node representing end + * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. + */ + run(start, goal) { + this.clear(); + const { cameFrom, costSoFar, frontier } = this; + + frontier.enqueue(start, 0); + costSoFar.set(start.key, 0); + const MAX_COST = canvas.dimensions.maxR; + + while ( frontier.length ) { + const current = frontier.dequeue(); + if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); + if ( this.goalReached(goal, current) ) break; + + // Get each neighbor destination in turn. + for ( const next of this.getNeighbors(current, goal) ) { + const newCost = (costSoFar.get(current.key) ?? MAX_COST) + next.cost; + if ( !costSoFar.has(next.key) || newCost < costSoFar.get(next.key) ) { + if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightgreen }); + costSoFar.set(next.key, newCost); + const priority = newCost + this.heuristic(goal, next); + frontier.enqueue(next, priority); + cameFrom.set(next.key, current); + } + } + } + + cameFrom.goal = goal; + cameFrom.start = start; + return cameFrom; + } +} diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index 891ae7c..733b717 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -1,16 +1,16 @@ /* globals canvas, CanvasQuadtree, +CONFIG, Delaunator, PIXI */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ import { BorderTriangle } from "./BorderTriangle.js"; -import { PriorityQueueArray } from "./PriorityQueueArray.js"; -// import { PriorityQueue } from "./PriorityQueue.js"; import { boundsForPoint } from "../util.js"; import { Draw } from "../geometry/Draw.js"; +import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./algorithms.js"; /* Testing @@ -36,10 +36,6 @@ pq.enqueue({"C": 3}, 3); pq.enqueue({"B": 2}, 2); pq.data - - - - // Test pathfinding Pathfinder.initialize() Pathfinder.borderTriangles.forEach(tri => tri.drawEdges()); @@ -52,13 +48,25 @@ startPoint = _token.center; pf = new Pathfinder(token); -path = pf.breadthFirstPath(startPoint, endPoint) + +path = pf.runPath(startPoint, endPoint, "breadth") pathPoints = pf.getPathPoints(path); -pf.drawPath(pathPoints, { color: Draw.COLORS.red }) +pf.drawPath(pathPoints, { color: Draw.COLORS.orange }) -path = pf.uniformCostPath(startPoint, endPoint) +path = pf.runPath(startPoint, endPoint, "uniform") pathPoints = pf.getPathPoints(path); -pf.drawPath(pathPoints, { color: Draw.COLORS.blue }) +pf.drawPath(pathPoints, { color: Draw.COLORS.yellow }) + +path = pf.runPath(startPoint, endPoint, "greedy") +pathPoints = pf.getPathPoints(path); +pf.drawPath(pathPoints, { color: Draw.COLORS.green }) + +pf.algorithm.greedy.debug = true + + +path = pf.runPath(startPoint, endPoint, "astar") +pathPoints = pf.getPathPoints(path); +pf.drawPath(pathPoints, { color: Draw.COLORS.white }) */ @@ -343,38 +351,55 @@ export class Pathfinder { return this._spacer || (this.token.w * 0.5) || (canvas.dimensions.size * 0.5); } + /** @enum {BreadthFirstPathSearch} */ + static ALGORITHMS = { + breadth: BreadthFirstPathSearch, + uniform: UniformCostPathSearch, + greedy: GreedyPathSearch, + astar: AStarPathSearch + }; + + /** @enum {string} */ + static COST_METHOD = { + breadth: "_identifyDestinations", + uniform: "_identifyDestinationsWithCost", + greedy: "_identifyDestinations", + astar: "_identifyDestinationsWithCost" + }; + + /** @type {object{BreadthFirstPathSearch}} */ + algorithm = {}; + /** - * Run a breadth first, early exit search of the border triangles. + * Find the path between startPoint and endPoint using the chosen algorithm. * @param {Point} startPoint Start point for the graph * @param {Point} endPoint End point for the graph * @returns {Map} */ - _breadthFirstPF; + runPath(startPoint, endPoint, type = "astar") { + // Initialize the algorithm if not already. + if ( !this.algorithm[type] ) { + const alg = this.algorithm[type] = new this.constructor.ALGORITHMS[type](); + const costMethod = this.constructor.COST_METHOD[type]; + alg.getNeighbors = this[costMethod]; + alg.heuristic = this._heuristic; + } - breadthFirstPath(startPoint, endPoint) { + // Run the algorithm. const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); - if ( !this._breadthFirstPF ) { - this._breadthFirstPF = new BreadthFirstPathSearch(); - this._breadthFirstPF.getNeighbors = this.identifyDestinations.bind(this); - } - return this._breadthFirstPF.run(start, end); + return this.algorithm[type].run(start, end); } + /** - * Run a uniform cost search (Dijkstra's). - * @param {Point} startPoint Start point for the graph - * @param {Point} endPoint End point for the graph - * @returns {Map} + * Heuristic that takes a goal node and a current node and returns a priority based on + * the canvas distance between two points. + * @param {PathNode} goal + * @param {PathNode} current */ - _uniformCostPF; - - uniformCostPath(startPoint, endPoint) { - const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); - if ( !this._uniformCostPF ) { - this._uniformCostPF = new UniformCostPathSearch(); - this._uniformCostPF.getNeighbors = this.identifyDestinations.bind(this); - } - return this._uniformCostPF.run(start, end); + // TODO: Handle 3d points? + _heuristic(goal, current) { + return CONFIG.GeometryLib.utils.gridUnitsToPixels(canvas.grid.measureDistance(goal.entryPoint, current.entryPoint)); } /** @@ -399,20 +424,38 @@ export class Pathfinder { collisionTest = (o, _rect) => o.t.contains(endPoint); const endTri = quadtree.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); - const start = { key: startTri, entryPoint: PIXI.Point.fromObject(startPoint) }; - const end = { key: endTri, entryPoint: endPoint }; + const start = { key: startPoint.key, entryTriangle: startTri, entryPoint: PIXI.Point.fromObject(startPoint) }; + const end = { key: endPoint.key, entryTriangle: endTri, entryPoint: endPoint }; return { start, end }; } - /** - * Get destinations for a given pathfinding object, which is a triangle plus an entryPoint. + * Get destinations for a given path node * @param {PathNode} pathObject * @returns {PathNode[]} Array of destination nodes */ - identifyDestinations(pathNode) { - return pathNode.key.getValidDestinations(pathNode.entryPoint, pathNode.key, this.spacer); + _identifyDestinations(pathNode, goal) { + // If the goal node is reached, return the goal with the cost. + if ( pathNode.entryTriangle === goal.entryTriangle ) return [goal]; + return pathNode.entryTriangle.getValidDestinations(pathNode.priorTriangle, this.spacer); + } + + /** + * Get destinations with cost calculated for a given path node. + * @param {PathNode} pathObject + * @returns {PathNode[]} Array of destination nodes + */ + _identifyDestinationsWithCost(pathNode, goal) { + // If the goal node is reached, return the goal with the cost. + if ( pathNode.entryTriangle === goal.entryTriangle ) { + // Need a copy so we can modify cost for this goal node only. + const newNode = {...goal}; + newNode.cost = goal.entryTriangle._calculateMovementCost(pathNode.entryPoint, goal.entryPoint); + newNode.priorTriangle = pathNode.priorTriangle; + return [newNode]; + } + return pathNode.entryTriangle.getValidDestinationsWithCost(pathNode.priorTriangle, this.spacer, pathNode.entryPoint); } /** @@ -440,128 +483,4 @@ export class Pathfinder { prior = curr; } } - -} - -// See https://www.redblobgames.com/pathfinding/a-star/introduction.html - - -/** - * Pathfinding objects used here must have the following properties: - * - {object} key Unique object, string, or number that can be used in a Set or as a Map key. - * - {number} cost For algorithms that measure value, the cost of this move. - * Objects can have any other properties needed. - */ -export class BreadthFirstPathSearch { - /** @type {PathNode[]} */ - frontier = []; - - /** @type {Map} */ - cameFrom = new Map(); // Path a -> b stored as cameFrom[b] = a - - /** - * Run the breadth first search on the graph. - * Each object must have a unique key property used for comparison. - * @param {PathNode} start Path node representing start - * @param {PathNode} goal Path node representing end - * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. - */ - run(start, goal) { - this.clear(); - const { cameFrom, frontier } = this; - frontier.unshift(start); - - while ( frontier.length ) { - const current = frontier.pop(); - if ( current.key === goal.key ) break; - - // Get each neighbor destination in turn. - for ( const next of this.getNeighbors(current) ) { - if ( !cameFrom.has(next.key) ) { - frontier.unshift(next); - cameFrom.set(next.key, current); - } - } - } - - cameFrom.goal = goal; - cameFrom.start = start; - return cameFrom; - } - - /** - * Neighbor method that can be overriden to handle different objects. - * @param {PathNode} pathNode Object representing a graph node - * @returns {Array[PathNode]} Array of neighboring objects to the node. - */ - getNeighbors(pathNode) { return pathNode.neighbors; } - - /** - * Clear the path properties. - */ - clear() { - this.frontier.length = 0; - this.cameFrom.clear(); - } -} - -/** - * Dijkstra's Algorithm, or uniform cost path search. - */ -export class UniformCostPathSearch extends BreadthFirstPathSearch { - frontier = new PriorityQueueArray("low"); - - /** @type {Map} */ - costSoFar = new Map(); - - clear() { - this.frontier.clear(); - this.costSoFar.clear(); - this.cameFrom.clear(); - } - - /** - * Run the cost search on the graph. - * Each PathNode must have a unique key property used for comparison - * and cost property used to value cost so far. - * @param {PathNode} start Path node representing start - * @param {PathNode} goal Path node representing end - * @returns {Map} Path as the cameFrom map. Note that rerunning will change this return. - */ - run(start, goal) { - this.clear(); - const { cameFrom, costSoFar, frontier } = this; - - frontier.enqueue(start, 0); - costSoFar.set(start.key, 0); - const MAX_COST = canvas.dimensions.maxR; - - while ( frontier.length ) { - const current = frontier.dequeue(); - if ( current.key === goal.key ) break; - - // Get each neighbor destination in turn. - for ( const next of this.getNeighbors(current) ) { - const newCost = (costSoFar.get(current.key) ?? MAX_COST) + next.cost; - if ( !costSoFar.has(next.key) || newCost < costSoFar.get(next.key) ) { - costSoFar.set(next.key, newCost); - frontier.enqueue(next, newCost); // Priority is newCost - cameFrom.set(next.key, current); - } - } - } - - cameFrom.goal = goal; - cameFrom.start = start; - return cameFrom; - } -} - -export class AStarPathSearch { - /** @type {PriorityQueueArray} */ - frontier = new PriorityQueueArray([], { comparator: (a, b) => a.distance - b.distance }); - - /** @type {Map} */ - cameFrom = new Map(); // path a -> b stored as cameFrom[b] = a - } From daa30a1099aa28c5fe627d0c5bf7a7f1d5f156dd Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Mon, 15 Jan 2024 08:40:31 -0800 Subject: [PATCH 09/25] Add benchmark functions --- scripts/benchmark_functions.js | 155 +++++++++++++++++++++++++++++++ scripts/pathfinding/benchmark.js | 8 ++ scripts/random.js | 114 +++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 scripts/benchmark_functions.js create mode 100644 scripts/pathfinding/benchmark.js create mode 100644 scripts/random.js diff --git a/scripts/benchmark_functions.js b/scripts/benchmark_functions.js new file mode 100644 index 0000000..e399e94 --- /dev/null +++ b/scripts/benchmark_functions.js @@ -0,0 +1,155 @@ +/* globals +foundry +*/ + +"use strict"; + + +/** + * For a given numeric array, calculate one or more quantiles. + * @param {Number[]} arr Array of numeric values to calculate. + * @param {Number[]} q Array of quantiles, each between 0 and 1. + * @return {Object} Object with each quantile number as a property. + * E.g., { ".1": 100, ".5": 150, ".9": 190 } + */ +function quantile(arr, q) { + arr.sort((a, b) => a - b); + if (!q.length) { return q_sorted(arr, q); } + + const out = {}; + for (let i = 0; i < q.length; i += 1) { + const q_i = q[i]; + out[q_i] = q_sorted(arr, q_i); + } + + return out; +} + +/** + * Re-arrange an array based on a given quantile. + * Used by quantile function to identify locations of elements at specified quantiles. + * @param {Number[]} arr Array of numeric values to calculate. + * @param {Number} q Quantile to locate. E.g., .1, or .5 (median). + */ +function q_sorted(arr, q) { + const pos = (arr.length - 1) * q; + const base = Math.floor(pos); + const rest = pos - base; + if (arr[base + 1] !== undefined) { + return arr[base] + (rest * (arr[base + 1] - arr[base])); + } + return arr[base]; +} + +/** + * Round a decimal number to a specified number of digits. + * @param {Number} n Number to round. + * @param {Number} digits Digits to round to. + */ +function precision(n, digits = 2) { + return Math.round(n * Math.pow(10, digits)) / Math.pow(10, digits); +} + +/** + * Benchmark a method of a class. + * Includes a 5% warmup (at least 1 iteration) and prints 10%/50%/90% quantiles along + * with the mean timing. + * @param {number} iterations Number of repetitions. Will add an additional 5% warmup. + * @param {Object} thisArg Class or other object that contains the method. + * @param {string} name Function name to benchmark + * @param {Object} ...args Additional arguments to pass to function + * @return {Number[]} Array with the time elapsed for each iteration. + */ +export async function QBenchmarkLoop(iterations, thisArg, fn_name, ...args) { + const name = `${thisArg.name || thisArg.constructor.name}.${fn_name}`; + const fn = (...args) => thisArg[fn_name](...args); + return await QBenchmarkLoopFn(iterations, fn, name, ...args); +} + +/** + * Benchmark a function + * Includes a 5% warmup (at least 1 iteration) and prints 10%/50%/90% quantiles along + * with the mean timing. + * @param {number} iterations Number of repetitions. Will add an additional 5% warmup. + * @param {Function} fn Function to benchmark + * @param {string} name Description to print to console + * @param {Object} ...args Additional arguments to pass to function + * @return {Number[]} Array with the time elapsed for each iteration. + */ +export async function QBenchmarkLoopFn(iterations, fn, name, ...args) { + const timings = []; + const num_warmups = Math.ceil(iterations * .05); + + for (let i = -num_warmups; i < iterations; i += 1) { + const t0 = performance.now(); + await fn(...args); + const t1 = performance.now(); + if (i >= 0) { timings.push(t1 - t0); } + } + + const sum = timings.reduce((prev, curr) => prev + curr); + const q = quantile(timings, [.1, .5, .9]); + + console.log(`${name} | ${iterations} iterations | ${precision(sum, 4)}ms | ${precision(sum / iterations, 4)}ms per | 10/50/90: ${precision(q[.1], 6)} / ${precision(q[.5], 6)} / ${precision(q[.9], 6)}`); + + return timings; +} + +/** + * Benchmark a function using a setup function called outside the timing loop. + * The setup function must pass any arguments needed to the function to be timed. + * @param {number} iterations Number of repetitions. Will add an additional 5% warmup. + * @param {Function} setupFn Function to call prior to each loop of the benchmark. + * @param {Function} fn Function to benchmark + * @param {string} name Description to print to console + * @param {Object} ...args Additional arguments to pass to setup function + * @return {Number[]} Array with the time elapsed for each iteration. + */ +export async function QBenchmarkLoopWithSetupFn(iterations, setupFn, fn, name, ...setupArgs) { + const timings = []; + const num_warmups = Math.ceil(iterations * .05); + + for (let i = -num_warmups; i < iterations; i += 1) { + const args = setupFn(...setupArgs); + const t0 = performance.now(); + await fn(...args); + const t1 = performance.now(); + if (i >= 0) { timings.push(t1 - t0); } + } + + const sum = timings.reduce((prev, curr) => prev + curr); + const q = quantile(timings, [.1, .5, .9]); + + console.log(`${name} | ${iterations} iterations | ${precision(sum, 4)}ms | ${precision(sum / iterations, 4)}ms per | 10/50/90: ${precision(q[.1], 6)} / ${precision(q[.5], 6)} / ${precision(q[.9], 6)}`); + + return timings; +} + +/** + * Helper function to run foundry.utils.benchmark a specified number of iterations + * for a specified function, printing the results along with the specified name. + * @param {Number} iterations Number of iterations to run the benchmark. + * @param {Function} fn Function to test + * @param ...args Arguments passed to fn. + */ +export async function benchmarkLoopFn(iterations, fn, name, ...args) { + const f = () => fn(...args); + Object.defineProperty(f, "name", {value: `${name}`, configurable: true}); + await foundry.utils.benchmark(f, iterations, ...args); +} + +/** + * Helper function to run foundry.utils.benchmark a specified number of iterations + * for a specified function in a class, printing the results along with the specified name. + * A class object must be passed to call the function and is used as the label. + * Otherwise, this is identical to benchmarkLoopFn. + * @param {Number} iterations Number of iterations to run the benchmark. + * @param {Object} thisArg Instantiated class object that has the specified fn. + * @param {Function} fn Function to test + * @param ...args Arguments passed to fn. + */ +export async function benchmarkLoop(iterations, thisArg, fn, ...args) { + const f = () => thisArg[fn](...args); + Object.defineProperty(f, "name", {value: `${thisArg.name || thisArg.constructor.name}.${fn}`, configurable: true}); + await foundry.utils.benchmark(f, iterations, ...args); +} diff --git a/scripts/pathfinding/benchmark.js b/scripts/pathfinding/benchmark.js new file mode 100644 index 0000000..ffa9d9a --- /dev/null +++ b/scripts/pathfinding/benchmark.js @@ -0,0 +1,8 @@ +/* globals + +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + + +// Methods to benchmark pathfinding. + diff --git a/scripts/random.js b/scripts/random.js new file mode 100644 index 0000000..49becc9 --- /dev/null +++ b/scripts/random.js @@ -0,0 +1,114 @@ +/* globals +canvas, +PolygonEdge, +PIXI +*/ +"use strict"; + +// Functions related to creating random shapes, for testing and benchmarking + +export function randomUniform(min = 0, max = 1) { + let num = Math.random(); + num *= max - min; // Stretch to fill range + num += min; // Offset to min + return num; +} + +/** + * Normal distribution of random numbers + * https://stackoverflow.com/questions/25582882/javascript-math-random-normal-distribution-gaussian-bell-curve + * @param {Number} min Minimum value + * @param {Number} max Maximum value + * @param {Number} skew Skew the peak left (skew > 1) or right (skew < 1). Should be greater than 0. + * @return {Number} Normally distributed random number between min and max + * + * @example + * Array.fromRange(1000).map(() => randNormal()) + */ +export function randomNormal(min = 0, max = 1, skew = 1) { + let num = (rand_bm() / 10.0) + 0.5; // Translate to (0, 1) + while ( num > 1 || num < 0 ) { + num = (rand_bm() / 10.0) + 0.5; // Resample if more than 3.6 SD away ( < 0.02% chance ) + } + num = Math.pow(num, skew); // Skew + num *= max - min; // Stretch to fill range + num += min; // Offset to min + + return num; +} + +/** + * Helper that creates a normally distributed number using Box-Muller. + */ +function rand_bm() { + let u = 0; + let v = 0; + while ( u === 0 ) u = Math.random(); // Converting [0,1) to (0,1) + while ( v === 0 ) v = Math.random(); + return Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v ); +} + +/** + * Construct a random point with integer coordinates between 0 and max_coord. + * @param {Number} max_x Maximum x-coordinate value. + * @param {Number} max_y Maximum y-coordinate value. + * @return {Point} Constructed random point. + */ +export function randomPoint(max_x = canvas.dimensions.width, max_y = canvas.dimensions.height) { + return { x: Math.floor(Math.random() * max_x), + y: Math.floor(Math.random() * max_y) }; // eslint-disable-line indent +} + + +/** + * Test if two points are nearly equal. + * @param {Point} p1 + * @param {Point} p2 + * @return {Boolean} True if equal or within a small epsilon of equality. + */ +export function pointsEqual(p1, p2) { return (p1.x.almostEqual(p2.x) && p1.y.almostEqual(p2.y)); } + +/** + * Construct a random segment. Will check that the segment has distance greater than 0. + * @param {Number} max_x Maximum x-coordinate value. + * @param {Number} max_y Maximum y-coordinate value. + * @return {PolygonEdge} Constructed random segment. + */ +export function randomSegment(max_x = canvas.dimensions.width, max_y = canvas.dimensions.height) { + let a = randomPoint(max_x, max_y); + let b = randomPoint(max_x, max_y); + while (pointsEqual(a, b)) { + // Don't create lines of zero length + a = randomPoint(max_x, max_y); + b = randomPoint(max_x, max_y); + } + return new PolygonEdge(a, b); +} + + +/** + * Construct a rectangle at a random origin with normally distributed width, height. + * @param {Point} origin Random if not defined. Left corner x,y + * @param {Number} minWidth Minimum width. + * @param {Number} maxWidth Maximum width. + * @param {Number} minHeight Minimum height. + * @param {Number} maxHeight Maximum height. + * @return {PIXI.Rectangle} + */ +export function randomRectangle({ + origin = randomPoint(), + minWidth = 100, + maxWidth = minWidth * 2, + minHeight = minWidth, + maxHeight = minWidth * 2 } = {}) { + + // Safety dance! + if ( minWidth <= 0 ) { minWidth = 1; } + if ( maxWidth <= 0 ) { maxWidth = minWidth * 2; } + if ( minHeight <= 0 ) { minHeight = 1; } + if ( maxHeight <= 0 ) { maxHeight = minHeight * 2; } + + const width = randomNormal(minWidth, maxWidth); + const height = randomNormal(minHeight, maxHeight); + return new PIXI.Rectangle(origin.x, origin.y, width, height); +} From b125a1645b74b14b583c1aae5bf5e09943acb15a Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Mon, 15 Jan 2024 09:10:31 -0800 Subject: [PATCH 10/25] Add benchmark --- scripts/module.js | 4 ++- scripts/pathfinding/benchmark.js | 36 +++++++++++++++++++ .../{ => pathfinding}/benchmark_functions.js | 0 scripts/{ => pathfinding}/random.js | 0 4 files changed, 39 insertions(+), 1 deletion(-) rename scripts/{ => pathfinding}/benchmark_functions.js (100%) rename scripts/{ => pathfinding}/random.js (100%) diff --git a/scripts/module.js b/scripts/module.js index 82378cd..b880c29 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -19,6 +19,7 @@ import { Pathfinder } from "./pathfinding/pathfinding.js"; import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./pathfinding/algorithms.js"; import { PriorityQueueArray } from "./pathfinding/PriorityQueueArray.js"; import { PriorityQueue } from "./pathfinding/PriorityQueue.js"; +import { benchPathfinding } from "./pathfinding/benchmark.js"; Hooks.once("init", function() { // Cannot access localization until init. @@ -38,7 +39,8 @@ Hooks.once("init", function() { GreedyPathSearch, AStarPathSearch, PriorityQueueArray, - PriorityQueue + PriorityQueue, + benchPathfinding } }; }); diff --git a/scripts/pathfinding/benchmark.js b/scripts/pathfinding/benchmark.js index ffa9d9a..f2eb3f7 100644 --- a/scripts/pathfinding/benchmark.js +++ b/scripts/pathfinding/benchmark.js @@ -3,6 +3,42 @@ */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +import { QBenchmarkLoopFn } from "./benchmark_functions.js"; +import { randomPoint } from "./random.js"; +import { Pathfinder } from "./pathfinding.js"; // Methods to benchmark pathfinding. +/* Use +api = game.modules.get("elevationruler").api; + +N = 1000 +await api.pathfinding.benchPathfinding(N) + + +*/ + +export async function benchPathfinding(nPaths = 100, type = "all", nIterations = 10) { + Pathfinder.initialize(); // TODO: Only needed until wall updating is fixed. + const token = canvas.tokens.controlled[0]; + const pf = new Pathfinder(token); + + let message = `Testing pathfinding for ${nPaths} random start/end locations.`; + if ( token ) message += ` Using size of ${token.name} token.`; + console.log(message); + + const startPoints = Array.fromRange(nPaths).map(elem => randomPoint()); + const endPoints = Array.fromRange(nPaths).map(elem => randomPoint()); + + const types = type === "all" ? Object.keys(Pathfinder.ALGORITHMS) : type; + for ( const type of types ) await QBenchmarkLoopFn(nIterations, benchPointSet, type, pf, type, startPoints, endPoints); +} + +function benchPointSet(pf, type, startPoints, endPoints) { + const nPoints = startPoints.length; + for ( let i = 0; i < nPoints; i += 1 ) { + pf.runPath(startPoints[i], endPoints[i], "breadth"); + } +} + + diff --git a/scripts/benchmark_functions.js b/scripts/pathfinding/benchmark_functions.js similarity index 100% rename from scripts/benchmark_functions.js rename to scripts/pathfinding/benchmark_functions.js diff --git a/scripts/random.js b/scripts/pathfinding/random.js similarity index 100% rename from scripts/random.js rename to scripts/pathfinding/random.js From aa7d1c0a6a9516409cca2c776e69be1798737e76 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 16 Jan 2024 07:06:34 -0800 Subject: [PATCH 11/25] Test using delaunator, constrainautor, cdt2d --- module.json | 4 +- scripts/delaunator/Constrainautor.min.js | 1 + .../delaunator/LICENSE - Constrainautor.txt | 15 + ...C license.txt => LICENSE - Delaunator.txt} | 0 scripts/delaunator/cdt2d_bundle.js | 1533 +++++++++++++++++ scripts/module.js | 10 +- scripts/patching.js | 7 +- scripts/pathfinding/Wall.js | 85 + scripts/pathfinding/WallTracer.js | 783 +++++++++ scripts/pathfinding/pathfinding.js | 266 ++- scripts/util.js | 24 + 11 files changed, 2655 insertions(+), 73 deletions(-) create mode 100644 scripts/delaunator/Constrainautor.min.js create mode 100644 scripts/delaunator/LICENSE - Constrainautor.txt rename scripts/delaunator/{ISC license.txt => LICENSE - Delaunator.txt} (100%) create mode 100644 scripts/delaunator/cdt2d_bundle.js create mode 100644 scripts/pathfinding/Wall.js create mode 100644 scripts/pathfinding/WallTracer.js diff --git a/module.json b/module.json index 26d406a..74c10fe 100644 --- a/module.json +++ b/module.json @@ -27,7 +27,9 @@ ] }, "scripts": [ - "/scripts/delaunator/delaunator.min.js" + "/scripts/delaunator/delaunator.min.js", + "/scripts/delaunator/Constrainautor.min.js", + "/scripts/delaunator/cdt2d_bundle.js" ], "esmodules": [ diff --git a/scripts/delaunator/Constrainautor.min.js b/scripts/delaunator/Constrainautor.min.js new file mode 100644 index 0000000..259e01b --- /dev/null +++ b/scripts/delaunator/Constrainautor.min.js @@ -0,0 +1 @@ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).Constrainautor=n()}(this,(function(){"use strict";const t=11102230246251565e-32,n=134217729,e=(3+8*t)*t;function i(t,n,e,i,r){let s,o,h,c,f=n[0],u=i[0],a=0,l=0;u>f==u>-f?(s=f,f=n[++a]):(s=u,u=i[++l]);let d=0;if(af==u>-f?(o=f+s,h=s-(o-f),f=n[++a]):(o=u+s,h=s-(o-u),u=i[++l]),s=o,0!==h&&(r[d++]=h);af==u>-f?(o=s+f,c=o-s,h=s-(o-c)+(f-c),f=n[++a]):(o=s+u,c=o-s,h=s-(o-c)+(u-c),u=i[++l]),s=o,0!==h&&(r[d++]=h);for(;a0!=p>0)return M;const E=Math.abs(g+p);return Math.abs(M)>=33306690738754716e-32*E?M:-function(t,r,s,h,d,w,g){let p,M,E,y,b,x,C,m,v,A,D,j,I,T,k,F,U,q;const N=t-d,O=s-d,S=r-w,V=h-w;T=N*V,x=n*N,C=x-(x-N),m=N-C,x=n*V,v=x-(x-V),A=V-v,k=m*A-(T-C*v-m*v-C*A),F=S*O,x=n*S,C=x-(x-S),m=S-C,x=n*O,v=x-(x-O),A=O-v,U=m*A-(F-C*v-m*v-C*A),D=k-U,b=k-D,c[0]=k-(D+b)+(b-U),j=T+D,b=j-T,I=T-(j-b)+(D-b),D=I-F,b=I-D,c[1]=I-(D+b)+(b-F),q=j+D,b=q-j,c[2]=j-(q-b)+(D-b),c[3]=q;let z=o(4,c),B=22204460492503146e-32*g;if(z>=B||-z>=B)return z;if(b=t-N,p=t-(N+b)+(b-d),b=s-O,E=s-(O+b)+(b-d),b=r-S,M=r-(S+b)+(b-w),b=h-V,y=h-(V+b)+(b-w),0===p&&0===M&&0===E&&0===y)return z;if(B=11093356479670487e-47*g+e*Math.abs(z),z+=N*y+V*p-(S*E+O*M),z>=B||-z>=B)return z;T=p*V,x=n*p,C=x-(x-p),m=p-C,x=n*V,v=x-(x-V),A=V-v,k=m*A-(T-C*v-m*v-C*A),F=M*O,x=n*M,C=x-(x-M),m=M-C,x=n*O,v=x-(x-O),A=O-v,U=m*A-(F-C*v-m*v-C*A),D=k-U,b=k-D,l[0]=k-(D+b)+(b-U),j=T+D,b=j-T,I=T-(j-b)+(D-b),D=I-F,b=I-D,l[1]=I-(D+b)+(b-F),q=j+D,b=q-j,l[2]=j-(q-b)+(D-b),l[3]=q;const G=i(4,c,4,l,f);T=N*y,x=n*N,C=x-(x-N),m=N-C,x=n*y,v=x-(x-y),A=y-v,k=m*A-(T-C*v-m*v-C*A),F=S*E,x=n*S,C=x-(x-S),m=S-C,x=n*E,v=x-(x-E),A=E-v,U=m*A-(F-C*v-m*v-C*A),D=k-U,b=k-D,l[0]=k-(D+b)+(b-U),j=T+D,b=j-T,I=T-(j-b)+(D-b),D=I-F,b=I-D,l[1]=I-(D+b)+(b-F),q=j+D,b=q-j,l[2]=j-(q-b)+(D-b),l[3]=q;const H=i(G,f,4,l,u);T=p*y,x=n*p,C=x-(x-p),m=p-C,x=n*y,v=x-(x-y),A=y-v,k=m*A-(T-C*v-m*v-C*A),F=M*E,x=n*M,C=x-(x-M),m=M-C,x=n*E,v=x-(x-E),A=E-v,U=m*A-(F-C*v-m*v-C*A),D=k-U,b=k-D,l[0]=k-(D+b)+(b-U),j=T+D,b=j-T,I=T-(j-b)+(D-b),D=I-F,b=I-D,l[1]=I-(D+b)+(b-F),q=j+D,b=q-j,l[2]=j-(q-b)+(D-b),l[3]=q;const J=i(H,u,4,l,a);return a[J-1]}(t,r,s,h,d,w,E)}const w=h(4),g=h(4),p=h(4),M=h(4),E=h(4),y=h(4),b=h(4),x=h(4),C=h(8),m=h(8),v=h(8),A=h(8),D=h(8),j=h(8),I=h(8),T=h(8),k=h(8),F=h(4),U=h(4),q=h(4),N=h(8),O=h(16),S=h(16),V=h(16),z=h(32),B=h(32),G=h(48),H=h(64);let J=h(1152),K=h(1152);function L(t,n,e){t=i(t,J,n,e,K);const r=J;return J=K,K=r,t}function P(t,h,c,f,u,a,l,d){const K=t-l,P=c-l,Q=u-l,R=h-d,W=f-d,X=a-d,Y=P*X,Z=Q*W,$=K*K+R*R,_=Q*R,tt=K*X,nt=P*P+W*W,et=K*W,it=P*R,rt=Q*Q+X*X,st=$*(Y-Z)+nt*(_-tt)+rt*(et-it),ot=(Math.abs(Y)+Math.abs(Z))*$+(Math.abs(_)+Math.abs(tt))*nt+(Math.abs(et)+Math.abs(it))*rt,ht=11102230246251577e-31*ot;return st>ht||-st>ht?st:function(t,h,c,f,u,a,l,d,K){let P,Q,R,W,X,Y,Z,$,_,tt,nt,et,it,rt,st,ot,ht,ct,ft,ut,at,lt,dt,wt,gt,pt,Mt,Et,yt,bt,xt,Ct,mt,vt,At;const Dt=t-l,jt=c-l,It=u-l,Tt=h-d,kt=f-d,Ft=a-d;xt=jt*Ft,dt=n*jt,wt=dt-(dt-jt),gt=jt-wt,dt=n*Ft,pt=dt-(dt-Ft),Mt=Ft-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=It*kt,dt=n*It,wt=dt-(dt-It),gt=It-wt,dt=n*kt,pt=dt-(dt-kt),Mt=kt-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,w[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,w[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,w[2]=yt-(At-lt)+(Et-lt),w[3]=At,xt=It*Tt,dt=n*It,wt=dt-(dt-It),gt=It-wt,dt=n*Tt,pt=dt-(dt-Tt),Mt=Tt-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=Dt*Ft,dt=n*Dt,wt=dt-(dt-Dt),gt=Dt-wt,dt=n*Ft,pt=dt-(dt-Ft),Mt=Ft-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,g[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,g[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,g[2]=yt-(At-lt)+(Et-lt),g[3]=At,xt=Dt*kt,dt=n*Dt,wt=dt-(dt-Dt),gt=Dt-wt,dt=n*kt,pt=dt-(dt-kt),Mt=kt-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=jt*Tt,dt=n*jt,wt=dt-(dt-jt),gt=jt-wt,dt=n*Tt,pt=dt-(dt-Tt),Mt=Tt-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,p[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,p[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,p[2]=yt-(At-lt)+(Et-lt),p[3]=At,P=i(i(i(s(s(4,w,Dt,N),N,Dt,O),O,s(s(4,w,Tt,N),N,Tt,S),S,z),z,i(s(s(4,g,jt,N),N,jt,O),O,s(s(4,g,kt,N),N,kt,S),S,B),B,H),H,i(s(s(4,p,It,N),N,It,O),O,s(s(4,p,Ft,N),N,Ft,S),S,z),z,J);let Ut=o(P,J),qt=4440892098500632e-31*K;if(Ut>=qt||-Ut>=qt)return Ut;if(lt=t-Dt,Q=t-(Dt+lt)+(lt-l),lt=h-Tt,X=h-(Tt+lt)+(lt-d),lt=c-jt,R=c-(jt+lt)+(lt-l),lt=f-kt,Y=f-(kt+lt)+(lt-d),lt=u-It,W=u-(It+lt)+(lt-l),lt=a-Ft,Z=a-(Ft+lt)+(lt-d),0===Q&&0===R&&0===W&&0===X&&0===Y&&0===Z)return Ut;if(qt=5423418723394464e-46*K+e*Math.abs(Ut),Ut+=(Dt*Dt+Tt*Tt)*(jt*Z+Ft*R-(kt*W+It*Y))+2*(Dt*Q+Tt*X)*(jt*Ft-kt*It)+((jt*jt+kt*kt)*(It*X+Tt*W-(Ft*Q+Dt*Z))+2*(jt*R+kt*Y)*(It*Tt-Ft*Dt))+((It*It+Ft*Ft)*(Dt*Y+kt*Q-(Tt*R+jt*X))+2*(It*W+Ft*Z)*(Dt*kt-Tt*jt)),Ut>=qt||-Ut>=qt)return Ut;if(0===R&&0===Y&&0===W&&0===Z||(xt=Dt*Dt,dt=n*Dt,wt=dt-(dt-Dt),gt=Dt-wt,Ct=gt*gt-(xt-wt*wt-(wt+wt)*gt),mt=Tt*Tt,dt=n*Tt,wt=dt-(dt-Tt),gt=Tt-wt,vt=gt*gt-(mt-wt*wt-(wt+wt)*gt),Et=Ct+vt,lt=Et-Ct,M[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,M[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,M[2]=yt-(At-lt)+(Et-lt),M[3]=At),0===W&&0===Z&&0===Q&&0===X||(xt=jt*jt,dt=n*jt,wt=dt-(dt-jt),gt=jt-wt,Ct=gt*gt-(xt-wt*wt-(wt+wt)*gt),mt=kt*kt,dt=n*kt,wt=dt-(dt-kt),gt=kt-wt,vt=gt*gt-(mt-wt*wt-(wt+wt)*gt),Et=Ct+vt,lt=Et-Ct,E[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,E[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,E[2]=yt-(At-lt)+(Et-lt),E[3]=At),0===Q&&0===X&&0===R&&0===Y||(xt=It*It,dt=n*It,wt=dt-(dt-It),gt=It-wt,Ct=gt*gt-(xt-wt*wt-(wt+wt)*gt),mt=Ft*Ft,dt=n*Ft,wt=dt-(dt-Ft),gt=Ft-wt,vt=gt*gt-(mt-wt*wt-(wt+wt)*gt),Et=Ct+vt,lt=Et-Ct,y[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,y[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,y[2]=yt-(At-lt)+(Et-lt),y[3]=At),0!==Q&&($=s(4,w,Q,C),P=L(P,r(s($,C,2*Dt,O),O,s(s(4,y,Q,N),N,kt,S),S,s(s(4,E,Q,N),N,-Ft,V),V,z,G),G)),0!==X&&(_=s(4,w,X,m),P=L(P,r(s(_,m,2*Tt,O),O,s(s(4,E,X,N),N,It,S),S,s(s(4,y,X,N),N,-jt,V),V,z,G),G)),0!==R&&(tt=s(4,g,R,v),P=L(P,r(s(tt,v,2*jt,O),O,s(s(4,M,R,N),N,Ft,S),S,s(s(4,y,R,N),N,-Tt,V),V,z,G),G)),0!==Y&&(nt=s(4,g,Y,A),P=L(P,r(s(nt,A,2*kt,O),O,s(s(4,y,Y,N),N,Dt,S),S,s(s(4,M,Y,N),N,-It,V),V,z,G),G)),0!==W&&(et=s(4,p,W,D),P=L(P,r(s(et,D,2*It,O),O,s(s(4,E,W,N),N,Tt,S),S,s(s(4,M,W,N),N,-kt,V),V,z,G),G)),0!==Z&&(it=s(4,p,Z,j),P=L(P,r(s(it,j,2*Ft,O),O,s(s(4,M,Z,N),N,jt,S),S,s(s(4,E,Z,N),N,-Dt,V),V,z,G),G)),0!==Q||0!==X){if(0!==R||0!==Y||0!==W||0!==Z?(xt=R*Ft,dt=n*R,wt=dt-(dt-R),gt=R-wt,dt=n*Ft,pt=dt-(dt-Ft),Mt=Ft-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=jt*Z,dt=n*jt,wt=dt-(dt-jt),gt=jt-wt,dt=n*Z,pt=dt-(dt-Z),Mt=Z-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,b[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,b[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,b[2]=yt-(At-lt)+(Et-lt),b[3]=At,xt=W*-kt,dt=n*W,wt=dt-(dt-W),gt=W-wt,dt=n*-kt,pt=dt-(dt- -kt),Mt=-kt-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=It*-Y,dt=n*It,wt=dt-(dt-It),gt=It-wt,dt=n*-Y,pt=dt-(dt- -Y),Mt=-Y-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,x[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,x[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,x[2]=yt-(At-lt)+(Et-lt),x[3]=At,st=i(4,b,4,x,T),xt=R*Z,dt=n*R,wt=dt-(dt-R),gt=R-wt,dt=n*Z,pt=dt-(dt-Z),Mt=Z-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=W*Y,dt=n*W,wt=dt-(dt-W),gt=W-wt,dt=n*Y,pt=dt-(dt-Y),Mt=Y-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,U[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,U[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,U[2]=yt-(At-lt)+(Et-lt),U[3]=At,ct=4):(T[0]=0,st=1,U[0]=0,ct=1),0!==Q){const t=s(st,T,Q,V);P=L(P,i(s($,C,Q,O),O,s(t,V,2*Dt,z),z,G),G);const n=s(ct,U,Q,N);P=L(P,r(s(n,N,2*Dt,O),O,s(n,N,Q,S),S,s(t,V,Q,z),z,B,H),H),0!==Y&&(P=L(P,s(s(4,y,Q,N),N,Y,O),O)),0!==Z&&(P=L(P,s(s(4,E,-Q,N),N,Z,O),O))}if(0!==X){const t=s(st,T,X,V);P=L(P,i(s(_,m,X,O),O,s(t,V,2*Tt,z),z,G),G);const n=s(ct,U,X,N);P=L(P,r(s(n,N,2*Tt,O),O,s(n,N,X,S),S,s(t,V,X,z),z,B,H),H)}}if(0!==R||0!==Y){if(0!==W||0!==Z||0!==Q||0!==X?(xt=W*Tt,dt=n*W,wt=dt-(dt-W),gt=W-wt,dt=n*Tt,pt=dt-(dt-Tt),Mt=Tt-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=It*X,dt=n*It,wt=dt-(dt-It),gt=It-wt,dt=n*X,pt=dt-(dt-X),Mt=X-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,b[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,b[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,b[2]=yt-(At-lt)+(Et-lt),b[3]=At,ut=-Ft,at=-Z,xt=Q*ut,dt=n*Q,wt=dt-(dt-Q),gt=Q-wt,dt=n*ut,pt=dt-(dt-ut),Mt=ut-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=Dt*at,dt=n*Dt,wt=dt-(dt-Dt),gt=Dt-wt,dt=n*at,pt=dt-(dt-at),Mt=at-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,x[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,x[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,x[2]=yt-(At-lt)+(Et-lt),x[3]=At,ot=i(4,b,4,x,k),xt=W*X,dt=n*W,wt=dt-(dt-W),gt=W-wt,dt=n*X,pt=dt-(dt-X),Mt=X-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=Q*Z,dt=n*Q,wt=dt-(dt-Q),gt=Q-wt,dt=n*Z,pt=dt-(dt-Z),Mt=Z-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,q[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,q[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,q[2]=yt-(At-lt)+(Et-lt),q[3]=At,ft=4):(k[0]=0,ot=1,q[0]=0,ft=1),0!==R){const t=s(ot,k,R,V);P=L(P,i(s(tt,v,R,O),O,s(t,V,2*jt,z),z,G),G);const n=s(ft,q,R,N);P=L(P,r(s(n,N,2*jt,O),O,s(n,N,R,S),S,s(t,V,R,z),z,B,H),H),0!==Z&&(P=L(P,s(s(4,M,R,N),N,Z,O),O)),0!==X&&(P=L(P,s(s(4,y,-R,N),N,X,O),O))}if(0!==Y){const t=s(ot,k,Y,V);P=L(P,i(s(nt,A,Y,O),O,s(t,V,2*kt,z),z,G),G);const n=s(ft,q,Y,N);P=L(P,r(s(n,N,2*kt,O),O,s(n,N,Y,S),S,s(t,V,Y,z),z,B,H),H)}}if(0!==W||0!==Z){if(0!==Q||0!==X||0!==R||0!==Y?(xt=Q*kt,dt=n*Q,wt=dt-(dt-Q),gt=Q-wt,dt=n*kt,pt=dt-(dt-kt),Mt=kt-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=Dt*Y,dt=n*Dt,wt=dt-(dt-Dt),gt=Dt-wt,dt=n*Y,pt=dt-(dt-Y),Mt=Y-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,b[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,b[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,b[2]=yt-(At-lt)+(Et-lt),b[3]=At,ut=-Tt,at=-X,xt=R*ut,dt=n*R,wt=dt-(dt-R),gt=R-wt,dt=n*ut,pt=dt-(dt-ut),Mt=ut-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=jt*at,dt=n*jt,wt=dt-(dt-jt),gt=jt-wt,dt=n*at,pt=dt-(dt-at),Mt=at-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct+vt,lt=Et-Ct,x[0]=Ct-(Et-lt)+(vt-lt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt+mt,lt=Et-bt,x[1]=bt-(Et-lt)+(mt-lt),At=yt+Et,lt=At-yt,x[2]=yt-(At-lt)+(Et-lt),x[3]=At,rt=i(4,b,4,x,I),xt=Q*Y,dt=n*Q,wt=dt-(dt-Q),gt=Q-wt,dt=n*Y,pt=dt-(dt-Y),Mt=Y-pt,Ct=gt*Mt-(xt-wt*pt-gt*pt-wt*Mt),mt=R*X,dt=n*R,wt=dt-(dt-R),gt=R-wt,dt=n*X,pt=dt-(dt-X),Mt=X-pt,vt=gt*Mt-(mt-wt*pt-gt*pt-wt*Mt),Et=Ct-vt,lt=Ct-Et,F[0]=Ct-(Et+lt)+(lt-vt),yt=xt+Et,lt=yt-xt,bt=xt-(yt-lt)+(Et-lt),Et=bt-mt,lt=bt-Et,F[1]=bt-(Et+lt)+(lt-mt),At=yt+Et,lt=At-yt,F[2]=yt-(At-lt)+(Et-lt),F[3]=At,ht=4):(I[0]=0,rt=1,F[0]=0,ht=1),0!==W){const t=s(rt,I,W,V);P=L(P,i(s(et,D,W,O),O,s(t,V,2*It,z),z,G),G);const n=s(ht,F,W,N);P=L(P,r(s(n,N,2*It,O),O,s(n,N,W,S),S,s(t,V,W,z),z,B,H),H),0!==X&&(P=L(P,s(s(4,E,W,N),N,X,O),O)),0!==Y&&(P=L(P,s(s(4,M,-W,N),N,Y,O),O))}if(0!==Z){const t=s(rt,I,Z,V);P=L(P,i(s(it,j,Z,O),O,s(t,V,2*Ft,z),z,G),G);const n=s(ht,F,Z,N);P=L(P,r(s(n,N,2*Ft,O),O,s(n,N,Z,S),S,s(t,V,Z,z),z,B,H),H)}}return J[P-1]}(t,h,c,f,u,a,l,d,ot)}class Q extends class{constructor(t,n){this.W=t,this.bs=n}add(t){const n=this.W,e=t/n|0,i=t%n;return this.bs[e]|=1<>1,r=t.triangles.length;this.t=new Uint32Array(i).fill(e),this.i=new Q(r),this.o=new Q(r);for(let n=0;n{u.delete(t);const n=i[t];-1!==n&&(u.delete(n),this.g(t)||(this.l(t),a++))}))}while(a>0);return this.findEdge(t,n)}delaunify(t=!1){const n=this.del.halfedges,e=this.i,i=this.o,r=n.length;do{var s=0;for(let t=0;t0);return this}constrainAll(t){const n=t.length;for(let e=0;e0&&f>0||c<0&&f<0)return!1;const u=d(r,s,t,n,e,i),a=d(o,h,t,n,e,i);return!(u>0&&a>0||u<0&&a<0)&&(0!==c||0!==f||0!==u||0!==a||!(Math.max(r,o)>> 1, x = a[m]; + var p = (c !== undefined) ? c(x, y) : (x - y); + if (p >= 0) { i = m; h = m - 1 } else { l = m + 1 } + } + return i; +}; + +function gt(a, y, c, l, h) { + var i = h + 1; + while (l <= h) { + var m = (l + h) >>> 1, x = a[m]; + var p = (c !== undefined) ? c(x, y) : (x - y); + if (p > 0) { i = m; h = m - 1 } else { l = m + 1 } + } + return i; +}; + +function lt(a, y, c, l, h) { + var i = l - 1; + while (l <= h) { + var m = (l + h) >>> 1, x = a[m]; + var p = (c !== undefined) ? c(x, y) : (x - y); + if (p < 0) { i = m; l = m + 1 } else { h = m - 1 } + } + return i; +}; + +function le(a, y, c, l, h) { + var i = l - 1; + while (l <= h) { + var m = (l + h) >>> 1, x = a[m]; + var p = (c !== undefined) ? c(x, y) : (x - y); + if (p <= 0) { i = m; l = m + 1 } else { h = m - 1 } + } + return i; +}; + +function eq(a, y, c, l, h) { + while (l <= h) { + var m = (l + h) >>> 1, x = a[m]; + var p = (c !== undefined) ? c(x, y) : (x - y); + if (p === 0) { return m } + if (p <= 0) { l = m + 1 } else { h = m - 1 } + } + return -1; +}; + +function norm(a, y, c, l, h, f) { + if (typeof c === 'function') { + return f(a, y, c, (l === undefined) ? 0 : l | 0, (h === undefined) ? a.length - 1 : h | 0); + } + return f(a, y, undefined, (c === undefined) ? 0 : c | 0, (l === undefined) ? a.length - 1 : l | 0); +} + +module.exports = { + ge: function(a, y, c, l, h) { return norm(a, y, c, l, h, ge)}, + gt: function(a, y, c, l, h) { return norm(a, y, c, l, h, gt)}, + lt: function(a, y, c, l, h) { return norm(a, y, c, l, h, lt)}, + le: function(a, y, c, l, h) { return norm(a, y, c, l, h, le)}, + eq: function(a, y, c, l, h) { return norm(a, y, c, l, h, eq)} +} + +},{}],3:[function(require,module,exports){ +'use strict' + +var monotoneTriangulate = require('./lib/monotone') +var makeIndex = require('./lib/triangulation') +var delaunayFlip = require('./lib/delaunay') +var filterTriangulation = require('./lib/filter') + +module.exports = cdt2d + +function canonicalizeEdge(e) { + return [Math.min(e[0], e[1]), Math.max(e[0], e[1])] +} + +function compareEdge(a, b) { + return a[0]-b[0] || a[1]-b[1] +} + +function canonicalizeEdges(edges) { + return edges.map(canonicalizeEdge).sort(compareEdge) +} + +function getDefault(options, property, dflt) { + if(property in options) { + return options[property] + } + return dflt +} + +function cdt2d(points, edges, options) { + + if(!Array.isArray(edges)) { + options = edges || {} + edges = [] + } else { + options = options || {} + edges = edges || [] + } + + //Parse out options + var delaunay = !!getDefault(options, 'delaunay', true) + var interior = !!getDefault(options, 'interior', true) + var exterior = !!getDefault(options, 'exterior', true) + var infinity = !!getDefault(options, 'infinity', false) + + //Handle trivial case + if((!interior && !exterior) || points.length === 0) { + return [] + } + + //Construct initial triangulation + var cells = monotoneTriangulate(points, edges) + + //If delaunay refinement needed, then improve quality by edge flipping + if(delaunay || interior !== exterior || infinity) { + + //Index all of the cells to support fast neighborhood queries + var triangulation = makeIndex(points.length, canonicalizeEdges(edges)) + for(var i=0; i 0) { + var b = stack.pop() + var a = stack.pop() + + //Find opposite pairs + var x = -1, y = -1 + var star = stars[a] + for(var i=1; i= 0) { + continue + } + + //Flip the edge + triangulation.flip(a, b) + + //Test flipping neighboring edges + testFlip(points, triangulation, stack, x, a, y) + testFlip(points, triangulation, stack, a, y, x) + testFlip(points, triangulation, stack, y, b, x) + testFlip(points, triangulation, stack, b, x, y) + } +} + +},{"binary-search-bounds":2,"robust-in-sphere":8}],5:[function(require,module,exports){ +'use strict' + +var bsearch = require('binary-search-bounds') + +module.exports = classifyFaces + +function FaceIndex(cells, neighbor, constraint, flags, active, next, boundary) { + this.cells = cells + this.neighbor = neighbor + this.flags = flags + this.constraint = constraint + this.active = active + this.next = next + this.boundary = boundary +} + +var proto = FaceIndex.prototype + +function compareCell(a, b) { + return a[0] - b[0] || + a[1] - b[1] || + a[2] - b[2] +} + +proto.locate = (function() { + var key = [0,0,0] + return function(a, b, c) { + var x = a, y = b, z = c + if(b < c) { + if(b < a) { + x = b + y = c + z = a + } + } else if(c < a) { + x = c + y = a + z = b + } + if(x < 0) { + return -1 + } + key[0] = x + key[1] = y + key[2] = z + return bsearch.eq(this.cells, key, compareCell) + } +})() + +function indexCells(triangulation, infinity) { + //First get cells and canonicalize + var cells = triangulation.cells() + var nc = cells.length + for(var i=0; i 0 || next.length > 0) { + while(active.length > 0) { + var t = active.pop() + if(flags[t] === -side) { + continue + } + flags[t] = side + var c = cells[t] + for(var j=0; j<3; ++j) { + var f = neighbor[3*t+j] + if(f >= 0 && flags[f] === 0) { + if(constraint[3*t+j]) { + next.push(f) + } else { + active.push(f) + flags[f] = side + } + } + } + } + + //Swap arrays and loop + var tmp = next + next = active + active = tmp + next.length = 0 + side = -side + } + + var result = filterCells(cells, flags, target) + if(infinity) { + return result.concat(index.boundary) + } + return result +} + +},{"binary-search-bounds":2}],6:[function(require,module,exports){ +'use strict' + +var bsearch = require('binary-search-bounds') +var orient = require('robust-orientation')[3] + +var EVENT_POINT = 0 +var EVENT_END = 1 +var EVENT_START = 2 + +module.exports = monotoneTriangulate + +//A partial convex hull fragment, made of two unimonotone polygons +function PartialHull(a, b, idx, lowerIds, upperIds) { + this.a = a + this.b = b + this.idx = idx + this.lowerIds = lowerIds + this.upperIds = upperIds +} + +//An event in the sweep line procedure +function Event(a, b, type, idx) { + this.a = a + this.b = b + this.type = type + this.idx = idx +} + +//This is used to compare events for the sweep line procedure +// Points are: +// 1. sorted lexicographically +// 2. sorted by type (point < end < start) +// 3. segments sorted by winding order +// 4. sorted by index +function compareEvent(a, b) { + var d = + (a.a[0] - b.a[0]) || + (a.a[1] - b.a[1]) || + (a.type - b.type) + if(d) { return d } + if(a.type !== EVENT_POINT) { + d = orient(a.a, a.b, b.b) + if(d) { return d } + } + return a.idx - b.idx +} + +function testPoint(hull, p) { + return orient(hull.a, hull.b, p) +} + +function addPoint(cells, hulls, points, p, idx) { + var lo = bsearch.lt(hulls, p, testPoint) + var hi = bsearch.gt(hulls, p, testPoint) + for(var i=lo; i 1 && orient( + points[lowerIds[m-2]], + points[lowerIds[m-1]], + p) > 0) { + cells.push( + [lowerIds[m-1], + lowerIds[m-2], + idx]) + m -= 1 + } + lowerIds.length = m + lowerIds.push(idx) + + //Insert p into upper hull + var upperIds = hull.upperIds + var m = upperIds.length + while(m > 1 && orient( + points[upperIds[m-2]], + points[upperIds[m-1]], + p) < 0) { + cells.push( + [upperIds[m-2], + upperIds[m-1], + idx]) + m -= 1 + } + upperIds.length = m + upperIds.push(idx) + } +} + +function findSplit(hull, edge) { + var d + if(hull.a[0] < edge.a[0]) { + d = orient(hull.a, hull.b, edge.a) + } else { + d = orient(edge.b, edge.a, hull.a) + } + if(d) { return d } + if(edge.b[0] < hull.b[0]) { + d = orient(hull.a, hull.b, edge.b) + } else { + d = orient(edge.b, edge.a, hull.b) + } + return d || hull.idx - edge.idx +} + +function splitHulls(hulls, points, event) { + var splitIdx = bsearch.le(hulls, event, findSplit) + var hull = hulls[splitIdx] + var upperIds = hull.upperIds + var x = upperIds[upperIds.length-1] + hull.upperIds = [x] + hulls.splice(splitIdx+1, 0, + new PartialHull(event.a, event.b, event.idx, [x], upperIds)) +} + + +function mergeHulls(hulls, points, event) { + //Swap pointers for merge search + var tmp = event.a + event.a = event.b + event.b = tmp + var mergeIdx = bsearch.eq(hulls, event, findSplit) + var upper = hulls[mergeIdx] + var lower = hulls[mergeIdx-1] + lower.upperIds = upper.upperIds + hulls.splice(mergeIdx, 1) +} + + +function monotoneTriangulate(points, edges) { + + var numPoints = points.length + var numEdges = edges.length + + var events = [] + + //Create point events + for(var i=0; i b[0]) { + events.push( + new Event(b, a, EVENT_START, i), + new Event(a, b, EVENT_END, i)) + } + } + + //Sort events + events.sort(compareEvent) + + //Initialize hull + var minX = events[0].a[0] - (1 + Math.abs(events[0].a[0])) * Math.pow(2, -52) + var hull = [ new PartialHull([minX, 1], [minX, 0], -1, [], [], [], []) ] + + //Process events in order + var cells = [] + for(var i=0, numEvents=events.length; i= 0 + } +})() + +proto.removeTriangle = function(i, j, k) { + var stars = this.stars + removePair(stars[i], j, k) + removePair(stars[j], k, i) + removePair(stars[k], i, j) +} + +proto.addTriangle = function(i, j, k) { + var stars = this.stars + stars[i].push(j, k) + stars[j].push(k, i) + stars[k].push(i, j) +} + +proto.opposite = function(j, i) { + var list = this.stars[i] + for(var k=1, n=list.length; k 0) { + if(r <= 0) { + return det + } else { + s = l + r + } + } else if(l < 0) { + if(r >= 0) { + return det + } else { + s = -(l + r) + } + } else { + return det + } + var tol = ERRBOUND3 * s + if(det >= tol || det <= -tol) { + return det + } + return orientation3Exact(a, b, c) + }, + function orientation4(a,b,c,d) { + var adx = a[0] - d[0] + var bdx = b[0] - d[0] + var cdx = c[0] - d[0] + var ady = a[1] - d[1] + var bdy = b[1] - d[1] + var cdy = c[1] - d[1] + var adz = a[2] - d[2] + var bdz = b[2] - d[2] + var cdz = c[2] - d[2] + var bdxcdy = bdx * cdy + var cdxbdy = cdx * bdy + var cdxady = cdx * ady + var adxcdy = adx * cdy + var adxbdy = adx * bdy + var bdxady = bdx * ady + var det = adz * (bdxcdy - cdxbdy) + + bdz * (cdxady - adxcdy) + + cdz * (adxbdy - bdxady) + var permanent = (Math.abs(bdxcdy) + Math.abs(cdxbdy)) * Math.abs(adz) + + (Math.abs(cdxady) + Math.abs(adxcdy)) * Math.abs(bdz) + + (Math.abs(adxbdy) + Math.abs(bdxady)) * Math.abs(cdz) + var tol = ERRBOUND4 * permanent + if ((det > tol) || (-det > tol)) { + return det + } + return orientation4Exact(a,b,c,d) + } +] + +function slowOrient(args) { + var proc = CACHED[args.length] + if(!proc) { + proc = CACHED[args.length] = orientation(args.length) + } + return proc.apply(undefined, args) +} + +function proc (slow, o0, o1, o2, o3, o4, o5) { + return function getOrientation(a0, a1, a2, a3, a4) { + switch (arguments.length) { + case 0: + case 1: + return 0; + case 2: + return o2(a0, a1) + case 3: + return o3(a0, a1, a2) + case 4: + return o4(a0, a1, a2, a3) + case 5: + return o5(a0, a1, a2, a3, a4) + } + + var s = new Array(arguments.length) + for (var i = 0; i < arguments.length; ++i) { + s[i] = arguments[i] + } + return slow(s) + } +} + +function generateOrientationProc() { + while(CACHED.length <= NUM_EXPAND) { + CACHED.push(orientation(CACHED.length)) + } + module.exports = proc.apply(undefined, [slowOrient].concat(CACHED)) + for(var i=0; i<=NUM_EXPAND; ++i) { + module.exports[i] = CACHED[i] + } +} + +generateOrientationProc() +},{"robust-scale":10,"robust-subtract":11,"robust-sum":12,"two-product":13}],10:[function(require,module,exports){ +"use strict" + +var twoProduct = require("two-product") +var twoSum = require("two-sum") + +module.exports = scaleLinearExpansion + +function scaleLinearExpansion(e, scale) { + var n = e.length + if(n === 1) { + var ts = twoProduct(e[0], scale) + if(ts[0]) { + return ts + } + return [ ts[1] ] + } + var g = new Array(2 * n) + var q = [0.1, 0.1] + var t = [0.1, 0.1] + var count = 0 + twoProduct(e[0], scale, q) + if(q[0]) { + g[count++] = q[0] + } + for(var i=1; i= nf)) { + a = ei + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + ea = abs(ei) + } + } else { + a = fi + fptr += 1 + if(fptr < nf) { + fi = -f[fptr] + fa = abs(fi) + } + } + var x = a + b + var bv = x - a + var y = b - bv + var q0 = y + var q1 = x + var _x, _bv, _av, _br, _ar + while(eptr < ne && fptr < nf) { + if(ea < fa) { + a = ei + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + ea = abs(ei) + } + } else { + a = fi + fptr += 1 + if(fptr < nf) { + fi = -f[fptr] + fa = abs(fi) + } + } + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + } + while(eptr < ne) { + a = ei + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + } + } + while(fptr < nf) { + a = fi + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + fptr += 1 + if(fptr < nf) { + fi = -f[fptr] + } + } + if(q0) { + g[count++] = q0 + } + if(q1) { + g[count++] = q1 + } + if(!count) { + g[count++] = 0.0 + } + g.length = count + return g +} +},{}],12:[function(require,module,exports){ +"use strict" + +module.exports = linearExpansionSum + +//Easy case: Add two scalars +function scalarScalar(a, b) { + var x = a + b + var bv = x - a + var av = x - bv + var br = b - bv + var ar = a - av + var y = ar + br + if(y) { + return [y, x] + } + return [x] +} + +function linearExpansionSum(e, f) { + var ne = e.length|0 + var nf = f.length|0 + if(ne === 1 && nf === 1) { + return scalarScalar(e[0], f[0]) + } + var n = ne + nf + var g = new Array(n) + var count = 0 + var eptr = 0 + var fptr = 0 + var abs = Math.abs + var ei = e[eptr] + var ea = abs(ei) + var fi = f[fptr] + var fa = abs(fi) + var a, b + if(ea < fa) { + b = ei + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + ea = abs(ei) + } + } else { + b = fi + fptr += 1 + if(fptr < nf) { + fi = f[fptr] + fa = abs(fi) + } + } + if((eptr < ne && ea < fa) || (fptr >= nf)) { + a = ei + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + ea = abs(ei) + } + } else { + a = fi + fptr += 1 + if(fptr < nf) { + fi = f[fptr] + fa = abs(fi) + } + } + var x = a + b + var bv = x - a + var y = b - bv + var q0 = y + var q1 = x + var _x, _bv, _av, _br, _ar + while(eptr < ne && fptr < nf) { + if(ea < fa) { + a = ei + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + ea = abs(ei) + } + } else { + a = fi + fptr += 1 + if(fptr < nf) { + fi = f[fptr] + fa = abs(fi) + } + } + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + } + while(eptr < ne) { + a = ei + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + eptr += 1 + if(eptr < ne) { + ei = e[eptr] + } + } + while(fptr < nf) { + a = fi + b = q0 + x = a + b + bv = x - a + y = b - bv + if(y) { + g[count++] = y + } + _x = q1 + x + _bv = _x - q1 + _av = _x - _bv + _br = x - _bv + _ar = q1 - _av + q0 = _ar + _br + q1 = _x + fptr += 1 + if(fptr < nf) { + fi = f[fptr] + } + } + if(q0) { + g[count++] = q0 + } + if(q1) { + g[count++] = q1 + } + if(!count) { + g[count++] = 0.0 + } + g.length = count + return g +} +},{}],13:[function(require,module,exports){ +"use strict" + +module.exports = twoProduct + +var SPLITTER = +(Math.pow(2, 27) + 1.0) + +function twoProduct(a, b, result) { + var x = a * b + + var c = SPLITTER * a + var abig = c - a + var ahi = c - abig + var alo = a - ahi + + var d = SPLITTER * b + var bbig = d - b + var bhi = d - bbig + var blo = b - bhi + + var err1 = x - (ahi * bhi) + var err2 = err1 - (alo * bhi) + var err3 = err2 - (ahi * blo) + + var y = alo * blo - err3 + + if(result) { + result[0] = y + result[1] = x + return result + } + + return [ y, x ] +} +},{}],14:[function(require,module,exports){ +"use strict" + +module.exports = fastTwoSum + +function fastTwoSum(a, b, result) { + var x = a + b + var bv = x - a + var av = x - bv + var br = b - bv + var ar = a - av + if(result) { + result[0] = ar + br + result[1] = x + return result + } + return [ar+br, x] +} +},{}]},{},[1]); diff --git a/scripts/module.js b/scripts/module.js index b880c29..d43200f 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -21,6 +21,9 @@ import { PriorityQueueArray } from "./pathfinding/PriorityQueueArray.js"; import { PriorityQueue } from "./pathfinding/PriorityQueue.js"; import { benchPathfinding } from "./pathfinding/benchmark.js"; +// Wall updates for pathfinding +import { SCENE_GRAPH, WallTracer, WallTracerEdge, WallTracerVertex } from "./pathfinding/WallTracer.js"; + Hooks.once("init", function() { // Cannot access localization until init. PREFER_TOKEN_CONTROL.title = game.i18n.localize(PREFER_TOKEN_CONTROL.title); @@ -40,8 +43,11 @@ Hooks.once("init", function() { AStarPathSearch, PriorityQueueArray, PriorityQueue, - benchPathfinding - } + benchPathfinding, + SCENE_GRAPH + }, + + WallTracer, WallTracerEdge, WallTracerVertex }; }); diff --git a/scripts/patching.js b/scripts/patching.js index a342739..fb663fe 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -13,6 +13,9 @@ import { PATCHES as PATCHES_BaseGrid } from "./BaseGrid.js"; import { PATCHES as PATCHES_HexagonalGrid } from "./HexagonalGrid.js"; import { PATCHES as PATCHES_ConstrainedTokenBorder } from "./ConstrainedTokenBorder.js"; +// Pathfinding +import { PATCHES as PATCHES_Wall } from "./pathfinding/Wall.js"; + // Settings import { PATCHES as PATCHES_Settings } from "./ModuleSettingsAbstract.js"; @@ -24,7 +27,8 @@ const PATCHES = { HexagonalGrid: PATCHES_HexagonalGrid, Ruler: PATCHES_Ruler, Token: PATCHES_Token, - Settings: PATCHES_Settings + Settings: PATCHES_Settings, + Wall: PATCHES_Wall }; export const PATCHER = new Patcher(); @@ -33,5 +37,6 @@ PATCHER.addPatchesFromRegistrationObject(PATCHES); export function initializePatching() { PATCHER.registerGroup("BASIC"); PATCHER.registerGroup("ConstrainedTokenBorder"); + PATCHER.registerGroup("PATHFINDING"); } diff --git a/scripts/pathfinding/Wall.js b/scripts/pathfinding/Wall.js new file mode 100644 index 0000000..276e7df --- /dev/null +++ b/scripts/pathfinding/Wall.js @@ -0,0 +1,85 @@ +/* globals +canvas, +Hooks +*/ +"use strict"; + +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { MODULE_ID } from "../const.js"; +import { SCENE_GRAPH } from "./WallTracer.js"; +import { Pathfinder } from "./pathfinding.js"; + +// Track wall creation, update, and deletion, constructing WallTracerEdges as we go. +// Use to update the pathfinding triangulation. + +export const PATCHES = {}; +PATCHES.PATHFINDING = {}; + + +// When canvas is ready, the existing walls are not created, so must re-do here. +Hooks.on("canvasReady", async function() { + console.debug(`outerBounds: ${canvas.walls.outerBounds.length}`); + console.debug(`innerBounds: ${canvas.walls.innerBounds.length}`); + + const t0 = performance.now(); + SCENE_GRAPH.clear(); + const walls = [ + ...canvas.walls.placeables, + ...canvas.walls.outerBounds, + ...canvas.walls.innerBounds + ]; + for ( const wall of walls ) SCENE_GRAPH.addWall(wall); + const t1 = performance.now(); + + // Use the scene graph to initialize Pathfinder triangulation. + Pathfinder.initialize(); + const t2 = performance.now(); + + console.debug(`${MODULE_ID}|Tracked ${walls.length} walls in ${t1 - t0} ms.`); + console.debug(`${MODULE_ID}|Initialized pathfinding in ${t2 - t1} ms.`); +}); + + +/** + * Hook createWall to update the scene graph and triangulation. + * @param {Document} document The new Document instance which has been created + * @param {DocumentModificationContext} options Additional options which modified the creation request + * @param {string} userId The ID of the User who triggered the creation workflow + */ +function createWall(document, _options, _userId) { + SCENE_GRAPH.addWall(document.object); + Pathfinder.dirty = true; +} + +/** + * Hook updateWall to update the scene graph and triangulation. + * @param {Document} document The existing Document which was updated + * @param {object} change Differential data that was used to update the document + * @param {DocumentModificationContext} options Additional options which modified the update request + * @param {string} userId The ID of the User who triggered the update workflow + */ +function updateWall(document, changes, _options, _userId) { + // Only update the edges if the coordinates have changed. + if ( !Object.hasOwn(changes, "c") ) return; + + // Easiest approach is to trash the edges for the wall and re-create them. + SCENE_GRAPH.removeWall(document.id); + SCENE_GRAPH.addWall(document.object); + + // Need to re-do the triangulation because the change to the wall could have added edges if intersected. + Pathfinder.dirty = true; +} + +/** + * Hook deleteWall to update the scene graph and triangulation. + * @param {Document} document The existing Document which was deleted + * @param {DocumentModificationContext} options Additional options which modified the deletion request + * @param {string} userId The ID of the User who triggered the deletion workflow + */ +function deleteWall(document, _options, _userId) { + SCENE_GRAPH.removeWall(document.id); // The document.object is now null; use the id to remove the wall. + Pathfinder.dirty = true; +} + +PATCHES.PATHFINDING.HOOKS = { createWall, updateWall, deleteWall }; diff --git a/scripts/pathfinding/WallTracer.js b/scripts/pathfinding/WallTracer.js new file mode 100644 index 0000000..c577273 --- /dev/null +++ b/scripts/pathfinding/WallTracer.js @@ -0,0 +1,783 @@ +/* globals +CONST, +CanvasQuadtree, +CONFIG, +foundry, +PIXI, +Wall +*/ +"use strict"; + +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +// WallTracer3 + +import { groupBy } from "../util.js"; +import { ClipperPaths } from "../geometry/ClipperPaths.js"; +import { Draw } from "../geometry/Draw.js"; +import { Graph, GraphVertex, GraphEdge } from "../geometry/Graph.js"; + +/* WallTracerVertex + +Represents the endpoint of a WallTracerEdge. +Like with Walls, these vertices use integer values and keys. + +The vertex provides links to connected WallTracerEdges. + +*/ + +/* WallTracerEdge + +Represents a portion of a Wall between two collisions: +- endpoint -- endpoint +- endpoint -- intersection +- intersection -- intersection + +Properties include: +- wall +- A and B, where each store the t ratio corresponding to a point on the wall +- Array? of WallTracerEdge that share an endpoint, organized from cw --> ccw angle + +If the wall overlaps a collinear wall? +- single edge should represent both + +Wall type: currently ignored + +*/ + +/* Connected WallTracerEdge identification + +A closed polygon formed from WallTracerEdge can only be formed from edges that have +connecting edges at both A and B endpoints. + +Store the set of connected WallTracerEdges. For a given set of edges, one can find the +set of connected edges by repeatedly removing edges with zero or 1 connected endpoints, +then updating the remainder and repeating until no more edges are removed. + +The connected edges remaining must form 1+ closed polygons. All dangling lines will have +been removed. + +*/ + +/* Wall updating + +1. Wall creation +- Locate collision walls (edges) using QuadTree. +- Split wall into edges. +- Split colliding edges. +- Update the set of connected edges. + +2. Wall update +- A changed: redo as in wall creation (1) +- B changed: change B endpoint. Possibly drop edges if shrinking (use t values). + +3. Wall deletion +- remove from set of edges +- remove from set of connected edges +- remove from shared endpoint edges +- redo set of connected edges + +*/ + +/* Angles +Foundry canvas angles using Ray: +--> e: 0 +--> se: π / 4 +--> s: π / 2 +--> sw: π * 3/4 +--> w: π +--> nw: -π * 3/4 +--> n: -π / 2 +--> ne: -π / 4 + +So northern hemisphere is negative, southern is positive. +0 --> π moves from east to west clockwise. +0 --> -π moves from east to west counterclockwise. +*/ + +// NOTE: Testing +/* +api = game.modules.get("elevatedvision").api +SCENE_GRAPH = api.SCENE_GRAPH +WallTracer = api.WallTracer +WallTracerEdge = api.WallTracerEdge +WallTracerVertex = api.WallTracerVertex + +origin = _token.center +*/ + + +// Wall Tracer tracks all edges and vertices that make up walls/wall intersections. + +/** + * Represents either a wall endpoint or the intersection between two walls. + * Collinear walls are considered to "intersect" at each overlapping endpoint. + * Cached, so that vertices may not repeat. Because of this, the object is used as its own key. + */ +export class WallTracerVertex extends GraphVertex { + + /** @type {PIXI.Point} */ + #vertex = new PIXI.Point(); // Stored separately so vertices can be added, etc. + + /** @type {number} */ + key = -1; + + /** @type {string} */ + keyString = "-1"; + + /** + * @param {number} x + * @param {number} y + */ + constructor(x, y) { + const point = new PIXI.Point(x, y); + point.roundDecimals(); + const key = point.key; + super(key); + this.#vertex = point; + this.key = key; + this.keyString = key.toString(); + } + + /** @type {*} */ + // get key() { return this; } // TODO: Faster using key or using a cache? + + /** @type {number} */ + get x() { return this.#vertex.x; } + + /** @type {number} */ + get y() { return this.#vertex.y; } + + /** @type {PIXI.Point} */ + get point() { return this.#vertex.clone(); } // Clone to avoid internal modification. + + /** + * Test for equality against another vertex + */ + equals(other) { + return this.#vertex.equals(other); + } + + /** + * Test for near equality against another vertex + */ + almostEqual(other, epsilon = 1e-08) { + return this.#vertex.almostEqual(other, epsilon); + } + + /** + * Convert the vertex to a string. String should be unique such that it can be an id or key. + * @param {function} [callback] + * @returns {string} + */ + toString() { return this.keyString; } + + draw(drawingOptions = {}) { + Draw.point(this, drawingOptions); + } +} + +/** + * Represents a portion of a wall. + * The wall is divided into distinct edges based on intersections with other walls. + */ +export class WallTracerEdge extends GraphEdge { + /** + * Number of places to round the ratio for wall collisions, in order to treat + * close collisions as equal. + * @type {number} + */ + static PLACES = 8; + + /** + * Wall represented by this edge. + * The edge may represent the entire wall or just a portion (see tA and tB). + * @type {Wall} + */ + wall; + + // Location on the wall, as a ratio, where the endpoint of the edge is located. + /** @type {number} */ + tA = 0; + + /** @type {number} */ + tB = 1; + + /** @type {PIXI.Point} */ + delta = new PIXI.Point(); + + /** + * Construct an edge from a wall. + * To be used instead of constructor in most cases. + * @param {Wall} wall Wall represented by this edge + * @param {number} [tA=0] Where the A endpoint of this edge falls on the wall + * @param {number} [tB=1] Where the B endpoint of this edge falls on the wall + * @returns {WallTracerEdge} + */ + static fromWall(wall, tA = 0, tB = 1) { + tA = Math.clamped(tA, 0, 1); + tB = Math.clamped(tB, 0, 1); + const eA = WallTracerEdge.pointAtWallRatio(wall, tA); + const eB = WallTracerEdge.pointAtWallRatio(wall, tB); + const A = new WallTracerVertex(eA.x, eA.y); + const B = new WallTracerVertex(eB.x, eB.y); + const dist = PIXI.Point.distanceSquaredBetween(A.point, B.point); + const edge = new this(A, B, dist); + + edge.tA = tA; + edge.tB = tB; + edge.wall = wall; + edge.delta = edge.B.point.subtract(edge.A.point); + return edge; + } + + /** + * Determine the point along the line of a wall given a ratio. + * @param {number} wallT + * @returns {PIXI.Point} The point along the wall line. Ratio 0: endpoint A; 1: endpoint B. + */ + static pointAtWallRatio(wall, wallT) { + const A = new PIXI.Point(wall.A.x, wall.A.y); + if ( wallT.almostEqual(0) ) return A; + + const B = new PIXI.Point(wall.B.x, wall.B.y); + if ( wallT.almostEqual(1) ) return B; + + wallT = Math.roundDecimals(wallT, WallTracerEdge.PLACES); + const outPoint = new PIXI.Point(); + A.projectToward(B, wallT, outPoint); + return outPoint; + } + + /** + * Boundary rectangle that encompasses this edge. + * @type {PIXI.Rectangle} + */ + get bounds() { + const { A, delta } = this; + return new PIXI.Rectangle(A.x, A.y, delta.x, delta.y).normalize(); + } + + // Methods to trick Foundry into thinking this edge is basically a wall. + + /** @type {string} */ + get id() { return this.wall.id; } + + /** @type {object} */ + get document() { return this.wall.document; } + + /** @type {boolean} */ + get hasActiveRoof() { return this.wall.hasActiveRoof; } + + /** @type {boolean} */ + get isOpen() { return this.wall.isOpen; } + + /** + * Reverse this edge + * @returns {GraphEdge} + */ + reverse() { + const edge = super.reverse(); + edge.tA = this.tB; + edge.tB = this.tA; + edge.wall = this.wall; + edge.delta = edge.B.point.subtract(edge.A.point); + return edge; + } + + /** + * @typedef {object} WallTracerCollision + * @property {number} wallT Location of collision on the wall, where A = 0 and B = 1 + * @property {number} edgeT Location of collision on the edge, where A = 0 and B = 1 + * @property {Point} pt Intersection point. + * @property {WallTracerEdge} edge Edge associated with this collision + * @property {Wall} wall Wall associated with this collision + */ + + /** + * Find the collision, if any, between this edge and a wall + * @param {Wall} wall Foundry wall object to test + * @returns {WallTracerCollision} + */ + findWallCollision(wall) { + const { A, B } = wall; + const { A: eA, B: eB } = this; + + let out; + if ( A.key === eA.key || eA.almostEqual(A) ) out = { wallT: 0, edgeT: 0, pt: A }; + else if ( A.key === eB.key || eB.almostEqual(A) ) out = { wallT: 0, edgeT: 1, pt: A }; + else if ( B.key === eA.key || eA.almostEqual(B) ) out = { wallT: 1, edgeT: 0, pt: B }; + else if ( B.key === eB.key || eB.almostEqual(B) ) out = { wallT: 1, edgeT: 1, pt: B }; + else if ( foundry.utils.lineSegmentIntersects(A, B, eA, eB) ) { + const ix = CONFIG.GeometryLib.utils.lineLineIntersection(A, B, eA, eB, { t1: true }); + out = { + wallT: Math.roundDecimals(ix.t0, WallTracerEdge.PLACES), + edgeT: Math.roundDecimals(ix.t1, WallTracerEdge.PLACES), + pt: ix }; + + } else { + // Edge is either completely collinear or does not intersect. + return null; + } + + out.pt = new PIXI.Point(out.pt.x, out.pt.y); + out.edge = this; + out.wall = wall; + return out; + } + + /** + * Split this edge at some t value. + * @param {number} edgeT The portion on this *edge* that designates a point. + * @returns {WallTracerEdge[]|null} Array of two wall tracer edges that share t endpoint. + */ + splitAtT(edgeT) { + edgeT = Math.clamped(edgeT, 0, 1); + if ( edgeT.almostEqual(0) || edgeT.almostEqual(1) ) return null; + + // Construct two new edges, divided at the edgeT location. + const wall = this.wall; + const wallT = this._tRatioToWallRatio(edgeT); + const edge1 = WallTracerEdge.fromWall(wall, this.tA, wallT); + const edge2 = WallTracerEdge.fromWall(wall, wallT, this.tB); + + return [edge1, edge2]; + } + + /** + * For a given t ratio for this edge, what is the equivalent wall ratio? + * @param {number} t + * @returns {number} + */ + _tRatioToWallRatio(t) { + if ( t.almostEqual(0) ) return this.tA; + if ( t.almostEqual(1) ) return this.tB; + + // Linear mapping where wallT === 0 --> tA, wallT === 1 --> tB + const dT = this.tB - this.tA; + return this.tA + (dT * t); + } + + /** + * Draw this edge on the canvas. + * Primarily for debugging. + */ + draw(drawingOptions = {}) { + Draw.segment(this, drawingOptions); + + drawingOptions.color = Draw.COLORS.red; + this.A.draw(drawingOptions); + + drawingOptions.color = Draw.COLORS.blue; + this.B.draw(drawingOptions); + } +} + +export class WallTracer extends Graph { + + /** + * Number of places to round the ratio for wall collisions, in order to treat + * close collisions as equal. + * @type {number} + */ + static PLACES = 8; + + /** + * Helper function used to group collisions into the collision map. + * @param {WallTracerCollision} c Collision to group + * @returns {number} The t0 property, rounded. + */ + static _keyGetter(c) { return Math.roundDecimals(c.wallT, WallTracer.PLACES); } + + /** + * Map of a set of edges for a given wall, keyed to the wall id. + * Must be wall id because deleted walls may still need to be accessed here. + * @type {Map>} */ + wallEdges = new Map(); + + /** @type {CanvasQuadtree} */ + edgesQuadtree = new CanvasQuadtree(); + + /** + * @type {object} + * @property {PIXI.Polygons} least + * @property {PIXI.Polygons} most + * @property {PIXI.Polygons} combined + */ + cyclePolygonsQuadtree = new CanvasQuadtree(); + + /** + * Clear all cached edges, etc. used in the graph. + */ + clear() { + this.edgesQuadtree.clear(); + this.cyclePolygonsQuadtree.clear(); + this.wallEdges.clear(); + super.clear(); + } + + /** + * When adding an edge, make sure to add to quadtree. + * @param {GraphEdge} edge + * @returns {GraphEdge} + * @inherited + */ + addEdge(edge) { + edge = super.addEdge(edge); + const bounds = edge.bounds; + this.edgesQuadtree.insert({ r: bounds, t: edge }); + return edge; + } + + /** + * When deleting an edge, make sure to remove from quadtree. + * @param {GraphEdge} edge + */ + deleteEdge(edge) { + this.edgesQuadtree.remove(edge); + super.deleteEdge(edge); + } + + /** + * Split the wall by edges already in this graph. + * @param {Wall} wall Wall to convert to edge(s) + * @returns {Set} + */ + addWall(wall) { + const wallId = wall.id; + if ( this.wallEdges.has(wallId) ) return this.wallEdges.get(wallId); + + // Construct a new wall edge set. + const edgeSet = new Set(); + this.wallEdges.set(wallId, edgeSet); + + // Locate collision points for any edges that collide with this wall. + // If no collisions, then a single edge can represent this wall. + const collisions = this.findWallCollisions(wall); + if ( !collisions.size ) { + const edge = WallTracerEdge.fromWall(wall); + this.addEdge(edge); + return edgeSet.add(edge); + } + + // Sort the keys so we can progress from A --> B along the wall. + const tArr = [...collisions.keys()]; + tArr.sort((a, b) => a - b); + + // For each collision, ordered along this wall from A --> B + // - construct a new edge for this wall portion + // - update the collision links for the colliding edge and this new edge + if ( !collisions.has(1) ) tArr.push(1); + let priorT = 0; + for ( const t of tArr ) { + // Build edge for portion of wall between priorT and t, skipping when t === 0 + if ( t ) { + const edge = WallTracerEdge.fromWall(wall, priorT, t); + this.addEdge(edge); + edgeSet.add(edge); + } + + // One or more edges may be split at this collision point. + const cObjs = collisions.get(t) ?? []; + for ( const cObj of cObjs ) { + const splitEdges = cObj.edge.splitAtT(cObj.edgeT); + if ( !splitEdges ) continue; // If the split is at the endpoint, will be null. + + // Remove the existing edge and add the new edges. + this.deleteEdge(cObj.edge); + const [edge1, edge2] = splitEdges; + this.addEdge(edge1); + this.addEdge(edge2); + edgeSet.add(edge1); + edgeSet.add(edge2); + } + + // Cycle to next. + priorT = t; + } + + return edgeSet; + } + + /** + * Remove all associated edges with this wall. + * @param {string|Wall} wallId Id of the wall to remove, or the wall itself. + */ + removeWall(wallId) { + if ( wallId instanceof Wall ) wallId = wallId.id; + const edges = this.wallEdges.get(wallId); + if ( !edges || !edges.size ) return; + + // Shallow copy the edges b/c they will be removed from the set with destroy. + const edgesArr = [...edges]; + for ( const edge of edgesArr ) this.deleteEdge(edge); + this.wallEdges.delete(wallId); + } + + /** + * Locate collision points for any edges that collide with this wall. + * @param {Wall} wall Wall to check for collisions + * @returns {Map} Map of locations of the collisions + */ + findWallCollisions(wall) { + const { A, B } = wall; + const collisions = []; + const collisionTest = (o, _rect) => segmentsOverlap(A, B, o.t.A, o.t.B); + const collidingEdges = this.edgesQuadtree.getObjects(wall.bounds, { collisionTest }); + for ( const edge of collidingEdges ) { + const collision = edge.findWallCollision(wall); + if ( collision ) collisions.push(collision); + } + return groupBy(collisions, WallTracer._keyGetter); + } + + // ----- Polygon handling ---- // + + /** + * @type {PIXI.Polygon} GraphCyclePolygon + * @type {object} _wallTracerData Object to store tracer data + * @property {Set} _wallTracerData.wallSet Walls that make up the polygon + * @property {object} _wallTracerData.restrictionTypes CONST.WALL_RESTRICTION_TYPES + * @property {number} _wallTracerData.restrictionTypes.light + * @property {number} _wallTracerData.restrictionTypes.sight + * @property {number} _wallTracerData.restrictionTypes.sound + * @property {number} _wallTracerData.restrictionTypes.move + * @property {object} _wallTracerData.height + * @property {number} _wallTracerData.height.min + * @property {number} _wallTracerData.height.max + * @property {number} _wallTracerData.hasOneWay + */ + + /** + * Convert a single cycle (array of vertices) to a polygon. + * Capture the wall set for edges in the polygon. + * Determine the minimum limit for each restriction type of all the walls. + * @param {WallTracerVertex[]} cycle Array of vertices that make up the cycle, in order. + * @returns {GraphCyclePolygon|null} Polygon, with additional tracer data added. + */ + static cycleToPolygon(cycle) { + const nVertices = cycle.length; + if ( nVertices < 3 ) return null; + const points = Array(nVertices * 2); + const wallSet = new Set(); + const restrictionTypes = { + light: CONST.WALL_SENSE_TYPES.NORMAL, + sight: CONST.WALL_SENSE_TYPES.NORMAL, + sound: CONST.WALL_SENSE_TYPES.NORMAL, + move: CONST.WALL_SENSE_TYPES.NORMAL + }; + const height = { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY + }; + let hasOneWay = false; + + let vertex = cycle[nVertices - 1]; + for ( let i = 0; i < nVertices; i += 1 ) { + const nextVertex = cycle[i]; + const j = i * 2; + points[j] = vertex.x; + points[j + 1] = vertex.y; + + const edge = vertex.edges.find(e => e.otherVertex(vertex).key === nextVertex.key); // eslint-disable-line no-loop-func + const wall = edge.wall; + wallSet.add(wall); + const doc = wall.document; + restrictionTypes.light = Math.min(restrictionTypes.light, doc.light); + restrictionTypes.sight = Math.min(restrictionTypes.sight, doc.sight); + restrictionTypes.sound = Math.min(restrictionTypes.sound, doc.sound); + restrictionTypes.move = Math.min(restrictionTypes.move, doc.move); + + height.min = Math.min(height.min, wall.bottomZ); + height.max = Math.max(height.max, wall.topZ); + + hasOneWay ||= doc.dir; + + vertex = nextVertex; + } + + const poly = new PIXI.Polygon(points); + poly.clean(); + poly._wallTracerData = { wallSet, restrictionTypes, height, hasOneWay }; + return poly; + } + + /** + * Update the quadtree of cycle polygons + */ + updateCyclePolygons() { + // Least, most, none are perform similarly. Most might be a bit faster + // (The sort can sometimes mean none is faster, but not always) + // Weighting by distance hurts performance. + this.cyclePolygonsQuadtree.clear(); + const cycles = this.getAllCycles({ sortType: Graph.VERTEX_SORT.LEAST, weighted: true }); + cycles.forEach(cycle => { + const poly = WallTracer.cycleToPolygon(cycle); + this.cyclePolygonsQuadtree.insert({ r: poly.getBounds(), t: poly }); + }); + } + + /** + * For a given origin point, find all polygons that encompass it. + * Then narrow to the one that has the smallest area. + * @param {Point} origin + * @param {CONST.WALL_RESTRICTION_TYPES} [type] Limit to polygons that are CONST.WALL_SENSE_TYPES.NORMAL + * for the given type + * @returns {PIXI.Polygon|null} + */ + encompassingPolygon(origin, type) { + const encompassingPolygons = this.encompassingPolygons(origin, type); + return this.smallestPolygon(encompassingPolygons); + } + + encompassingPolygons(origin, type) { + origin.z ??= 0; + + // Find those polygons that actually contain the origin. + // Start by using the bounds, then test containment. + const bounds = new PIXI.Rectangle(origin.x - 1, origin.y -1, 2, 2); + const collisionTest = (o, _rect) => o.t.contains(origin.x, origin.y); + let encompassingPolygons = this.cyclePolygonsQuadtree.getObjects(bounds, { collisionTest }); + + if ( type ) encompassingPolygons = encompassingPolygons.filter(poly => { + const wallData = poly._wallTracerData; + + if ( wallData.restrictionTypes[type] !== CONST.WALL_SENSE_TYPES.NORMAL + || wallData.height.max < origin.z + || wallData.height.min > origin.z ) return false; + + if ( !wallData.hasOneWay ) return true; + + // Confirm that each wall is blocking from the origin + for ( const wall of wallData.wallSet ) { + if ( !wallData.dir ) continue; + const side = wall.orientPoint(this.origin); + if ( side === wall.document.dir ) return false; + + } + return true; + }); + + return encompassingPolygons; + } + + smallestPolygon(polygons) { + const res = polygons.reduce((acc, curr) => { + const area = curr.area; + if ( area < acc.area ) { + acc.area = area; + acc.poly = curr; + } + return acc; + }, { area: Number.POSITIVE_INFINITY, poly: null}); + + return res.poly; + } + + /** + * For a given polygon, find all polygons that could be holes within it. + * @param {PIXI.Polygon} encompassingPolygon + * @param {CONST.WALL_RESTRICTION_TYPES} [type] Limit to polygons that are CONST.WALL_SENSE_TYPES.NORMAL + * for the given type + * @returns {encompassingPolygon: {PIXI.Polygon}, holes: {Set}} + */ + _encompassingPolygonsWithHoles(origin, type) { + const encompassingPolygons = this.encompassingPolygons(origin, type); + const encompassingPolygon = this.smallestPolygon(encompassingPolygons); + if ( !encompassingPolygon ) return { encompassingPolygon, holes: [] }; + + // Looking for all polygons that are not encompassing but do intersect with or are contained by + // the encompassing polygon. + const collisionTest = (o, _rect) => { + const poly = o.t; + if ( encompassingPolygons.some(ep => ep.equals(poly)) ) return false; + return poly.overlaps(encompassingPolygon); + }; + + const holes = this.cyclePolygonsQuadtree.getObjects(encompassingPolygon.getBounds(), { collisionTest }); + return { encompassingPolygon, holes }; + } + + /** + * Build the representation of a polygon that encompasses the origin point, + * along with any holes for that encompassing polygon. + * @param {Point} origin + * @param {CONST.WALL_RESTRICTION_TYPES} [type] Limit to polygons that are CONST.WALL_SENSE_TYPES.NORMAL + * for the given type + * @returns {PIXI.Polygon[]} + */ + encompassingPolygonWithHoles(origin, type) { + const { encompassingPolygon, holes } = this._encompassingPolygonsWithHoles(origin, type); + if ( !encompassingPolygon ) return []; + if ( !holes.size ) return [encompassingPolygon]; + + // Union the holes + const paths = ClipperPaths.fromPolygons(holes); + const combined = paths.combine(); + + // Diff the encompassing polygon against the holes + const diffPath = combined.diffPolygon(encompassingPolygon); + return diffPath.toPolygons(); + } + +} + +/** + * Do two segments overlap? + * Overlap means they intersect or they are collinear and overlap + * @param {PIXI.Point} a Endpoint of segment A|B + * @param {PIXI.Point} b Endpoint of segment A|B + * @param {PIXI.Point} c Endpoint of segment C|D + * @param {PIXI.Point} d Endpoint of segment C|D + * @returns {boolean} + */ +function segmentsOverlap(a, b, c, d) { + if ( foundry.utils.lineSegmentIntersects(a, b, c, d) ) return true; + + // If collinear, B is within A|B or D is within A|B + const pts = findOverlappingPoints(a, b, c, d); + return pts.length; +} + +/** + * Find the points of overlap between two segments A|B and C|D. + * @param {PIXI.Point} a Endpoint of segment A|B + * @param {PIXI.Point} b Endpoint of segment A|B + * @param {PIXI.Point} c Endpoint of segment C|D + * @param {PIXI.Point} d Endpoint of segment C|D + * @returns {PIXI.Point[]} Array with 0, 1, or 2 points. + * The points returned will be a, b, c, and/or d, whichever are contained by the others. + * No points are returned if A|B and C|D are not collinear, or if they do not overlap. + * A single point is returned if a single endpoint is shared. + */ +function findOverlappingPoints(a, b, c, d) { + if ( !foundry.utils.orient2dFast(a, b, c).almostEqual(0) + || !foundry.utils.orient2dFast(a, b, d).almostEqual(0) ) return []; + + // B is within A|B or D is within A|B + const abx = Math.minMax(a.x, b.x); + const aby = Math.minMax(a.y, b.y); + const cdx = Math.minMax(c.x, d.x); + const cdy = Math.minMax(c.y, d.y); + + const p0 = new PIXI.Point( + Math.max(abx.min, cdx.min), + Math.max(aby.min, cdy.min) + ); + + const p1 = new PIXI.Point( + Math.min(abx.max, cdx.max), + Math.min(aby.max, cdy.max) + ); + + const xEqual = p0.x.almostEqual(p1.x); + const yEqual = p1.y.almostEqual(p1.y); + if ( xEqual && yEqual ) return [p0]; + if ( xEqual ^ yEqual + || (p0.x < p1.x && p0.y < p1.y)) return [p0, p1]; + + return []; +} + +// Must declare this variable after defining WallTracer. +export const SCENE_GRAPH = new WallTracer(); diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index 733b717..c92bca4 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -11,13 +11,14 @@ import { BorderTriangle } from "./BorderTriangle.js"; import { boundsForPoint } from "../util.js"; import { Draw } from "../geometry/Draw.js"; import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./algorithms.js"; - +import { SCENE_GRAPH } from "./WallTracer.js"; /* Testing Draw = CONFIG.GeometryLib.Draw; api = game.modules.get("elevationruler").api Pathfinder = api.pathfinding.Pathfinder +SCENE_GRAPH = api.pathfinding.SCENE_GRAPH PriorityQueueArray = api.pathfinding.PriorityQueueArray; PriorityQueue = api.pathfinding.PriorityQueue; @@ -38,7 +39,7 @@ pq.data // Test pathfinding Pathfinder.initialize() -Pathfinder.borderTriangles.forEach(tri => tri.drawEdges()); +Pathfinder.drawTriangles(); endPoint = _token.center @@ -195,111 +196,127 @@ export class Pathfinder { /** @type {CanvasQuadTree} */ static quadtree = new CanvasQuadtree(); - /** @type {Set} */ - static endpointKeys = new Set(); - /** @type {Delaunator} */ static delaunay; - /** @type {Map>} */ - static wallKeys = new Map(); - /** @type {BorderTriangle[]} */ static borderTriangles = []; /** @type {Set} */ static triangleEdges = new Set(); + /** @type {object} */ + static #dirty = { + delauney: true, + triangles: true + } + + static get dirty() { return this.#dirty.delauney || this.#dirty.triangles; } + + static set dirty(value) { + this.#dirty.delauney ||= value; + this.#dirty.triangles ||= value; + } + /** * Initialize properties used for pathfinding related to the scene walls. */ static initialize() { this.clear(); - - performance.mark("Pathfinder|Initialize Walls"); - this.initializeWalls(); - - performance.mark("Pathfinder|Initialize Delauney"); this.initializeDelauney(); - - performance.mark("Pathfinder|Initialize Triangles"); this.initializeTriangles(); - - performance.mark("Pathfinder|Finished Initialization"); } static clear() { this.borderTriangles.length = 0; this.triangleEdges.clear(); - this.wallKeys.clear(); this.quadtree.clear(); - } - - /** - * Build a map of wall keys to walls. - * Each key points to a set of walls whose endpoint matches the key. - */ - static initializeWalls() { - const wallKeys = this.wallKeys; - for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { - const aKey = wall.vertices.a.key; - const bKey = wall.vertices.b.key; - if ( wallKeys.has(aKey) ) wallKeys.get(aKey).add(wall); - else wallKeys.set(aKey, new Set([wall])); - - if ( wallKeys.has(bKey) ) wallKeys.get(bKey).add(wall); - else wallKeys.set(bKey, new Set([wall])); - } + this.#dirty.delauney ||= true; + this.#dirty.triangles ||= true; } /** * Build a set of Delaunay triangles from the walls in the scene. - * TODO: Use wall segments instead of walls to handle overlapping walls. */ static initializeDelauney() { - const endpointKeys = this.endpointKeys; - for ( const wall of [...canvas.walls.placeables, ...canvas.walls.outerBounds] ) { - endpointKeys.add(wall.vertices.a.key); - endpointKeys.add(wall.vertices.b.key); - } - - const coords = new Uint32Array(endpointKeys.size * 2); + this.clear(); + const coords = new Uint32Array(SCENE_GRAPH.vertices.size * 2); let i = 0; - for ( const key of endpointKeys ) { - const pt = PIXI.Point.invertKey(key); - coords[i] = pt.x; - coords[i + 1] = pt.y; + const coordKeys = new Map(); + for ( const vertex of SCENE_GRAPH.vertices.values() ) { + coords[i] = vertex.x; + coords[i + 1] = vertex.y; + coordKeys.set(vertex.key, i); i += 2; } - this.delaunay = new Delaunator(coords); + this.delaunay.coordKeys = coordKeys + this.#dirty.delauney &&= false; } + + +// static _constrainDelauney() { +// // https://github.com/kninnug/Constrainautor +// +// // Build the points to be constrained. +// // Array of array of indices into the Delaunator points array. +// // Treat each edge in the SCENE_GRAPH as constraining. +// delaunay = Pathfinder.delaunay; +// coordKeys = delaunay.coordKeys; +// edges = new Array(SCENE_GRAPH.edges.size); +// let i = 0; +// for ( const edge of SCENE_GRAPH.edges.values() ) { +// const iA = delaunay.coordKeys.get(edge.A.key); +// const iB = delaunay.coordKeys.get(edge.B.key); +// edges[i] = [iA, iB]; +// i += 1; +// } +// con = new Constrainautor(delaunay); +// +// sceneEdges = [...SCENE_GRAPH.edges.values()] +// +// +// for ( let i = 0; i < SCENE_GRAPH.edges.size; i += 1) { +// edge = edges[i] +// try { +// con.constrainOne(edge[0], edge[1]) +// } catch(err) { +// console.debug(`Error constraining edge ${i}.`) +// } +// } +// +// i = 0 +// +// edge = edges[i] +// Draw.segment(sceneEdges[i], { color: Draw.COLORS.red }) +// con.constrainOne(edge[0], edge[1]) +// +// +// } + /** * Build the triangle objects used to represent the Delauney objects for pathfinding. * Must first run initializeDelauney and initializeWalls. */ static initializeTriangles() { - const { borderTriangles, triangleEdges, delaunay, wallKeys, quadtree } = this; + const { borderTriangles, triangleEdges, delaunay, quadtree } = this; + if ( this.#dirty.delauney ) this.initializeDelauney(); + + triangleEdges.clear(); + quadtree.clear(); // Build array of border triangles - const nTriangles = delaunay.triangles.length / 3; - borderTriangles.length = nTriangles; - for (let i = 0, ii = 0; i < delaunay.triangles.length; i += 3, ii += 1) { - const j = delaunay.triangles[i] * 2; - const k = delaunay.triangles[i + 1] * 2; - const l = delaunay.triangles[i + 2] * 2; - - const a = { x: delaunay.coords[j], y: delaunay.coords[j + 1] }; - const b = { x: delaunay.coords[k], y: delaunay.coords[k + 1] }; - const c = { x: delaunay.coords[l], y: delaunay.coords[l + 1] }; - const tri = BorderTriangle.fromPoints(a, b, c); - borderTriangles[ii] = tri; - tri.id = ii; // Mostly for debugging at this point. - - // Add to the quadtree - quadtree.insert({ r: tri.bounds, t: tri }); - } + borderTriangles.length = delaunay.triangles.length / 3; + forEachTriangle(delaunay, (i, pts) => { + const tri = BorderTriangle.fromPoints(pts[0], pts[1], pts[2]); + tri.id = i; + borderTriangles[i] = tri; + + // Add to the quadtree + quadtree.insert({ r: tri.bounds, t: tri }); + }); + // Set the half-edges const EDGE_NAMES = BorderTriangle.EDGE_NAMES; @@ -322,14 +339,21 @@ export class Pathfinder { } // Set the wall, if any, for each triangle edge - const nullSet = new Set(); + const aWalls = new Set(); + const bWalls = new Set(); for ( const edge of triangleEdges.values() ) { const aKey = edge.a.key; const bKey = edge.b.key; - const aWalls = wallKeys.get(aKey) || nullSet; - const bWalls = wallKeys.get(bKey) || nullSet; + const aVertex = SCENE_GRAPH.vertices.get(aKey); + const bVertex = SCENE_GRAPH.vertices.get(bKey); + if ( aVertex ) aVertex._edgeSet.forEach(e => aWalls.add(e.wall)); + if ( bVertex ) bVertex._edgeSet.forEach(e => bWalls.add(e.wall)); edge.wall = aWalls.intersection(bWalls).first(); // May be undefined. + aWalls.clear(); + bWalls.clear(); } + + this.#dirty.triangles &&= false; } /** @type {Token} token */ @@ -385,6 +409,9 @@ export class Pathfinder { alg.heuristic = this._heuristic; } + // Make sure pathfinder triangles are up-to-date. + if ( this.constructor.dirty ) this.constructor.initializeTriangles(); + // Run the algorithm. const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); return this.algorithm[type].run(start, end); @@ -483,4 +510,105 @@ export class Pathfinder { prior = curr; } } + + /** + * Debugging. Draw the triangle graph. + */ + static drawTriangles() { + if ( this.dirty ) this.initializeTriangles(); + this.borderTriangles.forEach(tri => tri.drawEdges()); + } +} + + +// NOTE: Helper functions to handle Delaunay coordinates. +// See https://mapbox.github.io/delaunator/ + +/** + * Get the three vertex coordinates (edges) for a delaunay triangle. + * @param {number} t Triangle index + * @returns {number[3]} + */ +function edgesOfTriangle(t) { return [3 * t, 3 * t + 1, 3 * t + 2]; } + +/** + * Get the points of a delaunay triangle. + * @param {Delaunator} delaunay The triangulation to use + * @param {number} t Triangle index + * @returns {PIXI.Point[3]} + */ +function pointsOfTriangle(delaunay, t) { + const points = delaunay.coords; + return edgesOfTriangle(t) + .map(e => delaunay.triangles[e]) + .map(p => new PIXI.Point(points[2 * p], points[(2 * p) + 1])); +} + +/** + * Apply a function to each triangle in the triangulation. + * @param {Delaunator} delaunay The triangulation to use + * @param {function} callback Function to apply, which is given the triangle id and array of 3 points + */ +function forEachTriangle(delaunay, callback) { + const nTriangles = delaunay.triangles.length / 3; + for ( let t = 0; t < nTriangles; t += 1 ) callback(t, pointsOfTriangle(delaunay, t)); +} + +/** + * Get index of triangle for a given edge. + * @param {number} e Edge index + * @returns {number} Triangle index + */ +function triangleOfEdge(e) { return Math.floor(e / 3); } + +/** + * For a given half-edge index, go to the next half-edge for the triangle. + * @param {number} e Edge index + * @returns {number} Edge index. + */ +function nextHalfedge(e) { return (e % 3 === 2) ? e - 2 : e + 1; } + +/** + * For a given half-edge index, go to the previous half-edge for the triangle. + * @param {number} e Edge index + * @returns {number} Edge index. + */ +function prevHalfedge(e) { return (e % 3 === 0) ? e + 2 : e - 1; } + +/** + * Apply a function for each triangle edge in the triangulation. + * @param {Delaunator} delaunay The triangulation to use + * @param {function} callback Function to call, passing the edge index and points of the edge. + */ +function forEachTriangleEdge(delaunay, callback) { + const points = delaunay.coords; + for (let e = 0; e < delaunay.triangles.length; e++) { + if (e > delaunay.halfedges[e]) { + const ip = delaunay.triangles[e]; + const p = new PIXI.Point(points[2 * ip], points[(2 * ip) + 1]) + + const iq = delaunay.triangles[nextHalfedge(e)]; + const q = new PIXI.Point(points[2 * iq], points[(2 * iq) + 1]) + callback(e, p, q); + } + } +} + +/** + * Identify triangle indices corresponding to triangles adjacent to the one provided. + * @param {Delaunator} delaunay The triangulation to use + * @param {number} t Triangle index + * @returns {number[]} + */ +function trianglesAdjacentToTriangle(delaunay, t) { + const adjacentTriangles = []; + for ( const e of edgesOfTriangle(t) ) { + const opposite = delaunay.halfedges[e]; + if (opposite >= 0) adjacentTriangles.push(triangleOfEdge(opposite)); + } + return adjacentTriangles; } + + + + diff --git a/scripts/util.js b/scripts/util.js index 26efeb8..9f67ca9 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -144,3 +144,27 @@ export function tokenIsSnapped(token) { */ export function boundsForPoint(pt) { return new PIXI.Rectangle(pt.x - 1, pt.y - 1, 3, 3); } +/** + * From https://stackoverflow.com/questions/14446511/most-efficient-method-to-groupby-on-an-array-of-objects + * Takes an Array, and a grouping function, + * and returns a Map of the array grouped by the grouping function. + * + * @param {Array} list An array of type V. + * @param {Function} keyGetter A Function that takes the the Array type V as an input, and returns a value of type K. + * K is generally intended to be a property key of V. + * keyGetter: (input: V) => K): Map> + * + * @returns Map of the array grouped by the grouping function. map = new Map>() + */ +export function groupBy(list, keyGetter) { + const map = new Map(); + list.forEach(item => { + const key = keyGetter(item); + const collection = map.get(key); + + if (!collection) map.set(key, [item]); + else collection.push(item); + }); + return map; +} + From 36924fd88d55858bdbce8c35768200a1c633c93d Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 16 Jan 2024 09:33:33 -0800 Subject: [PATCH 12/25] Use cdt2d to construct constrained delaunay graph --- scripts/delaunator/LICENSE - cdt2d.txt | 22 ++ scripts/delaunator/cdt2d_access_functions.js | 80 ++++ .../delaunator/delaunay_access_functions.js | 87 +++++ scripts/pathfinding/BorderTriangle.js | 67 +++- scripts/pathfinding/pathfinding.js | 343 ++---------------- 5 files changed, 282 insertions(+), 317 deletions(-) create mode 100644 scripts/delaunator/LICENSE - cdt2d.txt create mode 100644 scripts/delaunator/cdt2d_access_functions.js create mode 100644 scripts/delaunator/delaunay_access_functions.js diff --git a/scripts/delaunator/LICENSE - cdt2d.txt b/scripts/delaunator/LICENSE - cdt2d.txt new file mode 100644 index 0000000..1815ddd --- /dev/null +++ b/scripts/delaunator/LICENSE - cdt2d.txt @@ -0,0 +1,22 @@ + +The MIT License (MIT) + +Copyright (c) 2015 Mikola Lysenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/scripts/delaunator/cdt2d_access_functions.js b/scripts/delaunator/cdt2d_access_functions.js new file mode 100644 index 0000000..8381f6f --- /dev/null +++ b/scripts/delaunator/cdt2d_access_functions.js @@ -0,0 +1,80 @@ +/* globals +cdt2d, +PIXI +*/ +/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ + +import { BorderTriangle } from "../pathfinding/BorderTriangle.js"; + +// Functions to assist with cdt2d. + +/** + * Build a cdt2d constrained delaunay graph from the scene graph. + * @param {WallTracer} sceneGraph + */ +export function cdt2dConstrainedGraph(sceneGraph) { + const points = new Array(sceneGraph.vertices.size); + const pointIndexMap = new Map(); + + let i = 0; + for ( const [key, vertex] of sceneGraph.vertices ) { + pointIndexMap.set(key, i); + points[i] = [vertex.x, vertex.y]; // Could use Uint32Array ? + i += 1; + } + + /* Testing + points.forEach(ptArr => Draw.point({ x: ptArr[0], y: ptArr[1] })) + */ + + // Every edge must be constrained. + const edges = new Array(sceneGraph.edges.size); + i = 0; + for ( const edge of sceneGraph.edges.values() ) { + const iA = pointIndexMap.get(edge.A.key); + const iB = pointIndexMap.get(edge.B.key); + edges[i] = [iA, iB]; + i += 1; + } + + /* Testing + edges.forEach(edgeArr => { + const arrA = points[edgeArr[0]]; + const arrB = points[edgeArr[1]]; + const A = new PIXI.Point(...arrA); + const B = new PIXI.Point(...arrB); + Draw.segment({ A, B }); + }) + */ + + const triCoords = cdt2d(points, edges); + triCoords.points = points; + triCoords.edges = edges; + return triCoords; +} + +/** + * Build border triangles from the cd2td graph + * @param {cdt2d} triCoords Nested array of triangle coordinate indices. + * @returns {BorderTriangle[]} + */ +export function cdt2dToBorderTriangles(triCoords, borderTriangles) { + borderTriangles ??= []; + borderTriangles.length = triCoords.length; + const points = triCoords.points; + for ( let i = 0; i < triCoords.length; i += 1 ) { + const triCoord = triCoords[i]; + const a = new PIXI.Point(...points[triCoord[0]]); + const b = new PIXI.Point(...points[triCoord[1]]); + const c = new PIXI.Point(...points[triCoord[2]]); + const tri = BorderTriangle.fromPoints(a, b, c); + tri.id = i; + borderTriangles[i] = tri; + } + + /* Testing + borderTriangles.forEach(tri => tri.drawEdges()) + */ + + return borderTriangles; +} diff --git a/scripts/delaunator/delaunay_access_functions.js b/scripts/delaunator/delaunay_access_functions.js new file mode 100644 index 0000000..99c2ee2 --- /dev/null +++ b/scripts/delaunator/delaunay_access_functions.js @@ -0,0 +1,87 @@ +// NOTE: Helper functions to handle Delaunay coordinates. +// See https://mapbox.github.io/delaunator/ + +/** + * Get the three vertex coordinates (edges) for a delaunay triangle. + * @param {number} t Triangle index + * @returns {number[3]} + */ +function edgesOfTriangle(t) { return [3 * t, 3 * t + 1, 3 * t + 2]; } + +/** + * Get the points of a delaunay triangle. + * @param {Delaunator} delaunay The triangulation to use + * @param {number} t Triangle index + * @returns {PIXI.Point[3]} + */ +function pointsOfTriangle(delaunay, t) { + const points = delaunay.coords; + return edgesOfTriangle(t) + .map(e => delaunay.triangles[e]) + .map(p => new PIXI.Point(points[2 * p], points[(2 * p) + 1])); +} + +/** + * Apply a function to each triangle in the triangulation. + * @param {Delaunator} delaunay The triangulation to use + * @param {function} callback Function to apply, which is given the triangle id and array of 3 points + */ +function forEachTriangle(delaunay, callback) { + const nTriangles = delaunay.triangles.length / 3; + for ( let t = 0; t < nTriangles; t += 1 ) callback(t, pointsOfTriangle(delaunay, t)); +} + +/** + * Get index of triangle for a given edge. + * @param {number} e Edge index + * @returns {number} Triangle index + */ +function triangleOfEdge(e) { return Math.floor(e / 3); } + +/** + * For a given half-edge index, go to the next half-edge for the triangle. + * @param {number} e Edge index + * @returns {number} Edge index. + */ +function nextHalfedge(e) { return (e % 3 === 2) ? e - 2 : e + 1; } + +/** + * For a given half-edge index, go to the previous half-edge for the triangle. + * @param {number} e Edge index + * @returns {number} Edge index. + */ +function prevHalfedge(e) { return (e % 3 === 0) ? e + 2 : e - 1; } + +/** + * Apply a function for each triangle edge in the triangulation. + * @param {Delaunator} delaunay The triangulation to use + * @param {function} callback Function to call, passing the edge index and points of the edge. + */ +function forEachTriangleEdge(delaunay, callback) { + const points = delaunay.coords; + for (let e = 0; e < delaunay.triangles.length; e++) { + if (e > delaunay.halfedges[e]) { + const ip = delaunay.triangles[e]; + const p = new PIXI.Point(points[2 * ip], points[(2 * ip) + 1]) + + const iq = delaunay.triangles[nextHalfedge(e)]; + const q = new PIXI.Point(points[2 * iq], points[(2 * iq) + 1]) + callback(e, p, q); + } + } +} + +/** + * Identify triangle indices corresponding to triangles adjacent to the one provided. + * @param {Delaunator} delaunay The triangulation to use + * @param {number} t Triangle index + * @returns {number[]} + */ +function trianglesAdjacentToTriangle(delaunay, t) { + const adjacentTriangles = []; + for ( const e of edgesOfTriangle(t) ) { + const opposite = delaunay.halfedges[e]; + if (opposite >= 0) adjacentTriangles.push(triangleOfEdge(opposite)); + } + return adjacentTriangles; +} \ No newline at end of file diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index da4c983..7f03a3c 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -324,7 +324,7 @@ export class BorderTriangle { * Used to link triangles by an edge. * @param {string} edgeName "AB"|"BC"|"CA" */ - setEdge(edgeName, newEdge) { + _setEdge(edgeName, newEdge) { const oldEdge = this.edges[edgeName]; if ( !oldEdge ) { console.error(`No edge with name ${edgeName} found.`); @@ -346,6 +346,27 @@ export class BorderTriangle { newEdge.linkTriangle(this); } + /** + * Locate an edge name given edge keys. + * @param {number} key0 + * @param {number} key1 + * @returns {string|null} Edge name or null if none. + */ + _edgeNameForKeys(key0, key1) { + if ( !(this.endpointKeys.has(key0) && this.endpointKeys.has(key1)) ) return undefined; + + const keysAB = this.edges.AB.endpointKeys; + if ( keysAB.has(key0) && keysAB.has(key1) ) return "AB"; + + const keysBC = this.edges.BC.endpointKeys; + if ( keysBC.has(key0) && keysBC.has(key1) ) return "BC"; + + const keysCA = this.edges.CA.endpointKeys; + if ( keysCA.has(key0) && keysCA.has(key1) ) return "CA"; + + return undefined; // Should not be reached. + } + /** * For debugging. Draw edges on the canvas. */ @@ -364,4 +385,48 @@ export class BorderTriangle { } } } + + /** + * Link edges of an array of BorderTriangles. + * Each linked edge is shared with a second triangle. + * Assumed that no edge endpoint is in the middle of another edge. + * @param {BorderTriangle[]} borderTriangles Triangle to link. Modified in place. + * @returns {BorderTriangle[]} The same array, for convenience. + */ + static linkTriangleEdges(borderTriangles) { + // Map: edge key --> triangles set. + const pointMap = new Map(); + for ( const borderTriangle of borderTriangles ) { + const { a, b, c } = borderTriangle.vertices; + const aSet = pointMap.get(a.key) || new Set(); + const bSet = pointMap.get(b.key) || new Set(); + const cSet = pointMap.get(c.key) || new Set(); + + aSet.add(borderTriangle); + bSet.add(borderTriangle); + cSet.add(borderTriangle); + if ( !pointMap.has(a.key) ) pointMap.set(a.key, aSet); + if ( !pointMap.has(b.key) ) pointMap.set(b.key, aSet); + if ( !pointMap.has(c.key) ) pointMap.set(c.key, aSet); + } + + // For each triangle, if the edge is not yet linked, link if it has a shared edge. + // Use the point map to determine if a triangle has a shared edge. + for ( const borderTriangle of borderTriangles ) { + for ( const edge of Object.values(borderTriangle.edges) ) { + if ( edge.cwTriangle && edge.ccwTriangle ) continue; // Already linked. + const aSet = pointMap.get(edge.a.key); + const bSet = pointMap.get(edge.b.key); + const otherTriangle = aSet.intersection(bSet).first(); // Should always have 0 or 1 elements. + if ( !otherTriangle ) continue; // No bordering triangle. + + // Determine where this edge is on the other triangle and replace. + const otherEdgeName = otherTriangle._edgeNameForKeys(edge.a.key, edge.b.key); + if ( !otherEdgeName ) continue; + otherTriangle._setEdge(otherEdgeName, edge); + } + } + + return borderTriangles; + } } diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index c92bca4..e2fc8d7 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -2,7 +2,6 @@ canvas, CanvasQuadtree, CONFIG, -Delaunator, PIXI */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -12,6 +11,7 @@ import { boundsForPoint } from "../util.js"; import { Draw } from "../geometry/Draw.js"; import { BreadthFirstPathSearch, UniformCostPathSearch, GreedyPathSearch, AStarPathSearch } from "./algorithms.js"; import { SCENE_GRAPH } from "./WallTracer.js"; +import { cdt2dConstrainedGraph, cdt2dToBorderTriangles } from "../delaunator/cdt2d_access_functions.js"; /* Testing @@ -71,109 +71,6 @@ pf.drawPath(pathPoints, { color: Draw.COLORS.white }) */ -// Pathfinder.initialize(); -// -// Draw = CONFIG.GeometryLib.Draw; -// -// measureInitWalls = performance.measure("measureInitWalls", "Pathfinder|Initialize Walls", "Pathfinder|Initialize Delauney") -// measureInitDelaunay = performance.measure("measureInitDelaunay", "Pathfinder|Initialize Delauney", "Pathfinder|Initialize Triangles") -// measureInitTriangles = performance.measure("measureInitTriangles", "Pathfinder|Initialize Triangles", "Pathfinder|Finished Initialization") -// console.table([measureInitWalls, measureInitDelaunay,measureInitTriangles ]) -// -// -// -// // Triangulate -// /* -// Take the 4 corners plus coordinates of each wall endpoint. -// (TODO: Use wall edges to capture overlapping walls) -// -// Triangulate. -// -// Can traverse using the half-edge structure. -// -// Start in a triangle. For now, traverse between triangles at midpoints. -// Triangle coords correspond to a wall. Each triangle edge may or may not block. -// Can either look up the wall or just run collision between the two triangle midpoints (probably the latter). -// This handles doors, one-way walls, etc., and limits when the triangulation must be re-done. -// -// Each triangle can represent terrain. Triangle terrain is then used to affect the distance value. -// Goal heuristic based on distance (modified by terrain?). -// Alternatively, apply terrain only when moving. But should still triangulate terrain so can move around it. -// -// Ultimately traverse by choosing midpoint or points 1 grid square from each endpoint on the edge. -// -// */ -// -// -// // Draw each endpoint -// for ( const key of endpointKeys ) { -// const pt = PIXI.Point.invertKey(key); -// Draw.point(pt, { color: Draw.COLORS.blue }) -// } -// -// // Draw each triangle -// triangles = []; -// for (let i = 0; i < delaunay.triangles.length; i += 3) { -// const j = delaunay.triangles[i] * 2; -// const k = delaunay.triangles[i + 1] * 2; -// const l = delaunay.triangles[i + 2] * 2; -// triangles.push(new PIXI.Polygon( -// delaunay.coords[j], delaunay.coords[j + 1], -// delaunay.coords[k], delaunay.coords[k + 1], -// delaunay.coords[l], delaunay.coords[l + 1] -// )); -// } -// -// for ( const tri of triangles ) Draw.shape(tri); -// -// -// -// -// borderTriangles.forEach(tri => tri.drawEdges()); -// borderTriangles.forEach(tri => tri.drawLinks()) -// -// -// // Use Quadtree to locate starting triangle for a point. -// -// // quadtree.clear() -// // quadtree.update({r: bounds, t: this}) -// // quadtree.remove(this) -// // quadtree.update(this) -// -// -// quadtreeBT = new CanvasQuadtree() -// borderTriangles.forEach(tri => quadtreeBT.insert({r: tri.bounds, t: tri})) -// -// -// token = _token -// startPoint = _token.center; -// endPoint = _token.center -// -// // Find the strat and end triangles -// collisionTest = (o, _rect) => o.t.contains(startPoint); -// startTri = quadtreeBT.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); -// -// collisionTest = (o, _rect) => o.t.contains(endPoint); -// endTri = quadtreeBT.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); -// -// startTri.drawEdges(); -// endTri.drawEdges(); -// -// // Locate valid destinations -// destinations = startTri.getValidDestinations(startPoint, null, token.w * 0.5); -// destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -// destinations.sort((a, b) => a.distance - b.distance); -// -// -// // Pick direction, repeat. -// chosenDestination = destinations[0]; -// Draw.segment({ A: startPoint, B: chosenDestination.entryPoint }, { color: Draw.COLORS.yellow }) -// nextTri = chosenDestination.triangle; -// destinations = nextTri.getValidDestinations(startPoint, null, token.w * 0.5); -// destinations.forEach(d => Draw.point(d.entryPoint, { color: Draw.COLORS.yellow })) -// destinations.sort((a, b) => a.distance - b.distance); -// - /* For the triangles, need: √ Contains test. Could use PIXI.Polygon, but a custom contains will be faster. --> Used to find where a start/end point is located. @@ -196,9 +93,6 @@ export class Pathfinder { /** @type {CanvasQuadTree} */ static quadtree = new CanvasQuadtree(); - /** @type {Delaunator} */ - static delaunay; - /** @type {BorderTriangle[]} */ static borderTriangles = []; @@ -206,138 +100,42 @@ export class Pathfinder { static triangleEdges = new Set(); /** @type {object} */ - static #dirty = { - delauney: true, - triangles: true - } + static #dirty = true; - static get dirty() { return this.#dirty.delauney || this.#dirty.triangles; } + static get dirty() { return this.#dirty; } - static set dirty(value) { - this.#dirty.delauney ||= value; - this.#dirty.triangles ||= value; - } + static set dirty(value) { this.#dirty ||= value; } /** * Initialize properties used for pathfinding related to the scene walls. */ static initialize() { this.clear(); - this.initializeDelauney(); - this.initializeTriangles(); + const { borderTriangles, quadtree } = this; + const triCoords = cdt2dConstrainedGraph(SCENE_GRAPH); + cdt2dToBorderTriangles(triCoords, borderTriangles); + BorderTriangle.linkTriangleEdges(borderTriangles); + borderTriangles.forEach(tri => quadtree.insert({ r: tri.bounds, t: tri })); + this._linkWallsToEdges(); + this.#dirty &&= false; } static clear() { this.borderTriangles.length = 0; this.triangleEdges.clear(); this.quadtree.clear(); - this.#dirty.delauney ||= true; - this.#dirty.triangles ||= true; - } - - /** - * Build a set of Delaunay triangles from the walls in the scene. - */ - static initializeDelauney() { - this.clear(); - const coords = new Uint32Array(SCENE_GRAPH.vertices.size * 2); - let i = 0; - const coordKeys = new Map(); - for ( const vertex of SCENE_GRAPH.vertices.values() ) { - coords[i] = vertex.x; - coords[i + 1] = vertex.y; - coordKeys.set(vertex.key, i); - i += 2; - } - this.delaunay = new Delaunator(coords); - this.delaunay.coordKeys = coordKeys - this.#dirty.delauney &&= false; + this.#dirty ||= true; } - - -// static _constrainDelauney() { -// // https://github.com/kninnug/Constrainautor -// -// // Build the points to be constrained. -// // Array of array of indices into the Delaunator points array. -// // Treat each edge in the SCENE_GRAPH as constraining. -// delaunay = Pathfinder.delaunay; -// coordKeys = delaunay.coordKeys; -// edges = new Array(SCENE_GRAPH.edges.size); -// let i = 0; -// for ( const edge of SCENE_GRAPH.edges.values() ) { -// const iA = delaunay.coordKeys.get(edge.A.key); -// const iB = delaunay.coordKeys.get(edge.B.key); -// edges[i] = [iA, iB]; -// i += 1; -// } -// con = new Constrainautor(delaunay); -// -// sceneEdges = [...SCENE_GRAPH.edges.values()] -// -// -// for ( let i = 0; i < SCENE_GRAPH.edges.size; i += 1) { -// edge = edges[i] -// try { -// con.constrainOne(edge[0], edge[1]) -// } catch(err) { -// console.debug(`Error constraining edge ${i}.`) -// } -// } -// -// i = 0 -// -// edge = edges[i] -// Draw.segment(sceneEdges[i], { color: Draw.COLORS.red }) -// con.constrainOne(edge[0], edge[1]) -// -// -// } - - /** - * Build the triangle objects used to represent the Delauney objects for pathfinding. - * Must first run initializeDelauney and initializeWalls. - */ - static initializeTriangles() { - const { borderTriangles, triangleEdges, delaunay, quadtree } = this; - if ( this.#dirty.delauney ) this.initializeDelauney(); - + static _linkWallsToEdges() { + const triangleEdges = this.triangleEdges; triangleEdges.clear(); - quadtree.clear(); - - // Build array of border triangles - borderTriangles.length = delaunay.triangles.length / 3; - forEachTriangle(delaunay, (i, pts) => { - const tri = BorderTriangle.fromPoints(pts[0], pts[1], pts[2]); - tri.id = i; - borderTriangles[i] = tri; - - // Add to the quadtree - quadtree.insert({ r: tri.bounds, t: tri }); + this.borderTriangles.forEach(tri => { + triangleEdges.add(tri.edges.AB); + triangleEdges.add(tri.edges.BC); + triangleEdges.add(tri.edges.CA); }); - - // Set the half-edges - const EDGE_NAMES = BorderTriangle.EDGE_NAMES; - for ( let i = 0; i < delaunay.halfedges.length; i += 1 ) { - const halfEdgeIndex = delaunay.halfedges[i]; - if ( !~halfEdgeIndex ) continue; - const triFrom = borderTriangles[Math.floor(i / 3)]; - const triTo = borderTriangles[Math.floor(halfEdgeIndex / 3)]; - - // Always a, b, c in order (b/c ccw) - const fromEdge = EDGE_NAMES[i % 3]; - const toEdge = EDGE_NAMES[halfEdgeIndex % 3]; - - // Need to pick one; keep the fromEdge - const edgeToKeep = triFrom.edges[fromEdge]; - triTo.setEdge(toEdge, edgeToKeep); - - // Track edge set to link walls. - triangleEdges.add(edgeToKeep); - } - // Set the wall, if any, for each triangle edge const aWalls = new Set(); const bWalls = new Set(); @@ -352,8 +150,6 @@ export class Pathfinder { aWalls.clear(); bWalls.clear(); } - - this.#dirty.triangles &&= false; } /** @type {Token} token */ @@ -410,7 +206,7 @@ export class Pathfinder { } // Make sure pathfinder triangles are up-to-date. - if ( this.constructor.dirty ) this.constructor.initializeTriangles(); + if ( this.constructor.dirty ) this.constructor.initialize(); // Run the algorithm. const { start, end } = this._initializeStartEndNodes(startPoint, endPoint); @@ -482,7 +278,8 @@ export class Pathfinder { newNode.priorTriangle = pathNode.priorTriangle; return [newNode]; } - return pathNode.entryTriangle.getValidDestinationsWithCost(pathNode.priorTriangle, this.spacer, pathNode.entryPoint); + return pathNode.entryTriangle.getValidDestinationsWithCost( + pathNode.priorTriangle, this.spacer, pathNode.entryPoint); } /** @@ -515,100 +312,14 @@ export class Pathfinder { * Debugging. Draw the triangle graph. */ static drawTriangles() { - if ( this.dirty ) this.initializeTriangles(); + if ( this.dirty ) this.initialize(); this.borderTriangles.forEach(tri => tri.drawEdges()); } -} - - -// NOTE: Helper functions to handle Delaunay coordinates. -// See https://mapbox.github.io/delaunator/ - -/** - * Get the three vertex coordinates (edges) for a delaunay triangle. - * @param {number} t Triangle index - * @returns {number[3]} - */ -function edgesOfTriangle(t) { return [3 * t, 3 * t + 1, 3 * t + 2]; } -/** - * Get the points of a delaunay triangle. - * @param {Delaunator} delaunay The triangulation to use - * @param {number} t Triangle index - * @returns {PIXI.Point[3]} - */ -function pointsOfTriangle(delaunay, t) { - const points = delaunay.coords; - return edgesOfTriangle(t) - .map(e => delaunay.triangles[e]) - .map(p => new PIXI.Point(points[2 * p], points[(2 * p) + 1])); -} - -/** - * Apply a function to each triangle in the triangulation. - * @param {Delaunator} delaunay The triangulation to use - * @param {function} callback Function to apply, which is given the triangle id and array of 3 points - */ -function forEachTriangle(delaunay, callback) { - const nTriangles = delaunay.triangles.length / 3; - for ( let t = 0; t < nTriangles; t += 1 ) callback(t, pointsOfTriangle(delaunay, t)); -} - -/** - * Get index of triangle for a given edge. - * @param {number} e Edge index - * @returns {number} Triangle index - */ -function triangleOfEdge(e) { return Math.floor(e / 3); } - -/** - * For a given half-edge index, go to the next half-edge for the triangle. - * @param {number} e Edge index - * @returns {number} Edge index. - */ -function nextHalfedge(e) { return (e % 3 === 2) ? e - 2 : e + 1; } - -/** - * For a given half-edge index, go to the previous half-edge for the triangle. - * @param {number} e Edge index - * @returns {number} Edge index. - */ -function prevHalfedge(e) { return (e % 3 === 0) ? e + 2 : e - 1; } - -/** - * Apply a function for each triangle edge in the triangulation. - * @param {Delaunator} delaunay The triangulation to use - * @param {function} callback Function to call, passing the edge index and points of the edge. - */ -function forEachTriangleEdge(delaunay, callback) { - const points = delaunay.coords; - for (let e = 0; e < delaunay.triangles.length; e++) { - if (e > delaunay.halfedges[e]) { - const ip = delaunay.triangles[e]; - const p = new PIXI.Point(points[2 * ip], points[(2 * ip) + 1]) - - const iq = delaunay.triangles[nextHalfedge(e)]; - const q = new PIXI.Point(points[2 * iq], points[(2 * iq) + 1]) - callback(e, p, q); - } - } -} - -/** - * Identify triangle indices corresponding to triangles adjacent to the one provided. - * @param {Delaunator} delaunay The triangulation to use - * @param {number} t Triangle index - * @returns {number[]} - */ -function trianglesAdjacentToTriangle(delaunay, t) { - const adjacentTriangles = []; - for ( const e of edgesOfTriangle(t) ) { - const opposite = delaunay.halfedges[e]; - if (opposite >= 0) adjacentTriangles.push(triangleOfEdge(opposite)); + /** + * Debugging. Draw the edges. + */ + static drawEdges() { + this.triangleEdges.forEach(edge => edge.draw()); } - return adjacentTriangles; } - - - - From 1ecb552b94090e4f17610007a6020cd597c3e19f Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Tue, 16 Jan 2024 11:28:34 -0800 Subject: [PATCH 13/25] Add pathfinding control and setting to track user preference --- languages/en.json | 4 ++- scripts/module.js | 56 +++++++++++++++++++++++++++--------- scripts/settings.js | 24 ++++++++++++---- scripts/terrain_elevation.js | 4 +-- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/languages/en.json b/languages/en.json index d52c4cb..dde20b5 100644 --- a/languages/en.json +++ b/languages/en.json @@ -45,5 +45,7 @@ "elevationruler.settings.token-terrain-multiplier.name": "Token As Terrain Multiplier", "elevationruler.settings.token-terrain-multiplier.hint": "Multiplier to use to calculate movement speed when moving through other tokens. Set to 1 to ignore. Values less than 1 treat token spaces as faster than normal; values greater than 1 penalize movement through token spaces.", - "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation" + "elevationruler.controls.prefer-token-elevation.name": "Prefer Token Elevation", + "elevationruler.controls.pathfinding-control.name": "Use Pathfinding" + } \ No newline at end of file diff --git a/scripts/module.js b/scripts/module.js index d43200f..80a0e66 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -65,40 +65,68 @@ Hooks.once("devModeReady", ({ registerPackageDebugFlag }) => { registerPackageDebugFlag(MODULE_ID); }); + +// Add Token lock button to token controls to use token elevation when using the ruler. const PREFER_TOKEN_CONTROL = { - name: Settings.KEYS.PREFER_TOKEN_ELEVATION, - title: `${MODULE_ID}.controls.${Settings.KEYS.PREFER_TOKEN_ELEVATION}.name`, + name: Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION, + title: `${MODULE_ID}.controls.${Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION}.name`, icon: "fa-solid fa-user-lock", toggle: true }; +// Add pathfinding button to token controls. +const PATHFINDING_CONTROL = { + name: Settings.KEYS.CONTROLS.PATHFINDING, + title: `${MODULE_ID}.controls.${Settings.KEYS.CONTROLS.PATHFINDING}.name`, + icon:"fa-solid fa-route", + toggle: true +}; -// Render the prefer token control if that setting is enabled +// Render the pathfinding control. +// Render the prefer token control if that setting is enabled. Hooks.on("getSceneControlButtons", controls => { - if ( !canvas.scene || !Settings.get(Settings.KEYS.PREFER_TOKEN_ELEVATION) ) return; + if ( !canvas.scene ) return; const tokenTools = controls.find(c => c.name === "token"); - tokenTools.tools.push(PREFER_TOKEN_CONTROL); + tokenTools.tools.push(PATHFINDING_CONTROL); + if ( Settings.get(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION) ) tokenTools.tools.push(PREFER_TOKEN_CONTROL); }); Hooks.on("canvasInit", function(_canvas) { updatePreferTokenControl(); + updatePathfindingControl(); + ui.controls.render(true); }); Hooks.on("renderSceneControls", async function(controls, _html, _data) { - // Watch for enabling/disabling of the prefer token control - if ( controls.activeControl !== "token" || !Settings.get(Settings.KEYS.PREFER_TOKEN_ELEVATION) ) return; - const toggle = controls.control.tools.find(t => t.name === Settings.KEYS.PREFER_TOKEN_ELEVATION); - if ( !toggle ) return; // Shouldn't happen, but... - await Settings.set(Settings.KEYS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE, toggle.active); + // Monitor enabling/disabling of custom controls. + if ( controls.activeControl !== "token" ) return; + + if ( Settings.get(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION) ) { + const toggle = controls.control.tools.find(t => t.name === Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION); + // Should always find a toggle, but... + if ( toggle ) await Settings.set(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE, toggle.active); + } + + const toggle = controls.control.tools.find(t => t.name === Settings.KEYS.CONTROLS.PATHFINDING); + if ( toggle ) await Settings.set(Settings.KEYS.CONTROLS.PATHFINDING, toggle.active); }); function updatePreferTokenControl(enable) { - enable ??= Settings.get(Settings.KEYS.PREFER_TOKEN_ELEVATION); + enable ??= Settings.get(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION); const tokenTools = ui.controls.controls.find(c => c.name === "token"); - const index = tokenTools.tools.findIndex(b => b.name === Settings.KEYS.PREFER_TOKEN_ELEVATION); + const index = tokenTools.tools.findIndex(b => b.name === Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION); if ( enable && !~index ) tokenTools.tools.push(PREFER_TOKEN_CONTROL); else if ( ~index ) tokenTools.tools.splice(index, 1); - PREFER_TOKEN_CONTROL.active = Settings.get(Settings.KEYS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE); - ui.controls.render(true); + PREFER_TOKEN_CONTROL.active = Settings.get(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE); + // Do in the hook instead to avoid repetition: ui.controls.render(true); +} + +function updatePathfindingControl(enable) { + enable ??= Settings.get(Settings.KEYS.CONTROLS.PATHFINDING); + const tokenTools = ui.controls.controls.find(c => c.name === "token"); + const index = tokenTools.tools.findIndex(b => b.name === Settings.KEYS.CONTROLS.PATHFINDING); + if ( !~index ) tokenTools.tools.push(PATHFINDING_CONTROL); + PATHFINDING_CONTROL.active = Settings.get(Settings.KEYS.CONTROLS.PATHFINDING); + // Do in the hook instead to avoid repetition: ui.controls.render(true); } diff --git a/scripts/settings.js b/scripts/settings.js index dfe237f..096ff92 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -10,7 +10,12 @@ import { ModuleSettingsAbstract } from "./ModuleSettingsAbstract.js"; import { PATCHER } from "./patching.js"; const SETTINGS = { - PREFER_TOKEN_ELEVATION: "prefer-token-elevation", + CONTROLS: { + PATHFINDING: "pathfinding-control", + PREFER_TOKEN_ELEVATION: "prefer-token-elevation", + PREFER_TOKEN_ELEVATION_CURRENT_VALUE: "prefer-token-elevation-current-value" + }, + USE_EV: "enable-elevated-vision-elevation", USE_TERRAIN: "enable-enhanced-terrain-elevation", USE_LEVELS: "enable-levels-elevation", @@ -21,7 +26,6 @@ const SETTINGS = { ALWAYS: "levels-labels-always" }, NO_MODS: "no-modules-message", - PREFER_TOKEN_ELEVATION_CURRENT_VALUE: "prefer-token-elevation-current-value", TOKEN_RULER: { ENABLED: "enable-token-ruler", SPEED_HIGHLIGHTING: "token-ruler-highlighting", @@ -108,9 +112,9 @@ export class Settings extends ModuleSettingsAbstract { } }); - register(KEYS.PREFER_TOKEN_ELEVATION, { - name: localize(`${KEYS.PREFER_TOKEN_ELEVATION}.name`), - hint: localize(`${KEYS.PREFER_TOKEN_ELEVATION}.hint`), + register(KEYS.CONTROLS.PREFER_TOKEN_ELEVATION, { + name: localize(`${KEYS.CONTROLS.PREFER_TOKEN_ELEVATION}.name`), + hint: localize(`${KEYS.CONTROLS.PREFER_TOKEN_ELEVATION}.hint`), scope: "user", config: true, default: false, @@ -119,7 +123,15 @@ export class Settings extends ModuleSettingsAbstract { onChange: reloadTokenControls }); - register(KEYS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE, { + register(KEYS.CONTROLS.PREFER_TOKEN_ELEVATION_CURRENT_VALUE, { + scope: "user", + config: false, + default: false, + type: Boolean, + requiresReload: false + }); + + register(KEYS.CONTROLS.PATHFINDING, { scope: "user", config: false, default: false, diff --git a/scripts/terrain_elevation.js b/scripts/terrain_elevation.js index 3ebabf2..835478c 100644 --- a/scripts/terrain_elevation.js +++ b/scripts/terrain_elevation.js @@ -52,9 +52,9 @@ function tokenElevation(token) { * @returns {boolean} */ function preferTokenElevation() { - if ( !Settings.get(Settings.KEYS.PREFER_TOKEN_ELEVATION) ) return false; + if ( !Settings.get(Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION) ) return false; const token_controls = ui.controls.controls.find(elem => elem.name === "token"); - const prefer_token_control = token_controls.tools.find(elem => elem.name === Settings.KEYS.PREFER_TOKEN_ELEVATION); + const prefer_token_control = token_controls.tools.find(elem => elem.name === Settings.KEYS.CONTROLS.PREFER_TOKEN_ELEVATION); return prefer_token_control.active; } From a32403ae5698459940601ea033dc3a1c8ebf0e13 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 17 Jan 2024 11:25:21 -0800 Subject: [PATCH 14/25] Fixes to the pathfinding path creation --- scripts/Ruler.js | 4 ++ scripts/pathfinding/BorderTriangle.js | 48 ++++++++++----- scripts/pathfinding/algorithms.js | 42 ++++++++++++- scripts/pathfinding/pathfinding.js | 61 +++++++++++++++---- scripts/segments.js | 85 ++++++++++++++++++++++++--- 5 files changed, 202 insertions(+), 38 deletions(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 7a8dc1f..774e0a1 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -1,6 +1,7 @@ /* globals canvas, CONST, +duplicate, game, ui */ @@ -134,8 +135,10 @@ function _addWaypoint(wrapper, point) { /** * Wrap Ruler.prototype._removeWaypoint * Remove elevation increments. + * Remove calculated path. */ function _removeWaypoint(wrapper, point, { snap = true } = {}) { + if ( this._pathfindingSegmentMap ) this._pathfindingSegmentMap.delete(this.waypoints.at(-1)); this._userElevationIncrements = 0; wrapper(point, { snap }); } @@ -257,6 +260,7 @@ function _onMouseUp(wrapped, event) { return wrapped(event); } + PATCHES.BASIC.WRAPS = { clear, toJSON, diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index 7f03a3c..a17d1f1 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -288,7 +288,7 @@ export class BorderTriangle { entryPoint, key: entryPoint.key, // Key needs to be unique for each point, entryTriangle, // Needed to locate neighbors in the next iteration. - priorTriangle, // Needed to eliminate irrelevant neighbors in the next iteration. + priorTriangle: this // Needed to eliminate irrelevant neighbors in the next iteration. }); }); } @@ -303,7 +303,10 @@ export class BorderTriangle { */ getValidDestinationsWithCost(priorTriangle, spacer, fromPoint) { const destinations = this.getValidDestinations(priorTriangle, spacer); - destinations.forEach(d => d.cost = this._calculateMovementCost(fromPoint, d.entryPoint)); + destinations.forEach(d => { + d.cost = this._calculateMovementCost(fromPoint, d.entryPoint); + d.fromPoint = fromPoint; + }); return destinations; } @@ -370,18 +373,19 @@ export class BorderTriangle { /** * For debugging. Draw edges on the canvas. */ - drawEdges() { Object.values(this.edges).forEach(e => e.draw()); } + drawEdges(opts = {}) { Object.values(this.edges).forEach(e => e.draw(opts)); } /* * Draw links to other triangles. */ - drawLinks() { + drawLinks(toMedian = false) { const center = this.center; for ( const edge of Object.values(this.edges) ) { - if ( edge.otherTriangle(this) ) { + const other = edge.otherTriangle(this); + if ( other ) { + const B = toMedian ? edge.median : other.center; const color = edge.wallBlocks(center) ? Draw.COLORS.orange : Draw.COLORS.green; - Draw.segment({ A: center, B: edge.median }, { color }); - + Draw.segment({ A: center, B }, { color }); } } } @@ -398,16 +402,17 @@ export class BorderTriangle { const pointMap = new Map(); for ( const borderTriangle of borderTriangles ) { const { a, b, c } = borderTriangle.vertices; - const aSet = pointMap.get(a.key) || new Set(); - const bSet = pointMap.get(b.key) || new Set(); - const cSet = pointMap.get(c.key) || new Set(); + if ( !pointMap.has(a.key) ) pointMap.set(a.key, new Set()); + if ( !pointMap.has(b.key) ) pointMap.set(b.key, new Set()); + if ( !pointMap.has(c.key) ) pointMap.set(c.key, new Set()); + + const aSet = pointMap.get(a.key); + const bSet = pointMap.get(b.key); + const cSet = pointMap.get(c.key); aSet.add(borderTriangle); bSet.add(borderTriangle); cSet.add(borderTriangle); - if ( !pointMap.has(a.key) ) pointMap.set(a.key, aSet); - if ( !pointMap.has(b.key) ) pointMap.set(b.key, aSet); - if ( !pointMap.has(c.key) ) pointMap.set(c.key, aSet); } // For each triangle, if the edge is not yet linked, link if it has a shared edge. @@ -417,12 +422,23 @@ export class BorderTriangle { if ( edge.cwTriangle && edge.ccwTriangle ) continue; // Already linked. const aSet = pointMap.get(edge.a.key); const bSet = pointMap.get(edge.b.key); - const otherTriangle = aSet.intersection(bSet).first(); // Should always have 0 or 1 elements. - if ( !otherTriangle ) continue; // No bordering triangle. + const ixSet = aSet.intersection(bSet) + + // Debug: should always have 2 elements: this borderTriangle and the other. + if ( ixSet.size > 2 ) { + console.warn("aSet and bSet intersection is larger than expected.", pointMap, edge); + } + if ( ixSet.size && !ixSet.has(borderTriangle) ) { + console.warn("ixSet does not have this borderTriangle", pointMap, edge, borderTriangle); + } + + if ( ixSet.size !== 2 ) continue; // No bordering triangle. + const [tri1, tri2] = ixSet; + const otherTriangle = borderTriangle === tri1 ? tri2 : tri1; // Determine where this edge is on the other triangle and replace. const otherEdgeName = otherTriangle._edgeNameForKeys(edge.a.key, edge.b.key); - if ( !otherEdgeName ) continue; + if ( !otherEdgeName ) continue; // Should not happen. otherTriangle._setEdge(otherEdgeName, edge); } } diff --git a/scripts/pathfinding/algorithms.js b/scripts/pathfinding/algorithms.js index 9304b4a..7606108 100644 --- a/scripts/pathfinding/algorithms.js +++ b/scripts/pathfinding/algorithms.js @@ -41,13 +41,14 @@ export class BreadthFirstPathSearch { while ( frontier.length ) { const current = frontier.pop(); - if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); + if ( this.debug ) current.entryTriangle.drawEdges(); + if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.lightgreen }); if ( this.goalReached(goal, current) ) break; // Get each neighbor destination in turn. for ( const next of this.getNeighbors(current, goal) ) { if ( !cameFrom.has(next.key) ) { - if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightgreen }); + if ( this.debug ) Draw.point(next.entryPoint, { color: Draw.COLORS.lightyellow }); frontier.unshift(next); cameFrom.set(next.key, current); } @@ -82,6 +83,42 @@ export class BreadthFirstPathSearch { this.frontier.length = 0; this.cameFrom.clear(); } + + /** + * Debugging. Get a nested array of all paths for this algorithm's cameFrom map. + * @returns {PIXI.Point[][]} + */ + getAllPathPoints() { + const pathMap = this.cameFrom; + const paths = []; + for ( let [key, curr] of pathMap.entries() ) { + const path = [PIXI.Point.invertKey(key)]; + paths.push(path); + while ( pathMap.has(curr.key) ) { + path.push(PIXI.Point.invertKey(curr.key)) + curr = pathMap.get(curr.key); + } + path.push(PIXI.Point.invertKey(curr.key)); + path.reverse() + } + return paths; + } + + /** + * Draw a single path. + * @param {PIXI.Point[]} path Array of points to draw + * @param {object} [opts] Options to pass to Draw.point and Draw.segment + */ + drawPath(path, opts = {}) { + let A = path[0]; + Draw.point(A, opts); + for ( let i = 1; i < path.length; i += 1 ) { + const B = path[i]; + Draw.point(B, opts); + Draw.segment({ A, B }, opts); + A = B; + } + } } /** @@ -176,6 +213,7 @@ export class GreedyPathSearch extends BreadthFirstPathSearch { while ( frontier.length ) { const current = frontier.dequeue(); + if ( this.debug ) current.entryTriangle.drawEdges(); if ( this.debug ) Draw.point(current.entryPoint, { color: Draw.COLORS.green }); if ( this.goalReached(goal, current) ) break; diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index e2fc8d7..c564b5f 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -39,6 +39,8 @@ pq.data // Test pathfinding Pathfinder.initialize() + +Draw.clearDrawings() Pathfinder.drawTriangles(); @@ -51,24 +53,42 @@ pf = new Pathfinder(token); path = pf.runPath(startPoint, endPoint, "breadth") -pathPoints = pf.getPathPoints(path); +pathPoints = Pathfinder.getPathPoints(path); +paths = pf.algorithm.breadth.getAllPathPoints() +paths.forEach(path => pf.algorithm.breadth.drawPath(path, { color: Draw.COLORS.lightorange })) pf.drawPath(pathPoints, { color: Draw.COLORS.orange }) path = pf.runPath(startPoint, endPoint, "uniform") -pathPoints = pf.getPathPoints(path); +pathPoints = Pathfinder.getPathPoints(path); +paths = pf.algorithm.breadth.getAllPathPoints() +paths.forEach(path => pf.algorithm.uniform.drawPath(path, { color: Draw.COLORS.lightyellow })) pf.drawPath(pathPoints, { color: Draw.COLORS.yellow }) + path = pf.runPath(startPoint, endPoint, "greedy") -pathPoints = pf.getPathPoints(path); +pathPoints = Pathfinder.getPathPoints(path); +paths = pf.algorithm.breadth.getAllPathPoints() +paths.forEach(path => pf.algorithm.greedy.drawPath(path, { color: Draw.COLORS.lightgreen })) pf.drawPath(pathPoints, { color: Draw.COLORS.green }) pf.algorithm.greedy.debug = true path = pf.runPath(startPoint, endPoint, "astar") -pathPoints = pf.getPathPoints(path); +pathPoints = Pathfinder.getPathPoints(path); +paths = pf.algorithm.breadth.getAllPathPoints() +paths.forEach(path => pf.algorithm.astar.drawPath(path, { color: Draw.COLORS.gray })) pf.drawPath(pathPoints, { color: Draw.COLORS.white }) +// Walk through an algorithm + +let { start, end } = pf._initializeStartEndNodes(startPoint, endPoint) +Draw.point(startPoint, { color: Draw.COLORS.white }) +Draw.point(endPoint, { color: Draw.COLORS.green }) + +alg = pf.algorithm.breadth + + */ /* For the triangles, need: @@ -259,8 +279,14 @@ export class Pathfinder { * @returns {PathNode[]} Array of destination nodes */ _identifyDestinations(pathNode, goal) { - // If the goal node is reached, return the goal with the cost. - if ( pathNode.entryTriangle === goal.entryTriangle ) return [goal]; + // If the goal node is reached, return the goal + if ( pathNode.entryTriangle === goal.entryTriangle ) { + // Need a copy so we can modify priorTriangle for this node only. + const newNode = {...goal}; + newNode.priorTriangle = pathNode.priorTriangle; + return [newNode]; + + } return pathNode.entryTriangle.getValidDestinations(pathNode.priorTriangle, this.spacer); } @@ -276,6 +302,7 @@ export class Pathfinder { const newNode = {...goal}; newNode.cost = goal.entryTriangle._calculateMovementCost(pathNode.entryPoint, goal.entryPoint); newNode.priorTriangle = pathNode.priorTriangle; + newNode.fromPoint = pathNode.entryPoint; return [newNode]; } return pathNode.entryTriangle.getValidDestinationsWithCost( @@ -286,14 +313,14 @@ export class Pathfinder { * Identify path points, in order from start to finish, for a cameFrom path map. * @returns {PIXI.Point[]} */ - getPathPoints(pathMap) { - let current = pathMap.goal; - const pts = [current.entryPoint]; - while ( current.key !== pathMap.start.key ) { - current = pathMap.get(current.key); - pts.push(current.entryPoint); + static getPathPoints(pathMap) { + let curr = pathMap.goal; + const pts = []; + while ( curr && pts.length < 1000 ) { + pts.push(PIXI.Point.invertKey(curr.key)); + curr = pathMap.get(curr.key); } - return pts; + return pts.reverse(); } drawPath(pathPoints, opts) { @@ -322,4 +349,12 @@ export class Pathfinder { static drawEdges() { this.triangleEdges.forEach(edge => edge.draw()); } + + /** + * Debugging. Draw links between triangles. + */ + static drawLinks(toMedian = false) { + this.borderTriangles.forEach(tri => tri.drawLinks(toMedian)); + } + } diff --git a/scripts/segments.js b/scripts/segments.js index bc40ea4..dd263e5 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -1,15 +1,16 @@ /* globals +canvas, Color, +CONFIG, CONST, game, getProperty, -canvas, PIXI, -CONFIG +Ray */ "use strict"; -import { MODULE_ID, SPEED, MODULES_ACTIVE } from "./const.js"; +import { SPEED, MODULES_ACTIVE } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; import { Point3d } from "./geometry/3d/Point3d.js"; @@ -18,6 +19,7 @@ import { hexGridShape, perpendicularPoints, iterateGridUnderLine } from "./util.js"; +import { Pathfinder } from "./pathfinding/pathfinding.js"; /** * Calculate the elevation for a given waypoint. @@ -34,8 +36,77 @@ export function elevationAtWaypoint(waypoint) { * Add elevation information to the segments */ export function _getMeasurementSegments(wrapped) { - const segments = wrapped(); - return elevateSegments(this, segments); + const segments = elevateSegments(this, wrapped()); + const token = this._getMovementToken(); + + if ( !(token || Settings.get(Settings.KEYS.CONTROLS.PATHFINDING)) ) return segments; + + // Test for a collision; if none, no pathfinding. + const lastSegment = segments.at(-1); + if ( !lastSegment ) { + console.debug(`No last segment found`, [...segments]); + return segments; + } + + if ( !token.checkCollision(lastSegment.ray.B, {origin: lastSegment.ray.A, type: "move", mode: "any"}) ) return segments; + + const t0 = performance.now(); + + // Add pathfinding segments. + this._pathfindingSegmentMap ??= new Map(); + this._pathfinder ??= new Pathfinder(token); + + // Find path between last waypoint and destination. + + const path = this._pathfinder.runPath(lastSegment.ray.A, lastSegment.ray.B); + const pathPoints = Pathfinder.getPathPoints(path); + if ( pathPoints.length < 2 ) { + console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); + return segments; + } + + const t1 = performance.now(); + + // Store points in case a waypoint is added. + // Overwrite the last calculated path from this waypoint. + // Note that lastSegment.ray.A equals the last waypoint in memory. + this._pathfindingSegmentMap.set(lastSegment.ray.A.to2d().key, pathPoints); + + // For each segment, check the map for pathfinding points. + // If any, replace segment with the points. + // Make sure to keep the label for the last segment piece only + const newSegments = []; + for ( const segment of segments ) { + const { A, B } = segment.ray; + const pathPoints = this._pathfindingSegmentMap.get(A.to2d().key); + if ( !pathPoints ) { + newSegments.push(segment); + continue; + } + + const nPoints = pathPoints.length; + let prevPt = pathPoints[0]; + prevPt.z = segment.ray.A.z; // TODO: Handle 3d in path points? + for ( let i = 1; i < nPoints; i += 1 ) { + const currPt = pathPoints[i]; + currPt.z = A.z; + newSegments.push({ ray: new Ray3d(prevPt, currPt) }); + prevPt = currPt; + } + + const lastPathSegment = newSegments.at(-1); + if ( lastPathSegment ) { + lastPathSegment.ray.B.z = B.z; + lastPathSegment.label = segment.label; + } + } + + const t2 = performance.now(); + + console.debug(`Found ${pathPoints.length} path points between ${lastSegment.ray.A.x},${lastSegment.ray.A.y} -> ${lastSegment.ray.B.x},${lastSegment.ray.B.y} in ${t1 - t0} ms.`); + console.debug(`${newSegments.length} segments processed in ${t2-t1} ms.`); + + return newSegments; } /** @@ -316,7 +387,7 @@ function terrainTokenMoveMultiplier(ray, token) { ixs.forEach(ix => { inside ^= true; - tValues.push({ t: ix.t0, inside }) + tValues.push({ t: ix.t0, inside }); }); } @@ -331,7 +402,7 @@ function terrainTokenMoveMultiplier(ray, token) { nInside += 1; prevT ??= tValue.t; // Store only the first t to take us inside. } else if ( nInside > 2 ) nInside -= 1; - else if ( nInside === 1 ) { // inside is false and we are now outside. + else if ( nInside === 1 ) { // Inside is false and we are now outside. const startPt = ray.project(prevT); const endPt = ray.project(tValue.t); distInside += Point3d.distanceBetween(startPt, endPt); From e78ad02db23b9dbb007aae9dac29094f9b25cabf Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 17 Jan 2024 17:33:28 -0800 Subject: [PATCH 15/25] Fix error from token not being present --- scripts/segments.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/segments.js b/scripts/segments.js index dd263e5..e1846d1 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -39,7 +39,7 @@ export function _getMeasurementSegments(wrapped) { const segments = elevateSegments(this, wrapped()); const token = this._getMovementToken(); - if ( !(token || Settings.get(Settings.KEYS.CONTROLS.PATHFINDING)) ) return segments; + if ( !(token && Settings.get(Settings.KEYS.CONTROLS.PATHFINDING)) ) return segments; // Test for a collision; if none, no pathfinding. const lastSegment = segments.at(-1); @@ -48,7 +48,8 @@ export function _getMeasurementSegments(wrapped) { return segments; } - if ( !token.checkCollision(lastSegment.ray.B, {origin: lastSegment.ray.A, type: "move", mode: "any"}) ) return segments; + if ( !token.checkCollision(lastSegment.ray.B, + {origin: lastSegment.ray.A, type: "move", mode: "any"}) ) return segments; const t0 = performance.now(); From e982bb145bec4f371b88e1cb554c9b79586b2f4d Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Wed, 17 Jan 2024 19:20:10 -0800 Subject: [PATCH 16/25] Fix for token freezing during a pathfinding move `Ruler.prototype._animateSegment` assumes the token is always changing destination. If it doesn't, then no token animation is present and `anim.promise` throws a (silent) error. --- scripts/segments.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/segments.js b/scripts/segments.js index e1846d1..d731782 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -128,15 +128,16 @@ export function _getSegmentLabel(wrapped, segment, totalDistance) { * for the given segment. */ export async function _animateSegment(wrapped, token, segment, destination) { - const res = await wrapped(token, segment, destination); + // If the token is already at the destination, _animateSegment will throw an error when the animation is undefined. + // This can happen when setting artificial segments for highlighting or pathfinding. + if ( token.document.x !== destination.x + || token.document.y !== destination.y ) await wrapped(token, segment, destination); // Update elevation after the token move. if ( segment.ray.A.z !== segment.ray.B.z ) { const elevation = CONFIG.GeometryLib.utils.pixelsToGridUnits(segment.ray.B.z); await token.document.update({ elevation }); } - - return res; } /** From 575fb9e2500705791889b695f2f52bbe098a6137 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 18 Jan 2024 09:30:35 -0800 Subject: [PATCH 17/25] Override computeSegments Use a custom method to calculate distance that treats segments within a grid square as 0 distance. Properly counts grid squares. --- scripts/Ruler.js | 95 +++++++++++++++++++++++++++++++++++++++------ scripts/segments.js | 21 +++++----- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 774e0a1..00d748a 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -1,8 +1,10 @@ /* globals canvas, +CONFIG CONST, duplicate, game, +PIXI, ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -27,7 +29,7 @@ import { modifiedMoveDistance } from "./segments.js"; -import { tokenIsSnapped } from "./util.js"; +import { tokenIsSnapped, iterateGridUnderLine } from "./util.js"; /** * Modified Ruler @@ -183,22 +185,25 @@ function _canMove(wrapper, token) { } /** - * Wrap Ruler.prototype._computeDistance + * Override Ruler.prototype._computeDistance + * Use measurement that counts segments within a grid square properly. * Add moveDistance property to each segment; track the total. * If token not present or Terrain Mapper not active, this will be the same as segment distance. * @param {boolean} gridSpaces Base distance on the number of grid spaces moved? */ -function _computeDistance(wrapped, gridSpaces) { - wrapped(gridSpaces); - - // Add a movement distance based on token and terrain for the segment. - // Default to segment distance. +function _computeDistance(gridSpaces) { + const gridless = !gridSpaces; const token = this._getMovementToken(); + let totalDistance = 0; let totalMoveDistance = 0; for ( const segment of this.segments ) { - segment.moveDistance = modifiedMoveDistance(segment.distance, segment.ray, token); + segment.distance = this.measureDistance(segment.ray.A, segment.ray.B, gridless); + segment.moveDistance = this.modifiedMoveDistance(segment, token); + totalDistance += segment.distance; totalMoveDistance += segment.moveDistance; } + this.segments.at(-1).last = true; + this.totalDistance = totalDistance; this.totalMoveDistance = totalMoveDistance; } @@ -272,7 +277,6 @@ PATCHES.BASIC.WRAPS = { // Wraps related to segments _getMeasurementSegments, _getSegmentLabel, - _computeDistance, // Move token methods _animateMovement, @@ -286,10 +290,12 @@ PATCHES.BASIC.WRAPS = { _canMove }; -PATCHES.SPEED_HIGHLIGHTING.WRAPS = { _highlightMeasurementSegment }; - PATCHES.BASIC.MIXES = { _animateSegment }; +PATCHES.BASIC.OVERRIDES = { _computeDistance }; + +PATCHES.SPEED_HIGHLIGHTING.WRAPS = { _highlightMeasurementSegment }; + // ----- NOTE: Methods ----- // /** @@ -330,6 +336,68 @@ function decrementElevation() { game.user.broadcastActivity({ ruler: ruler.toJSON() }); } +/** + * Add separate method to measure distance of a segment based on grid type. + * Square or hex: count only distance for when the segment crosses to another square/hex. + * A segment wholly within a square is 0 distance. + * Instead of mathematical shortcuts from center, actual grid squares are counted. + * Euclidean also uses grid squares, but measures using actual diagonal from center to center. + * @param {Point} start Starting point for the measurement + * @param {Point} end Ending point for the measurement + * @param {boolean} [gridless=false] For gridded canvas, force gridless measurement + * @returns {number} Measure in grid (game) units (not pixels). + */ +const DIAGONAL_RULES = { + EUCL: 0, + 555: 1, + 5105: 2 +}; +const CHANGE = { + NONE: 0, + V: 1, + H: 2, + D: 3 +}; + +function measureDistance(start, end, gridless = false) { + gridless ||= canvas.grid.type === CONST.GRID_TYPES.GRIDLESS; + if ( gridless ) return CONFIG.GeometryLib.utils.pixelsToGridUnits(PIXI.Point.distanceBetween(start, end)); + + start = PIXI.Point.fromObject(start); + end = PIXI.Point.fromObject(end); + + const iter = iterateGridUnderLine(start, end); + let prev = iter.next().value; + if ( !prev ) return 0; + + // No change, vertical change, horizontal change, diagonal change. + const changeCount = new Uint32Array([0, 0, 0, 0]); + for ( const next of iter ) { + const xChange = prev[1] !== next[1]; // Column is x + const yChange = prev[0] !== next[0]; // Row is y + changeCount[((xChange * 2) + yChange)] += 1; + prev = next; + } + + const distance = canvas.dimensions.distance; + const diagonalRule = DIAGONAL_RULES[canvas.grid.diagonalRule] ?? DIAGONAL_RULES["555"]; + let diagonalDist = distance; + if ( diagonalRule === DIAGONAL_RULES.EUCL ) diagonalDist = Math.hypot(distance, distance); + + // Sum the horizontal, vertical, and diagonal grid moves. + let d = (changeCount[CHANGE.V] * distance) + + (changeCount[CHANGE.H] * distance) + + (changeCount[CHANGE.D] * diagonalDist); + + // If diagonal is 5-10-5, every even move gets an extra 5. + if ( diagonalRule === DIAGONAL_RULES["5105"] ) { + const nEven = ~~(changeCount[CHANGE.D] * 0.5); + d += (nEven * distance); + } + + return d; +} + PATCHES.BASIC.METHODS = { incrementElevation, decrementElevation, @@ -337,7 +405,10 @@ PATCHES.BASIC.METHODS = { // From terrain_elevation.js elevationAtOrigin, terrainElevationAtPoint, - terrainElevationAtDestination + terrainElevationAtDestination, + + measureDistance, + modifiedMoveDistance }; diff --git a/scripts/segments.js b/scripts/segments.js index d731782..85e5f38 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -5,8 +5,7 @@ CONFIG, CONST, game, getProperty, -PIXI, -Ray +PIXI */ "use strict"; @@ -96,7 +95,7 @@ export function _getMeasurementSegments(wrapped) { } const lastPathSegment = newSegments.at(-1); - if ( lastPathSegment ) { + if ( lastPathSegment ) { lastPathSegment.ray.B.z = B.z; lastPathSegment.label = segment.label; } @@ -299,7 +298,7 @@ function splitSegment(segment, pastDistance, cutoffDistance, token) { breakPoint.z = z; const shorterSegment = { ray: new Ray3d(A, breakPoint) }; shorterSegment.distance = canvas.grid.measureDistances([shorterSegment], { gridSpaces: true })[0]; - shorterSegment.moveDistance = modifiedMoveDistance(shorterSegment.distance, shorterSegment.ray, token); + shorterSegment.moveDistance = modifiedMoveDistance(shorterSegment, token); if ( shorterSegment.moveDistance <= cutoffDistance ) break; } } else { @@ -316,23 +315,25 @@ function splitSegment(segment, pastDistance, cutoffDistance, token) { const distances = canvas.grid.measureDistances(segments, { gridSpaces: false }); segment0.distance = distances[0]; segment1.distance = distances[1]; - segment0.moveDistance = modifiedMoveDistance(segment0.distance, segment0.ray, token); - segment1.moveDistance = modifiedMoveDistance(segment1.distance, segment1.ray, token); + segment0.moveDistance = modifiedMoveDistance(segment0, token); + segment1.moveDistance = modifiedMoveDistance(segment1, token); return segments; } /** * Modify distance by terrain mapper adjustment for token speed. - * @param {number} distance Distance of the ray - * @param {Ray|Ray3d} ray Ray to measure + * @param {RulerMeasurementSegment} segment * @param {Token} token Token to use * @returns {number} Modified distance */ -export function modifiedMoveDistance(distance, ray, token) { +export function modifiedMoveDistance(segment, token) { + const ray = segment.ray; + segment.distance ??= this.measureDistance(ray.A, ray.B); + token ??= this._getMovementToken(); const terrainMult = 1 / (terrainMoveMultiplier(ray, token) || 1); // Invert because moveMult is < 1 if speed is penalized. const tokenMult = terrainTokenMoveMultiplier(ray, token); const moveMult = terrainMult * tokenMult; - return distance * moveMult; + return segment.distance * moveMult; } /** From c3094f05820df992e7bcf7429ed6bb1eef9c7e22 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 18 Jan 2024 10:03:00 -0800 Subject: [PATCH 18/25] Catch if no segments are present when setting last property to avoid err --- scripts/Ruler.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 00d748a..1f5456d 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -201,8 +201,9 @@ function _computeDistance(gridSpaces) { segment.moveDistance = this.modifiedMoveDistance(segment, token); totalDistance += segment.distance; totalMoveDistance += segment.moveDistance; + segment.last = false; } - this.segments.at(-1).last = true; + if ( this.segments.length ) this.segments.at(-1).last = true; this.totalDistance = totalDistance; this.totalMoveDistance = totalMoveDistance; } From ac2b71fb923864f88ec566bb828b6f960de7cf86 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Thu, 18 Jan 2024 10:03:25 -0800 Subject: [PATCH 19/25] Clean up pathfinding segments code --- scripts/segments.js | 53 +++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/scripts/segments.js b/scripts/segments.js index 85e5f38..46d41a9 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -9,7 +9,7 @@ PIXI */ "use strict"; -import { SPEED, MODULES_ACTIVE } from "./const.js"; +import { SPEED, MODULES_ACTIVE, MODULE_ID } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; import { Point3d } from "./geometry/3d/Point3d.js"; @@ -43,42 +43,55 @@ export function _getMeasurementSegments(wrapped) { // Test for a collision; if none, no pathfinding. const lastSegment = segments.at(-1); if ( !lastSegment ) { - console.debug(`No last segment found`, [...segments]); + console.debug("No last segment found", [...segments]); return segments; } - if ( !token.checkCollision(lastSegment.ray.B, - {origin: lastSegment.ray.A, type: "move", mode: "any"}) ) return segments; - - const t0 = performance.now(); - - // Add pathfinding segments. - this._pathfindingSegmentMap ??= new Map(); - this._pathfinder ??= new Pathfinder(token); + const { A, B } = lastSegment.ray; + if ( !token.checkCollision(B, { origin: A, type: "move", mode: "any" }) ) return segments; // Find path between last waypoint and destination. - - const path = this._pathfinder.runPath(lastSegment.ray.A, lastSegment.ray.B); + const t0 = performance.now(); + token[MODULE_ID] ??= {}; + const pf = token[MODULE_ID].pathfinder ??= new Pathfinder(token); + const path = pf.runPath(A, B); const pathPoints = Pathfinder.getPathPoints(path); + const t1 = performance.now(); + console.debug(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); if ( pathPoints.length < 2 ) { console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); return segments; } - const t1 = performance.now(); - // Store points in case a waypoint is added. // Overwrite the last calculated path from this waypoint. - // Note that lastSegment.ray.A equals the last waypoint in memory. - this._pathfindingSegmentMap.set(lastSegment.ray.A.to2d().key, pathPoints); + const t2 = performance.now(); + const segmentMap = this._pathfindingSegmentMap ??= new Map(); + segmentMap.set(A.to2d().key, pathPoints); + // For each segment, replace with path sub-segment if pathfinding was used. + const newSegments = constructPathfindingSegments(segments, segmentMap); + const t3 = performance.now(); + console.debug(`${newSegments.length} segments processed in ${t3-t2} ms.`); + return newSegments; +} + +/** + * Check provided array of segments against stored path points. + * For each segment with pathfinding points, replace the segment with sub-segments + * between each pathfinding point. + * @param {RulerMeasurementSegment[]} segments + * @returns {RulerMeasurementSegment[]} Updated segment array + */ +function constructPathfindingSegments(segments, segmentMap) { // For each segment, check the map for pathfinding points. // If any, replace segment with the points. // Make sure to keep the label for the last segment piece only + if ( !segmentMap.size ) return segments; const newSegments = []; for ( const segment of segments ) { const { A, B } = segment.ray; - const pathPoints = this._pathfindingSegmentMap.get(A.to2d().key); + const pathPoints = segmentMap.get(A.to2d().key); if ( !pathPoints ) { newSegments.push(segment); continue; @@ -100,12 +113,6 @@ export function _getMeasurementSegments(wrapped) { lastPathSegment.label = segment.label; } } - - const t2 = performance.now(); - - console.debug(`Found ${pathPoints.length} path points between ${lastSegment.ray.A.x},${lastSegment.ray.A.y} -> ${lastSegment.ray.B.x},${lastSegment.ray.B.y} in ${t1 - t0} ms.`); - console.debug(`${newSegments.length} segments processed in ${t2-t1} ms.`); - return newSegments; } From 5d3d25e468a66c9556857600717f5a38a87ca359 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 07:45:47 -0800 Subject: [PATCH 20/25] Refactor highlightMeasurement and computeDistance computeDistance now adds token speeds and splits segments. highlight uses the identified color --- scripts/Ruler.js | 184 +++++++++++++++++++++++++++++++++++++++++++- scripts/const.js | 18 ++++- scripts/segments.js | 180 ++++++------------------------------------- 3 files changed, 222 insertions(+), 160 deletions(-) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 1f5456d..8136874 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -4,7 +4,9 @@ CONFIG CONST, duplicate, game, +getProperty, PIXI, +Ruler, ui */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ @@ -14,6 +16,9 @@ export const PATCHES = {}; PATCHES.BASIC = {}; PATCHES.SPEED_HIGHLIGHTING = {}; +import { SPEED } from "./const.js"; +import { Settings } from "./settings.js"; +import { Ray3d } from "./geometry/3d/Ray3d.js"; import { elevationAtOrigin, terrainElevationAtPoint, @@ -29,7 +34,11 @@ import { modifiedMoveDistance } from "./segments.js"; -import { tokenIsSnapped, iterateGridUnderLine } from "./util.js"; +import { + tokenIsSnapped, + iterateGridUnderLine, + squareGridShape, + hexGridShape } from "./util.js"; /** * Modified Ruler @@ -194,20 +203,183 @@ function _canMove(wrapper, token) { function _computeDistance(gridSpaces) { const gridless = !gridSpaces; const token = this._getMovementToken(); + const { measureDistance, modifiedMoveDistance } = this.constructor; let totalDistance = 0; let totalMoveDistance = 0; + + if ( this.segments.some(s => !s) ) { + console.error("Segment is undefined."); + } + for ( const segment of this.segments ) { - segment.distance = this.measureDistance(segment.ray.A, segment.ray.B, gridless); - segment.moveDistance = this.modifiedMoveDistance(segment, token); + segment.distance = measureDistance(segment.ray.A, segment.ray.B, gridless); + segment.moveDistance = modifiedMoveDistance(segment, token); totalDistance += segment.distance; totalMoveDistance += segment.moveDistance; segment.last = false; } - if ( this.segments.length ) this.segments.at(-1).last = true; this.totalDistance = totalDistance; this.totalMoveDistance = totalMoveDistance; + + const tokenSpeed = Number(getProperty(token, SPEED.ATTRIBUTE)); + if ( Settings.get(Settings.KEYS.TOKEN_RULER.SPEED_HIGHLIGHTING) + && tokenSpeed ) this._computeTokenSpeed(token, tokenSpeed, gridless); + if ( this.segments.length ) this.segments.at(-1).last = true; + + if ( this.segments.some(s => !s) ) { + console.error("Segment is undefined."); + } + + } +function _computeTokenSpeed(token, tokenSpeed, gridless = false) { + let totalMoveDistance = 0; + let dashing = false; + let atMaximum = false; + const walkDist = tokenSpeed; + const dashDist = tokenSpeed * SPEED.MULTIPLIER; + const newSegments = []; + for ( let segment of this.segments ) { + if ( atMaximum ) { + newSegments.push(segment); + continue; + } + + let newMoveDistance = totalMoveDistance + segment.moveDistance; + if ( !dashing && Number.between(walkDist, totalMoveDistance, newMoveDistance, false) ) { + // Split required + const splitMoveDistance = walkDist - totalMoveDistance; + const segments = splitSegment(segment, splitMoveDistance, token, gridless); + if ( segments.length === 1 ) { + segment.speed = SPEED.TYPES.WALK; + newSegments.push(segment); + totalMoveDistance += segment.moveDistance; + continue; + } else if ( segments.length === 2 ) { + segments[0].speed = SPEED.TYPES.WALK; + newSegments.push(segments[0]); + totalMoveDistance += segments[0].moveDistance; + segment = segments[1]; + newMoveDistance = totalMoveDistance + segment.moveDistance; + } + } + + if ( !atMaximum && Number.between(dashDist, totalMoveDistance, newMoveDistance, false) ) { + // Split required + const splitMoveDistance = dashDist - totalMoveDistance; + const segments = splitSegment(segment, splitMoveDistance, token, gridless); + if ( segments.length === 1 ) { + segment.speed = SPEED.TYPES.DASH; + newSegments.push(segment); + totalMoveDistance += segment.moveDistance; + continue; + } else if ( segments.length === 2 ) { + segments[0].speed = SPEED.TYPES.DASH; + newSegments.push(segments[0]); + totalMoveDistance += segments[0].moveDistance; + segment = segments[1]; + newMoveDistance = totalMoveDistance + segment.moveDistance; + } + } + + if ( totalMoveDistance > dashDist ) { + segment.speed = SPEED.TYPES.MAXIMUM; + dashing ||= true; + atMaximum ||= true; + } else if ( totalMoveDistance > walkDist ) { + segment.speed = SPEED.TYPES.DASH; + dashing ||= true; + } else segment.speed = SPEED.TYPES.WALK; + + totalMoveDistance += segment.moveDistance; + newSegments.push(segment); + } + + this.segments = newSegments; + return newSegments; +} + +/** + * Cut a ruler segment at a specific point such that the first subsegment + * measures a specific incremental move distance. + * @param {RulerMeasurementSegment} segment Segment, with ray property, to split + * @param {number} incrementalMoveDistance Distance, in grid units, of the desired first subsegment move distance + * @param {Token} token Token to use when measuring move distance + * @returns {RulerMeasurementSegment[]} + * If the incrementalMoveDistance is less than 0, returns []. + * If the incrementalMoveDistance is greater than segment move distance, returns [segment] + * Otherwise returns [RulerMeasurementSegment, RulerMeasurementSegment] + */ +function splitSegment(segment, splitMoveDistance, token, gridless) { + if ( splitMoveDistance <= 0 ) return []; + if ( splitMoveDistance > segment.moveDistance ) return [segment]; + + + // Determine where on the segment ray the cutoff occurs. + // Use canvas grid distance measurements to handle 5-5-5, 5-10-5, other measurement configs. + // At this point, the segment is too long for the cutoff. + // If we are using a grid, split the segment at grid/square hex. + // Find where the segment intersects the last grid square/hex before the cutoff. + const rulerClass = CONFIG.Canvas.rulerClass; + let breakPoint; + const { A, B } = segment.ray; + gridless ||= (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS); + if ( gridless ) { + // Use ratio (t) value + const t = splitMoveDistance / segment.moveDistance; + breakPoint = A.projectToward(B, t); + } else { + // Cannot just use the t value because segment distance may not be Euclidean. + // Also need to handle that a segment might break on a grid border. + // Determine all the grid positions, and drop each one in turn. + const z = segment.ray.A.z; + const gridShapeFn = canvas.grid.type === CONST.GRID_TYPES.SQUARE ? squareGridShape : hexGridShape; + const segmentDistZ = segment.ray.distance; + + // Cannot just use the t value because segment distance may not be Euclidean. + // Also need to handle that a segment might break on a grid border. + // Determine all the grid positions, and drop each one in turn. + breakPoint = B; + const gridIter = iterateGridUnderLine(A, B, { reverse: true }); + for ( const [r1, c1] of gridIter ) { + const [x, y] = canvas.grid.grid.getPixelsFromGridPosition(r1, c1); + const shape = gridShapeFn({x, y}); + const ixs = shape + .segmentIntersections(A, B) + .map(ix => PIXI.Point.fromObject(ix)); + if ( !ixs.length ) continue; + + // If more than one, split the distance. + // This avoids an issue whereby a segment is too short and so the first square is dropped when highlighting. + if ( ixs.length === 1 ) breakPoint = ixs[0]; + else { + ixs.forEach(ix => { + ix.distance = ix.subtract(A).magnitude(); + ix.t0 = ix.distance / segmentDistZ; + }); + const t = (ixs[0].t0 + ixs[1].t0) * 0.5; + breakPoint = A.projectToward(B, t); + } + + // Construct a shorter segment. + breakPoint.z = z; + const shorterSegment = { ray: new Ray3d(A, breakPoint) }; + shorterSegment.moveDistance = rulerClass.modifiedMoveDistance(shorterSegment, token); + if ( shorterSegment.moveDistance <= splitMoveDistance ) break; + } + } + + if ( breakPoint.almostEqual(B) ) return [segment]; + if ( breakPoint.almostEqual(A) ) return []; + + // Split the segment into two at the break point. + const segment0 = { ray: new Ray3d(A, breakPoint) }; + const segment1 = { ray: new Ray3d(breakPoint, B) }; + segment0.moveDistance = rulerClass.modifiedMoveDistance(segment0, token); + segment1.moveDistance = rulerClass.modifiedMoveDistance(segment1, token); + return [segment0, segment1]; +} // ----- NOTE: Event handling ----- // @@ -408,6 +580,10 @@ PATCHES.BASIC.METHODS = { terrainElevationAtPoint, terrainElevationAtDestination, + _computeTokenSpeed +}; + +PATCHES.BASIC.STATIC_METHODS = { measureDistance, modifiedMoveDistance }; diff --git a/scripts/const.js b/scripts/const.js index faded2f..221653a 100644 --- a/scripts/const.js +++ b/scripts/const.js @@ -1,4 +1,5 @@ /* globals +Color, game, Hooks */ @@ -50,9 +51,24 @@ SOFTWARE. export const SPEED = { ATTRIBUTE: "", - MULTIPLIER: 0 + MULTIPLIER: 0, + TYPES: { + WALK: 0, + DASH: 1, + MAXIMUM: -1 + }, + COLORS: { + WALK: Color.from(0x00ff00), + DASH: Color.from(0xffff00), + MAXIMUM: Color.from(0xff0000) + } }; +// Add the inversions for lookup +SPEED.COLORS[SPEED.TYPES.WALK] = SPEED.COLORS.WALK; +SPEED.COLORS[SPEED.TYPES.DASH] = SPEED.COLORS.DASH; +SPEED.COLORS[SPEED.TYPES.MAXIMUM] = SPEED.COLORS.MAXIMUM; + // Avoid testing for the system id each time. Hooks.once("init", function() { SPEED.ATTRIBUTE = defaultSpeedAttribute(); diff --git a/scripts/segments.js b/scripts/segments.js index 46d41a9..e9e741c 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -1,10 +1,8 @@ /* globals canvas, -Color, CONFIG, CONST, game, -getProperty, PIXI */ "use strict"; @@ -13,11 +11,7 @@ import { SPEED, MODULES_ACTIVE, MODULE_ID } from "./const.js"; import { Settings } from "./settings.js"; import { Ray3d } from "./geometry/3d/Ray3d.js"; import { Point3d } from "./geometry/3d/Point3d.js"; -import { - squareGridShape, - hexGridShape, - perpendicularPoints, - iterateGridUnderLine } from "./util.js"; +import { perpendicularPoints } from "./util.js"; import { Pathfinder } from "./pathfinding/pathfinding.js"; /** @@ -177,166 +171,42 @@ export function hasSegmentCollision(token, segments) { export function _highlightMeasurementSegment(wrapped, segment) { const token = this._getMovementToken(); if ( !token ) return wrapped(segment); - const tokenSpeed = Number(getProperty(token, SPEED.ATTRIBUTE)); - if ( !tokenSpeed ) return wrapped(segment); - - // Based on the token being measured. - // Track the distance to this segment. - // Split this segment at the break points for the colors as necessary. - let pastDistance = 0; - for ( const s of this.segments ) { - if ( s === segment ) break; - pastDistance += s.moveDistance; - } - - // Constants - const walkDist = tokenSpeed; - const dashDist = tokenSpeed * SPEED.MULTIPLIER; - const walkColor = Color.from(0x00ff00); - const dashColor = Color.from(0xffff00); - const maxColor = Color.from(0xff0000); - - // Track the splits. - let remainingSegment = segment; - const splitSegments = []; - - // Walk - remainingSegment.color = walkColor; - const walkSegments = splitSegment(remainingSegment, pastDistance, walkDist, token); - if ( walkSegments.length ) { - const segment0 = walkSegments[0]; - splitSegments.push(segment0); - pastDistance += segment0.moveDistance; - remainingSegment = walkSegments[1]; // May be undefined. - } - - // Dash - if ( remainingSegment ) { - remainingSegment.color = dashColor; - const dashSegments = splitSegment(remainingSegment, pastDistance, dashDist, token); - if ( dashSegments.length ) { - const segment0 = dashSegments[0]; - splitSegments.push(segment0); - if ( dashSegments.length > 1 ) { - const remainingSegment = dashSegments[1]; - remainingSegment.color = maxColor; - splitSegments.push(remainingSegment); - } - } - } // Highlight each split in turn, changing highlight color each time. const priorColor = this.color; - for ( const s of splitSegments ) { - this.color = s.color; - wrapped(s); - - // If gridless, highlight a rectangular shaped portion of the line. - if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) { - const { A, B } = s.ray; - const width = Math.floor(canvas.scene.dimensions.size * 0.2); - const ptsA = perpendicularPoints(A, B, width * 0.5); - const ptsB = perpendicularPoints(B, A, width * 0.5); - const shape = new PIXI.Polygon([ - ptsA[0], - ptsA[1], - ptsB[0], - ptsB[1] - ]); - canvas.grid.highlightPosition(this.name, {color: this.color, shape}); - } - } - this.color = priorColor; -} + this.color = SPEED.COLORS[segment.speed]; + wrapped(segment); -/** - * Cut a segment, represented as a ray and a distance, at a given point. - * @param {object} segment - * @param {number} pastDistance - * @param {number} cutoffDistance - * @returns {object[]} - * - If cutoffDistance is before the segment start, return []. - * - If cutoffDistance is after the segment end, return [segment]. - * - If cutoffDistance is within the segment, return [segment0, segment1] - */ -function splitSegment(segment, pastDistance, cutoffDistance, token) { - cutoffDistance -= pastDistance; - if ( cutoffDistance <= 0 ) return []; - if ( cutoffDistance >= segment.moveDistance ) return [segment]; - - // Determine where on the segment ray the cutoff occurs. - // Use canvas grid distance measurements to handle 5-5-5, 5-10-5, other measurement configs. - // At this point, the segment is too long for the cutoff. - // If we are using a grid, split the segment a grid/square hex. - // Find where the segment intersects the last grid square/hex before the cutoff. - let breakPoint; - const { A, B } = segment.ray; - if ( canvas.grid.type !== CONST.GRID_TYPES.GRIDLESS ) { - const z = segment.ray.A.z; - const gridShapeFn = canvas.grid.type === CONST.GRID_TYPES.SQUARE ? squareGridShape : hexGridShape; - const segmentDistZ = segment.ray.distance; - - // Cannot just use the t value because segment distance may not be Euclidean. - // Also need to handle that a segment might break on a grid border. - // Determine all the grid positions, and drop each one in turn. - breakPoint = B; - const gridIter = iterateGridUnderLine(A, B, { reverse: true }); - for ( const [r1, c1] of gridIter ) { - const [x, y] = canvas.grid.grid.getPixelsFromGridPosition(r1, c1); - const shape = gridShapeFn({x, y}); - const ixs = shape - .segmentIntersections(A, B) - .map(ix => PIXI.Point.fromObject(ix)); - if ( !ixs.length ) continue; - - // If more than one, split the distance. - // This avoids an issue whereby a segment is too short and so the first square is dropped when highlighting. - if ( ixs.length === 1 ) breakPoint = ixs[0]; - else { - ixs.forEach(ix => { - ix.distance = ix.subtract(A).magnitude(); - ix.t0 = ix.distance / segmentDistZ; - }); - const t = (ixs[0].t0 + ixs[1].t0) * 0.5; - breakPoint = A.projectToward(B, t); - } - - // Construct a shorter segment. - breakPoint.z = z; - const shorterSegment = { ray: new Ray3d(A, breakPoint) }; - shorterSegment.distance = canvas.grid.measureDistances([shorterSegment], { gridSpaces: true })[0]; - shorterSegment.moveDistance = modifiedMoveDistance(shorterSegment, token); - if ( shorterSegment.moveDistance <= cutoffDistance ) break; - } - } else { - // Use t values. - const t = cutoffDistance / segment.moveDistance; - breakPoint = A.projectToward(B, t); + // If gridless, highlight a rectangular shaped portion of the line. + if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) { + const { A, B } = segment.ray; + const width = Math.floor(canvas.scene.dimensions.size * 0.2); + const ptsA = perpendicularPoints(A, B, width * 0.5); + const ptsB = perpendicularPoints(B, A, width * 0.5); + const shape = new PIXI.Polygon([ + ptsA[0], + ptsA[1], + ptsB[0], + ptsB[1] + ]); + canvas.grid.highlightPosition(this.name, {color: this.color, shape}); } - if ( breakPoint === B ) return [segment]; - - // Split the segment into two at the break point. - const segment0 = { ray: new Ray3d(A, breakPoint), color: segment.color }; - const segment1 = { ray: new Ray3d(breakPoint, B) }; - const segments = [segment0, segment1]; - const distances = canvas.grid.measureDistances(segments, { gridSpaces: false }); - segment0.distance = distances[0]; - segment1.distance = distances[1]; - segment0.moveDistance = modifiedMoveDistance(segment0, token); - segment1.moveDistance = modifiedMoveDistance(segment1, token); - return segments; + + // Reset to the default color. + this.color = priorColor; } /** * Modify distance by terrain mapper adjustment for token speed. - * @param {RulerMeasurementSegment} segment - * @param {Token} token Token to use + * @param {RulerMeasurementSegment} segment + * @param {Token} token Token to use + * @param {boolean} gridless Passed to Ruler.measureDistance if segment distance not defined. * @returns {number} Modified distance */ -export function modifiedMoveDistance(segment, token) { +export function modifiedMoveDistance(segment, token, gridless = false) { + if ( !token ) return segment.distance; const ray = segment.ray; - segment.distance ??= this.measureDistance(ray.A, ray.B); - token ??= this._getMovementToken(); + segment.distance ??= this.measureDistance(ray.A, ray.B, gridless); const terrainMult = 1 / (terrainMoveMultiplier(ray, token) || 1); // Invert because moveMult is < 1 if speed is penalized. const tokenMult = terrainTokenMoveMultiplier(ray, token); const moveMult = terrainMult * tokenMult; From fd57570860f601d1e7228ff79827d36f2e6a2e2d Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 07:46:55 -0800 Subject: [PATCH 21/25] Fix for maximum color not getting set --- scripts/Ruler.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/Ruler.js b/scripts/Ruler.js index 8136874..b41ea50 100644 --- a/scripts/Ruler.js +++ b/scripts/Ruler.js @@ -242,6 +242,7 @@ function _computeTokenSpeed(token, tokenSpeed, gridless = false) { const newSegments = []; for ( let segment of this.segments ) { if ( atMaximum ) { + segment.speed = SPEED.TYPES.MAXIMUM; newSegments.push(segment); continue; } From b99a0cd4a1b6f03056b221824f29f2c268f4d5a8 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 13:50:07 -0800 Subject: [PATCH 22/25] Working path cleaning algorithm --- scripts/pathfinding/pathfinding.js | 116 +++++++++++++++++++++++++++++ scripts/segments.js | 14 +++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index c564b5f..2a232a5 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -357,4 +357,120 @@ export class Pathfinder { this.borderTriangles.forEach(tri => tri.drawLinks(toMedian)); } + /** + * Clean an array of path points. + * Straighten path and remove points that are very close to one another. + * If gridded, attempt to center the points on the grid. + * If not gridded, keep within the canvas grid size. + * Do not move a point if the path collides with a wall. + * Do not move a point if it would take it outside its grid square (to limit + * possibility that it would move the path into a terrain). + * @param {PIXI.Point[]} pathPoints + * @returns {PIXI.Point[]} + */ + static cleanPath(pathPoints) { + if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return cleanNonGridPath(pathPoints); + else return cleanGridPath(pathPoints); + } +} + +/** + * For given point on a grid: + * - if next point shares this grid square, delete if prev --> next has no collision. + * - temporarily move to the grid center. + * - if collision, move back and go to next point. Otherwise keep at center. + * Don't move the start or end points. + * @param {PIXI.Point[]} pathPoints + * @returns {PIXI.Point[]} + */ +function cleanGridPath(pathPoints) { + const nPoints = pathPoints.length; + if ( nPoints < 3 ) return pathPoints; + + const config = { mode: "any", type: "move" }; + let prev = pathPoints[0]; + let curr = pathPoints[1]; + const newPath = [prev]; + for ( let i = 2; i < nPoints; i += 1 ) { + const next = pathPoints[i]; + if ( next ) { + // If next shares this grid space, test if we can remove current. + const currGridPos = canvas.grid.grid.getGridPositionFromPixels(curr.x, curr.y); + const nextGridPos = canvas.grid.grid.getGridPositionFromPixels(next.x, next.y); + if ( currGridPos[0] === nextGridPos[0] + && currGridPos[1] === nextGridPos[1] + && !ClockwiseSweepPolygon.testCollision(curr, next, config) ) { + curr = next; + continue; + } + } + + // Test if we can move the current point to the center of the grid without a collision. + const currCenter = getGridCenterPoint(curr); + if ( !ClockwiseSweepPolygon.testCollision(prev, currCenter, config) ) curr = currCenter; + newPath.push(curr); + prev = curr; + curr = next; + } + newPath.push(pathPoints.at(-1)); + return newPath; +} + +function getGridCenterPoint(pt) { + const [x, y] = canvas.grid.grid.getCenter(pt.x, pt.y); + return new PIXI.Point(x, y); } + +/** + * For given point not on a grid: + * - Radial test: if next point is within canvas.dimensions.size * 0.5, delete if prev --> next has no collision. + * - Also (not yet implemented): Try Ramer–Douglas–Peucker to straighten line by removing points if no collision. + * Don't move the start or end points. + * @param {PIXI.Point[]} pathPoints + * @returns {PIXI.Point[]} + */ +function cleanNonGridPath(pathPoints) { + const nPoints = pathPoints.length; + if ( nPoints < 3 ) return pathPoints; + + const MAX_DIST2 = Math.pow(canvas.scene.dimensions.size * 0.5, 2); + const config = { mode: "any", type: "move" }; + let prev = pathPoints[0]; + let curr = pathPoints[1]; + const newPath = [prev]; + for ( let i = 2; i < nPoints; i += 1 ) { + const next = pathPoints[i]; + + // If next is sufficiently close to current, see if we can remove current. + if ( next + && PIXI.Point.distanceSquaredBetween(curr, next) < MAX_DIST2 + && !ClockwiseSweepPolygon.testCollision(curr, next, config) ) { + curr = next; + continue; + } + + newPath.push(curr); + prev = curr; + curr = next; + } + newPath.push(pathPoints.at(-1)); + return newPath; +} + +/** + * Perpendicular distance to a line from a point. + * https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line + * @param {PIXI.Point} a First endpoint of the line + * @param {PIXI.Point} b Second endpoint of the line + * @param {PIXI.Point} c Point to measure + * @returns {number|undefined} + */ +function perpendicularDistance(a, b, c) { + const deltaBA = b.subtract(a); + const deltaCA = c.subtract(a); + const denom = Math.pow(deltaBA.x, 2) + Math.pow(deltaBA.y, 2); + if ( !denom ) return undefined; // The line AB has length 0. + const num = (deltaBA.x * deltaCA.y) - (deltaBA.y * deltaCA.x); + return Math.abs(num) / Math.sqrt(denom); +} + diff --git a/scripts/segments.js b/scripts/segments.js index e9e741c..419ff49 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -49,14 +49,26 @@ export function _getMeasurementSegments(wrapped) { token[MODULE_ID] ??= {}; const pf = token[MODULE_ID].pathfinder ??= new Pathfinder(token); const path = pf.runPath(A, B); - const pathPoints = Pathfinder.getPathPoints(path); + let pathPoints = Pathfinder.getPathPoints(path); const t1 = performance.now(); console.debug(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); + + const t4 = performance.now(); + pathPoints = Pathfinder.cleanPath(pathPoints); + const t5 = performance.now(); + if ( !pathPoints ) { + console.debug("No path points after cleaning"); + return segments; + } + + console.debug(`Cleaned to ${pathPoints?.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t5 - t4} ms.`); if ( pathPoints.length < 2 ) { console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); return segments; } + + // Store points in case a waypoint is added. // Overwrite the last calculated path from this waypoint. const t2 = performance.now(); From 023ea9c96f00edcce4b993a21860b14a39874261 Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 16:48:47 -0800 Subject: [PATCH 23/25] Adjust edge test so tokens fit through doors of exact token size --- scripts/pathfinding/BorderTriangle.js | 9 ++++++--- scripts/pathfinding/pathfinding.js | 23 +++++++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/scripts/pathfinding/BorderTriangle.js b/scripts/pathfinding/BorderTriangle.js index a17d1f1..72669e0 100644 --- a/scripts/pathfinding/BorderTriangle.js +++ b/scripts/pathfinding/BorderTriangle.js @@ -80,12 +80,15 @@ export class BorderEdge { const length = this.length; const destinations = []; - // No destination if edge is smaller than 2x spacer. - if ( length < (spacer * 2) || this.wallBlocks(origin) ) return destinations; + // No destination if edge is smaller than 2x spacer unless it is a door. + // Cheat a little on the spacing so tokens exactly the right size will fit. + if ( !this.wall?.isOpen + && (length < (spacer * 1.9) || this.wallBlocks(origin)) ) return destinations; destinations.push(this.median); // Skip corners if not at least spacer away from median. - if ( length < (spacer * 4) ) return destinations; + // Again, cheat a little on the spacing. + if ( length < (spacer * 3.9) ) return destinations; const { a, b } = this; const t = spacer / length; diff --git a/scripts/pathfinding/pathfinding.js b/scripts/pathfinding/pathfinding.js index 2a232a5..ed21fb8 100644 --- a/scripts/pathfinding/pathfinding.js +++ b/scripts/pathfinding/pathfinding.js @@ -257,22 +257,29 @@ export class Pathfinder { _initializeStartEndNodes(startPoint, endPoint) { // Locate start and end triangles. // TODO: Handle 3d - const quadtree = this.constructor.quadtree; startPoint = PIXI.Point.fromObject(startPoint); endPoint = PIXI.Point.fromObject(endPoint); + const startTri = this.constructor.trianglesAtPoint(startPoint).first(); + const endTri = this.constructor.trianglesAtPoint(endPoint).first(); - let collisionTest = (o, _rect) => o.t.contains(startPoint); - const startTri = quadtree.getObjects(boundsForPoint(startPoint), { collisionTest }).first(); - - collisionTest = (o, _rect) => o.t.contains(endPoint); - const endTri = quadtree.getObjects(boundsForPoint(endPoint), { collisionTest }).first(); - + // Build PathNode for start and end. const start = { key: startPoint.key, entryTriangle: startTri, entryPoint: PIXI.Point.fromObject(startPoint) }; const end = { key: endPoint.key, entryTriangle: endTri, entryPoint: endPoint }; - return { start, end }; } + /** + * Locate a triangle at a specific point. + * Used to locate start and end nodes but also for debugging. + * @param {PIXI.Point} pt + * @returns {Set} Typically, only one triangle in the set. + * Possibly more than one at a border point. + */ + static trianglesAtPoint(pt) { + const collisionTest = (o, _rect) => o.t.contains(pt); + return this.quadtree.getObjects(boundsForPoint(pt), { collisionTest }); + } + /** * Get destinations for a given path node * @param {PathNode} pathObject From 0e57c8f97bfcd370acd3a7dbf09aa7ba05fbccab Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 16:55:41 -0800 Subject: [PATCH 24/25] Update changelog --- Changelog.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Changelog.md b/Changelog.md index 55991db..2f9c597 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,14 @@ +# 0.8.0 +Added pathfinding toggle. Pathfinding works on gridded (both hex and square) and gridless maps. Works when dragging tokens, if Token Ruler is enabled, or when using the Ruler control and you start at a token. + +To benchmark pathfinding in a scene: +```js +api = game.modules.get("elevationruler").api; +api.pathfinding.benchPathfinding() +``` + +Refactored movement highlighting to work better with pathfinding. + # 0.7.8 More tweaks to how token origin and destination are set when dragging so that the token movement follows the position of the cloned dragged token. Revisits issue #30. Fix issue where token dragging cannot move to the adjacent space. Closes issue #32. From dbdc696fdd2c70587b234330ae0721cfac04479b Mon Sep 17 00:00:00 2001 From: Michael Enion Date: Sat, 20 Jan 2024 16:57:10 -0800 Subject: [PATCH 25/25] Comment out console.debug lines --- scripts/pathfinding/Wall.js | 4 ++-- scripts/segments.js | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/pathfinding/Wall.js b/scripts/pathfinding/Wall.js index 276e7df..bece227 100644 --- a/scripts/pathfinding/Wall.js +++ b/scripts/pathfinding/Wall.js @@ -19,8 +19,8 @@ PATCHES.PATHFINDING = {}; // When canvas is ready, the existing walls are not created, so must re-do here. Hooks.on("canvasReady", async function() { - console.debug(`outerBounds: ${canvas.walls.outerBounds.length}`); - console.debug(`innerBounds: ${canvas.walls.innerBounds.length}`); + // console.debug(`outerBounds: ${canvas.walls.outerBounds.length}`); + // console.debug(`innerBounds: ${canvas.walls.innerBounds.length}`); const t0 = performance.now(); SCENE_GRAPH.clear(); diff --git a/scripts/segments.js b/scripts/segments.js index 419ff49..b64409b 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -37,7 +37,7 @@ export function _getMeasurementSegments(wrapped) { // Test for a collision; if none, no pathfinding. const lastSegment = segments.at(-1); if ( !lastSegment ) { - console.debug("No last segment found", [...segments]); + // console.debug("No last segment found", [...segments]); return segments; } @@ -51,19 +51,19 @@ export function _getMeasurementSegments(wrapped) { const path = pf.runPath(A, B); let pathPoints = Pathfinder.getPathPoints(path); const t1 = performance.now(); - console.debug(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); + // console.debug(`Found ${pathPoints.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t1 - t0} ms.`); const t4 = performance.now(); pathPoints = Pathfinder.cleanPath(pathPoints); const t5 = performance.now(); if ( !pathPoints ) { - console.debug("No path points after cleaning"); + // console.debug("No path points after cleaning"); return segments; } - console.debug(`Cleaned to ${pathPoints?.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t5 - t4} ms.`); + // console.debug(`Cleaned to ${pathPoints?.length} path points between ${A.x},${A.y} -> ${B.x},${B.y} in ${t5 - t4} ms.`); if ( pathPoints.length < 2 ) { - console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); + // console.debug(`Only ${pathPoints.length} path points found.`, [...pathPoints]); return segments; } @@ -78,7 +78,7 @@ export function _getMeasurementSegments(wrapped) { // For each segment, replace with path sub-segment if pathfinding was used. const newSegments = constructPathfindingSegments(segments, segmentMap); const t3 = performance.now(); - console.debug(`${newSegments.length} segments processed in ${t3-t2} ms.`); + // console.debug(`${newSegments.length} segments processed in ${t3-t2} ms.`); return newSegments; }