Skip to content

Commit

Permalink
Merge pull request #7 from lennart-g/feat/improve_stability
Browse files Browse the repository at this point in the history
Feat/improve stability
  • Loading branch information
lennart-g authored Nov 13, 2023
2 parents 4705481 + 3f1e0ac commit f41dbbd
Show file tree
Hide file tree
Showing 13 changed files with 201 additions and 58 deletions.
40 changes: 29 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,47 @@
# Quake 2 MD2 import add-on for Blender 2.8x

# Quake 2 MD2 import add-on for Blender 3.x
This add-on allows importing .md2 model files. This hasn't been
natively supported by [Blender](https://www.blender.org/) for
a decade now. Please note that this add-on is still work
in progress.
a decade now.

Please note that this add-on is still incomplete. **Bug reports and feature
requests are welcome!**

Developed and tested with Blender 3.6.5 and Python 3.10.13.
Older versions might still (partially) work.

## What can this importer (!) do so far?

- load an MD2 object to Blender
- attach a UV map linked in the .md2 file or a custom one
- supported formats: .png, .jpg, .tga, .pcx
- Blender native formats (not verified except .jpg):
- custom format: .pcx
- load and run keyframe animations

### Blender native image formats in Blender 3.6.5 / Python 3.10.13:
```python3
>>> bpy.path.extensions_image
frozenset({'.tx', '.png', '.cin', '.tiff', '.bmp', '.rgb', '.tga', '.sgi', '.jpeg', '.dpx', '.psd', '.jp2', '.j2c', '.hdr', '.webp', '.rgba', '.exr', '.pdd', '.jpg', '.psb', '.dds', '.tif'})
```
## What is missing?

- proper error handling (some .md2 files store broken skin pathes or ones to files that don't exist)
- aligning the animation keyframes to the fps used for the different animations
- .pcx is no longer natively supported by Blender so a different package is used
that loads all skins as grayscale

## [Releases](https://github.com/lennart-g/blender-md2-importer/releases)

## Installation
Install the provided .zip via the Edit > Preferences > Add-ons menu.

### Optional: PCX skin files
When loading a .pcx file, a message referring to this README is shown.

1. [install Pillow (used for loading .pcx files)](https://blender.stackexchange.com/a/122337).
Replace `pip install scipy` with `pip install pillow`.
On old Blender / Python versions, an upgrade of pip might be necessary.
2. Check the plugin activation checkbox again.

1. download the script and follow [this guide](https://github.com/rlguy/Blender-FLIP-Fluids/wiki/Addon-Installation-and-Uninstallation). When trying to activate the plugin, blender will show an error stating that the module PIL is missing. Proceed with Step 2.
2. [install Pillow (used for loading .pcx files)](https://blender.stackexchange.com/a/122337). Replace `pip install scipy` with `pip install pillow`.
3. Check the plugin activation checkbox again.
## Development
Follow [these instructions](https://github.com/lennart-g/bsp_hacking/blob/master/docs/blender_importer.md)
with adjustments for this repository.

## How to use

Expand Down
23 changes: 23 additions & 0 deletions build_md2_importer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os.path
import shutil

# files to include in the output zip file
files = [
"util/MD2.py",
"md2_importer/__init__.py",
"md2_importer/blender_load_md2.py",
"util/prepare_skin_paths.py"
]

# intermediary location for the directory to be zipped
dest = "build/io_import_md2"

if os.path.exists("build") and os.path.isdir("build"):
shutil.rmtree("build")
os.makedirs(dest)

for file in files:
shutil.copyfile(file, os.path.join(dest, os.path.basename(file)))

# create zip file
shutil.make_archive("blender-md2-importer", 'zip', "build")
26 changes: 18 additions & 8 deletions io_import_md2/__init__.py → md2_importer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,31 @@
"name": "Experimental MD2 Importer",
"author": "Lennart G",
"location": "File > Import > Quake 2 (.md2)",
"version": (0, 2, 0),
"version": (0, 3, 0),
"blender": (2, 80, 0),
"category": "Import-Export"
}

# To support reload properly, try to access a package var,
# if it's there, reload everything
if "bpy" in locals():
import imp
imp.reload(MD2)
imp.reload(blender_load_md2)
print("Reloaded multifiles")
import imp
try:
imp.reload(MD2)
except NameError:
from util import MD2
imp.reload(MD2)

try:
imp.reload(prepare_skin_paths)
except NameError:
from util import prepare_skin_paths
imp.reload(prepare_skin_paths)
imp.reload(blender_load_md2)
print("Reloaded multifiles")
else:
from . import blender_load_md2
print("Imported multifiles")
from . import blender_load_md2
print("Imported multifiles")

"""
This part is required for the UI, to make the Addon appear under File > Import once it's
Expand All @@ -29,7 +39,7 @@
# invoke() function which calls the file selector.
import bpy
from bpy_extras.io_utils import ImportHelper
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.props import StringProperty, BoolProperty
from bpy.types import Operator


Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import bpy
import sys
from importlib import reload # required when a self-written module is imported that's edited simultaneously
from PIL import Image, ImageFile
from . import MD2
try:
from . import MD2
except ImportError:
import util.MD2
try:
from .prepare_skin_paths import * #test
except ModuleNotFoundError:
from util.prepare_skin_paths import *
import os # for checking if skin pathes exist


# from https://blender.stackexchange.com/a/110112
def ShowMessageBox(message = "", title = "Message Box", icon = 'INFO'):

def draw(self, context):
self.layout.label(text=message)

bpy.context.window_manager.popup_menu(draw, title = title, icon = icon)


def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_skin_path):
"""
This function uses the information from a md2 dataclass into a blender object.
Expand All @@ -20,6 +33,9 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
- Assign skin to mesh
"""
""" Create MD2 dataclass object """
print("md2_path, displayed_name, use_custom_md2_skin, custom_md2_skin_path")
print(md2_path, displayed_name, use_custom_md2_skin, custom_md2_skin_path)
print(locals())
# ImageFile.LOAD_TRUNCATED_IMAGES = True # Necessary for loading jpgs with PIL

object_path = md2_path # Kept for testing purposes
Expand All @@ -44,24 +60,10 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
skin_path = custom_abs_path
else:
print("stored path:", my_object.skin_names) # unchanged path or pathes stored in the MD2
# strings are always stored as 64 bytes, so unused bytes are set to '\x00'
first_stored_path = my_object.skin_names[0].rstrip("\x00")
# only first stored path is used since Digital Paintball 2 only uses that one
first_stored_path = first_stored_path.split("/")[-1]
print(first_stored_path)
# absolute path is formed by using the given md2 object path
absolute_first_stored_path = "/".join(md2_path.split("/")[:-1]) + "/" + first_stored_path
print(absolute_first_stored_path)
skin_path = absolute_first_stored_path

""" Look for existing file of given name and supported image format """
supported_image_formats = [".png", ".jpg", ".jpeg", ".tga", ".pcx"] # Order doesn't match DP2 image order
skin_path_unextended = os.path.splitext(skin_path)[0] # remove extension (last one)
print(skin_path_unextended)
for format in supported_image_formats:
if os.path.isfile(skin_path_unextended + format):
skin_path = skin_path_unextended + format
break

skin_path = get_path_from_skin_name(object_path, my_object.skin_names[0])

skin_path = get_existing_skin_path(skin_path)
print("used skin path", skin_path)

""" Loads required information for mesh generation and UV mapping from the .md2 file"""
Expand Down Expand Up @@ -93,6 +95,30 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
# Creates mesh by taking first frame's vertices and connects them via indices in tris
mesh.from_pydata(all_verts[0], [], tris)


""" Create animation for animated models: set keyframe for each vertex in each frame individually """
# Create keyframes from first to last frame
for i in range(my_object.header.num_frames):
for idx, v in enumerate(obj.data.vertices):
obj.data.vertices[idx].co = all_verts[i][idx]
v.keyframe_insert('co', frame=i * 10) # parameter index=2 restricts keyframe to dimension

# insert first keyframe after last one to yield cyclic animation
for idx, v in enumerate(obj.data.vertices):
obj.data.vertices[idx].co = all_verts[0][idx]
v.keyframe_insert('co', frame=60)

if not skin_path:
ShowMessageBox("Defaulting to not assigning any material", "No skin found", "INFO")
return {'FINISHED'} # no idea, seems to be necessary for the UI

if skin_path.endswith('.pcx'):
try:
from PIL import Image
except ModuleNotFoundError:
ShowMessageBox("To load .pcx skin files, see the add-on README for manual PIL installation", "Module PIL not found", "INFO")
return {'FINISHED'} # no idea, seems to be necessary for the UI

""" UV Mapping: Create UV Layer, assign UV coordinates from md2 files for each face to each face's vertices """
uv_layer = (mesh.uv_layers.new())
mesh.uv_layers.active = uv_layer
Expand All @@ -107,18 +133,6 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
else:
uv_layer.data[loop_idx].uv = uvs_others[my_object.triangles[face_idx].textureIndices[idx]]

""" Create animation for animated models: set keyframe for each vertex in each frame individually """
# Create keyframes from first to last frame
for i in range(my_object.header.num_frames):
for idx, v in enumerate(obj.data.vertices):
obj.data.vertices[idx].co = all_verts[i][idx]
v.keyframe_insert('co', frame=i * 10) # parameter index=2 restricts keyframe to dimension

# insert first keyframe after last one to yield cyclic animation
for idx, v in enumerate(obj.data.vertices):
obj.data.vertices[idx].co = all_verts[0][idx]
v.keyframe_insert('co', frame=60)

""" Assign skin to mesh: Create material (barely understood copy and paste again) and set the image.
Might work by manually setting the textures pixels to the pixels of a PIL.Image if it would actually
load non-empty .pcx files
Expand All @@ -132,6 +146,7 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
# if only a pcx version of the desired skin exists, load it via PIL
# and copy pixels into the materials texture
# otherwise use blender internal image loader (supporting .png, .jpg and .tga)
print(f'skin_path: {skin_path}')
if skin_path.endswith(".pcx"):
skin = Image.open(skin_path)
skin.load()
Expand All @@ -140,10 +155,13 @@ def blender_load_md2(md2_path, displayed_name, use_custom_md2_skin, custom_md2_s
print("important", skin_rgba[:40])
print("path:", skin_path)
texImage.image = bpy.data.images.new("MyImage", width=skin.size[0], height=skin.size[1])
texImage.image.pixels = [y for x in skin_rgba for y in x]
tmp = [y for x in skin_rgba for y in x]
max_val = max(tmp)
texImage.image.pixels = [x / max_val for x in tmp]
else:
texImage.image = bpy.data.images.load(skin_path)
# again copy and paste

# again copy and paste
mat.node_tree.links.new(bsdf.inputs['Base Color'], texImage.outputs['Color'])

# Assign it to object
Expand Down
Binary file added tests/data/bigleaf2.md2
Binary file not shown.
Binary file added tests/data/car.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/data/car.md2
Binary file not shown.
Binary file added tests/data/leaf02.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions tests/test_md2_noerror.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os
from util import MD2
import pytest


def test_models():
data_dir = "tests/data"
models = os.listdir(data_dir)
models = [x for x in models if x.lower().endswith(".md2")]

for model in models:
MD2.load_file(os.path.join(data_dir, model))
assert True


def test_wrong_format():
with pytest.raises(ValueError):
MD2.load_file('tests/data/car.jpg')

22 changes: 22 additions & 0 deletions tests/test_prepare_skin_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from util.prepare_skin_paths import *


def test_get_path_from_skin_name():
obj_path = "blender-md2-importer/tests/data/car.md2"
skin_name = "'models/sk89q/w_sitters/car.jpg\x00ght.jpg\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"

skin_path = get_path_from_skin_name(obj_path, skin_name)
print(skin_path)
assert skin_path == "blender-md2-importer/tests/data/car.jpg\x00ght.jpg"


def test_get_existing_skin_path():
args = {
'skin_path': 'tests/data/car.jpg\x00ght.jpg'}
out = get_existing_skin_path(**args)
assert out is None

args = {
'skin_path': 'tests/data/car.bmp'}
out = get_existing_skin_path(**args)
assert out == "tests/data/car.jpg"
8 changes: 5 additions & 3 deletions io_import_md2/MD2.py → util/MD2.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,11 @@ def load_header(file_bytes):
header = md2_t(*arguments)
# Verify MD2
if not header.ident == 844121161 or not header.version == 8:
print(f"Error: File type is not MD2. Ident or version not matching")
print(f'Ident: {file_bytes[:4].decode("ascii", "ignore")} should be "IDP2"')
print(f"Version: {header.version} should be 8")
raise ValueError(
f"Error: File type is not MD2. Ident or version not matching. "
f'Ident: {file_bytes[:4].decode("ascii", "ignore")} should be "IDP2". '
f"Version: {header.version} should be 8"
)
return header


Expand Down
Empty file added util/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions util/prepare_skin_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os


def get_path_from_skin_name(md2_path: str, skin_name: str):
# strings are always stored as 64 bytes, so unused bytes are set to '\x00'
first_stored_path = skin_name.rstrip("\x00")
# only first stored path is used since Digital Paintball 2 only uses that one
first_stored_path = first_stored_path.split("/")[-1]
print(f'first_stored_path: {first_stored_path}')
# absolute path is formed by using the given md2 object path
absolute_first_stored_path = "/".join(md2_path.split("/")[:-1]) + "/" + first_stored_path
print(f'absolute_first_stored_path: {absolute_first_stored_path}')
skin_path = absolute_first_stored_path

return skin_path


def get_existing_skin_path(skin_path: str):
"""
Replaces the skin path extension with the one of an existing file of the same name.
"""
""" Look for existing file of given name and supported image format """
supported_image_formats = [".png", ".jpg", ".jpeg", ".tga", ".pcx"] # Order doesn't match DP2 image order
skin_path_unextended = os.path.splitext(skin_path)[0] # remove extension (last one)
print(f'skin_path_unextended: {skin_path_unextended}')
for format in supported_image_formats:
full_path = skin_path_unextended + format
print(f'full_path: {full_path}')
if os.path.isfile(full_path):
skin_path = skin_path_unextended + format
return skin_path

0 comments on commit f41dbbd

Please sign in to comment.