"""차량 glTF 를 시뮬레이터 관례로 정리해 .glb 한 파일로 만든다. 시뮬레이터(src/vkgs/src/viewer.cpp 의 gltf_to_box, gltf.cpp 의 바퀴 검출)가 기대하는 것: - +Y 위, +Z 앞, +X 왼쪽, 바닥(타이어 아래)이 y = 0 - 바퀴 하나 = 이름에 'wheel' 이 든 노드 하나, 그 아래에 타이어·림 등 구르는 부품 - 핸들 같은 다른 'wheel' 이름은 'steering' 을 포함해야 바퀴로 오인되지 않는다 Sketchfab 에서 받은 3ds Max 내보내기는 차가 비스듬히 놓여 있고(예: Suzuki Carry 45°), 바퀴 부품이 낱개로 흩어져 있다. 이 스크립트는 정점·노드 변환은 그대로 두고 맨 위에 회전·이동 노드 하나를 얹고, 바퀴 부품을 묶는 빈(항등 변환) 노드를 끼워 넣는다 — 그래서 모양·재질은 원본과 똑같다. python scripts/normalize_vehicle_gltf.py assets/gltf/suzuki_carry/scene.gltf assets/gltf/SuzukiCarry.glb \\ --tire '(?i)^tire' --parts '(?i)rim|logo|tire' --front '(?i)headlight' """ from __future__ import annotations import argparse import json import re import struct from pathlib import Path import numpy as np def node_matrix(n: dict) -> np.ndarray: if "matrix" in n: return np.array(n["matrix"], dtype=np.float64).reshape(4, 4).T # glTF 는 열 우선 t = np.array(n.get("translation", [0, 0, 0]), dtype=np.float64) x, y, z, w = n.get("rotation", [0, 0, 0, 1]) s = np.array(n.get("scale", [1, 1, 1]), dtype=np.float64) r = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]) m = np.eye(4) m[:3, :3] = r * s m[:3, 3] = t return m def world_matrices(j: dict) -> tuple[dict[int, np.ndarray], dict[int, int]]: world, parent = {}, {} stack = [(r, np.eye(4)) for r in j["scenes"][j.get("scene", 0)]["nodes"]] while stack: i, pm = stack.pop() world[i] = pm @ node_matrix(j["nodes"][i]) for c in j["nodes"][i].get("children", []): parent[c] = i stack.append((c, world[i])) return world, parent def positions(j: dict, gltf_dir: Path, acc: int, _cache: dict = {}) -> np.ndarray: """POSITION 접근자 → (N,3) float64 (float32 VEC3, byteStride 고려).""" if acc in _cache: return _cache[acc] if "bin" not in _cache: _cache["bin"] = (gltf_dir / j["buffers"][0]["uri"]).read_bytes() a = j["accessors"][acc] bv = j["bufferViews"][a["bufferView"]] off = bv.get("byteOffset", 0) + a.get("byteOffset", 0) stride = bv.get("byteStride", 12) raw = np.frombuffer(_cache["bin"], dtype=np.uint8, count=stride * (a["count"] - 1) + 12, offset=off) pts = np.lib.stride_tricks.as_strided(raw.view(np.float32) if stride % 4 == 0 else raw, shape=(a["count"], 3), strides=(stride, 4)).astype(np.float64) _cache[acc] = pts return pts def subtree_bbox(j: dict, world: dict, i: int, gltf_dir: Path) -> tuple[np.ndarray, np.ndarray] | None: """노드 i 아래 메시 정점들의 월드 경계 상자.""" lo, hi = np.full(3, np.inf), np.full(3, -np.inf) stack = [i] while stack: k = stack.pop() n = j["nodes"][k] if "mesh" in n: for p in j["meshes"][n["mesh"]]["primitives"]: v = positions(j, gltf_dir, p["attributes"]["POSITION"]) w = v @ world[k][:3, :3].T + world[k][:3, 3] lo, hi = np.minimum(lo, w.min(0)), np.maximum(hi, w.max(0)) stack += n.get("children", []) return (lo, hi) if np.isfinite(lo).all() else None def pack_glb(j: dict, gltf_dir: Path, out: Path) -> None: """외부 .bin·이미지를 버퍼 하나에 넣어 GLB 로 쓴다.""" assert len(j["buffers"]) == 1, "버퍼가 여러 개인 glTF 는 아직 다루지 않는다" blob = bytearray((gltf_dir / j["buffers"][0]["uri"]).read_bytes()) for img in j.get("images", []): if "uri" not in img: continue data = (gltf_dir / img.pop("uri")).read_bytes() while len(blob) % 4: blob.append(0) j["bufferViews"].append({"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}) blob += data img["bufferView"] = len(j["bufferViews"]) - 1 img["mimeType"] = "image/png" if data[:4] == b"\x89PNG" else "image/jpeg" while len(blob) % 4: blob.append(0) j["buffers"] = [{"byteLength": len(blob)}] js = json.dumps(j, separators=(",", ":")).encode() js += b" " * (-len(js) % 4) total = 12 + 8 + len(js) + 8 + len(blob) with open(out, "wb") as f: f.write(struct.pack(" None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("src", type=Path, help="원본 .gltf (같은 폴더에 .bin, textures/)") ap.add_argument("out", type=Path, help="결과 .glb") ap.add_argument("--tire", default=r"(?i)^tire", help="바퀴마다 하나인 타이어 노드 이름 (정규식)") ap.add_argument("--parts", default=r"(?i)rim|logo|tire|hub", help="타이어와 같이 구르는 부품 이름 (정규식)") ap.add_argument("--front", default=r"(?i)headlight", help="차 앞쪽에 있는 노드 이름 (앞뒤 판정용)") a = ap.parse_args() j = json.loads(a.src.read_text()) world, parent = world_matrices(j) names = {i: (n.get("name") or "") for i, n in enumerate(j["nodes"])} center = {} for i in world: b = subtree_bbox(j, world, i, a.src.parent) if b is not None: center[i] = 0.5 * (b[0] + b[1]) tires = [i for i in world if re.search(a.tire, names[i].strip()) and i in center] if len(tires) < 4: raise SystemExit(f"타이어 노드가 {len(tires)}개뿐 ({[names[i] for i in tires]}) — --tire 를 확인") tc = np.array([center[i] for i in tires]) # 위 = 원본 루트가 이미 Y 위로 돌려 놓았다고 가정하고 확인: 타이어 중심 높이가 거의 같아야 한다 if np.ptp(tc[:, 1]) > 0.2 * np.ptp(tc[:, [0, 2]]): raise SystemExit(f"타이어 중심 높이가 제각각 — 위 방향이 Y 가 아닌 듯: {tc}") # 앞뒤 축: 타이어 중심을 수평면에 놓고, 서로 가장 먼 방향(축거)이 앞뒤 hz = tc[:, [0, 2]] mid = hz.mean(0) cov = np.cov((hz - mid).T) evals, evecs = np.linalg.eigh(cov) fwd = evecs[:, np.argmax(evals)] # (x, z) fronts = [i for i in world if re.search(a.front, names[i].strip()) and i in center] if not fronts: raise SystemExit("앞쪽 노드를 못 찾음 — --front 를 확인") if np.dot(center[fronts[0]][[0, 2]] - mid, fwd) < 0: fwd = -fwd yaw = np.arctan2(fwd[0], fwd[1]) # +Z 에서 +X 쪽으로 잰 각 # Y 축으로 -yaw 회전하면 fwd → +Z c, s = np.cos(-yaw), np.sin(-yaw) R = np.array([[c, 0, s, 0], [0, 1, 0, 0], [-s, 0, c, 0], [0, 0, 0, 1]]) # 바닥 = 타이어 아래, 수평 중심 = 차체 전체 경계 상자 중앙 rworld = {k: R @ m for k, m in world.items()} roots = j["scenes"][j.get("scene", 0)]["nodes"] lo, hi = np.full(3, np.inf), np.full(3, -np.inf) for r in roots: b = subtree_bbox(j, rworld, r, a.src.parent) lo, hi = np.minimum(lo, b[0]), np.maximum(hi, b[1]) tire_lo = min(subtree_bbox(j, rworld, i, a.src.parent)[0][1] for i in tires) T = np.eye(4) T[:3, 3] = [-(lo[0] + hi[0]) / 2, -tire_lo, -(lo[2] + hi[2]) / 2] M = T @ R size = hi - lo print(f"앞 방향 회전 {np.degrees(yaw):.1f}°, 크기 (폭 x 높이 x 길이) = {size[0]:.3f} x {size[1]:.3f} x {size[2]:.3f} (원본 단위)") # 바퀴 묶기: 타이어마다, 같은 부모 아래에서 이름이 부품 패턴이고 타이어 중심에 가까운 노드들 taken: set[int] = set() radius = 0.5 * float(np.median([np.ptp(np.array(subtree_bbox(j, world, i, a.src.parent)), axis=0)[1] for i in tires])) for k, t in enumerate(tires): p = parent.get(t) sibs = j["nodes"][p]["children"] if p is not None else roots group = [n for n in sibs if n not in taken and re.search(a.parts, names.get(n, '').strip()) and n in center and np.linalg.norm(center[n] - center[t]) < radius] taken.update(group) gi = len(j["nodes"]) j["nodes"].append({"name": f"Wheel{k}", "children": group}) for n in group: sibs.remove(n) sibs.append(gi) print(f" Wheel{k}: {[names.get(n, '').strip() for n in group]}") # 'wheel' 이 든 다른 노드(핸들 등)는 바퀴로 오인되지 않게 이름을 바꾼다 for i, n in enumerate(j["nodes"]): nm = n.get("name") or "" if "wheel" in nm.lower() and not re.fullmatch(r"Wheel\d+", nm) and "steering" not in nm.lower(): n["name"] = "Steering_" + nm.strip() print(f" 이름 변경: {nm!r} → {n['name']!r} (바퀴 아님)") # 맨 위에 정렬 노드 j["nodes"].append({"name": "SimAlign", "matrix": M.T.reshape(-1).tolist(), "children": list(roots)}) j["scenes"][j.get("scene", 0)]["nodes"] = [len(j["nodes"]) - 1] a.out.parent.mkdir(parents=True, exist_ok=True) pack_glb(j, a.src.parent, a.out) print(f"→ {a.out} ({a.out.stat().st_size / 2**20:.1f} MB)") if __name__ == "__main__": main()