diff --git a/shared/obj_reader.py b/shared/obj_reader.py new file mode 100644 index 00000000..4f6da646 --- /dev/null +++ b/shared/obj_reader.py @@ -0,0 +1,57 @@ +""" +Simple Wavefront .obj reader for loading Sollumz builtin models. Only supports +vertices (`v`) and triangular faces (`f` with three vertex indices). +""" + +import bpy +import io +import numpy as np +from numpy.typing import NDArray +from typing import NamedTuple +from pathlib import Path + + +class ObjMesh(NamedTuple): + vertices: NDArray[np.float32] + indices: NDArray[np.uint16] + + def as_vertices_only(self) -> NDArray[np.float32]: + return self.vertices[self.indices.flatten()] + + def as_bpy_mesh(self, name: str) -> bpy.types.Mesh: + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(self.vertices, [], self.indices) + return mesh + + +def obj_read(obj_io: io.TextIOBase) -> ObjMesh: + vertices = [] + indices = [] + for line in obj_io.readlines(): + line = line.strip() + c = line[0] if len(line) > 0 else None + match c: + case "v": + x, y, z = line.strip("v ").split(" ") + vertices.extend((float(x), float(y), float(z))) + case "f": + v0, v1, v2 = line.strip("f ").split(" ") + indices.extend((int(v0) - 1, int(v1) - 1, int(v2) - 1)) + case _: + # ignore unknown/unsupported elements + pass + + return ObjMesh( + vertices=np.reshape(np.array(vertices, dtype=np.float32), (-1, 3)), + indices=np.reshape(np.array(indices, dtype=np.uint16), (-1, 3)) + ) + + +def obj_read_from_file(file_path: Path) -> ObjMesh: + with file_path.open("r") as f: + return obj_read(f) + + +def obj_read_from_str(obj_str: str) -> ObjMesh: + with io.StringIO(obj_str) as s: + return obj_read(s) diff --git a/tests/test_obj_reader.py b/tests/test_obj_reader.py new file mode 100644 index 00000000..440546a7 --- /dev/null +++ b/tests/test_obj_reader.py @@ -0,0 +1,109 @@ +import bpy +import pytest +import numpy as np +from numpy.testing import assert_array_equal +from pathlib import Path +from ..shared.obj_reader import obj_read_from_str, obj_read_from_file, ObjMesh + + +def test_obj_read(): + obj = obj_read_from_str(""" + v 1.2 3.4 5.6 + v 2.0 4.0 6.0 + v 10.0 5.0 10.0 + v 1.0 2.0 3.0 + f 1 2 3 + f 4 3 1 + """) + + assert obj is not None + + assert_array_equal(obj.vertices, np.array([ + [1.2, 3.4, 5.6], + [2.0, 4.0, 6.0], + [10.0, 5.0, 10.0], + [1.0, 2.0, 3.0], + ], dtype=np.float32)) + + assert_array_equal(obj.indices, np.array([ + [0, 1, 2], + [3, 2, 0], + ], dtype=np.uint16)) + + +def test_obj_read_ignores_comments_and_unknown_elements(): + obj = obj_read_from_str(""" + # comment + v 1.0 2.0 3.0 + # other comment + u unknown + f 1 2 3 + """) + + assert obj is not None + + assert_array_equal(obj.vertices, np.array([ + [1.0, 2.0, 3.0], + ], dtype=np.float32)) + + assert_array_equal(obj.indices, np.array([ + [0, 1, 2], + ], dtype=np.uint16)) + + +def test_obj_as_vertices_only(): + obj_mesh = ObjMesh( + vertices=np.array([ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ], dtype=np.float32), + indices=np.array([ + [0, 1, 2], + [2, 1, 0], + ], dtype=np.uint16) + ) + + obj_mesh_vertices = obj_mesh.as_vertices_only() + assert_array_equal(obj_mesh_vertices, np.array([ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + [7.0, 8.0, 9.0], + [4.0, 5.0, 6.0], + [1.0, 2.0, 3.0], + ])) + + +@pytest.mark.parametrize("obj_relative_file_path", ( + "../tools/car_model.obj", + "../ytyp/gizmos/models/AudioEmitter.obj", + "../ytyp/gizmos/models/AudioCollisionSettings.obj", + "../ytyp/gizmos/models/Buoyancy.obj", + "../ytyp/gizmos/models/Door.obj", + "../ytyp/gizmos/models/Expression.obj", + "../ytyp/gizmos/models/Ladder.obj", + "../ytyp/gizmos/models/LadderTop.obj", + "../ytyp/gizmos/models/LadderBottom.obj", + "../ytyp/gizmos/models/LightShaft.obj", + "../ytyp/gizmos/models/ParticleEffect.obj", + "../ytyp/gizmos/models/ProcObject.obj", + "../ytyp/gizmos/models/SpawnPoint.obj", + "../ytyp/gizmos/models/SpawnPointOverride.obj", + "../ytyp/gizmos/models/WindDisturbance.obj", +)) +def test_obj_read_sollumz_builtin_asset(obj_relative_file_path: str): + obj_path = Path(__file__).parent.joinpath(obj_relative_file_path) + obj = obj_read_from_file(obj_path) + + assert obj is not None + assert len(obj.vertices.flatten()) > 0 + assert len(obj.indices.flatten()) > 0 + + obj_mesh = obj.as_bpy_mesh(obj_path.stem) + assert obj_mesh is not None + + invalid_geom = obj_mesh.validate(verbose=True) + assert not invalid_geom + + bpy.data.meshes.remove(obj_mesh) diff --git a/ymap/car_model.obj b/tools/car_model.obj similarity index 100% rename from ymap/car_model.obj rename to tools/car_model.obj diff --git a/tools/ymaphelper.py b/tools/ymaphelper.py index 188b67d3..0f3c1cd2 100644 --- a/tools/ymaphelper.py +++ b/tools/ymaphelper.py @@ -1,7 +1,8 @@ import bpy - +from pathlib import Path from ..sollumz_properties import SOLLUMZ_UI_NAMES, SollumType from ..tools.blenderhelper import find_bsdf_and_material_output +from ..shared.obj_reader import obj_read_from_file # TODO: This is not a real flag calculation, definitely need to do better @@ -70,3 +71,16 @@ def add_occluder_material(sollum_type=None): bsdf.inputs["Metallic"].default_value = 0 return material + + +CARGEN_MESH_NAME = ".sollumz.cargen_mesh" + + +def get_cargen_mesh() -> bpy.types.Mesh: + mesh = bpy.data.meshes.get(CARGEN_MESH_NAME, None) + if mesh is None: + file_path = Path(__file__).parent.joinpath("car_model.obj") + cargen_obj_mesh = obj_read_from_file(file_path) + mesh = cargen_obj_mesh.as_bpy_mesh(CARGEN_MESH_NAME) + + return mesh diff --git a/ymap/operators.py b/ymap/operators.py index 5ca8e504..70a4249f 100644 --- a/ymap/operators.py +++ b/ymap/operators.py @@ -1,10 +1,7 @@ import bpy -import os - -from mathutils import Vector from ..sollumz_helper import SOLLUMZ_OT_base, set_object_collection -from ..tools.ymaphelper import add_occluder_material, create_ymap, create_ymap_group -from ..sollumz_properties import SOLLUMZ_UI_NAMES, SollumType +from ..tools.ymaphelper import add_occluder_material, create_ymap, create_ymap_group, get_cargen_mesh +from ..sollumz_properties import SollumType class SOLLUMZ_OT_create_ymap(SOLLUMZ_OT_base, bpy.types.Operator): @@ -162,10 +159,8 @@ class SOLLUMZ_OT_create_car_generator(SOLLUMZ_OT_base, bpy.types.Operator): def run(self, context): group_obj = context.active_object - file_loc = os.path.join(os.path.dirname(__file__), "car_model.obj") - bpy.ops.import_scene.obj(filepath=file_loc) - cargen_obj = bpy.context.selected_objects[0] - cargen_obj.name = "Car Generator" + cargen_ref_mesh = get_cargen_mesh() + cargen_obj = bpy.data.objects.new("Car Generator", object_data=cargen_ref_mesh) cargen_obj.sollum_type = SollumType.YMAP_CAR_GENERATOR cargen_obj.ymap_cargen_properties.orient_x = 0.0 cargen_obj.ymap_cargen_properties.orient_y = 0.0 @@ -178,7 +173,8 @@ def run(self, context): cargen_obj.ymap_cargen_properties.body_color_remap_4 = -1 cargen_obj.ymap_cargen_properties.pop_group = "" cargen_obj.ymap_cargen_properties.livery = -1 + bpy.context.collection.objects.link(cargen_obj) bpy.context.view_layer.objects.active = cargen_obj cargen_obj.parent = group_obj - return True \ No newline at end of file + return True diff --git a/ymap/ymapimport.py b/ymap/ymapimport.py index 91869cbe..cb09e84d 100644 --- a/ymap/ymapimport.py +++ b/ymap/ymapimport.py @@ -1,12 +1,10 @@ import binascii import struct import math -import os import bpy - from mathutils import Vector, Euler from ..sollumz_helper import duplicate_object_with_children, set_object_collection -from ..tools.ymaphelper import add_occluder_material +from ..tools.ymaphelper import add_occluder_material, get_cargen_mesh from ..sollumz_properties import SollumType from ..sollumz_preferences import get_import_settings from ..cwxml.ymap import CMapData, OccludeModel, YMAP @@ -210,11 +208,10 @@ def cargen_to_obj(obj: bpy.types.Object, ymap: CMapData): bpy.context.collection.objects.link(group_obj) bpy.context.view_layer.objects.active = group_obj - cargen_ref_mesh = import_cargen_mesh() + cargen_ref_mesh = get_cargen_mesh() for cargen in ymap.car_generators: - cargen_obj = bpy.data.objects.new( - "Car Generator", object_data=cargen_ref_mesh) + cargen_obj = bpy.data.objects.new("Car Generator", object_data=cargen_ref_mesh) cargen_obj.ymap_cargen_properties.orient_x = cargen.orient_x cargen_obj.ymap_cargen_properties.orient_y = cargen.orient_y cargen_obj.ymap_cargen_properties.perpendicular_length = cargen.perpendicular_length @@ -235,17 +232,6 @@ def cargen_to_obj(obj: bpy.types.Object, ymap: CMapData): cargen_obj.parent = group_obj -def import_cargen_mesh() -> bpy.types.Mesh: - file_loc = os.path.join(os.path.dirname(__file__), "car_model.obj") - bpy.ops.import_scene.obj(filepath=file_loc) - cargen_ref_obj = bpy.context.selected_objects[0] - mesh = cargen_ref_obj.data - - bpy.data.objects.remove(cargen_ref_obj) - - return mesh - - def ymap_to_obj(ymap: CMapData): ymap_obj = bpy.data.objects.new(ymap.name, None) ymap_obj.sollum_type = SollumType.YMAP diff --git a/ytyp/gizmos/extensions.py b/ytyp/gizmos/extensions.py index 2f8cb623..95b4ee46 100644 --- a/ytyp/gizmos/extensions.py +++ b/ytyp/gizmos/extensions.py @@ -1,13 +1,14 @@ import bpy import math -import os import functools +from pathlib import Path from mathutils import Matrix, Vector, Quaternion from typing import Literal from collections.abc import Iterator from ..utils import get_selected_archetype, get_selected_extension, get_selected_ytyp from ...tools.blenderhelper import tag_redraw from ...sollumz_properties import ArchetypeType +from ...shared.obj_reader import obj_read_from_file from ..properties.ytyp import ArchetypeProperties from ..properties.extensions import ExtensionType, ExtensionProperties @@ -46,21 +47,9 @@ def get_extension_shapes() -> dict[ExtensionType, object]: shapes = {} def _load_extension_model(extension_type: ExtensionType, file_name: str): - file_loc = os.path.join(os.path.dirname(__file__), "models", file_name) - with open(file_loc, "r") as f: - verts = [] - model_verts = [] - for line in f.readlines(): - if line.startswith("v"): - x, y, z = line.strip("v ").split(" ") - verts.append((float(x), float(y), float(z))) - elif line.startswith("f"): - v0, v1, v2 = line.strip("f ").split(" ") - model_verts.append(verts[int(v0) - 1]) - model_verts.append(verts[int(v1) - 1]) - model_verts.append(verts[int(v2) - 1]) - - shapes[extension_type] = bpy.types.Gizmo.new_custom_shape("TRIS", model_verts) + file_path = Path(__file__).parent.joinpath("models", file_name) + obj_mesh = obj_read_from_file(file_path) + shapes[extension_type] = bpy.types.Gizmo.new_custom_shape("TRIS", obj_mesh.as_vertices_only()) for ext_type, model_file_name in ( (ExtensionType.AUDIO_COLLISION, "AudioCollisionSettings.obj"),