mdx-m3-viewer
Version:
A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.
624 lines (472 loc) • 15.6 kB
JavaScript
ModelViewer = ModelViewer.default;
let glMatrix = ModelViewer.common.glMatrix;
let vec3 = glMatrix.vec3;
let quat = glMatrix.quat;
let geometry = ModelViewer.common.geometry;
let handlers = ModelViewer.viewer.handlers;
let parsers = ModelViewer.parsers;
let mdlx = parsers.mdlx;
let blp = parsers.blp;
let w3x = parsers.w3x;
let testsElement = document.getElementById('tests');
let statusElement = document.getElementById('status');
statusElement.textContent = 'Drop any combination of models (.mdl, .mdx), textures (.blp), or maps (.w3m, .w3x) to test them.'
console.log('Viewer version', ModelViewer.version);
let canvas = document.getElementById('canvas');
let viewer = new ModelViewer.viewer.ModelViewer(canvas);
viewer.gl.clearColor(0.7, 0.7, 0.7, 1);
viewer.addHandler(handlers.mdx); // Will add BLP too.
viewer.addHandler(handlers.geo);
viewer.on('error', (target, error, reason) => {
let parts = [error];
if (reason) {
parts.push(reason);
}
if (target.fetchUrl) {
parts.push(target.fetchUrl);
}
console.error('ERROR', parts.join(' '));
});
let scene = viewer.addScene();
setupCamera(scene, 500);
(function step() {
requestAnimationFrame(step);
viewer.updateAndRender();
}());
document.getElementById('animation_toggle').addEventListener('click', () => {
if (viewer.frameTime === 0) {
viewer.frameTime = 1000 / 60;
} else {
viewer.frameTime = 0;
}
});
let textureModel = viewer.load({
geometry: geometry.createUnitRectangle(),
material: {renderMode: 0, twoSided: true, isBGR: false}
}, src => [src, '.geo', false]);
function nodeName(node) {
let name = [node.objectType];
if (typeof node.index === 'number') {
name.push(node.index);
}
if (typeof node.name === 'string') {
name.push(`'${node.name}'`);
}
return name.join(' ');
}
function nodeTooltip(node) {
let message = node.message;
if (message.startsWith('No opening') || message.startsWith('No closing')) {
return 'Having no tracks at the beginning or ending of an animation can sometimes cause weird animations.\nThis can usually be ignored.'
} else if (message.startsWith('Number of sequence extents')) {
return 'Having more extents than sequences will cause Magos to crash.\nI am not sure if any program is affected by having too few.\nThe game does not care either way.';
} else if (message.endsWith('is not in any sequence')) {
return 'This track seems to be useless.';
} else if (message.includes('empty animation file')) {
return 'The animation file path is probably a leftover from the beta.\nWE crashes when it is not empty.';
}
}
function addTooltip(element, node) {
let tooltip = nodeTooltip(node);
if (tooltip) {
element.className += ' tooltip';
element.title = tooltip;
}
}
function handleTestNode(stream, node) {
if (node.warnings || node.errors || node.uses === 0) {
let name = nodeName(node);
if (node.errors) {
stream.error(name);
} else if (node.warnings) {
stream.warn(name);
} else if (node.uses === 0) {
stream.unused(name);
}
stream.br();
stream.indent();
if (node.uses === 0) {
stream.unused('Not used');
stream.br();
}
if (node.children) {
for (let child of node.children) {
handleTestNode(stream, child);
}
}
stream.unindent();
} else if (node.type === 'warning') {
let element = stream.warn(node.message);
addTooltip(element, node);
stream.br();
} else if (node.type === 'error') {
let element = stream.error(node.message);
addTooltip(element, node);
stream.br();
}
}
/**
*
*/
class TestInstance {
/**
* @param {string} name
* @param {Model|Texture} resource
* @param {ModelInstance} instance
* @param {mdlx.Model|blp.Texture} parser
*/
constructor(name, resource, instance, parser) {
this.name = name.toLowerCase();
this.resource = resource;
this.instance = instance;
this.parser = parser;
this.rendered = false;
this.container = null;
this.header = null;
this.body = null;
this.sourceMapContainer = null;
if (instance.model.extension === '.mdx') {
this.type = 'model';
this.renderModelTest(name, parser);
} else {
this.type = 'texture';
this.renderTextureTest(name, parser);
}
this.hide();
}
show() {
this.rendered = true;
this.header.classList.remove('closed');
this.header.classList.add('opened');
if (this.body) {
this.body.style.display = 'initial';
}
this.instance.show();
}
hide() {
this.rendered = false;
this.header.classList.remove('opened');
this.header.classList.add('closed');
if (this.body) {
this.body.style.display = 'none';
}
this.instance.hide();
}
getModel() {
return this.instance.model;
}
getTexture() {
return this.instance.modelView.textures.get(null);
}
renderModelTest(name, model) {
let stream = new LogStream(document.createElement('div'));
let result = ModelViewer.utils.mdxSanityTest(model);
let header = stream.start();
let body = null;
stream.info(`${name}: `);
if (result.errors || result.warnings || result.unused) {
let added = false;
if (result.errors) {
stream.error(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
added = true;
}
if (result.warnings) {
if (added) {
stream.info(', ');
}
stream.warn(`${result.warnings} warning${result.warnings === 1 ? '' : 's'}`);
added = true;
}
if (result.unused) {
if (added) {
stream.info(', ');
}
stream.unused(`${result.unused} unused`);
}
stream.commit();
body = stream.start();
stream.indent();
for (let node of result.nodes) {
handleTestNode(stream, node);
}
stream.commit();
} else {
stream.log('Passed');
stream.commit();
}
this.container = stream.container;
this.header = header;
this.body = body;
this.sourceMapContainer = handleSourceMap(model.saveMdl());
}
renderTextureTest(name, texture) {
let stream = new LogStream(document.createElement('div'));
let results = ModelViewer.utils.blpSanityTest(texture);
let header = stream.start();
let body = null;
stream.info(`${name}: `);
if (results.length) {
stream.warn(`${results.length} warning${results.length === 1 ? '' : 's'}`)
stream.commit();
body = stream.start();
stream.indent();
for (let result of results) {
stream.warn(result);
stream.br();
}
stream.commit();
} else {
stream.log('Passed');
stream.commit();
}
this.container = stream.container;
this.header = header;
this.body = body;
}
}
let allTests = [];
let visibleTest = null;
function showTest(test) {
if (visibleTest) {
visibleTest.hide();
}
visibleTest = test;
visibleTest.show();
if (viewedElement) {
viewedElement.className = 'sourceMapName';
}
// Clear the source map and view.
sourceMapElement.innerHTML = '';
sourceViewElement.innerHTML = '';
// Add or replace the source map.
if (visibleTest.sourceMapContainer) {
sourceMapElement.appendChild(visibleTest.sourceMapContainer);
}
}
function addTest(name, resource, instance, parser) {
let test = new TestInstance(name, resource, instance, parser);
test.container.style.paddingBottom = '3px';
testsElement.appendChild(test.container);
test.header.addEventListener('click', (e) => {
if (!test.rendered) {
test.header.classList.remove('closed');
test.header.classList.add('opened');
if (test.body) {
test.body.style.display = 'initial';
}
showTest(test);
}
});
allTests.push(test);
showTest(test);
statusElement.textContent = '';
return test;
}
function addModelTest(name, ext, buffer, pathSolver) {
let parser = new mdlx.Model(buffer);
let viewerModel = viewer.load(parser, (src) => {
if (src === parser) {
return [src, ext, false]
} else if (pathSolver) {
// If an external path solver is given, this is a Hive resource, and it will handle custom textures.
return pathSolver(src);
} else {
return [localOrHive(src), src.substr(src.lastIndexOf('.')), true];
}
});
viewerModel.whenLoaded().then(() => {
let instance = viewerModel.addInstance();
instance.setScene(scene);
instance.setSequence(0);
instance.setSequenceLoopMode(2);
instance.on('seqend', () => {
let sequence = instance.sequence + 1;
if (sequence === viewerModel.sequences.length) {
sequence = 0;
}
instance.setSequence(sequence);
});
let test = addTest(name, viewerModel, instance, parser);
tryToInjectCustomTextures(test);
});
}
function getPathFileName(path) {
const url = path.replace(/\\/g, '/');
return url.slice(url.lastIndexOf('/') + 1).toLowerCase();
}
function areSameFiles(a, b) {
return getPathFileName(a) === getPathFileName(b);
}
function* eachModelTest() {
for (let test of allTests) {
if (test.type === 'model') {
yield test;
}
}
}
function* eachTextureTest() {
for (let test of allTests) {
if (test.type === 'texture') {
yield test;
}
}
}
// Given a new custom model, go over all of the textures, and see if any match any of the model's textures.
// Matches are replaced with the matched textures.
function tryToInjectCustomTextures(modelTest) {
let model = modelTest.getModel();
let parser = modelTest.parser;
for (let textureTest of eachTextureTest()) {
for (let i = 0, l = model.textures.length; i < l; i++) {
const texture = model.textures[i];
texture.whenLoaded().then(() => {
if (!texture.ok && areSameFiles(parser.textures[i].path, textureTest.name)) {
model.textures[i] = textureTest.getTexture();
console.log(`NOTE: loaded ${textureTest.name} as a custom texture for model: ${modelTest.name}`);
}
});
}
}
}
// Given a new texture test, go over all of the models, and see if it can match one of their textures.
// Matches are replaced with this texture.
function tryToLoadCustomTexture(textureTest) {
for (let modelTest of eachModelTest()) {
const model = modelTest.getModel();
for (let i = 0, l = model.textures.length; i < l; i++) {
const modelTexture = model.textures[i];
// If the texture failed to load, check if it matches the name.
if (!modelTexture.ok && areSameFiles(modelTexture.fetchUrl, textureTest.name)) {
model.textures[i] = textureTest.getTexture();
console.log(`NOTE: loaded ${textureTest.name} as a custom texture for model: ${modelTest.name}`);
}
}
}
}
function addTextureTest(name, ext, buffer) {
let parser = new blp.Texture(buffer);
let viewerTexture = viewer.load(parser, (src) => {
return [src, ext, false]
});
let instance = textureModel.addInstance().uniformScale(128).rotate(quat.setAxisAngle([], [0, 0, 1], Math.PI / 2));
instance.setTexture(null, viewerTexture);
instance.setScene(scene);
viewerTexture.whenLoaded()
.then(() => {
// Don't really care about the size of the texture instance, just the proportions.
instance.scale([viewerTexture.width / viewerTexture.height, 1, 1]);
let test = addTest(name, viewerTexture, instance, parser);
// Try to load this texture as a custom texture, in case a model that uses it was loaded before.
tryToLoadCustomTexture(test);
});
}
function addMapTest(buffer) {
let map = new w3x.Map();
map.load(buffer);
for (let name of map.getImportNames()) {
let file = map.get(name);
let ext = name.substr(name.lastIndexOf('.')).toLowerCase();
if (ext === '.mdx') {
addModelTest(name, ext, file.arrayBuffer());
} else if (ext === '.mdl') {
addModelTest(name, ext, file.text());
} else if (ext === '.blp') {
addTextureTest(name, ext, file.arrayBuffer());
}
}
}
function onLocalFileLoaded(name, ext, buffer, pathSolver) {
if (ext === '.mdx' || ext === '.mdl') {
addModelTest(name, ext, buffer, pathSolver);
} else if (ext === '.blp') {
addTextureTest(name, ext, buffer);
} else if (ext === '.w3x' || ext === '.w3m') {
addMapTest(buffer);
}
}
function handleDrop(dataTransfer) {
for (let file of dataTransfer.files) {
let name = file.name;
let ext = name.substr(name.lastIndexOf('.')).toLowerCase();
if (ext === '.mdx' || ext === '.mdl' || ext === '.blp' || ext === '.w3x' || ext === '.w3m') {
let reader = new FileReader();
reader.addEventListener('loadend', (e) => onLocalFileLoaded(name, ext, e.target.result));
if (ext === '.mdl') {
reader.readAsText(file);
} else {
reader.readAsArrayBuffer(file);
}
}
}
}
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
document.addEventListener('dragend', (e) => {
e.preventDefault();
});
document.addEventListener('drop', (e) => {
e.preventDefault();
handleDrop(e.dataTransfer);
});
function getUrlParams() {
let params = [];
let search = window.location.search;
if (search !== '') {
for (let param of search.slice(1).split('&')) {
let [key, value] = param.split('=');
params.push({key, value});
}
}
return params;
}
function getExt(query) {
let match = query.match(/hiveworkshop\.com\/attachments\/.*-(.*)\.\d+/);
if (match) {
return `.${match[1]}`
} else {
let ext = query.slice(-4).toLowerCase();
if (ext === '.mdx' || ext === '.mdl' || ext === '.blp' || ext === '.w3x' || ext === '.w3m') {
return ext
}
}
return null;
}
async function loadQuery() {
let params = getUrlParams();
let files = [];
let overrides = new Map();
for (let param of params) {
let {key, value} = param;
let entry = {path: value, ext: getExt(value)};
if (key === 'file') {
files.push(entry);
} else if (key.startsWith('override')) {
// Discord changes \ to / in urls, ignoring escaping, so escape manually.
overrides.set(key.slice(9, -1).replace(/\//g, '\\'), entry);
}
}
let pathSolver = (src) => {
let override = overrides.get(src);
if (override) {
return [override.path, override.ext, true];
} else {
return [localOrHive(src), src.substr(src.lastIndexOf('.')), true];
}
};
for (let file of files) {
let path = file.path;
let ext = file.ext;
if (ext === '.mdx' || ext === '.mdl' || ext === '.blp' || ext === '.w3x' || ext === '.w3m') {
let response = await fetch(path);
let data;
if (ext === '.mdl') {
data = await response.text();
} else {
data = await response.arrayBuffer();
}
onLocalFileLoaded(path, ext, data, pathSolver);
}
}
}
loadQuery();