diff --git a/scripts/module.js b/scripts/module.js index 5778771..e3e9a42 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -1,6 +1,6 @@ import { registerSettings, registerHotkeys } from "./settings.js"; import { registerRuler } from "./patching.js"; -import { iterateGridUnder3dLine, projectElevatedPoint } from "./utility.js"; +import { iterateGridUnder3dLine, projectElevatedPoint, projectGridless } from "./utility.js"; export const MODULE_ID = 'elevationruler'; const FORCE_DEBUG = false; // used for logging before dev mode is set up @@ -25,6 +25,7 @@ Hooks.once('init', async function() { log("Initializing Elevation Ruler Options."); window['elevationRuler'] = { projectElevatedPoint: projectElevatedPoint, + projectGridless: projectGridless, iterateGridUnder3dLine: iterateGridUnder3dLine }; }); diff --git a/scripts/patching.js b/scripts/patching.js index aeb91e0..c2df756 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -10,7 +10,8 @@ import { elevationRulerAddProperties, elevationRulerGetText } from "./segments.js"; import { calculate3dDistance, - iterateGridUnder3dLine_wrapper } from "./utility.js"; + iterateGridUnder3dLine_wrapper, + points3dAlmostEqual } from "./utility.js"; export function registerRuler() { @@ -30,6 +31,7 @@ export function registerRuler() { // utilities libWrapper.register(MODULE_ID, 'window.libRuler.RulerUtilities.calculateDistance', calculate3dDistance, 'MIXED'); + libWrapper.register(MODULE_ID, 'window.libRuler.RulerUtilities.pointsAlmostEqual', points3dAlmostEqual, 'WRAPPER'); libWrapper.register(MODULE_ID, 'window.libRuler.RulerUtilities.iterateGridUnderLine', iterateGridUnder3dLine_wrapper, 'WRAPPER'); diff --git a/scripts/segments.js b/scripts/segments.js index f7de545..2be5a0f 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -148,7 +148,7 @@ export function elevationRulerConstructPhysicalPath(wrapped, ...args) { // --> this is done in AddProperties function log("Constructing the physical path."); const default_path = wrapped(...args); - log("Default path: (${default_path.origin.x}, ${default_path.origin.y}), (${default_path.destination.x}, ${default_path.destination.y})", default_path); + log(`Default path: (${default_path.origin.x}, ${default_path.origin.y}), (${default_path.destination.x}, ${default_path.destination.y})`, default_path); const starting_elevation = this.getFlag(MODULE_ID, "starting_elevation"); const ending_elevation = this.getFlag(MODULE_ID, "ending_elevation"); @@ -173,7 +173,7 @@ export function elevationRulerConstructPhysicalPath(wrapped, ...args) { default_path.origin.z = starting_elevation_grid_units; default_path.destination.z = (starting_elevation_grid_units + elevation_delta) * ratio; - log("Default path: (${default_path.origin.x}, ${default_path.origin.y}, ${default_path.origin.z}), (${default_path.destination.x}, ${default_path.destination.y}, ${default_path.destination.z})", default_path); + log(`Default path: (${default_path.origin.x}, ${default_path.origin.y}, ${default_path.origin.z}), (${default_path.destination.x}, ${default_path.destination.y}, ${default_path.destination.z})`, default_path); return default_path; } @@ -196,22 +196,10 @@ export function elevationRulerMeasurePhysicalPath(wrapped, physical_path) { if(!("z" in physical_path.origin)) physical_path.origin.z = 0; if(!("z" in physical_path.destination)) physical_path.destination.z = 0; - if(!window.libRuler.RulerUtilities.almostEqual(physical_path.origin.z, physical_path.destination.z)) { - // Project the 3-D path to 2-D canvas - log(`Projecting physical_path from origin ${physical_path.origin.x}, ${physical_path.origin.y}, ${physical_path.origin.z} to dest ${physical_path.destination.x}, ${physical_path.destination.y}, ${physical_path.destination.z}`); - physical_path.origin = projectElevatedPoint(physical_path.origin, physical_path.destination); - - // if we are using grid spaces, the destination needs to be re-centered to the grid. - // otherwise, when a token moves in 2-D diagonally, the 3-D measure will be inconsistent - // depending on cardinality of the move, as rounding will increase/decrease to the nearest gridspace - if(this.options?.gridSpaces) { - // canvas.grid.getCenter returns an array [x, y]; - const snapped = canvas.grid.getCenter(physical_path.origin.x, physical_path.origin.y); - log(`Snapping ${physical_path.origin.x}, ${physical_path.origin.y} to ${snapped[0]}, ${snapped[1]}`); - physical_path.origin = { x: snapped[0], y: snapped[1] }; - log(`Projected physical_path from origin ${physical_path.origin.x}, ${physical_path.origin.y} to dest ${physical_path.destination.x}, ${physical_path.destination.y}`); - } - } + // Project the 3-D path to 2-D canvas + // projectElevatedPoint will return origin/destination w/o z. + // projectElevatedPoint will not modify unless necessary. + [physical_path.origin, physical_path.destination] = projectElevatedPoint(physical_path.origin, physical_path.destination); } return wrapped(physical_path); diff --git a/scripts/utility.js b/scripts/utility.js index de374e3..f285613 100644 --- a/scripts/utility.js +++ b/scripts/utility.js @@ -60,23 +60,152 @@ export function iterateGridUnder3dLine_wrapper(wrapped, origin, destination) { * and you also move 'height' distance orthogonal to the plane, the distance is the * hypotenuse of the triangle formed by A, B, and C, where C is orthogonal to B. * Project by rotating the vertical triangle 90º, then calculate the new point C. - * - * Cx = { height * (By - Ay) / dist(A to B) } + Bx - * Cy = { height * (Bx - Ax) / dist(A to B) } + By - * @param {{x: number, y: number}} A - * @param {{x: number, y: number}} B + * For gridded maps, project A such that A <-> projected_A is straight on the grid. + * @param {{x: number, y: number, z: number}} A + * @param {{x: number, y: number, z: number}} B + */ export function projectElevatedPoint(A, B) { - const height = A.z - B.z; - const distance = window.libRuler.RulerUtilities.calculateDistance(A, B); + if(window.libRuler.RulerUtilities.pointsAlmostEqual(A, B)) { return [{ x: A.x, y: A.y }, { x: B.x, y: B.y }]; } + if(B.z === undefined || B.z === NaN) { B.z = A.z; } + if(A.z === undefined || A.z === NaN) { A.z = B.z; } + if(A.z === B.z) { return [{ x: A.x, y: A.y }, { x: B.x, y: B.y }]; } + if(window.libRuler.RulerUtilities.almostEqual(A.z, B.z)) { return [{ x: A.x, y: A.y }, { x: B.x, y: B.y }]; } + + switch(canvas.grid.type) { + case CONST.GRID_TYPES.GRIDLESS: return projectGridless(A, B); + case CONST.GRID_TYPES.SQUARE: return projectSquareGrid(A, B); + case CONST.GRID_TYPES.HEXODDR: + case CONST.GRID_TYPES.HEXEVENR: return projectEast(A, B); + case CONST.GRID_TYPES.HEXODDQ: + case CONST.GRID_TYPES.HEXEVENQ: return projectSouth(A, B); + } + + // catch-all + return projectGridless(A, B); +} + + /* + * Project A and B in a square grid. + * move A vertically or horizontally by the total height different + * If the points are already on a line, don't change B. + * So if B is to the west or east, set A to the south. + * Otherwise, set A to the east and B to the south. + * Represents the 90º rotation of the right triangle from height + */ +function projectSquareGrid(A, B) { + // if the points are already on a line, don't change B. + // Otherwise, set A to the east and B to the south + // Represents the 90º rotation of the right triangle from height + const height = Math.abs(A.z - B.z); + let projected_A, projected_B; + + if(window.libRuler.RulerUtilities.almostEqual(A.x, B.x)) { + // points are on vertical line + // set A to the east + // B is either north or south from A + // (quicker than calling projectEast b/c no distance calc req'd) + projected_A = {x: A.x + height, y: A.y}; // east + projected_B = {x: B.x, y: B.y}; + } else if(window.libRuler.RulerUtilities.almostEqual(A.y, B.y)) { + // points are on horizontal line + // B is either west or east from A + // set A to the south + // (quicker than calling projectSouth b/c no distance calc req'd) + projected_A = {x: A.x, y: A.y + height}; // south + projected_B = {x: B.x, y: B.y}; + } else { + // set B to point south, A pointing east + [projected_A, projected_B] = projectEast(A, B, height); + } + + log(`Projecting Square: A: (${A.x}, ${A.y}, ${A.z})->(${projected_A.x}, ${projected_A.y}); B: (${B.x}, ${B.y}, ${B.z})->(${projected_B.x}, ${projected_B.y})`); + + return [projected_A, projected_B]; +} + +function projectSouth(A, B, height, distance) { + if(height === undefined) height = A.z - B.z; + if(distance === undefined) distance = gridDistance(A, B); + + // set A pointing south; B pointing west + const projected_A = {x: A.x, y: A.y + height}; + const projected_B = {x: A.x - distance, y: A.y}; + + log(`Projecting South: A: (${A.x}, ${A.y}, ${A.z})->(${projected_A.x}, ${projected_A.y}); B: (${B.x}, ${B.y}, ${B.z})->(${projected_B.x}, ${projected_B.y})`); + + // if dnd5e 5-5-5 or 5-10-5, snap to nearest grid point + // origin should be fine if elevation is in increments. Otherwise, may need to be snapped. Leave for now. + // if((canvas.grid.diagonalRule === "555" || canvas.grid.diagonalRule === "5105" || game.system.id === "pf2e")) { +// log(`Snapping ${projected_B.x}, ${projected_B.y}`); +// [projected_B.x, projected_B.y] = canvas.grid.getCenter(projected_B.x, projected_B.y); +// log(`Snapped to ${projected_B.x}, ${projected_B.y}`); +// } +// + return [projected_A, projected_B]; +} + +function projectEast(A, B, height, distance) { + if(height === undefined) height = A.z - B.z; + if(distance === undefined) distance = gridDistance(A, B); + + // set A pointing east; B pointing south + const projected_A = {x: A.x + height, y: A.y}; + const projected_B = {x: A.x, y: A.y + distance}; + + log(`Projecting East: A: (${A.x}, ${A.y}, ${A.z})->(${projected_A.x}, ${projected_A.y}); B: (${B.x}, ${B.y}, ${B.z})->(${projected_B.x}, ${projected_B.y})`); + + // if dnd5e 5-5-5 or 5-10-5, snap to nearest grid point + // origin should be fine if elevation is in increments. Otherwise, may need to be snapped. Leave for now. + // if((canvas.grid.diagonalRule === "555" || canvas.grid.diagonalRule === "5105" || game.system.id === "pf2e")) { +// log(`Snapping ${projected_B.x}, ${projected_B.y}`); +// [projected_B.x, projected_B.y] = canvas.grid.getCenter(projected_B.x, projected_B.y); +// log(`Snapped to ${projected_B.x}, ${projected_B.y}`); +// } + + return [projected_A, projected_B]; +} + +function gridDistance(A, B) { + const use_grid = canvas.grid.diagonalRule === "555" || + canvas.grid.diagonalRule === "5105" || + game.system.id === "pf2e"; + + if(use_grid) { + const distance_segments = [{ray: new Ray(A, B)}]; + return canvas.grid.measureDistances(distance_segments, { gridSpaces: true })[0] * canvas.scene.data.grid / canvas.scene.data.gridDistance; + } + + return window.libRuler.RulerUtilities.calculateDistance({x: A.x, y: A.y}, + {x: B.x, y: B.y}) +} + + +/** + * Calculate a new point by projecting the elevated point back onto the 2-D surface + * If the movement on the plane is represented by moving from point A to point B, + * and you also move 'height' distance orthogonal to the plane, the distance is the + * hypotenuse of the triangle formed by A, B, and C, where C is orthogonal to B. + * Project by rotating the vertical triangle 90º, then calculate the new point C. + * + * Cx = { height * (By - Ay) / dist(A to B) } + Bx + * Cy = { height * (Bx - Ax) / dist(A to B) } + By + * @param {{x: number, y: number}} A + * @param {{x: number, y: number}} B + */ +export function projectGridless(A, B, height, distance) { + if(height === undefined) height = A.z - B.z; + if(distance === undefined) distance = window.libRuler.RulerUtilities.calculateDistance({x: A.x, y: A.y}, + {x: B.x, y: B.y}); + const projected_x = A.x + ((height / distance) * (B.y - A.y)); const projected_y = A.y - ((height / distance) * (B.x - A.x)); - // for square grids, rotate so that the origin point A is vertical or horizontal from original A? - // for hex grids, rotate so that the origin point A is in a straight line from original A? - // this will give correct results for diagonal moves, b/c A should always be straight line to projected A, as it is a vertical move + log(`Projecting Gridless: A: (${A.x}, ${A.y}, ${A.z})->(${projected_x}, ${projected_y}); B: (${B.x}, ${B.y}, ${B.z})->(${B.x}, ${B.y})`); + - return new PIXI.Point(projected_x, projected_y); + return [{ x: projected_x, y: projected_y }, + { x: B.x, y: B.y }]; } /* @@ -102,3 +231,21 @@ export function calculate3dDistance(wrapped, A, B, EPSILON = 1e-6) { } + /* + * Test if two points are almost equal, given a small error window. + * @param {PIXI.Point} p1 Point in {x, y, z} format. z optional + * @param {PIXI.Point} p2 Point in {x, y, z} format. + * @return {Boolean} True if the points are within the error of each other + */ +export function points3dAlmostEqual(wrapped, p1, p2, EPSILON = 1e-6) { + const equal2d = wrapped(p1, p2, EPSILON); + if(!equal2d) return false; + + if(p1.z === undefined || + p2.z === undefined || + p1.z === NaN || + p2.z === NaN) return true; + + return window.libRuler.RulerUtilities.almostEqual(p1.z, p2.z, EPSILON); +} +