@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,249 @@
|
|
|
1
|
+
"""Pull the gameplay markers out of a Unity scene: spawners, bases, powerup spawn points.
|
|
2
|
+
|
|
3
|
+
Colliders come out of `unity_extract.py`; this handles the objects that carry no collider and are
|
|
4
|
+
identified by the MonoBehaviour script attached to them (`markerScripts` in unity-import.json —
|
|
5
|
+
`PowerupSpawnPoint`), plus placed prefab instances of `Base.prefab` and friends.
|
|
6
|
+
|
|
7
|
+
python3 tools/unity_markers.py "<unity-project>/Assets/Scenes/<Scene>.unity" # defaults to the config
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
DOC_RE = re.compile(r'\n--- !u!(\d+) &(\d+)(?: stripped)?\n')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_unity_root(start):
|
|
20
|
+
d = os.path.dirname(os.path.abspath(start))
|
|
21
|
+
while d != os.path.dirname(d):
|
|
22
|
+
if os.path.isdir(os.path.join(d, 'Assets')):
|
|
23
|
+
return d
|
|
24
|
+
d = os.path.dirname(d)
|
|
25
|
+
raise SystemExit('could not find the Unity project root above ' + start)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def guid_map(root):
|
|
29
|
+
out = {}
|
|
30
|
+
for base, _dirs, files in os.walk(os.path.join(root, 'Assets')):
|
|
31
|
+
for f in files:
|
|
32
|
+
if not f.endswith('.meta'):
|
|
33
|
+
continue
|
|
34
|
+
p = os.path.join(base, f)
|
|
35
|
+
try:
|
|
36
|
+
t = open(p, encoding='utf-8', errors='ignore').read(400)
|
|
37
|
+
except Exception:
|
|
38
|
+
continue
|
|
39
|
+
m = re.search(r'guid: ([0-9a-f]{32})', t)
|
|
40
|
+
if m:
|
|
41
|
+
out[m.group(1)] = p[:-5]
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse(path):
|
|
46
|
+
txt = open(path, encoding='utf-8', errors='ignore').read()
|
|
47
|
+
parts = DOC_RE.split(txt)
|
|
48
|
+
docs = {}
|
|
49
|
+
i = 1
|
|
50
|
+
while i + 2 <= len(parts) - 1:
|
|
51
|
+
docs[parts[i + 1]] = (parts[i], parts[i + 2])
|
|
52
|
+
i += 3
|
|
53
|
+
return docs
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fid(body, key):
|
|
57
|
+
m = re.search(re.escape(key) + r': \{fileID: (-?\d+)', body)
|
|
58
|
+
return m.group(1) if m else '0'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def vec3(body, key, default=(0.0, 0.0, 0.0)):
|
|
62
|
+
m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+)\}', body)
|
|
63
|
+
return tuple(float(v) for v in m.groups()) if m else default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def quat(body, key):
|
|
67
|
+
m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+), w: ([-\d.eE+]+)\}', body)
|
|
68
|
+
return tuple(float(v) for v in m.groups()) if m else (0.0, 0.0, 0.0, 1.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def qmul(a, b):
|
|
72
|
+
ax, ay, az, aw = a
|
|
73
|
+
bx, by, bz, bw = b
|
|
74
|
+
return (aw * bx + ax * bw + ay * bz - az * by,
|
|
75
|
+
aw * by - ax * bz + ay * bw + az * bx,
|
|
76
|
+
aw * bz + ax * by - ay * bx + az * bw,
|
|
77
|
+
aw * bw - ax * bx - ay * by - az * bz)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def qrot(q, v):
|
|
81
|
+
x, y, z, w = q
|
|
82
|
+
vx, vy, vz = v
|
|
83
|
+
tx = 2 * (y * vz - z * vy)
|
|
84
|
+
ty = 2 * (z * vx - x * vz)
|
|
85
|
+
tz = 2 * (x * vy - y * vx)
|
|
86
|
+
return (vx + w * tx + (y * tz - z * ty),
|
|
87
|
+
vy + w * ty + (z * tx - x * tz),
|
|
88
|
+
vz + w * tz + (x * ty - y * tx))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def world_of(docs, tid, mods):
|
|
92
|
+
"""World transform of a scene transform, honouring prefab-instance overrides."""
|
|
93
|
+
chain = []
|
|
94
|
+
guard = 0
|
|
95
|
+
while tid and tid != '0' and tid in docs and guard < 64:
|
|
96
|
+
cls, body = docs[tid]
|
|
97
|
+
if cls == '1001':
|
|
98
|
+
# A prefab instance in the parent chain: fold in its root placement.
|
|
99
|
+
chain.append(('inst', body))
|
|
100
|
+
tid = fid(body, 'm_TransformParent')
|
|
101
|
+
elif cls == '4':
|
|
102
|
+
chain.append(('t', body))
|
|
103
|
+
tid = fid(body, 'm_Father')
|
|
104
|
+
else:
|
|
105
|
+
break
|
|
106
|
+
guard += 1
|
|
107
|
+
|
|
108
|
+
pos = (0.0, 0.0, 0.0)
|
|
109
|
+
rot = (0.0, 0.0, 0.0, 1.0)
|
|
110
|
+
scale = (1.0, 1.0, 1.0)
|
|
111
|
+
for kind, body in reversed(chain):
|
|
112
|
+
if kind == 't':
|
|
113
|
+
lp = vec3(body, 'm_LocalPosition')
|
|
114
|
+
lr = quat(body, 'm_LocalRotation')
|
|
115
|
+
ls = vec3(body, 'm_LocalScale', (1.0, 1.0, 1.0))
|
|
116
|
+
else:
|
|
117
|
+
lp, lr, ls = instance_trs(body)
|
|
118
|
+
sp = (lp[0] * scale[0], lp[1] * scale[1], lp[2] * scale[2])
|
|
119
|
+
rp = qrot(rot, sp)
|
|
120
|
+
pos = (pos[0] + rp[0], pos[1] + rp[1], pos[2] + rp[2])
|
|
121
|
+
rot = qmul(rot, lr)
|
|
122
|
+
scale = (scale[0] * ls[0], scale[1] * ls[1], scale[2] * ls[2])
|
|
123
|
+
_ = mods
|
|
124
|
+
return pos, rot, scale
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def instance_trs(body):
|
|
128
|
+
"""The root placement recorded in a PrefabInstance's modification list."""
|
|
129
|
+
p = [0.0, 0.0, 0.0]
|
|
130
|
+
r = [0.0, 0.0, 0.0, 1.0]
|
|
131
|
+
s = [1.0, 1.0, 1.0]
|
|
132
|
+
for m in re.finditer(r'propertyPath: m_Local(Position|Rotation|Scale)\.([xyzw])\s*\n\s*value: ([-\d.eE+]+)', body):
|
|
133
|
+
what, axis, val = m.group(1), m.group(2), float(m.group(3))
|
|
134
|
+
idx = 'xyzw'.index(axis)
|
|
135
|
+
if what == 'Position' and idx < 3:
|
|
136
|
+
p[idx] = val
|
|
137
|
+
elif what == 'Rotation':
|
|
138
|
+
r[idx] = val
|
|
139
|
+
elif what == 'Scale' and idx < 3:
|
|
140
|
+
s[idx] = val
|
|
141
|
+
return tuple(p), tuple(r), tuple(s)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def euler_z(q):
|
|
145
|
+
x, y, z, w = q
|
|
146
|
+
return math.degrees(math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
from import_config import CFG as _CFG, scene_path as scene_path_from_config
|
|
150
|
+
SCRIPTS = tuple(_CFG['markerScripts'])
|
|
151
|
+
|
|
152
|
+
# Markers are usually placed prefab instances rather than loose scene objects, so the interesting
|
|
153
|
+
# values arrive as property overrides on the instance rather than as fields on a scene component.
|
|
154
|
+
MARKER_PREFABS = tuple(_CFG['markerPrefabs'])
|
|
155
|
+
OVERRIDES = ('spawnerId', 'cameraZRotation', 'cameraOffset.x', 'cameraOffset.y', 'cameraOffset.z',
|
|
156
|
+
'm_Name')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def prefab_markers(docs, guids):
|
|
160
|
+
out = []
|
|
161
|
+
for _iid, (cls, body) in docs.items():
|
|
162
|
+
if cls != '1001':
|
|
163
|
+
continue
|
|
164
|
+
m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
|
|
165
|
+
if not m:
|
|
166
|
+
continue
|
|
167
|
+
src = guids.get(m.group(1))
|
|
168
|
+
base = os.path.basename(src) if src else ''
|
|
169
|
+
if base not in MARKER_PREFABS:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
parent = fid(body, 'm_TransformParent')
|
|
173
|
+
ppos, prot, pscale = world_of(docs, parent, None) if parent != '0' else (
|
|
174
|
+
(0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0))
|
|
175
|
+
lp, lr, _ls = instance_trs(body)
|
|
176
|
+
sp = (lp[0] * pscale[0], lp[1] * pscale[1], lp[2] * pscale[2])
|
|
177
|
+
rp = qrot(prot, sp)
|
|
178
|
+
pos = (ppos[0] + rp[0], ppos[1] + rp[1], ppos[2] + rp[2])
|
|
179
|
+
rot = qmul(prot, lr)
|
|
180
|
+
|
|
181
|
+
entry = dict(kind=base.replace('.prefab', ''),
|
|
182
|
+
pos=[round(v, 3) for v in pos],
|
|
183
|
+
zrot=round(euler_z(rot), 2))
|
|
184
|
+
for prop in OVERRIDES:
|
|
185
|
+
mm = re.search(r'propertyPath: ' + re.escape(prop) + r'\s*\n\s*value: (.*)', body)
|
|
186
|
+
if mm:
|
|
187
|
+
raw = mm.group(1).strip()
|
|
188
|
+
try:
|
|
189
|
+
entry[prop] = float(raw)
|
|
190
|
+
except ValueError:
|
|
191
|
+
entry[prop] = raw
|
|
192
|
+
out.append(entry)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
scene = sys.argv[1] if len(sys.argv) > 1 else scene_path_from_config()
|
|
198
|
+
root = find_unity_root(scene)
|
|
199
|
+
guids = guid_map(root)
|
|
200
|
+
want = {}
|
|
201
|
+
for guid, path in guids.items():
|
|
202
|
+
base = os.path.basename(path)
|
|
203
|
+
if base in SCRIPTS:
|
|
204
|
+
want[guid] = base[:-3]
|
|
205
|
+
|
|
206
|
+
docs = parse(scene)
|
|
207
|
+
|
|
208
|
+
# GameObject -> its Transform, so a MonoBehaviour can be located.
|
|
209
|
+
go_to_transform = {}
|
|
210
|
+
for tid, (cls, body) in docs.items():
|
|
211
|
+
if cls == '4':
|
|
212
|
+
go_to_transform[fid(body, 'm_GameObject')] = tid
|
|
213
|
+
|
|
214
|
+
markers = []
|
|
215
|
+
for _mid, (cls, body) in docs.items():
|
|
216
|
+
if cls != '114':
|
|
217
|
+
continue
|
|
218
|
+
m = re.search(r'm_Script: \{fileID: -?\d+, guid: ([0-9a-f]{32})', body)
|
|
219
|
+
if not m or m.group(1) not in want:
|
|
220
|
+
continue
|
|
221
|
+
kind = want[m.group(1)]
|
|
222
|
+
goid = fid(body, 'm_GameObject')
|
|
223
|
+
tid = go_to_transform.get(goid)
|
|
224
|
+
if not tid:
|
|
225
|
+
continue
|
|
226
|
+
pos, rot, _scale = world_of(docs, tid, None)
|
|
227
|
+
name = ''
|
|
228
|
+
gd = docs.get(goid)
|
|
229
|
+
if gd:
|
|
230
|
+
nm = re.search(r'm_Name: (.*)', gd[1])
|
|
231
|
+
if nm:
|
|
232
|
+
name = nm.group(1).strip()
|
|
233
|
+
entry = dict(kind=kind, name=name,
|
|
234
|
+
pos=[round(v, 3) for v in pos],
|
|
235
|
+
zrot=round(euler_z(rot), 2))
|
|
236
|
+
sid = re.search(r'\n spawnerId: (-?\d+)', body)
|
|
237
|
+
if sid:
|
|
238
|
+
entry['spawnerId'] = int(sid.group(1))
|
|
239
|
+
cz = re.search(r'\n cameraZRotation: ([-\d.eE+]+)', body)
|
|
240
|
+
if cz:
|
|
241
|
+
entry['cameraZRotation'] = float(cz.group(1))
|
|
242
|
+
markers.append(entry)
|
|
243
|
+
|
|
244
|
+
markers.extend(prefab_markers(docs, guids))
|
|
245
|
+
print(json.dumps(markers, indent=1))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == '__main__':
|
|
249
|
+
main()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Extract every MeshRenderer a Unity prefab or scene places, with its world transform.
|
|
2
|
+
|
|
3
|
+
`unity_extract.py` walks the same hierarchy to find colliders; this walks it to find
|
|
4
|
+
*visible* geometry, so the level can be rebuilt from the original models instead of
|
|
5
|
+
from boxes. It reuses that module's transform maths, which is already validated
|
|
6
|
+
against known collider sizes.
|
|
7
|
+
|
|
8
|
+
Output per renderer: the source FBX and which mesh inside it, the Unity materials the
|
|
9
|
+
renderer overrides with, and the composed world TRS.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
17
|
+
|
|
18
|
+
from import_config import CFG as _CFG
|
|
19
|
+
_UNITY = os.path.expanduser(_CFG['unityRoot'])
|
|
20
|
+
# unity_extract resolves its project root from argv[1]; point it at the active tree.
|
|
21
|
+
if len(sys.argv) < 2:
|
|
22
|
+
sys.argv = [sys.argv[0], os.path.join(_UNITY, 'Assets', 'Scenes')]
|
|
23
|
+
|
|
24
|
+
import unity_extract as ue # noqa: E402
|
|
25
|
+
|
|
26
|
+
MAX_DEPTH = 10
|
|
27
|
+
# the project's TagManager: a layer some games render only into a visibility or shadow mask.
|
|
28
|
+
SHADOW_LAYER = 18
|
|
29
|
+
_LOD_SUFFIX = re.compile(r'_(LOD)?[Ll]?[123]$')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_lod_child(name):
|
|
33
|
+
"""LODGroups list LOD0 first; the _L1/_L2 rungs are the same shape at lower detail."""
|
|
34
|
+
return bool(re.search(r'_L[123]$', name)) or bool(re.search(r'_LOD[123]$', name))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _under_lodgroup(docs, tbody):
|
|
38
|
+
"""Does this transform's parent GameObject carry a LODGroup (class 205)?
|
|
39
|
+
|
|
40
|
+
A name alone is not evidence of a LOD rung: some asset packs ship one FBX per detail level,
|
|
41
|
+
so a `..._L2` model placed by hand under a plain group is a whole object. The scene this was
|
|
42
|
+
built on had 16 of those — the four side-middle wall pieces and all twelve corner
|
|
43
|
+
staircases — and dropping them by name emptied the corners and the middle of every side.
|
|
44
|
+
"""
|
|
45
|
+
f = ue.fid(tbody, 'm_Father')
|
|
46
|
+
pt = docs.get(f)
|
|
47
|
+
if not pt or pt[0] != '4':
|
|
48
|
+
return False
|
|
49
|
+
pg = docs.get(ue.fid(pt[1], 'm_GameObject'))
|
|
50
|
+
if not pg:
|
|
51
|
+
return False
|
|
52
|
+
for m in re.finditer(r'- component: \{fileID: (\d+)\}', pg[1]):
|
|
53
|
+
c = docs.get(m.group(1))
|
|
54
|
+
if c and c[0] == '205':
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RendererWalk:
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.out = []
|
|
62
|
+
|
|
63
|
+
def walk(self, path, docs, tid, world, depth, mods_for_file, tag):
|
|
64
|
+
if depth > MAX_DEPTH:
|
|
65
|
+
return
|
|
66
|
+
entry = docs.get(tid)
|
|
67
|
+
if not entry:
|
|
68
|
+
return
|
|
69
|
+
cls, body = entry
|
|
70
|
+
if cls == '1001':
|
|
71
|
+
self.prefab(path, docs, tid, body, world, depth, tag, mods_for_file)
|
|
72
|
+
return
|
|
73
|
+
if 'm_LocalPosition' not in body:
|
|
74
|
+
return # a stripped stub standing in for a nested prefab; the 1001 sweep places it
|
|
75
|
+
|
|
76
|
+
trs = ue.local_trs(body)
|
|
77
|
+
mod = mods_for_file.get(tid)
|
|
78
|
+
if mod:
|
|
79
|
+
trs = ue.apply_mods(trs, mod)
|
|
80
|
+
goid = ue.fid(body, 'm_GameObject')
|
|
81
|
+
name = ue.go_name(docs, goid)
|
|
82
|
+
if depth == 0 and path.endswith('.unity'):
|
|
83
|
+
dp = ue.patch_move_for(name, trs[0]) # level patch: slide a scene object
|
|
84
|
+
if any(dp):
|
|
85
|
+
trs = ((trs[0][0] + dp[0], trs[0][1] + dp[1], trs[0][2] + dp[2]), trs[1], trs[2])
|
|
86
|
+
w = ue.compose(world, trs)
|
|
87
|
+
|
|
88
|
+
if not ue.go_active(docs, goid, mods_for_file):
|
|
89
|
+
return
|
|
90
|
+
layer = ue.go_layer(docs, goid, mods_for_file)
|
|
91
|
+
|
|
92
|
+
gd = docs.get(goid)
|
|
93
|
+
if gd and not (_is_lod_child(name) and _under_lodgroup(docs, body)):
|
|
94
|
+
self.components(path, docs, gd[1], name, w, tag, layer, mods_for_file)
|
|
95
|
+
|
|
96
|
+
seg = ''
|
|
97
|
+
if 'm_Children:' in body and 'm_Father:' in body:
|
|
98
|
+
seg = body[body.find('m_Children:'):body.find('m_Father:')]
|
|
99
|
+
for m in re.finditer(r'- \{fileID: (\d+)\}', seg):
|
|
100
|
+
self.walk(path, docs, m.group(1), w, depth + 1, mods_for_file, tag)
|
|
101
|
+
|
|
102
|
+
def components(self, path, docs, gbody, name, world, tag, layer, mods=None):
|
|
103
|
+
"""Pair the MeshFilter and MeshRenderer hanging off one GameObject."""
|
|
104
|
+
mesh = None
|
|
105
|
+
mats = None
|
|
106
|
+
enabled = True
|
|
107
|
+
casts = True
|
|
108
|
+
for m in re.finditer(r'- component: \{fileID: (\d+)\}', gbody):
|
|
109
|
+
cd = docs.get(m.group(1))
|
|
110
|
+
if not cd:
|
|
111
|
+
continue
|
|
112
|
+
ccls, cbody = cd
|
|
113
|
+
if ccls == '33': # MeshFilter
|
|
114
|
+
mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cbody)
|
|
115
|
+
if mm:
|
|
116
|
+
mesh = (mm.group(2), mm.group(1))
|
|
117
|
+
elif ccls == '23': # MeshRenderer
|
|
118
|
+
if not ue.comp_enabled(cbody, m.group(1), mods):
|
|
119
|
+
enabled = False
|
|
120
|
+
cs = re.search(r'm_CastShadows: (\d)', cbody)
|
|
121
|
+
if cs and cs.group(1) == '0':
|
|
122
|
+
casts = False
|
|
123
|
+
seg = cbody[cbody.find('m_Materials:'):]
|
|
124
|
+
seg = seg[:seg.find('\n m_', 1)] if '\n m_' in seg[1:] else seg
|
|
125
|
+
mats = re.findall(r'guid: ([0-9a-f]{32})', seg)
|
|
126
|
+
if not mesh or not enabled:
|
|
127
|
+
return
|
|
128
|
+
mesh_path = ue.guid2path.get(mesh[0])
|
|
129
|
+
if not mesh_path or not os.path.exists(mesh_path):
|
|
130
|
+
return
|
|
131
|
+
if not mesh_path.lower().endswith(('.fbx', '.obj')):
|
|
132
|
+
return # a built-in primitive; the collider pass already covers those
|
|
133
|
+
|
|
134
|
+
(p, q, s) = world
|
|
135
|
+
self.out.append({
|
|
136
|
+
'name': name,
|
|
137
|
+
'tag': tag,
|
|
138
|
+
'layer': layer,
|
|
139
|
+
'fbx': mesh_path,
|
|
140
|
+
'meshFileId': mesh[1],
|
|
141
|
+
'materials': [ue.guid2path.get(g) for g in (mats or []) if ue.guid2path.get(g)],
|
|
142
|
+
'castShadow': casts,
|
|
143
|
+
'pos': [round(v, 5) for v in p],
|
|
144
|
+
'quat': [round(v, 6) for v in q],
|
|
145
|
+
'scale': [round(v, 5) for v in s],
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
def prefab(self, path, docs, tid, body, world, depth, tag, outer_mods=None):
|
|
149
|
+
m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
|
|
150
|
+
if not m:
|
|
151
|
+
return
|
|
152
|
+
src = ue.guid2path.get(m.group(1))
|
|
153
|
+
if not src or not os.path.exists(src):
|
|
154
|
+
return
|
|
155
|
+
pdocs = ue.parse_file(src)
|
|
156
|
+
if not pdocs:
|
|
157
|
+
return
|
|
158
|
+
modmap = ue.instance_mods(body, docs, tid, pdocs, outer_mods)
|
|
159
|
+
proot = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
|
|
160
|
+
if proot is not None and not ue.go_active(pdocs, ue.fid(pdocs[proot][1], 'm_GameObject'), modmap):
|
|
161
|
+
return # the instance switches the whole prefab off (a scene-level m_IsActive override)
|
|
162
|
+
|
|
163
|
+
newtag = tag or os.path.basename(src).replace('.prefab', '')
|
|
164
|
+
if depth == 0:
|
|
165
|
+
# scene-level instance: honour level patches (tools/level_patches.json), the same way
|
|
166
|
+
# unity_colliders2d does, so what is drawn and what is hit stay identical.
|
|
167
|
+
root = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
|
|
168
|
+
if root is not None:
|
|
169
|
+
rp = ue.apply_mods(ue.local_trs(pdocs[root][1]), modmap.get(root, {}))
|
|
170
|
+
if ue.patch_skips_instance(newtag, ue.compose(world, rp)[0]):
|
|
171
|
+
return
|
|
172
|
+
self.expand(src, pdocs, world, depth + 1, modmap, newtag)
|
|
173
|
+
|
|
174
|
+
def expand(self, path, docs, world, depth, mods, tag):
|
|
175
|
+
"""Everything one file places: its root transforms, then its own nested prefab instances.
|
|
176
|
+
|
|
177
|
+
The second half is not optional. A nested instance appears in its parent's m_Children only
|
|
178
|
+
as a stripped stub the transform walk cannot follow, so a prefab built out of other prefabs
|
|
179
|
+
— which most of a kit-built level is — yields only the handful of objects stored directly in its
|
|
180
|
+
own file unless the 1001 records are swept as well. Sweeping only the top-level file, as
|
|
181
|
+
this used to, meant one placed prefab kept 16 of its 75 pieces and another
|
|
182
|
+
arrived empty.
|
|
183
|
+
"""
|
|
184
|
+
if depth > MAX_DEPTH:
|
|
185
|
+
return
|
|
186
|
+
for tid, (cls, body) in docs.items():
|
|
187
|
+
if cls == '4' and ue.fid(body, 'm_Father') == '0' and 'm_LocalPosition' in body:
|
|
188
|
+
self.walk(path, docs, tid, world, depth, mods, tag)
|
|
189
|
+
|
|
190
|
+
for tid, (cls, body) in docs.items():
|
|
191
|
+
if cls != '1001':
|
|
192
|
+
continue
|
|
193
|
+
chain = []
|
|
194
|
+
cur = ue.fid(body, 'm_TransformParent')
|
|
195
|
+
guard = 0
|
|
196
|
+
while cur != '0' and cur in docs and guard < 40:
|
|
197
|
+
ccls, cbody = docs[cur]
|
|
198
|
+
if ccls != '4' or 'm_LocalPosition' not in cbody:
|
|
199
|
+
break
|
|
200
|
+
chain.append((cur, cbody))
|
|
201
|
+
cur = ue.fid(cbody, 'm_Father')
|
|
202
|
+
guard += 1
|
|
203
|
+
w = world
|
|
204
|
+
for cid, cbody in reversed(chain):
|
|
205
|
+
trs = ue.local_trs(cbody)
|
|
206
|
+
mod = mods.get(cid)
|
|
207
|
+
if mod:
|
|
208
|
+
trs = ue.apply_mods(trs, mod)
|
|
209
|
+
w = ue.compose(w, trs)
|
|
210
|
+
self.prefab(path, docs, tid, body, w, depth, tag, mods)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
IDENT = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _add_patched_instances(rw, docs):
|
|
217
|
+
"""Extra placements a level patch adds, expanded from the scene's own copy of the prefab."""
|
|
218
|
+
for tid, (cls, body) in docs.items():
|
|
219
|
+
if cls != '1001': continue
|
|
220
|
+
m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
|
|
221
|
+
src = ue.guid2path.get(m.group(1)) if m else None
|
|
222
|
+
if not src: continue
|
|
223
|
+
name = os.path.basename(src).replace('.prefab', '')
|
|
224
|
+
adds = ue.patch_added_instances(name)
|
|
225
|
+
if not adds: continue
|
|
226
|
+
pdocs = ue.parse_file(src)
|
|
227
|
+
root = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
|
|
228
|
+
if root is None: continue
|
|
229
|
+
for (pos, quat) in adds:
|
|
230
|
+
modmap = {root: {'m_LocalPosition.x': pos[0], 'm_LocalPosition.y': pos[1], 'm_LocalPosition.z': pos[2],
|
|
231
|
+
'm_LocalRotation.x': quat[0], 'm_LocalRotation.y': quat[1], 'm_LocalRotation.z': quat[2], 'm_LocalRotation.w': quat[3]}}
|
|
232
|
+
rw.expand(src, pdocs, ((0.0, 0.0, 0.75), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0)), 1, modmap, name)
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def extract(path):
|
|
237
|
+
"""Every renderer a scene or prefab places, in that file's own space."""
|
|
238
|
+
docs = ue.parse_file(path)
|
|
239
|
+
rw = RendererWalk()
|
|
240
|
+
rw.expand(path, docs, IDENT, 0, {}, None)
|
|
241
|
+
if path.endswith('.unity'):
|
|
242
|
+
_add_patched_instances(rw, docs)
|
|
243
|
+
return _dedupe(rw.out)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _rank(item):
|
|
247
|
+
"""How much a copy deserves to be the one kept. Higher wins.
|
|
248
|
+
|
|
249
|
+
Coincident copies arise two ways, and they are not the same thing:
|
|
250
|
+
|
|
251
|
+
* Unity keeps a second copy of most level geometry on the SHADOWS layer — a stand-in the
|
|
252
|
+
original rendered only into its line-of-sight mask, never to the screen. It sits at exactly
|
|
253
|
+
the same transform as the piece you can see, so it must never win: dropping SHADOWS after
|
|
254
|
+
deduping deletes the visible piece along with it, which is what left whole structures as
|
|
255
|
+
scattered fragments.
|
|
256
|
+
* An object inside a nested prefab is also seen through the outer file, which is where Unity
|
|
257
|
+
stores the override putting it on a gameplay layer. That copy is the authoritative one.
|
|
258
|
+
"""
|
|
259
|
+
if item['layer'] == SHADOW_LAYER:
|
|
260
|
+
return 0
|
|
261
|
+
return 2 if item['layer'] != 0 else 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _dedupe(items):
|
|
265
|
+
"""One renderer reached twice is still one renderer."""
|
|
266
|
+
best = {}
|
|
267
|
+
order = []
|
|
268
|
+
for it in items:
|
|
269
|
+
key = (it['fbx'], it['meshFileId'], tuple(it['pos']), tuple(it['quat']), tuple(it['scale']))
|
|
270
|
+
if key not in best:
|
|
271
|
+
best[key] = it
|
|
272
|
+
order.append(key)
|
|
273
|
+
elif _rank(it) > _rank(best[key]):
|
|
274
|
+
best[key] = it
|
|
275
|
+
return [best[k] for k in order]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == '__main__':
|
|
279
|
+
print(json.dumps(extract(sys.argv[1] if len(sys.argv) > 1 else ue.scene_path_from_config()), indent=1))
|