added two files. #2
Conversation
Updated header information in the script.
There was a problem hiding this comment.
Pull request overview
This PR adds a Quake 2 MAP to Unreal Engine 5 T3D converter script. The converter parses Quake 2 .MAP files and transforms them into .T3D format for import into Unreal Engine 5, including geometry brushes, lights, monsters, triggers, and other game entities.
Changes:
- Added QUAKE2_MAP_2_T3D.py: A comprehensive Python script (1,235 lines) that converts Quake 2 MAP files to UE5 T3D format
- The script includes vector mathematics, brush/face parsing, polygon triangulation, and T3D file generation
- Supports conversion of world geometry, lights with color properties, entities (monsters, items, player starts), and triggers
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def normalize(self): | ||
| length = self.length() | ||
| if length > 0.0001: | ||
| return self / length | ||
| return Vector3(0, 0, 1) |
There was a problem hiding this comment.
The normalize method returns a default vector (0, 0, 1) when the length is too small, which is reasonable for degenerate cases. However, this behavior should be documented in a docstring since it's a non-obvious design choice that callers need to be aware of.
| f.write(f" Begin Object Name=\"Sprite\"\n") | ||
| f.write(f" AttachParent=\"SceneComp\"\n") | ||
| f.write(f" End Object\n") | ||
| f.write(f" Text=\"{properties_text}\"\n") |
There was a problem hiding this comment.
The properties_text value (which comes from entity.properties_to_string()) is directly embedded in the Text attribute without proper escaping. If entity properties contain special characters like quotes or backslashes, this could break the T3D file format or lead to injection issues. Consider escaping the text appropriately for the T3D format.
| 'player_starts': 0 | ||
| } | ||
|
|
||
| def write(self, entities: List[Entity]): | ||
| with open(self.output_path, 'w', encoding='utf-8') as f: | ||
| self._write_header(f) | ||
|
|
||
| for entity in entities: | ||
| try: | ||
| self._write_entity(f, entity) | ||
| except Exception as e: | ||
| logger.error(f"Error writing entity {entity.get_classname()}: {e}") | ||
|
|
There was a problem hiding this comment.
If an exception occurs during _write_entity, the error is logged but the conversion continues. This could result in an incomplete or invalid T3D file being written. Consider whether critical entity write failures should abort the conversion, or at minimum, track failed entities and report them in the final statistics.
| 'player_starts': 0 | |
| } | |
| def write(self, entities: List[Entity]): | |
| with open(self.output_path, 'w', encoding='utf-8') as f: | |
| self._write_header(f) | |
| for entity in entities: | |
| try: | |
| self._write_entity(f, entity) | |
| except Exception as e: | |
| logger.error(f"Error writing entity {entity.get_classname()}: {e}") | |
| 'player_starts': 0, | |
| 'failed_entities': 0, | |
| } | |
| def write(self, entities: List[Entity]): | |
| with open(self.output_path, 'w', encoding='utf-8') as f: | |
| self._write_header(f) | |
| failed_entities = [] | |
| for entity in entities: | |
| try: | |
| self._write_entity(f, entity) | |
| except Exception as e: | |
| logger.error(f"Error writing entity {entity.get_classname()}: {e}") | |
| self.stats['failed_entities'] += 1 | |
| try: | |
| failed_entities.append(entity.get_classname()) | |
| except Exception: | |
| failed_entities.append('<unknown>') | |
| if failed_entities: | |
| logger.warning( | |
| "Completed T3D conversion with %d failed entities: %s", | |
| self.stats['failed_entities'], | |
| ", ".join(failed_entities) | |
| ) | |
| lines.append("") | ||
| lines.append(f"Brushes: {len(self.brushes)}") | ||
|
|
||
| return "\\n".join(lines) |
There was a problem hiding this comment.
The escape sequence "\n" in the join creates a literal backslash followed by 'n' instead of a newline character. This should be "\n" (with a single backslash) to create actual newlines.
| return "\\n".join(lines) | |
| return "\n".join(lines) |
| def _write_entity(self, f, entity: Entity): | ||
| classname = entity.get_classname() | ||
|
|
||
| if entity.is_light(): | ||
| self._write_light_actor(f, entity) | ||
| self.stats['lights'] += 1 | ||
| if '_color' in entity.properties: |
There was a problem hiding this comment.
The stats dictionary tracks 'lights_with_color' by checking for '_color' in entity.properties, but the get_light_properties method also accepts color from the 'light' value itself (with hex or RGB values). This means the statistic may not accurately reflect all lights that have color information.
| def _write_entity(self, f, entity: Entity): | |
| classname = entity.get_classname() | |
| if entity.is_light(): | |
| self._write_light_actor(f, entity) | |
| self.stats['lights'] += 1 | |
| if '_color' in entity.properties: | |
| def _entity_has_color(self, entity: Entity) -> bool: | |
| """ | |
| Determine whether the given entity has color information. | |
| A light can specify color either via a dedicated '_color' property | |
| or encoded in the 'light' value itself (hex or RGB formats). | |
| This method mirrors that behavior for statistics purposes. | |
| """ | |
| # Explicit color property | |
| if '_color' in entity.properties: | |
| return True | |
| # Color encoded in the 'light' property | |
| light_value = entity.properties.get('light') | |
| if not light_value: | |
| return False | |
| value = str(light_value).strip() | |
| if not value: | |
| return False | |
| # Hex color: RRGGBB or #RRGGBB | |
| if re.fullmatch(r'#?[0-9A-Fa-f]{6}', value): | |
| return True | |
| # RGB triple: three numeric components separated by whitespace or commas | |
| parts = re.split(r'[\s,]+', value) | |
| if len(parts) != 3: | |
| return False | |
| try: | |
| components = [float(p) for p in parts] | |
| except ValueError: | |
| return False | |
| # Accept either normalized [0,1] or 0–255 ranges | |
| if all(0.0 <= c <= 1.0 for c in components): | |
| return True | |
| if all(0.0 <= c <= 255.0 for c in components): | |
| return True | |
| return False | |
| def _write_entity(self, f, entity: Entity): | |
| classname = entity.get_classname() | |
| if entity.is_light(): | |
| self._write_light_actor(f, entity) | |
| self.stats['lights'] += 1 | |
| if self._entity_has_color(entity): |
| texture = match.group(10) | ||
|
|
||
| return Face(p1, p2, p3, texture) | ||
| except: |
There was a problem hiding this comment.
Bare except clause catches all exceptions including KeyboardInterrupt and SystemExit, which should not be caught. Use a more specific exception type like ValueError or Exception.
| except: | |
| except Exception: |
| def to_unreal(self, grid_snap: float = 2.54): | ||
| scale = 2.54 | ||
| x = self.x * scale | ||
| y = -self.y * scale | ||
| z = self.z * scale | ||
|
|
||
| if grid_snap > 0: | ||
| x = self.snap_to_grid(x, grid_snap) | ||
| y = self.snap_to_grid(y, grid_snap) | ||
| z = self.snap_to_grid(z, grid_snap) | ||
|
|
||
| return Vector3(x, y, z) |
There was a problem hiding this comment.
The grid_snap parameter is used in the to_unreal method but the variable 'scale' is hardcoded to 2.54 on line 77, making the grid_snap parameter value meaningless for the scaling calculation. Consider whether scale should use the grid_snap parameter value or if these should be separate concerns.
| if (self.triangulator._vec_equal(tri[0], tri[1]) or | ||
| self.triangulator._vec_equal(tri[1], tri[2]) or | ||
| self.triangulator._vec_equal(tri[0], tri[2])): |
There was a problem hiding this comment.
Accessing internal/private method _vec_equal from outside the class violates encapsulation. Consider making this method public by removing the underscore prefix, or providing a public method to check vertex equality.
| def __truediv__(self, scalar): | ||
| if abs(scalar) < 0.0001: | ||
| return Vector3(0, 0, 0) | ||
| return Vector3(self.x / scalar, self.y / scalar, self.z / scalar) |
There was a problem hiding this comment.
Division by zero protection checks for values less than 0.0001, but this threshold might be too small for floating-point precision issues. Consider using a named constant (e.g., EPSILON) consistently throughout the code for such comparisons, or use a more robust comparison approach.
| except (ValueError, IndexError): | ||
| pass |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except (ValueError, IndexError): | |
| pass | |
| except (ValueError, IndexError) as e: | |
| logger.warning(f"Failed to parse RGB light components from '{light_value}': {e}") |
a Quake2 MAP converter for parsing a MAP file to a T3D format, and, an HTML t3D viewer to check results