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
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
public/blob/textures/*
|
public/blob/texture/*
|
||||||
!public/blob/textures/.gitkeep
|
!public/blob/texture/.gitkeep
|
||||||
public/blob/meshes.json
|
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
|
## Requirements
|
||||||
|
|
||||||
- pyFFI
|
- pyFFI
|
||||||
|
- PIL / Pillow
|
||||||
- yarn / npm
|
- yarn / npm
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd [project]
|
cd [project]
|
||||||
|
|
||||||
# Copy textures
|
|
||||||
cp [morrowind]/Data\ Files/Textures/TX_B_N_* ./public/blob/textures
|
|
||||||
# Generate JSON meshes
|
# 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
|
# Pack JS
|
||||||
yarn
|
yarn
|
||||||
yarn webpack
|
yarn webpack
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>Morrowind's Heads</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
@ -85,6 +86,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/dist/bundle.js"></script>
|
<script src="js/dist/bundle.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,12 +1,78 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from os import DirEntry
|
||||||
|
from typing import List, Tuple, Optional, Dict
|
||||||
|
|
||||||
from pyffi.formats.nif import NifFormat
|
from pyffi.formats.nif import NifFormat
|
||||||
|
from PIL import Image
|
||||||
import json
|
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()
|
data = NifFormat.Data()
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
data.inspect(f)
|
data.inspect(f)
|
||||||
|
@ -27,67 +93,135 @@ def export(path):
|
||||||
|
|
||||||
# Some NiTriShape's are a root object
|
# Some NiTriShape's are a root object
|
||||||
if isinstance(root, NifFormat.NiTriShape):
|
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
|
return objects
|
||||||
|
|
||||||
|
|
||||||
def export_object(root: NifFormat.NiNode):
|
def export_object(root: NifFormat.NiNode) -> List[ShapeCollection]:
|
||||||
obj = {'name': root.name.decode("utf-8"), 'shapes': []}
|
obj = ShapeCollection(root.name.decode('utf-8'))
|
||||||
|
|
||||||
for shape in root.get_children():
|
for shape in root.tree():
|
||||||
if not isinstance(shape, NifFormat.NiTriShape):
|
if not isinstance(shape, NifFormat.NiTriShape):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
obj['shapes'].append(export_shape(shape))
|
obj.shapes.append(export_shape(shape))
|
||||||
|
|
||||||
return obj
|
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)]
|
vertices = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.vertices)]
|
||||||
faces = shape.data.get_triangles()
|
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)]
|
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():
|
for property in shape.get_properties():
|
||||||
if isinstance(property, NifFormat.NiTexturingProperty):
|
if isinstance(property, NifFormat.NiTexturingProperty):
|
||||||
texture = export_texture(property.base_texture)
|
res_shape.texture = export_texture(property.base_texture)
|
||||||
|
|
||||||
return {
|
res_shape.name = shape.name.decode('utf-8')
|
||||||
'name': shape.name.decode("utf-8"),
|
res_shape.vertices = vertices
|
||||||
'vertices': vertices,
|
res_shape.faces = faces
|
||||||
'faces': faces,
|
res_shape.uv_sets = uv_sets
|
||||||
'uvSets': uv_sets,
|
res_shape.normals = normals
|
||||||
'normals': normals,
|
res_shape.translation = (shape.translation.x, shape.translation.y, shape.translation.z)
|
||||||
'texture': texture,
|
res_shape.rotation = shape.rotation.as_list()
|
||||||
'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_s = texture.clamp_mode in [2, 3]
|
||||||
wrap_t = texture.clamp_mode in [1, 3]
|
wrap_t = texture.clamp_mode in [1, 3]
|
||||||
source = texture.source
|
source = texture.source
|
||||||
|
|
||||||
return {
|
return Texture(source.file_name.decode('utf-8'), wrap_s, wrap_t, texture.uv_set)
|
||||||
'wrapS': wrap_s,
|
|
||||||
'wrapT': wrap_t,
|
|
||||||
'uvSet': texture.uv_set,
|
# Dumb file map allowing case-insensitive matching
|
||||||
'file': source.file_name.decode("utf-8")
|
# 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 __name__ == '__main__':
|
||||||
if len(sys.argv) < 2:
|
main()
|
||||||
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))
|
|
||||||
|
|
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 prevHead = document.querySelector('#prev_head');
|
||||||
const nextHead = document.querySelector('#next_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 () => {
|
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 = "";
|
part1.innerHTML = "";
|
||||||
part2.innerHTML = "";
|
part2.innerHTML = "";
|
||||||
const indexed = window.indexed = {};
|
const indexed = window.indexed = {};
|
||||||
const byName = window.byName = {};
|
const byName = window.byName = {};
|
||||||
const tree = {};
|
const tree = window.tree = {};
|
||||||
const shapeCache = {};
|
|
||||||
|
|
||||||
headRenderer.init();
|
headRenderer.init();
|
||||||
headRenderer.expose();
|
headRenderer.expose();
|
||||||
|
|
||||||
for (let mesh of allMeshes) {
|
for (let mesh of allMeshes) {
|
||||||
let [_, __, race, gender, part, nr = undefined] = mesh.name.split('_');
|
let [_, __, race = "object", gender = "X", part = "head", nr = undefined] = mesh.name.split('_');
|
||||||
race = race.replace(new RegExp("\\s+"), ' ');
|
race = race.replace(new RegExp("\\s+"), '');
|
||||||
|
race = RACES[race] || 'Object';
|
||||||
if (race === 'DarkElf') {
|
|
||||||
race = 'Dark Elf'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nr === undefined && !['head', 'hair'].includes(part.toLowerCase())) {
|
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");
|
let option = document.createElement("option");
|
||||||
option.textContent = mesh.name;
|
option.textContent = mesh.name;
|
||||||
option.value = mesh.id = uuidv4();
|
option.value = mesh.id = uuidv4();
|
||||||
|
@ -119,7 +129,7 @@ window.addEventListener("load", async () => {
|
||||||
function collectHashQuery() {
|
function collectHashQuery() {
|
||||||
return location.hash.substr(1).split('&').reduce((c, x) => {
|
return location.hash.substr(1).split('&').reduce((c, x) => {
|
||||||
let [name, value = ""] = x.split('=', 2).map(y => decodeURIComponent(y));
|
let [name, value = ""] = x.split('=', 2).map(y => decodeURIComponent(y));
|
||||||
c[name] = value;
|
c[name] = value.toLowerCase();
|
||||||
return c;
|
return c;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
@ -153,7 +163,7 @@ window.addEventListener("load", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function getShape(name) {
|
function getShape(name) {
|
||||||
return new ShapeCollection(indexed[name], "blob/textures/");
|
return new ShapeCollection(indexed[name], "blob/texture/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHash() {
|
function updateHash() {
|
||||||
|
@ -161,6 +171,8 @@ window.addEventListener("load", async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHair(id) {
|
function setHair(id) {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
part1.value = id;
|
part1.value = id;
|
||||||
if (shape1 !== null) {
|
if (shape1 !== null) {
|
||||||
headRenderer.remove(shape1);
|
headRenderer.remove(shape1);
|
||||||
|
@ -184,6 +196,8 @@ window.addEventListener("load", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function setHead(id) {
|
function setHead(id) {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
part2.value = id;
|
part2.value = id;
|
||||||
if (shape2 !== null) {
|
if (shape2 !== null) {
|
||||||
headRenderer.remove(shape2);
|
headRenderer.remove(shape2);
|
||||||
|
|
Loading…
Reference in a new issue