diff --git a/Changelog.md b/Changelog.md index d3b65cf..73944c9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,10 @@ +## 0.1.3 +Bug fixes related to calculating movement penalty; added ability to calculate movement penalty for a given shape. +Fix for tile cache not updating. +Move PixelCache to lib geometry. +Fix for updating settings cache. +Update lib geometry to v0.2.17. + ## 0.1.2 Improvements to calculating movement penalty across a path for compatibility with Elevation Ruler: - Change `Terrain.percentMovementForTokenAlongPath` to return the movement penalty, not the movement percent applied to the token. So if the token is at 50% movement speed for a given terrain, this would return 1.5 for a path completely in the terrain. Necessary so the averaging across different terrains works properly. diff --git a/scripts/ModuleSettingsAbstract.js b/scripts/ModuleSettingsAbstract.js index f59b52c..d719d1c 100644 --- a/scripts/ModuleSettingsAbstract.js +++ b/scripts/ModuleSettingsAbstract.js @@ -16,13 +16,12 @@ PATCHES.BASIC = {}; /** * Wipe the settings cache on update */ -function updateSetting(document, change, options, userId) { // eslint-disable-line no-unused-vars - const [module, ...arr] = document.key.split("."); - const key = arr.join("."); // If the key has periods, multiple will be returned by split. - if ( module === MODULE_ID && ModuleSettingsAbstract.cache.has(key) ) ModuleSettingsAbstract.cache.delete(key); +async function set(wrapper, namespace, key, value, options) { + if ( namespace === MODULE_ID ) ModuleSettingsAbstract.cache.delete(key); + return wrapper(namespace, key, value, options); } -PATCHES.BASIC.HOOKS = { updateSetting }; +PATCHES.BASIC.WRAPS = { set }; export class ModuleSettingsAbstract { /** @type {Map} */ @@ -64,10 +63,7 @@ export class ModuleSettingsAbstract { * @param {*} value * @returns {Promise} */ - static async set(key, value) { - this.cache.delete(key); - return game.settings.set(MODULE_ID, key, value); - } + static async set(key, value) { return game.settings.set(MODULE_ID, key, value); } static async toggle(key) { const curr = this.get(key); diff --git a/scripts/Patcher.js b/scripts/Patcher.js index d59868b..6bb3c65 100644 --- a/scripts/Patcher.js +++ b/scripts/Patcher.js @@ -131,6 +131,7 @@ export class Patcher { isStatic: typeName.includes("STATIC") }; switch ( typeName ) { case "HOOKS": patchCl = HookPatch; break; + case "STATIC_OVERRIDES": // eslint-disable-line no-fallthrough case "OVERRIDES": case "STATIC_MIXES": @@ -142,10 +143,20 @@ export class Patcher { ? libWrapper.OVERRIDE : typeName.includes("MIXES") ? libWrapper.MIXED : libWrapper.WRAPPER; break; - case "STATIC_GETTERS": // eslint-disable-line no-fallthrough + + case "STATIC_GETTERS": // eslint-disable-line no-fallthrough case "GETTERS": cfg.isGetter = true; - default: // eslint-disable-line no-fallthrough + patchCl = MethodPatch; + break; + + case "STATIC_SETTERS": // eslint-disable-line no-fallthrough + case "SETTERS": + cfg.isSetter = true; + patchCl = MethodPatch; + break; + + default: patchCl = MethodPatch; } const thePatch = patchCl.create(patchName, patch, cfg); @@ -166,11 +177,20 @@ export class Patcher { * @param {boolean} [opts.optional] True if the getter should not be set if it already exists. * @returns {undefined|object= 128 ? 16 : 8; -Matrix = CONFIG.GeometryLib.Matrix - -cache = canvas.elevation.elevationPixelCache - -cache.drawLocal({ gammaCorrect: true }) -cache.draw({ gammaCorrect: true }) - - -dims = canvas.dimensions -opts = { - resolution: 0.5, // TODO: Remove these defaults - width: dims.sceneWidth, - height: dims.sceneHeight, - mipmap: PIXI.MIPMAP_MODES.OFF, - scaleMode: PIXI.SCALE_MODES.NEAREST, - multisample: PIXI.MSAA_QUALITY.NONE, - format: PIXI.FORMATS.RED - // Cannot be extracted ( GL_INVALID_OPERATION: Invalid format and type combination) - // format: PIXI.FORMATS.RED_INTEGER, - // type: PIXI.TYPES.INT - } - -tex = PIXI.RenderTexture.create(opts); -cache = PixelCache.fromTexture(tex, { x: dims.sceneX, y: dims.sceneY }) - - -// For the moment, evTexture is -evTexture = canvas.elevation._elevationTexture -cache = PixelCache.fromTexture(evTexture, { frame: canvas.dimensions.sceneRect }) - -// Average pixel value -let sum = 0; -sumFn = px => { - sum += px; -} -cache.applyFunction(sumFn, { frame: _token.bounds }) -cache.pixels.reduce((acc, curr) => acc + curr) - - -// Too big to actually reliably draw. -// cache.draw() - -// Instead pull a token-sized amount and draw it -evTexture = canvas.elevation._elevationTexture -cache = PixelCache.fromTexture(evTexture, { frame: _token.bounds }) -cache.draw({ alphaAdder: .2}) - -// Take a texture at resolution 1 and shrink it. - - -cache = PixelCache.fromTexture(evTexture, { frame: canvas.dimensions.sceneRect, resolution: 1/gridPrecision }) -cache.pixels.reduce((acc, curr) => acc + curr) -cache.draw({ alphaAdder: .2}) - -evTexture = canvas.elevation._elevationTexture -cacheOrig = PixelCache.fromTexture(evTexture, { frame: _token.bounds }) -cacheSmall = PixelCache.fromTexture(evTexture, { frame: _token.bounds, resolution: gridPrecision / gridSize }) -cacheOrig.draw({ alphaAdder: .2}) -cacheSmall.draw({ color: Draw.COLORS.red }) - -cacheOrig2 = PixelCache.fromTexture(evTexture, { frame: _token.bounds, scalingMethod: PixelCache.boxDownscaling }) -cacheSmall2 = PixelCache.fromTexture(evTexture, { - frame: _token.bounds, - resolution: gridPrecision / gridSize, - scalingMethod: PixelCache.boxDownscaling }) - - -colors = {} -colors["0"] = Draw.COLORS.gray -colors["5"] = Draw.COLORS.lightred, -colors["10"] = Draw.COLORS.lightblue, -colors["15"] = Draw.COLORS.lightgreen, -colors["20"] = Draw.COLORS.red, -colors["25"] = Draw.COLORS.blue, -colors["30"] = Draw.COLORS.green - -cacheSmall.drawColors({ defaultColor: Draw.COLORS.yellow, colors}) -cacheOrig.drawColors({ defaultColor: Draw.COLORS.yellow, colors}) - -cacheSmall.pixels.reduce((acc, curr) => Math.min(acc, curr)) -cacheSmall.pixels.reduce((acc, curr) => Math.max(acc, curr)) - - -[tile] = canvas.tiles.placeables -cacheTile1 = TilePixelCache.fromTileAlpha(tile); -cacheTile1sm = TilePixelCache.fromTileAlpha(tile, { resolution: 0.25 }); -cacheTile2 = TilePixelCache.fromOverheadTileAlpha(tile); - -cacheTile1.draw({local: true}) -cacheTile1sm.draw({local: true}) -cacheTile2.draw({local: true}) - -cacheTile1.draw() -cacheTile1sm.draw() -cacheTile2.draw() - -cacheTile1.drawLocal() -cacheTile1sm.drawLocal() -cacheTile2.drawLocal() - -function testCoordinateTransform(pixelCache) { - const { left, right, top, bottom } = pixelCache; - for ( let x = left; x <= right; x += 1 ) { - for ( let y = top; y <= bottom; y += 1 ) { - const local = pixelCache._fromCanvasCoordinates(x, y); - const canvas = pixelCache._toCanvasCoordinates(local.x, local.y); - if ( !canvas.almostEqual({x, y}) ) { - console.log(`${x},${y} not equal.`); - return false; - } - } - } - return true; -} - -testCoordinateTransform(cacheTile1) -testCoordinateTransform(cacheTile1sm) - -fn = function() { - return PixelCache.fromTexture(canvas.elevation._elevationTexture); -} - -fn2 = function() { - const { pixels } = extractPixels(canvas.app.renderer, canvas.elevation._elevationTexture); - return pixels; -} - -async function fn3() { - const pixels = await canvas.app.renderer.plugins.extractAsync.pixels(canvas.elevation._elevationTexture) - return pixels -} - -async function fn4() { - return canvas.app.renderer.plugins.extractAsync.pixels(canvas.elevation._elevationTexture) -} - -await foundry.utils.benchmark(fn, 100) -await foundry.utils.benchmark(fn2, 100) -await foundry.utils.benchmark(fn3, 100) - - -*/ - -/* Resolution math - -Assume 4000 x 3000 texture. - -If resolution is 0.5 --> 2000 x 1500. - -If texture resolution is 0.5 --> 2000 x 1500. - -Combined ---> 1000 x 750. Which is 0.5 * 0.5 = 0.25. -*/ - - -// Original function: -// function fastFixed(num, n) { -// const pow10 = Math.pow(10,n); -// return Math.round(num*pow10)/pow10; // roundFastPositive fails for very large numbers -// } - -/** - * Fix a number to 8 decimal places - * @param {number} x Number to fix - * @returns {number} - */ -const POW10_8 = Math.pow(10, 8); -function fastFixed(x) { - return Math.round(x * POW10_8) / POW10_8; -} - - -/** - * Class representing a rectangular array of pixels, typically pulled from a texture. - * The underlying rectangle is in canvas coordinates. - */ -export class PixelCache extends PIXI.Rectangle { - /** @type {Uint8ClampedArray} */ - pixels = new Uint8ClampedArray(0); - - /** @type {number} */ - #localWidth = 0; - - /** @type {number} */ - #localHeight = 0; - - /** @type {PIXI.Rectangle} */ - localFrame = new PIXI.Rectangle(); - - /** @type {number} */ - #maximumPixelValue = 255; - - /** @type {Map} */ - #thresholdLocalBoundingBoxes = new Map(); - - /** @type {Map} */ - #thresholdCanvasBoundingBoxes = new Map(); - - /** - * @type {object} - * @property {number} x Translation in x direction - * @property {number} y Translation in y direction - * @property {number} resolution Ratio of pixels to canvas values. - */ - scale = { - resolution: 1 - }; - - /** @type {Matrix} */ - #toLocalTransform; - - /** @type {Matrix} */ - #toCanvasTransform; - - /** - * @param {number[]} pixels Array of integer values. - * @param {number} pixelWidth The width of the pixel rectangle. - * @param {object} [opts] Optional translation - * @param {number} [opts.x] Starting left canvas coordinate - * @param {number} [opts.y] Starting top canvas coordinate - * @param {number} [opts.resolution] Ratio between pixel width and canvas width: - * pixel width * resolution = canvas width. - */ - constructor(pixels, pixelWidth, { x = 0, y = 0, pixelHeight, resolution = 1 } = {}) { - // Clean up pixel width and define pixel height if not already. - const nPixels = pixels.length; - pixelWidth = roundFastPositive(pixelWidth); - pixelHeight ??= nPixels / pixelWidth; - if ( !Number.isInteger(pixelHeight) ) { - console.warn(`PixelCache pixelHeight is non-integer: ${pixelHeight}`); - pixelHeight = Math.ceil(pixelHeight); - } - - // Define the canvas rectangle. - const invResolution = 1 / resolution; - const canvasWidth = Math.ceil(pixelWidth * invResolution); - const canvasHeight = Math.ceil(pixelHeight * invResolution); - super(x, y, canvasWidth, canvasHeight); - - // Store values needed to translate between local and canvas coordinates. - this.pixels = pixels; - this.scale.resolution = resolution; - this.scale.invResolution = invResolution; - this.#localWidth = pixelWidth; - this.#localHeight = pixelHeight; - this.localFrame.width = this.#localWidth; - this.localFrame.height = this.#localHeight; - } - - - /** - * Refresh this pixel cache from a texture. - * Just like PixelCache.texture except that it overwrites this texture. - * Does not overwrite other texture parameters. - * @param {PIXI.Texture} texture Texture from which to pull pixel data - * @param {object} [options] Options affecting which pixel data is used - * @param {PIXI.Rectangle} [options.frame] Optional rectangle to trim the extraction - * @param {number} [options.resolution=1] At what resolution to pull the pixels - * @param {number} [options.x=0] Move the texture in the x direction by this value - * @param {number} [options.y=0] Move the texture in the y direction by this value - * @param {number} [options.channel=0] Which RGBA channel, where R = 0, A = 3. - * @param {function} [options.scalingMethod=PixelCache.nearestNeighborScaling] - * @param {function} [options.combineFn] Function to combine multiple channels of pixel data. - * Will be passed the r, g, b, and a channels. - * @param {TypedArray} [options.arr] - * @returns {PixelCache} - */ - updateFromTexture(texture, opts = {}) { - const { pixels, width, height } = extractPixels(canvas.app.renderer, texture, opts.frame); - const combinedPixels = opts.combineFn - ? this.constructor.combinePixels(pixels, opts.combineFn, opts.arrayClass) : pixels; - - opts.x ??= 0; - opts.y ??= 0; - opts.resolution ??= 1; - opts.channel ??= 0; - opts.scalingMethod ??= this.constructor.nearestNeighborScaling; - opts.scalingMethod(combinedPixels, width, height, opts.resolution, { - channel: opts.channel, - skip: opts.combineFn ? 1 : 4, - arrayClass: opts.arrayClass, - arr: this.pixels }); - - // Clear cached parameters. - this.clearTransforms(); - this._clearLocalThresholdBoundingBoxes(); - return this; - } - - /** - * Test whether the pixel cache contains a specific canvas point. - * See Tile.prototype.containsPixel - * @param {number} x Canvas x-coordinate - * @param {number} y Canvas y-coordinate - * @param {number} [alphaThreshold=0.75] Value required for the pixel to "count." - * @returns {boolean} - */ - containsPixel(x, y, alphaThreshold = 0.75) { - // First test against the bounding box - const bounds = this.getThresholdCanvasBoundingBox(alphaThreshold); - if ( !bounds.contains(x, y) ) return false; - - // Next test a specific pixel - const value = this.pixelAtCanvas(x, y); - return value > (alphaThreshold * this.#maximumPixelValue); - } - - /** @type {Matrix} */ - get toLocalTransform() { - return this.#toLocalTransform ?? (this.#toLocalTransform = this._calculateToLocalTransform()); - } - - /** @type {Matrix} */ - get toCanvasTransform() { - return this.#toCanvasTransform ?? (this.#toCanvasTransform = this.toLocalTransform.invert()); - } - - /** @type {number} */ - get maximumPixelValue() { return this.#maximumPixelValue; } - - /** - * Reset transforms. Typically used when size or resolution has changed. - */ - clearTransforms() { - this.#toLocalTransform = undefined; - this.#toCanvasTransform = undefined; - this.#thresholdCanvasBoundingBoxes.clear(); - } - - /** - * Clear the threshold bounding boxes. Should be rare, if ever, b/c these are local rects - * based on supposedly unchanging pixels. - */ - _clearLocalThresholdBoundingBoxes() { - this.#thresholdCanvasBoundingBoxes.clear(); - this.#thresholdLocalBoundingBoxes.clear(); - } - - _clearCanvasThresholdBoundingBoxes() { this.#thresholdCanvasBoundingBoxes.clear(); } - - /** - * Matrix that takes a canvas point and transforms to a local point. - * @returns {Matrix} - */ - _calculateToLocalTransform() { - // Translate so top corner is at 0, 0 - const { x, y, scale } = this; - const mTranslate = Matrix.translation(-x, -y); - - // Scale based on resolution. - const resolution = scale.resolution; - const mRes = Matrix.scale(resolution, resolution); - - // Combine the matrices - return mTranslate.multiply3x3(mRes); - } - - /** - * Get a canvas bounding box based on a specific threshold. - * @param {number} [threshold=0.75] Values lower than this will be ignored around the edges. - * @returns {PIXI.Rectangle} Rectangle based on local coordinates. - */ - getThresholdLocalBoundingBox(threshold = 0.75) { - const map = this.#thresholdLocalBoundingBoxes; - if ( !map.has(threshold) ) map.set(threshold, this.#calculateLocalBoundingBox(threshold)); - return map.get(threshold); - } - - /** - * Get a canvas bounding polygon or box based on a specific threshold. - * If you require a rectangle, use getThresholdLocalBoundingBox - * @returns {PIXI.Rectangle|PIXI.Polygon} Rectangle or polygon in canvas coordinates. - */ - getThresholdCanvasBoundingBox(threshold = 0.75) { - const map = this.#thresholdCanvasBoundingBoxes; - if ( !map.has(threshold) ) map.set(threshold, this.#calculateCanvasBoundingBox(threshold)); - return map.get(threshold); - } - - /** - * Calculate a canvas bounding box based on a specific threshold. - */ - #calculateCanvasBoundingBox(threshold=0.75) { - const localRect = this.getThresholdLocalBoundingBox(threshold); - - const { left, right, top, bottom } = localRect; - const TL = this._toCanvasCoordinates(left, top); - const TR = this._toCanvasCoordinates(right, top); - const BL = this._toCanvasCoordinates(left, bottom); - const BR = this._toCanvasCoordinates(right, bottom); - - // Can the box be represented with a rectangle? Points must be horizontal and vertical. - // Could also be rotated 90º - if ( (TL.x.almostEqual(BL.x) && TL.y.almostEqual(TR.y)) - || (TL.x.almostEqual(TR.x) && TL.y.almostEqual(BL.y)) ) { - const xMinMax = Math.minMax(TL.x, TR.x, BL.x, BR.x); - const yMinMax = Math.minMax(TL.y, TR.y, BL.y, BR.y); - return new PIXI.Rectangle(xMinMax.min, yMinMax.min, xMinMax.max - xMinMax.min, yMinMax.max - yMinMax.min); - } - - // Alternatively, represent as polygon, which allows for a tighter contains test. - return new PIXI.Polygon(TL, TR, BR, BL); - } - - - /** - * Calculate a bounding box based on a specific threshold. - * @param {number} [threshold=0.75] Values lower than this will be ignored around the edges. - * @returns {PIXI.Rectangle} Rectangle based on local coordinates. - */ - #calculateLocalBoundingBox(threshold=0.75) { - // (Faster or equal to the old method that used one double non-breaking loop.) - threshold = threshold * this.#maximumPixelValue; - - // By definition, the local frame uses 0 or positive integers. So we can use -1 as a placeholder value. - const { left, right, top, bottom } = this.localFrame; - let minLeft = -1; - let maxRight = -1; - let minTop = -1; - let maxBottom = -1; - - // Test left side - for ( let x = left; x <= right; x += 1 ) { - for ( let y = top; y <= bottom; y += 1 ) { - const a = this._pixelAtLocal(x, y); - if ( a > threshold ) { - minLeft = x; - break; - } - } - if ( ~minLeft ) break; - } - if ( !~minLeft ) return new PIXI.Rectangle(); - - // Test right side - for ( let x = right; x >= left; x -= 1 ) { - for ( let y = top; y <= bottom; y += 1 ) { - const a = this._pixelAtLocal(x, y); - if ( a > threshold ) { - maxRight = x; - break; - } - } - if ( ~maxRight ) break; - } - - // Test top side - for ( let y = top; y <= bottom; y += 1 ) { - for ( let x = left; x <= right; x += 1 ) { - const a = this._pixelAtLocal(x, y); - if ( a > threshold ) { - minTop = y; - break; - } - } - if ( ~minTop ) break; - } - - // Test bottom side - for ( let y = bottom; y >= top; y -= 1 ) { - for ( let x = left; x <= right; x += 1 ) { - const a = this._pixelAtLocal(x, y); - if ( a > threshold ) { - maxBottom = y; - break; - } - } - if ( ~maxBottom ) break; - } - - // Pad right/bottom by 1 b/c otherwise they would be inset. - // Pad all by 1 to ensure that any pixel on the thresholdBounds is under the threshold. - minLeft -= 1; - minTop -= 1; - maxRight += 2; - maxBottom += 2; - return (new PIXI.Rectangle(minLeft, minTop, maxRight - minLeft, maxBottom - minTop)); - } - - _calculateCanvasBoundingBox(threshold=0.75) { - return this.#calculateCanvasBoundingBox(threshold); - } - - /** - * Pixel index for a specific texture location - * @param {number} x Local texture x coordinate - * @param {number} y Local texture y coordinate - * @returns {number} - */ - _indexAtLocal(x, y) { - if ( x < 0 || y < 0 || x >= this.#localWidth || y >= this.#localHeight ) return -1; - - // Use floor to ensure consistency when converting to/from coordinates <--> index. - return ((~~y) * this.#localWidth) + (~~x); - // Equivalent: return (roundFastPositive(y) * this.#localWidth) + roundFastPositive(x); - } - - /** - * Calculate local coordinates given a pixel index. - * Inverse of _indexAtLocal - * @param {number} i The index, corresponding to a pixel in the array. - * @returns {PIXI.Point} - */ - _localAtIndex(i) { - const width = this.#localWidth; - const col = i % width; - const row = ~~(i / width); // Floor the row. - return new PIXI.Point(col, row); - } - - /** - * Calculate the canvas coordinates for a specific pixel index - * @param {number} i The index, corresponding to a pixel in the array. - * @returns {PIXI.Point} - */ - _canvasAtIndex(i) { - const local = this._localAtIndex(i); - return this._toCanvasCoordinates(local.x, local.y); - } - - /** - * Pixel index for a specific texture location - * @param {number} x Canvas x coordinate - * @param {number} y Canvas y coordinate - * @returns {number} - */ - _indexAtCanvas(x, y) { - const local = this._fromCanvasCoordinates(x, y); - return this._indexAtLocal(local.x, local.y); - } - - /** - * Transform canvas coordinates into the local pixel rectangle coordinates. - * @param {number} x Canvas x coordinate - * @param {number} y Canvas y coordinate - * @returns {PIXI.Point} - */ - _fromCanvasCoordinates(x, y) { - const pt = new PIXI.Point(x, y); - const local = this.toLocalTransform.multiplyPoint2d(pt, pt); - - // Avoid common rounding errors, like 19.999999999998. - local.x = fastFixed(local.x); - local.y = fastFixed(local.y); - return local; - } - - /** - * Transform local coordinates into canvas coordinates. - * Inverse of _fromCanvasCoordinates - * @param {number} x Local x coordinate - * @param {number} y Local y coordinate - * @returns {PIXI.Point} - */ - _toCanvasCoordinates(x, y) { - const pt = new PIXI.Point(x, y); - const canvas = this.toCanvasTransform.multiplyPoint2d(pt, pt); - - // Avoid common rounding errors, like 19.999999999998. - canvas.x = fastFixed(canvas.x); - canvas.y = fastFixed(canvas.y); - return canvas; - } - - /** - * Convert a ray to local texture coordinates - * @param {Ray} - * @returns {Ray} - */ - _rayToLocalCoordinates(ray) { - return new Ray( - this._fromCanvasCoordinates(ray.A.x, ray.A.y), - this._fromCanvasCoordinates(ray.B.x, ray.B.y)); - } - - /** - * Convert a circle to local texture coordinates - * @param {PIXI.Circle} - * @returns {PIXI.Circle} - */ - _circleToLocalCoordinates(circle) { - const origin = this._fromCanvasCoordinates(circle.x, circle.y); - - // For radius, use two points of equivalent distance to compare. - const radius = this._fromCanvasCoordinates(circle.radius, 0).x - - this._fromCanvasCoordinates(0, 0).x; - return new PIXI.Circle(origin.x, origin.y, radius); - } - - /** - * Convert an ellipse to local texture coordinates - * @param {PIXI.Ellipse} - * @returns {PIXI.Ellipse} - */ - _ellipseToLocalCoordinates(ellipse) { - const origin = this._fromCanvasCoordinates(ellipse.x, ellipse.y); - - // For halfWidth and halfHeight, use two points of equivalent distance to compare. - const halfWidth = this._fromCanvasCoordinates(ellipse.halfWidth, 0).x - - this._fromCanvasCoordinates(0, 0).x; - const halfHeight = this._fromCanvasCoordinates(ellipse.halfHeight, 0).x - - this._fromCanvasCoordinates(0, 0).x; - return new PIXI.Ellipse(origin.x, origin.y, halfWidth, halfHeight); - } - - /** - * Convert a rectangle to local texture coordinates - * @param {PIXI.Rectangle} rect - * @returns {PIXI.Rectangle} - */ - _rectangleToLocalCoordinates(rect) { - const TL = this._fromCanvasCoordinates(rect.left, rect.top); - const BR = this._fromCanvasCoordinates(rect.right, rect.bottom); - return new PIXI.Rectangle(TL.x, TL.y, BR.x - TL.x, BR.y - TL.y); - } - - /** - * Convert a polygon to local texture coordinates - * @param {PIXI.Polygon} - * @returns {PIXI.Polygon} - */ - _polygonToLocalCoordinates(poly) { - const points = poly.points; - const ln = points.length; - const newPoints = Array(ln); - for ( let i = 0; i < ln; i += 2 ) { - const x = points[i]; - const y = points[i + 1]; - const local = this._fromCanvasCoordinates(x, y); - newPoints[i] = local.x; - newPoints[i + 1] = local.y; - } - return new PIXI.Polygon(newPoints); - } - - /** - * Convert a shape to local coordinates. - * @param {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle|PIXI.Ellipse} shape - * @returns {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle|PIXI.Ellipse} - */ - _shapeToLocalCoordinates(shape) { - if ( shape instanceof PIXI.Rectangle ) return this._rectangleToLocalCoordinates(shape); - else if ( shape instanceof PIXI.Polygon ) return this._polygonToLocalCoordinates(shape); - else if ( shape instanceof PIXI.Circle ) return this._circleToLocalCoordinates(shape); - else if ( shape instanceof PIXI.Ellipse ) return this._ellipseToLocalCoordinates(shape); - else console.error("applyFunctionToShape: shape not recognized."); - } - - /** - * Get a pixel value given local coordinates. - * @param {number} x Local x coordinate - * @param {number} y Local y coordinate - * @returns {number|null} Return null otherwise. Sort will put nulls between -1 and 0. - */ - _pixelAtLocal(x, y) { return this.pixels[this._indexAtLocal(x, y)] ?? null; } - - /** - * Get a pixel value given canvas coordinates. - * @param {number} x Canvas x coordinate - * @param {number} y Canvas y coordinate - * @returns {number} - */ - pixelAtCanvas(x, y) { return this.pixels[this._indexAtCanvas(x, y)] ?? null; } - - /** - * Trim a line segment to only the portion that intersects this cache bounds. - * @param {Point} a Starting location, in canvas coordinates - * @param {Point} b Ending location, in canvas coordinates - * @param {number} alphaThreshold Value of threshold, if threshold bounds should be used. - * @returns {Point[2]|null} Points, in local coordinates. - */ - _trimCanvasRayToLocalBounds(a, b, alphaThreshold) { - const aLocal = this._fromCanvasCoordinates(a.x, a.y); - const bLocal = this._fromCanvasCoordinates(b.x, b.y); - return this._trimLocalRayToLocalBounds(aLocal, bLocal, alphaThreshold); - } - - /** - * Trim a line segment to only the portion that intersects this cache bounds. - * @param {Point} a Starting location, in local coordinates - * @param {Point} b Ending location, in local coordinates - * @param {number} alphaThreshold Value of threshold, if threshold bounds should be used. - * @returns {Point[2]|null} Points, in local coordinates - */ - _trimLocalRayToLocalBounds(a, b, alphaThreshold) { - const bounds = alphaThreshold ? this.getThresholdLocalBoundingBox(alphaThreshold) : this.localFrame; - return trimLineSegmentToPixelRectangle(bounds, a, b); - } - - // Convert a local point to canvas point, overwriting the local point. - #localToCanvasInline(pt) { - const canvasPt = this._toCanvasCoordinates(pt.x, pt.y); - pt.x = canvasPt.x; - pt.y = canvasPt.y; - return pt; - } - - // TODO: Combine the extraction functions so there is less repetition of code. - - /** - * Extract all pixel values for a canvas ray. - * @param {Point} a Starting location, in local coordinates - * @param {Point} b Ending location, in local coordinates - * @param {object} [opts] Optional parameters - * @param {number} [opts.alphaThreshold] Percent between 0 and 1, used to trim the pixel bounds - * @param {number[]} [opts.localOffsets] Numbers to add to the local x,y position when pulling the pixel(s) - * @param {function} [opts.reducerFn] Function that takes pixel array and reduces to a value or object to return - * @returns {number[]} The pixel values - */ - _extractAllPixelValuesAlongCanvasRay(a, b, { alphaThreshold, localOffsets, reducerFn } = {}) { - const localBoundsIx = this._trimCanvasRayToLocalBounds(a, b, alphaThreshold); - if ( !localBoundsIx ) return []; // Ray never intersects the cache bounds. - - const pixels = this._extractAllPixelValuesAlongLocalRay( - localBoundsIx[0], localBoundsIx[1], localOffsets, reducerFn); - pixels.forEach(pt => this.#localToCanvasInline(pt)); - return pixels; - } - - /** - * Extract all pixel values for a local ray. - * It is assumed, without checking, that a and be are within the bounds of the shape. - * @param {Point} a Starting location, in local coordinates - * @param {Point} b Ending location, in local coordinates - * @param {number[]} [localOffsets] Numbers to add to the local x,y position when pulling the pixel(s) - * @param {function} [reducerFn] Function that takes pixel array and reduces to a value or object to return - * @returns {number[]} The pixel values - */ - _extractAllPixelValuesAlongLocalRay(a, b, localOffsets, reducerFn) { - localOffsets ??= [0, 0]; - reducerFn ??= this.constructor.pixelAggregator("first"); - - const bresPts = bresenhamLine(a.x, a.y, b.x, b.y); - const nPts = bresPts.length; - const pixels = Array(nPts * 0.5); - for ( let i = 0, j = 0; i < nPts; i += 2, j += 1 ) { - const x = bresPts[i]; - const y = bresPts[i + 1]; - const pixelsAtPoint = this._pixelsForRelativePointsFromLocal(x, y, localOffsets); - const currPixel = reducerFn(pixelsAtPoint); - pixels[j] = { x, y, currPixel }; - } - return pixels; - } - - /** - * Extract all pixels values along a canvas ray that meet a test function. - * @param {Point} a Starting location, in canvas coordinates - * @param {Point} b Ending location, in canvas coordinates - * @param {function} markPixelFn Function to test pixels: (current pixel, previous pixel); returns true to mark - * @param {object} [opts] Optional parameters - * @param {number} [opts.alphaThreshold] Percent between 0 and 1, used to trim the pixel bounds - * @param {boolean} [opts.skipFirst] Skip the first pixel if true - * @param {boolean} [opts.forceLast] Include the last pixel (at b) even if unmarked - * @param {number[]} [opts.localOffsets] Numbers to add to the local x,y position when pulling the pixel(s) - * @param {function} [opts.reducerFn] Function that takes pixel array and reduces to a value or object to return - * @returns {object[]} Array of objects, each of which have: - * - {number} x Canvas coordinates - * - {number} y Canvas coordinates - * - {number} currPixel - * - {number} prevPixel - */ - _extractAllMarkedPixelValuesAlongCanvasRay(a, b, markPixelFn, - { alphaThreshold, skipFirst, forceLast, localOffsets, reducerFn } = {}) { - const localBoundsIx = this._trimCanvasRayToLocalBounds(a, b, alphaThreshold); - if ( !localBoundsIx ) return []; // Ray never intersects the cache bounds. - - const pixels = this._extractAllMarkedPixelValuesAlongLocalRay( - localBoundsIx[0], localBoundsIx[1], markPixelFn, skipFirst, forceLast, localOffsets, reducerFn); - pixels.forEach(pt => this.#localToCanvasInline(pt)); - return pixels; - } - - /** - * Extract all pixel values along a local ray that meet a test function. - * @param {Point} a Starting location, in local coordinates - * @param {Point} b Ending location, in local coordinates - * @param {function} markPixelFn Function to test pixels: (currentPixel, previousPixel); returns true to mark - * @param {boolean} skipFirst Skip the first pixel if true - * @param {boolean} forceLast Include the last pixel (at b) even if unmarked - * @param {number[]} [localOffsets] Numbers to add to the local x,y position when pulling the pixel(s) - * @param {function} [reducerFn] Function that takes pixel array and reduces to a value or object to return - * @returns {object[]} Array of objects, each of which have: - * - {number} x Local coordinates - * - {number} y Local coordinates - * - {number} currPixel - * - {number} prevPixel - */ - _extractAllMarkedPixelValuesAlongLocalRay(a, b, markPixelFn, skipFirst, forceLast, localOffsets, reducerFn) { - skipFirst ??= false; - forceLast ??= false; - localOffsets ??= [0, 0]; - reducerFn ??= this.constructor.pixelAggregator("first"); - - const bresPts = bresenhamLine(a.x, a.y, b.x, b.y); - const pixels = []; - let prevPixel; - if ( skipFirst ) { - const x = bresPts.shift(); - const y = bresPts.shift(); - if ( typeof y === "undefined" ) return pixels; // No more pixels! - const pixelsAtPoint = this._pixelsForRelativePointsFromLocal(x, y, localOffsets); - prevPixel = reducerFn(pixelsAtPoint); - } - - const nPts = bresPts.length; - for ( let i = 0; i < nPts; i += 2 ) { - const x = bresPts[i]; - const y = bresPts[i + 1]; - const pixelsAtPoint = this._pixelsForRelativePointsFromLocal(x, y, localOffsets); - const currPixel = reducerFn(pixelsAtPoint); - if ( markPixelFn(currPixel, prevPixel) ) pixels.push({ currPixel, prevPixel, x, y }); - prevPixel = currPixel; - } - - if ( forceLast ) { - const x = bresPts.at(-2); - const y = bresPts.at(-1); - // Add the last pixel regardless. - pixels.push({ currPixel: prevPixel, x, y, forceLast }); - } - - return pixels; - } - - /** - * Convenience function. - * Extract the first pixel value along a canvas ray that meets a test function. - * @param {Point} a Starting location, in canvas coordinates - * @param {Point} b Ending location, in canvas coordinates - * @param {function} markPixelFn Function to test pixels. - * Function takes current pixel, previous pixel - * @returns {object|null} If pixel found, returns: - * - {number} x Canvas coordinate - * - {number} y Canvas coordinate - * - {number} currPixel - * - {number} prevPixel - */ - _extractNextMarkedPixelValueAlongCanvasRay(a, b, markPixelFn, - { alphaThreshold, skipFirst, forceLast, localOffsets, reducerFn } = {}) { - - const localBoundsIx = this._trimCanvasRayToLocalBounds(a, b, alphaThreshold); - if ( !localBoundsIx ) return null; // Ray never intersects the cache bounds. - - const pixel = this._extractNextMarkedPixelValueAlongLocalRay( - localBoundsIx[0], localBoundsIx[1], markPixelFn, skipFirst, forceLast, localOffsets, reducerFn); - if ( !pixel ) return pixel; - this.#localToCanvasInline(pixel); - return pixel; - } - - /** - * Extract the first pixel value along a local ray that meets a test function. - * @param {Point} a Starting location, in local coordinates - * @param {Point} b Ending location, in local coordinates - * @param {function} markPixelFn Function to test pixels. - * Function takes current pixel, previous pixel - * @returns {object|null} If pixel found, returns: - * - {number} x Local coordinate - * - {number} y Local coordinate - * - {number} currPixel - * - {number} prevPixel - */ - _extractNextMarkedPixelValueAlongLocalRay(a, b, markPixelFn, skipFirst, forceLast, localOffsets, reducerFn) { - skipFirst ??= false; - forceLast ??= false; - localOffsets ??= [0, 0]; - reducerFn ??= this.constructor.pixelAggregator("first"); - - const bresIter = bresenhamLineIterator(a.x, a.y, b.x, b.y); - let prevPixel; - let pt; // Needed to recall the last point for forceLast. - if ( skipFirst ) { - // Iterate over the first value - pt = bresIter.next().value; - if ( !pt ) return null; // No more pixels! - const pixelsAtPoint = this._pixelsForRelativePointsFromLocal(pt.x, pt.y, localOffsets); - prevPixel = reducerFn(pixelsAtPoint); - } - - for ( pt of bresIter ) { - const pixelsAtPoint = this._pixelsForRelativePointsFromLocal(pt.x, pt.y, localOffsets); - const currPixel = reducerFn(pixelsAtPoint); - if ( markPixelFn(currPixel, prevPixel) ) return { currPixel, prevPixel, x: pt.x, y: pt.y }; - prevPixel = currPixel; - } - - // Might be a repeat but more consistent to always pass a forceLast object when requested. - // Faster than checking for last in the for loop. - if ( forceLast ) return { currPixel: prevPixel, x: b.x, y: b.y, forceLast }; - return null; - } - - /** - * For a given location, retrieve a set of pixel values based on x/y differences - * @param {number} x The center x coordinate, in local coordinates - * @param {number} y The center y coordinate, in local coordinates - * @param {number[]} offsets Array of offsets: [x0, y0, x1, y1] - * @returns {number|undefined[]} Array of pixels - * Each pixel is the value at x + x0, y + y0, ... - */ - _pixelsForRelativePointsFromLocal(x, y, offsets) { - offsets ??= [0, 0]; - const nOffsets = offsets.length; - const out = new this.pixels.constructor(nOffsets * 0.5); - for ( let i = 0, j = 0; i < nOffsets; i += 2, j += 1 ) { - out[j] = this._pixelAtLocal(x + offsets[i], y + offsets[i + 1]); - } - return out; - } - - /** - * For a given canvas location, retrieve a set of pixel values based on x/y differences - * @param {number} x The center x coordinate, in local coordinates - * @param {number} y The center y coordinate, in local coordinates - * @param {number[]} canvasOffsets Offset grid to use, in canvas coordinate system. [x0, y0, x1, y1, ...] - * @param {number[]} [localOffsets] Offset grid to use, in local coordinate system. Calculated if not provided. - * @returns {number|undefined[]} Array of pixels - * Each pixel is the value at x + x0, y + y0, ... - */ - pixelsForRelativePointsFromCanvas(x, y, canvasOffsets, localOffsets) { - localOffsets ??= this.convertCanvasOffsetGridToLocal(canvasOffsets); - const pt = this._fromCanvasCoordinates(x, y); - return this._pixelsForRelativePointsFromLocal(pt.x, pt.y, localOffsets); - } - - // Function to aggregate pixels. Must handle undefined pixels. - - /** - * Utility method to construct a function that can aggregate pixel array generated from offsets - * @param {string} type Type of aggregation to perform - * - first: take the first value, which in the case of offsets will be [0,0] - * - min: Minimum pixel value, excluding undefined pixels. - * - max: Maximum pixel value, excluding undefined pixels - * - sum: Add pixels. Returns object with total, numUndefined, numPixels. - * - countThreshold: Count pixels greater than a threshold. - * Returns object with count, numUndefined, numPixels, threshold. - * @param {number} [threshold] Optional pixel value used by "count" methods - * @returns {function} - */ - static pixelAggregator(type, threshold = -1) { - let reducerFn; - let startValue; - switch ( type ) { - case "first": return pixels => pixels[0]; - case "min": { - reducerFn = (acc, curr) => { - if ( curr == null ) return acc; // Undefined or null. - return Math.min(acc, curr); - }; - break; - } - case "max": { - reducerFn = (acc, curr) => { - if ( curr == null ) return acc; - return Math.max(acc, curr); - }; - break; - } - case "average": - case "sum": { - startValue = { numNull: 0, numPixels: 0, total: 0 }; - reducerFn = (acc, curr) => { - acc.numPixels += 1; - if ( curr == null ) acc.numNull += 1; // Undefined or null. - else acc.total += curr; - return acc; - }; - - // Re-zero values in case of rerunning with the same reducer function. - reducerFn.initialize = () => { - startValue.numNull = 0; - startValue.numPixels = 0; - startValue.total = 0; - }; - - break; - } - case "average_gt_threshold": - case "count_gt_threshold": { - startValue = { numNull: 0, numPixels: 0, threshold, count: 0 }; - reducerFn = (acc, curr) => { - acc.numPixels += 1; - if ( curr == null ) acc.numNull += 1; // Undefined or null. - else if ( curr > acc.threshold ) acc.count += 1; - return acc; - }; - - // Re-zero values in case of rerunning with the same reducer function. - reducerFn.initialize = () => { - startValue.numNull = 0; - startValue.numPixels = 0; - startValue.count = 0; - }; - - break; - } - case "median_no_null": { - return pixels => { - pixels = pixels.filter(x => x != null); // Strip null or undefined (undefined should not occur). - const nPixels = pixels.length; - const half = Math.floor(nPixels / 2); - pixels.sort((a, b) => a - b); - if ( nPixels % 2 ) return pixels[half]; - else return Math.round((pixels[half - 1] + pixels[half]) / 2); - }; - } - - case "median_zero_null": { - return pixels => { - // Sorting puts undefined at end, null in front. Pixels should never be null. - const nPixels = pixels.length; - const half = Math.floor(nPixels / 2); - pixels.sort((a, b) => a - b); - if ( nPixels % 2 ) return pixels[half]; - else return Math.round((pixels[half - 1] + pixels[half]) / 2); - }; - } - } - - - switch ( type ) { - case "average": reducerFn.finalize = acc => acc.total / acc.numPixels; break; // Treats undefined as 0. - case "average_gt_threshold": reducerFn.finalize = acc => acc.count / acc.numPixels; break; // Treats undefined as 0. - } - - const reducePixels = this.reducePixels; - const out = pixels => reducePixels(pixels, reducerFn, startValue); - out.type = type; // For debugging. - return out; - } - - /** - * Version of array.reduce that improves speed and handles some unique cases. - * @param {number[]} pixels - * @param {function} reducerFn Function that takes accumulated values and current value - * If startValue is undefined, the first acc will be pixels[0]; the first curr will be pixels[1]. - * @param {object} startValue - * @returns {object} The object returned by the reducerFn - */ - static reducePixels(pixels, reducerFn, startValue) { - const numPixels = pixels.length; - if ( numPixels < 2 ) return pixels[0]; - - if ( reducerFn.initialize ) reducerFn.initialize(); - let acc = startValue; - let startI = 0; - if ( typeof startValue === "undefined" ) { - acc = pixels[0]; - startI = 1; - } - for ( let i = startI; i < numPixels; i += 1 ) { - const curr = pixels[i]; - acc = reducerFn(acc, curr); - } - - if ( reducerFn.finalize ) acc = reducerFn.finalize(acc); - return acc; - } - - static pixelOffsetGrid(shape, skip = 0) { - if ( shape instanceof PIXI.Rectangle ) return this.rectanglePixelOffsetGrid(shape, skip); - if ( shape instanceof PIXI.Polygon ) return this.polygonPixelOffsetGrid(shape, skip); - if ( shape instanceof PIXI.Circle ) return this.shapePixelOffsetGrid(shape, skip); - console.warn("PixelCache|pixelOffsetGrid|shape not recognized.", shape); - return this.polygonPixelOffsetGrid(shape.toPolygon(), skip); - } - - /** - * For a rectangle, construct an array of pixel offsets from the center of the rectangle. - * @param {PIXI.Rectangle} rect - * @returns {number[]} - */ - static rectanglePixelOffsetGrid(rect, skip = 0) { - /* Example - Draw = CONFIG.GeometryLib.Draw - api = game.modules.get("elevatedvision").api - PixelCache = api.PixelCache - - rect = new PIXI.Rectangle(100, 200, 275, 300) - offsets = PixelCache.rectanglePixelOffsetGrid(rect, skip = 10) - - tmpPt = new PIXI.Point; - center = rect.center; - for ( let i = 0; i < offsets.length; i += 2 ) { - tmpPt.copyFrom({ x: offsets[i], y: offsets[i + 1] }); - tmpPt.translate(center.x, center.y, tmpPt); - Draw.point(tmpPt, { radius: 1 }) - if ( !rect.contains(tmpPt.x, tmpPt.y) ) - log(`Rectangle does not contain {tmpPt.x},${tmpPt.y} (${offsets[i]},${offsets[i+1]})`) - } - Draw.shape(rect) - - */ - - const width = Math.floor(rect.width); - const height = Math.floor(rect.height); - const incr = skip + 1; - const w_1_2 = Math.floor(width * 0.5); - const h_1_2 = Math.floor(height * 0.5); - const xiMax = width - w_1_2; - const yiMax = height - h_1_2; - - // Handle 0 row and 0 column. Add only if it would have been added by the increment or half increment. - const addZeroX = ((xiMax - 1) % (Math.ceil(incr * 0.5))) === 0; - const addZeroY = ((yiMax - 1) % (Math.ceil(incr * 0.5))) === 0; - - // Faster to pre-allocate the array, although the math is hard. - const xMod = Boolean((xiMax - 1) % incr); - const yMod = Boolean((yiMax - 1) % incr); - const numX = (xiMax < 2) ? 0 : Math.floor((xiMax - 1) / incr) + xMod; - const numY = (yiMax < 2) ? 0 : Math.floor((yiMax - 1) / incr) + yMod; - const total = (numX * numY * 4 * 2) + (addZeroX * 4 * numY) + (addZeroY * 4 * numX) + 2; - const offsets = new Array(total); - - // To make skipping pixels work well, set up so it always captures edges and corners - // and works its way in. - // And always add the 0,0 point. - offsets[0] = 0; - offsets[1] = 0; - offsets._centerPoint = rect.center; // Helpful when processing pixel values later. - let j = 2; - - // -3 to skip outermost edge and next closest pixel. Avoids issues with borders. - for ( let xi = xiMax - 3; xi > 0; xi -= incr ) { - for ( let yi = yiMax - 3; yi > 0; yi -= incr ) { - // BL quadrant - offsets[j++] = xi; - offsets[j++] = yi; - - // BR quadrant - offsets[j++] = -xi; - offsets[j++] = yi; - - // TL quadrant - offsets[j++] = -xi; - offsets[j++] = -yi; - - // TR quadrant - offsets[j++] = xi; - offsets[j++] = -yi; - } - } - - // Handle 0 row and 0 column. Add only if it would have been added by the increment or half increment. - if ( addZeroX ) { - for ( let yi = yiMax - 3; yi > 0; yi -= incr ) { - offsets[j++] = 0; - offsets[j++] = yi; - offsets[j++] = 0; - offsets[j++] = -yi; - } - } - - if ( addZeroY ) { - for ( let xi = xiMax - 3; xi > 0; xi -= incr ) { - offsets[j++] = xi; - offsets[j++] = 0; - offsets[j++] = -xi; - offsets[j++] = 0; - } - } - - return offsets; - } - - // For checking that offsets are not repeated: - // s = new Set(); - // pts = [] - // for ( let i = 0; i < offsets.length; i += 2 ) { - // pt = new PIXI.Point(offsets[i], offsets[i + 1]); - // pts.push(pt) - // s.add(pt.key) - // } - - /** - * For a polygon, construct an array of pixel offsets from the bounds center. - * Uses a faster multiple contains test specific to PIXI.Polygon. - * @param {PIXI.Rectangle} poly - * @param {number} skip - * @returns {number[]} - */ - static polygonPixelOffsetGrid(poly, skip = 0) { - /* Example - poly = new PIXI.Polygon({x: 100, y: 100}, {x: 200, y: 100}, {x: 150, y: 300}); - offsets = PixelCache.polygonPixelOffsetGrid(poly, skip = 10) - tmpPt = new PIXI.Point; - center = poly.getBounds().center; - for ( let i = 0; i < offsets.length; i += 2 ) { - tmpPt.copyFrom({ x: offsets[i], y: offsets[i + 1] }); - tmpPt.translate(center.x, center.y, tmpPt); - Draw.point(tmpPt, { radius: 1 }) - if ( !poly.contains(tmpPt.x, tmpPt.y) ) - log(`Poly does not contain {tmpPt.x},${tmpPt.y} (${offsets[i]},${offsets[i+1]})`) - } - Draw.shape(poly) - */ - const bounds = poly.getBounds(); - const { x, y } = bounds.center; - const offsets = this.rectanglePixelOffsetGrid(bounds, skip); - const nOffsets = offsets.length; - const testPoints = new Array(offsets.length); - for ( let i = 0; i < nOffsets; i += 2 ) { - testPoints[i] = x + offsets[i]; - testPoints[i + 1] = y + offsets[i + 1]; - } - const isContained = this.polygonMultipleContains(poly, testPoints); - const polyOffsets = []; // Unclear how many pixels until we test containment. - polyOffsets._centerPoint = offsets._centerPoint; - for ( let i = 0, j = 0; i < nOffsets; i += 2 ) { - if ( isContained[j++] ) polyOffsets.push(offsets[i], offsets[i + 1]); - } - return polyOffsets; - } - - /** - * For an arbitrary shape with contains and bounds methods, - * construct a grid of pixels from the bounds center that are within the shape. - * @param {object} shape Shape to test - * @param {number} [skip=0] How many pixels to skip when constructing the grid - * @returns {number[]} - */ - static shapePixelOffsetGrid(shape, skip = 0) { - const bounds = shape.getBounds(); - const { x, y } = bounds.center; - const offsets = this.rectanglePixelOffsetGrid(bounds, skip); - const nOffsets = offsets.length; - const shapeOffsets = []; // Unclear how many pixels until we test containment. - shapeOffsets._centerPoint = offsets._centerPoint; - for ( let i = 0; i < nOffsets; i += 2 ) { - const xOffset = offsets[i]; - const yOffset = offsets[i + 1]; - if ( shape.contains(x + xOffset, y + yOffset) ) shapeOffsets.push(xOffset, yOffset); - } - return shapeOffsets; - } - - /** - * Run contains test on a polygon for multiple points. - * @param {PIXI.Polygon} poly - * @param {number[]} testPoints Array of [x0, y0, x1, y1,...] coordinates - * @returns {number[]} Array of 0 or 1 values - */ - static polygonMultipleContains(poly, testPoints) { - // Modification of PIXI.Polygon.prototype.contains - const nPoints = testPoints.length; - if ( nPoints < 2 ) return undefined; - const res = new Uint8Array(nPoints * 0.5); // If we really need speed, could use bit packing - const r = poly.points.length / 2; - for ( let n = 0, o = r - 1; n < r; o = n++ ) { - const a = poly.points[n * 2]; - const h = poly.points[(n * 2) + 1]; - const l = poly.points[o * 2]; - const c = poly.points[(o * 2) + 1]; - - for ( let i = 0, j = 0; i < nPoints; i += 2, j += 1 ) { - const x = testPoints[i]; - const y = testPoints[i + 1]; - ((h > y) != (c > y)) && (x < (((l - a) * ((y - h)) / (c - h)) + a)) && (res[j] = !res[j]); // eslint-disable-line no-unused-expressions, eqeqeq - } - } - return res; - } - - /** - * Convert a canvas offset grid to a local one. - * @param {number[]} canvasOffsets - * @returns {number[]} localOffsets. May return canvasOffsets if no scaling required. - */ - convertCanvasOffsetGridToLocal(canvasOffsets) { - // Determine what one pixel move in the x direction equates to for a local move. - const canvasOrigin = this._toCanvasCoordinates(0, 0); - const xShift = this._fromCanvasCoordinates(canvasOrigin.x + 1, canvasOrigin.y); - const yShift = this._fromCanvasCoordinates(canvasOrigin.x, canvasOrigin.y + 1); - if ( xShift.equals(new PIXI.Point(1, 0)) && yShift.equals(new PIXI.Point(0, 1)) ) return canvasOffsets; - - const nOffsets = canvasOffsets.length; - const localOffsets = Array(nOffsets); - for ( let i = 0; i < nOffsets; i += 2 ) { - const xOffset = canvasOffsets[i]; - const yOffset = canvasOffsets[i + 1]; - - // A shift of 1 pixel in a canvas direction could shift both x and y locally, if rotated. - localOffsets[i] = (xOffset * xShift.x) + (xOffset * yShift.x); - localOffsets[i + 1] = (yOffset * xShift.y) + (yOffset * yShift.y); - } - return localOffsets; - } - - /** - * Extract pixel values for a line by transforming to a Bresenham line. - * The line will be intersected with the pixel cache bounds. - * Points outside the bounds will be given null values. - * @param {Point} a Starting coordinate - * @param {Point} b Ending coordinate - * @param {object} [opts] Optional parameters - * @param {number} [opts.alphaThreshold] Percent between 0 and 1. - * If defined, a and b will be intersected at the alpha boundary. - * @param {number} [opts.skip] How many pixels to skip along the walk - * @param {function} [opts.markPixelFn] Function to mark pixels along the walk. - * Function takes prev, curr, idx, and maxIdx; returns boolean. True if pixel should be marked. - * @returns {object|null} If the a --> b never overlaps the rectangle, then null. - * Otherwise, object with: - * - {number[]} coords: bresenham path coordinates between the boundsIx. These are in local coordinates. - * - {number[]} pixels: pixels corresponding to the path - * - {Point[]} boundsIx: the intersection points with this frame - * - {object[]} markers: If markPixelFn, the marked pixel information. - * Object has x, y, currPixel, prevPixel, tLocal (% of total) - */ - pixelValuesForLine(a, b, { alphaThreshold, skip = 0, markPixelFn } = {}) { - const aLocal = this._fromCanvasCoordinates(a.x, a.y); - const bLocal = this._fromCanvasCoordinates(b.x, b.y); - - // Find the points within the bounds (or alpha bounds) of this cache. - const bounds = alphaThreshold ? this.getThresholdLocalBoundingBox(alphaThreshold) : this.localFrame; - const localBoundsIx = trimLineSegmentToPixelRectangle(bounds, aLocal, bLocal); - if ( !localBoundsIx ) return null; // Segment never intersects the cache bounds. - - const out = this._pixelValuesForLocalLine(localBoundsIx[0], localBoundsIx[1], markPixelFn, skip); - out.localBoundsIx = localBoundsIx; - out.canvasBoundsIx = localBoundsIx.map(pt => this._toCanvasCoordinates(pt.x, pt.y)); // Used by TravelElevationRay - out.skip = skip; // All coords are returned but only some pixels if skip ≠ 0. - return out; - } - - /** - * Retrieve the pixel values (along the local bresenham line) between two points. - * @param {Point} a Start point, in canvas coordinates - * @param {Point} b End point, in canvas coordinates - * @param {number} [skip=0] How many pixels to skip along the walk - * @returns {object} - * - {number[]} coords Local pixel coordinates, in [x0, y0, x1, y1] - * - {number[]} pixels Pixel value at each coordinate - * - {object[]} markers Pixels that meet the markPixelFn, if any - */ - _pixelValuesForLocalLine(a, b, markPixelFn, skip = 0) { - const coords = bresenhamLine(a.x, a.y, b.x, b.y); - const jIncr = skip + 1; - return markPixelFn - ? this.#markPixelsForLocalCoords(coords, jIncr, markPixelFn, a, b) - : this.#pixelValuesForLocalCoords(coords, jIncr); - } - - /** - * Retrieve pixel values for coordinate set at provided intervals. - * @param {number[]} coords Coordinate array, in [x0, y0, x1, y1, ...] for which to pull pixels. - * @param {number} jIncr How to increment the walk over the pixels (i.e., skip?) - * @returns {object} - * - {number[]} coords Local pixel coordinates, in [x0, y0, x1, y1] - * - {number[]} pixels Pixel value at each coordinate - */ - #pixelValuesForLocalCoords(coords, jIncr) { - const nCoords = coords.length; - const iIncr = jIncr * 2; - const pixels = new this.pixels.constructor(nCoords * 0.5 * (1 / jIncr)); - for ( let i = 0, j = 0; i < nCoords; i += iIncr, j += jIncr ) { - pixels[j] = this.pixelsAtLocal(coords[i], coords[i + 1]); - } - return { coords, pixels }; - } - - /** - * Retrieve pixel values for coordinate set at provided intervals. - * Also mark pixel values along the walk, based on some test function. - * @param {number[]} coords Coordinate array, in [x0, y0, x1, y1, ...] for which to pull pixels. - * @param {number} jIncr How to increment the walk over the pixels (i.e., skip?) - * @param {function} markPixelFn Function to mark pixels along the walk. - * @returns {object} - * - {number[]} coords Local pixel coordinates, in [x0, y0, x1, y1] - * - {object[]} markers Pixels that meet the markPixelFn - */ - #markPixelsForLocalCoords(coords, jIncr, markPixelFn, start, end) { - const nCoords = coords.length; - const nCoordsInv = 1 / (nCoords - 2); - const markers = []; - const markerOpts = PixelMarker.calculateOptsFn(this, coords); - const startingMarker = new PixelMarker(0, start, end, markerOpts(0)); - markers.push(startingMarker); - - // Cycle over the coordinates, adding new markers whenever the markPixelFn test is met. - let prevMarker = startingMarker; - let prevPixel = startingMarker.options.currPixel; - const iIncr = jIncr * 2; - for ( let i = iIncr; i < nCoords; i += iIncr) { - const opts = markerOpts(i); - if ( markPixelFn(prevPixel, opts.currPixel, i, nCoords) ) { - const t = i * nCoordsInv; - opts.prevPixel = prevPixel; - prevMarker = prevMarker._addSubsequentMarkerFast(t, opts); - markers.push(prevMarker); - } - prevPixel = opts.currPixel; - } - - // Add an end marker if not already done. - if ( prevMarker.t !== 1 ) { - const opts = markerOpts(nCoords - 2); - opts.prevPixel = prevPixel; - const endingMarker = prevMarker._addSubsequentMarkerFast(1, opts); - markers.push(endingMarker); - } - - return { coords, markers }; - } - - // Use the new pixel offsets to calculate average, percent, total. - _aggregation(shape, reducerFn, skip, localOffsets) { - let canvasOffsets; - if ( !localOffsets ) canvasOffsets = this.constructor.pixelOffsetGrid(shape, skip); - const { x, y } = shape.getBounds().center; - const pixels = this.pixelsForRelativePointsFromCanvas(x, y, canvasOffsets, localOffsets); - return reducerFn(pixels); - } - - total(shape, { skip, localOffsets } = {}) { - const reducerFn = this.constructor.pixelAggregator("sum"); - return this._aggregation(shape, reducerFn, skip, localOffsets); - } - - average(shape, { skip, localOffsets } = {}) { - const reducerFn = this.constructor.pixelAggregator("average"); - return this._aggregation(shape, reducerFn, skip, localOffsets); - } - - count(shape, threshold, { skip, localOffsets } = {}) { - const reducerFn = this.constructor.pixelAggregator("count_gt_threshold", threshold); - return this._aggregation(shape, reducerFn, skip, localOffsets); - } - - /** - * Construct a pixel cache from a texture. - * Will automatically adjust the resolution of the pixel cache based on the texture resolution. - * @param {PIXI.Texture} texture Texture from which to pull pixel data - * @param {object} [options] Options affecting which pixel data is used - * @param {PIXI.Rectangle} [options.frame] Optional rectangle to trim the extraction - * @param {number} [options.resolution=1] At what resolution to pull the pixels - * @param {number} [options.x=0] Move the texture in the x direction by this value - * @param {number} [options.y=0] Move the texture in the y direction by this value - * @param {number} [options.channel=0] Which RGBA channel, where R = 0, A = 3. - * @param {function} [options.scalingMethod=PixelCache.nearestNeighborScaling] - * @param {function} [options.combineFn] Function to combine multiple channels of pixel data. - * Will be passed the r, g, b, and a channels. - * @param {TypedArray} [options.arrayClass] What array class to use to store the resulting pixel values - * @returns {PixelCache} - */ - static fromTexture(texture, opts = {}) { - const { pixels, x, y, width, height } = extractPixels(canvas.app.renderer, texture, opts.frame); - const combinedPixels = opts.combineFn ? this.combinePixels(pixels, opts.combineFn, opts.arrayClass) : pixels; - - opts.x ??= 0; - opts.y ??= 0; - opts.resolution ??= 1; - opts.channel ??= 0; - opts.scalingMethod ??= this.nearestNeighborScaling; - const arr = opts.scalingMethod(combinedPixels, width, height, opts.resolution, { - channel: opts.channel, - skip: opts.combineFn ? 1 : 4, - arrayClass: opts.arrayClass }); - - opts.x += x; - opts.y += y; - opts.resolution *= texture.resolution; - opts.pixelHeight = height; - return new this(arr, width, opts); - } - - /** - * Combine pixels using provided method. - * @param {number[]} pixels Array of pixels to consolidate. Assumed 4 channels. - * @param {function} combineFn Function to combine multiple channels of pixel data. - * Will be passed the r, g, b, and a channels. - * @param {class TypedArray} [options.arrayClass] What array class to use to store the resulting pixel values - */ - static combinePixels(pixels, combineFn, arrayClass = Float32Array) { - const numPixels = pixels.length; - if ( numPixels % 4 !== 0 ) { - console.error("fromTextureChannels requires a texture with 4 channels."); - return pixels; - } - - const combinedPixels = new arrayClass(numPixels * 0.25); - for ( let i = 0, j = 0; i < numPixels; i += 4, j += 1 ) { - combinedPixels[j] = combineFn(pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]); - } - return combinedPixels; - } - - /** - * Consider the nearest neighbor when upscaling or downscaling a texture pixel array. - * Average together. - * See https://towardsdatascience.com/image-processing-image-scaling-algorithms-ae29aaa6b36c. - * @param {number[]} pixels The original texture pixels - * @param {number} width Width of the original texture - * @param {number} height Height of the original texture - * @param {number} resolution Amount to grow or shrink the pixel array size. - * @param {object} [options] Parameters that affect which pixels are used. - * @param {number} [options.channel=0] Which RGBA channel (0–3) should be pulled? - * @param {number} [options.skip=4] How many channels to skip. - * @param {TypedArray} [options.arrayClass=Uint8Array] What array class to use to store the resulting pixel values - * @returns {number[]} - */ - static nearestNeighborScaling(pixels, width, height, resolution, { channel, skip, arrayClass, arr } = {}) { - channel ??= 0; - skip ??= 4; - arrayClass ??= Uint8Array; - - const invResolution = 1 / resolution; - const localWidth = Math.round(width * resolution); - const localHeight = Math.round(height * resolution); - const N = localWidth * localHeight; - - if ( arr && arr.length !== N ) { - console.error(`PixelCache.nearestNeighborScaling|Array provided must be length ${N}`); - arr = undefined; - } - arr ??= new arrayClass(N); - - for ( let col = 0; col < localWidth; col += 1 ) { - for ( let row = 0; row < localHeight; row += 1 ) { - // Locate the corresponding pixel in the original texture. - const x_nearest = roundFastPositive(col * invResolution); - const y_nearest = roundFastPositive(row * invResolution); - const j = ((y_nearest * width * skip) + (x_nearest * skip)) + channel; - - // Fill in the corresponding local value. - const i = ((~~row) * localWidth) + (~~col); - arr[i] = pixels[j]; - } - } - return arr; - } - - /** - * Consider every pixel in the downscaled image as a box in the original. - * Average together. - * See https://towardsdatascience.com/image-processing-image-scaling-algorithms-ae29aaa6b36c. - * @param {number[]} pixels The original texture pixels - * @param {number} width Width of the original texture - * @param {number} height Height of the original texture - * @param {number} resolution Amount to shrink the pixel array size. Must be less than 1. - * @param {object} [options] Parameters that affect which pixels are used. - * @param {number} [options.channel=0] Which RGBA channel (0–3) should be pulled? - * @param {number} [options.skip=4] How many channels to skip. - * @param {TypedArray} [options.arrayClass=Uint8Array] What array class to use to store the resulting pixel values - * @returns {number[]} - */ - static boxDownscaling(pixels, width, height, resolution, { channel, skip, arrayClass, arr } = {}) { - channel ??= 0; - skip ??= 4; - arrayClass ??= Uint8Array; - - const invResolution = 1 / resolution; - const localWidth = Math.round(width * resolution); - const localHeight = Math.round(height * resolution); - const N = localWidth * localHeight; - if ( arr && arr.length !== N ) { - console.error(`PixelCache.nearestNeighborScaling|Array provided must be length ${N}`); - arr = undefined; - } - arr ??= new arrayClass(N); - - const boxWidth = Math.ceil(invResolution); - const boxHeight = Math.ceil(invResolution); - - for ( let col = 0; col < localWidth; col += 1 ) { - for ( let row = 0; row < localHeight; row += 1 ) { - // Locate the corresponding pixel in the original texture. - const x_ = ~~(col * invResolution); - const y_ = ~~(row * invResolution); - - // Ensure the coordinates are not out-of-bounds. - const x_end = Math.min(x_ + boxWidth, width - 1) + 1; - const y_end = Math.min(y_ + boxHeight, height - 1) + 1; - - // Average colors in the box. - const values = []; - for ( let x = x_; x < x_end; x += 1 ) { - for ( let y = y_; y < y_end; y += 1 ) { - const j = ((y * width * skip) + (x * skip)) + channel; - values.push(pixels[j]); - } - } - - // Fill in the corresponding local value. - const i = ((~~row) * localWidth) + (~~col); - const avgPixel = values.reduce((a, b) => a + b, 0) / values.length; - arr[i] = roundFastPositive(avgPixel); - } - } - return arr; - } - - /** - * Draw a representation of this pixel cache on the canvas, where alpha channel is used - * to represent values. For debugging. - * @param {Hex} [color] Color to use for the fill - */ - draw({color = Draw.COLORS.blue, gammaCorrect = false, local = false } = {}) { - const ln = this.pixels.length; - const coordFn = local ? this._localAtIndex : this._canvasAtIndex; - const gammaExp = gammaCorrect ? 1 / 2.2 : 1; - - for ( let i = 0; i < ln; i += 1 ) { - const value = this.pixels[i]; - if ( !value ) continue; - const alpha = Math.pow(value / this.#maximumPixelValue, gammaExp); - const pt = coordFn.call(this, i); - Draw.point(pt, { color, alpha, radius: 1 }); - } - } - - /** - * Draw a representation of this pixel cache on the canvas, where alpha channel is used - * to represent values. For debugging. - * @param {Hex} [color] Color to use for the fill - */ - drawLocal({color = Draw.COLORS.blue, gammaCorrect = false } = {}) { - const ln = this.pixels.length; - const gammaExp = gammaCorrect ? 1 / 2.2 : 1; - for ( let i = 0; i < ln; i += 1 ) { - const value = this.pixels[i]; - if ( !value ) continue; - const alpha = Math.pow(value / this.#maximumPixelValue, gammaExp); - const pt = this._canvasAtIndex(i); - const local = this._fromCanvasCoordinates(pt.x, pt.y); - Draw.point(local, { color, alpha, radius: 1 }); - } - } - - /** - * Draw a representation of this pixel cache on the canvas, where alpha channel is used - * to represent values. For debugging. - * @param {Hex} [color] Color to use for the fill - */ - drawColors({defaultColor = Draw.COLORS.blue, colors = {}, local = false } = {}) { - const ln = this.pixels.length; - const coordFn = local ? this._localAtIndex : this._canvasAtIndex; - for ( let i = 0; i < ln; i += 1 ) { - const pt = coordFn.call(this, i); - const value = this.pixels[i]; - const color = colors[value] ?? defaultColor; - Draw.point(pt, { color, alpha: .9, radius: 1 }); - } - } - - drawCanvasCoords({color = Draw.COLORS.blue, gammaCorrect = false, skip = 10, radius = 1 } = {}) { - const gammaExp = gammaCorrect ? 1 / 2.2 : 1; - const { right, left, top, bottom } = this; - skip *= Math.round(1 / this.scale.resolution); - for ( let x = left; x <= right; x += skip ) { - for ( let y = top; y <= bottom; y += skip ) { - const value = this.pixelAtCanvas(x, y); - if ( !value ) continue; - const alpha = Math.pow(value / 255, gammaExp); - Draw.point({x, y}, { color, alpha, radius }); - } - } - } - - drawLocalCoords({color = Draw.COLORS.blue, gammaCorrect = false, skip = 10, radius = 2 } = {}) { - const gammaExp = gammaCorrect ? 1 / 2.2 : 1; - const { right, left, top, bottom } = this.localFrame; - for ( let x = left; x <= right; x += skip ) { - for ( let y = top; y <= bottom; y += skip ) { - const value = this._pixelAtLocal(x, y); - if ( !value ) continue; - const alpha = Math.pow(value / 255, gammaExp); - Draw.point({x, y}, { color, alpha, radius }); - } - } - } -} - - -/** - * Pixel cache specific to a tile texture. - * Adds additional handling for tile rotation, scaling. - */ -export class TilePixelCache extends PixelCache { - /** @type {Tile} */ - tile; - - /** - * @param {Tile} [options.tile] Tile for which this cache applies - If provided, scale will be updated - * @inherits - */ - constructor(pixels, width, opts = {}) { - super(pixels, width, opts); - this.tile = opts.tile; - this._resize(); - } - - /** @type {numeric} */ - get scaleX() { return this.tile.document.texture.scaleX; } - - /** @type {numeric} */ - get scaleY() { return this.tile.document.texture.scaleY; } - - /** @type {numeric} */ - get rotation() { return Math.toRadians(this.tile.document.rotation); } - - /** @type {numeric} */ - get rotationDegrees() { return this.tile.document.rotation; } - - /** @type {numeric} */ - get proportionalWidth() { return this.tile.document.width / this.tile.texture.width; } - - /** @type {numeric} */ - get proportionalHeight() { return this.tile.document.height / this.tile.texture.height; } - - /** @type {numeric} */ - get textureWidth() { return this.tile.texture.width; } - - /** @type {numeric} */ - get textureHeight() { return this.tile.texture.height; } - - /** @type {numeric} */ - get tileX() { return this.tile.document.x; } - - /** @type {numeric} */ - get tileY() { return this.tile.document.y; } - - /** @type {numeric} */ - get tileWidth() { return this.tile.document.width; } - - /** @type {numeric} */ - get tileHeight() { return this.tile.document.height; } - - /** - * Resize canvas dimensions for the tile. - * Account for rotation and scale by converting from local frame. - */ - _resize() { - const { width, height } = this.localFrame; - const TL = this._toCanvasCoordinates(0, 0); - const TR = this._toCanvasCoordinates(width, 0); - const BL = this._toCanvasCoordinates(0, height); - const BR = this._toCanvasCoordinates(width, height); - - const xMinMax = Math.minMax(TL.x, TR.x, BL.x, BR.x); - const yMinMax = Math.minMax(TL.y, TR.y, BL.y, BR.y); - this.x = xMinMax.min; - this.y = yMinMax.min; - this.width = xMinMax.max - xMinMax.min; - this.height = yMinMax.max - yMinMax.min; - - this.clearTransforms(); - } - - /** - * Transform canvas coordinates into the local pixel rectangle coordinates. - * @inherits - */ - _calculateToLocalTransform() { - // 1. Clear the rotation - // Translate so the center is 0,0 - const { x, y, width, height } = this.tile.document; - const mCenterTranslate = Matrix.translation(-(width * 0.5) - x, -(height * 0.5) - y); - - // Rotate around the Z axis - // (The center must be 0,0 for this to work properly.) - const rotation = -this.rotation; - const mRot = Matrix.rotationZ(rotation, false); - - // 2. Clear the scale - // (The center must be 0,0 for this to work properly.) - const { scaleX, scaleY } = this; - const mScale = Matrix.scale(1 / scaleX, 1 / scaleY); - - // 3. Clear the width/height - // Translate so top corner is 0,0 - const { textureWidth, textureHeight, proportionalWidth, proportionalHeight } = this; - const currWidth = textureWidth * proportionalWidth; - const currHeight = textureHeight * proportionalHeight; - const mCornerTranslate = Matrix.translation(currWidth * 0.5, currHeight * 0.5); - - // Scale the canvas width/height back to texture width/height, if not 1:1. - // (Must have top left corner at 0,0 for this to work properly.) - const mProportion = Matrix.scale(1 / proportionalWidth, 1 / proportionalHeight); - - // 4. Scale based on resolution of the underlying pixel data - const resolution = this.scale.resolution; - const mRes = Matrix.scale(resolution, resolution); - - // Combine the matrices. - return mCenterTranslate - .multiply3x3(mRot) - .multiply3x3(mScale) - .multiply3x3(mCornerTranslate) - .multiply3x3(mProportion) - .multiply3x3(mRes); - } - - /** - * Convert a tile's alpha channel to a pixel cache. - * At the moment mostly for debugging, b/c overhead tiles have an existing array that - * can be used. - * @param {Tile} tile Tile to pull a texture from - * @param {object} opts Options passed to `fromTexture` method - * @returns {TilePixelCache} - */ - static fromTileAlpha(tile, opts = {}) { - const texture = tile.texture; - opts.tile = tile; - opts.channel ??= 3; - return this.fromTexture(texture, opts); - } - - /** - * Convert an overhead tile's alpha channel to a pixel cache. - * Relies on already-cached overhead tile pixel data. - * @param {Tile} tile Tile to pull a texture from - * @param {object} opts Options passed to `fromTexture` method - * @returns {TilePixelCache} - */ - static fromOverheadTileAlpha(tile) { - if ( !tile.document.overhead ) return this.fromTileAlpha(tile); - if ( !tile.mesh._textureData ) tile.mesh.updateTextureData(); - - // Resolution consistent with `_createTextureData` which divides by 4. - const pixelWidth = tile.mesh._textureData.aw; - const texWidth = tile.mesh.texture.baseTexture.realWidth; - const pixelHeight = tile.mesh._textureData.ah; - const resolution = pixelWidth / texWidth; - - return new this(tile.mesh._textureData.pixels, pixelWidth, { pixelHeight, tile, resolution }); - } - - /** - * Convert a circle to local texture coordinates, taking into account scaling. - * @returns {PIXI.Circle|PIXI.Polygon} - */ - _circleToLocalCoordinates(_circle) { - console.error("_circleToLocalCoordinates: Not yet implemented for tiles."); - } - - /** - * Convert an ellipse to local texture coordinates, taking into account scaling. - * @returns {PIXI.Ellipse|PIXI.Polygon} - */ - _ellipseToLocalCoordinates(_ellipse) { - console.error("_circleToLocalCoordinates: Not yet implemented for tiles."); - } - - /** - * Convert a rectangle to local texture coordinates, taking into account scaling. - * @returns {PIXI.Rectangle|PIXI.Polygon} - * @inherits - */ - _rectangleToLocalCoordinates(rect) { - switch ( this.rotationDegrees ) { - case 0: - case 360: return super._rectangleToLocalCoordinates(rect); - case 90: - case 180: - case 270: { - // Rotation will change the TL and BR points; adjust accordingly. - const { left, right, top, bottom } = rect; - const TL = this._fromCanvasCoordinates(left, top); - const TR = this._fromCanvasCoordinates(right, top); - const BR = this._fromCanvasCoordinates(right, bottom); - const BL = this._fromCanvasCoordinates(left, bottom); - const localX = Math.minMax(TL.x, TR.x, BR.x, BL.x); - const localY = Math.minMax(TL.y, TR.y, BR.y, BL.y); - return new PIXI.Rectangle(localX.min, localY.min, localX.max - localX.min, localY.max - localY.min); - } - default: { - // Rotation would form a rotated rectangle-Use polygon instead. - const { left, right, top, bottom } = rect; - const poly = new PIXI.Polygon([left, top, right, top, right, bottom, left, bottom]); - return this._polygonToLocalCoordinates(poly); - } - } - } -} - -// ----- Marker class ----- // - -/** - * Store a point, a t value, and the underlying coordinate system - */ -export class Marker { - /** @type {PIXI.Point} */ - #point; - - /** @type {number} */ - t = -1; - - /** @type {object} */ - range = { - start: new PIXI.Point(), /** @type {PIXI.Point} */ - end: new PIXI.Point() /** @type {PIXI.Point} */ - }; - - /** @type {object} */ - options = {}; - - /** @type {Marker} */ - next; - - constructor(t, start, end, opts = {}) { - this.t = t; - this.options = opts; - this.range.start.copyFrom(start); - this.range.end.copyFrom(end); - } - - /** @type {PIXI.Point} */ - get point() { return this.#point ?? (this.#point = this.pointAtT(this.t)); } - - /** - * Given a t position, project the location given this marker's range. - * @param {number} t - * @returns {PIXI.Point} - */ - pointAtT(t) { return this.range.start.projectToward(this.range.end, t); } - - /** - * Build a new marker and link it as the next marker to this one. - * If this marker has a next marker, insert in-between. - * Will insert at later spot as necessary - * @param {number} t Must be greater than or equal to this t. - * @param {object} opts Will be combined with this marker options. - * @returns {Marker} - */ - addSubsequentMarker(t, opts) { - if ( this.t === t ) { return this; } - - // Insert further down the line if necessary. - if ( this.next && this.next.t < t ) return this.next.addSubsequentMarker(t, opts); - - // Merge the options with this marker's options and create a new marker. - if ( t < this.t ) console.error("Marker asked to create a next marker with a previous t value."); - const next = new this.constructor(t, this.range.start, this.range.end, { ...this.options, ...opts }); - - // Insert at the correct position. - if ( this.next ) next.next = this.next; - this.next = next; - return next; - } - - /** - * Like addSubsequentMarker but does not merge options and performs less checks. - * Assumes it should be the very next item and does not check for existing next object. - */ - _addSubsequentMarkerFast(t, opts) { - const next = new this.constructor(t, this.range.start, this.range.end, opts); - this.next = next; - return next; - } -} - -/** - * Class used by #markPixelsForLocalCoords to store relevant data for the pixel point. - */ -export class PixelMarker extends Marker { - - static calculateOptsFn(cache, coords ) { - const width = cache.localFrame.width; - return i => { - const localX = coords[i]; - const localY = coords[i+1]; - const idx = (localY * width) + localX; - const currPixel = cache.pixels[idx]; - return { localX, localY, currPixel }; - }; - } -} - diff --git a/scripts/Terrain.js b/scripts/Terrain.js index 4c59198..a18c0c7 100644 --- a/scripts/Terrain.js +++ b/scripts/Terrain.js @@ -25,6 +25,7 @@ import { Lock } from "./Lock.js"; import { getDefaultSpeedAttribute } from "./systems.js"; import { TravelTerrainRay } from "./TravelTerrainRay.js"; import { TerrainListConfig } from "./TerrainListConfig.js"; +import { TerrainLevel } from "./TerrainLevel.js"; // ----- Set up sockets for changing effects on tokens and creating a dialog ----- // // Don't pass complex classes through the socket. Use token ids instead. @@ -87,7 +88,7 @@ export class Terrain { if ( activeEffect ) { const instances = this.constructor._instances; const id = activeEffect.id; - if (instances.has(id) ) return instances.get(id); + if (instances.has(id) ) return instances.get(id); // eslint-disable-line no-constructor-return instances.set(id, this); } this._effectHelper = new EffectHelper(activeEffect); @@ -151,27 +152,27 @@ export class Terrain { async setIcon(value) { return this.activeEffect.update({ icon: value }); } /** @type {FLAGS.ANCHOR.CHOICES} */ - get anchor() { return this.#getAEFlag(FLAGS.ANCHOR.VALUE); } + get anchor() { return this.#getAEFlag(FLAGS.ANCHOR.VALUE) || FLAGS.ANCHOR.CHOICES.ABSOLUTE; } async setAnchor(value) { return this.#setAEFlag(FLAGS.ANCHOR, value); } /** @type {number} */ - get offset() { return this.#getAEFlag(FLAGS.OFFSET); } + get offset() { return this.#getAEFlag(FLAGS.OFFSET) || 0; } async setOffset(value) { return this.#setAEFlag(FLAGS.OFFSET, value); } /** @type {number} */ - get rangeBelow() { return this.#getAEFlag(FLAGS.RANGE_BELOW); } + get rangeBelow() { return this.#getAEFlag(FLAGS.RANGE_BELOW) || 0; } async setRangeBelow(value) { return this.#setAEFlag(FLAGS.RANGE_BELOW, value); } /** @type {number} */ - get rangeAbove() { return this.#getAEFlag(FLAGS.RANGE_ABOVE); } + get rangeAbove() { return this.#getAEFlag(FLAGS.RANGE_ABOVE) || 0; } async setRangeAbove(value) { return this.#setAEFlag(FLAGS.RANGE_ABOVE, value); } /** @type {boolean} */ - get userVisible() { return this.#getAEFlag(FLAGS.USER_VISIBLE); } + get userVisible() { return this.#getAEFlag(FLAGS.USER_VISIBLE) || false; } async setUserVisible(value) { return this.#setAEFlag(FLAGS.USER_VISIBLE, value); } @@ -402,8 +403,7 @@ export class Terrain { if ( !(origin instanceof PIXI.Point) ) origin = new PIXI.Point(origin.x, origin.y); if ( !(destination instanceof PIXI.Point) ) destination = new PIXI.Point(destination.x, destination.y); - const currTerrains = new Set(token.getAllTerrains()); - + const currTerrains = new Set(token.getAllTerrains()); // Store in advance for speed. const ttr = new TravelTerrainRay(token, { origin, destination}); const path = ttr.path; let tPrev = 0; @@ -412,13 +412,8 @@ export class Terrain { const nMarkers = path.length; const tChangeFn = markerT => { const tDiff = markerT - tPrev; - const droppedTerrains = currTerrains.difference(prevTerrains); - const addedTerrains = prevTerrains.difference(currTerrains); - - // If movementPercentChangeForToken returns the same value, map will fail. See issue #21. - const percentDropped = droppedTerrains.reduce((acc, curr) => acc * curr.movementPercentChangeForToken(token, speedAttribute), 1); - const percentAdded = addedTerrains.reduce((acc, curr) => acc * curr.movementPercentChangeForToken(token, speedAttribute), 1); - return (1 / (percentAdded * (1 / percentDropped))) * tDiff; + const percentChange = this.percentMovementChangeForTerrainSet(token, prevTerrains, speedAttribute, currTerrains); + return percentChange * tDiff; }; for ( let i = 1; i < nMarkers; i += 1 ) { @@ -441,6 +436,92 @@ export class Terrain { return percent; } + /** + * Percent change in token's move speed when adding specified terrains to the token. + * Ignores terrains already on the token. Assumes if the terrain is on the token but + * not in the set, it should be removed. + * @param {Token} token + * @param {Set} terrainSet Terrains to add to the token. Pass empty set to remove all terrains + * @param {string} [speedAttribute] Optional attribute to use to determine token speed + * @param {Set} [currTerrains] If not defined, set to all terrains on the token + * Mostly used for speed in loops. + * @returns {number} Percent change from token's current speed. + */ + static percentMovementChangeForTerrainSet(token, terrainSet, speedAttribute, currTerrains) { + speedAttribute ??= getDefaultSpeedAttribute(); + currTerrains ??= new Set(token.getAllTerrains()); + const droppedTerrains = currTerrains.difference(terrainSet); + const addedTerrains = terrainSet.difference(currTerrains); + + // If movementPercentChangeForToken returns the same value, map would fail. See issue #21. + const percentDropped = droppedTerrains.reduce((acc, curr) => + acc * curr.movementPercentChangeForToken(token, speedAttribute), 1); + const percentAdded = addedTerrains.reduce((acc, curr) => + acc * curr.movementPercentChangeForToken(token, speedAttribute), 1); + return (1 / (percentAdded * (1 / percentDropped))); + } + + /** + * Percent movement for the token at a given point on the canvas. Relies on the token's elevation. + * @param {Token} token Token to test + * @param {Point} [location] If not provided, taken from token center + * @param {string} [speedAttribute] Optional attribute to use to determine token speed + * @returns {number} The percent increase or decrease from default speed attribute. + * Greater than 100: increase. E.g. 120% is 20% increase over baseline. + * Equal to 100: no increase. + * Less than 100: decrease. + */ + static percentMovementChangeForTokenAtPoint(token, location, speedAttribute) { + location ??= token.center; + let elevationE; + if ( Object.hasOwn(location, "z") ) elevationE = CONFIG.GeometryLib.utils.pixelsToGridUnits(location.z); + else elevationE = token.elevationE; + const terrains = canvas.terrain.activeTerrainsAt(location, elevationE); + return this.percentMovementChangeForTerrainSet(token, terrains, speedAttribute); + } + + /** + * Percent movement for the token for a given shape on the canvas. Relies on the token's elevation. + * If the terrain covers more than minPercentArea, it counts as active assuming it is within elevation. + * + * @param {Token} token + * @param {Point} [shape] Shape to test, if not the constrained token boundary + * @param {object} [opts] Options that affect the measurement + * @param {number} [opts.minPercentArea] Minimum percent area of the shape that the terrain must overlap to count + * @param {string} [opts.speedAttribute] String pointing to where to find the token speed attribute + * @returns {number} The percent increase or decrease from default speed attribute. + * Greater than 100: increase. E.g. 120% is 20% increase over baseline. + * Equal to 100: no increase. + * Less than 100: decrease. + */ + + static percentMovementChangeForTokenWithinShape(token, shape, minPercentArea = 0.5, speedAttribute, elevationE) { + elevationE = token.elevationE; + shape ??= token.constrainedTokenBorder; + const ter = canvas.terrain; + const terrainLevels = ter._templateTerrainLevelsAtShape(shape) + .union(ter._tileTerrainLevelsAtShape(shape)); + + // TODO: Simpler way to get terrain levels. + // For now, cycle through all terrains and all levels. + const nLayers = ter.constructor.MAX_LAYERS; + for ( const t of ter.sceneMap.values() ) { + for ( let l = 0; l < nLayers; l += 1 ) { + const tl = new TerrainLevel(t, l); + terrainLevels.add(tl); + } + } + + // Keep terrains if they cover more than the percent area for the shape. + const terrains = new Set(); + terrainLevels.forEach(tl => { + const t = tl.terrain; + if ( terrains.has(t) ) return; + if ( tl.percentCoverage(shape, elevationE) >= minPercentArea ) terrains.add(t); + }); + return this.percentMovementChangeForTerrainSet(token, terrains, speedAttribute); + } + /** * Helper to measure diff --git a/scripts/TerrainLayer.js b/scripts/TerrainLayer.js index 7247235..6c7d8c6 100644 --- a/scripts/TerrainLayer.js +++ b/scripts/TerrainLayer.js @@ -311,6 +311,18 @@ export class TerrainLayer extends InteractionLayer { return tiles.map(tile => tile.attachedTerrain); } + /** + * TerrainTiles that overlap a given shape. + * @param {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle} shape Shape to test + * @returns {Set} + */ + _tileTerrainLevelsAtShape(shape) { + const bounds = shape.getBounds(); + const collisionTest = (o, rect) => o.t.hasAttachedTerrain && rect.overlaps(shape); + const tiles = canvas.tiles.quadtree.getObjects(bounds, { collisionTest }); + return tiles.map(tile => tile.attachedTerrain); + } + /** * TerrainMeasuredTemplates at a given position. * @param {Point} {x, y} location Position to test @@ -324,6 +336,18 @@ export class TerrainLayer extends InteractionLayer { return templates.map(template => template.attachedTerrain); } + /** + * TerrainMeasuredTemplates that overlap a given shape. + * @param {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle} shape Shape to test + * @returns {Set} + */ + _templateTerrainLevelsAtShape(shape) { + const bounds = shape.getBounds(); + const collisionTest = (o, rect) => o.t.hasAttachedTerrain && o.t.shape.overlaps(shape); + const templates = canvas.templates.quadtree.getObjects(bounds, { collisionTest }); + return templates.map(template => template.attachedTerrain); + } + /** * Terrain levels found at the provided 2d position. * @param {Point} {x, y} location diff --git a/scripts/TerrainLevel.js b/scripts/TerrainLevel.js index f59b4e4..6ce79f9 100644 --- a/scripts/TerrainLevel.js +++ b/scripts/TerrainLevel.js @@ -1,13 +1,15 @@ /* globals canvas, CONFIG, +MeasuredTemplate, Tile */ /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; import { MODULE_ID, FLAGS } from "./const.js"; -import { TerrainKey } from "./TerrainPixelCache.js"; +import { TerrainKey, TerrainPixelCache } from "./TerrainPixelCache.js"; +import { gridShapeFromGridCoords } from "./util.js"; /** @@ -27,7 +29,7 @@ export class TerrainLevel { this.level = level ?? canvas.terrain.controls.currentLevel; const instances = this.constructor._instances; - if (instances.has(this.id) ) return instances.get(this.id); + if (instances.has(this.id) ) return instances.get(this.id); // eslint-disable-line no-constructor-return instances.set(this.id, this); this.scene = canvas.scene; @@ -105,8 +107,6 @@ export class TerrainLevel { return minMax; } - - /** * Determine if the terrain is active at the provided elevation. * @param {number} elevation Elevation to test @@ -117,6 +117,32 @@ export class TerrainLevel { const minMaxE = this.elevationRange(location); return elevation.between(minMaxE.min, minMaxE.max); } + + /** + * Calculate what percentage of the grid square/hex is covered by this terrain. + * See percentCoverage. + */ + percentGridShapeCoverage(gridCoords, elevation = 0, opts = {}) { + const shape = gridShapeFromGridCoords(gridCoords); + return this.percentCoverage(shape, elevation, opts); + } + + /** + * Determine what percentage of a PIXI object is covered by this terrain for this level. + * The shape center at this elevation must be active unless elevationTest is false. + * @param {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle} shape + * @param [number] [elevation=0] Assumed elevation for this shape if not 0 + * @param {object} [opts] Options passed to PixelCache._aggregation + * @param {number} [opts.skip] Skip every X pixels when aggregating + * @param {number[]} [opts.localOffsets] Numbers to add to the local x,y position when pulling the pixel(s) + * @param {boolean} [opts.testElevation] If false, skip the activeAt test. + * @returns {number} + */ + percentCoverage(shape, elevation = 0, { skip, localOffsets, testElevation = true } = {}) { + if ( testElevation && !this.activeAt(elevation, shape.center) ) return 0; + const reducerFn = TerrainPixelCache.pixelAggregator("average_eq_threshold", Number(this.key)); + return canvas.terrain.pixelCache._aggregation(shape, reducerFn, skip, localOffsets); + } } /** @@ -166,17 +192,41 @@ export class TerrainTile extends TerrainLevel { if ( alphaThreshold === 1 ) return false; return this.tile.mesh.getPixelAlpha(location.x, location.y) < alphaThreshold; } + + /** + * Determine what percentage of a PIXI object is covered by this terrain tile. + * @inherits + */ + percentCoverage(shape, elevation = 0, { skip, localOffsets, testElevation = true } = {}) { + if ( testElevation && !super.activeAt(elevation, shape.center) ) return 0; + + const shapeArea = shape.area; + if ( shapeArea <= 0 ) return 0; + + const tile = this.tile; + const trimmedShape = tile.bounds.intersectPolygon(shape.toPolygon()); + const trimmedArea = trimmedShape.area; + if ( trimmedArea <= 0 ) return 0; + + const alphaThreshold = tile.document.getFlag(MODULE_ID, FLAGS.ALPHA_THRESHOLD); + if ( !alphaThreshold ) return trimmedArea / shapeArea || 0; + if ( alphaThreshold === 1 ) return 0; + + const reducerFn = TerrainPixelCache.pixelAggregator("average_gt_threshold", alphaThreshold); + const percent = tile.evPixelCache._aggregation(trimmedShape, reducerFn, skip, localOffsets); + return (trimmedArea / shapeArea) * percent; + } } /** - * Represent a measured template linked to a tile. + * Represent a measured template linked to a terrain. */ export class TerrainMeasuredTemplate extends TerrainLevel { /** @type {MeasuredTemplate} */ template; constructor(terrain, template) { - if ( template && !(template instanceof MeasuredTemplate) ) console.error("TerrainMeasuredTemplate requires a MeasuredTemplate object.", tile); + if ( template && !(template instanceof MeasuredTemplate) ) console.error("TerrainMeasuredTemplate requires a MeasuredTemplate object.", template); super(terrain, template); this.template = template; } @@ -208,7 +258,25 @@ export class TerrainMeasuredTemplate extends TerrainLevel { // Second, check if contained within the template shape. // Shape centered at origin 0, 0. - const shape = template.shape.translate(template.x, template.y) + const shape = template.shape.translate(template.x, template.y); return shape.contains(location.x, location.y); } + + /** + * Determine what percentage of a PIXI object is covered by this terrain tile. + * @inherits + */ + percentCoverage(shape, elevation = 0, { testElevation = true } = {}) { + if ( testElevation && !super.activeAt(elevation, shape.center) ) return 0; + + const shapeArea = shape.area; + if ( shapeArea <= 0 ) return 0; + + const template = this.template; + const trimmedShape = template.bounds.intersectPolygon(shape.toPolygon()); + const trimmedArea = trimmedShape.area; + if ( trimmedArea <= 0 ) return 0; + + return trimmedArea / shapeArea; + } } diff --git a/scripts/TerrainPixelCache.js b/scripts/TerrainPixelCache.js index 72bc3cd..d831727 100644 --- a/scripts/TerrainPixelCache.js +++ b/scripts/TerrainPixelCache.js @@ -4,7 +4,7 @@ canvas /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ "use strict"; -import { PixelCache } from "./PixelCache.js"; +import { PixelCache } from "./geometry/PixelCache.js"; import { log } from "./util.js"; /** diff --git a/scripts/Tile.js b/scripts/Tile.js index de78715..5a8bdce 100644 --- a/scripts/Tile.js +++ b/scripts/Tile.js @@ -6,7 +6,6 @@ import { MODULE_ID, FLAGS } from "./const.js"; import { Terrain } from "./Terrain.js"; import { TerrainTile } from "./TerrainLevel.js"; -import { TilePixelCache } from "./PixelCache.js"; export const PATCHES = {}; PATCHES.BASIC = {}; @@ -31,26 +30,6 @@ PATCHES.BASIC = {}; * @param {string} userId The ID of the User who triggered the update workflow */ function updateTile(tileD, changed, _options, _userId) { - // Should not be needed: if ( changed.overhead ) document.object._evPixelCache = undefined; - const cache = document.object?._evPixelCache; - if ( cache ) { - if ( Object.hasOwn(changed, "x") - || Object.hasOwn(changed, "y") - || Object.hasOwn(changed, "width") - || Object.hasOwn(changed, "height") ) { - cache._resize(); - } - - if ( Object.hasOwn(changed, "rotation") - || Object.hasOwn(changed, "texture") - || (changed.texture - && (Object.hasOwn(changed.texture, "scaleX") - || Object.hasOwn(changed.texture, "scaleY"))) ) { - - cache.clearTransforms(); - } - } - const modFlag = changed.flags?.[MODULE_ID]; if ( !modFlag || !Object.hasOwn(modFlag, [FLAGS.ATTACHED_TERRAIN]) ) return; tileD.object._terrain = undefined; @@ -110,12 +89,4 @@ function hasAttachedTerrain() { return Boolean(this.document.getFlag(MODULE_ID, FLAGS.ATTACHED_TERRAIN)); } -/** - * Getter for Tile.mesh._evPixelCache - */ -function evPixelCache() { - return this._evPixelCache || (this._evPixelCache = TilePixelCache.fromOverheadTileAlpha(this)); -} - - -PATCHES.BASIC.GETTERS = { attachedTerrain, hasAttachedTerrain, evPixelCache }; +PATCHES.BASIC.GETTERS = { attachedTerrain, hasAttachedTerrain }; diff --git a/scripts/geometry b/scripts/geometry index fdec893..92e8dc2 160000 --- a/scripts/geometry +++ b/scripts/geometry @@ -1 +1 @@ -Subproject commit fdec893a10fbb1536b1c97094f5a485f938f4f44 +Subproject commit 92e8dc292fa4b83f669be81438ccebfe5859cbd6 diff --git a/scripts/module.js b/scripts/module.js index 55cb4cd..84b65ba 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -9,6 +9,7 @@ socketlib import { MODULE_ID, SOCKETS } from "./const.js"; import { log } from "./util.js"; import { TerrainLayer } from "./TerrainLayer.js"; +import { TerrainLevel } from "./TerrainLevel.js"; import { Settings } from "./settings.js"; import { Terrain, addTerrainEffect, removeTerrainEffect } from "./Terrain.js"; import { TerrainMap } from "./TerrainMap.js"; @@ -94,7 +95,8 @@ function initializeAPI() { SCENE_GRAPH, TerrainLayerPixelCache, TerrainPixelCache, - TerrainKey + TerrainKey, + TerrainLevel }; } diff --git a/scripts/perfect-vision/extract-async.js b/scripts/perfect-vision/extract-async.js deleted file mode 100644 index e3bea75..0000000 --- a/scripts/perfect-vision/extract-async.js +++ /dev/null @@ -1,638 +0,0 @@ -class ExtractSystem { - /** @readonly */ - static extension = { - type: PIXI.ExtensionType.RendererPlugin, - name: "extractAsync" - }; - - /** - * @type {PIXI.Renderer} - * @readonly - */ - renderer; - - /** @type {ExtractWorker} */ - #worker = new ExtractWorker(); - - /** @type {Object.} */ - #arrays = {}; - - /** @type {Object.} */ - #buffers = {}; - - /** @type {number} */ - #count = 0; - - /** @type {number} */ - #checkCount = 0; - - /** @type {number} */ - #checkCountMax = PIXI.settings.GC_MAX_CHECK_COUNT * 3; - - /** @type {number} */ - #maxIdle = PIXI.settings.GC_MAX_IDLE * 5; - - /** @param {PIXI.Renderer} renderer */ - constructor(renderer) { - this.renderer = renderer; - - renderer.runners.contextChange.add(this); - renderer.runners.postrender.add(this); - } - - /** - * Extract a rectangular block of pixels and convert them to base64. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {string} [format] - A string indicating the image format. The default type is `image/png`; this image format will be also used if the specified type is not supported. - * @param {number} [quality] - A number between 0 and 1 indicating the image quality to be used when creating images using file formats that support lossy compression (such as image/jpeg or image/webp). - * A user agent will use its default quality value if this option is not specified, or if the number is outside the allowed range. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {Promise} The base64 data url created from the extracted pixels. - */ - async base64(target, format, quality, frame) { - return this.#extract(target, frame, "base64", format, quality); - } - - /** - * Extract a rectangular block of pixels and convert them to a bitmap. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {Promise} The image bitmap created from the extracted pixels. - */ - async bitmap(target, frame) { - return this.#extract(target, frame, "bitmap"); - } - - /** - * Extract a rectangular block of pixels and put them in a canvas. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {Promise} The canvas element. - */ - async canvas(target, frame) { - return this.#extract(target, frame, "canvas"); - } - - /** - * Extract a rectangular block of pixels and convert them to an image. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {string} [format] - A string indicating the image format. The default type is `image/png`; this image format will be also used if the specified type is not supported. - * @param {number} [quality] - A number between 0 and 1 indicating the image quality to be used when creating images using file formats that support lossy compression (such as image/jpeg or image/webp). - * A user agent will use its default quality value if this option is not specified, or if the number is outside the allowed range. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {Promise} The image element created from the extracted pixels. - */ - async image(target, format, quality, frame) { - const image = new Image(); - - image.src = await this.base64(target, format, quality, frame); - - return image; - } - - /** - * Extract a rectangular block of pixels. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {Promise} The canvas element created from the extracted pixels. - */ - async pixels(target, frame) { - return this.#extract(target, frame, "pixels"); - } - - contextChange() { - this.#arrays = {}; - this.#buffers = {}; - } - - postrender() { - const renderer = this.renderer; - - if (!renderer.renderingToScreen) { - return; - } - - this.#count++; - this.#checkCount++; - - if (this.#checkCount > this.#checkCountMax) { - this.#checkCount = 0; - - const threshold = this.#count - this.#maxIdle; - - this.#deleteArrays(threshold); - this.#deleteBuffers(threshold); - } - } - - destroy() { - this.renderer = null; - this.#arrays = null; - this.#buffers = null; - this.#worker.terminate(); - this.#worker = null; - } - - /** - * Extract a rectangular block of pixels from the texture. - * @param {PIXI.DisplayObject|PIXI.RenderTexture|null} target - The target the pixels are extracted from; if `null`, the pixels are extracted from the renderer. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @param {"base64"|"bitmap"|"canvas"|"pixels"} func - * @param {...*} args - * @returns {Promise} - */ - async #extract(target, frame, func, ...args) { - const renderer = this.renderer; - let renderTexture; - let resolution; - let flipped; - let premultiplied; - let generated = false; - - if (target) { - if (target instanceof PIXI.RenderTexture) { - renderTexture = target; - } else { - renderTexture = renderer.generateTexture(target, { - resolution: renderer.resolution, - multisample: renderer.multisample - }); - generated = true; - } - } - - if (renderTexture) { - frame ??= renderTexture.frame; - resolution = renderTexture.baseTexture.resolution; - premultiplied = renderTexture.baseTexture.alphaMode > 0 - && renderTexture.baseTexture.format === PIXI.FORMATS.RGBA; - flipped = false; - - if (!generated) { - renderer.renderTexture.bind(renderTexture); - - const fbo = renderTexture.framebuffer.glFramebuffers[renderer.CONTEXT_UID]; - - if (fbo.blitFramebuffer) { - renderer.framebuffer.bind(fbo.blitFramebuffer); - } - } - } else { - const { alpha, premultipliedAlpha } = gl.getContextAttributes(); - - frame ??= renderer.screen; - resolution = renderer.resolution; - flipped = true; - premultiplied = alpha && premultipliedAlpha; - renderer.renderTexture.bind(null); - } - - const x = Math.round(frame.left * resolution); - const y = Math.round(frame.top * resolution); - const width = Math.round(frame.right * resolution) - x; - const height = Math.round(frame.bottom * resolution) - y; - const pixelsSize = width * height * 4; - const bufferSize = PIXI.utils.nextPow2(pixelsSize); - const extract = { pixels: null, x, y, width, height, flipped, premultiplied }; - const gl = renderer.gl; - - try { - if (renderer.context.webGLVersion === 1) { - const pixels = extract.pixels = this.#getArray(bufferSize); - - gl.readPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - } else { - const buffer = this.#getBuffer(bufferSize); - - try { - gl.readPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, 0); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - - await new Promise(function (resolve, reject) { - const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); - const wait = (flags = 0) => { - const status = gl.clientWaitSync(sync, flags, 0); - - if (status === gl.TIMEOUT_EXPIRED) { - setTimeout(wait, 10); - } else { - gl.deleteSync(sync); - - if (status === gl.WAIT_FAILED) { - reject(); - } else { - resolve(); - } - } - }; - - setTimeout(wait, 0, gl.SYNC_FLUSH_COMMANDS_BIT); - }); - - const pixels = extract.pixels = this.#getArray(bufferSize); - - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buffer); - gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels, 0, pixelsSize); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - } finally { - this.#returnBuffer(buffer, bufferSize); - } - } - } finally { - if (generated) { - renderTexture.destroy(true); - } - } - - const result = await this.#worker[func](extract, ...args); - - this.#returnArray(extract.pixels); - - return result; - } - - /** - * @param {number} size - * @returns {Uint8ClampedArray} - */ - #getArray(size) { - return (this.#arrays[size] ??= []).pop()?.reference - ?? new Uint8ClampedArray(size); - } - - /** - * @param {Uint8ClampedArray} array - */ - #returnArray(array) { - if (!array?.byteLength) { - return; - } - - this.#arrays[array.length].push({ reference: array, touched: this.#count }); - } - - /** - * @param {number} [threshold] - */ - #deleteArrays(threshold) { - for (const size in this.#arrays) { - const arrays = this.#arrays[size]; - - for (let i = arrays.length - 1; i >= 0; i--) { - const array = arrays[i]; - - if (!(array.touched >= threshold)) { - arrays[i] = arrays[arrays.length - 1]; - arrays.length--; - } - } - } - } - - /** - * @param {number} size - * @returns {WebGLBuffer} - */ - #getBuffer(size) { - const gl = this.renderer.gl; - const entry = (this.#buffers[size] ??= []).pop(); - let buffer; - - if (entry) { - buffer = entry.reference; - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buffer); - } else { - buffer = gl.createBuffer(); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buffer); - gl.bufferData(gl.PIXEL_PACK_BUFFER, size, gl.DYNAMIC_READ); - } - - return buffer; - } - - /** - * @param {WebGLBuffer} buffer - * @param {number} size - */ - #returnBuffer(buffer, size) { - this.#buffers[size].push({ reference: buffer, touched: this.#count }); - } - - /** - * @param {number} [threshold] - */ - #deleteBuffers(threshold) { - for (const size in this.#buffers) { - const buffers = this.#buffers[size]; - - for (let i = buffers.length - 1; i >= 0; i--) { - const buffer = buffers[i]; - - if (!(buffer.touched >= threshold)) { - this.renderer.gl.deleteBuffer(buffer.reference); - - buffers[i] = buffers[buffers.length - 1]; - buffers.length--; - } - } - } - } -} - -/** - * @typedef {{pixels:Uint8ClampedArray,width:number,height:number,flipped:boolean,premultiplied:boolean}} ExtractData - */ - -class ExtractWorker extends Worker { - /** @type {string} */ - static #objectURL; - - /** - * @type {string} - * @readonly - */ - static get objectURL() { - return this.#objectURL ??= URL.createObjectURL( - new Blob([EXTRACT_WORKER_SOURCE], - { type: "application/javascript" })); - } - - /** - * Is OffscreenCanvas with 2d context supported? - * @type {boolean} - * @readonly - */ - static #isOffscreenCanvasSupported = typeof OffscreenCanvas !== "undefined" - && !!new OffscreenCanvas(0, 0).getContext("2d"); - - /** @type {Mapvoid,reject:(error:Error)=>void}} */ - #tasks = new Map(); - - /** @type {number} */ - #nextTaskId = 0; - - constructor() { - super(ExtractWorker.objectURL); - - this.onmessage = this.#onMessage.bind(this); - } - - /** - * @param {ExtractData} extract - * @param {string} [type] - * @param {number} [quality] - * @returns {Promise} - */ - async base64(extract, type = "image/png", quality) { - if (ExtractWorker.#isOffscreenCanvasSupported) { - return this.#process(extract, type, quality); - } - - const pixels = await this.#process(extract); - const { width, height } = extract; - const canvas = await pixelsToCanvas(pixels, width, height); - - return canvasToBase64(canvas, type, quality); - } - - /** - * @param {ExtractData} extract - * @returns {Promise} - */ - async bitmap(extract) { - const pixels = await this.#process(extract); - const { width, height } = extract; - const size = width * height * 4; - const imageData = new ImageData(pixels.subarray(0, size), width, height); - - return createImageBitmap(imageData, { - imageOrientation: "none", - premultiplyAlpha: "none", - colorSpaceConversion: "none" - }); - } - - /** - * @param {ExtractData} extract - * @returns {Promise} - */ - async canvas(extract) { - const pixels = await this.#process(extract); - const { width, height } = extract; - - return pixelsToCanvas(pixels, width, height); - } - - /** - * @param {ExtractData} extract - * @returns {Promise} - */ - async pixels(extract) { - return this.#process(extract); - } - - /** - * @param {ExtractData} extract - * @param {string} [type] - * @param {number} [quality] - * @returns {Promise} - */ - async #process(extract, type, quality) { - const taskId = this.#nextTaskId++; - const taskData = { id: taskId, ...extract, type, quality }; - - return new Promise((resolve, reject) => { - this.#tasks.set(taskId, { extract, resolve, reject }); - this.postMessage(taskData, [extract.pixels.buffer]); - }); - } - - /** @param {MessageEvent} event */ - #onMessage(event) { - const { id, result, error, pixels } = event.data; - const task = this.#tasks.get(id); - - if (!task) { - return; - } - - this.#tasks.delete(id); - task.extract.pixels = pixels; - - if (error) { - return task.reject(new Error(error)); - } else { - return task.resolve(result); - } - } -} - -const EXTRACT_WORKER_SOURCE = `\ -/** - * Create an offscreen canvas element containing the pixels. - * @param {Uint8ClampedArray} pixels - * @param {number} width - * @param {number} height - * @returns {Promise} - */ -async function pixelsToCanvas(pixels, width, height) { - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - const size = width * height * 4; - const imageData = new ImageData(pixels.subarray(0, size), width, height); - - context.putImageData(imageData, 0, 0); - - return canvas; -} - -/** - * Asynchronously convert an offscreen canvas element to base64. - * @param {OffscreenCanvas} canvas - * @param {string} [type] - * @param {number} [quality] - * @returns {Promise} The base64 string of the canvas. - */ -async function canvasToBase64(canvas, type, quality) { - return canvas.convertToBlob({ type, quality }).then( - blob => new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }) - ); -} - -/** - * Asynchronously convert the pixels to base64. - * @param {Uint8ClampedArray} pixels - * @param {number} width - * @param {number} height - * @param {string} [type] - * @param {number} [quality] - * @returns {Promise} The base64 string of the canvas. - */ -async function pixelsToBase64(pixels, width, height, type, quality) { - const canvas = await pixelsToCanvas(pixels, width, height); - - return canvasToBase64(canvas, type, quality); -} - -/** - * Flip the pixels. - * @param {Uint8ClampedArray} pixels - * @param {number} width - * @param {number} height - */ -function flipPixels(pixels, width, height) { - const w = width << 2; - const h = height >> 1; - const temp = new Uint8ClampedArray(w); - - for (let y = 0; y < h; y++) { - const t = y * w; - const b = (height - y - 1) * w; - - temp.set(pixels.subarray(t, t + w)); - pixels.copyWithin(t, b, b + w); - pixels.set(temp, b); - } -} - -/** - * Unpremultiply the pixels. - * @param {Uint8ClampedArray} pixels - * @param {number} width - * @param {number} height - */ -function unpremultiplyPixels(pixels, width, height) { - const n = width * height * 4; - - for (let i = 0; i < n; i += 4) { - const alpha = pixels[i + 3]; - - if (alpha !== 0) { - const a = 255 / alpha; - - pixels[i] = pixels[i] * a ; - pixels[i + 1] = pixels[i + 1] * a; - pixels[i + 2] = pixels[i + 2] * a; - } - } -} - -onmessage = function(event) { - const { id, pixels, width, height, flipped, premultiplied, type, quality } = event.data; - - setTimeout(async () => { - try { - if (flipped) { - flipPixels(pixels, width, height); - } - - if (premultiplied) { - unpremultiplyPixels(pixels, width, height); - } - - if (type !== undefined) { - const result = await pixelsToBase64(pixels, width, height, type, quality); - - postMessage({ id, result, pixels }, [pixels.buffer]); - } else { - const result = pixels.slice(0, width * height * 4); - - postMessage({ id, result, pixels }, [pixels.buffer, result.buffer]); - } - } catch (e) { - postMessage({ id, error: e.message, pixels }, [pixels.buffer]); - - throw e; - } - }, 0); -}; -`; - -/** - * Create a canvas element containing the pixels. - * @param {Uint8ClampedArray} pixels - * @param {number} width - * @param {number} height - * @returns {Promise} - */ -async function pixelsToCanvas(pixels, width, height) { - const canvas = document.createElement("canvas"); - - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext("2d"); - const size = width * height * 4; - const imageData = new ImageData(pixels.subarray(0, size), width, height); - - context.putImageData(imageData, 0, 0); - - return canvas; -} - -/** - * Asynchronously convert a canvas element to base64. - * @param {HTMLCanvasElement} canvas - * @param {string} [type] - * @param {number} [quality] - * @returns {Promise} The base64 string of the canvas. - */ -async function canvasToBase64(canvas, type, quality) { - return new Promise((resolve, reject) => { - canvas.toBlob(blob => { - const reader = new FileReader(); - - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }, type, quality); - }); -} - -PIXI.extensions.add(ExtractSystem); diff --git a/scripts/perfect-vision/extract-pixels.js b/scripts/perfect-vision/extract-pixels.js deleted file mode 100644 index a5cfa91..0000000 --- a/scripts/perfect-vision/extract-pixels.js +++ /dev/null @@ -1,123 +0,0 @@ -// Shamelessly borrowed from https://github.com/dev7355608/perfect-vision/blob/main/scripts/utils/extract-pixels.js - -/** - * Extract a rectangular block of pixels from the texture (without unpremultiplying). - * @param {PIXI.Renderer} renderer - The renderer. - * @param {PIXI.Texture|PIXI.RenderTexture|null} [texture] - The texture the pixels are extracted from; otherwise extract from the renderer. - * @param {PIXI.Rectangle} [frame] - The rectangle the pixels are extracted from. - * @returns {{pixels: Uint8Array, width: number, height: number}} The extracted pixel data. - */ -export function extractPixels(renderer, texture, frame) { - const baseTexture = texture?.baseTexture; - - if (texture && (!baseTexture || !baseTexture.valid || baseTexture.parentTextureArray)) { - throw new Error("Texture is invalid"); - } - - const gl = renderer.gl; - const readPixels = (frame, resolution) => { - const x = Math.round(frame.left * resolution); - const y = Math.round(frame.top * resolution); - const width = Math.round(frame.right * resolution) - x; - const height = Math.round(frame.bottom * resolution) - y; - const pixels = new Uint8Array(4 * width * height); - - gl.readPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - - return { pixels, x, y, width, height }; - } - - if (!texture) { - renderer.renderTexture.bind(null); - - return readPixels(frame ?? renderer.screen, renderer.resolution); - } else if (texture instanceof PIXI.RenderTexture) { - renderer.renderTexture.bind(texture); - - return readPixels(frame ?? texture.frame, baseTexture.resolution); - } else { - renderer.texture.bind(texture); - - const framebuffer = gl.createFramebuffer(); - - try { - gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.TEXTURE_2D, - baseTexture._glTextures[renderer.CONTEXT_UID]?.texture, - 0 - ); - - if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { - throw new Error("Failed to extract pixels from texture"); - } - - return readPixels(frame ?? texture.frame, baseTexture.resolution); - } finally { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.deleteFramebuffer(framebuffer); - } - } -} - -/** - * Unpremultiply the pixel data. - * @param {Uint8Array} pixels - */ -export function unpremultiplyPixels(pixels) { - const n = pixels.length; - - for (let i = 0; i < n; i += 4) { - const alpha = pixels[i + 3]; - - if (alpha === 0) { - const a = 255 / alpha; - - pixels[i] = Math.min(pixels[i] * a + 0.5, 255); - pixels[i + 1] = Math.min(pixels[i + 1] * a + 0.5, 255); - pixels[i + 2] = Math.min(pixels[i + 2] * a + 0.5, 255); - } - } -} - -/** - * Create a canvas element containing the pixel data. - * @param {Uint8Array} pixels - * @param {number} width - * @param {number} height - * @returns {HTMLCanvasElement} - */ -export function pixelsToCanvas(pixels, width, height) { - const canvas = document.createElement("canvas"); - - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext("2d"); - const imageData = context.getImageData(0, 0, width, height); - - imageData.data.set(pixels); - context.putImageData(imageData, 0, 0); - - return canvas; -} - -/** - * Asynchronously convert a canvas element to base64. - * @param {HTMLCanvasElement} canvas - * @param {string} [type="image/png"] - * @param {number} [quality] - * @returns {Promise} The base64 string of the canvas. - */ -export async function canvasToBase64(canvas, type, quality) { - return new Promise((resolve, reject) => { - canvas.toBlob(blob => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }, type, quality); - }); -} \ No newline at end of file diff --git a/scripts/perfect-vision/shader-patcher.js b/scripts/perfect-vision/shader-patcher.js deleted file mode 100644 index c6f0581..0000000 --- a/scripts/perfect-vision/shader-patcher.js +++ /dev/null @@ -1,580 +0,0 @@ -/* globals - -*/ -"use strict"; - -// From: https://github.com/dev7355608/perfect-vision/blob/498821468a50d4d446106969b00d2a1854b19481/scripts/core/point-source-shader.js#L479 - -export function applyPatches(shaderClass, patchVertex, patchFrag) { - if (patchVertex && !shaderClass.vertexShader.includes("#define ELEVATED_VISION")) { - shaderClass.vertexShader = patchVertex(shaderClass.vertexShader) - + "\n\n#define ELEVATED_VISION\n"; - } - if (patchFrag && !shaderClass.fragmentShader.includes("#define ELEVATED_VISION")) { - shaderClass.fragmentShader = patchFrag(shaderClass.fragmentShader) - + "\n\n#define ELEVATED_VISION\n"; - } -} - - -// From: https://github.com/dev7355608/perfect-vision/blob/498821468a50d4d446106969b00d2a1854b19481/scripts/utils/shader-patcher.js - -/** - * GLSL shader patcher. - */ -export class ShaderPatcher { - /** - * The seed of the random number generator. - * @type {number} - * @readonly - */ - static #seed = 0; - - /** - * The shader type. - * @type {"vert"|"frag"} - * @readonly - */ - #type; - - /** - * The shader source. - * @type {string} - */ - #source; - - /** - * The shader version. - * @type {"100"|"300 es"|""} - */ - #version; - - /** - * The unique sequence. - * @type {string} - */ - #unique; - - /** - * The patcher count. - * @type {string} - */ - #counter; - - /** - * The stashed comments. - * @type {string[]} - */ - #comments = []; - - /** - * The file name and line number that that applies a patch. - * @type {string[]} - */ - #caller = null; - - /** - * The stashed local scopes. - * @type {string[]} - */ - #scopes = null; - - /** - * @param {"vert"|"frag"} type - The shader type. - */ - constructor(type) { - console.assert(type === "vert" || type === "frag"); - - this.#type = type; - } - - /** - * @param {boolean} [stashLocalScopes=false] - * @returns {this} - */ - #preprocess(stashLocalScopes = false) { - if (this.#caller === null) { - const error = new Error(); - let caller; - - if (typeof error.stacktrace !== "undefined" || typeof error["opera#sourceloc"] !== "undefined") { // Opera - caller = error.stack.split("\n") - .filter(line => line.match(/(^|@)\S+:\d+/) && !line.match(/^Error created at/))[2] - ?.split("@").pop(); - } else if (error.stack && error.stack.match(/^\s*at .*(\S+:\d+|\(native\))/m)) { // V8/IE - caller = error.stack.split("\n") - .filter(line => line.match(/^\s*at .*(\S+:\d+|\(native\))/m))[2] - ?.match(/ \((.+:\d+:\d+)\)$/)?.[1]; - } else if (error.stack) { // FF/Safari - caller = error.stack.split("\n").filter(line => !line.match(/^(eval@)?(\[native code])?$/))[2]; - caller = !caller || caller.indexOf("@") === -1 && caller.indexOf(":") === -1 - ? caller : caller.replace(/(.*".+"[^@]*)?[^@]*@/, ""); - } - - this.#caller = caller || "unkown"; - } - - if (stashLocalScopes) { - this.#scopes = []; - - let index = 0; - - outer: while (index !== this.#source.length) { - while (this.#source[index++] !== "{") { - if (index === this.#source.length) { - break outer; - } - } - - const start = index; - let level = 1; - - do { - switch (this.#source[index++]) { - case "{": - level++; - break; - case "}": - level--; - break; - } - } while (level !== 0); - - this.#scopes.push(this.#source.slice(start, index - 1)); - this.#source = this.#source.slice(0, start) - + ` scope_${this.#unique}_s${this.#scopes.length - 1} ` - + this.#source.slice(index - 1); - - index = start + 1; - } - } - - return this; - } - - /** - * @returns {this} - */ - #postprocess() { - this.#source = this.#source - .replace(/(?:\/\*[\s\S]*?\*\/)|(?:\/\/.*)/gm, comment => { - this.#comments.push(comment); - return ` comment_${this.#unique}_c${this.#comments.length - 1} `; - }) - .replace(/@@(\w+)/g, `$1_${this.#unique}_p${this.#counter}`) - .replace(/@(\w+)/g, `$1_${this.#unique}_i`); - this.#caller = null; - - if (this.#scopes) { - this.#source = this.#source - .replace(new RegExp(` scope_${this.#unique}_s(\\d+) `, "g"), (_, i) => this.#scopes[parseInt(i, 10)]); - this.#scopes = null; - } - - return this; - } - - /** - * Set the shader source. - * @param {string} source - The shader source. - * @returns {this} - */ - setSource(source) { - console.assert(typeof source === "string"); - - if (this.#source !== undefined) { - throw new Error("Source was set already!"); - } - - this.#source = source.trim(); - this.#version = ""; - - if (this.#source.startsWith("#version")) { - this.#version = this.#source.match(/#version\s+(.+)/)[1].trim().replace(/\s+/g, " "); - this.#source = this.#source.split("\n").slice(1).join("\n").trim(); - } - - const [, unique, counter] = this.#source.match(/\/\/ ShaderPatcher-((?:[0-9][a-zA-Z]){4,})-(\d+)\n/) ?? []; - - this.#unique = unique; - this.#counter = counter; - - if (!this.#unique) { - function mulberry32(a) { - return function () { - let t = a = a + 0x6D2B79F5 | 0; - - t = Math.imul(t ^ t >>> 15, t | 1); - t ^= t + Math.imul(t ^ t >>> 7, t | 61); - - return ((t ^ t >>> 14) >>> 0) / 4294967296; - } - } - - const random = mulberry32(ShaderPatcher.#seed); - - do { - this.#unique = String.fromCharCode(49 + Math.floor(random() * 9)) + String.fromCharCode(103 + Math.floor(random() * 20)); - - for (let i = 2; i < 8; i++) { - if (i % 2) { - this.#unique += String.fromCharCode(((i - 1) % 4 ? 65 : 97) + Math.floor(random() * 26)); - } else { - this.#unique += String.fromCharCode(48 + Math.floor(random() * 10)); - } - } - } while (this.#source.includes(this.#unique)); - - this.#counter = 0; - } else { - this.#counter = parseInt(this.#counter, 10); - this.#source = this.#source.replace(/\/\/ ShaderPatcher-((?:[0-9][a-zA-Z]){4,})-(\d+)\n/g, ""); - } - - this.#postprocess(); - - if (this.#counter === 0) { - this.#preprocess(true); - this.#source = this.#source.replace( - /\b(?:(?:const|attribute|uniform|varying|in)\s+)?(?:(?:lowp|mediump|highp)\s+)?(?:\w+)\s+(\w+)\s*(?:=[^;]+?)?;/gm, - `\n/* Patched by ${this.#caller} */\n#ifndef $1_${this.#unique}_d\n#define $1_${this.#unique}_d\n$&\n#endif\n` - ); - this.#postprocess(); - } - - return this; - } - - /** - * Get the patched shader source. - * @param {object} [options] - * @param {"100"|"300 es"} [options.version] - * @param {"lowp"|"mediump"|"highp"} [options.precision] - * @returns {this} - */ - getSource({ version, precision } = {}) { - console.assert(version === undefined || version === "100" || version === "300 es"); - console.assert(precision === undefined || precision === "lowp" || precision === "mediump" || precision === "highp"); - - if (version === "100" && this.#version === "300 es") { - throw new Error("Shader cannot be converted from version 300 es to 100!"); - } - - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - let source = this.#source; - - if (version === "300 es" && this.#version !== "300 es") { - source = (this.#type === "vert" ? `\ -#define attribute in -#define varying out -` : `\ -#define varying in -#define texture2D texture -#define textureCube texture -#define texture2DProj textureProj -#define texture2DLodEXT textureLod -#define texture2DProjLodEXT textureProjLod -#define textureCubeLodEXT textureLod -#define texture2DGradEXT textureGrad -#define texture2DProjGradEXT textureProjGrad -#define textureCubeGradEXT textureGrad -#define gl_FragDepthEXT gl_FragDepth -#define gl_FragColor fragColor_${this.#unique}_o -layout(location = 0) out highp vec4 fragColor_${this.#unique}_o; -`) + "\n\n" + source.replace( - /\b(layout|centroid|smooth|case|mat2x2|mat2x3|mat2x4|mat3x2|mat3x3|mat3x4|mat4x2|mat4x3|mat4x4|uvec2|uvec3|uvec4|samplerCubeShadow|sampler2DArray|sampler2DArrayShadow|isampler2D|isampler3D|isamplerCube|isampler2DArray|usampler2D|usampler3D|usamplerCube|usampler2DArray|coherent|restrict|readonly|writeonly|resource|atomic_uint|noperspective|patch|sample|subroutine|common|partition|active|filter|image1D|image2D|image3D|imageCube|iimage1D|iimage2D|iimage3D|iimageCube|uimage1D|uimage2D|uimage3D|uimageCube|image1DArray|image2DArray|iimage1DArray|iimage2DArray|uimage1DArray|uimage2DArray|image1DShadow|image2DShadow|image1DArrayShadow|image2DArrayShadow|imageBuffer|iimageBuffer|uimageBuffer|sampler1DArray|sampler1DArrayShadow|isampler1D|isampler1DArray|usampler1D|usampler1DArray|isampler2DRect|usampler2DRect|samplerBuffer|isamplerBuffer|usamplerBuffer|sampler2DMS|isampler2DMS|usampler2DMS|sampler2DMSArray|isampler2DMSArray|usampler2DMSArray)\b/g, - `$&_${this.#unique}_r` - ); - } - - version ??= this.#version; - precision ??= this.#type === "vert" ? "highp" : "mediump"; - - return (version ? `#version ${version}\n\n` : "") - + `precision ${precision} float;\n\n` - + `// ShaderPatcher-${this.#unique}-${this.#counter + 1}\n\n` - + source - .replace(/\bprecision\s+(?:lowp|mediump|highp)\s+float\s*;/gm, "") - .replace(new RegExp(`\\b(\\w+)_${this.#unique}_i`, "g"), `\n#undef $1\n$1\n#define $1 $1_${this.#unique}_v\n`) - .replace(new RegExp(` comment_${this.#unique}_c(\\d+) `, "g"), (_, i) => this.#comments[parseInt(i, 10)]) - .trim(); - } - - /** - * Search and replace the pattern. - * @param {Regex} searchPattern - The search pattern. - * @param {string} replaceValue - The replacement string. - * @param {boolean} [requireMatch=true] - Require a match? - * @param {boolean} [noLocalScope=false] - Only search inside the global scope. - * @returns {this} - * @throws Throws an error if the pattern isn't found and a match is required. - */ - replace(searchPattern, replaceValue, requireMatch = true, noLocalScope = false) { - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - if (requireMatch && !this.#source.match(searchPattern)) { - throw new Error("No match was found!"); - } - - this.#preprocess(noLocalScope); - this.#source = this.#source.replace( - searchPattern, - typeof replaceValue === "string" - ? `${replaceValue} /* Patched by ${this.#caller} */` - : (...args) => `${replaceValue(...args)} /* Patched by ${this.#caller} */` - ); - - return this.#postprocess(); - } - - /** - * Require that the variable exists - * @param {string} variableName - The name of the variable. - * @returns {this} - * @throws Throws an error if variable doesn't exist. - */ - requireVariable(variableName) { - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - const regex = new RegExp(`\\b(?:const|attribute|uniform|varying|in)(?:\\s+(lowp|mediump|highp))?\\s+(\\w+)\\s+(${variableName})\\s*;`, "gm"); - - if (!this.#source.match(regex)) { - throw new Error(`Variable '${variableName}' was not found!`); - } - - return this; - } - - /** - * Override the variable. - * @param {string} variableName - The name of the variable. - * @param {string} [constantValue] - The constant to replace the variable by. - * @param {boolean} [requireMatch=true] - Require a match? - * @returns {this} - * @throws Throws an error if the variable doesn't exist and a match is required. - */ - overrideVariable(variableName, constantValue, requireMatch = true) { - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - if (this.#source.match(new RegExp(`#define ${variableName} ${variableName}_${this.#unique}_v\\b`))) { - if (constantValue) { - throw new Error(`Variable '${variableName}' was already replaced by a constant!`); - } - - return this; - } - - const regex = new RegExp(`\\b(?:const|attribute|uniform|varying|in)(?:\\s+(lowp|mediump|highp))?\\s+(\\w+)\\s+(${variableName})\\s*;`, "gm"); - - if (requireMatch && !this.#source.match(regex)) { - throw new Error(`Variable '${variableName}' was not found!`); - } - - this.#preprocess(true); - this.#source = this.#source.replace( - regex, - `\n/* Patched by ${this.#caller} */\n$&\n` - + (constantValue - ? `const $1 $2 $3_${this.#unique}_v = ${constantValue};\n` - : `$1 $2 $3_${this.#unique}_v;\n`) - + `#define $3 $3_${this.#unique}_v\n` - ); - - return this.#postprocess(); - } - - /** - * Override the function. - * @param {string} functionName - The name of the function. - * @param {string} functionBody - The new function body. - * @param {boolean} [requireMatch=true] - Require a match? - * @returns {this} - * @throws Throws an error if the variable doesn't exist and a match is required. - */ - overrideFunction(functionName, functionBody, requireMatch = true) { - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - let index = this.#source.search(new RegExp(`\\b(?:\\w+\\s+)+${functionName}\\s*\\([^)]*?\\)\\s*{`, "gm")); - - if (index < 0) { - if (requireMatch) { - throw new Error(`Function '${functionName}' was not found!`); - } - - return this; - } - - while (this.#source[index++] !== "{"); - - const start = index; - let level = 1; - - do { - switch (this.#source[index++]) { - case "{": - level++; - break; - case "}": - level--; - break; - } - } while (level !== 0); - - this.#preprocess(); - this.#source = this.#source.slice(0, start) - + `\n/* Patched by ${this.#caller} */\n/* ${this.#source.slice(start, index - 1)} */\n${functionBody}\n` - + this.#source.slice(index - 1); - - return this.#postprocess(); - } - - /** - * Prepend a code block. - * @param {string} code - The code block. - * @returns {this} - */ - prependBlock(code) { - this.#preprocess(true); - this.#source = `\n\n/* Patched by ${this.#caller} */\n` + code.trim() + `\n\n` + this.#source; - - return this.#postprocess(); - } - - /** - * Prepend a code block. - * @param {string} code - The code block. - * @returns {this} - */ - appendBlock(code) { - this.#preprocess(true); - this.#source += `\n\n/* Patched by ${this.#caller} */\n` + code.trim() + `\n\n`; - - return this.#postprocess(); - } - - /** - * Add the variable. - * @param {string} name - The name of the variable. - * @param {string} type - The type of the variable. - * @param {string} [value] - The value of the variable. - * @returns {this} - */ - #addVariable(name, type, value) { - let array = []; - - if (type.includes("[")) { - [type, ...array] = type.split(/(?=\[)/g); - } - - return this.#preprocess().prependBlock(`#ifndef ${name}_${this.#unique}_d\n#define ${name}_${this.#unique}_d\n${type} ${name}${array.join("")}${value !== undefined ? ` = ${value}` : ""};\n#endif\n`); - } - - /** - * Add the global variable. - * @param {string} name - The name of the global variable. - * @param {string} type - The type of the global variable. - * @param {string} [value] - The value of the global variable. - * @returns {this} - */ - addGlobal(name, type, value) { - return this.#preprocess().#addVariable(name, type, value); - } - - /** - * Add the constant. - * @param {string} name - The name of the constant. - * @param {string} type - The type of the constant. - * @param {string} [value] - The value of the constant. - * @returns {this} - */ - addConst(name, type, value) { - return this.#preprocess().#addVariable(name, `const ${type}`, value); - } - - /** - * Add the attribute. - * @param {string} name - The name of the attribute. - * @param {string} type - The type of the attribute. - * @returns {this} - */ - addAttribute(name, type) { - return this.#preprocess().#addVariable(name, `attribute ${type}`); - } - - /** - * Add the varying. - * @param {string} name - The name of the varying. - * @param {string} type - The type of the varying. - * @returns {this} - */ - addVarying(name, type) { - return this.#preprocess().#addVariable(name, `varying ${type}`); - } - - /** - * Add the uniform. - * @param {string} name - The name of the uniform. - * @param {string} type - The type of the uniform. - * @returns {this} - */ - addUniform(name, type) { - return this.#preprocess().#addVariable(name, `uniform ${type}`); - } - - /** - * Add the function. - * @param {string} name - The name of the function. - * @param {string} type - The type of the function. - * @param {string} body - The body of the function. - * @returns {this} - */ - addFunction(name, type, body) { - const [returnType, params] = type.split(/(?=\()/g); - - this.#preprocess().prependBlock(`#ifndef ${name}_${this.#unique}_d\n#define ${name}_${this.#unique}_d\n${returnType} ${name}${params};\n#endif\n`); - this.#preprocess().appendBlock(`#ifndef ${name}_${this.#unique}_f\n#define ${name}_${this.#unique}_f\n${returnType} ${name}${params} {\n${body}\n}\n#endif\n`) - - return this; - } - - /** - * Wrap the main function. - * @param {string} code - The body of the new main function. - * @returns {this} - */ - wrapMain(code) { - if (this.#source === undefined) { - throw new Error("Source has not been set yet!"); - } - - this.#preprocess(); - - let i = 0; - - for (const match of this.#source.matchAll(new RegExp(`\\bmain_${this.#unique}_w(\\d+)\\b`, "g"))) { - i = Math.max(i, parseInt(match[1], 10) + 1); - } - - this.#source = this.#source.replace( - /\bvoid\s+main(?=\s*\([^)]*?\))/gm, - `void main_${this.#unique}_w${i}` - ); - - this.#source += `\n\n\n/* Patched by ${this.#caller} */\n\n`; - this.#source += code.trim().replace(/@main(?=\()/g, `main_${this.#unique}_w${i}`); - this.#source += `\n\n\n`; - - return this.#postprocess(); - } -} diff --git a/scripts/util.js b/scripts/util.js index de57e2a..dbe163f 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -41,120 +41,6 @@ export function isString(obj) { return (typeof obj === "string" || obj instanceof String); } -/** - * Fast rounding for positive numbers - * @param {number} n - * @returns {number} - */ -export function roundFastPositive(n) { return (n + 0.5) << 0; } - -/** - * Bresenham line algorithm to generate pixel coordinates for a line between two points. - * All coordinates must be positive or zero. - * @param {number} x0 First coordinate x value - * @param {number} y0 First coordinate y value - * @param {number} x1 Second coordinate x value - * @param {number} y1 Second coordinate y value - * @testing -Draw = CONFIG.GeometryLib.Draw -let [t0, t1] = canvas.tokens.controlled -pixels = bresenhamLine(t0.center.x, t0.center.y, t1.center.x, t1.center.y) -for ( let i = 0; i < pixels.length; i += 2 ) { - Draw.point({ x: pixels[i], y: pixels[i + 1]}, { radius: 1 }); -} - */ -export function bresenhamLine(x0, y0, x1, y1) { - x0 = Math.round(x0); - y0 = Math.round(y0); - x1 = Math.round(x1); - y1 = Math.round(y1); - - const dx = Math.abs(x1 - x0); - const dy = Math.abs(y1 - y0); - const sx = (x0 < x1) ? 1 : -1; - const sy = (y0 < y1) ? 1 : -1; - let err = dx - dy; - - const pixels = [x0, y0]; - while ( x0 !== x1 || y0 !== y1 ) { - const e2 = err * 2; - if ( e2 > -dy ) { - err -= dy; - x0 += sx; - } - if ( e2 < dx ) { - err += dx; - y0 += sy; - } - - pixels.push(x0, y0); - } - return pixels; -} - -export function* bresenhamLineIterator(x0, y0, x1, y1) { - x0 = Math.floor(x0); - y0 = Math.floor(y0); - x1 = Math.floor(x1); - y1 = Math.floor(y1); - - const dx = Math.abs(x1 - x0); - const dy = Math.abs(y1 - y0); - const sx = (x0 < x1) ? 1 : -1; - const sy = (y0 < y1) ? 1 : -1; - let err = dx - dy; - yield { x: x0, y: y0 }; - while ( x0 !== x1 || y0 !== y1 ) { - const e2 = err * 2; - if ( e2 > -dy ) { - err -= dy; - x0 += sx; - } - if ( e2 < dx ) { - err += dx; - y0 += sy; - } - - yield { x: x0, y: y0 }; - } -} - -/** - * Trim line segment to its intersection points with a rectangle. - * If the endpoint is inside the rectangle, keep it. - * Note: points on the right or bottom border of the rectangle do not count b/c we want the pixel positions. - * @param {PIXI.Rectangle} rect - * @param {Point} a - * @param {Point} b - * @returns { Point[2]|null } Null if both are outside. - */ -export function trimLineSegmentToPixelRectangle(rect, a, b) { - rect = new PIXI.Rectangle(rect.x, rect.y, rect.width - 1, rect.height - 1); - - if ( !rect.lineSegmentIntersects(a, b, { inside: true }) ) return null; - - const ixs = rect.segmentIntersections(a, b); - if ( ixs.length === 2 ) return ixs; - if ( ixs.length === 0 ) return [a, b]; - - // If only 1 intersection: - // 1. a || b is inside and the other is outside. - // 2. a || b is on the edge and the other is outside. - // 3. a || b is on the edge and the other is inside. - // Point on edge will be considered inside by _getZone. - - // 1 or 2 for a - const aOutside = rect._getZone(a) !== PIXI.Rectangle.CS_ZONES.INSIDE; - if ( aOutside ) return [ixs[0], b]; - - // 1 or 2 for b - const bOutside = rect._getZone(b) !== PIXI.Rectangle.CS_ZONES.INSIDE; - if ( bOutside ) return [a, ixs[0]]; - - // 3. One point on the edge; other inside. Doesn't matter which. - return [a, b]; -} - /** * From https://stackoverflow.com/questions/14446511/most-efficient-method-to-groupby-on-an-array-of-objects * Takes an Array, and a grouping function, @@ -177,4 +63,49 @@ export function groupBy(list, keyGetter) { else collection.push(item); }); return map; -} \ No newline at end of file +} + + +/** + * Get the grid shape for a given set of grid coordinates. + * @param {number[2]} gridCoords Array of [row, col] grid coordinates. See canvas.grid.grid.getGridPositionFromPixels + * @returns {PIXI.Rectangle|PIXI.Polygon} + */ +export function gridShapeFromGridCoords(gridCoords) { + const [tlx, tly] = canvas.grid.grid.getPixelsFromGridPosition(gridCoords[0], gridCoords[1]); + if ( canvas.grid.isHex ) return hexGridShape(tlx, tly); + return squareGridShape(tlx, tly) + +} + +/** + * Get a square grid shape from the top left corner position. + * @param {number} tlx Top left x coordinate + * @param {number} tly Top left y coordinate + * @returns {PIXI.Rectangle} + */ +function squareGridShape(tlx, tly) { + // Get the top left corner + const { w, h } = canvas.grid; + return new PIXI.Rectangle(tlx, tly, w, h); +} + +/** + * Get a hex grid shape from the top left corner position. + * @param {number} tlx Top left x coordinate + * @param {number} tly Top left y coordinate + * @returns {PIXI.Polygon} + */ +function hexGridShape(tlx, tly, { width = 1, height = 1 } = {}) { + // Canvas.grid.grid.getBorderPolygon will return null if width !== height. + if ( width !== height ) return null; + + // Get the top left corner + const points = canvas.grid.grid.getBorderPolygon(width, height, 0); + const pointsTranslated = []; + const ln = points.length; + for ( let i = 0; i < ln; i += 2) { + pointsTranslated.push(points[i] + tlx, points[i+1] + tly); + } + return new PIXI.Polygon(pointsTranslated); +}