@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.
- package/README.md +27 -3
- package/assets/unity-import/README.md +13 -0
- package/assets/unity-import/blender_build.py +462 -0
- package/assets/unity-import/build_parts.py +263 -0
- package/assets/unity-import/convert_assets.py +193 -0
- package/assets/unity-import/fbx_ascii.py +156 -0
- package/assets/unity-import/fbx_axis.py +174 -0
- package/assets/unity-import/fbx_bounds.py +202 -0
- package/assets/unity-import/import_config.py +86 -0
- package/assets/unity-import/level_patches.example.json +23 -0
- package/assets/unity-import/mesh_walls.py +78 -0
- package/assets/unity-import/render_topdown.py +33 -0
- package/assets/unity-import/unity-import.example.json +27 -0
- package/assets/unity-import/unity_assets.py +114 -0
- package/assets/unity-import/unity_colliders2d.py +127 -0
- package/assets/unity-import/unity_extract.py +774 -0
- package/assets/unity-import/unity_markers.py +249 -0
- package/assets/unity-import/unity_renderers.py +279 -0
- package/assets/unity-import/write_arena_data.py +198 -0
- package/dist/cli.d.ts +12 -0
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.js +5 -0
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +20 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/upgrade.d.ts +4 -0
- package/dist/commands/upgrade.js +31 -5
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/editor/server.js +3 -3
- package/dist/editor/shell-page.js +16 -1
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/project/context.d.ts +9 -0
- package/dist/project/context.js +4 -1
- package/dist/project/context.js.map +1 -1
- package/dist/scaffold/agent-targets.d.ts +126 -0
- package/dist/scaffold/agent-targets.js +180 -0
- package/dist/scaffold/agent-targets.js.map +1 -0
- package/dist/scaffold/agents-md.d.ts +4 -1
- package/dist/scaffold/agents-md.js +6 -2
- package/dist/scaffold/agents-md.js.map +1 -1
- package/dist/scaffold/project-files.d.ts +40 -5
- package/dist/scaffold/project-files.js +67 -25
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.d.ts +42 -15
- package/dist/scaffold/project.js +100 -38
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/reference-guidance.d.ts +1 -1
- package/dist/scaffold/reference-guidance.js +2 -1
- package/dist/scaffold/reference-guidance.js.map +1 -1
- package/dist/scaffold/skill-files.d.ts +32 -9
- package/dist/scaffold/skill-files.js +71 -15
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/scaffold/unity-skill-files.d.ts +30 -0
- package/dist/scaffold/unity-skill-files.js +474 -0
- package/dist/scaffold/unity-skill-files.js.map +1 -0
- package/dist/scaffold/upgrade-project.d.ts +31 -11
- package/dist/scaffold/upgrade-project.js +35 -23
- package/dist/scaffold/upgrade-project.js.map +1 -1
- 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))
|