Skip to content

Commit 783df4b

Browse files
cursoragentBoden
andcommitted
fix: tile BWM quat/offset, narrow except in tilekit_io, allow empty tool discovery in ci
Co-authored-by: Boden <th3w1zard1@users.noreply.github.com>
1 parent c095935 commit 783df4b

5 files changed

Lines changed: 213 additions & 10 deletions

File tree

.github/scripts/discover_tools.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ def main() -> None:
3232
tools = [tool for tool in tools if tool.is_cli]
3333

3434
if not tools:
35-
print("Error: No tools discovered", file=sys.stderr)
36-
sys.exit(1)
35+
if args.format == "json":
36+
print("[]")
37+
else:
38+
print("tools_matrix=[]")
39+
print("Discovered 0 tools (workspace may lack vendored Tools/* checkouts)", file=sys.stderr)
40+
return
3741

3842
payload = [tool.to_dict() for tool in tools]
3943
if args.format == "json":

Libraries/PyKotor/src/pykotor/tools/tilekit_io.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _load_doors_tilekit(
125125
try:
126126
utd_k1_path = base_path / f"{door_json['utd_k1']}.utd"
127127
utd_k2_path = base_path / f"{door_json['utd_k2']}.utd"
128-
except Exception:
128+
except (KeyError, TypeError, ValueError):
129129
continue
130130
try:
131131
utd_k1 = read_utd(utd_k1_path)
@@ -153,7 +153,7 @@ def _parse_doorhooks(
153153
if di < 0 or di >= len(doors):
154154
continue
155155
hooks.append(KitComponentHook(pos, rot, edge, doors[di]))
156-
except Exception:
156+
except (KeyError, TypeError, ValueError, IndexError):
157157
continue
158158
return hooks
159159

Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,53 @@
22

33
from __future__ import annotations
44

5+
import math
56
from copy import deepcopy
67
from dataclasses import dataclass, field
78

89
from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor
910
from pykotor.common.indoormap import IndoorMap, IndoorMapRoom, _ensure_embedded_kit
10-
from pykotor.common.tilekit import TileKit
11+
from pykotor.common.tilekit import QuaternionWXYZ, TileKit
1112
from pykotor.resource.formats.bwm.bwm_data import BWM
1213
from pykotor.resource.generics.utd import UTD
1314
from pykotor.tools.tile_bwm import generate_flat_floor_quad, merge_translated_bwms
1415
from utility.common.geometry import Vector3
1516

17+
_EM_EPS = 1e-6
18+
1619
_EMBEDDED_TILE = "__tile_compiled__"
1720

1821

22+
def _is_identity_quaternion(q: QuaternionWXYZ) -> bool:
23+
return (
24+
abs(q.w - 1.0) < _EM_EPS
25+
and abs(q.x) < _EM_EPS
26+
and abs(q.y) < _EM_EPS
27+
and abs(q.z) < _EM_EPS
28+
)
29+
30+
31+
def _apply_quaternion_wxyz_to_bwm(bwm: BWM, q: QuaternionWXYZ) -> None:
32+
"""Rotate BWM vertex positions in-place with unit quaternion (w, x, y, z)."""
33+
if _is_identity_quaternion(q):
34+
return
35+
w, x, y, z = q.w, q.x, q.y, q.z
36+
inv_len = 1.0 / math.sqrt(w * w + x * x + y * y + z * z)
37+
w, x, y, z = w * inv_len, x * inv_len, y * inv_len, z * inv_len
38+
xx, yy, zz = x * x, y * y, z * z
39+
xy, xz, yz = x * y, x * z, y * z
40+
wx, wy, wz = w * x, w * y, w * z
41+
m00, m01, m02 = 1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy)
42+
m10, m11, m12 = 2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx)
43+
m20, m21, m22 = 2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy)
44+
for vertex in bwm.vertices():
45+
ox, oy, oz = vertex.x, vertex.y, vertex.z
46+
vertex.x = m00 * ox + m01 * oy + m02 * oz
47+
vertex.y = m10 * ox + m11 * oy + m12 * oz
48+
vertex.z = m20 * ox + m21 * oy + m22 * oz
49+
bwm._invalidate_face_cache() # noqa: SLF001
50+
51+
1952
@dataclass
2053
class TileLayout:
2154
"""Grid of floor template ids for a v2 tile kit (PyKotor `.indoor` extension)."""
@@ -62,11 +95,13 @@ def tile_layout_to_merged_bwm(
6295
continue
6396
wx = float(ix) * cell
6497
wy = float(iy) * cell
98+
ox = tpl.offset.x
99+
oy = tpl.offset.y
100+
oz = tpl.offset.z
65101
if tpl.wok and tpl.wok.faces:
66102
b = deepcopy(tpl.wok)
67-
parts.append(
68-
(b, wx + tpl.offset.x, wy + tpl.offset.y, z + tpl.offset.z),
69-
)
103+
_apply_quaternion_wxyz_to_bwm(b, tpl.rotation)
104+
parts.append((b, wx + ox, wy + oy, z + oz))
70105
else:
71106
b = generate_flat_floor_quad(
72107
min_x=0.0,
@@ -75,7 +110,8 @@ def tile_layout_to_merged_bwm(
75110
size_y=cell,
76111
z=0.0,
77112
)
78-
parts.append((b, wx, wy, z + tpl.offset.z))
113+
_apply_quaternion_wxyz_to_bwm(b, tpl.rotation)
114+
parts.append((b, wx + ox, wy + oy, z + oz))
79115
if not parts:
80116
return BWM()
81117
return merge_translated_bwms(parts)

Libraries/PyKotor/tests/test_tilekit_v2.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from pykotor.common.indoorkit import Kit
88
from pykotor.common.indoormap import EmbeddedKit, IndoorMap
9-
from pykotor.common.tilekit import TileKit
109
from pykotor.resource.formats.bwm.bwm_data import BWM
1110
from pykotor.tools.indoorkit import load_kits_unified, load_tile_kit_v2
1211
from pykotor.tools.tile_bwm import merge_translated_bwms

tool_metadata.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Repository tool discovery and PyKotor library path constants (CI + compile scripts)."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import sys
7+
from dataclasses import dataclass, field
8+
from pathlib import Path
9+
10+
if sys.version_info >= (3, 11):
11+
import tomllib
12+
else:
13+
try:
14+
import tomli as tomllib
15+
except ImportError:
16+
tomllib = None # type: ignore[assignment, misc]
17+
18+
LIBRARY_SOURCE_PATHS: list[str] = [
19+
"Libraries/PyKotor/src",
20+
"Libraries/bioware-kaitai-formats",
21+
]
22+
LIBRARY_TEST_PATHS: list[str] = [
23+
"Libraries/PyKotor/tests",
24+
"Libraries/bioware-kaitai-formats",
25+
]
26+
27+
28+
@dataclass
29+
class ToolInfo:
30+
"""Metadata for a package under `Tools/`."""
31+
32+
directory: str
33+
name: str
34+
build_name: str
35+
display_name: str
36+
path: str
37+
src_path: str
38+
module_name: str
39+
requires_qt: bool
40+
is_cli: bool
41+
tests_path: str | None = field(default=None)
42+
43+
@property
44+
def relative_path(self) -> str:
45+
return f"Tools/{self.directory}"
46+
47+
def to_dict(self) -> dict[str, object | str | bool | None]:
48+
return {
49+
"directory": self.directory,
50+
"name": self.name,
51+
"build_name": self.build_name,
52+
"display_name": self.display_name,
53+
"path": self.path,
54+
"src_path": self.src_path,
55+
"module_name": self.module_name,
56+
"requires_qt": self.requires_qt,
57+
"is_cli": self.is_cli,
58+
}
59+
60+
61+
def _read_pyproject_data(tool_dir: Path) -> dict[str, object]:
62+
pyproject = tool_dir / "pyproject.toml"
63+
if not pyproject.is_file() or tomllib is None:
64+
return {}
65+
try:
66+
return tomllib.load(pyproject.read_bytes()) # type: ignore[no-untyped-call]
67+
except (OSError, TypeError, ValueError, UnicodeError):
68+
return {}
69+
70+
71+
def _script_module(data: dict[str, object]) -> str:
72+
project = data.get("project")
73+
if not isinstance(project, dict):
74+
return ""
75+
scripts = project.get("scripts")
76+
if not isinstance(scripts, dict) or not scripts:
77+
return ""
78+
for v in scripts.values():
79+
if isinstance(v, str) and ":" in v:
80+
return v.split(":", 1)[0].strip()
81+
return ""
82+
83+
84+
def _infer_requires_qt(text: str) -> bool:
85+
low = text.lower()
86+
return "pyqt" in low or "pyside" in low or "qt5" in low or "qt6" in low
87+
88+
89+
def _one_tool(repo_root: Path, tool_dir: Path) -> ToolInfo | None:
90+
if not (tool_dir / "pyproject.toml").is_file():
91+
return None
92+
directory = tool_dir.name
93+
data = _read_pyproject_data(tool_dir)
94+
project = data.get("project") if isinstance(data.get("project"), dict) else {}
95+
proj_name = str(project.get("name", directory))
96+
build_name = re.sub(r"[^0-9a-zA-Z]+", "-", proj_name).lower().strip("-")
97+
if not build_name:
98+
build_name = directory.lower()
99+
toml_text = (tool_dir / "pyproject.toml").read_text(encoding="utf-8", errors="replace")
100+
requires_qt = _infer_requires_qt(toml_text)
101+
is_cli = not requires_qt
102+
if (tool_dir / "src").is_dir():
103+
src = tool_dir / "src"
104+
else:
105+
src = tool_dir
106+
src_path = str(src.relative_to(repo_root))
107+
tests: Path | None = None
108+
for tname in ("tests", "test"):
109+
tpath = tool_dir / tname
110+
if tpath.is_dir():
111+
tests = tpath
112+
break
113+
tests_path = str(tests.relative_to(repo_root)) if tests is not None else None
114+
mod = _script_module(data)
115+
if not mod:
116+
if (src / "toolset").is_dir():
117+
mod = "toolset"
118+
elif (src / "pykotor").is_dir():
119+
mod = "pykotor"
120+
else:
121+
mod = directory
122+
return ToolInfo(
123+
directory=directory,
124+
name=build_name,
125+
build_name=build_name,
126+
display_name=proj_name,
127+
path=f"Tools/{directory}",
128+
src_path=src_path,
129+
module_name=mod,
130+
requires_qt=requires_qt,
131+
is_cli=is_cli,
132+
tests_path=tests_path,
133+
)
134+
135+
136+
def discover_tools(repo_root: Path | str) -> list[ToolInfo]:
137+
"""List each `Tools/*/` that contains a `pyproject.toml`."""
138+
root = Path(repo_root).resolve()
139+
tools_base = root / "Tools"
140+
if not tools_base.is_dir():
141+
return []
142+
out: list[ToolInfo] = []
143+
for child in sorted(tools_base.iterdir(), key=lambda p: p.name.lower()):
144+
if not child.is_dir() or child.name.startswith("."):
145+
continue
146+
t = _one_tool(root, child)
147+
if t is not None:
148+
out.append(t)
149+
return out
150+
151+
152+
def resolve_tool(name: str, repo_root: Path | str) -> ToolInfo:
153+
"""Return tool metadata; *name* matches directory, build name, or project name (casefold)."""
154+
n = (name or "").strip().casefold()
155+
for tool in discover_tools(repo_root):
156+
if n in {
157+
tool.directory.casefold(),
158+
tool.build_name.casefold(),
159+
tool.name.casefold(),
160+
tool.display_name.casefold(),
161+
}:
162+
return tool
163+
msg = f"Unknown tool: {name!r}"
164+
raise KeyError(msg)

0 commit comments

Comments
 (0)