From ece15b8bd188cf7e11def2e75cf39b090b2c7690 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 27 Apr 2026 20:25:34 -0500 Subject: [PATCH 01/29] feat: v2 tile kits, unified loader, and tile map compile to IndoorMap --- .../PyKotor/src/pykotor/common/indoormap.py | 13 + .../PyKotor/src/pykotor/common/tilekit.py | 110 ++++++ .../PyKotor/src/pykotor/tools/indoorkit.py | 48 ++- .../PyKotor/src/pykotor/tools/tile_bwm.py | 54 +++ .../PyKotor/src/pykotor/tools/tilekit_io.py | 322 ++++++++++++++++++ .../src/pykotor/tools/tilemap_compile.py | 150 ++++++++ .../tests/fixtures/kits_v2/minimal_tiles.json | 21 ++ .../fixtures/kits_v2/minimal_tiles/.gitkeep | 0 Libraries/PyKotor/tests/test_tilekit_v2.py | 76 +++++ docs/specs/kits_v2_kotor_net_v0_1.md | 57 ++++ 10 files changed, 846 insertions(+), 5 deletions(-) create mode 100644 Libraries/PyKotor/src/pykotor/common/tilekit.py create mode 100644 Libraries/PyKotor/src/pykotor/tools/tile_bwm.py create mode 100644 Libraries/PyKotor/src/pykotor/tools/tilekit_io.py create mode 100644 Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep create mode 100644 Libraries/PyKotor/tests/test_tilekit_v2.py create mode 100644 docs/specs/kits_v2_kotor_net_v0_1.md diff --git a/Libraries/PyKotor/src/pykotor/common/indoormap.py b/Libraries/PyKotor/src/pykotor/common/indoormap.py index 3b879a25b3..9ba0c8515f 100644 --- a/Libraries/PyKotor/src/pykotor/common/indoormap.py +++ b/Libraries/PyKotor/src/pykotor/common/indoormap.py @@ -121,6 +121,8 @@ class IndoorMapDataDictBase(TypedDict): class IndoorMapDataDict(IndoorMapDataDictBase, total=False): target_game_type: bool embedded_components: list[EmbeddedComponentDataDict] + tile_layout: dict[str, Any] + indoor_map_version: int _EMBEDDED_KIT_ID = "__embedded__" @@ -217,6 +219,9 @@ def __init__( self.used_rooms: set[KitComponent] = set() self.used_kits: set[Kit] = set() self.scan_mdls: set[bytes] = set() + # Optional v2 tile-grid state (Kotor.NET-style `tile_layout`); see `pykotor.tools.tilemap_compile`. + self.tile_layout: dict[str, Any] | None = None + self.indoor_map_version: int = 1 def rebuild_room_connections(self): for room in self.rooms: @@ -808,6 +813,10 @@ def write(self) -> bytes: if embedded_components: # JSON-friendly list form for stable ordering. data["embedded_components"] = list(embedded_components.values()) + if self.indoor_map_version and self.indoor_map_version != 1: + data["indoor_map_version"] = self.indoor_map_version + if self.tile_layout: + data["tile_layout"] = self.tile_layout return json.dumps(data).encode("utf-8") @@ -840,6 +849,8 @@ def _load_data( self.module_id = data.get("warp", data.get("module_id", "test01")) self.skybox = data.get("skybox", "") self.target_game_type = data.get("target_game_type", None) + self.indoor_map_version = int(data.get("indoor_map_version", 1) or 1) + self.tile_layout = data.get("tile_layout") # Load any embedded components first, so room references can resolve. self._load_embedded_components(data.get("embedded_components") or [], kits, logger) @@ -1076,6 +1087,8 @@ def reset(self): self._source_module = None self._source_lyt_for_preserve = None self._source_vis_for_preserve = None + self.tile_layout = None + self.indoor_map_version = 1 class IndoorMapRoom(ComparableMixin): diff --git a/Libraries/PyKotor/src/pykotor/common/tilekit.py b/Libraries/PyKotor/src/pykotor/common/tilekit.py new file mode 100644 index 0000000000..14b9928710 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/common/tilekit.py @@ -0,0 +1,110 @@ +"""Tile-based indoor kit (format_version 2) data model. + +Semantic parity with Kotor.NET KitSerializer_V0_1: template libraries of floor, ceiling, wall, +corner, and doorframe pieces—not preassembled room components. WOK is optional per template; +build may merge or generate BWM. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path + +from pykotor.common.indoorkit import Kit, KitComponentHook, KitDoor, MDLMDXTuple +from pykotor.resource.formats.bwm.bwm_data import BWM +from utility.common.geometry import Vector3 + + +class TileTemplateKind(IntEnum): + FLOOR = 0 + CEILING = 1 + WALL = 2 + CORNER = 3 + DOORFRAME = 4 + + +@dataclass +class QuaternionWXYZ: + """Unit quaternion as (w, x, y, z) — JSON array order in v2 spec.""" + + w: float = 1.0 + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + @classmethod + def from_json(cls, data: list[float] | None) -> QuaternionWXYZ: + if not data or len(data) < 4: + return cls() + return cls(float(data[0]), float(data[1]), float(data[2]), float(data[3])) + + +@dataclass +class TileTemplate: + """A single v2 template piece (one category: floor, wall, etc.).""" + + kind: TileTemplateKind + template_id: str + resref: str + offset: Vector3 = field(default_factory=Vector3.from_null) + rotation: QuaternionWXYZ = field(default_factory=QuaternionWXYZ) + mdl: bytes = b"" + mdx: bytes = b"" + wok: BWM | None = None + doorhooks: list[KitComponentHook] = field(default_factory=list) + + @property + def has_walkmesh(self) -> bool: + return self.wok is not None and bool(self.wok.faces) + + +@dataclass +class TileKit: + """Container for format_version 2 tile kits (parallel to v1 `Kit` for room components).""" + + name: str + kit_id: str + doors: list[KitDoor] = field(default_factory=list) + floors: list[TileTemplate] = field(default_factory=list) + ceilings: list[TileTemplate] = field(default_factory=list) + walls: list[TileTemplate] = field(default_factory=list) + corners: list[TileTemplate] = field(default_factory=list) + doorframes: list[TileTemplate] = field(default_factory=list) + formats_serializer: str = "" + + textures: dict[str, bytes] = field(default_factory=dict) + lightmaps: dict[str, bytes] = field(default_factory=dict) + txis: dict[str, bytes] = field(default_factory=dict) + always: dict[Path, bytes] = field(default_factory=dict) + side_padding: dict[int, dict[int, MDLMDXTuple]] = field(default_factory=dict) + top_padding: dict[int, dict[int, MDLMDXTuple]] = field(default_factory=dict) + skyboxes: dict[str, MDLMDXTuple] = field(default_factory=dict) + + def all_templates(self) -> list[TileTemplate]: + return [ + *self.floors, + *self.ceilings, + *self.walls, + *self.corners, + *self.doorframes, + ] + + def template_by_id(self, template_id: str) -> TileTemplate | None: + for t in self.all_templates(): + if t.template_id == template_id: + return t + return None + + def as_runtime_kit(self) -> Kit: + """Expose doors/textures in a v1 `Kit` shell for code paths that only accept `Kit` names.""" + k = Kit(self.name, self.kit_id) + k.doors.extend(self.doors) + k.textures.update(self.textures) + k.lightmaps.update(self.lightmaps) + k.txis.update(self.txis) + k.always.update(self.always) + k.side_padding.update(self.side_padding) + k.top_padding.update(self.top_padding) + k.skyboxes.update(self.skyboxes) + return k diff --git a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py index 82678a4540..7c3aea4bdb 100644 --- a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py +++ b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py @@ -18,8 +18,10 @@ from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor, MDLMDXTuple from pykotor.common.stream import BinaryReader +from pykotor.common.tilekit import TileKit from pykotor.resource.formats.bwm import read_bwm from pykotor.resource.generics.utd import read_utd +from pykotor.tools.tilekit_io import load_tile_kit_v2 from utility.common.geometry import Vector3 if TYPE_CHECKING: @@ -239,8 +241,9 @@ def _load_kits_internal( path: os.PathLike | str, *, record_missing: bool, -) -> tuple[list[Kit], list[MissingFileInfo]]: +) -> tuple[list[Kit], list[TileKit], list[MissingFileInfo]]: kits: list[Kit] = [] + tile_kits: list[TileKit] = [] missing_files: list[MissingFileInfo] = [] missing_ref: list[MissingFileInfo] | None = missing_files if record_missing else None @@ -254,13 +257,30 @@ def _load_kits_internal( kit_json_raw = json.loads(BinaryReader.load_file(file)) except Exception: continue - if not isinstance(kit_json_raw, dict) or "name" not in kit_json_raw: + if not isinstance(kit_json_raw, dict): + continue + if kit_json_raw.get("format_version") == 2: + try: + tk, tmiss = load_tile_kit_v2(file, record_missing=record_missing) + except (OSError, ValueError, TypeError, KeyError): + continue + tile_kits.append(tk) + missing_files.extend(tmiss) + continue + if "name" not in kit_json_raw: continue kit_json = kit_json_raw kit_id = str(kit_json.get("id") or file.stem) kit_name = str(kit_json["name"]) else: kit_json = json.loads(BinaryReader.load_file(file)) + if kit_json.get("format_version") == 2: + try: + tk, _ = load_tile_kit_v2(file, record_missing=False) + except (OSError, ValueError, TypeError, KeyError): + continue + tile_kits.append(tk) + continue kit_id = kit_json.get("id") or file.stem kit_name = kit_json["name"] @@ -311,7 +331,22 @@ def _load_kits_internal( kits.append(kit) - return kits, missing_files + return kits, tile_kits, missing_files + + +def load_kits_unified( + path: os.PathLike | str, +) -> tuple[list[Kit], list[TileKit]]: + """Load v1 Holocron `Kit`s and v2 Kotor.NET-style `TileKit`s from the same directory.""" + kits, tile_kits, _ = _load_kits_internal(path, record_missing=False) + return kits, tile_kits + + +def load_kits_unified_with_missing( + path: os.PathLike | str, +) -> tuple[list[Kit], list[TileKit], list[tuple[str, Path, str]]]: + """Like `load_kits_unified` but also return missing v1/v2 asset paths.""" + return _load_kits_internal(path, record_missing=True) def load_kits(path: os.PathLike | str) -> list[Kit]: @@ -320,8 +355,10 @@ def load_kits(path: os.PathLike | str) -> list[Kit]: Expected layout matches Holocron Toolset kits: - `/.json` - `//...` (folders with resources) + + Files with ``format_version: 2`` are v2 tile kits; use `load_kits_unified` to load them. """ - kits, _missing = _load_kits_internal(path, record_missing=False) + kits, _tk, _missing = _load_kits_internal(path, record_missing=False) return kits @@ -333,4 +370,5 @@ def load_kits_with_missing_files( This mirrors the Toolset's historical `load_kits()` behavior (minus Qt preview loading), so Toolset UI can report missing resources while keeping all non-Qt logic in PyKotor. """ - return _load_kits_internal(path, record_missing=True) + kits, _tk, missing = _load_kits_internal(path, record_missing=True) + return kits, missing diff --git a/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py b/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py new file mode 100644 index 0000000000..a6909d6a41 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/tile_bwm.py @@ -0,0 +1,54 @@ +"""Merge and generate BWM walkmeshes for v2 tile-based indoor maps.""" + +from __future__ import annotations + +from copy import deepcopy + +from pykotor.resource.formats.bwm.bwm_data import BWM, BWMFace, BWMType +from utility.common.geometry import SurfaceMaterial, Vector3 + + +def merge_translated_bwms(sources: list[tuple[BWM, float, float, float]]) -> BWM: + """Merge multiple BWMs into one area walkmesh, each translated by (tx, ty, tz) in world space. + + Vertices are copied per face to avoid shared-mutation issues. Empty input yields an empty + area BWM (caller may substitute a generated floor). + """ + out = BWM() + out.walkmesh_type = BWMType.AreaModel + for bwm, tx, ty, tz in sources: + if not bwm.faces: + continue + b = deepcopy(bwm) + b.translate(tx, ty, tz) + out.faces.extend(b.faces) + return out + + +def generate_flat_floor_quad( + *, + min_x: float, + min_y: float, + size_x: float, + size_y: float, + z: float = 0.0, + material: SurfaceMaterial = SurfaceMaterial.STONE, +) -> BWM: + """Two walkable triangles covering an axis-aligned rectangle in the X/Y plane at fixed Z. + + Used when a floor tile has no WOK; coarse stand-in for procedural walkmesh. KotOR area + walkmeshes are consumed in world space; match your tile compiler's placement convention. + """ + b = BWM() + b.walkmesh_type = BWMType.AreaModel + v0 = Vector3(min_x, min_y, z) + v1 = Vector3(min_x + size_x, min_y, z) + v2 = Vector3(min_x + size_x, min_y + size_y, z) + v3 = Vector3(min_x, min_y + size_y, z) + f1 = BWMFace(v0, v1, v2) + f2 = BWMFace(v0, v2, v3) + f1.material = material + f2.material = material + b.faces.append(f1) + b.faces.append(f2) + return b diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py new file mode 100644 index 0000000000..c8fe2e26c2 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py @@ -0,0 +1,322 @@ +"""Load format_version 2 tile kits (Kotor.NET KitSerializer_V0_1 semantics) from disk.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from pykotor.common.indoorkit import KitComponentHook, KitDoor +from pykotor.common.stream import BinaryReader +from pykotor.common.tilekit import QuaternionWXYZ, TileKit, TileTemplate, TileTemplateKind +from pykotor.resource.formats.bwm import read_bwm +from pykotor.resource.formats.bwm.bwm_data import BWM +from pykotor.resource.generics.utd import read_utd +from utility.common.geometry import Vector3 + +if TYPE_CHECKING: + import os + + MissingFileInfo = tuple[str, Path, str] + +_NUM_RE = re.compile(r"(\d+)") + + +def _get_nums(s: str) -> list[int]: + return [int(m.group(1)) for m in _NUM_RE.finditer(s)] + + +def _load_binary( + file_path: Path, + *, + kit_name: str, + kind: str, + missing_files: list[MissingFileInfo] | None, +) -> bytes | None: + if missing_files is None: + return BinaryReader.load_file(file_path) if file_path.is_file() else None + if not file_path.is_file(): + missing_files.append((kit_name, file_path, kind)) + return None + try: + return BinaryReader.load_file(file_path) + except FileNotFoundError: + missing_files.append((kit_name, file_path, kind)) + return None + + +def _load_textures_txi( + folder_path: Path, + target: dict[str, bytes], + txis: dict[str, bytes], + kit_name: str, + missing_files: list[MissingFileInfo] | None, + kind: str, + use_upper_txi_name: bool, +) -> None: + if not folder_path.is_dir(): + return + for tga_file in (f for f in folder_path.iterdir() if f.suffix.lower() == ".tga"): + resref = tga_file.stem.upper() + raw = _load_binary(tga_file, kit_name=kit_name, kind=kind, missing_files=missing_files) + if raw is not None: + target[resref] = raw + txi_stem = resref if use_upper_txi_name else tga_file.stem + txi_path = folder_path / f"{txi_stem}.txi" + txis[resref] = BinaryReader.load_file(txi_path) if txi_path.is_file() else b"" + + +def _load_skyboxes_tilekit( + kit: TileKit, + skyboxes_path: Path, + kit_name: str, + missing_files: list[MissingFileInfo] | None, +) -> None: + if not skyboxes_path.is_dir(): + return + for skybox_str in (f.stem.upper() for f in skyboxes_path.iterdir() if f.suffix.lower() == ".mdl"): + mdl_path = skyboxes_path / f"{skybox_str}.mdl" + mdx_path = skyboxes_path / f"{skybox_str}.mdx" + mdl = _load_binary(mdl_path, kit_name=kit_name, kind="skybox model", missing_files=missing_files) + mdx = _load_binary(mdx_path, kit_name=kit_name, kind="skybox model", missing_files=missing_files) + if mdl and mdx: + from pykotor.common.indoorkit import MDLMDXTuple # noqa: PLC0415 + + kit.skyboxes[skybox_str] = MDLMDXTuple(mdl, mdx) + + +def _load_doorway_padding_tilekit( + kit: TileKit, + doorway_path: Path, + kit_name: str, + missing_files: list[MissingFileInfo] | None, +) -> None: + if not doorway_path.is_dir(): + return + from pykotor.common.indoorkit import MDLMDXTuple # noqa: PLC0415 + + for padding_id in (f.stem for f in doorway_path.iterdir() if f.suffix.lower() == ".mdl"): + mdl_path = doorway_path / f"{padding_id}.mdl" + mdx_path = doorway_path / f"{padding_id}.mdx" + mdl = _load_binary(mdl_path, kit_name=kit_name, kind="doorway padding", missing_files=missing_files) + mdx = _load_binary(mdx_path, kit_name=kit_name, kind="doorway padding", missing_files=missing_files) + if mdl is None or mdx is None: + continue + nums = _get_nums(padding_id) + if len(nums) < 2: + continue + door_id, padding_size = nums[0], nums[1] + tup = MDLMDXTuple(mdl, mdx) + if padding_id.lower().startswith("side"): + kit.side_padding.setdefault(door_id, {})[padding_size] = tup + if padding_id.lower().startswith("top"): + kit.top_padding.setdefault(door_id, {})[padding_size] = tup + + +def _load_doors_tilekit( + kit: TileKit, + doors_json: list[dict], + base_path: Path, + kit_name: str, + missing_files: list[MissingFileInfo] | None, +) -> None: + for door_json in doors_json: + try: + utd_k1_path = base_path / f"{door_json['utd_k1']}.utd" + utd_k2_path = base_path / f"{door_json['utd_k2']}.utd" + except Exception: + continue + try: + utd_k1 = read_utd(utd_k1_path) + utd_k2 = read_utd(utd_k2_path) + except FileNotFoundError as e: + if missing_files is not None: + missing_files.append((kit_name, Path(e.filename or ""), "door utd")) + continue + w = float(door_json.get("width", 2.0)) + h = float(door_json.get("height", 3.0)) + kit.doors.append(KitDoor(utd_k1, utd_k2, w, h)) + + +def _parse_doorhooks( + doorhooks_json: list[dict], + doors: list[KitDoor], +) -> list[KitComponentHook]: + hooks: list[KitComponentHook] = [] + for h in doorhooks_json: + try: + pos = Vector3(float(h["x"]), float(h["y"]), float(h["z"])) + rot = float(h["rotation"]) + di = int(h["door"]) + edge = int(h["edge"]) + if di < 0 or di >= len(doors): + continue + hooks.append(KitComponentHook(pos, rot, edge, doors[di])) + except Exception: + continue + return hooks + + +def _load_template_entry( + data: dict, + kind: TileTemplateKind, + resref: str, + base_path: Path, + doors: list[KitDoor], + kit_name: str, + missing_files: list[MissingFileInfo] | None, +) -> TileTemplate: + offset_l = data.get("offset", [0.0, 0.0, 0.0]) + if len(offset_l) < 3: + offset_l = [0.0, 0.0, 0.0] + offset = Vector3(float(offset_l[0]), float(offset_l[1]), float(offset_l[2])) + quat = QuaternionWXYZ.from_json(data.get("rotation")) + wok_path = base_path / f"{resref}.wok" + mdl_path = base_path / f"{resref}.mdl" + mdx_path = base_path / f"{resref}.mdx" + wok: BWM | None = None + # v2 templates may ship MDL-only (Kotor.NET); do not treat missing WOK/MDL/MDX as errors. + wok_bytes = _load_binary(wok_path, kit_name=kit_name, kind="walkmesh", missing_files=None) + if wok_bytes: + wok = read_bwm(wok_bytes) + mdl = _load_binary(mdl_path, kit_name=kit_name, kind="model", missing_files=None) or b"" + mdx = _load_binary(mdx_path, kit_name=kit_name, kind="mdx", missing_files=None) or b"" + th = _parse_doorhooks(data.get("doorhooks", []), doors) + return TileTemplate( + kind=kind, + template_id=str(data.get("id", resref)), + resref=resref, + offset=offset, + rotation=quat, + mdl=mdl, + mdx=mdx, + wok=wok, # type: ignore[arg-type] + doorhooks=th, + ) + + +def _load_template_list( + items: list[dict] | None, + kind: TileTemplateKind, + base_path: Path, + doors: list[KitDoor], + kit_name: str, + missing_files: list[MissingFileInfo] | None, +) -> list[TileTemplate]: + if not items: + return [] + out: list[TileTemplate] = [] + for data in items: + if not isinstance(data, dict): + continue + resref = str(data.get("resref") or data.get("id") or "") + if not resref: + continue + out.append( + _load_template_entry( + data, + kind, + resref, + base_path, + doors, + kit_name, + missing_files, + ) + ) + return out + + +def load_tile_kit_v2( + path: os.PathLike | str, + *, + record_missing: bool = False, +) -> tuple[TileKit, list[MissingFileInfo]]: + """Load a single v2 `tile_kit.json` file (or kit root json inside kits dir).""" + p = Path(path) + raw = json.loads(BinaryReader.load_file(p)) + if not isinstance(raw, dict) or raw.get("format_version") != 2: + msg = "Not a v2 tile kit json" + raise ValueError(msg) + kit_id = str(raw.get("id") or p.stem) + name = str(raw.get("name", kit_id)) + base_path = p.parent if p.parent.name == kit_id else p.parent / kit_id + if not base_path.is_dir(): + base_path = p.parent + + missing: list[MissingFileInfo] = [] + mref: list[MissingFileInfo] | None = missing if record_missing else None + kit = TileKit( + name=name, + kit_id=kit_id, + formats_serializer=str(raw.get("serializer", "")), + ) + _load_doors_tilekit(kit, raw.get("doors", []), base_path, name, mref) + _load_textures_txi( + base_path / "textures", + kit.textures, + kit.txis, + name, + mref, + "texture", + use_upper_txi_name=True, + ) + _load_textures_txi( + base_path / "lightmaps", + kit.lightmaps, + kit.txis, + name, + mref, + "lightmap", + use_upper_txi_name=False, + ) + if (base_path / "always").is_dir(): + for f in (base_path / "always").iterdir(): + b = _load_binary(f, kit_name=name, kind="always file", missing_files=mref) + if b is not None: + kit.always[f] = b + _load_skyboxes_tilekit(kit, base_path / "skyboxes", name, mref) + _load_doorway_padding_tilekit(kit, base_path / "doorway", name, mref) + + tpl = raw.get("templates") or {} + kit.floors = _load_template_list( + tpl.get("floors"), + TileTemplateKind.FLOOR, + base_path, + kit.doors, + name, + mref, + ) + kit.ceilings = _load_template_list( + tpl.get("ceilings"), + TileTemplateKind.CEILING, + base_path, + kit.doors, + name, + mref, + ) + kit.walls = _load_template_list( + tpl.get("walls"), + TileTemplateKind.WALL, + base_path, + kit.doors, + name, + mref, + ) + kit.corners = _load_template_list( + tpl.get("corners"), + TileTemplateKind.CORNER, + base_path, + kit.doors, + name, + mref, + ) + kit.doorframes = _load_template_list( + tpl.get("doorframes"), + TileTemplateKind.DOORFRAME, + base_path, + kit.doors, + name, + mref, + ) + return kit, missing diff --git a/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py new file mode 100644 index 0000000000..57a5e7b0d6 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py @@ -0,0 +1,150 @@ +"""Compile v2 tile_layout + TileKit into IndoorMap rooms (EmbeddedKit / synthetic component).""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor +from pykotor.common.indoormap import EmbeddedKit, IndoorMap, IndoorMapRoom, _ensure_embedded_kit +from pykotor.common.tilekit import TileKit +from pykotor.resource.formats.bwm.bwm_data import BWM +from pykotor.resource.generics.utd import UTD +from pykotor.tools.tile_bwm import generate_flat_floor_quad, merge_translated_bwms +from utility.common.geometry import Vector3 + +if TYPE_CHECKING: + pass + +_EMBEDDED_TILE = "__tile_compiled__" + + +@dataclass +class TileLayout: + """Grid of floor template ids for a v2 tile kit (PyKotor `.indoor` extension).""" + + format_version: int = 1 + kit_id: str = "" + cell_size: float = 4.0 + grid_w: int = 0 + grid_h: int = 0 + floor_cells: list[str | None] = field(default_factory=list) + + def cell_index(self, ix: int, iy: int) -> int: + return iy * self.grid_w + ix + + +def _default_door() -> KitDoor: + utd = UTD() + utd.resref.set_data("sw_door") + utd.tag = "tile_default_door" + return KitDoor(utdK1=utd, utdK2=utd, width=2.0, height=3.0) + + +def tile_layout_to_merged_bwm( + layout: TileLayout, + tile_kit: TileKit, + *, + z: float = 0.0, +) -> BWM: + """Build one merged world-space BWM from the floor layer.""" + if layout.grid_w <= 0 or layout.grid_h <= 0: + return BWM() + cell = float(layout.cell_size) + parts: list[tuple[BWM, float, float, float]] = [] + for iy in range(layout.grid_h): + for ix in range(layout.grid_w): + idx = layout.cell_index(ix, iy) + if idx >= len(layout.floor_cells): + continue + tid = layout.floor_cells[idx] + if not tid: + continue + tpl = tile_kit.template_by_id(tid) + if tpl is None: + continue + wx = float(ix) * cell + wy = float(iy) * cell + if tpl.wok and tpl.wok.faces: + b = deepcopy(tpl.wok) + parts.append( + (b, wx + tpl.offset.x, wy + tpl.offset.y, z + tpl.offset.z), + ) + else: + b = generate_flat_floor_quad( + min_x=0.0, + min_y=0.0, + size_x=cell, + size_y=cell, + z=0.0, + ) + parts.append((b, wx, wy, z + tpl.offset.z)) + if not parts: + return BWM() + return merge_translated_bwms(parts) + + +def apply_tile_layout_to_map( + im: IndoorMap, + layout: TileLayout, + tile_kit: TileKit, + kits: list[Kit], + *, + floor_z: float = 0.0, +) -> None: + """Replace map rooms with one compiled floor room; persist `tile_layout` on the map. + + The merged walkmesh and first non-empty floor template MDL/MDX are stored in EmbeddedKit. + """ + merged = tile_layout_to_merged_bwm(layout, tile_kit, z=floor_z) + mdl, mdx = b"", b"" + for tid in layout.floor_cells: + if not tid: + continue + tpl = tile_kit.template_by_id(tid) + if tpl and len(tpl.mdl) >= 12: + mdl, mdx = tpl.mdl, tpl.mdx + break + ek = _ensure_embedded_kit(kits) + if not ek.doors: + ek.doors.append(_default_door()) + ek.components[:] = [c for c in ek.components if c.id != _EMBEDDED_TILE] + comp = KitComponent(ek, "Tile floor (compiled)", _EMBEDDED_TILE, merged, mdl, mdx) + if tile_kit.floors: + ref = tile_kit.floors[0] + for hook in ref.doorhooks: + if hook.door in ek.doors: + d = hook.door + elif ek.doors: + d = ek.doors[0] + else: + continue + comp.hooks.append( + KitComponentHook( + Vector3(hook.position.x, hook.position.y, hook.position.z), + hook.rotation, + hook.edge, + d, + ) + ) + ek.components.append(comp) + im.rooms.clear() + im.rooms.append( + IndoorMapRoom( + comp, + Vector3(0.0, 0.0, 0.0), + 0.0, + flip_x=False, + flip_y=False, + ) + ) + im.tile_layout = { + "format_version": layout.format_version, + "kit_id": layout.kit_id, + "cell_size": layout.cell_size, + "grid_w": layout.grid_w, + "grid_h": layout.grid_h, + "floor_cells": list(layout.floor_cells), + } + im.indoor_map_version = max(im.indoor_map_version, 2) diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json new file mode 100644 index 0000000000..c4b6e592d1 --- /dev/null +++ b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles.json @@ -0,0 +1,21 @@ +{ + "format_version": 2, + "serializer": "Kotor.NET KitSerializer_V0_1", + "name": "Minimal Tile Fixture", + "id": "minimal_tiles", + "doors": [], + "templates": { + "floors": [ + { + "id": "floor_plain", + "resref": "floor_plain", + "offset": [0, 0, 0], + "rotation": [1, 0, 0, 0] + } + ], + "ceilings": [], + "walls": [], + "corners": [], + "doorframes": [] + } +} diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Libraries/PyKotor/tests/test_tilekit_v2.py b/Libraries/PyKotor/tests/test_tilekit_v2.py new file mode 100644 index 0000000000..20713383a3 --- /dev/null +++ b/Libraries/PyKotor/tests/test_tilekit_v2.py @@ -0,0 +1,76 @@ +"""Tests for v2 tile kits, merge BWM, and tile layout compile.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pykotor.common.indoorkit import Kit +from pykotor.common.indoormap import EmbeddedKit, IndoorMap +from pykotor.common.tilekit import TileKit +from pykotor.resource.formats.bwm.bwm_data import BWM +from pykotor.tools.indoorkit import load_kits_unified, load_tile_kit_v2 +from pykotor.tools.tile_bwm import merge_translated_bwms +from pykotor.tools.tilemap_compile import ( + TileLayout, + apply_tile_layout_to_map, + tile_layout_to_merged_bwm, +) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "kits_v2" + + +def test_load_minimal_v2_tile_kit() -> None: + json_path = FIXTURES / "minimal_tiles.json" + tk, missing = load_tile_kit_v2(json_path, record_missing=True) + assert isinstance(tk, object) + assert tk.kit_id == "minimal_tiles" + assert len(tk.floors) == 1 + assert tk.floors[0].template_id == "floor_plain" + assert not missing # no door assets required + + +def test_load_kits_unified_picks_v2() -> None: + kits, tile_kits = load_kits_unified(FIXTURES) + assert isinstance(kits, list) + assert len(tile_kits) >= 1 + assert any(tk.kit_id == "minimal_tiles" for tk in tile_kits) + + +def test_tile_layout_merged_bwm_2x2() -> None: + tk, _ = load_tile_kit_v2(FIXTURES / "minimal_tiles.json", record_missing=False) + lo = TileLayout( + format_version=1, + kit_id=tk.kit_id, + cell_size=2.0, + grid_w=2, + grid_h=2, + floor_cells=["floor_plain", "floor_plain", "floor_plain", "floor_plain"], + ) + bwm = tile_layout_to_merged_bwm(lo, tk, z=0.0) + assert len(bwm.faces) == 8 + assert isinstance(bwm, BWM) + + +def test_apply_tile_layout_creates_embedded_room() -> None: + tk, _ = load_tile_kit_v2(FIXTURES / "minimal_tiles.json", record_missing=False) + im = IndoorMap() + lo = TileLayout( + kit_id=tk.kit_id, + cell_size=4.0, + grid_w=1, + grid_h=1, + floor_cells=["floor_plain"], + ) + kits_merged: list[Kit] = [] + apply_tile_layout_to_map(im, lo, tk, kits_merged) + assert len(im.rooms) == 1 + assert im.tile_layout is not None + assert im.tile_layout.get("kit_id") == "minimal_tiles" + assert isinstance(im.rooms[0].component.kit, EmbeddedKit) + + +def test_merge_translated_empty() -> None: + b = merge_translated_bwms([]) + assert b.faces == [] diff --git a/docs/specs/kits_v2_kotor_net_v0_1.md b/docs/specs/kits_v2_kotor_net_v0_1.md new file mode 100644 index 0000000000..207a6434f5 --- /dev/null +++ b/docs/specs/kits_v2_kotor_net_v0_1.md @@ -0,0 +1,57 @@ +# Indoor kit format v2 (Kotor.NET `KitSerializer_V0_1` semantic parity) + +PyKotor `format_version: 2` JSON matches the **on-disk contract** used by Kotor.NET area-designer kit serialization (v0.1). This document is the compatibility reference; it does **not** require checking out or modifying the Kotor.NET `rework-area-designer` branch—copy field names and sample payloads from a read-only clone when drift is suspected. + +## Top-level object + +| Field | Type | Required | Notes | +|------|------|----------|--------| +| `format_version` | `integer` | Yes | Must be `2` for this spec. | +| `serializer` | `string` | No | e.g. `"Kotor.NET KitSerializer_V0_1"` (metadata only). | +| `name` | `string` | Yes | Human-readable kit name. | +| `id` | `string` | Yes | Directory name under `kits/` (same as v1). | +| `doors` | `array` | No | Same as v1: `utd_k1`, `utd_k2`, `width`, `height`. | +| `templates` | `object` | Yes | Categorized **tile** templates (not prebuilt rooms). | + +## `templates` categories + +Each category is an array of **template** objects. Templates are small MDL/MDX (and optional WOK) pieces: floors, ceilings, walls, corners, door frames. + +| Category key | Role | +|-------------|------| +| `floors` | Walkable horizontal tiles. | +| `ceilings` | Ceiling geometry. | +| `walls` | Wall segments. | +| `corners` | Corner pieces. | +| `doorframes` | Doorway framing. | + +## Template object + +| Field | Type | Required | Notes | +|------|------|----------|--------| +| `id` | `string` | Yes | Unique within the kit. | +| `resref` | `string` | No | If omitted, `id` is used to resolve `resref.mdl` / `resref.mdx` / optional `resref.wok`. | +| `offset` | `[x, y, z]` | No | Local origin (float), default `[0,0,0]`. | +| `rotation` | `[w, x, y, z]` | No | **Unit quaternion** (float). Default identity `[1, 0, 0, 0]`. | +| `doorhooks` | `array` | No | Optional; same semantics as v1 `doorhooks` on components (`x`,`y`,`z`,`rotation`,`door` index, `edge`). | + +On-disk layout under `kits//`: + +- `templates` do **not** require `.wok` (Kotor.NET v0.1 may be MDL-only). PyKotor may **merge** per-piece WOKs when present, or **generate** walkable BWM at build from floor geometry (see implementation). + +## v1 vs v2 + +- **v1** Holocron kits: top-level `components[]` with full room pieces and required `.wok` per component. +- **v2** tile kits: `templates` + `format_version: 2`; aimed at grid-based area design and optional procedural WOK. + +## `.indoor` map: `tile_layout` (PyKotor extension) + +Optional block on the indoor map JSON (alongside `rooms`): + +- `format_version` (int): layout schema version. +- `kit_id`: which v2 `TileKit` the grid references. +- `cell_size`: world units per cell (float or `[sx, sy]`). +- `grid_w`, `grid_h`: integer dimensions. +- `floor_cells`: row-major list of `template_id` or `null` (length `grid_w * grid_h`). + +The authoritative build path still produces **WOK/MDL** via `IndoorMap.build()`; `tile_layout` is compiled to placed geometry and merged walkmeshes as implemented in `pykotor.tools.tilemap_compile`. From c0959353d638f024c1935bd651a9744be6896383 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 27 Apr 2026 20:33:43 -0500 Subject: [PATCH 02/29] chore: remove unused imports in tilemap compile and v2 tests --- Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py | 6 +----- Libraries/PyKotor/tests/test_tilekit_v2.py | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py index 57a5e7b0d6..160be705ac 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py @@ -4,19 +4,15 @@ from copy import deepcopy from dataclasses import dataclass, field -from typing import TYPE_CHECKING from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor -from pykotor.common.indoormap import EmbeddedKit, IndoorMap, IndoorMapRoom, _ensure_embedded_kit +from pykotor.common.indoormap import IndoorMap, IndoorMapRoom, _ensure_embedded_kit from pykotor.common.tilekit import TileKit from pykotor.resource.formats.bwm.bwm_data import BWM from pykotor.resource.generics.utd import UTD from pykotor.tools.tile_bwm import generate_flat_floor_quad, merge_translated_bwms from utility.common.geometry import Vector3 -if TYPE_CHECKING: - pass - _EMBEDDED_TILE = "__tile_compiled__" diff --git a/Libraries/PyKotor/tests/test_tilekit_v2.py b/Libraries/PyKotor/tests/test_tilekit_v2.py index 20713383a3..1f2bcf6a25 100644 --- a/Libraries/PyKotor/tests/test_tilekit_v2.py +++ b/Libraries/PyKotor/tests/test_tilekit_v2.py @@ -4,8 +4,6 @@ from pathlib import Path -import pytest - from pykotor.common.indoorkit import Kit from pykotor.common.indoormap import EmbeddedKit, IndoorMap from pykotor.common.tilekit import TileKit From 8c078e2299582b49fd972021830eef9368181b5f Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 27 Apr 2026 20:43:51 -0500 Subject: [PATCH 03/29] changes --- Tools/HolocronToolset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index b0cbf9a32c..f7a656b387 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit b0cbf9a32ca57c3bb2f4d16224e8ec71ab31e784 +Subproject commit f7a656b3872188461ce3faaf362a8af29ad0c250 From 783df4befbc7338c8dceddd48adc1bfbf2af517d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 02:04:39 +0000 Subject: [PATCH 04/29] fix: tile BWM quat/offset, narrow except in tilekit_io, allow empty tool discovery in ci Co-authored-by: Boden --- .github/scripts/discover_tools.py | 8 +- .../PyKotor/src/pykotor/tools/tilekit_io.py | 4 +- .../src/pykotor/tools/tilemap_compile.py | 46 ++++- Libraries/PyKotor/tests/test_tilekit_v2.py | 1 - tool_metadata.py | 164 ++++++++++++++++++ 5 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 tool_metadata.py diff --git a/.github/scripts/discover_tools.py b/.github/scripts/discover_tools.py index f33264b0aa..5dbfe60d0d 100644 --- a/.github/scripts/discover_tools.py +++ b/.github/scripts/discover_tools.py @@ -32,8 +32,12 @@ def main() -> None: tools = [tool for tool in tools if tool.is_cli] if not tools: - print("Error: No tools discovered", file=sys.stderr) - sys.exit(1) + if args.format == "json": + print("[]") + else: + print("tools_matrix=[]") + print("Discovered 0 tools (workspace may lack vendored Tools/* checkouts)", file=sys.stderr) + return payload = [tool.to_dict() for tool in tools] if args.format == "json": diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py index c8fe2e26c2..ac0491a66d 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py @@ -125,7 +125,7 @@ def _load_doors_tilekit( try: utd_k1_path = base_path / f"{door_json['utd_k1']}.utd" utd_k2_path = base_path / f"{door_json['utd_k2']}.utd" - except Exception: + except (KeyError, TypeError, ValueError): continue try: utd_k1 = read_utd(utd_k1_path) @@ -153,7 +153,7 @@ def _parse_doorhooks( if di < 0 or di >= len(doors): continue hooks.append(KitComponentHook(pos, rot, edge, doors[di])) - except Exception: + except (KeyError, TypeError, ValueError, IndexError): continue return hooks diff --git a/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py index 160be705ac..27177f1e54 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilemap_compile.py @@ -2,20 +2,53 @@ from __future__ import annotations +import math from copy import deepcopy from dataclasses import dataclass, field from pykotor.common.indoorkit import Kit, KitComponent, KitComponentHook, KitDoor from pykotor.common.indoormap import IndoorMap, IndoorMapRoom, _ensure_embedded_kit -from pykotor.common.tilekit import TileKit +from pykotor.common.tilekit import QuaternionWXYZ, TileKit from pykotor.resource.formats.bwm.bwm_data import BWM from pykotor.resource.generics.utd import UTD from pykotor.tools.tile_bwm import generate_flat_floor_quad, merge_translated_bwms from utility.common.geometry import Vector3 +_EM_EPS = 1e-6 + _EMBEDDED_TILE = "__tile_compiled__" +def _is_identity_quaternion(q: QuaternionWXYZ) -> bool: + return ( + abs(q.w - 1.0) < _EM_EPS + and abs(q.x) < _EM_EPS + and abs(q.y) < _EM_EPS + and abs(q.z) < _EM_EPS + ) + + +def _apply_quaternion_wxyz_to_bwm(bwm: BWM, q: QuaternionWXYZ) -> None: + """Rotate BWM vertex positions in-place with unit quaternion (w, x, y, z).""" + if _is_identity_quaternion(q): + return + w, x, y, z = q.w, q.x, q.y, q.z + inv_len = 1.0 / math.sqrt(w * w + x * x + y * y + z * z) + w, x, y, z = w * inv_len, x * inv_len, y * inv_len, z * inv_len + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + m00, m01, m02 = 1.0 - 2.0 * (yy + zz), 2.0 * (xy - wz), 2.0 * (xz + wy) + m10, m11, m12 = 2.0 * (xy + wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz - wx) + m20, m21, m22 = 2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (xx + yy) + for vertex in bwm.vertices(): + ox, oy, oz = vertex.x, vertex.y, vertex.z + vertex.x = m00 * ox + m01 * oy + m02 * oz + vertex.y = m10 * ox + m11 * oy + m12 * oz + vertex.z = m20 * ox + m21 * oy + m22 * oz + bwm._invalidate_face_cache() # noqa: SLF001 + + @dataclass class TileLayout: """Grid of floor template ids for a v2 tile kit (PyKotor `.indoor` extension).""" @@ -62,11 +95,13 @@ def tile_layout_to_merged_bwm( continue wx = float(ix) * cell wy = float(iy) * cell + ox = tpl.offset.x + oy = tpl.offset.y + oz = tpl.offset.z if tpl.wok and tpl.wok.faces: b = deepcopy(tpl.wok) - parts.append( - (b, wx + tpl.offset.x, wy + tpl.offset.y, z + tpl.offset.z), - ) + _apply_quaternion_wxyz_to_bwm(b, tpl.rotation) + parts.append((b, wx + ox, wy + oy, z + oz)) else: b = generate_flat_floor_quad( min_x=0.0, @@ -75,7 +110,8 @@ def tile_layout_to_merged_bwm( size_y=cell, z=0.0, ) - parts.append((b, wx, wy, z + tpl.offset.z)) + _apply_quaternion_wxyz_to_bwm(b, tpl.rotation) + parts.append((b, wx + ox, wy + oy, z + oz)) if not parts: return BWM() return merge_translated_bwms(parts) diff --git a/Libraries/PyKotor/tests/test_tilekit_v2.py b/Libraries/PyKotor/tests/test_tilekit_v2.py index 1f2bcf6a25..a33a4ff188 100644 --- a/Libraries/PyKotor/tests/test_tilekit_v2.py +++ b/Libraries/PyKotor/tests/test_tilekit_v2.py @@ -6,7 +6,6 @@ from pykotor.common.indoorkit import Kit from pykotor.common.indoormap import EmbeddedKit, IndoorMap -from pykotor.common.tilekit import TileKit from pykotor.resource.formats.bwm.bwm_data import BWM from pykotor.tools.indoorkit import load_kits_unified, load_tile_kit_v2 from pykotor.tools.tile_bwm import merge_translated_bwms diff --git a/tool_metadata.py b/tool_metadata.py new file mode 100644 index 0000000000..480e7fef08 --- /dev/null +++ b/tool_metadata.py @@ -0,0 +1,164 @@ +"""Repository tool discovery and PyKotor library path constants (CI + compile scripts).""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + tomllib = None # type: ignore[assignment, misc] + +LIBRARY_SOURCE_PATHS: list[str] = [ + "Libraries/PyKotor/src", + "Libraries/bioware-kaitai-formats", +] +LIBRARY_TEST_PATHS: list[str] = [ + "Libraries/PyKotor/tests", + "Libraries/bioware-kaitai-formats", +] + + +@dataclass +class ToolInfo: + """Metadata for a package under `Tools/`.""" + + directory: str + name: str + build_name: str + display_name: str + path: str + src_path: str + module_name: str + requires_qt: bool + is_cli: bool + tests_path: str | None = field(default=None) + + @property + def relative_path(self) -> str: + return f"Tools/{self.directory}" + + def to_dict(self) -> dict[str, object | str | bool | None]: + return { + "directory": self.directory, + "name": self.name, + "build_name": self.build_name, + "display_name": self.display_name, + "path": self.path, + "src_path": self.src_path, + "module_name": self.module_name, + "requires_qt": self.requires_qt, + "is_cli": self.is_cli, + } + + +def _read_pyproject_data(tool_dir: Path) -> dict[str, object]: + pyproject = tool_dir / "pyproject.toml" + if not pyproject.is_file() or tomllib is None: + return {} + try: + return tomllib.load(pyproject.read_bytes()) # type: ignore[no-untyped-call] + except (OSError, TypeError, ValueError, UnicodeError): + return {} + + +def _script_module(data: dict[str, object]) -> str: + project = data.get("project") + if not isinstance(project, dict): + return "" + scripts = project.get("scripts") + if not isinstance(scripts, dict) or not scripts: + return "" + for v in scripts.values(): + if isinstance(v, str) and ":" in v: + return v.split(":", 1)[0].strip() + return "" + + +def _infer_requires_qt(text: str) -> bool: + low = text.lower() + return "pyqt" in low or "pyside" in low or "qt5" in low or "qt6" in low + + +def _one_tool(repo_root: Path, tool_dir: Path) -> ToolInfo | None: + if not (tool_dir / "pyproject.toml").is_file(): + return None + directory = tool_dir.name + data = _read_pyproject_data(tool_dir) + project = data.get("project") if isinstance(data.get("project"), dict) else {} + proj_name = str(project.get("name", directory)) + build_name = re.sub(r"[^0-9a-zA-Z]+", "-", proj_name).lower().strip("-") + if not build_name: + build_name = directory.lower() + toml_text = (tool_dir / "pyproject.toml").read_text(encoding="utf-8", errors="replace") + requires_qt = _infer_requires_qt(toml_text) + is_cli = not requires_qt + if (tool_dir / "src").is_dir(): + src = tool_dir / "src" + else: + src = tool_dir + src_path = str(src.relative_to(repo_root)) + tests: Path | None = None + for tname in ("tests", "test"): + tpath = tool_dir / tname + if tpath.is_dir(): + tests = tpath + break + tests_path = str(tests.relative_to(repo_root)) if tests is not None else None + mod = _script_module(data) + if not mod: + if (src / "toolset").is_dir(): + mod = "toolset" + elif (src / "pykotor").is_dir(): + mod = "pykotor" + else: + mod = directory + return ToolInfo( + directory=directory, + name=build_name, + build_name=build_name, + display_name=proj_name, + path=f"Tools/{directory}", + src_path=src_path, + module_name=mod, + requires_qt=requires_qt, + is_cli=is_cli, + tests_path=tests_path, + ) + + +def discover_tools(repo_root: Path | str) -> list[ToolInfo]: + """List each `Tools/*/` that contains a `pyproject.toml`.""" + root = Path(repo_root).resolve() + tools_base = root / "Tools" + if not tools_base.is_dir(): + return [] + out: list[ToolInfo] = [] + for child in sorted(tools_base.iterdir(), key=lambda p: p.name.lower()): + if not child.is_dir() or child.name.startswith("."): + continue + t = _one_tool(root, child) + if t is not None: + out.append(t) + return out + + +def resolve_tool(name: str, repo_root: Path | str) -> ToolInfo: + """Return tool metadata; *name* matches directory, build name, or project name (casefold).""" + n = (name or "").strip().casefold() + for tool in discover_tools(repo_root): + if n in { + tool.directory.casefold(), + tool.build_name.casefold(), + tool.name.casefold(), + tool.display_name.casefold(), + }: + return tool + msg = f"Unknown tool: {name!r}" + raise KeyError(msg) From 8e98514981c544fbe2c1d4cd8e333261cdd1046b Mon Sep 17 00:00:00 2001 From: Boden Date: Mon, 27 Apr 2026 21:44:52 -0500 Subject: [PATCH 05/29] Potential fix for pull request finding 'Unused global variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tool_metadata.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tool_metadata.py b/tool_metadata.py index 480e7fef08..c8f3705ef9 100644 --- a/tool_metadata.py +++ b/tool_metadata.py @@ -19,10 +19,6 @@ "Libraries/PyKotor/src", "Libraries/bioware-kaitai-formats", ] -LIBRARY_TEST_PATHS: list[str] = [ - "Libraries/PyKotor/tests", - "Libraries/bioware-kaitai-formats", -] @dataclass From 5f8452691ec5d17bee69e779e98fae69d1d8835f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:10:29 +0000 Subject: [PATCH 06/29] feat(tilekit): parse kotor.net kitserializer_v0_1 format and tile hooks Co-authored-by: Boden --- .../PyKotor/src/pykotor/common/tilekit.py | 85 ++++- .../PyKotor/src/pykotor/tools/tilekit_io.py | 310 +++++++++++++++--- 2 files changed, 337 insertions(+), 58 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/common/tilekit.py b/Libraries/PyKotor/src/pykotor/common/tilekit.py index 14b9928710..4364508a08 100644 --- a/Libraries/PyKotor/src/pykotor/common/tilekit.py +++ b/Libraries/PyKotor/src/pykotor/common/tilekit.py @@ -1,15 +1,20 @@ +from __future__ import annotations + """Tile-based indoor kit (format_version 2) data model. -Semantic parity with Kotor.NET KitSerializer_V0_1: template libraries of floor, ceiling, wall, -corner, and doorframe pieces—not preassembled room components. WOK is optional per template; -build may merge or generate BWM. -""" +Semantic parity with Kotor.NET `KitSerializer_V0_1`: template libraries of floor, ceiling, wall, +inner/outer corner, doorframe, and object pieces—not preassembled room components. WOK is optional +per template; build may merge or generate BWM. -from __future__ import annotations +JSON note (Kotor.NET): `Quaternion.ToFloatArray()` is `[x, y, z, w]` (System.Numerics). PyKotor +stores quaternions internally as `(w, x, y, z)` in `QuaternionWXYZ`; use +`QuaternionWXYZ.from_kotor_net_float_array` when reading hook orientations from .NET JSON. +""" from dataclasses import dataclass, field from enum import IntEnum from pathlib import Path +from typing import Sequence from pykotor.common.indoorkit import Kit, KitComponentHook, KitDoor, MDLMDXTuple from pykotor.resource.formats.bwm.bwm_data import BWM @@ -22,11 +27,14 @@ class TileTemplateKind(IntEnum): WALL = 2 CORNER = 3 DOORFRAME = 4 + INNER_CORNER = 5 + OUTER_CORNER = 6 + OBJECT = 7 @dataclass class QuaternionWXYZ: - """Unit quaternion as (w, x, y, z) — JSON array order in v2 spec.""" + """Unit quaternion as (w, x, y, z) — PyKotor internal order.""" w: float = 1.0 x: float = 0.0 @@ -34,11 +42,64 @@ class QuaternionWXYZ: z: float = 0.0 @classmethod - def from_json(cls, data: list[float] | None) -> QuaternionWXYZ: + def from_json_wxyz(cls, data: list[float] | None) -> QuaternionWXYZ: + """Parse extended PyKotor v2 JSON template field `rotation`: ``[w, x, y, z]``.""" if not data or len(data) < 4: return cls() return cls(float(data[0]), float(data[1]), float(data[2]), float(data[3])) + @classmethod + def from_kotor_net_float_array(cls, data: Sequence[float] | None) -> QuaternionWXYZ: + """Parse Kotor.NET / System.Numerics JSON: ``[x, y, z, w]`` (see `ToFloatArray()`).""" + if not data or len(data) < 4: + return cls() + x, y, z, w = (float(data[0]), float(data[1]), float(data[2]), float(data[3])) + return cls(w=w, x=x, y=y, z=z) + + # Backwards compatibility for older tests/code using `from_json` + from_json = from_json_wxyz + + +@dataclass +class DoorframeHookTemplate: + """Hook on a doorframe template (`KitSerializer_V0_1` doorframes[].hooks).""" + + position: Vector3 = field(default_factory=Vector3.from_null) + orientation: QuaternionWXYZ = field(default_factory=QuaternionWXYZ) + + +@dataclass +class WallHookTemplate: + """Per-side wall slot on a kit tile (`tile.wallHooks`).""" + + default_wall_id: str + position: Vector3 + orientation: QuaternionWXYZ + + +@dataclass +class CornerHookTemplate: + """Inner or outer corner hook on a kit tile.""" + + default_corner_id: str + adjacent: list[int] + position: Vector3 + orientation: QuaternionWXYZ + + +@dataclass +class KitTileRecord: + """One composable floor cell definition from `data.tiles` (Kotor.NET `TileTemplate`).""" + + tile_id: str + name: str + default_floor_id: str + default_ceiling_id: str + wall_hooks: list[WallHookTemplate] = field(default_factory=list) + inner_corner_hooks: list[CornerHookTemplate] = field(default_factory=list) + outer_corner_hooks: list[CornerHookTemplate] = field(default_factory=list) + ceiling_hooks: list[CornerHookTemplate] = field(default_factory=list) + @dataclass class TileTemplate: @@ -53,6 +114,8 @@ class TileTemplate: mdx: bytes = b"" wok: BWM | None = None doorhooks: list[KitComponentHook] = field(default_factory=list) + doorframe_hooks: list[DoorframeHookTemplate] = field(default_factory=list) + doorframe_id: str | None = None @property def has_walkmesh(self) -> bool: @@ -70,8 +133,13 @@ class TileKit: ceilings: list[TileTemplate] = field(default_factory=list) walls: list[TileTemplate] = field(default_factory=list) corners: list[TileTemplate] = field(default_factory=list) + inner_corners: list[TileTemplate] = field(default_factory=list) + outer_corners: list[TileTemplate] = field(default_factory=list) doorframes: list[TileTemplate] = field(default_factory=list) + objects: list[TileTemplate] = field(default_factory=list) + tiles: list[KitTileRecord] = field(default_factory=list) formats_serializer: str = "" + kotor_net_format_id: str = "" textures: dict[str, bytes] = field(default_factory=dict) lightmaps: dict[str, bytes] = field(default_factory=dict) @@ -87,7 +155,10 @@ def all_templates(self) -> list[TileTemplate]: *self.ceilings, *self.walls, *self.corners, + *self.inner_corners, + *self.outer_corners, *self.doorframes, + *self.objects, ] def template_by_id(self, template_id: str) -> TileTemplate | None: diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py index ac0491a66d..df23c9e81c 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py @@ -1,4 +1,4 @@ -"""Load format_version 2 tile kits (Kotor.NET KitSerializer_V0_1 semantics) from disk.""" +"""Load format_version 2 tile kits (Kotor.NET `KitSerializer_V0_1` semantics) from disk.""" from __future__ import annotations @@ -9,7 +9,16 @@ from pykotor.common.indoorkit import KitComponentHook, KitDoor from pykotor.common.stream import BinaryReader -from pykotor.common.tilekit import QuaternionWXYZ, TileKit, TileTemplate, TileTemplateKind +from pykotor.common.tilekit import ( + CornerHookTemplate, + DoorframeHookTemplate, + KitTileRecord, + QuaternionWXYZ, + TileKit, + TileTemplate, + TileTemplateKind, + WallHookTemplate, +) from pykotor.resource.formats.bwm import read_bwm from pykotor.resource.formats.bwm.bwm_data import BWM from pykotor.resource.generics.utd import read_utd @@ -158,6 +167,24 @@ def _parse_doorhooks( return hooks +def _vec3_from_json(seq: object) -> Vector3: + if not isinstance(seq, (list, tuple)) or len(seq) < 3: + return Vector3.from_null() + return Vector3(float(seq[0]), float(seq[1]), float(seq[2])) + + +def _parse_kotor_net_orient(data: object) -> QuaternionWXYZ: + if isinstance(data, list) and len(data) >= 4: + return QuaternionWXYZ.from_kotor_net_float_array([float(x) for x in data[:4]]) + return QuaternionWXYZ() + + +def _parse_kotor_net_orient_wxyz(data: object) -> QuaternionWXYZ: + if isinstance(data, list) and len(data) >= 4: + return QuaternionWXYZ.from_json_wxyz([float(x) for x in data[:4]]) + return QuaternionWXYZ() + + def _load_template_entry( data: dict, kind: TileTemplateKind, @@ -166,23 +193,44 @@ def _load_template_entry( doors: list[KitDoor], kit_name: str, missing_files: list[MissingFileInfo] | None, + *, + kotor_net_json: bool, ) -> TileTemplate: offset_l = data.get("offset", [0.0, 0.0, 0.0]) - if len(offset_l) < 3: + if not isinstance(offset_l, (list, tuple)) or len(offset_l) < 3: offset_l = [0.0, 0.0, 0.0] offset = Vector3(float(offset_l[0]), float(offset_l[1]), float(offset_l[2])) - quat = QuaternionWXYZ.from_json(data.get("rotation")) + rot_raw = data.get("rotation") + quat = ( + _parse_kotor_net_orient(rot_raw) + if kotor_net_json + else _parse_kotor_net_orient_wxyz(rot_raw) + ) wok_path = base_path / f"{resref}.wok" mdl_path = base_path / f"{resref}.mdl" mdx_path = base_path / f"{resref}.mdx" wok: BWM | None = None - # v2 templates may ship MDL-only (Kotor.NET); do not treat missing WOK/MDL/MDX as errors. wok_bytes = _load_binary(wok_path, kit_name=kit_name, kind="walkmesh", missing_files=None) if wok_bytes: wok = read_bwm(wok_bytes) mdl = _load_binary(mdl_path, kit_name=kit_name, kind="model", missing_files=None) or b"" mdx = _load_binary(mdx_path, kit_name=kit_name, kind="mdx", missing_files=None) or b"" th = _parse_doorhooks(data.get("doorhooks", []), doors) + df_hooks_raw = data.get("hooks") or [] + df_hooks: list[DoorframeHookTemplate] = [] + if isinstance(df_hooks_raw, list): + for h in df_hooks_raw: + if not isinstance(h, dict): + continue + try: + pos = _vec3_from_json(h.get("position")) + orient = _parse_kotor_net_orient(h.get("orientation")) + df_hooks.append(DoorframeHookTemplate(position=pos, orientation=orient)) + except (TypeError, ValueError): + continue + doorframe_id = data.get("doorframeID") + if doorframe_id is not None: + doorframe_id = str(doorframe_id) return TileTemplate( kind=kind, template_id=str(data.get("id", resref)), @@ -193,6 +241,8 @@ def _load_template_entry( mdx=mdx, wok=wok, # type: ignore[arg-type] doorhooks=th, + doorframe_hooks=df_hooks, + doorframe_id=doorframe_id, ) @@ -203,6 +253,8 @@ def _load_template_list( doors: list[KitDoor], kit_name: str, missing_files: list[MissingFileInfo] | None, + *, + kotor_net_json: bool, ) -> list[TileTemplate]: if not items: return [] @@ -210,7 +262,7 @@ def _load_template_list( for data in items: if not isinstance(data, dict): continue - resref = str(data.get("resref") or data.get("id") or "") + resref = str(data.get("model") or data.get("resref") or data.get("id") or "") if not resref: continue out.append( @@ -222,22 +274,105 @@ def _load_template_list( doors, kit_name, missing_files, + kotor_net_json=kotor_net_json, ) ) return out +def _parse_kit_tiles(raw_tiles: object, *, kotor_net_json: bool) -> list[KitTileRecord]: + if not isinstance(raw_tiles, list): + return [] + tiles: list[KitTileRecord] = [] + for t in raw_tiles: + if not isinstance(t, dict): + continue + try: + tid = str(t["id"]) + name = str(t.get("name", tid)) + default_floor = str(t["defaultFloorID"]) + except (KeyError, TypeError, ValueError): + continue + default_ceiling = "" + dc = t.get("defaultCeilingID") + if dc is not None: + default_ceiling = str(dc) + wall_hooks: list[WallHookTemplate] = [] + for h in t.get("wallHooks") or []: + if not isinstance(h, dict): + continue + try: + dw = str(h["defaultWallID"]) + except (KeyError, TypeError, ValueError): + continue + pos = _vec3_from_json(h.get("position")) + orient = _parse_kotor_net_orient(h.get("orientation")) if kotor_net_json else _parse_kotor_net_orient_wxyz(h.get("orientation")) + wall_hooks.append(WallHookTemplate(default_wall_id=dw, position=pos, orientation=orient)) + + def parse_corner_hooks(key: str, id_key: str) -> list[CornerHookTemplate]: + out: list[CornerHookTemplate] = [] + for h in t.get(key) or []: + if not isinstance(h, dict): + continue + try: + cid = str(h[id_key]) + except (KeyError, TypeError, ValueError): + continue + adj = h.get("adjacencies") + adj_list: list[int] = [] + if isinstance(adj, list): + adj_list = [int(x) for x in adj if isinstance(x, (int, float))] + pos = _vec3_from_json(h.get("position")) + orient = _parse_kotor_net_orient(h.get("orientation")) if kotor_net_json else _parse_kotor_net_orient_wxyz(h.get("orientation")) + out.append( + CornerHookTemplate( + default_corner_id=cid, + adjacent=adj_list, + position=pos, + orientation=orient, + ) + ) + return out + + inner_h = parse_corner_hooks("innerCornerHooks", "defaultInnerCornerID") + outer_h = parse_corner_hooks("outerCornerHooks", "defaultOuterCornerID") + + tiles.append( + KitTileRecord( + tile_id=tid, + name=name, + default_floor_id=default_floor, + default_ceiling_id=default_ceiling, + wall_hooks=wall_hooks, + inner_corner_hooks=inner_h, + outer_corner_hooks=outer_h, + ceiling_hooks=[], + ) + ) + return tiles + + +def _is_v2_tile_kit_dict(raw: dict) -> bool: + if raw.get("format_version") == 2: + return True + fmt = raw.get("format") + return isinstance(fmt, str) and fmt.strip() == "0.1" + + def load_tile_kit_v2( path: os.PathLike | str, *, record_missing: bool = False, ) -> tuple[TileKit, list[MissingFileInfo]]: - """Load a single v2 `tile_kit.json` file (or kit root json inside kits dir).""" + """Load a v2 tile kit JSON (PyKotor `format_version: 2` or Kotor.NET `format: \"0.1\"`).""" p = Path(path) - raw = json.loads(BinaryReader.load_file(p)) - if not isinstance(raw, dict) or raw.get("format_version") != 2: - msg = "Not a v2 tile kit json" + raw_any = json.loads(BinaryReader.load_file(p)) + if not isinstance(raw_any, dict) or not _is_v2_tile_kit_dict(raw_any): + msg = "Not a v2 tile kit json (expect format_version 2 or format 0.1)" raise ValueError(msg) + raw = raw_any + kotor_net_json = raw.get("format_version") != 2 + kit_id = str(raw.get("id") or p.stem) name = str(raw.get("name", kit_id)) base_path = p.parent if p.parent.name == kit_id else p.parent / kit_id @@ -250,6 +385,7 @@ def load_tile_kit_v2( name=name, kit_id=kit_id, formats_serializer=str(raw.get("serializer", "")), + kotor_net_format_id=str(raw.get("format", "")) if isinstance(raw.get("format"), str) else "", ) _load_doors_tilekit(kit, raw.get("doors", []), base_path, name, mref) _load_textures_txi( @@ -278,45 +414,117 @@ def load_tile_kit_v2( _load_skyboxes_tilekit(kit, base_path / "skyboxes", name, mref) _load_doorway_padding_tilekit(kit, base_path / "doorway", name, mref) - tpl = raw.get("templates") or {} - kit.floors = _load_template_list( - tpl.get("floors"), - TileTemplateKind.FLOOR, - base_path, - kit.doors, - name, - mref, - ) - kit.ceilings = _load_template_list( - tpl.get("ceilings"), - TileTemplateKind.CEILING, - base_path, - kit.doors, - name, - mref, - ) - kit.walls = _load_template_list( - tpl.get("walls"), - TileTemplateKind.WALL, - base_path, - kit.doors, - name, - mref, - ) - kit.corners = _load_template_list( - tpl.get("corners"), - TileTemplateKind.CORNER, - base_path, - kit.doors, - name, - mref, - ) - kit.doorframes = _load_template_list( - tpl.get("doorframes"), - TileTemplateKind.DOORFRAME, - base_path, - kit.doors, - name, - mref, - ) + if kotor_net_json: + kit.floors = _load_template_list( + raw.get("floors"), + TileTemplateKind.FLOOR, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.ceilings = _load_template_list( + raw.get("ceilings"), + TileTemplateKind.CEILING, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.walls = _load_template_list( + raw.get("walls"), + TileTemplateKind.WALL, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.doorframes = _load_template_list( + raw.get("doorframes"), + TileTemplateKind.DOORFRAME, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.inner_corners = _load_template_list( + raw.get("innerCorners"), + TileTemplateKind.INNER_CORNER, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.outer_corners = _load_template_list( + raw.get("outerCorners"), + TileTemplateKind.OUTER_CORNER, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.objects = _load_template_list( + raw.get("objects"), + TileTemplateKind.OBJECT, + base_path, + kit.doors, + name, + mref, + kotor_net_json=True, + ) + kit.tiles = _parse_kit_tiles(raw.get("tiles"), kotor_net_json=True) + else: + tpl = raw.get("templates") or {} + kit.floors = _load_template_list( + tpl.get("floors"), + TileTemplateKind.FLOOR, + base_path, + kit.doors, + name, + mref, + kotor_net_json=False, + ) + kit.ceilings = _load_template_list( + tpl.get("ceilings"), + TileTemplateKind.CEILING, + base_path, + kit.doors, + name, + mref, + kotor_net_json=False, + ) + kit.walls = _load_template_list( + tpl.get("walls"), + TileTemplateKind.WALL, + base_path, + kit.doors, + name, + mref, + kotor_net_json=False, + ) + kit.corners = _load_template_list( + tpl.get("corners"), + TileTemplateKind.CORNER, + base_path, + kit.doors, + name, + mref, + kotor_net_json=False, + ) + kit.doorframes = _load_template_list( + tpl.get("doorframes"), + TileTemplateKind.DOORFRAME, + base_path, + kit.doors, + name, + mref, + kotor_net_json=False, + ) + kit.tiles = _parse_kit_tiles(raw.get("tiles"), kotor_net_json=False) return kit, missing From aed782ad1839e37a89a19bb8009b30b4168b09f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:10:33 +0000 Subject: [PATCH 07/29] feat(indoorkit): detect format 0.1 tile kits in unified loader Co-authored-by: Boden --- Libraries/PyKotor/src/pykotor/tools/indoorkit.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py index 7c3aea4bdb..4551da6022 100644 --- a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py +++ b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py @@ -259,7 +259,9 @@ def _load_kits_internal( continue if not isinstance(kit_json_raw, dict): continue - if kit_json_raw.get("format_version") == 2: + fmt = kit_json_raw.get("format") + is_net_v01 = isinstance(fmt, str) and fmt.strip() == "0.1" + if kit_json_raw.get("format_version") == 2 or is_net_v01: try: tk, tmiss = load_tile_kit_v2(file, record_missing=record_missing) except (OSError, ValueError, TypeError, KeyError): @@ -274,7 +276,9 @@ def _load_kits_internal( kit_name = str(kit_json["name"]) else: kit_json = json.loads(BinaryReader.load_file(file)) - if kit_json.get("format_version") == 2: + fmt2 = kit_json.get("format") + is_net_v01_b = isinstance(fmt2, str) and fmt2.strip() == "0.1" + if kit_json.get("format_version") == 2 or is_net_v01_b: try: tk, _ = load_tile_kit_v2(file, record_missing=False) except (OSError, ValueError, TypeError, KeyError): From 7c1b3d5baf388808b172ade2b905464331f3283a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:10:36 +0000 Subject: [PATCH 08/29] feat(gl): add tile kit scene preview helper and invalidate_render_cache Co-authored-by: Boden --- .../PyKotor/src/pykotor/gl/scene/scene.py | 4 + .../src/pykotor/tools/tilekit_preview.py | 96 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py diff --git a/Libraries/PyKotor/src/pykotor/gl/scene/scene.py b/Libraries/PyKotor/src/pykotor/gl/scene/scene.py index 9dbb4bfef2..5f281360b2 100644 --- a/Libraries/PyKotor/src/pykotor/gl/scene/scene.py +++ b/Libraries/PyKotor/src/pykotor/gl/scene/scene.py @@ -161,6 +161,10 @@ def _invalidate_object_cache(self): self._cached_encounter_objects = None self._cached_trigger_objects = None + def invalidate_render_cache(self) -> None: + """Public alias for `_invalidate_object_cache` (e.g. tile kit preview repopulating `objects`).""" + self._invalidate_object_cache() + def _rebuild_object_caches(self): """Rebuild cached object lists for efficient iteration. diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py new file mode 100644 index 0000000000..421410d1dc --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py @@ -0,0 +1,96 @@ +"""Headless 3D preview bridge: map a `TileKit` + `TileLayout` onto `pykotor.gl.scene.Scene`. + +Kotor.NET (`GLEngine.Render`): dequeue GL thread work → viewport → clear → `GeometryRenderer.Render`, +which binds the standard shader, sets view/projection, then for each mesh descriptor binds textures +(two slots + placeholder) and draws. + +PyKotor (`Scene.render`): sync caches → view/projection → main shader → iterate `RenderObject`s and +draw meshes (same conceptual pipeline; richer editor features). + +Use inside a current OpenGL context (e.g. `QOpenGLWidget.paintGL`): call `upload_tile_kit_assets`, +then `populate_scene_tile_grid_floor_preview`, then `scene.render()`. +""" + +from __future__ import annotations + +import io + +from pykotor.common.stream import BinaryReader +from pykotor.common.tilekit import TileKit +from pykotor.gl import eulerAngles, quat +from pykotor.gl.models.read_mdl import gl_load_stitched_model +from pykotor.gl.scene.scene import Scene +from pykotor.gl.shader import Texture +from pykotor.resource.formats.tpc.tpc_auto import read_tpc +from pykotor.tools.tilemap_compile import TileLayout +from utility.common.geometry import Vector3 + + +def _quat_to_euler_v3(q_wxyz: object) -> Vector3: + from pykotor.common.tilekit import QuaternionWXYZ + + if not isinstance(q_wxyz, QuaternionWXYZ): + return Vector3() + r = quat(q_wxyz.w, q_wxyz.x, q_wxyz.y, q_wxyz.z) + e = eulerAngles(r) + return Vector3(float(e.x), float(e.y), float(e.z)) + + +def upload_tile_kit_assets(scene: Scene, tile_kit: TileKit) -> None: + """Upload kit TGAs as TPC-derived textures and register template MDL/MDX in `scene.models`.""" + for resref, raw in tile_kit.textures.items(): + try: + tpc = read_tpc(io.BytesIO(raw)) + scene.textures[resref] = Texture.from_tpc(tpc) + except (OSError, ValueError): + continue + + for tpl in tile_kit.all_templates(): + if len(tpl.mdl) < 12 or not tpl.mdx: + continue + try: + mdl_r = BinaryReader.from_bytes(tpl.mdl, 12) + mdx_r = BinaryReader.from_bytes(tpl.mdx) + scene.models[tpl.resref] = gl_load_stitched_model(scene, mdl_r, mdx_r) + except (OSError, ValueError, RuntimeError): + continue + + +def populate_scene_tile_grid_floor_preview( + scene: Scene, + tile_kit: TileKit, + layout: TileLayout, + *, + floor_z: float = 0.0, + cell_override: float | None = None, +) -> None: + """Place one `RenderObject` per non-empty floor cell (template `resref` model names).""" + scene.objects.clear() + scene.selection.clear() + scene.invalidate_render_cache() + + cell = float(layout.cell_size if cell_override is None else cell_override) + if layout.grid_w <= 0 or layout.grid_h <= 0: + return + + for iy in range(layout.grid_h): + for ix in range(layout.grid_w): + idx = layout.cell_index(ix, iy) + if idx >= len(layout.floor_cells): + continue + tid = layout.floor_cells[idx] + if not tid: + continue + tpl = tile_kit.template_by_id(tid) + if tpl is None or not tpl.resref: + continue + wx = float(ix) * cell + tpl.offset.x + wy = float(iy) * cell + tpl.offset.y + wz = floor_z + tpl.offset.z + rot_euler = _quat_to_euler_v3(tpl.rotation) + from pykotor.gl.scene.render_object import RenderObject # noqa: PLC0415 + + ro = RenderObject(tpl.resref, Vector3(wx, wy, wz), rot_euler) + scene.objects[ro] = ro + + scene.invalidate_render_cache() From 8c369c673380a9628da10f12ed404356ea19bc85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:10:39 +0000 Subject: [PATCH 09/29] test(tilekit): cover kotor.net format 0.1 fixture load Co-authored-by: Boden --- .../fixtures/kits_v2/minimal_kotor_net.json | 31 +++++++++++++++++++ Libraries/PyKotor/tests/test_tilekit_v2.py | 14 +++++++++ 2 files changed, 45 insertions(+) create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json new file mode 100644 index 0000000000..2cb5bf58bd --- /dev/null +++ b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_kotor_net.json @@ -0,0 +1,31 @@ +{ + "format": "0.1", + "version": 1, + "name": "Minimal Kotor.NET-shaped fixture", + "id": "minimal_kotor_net", + "doors": [], + "tiles": [ + { + "id": "cell_a", + "name": "Cell A", + "defaultFloorID": "floor_plain", + "defaultCeilingID": "", + "wallHooks": [], + "innerCornerHooks": [], + "outerCornerHooks": [] + } + ], + "floors": [ + { + "id": "floor_plain", + "name": "Plain", + "model": "floor_plain" + } + ], + "ceilings": [], + "doorframes": [], + "walls": [], + "innerCorners": [], + "outerCorners": [], + "objects": [] +} diff --git a/Libraries/PyKotor/tests/test_tilekit_v2.py b/Libraries/PyKotor/tests/test_tilekit_v2.py index a33a4ff188..ca8a68fc82 100644 --- a/Libraries/PyKotor/tests/test_tilekit_v2.py +++ b/Libraries/PyKotor/tests/test_tilekit_v2.py @@ -28,11 +28,25 @@ def test_load_minimal_v2_tile_kit() -> None: assert not missing # no door assets required +def test_load_kotor_net_format_0_1_tile_kit() -> None: + json_path = FIXTURES / "minimal_kotor_net.json" + tk, missing = load_tile_kit_v2(json_path, record_missing=True) + assert tk.kit_id == "minimal_kotor_net" + assert tk.kotor_net_format_id == "0.1" + assert len(tk.floors) == 1 + assert tk.floors[0].resref == "floor_plain" + assert len(tk.tiles) == 1 + assert tk.tiles[0].tile_id == "cell_a" + assert tk.tiles[0].default_floor_id == "floor_plain" + assert not missing + + def test_load_kits_unified_picks_v2() -> None: kits, tile_kits = load_kits_unified(FIXTURES) assert isinstance(kits, list) assert len(tile_kits) >= 1 assert any(tk.kit_id == "minimal_tiles" for tk in tile_kits) + assert any(tk.kit_id == "minimal_kotor_net" for tk in tile_kits) def test_tile_layout_merged_bwm_2x2() -> None: From 6b39c954f02e15c34b90bc8b21ca0da5d2fa94d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:10:42 +0000 Subject: [PATCH 10/29] docs(specs): align kits v2 spec with kitserializer_v0_1 and gl preview Co-authored-by: Boden --- docs/specs/kits_v2_kotor_net_v0_1.md | 70 ++++++++++++++-------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/specs/kits_v2_kotor_net_v0_1.md b/docs/specs/kits_v2_kotor_net_v0_1.md index 207a6434f5..3c5a110ba9 100644 --- a/docs/specs/kits_v2_kotor_net_v0_1.md +++ b/docs/specs/kits_v2_kotor_net_v0_1.md @@ -1,57 +1,57 @@ # Indoor kit format v2 (Kotor.NET `KitSerializer_V0_1` semantic parity) -PyKotor `format_version: 2` JSON matches the **on-disk contract** used by Kotor.NET area-designer kit serialization (v0.1). This document is the compatibility reference; it does **not** require checking out or modifying the Kotor.NET `rework-area-designer` branch—copy field names and sample payloads from a read-only clone when drift is suspected. +PyKotor accepts **two JSON shapes** that deserialize to the same `TileKit` model: -## Top-level object +1. **PyKotor envelope:** `format_version: 2` with a nested `templates { ... }` object (Holocron extension for grouped categories + optional `offset` / `rotation` per template). +2. **Kotor.NET on-disk:** top-level `format: "0.1"` (constant `KitSerializer_V0_1.FormatID`), integer `version`, and **parallel arrays** `floors`, `ceilings`, `walls`, `doorframes`, `innerCorners`, `outerCorners`, `objects`, plus **`tiles`** for composable cell definitions. + +This document is the compatibility reference; it does **not** require checking out or modifying the Kotor.NET `rework-area-designer` branch. + +## Top-level object (Kotor.NET `0.1`) | Field | Type | Required | Notes | |------|------|----------|--------| -| `format_version` | `integer` | Yes | Must be `2` for this spec. | -| `serializer` | `string` | No | e.g. `"Kotor.NET KitSerializer_V0_1"` (metadata only). | +| `format` | `string` | Yes | Must be `"0.1"`. | +| `version` | `integer` | Yes | Kit revision (C# `Kit.Version`). | | `name` | `string` | Yes | Human-readable kit name. | -| `id` | `string` | Yes | Directory name under `kits/` (same as v1). | -| `doors` | `array` | No | Same as v1: `utd_k1`, `utd_k2`, `width`, `height`. | -| `templates` | `object` | Yes | Categorized **tile** templates (not prebuilt rooms). | +| `id` | `string` | Yes | Must match the JSON filename stem (same rule as Kotor.NET). | +| `doors` | `array` | No | Same as v1 Holocron: `utd_k1`, `utd_k2`, `width`, `height`. | +| `tiles` | `array` | No | Floor-cell blueprints (`defaultFloorID`, wall/corner hooks). | +| `floors`, `ceilings`, `walls`, `doorframes`, `innerCorners`, `outerCorners`, `objects` | `array` | Yes\* | \*May be empty arrays. | -## `templates` categories +## Template entries (`floors` / …) -Each category is an array of **template** objects. Templates are small MDL/MDX (and optional WOK) pieces: floors, ceilings, walls, corners, door frames. +Each entry: `id`, `name`, **`model`** (MDL resref). PyKotor resolves `model.mdl` / `model.mdx` / optional `model.wok` under `kits//`. -| Category key | Role | -|-------------|------| -| `floors` | Walkable horizontal tiles. | -| `ceilings` | Ceiling geometry. | -| `walls` | Wall segments. | -| `corners` | Corner pieces. | -| `doorframes` | Doorway framing. | - -## Template object +## PyKotor envelope (`format_version: 2`) | Field | Type | Required | Notes | |------|------|----------|--------| -| `id` | `string` | Yes | Unique within the kit. | -| `resref` | `string` | No | If omitted, `id` is used to resolve `resref.mdl` / `resref.mdx` / optional `resref.wok`. | -| `offset` | `[x, y, z]` | No | Local origin (float), default `[0,0,0]`. | -| `rotation` | `[w, x, y, z]` | No | **Unit quaternion** (float). Default identity `[1, 0, 0, 0]`. | -| `doorhooks` | `array` | No | Optional; same semantics as v1 `doorhooks` on components (`x`,`y`,`z`,`rotation`,`door` index, `edge`). | +| `format_version` | `integer` | Yes | Must be `2`. | +| `serializer` | `string` | No | e.g. `"Kotor.NET KitSerializer_V0_1"`. | +| `templates` | `object` | Yes | `floors`, `ceilings`, `walls`, `corners`, `doorframes` (legacy single `corners` bucket). | + +Template objects may use `resref` **or** `id` for disk assets; optional `offset`, `rotation`. -On-disk layout under `kits//`: +## Quaternions -- `templates` do **not** require `.wok` (Kotor.NET v0.1 may be MDL-only). PyKotor may **merge** per-piece WOKs when present, or **generate** walkable BWM at build from floor geometry (see implementation). +- **Kotor.NET JSON** saves `System.Numerics.Quaternion` via `ToFloatArray()` → **`[x, y, z, w]`** on hooks. +- **PyKotor `TileTemplate.rotation`** and internal `QuaternionWXYZ` use **`(w, x, y, z)`**. Use `QuaternionWXYZ.from_kotor_net_float_array` when ingesting .NET hook arrays; use `from_json_wxyz` for PyKotor `rotation: [w,x,y,z]` on templates. -## v1 vs v2 +## `tiles[]` (composable cells) -- **v1** Holocron kits: top-level `components[]` with full room pieces and required `.wok` per component. -- **v2** tile kits: `templates` + `format_version: 2`; aimed at grid-based area design and optional procedural WOK. +Each tile: `id`, `name`, `defaultFloorID`, optional `defaultCeilingID`, `wallHooks`, `innerCornerHooks`, `outerCornerHooks` (Kotor.NET also has `CeilingHooks` in memory; serializer currently writes `ceilingHooks: []`). + +Hook entries include `position: [x,y,z]` and `orientation` as **`[x,y,z,w]`** from .NET. ## `.indoor` map: `tile_layout` (PyKotor extension) -Optional block on the indoor map JSON (alongside `rooms`): +Optional block alongside `rooms`: + +- `kit_id`, `cell_size`, `grid_w`, `grid_h`, `floor_cells` (row-major template ids). + +Build still produces **WOK/MDL** via `IndoorMap.build()`; floor walkmesh merging uses `pykotor.tools.tilemap_compile`. -- `format_version` (int): layout schema version. -- `kit_id`: which v2 `TileKit` the grid references. -- `cell_size`: world units per cell (float or `[sx, sy]`). -- `grid_w`, `grid_h`: integer dimensions. -- `floor_cells`: row-major list of `template_id` or `null` (length `grid_w * grid_h`). +## 3D preview (PyKotor GL) -The authoritative build path still produces **WOK/MDL** via `IndoorMap.build()`; `tile_layout` is compiled to placed geometry and merged walkmeshes as implemented in `pykotor.tools.tilemap_compile`. +Kotor.NET: `GLEngine.Render` → clear → `GeometryRenderer` (shader + mesh descriptors). PyKotor: `pykotor.gl.scene.Scene.render()` with `RenderObject` instances. For kit grids, `pykotor.tools.tilekit_preview` uploads kit TGAs / registers MDL bytes on a `Scene`, places floor `RenderObject`s for each grid cell, then the Toolset calls `scene.render()` from `QOpenGLWidget.paintGL`. From 184c12728701277892a174200c6382b25abec91d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:19:52 +0000 Subject: [PATCH 11/29] feat(area-designer): add area serializer v0.1 json helpers Co-authored-by: Boden --- .../src/pykotor/tools/area_designer_io.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 Libraries/PyKotor/src/pykotor/tools/area_designer_io.py diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py new file mode 100644 index 0000000000..8cfde7e21f --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py @@ -0,0 +1,73 @@ +"""Kotor.NET AreaDesigner `AreaSerializer_V0_1` JSON (`format: \"0.1\"`) load/save. + +Mirrors `Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization.AreaSerializer_V0_1`. +Round-trip is used for interchange with .NET tools; PyKotor `.indoor` maps may embed the same +payload under `IndoorMap.area_designer_v01` for lossless studio workflow. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, TypedDict + + +class _FloorRefDict(TypedDict): + kitID: str + templateID: str + + +class _WallRefDict(TypedDict): + kitID: str + templateID: str + + +class _TileDict(TypedDict, total=False): + kitID: str + templateID: str + position: list[float] + orientation: list[float] + floor: _FloorRefDict + ceiling: _FloorRefDict + walls: list[_WallRefDict] + + +class _RoomDict(TypedDict, total=False): + position: list[float] + orientation: list[float] + tiles: list[_TileDict] + + +class AreaDesignerFileV01(TypedDict, total=False): + format: str + rooms: list[_RoomDict] + + +FORMAT_ID = "0.1" + + +def load_area_designer_v01(path: Path | str) -> AreaDesignerFileV01: + """Load an Area Designer JSON file from disk.""" + p = Path(path) + raw = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + msg = "Area file must be a JSON object" + raise ValueError(msg) + fmt = raw.get("format") + if fmt != FORMAT_ID: + msg = f"Expected format '{FORMAT_ID}', got {fmt!r}" + raise ValueError(msg) + return raw # type: ignore[return-value] + + +def save_area_designer_v01(path: Path | str, data: AreaDesignerFileV01) -> None: + """Write an Area Designer JSON file (pretty-printed, UTF-8).""" + p = Path(path) + out: dict[str, Any] = dict(data) + out["format"] = FORMAT_ID + p.write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def empty_area_designer_v01() -> AreaDesignerFileV01: + """Return an empty area matching a new `Area` in Kotor.NET (`AreaDesignerViewModel.NewArea`).""" + return {"format": FORMAT_ID, "rooms": []} From 0ac4bee08f113f09632898ee5a66de64f06d0267 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:19:56 +0000 Subject: [PATCH 12/29] feat(indoormap): persist area_designer_v01; expand tilekit scene to match areaentity Co-authored-by: Boden --- .../PyKotor/src/pykotor/common/indoormap.py | 7 + .../src/pykotor/tools/tilekit_preview.py | 227 ++++++++++++++++-- 2 files changed, 218 insertions(+), 16 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/common/indoormap.py b/Libraries/PyKotor/src/pykotor/common/indoormap.py index 9ba0c8515f..87eb8f5157 100644 --- a/Libraries/PyKotor/src/pykotor/common/indoormap.py +++ b/Libraries/PyKotor/src/pykotor/common/indoormap.py @@ -123,6 +123,7 @@ class IndoorMapDataDict(IndoorMapDataDictBase, total=False): embedded_components: list[EmbeddedComponentDataDict] tile_layout: dict[str, Any] indoor_map_version: int + area_designer_v01: dict[str, Any] _EMBEDDED_KIT_ID = "__embedded__" @@ -221,6 +222,8 @@ def __init__( self.scan_mdls: set[bytes] = set() # Optional v2 tile-grid state (Kotor.NET-style `tile_layout`); see `pykotor.tools.tilemap_compile`. self.tile_layout: dict[str, Any] | None = None + # Optional Kotor.NET AreaDesigner JSON (`format: "0.1"`); see `pykotor.tools.area_designer_io`. + self.area_designer_v01: dict[str, Any] | None = None self.indoor_map_version: int = 1 def rebuild_room_connections(self): @@ -817,6 +820,8 @@ def write(self) -> bytes: data["indoor_map_version"] = self.indoor_map_version if self.tile_layout: data["tile_layout"] = self.tile_layout + if self.area_designer_v01: + data["area_designer_v01"] = self.area_designer_v01 return json.dumps(data).encode("utf-8") @@ -851,6 +856,7 @@ def _load_data( self.target_game_type = data.get("target_game_type", None) self.indoor_map_version = int(data.get("indoor_map_version", 1) or 1) self.tile_layout = data.get("tile_layout") + self.area_designer_v01 = data.get("area_designer_v01") # Load any embedded components first, so room references can resolve. self._load_embedded_components(data.get("embedded_components") or [], kits, logger) @@ -1088,6 +1094,7 @@ def reset(self): self._source_lyt_for_preserve = None self._source_vis_for_preserve = None self.tile_layout = None + self.area_designer_v01 = None self.indoor_map_version = 1 diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py index 421410d1dc..f415f191e6 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py @@ -1,41 +1,71 @@ -"""Headless 3D preview bridge: map a `TileKit` + `TileLayout` onto `pykotor.gl.scene.Scene`. +"""Bridge Kotor.NET AreaDesigner scene graph to `pykotor.gl.scene.Scene`. -Kotor.NET (`GLEngine.Render`): dequeue GL thread work → viewport → clear → `GeometryRenderer.Render`, -which binds the standard shader, sets view/projection, then for each mesh descriptor binds textures -(two slots + placeholder) and draws. +`RoomEntity` / `AreaEntity` in Kotor.NET gather mesh descriptors per tile (floor), wall, +doorframe, corners, and objects with transforms (`Transform = Local * Parent` chain). -PyKotor (`Scene.render`): sync caches → view/projection → main shader → iterate `RenderObject`s and -draw meshes (same conceptual pipeline; richer editor features). +PyKotor matches that draw order by placing `RenderObject`s with `set_transform(mat4)` built from +the same quaternion × translation factors as `Matrix4x4.CreateFromQuaternion * +Matrix4x4.CreateTranslation` in C# (rotation × translation in GLM). -Use inside a current OpenGL context (e.g. `QOpenGLWidget.paintGL`): call `upload_tile_kit_assets`, -then `populate_scene_tile_grid_floor_preview`, then `scene.render()`. +Also supports the simpler PyKotor `TileLayout` grid (floors only) for `.indoor` `tile_layout`. """ from __future__ import annotations import io +from typing import TYPE_CHECKING, Any from pykotor.common.stream import BinaryReader -from pykotor.common.tilekit import TileKit -from pykotor.gl import eulerAngles, quat +from pykotor.common.tilekit import ( + KitTileRecord, + QuaternionWXYZ, + TileKit, + TileTemplate, +) +from pykotor.gl import eulerAngles, mat4, mat4_cast, quat, translate, vec3 from pykotor.gl.models.read_mdl import gl_load_stitched_model +from pykotor.gl.scene.render_object import RenderObject from pykotor.gl.scene.scene import Scene from pykotor.gl.shader import Texture from pykotor.resource.formats.tpc.tpc_auto import read_tpc from pykotor.tools.tilemap_compile import TileLayout from utility.common.geometry import Vector3 +if TYPE_CHECKING: + pass -def _quat_to_euler_v3(q_wxyz: object) -> Vector3: - from pykotor.common.tilekit import QuaternionWXYZ - if not isinstance(q_wxyz, QuaternionWXYZ): - return Vector3() +def _quat_to_euler_v3(q_wxyz: QuaternionWXYZ) -> Vector3: r = quat(q_wxyz.w, q_wxyz.x, q_wxyz.y, q_wxyz.z) e = eulerAngles(r) return Vector3(float(e.x), float(e.y), float(e.z)) +def _v3(v: Vector3) -> Any: + return vec3(float(v.x), float(v.y), float(v.z)) + + +def _quat_from_net_xyzw(seq: list[float] | None) -> Any: + """System.Numerics JSON order ``[x, y, z, w]``.""" + if not seq or len(seq) < 4: + return quat(1.0, 0.0, 0.0, 0.0) + x, y, z, w = (float(seq[0]), float(seq[1]), float(seq[2]), float(seq[3])) + return quat(w, x, y, z) + + +def _quat_from_py_wxyz(q: QuaternionWXYZ) -> Any: + return quat(q.w, q.x, q.y, q.z) + + +def _mat_rt(q: Any, pos: Vector3) -> Any: + """Match C# ``Matrix4x4.CreateFromQuaternion * CreateFromTranslation``.""" + return mat4_cast(q) * translate(_v3(pos)) + + +def _multiply(a: Any, b: Any) -> Any: + return a * b + + def upload_tile_kit_assets(scene: Scene, tile_kit: TileKit) -> None: """Upload kit TGAs as TPC-derived textures and register template MDL/MDX in `scene.models`.""" for resref, raw in tile_kit.textures.items(): @@ -56,6 +86,20 @@ def upload_tile_kit_assets(scene: Scene, tile_kit: TileKit) -> None: continue +def _template_for_resref(kit: TileKit, resref: str) -> TileTemplate | None: + for t in kit.all_templates(): + if t.resref == resref or t.template_id == resref: + return t + return None + + +def _kit_tile_record(kit: TileKit, tile_template_id: str) -> KitTileRecord | None: + for tr in kit.tiles: + if tr.tile_id == tile_template_id: + return tr + return None + + def populate_scene_tile_grid_floor_preview( scene: Scene, tile_kit: TileKit, @@ -88,9 +132,160 @@ def populate_scene_tile_grid_floor_preview( wy = float(iy) * cell + tpl.offset.y wz = floor_z + tpl.offset.z rot_euler = _quat_to_euler_v3(tpl.rotation) - from pykotor.gl.scene.render_object import RenderObject # noqa: PLC0415 - ro = RenderObject(tpl.resref, Vector3(wx, wy, wz), rot_euler) scene.objects[ro] = ro scene.invalidate_render_cache() + + +def populate_scene_from_area_designer_v01( + scene: Scene, + area: dict[str, Any], + kits_by_id: dict[str, TileKit], + *, + show_walls: bool = True, + show_doors: bool = True, + show_corners: bool = True, + show_ceilings: bool = False, +) -> None: + """Populate `scene.objects` like `AreaEntity.GetMeshDescriptors` (Kotor.NET). + + Expects `area` JSON with ``format: \"0.1\"`` and ``rooms[]`` as saved by `AreaSerializer_V0_1`. + """ + scene.objects.clear() + scene.selection.clear() + scene.invalidate_render_cache() + + rooms = area.get("rooms") + if not isinstance(rooms, list): + scene.invalidate_render_cache() + return + + def add_model(resref: str, world: Any) -> None: + if not resref: + return + ro = RenderObject(resref) + ro.set_transform(world) + scene.objects[ro] = ro + + for room_data in rooms: + if not isinstance(room_data, dict): + continue + pos_l = room_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_l) < 3: + pos_l = [0.0, 0.0, 0.0] + room_pos = Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])) + q_room = _quat_from_net_xyzw(room_data.get("orientation")) + m_room = _mat_rt(q_room, room_pos) + + tiles = room_data.get("tiles") + if not isinstance(tiles, list): + continue + + for tile_data in tiles: + if not isinstance(tile_data, dict): + continue + kit_id = str(tile_data.get("kitID", "")) + template_id = str(tile_data.get("templateID", "")) + kit = kits_by_id.get(kit_id) + if kit is None: + continue + + pos_l = tile_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_l) < 3: + pos_l = [0.0, 0.0, 0.0] + tile_pos = Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])) + q_tile = _quat_from_net_xyzw(tile_data.get("orientation")) + m_tile_local = _mat_rt(q_tile, tile_pos) + # Column vectors: world = parent * local (matches C# child * parent multiply order). + m_tile = _multiply(m_room, m_tile_local) + + kt = _kit_tile_record(kit, template_id) + + floor_block = tile_data.get("floor") + if isinstance(floor_block, dict): + fk = str(floor_block.get("kitID", kit_id)) + ftid = str(floor_block.get("templateID", "")) + fkit = kits_by_id.get(fk) or kit + ftpl = _template_for_resref(fkit, ftid) + if ftpl is not None and ftpl.resref: + add_model(ftpl.resref, m_tile) + + if show_ceilings and isinstance(tile_data.get("ceiling"), dict): + ck = str(tile_data["ceiling"].get("kitID", "")) + ctid = str(tile_data["ceiling"].get("templateID", "")) + if ck and ctid: + ckit = kits_by_id.get(ck) or kit + ctpl = _template_for_resref(ckit, ctid) + if ctpl is not None and ctpl.resref: + add_model(ctpl.resref, m_tile) + + walls_saved = tile_data.get("walls") + if show_walls and isinstance(walls_saved, list) and kt is not None: + for i, wall_block in enumerate(walls_saved): + if not isinstance(wall_block, dict): + continue + wk = str(wall_block.get("kitID", kit_id)) + wtid = str(wall_block.get("templateID", "")) + wkit = kits_by_id.get(wk) or kit + wtpl = _template_for_resref(wkit, wtid) + if wtpl is None or not wtpl.resref: + continue + if i >= len(kt.wall_hooks): + continue + hook = kt.wall_hooks[i] + q_h = _quat_from_py_wxyz(hook.orientation) + m_hook = _mat_rt(q_h, hook.position) + m_wall = _multiply(m_tile, m_hook) + add_model(wtpl.resref, m_wall) + if ( + show_doors + and wtpl.doorframe_id + and wtpl.doorframe_hooks + and (df := _template_for_resref(wkit, wtpl.doorframe_id)) is not None + ): + for dh in wtpl.doorframe_hooks: + q_df = _quat_from_py_wxyz(dh.orientation) + m_df_loc = _mat_rt(q_df, dh.position) + m_df = _multiply(m_wall, m_df_loc) + add_model(df.resref, m_df) + + if show_corners and kt is not None: + for ic in kt.inner_corner_hooks: + itpl = _template_for_resref(kit, ic.default_corner_id) + if itpl is None or not itpl.resref: + continue + q_h = _quat_from_py_wxyz(ic.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, ic.position)) + add_model(itpl.resref, m_h) + for oc in kt.outer_corner_hooks: + otpl = _template_for_resref(kit, oc.default_corner_id) + if otpl is None or not otpl.resref: + continue + q_h = _quat_from_py_wxyz(oc.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, oc.position)) + add_model(otpl.resref, m_h) + + objects_l = room_data.get("objects") + if isinstance(objects_l, list): + for od in objects_l: + if not isinstance(od, dict): + continue + ok = str(od.get("kitID", "")) + otid = str(od.get("templateID", "")) + okit = kits_by_id.get(ok) + if okit is None: + continue + otpl = _template_for_resref(okit, otid) + if otpl is None or not otpl.resref: + continue + opos_l = od.get("position") or [0.0, 0.0, 0.0] + if len(opos_l) < 3: + opos_l = [0.0, 0.0, 0.0] + o_pos = Vector3(float(opos_l[0]), float(opos_l[1]), float(opos_l[2])) + q_o = _quat_from_net_xyzw(od.get("orientation")) + m_obj_local = _mat_rt(q_o, o_pos) + m_obj = _multiply(m_room, m_obj_local) + add_model(otpl.resref, m_obj) + + scene.invalidate_render_cache() From 54c13b7c7bf2fad0f93e04b0229baf2a6e475a0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:20:06 +0000 Subject: [PATCH 13/29] chore(toolset): update submodule for indoor builder 3d preview Co-authored-by: Boden --- Tools/HolocronToolset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index f7a656b387..2f557bde47 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit f7a656b3872188461ce3faaf362a8af29ad0c250 +Subproject commit 2f557bde476407f2de70e2efebd1ba0c78368d78 From 100b16eea261664c6785d777f3c912de1d4cba65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:21:16 +0000 Subject: [PATCH 14/29] docs(patches): add holocron indoor builder 3d preview; restore submodule pin Co-authored-by: Boden --- Tools/HolocronToolset | 2 +- patches/README.md | 9 + .../holocron-indoor-builder-3d-preview.patch | 336 ++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 patches/README.md create mode 100644 patches/holocron-indoor-builder-3d-preview.patch diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index 2f557bde47..f7a656b387 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit 2f557bde476407f2de70e2efebd1ba0c78368d78 +Subproject commit f7a656b3872188461ce3faaf362a8af29ad0c250 diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000000..38f7364e14 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,9 @@ +# Patches (CI / fork note) + +`holocron-indoor-builder-3d-preview.patch` is a `git format-patch` export of HolocronToolset changes for Indoor Map Builder’s OpenGL tile preview. Apply inside `Tools/HolocronToolset` when the submodule gitlink cannot be fetched: + +```bash +cd Tools/HolocronToolset && git am ../../patches/holocron-indoor-builder-3d-preview.patch +``` + +Then commit the submodule in the PyKotor root as usual. diff --git a/patches/holocron-indoor-builder-3d-preview.patch b/patches/holocron-indoor-builder-3d-preview.patch new file mode 100644 index 0000000000..ebc9214635 --- /dev/null +++ b/patches/holocron-indoor-builder-3d-preview.patch @@ -0,0 +1,336 @@ +From 2f557bde476407f2de70e2efebd1ba0c78368d78 Mon Sep 17 00:00:00 2001 +From: Cursor Agent +Date: Tue, 28 Apr 2026 03:20:02 +0000 +Subject: [PATCH] feat(indoor-builder): real opengl tile preview with area + layout sync + +--- + .../gui/windows/indoor_builder/builder.py | 19 +- + .../windows/indoor_builder/tile_editor_3d.py | 209 ++++++++++++++---- + 2 files changed, 187 insertions(+), 41 deletions(-) + +diff --git a/src/toolset/gui/windows/indoor_builder/builder.py b/src/toolset/gui/windows/indoor_builder/builder.py +index b2c1294..60a4845 100644 +--- a/src/toolset/gui/windows/indoor_builder/builder.py ++++ b/src/toolset/gui/windows/indoor_builder/builder.py +@@ -218,6 +218,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._installation: HTInstallation | None = installation + self._kits: list[Kit] = [] + self._tile_kits: list[TileKit] = [] ++ self._tile_gl = None # IndoorTileGridRenderer | None (optional 3D preview) + self._map: IndoorMap = IndoorMap() + # Synthetic components (e.g. merged rooms) are stored in an embedded kit so they + # can be serialized into `.indoor` and restored on load. +@@ -244,15 +245,20 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui: Ui_MainWindow = Ui_MainWindow() + self.ui.setupUi(self) + from toolset.gui.windows.indoor_builder.tile_editor_3d import ( # noqa: PLC0415 ++ IndoorTileGridRenderer, + setup_indoor_builder_tile_3d, + ) + +- setup_indoor_builder_tile_3d( ++ self._tile_gl = setup_indoor_builder_tile_3d( + main_splitter=self.ui.mainViewSplitter, + host=self.ui.tileGrid3DHost, + host_layout=self.ui.tileGrid3DHostLayout, + fallback_label=self.ui.tileGrid3DFallbackLabel, ++ installation=self._installation, + ) ++ if isinstance(self._tile_gl, IndoorTileGridRenderer): ++ self._undo_stack.cleanChanged.connect(self._refresh_tile_gl_view) ++ self._undo_stack.indexChanged.connect(self._refresh_tile_gl_view) + + # Add a missing "Open .mod" action at runtime (UI code is generated; do not edit it). + self._action_open_mod: QAction = QAction(tr("Open .mod..."), self) +@@ -343,6 +349,9 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + def _on_installation_changed(self, installation: HTInstallation | None) -> None: + # self._installation is already set by Editor._handle_installation_changed before this call. + self._module_kit_manager = None if installation is None else ModuleKitManager(installation) ++ gl = getattr(self, "_tile_gl", None) ++ if gl is not None and hasattr(gl, "set_installation"): ++ gl.set_installation(installation) + try: + self._setup_settings_toolbar() + self._setup_modules() +@@ -810,6 +819,13 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.mapRenderer.invalidate_rooms(rooms) + self._refresh_status_bar() + ++ def _refresh_tile_gl_view(self, *_args: object) -> None: ++ """Sync optional 3D preview with map + tile kits (AreaDesigner / tile_layout).""" ++ gl = getattr(self, "_tile_gl", None) ++ if gl is None or not hasattr(gl, "refresh_from_map"): ++ return ++ gl.refresh_from_map(self._map, self._tile_kits) ++ + def _kits_for_build(self) -> list[Kit]: + """K1 room kits plus v2 tile kit shells (textures/skyboxes) for `IndoorMap.build`.""" + out: list[Kit] = list(self._kits) +@@ -832,6 +848,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.kitSelect.addItem(kit.name, kit) + # v2 tile kits are loaded into `_tile_kits` for tile-layout / 3D workflows; the v1 + # component listbox remains for classic room components only. ++ self._refresh_tile_gl_view() + + def _show_no_kits_dialog(self): + """Show dialog asking if user wants to open kit downloader. +diff --git a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py b/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py +index 28a0f72..6bb8183 100644 +--- a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py ++++ b/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py +@@ -1,19 +1,33 @@ +-"""Optional 3D tile-grid preview for Indoor Map Builder (PyQt + OpenGL). ++"""3D tile / area preview for Indoor Map Builder (PyQt + PyKotor GL). + +-When ``INDOOR_BUILDER_DISABLE_3D`` is set, or PyOpenGL is unavailable, the UI keeps the +-fallback label from ``indoor_builder.ui``. ++Replaces the solid-color placeholder with a real `OpenGLSceneRenderer` that mirrors Kotor.NET's ++``GLEngine.Render`` → mesh draws for placed tiles (see ``AreaEntity.GetMeshDescriptors``). + """ + + from __future__ import annotations + + import os +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Any + +-from qtpy.QtCore import Qt ++from qtpy.QtCore import QTimer ++from qtpy.QtGui import QCloseEvent, QOpenGLContext + from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + ++from loggerplus import RobustLogger ++from pykotor.common.indoormap import IndoorMap ++from pykotor.common.tilekit import TileKit ++from pykotor.gl.scene.scene import Scene ++from pykotor.tools.tilekit_preview import ( ++ populate_scene_from_area_designer_v01, ++ populate_scene_tile_grid_floor_preview, ++ upload_tile_kit_assets, ++) ++from pykotor.tools.tilemap_compile import TileLayout ++from toolset.gui.widgets.renderer.base import OpenGLSceneRenderer ++from toolset.gui.widgets.settings.widgets.module_designer import get_renderer_loop_interval_ms ++ + if TYPE_CHECKING: +- from qtpy.QtWidgets import QSplitter ++ from toolset.data.installation import HTInstallation + + + def indoor_builder_3d_enabled() -> bool: +@@ -24,62 +38,177 @@ def indoor_builder_3d_enabled() -> bool: + ) + + +-class _MinimalTileGridGL(QWidget): +- """Clears a color buffer; placeholder for a full tile-mesh + grid renderer.""" ++class IndoorTileGridRenderer(OpenGLSceneRenderer): ++ """OpenGL preview: AreaDesigner JSON and/or PyKotor ``tile_layout`` on the current map.""" + +- def __init__(self, parent: QWidget | None = None) -> None: +- super().__init__(parent) +- from qtpy.QtWidgets import QOpenGLWidget # noqa: PLC0415 ++ def __init__(self, parent: QWidget) -> None: ++ super().__init__(parent, loop_interval_ms=get_renderer_loop_interval_ms()) ++ self._installation: HTInstallation | None = None ++ self._last_map_id: int | None = None ++ self._kits_signature: tuple[str, ...] = () ++ self._uploaded_for_kits: set[str] = set() ++ self._map_ref: IndoorMap | None = None ++ self._tile_kits_ref: list[TileKit] = [] + +- self._gl: object | None = None +- try: +- from OpenGL.GL import ( # noqa: PLC0415 +- GL_COLOR_BUFFER_BIT, +- GL_DEPTH_BUFFER_BIT, +- glClear, +- glClearColor, +- ) ++ self.loop_timer.timeout.disconnect() ++ self.loop_timer.timeout.connect(self._on_loop_timer_timeout) + +- class _V(QOpenGLWidget): +- def initializeGL(self) -> None: +- glClearColor(0.12, 0.12, 0.14, 1.0) ++ def _on_loop_timer_timeout(self) -> None: ++ if self.isVisible(): ++ self.update() + +- def paintGL(self) -> None: +- glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) ++ def set_installation(self, installation: HTInstallation | None) -> None: ++ self._installation = installation ++ self._uploaded_for_kits.clear() + +- self._gl = _V(self) +- layout = QVBoxLayout(self) +- layout.setContentsMargins(0, 0, 0, 0) +- layout.addWidget(self._gl) +- except Exception: +- lab = QLabel(self) +- lab.setAlignment(Qt.AlignmentFlag.AlignCenter) +- lab.setText( +- "3D view: PyOpenGL not available. Use 2D map or install PyOpenGL.", ++ def shutdown_renderer(self) -> None: ++ super().shutdown_renderer() ++ self._uploaded_for_kits.clear() ++ ++ def closeEvent(self, event: QCloseEvent) -> None: # pyright: ignore[reportIncompatibleMethodOverride] ++ self.shutdown_renderer() ++ super().closeEvent(event) ++ ++ def initializeGL(self) -> None: ++ self.makeCurrent() ++ try: ++ from pykotor.gl.compat import HAS_PYOPENGL # noqa: PLC0415 ++ except ImportError: ++ HAS_PYOPENGL = False # noqa: N806 ++ if not HAS_PYOPENGL: ++ RobustLogger().warning("IndoorTileGridRenderer: PyOpenGL missing; 3D preview disabled.") ++ return ++ ++ self.scene = Scene(installation=self._installation) ++ self.scene.enable_frustum_culling = False ++ self.scene.camera.distance = 25.0 ++ self.scene.camera.x = 0.0 ++ self.scene.camera.y = 0.0 ++ self.scene.camera.z = 12.0 ++ self.scene.show_focus_point_gizmo = False ++ self.scene.show_cursor = False ++ self._sync_camera_drawable_size() ++ self.loop_timer.start() ++ ++ def resizeGL(self, width: int, height: int) -> None: # noqa: ARG002 ++ self._sync_camera_drawable_size() ++ ++ def refresh_from_map(self, indoor_map: IndoorMap, tile_kits: list[TileKit]) -> None: ++ """Rebuild GPU scene from map state (call after map / kits change).""" ++ self._map_ref = indoor_map ++ self._tile_kits_ref = tile_kits ++ self.update() ++ ++ def paintGL(self) -> None: ++ if self.scene is None: ++ return ++ ctx: QOpenGLContext | None = self.context() ++ if ctx is None or not ctx.isValid(): ++ return ++ self.makeCurrent() ++ self._sync_camera_drawable_size() ++ ++ indoor_map = self._map_ref ++ tile_kits = self._tile_kits_ref ++ if indoor_map is None: ++ try: ++ self.scene.render() ++ except Exception: # noqa: BLE001 ++ RobustLogger().exception("IndoorTileGridRenderer.render failed.") ++ return ++ ++ kits_by_id: dict[str, TileKit] = {tk.kit_id: tk for tk in tile_kits} ++ sig = tuple(sorted(kits_by_id.keys())) ++ mid = id(indoor_map) ++ if sig != self._kits_signature or mid != self._last_map_id: ++ self._uploaded_for_kits.clear() ++ self._kits_signature = sig ++ self._last_map_id = mid ++ ++ for kid, tk in kits_by_id.items(): ++ if kid not in self._uploaded_for_kits: ++ upload_tile_kit_assets(self.scene, tk) ++ self._uploaded_for_kits.add(kid) ++ ++ area_payload = getattr(indoor_map, "area_designer_v01", None) ++ if isinstance(area_payload, dict) and area_payload.get("format") == "0.1": ++ populate_scene_from_area_designer_v01( ++ self.scene, ++ area_payload, ++ kits_by_id, ++ show_walls=True, ++ show_doors=True, ++ show_corners=True, ++ show_ceilings=False, + ) +- layout = QVBoxLayout(self) +- layout.addWidget(lab) ++ else: ++ tl = getattr(indoor_map, "tile_layout", None) ++ if isinstance(tl, dict) and tl.get("kit_id"): ++ kit_id = str(tl["kit_id"]) ++ tk = kits_by_id.get(kit_id) ++ if tk is not None: ++ layout = TileLayout( ++ format_version=int(tl.get("format_version", 1)), ++ kit_id=kit_id, ++ cell_size=float(tl.get("cell_size", 4.0)), ++ grid_w=int(tl.get("grid_w", 0)), ++ grid_h=int(tl.get("grid_h", 0)), ++ floor_cells=list(tl.get("floor_cells") or []), ++ ) ++ populate_scene_tile_grid_floor_preview(self.scene, tk, layout) ++ else: ++ self.scene.objects.clear() ++ self.scene.invalidate_render_cache() ++ else: ++ self.scene.objects.clear() ++ self.scene.invalidate_render_cache() ++ ++ try: ++ self.scene.render() ++ except Exception: # noqa: BLE001 ++ RobustLogger().exception("IndoorTileGridRenderer.render failed.") + + + def setup_indoor_builder_tile_3d( + *, +- main_splitter: QSplitter, ++ main_splitter: Any, + host: QWidget, + host_layout: QVBoxLayout, + fallback_label: QLabel, +-) -> None: +- """Replace the fallback label with a minimal GL widget when 3D is allowed.""" ++ installation: HTInstallation | None = None, ++) -> IndoorTileGridRenderer | None: ++ """Replace the fallback label with the GL renderer when 3D is allowed.""" + if not indoor_builder_3d_enabled(): + fallback_label.setText("3D tile view disabled (INDOOR_BUILDER_DISABLE_3D).") + try: + main_splitter.setSizes([480, 0]) + except Exception: + pass +- return ++ return None ++ ++ try: ++ from pykotor.gl.compat import HAS_PYOPENGL # noqa: PLC0415 ++ except ImportError: ++ HAS_PYOPENGL = False # noqa: N806 ++ ++ if not HAS_PYOPENGL: ++ fallback_label.setText( ++ "3D view: PyOpenGL not available. Use 2D map or install PyOpenGL.", ++ ) ++ try: ++ main_splitter.setSizes([480, 0]) ++ except Exception: ++ pass ++ return None ++ + host_layout.removeWidget(fallback_label) + fallback_label.hide() +- host_layout.addWidget(_MinimalTileGridGL(host)) ++ gl_widget = IndoorTileGridRenderer(host) ++ gl_widget.set_installation(installation) ++ host_layout.addWidget(gl_widget) + try: + main_splitter.setSizes([400, 200]) + except Exception: + pass ++ QTimer.singleShot(0, gl_widget.update) ++ return gl_widget +-- +2.43.0 + From fdee74a4f79a1e1f451b55b5e1fbb4e2bd2e7a6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:22:33 +0000 Subject: [PATCH 15/29] docs(patches): use unified diff apply instructions for holocron preview Co-authored-by: Boden --- patches/README.md | 6 +++--- patches/holocron-indoor-builder-3d-preview.patch | 14 -------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/patches/README.md b/patches/README.md index 38f7364e14..879049b1a7 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,9 +1,9 @@ # Patches (CI / fork note) -`holocron-indoor-builder-3d-preview.patch` is a `git format-patch` export of HolocronToolset changes for Indoor Map Builder’s OpenGL tile preview. Apply inside `Tools/HolocronToolset` when the submodule gitlink cannot be fetched: +`holocron-indoor-builder-3d-preview.patch` is a unified diff against the checked-in HolocronToolset submodule ref (`f7a656b38…`) for Indoor Map Builder’s OpenGL tile preview. Apply when the HolocronToolset remote is unreachable: ```bash -cd Tools/HolocronToolset && git am ../../patches/holocron-indoor-builder-3d-preview.patch +cd Tools/HolocronToolset && git apply ../../patches/holocron-indoor-builder-3d-preview.patch ``` -Then commit the submodule in the PyKotor root as usual. +Then commit inside `Tools/HolocronToolset` and update the submodule gitlink in the PyKotor root. diff --git a/patches/holocron-indoor-builder-3d-preview.patch b/patches/holocron-indoor-builder-3d-preview.patch index ebc9214635..36faf57305 100644 --- a/patches/holocron-indoor-builder-3d-preview.patch +++ b/patches/holocron-indoor-builder-3d-preview.patch @@ -1,14 +1,3 @@ -From 2f557bde476407f2de70e2efebd1ba0c78368d78 Mon Sep 17 00:00:00 2001 -From: Cursor Agent -Date: Tue, 28 Apr 2026 03:20:02 +0000 -Subject: [PATCH] feat(indoor-builder): real opengl tile preview with area - layout sync - ---- - .../gui/windows/indoor_builder/builder.py | 19 +- - .../windows/indoor_builder/tile_editor_3d.py | 209 ++++++++++++++---- - 2 files changed, 187 insertions(+), 41 deletions(-) - diff --git a/src/toolset/gui/windows/indoor_builder/builder.py b/src/toolset/gui/windows/indoor_builder/builder.py index b2c1294..60a4845 100644 --- a/src/toolset/gui/windows/indoor_builder/builder.py @@ -331,6 +320,3 @@ index 28a0f72..6bb8183 100644 pass + QTimer.singleShot(0, gl_widget.update) + return gl_widget --- -2.43.0 - From b4ffdc845ca30191021fa9078516a33b75571184 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:27:31 +0000 Subject: [PATCH 16/29] fix(tilekit): areaentity doorframe last hook, room objects; area serializer api Co-authored-by: Boden --- .../src/pykotor/tools/area_designer_io.py | 25 ++++++++++++----- .../src/pykotor/tools/tilekit_preview.py | 28 +++++++++++-------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py index 8cfde7e21f..8ac9d2d9c9 100644 --- a/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_io.py @@ -1,8 +1,9 @@ -"""Kotor.NET AreaDesigner `AreaSerializer_V0_1` JSON (`format: \"0.1\"`) load/save. +"""Kotor.NET AreaDesigner area JSON (`AreaSerializer` / `AreaSerializer_V0_1`). -Mirrors `Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization.AreaSerializer_V0_1`. -Round-trip is used for interchange with .NET tools; PyKotor `.indoor` maps may embed the same -payload under `IndoorMap.area_designer_v01` for lossless studio workflow. +Mirrors `Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization.AreaSerializer`: +**load** dispatches on `format`; **save** always writes `AreaSerializer_V0_1` (same as .NET). + +PyKotor `.indoor` maps may embed the same payload under `IndoorMap.area_designer_v01`. """ from __future__ import annotations @@ -46,8 +47,8 @@ class AreaDesignerFileV01(TypedDict, total=False): FORMAT_ID = "0.1" -def load_area_designer_v01(path: Path | str) -> AreaDesignerFileV01: - """Load an Area Designer JSON file from disk.""" +def load_area_designer(path: Path | str) -> AreaDesignerFileV01: + """Load an Area Designer JSON file; dispatch on ``format`` like `AreaSerializer.Load`.""" p = Path(path) raw = json.loads(p.read_text(encoding="utf-8")) if not isinstance(raw, dict): @@ -55,11 +56,21 @@ def load_area_designer_v01(path: Path | str) -> AreaDesignerFileV01: raise ValueError(msg) fmt = raw.get("format") if fmt != FORMAT_ID: - msg = f"Expected format '{FORMAT_ID}', got {fmt!r}" + msg = f"Unsupported area format {fmt!r} (expected '{FORMAT_ID}')" raise ValueError(msg) return raw # type: ignore[return-value] +def save_area_designer(path: Path | str, data: AreaDesignerFileV01) -> None: + """Write area JSON (`AreaSerializer.Save` → `AreaSerializer_V0_1`).""" + save_area_designer_v01(path, data) + + +def load_area_designer_v01(path: Path | str) -> AreaDesignerFileV01: + """Alias for `load_area_designer` (v0.1 is the only supported on-disk version today).""" + return load_area_designer(path) + + def save_area_designer_v01(path: Path | str, data: AreaDesignerFileV01) -> None: """Write an Area Designer JSON file (pretty-printed, UTF-8).""" p = Path(path) diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py index f415f191e6..a701765dba 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py @@ -1,13 +1,17 @@ """Bridge Kotor.NET AreaDesigner scene graph to `pykotor.gl.scene.Scene`. -`RoomEntity` / `AreaEntity` in Kotor.NET gather mesh descriptors per tile (floor), wall, -doorframe, corners, and objects with transforms (`Transform = Local * Parent` chain). +`AreaEntity.GetMeshDescriptors` draws **floors, walls, doorframes, inner corners, outer corners** +(per tile), then **objects** at room scope (`Room.Objects`). `AreaExporter.RoomToMDL` stitches the +same categories (ceilings commented out in .NET). -PyKotor matches that draw order by placing `RenderObject`s with `set_transform(mat4)` built from -the same quaternion × translation factors as `Matrix4x4.CreateFromQuaternion * -Matrix4x4.CreateTranslation` in C# (rotation × translation in GLM). +Doorframe world pose matches `DoorFrame` in `Room.cs`: ``LocalTransform`` uses **only the last** +template hook (`Template.Hooks.Last()`). -Also supports the simpler PyKotor `TileLayout` grid (floors only) for `.indoor` `tile_layout`. +Saved JSON does not encode wall-link visibility; we draw wall meshes whenever template refs exist. +Outer-corner visibility in .NET depends on adjacency + links; preview draws kit hooks unless we add +a future visibility pass. + +Also supports PyKotor `TileLayout` (floors only) for `.indoor` `tile_layout`. """ from __future__ import annotations @@ -238,17 +242,18 @@ def add_model(resref: str, world: Any) -> None: m_hook = _mat_rt(q_h, hook.position) m_wall = _multiply(m_tile, m_hook) add_model(wtpl.resref, m_wall) + # DoorFrame.Transform uses the last hook on the doorframe template (Room.cs). if ( show_doors and wtpl.doorframe_id and wtpl.doorframe_hooks and (df := _template_for_resref(wkit, wtpl.doorframe_id)) is not None ): - for dh in wtpl.doorframe_hooks: - q_df = _quat_from_py_wxyz(dh.orientation) - m_df_loc = _mat_rt(q_df, dh.position) - m_df = _multiply(m_wall, m_df_loc) - add_model(df.resref, m_df) + dh = wtpl.doorframe_hooks[-1] + q_df = _quat_from_py_wxyz(dh.orientation) + m_df_loc = _mat_rt(q_df, dh.position) + m_df = _multiply(m_wall, m_df_loc) + add_model(df.resref, m_df) if show_corners and kt is not None: for ic in kt.inner_corner_hooks: @@ -266,6 +271,7 @@ def add_model(resref: str, world: Any) -> None: m_h = _multiply(m_tile, _mat_rt(q_h, oc.position)) add_model(otpl.resref, m_h) + # Room-scoped props (`Room.Objects`; exporter adds once per tile iteration — same transforms). objects_l = room_data.get("objects") if isinstance(objects_l, list): for od in objects_l: From 54e2eec68ae309de23963caace4d47b007558fd7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 03:27:37 +0000 Subject: [PATCH 17/29] docs(patches): refresh holocron gl preview patch and parity notes Co-authored-by: Boden --- patches/README.md | 2 + .../holocron-indoor-builder-3d-preview.patch | 40 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/patches/README.md b/patches/README.md index 879049b1a7..767a92dad1 100644 --- a/patches/README.md +++ b/patches/README.md @@ -7,3 +7,5 @@ cd Tools/HolocronToolset && git apply ../../patches/holocron-indoor-builder-3d-p ``` Then commit inside `Tools/HolocronToolset` and update the submodule gitlink in the PyKotor root. + +The patch matches **`AreaEntity` / `AreaExporter`** mesh categories (floors, walls, doorframes from last hook, inner/outer corners, room objects) and wires **`OpenGLSceneRenderer`** (`QOpenGLWidget`) with map refresh after load/new/module extract. Separate Avalonia apps (**Kit Editor**, interaction **modes**) are not duplicated in PyKotor—only data + preview/export parity. diff --git a/patches/holocron-indoor-builder-3d-preview.patch b/patches/holocron-indoor-builder-3d-preview.patch index 36faf57305..f120d63c4e 100644 --- a/patches/holocron-indoor-builder-3d-preview.patch +++ b/patches/holocron-indoor-builder-3d-preview.patch @@ -1,5 +1,5 @@ diff --git a/src/toolset/gui/windows/indoor_builder/builder.py b/src/toolset/gui/windows/indoor_builder/builder.py -index b2c1294..60a4845 100644 +index b2c1294..dbe7446 100644 --- a/src/toolset/gui/windows/indoor_builder/builder.py +++ b/src/toolset/gui/windows/indoor_builder/builder.py @@ -218,6 +218,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): @@ -32,7 +32,15 @@ index b2c1294..60a4845 100644 # Add a missing "Open .mod" action at runtime (UI code is generated; do not edit it). self._action_open_mod: QAction = QAction(tr("Open .mod..."), self) -@@ -343,6 +349,9 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): +@@ -316,6 +322,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self.ui.mapRenderer.set_map(self._map) + self.ui.mapRenderer.set_undo_stack(self._undo_stack) + self.ui.mapRenderer.set_status_callback(self._refresh_status_bar) ++ self._refresh_tile_gl_view() + self._nav_helper = Viewport2DNavigationHelper( + self.ui.mapRenderer, + get_content_bounds=self._content_bounds, +@@ -343,6 +350,9 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): def _on_installation_changed(self, installation: HTInstallation | None) -> None: # self._installation is already set by Editor._handle_installation_changed before this call. self._module_kit_manager = None if installation is None else ModuleKitManager(installation) @@ -42,7 +50,7 @@ index b2c1294..60a4845 100644 try: self._setup_settings_toolbar() self._setup_modules() -@@ -810,6 +819,13 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): +@@ -810,6 +820,13 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): self.ui.mapRenderer.invalidate_rooms(rooms) self._refresh_status_bar() @@ -56,7 +64,7 @@ index b2c1294..60a4845 100644 def _kits_for_build(self) -> list[Kit]: """K1 room kits plus v2 tile kit shells (textures/skyboxes) for `IndoorMap.build`.""" out: list[Kit] = list(self._kits) -@@ -832,6 +848,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): +@@ -832,6 +849,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): self.ui.kitSelect.addItem(kit.name, kit) # v2 tile kits are loaded into `_tile_kits` for tile-layout / 3D workflows; the v1 # component listbox remains for classic room components only. @@ -64,6 +72,30 @@ index b2c1294..60a4845 100644 def _show_no_kits_dialog(self): """Show dialog asking if user wants to open kit downloader. +@@ -1631,6 +1649,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._undo_stack.setClean() # Mark as clean for new file + self._update_settings_ui() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + def open(self): + if not self._undo_stack.isClean(): +@@ -1663,6 +1682,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._update_settings_ui() + self._sync_module_combo_to_current_map() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + if missing_rooms: + self._show_missing_rooms_dialog(missing_rooms) +@@ -1921,6 +1941,7 @@ class IndoorMapBuilder(Editor, BlenderEditorMixin): + self._update_settings_ui() + self._sync_module_combo_to_current_map() + self._refresh_window_title() ++ self._refresh_tile_gl_view() + + return True + diff --git a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py b/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py index 28a0f72..6bb8183 100644 --- a/src/toolset/gui/windows/indoor_builder/tile_editor_3d.py From 5bc7b97203d14b839b0b47cc9bec28b5b1a4a72a Mon Sep 17 00:00:00 2001 From: Boden Date: Mon, 27 Apr 2026 22:50:22 -0500 Subject: [PATCH 18/29] Update Libraries/PyKotor/tests/test_tilekit_v2.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Libraries/PyKotor/tests/test_tilekit_v2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Libraries/PyKotor/tests/test_tilekit_v2.py b/Libraries/PyKotor/tests/test_tilekit_v2.py index ca8a68fc82..8c064c10b5 100644 --- a/Libraries/PyKotor/tests/test_tilekit_v2.py +++ b/Libraries/PyKotor/tests/test_tilekit_v2.py @@ -21,7 +21,6 @@ def test_load_minimal_v2_tile_kit() -> None: json_path = FIXTURES / "minimal_tiles.json" tk, missing = load_tile_kit_v2(json_path, record_missing=True) - assert isinstance(tk, object) assert tk.kit_id == "minimal_tiles" assert len(tk.floors) == 1 assert tk.floors[0].template_id == "floor_plain" From 184638d90725a15a91eb008b7fb085497e05cbfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:54:59 +0000 Subject: [PATCH 19/29] fix: address PR review feedback on tomllib, to_dict, missing_files, and JSON guard Agent-Logs-Url: https://github.com/OpenKotOR/PyKotor/sessions/d17a3aa4-5c06-45ad-a0e3-bdb31192dbee Co-authored-by: th3w1zard1 <2219836+th3w1zard1@users.noreply.github.com> --- Libraries/PyKotor/src/pykotor/tools/indoorkit.py | 5 ++++- Libraries/PyKotor/src/pykotor/tools/tilekit_io.py | 6 +++--- tool_metadata.py | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py index 4551da6022..3521ba2f04 100644 --- a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py +++ b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py @@ -275,7 +275,10 @@ def _load_kits_internal( kit_id = str(kit_json.get("id") or file.stem) kit_name = str(kit_json["name"]) else: - kit_json = json.loads(BinaryReader.load_file(file)) + try: + kit_json = json.loads(BinaryReader.load_file(file)) + except Exception: + continue fmt2 = kit_json.get("format") is_net_v01_b = isinstance(fmt2, str) and fmt2.strip() == "0.1" if kit_json.get("format_version") == 2 or is_net_v01_b: diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py index df23c9e81c..80828e7b43 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_io.py @@ -210,11 +210,11 @@ def _load_template_entry( mdl_path = base_path / f"{resref}.mdl" mdx_path = base_path / f"{resref}.mdx" wok: BWM | None = None - wok_bytes = _load_binary(wok_path, kit_name=kit_name, kind="walkmesh", missing_files=None) + wok_bytes = _load_binary(wok_path, kit_name=kit_name, kind="walkmesh", missing_files=missing_files) if wok_bytes: wok = read_bwm(wok_bytes) - mdl = _load_binary(mdl_path, kit_name=kit_name, kind="model", missing_files=None) or b"" - mdx = _load_binary(mdx_path, kit_name=kit_name, kind="mdx", missing_files=None) or b"" + mdl = _load_binary(mdl_path, kit_name=kit_name, kind="model", missing_files=missing_files) or b"" + mdx = _load_binary(mdx_path, kit_name=kit_name, kind="mdx", missing_files=missing_files) or b"" th = _parse_doorhooks(data.get("doorhooks", []), doors) df_hooks_raw = data.get("hooks") or [] df_hooks: list[DoorframeHookTemplate] = [] diff --git a/tool_metadata.py b/tool_metadata.py index c8f3705ef9..ccc6f1129d 100644 --- a/tool_metadata.py +++ b/tool_metadata.py @@ -51,6 +51,7 @@ def to_dict(self) -> dict[str, object | str | bool | None]: "module_name": self.module_name, "requires_qt": self.requires_qt, "is_cli": self.is_cli, + "tests_path": self.tests_path, } @@ -59,7 +60,8 @@ def _read_pyproject_data(tool_dir: Path) -> dict[str, object]: if not pyproject.is_file() or tomllib is None: return {} try: - return tomllib.load(pyproject.read_bytes()) # type: ignore[no-untyped-call] + with pyproject.open("rb") as f: + return tomllib.load(f) # type: ignore[no-untyped-call] except (OSError, TypeError, ValueError, UnicodeError): return {} From 1cae5df6752188b40bc2d7682fdca3ba161dc4a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:55:46 +0000 Subject: [PATCH 20/29] fix: narrow bare except to specific exceptions in indoorkit.py else branch Agent-Logs-Url: https://github.com/OpenKotOR/PyKotor/sessions/d17a3aa4-5c06-45ad-a0e3-bdb31192dbee Co-authored-by: th3w1zard1 <2219836+th3w1zard1@users.noreply.github.com> --- Libraries/PyKotor/src/pykotor/tools/indoorkit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py index 3521ba2f04..d810f96ac8 100644 --- a/Libraries/PyKotor/src/pykotor/tools/indoorkit.py +++ b/Libraries/PyKotor/src/pykotor/tools/indoorkit.py @@ -277,7 +277,7 @@ def _load_kits_internal( else: try: kit_json = json.loads(BinaryReader.load_file(file)) - except Exception: + except (OSError, ValueError, UnicodeDecodeError): continue fmt2 = kit_json.get("format") is_net_v01_b = isinstance(fmt2, str) and fmt2.strip() == "0.1" From 2a780cca6655b6561d254234e9fc54978f098831 Mon Sep 17 00:00:00 2001 From: Boden Date: Mon, 27 Apr 2026 23:14:14 -0500 Subject: [PATCH 21/29] Update defender-for-devops.yml --- .github/workflows/defender-for-devops.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/defender-for-devops.yml b/.github/workflows/defender-for-devops.yml index 12421b24e1..b27f256d1f 100644 --- a/.github/workflows/defender-for-devops.yml +++ b/.github/workflows/defender-for-devops.yml @@ -48,15 +48,17 @@ jobs: 5.0.x 6.0.x - name: Enable long paths on Windows - if: runner.os == 'Windows' + if: ${{ runner.os == 'Windows' && (success() || failure()) }} shell: pwsh run: | Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 git config --system core.longpaths true - name: Run Microsoft Security DevOps + if: ${{ success() || failure() }} uses: microsoft/security-devops-action@v1.12.0 id: msdo - name: Upload results to Security tab + if: ${{ success() || failure() }} uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} From 70876b9e25a2974076734f9f4e37810e5ef1a8ba Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 28 Apr 2026 02:11:44 -0500 Subject: [PATCH 22/29] changes --- .../AreaExportation_AreaExporter.cs | 82 +++ .../AreaSerialization_AreaSerializer.cs | 31 + .../AreaSerialization_AreaSerializer_V0_1.cs | 100 +++ .cache/kotor_net_area_designer/Kit.cs | 61 ++ .../KitSerialization_KitSerializer_V0_1.cs | 244 +++++++ .../Mode_AddObjectMode.cs | 48 ++ .../Mode_AddRoomMode.cs | 62 ++ .../kotor_net_area_designer/Mode_BaseMode.cs | 79 +++ .../Mode_ExtendRoomMode.cs | 63 ++ .../Mode_SwitchWallMode.cs | 48 ++ .cache/kotor_net_area_designer/Room.cs | 422 +++++++++++ .cache/kotor_net_area_designer/RoomEntity.cs | 111 +++ .../kotor_net_area_designer/RoomTemplate.cs | 134 ++++ .../gl/native/_gl_accel.cp313-win_amd64.pyd | Bin 25600 -> 25600 bytes .../_render2d_accel.cp313-win_amd64.pyd | Bin 17408 -> 17408 bytes .../src/pykotor/tools/area_designer_ops.py | 43 ++ .../pykotor/tools/area_designer_runtime.py | 669 ++++++++++++++++++ .../src/pykotor/tools/tilekit_preview.py | 151 +--- .../tests/tools/test_area_designer_runtime.py | 220 ++++++ 19 files changed, 2439 insertions(+), 129 deletions(-) create mode 100644 .cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs create mode 100644 .cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs create mode 100644 .cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs create mode 100644 .cache/kotor_net_area_designer/Kit.cs create mode 100644 .cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs create mode 100644 .cache/kotor_net_area_designer/Mode_AddObjectMode.cs create mode 100644 .cache/kotor_net_area_designer/Mode_AddRoomMode.cs create mode 100644 .cache/kotor_net_area_designer/Mode_BaseMode.cs create mode 100644 .cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs create mode 100644 .cache/kotor_net_area_designer/Mode_SwitchWallMode.cs create mode 100644 .cache/kotor_net_area_designer/Room.cs create mode 100644 .cache/kotor_net_area_designer/RoomEntity.cs create mode 100644 .cache/kotor_net_area_designer/RoomTemplate.cs create mode 100644 Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py create mode 100644 Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py create mode 100644 Libraries/PyKotor/tests/tools/test_area_designer_runtime.py diff --git a/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs b/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs new file mode 100644 index 0000000000..eff574b31c --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaExportation_AreaExporter.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Kotor.NET.Resources.KotorMDL; +using Kotor.NET.Resources.KotorMDL.Controllers; +using Kotor.NET.Resources.KotorMDL.Nodes; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaExportation; + +public static class AreaExporter +{ + public static MDL RoomToMDL(Room room) + { + var mdl = new MDL(); + mdl.Name = "test"; + + foreach (var tile in room.Tiles) + { + mdl.Root.Children.Add(FloorToMDLNode(tile.Floor)); + //mdl.Root.Children.Add(CeilingToMDLNode(tile.Ceiling)); + mdl.Root.Children.AddRange(tile.Walls.Where(x => x.Visible).Select(WallToMDLNode)); + mdl.Root.Children.AddRange(tile.Walls.Select(x => x.DoorFrame).Where(x => x?.Visible == true).Select(DoorFrameToMDLNode)); + mdl.Root.Children.AddRange(tile.InnerCorners.Where(x => x.Visible == true).Select(InnerCornerToMDLNode)); + mdl.Root.Children.AddRange(room.Objects.Select(ObjectToMDLNode)); + } + + mdl.Root.GetAllDescendants().Select((x, i) => x.Name = i.ToString()).ToArray(); + mdl.RedoNodeNumbers(); + return mdl; + } + + private static MDLNode FloorToMDLNode(Floor floor) + { + var floorMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{floor.KitID}/{floor.Template.Model}.mdl"); + floorMDL.Root.GetController().AddLinear(0, new(floor.Position)); + floorMDL.Root.GetController().AddLinear(0, new(floor.Orientation)); + return floorMDL.Root; + } + + private static MDLNode CeilingToMDLNode(Ceiling ceiling) + { + var ceilingMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{ceiling.KitID}/{ceiling.Template.Model}.mdl"); + ceilingMDL.Root.GetController().AddLinear(0, new(ceiling.Position)); + ceilingMDL.Root.GetController().AddLinear(0, new(ceiling.Orientation)); + return ceilingMDL.Root; + } + + private static MDLNode WallToMDLNode(Wall wall) + { + var wallMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{wall.KitID}/{wall.Template.Model}.mdl"); + wallMDL.Root.GetController().AddLinear(0, new(wall.Position)); + wallMDL.Root.GetController().AddLinear(0, new(wall.Orientation)); + return wallMDL.Root; + } + + private static MDLNode DoorFrameToMDLNode(DoorFrame doorframe) + { + var doorframeMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{doorframe.KitID}/{doorframe.Template.Model}.mdl"); + doorframeMDL.Root.GetController().AddLinear(0, new(doorframe.Position)); + doorframeMDL.Root.GetController().AddLinear(0, new(doorframe.Orientation)); + return doorframeMDL.Root; + } + + private static MDLNode InnerCornerToMDLNode(InnerCorner corner) + { + var cornerMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{corner.KitID}/{corner.Template.Model}.mdl"); + cornerMDL.Root.GetController().AddLinear(0, new(corner.Position)); + cornerMDL.Root.GetController().AddLinear(0, new(corner.Orientation)); + return cornerMDL.Root; + } + + private static MDLNode ObjectToMDLNode(Object @object) + { + var objectMDL = MDL.FromFile($"{Kit.Manager.ActiveDirectory}/{@object.KitID}/{@object.Template.Model}.mdl"); + objectMDL.Root.GetController().AddLinear(0, new(@object.LocalPosition)); + objectMDL.Root.GetController().AddLinear(0, new(@object.LocalOrientation)); + return objectMDL.Root; + } +} diff --git a/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs new file mode 100644 index 0000000000..5facfadcaf --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; +using Newtonsoft.Json; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization; + +public class AreaSerializer +{ + public static Area Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + string format = (string)data.format.Value; + + return format switch + { + "0.1" => AreaSerializer_V0_1.Load(filepath), + _ => throw new ArgumentException("Kit version is unsupported.") + }; + } + + public static void Save(string filepath, Area area) + { + AreaSerializer_V0_1.Save(filepath, area); + } +} diff --git a/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs new file mode 100644 index 0000000000..d863a026f5 --- /dev/null +++ b/.cache/kotor_net_area_designer/AreaSerialization_AreaSerializer_V0_1.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; +using Kotor.NET.Graphics.Extensions; +using Newtonsoft.Json; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.AreaSerialization; + +public class AreaSerializer_V0_1 +{ + public const string FormatID = "0.1"; + + public static Area Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + + var area = new Area(); + + foreach (var roomData in data.rooms.ToObject()) + { + var room = new Room(); + area.AddRoom(room); + + foreach (var tileData in roomData.tiles.ToObject()) + { + var tile = new Tile(room, Kit.Manager.Get(tileData.kitID.Value).Tile(tileData.templateID.Value)); + tile.LocalPosition = new Vector3(tileData.position.ToObject()); + tile.LocalOrientation = ((float[])tileData.orientation.ToObject()).ToQuaternion(); + + var floorData = tileData.floor; + var floorTemplate = Kit.Manager.Get(floorData.kitID.Value).Floor(floorData.templateID.Value); + tile.Floor.SwitchTemplate(floorTemplate); + + // TODO + //var ceilingData = tileData.ceiling; + //var ceilingTemplate = Kit.Manager.Get(ceilingData.kitID.Value).Ceiling(ceilingData.templateID.Value); + //tile.Ceiling.SwitchTemplate(ceilingTemplate); + + for (int i = 0; i < tileData.walls.Count; i++) + { + var wallData = tileData.walls[i]; + var wallTemplate = Kit.Manager.Get(wallData.kitID.Value).Wall(wallData.templateID.Value); + var wall = tile.Walls.ElementAt(i); + wall.SwitchTemplate(wallTemplate); + } + + room.Tiles.Add(tile); + } + + room.FixWalls(); + } + + return area; + } + + public static void Save(string filepath, Area area) + { + dynamic data = new ExpandoObject(); + + data.format = FormatID; + + data.rooms = area.Rooms.Select(room => new + { + position = room.Position.ToFloatArray(), + orientation = room.Orientation.ToFloatArray(), + tiles = room.Tiles.Select(tile => new + { + kitID = tile.KitID, + templateID = tile.TemplateID, + position = tile.LocalPosition.ToFloatArray(), + orientation = tile.LocalOrientation.ToFloatArray(), + floor = new + { + kitID = tile.Floor.KitID, + templateID = tile.Floor.TemplateID, + }, + ceiling = new + { + kitID = "", + templateID = "", + }, + walls = tile.Walls.Select(x => new + { + kitID = x.KitID, + templateID = x.TemplateID, + }), + }) + }); + + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + File.WriteAllText(filepath, json); + } +} diff --git a/.cache/kotor_net_area_designer/Kit.cs b/.cache/kotor_net_area_designer/Kit.cs new file mode 100644 index 0000000000..aa032d8cf7 --- /dev/null +++ b/.cache/kotor_net_area_designer/Kit.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class Kit +{ + public static KitManager Manager { get; } = new(); + + public string ID { get; } + public string Name { get; } + public string FilePath { get; } + public int Version { get; } + public ICollection Floors { get; init; } = []; + public ICollection Tiles { get; init; } = []; + public ICollection Walls { get; init; } = []; + public ICollection DoorFrames { get; init; } = []; + public ICollection Ceilings { get; init; } = []; + public ICollection InnerCorners { get; init; } = []; + public ICollection OuterCorners { get; init; } = []; + public ICollection Objects { get; init; } = []; + + public FloorTemplate Floor(string id) => Floors.Single(x => x.ID == id); + public TileTemplate Tile(string id) => Tiles.Single(x => x.ID == id); + public WallTemplate Wall(string id) => Walls.Single(x => x.ID == id); + public DoorFrameTemplate DoorFrame(string id) => DoorFrames.Single(x => x.ID == id); + public CeilingTemplate Ceiling(string id) => Ceilings.Single(x => x.ID == id); + public InnerCornerTemplate InnerCorner(string id) => InnerCorners.Single(x => x.ID == id); + public OuterCornerTemplate OuterCorner(string id) => OuterCorners.Single(x => x.ID == id); + public ObjectTemplate Object(string id) => Objects.Single(x => x.ID == id); + + public Kit(string filepath, string id, int version, string name) + { + FilePath = filepath; + ID = id; + Name = name; + Version = version; + } +} + +public class KitManager +{ + public string ActiveDirectory = @"C:/Kits"; + public ICollection Kits { get; } = []; + + public void Refresh() + { + Kits.Clear(); + Directory.GetFiles(Kit.Manager.ActiveDirectory) + .Where(x => Path.GetExtension(x).ToLower() == ".kit") + .Select(KitSerializer.Load) + .ToList() + .ForEach(Kits.Add); + } + public Kit Get(string id) => Kits.Single(x => x.ID == id); +} diff --git a/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs b/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs new file mode 100644 index 0000000000..e3c3030512 --- /dev/null +++ b/.cache/kotor_net_area_designer/KitSerialization_KitSerializer_V0_1.cs @@ -0,0 +1,244 @@ +using System; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Numerics; +using Kotor.NET.Graphics.Extensions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.KitSerialization; + +public class KitSerializer_V0_1 +{ + public const string FormatID = "0.1"; + + public static Kit Load(string filepath) + { + var json = File.ReadAllText(filepath); + dynamic data = JsonConvert.DeserializeObject(json); + + string kitName = data.name.Value; + string kitID = data.id.Value; + int kitVersion = (int)data.version.Value; + + if (kitID != Path.GetFileNameWithoutExtension(filepath)) + throw new ArgumentException($"Kit ID {kitID} does not match filename {Path.GetFileName(filepath)}."); + + var kit = new Kit(filepath, kitID, kitVersion, kitName); + + foreach (var floor in data.floors) + { + kit.Floors.Add(new FloorTemplate + { + KitID = kitID, + ID = floor.id.Value, + Name = floor.name.Value, + Model = floor.model.Value, + }); + } + + foreach (var ceiling in data.ceilings) + { + kit.Ceilings.Add(new CeilingTemplate + { + KitID = kitID, + ID = ceiling.id.Value, + Name = ceiling.name.Value, + Model = ceiling.model.Value, + }); + } + + foreach (var door in data.doorframes) + { + kit.DoorFrames.Add(new DoorFrameTemplate + { + KitID = kitID, + ID = door.id.Value, + Name = door.name.Value, + Model = door.model.Value, + Hooks = ((JArray)door.hooks).Select(x => (dynamic)x).Select(hook => new DoorFrameHookTemplate + { + Position = new Vector3(hook.position.ToObject()), + Orientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray() + }); + } + + foreach (var wall in data.walls) + { + kit.Walls.Add(new WallTemplate + { + KitID = kitID, + ID = wall.id.Value, + Name = wall.name.Value, + Model = wall.model.Value, + DoorFrameID = wall.doorframeID?.Value, + }); + } + + foreach (var tile in data.tiles) + { + kit.Tiles.Add(new TileTemplate + { + KitID = kitID, + ID = tile.id.Value, + Name = tile.name.Value, + DefaultFloorID = tile.defaultFloorID.Value, + DefaultCeilingID = tile.defaultCeilingID?.Value ?? "", + Walls = ((JArray)tile.wallHooks).Select(x => (dynamic)x).Select(hook => new WallHookTemplate + { + DefaultWallID = hook.defaultWallID, + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + InnerCorners = ((JArray)tile.innerCornerHooks).Select(x => (dynamic)x).Select(hook => new InnerCornerHookTemplate + { + DefaultCornerID = hook.defaultInnerCornerID.Value, + Adjacent = hook.adjacencies?.ToObject() ?? new int[0], + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + OuterCorners = ((JArray)tile.outerCornerHooks).Select(x => (dynamic)x).Select(hook => new OuterCornerHookTemplate + { + DefaultCornerID = hook.defaultOuterCornerID.Value, + Adjacent = hook.adjacencies?.ToObject() ?? new int[0], + LocalPosition = new Vector3(hook.position.ToObject()), + LocalOrientation = ((float[])hook.orientation.ToObject()).ToQuaternion() + }).ToArray(), + CeilingHooks = [] + }); + } + + foreach (var innerCorner in data.innerCorners) + { + kit.InnerCorners.Add(new InnerCornerTemplate + { + KitID = kitID, + ID = innerCorner.id.Value, + Name = innerCorner.name.Value, + Model = innerCorner.model.Value, + }); + } + + foreach (var outerCorner in data.outerCorners) + { + kit.OuterCorners.Add(new OuterCornerTemplate + { + KitID = kitID, + ID = outerCorner.id.Value, + Name = outerCorner.name.Value, + Model = outerCorner.model.Value, + }); + } + + foreach (var @object in data.objects) + { + kit.Objects.Add(new ObjectTemplate + { + KitID = kitID, + ID = @object.id.Value, + Name = @object.name.Value, + Model = @object.model.Value, + }); + } + + return kit; + } + + public static void Save(string filepath, Kit kit) + { + dynamic data = new ExpandoObject(); + + data.id = kit.ID; + data.version = kit.Version; + data.name = kit.Name; + data.format = FormatID; + + data.tiles = kit.Tiles.Select(tile => new + { + id = tile.ID, + name = tile.Name, + defaultFloorID = tile.DefaultFloorID, + defaultCeilingID = tile.DefaultCeilingID, + wallHooks = tile.Walls.Select(x => new + { + defaultWallID = x.DefaultWallID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + }), + innerCornerHooks = tile.InnerCorners.Select(x => new + { + defaultInnerCornerID = x.DefaultCornerID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + adjacencies = x.Adjacent, + }), + outerCornerHooks = tile.OuterCorners.Select(x => new + { + defaultOuterCornerID = x.DefaultCornerID, + position = x.LocalPosition.ToFloatArray(), + orientation = x.LocalOrientation.ToFloatArray(), + adjacencies = x.Adjacent, + }), + }); + + data.floors = kit.Floors.Select(floor => new + { + id = floor.ID, + name = floor.Name, + model = floor.Model, + }); + + data.ceilings = kit.Ceilings.Select(ceiling => new + { + id = ceiling.ID, + name = ceiling.Name, + model = ceiling.Model, + }); + + data.doorframes = kit.DoorFrames.Select(doorframe => new + { + id = doorframe.ID, + name = doorframe.Name, + model = doorframe.Model, + hooks = doorframe.Hooks.Select(hook => new + { + position = hook.Position.ToFloatArray(), + orientation = hook.Orientation.ToFloatArray(), + }) + }); + + data.walls = kit.Walls.Select(wall => new + { + id = wall.ID, + name = wall.Name, + model = wall.Model, + doorframeID = wall.DoorFrameID, + }); + + data.innerCorners = kit.InnerCorners.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + data.outerCorners = kit.OuterCorners.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + data.objects = kit.Objects.Select(obj => new + { + id = obj.ID, + name = obj.Name, + model = obj.Model, + }); + + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + File.WriteAllText(filepath, json); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs b/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs new file mode 100644 index 0000000000..ec7f4c7ba5 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_AddObjectMode.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Avalonia; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class AddObjectMode : BaseMode +{ + public override string Name => "Add Object"; + + private Object _addObject = null; + private float angle = 0; + + public AddObjectMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + var ray = camera.ProjectRay((int)mouse.X, (int)mouse.Y, _engine.Width, _engine.Height); + var point = ray.FindPointOnPlane(Axis.Z, 0); + + // todo - should be placed in room where walkmesh intersects. + // todo - should not be hardcoded + _addObject = new(_area.Rooms.Last(), Kit.Manager.Get("sandral").Object("sandral_object_0")); + _addObject.LocalPosition = point; + _addObject.LocalOrientation = Quaternion.CreateFromYawPitchRoll(0, 0, angle * (float)Math.PI / 180); + + var roomMeshDescriptors = new List(); + _areaEntity.RenderObject(_engine.AssetManager, _addObject, ref roomMeshDescriptors); + roomMeshDescriptors.ForEach(x => x.AmbientColor = new Vector3(1.5f, 1.5f, 1.5f)); + descriptors.AddRange(roomMeshDescriptors); + } + + public override async Task Trigger() + { + // todo - add to room within bounds of the cursor + var room = _area.Rooms.First(); + room.AddObject(_addObject); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs b/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs new file mode 100644 index 0000000000..1ee84fd526 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_AddRoomMode.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Avalonia; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class AddRoomMode : BaseMode +{ + public override string Name => "Add Room"; + + private Room _addRoomRoom = new Room(null); + private float angle = 0; + + public AddRoomMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + var ray = camera.ProjectRay((int)mouse.X, (int)mouse.Y, _engine.Width, _engine.Height); + var point = ray.FindPointOnPlane(Axis.Z, 0); + + _addRoomRoom = new Room(null); + _addRoomRoom.Position = point; + _addRoomRoom.Orientation = Quaternion.CreateFromYawPitchRoll(0, 0, angle * (float)Math.PI / 180); + + (var newWall, var oldWall, var distance) = NearestAdjacentWall(_addRoomRoom); + if (oldWall is not null) + { + newWall.SwitchTemplate(oldWall.Template); + newWall.DoorFrame.Enabled = false; + + if (oldWall.DoorFrame is not null) + { + _addRoomRoom.Orientation = oldWall.Orientation / newWall.Orientation * Quaternion.CreateFromYawPitchRoll(0, 0, MathF.PI); + _addRoomRoom.Position = oldWall.DoorFrame.Hooks.First().Position; + _addRoomRoom.Position += newWall.Parent.Position - newWall.DoorFrame.Hooks.Last().Position; + } + else + { + _addRoomRoom.Position = new(-1000, 0, 0); + } + } + + var roomMeshDescriptors = new List(); + _areaEntity.RenderRoom(_engine.AssetManager, _addRoomRoom, ref roomMeshDescriptors); + roomMeshDescriptors.ForEach(x => x.AmbientColor = new Vector3(1.5f, 1.5f, 1.5f)); + descriptors.AddRange(roomMeshDescriptors); + } + + public override async Task Trigger() + { + _area.AddRoom(_addRoomRoom); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_BaseMode.cs b/.cache/kotor_net_area_designer/Mode_BaseMode.cs new file mode 100644 index 0000000000..4b9602ae8a --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_BaseMode.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class BaseMode +{ + public virtual string Name { get; } + + protected readonly GLEngine _engine; + protected readonly Area _area; + + protected AreaEntity _areaEntity => _engine.Scene.Entities.OfType().Single(x => x.Area == _area); + + public BaseMode(GLEngine engine, Area area) + { + _engine = engine; + _area = area; + } + + public virtual Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + return Task.CompletedTask; + } + + public virtual Task Trigger() + { + return Task.CompletedTask; + } + + public virtual Task AlternativeTrigger() + { + return Task.CompletedTask; + } + + protected RaycastResult? NearestWallMagnest(OrbitCamera camera, double x, double y) + { + var ray = camera.ProjectRay((int)x, (int)y, _engine.Width, _engine.Height); + + return _area.Rooms + .SelectMany(x => x.Walls) + .Where(x => x.LinkedTile is null) + .OrderBy(x => ray.ShortestDistanceTo(x.Position)) + .Select(x => new RaycastResult(x, ray.ShortestDistanceTo(x.Position))) + .Where(x => x.Distance < 3) + .FirstOrDefault(); + } + + protected (Wall ThisHook, Wall OtherHook, float distance) NearestAdjacentWall(Room room) + { + var near = new List<(Wall NewHook, Wall OldHook, float distance)>(); + var otherWalls = _area.Rooms.SelectMany(x => x.Walls).ToList(); + + foreach (var wall in room.Walls) + { + var match = otherWalls + .Where(x => x.DoorFrame is not null) + .Where(x => Vector3.Distance(wall.Position, x.Position) < 3) + .OrderBy(x => Vector3.Distance(wall.Position, x.Position)) + .Select(x => (wall, x, Vector3.Distance(wall.Position, x.Position))) + .ToList(); + + if (match.Count > 0) + near.AddRange(match); + } + + return near.OrderBy(x => x.distance).FirstOrDefault(); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs b/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs new file mode 100644 index 0000000000..966d858003 --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_ExtendRoomMode.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; +using ReactiveUI; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class ExtendRoomMode : BaseMode +{ + public required Interaction GetMousePoint { get; init; } + public required Interaction SelectTileTemplate { get; init; } + + public override string Name => "Extend Room"; + + private Wall? _wall; + private bool validWall => _wall is not null && _wall.DoorFrame is null; + + public ExtendRoomMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + _wall = NearestWallMagnest(camera, (int)mouse.X, (int)mouse.Y)?.Result; + + if (_wall is not null) + { + if (!validWall) + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 0.5f, 0.5f)); + else + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 1.5f, 1.5f)); + } + } + + public override async Task Trigger() + { + //if (validWall) + // _wall!.Extend(TileTemplate.Sandral8x8); + } + + public override async Task AlternativeTrigger() + { + if (!validWall) + return; + + var template = await SelectTileTemplate.Handle(Unit.Default); + + if (template is null) + return; + + var tile = _wall!.Extend(template); + } +} diff --git a/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs b/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs new file mode 100644 index 0000000000..65ffa93c9b --- /dev/null +++ b/.cache/kotor_net_area_designer/Mode_SwitchWallMode.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia; +using Kotor.DevelopmentKit.AreaDesigner.Views; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Cameras; +using Kotor.NET.Graphics.Model; +using Kotor.NET.Graphics.OpenGL; +using ReactiveUI; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate.Mode; + +public class SwitchWallMode : BaseMode +{ + public required Interaction GetMousePoint { get; init; } + public required Interaction SelectWallTemplate { get; init; } + + public override string Name => "Switch Wall"; + + private Wall? _wall; + + public SwitchWallMode(GLEngine engine, Area area) : base(engine, area) + { + } + + public override async Task RenderIntercept(OrbitCamera camera, Point mouse, List descriptors) + { + _wall = NearestWallMagnest(camera, mouse.X, mouse.Y)?.Result; + + if (_wall is not null) + descriptors.Where(x => x.Tag == _wall).ToList().ForEach(x => x.AmbientColor = new(1.5f, 1.5f, 1.5f)); + } + + public override async Task Trigger() + { + var template = await SelectWallTemplate.Handle(Unit.Default); + + if (_wall is not null && template is not null) + { + _wall.SwitchTemplate(template); + } + } +} diff --git a/.cache/kotor_net_area_designer/Room.cs b/.cache/kotor_net_area_designer/Room.cs new file mode 100644 index 0000000000..276ca0fce7 --- /dev/null +++ b/.cache/kotor_net_area_designer/Room.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Avalonia.Markup.Xaml.Templates; +using DynamicData; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class Area +{ + private List _rooms = new(); + public IReadOnlyList Rooms => _rooms.AsReadOnly(); + + public void AddRoom(Room room) + { + _rooms.Add(room); + } +} + +public class Room +{ + public Vector3 Position { get; set; } = new(); + public Quaternion Orientation { get; set; } = new(); + public Matrix4x4 Transform => Matrix4x4.CreateFromQuaternion(Orientation) * Matrix4x4.CreateTranslation(Position); + + public ICollection Tiles { get; } = new List(); + public ICollection Walls => Tiles.SelectMany(x => x.Walls).ToList(); + public ICollection InnerCorners => Tiles.SelectMany(x => x.InnerCorners).ToList(); + public ICollection OuterCorners => Tiles.SelectMany(x => x.OuterCorners).ToList(); + public ICollection DoorFrames => Walls.Select(x => x.DoorFrame).Where(x => x is not null).ToList(); + public ICollection Objects = []; + + public Room() + { + } + public Room(RoomTemplate template) + { + Tiles.Add(new(this, Kit.Manager.Get("sandral").Tiles.ElementAt(0))); + } + + public void FixWalls() + { + foreach (var tileA in Tiles) + { + foreach (var tileB in Tiles) + { + if (tileA == tileB) + continue; + + foreach (var adjacent in GetCombinations(tileA.Walls, tileB.Walls)) + { + if (Vector3.Distance(adjacent.Item1.Position, adjacent.Item2.Position) < 0.01f) + { + adjacent.Item1.LinkedTile = tileB; + adjacent.Item2.LinkedTile = tileA; + } + } + } + } + } + + public void AddObject(Object @object) + { + Objects.Add(@object); + } + + // todo ienumerable extension + private List<(T Item1, T Item2)> GetCombinations(IEnumerable listA, IEnumerable listB) + { + // TODO convert to list extensions method? + + List<(T A, T B)> combinations = new(); + + foreach (var a in listA) + { + foreach (var b in listB) + { + var tuple = (a, b); + if (!combinations.Contains(tuple)) + combinations.Add(tuple); + } + } + + return combinations; + } +} + +public class Tile +{ + public Room Parent { get; } + + public Floor Floor { get; private set; } + public Ceiling Ceiling { get; private set; } + public IReadOnlyCollection Walls { get; private set; } + public IReadOnlyCollection InnerCorners { get; private set; } + public IReadOnlyCollection OuterCorners { get; private set; } + + public string KitID { get; private set; } + public string TemplateID { get; private set; } + public TileTemplate Template => Kit.Manager.Get(KitID).Tile(TemplateID); + + public Vector3 LocalPosition { get; set; } + public Quaternion LocalOrientation { get; set; } = new(0, 0, 0, 1); + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public Tile(Room parent, TileTemplate template) + { + Parent = parent; + KitID = template.KitID; + TemplateID = template.ID; + Floor = new(this, template.Floor); + Walls = template.Walls.Select(x => new Wall(this, x.DefaultTemplate, x)).ToArray(); + InnerCorners = template.InnerCorners.Select(x => new InnerCorner(this, x.DefaultTemplate, x)).ToArray(); + OuterCorners = template.OuterCorners.Select(x => new OuterCorner(this, x.DefaultTemplate, x)).ToArray(); + } + + public Tile Extend(Wall wall, TileTemplate template) + { + var newTile = new Tile(Parent, template); + + // todo - first compatible + var adjacent = newTile.Walls + .Where(x => x.Template.ID == wall.Template.ID) + //.OrderBy(x => x.LocalOrientaiton == wall.LocalOrientaiton) + .First(); + + newTile.LocalOrientation = wall.LocalOrientation + / adjacent.Hook.LocalOrientation + * Quaternion.CreateFromYawPitchRoll(0, 0, MathF.PI) + * Orientation + / Parent.Orientation; + + newTile.LocalPosition = LocalPosition + + Vector3.Transform(wall.LocalPosition, LocalOrientation) + - Vector3.Transform(adjacent.LocalPosition, newTile.LocalOrientation); + + Parent.Tiles.Add(newTile); + + // Link the new tile to the old tile, as well as any other touching tiles + Parent.FixWalls(); + + return newTile; + } + + public void SwitchTemplate(TileTemplate template) + { + //Template = template; + KitID = template.KitID; + TemplateID = template.ID; + Floor = new(this, template.Floor); + Walls = template.Walls.Select(x => new Wall(this, x.DefaultTemplate, x)).ToArray(); + InnerCorners = template.InnerCorners.Select(x => new InnerCorner(this, x.DefaultTemplate, x)).ToArray(); + OuterCorners = template.OuterCorners.Select(x => new OuterCorner(this, x.DefaultTemplate, x)).ToArray(); + } +} + +public class Wall +{ + public Tile Parent { get; } + public Room? LinkedRoom { get; set; } + public Tile? LinkedTile { get; set; } + public DoorFrame? DoorFrame { get; set; } + public WallHookTemplate Hook { get; set; } + + public string KitID { get; private set;} + public string TemplateID { get; private set; } + public WallTemplate Template => Kit.Manager.Get(KitID).Wall(TemplateID); + + public Vector3 LocalPosition => Hook.LocalPosition; + public Quaternion LocalOrientation => Hook.LocalOrientation; + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible => LinkedTile is null; + + public Wall(Tile parent, WallTemplate template, WallHookTemplate hook) + { + Parent = parent; + Hook = hook; + KitID = template.KitID; + TemplateID = template.ID; + } + + public Tile Extend(TileTemplate template) + { + return Parent.Extend(this, template); + } + + public void SwitchTemplate(WallTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + + if (template.DoorFrame is not null) + { + DoorFrame = new(this, template.DoorFrame); + } + else + { + DoorFrame = null; + } + } +} + +public class Floor +{ + public Tile Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public FloorTemplate Template => Kit.Manager.Get(KitID).Floor(TemplateID); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Parent.Transform; + + public Floor(Tile parent, FloorTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(FloorTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class Ceiling +{ + public Tile Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public CeilingTemplate Template => Kit.Manager.Get(KitID).Ceiling(TemplateID); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Parent.Transform; + + public Ceiling(Tile parent, CeilingTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(CeilingTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class InnerCorner +{ + public Tile Parent { get; } + public InnerCornerHookTemplate Hook { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public InnerCornerTemplate Template => Kit.Manager.Get(KitID).InnerCorner(TemplateID); + + public Vector3 LocalPosition => Hook.LocalPosition; + public Quaternion LocalOrientation => Hook.LocalOrientation; + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible + { + get + { + return Hook.Adjacent.Any() && Hook.Adjacent.All(x => Parent.Walls.ElementAt(x).LinkedTile is null); + } + } + + public InnerCorner(Tile parent, InnerCornerTemplate template, InnerCornerHookTemplate hook) + { + Parent = parent; + Hook = hook; + SwitchTemplate(template); + } + + public void SwitchTemplate(InnerCornerTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class OuterCorner +{ + public Tile Parent { get; } + public OuterCornerHookTemplate Hook { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public OuterCornerTemplate Template => Kit.Manager.Get(KitID).OuterCorner(TemplateID); + + public Vector3 Position => Hook.LocalPosition; + public Quaternion Orientation => Hook.LocalOrientation; + public Matrix4x4 Transform => Hook.LocalTransform * Parent.Transform; + + public bool Visible + { + get + { + if (Hook.Adjacent.Count() != 2) + return false; + if (Hook.Adjacent.Any(x => Parent.Walls.ElementAt(x).LinkedTile is null)) + return false; + + var a = Parent.Walls.ElementAt(Hook.Adjacent[0]).LinkedTile!.Walls.Select(x => x.LinkedTile).Where(x => x != Parent); + var b = Parent.Walls.ElementAt(Hook.Adjacent[1]).LinkedTile!.Walls.Select(x => x.LinkedTile).Where(x => x != Parent); + + var circuit = a.Intersect(b).Any(); + return !circuit; + } + } + + public OuterCorner(Tile parent, OuterCornerTemplate template, OuterCornerHookTemplate hook) + { + Parent = parent; + Hook = hook; + SwitchTemplate(template); + } + + public void SwitchTemplate(OuterCornerTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class DoorFrame +{ + public Wall Parent { get; } + + public string KitID { get; private set; } = ""; + public string TemplateID { get; private set; } = ""; + public DoorFrameTemplate Template => Kit.Manager.Get(KitID).DoorFrame(TemplateID); + + public bool Enabled { get; set; } = true; + + public IEnumerable Hooks => Template.Hooks.Select(x => new DoorFrameHook(this, x)); + + public Vector3 LocalPosition => Template.Hooks.Last().Position; + public Quaternion LocalOrientation => Template.Hooks.Last().Orientation; + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public bool Visible => Enabled; + + public DoorFrame(Wall parent, DoorFrameTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(DoorFrameTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} + +public class DoorFrameHook +{ + public DoorFrame Parent { get; } + public DoorFrameHookTemplate Template { get; } + + public Vector3 LocalPosition => Template.Position; + public Quaternion LocalOrientation => Template.Orientation; + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Vector3 Position => Matrix4x4.Decompose(Transform, out _, out _, out var value) ? value : new(); + public Quaternion Orientation => Matrix4x4.Decompose(Transform, out _, out var value, out _) ? value : new(); + public Matrix4x4 Transform => LocalTransform * Parent.Transform; + + public DoorFrameHook(DoorFrame parent, DoorFrameHookTemplate template) + { + Parent = parent; + Template = template; + } +} + +public class Object +{ + public Room Parent { get; } + + public string KitID { get; private set; } + public string TemplateID { get; private set; } + public ObjectTemplate Template => Kit.Manager.Get(KitID).Object(TemplateID); + + public Vector3 LocalPosition { get; set; } + public Quaternion LocalOrientation { get; set; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public Object(Room parent, ObjectTemplate template) + { + Parent = parent; + SwitchTemplate(template); + } + + public void SwitchTemplate(ObjectTemplate template) + { + KitID = template.KitID; + TemplateID = template.ID; + } +} diff --git a/.cache/kotor_net_area_designer/RoomEntity.cs b/.cache/kotor_net_area_designer/RoomEntity.cs new file mode 100644 index 0000000000..1d8713b787 --- /dev/null +++ b/.cache/kotor_net_area_designer/RoomEntity.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Controls; +using Kotor.NET.Graphics; +using Kotor.NET.Graphics.Entities; +using Kotor.NET.Graphics.Model; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class AreaEntity : BaseEntity +{ + public Area Area { get; set; } = new(); + + public override ICollection GetMeshDescriptors(IAssetManager assets) + { + var descriptors = new List(); + + foreach (var room in Area.Rooms) + { + RenderRoom(assets, room, ref descriptors); + } + + return descriptors; + } + public void RenderRoom(IAssetManager assets, Room room, ref List descriptors) + { + foreach (var tile in room.Tiles) + { + RenderTile(assets, tile, ref descriptors); + } + foreach (var wall in room.Walls) + { + RenderWall(assets, wall, ref descriptors); + } + foreach (var doorframe in room.DoorFrames) + { + RenderDoorFrame(assets, doorframe, ref descriptors); + } + foreach (var corner in room.InnerCorners) + { + RenderInnerCorner(assets, corner, ref descriptors); + } + foreach (var corner in room.OuterCorners) + { + RenderOuterCorner(assets, corner, ref descriptors); + } + foreach (var @object in room.Objects) + { + RenderObject(assets, @object, ref descriptors); + } + } + private void RenderTile(IAssetManager assets, Tile tile, ref List descriptors) + { + descriptors.AddRange(DescriptorsForModel(assets, tile.Floor.Template.Model, tile.Transform)); + } + private void RenderWall(IAssetManager assets, Wall wall, ref List descriptors) + { + if (!wall.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, wall.Template.Model, wall.Transform, wall)); + } + private void RenderDoorFrame(IAssetManager assets, DoorFrame doorframe, ref List descriptors) + { + if (!doorframe.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, doorframe.Template.Model, doorframe.Transform, doorframe)); + } + private void RenderInnerCorner(IAssetManager assets, InnerCorner corner, ref List descriptors) + { + if (!corner.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, corner.Template.Model, corner.Transform)); + } + private void RenderOuterCorner(IAssetManager assets, OuterCorner corner, ref List descriptors) + { + if (!corner.Visible) + return; + + descriptors.AddRange(DescriptorsForModel(assets, corner.Template.Model, corner.Transform)); + } + public void RenderObject(IAssetManager assets, Object @object, ref List descriptors) + { + descriptors.AddRange(DescriptorsForModel(assets, @object.Template.Model, @object.LocalTransform)); + } + // TODO - clean this up somehow + private ICollection DescriptorsForModel(IAssetManager assets, string modelName, Matrix4x4 transform, object tag = null) + { + var model = assets.GetModel(modelName); + model.Root.GenerateTransform([]); + return model.GetAllNodes() + .SelectMany(node => node.GetMeshDescriptors(transform)) + .Select(x => + { + x.Tag = tag; + return x; + }) + .ToList(); + } + + public override void Update(IAssetManager assetManager, float delta) + { + + } +} diff --git a/.cache/kotor_net_area_designer/RoomTemplate.cs b/.cache/kotor_net_area_designer/RoomTemplate.cs new file mode 100644 index 0000000000..f03f34dc99 --- /dev/null +++ b/.cache/kotor_net_area_designer/RoomTemplate.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using Kotor.DevelopmentKit.AreaDesigner.relocate; + +namespace Kotor.DevelopmentKit.AreaDesigner.relocate; + +public class RoomTemplate +{ +} + +public class FloorTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class TileTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string DefaultFloorID { get; init; } + public required string DefaultCeilingID { get; init; } + public required WallHookTemplate[] Walls { get; init; } + public required InnerCornerHookTemplate[] InnerCorners { get; init; } + public required OuterCornerHookTemplate[] OuterCorners { get; init; } + public required Vector3[] CeilingHooks { get; init; } + + public FloorTemplate Floor => Kit.Manager.Get(KitID).Floor(DefaultFloorID); +} + + +public class WallTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + public required string DoorFrameID { get; init; } + + public DoorFrameTemplate? DoorFrame => (DoorFrameID is not null) ? Kit.Manager.Get(KitID).DoorFrame(DoorFrameID) : null; + public bool CanBeDoor => DoorFrame is not null; +} +public class WallHookTemplate +{ + public required string DefaultWallID { get; init; } + public WallTemplate DefaultTemplate => Kit.Manager.Get("sandral").Wall(DefaultWallID); // todo - remove hardcoding + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + + public int[] AdjacentWalls { get; init; } = []; + + //public ICollection CompatibleWallTemplates { get; } + //public ICollection CompatibleTileTemplates { get; } +} +public class InnerCornerHookTemplate +{ + public required string DefaultCornerID { get; init; } + public InnerCornerTemplate DefaultTemplate => Kit.Manager.Get("sandral").InnerCorner(DefaultCornerID); // todo - remove hardcoding + + public required int[] Adjacent { get; init; } + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + +} +public class OuterCornerHookTemplate +{ + public required string DefaultCornerID { get; init; } + public OuterCornerTemplate DefaultTemplate => Kit.Manager.Get("sandral").OuterCorner(DefaultCornerID); // todo - remove hardcoding + + public required int[] Adjacent { get; init; } + + public required Vector3 LocalPosition { get; init; } + public required Quaternion LocalOrientation { get; init; } + public Matrix4x4 LocalTransform => Matrix4x4.CreateFromQuaternion(LocalOrientation) * Matrix4x4.CreateTranslation(LocalPosition); + +} + +public class DoorFrameTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + public required DoorFrameHookTemplate[] Hooks { get; init; } +} +public class DoorFrameHookTemplate +{ + public required Vector3 Position { get; init; } + public required Quaternion Orientation { get; init; } +} + +public class CeilingTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class ObjectTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + + +public class InnerCornerTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} + +public class OuterCornerTemplate +{ + public required string KitID { get; init; } + public required string ID { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } +} diff --git a/Libraries/PyKotor/src/pykotor/gl/native/_gl_accel.cp313-win_amd64.pyd b/Libraries/PyKotor/src/pykotor/gl/native/_gl_accel.cp313-win_amd64.pyd index b10270fb7a55c1d5bf563082d5c90f5b6511bbf2..5145a1de49dcbceab2258a4a9e9cec93065a7125 100644 GIT binary patch delta 27 fcmZoT!Pszuaf1LO^L2v{n}rzc# delta 27 gcmZoT!Pszuaf1LO^QnigHw!Z!bOST4yKBk-0I5(6Y5)KL diff --git a/Libraries/PyKotor/src/pykotor/gl/native/_render2d_accel.cp313-win_amd64.pyd b/Libraries/PyKotor/src/pykotor/gl/native/_render2d_accel.cp313-win_amd64.pyd index 35de7c1607529123a0cbabdfce63f3fdf4826a7f..61dd8ae05e07f2e2615d0c7b3784ba92b228608e 100644 GIT binary patch delta 27 fcmZqZU~K4M+#tZne8b?wW?{yS+F-_U9ZPWljJyhC delta 27 fcmZqZU~K4M+#tZneCpxr&BBZuwZV+zI+o%9n%oPY diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py new file mode 100644 index 0000000000..3b1d75ecc7 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_ops.py @@ -0,0 +1,43 @@ +"""Stable operations API for the indoor Area Designer runtime (Kotor.NET ``Room`` / ``Tile`` parity). + +Prefer importing from here in Toolset code so call sites stay decoupled from +``area_designer_runtime`` internals. +""" + +from __future__ import annotations + +from pykotor.tools.area_designer_runtime import ( + ADArea, + ADObject, + ADRoom, + ADTile, + ADWall, + add_object_to_room, + add_room_with_tile, + build_runtime_from_v01, + fix_walls, + inner_corner_visible, + iter_render_instances, + outer_corner_visible, + runtime_to_v01, + switch_wall_template, + tile_extend_wall, +) + +__all__ = [ + "ADArea", + "ADObject", + "ADRoom", + "ADTile", + "ADWall", + "add_object_to_room", + "add_room_with_tile", + "build_runtime_from_v01", + "fix_walls", + "inner_corner_visible", + "iter_render_instances", + "outer_corner_visible", + "runtime_to_v01", + "switch_wall_template", + "tile_extend_wall", +] diff --git a/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py b/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py new file mode 100644 index 0000000000..37a57b7220 --- /dev/null +++ b/Libraries/PyKotor/src/pykotor/tools/area_designer_runtime.py @@ -0,0 +1,669 @@ +"""Kotor.NET ``relocate/Room.cs`` + ``RoomEntity`` runtime mirror (pure Python). + +Provides the same structural behaviour as the C# AreaDesigner domain layer: + +- ``Room.FixWalls`` interior edge pairing +- ``Wall.Visible``, ``DoorFrame.Visible``, ``InnerCorner.Visible``, ``OuterCorner.Visible`` +- JSON ↔ runtime round-trip aligned with ``AreaSerializer_V0_1`` (plus optional ``objects[]``) + +Rendering order matches ``AreaEntity.RenderRoom``: floors (per tile), walls, doorframes, inner corners, +outer corners, room objects — see ``iter_render_instances``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterator + +from pykotor.common.tilekit import ( + CornerHookTemplate, + KitTileRecord, + QuaternionWXYZ, + TileKit, + TileTemplate, +) +from pykotor.gl import decompose, inverse, mat4_cast, quat, translate, vec3, vec4 +from pykotor.gl import glm as glm_mod +from utility.common.geometry import Vector3 + +# Match ``Room.FixWalls``: ``Vector3.Distance(...) < 0.01f`` +_LINK_EPS_SQ = (0.01) ** 2 + + +def _multiply(a: Any, b: Any) -> Any: + return a * b + + +def _template_for_resref(kit: TileKit, resref: str) -> TileTemplate | None: + if not resref: + return None + for t in kit.all_templates(): + if t.resref == resref or t.template_id == resref: + return t + return None + + +def _kit_tile_record(kit: TileKit, tile_template_id: str) -> KitTileRecord | None: + for tr in kit.tiles: + if tr.tile_id == tile_template_id: + return tr + return None + + +def _v3(v: Vector3) -> Any: + return vec3(float(v.x), float(v.y), float(v.z)) + + +def _mat_rt(q: Any, pos: Vector3) -> Any: + return mat4_cast(q) * translate(_v3(pos)) + + +def _glm_quat_from_net_xyzw(seq: list[float] | None) -> Any: + if not seq or len(seq) < 4: + return quat(1.0, 0.0, 0.0, 0.0) + x, y, z, w = (float(seq[0]), float(seq[1]), float(seq[2]), float(seq[3])) + return quat(w, x, y, z) + + +def _glm_quat_from_wxyz(qw: QuaternionWXYZ) -> Any: + return quat(qw.w, qw.x, qw.y, qw.z) + + +def _mat4_translation_xyz(m: Any) -> tuple[float, float, float]: + return (float(m[3][0]), float(m[3][1]), float(m[3][2])) + + +def _dist_sq(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + dx = a[0] - b[0] + dy = a[1] - b[1] + dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz + + +@dataclass +class ADWall: + """Runtime wall slot on a tile (``relocate/Wall``).""" + + parent_tile: ADTile + hook_index: int + kit_id: str + template_id: str + doorframe_enabled: bool = True + linked_tile: ADTile | None = None + + @property + def visible(self) -> bool: + return self.linked_tile is None + + +@dataclass +class ADFloor: + kit_id: str + template_id: str + + +@dataclass +class ADObject: + kit_id: str + template_id: str + position: Vector3 + orientation_net: list[float] + + +@dataclass +class ADTile: + room: ADRoom + kit_id: str + template_id: str + local_position: Vector3 + orientation_net: list[float] + floor: ADFloor + ceiling: ADFloor | None = None + walls: list[ADWall] = field(default_factory=list) + kit_record: KitTileRecord | None = None + + def world_matrix(self, m_room: Any) -> Any: + q_tile = _glm_quat_from_net_xyzw(self.orientation_net) + m_local = _mat_rt(q_tile, self.local_position) + return _multiply(m_room, m_local) + + +@dataclass +class ADRoom: + position: Vector3 + orientation_net: list[float] + tiles: list[ADTile] = field(default_factory=list) + objects: list[ADObject] = field(default_factory=list) + + def world_matrix(self) -> Any: + q = _glm_quat_from_net_xyzw(self.orientation_net) + return _mat_rt(q, self.position) + + +@dataclass +class ADArea: + rooms: list[ADRoom] = field(default_factory=list) + + +def fix_walls(room: ADRoom) -> None: + """Mirror ``Room.FixWalls``: pair walls from different tiles by world hook positions.""" + for w in _iter_walls(room): + w.linked_tile = None + + samples: list[tuple[ADWall, tuple[float, float, float]]] = [] + m_room = room.world_matrix() + for tile in room.tiles: + m_tile = tile.world_matrix(m_room) + kt = tile.kit_record + if kt is None: + continue + for w in tile.walls: + if w.hook_index >= len(kt.wall_hooks): + continue + hook = kt.wall_hooks[w.hook_index] + qw = _glm_quat_from_wxyz(hook.orientation) + m_hook = _mat_rt(qw, hook.position) + m_wall = _multiply(m_tile, m_hook) + samples.append((w, _mat4_translation_xyz(m_wall))) + + for a in range(len(samples)): + wa, pa = samples[a] + for b in range(a + 1, len(samples)): + wb, pb = samples[b] + if wa.parent_tile is wb.parent_tile: + continue + if _dist_sq(pa, pb) <= _LINK_EPS_SQ: + wa.linked_tile = wb.parent_tile + wb.linked_tile = wa.parent_tile + + +def _iter_walls(room: ADRoom) -> Iterator[ADWall]: + for t in room.tiles: + yield from t.walls + + +def inner_corner_visible(ic: CornerHookTemplate, tile: ADTile) -> bool: + """``InnerCorner.Visible`` from ``Room.cs``.""" + if not ic.adjacent: + return False + return all(tile.walls[j].linked_tile is None for j in ic.adjacent) + + +def outer_corner_visible(oc: CornerHookTemplate, tile: ADTile) -> bool: + """``OuterCorner.Visible`` from ``Room.cs`` (circuit test).""" + adj = oc.adjacent + if len(adj) != 2: + return False + w0, w1 = tile.walls[adj[0]], tile.walls[adj[1]] + if w0.linked_tile is None or w1.linked_tile is None: + return False + lt0, lt1 = w0.linked_tile, w1.linked_tile + parent = tile + + def neighbor_tiles(other: ADTile) -> set[ADTile]: + out: set[ADTile] = set() + for w in other.walls: + if w.linked_tile is not None and w.linked_tile is not parent: + out.add(w.linked_tile) + return out + + set_a = neighbor_tiles(lt0) + set_b = neighbor_tiles(lt1) + circuit = bool(set_a & set_b) + return not circuit + + +def build_runtime_from_v01( + area: dict[str, Any], + kits_by_id: dict[str, TileKit], +) -> ADArea: + """Construct runtime model from ``format: \"0.1\"`` JSON (optionally with ``objects`` per room).""" + out = ADArea() + rooms = area.get("rooms") + if not isinstance(rooms, list): + return out + + for room_data in rooms: + if not isinstance(room_data, dict): + continue + pos_l = room_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_l) < 3: + pos_l = [0.0, 0.0, 0.0] + room = ADRoom( + position=Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])), + orientation_net=list(room_data.get("orientation") or [0.0, 0.0, 0.0, 1.0])[:4], + ) + + tiles_l = room_data.get("tiles") + if isinstance(tiles_l, list): + for tile_data in tiles_l: + if not isinstance(tile_data, dict): + continue + kit_id = str(tile_data.get("kitID", "")) + template_id = str(tile_data.get("templateID", "")) + kit = kits_by_id.get(kit_id) + if kit is None: + continue + kt = _kit_tile_record(kit, template_id) + pos_t = tile_data.get("position") or [0.0, 0.0, 0.0] + if len(pos_t) < 3: + pos_t = [0.0, 0.0, 0.0] + ori_t = tile_data.get("orientation") or [0.0, 0.0, 0.0, 1.0] + floor_block = tile_data.get("floor") + fk, ftid = kit_id, "" + if isinstance(floor_block, dict): + fk = str(floor_block.get("kitID", kit_id)) + ftid = str(floor_block.get("templateID", "")) + ceiling_ad: ADFloor | None = None + ceiling_block = tile_data.get("ceiling") + if isinstance(ceiling_block, dict): + ck = str(ceiling_block.get("kitID", "")) + ctid = str(ceiling_block.get("templateID", "")) + if ck or ctid: + ceiling_ad = ADFloor(kit_id=ck or kit_id, template_id=ctid) + tile = ADTile( + room=room, + kit_id=kit_id, + template_id=template_id, + local_position=Vector3(float(pos_t[0]), float(pos_t[1]), float(pos_t[2])), + orientation_net=list(ori_t)[:4] if isinstance(ori_t, list) else [0.0, 0.0, 0.0, 1.0], + floor=ADFloor(kit_id=fk, template_id=ftid), + ceiling=ceiling_ad, + kit_record=kt, + ) + + walls_saved = tile_data.get("walls") + if kt is not None and isinstance(walls_saved, list): + for i, wall_block in enumerate(walls_saved): + if not isinstance(wall_block, dict): + continue + if i >= len(kt.wall_hooks): + break + wk = str(wall_block.get("kitID", kit_id)) + wtid = str(wall_block.get("templateID", "")) + tile.walls.append( + ADWall( + parent_tile=tile, + hook_index=i, + kit_id=wk, + template_id=wtid, + ), + ) + room.tiles.append(tile) + + objs = room_data.get("objects") + if isinstance(objs, list): + for od in objs: + if not isinstance(od, dict): + continue + op = od.get("position") or [0.0, 0.0, 0.0] + if len(op) < 3: + op = [0.0, 0.0, 0.0] + room.objects.append( + ADObject( + kit_id=str(od.get("kitID", "")), + template_id=str(od.get("templateID", "")), + position=Vector3(float(op[0]), float(op[1]), float(op[2])), + orientation_net=list(od.get("orientation") or [0.0, 0.0, 0.0, 1.0])[:4], + ), + ) + + fix_walls(room) + out.rooms.append(room) + + return out + + +def runtime_to_v01(area: ADArea) -> dict[str, Any]: + """Serialize to ``AreaSerializer_V0_1``-shaped JSON (includes ``objects`` when present).""" + rooms_out: list[dict[str, Any]] = [] + for room in area.rooms: + pos = room.position + tiles_js: list[dict[str, Any]] = [] + for tile in room.tiles: + floor = tile.floor + walls_js = [{"kitID": w.kit_id, "templateID": w.template_id} for w in tile.walls] + ceil_js = {"kitID": "", "templateID": ""} + if tile.ceiling: + ceil_js = {"kitID": tile.ceiling.kit_id, "templateID": tile.ceiling.template_id} + tiles_js.append( + { + "kitID": tile.kit_id, + "templateID": tile.template_id, + "position": [tile.local_position.x, tile.local_position.y, tile.local_position.z], + "orientation": list(tile.orientation_net), + "floor": {"kitID": floor.kit_id, "templateID": floor.template_id}, + "ceiling": ceil_js, + "walls": walls_js, + }, + ) + rd: dict[str, Any] = { + "position": [pos.x, pos.y, pos.z], + "orientation": list(room.orientation_net), + "tiles": tiles_js, + } + if room.objects: + rd["objects"] = [ + { + "kitID": o.kit_id, + "templateID": o.template_id, + "position": [o.position.x, o.position.y, o.position.z], + "orientation": list(o.orientation_net), + } + for o in room.objects + ] + rooms_out.append(rd) + + return {"format": "0.1", "rooms": rooms_out} + + +def iter_render_instances( + area: ADArea, + kits_by_id: dict[str, TileKit], + *, + show_walls: bool = True, + show_doors: bool = True, + show_corners: bool = True, + show_ceilings: bool = False, + show_objects: bool = True, + respect_adjacency_visibility: bool = True, +) -> Iterator[tuple[str, Any]]: + """Yield ``(resref, world_mat4)`` in ``AreaEntity.RenderRoom`` order.""" + + def add_model(resref: str, world: Any) -> Iterator[tuple[str, Any]]: + if resref: + yield (resref, world) + + for room in area.rooms: + m_room = room.world_matrix() + # --- Floors (per tile, tile iteration order) --- + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + if kit is None: + continue + ftpl = _template_for_resref(kit, tile.floor.template_id) + if ftpl is not None and ftpl.resref: + yield from add_model(ftpl.resref, tile.world_matrix(m_room)) + + if show_ceilings and tile.ceiling and tile.ceiling.template_id: + ckit = kits_by_id.get(tile.ceiling.kit_id) or kit + ctpl = _template_for_resref(ckit, tile.ceiling.template_id) + if ctpl is not None and ctpl.resref: + yield from add_model(ctpl.resref, tile.world_matrix(m_room)) + + # --- Walls + doorframes --- + if show_walls: + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + kt = tile.kit_record + if kit is None or kt is None: + continue + m_tile = tile.world_matrix(m_room) + for i, w in enumerate(tile.walls): + if respect_adjacency_visibility and not w.visible: + continue + if i >= len(kt.wall_hooks): + continue + wkit = kits_by_id.get(w.kit_id) or kit + wtpl = _template_for_resref(wkit, w.template_id) + if wtpl is None or not wtpl.resref: + continue + hook = kt.wall_hooks[i] + q_h = _glm_quat_from_wxyz(hook.orientation) + m_hook = _mat_rt(q_h, hook.position) + m_wall = _multiply(m_tile, m_hook) + yield from add_model(wtpl.resref, m_wall) + if ( + show_doors + and w.doorframe_enabled + and wtpl.doorframe_id + and wtpl.doorframe_hooks + and (df := _template_for_resref(wkit, wtpl.doorframe_id)) is not None + ): + dh = wtpl.doorframe_hooks[-1] + q_df = _glm_quat_from_wxyz(dh.orientation) + m_df_loc = _mat_rt(q_df, dh.position) + m_df = _multiply(m_wall, m_df_loc) + yield from add_model(df.resref, m_df) + + # --- Corners --- + if show_corners: + for tile in room.tiles: + kit = kits_by_id.get(tile.kit_id) + kt = tile.kit_record + if kit is None or kt is None: + continue + m_tile = tile.world_matrix(m_room) + for ic in kt.inner_corner_hooks: + if respect_adjacency_visibility and not inner_corner_visible(ic, tile): + continue + itpl = _template_for_resref(kit, ic.default_corner_id) + if itpl is None or not itpl.resref: + continue + q_h = _glm_quat_from_wxyz(ic.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, ic.position)) + yield from add_model(itpl.resref, m_h) + for oc in kt.outer_corner_hooks: + if respect_adjacency_visibility and not outer_corner_visible(oc, tile): + continue + otpl = _template_for_resref(kit, oc.default_corner_id) + if otpl is None or not otpl.resref: + continue + q_h = _glm_quat_from_wxyz(oc.orientation) + m_h = _multiply(m_tile, _mat_rt(q_h, oc.position)) + yield from add_model(otpl.resref, m_h) + + # --- Objects (room scope), ``AreaExporter`` uses local transform then ``RoomToMDL`` — + # ``AreaEntity`` applies ``LocalTransform`` only; we apply ``room`` like PyKotor preview. --- + if show_objects: + for obj in room.objects: + okit = kits_by_id.get(obj.kit_id) + if okit is None: + continue + otpl = _template_for_resref(okit, obj.template_id) + if otpl is None or not otpl.resref: + continue + q_o = _glm_quat_from_net_xyzw(obj.orientation_net) + m_obj_local = _mat_rt(q_o, obj.position) + m_obj = _multiply(m_room, m_obj_local) + yield from add_model(otpl.resref, m_obj) + + +def quat_yaw_pi() -> Any: + """``Quaternion.CreateFromYawPitchRoll(0, 0, PI)`` for ``Tile.Extend``.""" + return quat(0.0, 0.0, 0.0, -1.0) # 180° about Z in common game coords — matches yaw roll 0,0,pi z-up + + +def tile_extend_wall( + tile: ADTile, + wall_index: int, + new_tile_template_id: str, + kits_by_id: dict[str, TileKit], +) -> ADTile: + """Mirror ``Tile.Extend`` / ``Wall.Extend`` from ``Room.cs`` (new tile in same room).""" + kit = kits_by_id.get(tile.kit_id) + if kit is None: + msg = f"Unknown kit {tile.kit_id!r}" + raise ValueError(msg) + kt_src = tile.kit_record + if kt_src is None: + msg = "Source tile has no kit tile record" + raise ValueError(msg) + new_tpl_rec = _kit_tile_record(kit, new_tile_template_id) + if new_tpl_rec is None: + msg = f"Unknown tile template {new_tile_template_id!r} in kit {tile.kit_id!r}" + raise ValueError(msg) + + if wall_index < 0 or wall_index >= len(tile.walls): + msg = f"Wall index {wall_index} out of range" + raise ValueError(msg) + wall = tile.walls[wall_index] + # Matching wall on new tile: same wall **template id** as source wall's slot default was designed for; + # C# matches ``x.Template.ID == wall.Template.ID`` on **WallTemplate.ID**. + candidate_idx: int | None = None + for j, wn in enumerate(new_tpl_rec.wall_hooks): + dw = _template_for_resref(kit, wn.default_wall_id) + if dw is not None and dw.template_id == wall.template_id: + candidate_idx = j + break + if candidate_idx is None: + # Fallback: first hook with same default wall template id string + for j, wn in enumerate(new_tpl_rec.wall_hooks): + if wn.default_wall_id == wall.template_id: + candidate_idx = j + break + if candidate_idx is None: + msg = "Could not find compatible wall hook on new tile template for Extend" + raise ValueError(msg) + + adjacent_hook = new_tpl_rec.wall_hooks[candidate_idx] + room = tile.room + hook_old = kt_src.wall_hooks[wall_index] + q_wall = _glm_quat_from_wxyz(hook_old.orientation) + q_adj = _glm_quat_from_wxyz(adjacent_hook.orientation) + q_yaw = quat_yaw_pi() + + m_room = room.world_matrix() + m_tile_old = tile.world_matrix(m_room) + q_tile_world = _decompose_rotation(m_tile_old) + q_room_world = _decompose_rotation(m_room) + + # ``Tile.Extend``: wall.LocalOrientation / adjacent.Hook.LocalOrientation * yaw * Orientation / Parent.Orientation + q_local = q_wall * inverse(q_adj) * q_yaw * q_tile_world * inverse(q_room_world) + + q_tile_old = _glm_quat_from_net_xyzw(tile.orientation_net) + t_add = _transform_vec3_by_quat(q_tile_old, hook_old.position) + t_sub = _transform_vec3_by_quat(q_local, adjacent_hook.position) + new_pos = tile.local_position + t_add - t_sub + + ceil_ad: ADFloor | None = None + if new_tpl_rec.default_ceiling_id: + ceil_ad = ADFloor(kit_id=tile.kit_id, template_id=new_tpl_rec.default_ceiling_id) + + new_tile = ADTile( + room=room, + kit_id=tile.kit_id, + template_id=new_tile_template_id, + local_position=new_pos, + orientation_net=_net_xyzw_from_glm_quat(q_local), + floor=ADFloor(kit_id=tile.kit_id, template_id=new_tpl_rec.default_floor_id or ""), + ceiling=ceil_ad, + kit_record=new_tpl_rec, + ) + # Populate walls from kit hooks + default templates + for i, _wh in enumerate(new_tpl_rec.wall_hooks): + dw = _template_for_resref(kit, _wh.default_wall_id) + tid = dw.template_id if dw is not None else _wh.default_wall_id + kid = dw.kit_id if dw is not None else tile.kit_id + new_tile.walls.append( + ADWall(parent_tile=new_tile, hook_index=i, kit_id=kid, template_id=tid), + ) + + room.tiles.append(new_tile) + fix_walls(room) + return new_tile + + +def _decompose_rotation(m: Any) -> Any: + """Extract quaternion rotation from a 4×4 transform (PyGLM ``decompose``).""" + scale = glm_mod.vec3() + rotation = glm_mod.quat() + translation = glm_mod.vec3() + skew = glm_mod.vec3() + persp = glm_mod.vec4() + decompose(m, scale, rotation, translation, skew, persp) + return rotation + + +def _transform_vec3_by_quat(q: Any, v: Vector3) -> Vector3: + """``Vector3.Transform(v, q)`` (System.Numerics): rotate vector by unit quaternion.""" + mm = mat4_cast(q) + r = mm * vec4(float(v.x), float(v.y), float(v.z), 1.0) + return Vector3(float(r.x), float(r.y), float(r.z)) + + +def _net_xyzw_from_glm_quat(q: Any) -> list[float]: + """glm quat (w,x,y,z) → .NET ``[x,y,z,w]``.""" + return [float(q.x), float(q.y), float(q.z), float(q.w)] + + +def switch_wall_template( + wall: ADWall, + wall_template_id: str, + kits_by_id: dict[str, TileKit], +) -> None: + """Mirror ``Wall.SwitchTemplate`` — updates kit/template refs and doorframe presence.""" + kit = kits_by_id.get(wall.kit_id) + if kit is None: + msg = f"Unknown kit {wall.kit_id!r}" + raise ValueError(msg) + wtpl = _template_for_resref(kit, wall_template_id) + if wtpl is None: + msg = f"Unknown wall template {wall_template_id!r}" + raise ValueError(msg) + wall.template_id = wtpl.template_id + wall.kit_id = kit.kit_id # TileKit.kit_id matches wall kit from template + wall.doorframe_enabled = bool(wtpl.doorframe_id) + + +def add_room_with_tile( + area: ADArea, + *, + kit_id: str, + tile_template_id: str, + position: Vector3, + orientation_net: list[float], + kits_by_id: dict[str, TileKit], +) -> ADRoom: + """Create a room with a single tile (minimal ``AddRoomMode`` / ``Room(RoomTemplate)`` analogue).""" + kit = kits_by_id.get(kit_id) + if kit is None: + msg = f"Unknown kit {kit_id!r}" + raise ValueError(msg) + kt = _kit_tile_record(kit, tile_template_id) + if kt is None: + msg = f"Unknown tile template {tile_template_id!r}" + raise ValueError(msg) + + room = ADRoom(position=position, orientation_net=list(orientation_net)[:4]) + ceil_ad: ADFloor | None = None + if kt.default_ceiling_id: + ceil_ad = ADFloor(kit_id=kit_id, template_id=kt.default_ceiling_id) + tile = ADTile( + room=room, + kit_id=kit_id, + template_id=tile_template_id, + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=ADFloor(kit_id=kit_id, template_id=kt.default_floor_id or ""), + ceiling=ceil_ad, + kit_record=kt, + ) + for i, _wh in enumerate(kt.wall_hooks): + dw = _template_for_resref(kit, _wh.default_wall_id) + tid = dw.template_id if dw is not None else _wh.default_wall_id + kid = dw.kit_id if dw is not None else kit_id + tile.walls.append(ADWall(parent_tile=tile, hook_index=i, kit_id=kid, template_id=tid)) + room.tiles.append(tile) + fix_walls(room) + area.rooms.append(room) + return room + + +def add_object_to_room( + room: ADRoom, + *, + kit_id: str, + template_id: str, + position: Vector3, + orientation_net: list[float], +) -> None: + """Append a room-scoped placeable (``Room.AddObject``).""" + room.objects.append( + ADObject( + kit_id=kit_id, + template_id=template_id, + position=position, + orientation_net=list(orientation_net)[:4], + ), + ) diff --git a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py index a701765dba..dce7cbe55f 100644 --- a/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py +++ b/Libraries/PyKotor/src/pykotor/tools/tilekit_preview.py @@ -7,9 +7,9 @@ Doorframe world pose matches `DoorFrame` in `Room.cs`: ``LocalTransform`` uses **only the last** template hook (`Template.Hooks.Last()`). -Saved JSON does not encode wall-link visibility; we draw wall meshes whenever template refs exist. -Outer-corner visibility in .NET depends on adjacency + links; preview draws kit hooks unless we add -a future visibility pass. +When ``respect_adjacency_visibility`` is set (Toolset default), wall pairing follows ``Room.FixWalls`` +(interior walls hidden), ``InnerCorner.Visible`` / ``OuterCorner.Visible`` from ``Room.cs`` (via +``area_designer_runtime``). Also supports PyKotor `TileLayout` (floors only) for `.indoor` `tile_layout`. """ @@ -26,8 +26,9 @@ TileKit, TileTemplate, ) -from pykotor.gl import eulerAngles, mat4, mat4_cast, quat, translate, vec3 +from pykotor.gl import eulerAngles, mat4_cast, quat, translate, vec3 from pykotor.gl.models.read_mdl import gl_load_stitched_model +from pykotor.tools.area_designer_runtime import build_runtime_from_v01, iter_render_instances from pykotor.gl.scene.render_object import RenderObject from pykotor.gl.scene.scene import Scene from pykotor.gl.shader import Texture @@ -151,19 +152,21 @@ def populate_scene_from_area_designer_v01( show_doors: bool = True, show_corners: bool = True, show_ceilings: bool = False, + show_objects: bool = True, + respect_adjacency_visibility: bool = True, ) -> None: """Populate `scene.objects` like `AreaEntity.GetMeshDescriptors` (Kotor.NET). Expects `area` JSON with ``format: \"0.1\"`` and ``rooms[]`` as saved by `AreaSerializer_V0_1`. + + Delegates to :func:`build_runtime_from_v01` and :func:`iter_render_instances` so preview matches + the Area Designer runtime (``Room.FixWalls``, wall/corner visibility). """ scene.objects.clear() scene.selection.clear() scene.invalidate_render_cache() - rooms = area.get("rooms") - if not isinstance(rooms, list): - scene.invalidate_render_cache() - return + runtime = build_runtime_from_v01(area, kits_by_id) def add_model(resref: str, world: Any) -> None: if not resref: @@ -172,126 +175,16 @@ def add_model(resref: str, world: Any) -> None: ro.set_transform(world) scene.objects[ro] = ro - for room_data in rooms: - if not isinstance(room_data, dict): - continue - pos_l = room_data.get("position") or [0.0, 0.0, 0.0] - if len(pos_l) < 3: - pos_l = [0.0, 0.0, 0.0] - room_pos = Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])) - q_room = _quat_from_net_xyzw(room_data.get("orientation")) - m_room = _mat_rt(q_room, room_pos) - - tiles = room_data.get("tiles") - if not isinstance(tiles, list): - continue - - for tile_data in tiles: - if not isinstance(tile_data, dict): - continue - kit_id = str(tile_data.get("kitID", "")) - template_id = str(tile_data.get("templateID", "")) - kit = kits_by_id.get(kit_id) - if kit is None: - continue - - pos_l = tile_data.get("position") or [0.0, 0.0, 0.0] - if len(pos_l) < 3: - pos_l = [0.0, 0.0, 0.0] - tile_pos = Vector3(float(pos_l[0]), float(pos_l[1]), float(pos_l[2])) - q_tile = _quat_from_net_xyzw(tile_data.get("orientation")) - m_tile_local = _mat_rt(q_tile, tile_pos) - # Column vectors: world = parent * local (matches C# child * parent multiply order). - m_tile = _multiply(m_room, m_tile_local) - - kt = _kit_tile_record(kit, template_id) - - floor_block = tile_data.get("floor") - if isinstance(floor_block, dict): - fk = str(floor_block.get("kitID", kit_id)) - ftid = str(floor_block.get("templateID", "")) - fkit = kits_by_id.get(fk) or kit - ftpl = _template_for_resref(fkit, ftid) - if ftpl is not None and ftpl.resref: - add_model(ftpl.resref, m_tile) - - if show_ceilings and isinstance(tile_data.get("ceiling"), dict): - ck = str(tile_data["ceiling"].get("kitID", "")) - ctid = str(tile_data["ceiling"].get("templateID", "")) - if ck and ctid: - ckit = kits_by_id.get(ck) or kit - ctpl = _template_for_resref(ckit, ctid) - if ctpl is not None and ctpl.resref: - add_model(ctpl.resref, m_tile) - - walls_saved = tile_data.get("walls") - if show_walls and isinstance(walls_saved, list) and kt is not None: - for i, wall_block in enumerate(walls_saved): - if not isinstance(wall_block, dict): - continue - wk = str(wall_block.get("kitID", kit_id)) - wtid = str(wall_block.get("templateID", "")) - wkit = kits_by_id.get(wk) or kit - wtpl = _template_for_resref(wkit, wtid) - if wtpl is None or not wtpl.resref: - continue - if i >= len(kt.wall_hooks): - continue - hook = kt.wall_hooks[i] - q_h = _quat_from_py_wxyz(hook.orientation) - m_hook = _mat_rt(q_h, hook.position) - m_wall = _multiply(m_tile, m_hook) - add_model(wtpl.resref, m_wall) - # DoorFrame.Transform uses the last hook on the doorframe template (Room.cs). - if ( - show_doors - and wtpl.doorframe_id - and wtpl.doorframe_hooks - and (df := _template_for_resref(wkit, wtpl.doorframe_id)) is not None - ): - dh = wtpl.doorframe_hooks[-1] - q_df = _quat_from_py_wxyz(dh.orientation) - m_df_loc = _mat_rt(q_df, dh.position) - m_df = _multiply(m_wall, m_df_loc) - add_model(df.resref, m_df) - - if show_corners and kt is not None: - for ic in kt.inner_corner_hooks: - itpl = _template_for_resref(kit, ic.default_corner_id) - if itpl is None or not itpl.resref: - continue - q_h = _quat_from_py_wxyz(ic.orientation) - m_h = _multiply(m_tile, _mat_rt(q_h, ic.position)) - add_model(itpl.resref, m_h) - for oc in kt.outer_corner_hooks: - otpl = _template_for_resref(kit, oc.default_corner_id) - if otpl is None or not otpl.resref: - continue - q_h = _quat_from_py_wxyz(oc.orientation) - m_h = _multiply(m_tile, _mat_rt(q_h, oc.position)) - add_model(otpl.resref, m_h) - - # Room-scoped props (`Room.Objects`; exporter adds once per tile iteration — same transforms). - objects_l = room_data.get("objects") - if isinstance(objects_l, list): - for od in objects_l: - if not isinstance(od, dict): - continue - ok = str(od.get("kitID", "")) - otid = str(od.get("templateID", "")) - okit = kits_by_id.get(ok) - if okit is None: - continue - otpl = _template_for_resref(okit, otid) - if otpl is None or not otpl.resref: - continue - opos_l = od.get("position") or [0.0, 0.0, 0.0] - if len(opos_l) < 3: - opos_l = [0.0, 0.0, 0.0] - o_pos = Vector3(float(opos_l[0]), float(opos_l[1]), float(opos_l[2])) - q_o = _quat_from_net_xyzw(od.get("orientation")) - m_obj_local = _mat_rt(q_o, o_pos) - m_obj = _multiply(m_room, m_obj_local) - add_model(otpl.resref, m_obj) + for resref, world in iter_render_instances( + runtime, + kits_by_id, + show_walls=show_walls, + show_doors=show_doors, + show_corners=show_corners, + show_ceilings=show_ceilings, + show_objects=show_objects, + respect_adjacency_visibility=respect_adjacency_visibility, + ): + add_model(resref, world) scene.invalidate_render_cache() diff --git a/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py b/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py new file mode 100644 index 0000000000..fed97ed583 --- /dev/null +++ b/Libraries/PyKotor/tests/tools/test_area_designer_runtime.py @@ -0,0 +1,220 @@ +"""Unit tests for ``area_designer_runtime`` (Kotor.NET ``Room`` / ``FixWalls`` / corner visibility).""" + +from __future__ import annotations + +from pykotor.common.tilekit import ( + CornerHookTemplate, + KitTileRecord, + QuaternionWXYZ, + TileKit, + WallHookTemplate, +) +from pykotor.tools.area_designer_runtime import ( + ADFloor, + ADRoom, + ADTile, + ADWall, + build_runtime_from_v01, + fix_walls, + inner_corner_visible, + outer_corner_visible, + runtime_to_v01, +) +from utility.common.geometry import Vector3 + + +def _hook(px: float = 1.0, py: float = 0.0, pz: float = 0.0) -> WallHookTemplate: + return WallHookTemplate( + default_wall_id="wall_a", + position=Vector3(px, py, pz), + orientation=QuaternionWXYZ(), + ) + + +def test_fix_walls_pairs_across_tiles_when_hooks_coincide() -> None: + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[_hook(1.0, 0.0, 0.0)], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + + t1 = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + t1.walls.append(ADWall(parent_tile=t1, hook_index=0, kit_id="k", template_id="wall_a")) + + t2 = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + t2.walls.append(ADWall(parent_tile=t2, hook_index=0, kit_id="k", template_id="wall_a")) + + room.tiles.extend([t1, t2]) + fix_walls(room) + + assert t1.walls[0].linked_tile is t2 + assert t2.walls[0].linked_tile is t1 + + +def test_inner_corner_visible_only_when_adjacent_walls_unlinked() -> None: + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[ + _hook(0.0, 0.0, 0.0), + _hook(1.0, 0.0, 0.0), + ], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + tile = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + tile.walls.append(ADWall(parent_tile=tile, hook_index=0, kit_id="k", template_id="w")) + tile.walls.append(ADWall(parent_tile=tile, hook_index=1, kit_id="k", template_id="w")) + + ic = CornerHookTemplate( + default_corner_id="c", + adjacent=[0, 1], + position=Vector3(0.0, 0.0, 0.0), + orientation=QuaternionWXYZ(), + ) + + assert inner_corner_visible(ic, tile) is True + + dummy = ADTile( + room=room, + kit_id="k", + template_id="other", + local_position=Vector3(9.0, 9.0, 9.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=None, + ) + tile.walls[0].linked_tile = dummy + assert inner_corner_visible(ic, tile) is False + + +def test_outer_corner_requires_circuit_negative() -> None: + """Smoke-test outer-corner predicate shape (full circuit logic lives in ``outer_corner_visible``).""" + kt = KitTileRecord( + tile_id="cell", + name="", + default_floor_id="", + default_ceiling_id="", + wall_hooks=[ + _hook(0.0, 0.0, 0.0), + _hook(1.0, 0.0, 0.0), + ], + ) + room = ADRoom(position=Vector3(0.0, 0.0, 0.0), orientation_net=[0.0, 0.0, 0.0, 1.0]) + floor = ADFloor(kit_id="k", template_id="") + center = ADTile( + room=room, + kit_id="k", + template_id="cell", + local_position=Vector3(0.0, 0.0, 0.0), + orientation_net=[0.0, 0.0, 0.0, 1.0], + floor=floor, + kit_record=kt, + ) + center.walls.append(ADWall(parent_tile=center, hook_index=0, kit_id="k", template_id="w")) + center.walls.append(ADWall(parent_tile=center, hook_index=1, kit_id="k", template_id="w")) + + oc = CornerHookTemplate( + default_corner_id="o", + adjacent=[0, 1], + position=Vector3(0.0, 0.0, 0.0), + orientation=QuaternionWXYZ(), + ) + + assert outer_corner_visible(oc, center) is False + + +def test_runtime_json_round_trip_minimal_room_without_tiles() -> None: + area_js = { + "format": "0.1", + "rooms": [ + { + "position": [1.0, 2.0, -3.5], + "orientation": [0.1, 0.2, 0.3, 0.9330127], + "tiles": [], + }, + ], + } + r = build_runtime_from_v01(area_js, {}) + back = runtime_to_v01(r) + assert back["format"] == "0.1" + assert len(back["rooms"]) == 1 + assert back["rooms"][0]["position"] == [1.0, 2.0, -3.5] + assert len(back["rooms"][0]["tiles"]) == 0 + + +def test_runtime_round_trip_tile_with_kit() -> None: + kt = KitTileRecord( + tile_id="cell01", + name="", + default_floor_id="floor1", + default_ceiling_id="", + wall_hooks=[_hook(1.0, 0.0, 0.0)], + ) + kit = TileKit(name="testkit", kit_id="sandral") + kit.tiles.append(kt) + + area_js = { + "format": "0.1", + "rooms": [ + { + "position": [0.0, 0.0, 0.0], + "orientation": [0.0, 0.0, 0.0, 1.0], + "tiles": [ + { + "kitID": "sandral", + "templateID": "cell01", + "position": [0.5, 0.0, 0.0], + "orientation": [0.0, 0.0, 0.0, 1.0], + "floor": {"kitID": "sandral", "templateID": "floor1"}, + "walls": [{"kitID": "sandral", "templateID": "wall_a"}], + }, + ], + }, + ], + } + r = build_runtime_from_v01(area_js, {"sandral": kit}) + assert len(r.rooms) == 1 + assert len(r.rooms[0].tiles) == 1 + tile = r.rooms[0].tiles[0] + assert tile.local_position.x == 0.5 + assert tile.floor.template_id == "floor1" + assert len(tile.walls) == 1 + + back = runtime_to_v01(r) + t0 = back["rooms"][0]["tiles"][0] + assert t0["kitID"] == "sandral" + assert t0["templateID"] == "cell01" + assert t0["position"] == [0.5, 0.0, 0.0] + assert t0["floor"] == {"kitID": "sandral", "templateID": "floor1"} + assert t0["walls"] == [{"kitID": "sandral", "templateID": "wall_a"}] From 809b9037404f553b4ad18a6d873ad21c914910fb Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 18:39:18 -0500 Subject: [PATCH 23/29] chore(test): remove empty kits_v2 minimal_tiles gitkeep --- Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 From b5c85a57019767b47fbd408ca6cd3b45e6856e10 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 18:39:23 -0500 Subject: [PATCH 24/29] test(fixtures): add floor_plain kit stubs for minimal tilekit tests --- .../tests/fixtures/kits_v2/floor_plain.mdl | 0 .../tests/fixtures/kits_v2/floor_plain.mdx | 0 .../tests/fixtures/kits_v2/floor_plain.wok | Bin 0 -> 436 bytes .../kits_v2/minimal_tiles/floor_plain.mdl | 0 .../kits_v2/minimal_tiles/floor_plain.mdx | 0 .../kits_v2/minimal_tiles/floor_plain.wok | Bin 0 -> 436 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdl create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdx create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdl create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdx create mode 100644 Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdl b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdx b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.mdx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok b/Libraries/PyKotor/tests/fixtures/kits_v2/floor_plain.wok new file mode 100644 index 0000000000000000000000000000000000000000..e7add1d41d1b046d9b302fe5937e2cae3c1bb82b GIT binary patch literal 436 zcmbu3F%p6>5JjWNJ#qp@Phha8)yq&&asZEFVe1VnJqD#+_%CD!fzm(o*dOwDHeQcKpU7Laon1UJFn1`tBVJ^(-&e?a) zC9mor%hxht!oQ+KKeK=5>nJU=+}ketP>Q9`GRv=ZcIt?cck_Nblf^TiN*}e%bp&8Q BMbiKP literal 0 HcmV?d00001 diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdl b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdx b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.mdx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok b/Libraries/PyKotor/tests/fixtures/kits_v2/minimal_tiles/floor_plain.wok new file mode 100644 index 0000000000000000000000000000000000000000..e7add1d41d1b046d9b302fe5937e2cae3c1bb82b GIT binary patch literal 436 zcmbu3F%p6>5JjWNJ#qp@Phha8)yq&&asZEFVe1VnJqD#+_%CD!fzm(o*dOwDHeQcKpU7Laon1UJFn1`tBVJ^(-&e?a) zC9mor%hxht!oQ+KKeK=5>nJU=+}ketP>Q9`GRv=ZcIt?cck_Nblf^TiN*}e%bp&8Q BMbiKP literal 0 HcmV?d00001 From 274a20a581c22ba4fcd49d983a9286b294e16efb Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 18:39:42 -0500 Subject: [PATCH 25/29] chore(toolset): bump HolocronToolset submodule for unified Layout editor --- Tools/HolocronToolset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index f7a656b387..30e049e418 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit f7a656b3872188461ce3faaf362a8af29ad0c250 +Subproject commit 30e049e418f4d2dbdb00878fc878102a2020087b From c4ea450fc0c8cba2f6d922d489b0193f7d2e6805 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 18:45:12 -0500 Subject: [PATCH 26/29] chore(submodules): point HolocronToolset submodule at branch cursor/indoor-builder-tilekit-v2 for Layout merge PR --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 9868755145..887f7e28c1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,6 +15,7 @@ [submodule "Tools/HolocronToolset"] path = Tools/HolocronToolset url = https://github.com/OldRepublicDevs/HolocronToolset.git + branch = cursor/indoor-builder-tilekit-v2 [submodule "Tools/HoloPatcher"] path = Tools/HoloPatcher url = https://github.com/OldRepublicDevs/HoloPatcher.git From 758e4c5cb365759fa8035bc500fa8a2787e59b61 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 18:50:55 -0500 Subject: [PATCH 27/29] docs(wiki): Layout Editor paths in shipped wiki tree; bump HolocronToolset; use OpenKotOR HolocronToolset URL in .gitmodules --- .gitmodules | 2 +- Tools/HolocronToolset | 2 +- wiki/Holocron-Toolset-Getting-Started.md | 2 +- wiki/Holocron-Toolset-Map-Builder.md | 6 +++--- wiki/Home.md | 2 +- wiki/Indoor-Map-Builder-User-Guide.md | 10 +++++----- wiki/Tutorial-Area-Transitions.md | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.gitmodules b/.gitmodules index 887f7e28c1..f845002111 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,7 +14,7 @@ [submodule "Tools/HolocronToolset"] path = Tools/HolocronToolset - url = https://github.com/OldRepublicDevs/HolocronToolset.git + url = https://github.com/OpenKotOR/HolocronToolset.git branch = cursor/indoor-builder-tilekit-v2 [submodule "Tools/HoloPatcher"] path = Tools/HoloPatcher diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index 30e049e418..6711a47e81 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit 30e049e418f4d2dbdb00878fc878102a2020087b +Subproject commit 6711a47e8119f5a656bef168adc7af34d585d0b7 diff --git a/wiki/Holocron-Toolset-Getting-Started.md b/wiki/Holocron-Toolset-Getting-Started.md index a18f11b293..d2a991074b 100644 --- a/wiki/Holocron-Toolset-Getting-Started.md +++ b/wiki/Holocron-Toolset-Getting-Started.md @@ -2,7 +2,7 @@ ## What is Holocron Toolset? -Holocron Toolset is an application designed to edit the game files for the KotOR games. It comes with file editors specialized for nearly every file type used with the game. Packaged in the toolset is also a 3D module editor for editing existing maps plus a "Map Builder" tool (Indoor Map Builder) which allows the user to create brand new layouts for maps using existing rooms. +Holocron Toolset is an application designed to edit the game files for the KotOR games. It comes with file editors specialized for nearly every file type used with the game. Packaged in the toolset is **Module Designer**, which combines a 3D module editor for existing maps with **Layout** mode (the kit-based “Map Builder” / Indoor Map workflow) for authoring new indoor layouts from existing room pieces. ## Configuration diff --git a/wiki/Holocron-Toolset-Map-Builder.md b/wiki/Holocron-Toolset-Map-Builder.md index cd90f504fb..5b34673cb3 100644 --- a/wiki/Holocron-Toolset-Map-Builder.md +++ b/wiki/Holocron-Toolset-Map-Builder.md @@ -2,13 +2,13 @@ The Map Builder creates new playable indoor areas from existing room models, without requiring Blender or any other external 3D modeling tool. Use it when you want to build a new interior area — a Sith tomb, a space station corridor, a cantina back room — by snapping together pre-made room pieces from the game's own model library. -Open it from the Main Window via **Tools** -> **Indoor Map Builder**. +Open it from the main window via **Tools** → **Layout Editor...** (Module Designer in Layout mode), or via **Level Builder** after selecting a module. -**Typical workflow:** Select a kit (a set of room models from a specific game area) -> place rooms on the canvas -> snap connections between them (shown as green lines) -> **File** -> **Build** to output a ready-to-play module to the game's `Modules/` folder. +**Typical workflow:** Select a kit (a set of room models from a specific game area) → place rooms on the canvas → snap connections between them (shown as green lines) → **Indoor** → **Build Module...** to output a ready-to-play `.mod` to the game's `Modules/` folder. ## Kits -Kits are collections of room models extracted from specific game areas. The toolset does not ship with kits; download them when first prompted (or via **File** -> **Download Kits**). See [Kit Structure Documentation](Kit-Structure-Documentation) for the file format details. +Kits are collections of room models extracted from specific game areas. The toolset does not ship with kits; download them when first prompted, or via **Indoor** → **Download Kits...** (or **Download/Update Kits...** on the layout toolbar). See [Kit Structure Documentation](Kit-Structure-Documentation) for the file format details. ## Controls diff --git a/wiki/Home.md b/wiki/Home.md index 6930200600..cd424edaf8 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -11,7 +11,7 @@ The Markdown pages in `wiki/` are packaged into Holocron Toolset help output thr - **PyKotor** describes itself as a typed Python library for reading, modifying, and writing KotOR engine files, exposes `pykotor` and `pykotorcli` console scripts, and depends on `bioware-kaitai-formats` plus `kaitaistruct` as part of its format stack. [[PyKotor `pyproject.toml`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/pyproject.toml)] - **PyKotor** organizes installation discovery and extraction in `pykotor.extract.installation`, resource typing in `pykotor.resource.type`, and parser/writer implementations under `pykotor.resource.formats`. [[`pykotor.extract.installation`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/extract/installation.py), [`pykotor.resource.type`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/resource/type.py), [`pykotor.resource.formats`](https://github.com/OpenKotOR/PyKotor/tree/master/Libraries/PyKotor/src/pykotor/resource/formats)] - **HoloPatcher** is packaged as a cross-platform TSLPatcher-compatible installer with a `holopatcher` console entry point, and its patching flow is implemented through `holopatcher.__main__` plus the shared `pykotor.tslpatcher.reader`, `pykotor.tslpatcher.patcher`, and `pykotor.tslpatcher.writer` modules. [[HoloPatcher `pyproject.toml`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/HoloPatcher/pyproject.toml), [`holopatcher.__main__`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/HoloPatcher/src/holopatcher/__main__.py), [`pykotor.tslpatcher.reader`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/tslpatcher/reader.py), [`pykotor.tslpatcher.patcher`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/tslpatcher/patcher.py), [`pykotor.tslpatcher.writer`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/tslpatcher/writer.py)] -- **Holocron Toolset** is packaged as a PyQt-backed GUI application with script entry points for the full toolset, standalone editors such as `are-editor`, `mdl-editor`, `utc-editor`, and `twoda-editor`, and standalone applications such as `module-designer` and `indoor-builder`. [[Holocron Toolset `pyproject.toml`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/HolocronToolset/pyproject.toml)] +- **Holocron Toolset** is packaged as a PyQt-backed GUI application with script entry points for the full toolset, standalone editors such as `are-editor`, `mdl-editor`, `utc-editor`, and `twoda-editor`, and **`module-designer`** / **`indoor-builder`** entry points that both open **Module Designer** (`indoor-builder` jumps straight into Layout mode). [[Holocron Toolset `pyproject.toml`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/HolocronToolset/pyproject.toml)] - **Holocron Toolset** maps individual resource families to editor classes and file extensions in `toolset.gui.editors.standalone`, which is why the wiki includes editor- and workflow-oriented pages in addition to file-format references. [[`toolset.gui.editors.standalone`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/HolocronToolset/src/toolset/gui/editors/standalone.py)] - **KotorDiff** is packaged as a shim console tool over `pykotor.diff_tool`, and the runtime application layer imports the diff engine, TSLPatcher data generator, and incremental writer from the shared PyKotor codebase. [[KotorDiff `pyproject.toml`](https://github.com/OpenKotOR/PyKotor/blob/master/Tools/KotorDiff/pyproject.toml), [`pykotor.diff_tool.__init__`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/diff_tool/__init__.py), [`pykotor.diff_tool.app`](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/PyKotor/src/pykotor/diff_tool/app.py)] - **bioware-kaitai-formats** is included in this workspace as an installable package of generated Python parsers, while its canonical `.ksy` specifications live in the upstream `OpenKotOR/bioware-kaitai-formats` repository. [[workspace `bioware-kaitai-formats` README](https://github.com/OpenKotOR/PyKotor/blob/master/Libraries/bioware-kaitai-formats/README.md), [upstream `OpenKotOR/bioware-kaitai-formats`](https://github.com/OpenKotOR/bioware-kaitai-formats)] diff --git a/wiki/Indoor-Map-Builder-User-Guide.md b/wiki/Indoor-Map-Builder-User-Guide.md index 272d4a1da8..049d68f86a 100644 --- a/wiki/Indoor-Map-Builder-User-Guide.md +++ b/wiki/Indoor-Map-Builder-User-Guide.md @@ -2,19 +2,19 @@ ## Overview -The Indoor Map Builder is a visual editor for creating indoor modules (areas) for Knights of the Old Republic. It allows you to place room components, connect them with doors, and build complete playable modules without manually editing game files. +The Indoor Map Builder is the **Layout** workflow inside **Module Designer**: a visual editor for creating indoor modules (areas) for Knights of the Old Republic. You place room components, connect them with doors, and build playable modules without manually editing game files. The main window action is labeled **Layout Editor...**; it opens the same tools as the dedicated **Level Builder** button when a module is selected. ## Getting Started ### Opening the Editor 1. Launch Holocron Toolset -2. Navigate to **Tools** -> **Indoor Map Builder** +2. Navigate to **Tools** → **Layout Editor...** (or use **Level Builder** on the toolbar after choosing a module) 3. Select your game installation when prompted ### Creating a New Map -1. Click **file** -> **New** (or press `Ctrl+N`) +1. In Module Designer Layout mode, click **Indoor** → **New Indoor Map** (or press `Ctrl+N` when focused) 2. Configure your module settings: - **Module ID**: The warp code used in-game (e.g., `test01`) - **Name**: Display name for the module @@ -24,7 +24,7 @@ The Indoor Map Builder is a visual editor for creating indoor modules (areas) fo ### Opening an Existing Map -1. Click **file** -> **Open** (or press `Ctrl+O`) +1. Click **Indoor** → **Open Indoor Map...** (or press `Ctrl+O` when focused) 2. Select a `.indoor` file 3. The map will load with all rooms and connections @@ -250,7 +250,7 @@ Hooks are connection points between rooms. You can edit them: Once your map is complete: -1. Click **file** -> **Build Module** (or press `Ctrl+B`) +1. Click **Indoor** → **Build Module...** (or press `Ctrl+B` when focused) 2. Wait for the build process to complete 3. The module will be saved to your installation's modules folder 4. You can warp to it in-game using: `warp ` diff --git a/wiki/Tutorial-Area-Transitions.md b/wiki/Tutorial-Area-Transitions.md index 23f46296ef..cc10aec343 100644 --- a/wiki/Tutorial-Area-Transitions.md +++ b/wiki/Tutorial-Area-Transitions.md @@ -11,11 +11,11 @@ This tutorial shows how to link two modules with area transitions. You will: ## 1. Create a module with the Map Builder -Open **File** -> **Indoor Map Builder**. Download the **Enclave Surface** kit if needed (**File** -> **Download Kits**). Create a simple layout. +Open **Tools** → **Layout Editor...**. Download the **Enclave Surface** kit if needed (**Indoor** → **Download Kits...** or **Download/Update Kits...** on the layout toolbar). Create a simple layout. ![Layout](https://raw.githubusercontent.com/OpenKotOR/HolocronToolset/refs/heads/master/src/toolset/help/tutorials/3a.png) -Open **File** -> **Settings** and set the warp code (e.g. **nthenc**). Build via **File** -> **Build**. Test in-game with **warp nthenc**. +Set the warp code and module options from the Layout editor’s indoor/module fields (e.g. **nthenc**). Use **File** → **Settings** on the main window only for Holocron paths and global options. Build via **Indoor** → **Build Module...**. Test in-game with **warp nthenc**. ![Settings](https://raw.githubusercontent.com/OpenKotOR/HolocronToolset/refs/heads/master/src/toolset/help/tutorials/3b.png) ![Build](https://raw.githubusercontent.com/OpenKotOR/HolocronToolset/refs/heads/master/src/toolset/help/tutorials/3c.png) From 4809b97929cf0e8df998c9ce502c356c441dc963 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 23:01:09 -0500 Subject: [PATCH 28/29] chore: bump version to 2.3.13 in pyproject.toml, uv.lock, and update submodule reference for KotOR.js --- Libraries/PyKotor/pyproject.toml | 4 ++-- pyproject.toml | 26 +++++++++++++------------- uv.lock | 4 ++-- vendor/KotOR.js | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Libraries/PyKotor/pyproject.toml b/Libraries/PyKotor/pyproject.toml index 462de3cf29..21b81b11d3 100644 --- a/Libraries/PyKotor/pyproject.toml +++ b/Libraries/PyKotor/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=67.8.0", "wheel"] [project] name = "pykotor" -version = "2.3.12" +version = "2.3.13" description = "Read, modify and write files used by KotOR's game engine." authors = [{ name = "Nick Hugi" }, { name = "th3w1zard1" }] maintainers = [{ name = "th3w1zard1", email = "boden.crouch@gmail.com" }] @@ -153,7 +153,7 @@ Repository = "https://github.com/OpenKotOR/PyKotor.git" # Poetry configuration [tool.poetry] name = "pykotor" -version = "2.3.12" +version = "2.3.13" description = "Read, modify and write files used by KotOR's game engine." authors = ["Nick Hugi", "th3w1zard1 "] maintainers = ["th3w1zard1 "] diff --git a/pyproject.toml b/pyproject.toml index af89f14169..581f6c2030 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ name = "pykotor-workspace" authors = [{ name = "Nick Hugi" }, { name = "th3w1zard1" }] maintainers = [{ name = "th3w1zard1", email = "boden.crouch@gmail.com" }] dependencies = [ - "pykotor[render]>=2.3.12", + "pykotor[render]>=2.3.13", "holocrontoolset>=4.0.0b31", "holopatcher>=2.0.0a3", "holopazaak>=2.0.0", @@ -83,7 +83,7 @@ keywords = ["editor", "holocron", "holopatcher", "kotor", "library", "pykotor", license = {text = "LGPL-3.0-or-later License"} readme = {content-type = "text/markdown", file = "README.md"} requires-python = ">=3.8" -version = "2.3.12" +version = "2.3.13" # Re-export every optional-dependency set from Libraries/PyKotor/pyproject.toml so # that anything previously installable as `pykotor[]` is also reachable via @@ -91,17 +91,17 @@ version = "2.3.12" # corresponding pykotor extra; the actual dependency specifiers live in the # PyKotor sub-project so we never drift from its pinning logic. [project.optional-dependencies] -all = ["pykotor[encodings,exp,extra,font,gl,updater,render]>=2.3.12"] -encodings = ["pykotor[encodings]>=2.3.12"] -exp = ["pykotor[exp]>=2.3.12"] -extra = ["pykotor[extra]>=2.3.12"] -font = ["pykotor[font]>=2.3.12"] -gl = ["pykotor[gl]>=2.3.12"] -gl_exp = ["pykotor[gl_exp]>=2.3.12"] -updater = ["pykotor[updater]>=2.3.12"] -render = ["pykotor[render]>=2.3.12"] +all = ["pykotor[encodings,exp,extra,font,gl,updater,render]>=2.3.13"] +encodings = ["pykotor[encodings]>=2.3.13"] +exp = ["pykotor[exp]>=2.3.13"] +extra = ["pykotor[extra]>=2.3.13"] +font = ["pykotor[font]>=2.3.13"] +gl = ["pykotor[gl]>=2.3.13"] +gl_exp = ["pykotor[gl_exp]>=2.3.13"] +updater = ["pykotor[updater]>=2.3.13"] +render = ["pykotor[render]>=2.3.13"] # Surface pykotor's own dev extra too, for parity with the sub-project. -dev = ["pykotor[dev]>=2.3.12"] +dev = ["pykotor[dev]>=2.3.13"] [tool.setuptools] packages = [] @@ -267,7 +267,7 @@ keywords = ["editor", "holocron", "holopatcher", "kotor", "library", "pykotor", [tool.poetry.dependencies] python = "^3.8" -pykotor = "^2.3.12" +pykotor = "^2.3.13" holocrontoolset = ">=4.0.0b31" holopatcher = ">=2.0.0a3" holopazaak = ">=2.0.0" diff --git a/uv.lock b/uv.lock index f3545dd7dc..1e1403a357 100644 --- a/uv.lock +++ b/uv.lock @@ -2903,7 +2903,7 @@ wheels = [ [[package]] name = "pykotor" -version = "2.3.12" +version = "2.3.13" source = { editable = "Libraries/PyKotor" } dependencies = [ { name = "bioware-kaitai-formats" }, @@ -3093,7 +3093,7 @@ provides-extras = ["all", "dev", "encodings", "exp", "extra", "font", "gl", "gl- [[package]] name = "pykotor-workspace" -version = "2.3.12" +version = "2.3.13" source = { editable = "." } dependencies = [ { name = "holocrontoolset" }, diff --git a/vendor/KotOR.js b/vendor/KotOR.js index 1a6dd62493..3300cf7d43 160000 --- a/vendor/KotOR.js +++ b/vendor/KotOR.js @@ -1 +1 @@ -Subproject commit 1a6dd62493c4d80ab1f30e39589b4112a8b05c87 +Subproject commit 3300cf7d43f2399d85594ba1bb45b31392911ceb From 6afd1754de5715d3d37de4baeb14dbe06f59d27f Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 1 May 2026 23:01:43 -0500 Subject: [PATCH 29/29] chore(submodules): update HolocronToolset submodule to latest commit --- Tools/HolocronToolset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/HolocronToolset b/Tools/HolocronToolset index 6711a47e81..c85ff54848 160000 --- a/Tools/HolocronToolset +++ b/Tools/HolocronToolset @@ -1 +1 @@ -Subproject commit 6711a47e8119f5a656bef168adc7af34d585d0b7 +Subproject commit c85ff54848eca3ff8e20d1d697b1cbd994340aee