Skip to content

Commit

Permalink
(extensions.editor) Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
snake-biscuits committed Jan 4, 2024
1 parent ed8eb02 commit 61f6860
Show file tree
Hide file tree
Showing 22 changed files with 1,103 additions and 0 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### New
* `__init__` methods for all SpecialLumpClasses
* `extensions.editor`
- parse `.map` & `.vmf`
- compare uncompiled maps to `.bsp`

### Changed
* SpecialLumpClasses & GameLumpClasses refactor
Expand Down
8 changes: 8 additions & 0 deletions bsp_tool/extensions/editor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__all__ = ["base", "common", "generic", "map", "vmf"]

from . import base # Pattern, AttrMap & MetaPattern
from . import common # Integer, Float, Point & Plane patterns
from . import generic # Brush, BrushSide, Entity & Displacement / Patch baseclasses
# file formats
from . import map # oops, collides with a builtin function
from . import vmf
122 changes: 122 additions & 0 deletions bsp_tool/extensions/editor/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from __future__ import annotations
import re
from typing import Callable, Dict, Union


# TODO: caches & write-once members
# -- lock _regex etc. to class definition
# -- store results of properties / MetaPattern regex generators


class Pattern:
regex: str
ValueType: Callable[str, object]
value: object

def __init__(self, *args, **kwargs):
self.value = self.ValueType(*args, **kwargs)

def __repr__(self) -> str:
return f'{self.__class__.__name__}("{self!s}")'

def __str__(self) -> str:
return str(self.value)

@classmethod
def from_string(cls, string: str):
match = re.match(cls.regex, string)
assert match is not None, "string doesn't match regex"
return cls(string)


def escape(string: str) -> str:
special = ".?*+^$[](){}|\\"
return "".join([(f"\\{c}" if c in special else c) for c in string])


class AttrMap:
"""mutable dict-like object"""

def __init__(self, **kwargs):
self._keys = tuple(kwargs.keys())
for a, v in kwargs.items():
setattr(self, a, v)

def __eq__(self, other: AttrMap) -> bool:
if isinstance(other, AttrMap):
return self.as_dict() == other.as_dict()
return False

def __repr__(self) -> str:
values = ", ".join([f"{k}={getattr(self, k)}" for k in self._keys])
return f"{self.__class__.__name__}({values})"

def as_dict(self) -> Dict[str, object]:
return {k: getattr(self, k) for k in self._keys}


class MetaPattern:
"""container for regex patterns"""
spec: str
# ^ "SYMBOL name1 name2 name3"
patterns: Dict[str, Union[Pattern, MetaPattern]]
# ^ {"name1": PatternSubclass, "name2": MetaPattern}
ValueType: Callable[Dict[str, object], object] = AttrMap
value: object # type should be ValueType
# ^ value = ValueType(**{"key": value})

def __init__(self, *args, **kwargs):
attrs = {t: self.patterns[t]().value for t in self.spec.split() if t in self.patterns}
attrs.update(dict(zip((t for t in self.spec.split() if t in self.patterns), args)))
attrs.update(kwargs)
self.value = self.ValueType(**attrs)

def __repr__(self) -> str:
return f'{self.__class__.__name__}("{self!s}")'

def __str__(self) -> str:
return " ".join([
str(getattr(self.value, t)) if t in self.patterns else t
for t in self.spec.split()])

@classmethod
def from_string(cls, string: str) -> MetaPattern:
matches = re.match(cls.regex_groups(), string)
assert matches is not None, f"string does not match pattern: {cls.regex_groups()}"
return cls(**{t: cls.patterns[t].from_string(m).value for t, m in matches.groupdict().items()})

# REGEX ASSEMBLERS

@classmethod
def regex(cls):
"""no groups; for matching"""
regexes = list()
for token in cls.spec.split(" "):
if token in cls.patterns:
pattern = cls.patterns[token]
if issubclass(pattern, Pattern):
regexes.append(pattern.regex)
elif issubclass(pattern, MetaPattern):
regexes.append(pattern.regex())
else:
raise TypeError(f"{token}: {pattern} is invalid")
else: # symbol / keyword
regexes.append(escape(token))
return r"\s*".join(regexes)

@classmethod
def regex_groups(cls):
"""named top-level groups only"""
regexes = list()
for token in cls.spec.split(" "):
if token in cls.patterns:
pattern = cls.patterns[token]
if issubclass(pattern, Pattern):
regexes.append(f"(?P<{token}>{pattern.regex})")
elif issubclass(pattern, MetaPattern):
regexes.append(f"(?P<{token}>{pattern.regex()})")
else:
raise TypeError()
else: # symbol / keyword
regexes.append(escape(token))
return r"\s*".join(regexes)
63 changes: 63 additions & 0 deletions bsp_tool/extensions/editor/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from typing import Dict

from ...branches import physics
from ...branches import vector
from . import base


class Comment(base.Pattern):
regex = r"(\\\\|//) .*"
ValueType = str


class Everything(base.Pattern):
regex = r".*"
ValueType = str


class TrailingComment(base.MetaPattern):
spec = r"line \\ comment"
patterns = {"line": Everything, "comment": Everything}


class Filepath(base.Pattern):
regex = r"[A-Za-z0-9_\./\\:]*"
ValueType = str


class Float(base.Pattern):
regex = r"[+-]?[0-9]+(\.[0-9]+?(e[+-]?[0-9]+)?)?"
ValueType = float


class Integer(base.Pattern):
regex = r"[+-]?[0-9]+"
ValueType = int


class KeyValuePair(base.MetaPattern):
spec = r'" key " " value "'
patterns = {"key": Everything, "value": Everything}


class Point(base.MetaPattern):
spec = "( x y z )"
patterns = {a: Float for a in "xyz"}
ValueType = vector.vec3


class Plane(base.MetaPattern):
spec = "A B C"
patterns = {P: Point for P in "ABC"}

class ValueType(physics.Plane):
def __init__(self, **kwargs: Dict[str, Point]):
if kwargs["A"] == kwargs["B"] == kwargs["C"]:
# TODO: warning for invalid face
self.normal, self.distance = vector.vec3(z=1), 0
else:
plane = physics.Plane.from_triangle(*[kwargs[P] for P in "ABC"])
self.normal, self.distance = plane.normal, plane.distance

def __str__(self) -> str:
return " ".join([f"({P.x} {P.y} {P.z})" for P in self.value.as_triangle()])
121 changes: 121 additions & 0 deletions bsp_tool/extensions/editor/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List

from ...branches import physics
from ...branches import texture


# TODO: Curve (CoDRadiant)
# TODO: Displacement (Source)
# TODO: Patch (IdTech 3)
# TODO: Tricoll (Titanfall) [for MRVN-Radiant]
# -- misc_model (embeds a .mdl for lightmapping)
# -- .gltf? iirc Respawn builds terrain in Autodesk Maya [citation needed]


class Brush:
sides: List[BrushSide]

def __init__(self, sides=None):
self.sides = sides if sides is not None else list()

def __repr__(self) -> str:
return f"<Brush {len(self.sides)} sides @ 0x{id(self):012X}>"

def as_physics(self) -> physics.Brush:
# need to calculate brush bounds from sides
# also need to confirm brush is valid (convex & closed)
raise NotImplementedError()


class BrushSide:
plane: physics.Plane
shader: str
texture_vector: texture.TextureVector
# TODO: include rotation in texture.TextureVector
texture_rotation: float

def __init__(self, plane=physics.Plane((0, 0, 1), 0), shader="__TB_empty", texture_vector=None, rotation=0):
self.plane = plane
self.shader = shader
if texture_vector is None:
self.texture_vector = texture.TextureVector.from_normal(self.plane.normal)
else:
self.texture_vector = texture_vector
self.texture_rotation = rotation

def __repr__(self) -> str:
# TODO: kwargs for texture axis & rotation (if shorter)
return f"BrushSide({self.plane!r}, {self.shader!r}, ...)"


class Entity:
# could do a class for each classname & use fgd-tool for type conversion...
classname: str # must be defined
# NOT KEYVALUES
brushes: List[Brush]
_keys: List[str] # key-value pairs to write
# NOTE: keys will be stored w/ setattr

def __init__(self, **kwargs):
self.brushes = list()
self._keys = list()
for key, value in kwargs.items():
self[key] = value

def __delitem__(self, key: str):
assert key in self._keys, f'cannot delete key "{key}"'
self._keys.remove(key)
delattr(self, key)

def __getitem__(self, key: str) -> str:
return getattr(self, key)

def __repr__(self) -> str:
lines = [
"{",
*[f'"{k}" "{self[k]}"' for k in self._keys],
f"...skipped {len(self.brushes)} brushes...",
"}"]
return "\n".join(lines)

def __setitem__(self, key: str, value: str):
assert key not in ("_keys", "brushes"), f'cannot set key "{key}"'
self._keys.append(key)
setattr(self, key, value)

def __str__(self):
lines = [
"{",
*[f'"{k}" "{self[k]}"' for k in self._keys],
*map(str, self.brushes),
"}"]
return "\n".join(lines)

def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default)


class MapFile:
comments: Dict[int, str]
# ^ {line_no: "comment"}
entities: List[Entity]
worldspawn: Entity = property(lambda s: s.entities[0])

def __init__(self):
self.comments = dict()
self.entities = list()

def __repr__(self) -> str:
return f"<{self.__class__.__name__} {len(self.entities)} entities>"

def search(self, **search: Dict[str, str]) -> List[Entity]:
"""Search for entities by key-values; e.g. .search(key=value) -> [{"key": value, ...}, ...]"""
return [e for e in self.entities if all([e.get(k, "") == v for k, v in search.items()])]

def entities_by_classname(self) -> Dict[str, List[Entity]]:
out = defaultdict(str)
for entity in self.entities:
out[entity.classname].append(entity)
return dict(out)
5 changes: 5 additions & 0 deletions bsp_tool/extensions/editor/map/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__all__ = ["cod4", "quake", "valve"]

from . import cod4
from . import quake
from . import valve # version 220
Loading

0 comments on commit 61f6860

Please sign in to comment.