diff --git a/Changelog.md b/Changelog.md index 93d9b72..fa20aac 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,9 @@ +# 0.3.0 +Move to libRuler 0.1 compatibility. +Breaking changes due to libRuler changes: +- `Segment` class is now `RulerSegment` +- libRuler now handles the measurement from a 3-D path; Elevation Ruler only creates the path. + # 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..5778771 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 } from "./utility.js"; + export const MODULE_ID = 'elevationruler'; const FORCE_DEBUG = false; // used for logging before dev mode is set up @@ -24,7 +24,8 @@ export function log(...args) { Hooks.once('init', async function() { log("Initializing Elevation Ruler Options."); - window['elevationRuler'] = { ProjectElevatedPoint: ProjectElevatedPoint }; + window['elevationRuler'] = { projectElevatedPoint: projectElevatedPoint, + iterateGridUnder3dLine: iterateGridUnder3dLine }; }); diff --git a/scripts/patching.js b/scripts/patching.js index 6541eb7..aeb91e0 100644 --- a/scripts/patching.js +++ b/scripts/patching.js @@ -2,21 +2,23 @@ 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 } 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 +28,12 @@ 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.iterateGridUnderLine', iterateGridUnder3dLine_wrapper, 'WRAPPER'); + + log("registerRuler finished!"); } + + diff --git a/scripts/ruler.js b/scripts/ruler.js index 18d53f7..3dba52f 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); diff --git a/scripts/segments.js b/scripts/segments.js index e37fe86..f7de545 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: @@ -117,7 +118,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 +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); + 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 +165,59 @@ 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; + + 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}`); + } + } } - - 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 @@ -412,71 +439,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/utility.js b/scripts/utility.js new file mode 100644 index 0000000..de374e3 --- /dev/null +++ b/scripts/utility.js @@ -0,0 +1,104 @@ +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. + * + * 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 = window.libRuler.RulerUtilities.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)); + + // 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 + + return new PIXI.Point(projected_x, projected_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); +} + +