scopedvr-map-editor/tools/gen_container_varianten.py

349 lines
15 KiB
Python

#!/usr/bin/env python3
"""Offene Container-Varianten (Military-Pack, container_001..008).
Aus jedem Pack-Container entstehen drei begehbare Varianten:
<oid>_offen1 eine Tuer (Stirnseite +Z) aufgeschwenkt, die andere zu
<oid>_offen2 beide Stirnseiten offen, Fluegel aufgeschwenkt
<oid>_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("<I", b[12:16])[0]
j = json.loads(b[20:20 + jl])
off = 20 + jl
bl = struct.unpack("<I", b[off:off + 4])[0]
return j, b[off + 8:off + 8 + bl]
def accessor(j, binb, i):
a = j["accessors"][i]
bv = j["bufferViews"][a["bufferView"]]
n = {"SCALAR": 1, "VEC2": 2, "VEC3": 3}[a["type"]]
dt = {5126: "<f4", 5123: "<u2", 5125: "<u4"}[a["componentType"]]
o = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return np.frombuffer(binb, dtype=dt, count=a["count"] * n, offset=o).reshape(-1, n)
def schreibe_glb(pfad, j_alt, pos, nor, uv, trans):
"""Indiziertes GLB (ein Mesh, ein Material) — Material/Textur wie im Original."""
key = np.concatenate([pos, nor, uv], axis=1).astype(np.float32)
_, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
inv = inv.ravel()
# stabile Reihenfolge: nach erstem Auftreten
order = np.argsort(first)
rank = np.empty_like(order)
rank[order] = np.arange(len(order))
first = first[order]
idx = rank[inv].astype(np.uint32)
P = pos[first].astype("<f4"); N = nor[first].astype("<f4"); U = uv[first].astype("<f4")
parts = [P.tobytes(), N.tobytes(), U.tobytes(), idx.astype("<u4").tobytes()]
views, offs, o = [], [], 0
for k, p in enumerate(parts):
views.append({"buffer": 0, "byteOffset": o, "byteLength": len(p),
"target": 34963 if k == 3 else 34962})
o += len(p)
binb = b"".join(parts)
n = len(P)
accs = [
{"bufferView": 0, "componentType": 5126, "count": n, "type": "VEC3",
"min": P.min(0).tolist(), "max": P.max(0).tolist()},
{"bufferView": 1, "componentType": 5126, "count": n, "type": "VEC3"},
{"bufferView": 2, "componentType": 5126, "count": n, "type": "VEC2"},
{"bufferView": 3, "componentType": 5125, "count": len(idx), "type": "SCALAR"},
]
name = os.path.splitext(os.path.basename(pfad))[0]
j = {
"asset": j_alt["asset"],
"scene": 0,
"scenes": [{"name": "Scene", "nodes": [0]}],
"nodes": [{"mesh": 0, "name": name, "translation": trans}],
"meshes": [{"name": name + "_mesh", "primitives": [
{"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2}, "indices": 3, "material": 0}]}],
"materials": j_alt["materials"],
"textures": j_alt["textures"],
"images": j_alt["images"],
"samplers": j_alt.get("samplers", [{}]),
"accessors": accs,
"bufferViews": views,
"buffers": [{"byteLength": len(binb)}],
}
js = json.dumps(j, separators=(",", ":")).encode()
js += b" " * (-len(js) % 4)
binb += b"\0" * (-len(binb) % 4)
total = 12 + 8 + len(js) + 8 + len(binb)
with open(pfad, "wb") as f:
f.write(struct.pack("<III", 0x46546C67, 2, total))
f.write(struct.pack("<II", len(js), 0x4E4F534A) + js)
f.write(struct.pack("<II", len(binb), 0x004E4942) + binb)
# ------------------------------------------------------------- Geometrie ----
def komponenten(tris_pos):
"""Union-Find ueber gemeinsame (gerundete) Eckpunkte -> 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())