diff --git a/Changelog.md b/Changelog.md index 93d9b72..188f9c8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,13 @@ +# 0.3.0 +Move to libRuler 0.1 compatibility. +Improvements: +- Wraps libRuler RulerUtilities functions to 3d versions: `iterateGridUnderLine`, `calculateDistance`, `pointsAlmostEqual`. +- Adds user setting to prefer the starting token elevation when measuring. +- Revamps the projection from 3-D to 2-D to account for specific grid types and diagonal rules. This should more closely correspond to user expectations concerning vertical movement in a grid. + +Breaking changes due to libRuler changes: +- Relies on the libRuler RulerUtilities functions + # 0.2.5 Correct "jumping token" issue where when the token is moved, it will appear to drift off the path and move twice. diff --git a/scripts/module.js b/scripts/module.js index a750af1..e3e9a42 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -1,7 +1,7 @@ import { registerSettings, registerHotkeys } from "./settings.js"; import { registerRuler } from "./patching.js"; -import { ProjectElevatedPoint } from "./segments.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 @@ -24,7 +24,9 @@ export function log(...args) { Hooks.once('init', async function() { log("Initializing Elevation Ruler Options."); - window['elevationRuler'] = { ProjectElevatedPoint: ProjectElevatedPoint }; + window['elevationRuler'] = { projectElevatedPoint: projectElevatedPoint, + projectGridless: projectGridless, + iterateGridUnder3dLine: iterateGridUnder3dLine }; }); diff --git a/scripts/patching.js b/scripts/patching.js index 6541eb7..c2df756 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -2,21 +2,24 @@ import { MODULE_ID, log } from "./module.js"; import { elevationRulerClear, elevationRulerAddWaypoint, elevationRulerRemoveWaypoint, - elevationRulerAnimateToken } from "./ruler.js"; import { elevationRulerAddProperties, elevationRulerConstructPhysicalPath, - elevationRulerDistanceFunction, + elevationRulerMeasurePhysicalPath, elevationRulerGetText } from "./segments.js"; +import { calculate3dDistance, + iterateGridUnder3dLine_wrapper, + points3dAlmostEqual } from "./utility.js"; + export function registerRuler() { // segment methods (for measuring) - libWrapper.register(MODULE_ID, 'window.libRuler.Segment.prototype.addProperties', elevationRulerAddProperties, 'WRAPPER'); - libWrapper.register(MODULE_ID, 'window.libRuler.Segment.prototype.constructPhysicalPath', elevationRulerConstructPhysicalPath, 'WRAPPER'); - libWrapper.register(MODULE_ID, 'window.libRuler.Segment.prototype.distanceFunction', elevationRulerDistanceFunction, 'WRAPPER'); - libWrapper.register(MODULE_ID, 'window.libRuler.Segment.prototype.text', elevationRulerGetText, 'WRAPPER'); + libWrapper.register(MODULE_ID, 'window.libRuler.RulerSegment.prototype.addProperties', elevationRulerAddProperties, 'WRAPPER'); + libWrapper.register(MODULE_ID, 'window.libRuler.RulerSegment.prototype.constructPhysicalPath', elevationRulerConstructPhysicalPath, 'WRAPPER'); + libWrapper.register(MODULE_ID, 'window.libRuler.RulerSegment.prototype.measurePhysicalPath', elevationRulerMeasurePhysicalPath, 'WRAPPER'); + libWrapper.register(MODULE_ID, 'window.libRuler.RulerSegment.prototype.text', elevationRulerGetText, 'WRAPPER'); // move token methods libWrapper.register(MODULE_ID, 'Ruler.prototype.animateToken', elevationRulerAnimateToken, 'WRAPPER'); @@ -26,5 +29,13 @@ export function registerRuler() { libWrapper.register(MODULE_ID, 'Ruler.prototype._addWaypoint', elevationRulerAddWaypoint, 'WRAPPER'); libWrapper.register(MODULE_ID, 'Ruler.prototype._removeWaypoint', elevationRulerRemoveWaypoint, 'WRAPPER'); + // 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'); + + log("registerRuler finished!"); } + + diff --git a/scripts/ruler.js b/scripts/ruler.js index 18d53f7..7763d11 100644 --- a/scripts/ruler.js +++ b/scripts/ruler.js @@ -22,156 +22,6 @@ import { ElevationAtPoint, toGridDistance } from "./segments.js"; // wrapping the constructor appears not to work. // see https://github.com/ruipin/fvtt-lib-wrapper/issues/14 - -/** - * 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 - */ - - - -function projectElevatedPoint(A, B, height) { - const distance = CalculateDistance(A, B); - const projected_x = B.x + ((height / distance) * (A.y - B.y)); - const projected_y = B.y - ((height / distance) * (A.x - B.x)); - - return new PIXI.Point(projected_x, projected_y); -} - -Object.defineProperty(Ruler.prototype, "projectElevatedPoint", { - value: projectElevatedPoint, - writable: true, - configurable: true -}); - -function CalculateDistance(A, B) { - const dx = B.x - A.x; - const dy = B.y - A.y; - return Math.hypot(dy, dx); -} - -// console.log(Math.hypot(3, 4)); -// // expected output: 5 -// -// console.log(Math.hypot(5, 12)); -// // expected output: 13 -// -// let m; -// let o = {x:0, y:0} -// m = ProjectElevatedPoint(o, {x:1, y:0}, 1); -// CalculateDistance(o, m) // 1.414 -// -// m = ProjectElevatedPoint(o, {x:3, y:0}, 4); -// CalculateDistance(o, m) // 5 -// -// m = ProjectElevatedPoint(o, {x:0, y:3}, 4); -// CalculateDistance(o, m) // 5 -// -// m = ProjectElevatedPoint(o, {x:0, y:3}, 4); - -// m = distance -// n = height -// A = origin () -// B = destination (1) -// C = destination with height (2) -// |Ay - By| / m = |Bx - Cx| / n -// |Ax - Bx| / m = |Cy - By| / n -// -// |Bx - Cx| / n = |Ay - By| / m -// |Cy - By| / n = |Ax - Bx| / m -// -// |Bx - Cx| = |Ay - By| * n/m -// |Cy - By| = |Ax - Bx| * n/m -// -// Bx - Cx = ± n/m * (Ay - By) -// Cy - By = ± n/m * (Ax - Bx) -// -// Cx = Bx ± n/m * (Ay - By) -// Cy = By ± n/m * (Ax - Bx) - - - - - -// will need to update measuring to account for elevation -export function elevationRulerMeasure(wrapped, destination, {gridSpaces=true}={}) { - log("we are measuring!"); - log(`${this.waypoints.length} waypoints. ${this.destination_elevation_increment} elevation increments for destination. ${this.elevation_increments.length} elevation waypoints.`, this.elevation_increments); - - // if no elevation present, go with original function. - if(!this.destination_elevation_increment && - (!this.elevation_increments || - this.elevation_increments.every(i => i === 0))) { - - log("Using original measure"); - return wrapped(destination, gridSpaces); - } - - // Mostly a copy from Ruler.measure, but adding in distance for elevation - // Original segments need to be retained so that the displayed path is correct. - // But the distances need to be modified to account for segment elevation. - // Project the elevated point back to the 2-D space, using a rotated right triangle. - // See, e.g. https://math.stackexchange.com/questions/927802/how-to-find-coordinates-of-3rd-vertex-of-a-right-angled-triangle-when-everything - - destination = new PIXI.Point(...canvas.grid.getCenter(destination.x, destination.y)); - const waypoints = this.waypoints.concat([destination]); - const waypoints_elevation = this.elevation_increments.concat([this.destination_elevation_increment]); - - const r = this.ruler; - this.destination = destination; - - log("Measure ruler", r); - - // Iterate over waypoints and construct segment rays - // Also create elevation segments, adjusting segments for elevation - // waypoint 0 is added as the origin (see _onDragStart) - // so elevation_waypoint 0 should also be the origin, and so 0 - // the for loop uses the next waypoint as destination. - // for loop will count from 0 to waypoints.length - 1 - - const segments = []; - const elevation_segments = []; - for ( let [i, dest] of waypoints.slice(1).entries() ) { - log(`Processing waypoint ${i}`, dest); - - const origin = waypoints[i]; - const label = this.labels.children[i]; - const ray = new Ray(origin, dest); - - // first waypoint is origin; elevation increment is 0. - // need to account for units of the grid - // canvas.scene.data.grid e.g. 140; canvas.scene.data.gridDistance e.g. 5 - const elevation = waypoints_elevation[i + 1] * canvas.scene.data.grid; - log("Origin", origin); - log("Destination", dest); - log(`Elevation ${elevation} for i = ${i}.`); - - - const elevated_dest = this.projectElevatedPoint(origin, dest, elevation); - const ray_elevated = new Ray(origin, elevated_dest); - - log("Elevated_dest", elevated_dest); - log("Ray", ray); - log("Elevated Ray", ray_elevated); - - if ( ray_elevated.distance < 10 ) { - if ( label ) label.visible = false; - continue; - } - segments.push({ray, label}); - elevation_segments.push({ray: ray_elevated, label: label}); - } -} - - // clear should reset elevation info export function elevationRulerClear(wrapped, ...args) { log("we are clearing!", this); @@ -260,7 +110,12 @@ export async function elevationRulerAnimateToken(wrapped, token, ray, dx, dy, se const incremental_elevation = toGridDistance(elevation_increments[segment_num]); const current_elevation = getProperty(token, "data.elevation"); - const destination_point_elevation = ElevationAtPoint(ray.B, undefined, current_elevation); + + const ignore_below = game.settings.get(MODULE_ID, "prefer-token-elevation") ? + this.getFlag(MODULE_ID, "starting_token_elevation") : + undefined; + + const destination_point_elevation = ElevationAtPoint(ray.B, current_elevation, ignore_below); const end_elevation = destination_point_elevation + incremental_elevation; log(`Current token elevation is ${current_elevation}. Will be changed to ${end_elevation}.`); diff --git a/scripts/segments.js b/scripts/segments.js index e37fe86..9e86fe6 100644 --- a/scripts/segments.js +++ b/scripts/segments.js @@ -1,4 +1,5 @@ import { MODULE_ID, log } from "./module.js"; +import { projectElevatedPoint } from "./utility.js"; /* * Add flags to the segment specific to elevation: @@ -51,8 +52,12 @@ UX goals: if(this.segment_num === 0) { // starting elevation equals the token elevation // if no token, use elevation at the point. - starting_elevation = ElevationAtPoint(this.ray.A, this.ruler._getMovementToken(), 0) // 0 starting elevation otherwise - log(`Starting elevation using origin ${this.ray.A.x}, ${this.ray.A.y}`, this.ruler._getMovementToken()); + const t = this.ruler._getMovementToken(); + const starting_token_elevation = t ? getProperty(t, "data.elevation") : undefined; + this.ruler.setFlag(MODULE_ID, "starting_token_elevation", starting_token_elevation); + + starting_elevation = t ? starting_token_elevation : ElevationAtPoint(this.ray.A, 0); + log(`Starting elevation using origin ${this.ray.A.x}, ${this.ray.A.y} is ${starting_elevation}`, this.ruler._getMovementToken()); } else { // starting elevation is the prior segment end elevation @@ -60,11 +65,13 @@ UX goals: log(`Current ending elevation is ${this.getFlag(MODULE_ID, "ending_elevation")}; Prior segment ending elevation is ${starting_elevation}`); } - const incremental_elevation = toGridDistance(elevation_increments[this.segment_num]); - const current_point_elevation = ElevationAtPoint(this.ray.B, undefined, starting_elevation); // no starting token; assume we are at the elevation from the last segment + const ignore_below = game.settings.get(MODULE_ID, "prefer-token-elevation") ? + this.ruler.getFlag(MODULE_ID, "starting_token_elevation") : + undefined; + const current_point_elevation = ElevationAtPoint(this.ray.B, starting_elevation, ignore_below); // no starting token; assume we are at the elevation from the last segment const ending_elevation = current_point_elevation + incremental_elevation; - log(`Current elevation using point ${this.ray.B.x}, ${this.ray.B.y}`); + log(`Current elevation using point ${this.ray.B.x}, ${this.ray.B.y} is ${current_point_elevation}`); log(`elevationRulerAddProperties segment ${this.segment_num}: ${starting_elevation}[start]; ${incremental_elevation}[incremental]; ${current_point_elevation}[current point]`); @@ -117,7 +124,29 @@ export function toGridDistance(increment) { return Math.round(increment * canvas.scene.data.gridDistance * 100) / 100; } - + /* + * Construct a physical path for the segment that represents how the measured item + * actually would move within the segment. + * + * This patch adds the 3rd dimension as z. + * + * The constructed path is an object with an origin and destination. + * By convention, each point should have at least x and y. If 3d, it should have z. + * The physical path object may have other properties, but these may be ignored by + * other modules. + * + * If you intend to create deviations from a line, you may want to include + * additional properties in the segment or in the path to represent those deviations. + * For example, a property for a formula to represent a curve. + * In such a case, modifying measurePhysicalPath distanceFunction methods may be necessary. + * + * @param {Segment} destination_point If provided, this should be either a Segment class or an object + * with the properties ray containing a Ray object. + * @return {Object} An object that contains {origin, destination}. + * It may contain other properties related to the physical path to be handled by specific modules. + * Default origin and destination will contain {x, y}. By convention, elevation should + * be represented by a {z} property. + */ export function elevationRulerConstructPhysicalPath(wrapped, ...args) { // elevate or lower the destination point in 3-D space // measure from the origin of the ruler movement, so that canvas = 0 and each segment @@ -125,7 +154,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); + 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"); @@ -142,55 +171,47 @@ export function elevationRulerConstructPhysicalPath(wrapped, ...args) { // destination // Need to apply canvas.scene.data.grid (140) and canvas.scene.data.gridDistance (5) // 7350 (x1) - 6930 (x0) = 420 (delta_x) / 140 * 5 = move in canvas units (e.g. 15') - - // will need to address later if there are multiple points in the physical path, rather - // than just origin and destination... const elevation_delta = ending_elevation_grid_units - starting_elevation_grid_units; const ruler_distance = this.ray.distance; - // destination - const simple_path_distance = CalculateDistance(default_path.origin, default_path.destination); + const simple_path_distance = window.libRuler.RulerUtilities.calculateDistance(default_path.origin, default_path.destination); const ratio = simple_path_distance / ruler_distance; - default_path.destination.z = starting_elevation_grid_units + elevation_delta * ratio; - - // origin default_path.origin.z = starting_elevation_grid_units; + default_path.destination.z = (starting_elevation_grid_units + elevation_delta) * ratio; - log("Default path", 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; } -export function elevationRulerDistanceFunction(wrapped, physical_path) { - // 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}`); - - // for each of the points, construct a 2-D path and send to the underlying function - // will need to address later if there are multiple points in the physical path, rather - // than just origin and destination... - - physical_path.origin = ProjectElevatedPoint(physical_path.origin, physical_path.destination); - delete physical_path.origin.z; - delete physical_path.destination.z; - - // if we are using grid spaces, the projected origin 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.measure_distance_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.destination.x}, ${physical_path.destination.y} to ${snapped[0]}, ${snapped[1]}`); - physical_path.origin = { x: snapped[0], y: snapped[1] }; + /* + * Extend libRuler measurePhysicalPath to measure in 3 dimensions. + * Project the z dimension back to the 2-D canvas and measure using the default + * distanceFunction method. + * Projection is accomplished by imagining a right triangle with the hypotenuse between + * p0 and p1, + * where p0 is the origin in 3d + * p1 is the destination in 3d + * @param {Object} physical_path An object that contains {origin, destination}. + * and the two sides of the triangle are orthogonal in 3d space. + * Each has {x, y, z} where z is optional. + * @return {Number} Total distance for the path + */ +export function elevationRulerMeasurePhysicalPath(wrapped, physical_path) { + if("z" in physical_path.origin || "z" in physical_path.destination) { + if(!("z" in physical_path.origin)) physical_path.origin.z = 0; + if(!("z" in physical_path.destination)) physical_path.destination.z = 0; + + // 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); } - - log(`Projected physical_path from origin ${physical_path.origin.x}, ${physical_path.origin.y} - to dest ${physical_path.destination.x}, ${physical_path.destination.y}`); - return wrapped(physical_path); } + /* * @param {number} segmentDistance * @param {number} totalDistance @@ -268,32 +289,34 @@ function segmentElevationLabel(segmentElevationIncrement, segmentCurrentElevatio * @return {Number} Elevation for the given point. */ // also needed to move tokens in Ruler class -export function ElevationAtPoint(p, token, starting_elevation = 0) { - if(token) { return getProperty(token, "data.elevation"); } - +export function ElevationAtPoint(p, starting_elevation = 0, ignore_below) { + if(ignore_below === undefined) ignore_below = Number.NEGATIVE_INFINITY; + + log(`Checking Elevation at (${p.x}, ${p.y}, ${p.z}) with starting elevation ${starting_elevation}, ignoring below ${ignore_below}`); + // check for tokens; take the highest one at a given position let tokens = retrieveVisibleTokens(); - const max_token_elevation = tokens.reduce((total, t) => { + const max_token_elevation = tokens.reduce((e, t) => { // is the point within the token control area? - if(!pointWithinToken(p, t)) return total; - return Math.max(t.data.elevation, total); - }, Number.NEGATIVE_INFINITY) || Number.NEGATIVE_INFINITY; + if(!pointWithinToken(p, t)) return e; + return Math.max(t.data.elevation, e); + }, Number.NEGATIVE_INFINITY); - log(`calculateEndElevation: ${tokens.length} tokens with maximum elevation ${max_token_elevation}`); + log(`calculateEndElevation: ${tokens.length} tokens at (${p.x}, ${p.y}, ${p.z}) with maximum elevation ${max_token_elevation}`); // use tokens rather than elevation if available if(isFinite(max_token_elevation)) { return max_token_elevation; } // try levels const levels_elevation = LevelsElevationAtPoint(p, starting_elevation); - if(levels_elevation !== undefined) { return levels_elevation; } + if(levels_elevation !== undefined && levels_elevation > ignore_below) { return levels_elevation; } // try terrain const terrain_elevation = TerrainElevationAtPoint(p); - if(terrain_elevation !== undefined) { return terrain_elevation; } + if(terrain_elevation !== undefined && terrain_elevation > ignore_below) { return terrain_elevation; } // default to 0 elevation for the point - return 0; + return Math.max(ignore_below, 0); } @@ -412,71 +435,3 @@ function checkForHole(intersectionPT, zz) { } return undefined; } - - -// ----- MATH FOR MEASURING ELEVATION DISTANCE ----- // -/** - * 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 ProjectElevatedPoint(A, B) { - const height = A.z - B.z; - const distance = CalculateDistance(A, B); - const projected_x = A.x + ((height / distance) * (B.y - A.y)); - const projected_y = A.y - ((height / distance) * (B.x - A.x)); - - return new PIXI.Point(projected_x, projected_y); -} - -function CalculateDistance(A, B) { - const dx = B.x - A.x; - const dy = B.y - A.y; - return Math.hypot(dy, dx); -} - -// console.log(Math.hypot(3, 4)); -// // expected output: 5 -// -// console.log(Math.hypot(5, 12)); -// // expected output: 13 -// -// let m; -// let o = {x:0, y:0} -// m = ProjectElevatedPoint(o, {x:1, y:0}, 1); -// CalculateDistance(o, m) // 1.414 -// -// m = ProjectElevatedPoint(o, {x:3, y:0}, 4); -// CalculateDistance(o, m) // 5 -// -// m = ProjectElevatedPoint(o, {x:0, y:3}, 4); -// CalculateDistance(o, m) // 5 -// -// m = ProjectElevatedPoint(o, {x:0, y:3}, 4); - -// m = distance -// n = height -// A = origin () -// B = destination (1) -// C = destination with height (2) -// |Ay - By| / m = |Bx - Cx| / n -// |Ax - Bx| / m = |Cy - By| / n -// -// |Bx - Cx| / n = |Ay - By| / m -// |Cy - By| / n = |Ax - Bx| / m -// -// |Bx - Cx| = |Ay - By| * n/m -// |Cy - By| = |Ax - Bx| * n/m -// -// Bx - Cx = ± n/m * (Ay - By) -// Cy - By = ± n/m * (Ax - Bx) -// -// Cx = Bx ± n/m * (Ay - By) -// Cy = By ± n/m * (Ax - Bx) diff --git a/scripts/settings.js b/scripts/settings.js index 0f744de..b5eaa54 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -15,6 +15,16 @@ export function registerSettings() { icon: "fas fa-arrows-alt-v", type: Hotkeys.createConfig('Elevation Ruler Hotkeys', [`${MODULE_ID}.change-elevation-group`]), }); + + log("Registering Elevation Ruler settings."); + game.settings.register(MODULE_ID, "prefer-token-elevation", { + name: 'Prefer Token Elevation', + hint: "If unset, dragging the ruler over the canvas will default to the elevation of the terrain (0 if none). If set, the ruler will remain at the token's elevation if the token is higher (for example, if the token is flying), unless the ruler is over another token.", + scope: "user", + config: true, + default: false, + type: Boolean + }); log("Registering terrain layer settings."); diff --git a/scripts/utility.js b/scripts/utility.js new file mode 100644 index 0000000..f285613 --- /dev/null +++ b/scripts/utility.js @@ -0,0 +1,251 @@ +import { log } from "./module.js"; + +/* + * Generator to iterate grid points under a line. + * This version handles lines in 3d. + * It assumes elevation movement by the set grid distance. + * @param {x: Number, y: Number, z: Number} origin Origination point + * @param {x: Number, y: Number, z: Number} destination Destination point + * @return Iterator, which in turn + * returns [row, col, elevation] for each grid point under the line. + */ +export function * iterateGridUnder3dLine(generator, origin, destination) { + let prior_elevation = origin.z || 0; + const end_elevation = destination.z || 0; + const direction = prior_elevation <= end_elevation ? 1 : -1; + const elevation_increment = canvas.scene.data.gridDistance * canvas.scene.data.grid; + log(`elevation: ${prior_elevation}[prior], ${end_elevation}[end], ${direction}[direction], ${elevation_increment}[increment]`); + //log(generator); + let last_row, last_col; + + for(const res of generator) { + // step down in elevation if necessary + log(res); + //const {value, done} = res; + const [row, col] = res; + [last_row, last_col] = res; + + if(prior_elevation != end_elevation) { + const remainder = Math.abs(prior_elevation - end_elevation); + const step_elevation = Math.min(remainder, elevation_increment); + prior_elevation += step_elevation * direction; + + } + yield [row, col, prior_elevation]; + } + + // more elevation? increment straight down. + const MAX_ITERATIONS = 1000; // to avoid infinite loops + let iteration = 0; + while(prior_elevation != end_elevation && iteration < MAX_ITERATIONS) { + iteration += 1; + const remainder = Math.abs(prior_elevation - end_elevation); + const step_elevation = Math.min(remainder, elevation_increment); + log(`elevation: ${prior_elevation}[prior], ${end_elevation}[end], ${step_elevation}[step]`); + prior_elevation += step_elevation * direction; + + yield [last_row, last_col, prior_elevation]; + } +} + +// needed for libWrapper +export function iterateGridUnder3dLine_wrapper(wrapped, origin, destination) { + log(`iterateGrid origin, destination`, origin, destination); + return iterateGridUnder3dLine(wrapped(origin, destination), origin, destination); +} + + /* + * 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. + * 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) { + 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)); + + 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 [{ x: projected_x, y: projected_y }, + { x: B.x, y: B.y }]; +} + + /* + * Calculate the distance between two points in {x,y,z} dimensions. + * @param {PIXI.Point} A Point in {x, y, z} format. + * @param {PIXI.Point} B Point in {x, y, z} format. + * @return The distance between the two points. + */ +export function calculate3dDistance(wrapped, A, B, EPSILON = 1e-6) { + if(A.z === undefined) A.z = 0; + if(B.z === undefined) B.z = 0; + + const dz = Math.abs(B.z - A.z); + if(dz < EPSILON) { return wrapped(A, B, EPSILON); } + + const dy = Math.abs(B.y - A.y); + if(dy < EPSILON) { return wrapped({x: A.x, y: A.z}, {x: B.x, y: B.z}, EPSILON); } + + const dx = Math.abs(B.x - A.x); + if(dx < EPSILON) { return wrapped({x: A.z, y: A.y}, {x: B.z, y: B.y}, EPSILON); } + + return Math.hypot(dz, dy, dx); +} + + + /* + * 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); +} +