improve export_nif script and process textures, make query hash case-insentisive

master
eater 5 years ago
parent c47d712624
commit 76f50d2f5a
Signed by: eater
GPG Key ID: AD2560A0F84F0759

4
.gitignore vendored

@ -1,5 +1,5 @@
dist
node_modules
public/blob/textures/*
!public/blob/textures/.gitkeep
public/blob/texture/*
!public/blob/texture/.gitkeep
public/blob/meshes.json

@ -7,17 +7,15 @@ A project that takes morrowind's player heads and shows it on a webpage - [see i
## Requirements
- pyFFI
- PIL / Pillow
- yarn / npm
# Setup
```bash
cd [project]
# Copy textures
cp [morrowind]/Data\ Files/Textures/TX_B_N_* ./public/blob/textures
# Generate JSON meshes
./script/export_nif.py [morrowind]/Data\ Files/Meshes/b > ./public/blob/meshes.json
./script/export_nif.py [morrowind]/Data\ Files/Meshes/b [morrowind]/Data\ Files/Textures public/blob
# Pack JS
yarn
yarn webpack

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<title>Morrowind's Heads</title>
<style>
body {
background-color: #ffffff;
@ -85,6 +86,6 @@
</div>
</div>
<script src="js/dist/bundle.js"></script>
<script src="js/dist/bundle.js?v=5"></script>
</body>
</html>

@ -1,12 +1,78 @@
#!/usr/bin/env python3
import os
import sys
from os import DirEntry
from typing import List, Tuple, Optional, Dict
from pyffi.formats.nif import NifFormat
from PIL import Image
import json
def export(path):
class Texture:
file: str
wrap_s: bool
wrap_t: bool
uv_set: int
def __init__(self, file: str, wrap_s: bool = True, wrap_t: bool = True, uv_set: int = 0):
self.file = file
self.wrap_s = wrap_s
self.wrap_t = wrap_t
self.uv_set = uv_set
def __dict__(self):
return {
'file': self.file,
'wrapS': self.wrap_s,
'wrapT': self.wrap_t,
'uvSet': self.uv_set,
}
class Shape:
name: str
vertices: List[Tuple[float, float, float]]
normals: List[Tuple[float, float, float]]
faces: List[Tuple[float, float, float]]
uv_sets: List[List[float]]
translation: Tuple[float, float, float]
rotation: List[Tuple[float, float, float]]
texture: Optional[Texture]
def __init__(self, name: str = ""):
self.name = name
self.texture = None
def __dict__(self):
return {
'name': self.name,
'vertices': self.vertices,
'normals': self.normals,
'faces': self.faces,
'uvSets': self.uv_sets,
'translation': self.translation,
'rotation': self.rotation,
'texture': self.texture.__dict__(),
}
class ShapeCollection:
name: str
shapes: List[Shape]
def __init__(self, name: str = ""):
self.name = name
self.shapes = []
def __dict__(self):
return {
'name': self.name,
'shapes': [s.__dict__() for s in self.shapes],
}
def export(path) -> List[ShapeCollection]:
data = NifFormat.Data()
with open(path, 'rb') as f:
data.inspect(f)
@ -27,67 +93,135 @@ def export(path):
# Some NiTriShape's are a root object
if isinstance(root, NifFormat.NiTriShape):
objects.append({'name': root.name.decode('utf-8'), 'shapes': [export_shape(root)]})
coll = ShapeCollection(name)
coll.shapes = [export_shape(root)]
objects.append(coll)
return objects
def export_object(root: NifFormat.NiNode):
obj = {'name': root.name.decode("utf-8"), 'shapes': []}
def export_object(root: NifFormat.NiNode) -> List[ShapeCollection]:
obj = ShapeCollection(root.name.decode('utf-8'))
for shape in root.get_children():
for shape in root.tree():
if not isinstance(shape, NifFormat.NiTriShape):
continue
obj['shapes'].append(export_shape(shape))
obj.shapes.append(export_shape(shape))
return obj
def export_shape(shape: NifFormat.NiTriShape):
def export_shape(shape: NifFormat.NiTriShape) -> Shape:
vertices = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.vertices)]
faces = shape.data.get_triangles()
uv_sets = [[[coord.u, coord.v] for coord in uv_set] for uv_set in shape.data.uv_sets]
uv_sets = [[(coord.u, coord.v) for coord in uv_set] for uv_set in shape.data.uv_sets]
normals = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.normals)]
texture = None
res_shape = Shape()
for property in shape.get_properties():
if isinstance(property, NifFormat.NiTexturingProperty):
texture = export_texture(property.base_texture)
res_shape.texture = export_texture(property.base_texture)
res_shape.name = shape.name.decode('utf-8')
res_shape.vertices = vertices
res_shape.faces = faces
res_shape.uv_sets = uv_sets
res_shape.normals = normals
res_shape.translation = (shape.translation.x, shape.translation.y, shape.translation.z)
res_shape.rotation = shape.rotation.as_list()
return {
'name': shape.name.decode("utf-8"),
'vertices': vertices,
'faces': faces,
'uvSets': uv_sets,
'normals': normals,
'texture': texture,
'translation': (shape.translation.x, shape.translation.y, shape.translation.z),
'rotation': shape.rotation.as_list(),
}
return res_shape
def export_texture(texture):
def export_texture(texture) -> Texture:
wrap_s = texture.clamp_mode in [2, 3]
wrap_t = texture.clamp_mode in [1, 3]
source = texture.source
return {
'wrapS': wrap_s,
'wrapT': wrap_t,
'uvSet': texture.uv_set,
'file': source.file_name.decode("utf-8")
}
return Texture(source.file_name.decode('utf-8'), wrap_s, wrap_t, texture.uv_set)
if __name__ == '__main__':
# Dumb file map allowing case-insensitive matching
# And Morrowind's fucky file resolving
class TextureMap:
path: str
files: Dict[str, DirEntry]
dds_used: int
original_dds: int
original_used: int
def __init__(self, path):
self.path = path
self.files = {}
self.dds_used = 0
self.original_dds = 0
self.original_used = 0
def build(self):
for item in os.scandir(self.path):
self.files[item.name.lower()] = item
def get(self, name):
lower_name = name.lower()
dds_instead = lower_name.rsplit('.', maxsplit=1)[0] + '.dds'
if dds_instead in self.files:
if dds_instead == lower_name:
self.original_dds += 1
else:
self.dds_used += 1
return self.files[dds_instead].path
self.original_used += 1
return self.files[lower_name].path
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <dir>")
print(f"Usage: {sys.argv[0]} <nif dir> <texture dir> <output dir>")
exit(1)
objects = []
for item in os.scandir(sys.argv[1]):
if item.is_file():
objects.extend(export(item.path))
nif_dir = sys.argv[1]
texture_dir = sys.argv[2]
texture_dir_map = TextureMap(texture_dir)
texture_dir_map.build()
output_dir = sys.argv[3]
output_texture_dir = os.path.join(output_dir, 'texture')
os.makedirs(output_texture_dir, exist_ok=True)
print(json.dumps(objects))
output_mesh_file = os.path.join(output_dir, 'meshes.json')
shape_colls = []
for item in os.scandir(nif_dir):
if not item.is_file():
continue
file_objects = export(item.path)
for file_object in file_objects:
file_object.name = file_object.name.lower()
for shape in file_object.shapes:
if shape.texture is not None:
new_texture_name = shape.texture.file.rsplit('.', maxsplit=1)[0] + '.png'
new_texture_name = new_texture_name.lower()
new_file_name = os.path.join(output_texture_dir, new_texture_name)
texture_image = Image.open(texture_dir_map.get(shape.texture.file))
texture_image.save(new_file_name, 'png')
shape.texture.file = new_texture_name
shape_colls.append(file_object.__dict__())
with open(output_mesh_file, 'w') as j:
json.dump(shape_colls, j)
print(f"Textures: {texture_dir_map.original_dds} Orig. DDS, {texture_dir_map.dds_used} New DDS, {texture_dir_map.original_used} Orig. Non-DDS")
if __name__ == '__main__':
main()

@ -19,32 +19,42 @@ const nextHair = document.querySelector('#next_hair');
const prevHead = document.querySelector('#prev_head');
const nextHead = document.querySelector('#next_head');
const RACES = {
'darkelf': 'Dark Elf',
'khajiit': 'Khajiit',
'woodelf': 'Wood Elf',
'highelf': 'High Elf',
'redguard': 'Redguard',
'breton': 'Breton',
'argonian': 'Argonian',
'orc': 'Orc',
'imperial': 'Imperial',
'nord': 'Nord',
};
window.addEventListener("load", async () => {
const allMeshes = await (await fetch("blob/meshes.json")).json();
const allMeshes = await (await fetch("blob/meshes.json?v=2")).json();
part1.innerHTML = "";
part2.innerHTML = "";
const indexed = window.indexed = {};
const byName = window.byName = {};
const tree = {};
const shapeCache = {};
const tree = window.tree = {};
headRenderer.init();
headRenderer.expose();
for (let mesh of allMeshes) {
let [_, __, race, gender, part, nr = undefined] = mesh.name.split('_');
race = race.replace(new RegExp("\\s+"), ' ');
if (race === 'DarkElf') {
race = 'Dark Elf'
}
let [_, __, race = "object", gender = "X", part = "head", nr = undefined] = mesh.name.split('_');
race = race.replace(new RegExp("\\s+"), '');
race = RACES[race] || 'Object';
if (nr === undefined && !['head', 'hair'].includes(part.toLowerCase())) {
[_, part, nr] = part.match(/(head|hair)(\d+)?/i) || [];
[_, part = "head", nr] = part.match(/(head|hair)(\d+)?/i) || [];
}
gender = gender.toUpperCase();
let option = document.createElement("option");
option.textContent = mesh.name;
option.value = mesh.id = uuidv4();
@ -119,7 +129,7 @@ window.addEventListener("load", async () => {
function collectHashQuery() {
return location.hash.substr(1).split('&').reduce((c, x) => {
let [name, value = ""] = x.split('=', 2).map(y => decodeURIComponent(y));
c[name] = value;
c[name] = value.toLowerCase();
return c;
}, {});
}
@ -153,7 +163,7 @@ window.addEventListener("load", async () => {
});
function getShape(name) {
return new ShapeCollection(indexed[name], "blob/textures/");
return new ShapeCollection(indexed[name], "blob/texture/");
}
function updateHash() {
@ -161,6 +171,8 @@ window.addEventListener("load", async () => {
}
function setHair(id) {
if (!id) return;
part1.value = id;
if (shape1 !== null) {
headRenderer.remove(shape1);
@ -184,6 +196,8 @@ window.addEventListener("load", async () => {
});
function setHead(id) {
if (!id) return;
part2.value = id;
if (shape2 !== null) {
headRenderer.remove(shape2);

Loading…
Cancel
Save