diff --git a/.gitignore b/.gitignore index a992f1d..7e2f179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ assets -js/main.js diff --git a/Makefile b/Makefile index 16da0b5..685324b 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,9 @@ all: js -OUTPUT=js/main.js +OUTPUT=js/obja.js js: src/* rm -f ${OUTPUT} cat src/Loader.js >> ${OUTPUT} && echo >> ${OUTPUT} cat src/Model.js >> ${OUTPUT} && echo >> ${OUTPUT} - cat src/main.js >> ${OUTPUT} && echo >> ${OUTPUT} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a37827 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# OBJ augmenté + +WaveFront OBJ est un format permettant d'encoder des modèles 3D de manière +simple. Cependant, il n'est pas adapté aux représentations progressives. Pour +cela, nous avons augmenté OBJ de nouvelles commandes qui permettent de modifier +le contenu préalablement déclaré. + +## Commandes +###### Ajout d'un sommet + +Comme dans le OBJ standard, pour ajouter un sommet, il suffit d'utiliser le +caractère `v` suivi des coordonnées du sommet. Par exemple : + +``` +v 1.0 2.0 3.0 +``` + +###### Ajout d'une face + +Comme dans le OBJ standard, pour ajouter une face, il suffit d'utiliser le +caractère `f` suivi des indices des sommets de la face. Par exemple : + +``` +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +f 1 2 3 +``` + +**Attention :** en OBJ, les indices commencent à partir de 1 + +**Attention :** dans notre logiciel, seule les faces triangulaires sont supportées. + +###### Edition d'un sommet + +Notre format OBJ permet la modification d'un ancien sommet. Pour modifier un +sommet, il suffit d'utiliser les caractères `ev` suivis de l'indice du sommet à +modifier puis de ses nouvelles coordonées. Par exemple : + +``` +v 0.0 0.0 0.0 +ev 1 1.0 1.0 1.0 +``` + +###### Edition d'une face + +Notre format OBJ permet la modification d'une ancienne face. Pour modifier une +face, il suffit d'utiliser les caractères `ef` suivis de l'indice de la face à +modifier puis des indices de ses nouveaux sommets. Par exemple : + +``` +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +v 1.0 1.0 1.0 +f 1 2 3 +ef 1 1 2 4 +``` + +###### Suppression d'une face +Notre format OBJ permet la suppression d'une ancienne face. Pour supprimer une +face, il suffit d'utiliser les caracètres `df` suivis de l'indice de la face à +supprimer. Par exemple : + +``` +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +v 1.0 1.0 1.0 +f 1 2 3 +df 1 +``` + +**Attention :** les indices des faces ne sont pas changés après la suppression +d'une ancienne face. + +###### Triangle strips et triangle fans +Pour la compression de contenu 3D, on utilise souvent des [Triangle +Strips](https://en.wikipedia.org/wiki/Triangle_strip) et des [Triangle +Fans](https://en.wikipedia.org/wiki/Triangle_fan). + +Notre format OBJ augmenté permet la déclaration de strips et de fans en +utilisant respectivement les caractères `ts` et `tf` suivis des indices des +sommets. Par exemple : + +``` +v -1.0 0.0 0.0 +v -0.5 1.0 0.0 +v 0.0 0.0 0.0 +v 0.5 1.0 0.0 +v 1.0 0.0 0.0 +ts 1 2 3 4 5 +``` + +ou bien + +``` +v 0.0 0.0 0.0 +v -1.0 0.0 0.0 +v -0.707 0.707 0.0 +v 0.0 1.0 0.0 +v 0.707 0.707 0.0 +v 1.0 0.0 0.0 +tf 1 2 3 4 5 6 +``` + +## Utilisation + +Vous pouvez récupérer les sources de cette application en lançant la commande +``` +git clone https://gitea.tforgione.fr/tforgione/obja +``` + +À la racine de ce projet, le script `server.py` vous permet de démarrer un +server de streaming. Vous pouvez l'exécuter en lançant `./server.py`. Une fois +cela fait, vous pouvez allez sur [localhost:8000](http://localhost:8000) pour +lancer le streaming. Le navigateur télécharge progressivement les données et +les affiche. + +Les modèles doivent être sauvegardés dans le dossiers `assets`, et peuvent être +visualisés en ajouter `?nom_du_modele.obj` à la fin de l'url. Par exemple, +[localhost:8000/?bunny.obj](http://localhost:8000/?bunny.obj) chargera le +modèle `bunny.obj` du dossier `assets`. Ce modèle est un modèle d'exemple, il +commence par encoder la version basse résolution du [Stanford +bunny](https://graphics.stanford.edu/data/3Dscanrep/), translate tous ses +sommets, les retranslate vers leurs positions d'origines puis supprime toutes +les faces. diff --git a/index.html b/index.html index 76709a2..62de7df 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,7 @@ + diff --git a/src/main.js b/js/main.js similarity index 95% rename from src/main.js rename to js/main.js index e3b60ed..78b1fcb 100644 --- a/src/main.js +++ b/js/main.js @@ -5,7 +5,7 @@ animate(); function init() { - let url = 'assets/' + (document.URL.split('?')[1] || "bunny_remove.obj"); + let url = 'assets/' + (document.URL.split('?')[1] || "bunny.obj"); loader = new Loader(url, 1024, 20); loader.start(function(elements) { diff --git a/js/obja.js b/js/obja.js new file mode 100644 index 0000000..fab109d --- /dev/null +++ b/js/obja.js @@ -0,0 +1,313 @@ +function fetchDataLength(path, callback) { + let xhr = new XMLHttpRequest(); + + xhr.open('HEAD', path, true); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + callback(xhr.getResponseHeader('Content-Length')); + } + } + }; + xhr.send(); +} + +function fetchData(path, start, end, callback) { + let xhr = new XMLHttpRequest(); + + xhr.open('GET', path, true); + xhr.setRequestHeader('Range', 'bytes=' + start + "-" + (end - 1)); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status === 206) { + callback(xhr.responseText); + } + } + }; + xhr.send(); +} + +function parseLine(line) { + let element = {}; + let split = line.split(/[ \t]+/); + + if (split.length === 0) { + return; + } + + switch (split[0]) { + case "v": + element.type = Element.AddVertex; + element.value = new THREE.Vector3( + parseFloat(split[1]), + parseFloat(split[2]), + parseFloat(split[3]), + ); + return element; + + case "f": + element.type = Element.AddFace; + element.value = new THREE.Face3( + parseInt(split[1], 10) - 1, + parseInt(split[2], 10) - 1, + parseInt(split[3], 10) - 1, + ); + return element; + + case "ts": + element.type = Element.AddTriangleStrip; + element.value = []; + for (let i = 1; i < split.length - 2; i++) { + element.value.push(new THREE.Face3( + parseInt(split[i] , 10) - 1, + i % 2 === 1 ? parseInt(split[i+1], 10) - 1 : parseInt(split[i+2], 10) - 1, + i % 2 === 1 ? parseInt(split[i+2], 10) - 1 : parseInt(split[i+1], 10) - 1, + )); + } + return element; + + case "tf": + element.type = Element.AddTriangleFan; + element.value = []; + for (let i = 1; i < split.length - 2; i++) { + element.value.push(new THREE.Face3( + parseInt(split[1] , 10) - 1, + parseInt(split[i+1], 10) - 1, + parseInt(split[i+2], 10) - 1, + )); + } + return element; + + case "ev": + element.type = Element.EditVertex; + element.id = parseInt(split[1], 10) - 1; + element.value = new THREE.Vector3( + parseFloat(split[2]), + parseFloat(split[3]), + parseFloat(split[4]), + ); + return element; + + case "ef": + element.type = Element.EditFace; + element.id = parseInt(split[1], 10) - 1; + element.value = new THREE.Face3( + parseInt(split[2], 10) - 1, + parseInt(split[3], 10) - 1, + parseInt(split[4], 10) - 1, + ); + return element; + + case "df": + element.type = Element.DeleteFace; + element.id = parseInt(split[1], 10) - 1; + return element; + + case "": + case "#": + return; + + default: + throw new Error(split[0] + " is not a defined macro"); + } + +} + +const Element = {}; +Element.AddVertex = "AddVertex"; +Element.AddFace = "AddFace"; +Element.AddTriangleStrip = "AddTriangleStrip"; +Element.AddTriangleFan = "AddTriangleFan"; +Element.EditVertex = "EditVertex"; +Element.EditFace = "EditFace"; +Element.DeleteFace = "DeleteFace"; + +class Loader { + constructor(path, chunkSize = 1024, timeout = 20) { + this.path = path; + this.chunkSize = chunkSize; + this.timeout = timeout; + this.currentByte = 0; + this.remainder = ""; + } + + start(callback) { + fetchDataLength(this.path, (length) => { + this.dataLength = length; + this.next(callback); + }); + } + + percentage() { + return 100 * this.currentByte / this.dataLength; + } + + next(callback) { + this.downloadAndParseNextChunk((data) => { + callback(data); + setTimeout(() => { + this.next(callback); + }, this.timeout); + }); + } + + downloadAndParseNextChunk(callback) { + + let upperBound = Math.min(this.currentByte + this.chunkSize, this.dataLength); + + if (upperBound <= this.currentByte) { + return; + } + + fetchData(this.path, this.currentByte, upperBound, (data) => { + + this.currentByte = upperBound; + + let elements = []; + let split = data.split('\n'); + split[0] = this.remainder + split[0]; + this.remainder = split.pop(); + + for (let line of split) { + elements.push(parseLine(line)); + } + + callback(elements); + + }); + } +} + +class Model extends THREE.Mesh { + constructor(path) { + let geometry = new THREE.Geometry(); + let materials = [ + new THREE.MeshLambertMaterial( { color: 0xffffff, side: THREE.DoubleSide } ), + new THREE.MeshBasicMaterial( { transparent: true, opacity: 0 } ) + ]; + super(geometry, materials); + this.frustumCulled = false; + this.path = path; + this.vertices = []; + this.currentLine = 1; + } + + throwError(message) { + let e = new Error("In " + this.path + ":L" + this.currentLine + " " + message); + e.type = "custom"; + throw e; + } + + checkVertex(id) { + if (this.geometry.vertices[id] === undefined) { + this.throwError("EditVertex requires vertex " + (id + 1) + " but there is no such vertex"); + } + } + + checkFaceId(id) { + if (this.geometry.faces[id] === undefined) { + this.throwError("EditFace requires face " + (id + 1) + " but there is no such face"); + } + } + + checkFace(f) { + let vertices = this.geometry.vertices; + + if (vertices[f.a] === undefined) { + this.throwError("Face requires vertex " + (f.a + 1) + " but there is no such vertex"); + } + + if (vertices[f.b] === undefined) { + this.throwError("Face requires vertex " + (f.b + 1) + " but there is no such vertex"); + } + + if (vertices[f.c] === undefined) { + this.throwError("Face requires vertex " + (f.c + 1) + " but there is no such vertex"); + } + + } + + manageElement(element) { + + let vertices = this.geometry.vertices; + let f, normal; + + switch (element.type) { + case Element.AddVertex: + this.geometry.vertices.push(element.value); + this.geometry.verticesNeedUpdate = true; + break; + + case Element.EditVertex: + this.checkVertex(element.id); + this.geometry.vertices[element.id].copy(element.value); + this.geometry.verticesNeedUpdate = true; + break; + + case Element.AddFace: + + f = element.value; + this.checkFace(f); + normal = + vertices[f.b].clone().sub(vertices[f.a]) + .cross(vertices[f.c].clone().sub(vertices[f.a])); + normal.normalize(); + + f.normal = normal; + f.materialIndex = 0; + this.geometry.faces.push(f); + this.geometry.elementsNeedUpdate = true; + break; + + case Element.AddTriangleStrip: + case Element.AddTriangleFan: + + for (let f of element.value) { + + this.checkFace(f); + let normal = + vertices[f.b].clone().sub(vertices[f.a]) + .cross(vertices[f.c].clone().sub(vertices[f.a])); + normal.normalize(); + + f.normal = normal; + f.materialIndex = 0; + this.geometry.faces.push(f); + + } + + this.geometry.elementsNeedUpdate = true; + + break; + + case Element.EditFace: + + f = element.value; + this.checkFaceId(element.id); + this.checkFace(f); + normal = + vertices[f.b].clone().sub(vertices[f.a]) + .cross(vertices[f.c].clone().sub(vertices[f.a])); + normal.normalize(); + + f.normal = normal; + + + this.geometry.faces[element.id] = f; + this.geometry.elementsNeedUpdate = true; + break; + + case Element.DeleteFace: + this.geometry.faces[element.id].materialIndex = 1; + this.geometry.elementsNeedUpdate = true; + break; + + + default: + throw new Error("unknown element type: " + element.type); + } + + this.currentLine++; + } +} +