diff --git a/src/components/modal-dialog/modal-dialog.tsx b/src/components/modal-dialog/modal-dialog.tsx index c25df8d8..cffc26cb 100644 --- a/src/components/modal-dialog/modal-dialog.tsx +++ b/src/components/modal-dialog/modal-dialog.tsx @@ -79,7 +79,7 @@ const ButtonsContainer = styled.div` display: flex; flex-direction: row; justify-content: ${(props) => props.justify}; - margin: 32px; + margin: 32px 32px 0 32px; column-gap: 18px; { > * { @@ -88,6 +88,10 @@ const ButtonsContainer = styled.div` } `; +const AfterButtonsPlaceholder = styled.div` + margin: 0 32px 32px 32px; +`; + type LogoutPanelProps = { title: string; children: JSX.Element | JSX.Element[]; @@ -125,17 +129,22 @@ export const ModalDialog = ({ {title} {children} - - {cancelButtonText && ( - - {cancelButtonText} - - )} - {okButtonText} - + {(cancelButtonText || okButtonText) && ( + + {cancelButtonText && ( + + {cancelButtonText} + + )} + {okButtonText && ( + {okButtonText} + )} + + )} + diff --git a/src/components/tile-details-panel/texture-section.spec.tsx b/src/components/tile-details-panel/texture-section.spec.tsx new file mode 100644 index 00000000..bf869e41 --- /dev/null +++ b/src/components/tile-details-panel/texture-section.spec.tsx @@ -0,0 +1,30 @@ +import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; +import { TextureSection } from "./texture-section"; +import { getTile3d } from "../../test/tile-stub"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +let tile3d; +beforeEach(() => { + tile3d = getTile3d(); +}); + +const callRender = (renderFunc, props = {}) => { + return renderFunc(); +}; + +describe("Texture Section", () => { + it("Should render texture section", async () => { + const { container } = callRender(renderWithTheme); + expect(container.firstChild).toBeInTheDocument(); + + const texturePanel = await screen.findByText("Texture:"); + expect(texturePanel).toBeInTheDocument(); + + const texture = texturePanel?.nextSibling as Element; + texture && userEvent.click(texture); + + const texturePreview = await screen.findByText("Preview texture"); + expect(texturePreview).toBeInTheDocument(); + }); +}); diff --git a/src/components/tile-details-panel/texture-section.tsx b/src/components/tile-details-panel/texture-section.tsx new file mode 100644 index 00000000..60122200 --- /dev/null +++ b/src/components/tile-details-panel/texture-section.tsx @@ -0,0 +1,140 @@ +import styled from "styled-components"; +import { Title, TileInfoSectionWrapper } from "../common"; +import { useEffect, useState } from "react"; +import { Tile3D } from "@loaders.gl/tiles"; +import { ModalDialog } from "../modal-dialog/modal-dialog"; + +import { + drawCompressedTexture, + drawBitmapTexture, +} from "../../utils/debug/texture-render-utils"; + +type Size = { + width: number; + height: number; +}; + +const TextureContainer = styled.div` + display: flex; + justify-content: center; + align-items: top; + border-radius: 4px; + margin-right: 16px; + cursor: pointer; +`; + +const TextureButton = styled.button<{ + image: string; + width: number; + height: number; +}>` + height: ${({ height }) => `${height}px`}; + width: ${({ width }) => `${width}px`}; + position: relative; + margin: 0; + border: 0; + background-image: ${({ image }) => `${image}`}; + background-repeat: no-repeat; + cursor: inherit; +`; + +const SIZE = 149; +const PREVIEW_SIZE = 592; + +type TextureSectionProps = { + tile: Tile3D; +}; + +export const TextureSection = ({ tile }: TextureSectionProps) => { + const [texture, setTexture] = useState(""); + const [previewTexture, setPreviewTexture] = useState(""); + const [showPreviewTexture, setShowPreviewTexture] = useState(false); + + const [size, setSize] = useState({ + width: SIZE, + height: SIZE, + }); + const [previewSize, setPreviewSize] = useState({ + width: PREVIEW_SIZE, + height: PREVIEW_SIZE, + }); + + const contentImage = + tile.content?.material?.pbrMetallicRoughness?.baseColorTexture?.texture + ?.source?.image; + const originalTexture = tile.userData?.originalTexture; + const image = originalTexture || contentImage; + + useEffect(() => { + if (image) { + if (image.compressed) { + drawCompressedTexture(image, SIZE).then((result) => { + const { url, width, height } = result; + setSize({ width, height }); + setTexture(url); + }); + } else { + drawBitmapTexture(image, SIZE).then((result) => { + const { url, width, height } = result; + setSize({ width: width, height: height }); + setTexture(url); + }); + } + } + }, [image]); + + const onClickHandler = () => { + if (image) { + if (image.compressed) { + drawCompressedTexture(image, PREVIEW_SIZE).then((result) => { + const { url, width, height } = result; + setPreviewSize({ width: width, height: height }); + setPreviewTexture(url); + }); + } else { + drawBitmapTexture(image, PREVIEW_SIZE).then((result) => { + const { url, width, height } = result; + setPreviewSize({ width: width, height: height }); + setPreviewTexture(url); + }); + } + } + + setShowPreviewTexture(true); + }; + + return ( + <> + + Texture: + + + + + + {showPreviewTexture && ( + { + setShowPreviewTexture(false); + }} + onCancel={() => { + setShowPreviewTexture(false); + }} + > + + + )} + + ); +}; diff --git a/src/components/tile-details-panel/tile-details-panel.tsx b/src/components/tile-details-panel/tile-details-panel.tsx index f4dc7aa6..24f959af 100644 --- a/src/components/tile-details-panel/tile-details-panel.tsx +++ b/src/components/tile-details-panel/tile-details-panel.tsx @@ -24,6 +24,7 @@ import { formatStringValue, } from "../../utils/format/format-utils"; import { getBoundingType } from "../../utils/debug/bounding-volume"; +import { TextureSection } from "./texture-section"; enum ActiveTileInfoPanel { TileDetailsPanel, @@ -162,7 +163,7 @@ export const TileDetailsPanel = ({ lodMetricType, lodMetricValue, screenSpaceError, - depth + depth, } = tile; const childrenInfo = getChildrenInfo(tileChildren); @@ -411,6 +412,7 @@ export const TileDetailsPanel = ({ {children} + ({})), +}); + +Object.defineProperty(window, "ImageBitmap", { + writable: true, + value: jest.fn().mockImplementation((name) => ({ name: name })), +}); + +describe("Texture Selector Utils - selectDebugTextureForTileset", () => { + beforeAll(() => { + HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue({ + drawImage: mockDrawImage, + }); + HTMLCanvasElement.prototype.toDataURL = jest.fn().mockReturnValue({}); + }); + + test("Should return new width and height of bitmap image", async () => { + const { width, height } = await drawBitmapTexture( + { width: 64, height: 128 } as ImageData, + 512 + ); + expect(width).toBe(256); + expect(height).toBe(512); + }); +}); diff --git a/src/utils/debug/texture-render-utils.ts b/src/utils/debug/texture-render-utils.ts new file mode 100644 index 00000000..c2ecd86b --- /dev/null +++ b/src/utils/debug/texture-render-utils.ts @@ -0,0 +1,324 @@ +import { + getSupportedGPUTextureFormats, + GL_EXTENSIONS_CONSTANTS, +} from "@loaders.gl/textures"; + +import type { TextureLevel } from "@loaders.gl/schema"; +import { Texture2D, instrumentGLContext, Program } from "@luma.gl/core"; + +const { + COMPRESSED_RGB_S3TC_DXT1_EXT, + COMPRESSED_RGBA_S3TC_DXT1_EXT, + COMPRESSED_RGBA_S3TC_DXT3_EXT, + COMPRESSED_RGBA_S3TC_DXT5_EXT, + COMPRESSED_RGB_PVRTC_4BPPV1_IMG, + COMPRESSED_RGBA_PVRTC_4BPPV1_IMG, + COMPRESSED_RGB_PVRTC_2BPPV1_IMG, + COMPRESSED_RGBA_PVRTC_2BPPV1_IMG, + COMPRESSED_RGB_ATC_WEBGL, + COMPRESSED_RGBA_ATC_EXPLICIT_ALPHA_WEBGL, + COMPRESSED_RGBA_ATC_INTERPOLATED_ALPHA_WEBGL, + COMPRESSED_RGB_ETC1_WEBGL, + COMPRESSED_RGBA_ASTC_4X4_KHR, + COMPRESSED_RGBA_ASTC_5X4_KHR, + COMPRESSED_RGBA_ASTC_5X5_KHR, + COMPRESSED_RGBA_ASTC_6X5_KHR, + COMPRESSED_RGBA_ASTC_6X6_KHR, + COMPRESSED_RGBA_ASTC_8X5_KHR, + COMPRESSED_RGBA_ASTC_8X6_KHR, + COMPRESSED_RGBA_ASTC_8X8_KHR, + COMPRESSED_RGBA_ASTC_10X5_KHR, + COMPRESSED_RGBA_ASTC_10X6_KHR, + COMPRESSED_RGBA_ASTC_10X8_KHR, + COMPRESSED_RGBA_ASTC_10X10_KHR, + COMPRESSED_RGBA_ASTC_12X10_KHR, + COMPRESSED_RGBA_ASTC_12X12_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_4X4_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_5X4_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_5X5_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_6X5_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_6X6_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_8X5_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_8X6_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_8X8_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_10X5_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_10X6_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_10X8_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_10X10_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_12X10_KHR, + COMPRESSED_SRGB8_ALPHA8_ASTC_12X12_KHR, + COMPRESSED_R11_EAC, + COMPRESSED_RG11_EAC, + COMPRESSED_SIGNED_RG11_EAC, + COMPRESSED_RGB8_ETC2, + COMPRESSED_RGBA8_ETC2_EAC, + COMPRESSED_SRGB8_ETC2, + COMPRESSED_SRGB8_ALPHA8_ETC2_EAC, + COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2, + COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2, + COMPRESSED_RED_RGTC1_EXT, + COMPRESSED_SIGNED_RED_RGTC1_EXT, + COMPRESSED_RED_GREEN_RGTC2_EXT, + COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT, + COMPRESSED_SRGB_S3TC_DXT1_EXT, + COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT, + COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT, + COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT, +} = GL_EXTENSIONS_CONSTANTS; + +// eslint-disable-next-line complexity +const isFormatSupported = ( + gl: WebGLRenderingContext, + format: number | undefined +) => { + if (typeof format !== "number") { + throw new Error("Invalid internal format of compressed texture"); + } + const supportedFormats = getSupportedGPUTextureFormats(gl); + + switch (format) { + case COMPRESSED_RGB_S3TC_DXT1_EXT: + case COMPRESSED_RGBA_S3TC_DXT3_EXT: + case COMPRESSED_RGBA_S3TC_DXT5_EXT: + case COMPRESSED_RGBA_S3TC_DXT1_EXT: + return supportedFormats.has("dxt"); + + case COMPRESSED_RGB_PVRTC_4BPPV1_IMG: + case COMPRESSED_RGBA_PVRTC_4BPPV1_IMG: + case COMPRESSED_RGB_PVRTC_2BPPV1_IMG: + case COMPRESSED_RGBA_PVRTC_2BPPV1_IMG: + return supportedFormats.has("pvrtc"); + + case COMPRESSED_RGB_ATC_WEBGL: + case COMPRESSED_RGBA_ATC_EXPLICIT_ALPHA_WEBGL: + case COMPRESSED_RGBA_ATC_INTERPOLATED_ALPHA_WEBGL: + return supportedFormats.has("atc"); + + case COMPRESSED_RGB_ETC1_WEBGL: + return supportedFormats.has("etc1"); + + case COMPRESSED_RGBA_ASTC_4X4_KHR: + case COMPRESSED_RGBA_ASTC_5X4_KHR: + case COMPRESSED_RGBA_ASTC_5X5_KHR: + case COMPRESSED_RGBA_ASTC_6X5_KHR: + case COMPRESSED_RGBA_ASTC_6X6_KHR: + case COMPRESSED_RGBA_ASTC_8X5_KHR: + case COMPRESSED_RGBA_ASTC_8X6_KHR: + case COMPRESSED_RGBA_ASTC_8X8_KHR: + case COMPRESSED_RGBA_ASTC_10X5_KHR: + case COMPRESSED_RGBA_ASTC_10X6_KHR: + case COMPRESSED_RGBA_ASTC_10X8_KHR: + case COMPRESSED_RGBA_ASTC_10X10_KHR: + case COMPRESSED_RGBA_ASTC_12X10_KHR: + case COMPRESSED_RGBA_ASTC_12X12_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_4X4_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_5X4_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_5X5_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_6X5_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_6X6_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_8X5_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_8X6_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_8X8_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_10X5_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_10X6_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_10X8_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_10X10_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_12X10_KHR: + case COMPRESSED_SRGB8_ALPHA8_ASTC_12X12_KHR: + return supportedFormats.has("astc"); + + case COMPRESSED_R11_EAC: + case COMPRESSED_RG11_EAC: + case COMPRESSED_SIGNED_RG11_EAC: + case COMPRESSED_RGB8_ETC2: + case COMPRESSED_RGBA8_ETC2_EAC: + case COMPRESSED_SRGB8_ETC2: + case COMPRESSED_SRGB8_ALPHA8_ETC2_EAC: + case COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2: + case COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2: + return supportedFormats.has("etc2"); + + case COMPRESSED_RED_RGTC1_EXT: + case COMPRESSED_SIGNED_RED_RGTC1_EXT: + case COMPRESSED_RED_GREEN_RGTC2_EXT: + case COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT: + return supportedFormats.has("rgtc"); + + case COMPRESSED_SRGB_S3TC_DXT1_EXT: + case COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT: + case COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT: + case COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT: + return supportedFormats.has("dxt-srgb"); + default: + return false; + } +}; + +/** + * Creates Texture2D from the images provided + * @param gl - GL context + * @param images - compressed images used to create Texture2D + * @returns texture handle + */ +const createCompressedTexture2D = ( + gl: WebGLRenderingContext, + images: unknown[] +): WebGLTexture => { + const texture = new Texture2D(gl, { + data: images, + compressed: true, + mipmaps: false, + parameters: { + [gl.TEXTURE_MAG_FILTER]: gl.LINEAR, + [gl.TEXTURE_MIN_FILTER]: + images.length > 1 ? gl.LINEAR_MIPMAP_NEAREST : gl.LINEAR, + [gl.TEXTURE_WRAP_S]: gl.CLAMP_TO_EDGE, + [gl.TEXTURE_WRAP_T]: gl.CLAMP_TO_EDGE, + }, + }); + + return texture.handle; +}; + +/** + * Renders the images provided + * @param gl - GL context + * @param program - GL program + * @param images - compressed images to render + */ +const renderCompressedTexture = ( + gl: WebGLRenderingContext, + program: WebGLProgram, + images: TextureLevel[] +) => { + // We take the first image because it has main properties of compressed image. + const { format } = images[0]; + + if (!isFormatSupported(gl, format)) { + throw new Error(`Texture format ${format} not supported by this GPU`); + } + + const texture = createCompressedTexture2D(gl, images); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.useProgram(program); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); +}; + +const createAndFillBufferObject = (gl: WebGLRenderingContext, program: any) => { + const data = new Float32Array([-1, -1, -1, 1, 1, -1, 1, 1]); + const positionArrayLocation = + program.configuration.attributeInfosByName.position.location; + const bufferId = gl.createBuffer(); + + if (!bufferId) { + console.error("Failed to create the buffer object"); // eslint-disable-line + } + + gl.bindBuffer(gl.ARRAY_BUFFER, bufferId); + gl.enableVertexAttribArray(positionArrayLocation); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + gl.vertexAttribPointer(positionArrayLocation, 2, gl.FLOAT, false, 0, 0); +}; + +// TEXTURE SHADERS + +const vs = ` +precision highp float; + +attribute vec2 position; +varying vec2 uv; + +void main() { + gl_Position = vec4(position, 0.0, 1.0); + uv = vec2(position.x * .5, -position.y * .5) + vec2(.5, .5); +} +`; + +const fs = ` +precision highp float; + +uniform sampler2D tex; +varying vec2 uv; + +void main() { + gl_FragColor = vec4(texture2D(tex, uv).rgb, 1.); +} +`; + +/** + * Draws and rescales the image provided + * @param image - image to draw + * @param size - size of the image drawn + * @returns texture drawn and its size + */ +export const drawBitmapTexture = async ( + image: ImageData | HTMLCanvasElement, + size: number +): Promise<{ url: string; width: number; height: number }> => { + const bitmap = await createImageBitmap(image); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("No 2d context"); + } + const imageWidth = image.width; + const imageHeight = image.height; + + canvas.width = imageWidth; + canvas.height = imageHeight; + + const imageSizeMax = imageWidth > imageHeight ? imageWidth : imageHeight; + const coeff = imageSizeMax / size; + const areaWidth = imageWidth / coeff; + const areaHeight = imageHeight / coeff; + + // Position the image on the canvas (0, 0), and specify width and height of the image (areaWidth, areaHeight) + ctx.drawImage(bitmap, 0, 0, areaWidth, areaHeight); + + return { + url: canvas.toDataURL("image/png"), + width: areaWidth, + height: areaHeight, + }; +}; + +/** + * Draws and rescales the compressed image provided + * @param data - object containing the compressed image + * @param size - size of the image drawn + * @returns texture drawn and its size + */ +export const drawCompressedTexture = async ( + data: { + compressed: boolean; + mipmaps: boolean; + width: number; + height: number; + data: TextureLevel[]; + }, + size: number +): Promise<{ url: string; width: number; height: number }> => { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl"); + instrumentGLContext(gl); + if (!gl) { + throw new Error("No webgl context"); + } + + const program = new Program(gl, { vs, fs }); + createAndFillBufferObject(gl, program); + + const images = [data.data[0]]; // The first image only + const imageWidth = data.width; + const imageHeight = data.height; + + canvas.width = imageWidth; + canvas.height = imageHeight; + + gl.viewport(0, 0, imageWidth, imageHeight); + renderCompressedTexture(gl, program.handle, images); + + return await drawBitmapTexture(canvas, size); +};