Browse Source

Texture Inspector Tools (#8601)

* ui refresh

* add environment texture support and reset

* disable tools, ui improvements

* clean up

* remove extra setting

* fix imports and add whats new.md

* ui fixes

* inspector fixes

* upload and resize

* color picker UI

* add external tools

* add default tools to codebase

* remove comments

* remove extra flipping code

* linting and tool fixes

* fix import

* allow tools to support dynamic parameters

* rename defaultTools index for tree shaking
DarraghBurkeMS 5 years ago
parent
commit
149a5008f9

+ 1 - 1
dist/preview release/what's new.md

@@ -74,7 +74,7 @@
 - Added support for recording GIF ([Deltakosh](https://github.com/deltakosh))
 - Popup Window available (To be used in Curve Editor) ([pixelspace](https://github.com/devpixelspace))
 - Add support to update inspector when switching to a new scene ([belfortk](https://github.com/belfortk))
-- View textures in pop out inspector with zoom & pan and individual channels, and save to local machine. ([DarraghBurkeMS](https://github.com/DarraghBurkeMS))
+- View & edit textures in pop out inspector with zoom & pan and individual channels, and save to local machine. ([DarraghBurkeMS](https://github.com/DarraghBurkeMS))
 
 ### Cameras
 

+ 1 - 0
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/assets/addTool.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" style="fill:none"/><path d="M20,10A10,10,0,1,1,10,20,10,10,0,0,1,20,10Zm0,1.5A8.5,8.5,0,1,0,28.5,20,8.51,8.51,0,0,0,20,11.5ZM20,15a.76.76,0,0,1,.75.75v3.5h3.5a.75.75,0,0,1,0,1.5h-3.5v3.5a.75.75,0,0,1-1.5,0v-3.5h-3.5a.75.75,0,0,1,0-1.5h3.5v-3.5A.76.76,0,0,1,20,15Z" style="fill:#fff"/></svg>

+ 60 - 0
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/defaultTools/Eyedropper.ts

@@ -0,0 +1,60 @@
+import { ToolParameters, ToolData } from '../textureEditorComponent';
+import { PointerEventTypes, PointerInfo } from 'babylonjs/Events/pointerEvents';
+
+export const Eyedropper : ToolData = {
+    name: "Eyedropper",
+    type: class {
+        getParameters: () => ToolParameters;
+        pointerObservable: any;
+        isPicking: boolean;
+
+        constructor(getParameters: () => ToolParameters) {
+            this.getParameters = getParameters;
+        }
+
+        pick(pointerInfo : PointerInfo) {
+            const p = this.getParameters();
+            const ctx = p.canvas2D.getContext('2d');
+            const x = pointerInfo.pickInfo!.getTextureCoordinates()!.x * p.size.width;
+            const y = (1 - pointerInfo.pickInfo!.getTextureCoordinates()!.y) * p.size.height;
+            const pixel = ctx!.getImageData(x, y, 1, 1).data;
+            p.setMetadata({
+                color: "#" + ("000000" + this.rgbToHex(pixel[0], pixel[1], pixel[2])).slice(-6),
+                opacity: pixel[3] / 255
+            });
+        }
+        
+        setup () {
+            this.pointerObservable = this.getParameters().scene.onPointerObservable.add((pointerInfo) => {
+                if (pointerInfo.pickInfo?.hit) {
+                    if (pointerInfo.type === PointerEventTypes.POINTERDOWN) {
+                        this.isPicking = true;
+                        this.pick(pointerInfo);
+                    }
+                    if (pointerInfo.type === PointerEventTypes.POINTERMOVE && this.isPicking) {
+                        this.pick(pointerInfo);
+                    }
+                    if (pointerInfo.type === PointerEventTypes.POINTERUP) {
+                        this.isPicking = false;
+                    }
+                }
+            });
+            this.isPicking = false;
+        }
+        cleanup () {
+            if (this.pointerObservable) {
+                this.getParameters().scene.onPointerObservable.remove(this.pointerObservable);
+            }
+        }
+        rgbToHex(r: number, g:number, b: number) {
+            return ((r << 16) | (g << 8) | b).toString(16);
+        }
+    },
+    icon: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHN0eWxlPSJmaWxsOm5vbmUiLz48cGF0aCBkPSJNMjkuMzIsMTAu
+    NjhjLTEuNjYtMS42Ni00LjA2LTEtNS41Ni41YTExLjg5LDExLjg5LDAsMCwwLTEuNjYsMi4zMUwyMiwxMy40MWExLjg5LDEuODksMCwwLDAtMi42NiwwbC0uOS45YTEuODksMS44OSwwLDAsMC0uMjIsMi4zOWwtNi4wOSw2LjA5YTIuNzUsMi43NSwwLDAsMC0uNzMs
+    MS4yOGwtLjgxLDMuMjYtLjU2LjU2YTEuMTYsMS4xNiwwLDAsMCwwLDEuNjVsLjQxLjQxYTEuMTcsMS4xNywwLDAsMCwxLjY1LDBsLjU2LS41NiwzLjI2LS44MWEyLjc1LDIuNzUsMCwwLDAsMS4yOC0uNzNsNi4xNC02LjE0YTEuODcsMS44NywwLDAsMCwuODQuMjEs
+    MS44MywxLjgzLDAsMCwwLDEuMzMtLjU1bC45LS45YTEuODcsMS44NywwLDAsMCwuMDgtMi41NywxMS41NCwxMS41NCwwLDAsMCwyLjMyLTEuNjZDMzAuMzIsMTQuNzQsMzEsMTIuMzUsMjkuMzIsMTAuNjhaTTE2LjE1LDI2Ljc5YTEuMjEsMS4yMSwwLDAsMS0uNTgu
+    MzNMMTIsMjhsLjktMy41OWExLjIxLDEuMjEsMCwwLDEsLjMzLS41OGw2LjA3LTYuMDcsMi45NCwyLjk0Wm05LjIxLTcuMzgtLjkuOWMtLjE5LjItLjM0LjItLjU0LDBsLTQuNC00LjRhLjQuNCwwLDAsMSwwLS41NGwuOS0uOWEuNDMuNDMsMCwwLDEsLjI3LS4xMS4z
+    OS4zOSwwLDAsMSwuMjcuMTFsNC40LDQuNEEuMzguMzgsMCwwLDEsMjUuMzYsMTkuNDFabTMuMzgtNS45M2EzLjcsMy43LDAsMCwxLTEsMS43LDExLjY3LDExLjY3LDAsMCwxLTIuMzUsMS42MkwyMy4yLDE0LjU5YTExLjY3LDExLjY3LDAsMCwxLDEuNjItMi4zNSwz
+    LjcsMy43LDAsMCwxLDEuNy0xLDEuODMsMS44MywwLDAsMSwyLjIyLDIuMjJaIiBzdHlsZT0iZmlsbDojZmZmIi8+PC9zdmc+`
+};

+ 44 - 0
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/defaultTools/Floodfill.ts

@@ -0,0 +1,44 @@
+import { ToolParameters, ToolData } from '../textureEditorComponent';
+import { PointerEventTypes } from 'babylonjs/Events/pointerEvents';
+
+export const Floodfill : ToolData = {
+    name: "Floodfill",
+    type: class {
+        getParameters: () => ToolParameters;
+        pointerObservable: any;
+
+        constructor(getParameters: () => ToolParameters) {
+            this.getParameters = getParameters;
+        }
+
+        fill() {
+            const p = this.getParameters();
+            const ctx = p.canvas2D.getContext('2d')!;
+            ctx.fillStyle = p.getMetadata().color;
+            ctx.globalAlpha = p.getMetadata().opacity;
+            ctx.globalCompositeOperation = 'source-over';
+            ctx.fillRect(0,0, p.size.width, p.size.height);
+            p.updateTexture();
+        }
+        
+        setup () {
+            this.pointerObservable = this.getParameters().scene.onPointerObservable.add((pointerInfo) => {
+                if (pointerInfo.pickInfo?.hit) {
+                    if (pointerInfo.type === PointerEventTypes.POINTERDOWN) {
+                        this.fill();
+                    }
+                }
+            });
+        }
+        cleanup () {
+            if (this.pointerObservable) {
+                this.getParameters().scene.onPointerObservable.remove(this.pointerObservable);
+            }
+        }
+    },
+    icon: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHN0eWxlPSJmaWxsOm5vbmUiLz48cGF0aCBkPSJNMjAsMTAuNWEu
+    NzUuNzUsMCwwLDAtMS41LDB2MS4yNWEyLjE0LDIuMTQsMCwwLDAtLjg0LjUzbC02Ljg4LDYuODhhMi4yNSwyLjI1LDAsMCwwLDAsMy4xOGw0Ljg4LDQuODhhMi4yNSwyLjI1LDAsMCwwLDMuMTgsMGw2Ljg4LTYuODhhMi4yNSwyLjI1LDAsMCwwLDAtMy4xOGwtNC44
+    OC00Ljg4YTIuMjksMi4yOSwwLDAsMC0uODQtLjUzWm0tOC4xNiw5LjcyLDYuNjYtNi42NlYxNUEuNzUuNzUsMCwwLDAsMjAsMTVWMTMuNTZsNC42Niw0LjY2YS43NS43NSwwLDAsMSwwLDEuMDZsLTEsMUgxMS44Wm0uNDcsMS41M2g5Ljg4bC00LjQxLDQuNDFhLjc1
+    Ljc1LDAsMCwxLTEuMDYsMFoiIHN0eWxlPSJmaWxsOiNmZmYiLz48cGF0aCBkPSJNMjcuNTEsMjEuODVhLjg4Ljg4LDAsMCwwLTEuNTQsMGwtMiwzLjc3YTMuMTUsMy4xNSwwLDEsMCw1LjU2LDBabS0yLjIzLDQuNDcsMS40Ni0yLjczLDEuNDUsMi43M2ExLjY1LDEu
+    NjUsMCwxLDEtMi45MSwwWiIgc3R5bGU9ImZpbGw6I2ZmZiIvPjwvc3ZnPg==`
+};

+ 59 - 0
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/defaultTools/Paintbrush.ts

@@ -0,0 +1,59 @@
+import { ToolParameters, ToolData } from '../textureEditorComponent';
+import { PointerEventTypes, PointerInfo } from 'babylonjs/Events/pointerEvents';
+
+export const Paintbrush : ToolData = {
+    name: "Paintbrush",
+    type: class {
+        getParameters: () => ToolParameters;
+        pointerObservable: any;
+        isPainting: boolean;
+
+        constructor(getParameters: () => ToolParameters) {
+            this.getParameters = getParameters;
+        }
+
+        paint(pointerInfo : PointerInfo) {
+            const p = this.getParameters();
+            const ctx = p.canvas2D.getContext('2d')!;
+            const x = pointerInfo.pickInfo!.getTextureCoordinates()!.x * p.size.width;
+            const y = (1 - pointerInfo.pickInfo!.getTextureCoordinates()!.y) * p.size.height;
+            ctx.globalCompositeOperation = 'source-over';
+            ctx.fillStyle = p.getMetadata().color;
+            ctx.globalAlpha = p.getMetadata().opacity;
+            ctx.beginPath();
+            ctx.ellipse(x, y, 15, 15, 0, 0, Math.PI * 2);
+            ctx.fill();
+            p.updateTexture();
+        }
+        
+        setup () {
+            this.pointerObservable = this.getParameters().scene.onPointerObservable.add((pointerInfo) => {
+                if (pointerInfo.pickInfo?.hit) {
+                    if (pointerInfo.type === PointerEventTypes.POINTERDOWN) {
+                        if (pointerInfo.event.button == 0) {
+                            this.isPainting = true;
+                          }
+                    }
+                    if (pointerInfo.type === PointerEventTypes.POINTERMOVE && this.isPainting) {
+                        this.paint(pointerInfo);
+                    }
+                }
+                if (pointerInfo.type === PointerEventTypes.POINTERUP) {
+                    if (pointerInfo.event.button == 0) {
+                        this.isPainting = false;
+                      }
+                }
+            });
+            this.isPainting = false;
+        }
+        cleanup () {
+            this.isPainting = false;
+            if (this.pointerObservable) {
+                this.getParameters().scene.onPointerObservable.remove(this.pointerObservable);
+            }
+        }
+    },
+    icon: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHN0eWxlPSJmaWxsOm5vbmUiLz48cGF0aCBkPSJNMjksMTFhMy41
+    NywzLjU3LDAsMCwxLDAsNS4wNkwxNywyOGEyLjM0LDIuMzQsMCwwLDEtMSwuNThMMTAuOTEsMzBhLjc1Ljc1LDAsMCwxLS45Mi0uOTJMMTEuMzgsMjRBMi4zNCwyLjM0LDAsMCwxLDEyLDIzbDEyLTEyQTMuNTcsMy41NywwLDAsMSwyOSwxMVpNMjMsMTQuMSwxMywy
+    NGEuNjkuNjksMCwwLDAtLjE5LjMzbC0xLjA1LDMuODUsMy44NS0xQS42OS42OSwwLDAsMCwxNiwyN0wyNS45LDE3Wm0yLTItMSwxTDI3LDE2bDEtMUEyLjA4LDIuMDgsMCwxLDAsMjUsMTIuMDdaIiBzdHlsZT0iZmlsbDojZmZmIi8+PC9zdmc+`
+};

+ 5 - 0
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/defaultTools/defaultTools.ts

@@ -0,0 +1,5 @@
+import { Paintbrush } from './Paintbrush';
+import { Eyedropper } from './Eyedropper';
+import { Floodfill } from './Floodfill';
+
+export default [Paintbrush, Eyedropper, Floodfill];

+ 59 - 10
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/propertiesBar.tsx

@@ -9,6 +9,18 @@ interface PropertiesBarProps {
     face: number;
     setFace(face : number): void;
     resetTexture() : void;
+    resizeTexture(width: number, height: number) : void;
+    uploadTexture(file : File) : void;
+}
+
+interface PropertiesBarState {
+    width: number;
+    height: number;
+}
+
+interface PixelDataProps {
+    name : string;
+    data?: number;
 }
 
 const resetButton = require('./assets/reset.svg');
@@ -37,25 +49,42 @@ const faces = [
     negZ
 ]
 
-interface PixelDataProps {
-    name : string;
-    data?: number;
-}
-
 function PixelData(props : PixelDataProps) {
     return <span className='pixel-data'>{props.name}: <span className='value'>{props.data || '-'}</span></span>
 }
 
-export class PropertiesBar extends React.Component<PropertiesBarProps> {
+function getNewDimension(oldDim : number, newDim : any) {
+    if (!isNaN(newDim)) {
+        if (parseInt(newDim) > 0) {
+            if (Number.isInteger(parseInt(newDim)))
+                return parseInt(newDim);
+        }
+    }
+    return oldDim;
+}
+
+export class PropertiesBar extends React.Component<PropertiesBarProps,PropertiesBarState> {
+    constructor(props : PropertiesBarProps) {
+        super(props);
+
+        this.state = {
+            width: props.texture.getSize().width,
+            height: props.texture.getSize().height
+        }
+    }
     render() {
         return <div id='properties'>
                 <div className='tab' id='logo-tab'>
                     <img className='icon' src={babylonLogo}/>
                 </div>
                 <div className='tab' id='dimensions-tab'>
-                    <label className='dimensions'>W: <input type='text' readOnly contentEditable={false} value={this.props.texture.getSize().width}/></label>
-                    <label className='dimensions'>H: <input type='text' readOnly contentEditable={false} value={this.props.texture.getSize().height} /></label>
-                    <img id='resize' className='icon button' title='Resize' alt='Resize' src={resizeButton}/>
+                    <label className='dimensions'>
+                        W: <input type='text' value={this.state.width} onChange={(evt) => this.setState({width: getNewDimension(this.state.width, evt.target.value)})}/>
+                        </label>
+                    <label className='dimensions'>
+                        H: <input type='text' value={this.state.height} onChange={(evt) => this.setState({height: getNewDimension(this.state.height, evt.target.value)})}/>
+                        </label>
+                    <img id='resize' className='icon button' title='Resize' alt='Resize' src={resizeButton} onClick={() => this.props.resizeTexture(this.state.width, this.state.height)}/>
                 </div>
                 <div className='tab' id='pixel-coords-tab'>
                     <PixelData name='X' data={this.props.pixelData.x}/>
@@ -86,7 +115,27 @@ export class PropertiesBar extends React.Component<PropertiesBarProps> {
                 <div className='tab' id='right-tab'>
                     <div className='content'>
                         <img title='Reset' className='icon button' src={resetButton} onClick={() => this.props.resetTexture()}/>
-                        <img title='Upload' className='icon button' src={uploadButton}/>
+                        <label>
+                            <input
+                                accept='.jpg, .png, .tga, .dds, .env'
+                                type='file'
+                                onChange={
+                                    (evt : React.ChangeEvent<HTMLInputElement>) => {
+                                        const files = evt.target.files;
+                                        if (files && files.length) {
+                                            this.props.uploadTexture(files[0]);
+                                        }
+                                
+                                        evt.target.value = "";
+                                    }
+                                }
+                            />
+                            <img
+                                title='Upload'
+                                className='icon button'
+                                src={uploadButton}
+                            />
+                        </label>
                         <img title='Save' className='icon button' src={saveButton} onClick={() => this.props.saveTexture()}/>
                     </div>
                 </div>

+ 112 - 29
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/textureCanvasManager.ts

@@ -8,18 +8,25 @@ import { Nullable } from 'babylonjs/types'
 import { PlaneBuilder } from 'babylonjs/Meshes/Builders/planeBuilder';
 import { Mesh } from 'babylonjs/Meshes/mesh';
 import { Camera } from 'babylonjs/Cameras/camera';
+
 import { BaseTexture } from 'babylonjs/Materials/Textures/baseTexture';
 import { HtmlElementTexture } from 'babylonjs/Materials/Textures/htmlElementTexture';
 import { InternalTexture } from 'babylonjs/Materials/Textures/internalTexture';
+import { Texture } from 'babylonjs/Materials/Textures/texture';
 import { NodeMaterial } from 'babylonjs/Materials/Node/nodeMaterial';
 import { PBRMaterial } from 'babylonjs/Materials/PBR/pbrMaterial';
 import { RawCubeTexture } from 'babylonjs/Materials/Textures/rawCubeTexture';
-import { TextureHelper, TextureChannelsToDisplay } from '../../../../../../textureHelper';
+import { CubeTexture } from 'babylonjs/Materials/Textures/cubeTexture';
+
+
 import { ISize } from 'babylonjs/Maths/math.size';
+import { Tools } from 'babylonjs/Misc/tools';
 
 import { PointerEventTypes, PointerInfo } from 'babylonjs/Events/pointerEvents';
 import { KeyboardEventTypes } from 'babylonjs/Events/keyboardEvents';
 
+import { TextureHelper, TextureChannelsToDisplay } from '../../../../../../textureHelper';
+
 import { Tool } from './toolBar';
 import { Channel } from './channelsBar';
 
@@ -48,8 +55,6 @@ export class TextureCanvasManager {
 
     /* The canvas we paint onto using the canvas API */
     private _2DCanvas : HTMLCanvasElement;
-    /* The texture we are currently editing, which is based on _2DCanvas */
-    private _texture: HtmlElementTexture;
 
     private _displayCanvas : HTMLCanvasElement;
     private _channels : Channel[] = [];
@@ -115,19 +120,16 @@ export class TextureCanvasManager {
 
         this._camera = new FreeCamera("Camera", new Vector3(0, 0, -1), this._scene);
         this._camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
-        this._texture = new HtmlElementTexture("texture", this._2DCanvas, {engine: this._engine, scene: this._scene});
-        this._displayTexture = new HtmlElementTexture("display", this._displayCanvas, {engine: this._engine, scene: this._scene});
-        this._displayTexture.updateSamplingMode(Engine.TEXTURE_NEAREST_LINEAR);
-        this.grabOriginalTexture();
 
-        const textureRatio = this._size.width / this._size.height;
-        
-        this._plane = PlaneBuilder.CreatePlane("plane", {width: textureRatio, height: 1}, this._scene);
         this._planeFallbackMaterial = new PBRMaterial('fallback_material', this._scene);
         this._planeFallbackMaterial.albedoTexture = this._displayTexture;
         this._planeFallbackMaterial.disableLighting = true;
         this._planeFallbackMaterial.unlit = true;
-        this._plane.material = this._planeFallbackMaterial;
+
+        this._displayTexture = new HtmlElementTexture("display", this._displayCanvas, {engine: this._engine, scene: this._scene});
+        this._displayTexture.updateSamplingMode(Engine.TEXTURE_NEAREST_LINEAR);
+        this.grabOriginalTexture();
+
         NodeMaterial.ParseFromSnippetAsync("#TPSEV2#4", this._scene)
             .then((material) => {
                 this._planeMaterial = material;
@@ -135,10 +137,6 @@ export class TextureCanvasManager {
                 this._plane.material = this._planeMaterial;
                 this._UICanvas.focus();
             });
-        this._plane.enableEdgesRendering();
-        this._plane.edgesWidth = 4.0;
-        this._plane.edgesColor = new Color4(1,1,1,1);
-        this._plane.enablePointerMoveEvents = true;
 
         this._engine.runRenderLoop(() => {
             this._engine.resize();
@@ -220,10 +218,11 @@ export class TextureCanvasManager {
             }
         })
 
+        this._scene.debugLayer.show();
+
     }
 
     public async updateTexture() {
-        this._texture.update();
         this._didEdit = true;
         if (this._originalTexture.isCube) {
             // TODO: fix cube map editing
@@ -231,7 +230,7 @@ export class TextureCanvasManager {
             for (let face = 0; face < 6; face++) {
                 let textureToCopy = this._originalTexture;
                 if (face === this._face) {
-                    textureToCopy = this._texture;
+                    // textureToCopy = this._texture;
                 }
                 pixels[face] = await TextureHelper.GetTextureDataAsync(textureToCopy, this._size.width, this._size.height, face, {R: true, G: true, B: true, A: true});
             }
@@ -243,16 +242,23 @@ export class TextureCanvasManager {
             }
         } else {
             if (!this._target) {
-                this._target = new HtmlElementTexture("editor", this._2DCanvas, {engine: this._originalTexture.getScene()?.getEngine()!, scene: null});
-            } else {
-                (this._target as HtmlElementTexture).update();
+                this._target = new HtmlElementTexture(
+                    "editor",
+                    this._2DCanvas,
+                    {
+                        engine: this._originalTexture.getScene()?.getEngine()!,
+                        scene: null,
+                        samplingMode: (this._originalTexture as Texture).samplingMode
+                    }
+                );
             }
+            (this._target as HtmlElementTexture).update((this._originalTexture as Texture).invertY);
         }
         this._originalTexture._texture = this._target._texture;
         this.copyTextureToDisplayTexture();
     }
 
-    private copyTextureToDisplayTexture() {
+    private async copyTextureToDisplayTexture() {
         let channelsToDisplay : TextureChannelsToDisplay = {
             R: true,
             G: true,
@@ -260,11 +266,9 @@ export class TextureCanvasManager {
             A: true
         }
         this._channels.forEach(channel => channelsToDisplay[channel.id] = channel.visible);
-        TextureHelper.GetTextureDataAsync(this._originalTexture, this._size.width, this._size.height, this._face, channelsToDisplay)
-            .then(data => {
-                TextureCanvasManager.paintPixelsOnCanvas(data, this._displayCanvas);
-                this._displayTexture.update();
-            })
+        const pixels = await TextureHelper.GetTextureDataAsync(this._originalTexture, this._size.width, this._size.height, this._face, channelsToDisplay);
+        TextureCanvasManager.paintPixelsOnCanvas(pixels, this._displayCanvas);
+        this._displayTexture.update();
     }
 
     public set channels(channels: Channel[]) {
@@ -297,6 +301,8 @@ export class TextureCanvasManager {
 
     public grabOriginalTexture() {
         // Grab image data from original texture and paint it onto the context of a DynamicTexture
+        this._size = this._originalTexture.getSize();
+        this.updateSize();
         TextureHelper.GetTextureDataAsync(
             this._originalTexture,
             this._size.width,
@@ -305,7 +311,6 @@ export class TextureCanvasManager {
             {R:true ,G:true ,B:true ,A:true}
         ).then(data => {
             TextureCanvasManager.paintPixelsOnCanvas(data, this._2DCanvas);
-            this._texture.update();
             this.copyTextureToDisplayTexture();
         })
     }
@@ -353,12 +358,91 @@ export class TextureCanvasManager {
         }
     }
 
-    public resetTexture() : void {
+    private makePlane() {
+        const textureRatio = this._size.width / this._size.height;
+        if (this._plane) this._plane.dispose();
+        this._plane = PlaneBuilder.CreatePlane("plane", {width: textureRatio, height: 1}, this._scene);
+        this._plane.enableEdgesRendering();
+        this._plane.edgesWidth = 4.0;
+        this._plane.edgesColor = new Color4(1,1,1,1);
+        this._plane.enablePointerMoveEvents = true;
+        if (this._planeMaterial) this._plane.material = this._planeMaterial; else this._plane.material = this._planeFallbackMaterial;
+    }
+
+    public reset() : void {
         this._originalTexture._texture = this._originalInternalTexture;
         this.grabOriginalTexture();
+        this.makePlane();
         this._didEdit = false;
     }
 
+    public async resize(newSize : ISize) {
+        const data = await TextureHelper.GetTextureDataAsync(this._originalTexture, newSize.width, newSize.height, this._face, {R: true,G: true,B: true,A: true});
+        this._size = newSize;
+        this.updateSize();
+        TextureCanvasManager.paintPixelsOnCanvas(data, this._2DCanvas);
+        this.updateTexture();
+        this._didEdit = true;
+    }
+
+    private updateSize() {
+        this._2DCanvas.width = this._size.width;
+        this._2DCanvas.height = this._size.height;
+        this._displayCanvas.width = this._size.width;
+        this._displayCanvas.height = this._size.height;
+        this.makePlane();
+    }
+
+    public upload(file : File) {
+        Tools.ReadFile(file, (data) => {
+            var blob = new Blob([data], { type: "octet/stream" });
+            let extension: string | undefined = undefined;
+            if (file.name.toLowerCase().indexOf(".dds") > 0) {
+                extension = ".dds";
+            } else if (file.name.toLowerCase().indexOf(".env") > 0) {
+                extension = ".env";
+            }
+            var reader = new FileReader();
+            reader.readAsDataURL(blob); 
+            reader.onloadend = () => {
+                let base64data = reader.result as string;     
+
+                if (extension === '.dds' || extension === '.env') {
+                    const texture = new CubeTexture(
+                        base64data,
+                        this._scene,
+                        [extension],
+                        this._originalTexture.noMipmap,                        
+                        null,
+                        () => {
+                            // TO-DO: implement cube loading
+                            texture.dispose();
+                        }
+                    );
+                } else {
+                    const texture = new Texture(
+                        base64data,
+                        this._scene,
+                        this._originalTexture.noMipmap,
+                        false,
+                        Engine.TEXTURE_NEAREST_SAMPLINGMODE,
+                        () => {
+                            TextureHelper.GetTextureDataAsync(texture, texture.getSize().width, texture.getSize().height, 0, {R: true, G: true, B: true, A: true})
+                                .then((pixels) => {
+                                    this._size = texture.getSize();
+                                    this.updateSize();
+                                    TextureCanvasManager.paintPixelsOnCanvas(pixels, this._2DCanvas);
+                                    this.updateTexture();
+                                    texture.dispose();
+                                });
+                        });
+                    
+                }
+            };
+
+        }, undefined, true);
+    }
+
     public dispose() {
         if (this._planeMaterial) {
             this._planeMaterial.dispose();
@@ -370,7 +454,6 @@ export class TextureCanvasManager {
             this._tool.instance.cleanup();
         }
         this._displayTexture.dispose();
-        this._texture.dispose();
         this._plane.dispose();
         this._camera.dispose();
         this._scene.dispose();

+ 58 - 1
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/textureEditor.scss

@@ -83,6 +83,19 @@
                     margin-right: 8px;
                 }
             }
+            @media only screen and (max-width: 334px) {
+                display: none;
+            }
+        }
+        #pixel-coords-tab {
+            @media only screen and (max-width: 440px) {
+                display: none;
+            }
+        }
+        #pixel-color-tab {
+            @media only screen and (max-width: 640px) {
+                display: none;
+            }
         }
         #right-tab {
             flex-grow: 1;
@@ -92,6 +105,10 @@
                 position: absolute;
                 right: 0px;
             }
+            
+            input[type="file"] {
+                display: none;
+            }
         }
     
         .pixel-data {
@@ -124,7 +141,36 @@
             flex-direction: column;
             
         }
-    
+
+        #add-tool {
+            position: relative;
+            #add-tool-popup {
+                background-color: #333333;
+                width: 340px;
+                margin-left: 40px;
+                position: absolute;
+                top: 0px;
+                height: 40px;
+                padding-left: 4px;
+                line-height: 40px;
+                user-select: none;
+                button {
+                    background: #222222;
+                    border: 1px solid rgb(51, 122, 183);
+                    margin: 5px 10px 5px 10px;
+                    color:white;
+                    padding: 4px 5px;
+                    opacity: 0.9;
+                    cursor: pointer;
+                }
+            }
+        }
+
+        #color-picker {
+            position: absolute;
+            margin-left: 40px;
+        }
+
         #color {
             margin-top: 8px;
             #activeColor {
@@ -134,6 +180,17 @@
                 border-radius: 50%;
             }
         }
+        .color-picker-cover {
+            position: fixed;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+        }
+        .color-picker {
+            position: absolute;
+            margin-left: 40px;
+        }
     }
     
     #channels-bar {

+ 58 - 21
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/textureEditorComponent.tsx

@@ -1,13 +1,17 @@
 import * as React from 'react';
 import { GlobalState } from '../../../../../globalState';
-import { BaseTexture } from 'babylonjs/Materials/Textures/baseTexture';
 import { TextureCanvasManager, PixelData } from './textureCanvasManager';
 import { Tool, ToolBar } from './toolBar';
 import { PropertiesBar } from './propertiesBar';
 import { Channel, ChannelsBar } from './channelsBar';
 import { BottomBar } from './bottomBar';
-import { Tools } from "babylonjs/Misc/tools";
 import { TextureCanvasComponent } from './textureCanvasComponent';
+import defaultTools from './defaultTools/defaultTools';
+
+import { BaseTexture } from 'babylonjs/Materials/Textures/baseTexture';
+import { Tools } from 'babylonjs/Misc/tools';
+import { Scene } from 'babylonjs/scene';
+import { ISize } from 'babylonjs/Maths/math.size';
 
 require('./textureEditor.scss');
 
@@ -26,7 +30,16 @@ interface TextureEditorComponentState {
     face: number;
 }
 
-interface ToolData {
+export interface ToolParameters {
+    scene: Scene;
+    canvas2D: HTMLCanvasElement;
+    size: ISize;
+    updateTexture: () => void;
+    getMetadata: () => any;
+    setMetadata: (data : any) => void;
+}
+
+export interface ToolData {
     name: string;
     type: any;
     icon: string;
@@ -65,12 +78,15 @@ export class TextureEditorComponent extends React.Component<TextureEditorCompone
             pixelData: {},
             face: 0
         }
-        this.loadTool = this.loadTool.bind(this);
+        this.loadToolFromURL = this.loadToolFromURL.bind(this);
         this.changeTool = this.changeTool.bind(this);
         this.setMetadata = this.setMetadata.bind(this);
         this.saveTexture = this.saveTexture.bind(this);
         this.setFace = this.setFace.bind(this);
         this.resetTexture = this.resetTexture.bind(this);
+        this.resizeTexture = this.resizeTexture.bind(this);
+        this.uploadTexture = this.uploadTexture.bind(this);
+
     }
 
     componentDidMount() {
@@ -81,6 +97,7 @@ export class TextureEditorComponent extends React.Component<TextureEditorCompone
             this.canvasDisplay.current!,
             (data : PixelData) => {this.setState({pixelData: data})}
         );
+        this.addTools(defaultTools);
     }
 
     componentDidUpdate() {
@@ -95,24 +112,34 @@ export class TextureEditorComponent extends React.Component<TextureEditorCompone
         this._textureCanvasManager.dispose();
     }
 
-    // There is currently no UI for adding a tool, so this function does not get called
-    loadTool(url : string) {
+    loadToolFromURL(url : string) {
         Tools.LoadScript(url, () => {
+            this.addTools([_TOOL_DATA_]);
+        });
+    }
+    
+    addTools(tools : ToolData[]) {
+        let newTools : Tool[] = [];
+        tools.forEach(toolData => {
             const tool : Tool = {
-                ..._TOOL_DATA_,
-                instance: new _TOOL_DATA_.type({
-                    scene: this._textureCanvasManager.scene,
-                    canvas2D: this._textureCanvasManager.canvas2D,
-                    size: this._textureCanvasManager.size,
-                    updateTexture: () => this._textureCanvasManager.updateTexture(),
-                    getMetadata: () => this.state.metadata,
-                    setMetadata: (data : any) => this.setMetadata(data)
-                })
-            };
-            const newTools = this.state.tools.concat(tool);
-            this.setState({tools: newTools});
-                console.log(tool);
+                ...toolData,
+                instance: new toolData.type(() => this.getToolParameters())};
+            newTools = newTools.concat(tool);
         });
+        newTools = this.state.tools.concat(newTools);
+        this.setState({tools: newTools});
+        console.log(newTools);
+    }
+
+    getToolParameters() : ToolParameters {
+        return {
+            scene: this._textureCanvasManager.scene,
+            canvas2D: this._textureCanvasManager.canvas2D,
+            size: this._textureCanvasManager.size,
+            updateTexture: () => this._textureCanvasManager.updateTexture(),
+            getMetadata: () => this.state.metadata,
+            setMetadata: (data : any) => this.setMetadata(data)
+        };
     }
 
     changeTool(index : number) {
@@ -143,7 +170,15 @@ export class TextureEditorComponent extends React.Component<TextureEditorCompone
     }
 
     resetTexture() {
-        this._textureCanvasManager.resetTexture();
+        this._textureCanvasManager.reset();
+    }
+
+    resizeTexture(width: number, height: number) {
+        this._textureCanvasManager.resize({width, height});
+    }
+
+    uploadTexture(file : File) {
+        this._textureCanvasManager.upload(file);
     }
 
     render() {
@@ -155,11 +190,13 @@ export class TextureEditorComponent extends React.Component<TextureEditorCompone
                 face={this.state.face}
                 setFace={this.setFace}
                 resetTexture={this.resetTexture}
+                resizeTexture={this.resizeTexture}
+                uploadTexture={this.uploadTexture}
             />
             <ToolBar
                 tools={this.state.tools}
                 activeToolIndex={this.state.activeToolIndex}
-                addTool={this.loadTool}
+                addTool={this.loadToolFromURL}
                 changeTool={this.changeTool}
                 metadata={this.state.metadata}
                 setMetadata={this.setMetadata}

+ 53 - 4
inspector/src/components/actionTabs/tabs/propertyGrids/materials/textures/toolBar.tsx

@@ -1,4 +1,5 @@
 import * as React from 'react';
+import { SketchPicker } from 'react-color';
 
 export interface Tool {
     type: any,
@@ -18,18 +19,31 @@ interface ToolBarProps {
 
 interface ToolBarState {
     toolURL : string;
+    pickerOpen : boolean;
+    addOpen : boolean;
 }
 
+const addTool = require('./assets/addTool.svg');
+
 export class ToolBar extends React.Component<ToolBarProps, ToolBarState> {
+    private pickerRef : React.RefObject<HTMLDivElement>;
     constructor(props : ToolBarProps) {
         super(props);
         this.state = {
             toolURL: "",
+            pickerOpen: false,
+            addOpen: false
         };
+        this.pickerRef = React.createRef();
+    }
+
+    computeRGBAColor() {
+        const opacityInt = Math.floor(this.props.metadata.opacity * 255);
+        const opacityHex = opacityInt.toString(16).padStart(2, '0');
+        return `${this.props.metadata.color}${opacityHex}`;
     }
+
     render() {
-        // No need to display toolbar if there aren't any tools loaded
-        if (this.props.tools.length === 0) return <></>;
         return <div id='toolbar'>
             <div id='tools'>
                 {this.props.tools.map(
@@ -39,15 +53,50 @@ export class ToolBar extends React.Component<ToolBarProps, ToolBarState> {
                             className={index === this.props.activeToolIndex ? 'icon button active' : 'icon button'}
                             alt={item.name}
                             title={item.name}
-                            onClick={() => this.props.changeTool(index)}
+                            onClick={evt => {
+                                if (evt.button === 0) {
+                                    this.props.changeTool(index)
+                                }
+                            }}
                             key={index}
                         />
                     }
                 )}
+                <div id='add-tool'>
+                    <img src={addTool} className='icon button' title='Add Tool' alt='Add Tool' onClick={() => this.setState({addOpen: !this.state.addOpen})}/>
+                    { this.state.addOpen && 
+                    <div id='add-tool-popup'>
+                        <form onSubmit={event => {
+                            event.preventDefault();
+                            this.props.addTool(this.state.toolURL);
+                            this.setState({toolURL: '', addOpen: false})
+                        }}>
+                            <label>
+                                Enter tool URL: <input value={this.state.toolURL} onChange={evt => this.setState({toolURL: evt.target.value})} type='text'/>
+                            </label>
+                            <button>Add</button>
+                        </form>
+                    </div> }
+                </div>
             </div>
-            <div id='color' title='Color' className='icon button'>
+            <div id='color' onClick={() => this.setState({pickerOpen: !this.state.pickerOpen})} title='Color' className={`icon button${this.state.pickerOpen ? ` active` : ``}`}>
                 <div id='activeColor' style={{backgroundColor: this.props.metadata.color}}></div>
             </div>
+            {
+                this.state.pickerOpen &&
+                <>
+                    <div className='color-picker-cover' onClick={evt => {
+                        if (evt.target !== this.pickerRef.current?.ownerDocument.querySelector('.color-picker-cover')) {
+                            return;
+                        }
+                        this.setState({pickerOpen: false});
+                    }}>
+                    </div>
+                    <div className='color-picker' ref={this.pickerRef}>
+                            <SketchPicker color={this.computeRGBAColor()}  onChange={color => this.props.setMetadata({color: color.hex, opacity: color.rgb.a})}/>
+                    </div>
+                </>
+            }
         </div>;
     }
 }