Skip to content

Commit

Permalink
v1.1 - added trigger brushes
Browse files Browse the repository at this point in the history
  • Loading branch information
snake-biscuits committed Sep 30, 2021
1 parent cff4c76 commit 02f7a81
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 32 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# Changelog

# v1.0.0_b2.93 (~2021)
## v1.1.0_b2.93 (1st October 2021)

### Added
* Geometry for `trigger_*` brush entities is now generated on import

## v1.0.0_b2.93 (28th September 2021)
Initial Release
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,12 @@ Once you've extracted the files you need:
## FAQs
* Why can't I see anything?
- Titanfall Engine maps are huge, you need to increase your view distance
- `3D View > N > View > Clip Start: 16, End: 51200`
- `3D View > N > View > Clip Start: 16, End: 102400` (only affects that 3D view)
- You will also need to increase the clipping distance for all cameras
* Why is my `.blend` file still huge after I deleted everything?
- Blender keeps deleted items cached in case you want to undo
- To clear this cache use: `File > Clean Up > Recursive Unused Data Blocks`
- Or set the **Outliner** display mode to **Orphan Data** & click **Purge**
* It broke? Help?
- Ask around on Discord, you might've missed a step someplace
- If you're loading a brand new Apex map, it might not be supported yet
Expand All @@ -86,9 +90,13 @@ Once you've extracted the files you need:

### Further Questions
I can be found on the following Titanfall related Discord Servers as `b!scuit#3659`:
* Titanfall 1: [TF Remnant Fleet](https://discord.gg/hKpQeJqdZR)
* Titanfall 2: [NoSkill Community](https://discord.gg/sEgmTKg)
* Apex Legends: [R5Reloaded](https://discord.com/invite/jqMkUdXrBr)
* Titanfall 1:
- [TF Remnant Fleet](https://discord.gg/hKpQeJqdZR)
* Titanfall 2:
- [NoSkill Community](https://discord.gg/sEgmTKg)
- Titanfall 2 Speedrunning Discord
* Apex Legends:
- [R5Reloaded](https://discord.com/invite/jqMkUdXrBr)
<!-- TODO: add Titanfall Online & Titanfall 2 Custom Servers when they go public -->

> NOTE: I am a fully time Uni Student in an Australian Timezone
Expand Down
17 changes: 15 additions & 2 deletions io_import_rbsp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import bpy
from bpy_extras.io_utils import ImportHelper
from bpy.props import StringProperty, BoolProperty
from bpy.props import StringProperty, BoolProperty, EnumProperty
from bpy.types import Operator

from . import bsp_tool
Expand Down Expand Up @@ -28,6 +28,10 @@ class ImportRBSP(Operator, ImportHelper):
# TODO: load_materials EnumProperty: None, Names, Base Colours, Nodes
load_geometry: BoolProperty(name="Geometry", description="Load .bsp Geometry", default=True) # noqa F722
load_entities: BoolProperty(name="Entities", description="Load .bsp Entities", default=True) # noqa F722
load_triggers: EnumProperty(name="Triggers", description="Generate trigger geometry", # noqa F722
items=(("None", "No Triggers", "Empties at trigger entity origins"), # noqa F722
("Brushes", "Trigger Brushes", "Generate brushes from planes stored in the entity")), # noqa F722
default="Brushes") # noqa F722
# TODO: cubemap volumes?
# TODO: load_lighting EnumProperty: None, Empties, All, PortalLights
# TODO: load_prop_dynamic EnumProperty: None, Empties, Low-Poly, High-Poly
Expand All @@ -37,6 +41,7 @@ class ImportRBSP(Operator, ImportHelper):
# TODO: Lightmaps with Pillow (PIL)

def execute(self, context):
"""Import selected .bsp"""
bsp = bsp_tool.load_bsp(self.filepath)
import_script = {bsp_tool.branches.respawn.titanfall: rbsp.titanfall,
bsp_tool.branches.respawn.titanfall2: rbsp.titanfall2,
Expand All @@ -57,9 +62,17 @@ def execute(self, context):
# TODO: load specific model / mesh (e.g. worldspawn only, skip tool brushes etc.)
importer.geometry(bsp, master_collection, materials)
# entities
triggers = ["trigger_capture_point", "trigger_hurt",
"trigger_indoor_area", "trigger_multiple",
"trigger_once", "trigger_out_of_bounds",
"trigger_soundscape"]
if self.load_triggers == "Brushes":
trigger_brushes = {classname: importer.entities.trigger_brushes for classname in triggers}
importer.entities.ent_object_data.update(trigger_brushes)
if self.load_entities:
# TODO: link worldspawn to Model[0]
importer.entities.as_empties(bsp, master_collection)
importer.entities.all_entities(bsp, master_collection)
# NOTE: includes lights, square info_node* & speakers
# NOTE: Eevee has limited lighting, try Cycles
# props
# TODO: import scale (Engine Units -> Inches)
Expand Down
124 changes: 99 additions & 25 deletions io_import_rbsp/rbsp/titanfall/entities.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import collections
import math
import re
from typing import Dict, List
from typing import Dict, List, Tuple

import bmesh
import bpy
import mathutils

Expand Down Expand Up @@ -70,7 +72,7 @@ def ent_to_light(entity: Dict[str, str]) -> bpy.types.PointLight:
# -- this is likely used for script based object classes (weapon pickups, cameras etc.)


def as_empties(bsp, master_collection):
def all_entities(bsp, master_collection):
all_entities = (bsp.ENTITIES, bsp.ENTITIES_env, bsp.ENTITIES_fx,
bsp.ENTITIES_script, bsp.ENTITIES_snd, bsp.ENTITIES_spawn)
block_names = ("bsp", "env", "fx", "script", "sound", "spawn")
Expand All @@ -86,6 +88,7 @@ def as_empties(bsp, master_collection):
if object_data is None:
entity_object.empty_display_type = "SPHERE"
entity_object.empty_display_size = 64
# cubes for ai pathing entities
if entity["classname"].startswith("info_node"):
entity_object.empty_display_type = "CUBE"
entity_collection.objects.link(entity_object)
Expand All @@ -104,36 +107,107 @@ def as_empties(bsp, master_collection):
entity_object.rotation_euler = mathutils.Euler(angles, "YZX")
# NOTE: default orientation is facing east (+X), props may appear rotated?
# TODO: further optimisation (props with shared worldmodel share mesh data) [ent_object_data]
# set all key values as custom properties
for field in entity:
entity_object[field] = entity[field]
# TODO: once all ents are loaded, connect paths for keyframe_rope / path_track etc.
# TODO: do a second pass of entities to apply parental relationships (based on targetnames)


# trigger_multiple, trigger_once, trigger_hurt


# ent_object_data["trigger_*"] = trigger_bounds
def trigger_bounds(trigger_ent: Dict[str, str]) -> bpy.types.Mesh:
# TODO: only for entities with no mesh geometry
raise NotImplementedError()
# pattern_vector = re.compile(r"([^\s]+) ([^\s]+) ([^\s]+)")
# mins = list(map(float, pattern_vector.match(trigger_ent["*trigger_bounds_mins"])))
# maxs = list(map(float, pattern_vector.match(trigger_ent["*trigger_bounds_maxs"])))
# TODO: return mesh data for a cube scaled to mins & maxs


# ent_object_data["trigger_*"] = trigger_bounds
def trigger_bmesh(trigger_ent: Dict[str, str]) -> bpy.types.Mesh:
# TODO: only for entities with no mesh geometry
pattern_plane_key = re.compile(r"\*trigger_brush([0-9]+)_plane([0-9]+)") # brush_index, plane_index
pattern_plane_value = re.compile(r"([^\s]+) ([^\s]+) ([^\s]+) ([^\s]+)") # *normal, distance
brushes = dict()
for key in trigger_ent.keys():
def trigger_brushes(entity: Dict[str, str]) -> bpy.types.Mesh:
bm = bmesh.new()
# get brush planes
pattern_plane_key = re.compile(r"\*trigger_brush_([0-9]+)_plane_([0-9]+)")
brushes = collections.defaultdict(lambda: collections.defaultdict(list))
# ^ {brush_index: {plane_index: " ".join(map(str, (*normal, distance)))}}
for key in entity.keys():
match = pattern_plane_key.match(key)
if match:
brush_index, plane_index = map(int, match.groups())
*normal, distance = map(float, pattern_plane_value.match(trigger_ent[key]).groups())
brushes[(brush_index, plane_index)] = (normal, distance)
raise NotImplementedError()
# TODO: use Logic&Trick's brush creation code from QtPyHammer here
*normal, distance = map(float, entity[key].split())
normal = mathutils.Vector(normal)
brushes[brush_index][plane_index] = (normal, distance)

vertices: Dict[mathutils.Vector, bmesh.types.BMVert] = dict()
# ^ {(x, y, z): BMVert(...)}

# adapted from vmf_tool.solid.from_namespace by QtPyHammer-devs
# https://github.com/QtPyHammer-devs/vmf_tool/blob/master/vmf_tool/solid.py
for brush_index, brush in brushes.items():
for plane_index, plane in brush.items():
normal, distance = plane
# make base polygon
non_parallel = mathutils.Vector((0, 0, -1)) if abs(normal.z) != 1 else mathutils.Vector((0, -1, 0))
local_y = mathutils.Vector.cross(non_parallel, normal).normalized()
local_x = mathutils.Vector.cross(local_y, normal).normalized()
center = normal * distance
radius = 10 ** 6 # may encounter issues if brush is larger than this
polygon = [center + ((-local_x + local_y) * radius),
center + ((local_x + local_y) * radius),
center + ((local_x + -local_y) * radius),
center + ((-local_x + -local_y) * radius)]
# slice by other planes
for other_plane_index, other_plane in brushes[brush_index].items():
if other_plane_index == plane_index:
continue # skip self
polygon = clip(polygon, other_plane)["back"]
# slice by other planes
for other_plane_index, other_plane in brushes[brush_index].items():
if other_plane_index == plane_index:
continue # skip self
polygon = clip(polygon, other_plane)["back"]
# append polygon to bmesh
polygon = list(map(mathutils.Vector.freeze, polygon))
polygon = list(sorted(set(polygon), key=lambda v: polygon.index(v))) # remove doubles
for vertex in polygon:
if vertex not in vertices:
vertices[vertex] = bm.verts.new(vertex)
if len(polygon) >= 3:
try: # HACKY FIX
bm.faces.new([vertices[v] for v in polygon])
except ValueError:
pass # "face already exists"
mesh_data_name = entity.get("targetname", entity["classname"])
mesh_data = bpy.data.meshes.new(mesh_data_name)
bm.to_mesh(mesh_data)
bm.free()
mesh_data.update()
# HACK: apply trigger material
if "TOOLS\\TOOLSTRIGGER" not in bpy.data.materials:
trigger_material = bpy.data.materials.new("TOOLS\\TOOLSTRIGGER")
trigger_material.diffuse_color = (0.944, 0.048, 0.004, 0.25)
trigger_material.blend_method = "BLEND"
trigger_material = bpy.data.materials["TOOLS\\TOOLSTRIGGER"]
mesh_data.materials.append(trigger_material)
# mesh_data has no faces?
return mesh_data


Plane = Tuple[mathutils.Vector, float]
Polygon = List[mathutils.Vector]


# adapted from Sledge by LogicAndTrick (archived)
# https://github.com/LogicAndTrick/sledge/blob/master/Sledge.DataStructures/Geometric/Precision/Polygon.cs
def clip(polygon: Polygon, plane: Plane) -> Dict[str, Polygon]:
normal, distance = plane
split = {"back": [], "front": []}
for i, A in enumerate(polygon): # NOTE: polygon's winding order is preserved
B = polygon[(i + 1) % len(polygon)] # next point
A_distance = mathutils.Vector.dot(normal, A) - distance
B_distance = mathutils.Vector.dot(normal, B) - distance
A_behind = round(A_distance, 6) < 0
B_behind = round(B_distance, 6) < 0
if A_behind:
split["back"].append(A)
else: # A is in front of the clipping plane
split["front"].append(A)
# does the edge AB intersect the clipping plane?
if (A_behind and not B_behind) or (B_behind and not A_behind):
t = A_distance / (A_distance - B_distance)
cut_point = mathutils.Vector(A).lerp(mathutils.Vector(B), t)
split["back"].append(cut_point)
split["front"].append(cut_point)
# ^ won't one of these points be added twice?
return split
102 changes: 102 additions & 0 deletions io_import_rbsp/rbsp/titanfall2/make_trigger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import collections
import re
from typing import Dict, List, Tuple

import bpy
import bmesh
from mathutils import Vector


Plane = Tuple[Vector, float]
Polygon = List[Vector]


trigger_obj = bpy.data.objects["trigger_multiple.060"]

pattern_plane_key = re.compile(r"\*trigger_brush_([0-9]+)_plane_([0-9]+)")

brushes = collections.defaultdict(lambda: collections.defaultdict(list))
# ^ {brush_index: {plane_index: " ".join(map(str, (*normal, distance)))}}
# ^ Mapping[int, Mapping[int, str]]

# for key in entity
for key in trigger_obj.keys():
match = pattern_plane_key.match(key)
if match:
brush_index, plane_index = map(int, match.groups())
*normal, distance = map(float, trigger_obj[key].split()) # entity[key]
normal = Vector(normal)
brushes[brush_index][plane_index] = (normal, distance)


# adapted from Sledge by LogicAndTrick (archived)
# https://github.com/LogicAndTrick/sledge/blob/master/Sledge.DataStructures/Geometric/Precision/Polygon.cs
def clip(polygon: Polygon, plane: Plane) -> Dict[str, Polygon]:
normal, distance = plane
split = {"back": [], "front": []}
# NOTE: polygon's winding order is preserved
for i, A in enumerate(polygon):
B = polygon[(i + 1) % len(polygon)] # next point
A_distance = Vector.dot(normal, A) - distance
B_distance = Vector.dot(normal, B) - distance
A_behind = round(A_distance, 6) < 0
B_behind = round(B_distance, 6) < 0
if A_behind:
split["back"].append(A)
else: # A is in front of the clipping plane
split["front"].append(A)
# does the edge AB intersect the clipping plane?
if (A_behind and not B_behind) or (B_behind and not A_behind):
t = A_distance / (A_distance - B_distance)
cut_point = Vector(A).lerp(Vector(B), t)
split["back"].append(cut_point)
split["front"].append(cut_point)
# ^ won't one of these points be added twice?
return split


mesh_data_name = trigger_obj.get("targetname", trigger_obj["classname"])
mesh_data = bpy.data.meshes.new(mesh_data_name)
bm = bmesh.new()
vertices: Dict[Vector, bmesh.types.BMVert] = dict()
# ^ {(x, y, z): BMVert(...)}

# adapted from vmf_tool.solid.from_namespace by QtPyHammer-devs
# https://github.com/QtPyHammer-devs/vmf_tool/blob/master/vmf_tool/solid.py
for brush_index, brush in brushes.items():
for plane_index, plane in brush.items():
normal, distance = plane
# make base polygon
non_parallel = Vector((0, 0, -1)) if abs(normal.z) != 1 else Vector((0, -1, 0))
local_y = Vector.cross(non_parallel, normal).normalized()
local_x = Vector.cross(local_y, normal).normalized()
center = normal * distance
radius = 10 ** 6 # may encounter issues if brush is larger than this
polygon = [center + ((-local_x + local_y) * radius),
center + ((local_x + local_y) * radius),
center + ((local_x + -local_y) * radius),
center + ((-local_x + -local_y) * radius)]
# slice by other planes
for other_plane_index, other_plane in brushes[brush_index].items():
if other_plane_index == plane_index:
continue # skip self
polygon = clip(polygon, other_plane)["back"]
# append polygon to bmesh
polygon = list(map(Vector.freeze, polygon))
for vertex in polygon:
if vertex not in vertices:
vertices[vertex] = bm.verts.new(vertex)
# try:
if len(polygon) != 0: # fix for diagonal planes
bm.faces.new([vertices[v] for v in polygon])
# # HACKY BUGFIX
# except ValueError:
# pass # "face already exists", idk why this happens
bm.to_mesh(mesh_data)
bm.free()
mesh_data.update()

object = bpy.data.objects.new("generated_trigger", mesh_data)
object.location = tuple(map(float, trigger_obj["origin"].split()))
object.data.materials.append(bpy.data.materials["TOOLS\\TOOLSTRIGGER"])
bpy.data.collections["entities"].objects.link(object)

0 comments on commit 02f7a81

Please sign in to comment.