improve export_nif script and process textures, make query hash case-insentisive
This commit is contained in:
parent
c47d712624
commit
76f50d2f5a
6 changed files with 203 additions and 56 deletions
4
.gitignore
vendored
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)
|
||||
|
||||
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(),
|
||||
}
|
||||
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 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)
|
||||
|
||||
|
||||
# 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]} <nif dir> <texture dir> <output dir>")
|
||||
exit(1)
|
||||
|
||||
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)
|
||||
|
||||
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__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <dir>")
|
||||
exit(1)
|
||||
|
||||
objects = []
|
||||
for item in os.scandir(sys.argv[1]):
|
||||
if item.is_file():
|
||||
objects.extend(export(item.path))
|
||||
|
||||
print(json.dumps(objects))
|
||||
main()
|
||||
|
|
38
src/index.js
38
src/index.js
|
@ -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…
Reference in a new issue