@bitmagic/cli 0.1.61 → 0.1.62-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -3
  2. package/assets/unity-import/README.md +13 -0
  3. package/assets/unity-import/blender_build.py +462 -0
  4. package/assets/unity-import/build_parts.py +263 -0
  5. package/assets/unity-import/convert_assets.py +193 -0
  6. package/assets/unity-import/fbx_ascii.py +156 -0
  7. package/assets/unity-import/fbx_axis.py +174 -0
  8. package/assets/unity-import/fbx_bounds.py +202 -0
  9. package/assets/unity-import/import_config.py +86 -0
  10. package/assets/unity-import/level_patches.example.json +23 -0
  11. package/assets/unity-import/mesh_walls.py +78 -0
  12. package/assets/unity-import/render_topdown.py +33 -0
  13. package/assets/unity-import/unity-import.example.json +27 -0
  14. package/assets/unity-import/unity_assets.py +114 -0
  15. package/assets/unity-import/unity_colliders2d.py +127 -0
  16. package/assets/unity-import/unity_extract.py +774 -0
  17. package/assets/unity-import/unity_markers.py +249 -0
  18. package/assets/unity-import/unity_renderers.py +279 -0
  19. package/assets/unity-import/write_arena_data.py +198 -0
  20. package/dist/cli.d.ts +12 -0
  21. package/dist/commands/import.d.ts +4 -0
  22. package/dist/commands/import.js +5 -0
  23. package/dist/commands/import.js.map +1 -1
  24. package/dist/commands/init.d.ts +4 -0
  25. package/dist/commands/init.js +20 -0
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/upgrade.d.ts +4 -0
  28. package/dist/commands/upgrade.js +31 -5
  29. package/dist/commands/upgrade.js.map +1 -1
  30. package/dist/editor/server.js +3 -3
  31. package/dist/editor/shell-page.js +16 -1
  32. package/dist/editor/shell-page.js.map +1 -1
  33. package/dist/project/context.d.ts +9 -0
  34. package/dist/project/context.js +4 -1
  35. package/dist/project/context.js.map +1 -1
  36. package/dist/scaffold/agent-targets.d.ts +126 -0
  37. package/dist/scaffold/agent-targets.js +180 -0
  38. package/dist/scaffold/agent-targets.js.map +1 -0
  39. package/dist/scaffold/agents-md.d.ts +4 -1
  40. package/dist/scaffold/agents-md.js +6 -2
  41. package/dist/scaffold/agents-md.js.map +1 -1
  42. package/dist/scaffold/project-files.d.ts +40 -5
  43. package/dist/scaffold/project-files.js +67 -25
  44. package/dist/scaffold/project-files.js.map +1 -1
  45. package/dist/scaffold/project.d.ts +42 -15
  46. package/dist/scaffold/project.js +100 -38
  47. package/dist/scaffold/project.js.map +1 -1
  48. package/dist/scaffold/reference-guidance.d.ts +1 -1
  49. package/dist/scaffold/reference-guidance.js +2 -1
  50. package/dist/scaffold/reference-guidance.js.map +1 -1
  51. package/dist/scaffold/skill-files.d.ts +32 -9
  52. package/dist/scaffold/skill-files.js +71 -15
  53. package/dist/scaffold/skill-files.js.map +1 -1
  54. package/dist/scaffold/unity-skill-files.d.ts +30 -0
  55. package/dist/scaffold/unity-skill-files.js +474 -0
  56. package/dist/scaffold/unity-skill-files.js.map +1 -0
  57. package/dist/scaffold/upgrade-project.d.ts +31 -11
  58. package/dist/scaffold/upgrade-project.js +35 -23
  59. package/dist/scaffold/upgrade-project.js.map +1 -1
  60. package/package.json +4 -4
@@ -0,0 +1,774 @@
1
+ import re, os, json, math, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
3
+ from fbx_bounds import read_fbx
4
+
5
+ _meshcache = {}
6
+ _metaidx = {}
7
+
8
+
9
+ def mesh_index_for(fbx_path, file_id):
10
+ """Which mesh inside a multi-mesh FBX a MeshFilter's fileID refers to.
11
+
12
+ A model file can hold many meshes — one statue pack here held eight statues spread over 141
13
+ units — and a MeshFilter picks exactly one. Unioning them all gives an object the size of the
14
+ whole collection, centred on the collection's centroid: the statue came out 141 units long and
15
+ 10.8 units off the origin it is placed at.
16
+
17
+ Unity's .meta lists each mesh by fileID in the file's own order, so the position of this fileID
18
+ among the 43000xx (Mesh class) entries is the index of the mesh to use.
19
+ """
20
+ if fbx_path not in _metaidx:
21
+ order = []
22
+ try:
23
+ with open(fbx_path + '.meta', encoding='utf-8', errors='ignore') as fh:
24
+ text = fh.read()
25
+ except OSError:
26
+ text = ''
27
+ for m in re.finditer(r'^\s{4}(43000\d\d): (.+)$', text, re.M):
28
+ name = m.group(2).strip()
29
+ if not name.startswith('//'):
30
+ order.append(m.group(1))
31
+ _metaidx[fbx_path] = {fid: i for i, fid in enumerate(sorted(order, key=int))}
32
+ return _metaidx[fbx_path].get(str(file_id))
33
+
34
+
35
+ _axis_map = None
36
+ _axis_ready = False
37
+
38
+ def _axis():
39
+ """Unity's mesh axes are not always the raw FBX axes; derive the per-pack correction once."""
40
+ global _axis_map, _axis_ready
41
+ if not _axis_ready:
42
+ _axis_ready = True # set first: build_axis_map calls back into mesh_bounds_raw
43
+ try:
44
+ from fbx_axis import build_axis_map
45
+ _axis_map = build_axis_map(sys.modules[__name__], os.path.join(ROOT, 'Assets'))
46
+ except Exception as exc:
47
+ print(f'warning: could not derive FBX axis map ({exc}); assuming identity', file=sys.stderr)
48
+ _axis_map = {}
49
+ return _axis_map
50
+
51
+ def mesh_bounds(guid, guid2path, file_id=None):
52
+ """Mesh bounds in UNITY's mesh space, which may be a permutation of the raw FBX axes."""
53
+ res = mesh_bounds_raw(guid, guid2path, file_id)
54
+ if not res: return res
55
+ p = guid2path.get(guid)
56
+ if not p: return res
57
+ # fbx_bounds.read_fbx already delivers Unity's mesh: geometric transform applied and X negated.
58
+ # Applying any further axis change here would double it.
59
+ return res
60
+
61
+ # The depth band the player occupies, in Unity's Z. PLANE_HALF is the player's collider radius.
62
+ #
63
+ # PLANE_CENTER is NOT zero, and that was the bug behind "I collide with air where I can see an
64
+ # opening". The arena's walls are building facades with archways cut through them, and those
65
+ # archways sit at z -5..0 with the lintel from 0 up — a ship sliced at z=0 meets the lintel and
66
+ # every base seals shut (a depth sweep on the corrected geometry: base reachability 0.8% at z 0
67
+ # and -0.5). Between -2.5 and -1.0 the wall cross-section was flat in the origin game (~68 units² either
68
+ # way); -1 sits in the walls' densest band (41% of their area lies in -1..0) and every base reaches
69
+ # 100% of the level there, so that was the plane. Measure yours the same way: slice at a few
70
+ # depths and pick the one where walls are flat and every spawn reaches everything.
71
+ #
72
+ # `planeZ` in unity-import.json; re-measure if the level changes.
73
+ #
74
+ # NOTE: for a 2D-physics game walls do not come from this slice at all — the player collides with Unity's 2D colliders
75
+ # (see unity_colliders2d.py). This band now only classifies backdrop/foreground and the land-check
76
+ # floors.
77
+ from import_config import CFG as _CFG, scene_path as scene_path_from_config
78
+ PLANE_HALF = float(_CFG['planeHalf'])
79
+ PLANE_CENTER = float(_CFG['planeZ'])
80
+
81
+ _vertcache = {}
82
+
83
+ def mesh_geometry(guid, guid2path, file_id=None):
84
+ """(vertices in Unity mesh space, triangle index triples) for a model, or None."""
85
+ key = ('geo', guid, file_id)
86
+ if key in _vertcache: return _vertcache[key]
87
+ res = None
88
+ p = guid2path.get(guid)
89
+ if p and os.path.exists(p) and p.lower().endswith('.fbx'):
90
+ try:
91
+ r = read_fbx(p, keep_vertices=True)
92
+ if r:
93
+ ms, us = r
94
+ gscale, usefile = 1.0, True
95
+ meta = p + '.meta'
96
+ if os.path.exists(meta):
97
+ t = open(meta, encoding='utf-8', errors='ignore').read()
98
+ m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
99
+ if m1: gscale = float(m1.group(1))
100
+ m2 = re.search(r'useFileScale: (\d)', t)
101
+ if m2: usefile = m2.group(1) == '1'
102
+ sc = gscale * ((us / 100.0) if (usefile and us) else 1.0)
103
+ tf = None # reader output is already Unity's mesh; see mesh_bounds
104
+ want = mesh_index_for(p, file_id) if file_id is not None else None
105
+ verts, tris, base = [], [], 0
106
+ for mi, entry in enumerate(ms):
107
+ if want is not None and mi != want:
108
+ continue
109
+ ev = entry[3] if len(entry) > 3 else None
110
+ ei = entry[4] if len(entry) > 4 else None
111
+ if not ev: continue
112
+ verts.extend(tuple(v * sc for v in pt) for pt in ev)
113
+ if ei:
114
+ face = []
115
+ for raw in ei:
116
+ i = int(raw)
117
+ if i < 0:
118
+ face.append(-i - 1)
119
+ for k in range(1, len(face) - 1): # fan-triangulate
120
+ tris.append((base + face[0], base + face[k], base + face[k + 1]))
121
+ face = []
122
+ else:
123
+ face.append(i)
124
+ base += len(ev)
125
+ if verts: res = (verts, tris)
126
+ except Exception:
127
+ res = None
128
+ _vertcache[key] = res
129
+ return res
130
+
131
+
132
+ def mesh_vertices(guid, guid2path):
133
+ """Mesh vertices in UNITY's mesh space, or None."""
134
+ if guid in _vertcache: return _vertcache[guid]
135
+ res = None
136
+ p = guid2path.get(guid)
137
+ if p and os.path.exists(p) and p.lower().endswith('.fbx'):
138
+ try:
139
+ r = read_fbx(p, keep_vertices=True)
140
+ if r:
141
+ ms, us = r
142
+ pts = []
143
+ for entry in ms:
144
+ if len(entry) > 3 and entry[3]:
145
+ pts.extend(entry[3])
146
+ if pts:
147
+ gscale, usefile = 1.0, True
148
+ meta = p + '.meta'
149
+ if os.path.exists(meta):
150
+ t = open(meta, encoding='utf-8', errors='ignore').read()
151
+ m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
152
+ if m1: gscale = float(m1.group(1))
153
+ m2 = re.search(r'useFileScale: (\d)', t)
154
+ if m2: usefile = m2.group(1) == '1'
155
+ sc = gscale * ((us / 100.0) if (usefile and us) else 1.0)
156
+ res = [tuple(v * sc for v in pt) for pt in pts]
157
+ except Exception:
158
+ res = None
159
+ _vertcache[guid] = res
160
+ return res
161
+
162
+
163
+ # How finely a mesh's cross-section is traced. Small enough to keep a doorway open, coarse enough
164
+ # that a whole level's worth of geometry stays cheap.
165
+ SLICE_CELL = 0.5
166
+
167
+
168
+ def _clip_to_slab(tri, lo, hi):
169
+ """Clip a triangle to the slab lo <= z <= hi. Returns a convex polygon, possibly empty."""
170
+ poly = list(tri)
171
+ for sign, bound in ((1.0, hi), (-1.0, lo)):
172
+ out = []
173
+ n = len(poly)
174
+ for i in range(n):
175
+ a = poly[i]
176
+ b = poly[(i + 1) % n]
177
+ da = sign * (a[2] - bound)
178
+ db = sign * (b[2] - bound)
179
+ if da <= 0:
180
+ out.append(a)
181
+ if (da <= 0) != (db <= 0):
182
+ t = da / (da - db)
183
+ out.append((a[0] + (b[0] - a[0]) * t,
184
+ a[1] + (b[1] - a[1]) * t,
185
+ a[2] + (b[2] - a[2]) * t))
186
+ poly = out
187
+ if not poly:
188
+ return []
189
+ return poly
190
+
191
+
192
+ def _point_in_poly(px, py, poly):
193
+ inside = False
194
+ n = len(poly)
195
+ for i in range(n):
196
+ x0, y0 = poly[i][0], poly[i][1]
197
+ x1, y1 = poly[(i + 1) % n][0], poly[(i + 1) % n][1]
198
+ if (y0 > py) != (y1 > py):
199
+ xx = x0 + (py - y0) * (x1 - x0) / (y1 - y0)
200
+ if xx > px:
201
+ inside = not inside
202
+ return inside
203
+
204
+
205
+ def mesh_plane_rects(guid, world, file_id=None):
206
+ """The XY rectangles a MeshCollider actually occupies AT the gameplay plane, in world space.
207
+
208
+ Two things matter here, and both were learned from walls that behaved wrongly in play:
209
+
210
+ * A MeshCollider collides with triangles, not with its bounding box, and these walls are
211
+ building facades with doorways cut through them. Reducing one to a rectangle paves the
212
+ doorway over and seals a ship inside its base.
213
+ * A triangle that *crosses* the plane must be clipped TO the plane before its footprint is
214
+ taken. A facade panel runs from z=-6 to z=+2; only a thin band of it is at the height a ship
215
+ flies at, but its full XY extent spans the doorway. Rasterising the whole triangle therefore
216
+ fills the opening back in even though nothing is there at the player's level — which is what
217
+ put walls across the gaps either side of every base.
218
+
219
+ So each triangle is clipped to the slab the player occupies, and only what survives is rasterised.
220
+ Returns None when nothing reaches the plane.
221
+ """
222
+ geo = mesh_geometry(guid, guid2path, file_id)
223
+ if not geo:
224
+ return None
225
+ verts, tris = geo
226
+ if not tris:
227
+ return None
228
+ (p, q, sc) = world
229
+
230
+ def to_world(v):
231
+ rv = qrot(q, (v[0] * sc[0], v[1] * sc[1], v[2] * sc[2]))
232
+ return (p[0] + rv[0], p[1] + rv[1], p[2] + rv[2])
233
+
234
+ cells = set()
235
+ nv = len(verts)
236
+ for (ia, ib, ic) in tris:
237
+ if ia >= nv or ib >= nv or ic >= nv:
238
+ continue
239
+ a, b, c = to_world(verts[ia]), to_world(verts[ib]), to_world(verts[ic])
240
+ if (min(a[2], b[2], c[2]) > PLANE_CENTER + PLANE_HALF
241
+ or max(a[2], b[2], c[2]) < PLANE_CENTER - PLANE_HALF):
242
+ continue
243
+ poly = _clip_to_slab((a, b, c), PLANE_CENTER - PLANE_HALF, PLANE_CENTER + PLANE_HALF)
244
+ if len(poly) < 2:
245
+ continue
246
+
247
+ xs = [v[0] for v in poly]
248
+ ys = [v[1] for v in poly]
249
+ gx0 = int(math.floor(min(xs) / SLICE_CELL)); gx1 = int(math.floor(max(xs) / SLICE_CELL))
250
+ gy0 = int(math.floor(min(ys) / SLICE_CELL)); gy1 = int(math.floor(max(ys) / SLICE_CELL))
251
+ if (gx1 - gx0) > 400 or (gy1 - gy0) > 400:
252
+ continue
253
+
254
+ # Edges first: a clipped triangle is often a sliver whose cell centres all miss.
255
+ n = len(poly)
256
+ for i in range(n):
257
+ x0, y0 = poly[i][0], poly[i][1]
258
+ x1, y1 = poly[(i + 1) % n][0], poly[(i + 1) % n][1]
259
+ steps = int(max(abs(x1 - x0), abs(y1 - y0)) / (SLICE_CELL * 0.5)) + 1
260
+ for k in range(steps + 1):
261
+ t = k / steps
262
+ cells.add((int(math.floor((x0 + (x1 - x0) * t) / SLICE_CELL)),
263
+ int(math.floor((y0 + (y1 - y0) * t) / SLICE_CELL))))
264
+ # Then the interior.
265
+ if n >= 3 and (gx1 - gx0) + (gy1 - gy0) > 1:
266
+ for gx in range(gx0, gx1 + 1):
267
+ for gy in range(gy0, gy1 + 1):
268
+ if (gx, gy) in cells:
269
+ continue
270
+ if _point_in_poly((gx + 0.5) * SLICE_CELL, (gy + 0.5) * SLICE_CELL, poly):
271
+ cells.add((gx, gy))
272
+
273
+ if not cells:
274
+ return None
275
+
276
+ rows = {}
277
+ for (gx, gy) in cells:
278
+ rows.setdefault(gy, []).append(gx)
279
+ out = []
280
+ for gy, xs in rows.items():
281
+ xs.sort()
282
+ start = prev = xs[0]
283
+ for x in xs[1:] + [None]:
284
+ if x is not None and x == prev + 1:
285
+ prev = x
286
+ continue
287
+ out.append((start * SLICE_CELL, gy * SLICE_CELL,
288
+ (prev + 1) * SLICE_CELL, (gy + 1) * SLICE_CELL))
289
+ if x is None:
290
+ break
291
+ start = prev = x
292
+ return out
293
+
294
+
295
+ def mesh_bounds_raw(guid, guid2path, file_id=None):
296
+ key = (guid, file_id)
297
+ if key in _meshcache: return _meshcache[key]
298
+ p = guid2path.get(guid)
299
+ res = None
300
+ if p and os.path.exists(p) and p.lower().endswith(('.fbx',)):
301
+ try:
302
+ r = read_fbx(p)
303
+ if r:
304
+ ms, us = r
305
+ # One model file can hold many meshes; a MeshFilter picks exactly one by fileID.
306
+ want = mesh_index_for(p, file_id) if file_id is not None else None
307
+ if want is not None and want < len(ms):
308
+ ms = [ms[want]]
309
+ if ms:
310
+ mn = [min(m[1][k] for m in ms) for k in range(3)]
311
+ mx = [max(m[2][k] for m in ms) for k in range(3)]
312
+ scale = 1.0
313
+ meta = p + '.meta'
314
+ gscale = 1.0; usefile = True
315
+ if os.path.exists(meta):
316
+ t = open(meta, encoding='utf-8', errors='ignore').read()
317
+ m1 = re.search(r'globalScale: ([-\d.eE+]+)', t)
318
+ if m1: gscale = float(m1.group(1))
319
+ m2 = re.search(r'useFileScale: (\d)', t)
320
+ if m2: usefile = m2.group(1) == '1'
321
+ fs = (us / 100.0) if (usefile and us) else 1.0
322
+ scale = gscale * fs
323
+ res = ([v*scale for v in mn], [v*scale for v in mx])
324
+ except Exception as e:
325
+ res = None
326
+ _meshcache[key] = res
327
+ return res
328
+
329
+
330
+ def _find_unity_root(start):
331
+ """Walk up from the scene file to the folder that holds `Assets/`."""
332
+ d = os.path.dirname(os.path.abspath(start))
333
+ while d != os.path.dirname(d):
334
+ if os.path.isdir(os.path.join(d, 'Assets')):
335
+ return d
336
+ d = os.path.dirname(d)
337
+ raise SystemExit('could not find the Unity project root above ' + start)
338
+
339
+
340
+ _SCENE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.expanduser(_CFG['unityRoot']), _CFG['scene'])
341
+ ROOT = _find_unity_root(_SCENE)
342
+
343
+ # ---------- guid map ----------
344
+ guid2path = {}
345
+ for root, dirs, files in os.walk(os.path.join(ROOT, 'Assets')):
346
+ for f in files:
347
+ if f.endswith('.meta'):
348
+ p = os.path.join(root, f)
349
+ try:
350
+ t = open(p, encoding='utf-8', errors='ignore').read(500)
351
+ except Exception:
352
+ continue
353
+ m = re.search(r'guid: ([0-9a-f]{32})', t)
354
+ if m:
355
+ guid2path[m.group(1)] = p[:-5]
356
+
357
+ DOC_RE = re.compile(r'\n--- !u!(\d+) &(\d+)(?: stripped)?\n')
358
+
359
+ _cache = {}
360
+ def parse_file(path):
361
+ if path in _cache: return _cache[path]
362
+ try:
363
+ txt = open(path, encoding='utf-8', errors='ignore').read()
364
+ except Exception:
365
+ _cache[path] = {}
366
+ return {}
367
+ parts = DOC_RE.split(txt)
368
+ docs = {}
369
+ i = 1
370
+ while i + 2 <= len(parts) - 1:
371
+ docs[parts[i+1]] = (parts[i], parts[i+2])
372
+ i += 3
373
+ _cache[path] = docs
374
+ return docs
375
+
376
+ # ---------- math ----------
377
+ def qmul(a, b):
378
+ ax,ay,az,aw=a; bx,by,bz,bw=b
379
+ return (aw*bx+ax*bw+ay*bz-az*by,
380
+ aw*by-ax*bz+ay*bw+az*bx,
381
+ aw*bz+ax*by-ay*bx+az*bw,
382
+ aw*bw-ax*bx-ay*by-az*bz)
383
+ def qrot(q, v):
384
+ x,y,z,w=q; vx,vy,vz=v
385
+ # t = 2*q_vec x v
386
+ tx = 2*(y*vz - z*vy); ty = 2*(z*vx - x*vz); tz = 2*(x*vy - y*vx)
387
+ return (vx + w*tx + (y*tz - z*ty),
388
+ vy + w*ty + (z*tx - x*tz),
389
+ vz + w*tz + (x*ty - y*tx))
390
+
391
+ def compose(parent, local):
392
+ # parent/local = (pos, rot, scale)
393
+ pp, pr, ps = parent
394
+ lp, lr, ls = local
395
+ sp = (lp[0]*ps[0], lp[1]*ps[1], lp[2]*ps[2])
396
+ rp = qrot(pr, sp)
397
+ pos = (pp[0]+rp[0], pp[1]+rp[1], pp[2]+rp[2])
398
+ rot = qmul(pr, lr)
399
+ scale = (ps[0]*ls[0], ps[1]*ls[1], ps[2]*ls[2])
400
+ return (pos, rot, scale)
401
+
402
+ def quat_euler_z(q):
403
+ x,y,z,w = q
404
+ return math.degrees(math.atan2(2*(w*z+x*y), 1-2*(y*y+z*z)))
405
+
406
+ def vec3(body, key, default):
407
+ m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+)\}', body)
408
+ return (float(m.group(1)), float(m.group(2)), float(m.group(3))) if m else default
409
+
410
+ def quat(body, key):
411
+ m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+), w: ([-\d.eE+]+)\}', body)
412
+ return (float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))) if m else (0.0,0.0,0.0,1.0)
413
+
414
+ def fid(body, key):
415
+ m = re.search(re.escape(key) + r': \{fileID: (-?\d+)', body)
416
+ return m.group(1) if m else '0'
417
+
418
+ MAX_DEPTH = 8
419
+
420
+ # ---------- level patches (see tools/level_patches.json) ----------
421
+ def _load_patches():
422
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'level_patches.json')
423
+ try:
424
+ return json.load(open(path))
425
+ except Exception:
426
+ return {"add_instances": [], "remove_instances": []}
427
+ LEVEL_PATCHES = _load_patches()
428
+
429
+ def patch_skips_instance(prefab_name, world_pos):
430
+ """True when a level patch removes this scene instance (matched by prefab and position)."""
431
+ for r in LEVEL_PATCHES.get('remove_instances', []):
432
+ if r.get('prefab') == prefab_name and 'near' in r:
433
+ if abs(world_pos[0] - r['near'][0]) < 2.0 and abs(world_pos[1] - r['near'][1]) < 2.0:
434
+ return True
435
+ return False
436
+
437
+ def patch_move_for(object_name, world_pos):
438
+ """Offset a scene object by a `move_objects` patch entry, or (0,0,0).
439
+
440
+ `{"name": "CornerTowerTopLeft", "toward_center": 3.0}` slides the object 3 units toward
441
+ the map centre on each horizontal axis (sign taken from its own position), so a corner block
442
+ overlaps the wall runs it is meant to close. `"dpos": [x, y, z]` is an explicit offset.
443
+ """
444
+ for m in LEVEL_PATCHES.get('move_objects', []) or []:
445
+ if m.get('name') != object_name: continue
446
+ if 'dpos' in m:
447
+ d = m['dpos']; return (float(d[0]), float(d[1]), float(d[2]) if len(d) > 2 else 0.0)
448
+ t = float(m.get('toward_center', 0.0))
449
+ sx = -1.0 if world_pos[0] > 0 else (1.0 if world_pos[0] < 0 else 0.0)
450
+ sy = -1.0 if world_pos[1] > 0 else (1.0 if world_pos[1] < 0 else 0.0)
451
+ return (sx * t, sy * t, 0.0)
452
+ return (0.0, 0.0, 0.0)
453
+
454
+ def patch_added_instances(prefab_name):
455
+ """Extra scene-level placements a level patch adds for this prefab: [(pos, quat)]."""
456
+ out = []
457
+ for a in LEVEL_PATCHES.get('add_instances', []):
458
+ if a.get('prefab') == prefab_name:
459
+ zr = math.radians(a.get('zrot', 0.0)) / 2.0
460
+ out.append((tuple(a['pos']), (0.0, 0.0, math.sin(zr), math.cos(zr))))
461
+ return out
462
+
463
+ results = [] # collected colliders
464
+ visited_gos = set() # (file path, gameObject fileID) actually walked
465
+
466
+ def parse_mods(body):
467
+ """Return dict: targetFileID -> {property: value} and prefab guid overrides."""
468
+ mods = {}
469
+ # each modification entry
470
+ for m in re.finditer(r'- target: \{fileID: (-?\d+), guid: ([0-9a-f]{32}), type: \d+\}\s*\n\s*propertyPath: (\S+)\s*\n\s*value: (.*?)\n\s*objectReference:', body):
471
+ tgt, guid, prop, val = m.group(1), m.group(2), m.group(3), m.group(4).strip()
472
+ mods.setdefault((guid, tgt), {})[prop] = val
473
+ return mods
474
+
475
+ def local_trs(body):
476
+ return (vec3(body, 'm_LocalPosition', (0,0,0)),
477
+ quat(body, 'm_LocalRotation'),
478
+ vec3(body, 'm_LocalScale', (1,1,1)))
479
+
480
+ def apply_mods(trs, mod):
481
+ (p, r, s) = trs
482
+ p = list(p); r = list(r); s = list(s)
483
+ for k, v in mod.items():
484
+ try: fv = float(v)
485
+ except Exception: continue
486
+ if k == 'm_LocalPosition.x': p[0]=fv
487
+ elif k == 'm_LocalPosition.y': p[1]=fv
488
+ elif k == 'm_LocalPosition.z': p[2]=fv
489
+ elif k == 'm_LocalRotation.x': r[0]=fv
490
+ elif k == 'm_LocalRotation.y': r[1]=fv
491
+ elif k == 'm_LocalRotation.z': r[2]=fv
492
+ elif k == 'm_LocalRotation.w': r[3]=fv
493
+ elif k == 'm_LocalScale.x': s[0]=fv
494
+ elif k == 'm_LocalScale.y': s[1]=fv
495
+ elif k == 'm_LocalScale.z': s[2]=fv
496
+ return (tuple(p), tuple(r), tuple(s))
497
+
498
+ def go_name(docs, goid):
499
+ d = docs.get(goid)
500
+ if not d: return ''
501
+ m = re.search(r'm_Name: (.*)', d[1])
502
+ return m.group(1).strip() if m else ''
503
+
504
+ def go_active(docs, goid, mods=None):
505
+ """Is this GameObject active — honouring the prefab instance's m_IsActive override.
506
+
507
+ A scene can switch whole prefab instances off this way (in the scene this was built on, two
508
+ wall prefabs were root-inactive in every placement), so reading the source prefab alone
509
+ builds a level Unity never shows.
510
+ """
511
+ if mods:
512
+ over = mods.get(goid, {}).get('m_IsActive')
513
+ if over is not None: return str(over).strip() == '1'
514
+ d = docs.get(goid)
515
+ if not d: return True
516
+ m = re.search(r'm_IsActive: (\d)', d[1])
517
+ return (m.group(1) == '1') if m else True
518
+
519
+ def comp_enabled(cbody, cid=None, mods=None):
520
+ """A component's m_Enabled, honouring the instance override that targets it."""
521
+ if mods and cid is not None:
522
+ over = mods.get(cid, {}).get('m_Enabled')
523
+ if over is not None: return str(over).strip() == '1'
524
+ m = re.search(r'm_Enabled: (\d)', cbody)
525
+ return (m.group(1) == '1') if m else True
526
+
527
+ NESTED_ID_MASK = 0x7FFFFFFFFFFFFFFF
528
+
529
+ def instance_mods(body, docs, pid, pdocs, outer_mods=None):
530
+ """The modification map a prefab instance applies to its source file.
531
+
532
+ Overrides written on the instance itself come from its m_Modifications. Overrides that an
533
+ OUTER instance wrote against objects inside this nested one are keyed, in the outer file's
534
+ namespace, by (nestedObjectFileID ^ nestedInstanceFileID) & 0x7FFF..., so they are
535
+ re-keyed here into the nested file's own ids. Verified on the origin scene: an instance's
536
+ m_Enabled override resolved to a MeshCollider inside a nested prefab, exactly as Unity shows it.
537
+ """
538
+ modmap = {}
539
+ for (_g, t), props in parse_mods(body).items():
540
+ modmap.setdefault(t, {}).update(props)
541
+ if outer_mods:
542
+ try: p_int = int(pid)
543
+ except ValueError: return modmap
544
+ pending = {t: props for t, props in outer_mods.items() if t not in docs}
545
+ if pending:
546
+ for oid in pdocs:
547
+ try: comp = (int(oid) ^ p_int) & NESTED_ID_MASK
548
+ except ValueError: continue
549
+ props = pending.get(str(comp))
550
+ if props: modmap.setdefault(oid, {}).update(props)
551
+ return modmap
552
+
553
+ def go_layer(docs, goid, mods=None):
554
+ """A GameObject's collision layer, honouring the instance's override.
555
+
556
+ Unity stores "this copy lives on Terrain" as an `m_Layer` modification on the prefab instance,
557
+ not in the source prefab, so reading the source alone reports every piece as Default and the
558
+ real wall set disappears into the decoration.
559
+ """
560
+ if mods:
561
+ over = mods.get(goid, {}).get('m_Layer')
562
+ if over is not None:
563
+ try: return int(float(over))
564
+ except ValueError: pass
565
+ d = docs.get(goid)
566
+ if not d: return 0
567
+ m = re.search(r'm_Layer: (\d+)', d[1])
568
+ return int(m.group(1)) if m else 0
569
+
570
+ def walk(path, docs, tid, world, depth, mods_for_file, tag):
571
+ """Walk transform tid in file `path`, world = parent world TRS."""
572
+ if depth > MAX_DEPTH: return
573
+ cls, body = docs[tid]
574
+ if cls == '1001':
575
+ # nested prefab instance
576
+ handle_prefab_instance(path, docs, tid, body, world, depth, mods_for_file, tag)
577
+ return
578
+ if 'm_LocalPosition' not in body:
579
+ return # stripped stub for a nested prefab; expand_file/sweep_orphan_colliders place it
580
+ trs = local_trs(body)
581
+ key = mods_for_file.get(tid)
582
+ if key: trs = apply_mods(trs, key)
583
+ w = compose(world, trs)
584
+ goid = fid(body, 'm_GameObject')
585
+ if not go_active(docs, goid, mods_for_file):
586
+ return
587
+ name = go_name(docs, goid)
588
+ layer = go_layer(docs, goid, mods_for_file)
589
+ visited_gos.add((path, goid))
590
+ # find components on this GameObject
591
+ gd = docs.get(goid)
592
+ if gd:
593
+ for m in re.finditer(r'- component: \{fileID: (\d+)\}', gd[1]):
594
+ cid = m.group(1)
595
+ cd = docs.get(cid)
596
+ if not cd: continue
597
+ ccls, cbody = cd
598
+ if ccls == '65': # BoxCollider
599
+ if not comp_enabled(cbody, cid, mods_for_file): continue
600
+ size = vec3(cbody, 'm_Size', (1,1,1))
601
+ center = vec3(cbody, 'm_Center', (0,0,0))
602
+ trig = re.search(r'm_IsTrigger: (\d)', cbody)
603
+ results.append(dict(kind='box', name=name, tag=tag, layer=layer,
604
+ world=w, size=size, center=center,
605
+ trigger=(trig.group(1)=='1') if trig else False))
606
+ elif ccls == '64': # MeshCollider
607
+ if not comp_enabled(cbody, cid, mods_for_file): continue
608
+ mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cbody)
609
+ if not mm: continue
610
+ rects = mesh_plane_rects(mm.group(2), w, mm.group(1))
611
+ if rects is not None:
612
+ for (rx0, ry0, rx1, ry1) in rects:
613
+ cw = ((rx0 + rx1) / 2, (ry0 + ry1) / 2, PLANE_CENTER)
614
+ results.append(dict(kind='box', name=name, tag=tag, layer=layer,
615
+ world=(cw, (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0)),
616
+ size=(rx1 - rx0, ry1 - ry0, 2 * PLANE_HALF),
617
+ center=(0.0, 0.0, 0.0), trigger=False, src='mesh'))
618
+ continue
619
+ mb = mesh_bounds(mm.group(2), guid2path, mm.group(1))
620
+ if not mb: continue
621
+ mn, mx = mb
622
+ if mn[2] > PLANE_CENTER + PLANE_HALF or mx[2] < PLANE_CENTER - PLANE_HALF:
623
+ pass
624
+ size = (mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2])
625
+ center = ((mx[0]+mn[0])/2, (mx[1]+mn[1])/2, (mx[2]+mn[2])/2)
626
+ results.append(dict(kind='box', name=name, tag=tag, layer=layer,
627
+ world=w, size=size, center=center, trigger=False, src='meshbox'))
628
+ elif ccls == '135': # SphereCollider
629
+ if not comp_enabled(cbody, cid, mods_for_file): continue
630
+ rad = re.search(r'm_Radius: ([-\d.eE+]+)', cbody)
631
+ center = vec3(cbody, 'm_Center', (0,0,0))
632
+ trig = re.search(r'm_IsTrigger: (\d)', cbody)
633
+ results.append(dict(kind='sphere', name=name, tag=tag, layer=layer,
634
+ world=w, radius=float(rad.group(1)) if rad else 0.5,
635
+ center=center, trigger=(trig.group(1)=='1') if trig else False))
636
+ # children
637
+ for m in re.finditer(r'- \{fileID: (\d+)\}', body[body.find('m_Children:'):body.find('m_Father:')] if 'm_Children:' in body and 'm_Father:' in body else ''):
638
+ cid = m.group(1)
639
+ if cid in docs:
640
+ walk(path, docs, cid, w, depth+1, mods_for_file, tag)
641
+
642
+ def handle_prefab_instance(path, docs, tid, body, world, depth, outer_mods, tag):
643
+ if depth > MAX_DEPTH: return
644
+ m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
645
+ if not m: return
646
+ src = guid2path.get(m.group(1))
647
+ if not src or not os.path.exists(src): return
648
+ pdocs = parse_file(src)
649
+ if not pdocs: return
650
+ modmap = instance_mods(body, docs, tid, pdocs, outer_mods)
651
+ # find prefab root transform: the transform with m_Father 0
652
+ root = None
653
+ for pid, (pcls, pbody) in pdocs.items():
654
+ if pcls in ('4',) and fid(pbody, 'm_Father') == '0':
655
+ root = pid; break
656
+ if root is None:
657
+ for pid, (pcls, pbody) in pdocs.items():
658
+ if pcls == '1001':
659
+ root = pid; break
660
+ if root is None: return
661
+ newtag = tag or os.path.basename(src).replace('.prefab','')
662
+ expand_file(src, pdocs, world, depth+1, modmap, newtag)
663
+ # Orphan colliders are stored relative to the prefab ROOT, so they need the root's world
664
+ # transform (parent chain + this instance's own placement), not the parent's.
665
+ root_world = world
666
+ rcls, rbody = pdocs[root]
667
+ if rcls == '4':
668
+ root_world = compose(world, apply_mods(local_trs(rbody), modmap.get(root, {})))
669
+ sweep_orphan_colliders(src, pdocs, root_world, newtag)
670
+
671
+
672
+ def expand_file(path, docs, world, depth, mods, tag):
673
+ """Every collider one file places: its root transforms, then its own nested prefab instances.
674
+
675
+ The second half is what was missing. A nested instance is listed in its parent's m_Children only
676
+ as a stripped stub the transform walk cannot follow, so a prefab assembled out of other prefabs
677
+ contributed only the colliders stored directly in its own file — one prefab arrived with no
678
+ collision at all, another with 6 of its 56 boxes.
679
+ """
680
+ if depth > MAX_DEPTH: return
681
+ for tid, (cls, body) in docs.items():
682
+ if cls == '4' and fid(body, 'm_Father') == '0' and 'm_LocalPosition' in body:
683
+ walk(path, docs, tid, world, depth, mods, tag)
684
+ for tid, (cls, body) in docs.items():
685
+ if cls != '1001': continue
686
+ chain = []
687
+ cur = fid(body, 'm_TransformParent')
688
+ guard = 0
689
+ while cur != '0' and cur in docs and guard < 40:
690
+ ccls, cbody = docs[cur]
691
+ if ccls != '4' or 'm_LocalPosition' not in cbody: break
692
+ chain.append((cur, cbody))
693
+ cur = fid(cbody, 'm_Father')
694
+ guard += 1
695
+ w = world
696
+ for cid, cbody in reversed(chain):
697
+ trs = local_trs(cbody)
698
+ m = mods.get(cid)
699
+ if m: trs = apply_mods(trs, m)
700
+ w = compose(w, trs)
701
+ handle_prefab_instance(path, docs, tid, body, w, depth, mods, tag)
702
+
703
+
704
+ def sweep_orphan_colliders(path, docs, world, tag):
705
+ """Colliders defined in a prefab file but attached to a GameObject that lives inside a nested
706
+ prefab instance never get reached by the transform walk (their Transform is a stripped stub).
707
+ Unity stores their center/size relative to the object, which for these cases is the prefab
708
+ root, so attaching them there is correct. Dedupe identical boxes: destructible props carry a
709
+ second, inactive copy for their fracture pieces."""
710
+ seen = set()
711
+ for cid, (ccls, cbody) in docs.items():
712
+ if ccls not in ('65',):
713
+ continue
714
+ goid = fid(cbody, 'm_GameObject')
715
+ if (path, goid) in visited_gos:
716
+ continue
717
+ gd = docs.get(goid)
718
+ if gd is not None and go_active(docs, goid) is False:
719
+ continue
720
+ enabled = re.search(r'm_Enabled: (\d)', cbody)
721
+ if enabled and enabled.group(1) != '1':
722
+ continue
723
+ size = vec3(cbody, 'm_Size', (1, 1, 1))
724
+ center = vec3(cbody, 'm_Center', (0, 0, 0))
725
+ key = (round(size[0], 3), round(size[1], 3), round(size[2], 3),
726
+ round(center[0], 3), round(center[1], 3), round(center[2], 3))
727
+ if key in seen:
728
+ continue
729
+ seen.add(key)
730
+ trig = re.search(r'm_IsTrigger: (\d)', cbody)
731
+ results.append(dict(kind='box', name=tag, tag=tag, layer=7,
732
+ world=world, size=size, center=center,
733
+ trigger=(trig.group(1) == '1') if trig else False))
734
+
735
+ def extract_scene(scene_path):
736
+ docs = parse_file(scene_path)
737
+ IDENT = ((0.0,0.0,0.0), (0.0,0.0,0.0,1.0), (1.0,1.0,1.0))
738
+ expand_file(scene_path, docs, IDENT, 0, {}, None)
739
+
740
+
741
+ def dedupe(items):
742
+ """One collider reached by two paths is still one collider."""
743
+ seen = set()
744
+ out = []
745
+ for it in items:
746
+ (p, q, sc) = it['world']
747
+ key = (it['kind'], it['tag'],
748
+ tuple(round(v, 4) for v in p), tuple(round(v, 4) for v in q),
749
+ tuple(round(v, 4) for v in sc),
750
+ tuple(round(v, 4) for v in it.get('size', (0,0,0))),
751
+ tuple(round(v, 4) for v in it.get('center', (0,0,0))),
752
+ round(it.get('radius', 0), 4), it.get('trigger', False))
753
+ if key in seen: continue
754
+ seen.add(key)
755
+ out.append(it)
756
+ return out
757
+
758
+
759
+ if __name__ == '__main__':
760
+ scene = _SCENE
761
+ extract_scene(scene)
762
+ out = []
763
+ for r in dedupe(results):
764
+ (p, q, s) = r['world']
765
+ e = dict(r)
766
+ e['pos'] = [round(v,4) for v in p]
767
+ e['scale'] = [round(v,4) for v in s]
768
+ e['zrot'] = round(quat_euler_z(q), 2)
769
+ e['quat'] = [round(v,5) for v in q]
770
+ del e['world']
771
+ e['size'] = [round(v,4) for v in r.get('size',(0,0,0))]
772
+ e['center'] = [round(v,4) for v in r.get('center',(0,0,0))]
773
+ out.append(e)
774
+ print(json.dumps(out))