split_glb.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. #!/usr/bin/env python3
  2. """按空间平面把一个 GLB 拆成多个模块(默认沿 Y 轴切成 3 份)。
  3. 针对"多个相同模块 + 共享地板"的场景:
  4. - 完全落在某个模块范围内的零件,整体归入该模块(保留零件名/材质/世界变换);
  5. - 跨越分割面的共享零件(如地板),在分割面处做几何裁剪,每个模块各留一段。
  6. 用法示例:
  7. python3 3d/split_glb.py \
  8. --input 3d/压缩机2326-3000.glb \
  9. --axis y --planes 16 32 \
  10. --outdir 3d/models --prefix 压缩机2326-3000
  11. """
  12. import argparse, json, os, struct
  13. import numpy as np
  14. GLTF_JSON = 0x4E4F534A
  15. GLTF_BIN = 0x004E4942
  16. COMP_DTYPE = {5120: np.int8, 5121: np.uint8, 5122: np.int16,
  17. 5123: np.uint16, 5125: np.uint32, 5126: np.float32}
  18. COMP_SIZE = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4}
  19. NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
  20. MODES = {0: "POINTS", 1: "LINES", 2: "LINE_LOOP", 3: "LINE_STRIP",
  21. 4: "TRIANGLES", 5: "TRIANGLE_STRIP", 6: "TRIANGLE_FAN"}
  22. # --------------------------------------------------------------------------
  23. # GLB 读写
  24. # --------------------------------------------------------------------------
  25. def read_glb(path):
  26. with open(path, "rb") as f:
  27. magic, ver, length = struct.unpack("<III", f.read(12))
  28. if magic != 0x46546C67:
  29. raise ValueError("不是有效的 GLB 文件")
  30. chunks = {}
  31. while f.tell() < length:
  32. clen, ctype = struct.unpack("<II", f.read(8))
  33. chunks[ctype] = f.read(clen)
  34. gltf = json.loads(chunks[GLTF_JSON].decode("utf-8"))
  35. return gltf, chunks.get(GLTF_BIN, b"")
  36. def write_glb(path, gltf, bin_data):
  37. j = json.dumps(gltf, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
  38. j += b" " * ((4 - len(j) % 4) % 4)
  39. b = bytes(bin_data)
  40. b += b"\x00" * ((4 - len(b) % 4) % 4)
  41. total = 12 + 8 + len(j) + 8 + len(b)
  42. with open(path, "wb") as f:
  43. f.write(struct.pack("<III", 0x46546C67, 2, total))
  44. f.write(struct.pack("<II", len(j), GLTF_JSON))
  45. f.write(j)
  46. f.write(struct.pack("<II", len(b), GLTF_BIN))
  47. f.write(b)
  48. # --------------------------------------------------------------------------
  49. # 世界变换与包围盒
  50. # --------------------------------------------------------------------------
  51. def node_local_matrix(n):
  52. if "matrix" in n:
  53. return np.array(n["matrix"], dtype=np.float64).reshape(4, 4, order="F")
  54. T = np.eye(4)
  55. if "translation" in n:
  56. T[:3, 3] = n["translation"]
  57. R = np.eye(4)
  58. if "rotation" in n:
  59. x, y, z, w = n["rotation"]
  60. R[:3, :3] = np.array([
  61. [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
  62. [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
  63. [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
  64. S = np.eye(4)
  65. if "scale" in n:
  66. S[0, 0], S[1, 1], S[2, 2] = n["scale"]
  67. return T @ R @ S
  68. def world_matrices(gltf):
  69. nodes = gltf["nodes"]
  70. world = [None] * len(nodes)
  71. def walk(i, parent):
  72. world[i] = parent @ node_local_matrix(nodes[i])
  73. for c in nodes[i].get("children", []):
  74. walk(c, world[i])
  75. for root in gltf["scenes"][gltf.get("scene", 0)]["nodes"]:
  76. walk(root, np.eye(4))
  77. return world
  78. def mesh_node_bbox(gltf, world, node_idx):
  79. """用 POSITION accessor 的 min/max 变换出世界包围盒(不做顶点解码,很快)。"""
  80. n = gltf["nodes"][node_idx]
  81. lo = np.array([np.inf] * 3)
  82. hi = np.array([-np.inf] * 3)
  83. for pr in gltf["meshes"][n["mesh"]]["primitives"]:
  84. ai = pr.get("attributes", {}).get("POSITION")
  85. if ai is None:
  86. continue
  87. a = gltf["accessors"][ai]
  88. if "min" not in a:
  89. continue
  90. mn = np.array(a["min"], float)
  91. mx = np.array(a["max"], float)
  92. corners = np.array([[x, y, z, 1.0] for x in (mn[0], mx[0])
  93. for y in (mn[1], mx[1]) for z in (mn[2], mx[2])])
  94. wc = (world[node_idx] @ corners.T).T[:, :3]
  95. lo = np.minimum(lo, wc.min(axis=0))
  96. hi = np.maximum(hi, wc.max(axis=0))
  97. return lo, hi
  98. # --------------------------------------------------------------------------
  99. # 顶点解码 / 裁剪
  100. # --------------------------------------------------------------------------
  101. def decode_accessor(gltf, bin_data, ai):
  102. a = gltf["accessors"][ai]
  103. n = NCOMP[a["type"]]
  104. dt = COMP_DTYPE[a["componentType"]]
  105. bv = gltf["bufferViews"][a["bufferView"]]
  106. base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
  107. item = COMP_SIZE[a["componentType"]] * n
  108. stride = bv.get("byteStride") or item
  109. count = a["count"]
  110. if stride == item:
  111. arr = np.frombuffer(bin_data, dtype=dt, count=count * n, offset=base).reshape(count, n)
  112. else:
  113. arr = np.empty((count, n), dtype=dt)
  114. for i in range(count):
  115. arr[i] = np.frombuffer(bin_data, dtype=dt, count=n, offset=base + i * stride)
  116. arr = np.array(arr)
  117. if a.get("normalized") and dt != np.float32:
  118. info = np.iinfo(dt)
  119. arr = arr.astype(np.float32) / max(abs(info.min), info.max)
  120. else:
  121. arr = arr.astype(np.float64)
  122. return arr
  123. def lerp_vert(a, b, t):
  124. return {k: a[k] + (b[k] - a[k]) * t for k in a}
  125. def clip_half(verts, c, keep_le):
  126. out = []
  127. m = len(verts)
  128. for i in range(m):
  129. cur = verts[i]
  130. nxt = verts[(i + 1) % m]
  131. cy = cur["POSITION"][1]
  132. ny = nxt["POSITION"][1]
  133. cin = cy <= c if keep_le else cy >= c
  134. nin = ny <= c if keep_le else ny >= c
  135. if cin:
  136. out.append(cur)
  137. if cin != nin:
  138. t = (c - cy) / (ny - cy)
  139. out.append(lerp_vert(cur, nxt, t))
  140. return out
  141. def clip_band(verts, indices, mode, lo, hi):
  142. """把一个图元裁剪到 lo<=y<=hi,返回 [('tri'|'line', [顶点...]), ...]。"""
  143. out = []
  144. if mode == 4:
  145. tri = indices if indices is not None else np.arange(len(verts))
  146. tri = tri.reshape(-1)
  147. for k in range(0, len(tri) - 2, 3):
  148. poly = [verts[tri[k]], verts[tri[k + 1]], verts[tri[k + 2]]]
  149. if hi is not None:
  150. poly = clip_half(poly, hi, True)
  151. if lo is not None:
  152. poly = clip_half(poly, lo, False)
  153. if len(poly) >= 3:
  154. out.append(("tri", poly))
  155. elif mode == 1:
  156. seg = indices if indices is not None else np.arange(len(verts))
  157. seg = seg.reshape(-1)
  158. for k in range(0, len(seg) - 1, 2):
  159. poly = [verts[seg[k]], verts[seg[k + 1]]]
  160. if hi is not None:
  161. poly = clip_half(poly, hi, True)
  162. if lo is not None:
  163. poly = clip_half(poly, lo, False)
  164. if len(poly) >= 2:
  165. out.append(("line", poly[:2]))
  166. else:
  167. raise NotImplementedError(f"暂不支持的图元模式 {MODES.get(mode, mode)}")
  168. return out
  169. # --------------------------------------------------------------------------
  170. # 输出文件构建
  171. # --------------------------------------------------------------------------
  172. class BandBuilder:
  173. def __init__(self, src):
  174. self.src = src
  175. self.gltf = {
  176. "asset": {"version": "2.0", "generator": "split_glb.py"},
  177. "scene": 0,
  178. "scenes": [{"nodes": []}],
  179. "nodes": [],
  180. "meshes": [],
  181. "accessors": [],
  182. "bufferViews": [],
  183. "materials": src.get("materials", []),
  184. "buffers": [{"byteLength": 0}],
  185. }
  186. for ext in ("extensionsUsed", "extensionsRequired"):
  187. if ext in src:
  188. self.gltf[ext] = src[ext]
  189. self.bin = bytearray()
  190. self._acc = {}
  191. self._bv = {}
  192. self._mesh = {}
  193. # ---- 原样拷贝 ----
  194. def _copy_bv(self, bi):
  195. if bi in self._bv:
  196. return self._bv[bi]
  197. bv = self.src["bufferViews"][bi]
  198. off = bv.get("byteOffset", 0)
  199. data = self.src_bin[off:off + bv["byteLength"]]
  200. while len(self.bin) % 4:
  201. self.bin.append(0)
  202. new = {"buffer": 0, "byteOffset": len(self.bin), "byteLength": len(data)}
  203. if "target" in bv:
  204. new["target"] = bv["target"]
  205. if "byteStride" in bv:
  206. new["byteStride"] = bv["byteStride"]
  207. self.bin += data
  208. self._bv[bi] = len(self.gltf["bufferViews"])
  209. self.gltf["bufferViews"].append(new)
  210. return self._bv[bi]
  211. def _copy_acc(self, ai):
  212. if ai in self._acc:
  213. return self._acc[ai]
  214. a = self.src["accessors"][ai]
  215. new = {k: a[k] for k in ("componentType", "count", "type", "normalized", "min", "max")
  216. if k in a}
  217. new["bufferView"] = self._copy_bv(a["bufferView"])
  218. new["byteOffset"] = a.get("byteOffset", 0)
  219. self._acc[ai] = len(self.gltf["accessors"])
  220. self.gltf["accessors"].append(new)
  221. return self._acc[ai]
  222. def _copy_mesh(self, mi):
  223. if mi in self._mesh:
  224. return self._mesh[mi]
  225. m = self.src["meshes"][mi]
  226. prims = []
  227. for pr in m["primitives"]:
  228. np_ = {"attributes": {k: self._copy_acc(v) for k, v in pr["attributes"].items()}}
  229. if "indices" in pr:
  230. np_["indices"] = self._copy_acc(pr["indices"])
  231. if "material" in pr:
  232. np_["material"] = pr["material"]
  233. if "mode" in pr:
  234. np_["mode"] = pr["mode"]
  235. prims.append(np_)
  236. self._mesh[mi] = len(self.gltf["meshes"])
  237. self.gltf["meshes"].append({"primitives": prims})
  238. return self._mesh[mi]
  239. def add_verbatim_node(self, node_idx, world):
  240. n = self.src["nodes"][node_idx]
  241. new = {
  242. "name": n.get("name", ""),
  243. "mesh": self._copy_mesh(n["mesh"]),
  244. "matrix": [float(x) for x in world.flatten(order="F")],
  245. }
  246. self.gltf["nodes"].append(new)
  247. self.gltf["scenes"][0]["nodes"].append(len(self.gltf["nodes"]) - 1)
  248. # ---- 裁剪件 ----
  249. def _add_float_accessor(self, arr, type_str, with_minmax=True):
  250. arr = np.asarray(arr, dtype=np.float32)
  251. while len(self.bin) % 4:
  252. self.bin.append(0)
  253. off = len(self.bin)
  254. self.bin += arr.tobytes()
  255. bv = {"buffer": 0, "byteOffset": off, "byteLength": arr.nbytes}
  256. self.gltf["bufferViews"].append(bv)
  257. acc = {"bufferView": len(self.gltf["bufferViews"]) - 1, "componentType": 5126,
  258. "count": len(arr), "type": type_str}
  259. if with_minmax and len(arr):
  260. acc["min"] = arr.min(axis=0).tolist()
  261. acc["max"] = arr.max(axis=0).tolist()
  262. self.gltf["accessors"].append(acc)
  263. return len(self.gltf["accessors"]) - 1
  264. def add_clipped_node(self, node_idx, world, lo, hi):
  265. src = self.src
  266. node = src["nodes"][node_idx]
  267. mesh = src["meshes"][node["mesh"]]
  268. W = world
  269. W3 = W[:3, :3]
  270. t = W[:3, 3]
  271. N = np.linalg.inv(W3).T
  272. prims = []
  273. for pr in mesh["primitives"]:
  274. mode = pr.get("mode", 4)
  275. P = decode_accessor(src, self.src_bin, pr["attributes"]["POSITION"])
  276. Pw = (W3 @ P.T).T + t
  277. has_n = "NORMAL" in pr["attributes"]
  278. if has_n:
  279. Nw = (N @ decode_accessor(src, self.src_bin, pr["attributes"]["NORMAL"]).T).T
  280. norms = np.linalg.norm(Nw, axis=1, keepdims=True)
  281. Nw = Nw / np.where(norms == 0, 1, norms)
  282. idx = decode_accessor(src, self.src_bin, pr["indices"]).reshape(-1).astype(np.int64) \
  283. if "indices" in pr else None
  284. verts = []
  285. for i in range(len(Pw)):
  286. v = {"POSITION": Pw[i]}
  287. if has_n:
  288. v["NORMAL"] = Nw[i]
  289. verts.append(v)
  290. polys = clip_band(verts, idx, mode, lo, hi)
  291. if not polys:
  292. continue
  293. pos, nor = [], []
  294. for kind, poly in polys:
  295. if kind == "tri":
  296. for j in range(1, len(poly) - 1):
  297. for v in (poly[0], poly[j], poly[j + 1]):
  298. pos.append(v["POSITION"])
  299. if has_n:
  300. nor.append(v["NORMAL"])
  301. else:
  302. for v in poly[:2]:
  303. pos.append(v["POSITION"])
  304. if has_n:
  305. nor.append(v["NORMAL"])
  306. if not pos:
  307. continue
  308. attrs = {"POSITION": self._add_float_accessor(pos, "VEC3")}
  309. if has_n:
  310. attrs["NORMAL"] = self._add_float_accessor(nor, "VEC3", with_minmax=False)
  311. p = {"attributes": attrs, "mode": mode}
  312. if "material" in pr:
  313. p["material"] = pr["material"]
  314. prims.append(p)
  315. if not prims:
  316. return False
  317. self.gltf["meshes"].append({"primitives": prims})
  318. self.gltf["nodes"].append({
  319. "name": node.get("name", ""),
  320. "mesh": len(self.gltf["meshes"]) - 1,
  321. })
  322. self.gltf["scenes"][0]["nodes"].append(len(self.gltf["nodes"]) - 1)
  323. return True
  324. def finish(self, path):
  325. self.gltf["buffers"][0]["byteLength"] = len(self.bin)
  326. write_glb(path, self.gltf, self.bin)
  327. # --------------------------------------------------------------------------
  328. def main():
  329. ap = argparse.ArgumentParser(description="按平面把 GLB 拆成多个模块并裁剪共享件")
  330. ap.add_argument("--input", required=True)
  331. ap.add_argument("--outdir", required=True)
  332. ap.add_argument("--prefix", default=None)
  333. ap.add_argument("--axis", default="y", choices=["x", "y", "z"])
  334. ap.add_argument("--planes", type=float, nargs="+", required=True,
  335. help="分割面坐标,例如 --planes 16 32 会生成 3 个模块")
  336. args = ap.parse_args()
  337. src, src_bin = read_glb(args.input)
  338. world = world_matrices(src)
  339. ax = "xyz".index(args.axis)
  340. planes = sorted(args.planes)
  341. nband = len(planes) + 1
  342. # 每个 mesh 节点的世界包围盒
  343. nodes_info = []
  344. for i, n in enumerate(src["nodes"]):
  345. if "mesh" not in n:
  346. continue
  347. lo, hi = mesh_node_bbox(src, world, i)
  348. if not np.isfinite(lo).all():
  349. continue
  350. nodes_info.append((i, lo, hi, (lo[ax] + hi[ax]) / 2))
  351. # 跨分割面的零件(需要裁剪):对每个分割面分别判定
  352. def crosses(idx, plane):
  353. _, lo, hi, _ = nodes_info[idx]
  354. return lo[ax] < plane < hi[ax]
  355. global_idx = [k for k in range(len(nodes_info))
  356. if all(crosses(k, p) for p in planes)]
  357. global_set = {nodes_info[k][0] for k in global_idx}
  358. print(f"mesh 节点 {len(nodes_info)},其中跨越全部分割面的共享件 {len(global_set)} 个:")
  359. for k in global_idx:
  360. ni, lo, hi, _ = nodes_info[k]
  361. print(f" - {src['nodes'][ni].get('name','')!r} {args.axis}: {lo[ax]:.1f}..{hi[ax]:.1f}")
  362. prefix = args.prefix or os.path.splitext(os.path.basename(args.input))[0]
  363. os.makedirs(args.outdir, exist_ok=True)
  364. for b in range(nband):
  365. lo_p = planes[b - 1] if b > 0 else None
  366. hi_p = planes[b] if b < nband - 1 else None
  367. builder = BandBuilder(src)
  368. builder.src_bin = src_bin
  369. for k, (ni, nlo, nhi, c) in enumerate(nodes_info):
  370. if ni in global_set:
  371. continue
  372. if (lo_p is None or c > lo_p) and (hi_p is None or c <= hi_p):
  373. builder.add_verbatim_node(ni, world[ni])
  374. clipped = 0
  375. for k in global_idx:
  376. ni = nodes_info[k][0]
  377. if builder.add_clipped_node(ni, world[ni], lo_p, hi_p):
  378. clipped += 1
  379. name = f"{prefix}_模块{b + 1}.glb"
  380. path = os.path.join(args.outdir, name)
  381. builder.finish(path)
  382. size = os.path.getsize(path) / 1048576
  383. print(f"[{b+1}/{nband}] {name}: 节点 {len(builder.gltf['nodes'])} "
  384. f"(裁剪件 {clipped}) 几何体 {len(builder.gltf['meshes'])} {size:.1f} MB")
  385. if __name__ == "__main__":
  386. main()