#!/usr/bin/env python3 """Offene Container-Varianten (Military-Pack, container_001..008). Aus jedem Pack-Container entstehen drei begehbare Varianten: _offen1 eine Tuer (Stirnseite +Z) aufgeschwenkt, die andere zu _offen2 beide Stirnseiten offen, Fluegel aufgeschwenkt _ohne alle Tuer-Teile entfernt (freier Durchgang) Das Pack-Original hat einseitige Waende (Innenseite wird weggecullt) — hier bekommen Boden/Decke/Waende/Tuerblaetter eine umgedrehte Innenseite mit Paletten-Textur (gleiche Pack-Textur, kein zweites Material -> keine Extra- Draw-Calls auf der Quest). Die Kollision ist ein Trimesh aus derselben Geometrie ohne Tuer-Beschlaege (Riegel/Scharniere/Griffe), damit Server, Client und Editor dieselbe Form bauen. Aufruf (aus dem Editor-Repo, Geschwister-Klone wie bei gen_bauteile.gd): python3 tools/gen_container_varianten.py --client ../scopedvr-client \ --server ../scopedvr-match-server Danach: im Match-Server python3 tools/merge_object_library.py und im Editor tools/sync_objects.sh. Idempotent (gleiche Eingabe -> gleiche Dateien). """ from __future__ import annotations import argparse import json import math import os import struct import sys import numpy as np KATEGORIE = "kisten" FARBEN = range(1, 9) VARIANTEN = [ # suffix, offene Enden, Tuer-Teile entfernen?, Namenszusatz ("offen1", ("+z",), False, "eine Tür offen"), ("offen2", ("+z", "-z"), False, "beide Türen offen"), ("ohne", (), True, "ohne Türen"), ] AUF_WINKEL = 100.0 # Aufschwenkwinkel der Fluegel (Grad) RUND = 2 # Kollisions-JSON wie im Pack: 2 Nachkommastellen # Paletten-Texel (Demonstration_Textures1.png: 19 Spalten x 10 Zeilen, je 51,2 px) def _texel(spalte: int, zeile: int) -> tuple: return ((spalte + 0.5) * 51.2 / 1024.0, (zeile + 0.5) * 51.2 / 1024.0) UV_WAND = _texel(17, 5) # Grau UV_DECKE = _texel(17, 3) # helleres Grau UV_BODEN = _texel(16, 6) # Holzbraun def lade_glb(pfad): b = open(pfad, "rb").read() jl = struct.unpack(" Komponenten-ID je Dreieck.""" v = np.round(tris_pos, 3) n = len(v) par = list(range(n)) def f(x): while par[x] != x: par[x] = par[par[x]] x = par[x] return x first = {} for i in range(n): for p in v[i]: k = tuple(p) if k in first: par[f(i)] = f(first[k]) else: first[k] = i return np.array([f(i) for i in range(n)]) def ist_tuerteil(box_min, box_max, zc): """Tuer-Hardware/-Blatt: an einer Stirnseite, aber kein Eck-Beschlag.""" if abs(zc) <= 2.85: return False ecke = (box_max[1] < 0.3 or box_min[1] > 2.4) and max(abs(box_min[0]), abs(box_max[0])) > 0.95 return not ecke def rot_y(pos, nor, pivot, grad): """Drehung um die senkrechte Achse durch pivot=(x,z). (a,b) -> (a cos - b sin, a sin + b cos).""" w = math.radians(grad) c, s = math.cos(w), math.sin(w) p = pos.copy(); n = nor.copy() a = pos[..., 0] - pivot[0]; b = pos[..., 2] - pivot[1] p[..., 0] = pivot[0] + a * c - b * s p[..., 2] = pivot[1] + a * s + b * c n[..., 0] = nor[..., 0] * c - nor[..., 2] * s n[..., 2] = nor[..., 0] * s + nor[..., 2] * c return p, n def baue(j, binb): prim = j["meshes"][0]["primitives"][0] P = accessor(j, binb, prim["attributes"]["POSITION"]).astype(np.float64) N = accessor(j, binb, prim["attributes"]["NORMAL"]).astype(np.float64) U = accessor(j, binb, prim["attributes"]["TEXCOORD_0"]).astype(np.float64) I = accessor(j, binb, prim["indices"]).ravel().reshape(-1, 3) trans = np.array(j["nodes"][0].get("translation", [0, 0, 0]), dtype=np.float64) tp = P[I] + trans # (n,3,3) im Objekt-Fusspunkt-Raum (= Kollisions-JSON) tn = N[I] tu = U[I] comp = komponenten(tp) tuer = np.zeros(len(tp), dtype=bool) ende = np.zeros(len(tp), dtype=np.int8) # +1 / -1 je Tuer-Dreieck hardware = np.zeros(len(tp), dtype=bool) # Riegel/Scharniere/Griffe (nicht Blatt) global _COMP_N, _KOMP _KOMP = comp _COMP_N = np.array([(comp == c).sum() for c in comp]) for c in np.unique(comp): m = comp == c pts = tp[m].reshape(-1, 3) lo, hi = pts.min(0), pts.max(0) zc = (lo[2] + hi[2]) / 2 if ist_tuerteil(lo, hi, zc): tuer |= m ende[m] = 1 if zc > 0 else -1 # Tuerblaetter = grosse ebene Dreiecke senkrecht Z, Rest = Hardware area = np.linalg.norm(np.cross(tp[:, 1] - tp[:, 0], tp[:, 2] - tp[:, 0]), axis=1) / 2 nz = np.abs(np.cross(tp[:, 1] - tp[:, 0], tp[:, 2] - tp[:, 0])[:, 2]) / np.maximum(area * 2, 1e-9) blatt = tuer & (area > 0.5) & (nz > 0.99) # 60-Tri-Schale (Blatt mit Rahmenprofil, beide Fluegel verschweisst) gehoert ebenfalls zum Blatt hardware = tuer & ~blatt return tp, tn, tu, tuer, ende, blatt, hardware, trans _COMP_N = None _KOMP = None def scharniere(tp, tuer, ende, hardware): """Drehpunkt (x,z) je (Ende, Seite) aus den Scharnier-Manschetten (16 Dreiecke, x ~ +-1,05).""" piv = {} cen = tp.mean(1) for e in (1, -1): for seite in (-1, 1): m = tuer & (ende == e) & hardware & (_COMP_N == 16) & (np.sign(cen[:, 0]) == seite) & (np.abs(cen[:, 0]) > 0.95) if not m.any(): raise SystemExit("Scharnier nicht gefunden (Ende %d Seite %d)" % (e, seite)) pts = tp[m].reshape(-1, 3) piv[(e, seite)] = ((pts[:, 0].min() + pts[:, 0].max()) / 2, (pts[:, 2].min() + pts[:, 2].max()) / 2) return piv def variante(tp, tn, tu, tuer, ende, blatt, hardware, offene_enden, ohne_tuer): """-> (pos, nor, uv) sichtbar und kollisions-Dreiecke (n,3,3).""" piv = scharniere(tp, tuer, ende, hardware) sicht_p, sicht_n, sicht_u = [], [], [] kol = [] cen = tp.mean(1) # --- statischer Koerper (kein Tuerteil) --- k = ~tuer sicht_p.append(tp[k]); sicht_n.append(tn[k]); sicht_u.append(tu[k]) # Kollision: nur Rahmen (grosse Komponente) + Wand-/Boden-/Deckenflaechen; die 6-cm- # Rippen und Eckbeschlaege bleiben sichtbar, kollidieren aber nicht (Dreiecks-Budget # der Registry: test_trimesh_alle S2 <= 130000). gruppe = np.array([(_KOMP == c).sum() for c in _KOMP]) kol.append(tp[k & ((gruppe >= 60) | (gruppe == 2))]) area = np.linalg.norm(np.cross(tp[:, 1] - tp[:, 0], tp[:, 2] - tp[:, 0]), axis=1) / 2 nrm = np.cross(tp[:, 1] - tp[:, 0], tp[:, 2] - tp[:, 0]) nrm = nrm / np.maximum(np.linalg.norm(nrm, axis=1), 1e-9)[:, None] def innen(mask, uvfix): """Umgedrehte Kopie (Innenseite) der Dreiecke in mask, mit Paletten-UV.""" p = tp[mask][:, ::-1, :].copy() n = -tn[mask][:, ::-1, :].copy() u = np.tile(np.array(uvfix, dtype=np.float64), (len(p), 3, 1)) return p, n, u gross = ~tuer & (area > 5) # Waende / Decke / Boden (einseitig) for mask, uvfix in ( (gross & (np.abs(nrm[:, 0]) > 0.99), UV_WAND), (gross & (nrm[:, 1] > 0.99), UV_DECKE), (gross & (nrm[:, 1] < -0.99), UV_BODEN), ): p, n, u = innen(mask, uvfix) sicht_p.append(p); sicht_n.append(n); sicht_u.append(u) kol.append(p) # --- Tuer-Teile --- for e in (1, -1): m_e = tuer & (ende == e) if ohne_tuer: continue if (("+z" if e > 0 else "-z") in offene_enden): for seite in (-1, 1): ms = m_e & (np.where(cen[:, 0] < 0, -1, 1) == seite) # Blatt-Innenseite (flach, einseitig) mit anhaengen bl = ms & blatt ip, inn, iu = innen(bl, UV_WAND) p = np.concatenate([tp[ms], ip]); n = np.concatenate([tn[ms], inn]); u = np.concatenate([tu[ms], iu]) # Aufschwenken nach aussen: linkes Blatt am +Z-Ende dreht +w, rechtes -w; am -Z-Ende umgekehrt w = AUF_WINKEL * seite * -1 * e p, n = rot_y(p, n, piv[(e, seite)], w) sicht_p.append(p); sicht_n.append(n); sicht_u.append(u) # Kollision: nur die Blatt-Platten (ohne Beschlaege), mit gedreht kb = np.concatenate([tp[bl], ip]) kb, _ = rot_y(kb, np.zeros_like(kb), piv[(e, seite)], w) kol.append(kb) else: bl = m_e & blatt ip, inn, iu = innen(bl, UV_WAND) sicht_p.append(tp[m_e]); sicht_n.append(tn[m_e]); sicht_u.append(tu[m_e]) sicht_p.append(ip); sicht_n.append(inn); sicht_u.append(iu) kol.append(tp[bl]); kol.append(ip) cat = lambda L: np.concatenate(L).reshape(-1, 3) return cat(sicht_p), cat(sicht_n), np.concatenate(sicht_u).reshape(-1, 2), np.concatenate(kol) def kollisions_json(oid, glb_pfad, tris): v = np.round(tris.reshape(-1, 3), RUND) # entartete Dreiecke (nach Rundung) raus t = v.reshape(-1, 3, 3) ok = ~((t[:, 0] == t[:, 1]).all(1) | (t[:, 1] == t[:, 2]).all(1) | (t[:, 0] == t[:, 2]).all(1)) t = t[ok] v = t.reshape(-1, 3) return { "aabb_max": [round(float(x), RUND) for x in v.max(0)], "aabb_min": [round(float(x), RUND) for x in v.min(0)], "dreiecke": int(len(t)), "oid": oid, "quelle_glb": "res://assets/objekte/military_desert/%s/%s.glb" % (KATEGORIE, oid), "vertices": [[float(x) for x in p] for p in v], }, t def main(): ap = argparse.ArgumentParser() ap.add_argument("--client", required=True) ap.add_argument("--server", required=True) ap.add_argument("--editor", default=os.path.join(os.path.dirname(__file__), "..")) a = ap.parse_args() glb_dir = os.path.join(a.client, "assets/objekte/military_desert", KATEGORIE) kol_dirs = [os.path.join(d, "library/kollisions") for d in (a.server, a.client, a.editor)] for d in kol_dirs: os.makedirs(d, exist_ok=True) fragment = {"objects": {}} for n in FARBEN: quelle = "container_%03d" % n j, binb = lade_glb(os.path.join(glb_dir, quelle + ".glb")) tp, tn, tu, tuer, ende, blatt, hardware, trans = baue(j, binb) if n == 1: print("Tuer-Dreiecke: %d (Blatt %d, Beschlaege %d) von %d" % ( tuer.sum(), blatt.sum(), hardware.sum(), len(tp))) for suffix, enden, ohne, text in VARIANTEN: oid = "%s_%s" % (quelle, suffix) pos, nor, uv, kol = variante(tp, tn, tu, tuer, ende, blatt, hardware, enden, ohne) # GLB im Fusspunkt-Raum (translation=0): Kollision == Sichtgeometrie schreibe_glb(os.path.join(glb_dir, oid + ".glb"), j, pos.astype(np.float32), nor.astype(np.float32), uv.astype(np.float32), [0.0, 0.0, 0.0]) kj, kt = kollisions_json(oid, None, kol) s = json.dumps(kj, separators=(",", ":"), sort_keys=True) for d in kol_dirs: with open(os.path.join(d, oid + ".json"), "w") as f: f.write(s) size = [round(float(x), 2) for x in (kj["aabb_max"][i] - kj["aabb_min"][i] for i in range(3))] fragment["objects"][oid] = { "poly": int(len(pos) // 3), "collision": {"shape": "trimesh", "size": size, "quelle": "kollisions/%s.json" % oid}, "glb": "res://assets/objekte/military_desert/%s/%s.glb" % (KATEGORIE, oid), "kategorie": KATEGORIE, "name_de": "Container %02d (%s)" % (n, text), } print("%s: %d Sicht-, %d Kollisions-Dreiecke" % (oid, len(pos) // 3, len(kt))) frag = os.path.join(a.server, "library/objects.d/container_offen.json") with open(frag, "w") as f: f.write(json.dumps(fragment, indent=2, ensure_ascii=False) + "\n") print("Fertig: %d Objekte." % len(fragment["objects"])) if __name__ == "__main__": sys.exit(main())