import { SchemaJSONInterface } from '../node_modules/@khronosgroup/gltf-asset-auditor/dist/SchemaJSON'; import { Auditor } from '../node_modules/@khronosgroup/gltf-asset-auditor/dist/Auditor'; import { GltfValidatorReportIssuesMessageInterface } from '../node_modules/@khronosgroup/gltf-asset-auditor/dist/GltfValidatorReport'; import { ArcRotateCamera, Color4, Engine, FilesInputStore, HemisphericLight, Scene, SceneLoader, Vector3, } from '../node_modules/@babylonjs/core/index'; import '@babylonjs/loaders/glTF/2.0/glTFLoader.js'; // Helper function function $(id: string) { return document.getElementById(id); } // Highlight an element for drag and drop function addHighlightClass(element: HTMLElement) { element.classList.add('highlight'); } // Remove the child elements of the report function clearReport() { const reportTable = $('report'); while (reportTable.hasChildNodes()) { reportTable.removeChild(reportTable.firstChild); } } // Extract the csv data from the audit report and provide it as a file to the user function downloadReportCsv(auditor: Auditor) { const csvString = auditor.getReportCsv(); const dataString = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvString); const a = document.createElement('a'); a.href = dataString; a.download = getFilenameWithTimestamp(auditor.model.glb.filename) + '.csv'; a.click(); } // Extract the json data from the audit report and provide it as a file to the user function downloadReportJson(auditor: Auditor) { const jsonString = auditor.getReportJson(); const dataString = 'data:text/json;charset=utf-8,' + encodeURIComponent(jsonString); const a = document.createElement('a'); a.href = dataString; a.download = getFilenameWithTimestamp(auditor.model.glb.filename) + '.json'; a.click(); } // Extract the json object from the form inputs and provide it as a file to the user function downloadSchemaJson() { const json = getSchemaFromForm(); const jsonString = JSON.stringify(json); const dataString = 'data:text/json;charset=utf-8,' + encodeURIComponent(jsonString); const a = document.createElement('a'); a.href = dataString; a.download = 'gltf-asset-auditor-schema.json'; a.click(); } // Gives a name + timestamp file name string function getFilenameWithTimestamp(fileName: string) { const date = new Date(); const timestamp = [ date.getFullYear(), ('0' + (date.getMonth() + 1)).slice(-2), ('0' + date.getDate()).slice(-2), ('0' + date.getHours()).slice(-2), ('0' + date.getMinutes()).slice(-2), ('0' + date.getSeconds()).slice(-2), ].join('-'); return fileName.substring(0, fileName.lastIndexOf('.')) + '-3DQC-' + timestamp; } // Read the input values and put them in a schema json object function getSchemaFromForm() { return { version: ($('formVersion') as HTMLInputElement).value as string, fileSizeInKb: { maximum: parseInt(($('formFileSizeMax') as HTMLInputElement).value), minimum: parseInt(($('formFileSizeMin') as HTMLInputElement).value), }, materials: { maximum: parseInt(($('formMaterialCountMax') as HTMLInputElement).value), minimum: parseInt(($('formMaterialCountMin') as HTMLInputElement).value), }, model: { objectCount: { nodes: { maximum: parseInt(($('formNodeCountMax') as HTMLInputElement).value), minimum: parseInt(($('formNodeCountMin') as HTMLInputElement).value), }, meshes: { maximum: parseInt(($('formMeshCountMax') as HTMLInputElement).value), minimum: parseInt(($('formMeshCountMin') as HTMLInputElement).value), }, primitives: { maximum: parseInt(($('formPrimitiveCountMax') as HTMLInputElement).value), minimum: parseInt(($('formPrimitiveCountMin') as HTMLInputElement).value), }, }, requireBeveledEdges: ($('formBeveledEdgesYes') as HTMLInputElement).checked, requireCleanRootNodeTransform: ($('formCleanTransformsYes') as HTMLInputElement).checked, requireManifoldEdges: ($('formRequireManifoldEdgesYes') as HTMLInputElement).checked, triangles: { maximum: parseInt(($('formTriangleCountMax') as HTMLInputElement).value), minimum: parseInt(($('formTriangleCountMin') as HTMLInputElement).value), }, }, product: { dimensions: { height: { maximum: parseInt(($('formModelHeightMax') as HTMLInputElement).value), minimum: parseInt(($('formModelHeightMin') as HTMLInputElement).value), percentTolerance: parseInt(($('formModelHeightTolerance') as HTMLInputElement).value), }, length: { maximum: parseInt(($('formModelLengthMax') as HTMLInputElement).value), minimum: parseInt(($('formModelLengthMin') as HTMLInputElement).value), percentTolerance: parseInt(($('formModelLengthTolerance') as HTMLInputElement).value), }, width: { maximum: parseInt(($('formModelWidthMax') as HTMLInputElement).value), minimum: parseInt(($('formModelWidthMin') as HTMLInputElement).value), percentTolerance: parseInt(($('formModelWidthTolerance') as HTMLInputElement).value), }, }, }, textures: { height: { maximum: parseInt(($('formTextureHeightMax') as HTMLInputElement).value), minimum: parseInt(($('formTextureHeightMin') as HTMLInputElement).value), }, pbrColorRange: { maximum: parseInt(($('formPbrColorRangeMax') as HTMLInputElement).value), minimum: parseInt(($('formPbrColorRangeMin') as HTMLInputElement).value), }, requireDimensionsBePowersOfTwo: ($('formTexturesPowerOfTwoYes') as HTMLInputElement).checked, requireDimensionsBeQuadratic: ($('formTexturesQuadraticYes') as HTMLInputElement).checked, width: { maximum: parseInt(($('formTextureWidthMax') as HTMLInputElement).value), minimum: parseInt(($('formTextureWidthMin') as HTMLInputElement).value), }, }, uvs: { gutterWidth: { resolution256: parseInt(($('formUvGutterWidth256') as HTMLInputElement).value), resolution512: parseInt(($('formUvGutterWidth512') as HTMLInputElement).value), resolution1024: parseInt(($('formUvGutterWidth1024') as HTMLInputElement).value), resolution2048: parseInt(($('formUvGutterWidth2048') as HTMLInputElement).value), resolution4096: parseInt(($('formUvGutterWidth4096') as HTMLInputElement).value), }, pixelsPerMeter: { maximum: parseInt(($('formPixelsPerMeterMax') as HTMLInputElement).value), minimum: parseInt(($('formPixelsPerMeterMin') as HTMLInputElement).value), }, requireNotInverted: ($('formUvsNotInvertedYes') as HTMLInputElement).checked, requireNotOverlapping: ($('formUvsNotOverlappingYes') as HTMLInputElement).checked, requireRangeZeroToOne: ($('formUvsInRange01Yes') as HTMLInputElement).checked, }, }; } // Load model action that loads a model (.glb or .gltf + files) from an element async function loadModel(auditor: Auditor): Promise { try { // ensure the latest settings in the form are loaded, if the user did not save and close auditor.schema.loadFromSchemaObject(getSchemaFromForm()); const input = $('modelInput') as HTMLInputElement; const modelCubeSvg = $('modelCubeSvg'); const modelLoadingSpinner = $('modelLoadingSpinner'); // Show a loading spinner // Note the animation may not play smoothly if the auditor is using all of the resources // V2: change the loading gif to something simpler. Perhaps use css to rotate it. modelCubeSvg.style.display = 'none'; modelLoadingSpinner.style.display = 'block'; // Use the auditor to load the model from the files array (can be just a single .glb file or .gltf + files) await auditor.model.loadFromFileInput(Array.from(input.files)); // Remove the loading cube modelCubeSvg.style.display = ''; modelLoadingSpinner.style.display = 'none'; if (auditor.model.loaded) { // Show the 3D viewer $('modelIcon').classList.remove('fail'); $('modelLoader').style.display = 'none'; $('model3dView').style.display = 'block'; // Use Babylon with a white background to render the model const canvas = $('modelCanvas') as HTMLCanvasElement; const engine = new Engine(canvas); const scene = new Scene(engine); scene.clearColor = new Color4(1.0, 1.0, 1.0, 1.0); // Position the camera based on the model's bounding box const height = auditor.model.height.value as number; const length = auditor.model.length.value as number; const width = auditor.model.width.value as number; const cameraRadius = height + width + length; const minDimension = Math.min(height, length, width); const maxDimension = Math.max(height, length, width); const camera = new ArcRotateCamera( 'camera', Math.PI / 2, Math.PI / 2.5, cameraRadius, new Vector3(0, height / 2, 0), scene, ); camera.lowerRadiusLimit = cameraRadius / 2; camera.upperRadiusLimit = (cameraRadius * 3) / 2; camera.minZ = minDimension / 10; camera.maxZ = maxDimension * 10; camera.panningDistanceLimit = cameraRadius / 2; camera.attachControl(canvas, true); // Add a light at the top the same distance as the initial camera radius new HemisphericLight('light', new Vector3(0, cameraRadius, 0), scene); // Start Babylon engine.runRenderLoop(() => scene.render()); // Load the model using the GLB data from the auditor const assetArrayBuffer = auditor.model.glb.getBytes(); if (assetArrayBuffer) { // Single glb with data available from the auditor const assetBlob = new Blob([assetArrayBuffer]); const assetUrl = URL.createObjectURL(assetBlob); await SceneLoader.AppendAsync(assetUrl, undefined, scene, undefined, '.glb'); $('model3dView').scrollIntoView(); } else { // glTF + files, load from the file input form element // The auditor now converts .gltf multi-files into the .glb format, so // this else won't be called, but I'm leaving it as a demo of how to // load .gltf + files in Babylon const files = Array.from(input.files); let gltfFile = null as unknown as File; files.forEach(file => { FilesInputStore.FilesToLoad[file.name] = file; if (file.name.endsWith('gltf')) { gltfFile = file; } }); if (gltfFile) { await SceneLoader.AppendAsync('file:', gltfFile, scene); } } } else { $('modelIcon').classList.add('fail'); } // Show the report renderReport(auditor); } catch (err) { $('modelIcon').classList.add('fail'); reportError((err as Error).message); } } // Read product info from a json file async function loadProductInfo(auditor: Auditor): Promise { try { const input = $('productInfoInput') as HTMLInputElement; // V2: add another function that takes a json object with the product data // that would make it easier to add editable input fields on the frontend await auditor.productInfo.loadFromFileInput(input.files[0]); if (auditor.productInfo.loaded) { $('productInfoIcon').classList.remove('fail'); $('productInfoIcon').classList.add('pass'); } else { $('productInfoIcon').classList.remove('pass'); $('productInfoIcon').classList.add('fail'); } // Try to render the report, which will rerun it if already run // The report won't be ready if the model hasn't been loaded yet renderReport(auditor); } catch (err) { $('productInfoIcon').classList.remove('pass'); $('productInfoIcon').classList.add('fail'); reportError((err as Error).message); } } // Read schema data from a json file async function loadSchema(auditor: Auditor): Promise { try { // If there is a report already shown, get rid of it clearReport(); const input = $('schemaInput') as HTMLInputElement; await auditor.schema.loadFromFileInput(input.files[0]); // V2: Additional front-end auditing and error messaging would be helpful if (auditor.schema.loaded) { setSchemaFormFromAuditor(auditor); $('schemaIcon').classList.remove('fail'); $('schemaIcon').classList.add('pass'); } else { $('schemaIcon').classList.remove('pass'); $('schemaIcon').classList.add('fail'); } // Show the report if, it is ready (model already loaded), but generally // it is recommended to load the model last, in which case this does nothing // Tests that need vertex indices calculated are slow, so they are not // enabled by default. If the model is loaded before the schema, those // indices are not calculated because the auditor does not yet know // if they are required. // V2: take out renderReport and don't allow the schema to be changed after the model is loaded renderReport(auditor); } catch (err) { $('schemaIcon').classList.remove('pass'); $('schemaIcon').classList.add('fail'); reportError((err as Error).message); } } // Stop event propagation function preventDefaults(e: Event) { e.preventDefault(); e.stopPropagation(); } // Un-highlight drag and drop element function removeHighlightClass(element: HTMLElement) { element.classList.remove('highlight'); } // Show the report if both the schema and the model have been loaded function renderReport(auditor: Auditor) { try { if (!auditor.schema.loaded || !auditor.model.loaded) { // the report requires both files to be loaded return; } // generate (or re-generate) the report auditor.generateReport(); // show the results const reportTable = $('report'); clearReport(); auditor.report.getItems().forEach((item: any) => { const row = document.createElement('tr'); const name = document.createElement('td'); // Test Name name.setAttribute('class', 'report-item-name'); name.appendChild(document.createTextNode(item.name + ':')); row.appendChild(name); // PASS | FAIL | NOT TESTED const result = document.createElement('td'); result.setAttribute('class', 'report-item-value'); result.setAttribute('class', !item.tested ? 'not-tested' : item.pass ? 'pass' : 'fail'); const resultText = !item.tested ? 'NOT TESTED' : item.pass ? 'PASS' : 'FAIL'; result.appendChild(document.createTextNode(resultText)); row.appendChild(result); // Info Message const message = document.createElement('td'); message.setAttribute('class', 'report-item-message'); message.setAttribute('class', !item.tested ? 'not-tested' : item.pass ? 'not-tested' : 'fail'); message.appendChild(document.createTextNode(item.message)); // Display extra info from the glTF Validator report if (item.name == 'glTF Validator') { if (auditor.model.gltfValidatorReport.issues.messages.length > 0) { const ul = document.createElement('ul'); const error_names = ['Error', 'Warning', 'Info', 'Hint']; auditor.model.gltfValidatorReport.issues.messages.forEach( (message: GltfValidatorReportIssuesMessageInterface) => { const li = document.createElement('li'); li.appendChild( document.createTextNode(error_names[message.severity] + ': ' + message.message + ' ' + message.pointer), ); ul.appendChild(li); }, ); message.appendChild(ul); } } row.appendChild(message); // Component specific message (such as which file failed). Not available for all tests. const componentMessage = document.createElement('td'); componentMessage.appendChild(document.createTextNode(item.componentMessage)); row.appendChild(componentMessage); // Link to more information about what the test covers const url = document.createElement('td'); url.setAttribute('class', 'report-item-url'); const urlLink = document.createElement('a'); urlLink.setAttribute('href', item.guidelinesUrl); urlLink.appendChild(document.createTextNode('Reference Link')); url.appendChild(urlLink); row.appendChild(url); reportTable.appendChild(row); }); // Download report (json / csv) buttons const tr = document.createElement('tr'); const td = document.createElement('td'); td.setAttribute('colspan', '5'); td.setAttribute('style', 'text-align:center'); // JSON const downloadJsonButton = document.createElement('button'); downloadJsonButton.setAttribute('class', 'download'); downloadJsonButton.onclick = () => { downloadReportJson(auditor); }; downloadJsonButton.appendChild(document.createTextNode('Download Report in JSON')); td.appendChild(downloadJsonButton); td.appendChild(document.createTextNode(' ')); // CSV const downloadCsvButton = document.createElement('button'); downloadCsvButton.setAttribute('class', 'download'); downloadCsvButton.onclick = () => { downloadReportCsv(auditor); }; downloadCsvButton.appendChild(document.createTextNode('Download Report in CSV')); td.appendChild(downloadCsvButton); tr.appendChild(td); reportTable.appendChild(tr); } catch (err) { reportError((err as Error).message); } } // Log an error to the console function reportError(message: string) { console.log('ERROR: ' + message); } // Action when the user clicks the save and close button function saveAndCloseSchema(auditor: Auditor) { auditor.schema.loadFromSchemaObject(getSchemaFromForm()); renderReport(auditor); toggleEditSchema(auditor); } // The auditor can provided 3D Commerce recommended values function setSchemaWithRecommended(auditor: Auditor) { updateSchemaForm(auditor.schema.getRecommended()); auditor.schema.loadFromSchemaObject(getSchemaFromForm()); renderReport(auditor); // if the model is already loaded } // Populates the form with the values already set in the auditor function setSchemaFormFromAuditor(auditor: Auditor) { updateSchemaForm(auditor.schema.getJsonObject()); } // Action when the user clicks the Edit Schema button function toggleEditSchema(auditor: Auditor) { if ($('editSchemaButton').style.display === 'none') { // Hide form $('schemaSettings').style.display = 'none'; $('editSchemaButton').style.display = 'block'; } else { // Show form setSchemaFormFromAuditor(auditor); $('schemaSettings').style.display = 'block'; $('editSchemaButton').style.display = 'none'; } } // Set all of the form inputs to match the provided schema values function updateSchemaForm(schema: SchemaJSONInterface) { ($('formVersion') as HTMLInputElement).value = schema.version; // File Size ($('formFileSizeMax') as HTMLInputElement).value = schema.fileSizeInKb.maximum.toString(); ($('formFileSizeMin') as HTMLInputElement).value = schema.fileSizeInKb.minimum.toString(); // Number of Materials ($('formMaterialCountMax') as HTMLInputElement).value = schema.materials.maximum.toString(); ($('formMaterialCountMin') as HTMLInputElement).value = schema.materials.minimum.toString(); // Number of Nodes / Meshes / Primitives ($('formNodeCountMax') as HTMLInputElement).value = schema.model.objectCount.nodes.maximum.toString(); ($('formNodeCountMin') as HTMLInputElement).value = schema.model.objectCount.nodes.minimum.toString(); ($('formMeshCountMax') as HTMLInputElement).value = schema.model.objectCount.meshes.maximum.toString(); ($('formMeshCountMin') as HTMLInputElement).value = schema.model.objectCount.meshes.minimum.toString(); ($('formPrimitiveCountMax') as HTMLInputElement).value = schema.model.objectCount.primitives.maximum.toString(); ($('formPrimitiveCountMin') as HTMLInputElement).value = schema.model.objectCount.primitives.minimum.toString(); // Beveled Edges (also known as no hard edges) ($('formBeveledEdgesYes') as HTMLInputElement).checked = schema.model.requireBeveledEdges; ($('formBeveledEdgesNo') as HTMLInputElement).checked = !schema.model.requireBeveledEdges; // Clean Transform ($('formCleanTransformsYes') as HTMLInputElement).checked = schema.model.requireCleanRootNodeTransform; ($('formCleanTransformsNo') as HTMLInputElement).checked = !schema.model.requireCleanRootNodeTransform; // Manifold Edges ($('formRequireManifoldEdgesYes') as HTMLInputElement).checked = schema.model.requireManifoldEdges; ($('formRequireManifoldEdgesNo') as HTMLInputElement).checked = !schema.model.requireManifoldEdges; // Triangle Count ($('formTriangleCountMax') as HTMLInputElement).value = schema.model.triangles.maximum.toString(); ($('formTriangleCountMin') as HTMLInputElement).value = schema.model.triangles.minimum.toString(); // Height (bounds) ($('formModelHeightMax') as HTMLInputElement).value = schema.product.dimensions.height.maximum.toString(); ($('formModelHeightMin') as HTMLInputElement).value = schema.product.dimensions.height.minimum.toString(); ($('formModelHeightTolerance') as HTMLInputElement).value = schema.product.dimensions.height.percentTolerance.toString(); // Length (bounds) ($('formModelLengthMax') as HTMLInputElement).value = schema.product.dimensions.length.maximum.toString(); ($('formModelLengthMin') as HTMLInputElement).value = schema.product.dimensions.length.minimum.toString(); ($('formModelLengthTolerance') as HTMLInputElement).value = schema.product.dimensions.length.percentTolerance.toString(); // Width (bounds) ($('formModelWidthMax') as HTMLInputElement).value = schema.product.dimensions.width.maximum.toString(); ($('formModelWidthMin') as HTMLInputElement).value = schema.product.dimensions.width.minimum.toString(); ($('formModelWidthTolerance') as HTMLInputElement).value = schema.product.dimensions.width.percentTolerance.toString(); // Texture Hight ($('formTextureHeightMax') as HTMLInputElement).value = schema.textures.height.maximum.toString(); ($('formTextureHeightMin') as HTMLInputElement).value = schema.textures.height.minimum.toString(); // Texture Width ($('formTextureWidthMax') as HTMLInputElement).value = schema.textures.width.maximum.toString(); ($('formTextureWidthMin') as HTMLInputElement).value = schema.textures.width.minimum.toString(); // PBR Range ($('formPbrColorRangeMax') as HTMLInputElement).value = schema.textures.pbrColorRange.maximum.toString(); ($('formPbrColorRangeMin') as HTMLInputElement).value = schema.textures.pbrColorRange.minimum.toString(); // Power of 2 Textures ($('formTexturesPowerOfTwoYes') as HTMLInputElement).checked = schema.textures.requireDimensionsBePowersOfTwo; ($('formTexturesPowerOfTwoNo') as HTMLInputElement).checked = !schema.textures.requireDimensionsBePowersOfTwo; // Quadratic Textures ($('formTexturesQuadraticYes') as HTMLInputElement).checked = schema.textures.requireDimensionsBeQuadratic; ($('formTexturesQuadraticNo') as HTMLInputElement).checked = !schema.textures.requireDimensionsBeQuadratic; // UV Spacing (gutter width) ($('formUvGutterWidth256') as HTMLInputElement).value = schema.uvs.gutterWidth.resolution256.toString(); ($('formUvGutterWidth512') as HTMLInputElement).value = schema.uvs.gutterWidth.resolution512.toString(); ($('formUvGutterWidth1024') as HTMLInputElement).value = schema.uvs.gutterWidth.resolution1024.toString(); ($('formUvGutterWidth2048') as HTMLInputElement).value = schema.uvs.gutterWidth.resolution2048.toString(); ($('formUvGutterWidth4096') as HTMLInputElement).value = schema.uvs.gutterWidth.resolution4096.toString(); // Texel Density ($('formPixelsPerMeterMax') as HTMLInputElement).value = schema.uvs.pixelsPerMeter.maximum.toString(); ($('formPixelsPerMeterMin') as HTMLInputElement).value = schema.uvs.pixelsPerMeter.minimum.toString(); // Inverted UVs ($('formUvsNotInvertedYes') as HTMLInputElement).checked = schema.uvs.requireNotInverted; ($('formUvsNotInvertedNo') as HTMLInputElement).checked = !schema.uvs.requireNotInverted; // Overlapping UVs ($('formUvsNotOverlappingYes') as HTMLInputElement).checked = schema.uvs.requireNotOverlapping; ($('formUvsNotOverlappingNo') as HTMLInputElement).checked = !schema.uvs.requireNotOverlapping; // UVs in 0-1 Range ($('formUvsInRange01Yes') as HTMLInputElement).checked = schema.uvs.requireRangeZeroToOne; ($('formUvsInRange01No') as HTMLInputElement).checked = !schema.uvs.requireRangeZeroToOne; } // Main function document.body.onload = () => { // Global auditor object var auditor = new Auditor(); // Setup Button Actions $('editSchemaButton').onclick = () => { toggleEditSchema(auditor); }; $('closeSchemaButton').onclick = () => { toggleEditSchema(auditor); }; $('saveAndCloseSchemaButton').onclick = () => { saveAndCloseSchema(auditor); }; $('schemaDefaults').onclick = () => { setSchemaWithRecommended(auditor); }; $('downloadSchemaJson').onclick = downloadSchemaJson; // Load default schema setSchemaWithRecommended(auditor); // Show the version $('version').appendChild(document.createTextNode('Version: ' + auditor.version)); setSchemaFormFromAuditor(auditor); // Set up drag and drop area highlighting ['modelDropArea', 'productInfoDropArea', 'schemaDropArea'].forEach(elementName => { ['dragenter', 'dragover'].forEach(eventName => { $(elementName).addEventListener(eventName, ev => { preventDefaults(ev); addHighlightClass($(elementName)); }); }); $(elementName).addEventListener('dragleave', ev => { preventDefaults(ev); removeHighlightClass($(elementName)); }); }); // Set up drag and drop model load $('modelDropArea').addEventListener('drop', ev => { preventDefaults(ev); removeHighlightClass($('modelDropArea')); ($('modelInput') as HTMLInputElement).files = ev.dataTransfer.files; loadModel(auditor); }); // Set up drag and drop product info load $('productInfoDropArea').addEventListener('drop', ev => { preventDefaults(ev); removeHighlightClass($('productInfoDropArea')); ($('productInfoInput') as HTMLInputElement).files = ev.dataTransfer.files; loadProductInfo(auditor); }); // Set up drag and drop schema load $('schemaDropArea').addEventListener('drop', ev => { preventDefaults(ev); removeHighlightClass($('schemaDropArea')); ($('schemaInput') as HTMLInputElement).files = ev.dataTransfer.files; loadSchema(auditor); }); // Add listeners to traditional file input elements $('modelInput').addEventListener('change', ev => { loadModel(auditor); }); $('productInfoInput').addEventListener('change', ev => { loadProductInfo(auditor); }); $('schemaInput').addEventListener('change', ev => { loadSchema(auditor); }); };