diff --git a/examples/source_file_geojson_raster_2.html b/examples/source_file_geojson_raster_2.html index 3215a697a0..89072cd100 100644 --- a/examples/source_file_geojson_raster_2.html +++ b/examples/source_file_geojson_raster_2.html @@ -130,7 +130,7 @@ view.addLayer(earthquakeLayer).then(FeatureToolTip.addLayer); // Request redraw - view.notifyChange(true); + view.notifyChange(); debug.createTileDebugUI(menuGlobe.gui, view); window.view = view; diff --git a/packages/Main/src/Converter/Feature2Texture.js b/packages/Main/src/Converter/Feature2Texture.js index b169fd00d9..043f4a034c 100644 --- a/packages/Main/src/Converter/Feature2Texture.js +++ b/packages/Main/src/Converter/Feature2Texture.js @@ -1,11 +1,88 @@ import * as THREE from 'three'; import { FEATURE_TYPES } from 'Core/Feature'; import { Extent, Coordinates } from '@itowns/geographic'; -import Style, { StyleContext } from 'Core/Style'; +import Style, { StyleContext, loadImage, cropImage } from 'Core/Style'; +import { createContext2D, sharedContext2D } from 'Utils/CanvasUtils'; + +let _matrix; +function matrix() { + if (!_matrix) { + _matrix = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix(); + } + return _matrix; +} + +export function applyStrokeToPolygon( + /** @type {CanvasRenderingContext2D} */context, + /** @type {Path2D} */polygon, + stroke, + /** @type {number} */scale, +) { + if (context.strokeStyle !== stroke.color) { + context.strokeStyle = stroke.color; + } + const width = stroke.width * scale; + if (context.lineWidth !== width) { + context.lineWidth = width; + } + const alpha = stroke.opacity; + if (alpha !== context.globalAlpha && typeof alpha == 'number') { + context.globalAlpha = alpha; + } + if (context.lineCap !== stroke.lineCap) { + context.lineCap = stroke.lineCap; + } + context.setLineDash(stroke.dasharray.map(a => a * scale * 2)); + context.stroke(polygon); +} + +async function createPattern( + /** @type {CanvasRenderingContext2D} */context, + pattern, +) { + if (typeof pattern == 'object' && 'source' in pattern) { // ImageRegion + const { source, cropValues = {} } = pattern; + const img = await loadImage(source); + const { + x = 0, + y = 0, + width = 'naturalWidth' in img ? img.naturalWidth : img.width, + height = 'naturalHeight' in img ? img.naturalHeight : img.height, + } = cropValues; + + const cropCtx = sharedContext2D(); + cropCtx.canvas.width = width; + cropCtx.canvas.height = height; + cropImage(cropCtx, img, x, y, width, height); + return context.createPattern(cropCtx.canvas, 'repeat'); + } else { // string | HTMLImageElement | HTMLCanvasElement + const img = await loadImage(pattern); + context.drawImage(img, 0, 0); + } + return context.createPattern(context.canvas, 'repeat'); +} + +export async function applyFillToPolygon( + /** @type {CanvasRenderingContext2D} */context, + /** @type {Path2D} */polygon, + fill, + /** @type {number} */scale, +) { + if (fill.pattern) { + const fillStyle = await createPattern(context, fill.pattern); // by image already loaded + fillStyle.setTransform(matrix().scale(scale)); + context.fillStyle = fillStyle; + } else if (context.fillStyle !== fill.color) { + context.fillStyle = fill.color; + } + if (fill.opacity !== context.globalAlpha) { + context.globalAlpha = fill.opacity; + } + context.fill(polygon); +} const defaultStyle = new Style(); const context = new StyleContext(); -let style; /** * Draw polygon (contour, line edge and fill) based on feature vertices into canvas @@ -16,12 +93,22 @@ let style; * @param {object[]} indices - Contains the indices that define the geometry. * Objects stored in this array have two properties, an `offset` and a `count`. * The offset is related to the overall number of vertices in the Feature. + * @param {object} style - The style to apply for this feature. * @param {number} size - The size of the feature. * @param {number} extent - The extent. * @param {number} invCtxScale - The ration to scale line width and radius circle. * @param {boolean} canBeFilled - true if feature.type == FEATURE_TYPES.POLYGON */ -function drawPolygon(ctx, vertices, indices = [{ offset: 0, count: 1 }], size, extent, invCtxScale, canBeFilled) { +function drawPolygon( + ctx, + vertices, + indices = [{ offset: 0, count: 1 }], + size, + style, + extent, + invCtxScale, + canBeFilled, +) { if (vertices.length === 0) { return; } @@ -38,59 +125,83 @@ function drawPolygon(ctx, vertices, indices = [{ offset: 0, count: 1 }], size, e } } } - style.applyToCanvasPolygon(ctx, path, invCtxScale, canBeFilled); + + const { stroke, fill } = style; + + if (stroke && stroke.width > 0) { + // TO DO add possibility of using a pattern (https://github.com/iTowns/itowns/issues/2210) + applyStrokeToPolygon(ctx, path, stroke, invCtxScale); + } + + if (canBeFilled && fill && (fill.pattern || fill.color)) { + applyFillToPolygon(ctx, path, fill, invCtxScale); + } } -function drawPoint(ctx, x, y, invCtxScale) { +function drawPoint( + /** @type {CanvasRenderingContext2D} */ctx, + /** @type {number} */x, + /** @type {number} */y, + point, + /** @type {number} */scale, +) { + const { color, radius = 3.0, line, width = 1.0, opacity = 1.0 } = point; + ctx.beginPath(); - const opacity = style.point.opacity == undefined ? 1.0 : style.point.opacity; + ctx.arc(x, y, radius * scale, 0, 2 * Math.PI, false); + if (opacity !== ctx.globalAlpha) { ctx.globalAlpha = opacity; } - - ctx.arc(x, y, (style.point.radius || 3.0) * invCtxScale, 0, 2 * Math.PI, false); - if (style.point.color) { - ctx.fillStyle = style.point.color; + if (color) { + ctx.fillStyle = color; ctx.fill(); } - if (style.point.line) { - ctx.lineWidth = (style.point.width || 1.0) * invCtxScale; - ctx.strokeStyle = style.point.line; + if (line) { + ctx.lineWidth = width * scale; + ctx.strokeStyle = line; ctx.stroke(); } } const coord = new Coordinates('EPSG:4326', 0, 0, 0); -function drawFeature(ctx, feature, extent, invCtxScale) { +function drawFeature( + /** @type {CanvasRenderingContext2D} */ctx, + /** @type {Feature} */feature, + style, + /** @type {Extent} */extent, + /** @type {number} */invCtxScale, +) { const extentDim = extent.planarDimensions(); const scaleRadius = extentDim.x / ctx.canvas.width; + const { zoom } = style; + const { min = 0, max = Infinity } = zoom; for (const geometry of feature.geometries) { - if (Extent.intersectsExtent(geometry.extent, extent)) { + if (geometry.extent && Extent.intersectsExtent(geometry.extent, extent)) { context.setGeometry(geometry); - if (style.zoom.min > style.context.zoom || style.zoom.max <= style.context.zoom) { + if (min > style.context.zoom || max <= style.context.zoom) { return; } - if ( - feature.type === FEATURE_TYPES.POINT && style.point - ) { + if (feature.type === FEATURE_TYPES.POINT && style.point) { + const { radius = 3.0 } = style.point; // cross multiplication to know in the extent system the real size of // the point - const px = (Math.round(style.point.radius * invCtxScale) || 3 * invCtxScale) * scaleRadius; + const px = Math.round(radius * invCtxScale) * scaleRadius; for (const indice of geometry.indices) { const offset = indice.offset * feature.size; const count = offset + indice.count * feature.size; for (let j = offset; j < count; j += feature.size) { coord.setFromArray(feature.vertices, j); if (extent.isPointInside(coord, px)) { - drawPoint(ctx, feature.vertices[j], feature.vertices[j + 1], invCtxScale); + drawPoint(ctx, feature.vertices[j], feature.vertices[j + 1], style.point, invCtxScale); } } } } else { - drawPolygon(ctx, feature.vertices, geometry.indices, feature.size, extent, invCtxScale, (feature.type == FEATURE_TYPES.POLYGON)); + drawPolygon(ctx, feature.vertices, geometry.indices, feature.size, style, extent, invCtxScale, (feature.type == FEATURE_TYPES.POLYGON)); } } } @@ -109,29 +220,32 @@ const featureExtent = new Extent('EPSG:4326', 0, 0, 0, 0); export default { // backgroundColor is a THREE.Color to specify a color to fill the texture // with, given there is no feature passed in parameter - createTextureFromFeature(collection, extent, sizeTexture, layerStyle, backgroundColor) { - style = layerStyle || defaultStyle; + createTextureFromFeature( + /** @type {FeatureCollection | null} */collection, + /** @type {Extent} */extent, + /** @type {number} */zoom, + /** @type {number} */sizeTexture, + /** @type {object} */layerStyle, + /** @type {THREE.Color} */backgroundColor, + ) { + const style = layerStyle ?? defaultStyle; style.setContext(context); - let texture; + let /** @type {THREE.Texture} */texture; if (collection) { // A texture is instancied drawn canvas // origin and dimension are used to transform the feature's coordinates to canvas's space extent.planarDimensions(dimension); - const c = document.createElement('canvas'); - coord.crs = extent.crs; - c.width = sizeTexture; - c.height = sizeTexture; - const ctx = c.getContext('2d', { willReadFrequently: true }); + const ctx = createContext2D(sizeTexture, sizeTexture); if (backgroundColor) { ctx.fillStyle = backgroundColor.getStyle(); ctx.fillRect(0, 0, sizeTexture, sizeTexture); } // Documentation needed !! - ctx.globalCompositeOperation = layerStyle.globalCompositeOperation || 'source-over'; + ctx.globalCompositeOperation = style.globalCompositeOperation || 'source-over'; ctx.imageSmoothingEnabled = false; ctx.lineJoin = 'round'; @@ -160,15 +274,15 @@ export default { // to scale line width and radius circle const invCtxScale = Math.abs(1 / scale.x); - context.setZoom(extent.zoom); + context.setZoom(zoom); // Draw the canvas for (const feature of collection.features) { context.setFeature(feature); - drawFeature(ctx, feature, featureExtent, invCtxScale); + drawFeature(ctx, feature, style, featureExtent, invCtxScale); } - texture = new THREE.CanvasTexture(c); + texture = new THREE.CanvasTexture(ctx.canvas); texture.flipY = collection.isInverted; } else if (backgroundColor) { const data = new Uint8Array(3); diff --git a/packages/Main/src/Converter/textureConverter.js b/packages/Main/src/Converter/textureConverter.js index 3ea5553818..a8b3bc8afe 100644 --- a/packages/Main/src/Converter/textureConverter.js +++ b/packages/Main/src/Converter/textureConverter.js @@ -27,7 +27,15 @@ export default { undefined; destinationTile.toExtent(layer.crs, extentTexture); - texture = Feature2Texture.createTextureFromFeature(data, extentTexture, layer.subdivisionThreshold, layer.style, backgroundColor); + texture = Feature2Texture.createTextureFromFeature( + data, + extentTexture, + destinationTile.zoom, + layer.subdivisionThreshold, + layer.style, + backgroundColor, + destinationTile.zoom, + ); texture.features = data; texture.extent = destinationTile; } else if (data.isTexture) { diff --git a/packages/Main/src/Core/Style.js b/packages/Main/src/Core/Style.js index bc7860148c..99efb28439 100644 --- a/packages/Main/src/Core/Style.js +++ b/packages/Main/src/Core/Style.js @@ -1,21 +1,13 @@ import { Coordinates } from '@itowns/geographic'; import { LRUCache } from 'lru-cache'; import Fetcher from 'Provider/Fetcher'; -import { Color, EventDispatcher } from 'three'; -import { deltaE } from 'Renderer/Color'; +import { EventDispatcher } from 'three'; +import { sharedReadContext2D } from 'Utils/CanvasUtils'; import itowns_stroke_single_before from './StyleChunk/itowns_stroke_single_before.css'; const cachedImg = new LRUCache({ max: 500 }); -let matrix; -let canvas; - -if (typeof document !== 'undefined') { - matrix = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix(); - canvas = document.createElement('canvas'); -} - function baseAltitudeDefault(properties, ctx) { return ctx?.coordinates?.z || 0; } @@ -48,49 +40,27 @@ export function readExpression(property, ctx) { return property; } -async function loadImage(url) { - const imgUrl = url.split('?')[0]; +/** + * @param {string | HTMLImageElement | HTMLCanvasElement} source + * @returns {Promise} + */ +export async function loadImage(source) { + if (typeof source !== 'string') { + return source; + } + const imgUrl = source.split('?')[0]; let promise = cachedImg.get(imgUrl); if (!promise) { - promise = Fetcher.texture(url, { crossOrigin: 'anonymous' }); + promise = Fetcher.texture(source, { crossOrigin: 'anonymous' }); cachedImg.set(imgUrl, promise); } return (await promise).image; } -function cropImage(img, cropValues) { - const x = cropValues.x || 0; - const y = cropValues.y || 0; - const width = cropValues.width || img.naturalWidth; - const height = cropValues.height || img.naturalHeight; - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d', { willReadFrequently: true }); +export function cropImage(ctx, img, x, y, width, height) { ctx.drawImage(img, x, y, width, height, 0, 0, width, height); - return ctx.getImageData(0, 0, width, height); -} - -function replaceWhitePxl(imgd, color, id) { - if (!color) { - return imgd; - } - const imgdColored = cachedImg.get(`${id}_${color}`); - if (!imgdColored) { - const pix = imgd.data; - const newColor = new Color(color); - const colorToChange = new Color('white'); - for (let i = 0, n = pix.length; i < n; i += 4) { - const d = deltaE(pix.slice(i, i + 3), colorToChange) / 100; - pix[i] = (pix[i] * d + newColor.r * 255 * (1 - d)); - pix[i + 1] = (pix[i + 1] * d + newColor.g * 255 * (1 - d)); - pix[i + 2] = (pix[i + 2] * d + newColor.b * 255 * (1 - d)); - } - cachedImg.set(`${id}_${color}`, imgd); - return imgd; - } - return imgdColored; } const textAnchorPosition = { @@ -655,72 +625,6 @@ class Style extends EventDispatcher { this.context = ctx; } - /** - * Applies the style.fill to a polygon of the texture canvas. - * @param {CanvasRenderingContext2D} txtrCtx The Context 2D of the texture canvas. - * @param {Path2D} polygon The current texture canvas polygon. - * @param {number} invCtxScale The ratio to scale line width and radius circle. - * @param {boolean} canBeFilled - true if feature.type == FEATURE_TYPES.POLYGON. - */ - applyToCanvasPolygon(txtrCtx, polygon, invCtxScale, canBeFilled) { - // draw line or edge of polygon - if (this.stroke.width > 0) { - // TO DO add possibility of using a pattern (https://github.com/iTowns/itowns/issues/2210) - this._applyStrokeToPolygon(txtrCtx, invCtxScale, polygon); - } - - // fill inside of polygon - if (canBeFilled && (this.fill.pattern || this.fill.color)) { - // canBeFilled can be move to StyleContext in the later PR - this._applyFillToPolygon(txtrCtx, invCtxScale, polygon); - } - } - - _applyStrokeToPolygon(txtrCtx, invCtxScale, polygon) { - if (txtrCtx.strokeStyle !== this.stroke.color) { - txtrCtx.strokeStyle = this.stroke.color; - } - const width = this.stroke.width * invCtxScale; - if (txtrCtx.lineWidth !== width) { - txtrCtx.lineWidth = width; - } - const alpha = this.stroke.opacity; - if (alpha !== txtrCtx.globalAlpha && typeof alpha == 'number') { - txtrCtx.globalAlpha = alpha; - } - if (txtrCtx.lineCap !== this.stroke.lineCap) { - txtrCtx.lineCap = this.stroke.lineCap; - } - txtrCtx.setLineDash(this.stroke.dasharray.map(a => a * invCtxScale * 2)); - txtrCtx.stroke(polygon); - } - - async _applyFillToPolygon(txtrCtx, invCtxScale, polygon) { - // if (this.fill.pattern && txtrCtx.fillStyle.src !== this.fill.pattern.src) { - // need doc for the txtrCtx.fillStyle.src that seems to always be undefined - if (this.fill.pattern) { - let img = this.fill.pattern; - const cropValues = { ...this.fill.pattern.cropValues }; - if (this.fill.pattern.source) { - img = await loadImage(this.fill.pattern.source); - } - cropImage(img, cropValues); - - txtrCtx.fillStyle = txtrCtx.createPattern(canvas, 'repeat'); - if (txtrCtx.fillStyle.setTransform) { - txtrCtx.fillStyle.setTransform(matrix.scale(invCtxScale)); - } else { - console.warn('Raster pattern isn\'t completely supported on Ie and edge', txtrCtx.fillStyle); - } - } else if (txtrCtx.fillStyle !== this.fill.color) { - txtrCtx.fillStyle = this.fill.color; - } - if (this.fill.opacity !== txtrCtx.globalAlpha) { - txtrCtx.globalAlpha = this.fill.opacity; - } - txtrCtx.fill(polygon); - } - /** * Applies this style to a DOM element. Limited to the `text` and `icon` * properties of this style. @@ -755,16 +659,15 @@ class Style extends EventDispatcher { domElement.setAttribute('data-before', domElement.textContent); } - if (!this.icon.source) { + // Style properties are evaluated lazily within a shared and mutable + // context. Evaluating them after an await could yield incorrect values + // since the context has been mutated. + const { source, cropValues, color } = this.icon; + if (!source) { return; } - let icon; - - if (typeof document !== 'undefined') { - icon = document.createElement('img'); - } - + const icon = document.createElement('img'); const iconPromise = new Promise((resolve, reject) => { const opt = { size: this.icon.size, @@ -776,17 +679,25 @@ class Style extends EventDispatcher { icon.onerror = err => reject(err); }); - if (!this.icon.cropValues && !this.icon.color) { - icon.src = this.icon.source; + if (!cropValues && !color) { + icon.src = source; } else { - const cropValues = { ...this.icon.cropValues }; - const color = this.icon.color; - const id = this.icon.id || this.icon.source; - const img = await loadImage(this.icon.source); - const imgd = cropImage(img, cropValues); - const imgdColored = replaceWhitePxl(imgd, color, id); - canvas.getContext('2d').putImageData(imgdColored, 0, 0); - icon.src = canvas.toDataURL('image/png'); + const img = await loadImage(source); + const { x = 0, y = 0, width = img.naturalWidth, height = img.naturalHeight } = cropValues ?? {}; + const cropCtx = sharedReadContext2D(); + cropCtx.canvas.width = width; + cropCtx.canvas.height = height; + cropImage(cropCtx, img, x, y, width, height); + if (color) { + const oldGlobalCompositeOp = cropCtx.globalCompositeOperation; + cropCtx.globalCompositeOperation = 'multiply'; + cropCtx.fillStyle = color; + cropCtx.fillRect(0, 0, width, height); + cropCtx.globalCompositeOperation = 'destination-in'; + cropImage(cropCtx, img, x, y, width, height); + cropCtx.globalCompositeOperation = oldGlobalCompositeOp; + } + icon.src = cropCtx.canvas.toDataURL('image/png'); } return iconPromise; } diff --git a/packages/Main/src/Renderer/Color.js b/packages/Main/src/Renderer/Color.js deleted file mode 100644 index 1afc96a20f..0000000000 --- a/packages/Main/src/Renderer/Color.js +++ /dev/null @@ -1,69 +0,0 @@ -export function lab2rgb(lab) { - let y = (lab[0] + 16) / 116; - let x = lab[1] / 500 + y; - let z = y - lab[2] / 200; - let r; let g; let - b; - - x = 0.95047 * ((x * x * x > 0.008856) ? x * x * x : (x - 16 / 116) / 7.787); - y = 1.00000 * ((y * y * y > 0.008856) ? y * y * y : (y - 16 / 116) / 7.787); - z = 1.08883 * ((z * z * z > 0.008856) ? z * z * z : (z - 16 / 116) / 7.787); - - r = x * 3.2406 + y * -1.5372 + z * -0.4986; - g = x * -0.9689 + y * 1.8758 + z * 0.0415; - b = x * 0.0557 + y * -0.2040 + z * 1.0570; - - r = (r > 0.0031308) ? (1.055 * r ** (1 / 2.4) - 0.055) : 12.92 * r; - g = (g > 0.0031308) ? (1.055 * g ** (1 / 2.4) - 0.055) : 12.92 * g; - b = (b > 0.0031308) ? (1.055 * b ** (1 / 2.4) - 0.055) : 12.92 * b; - - return [Math.max(0, Math.min(1, r)) * 255, - Math.max(0, Math.min(1, g)) * 255, - Math.max(0, Math.min(1, b)) * 255]; -} - - -export function rgb2lab(rgb) { - let r = rgb.r || rgb[0] / 255; - let g = rgb.g || rgb[1] / 255; - let b = rgb.b || rgb[2] / 255; - let x; let y; let - z; - - r = (r > 0.04045) ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92; - g = (g > 0.04045) ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92; - b = (b > 0.04045) ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92; - - x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; - y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; - z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; - - x = (x > 0.008856) ? x ** (1 / 3) : (7.787 * x) + 16 / 116; - y = (y > 0.008856) ? y ** (1 / 3) : (7.787 * y) + 16 / 116; - z = (z > 0.008856) ? z ** (1 / 3) : (7.787 * z) + 16 / 116; - - return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]; -} - -// calculate the perceptual distance between colors in CIELAB -// https://github.com/THEjoezack/ColorMine/blob/master/ColorMine/ColorSpaces/Comparisons/Cie94Comparison.cs -export function deltaE(rgbA, rgbB) { - const labA = rgb2lab(rgbA); - const labB = rgb2lab(rgbB); - const deltaL = labA[0] - labB[0]; - const deltaA = labA[1] - labB[1]; - const deltaB = labA[2] - labB[2]; - const c1 = Math.sqrt(labA[1] * labA[1] + labA[2] * labA[2]); - const c2 = Math.sqrt(labB[1] * labB[1] + labB[2] * labB[2]); - const deltaC = c1 - c2; - let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC; - deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH); - const sc = 1.0 + 0.045 * c1; - const sh = 1.0 + 0.015 * c1; - const deltaLKlsl = deltaL / (1.0); - const deltaCkcsc = deltaC / (sc); - const deltaHkhsh = deltaH / (sh); - const i = deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh; - return i < 0 ? 0 : Math.sqrt(i); -} - diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index 05d2c2abff..246b97c16a 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import PointsVS from 'Renderer/Shader/PointsVS.glsl'; import PointsFS from 'Renderer/Shader/PointsFS.glsl'; import CommonMaterial from 'Renderer/CommonMaterial'; +import { createContext2D } from 'Utils/CanvasUtils'; import Gradients from 'Utils/Gradients'; export const PNTS_MODE = { @@ -82,12 +83,7 @@ function generateGradientTexture(gradient) { const size = 64; // create canvas - const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - - // get context - const context = canvas.getContext('2d'); + const context = createContext2D(size, size); // draw gradient context.rect(0, 0, size, size); @@ -102,7 +98,7 @@ function generateGradientTexture(gradient) { context.fillStyle = ctxGradient; context.fill(); - const texture = new THREE.CanvasTexture(canvas); + const texture = new THREE.CanvasTexture(context.canvas); texture.needsUpdate = true; texture.minFilter = THREE.LinearFilter; diff --git a/packages/Main/src/Renderer/c3DEngine.js b/packages/Main/src/Renderer/c3DEngine.js index bc535e2e87..7f1dac3ab0 100644 --- a/packages/Main/src/Renderer/c3DEngine.js +++ b/packages/Main/src/Renderer/c3DEngine.js @@ -5,11 +5,11 @@ */ import * as THREE from 'three'; +import WEBGL from 'three/addons/capabilities/WebGL.js'; import Capabilities from 'Core/System/Capabilities'; import { unpack1K } from 'Renderer/LayeredMaterial'; import Label2DRenderer from 'Renderer/Label2DRenderer'; import { deprecatedC3DEngineWebGLOptions } from 'Core/Deprecated/Undeprecator'; -import WEBGL from 'three/addons/capabilities/WebGL.js'; import { EffectComposer } from 'postprocessing'; const depthRGBA = new THREE.Vector4(); @@ -230,28 +230,6 @@ class c3DEngine { return target; } - bufferToImage(pixelBuffer, width, height) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - - // size the canvas to your desired image - canvas.width = width; - canvas.height = height; - - const imgData = ctx.getImageData(0, 0, width, height); - imgData.data.set(pixelBuffer); - - ctx.putImageData(imgData, 0, 0); - - // create a new img object - const image = new Image(); - - // set the img.src to the canvas data url - image.src = canvas.toDataURL(); - - return image; - } - depthBufferRGBAValueToOrthoZ(depthBufferRGBA, camera) { depthRGBA.fromArray(depthBufferRGBA).divideScalar(255.0); diff --git a/packages/Main/src/Utils/CanvasUtils.ts b/packages/Main/src/Utils/CanvasUtils.ts new file mode 100644 index 0000000000..04752e50cc --- /dev/null +++ b/packages/Main/src/Utils/CanvasUtils.ts @@ -0,0 +1,71 @@ +let ctx2D: CanvasRenderingContext2D; +let rCtx2D: CanvasRenderingContext2D; + +/** + * Creates a dedicated 2D canvas context. + * + * @remarks + * The caller has exclusive ownership of the returned context and may safely + * retains it across asynchronous work. + * + * @param width - Canvas width (in pixels) + * @param height - Canvas height (in pixels) + * @param options - Options passed to {@link HTMLCanvasElement.getContext} + * @returns A new 2D rendering context sized to `width` x `height` + */ +export function createContext2D( + width: number, height: number, + options?: CanvasRenderingContext2DSettings, +): CanvasRenderingContext2D { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas.getContext('2d', options) as CanvasRenderingContext2D; +} + +/** + * Returns a shared 2D canvas context for draw-only synchronous work. + * + * @remarks + * The canvas dimensions must be set before use. Setting them clears the canvas + * and resets its context state. + * + * **Warning**: The context is shared and reused between calls, it must not + * be retained or used across asynchronous work. + * + * Use {@link sharedReadContext2D} when frequent pixel access is required. + * + * @returns A shared 2D canvas context for draw-only synchronous work. + */ +export function sharedContext2D(): CanvasRenderingContext2D { + if (!ctx2D) { + ctx2D = createContext2D(1, 1); + } + return ctx2D; +} + +/** + * Returns a shared 2D canvas context for synchronous pixel readback work. + * + * @remarks + * The canvas dimensions must be set before use. Setting them clears the canvas + * and resets its context state. + * + * The context hints the browser that pixel data will be read back frequently + * (e.g. using {@link CanvasRenderingContext2D.getImageData}, + * {@link HTMLCanvasElement.toDataURL} or {@link HTMLCanvasElement.toBlob}). + * Browsers may optimize for read performance at the cost of drawing speed. + * + * **Warning**: The context is shared and reused between calls, it must not + * be retained or used across asynchronous work. + * + * Use {@link sharedContext2D} for draw-only operations. + * + * @returns A shared 2D canvas context for synchronous pixel readback work. + */ +export function sharedReadContext2D(): CanvasRenderingContext2D { + if (!rCtx2D) { + rCtx2D = createContext2D(1, 1, { willReadFrequently: true }); + } + return rCtx2D; +} diff --git a/packages/Main/test/unit/feature2Texture.js b/packages/Main/test/unit/feature2Texture.js new file mode 100644 index 0000000000..5521cee7ce --- /dev/null +++ b/packages/Main/test/unit/feature2Texture.js @@ -0,0 +1,61 @@ +import Style from 'Core/Style'; +import { applyFillToPolygon, applyStrokeToPolygon } from 'Converter/Feature2Texture'; +import assert from 'assert'; + +describe('Feature2Texture', () => { + const styleOpt = { + point: {}, + fill: {}, + stroke: {}, + text: {}, + }; + styleOpt.point.color = 'red'; + styleOpt.fill.color = 'blue'; + styleOpt.stroke.color = 'black'; + styleOpt.text.haloWidth = 1; + + const c = document.createElement('canvas'); + const txtrCtx = c.getContext('2d'); + + describe('applyStrokeToPolygon()', () => { + it('with invCtxScale = 0.75', () => { + const invCtxScale = 0.75; + const style = new Style(styleOpt); + applyStrokeToPolygon(style, txtrCtx, invCtxScale); + assert.equal(txtrCtx.strokeStyle, style.stroke.color); + assert.equal(txtrCtx.lineWidth, style.stroke.width * invCtxScale); + assert.equal(txtrCtx.lineCap, style.stroke.lineCap); + assert.equal(txtrCtx.globalAlpha, style.stroke.opacity); + }); + }); + + describe('applyFillToPolygon()', () => { + it('with fill.pattern = img', function (done) { + const invCtxScale = 1; + const polygon = new Path2D(); + const img = document.createElement('img'); + const style = new Style(styleOpt); + style.fill.pattern = img; + style.fill.opacity = 0.1; + applyFillToPolygon(style, txtrCtx, invCtxScale, polygon) + .then(() => { + assert.equal(txtrCtx.fillStyle.constructor.name, 'CanvasPattern'); + assert.equal(txtrCtx.globalAlpha, style.fill.opacity); + done(); + }).catch(done); + }); + it('with fill.color = #0500fd', function (done) { + const invCtxScale = 1; + const polygon = new Path2D(); + const style = new Style(styleOpt); + style.fill.color = '#0500fd'; + style.fill.opacity = 0.2; + applyFillToPolygon(style, txtrCtx, invCtxScale, polygon) + .then(() => { + assert.equal(txtrCtx.fillStyle, '#0500fd'); + assert.equal(txtrCtx.globalAlpha, style.fill.opacity); + done(); + }).catch(done); + }); + }); +}); diff --git a/packages/Main/test/unit/style.js b/packages/Main/test/unit/style.js index b50d32ae9c..9ea0a383d3 100644 --- a/packages/Main/test/unit/style.js +++ b/packages/Main/test/unit/style.js @@ -82,62 +82,6 @@ describe('Style', function () { assert.equal(style.stroke.color, 'pink'); }); - describe('applyToCanvasPolygon()', () => { - const styleOpt = { - point: {}, - fill: {}, - stroke: {}, - text: {}, - }; - styleOpt.point.color = 'red'; - styleOpt.fill.color = 'blue'; - styleOpt.stroke.color = 'black'; - styleOpt.text.haloWidth = 1; - - const c = document.createElement('canvas'); - const txtrCtx = c.getContext('2d'); - describe('_applyStrokeToPolygon()', () => { - it('with invCtxScale = 0.75', () => { - const invCtxScale = 0.75; - const style = new Style(styleOpt); - style._applyStrokeToPolygon(txtrCtx, invCtxScale); - assert.equal(txtrCtx.strokeStyle, style.stroke.color); - assert.equal(txtrCtx.lineWidth, style.stroke.width * invCtxScale); - assert.equal(txtrCtx.lineCap, style.stroke.lineCap); - assert.equal(txtrCtx.globalAlpha, style.stroke.opacity); - }); - }); - describe('_applyFillToPolygon()', () => { - it('with fill.pattern = img', function (done) { - const invCtxScale = 1; - const polygon = new Path2D(); - const img = document.createElement('img'); - const style = new Style(styleOpt); - style.fill.pattern = img; - style.fill.opacity = 0.1; - style._applyFillToPolygon(txtrCtx, invCtxScale, polygon) - .then(() => { - assert.equal(txtrCtx.fillStyle.constructor.name, 'CanvasPattern'); - assert.equal(txtrCtx.globalAlpha, style.fill.opacity); - done(); - }).catch(done); - }); - it('with fill.color = #0500fd', function (done) { - const invCtxScale = 1; - const polygon = new Path2D(); - const style = new Style(styleOpt); - style.fill.color = '#0500fd'; - style.fill.opacity = 0.2; - style._applyFillToPolygon(txtrCtx, invCtxScale, polygon) - .then(() => { - assert.equal(txtrCtx.fillStyle, '#0500fd'); - assert.equal(txtrCtx.globalAlpha, style.fill.opacity); - done(); - }).catch(done); - }); - }); - }); - describe('applyToHTML()', () => { const styleOpt = { point: {},