@bitmagic/cli 0.1.61 → 0.1.62-dev.1
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/check.js +2 -2
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/dev.js +4 -4
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/forge.js +4 -3
- package/dist/commands/forge.js.map +1 -1
- 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/commands/verify.js +5 -4
- package/dist/commands/verify.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/editor/watch.d.ts +1 -1
- package/dist/editor/watch.js +2 -21
- package/dist/editor/watch.js.map +1 -1
- package/dist/forge/voxelize-session.d.ts +1 -1
- package/dist/forge/voxelize-session.js +4 -32
- package/dist/forge/voxelize-session.js.map +1 -1
- package/dist/project/context.d.ts +14 -1
- package/dist/project/context.js +17 -4
- package/dist/project/context.js.map +1 -1
- package/dist/project/package-manager.js +2 -2
- package/dist/project/package-manager.js.map +1 -1
- package/dist/project/spawn-bin.d.ts +69 -0
- package/dist/project/spawn-bin.js +96 -0
- package/dist/project/spawn-bin.js.map +1 -0
- package/dist/publish/bundle.js +3 -3
- package/dist/publish/bundle.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/scaffold-run.js +2 -2
- package/dist/scaffold/scaffold-run.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/dist/verify/iterate.js +2 -1
- package/dist/verify/iterate.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Turn raw Unity colliders into the four depth-sorted sets `src/work/ArenaData.ts` exports.
|
|
2
|
+
|
|
3
|
+
python3 tools/unity_extract.py "<scene>.unity" > tools/colliders.json
|
|
4
|
+
python3 tools/unity_markers.py "<scene>.unity" > tools/markers.json
|
|
5
|
+
python3 tools/build_parts.py [--map] > tools/arena_parts.json
|
|
6
|
+
|
|
7
|
+
Ships fly at z=0 with a 0.8-radius collider and their Z frozen, so only geometry crossing
|
|
8
|
+
z ∈ [-0.8, 0.8] can be hit. Everything else is set dressing at its own depth, in front of the plane
|
|
9
|
+
or behind it. `--map` prints an ASCII plan of the result.
|
|
10
|
+
|
|
11
|
+
Unity renumbered its collision layers between the two project trees — TERRAIN is 7 in
|
|
12
|
+
one tree and 10 in the other, and the level lived in the second — so the
|
|
13
|
+
number is read by NAME out of the project's own TagManager. It used to be inferred as "whichever
|
|
14
|
+
layer holds the most in-plane boxes", which silently picked Default once the extractor started
|
|
15
|
+
finding the colliders nested inside prefabs, and quietly dropped every real Terrain box.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import collections
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
SC = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
|
|
26
|
+
# Must match unity_extract's slice: the depth band a ship is deemed to occupy, in Unity's Z. The
|
|
27
|
+
# arena's doorways are archways cut at z -3..-1, so the play plane is not at zero — see the note in
|
|
28
|
+
# unity_extract.py. Classifying around a different centre than the extractor sliced at would drop
|
|
29
|
+
# every sliced wall into "foreground" and leave the level with no collision at all.
|
|
30
|
+
PLANE_HALF = float(_CFG['planeHalf'])
|
|
31
|
+
PLANE_CENTER = float(_CFG['planeZ'])
|
|
32
|
+
PLANE_LO = PLANE_CENTER - PLANE_HALF
|
|
33
|
+
PLANE_HI = PLANE_CENTER + PLANE_HALF
|
|
34
|
+
|
|
35
|
+
# The project the scene belongs to; its TagManager names the collision layers.
|
|
36
|
+
from import_config import CFG as _CFG, unity_root as _unity_root
|
|
37
|
+
UNITY_ROOT = _unity_root()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def layer_index(name):
|
|
41
|
+
"""The number Unity gave a named collision layer, or None if the project cannot be read."""
|
|
42
|
+
path = os.path.join(UNITY_ROOT, 'ProjectSettings', 'TagManager.asset')
|
|
43
|
+
try:
|
|
44
|
+
with open(path, encoding='utf-8', errors='ignore') as fh:
|
|
45
|
+
text = fh.read()
|
|
46
|
+
except OSError:
|
|
47
|
+
return None
|
|
48
|
+
block = re.search(r'\n layers:\n(.*?)(?=\n m_\w)', text, re.S)
|
|
49
|
+
if not block:
|
|
50
|
+
return None
|
|
51
|
+
for i, line in enumerate(block.group(1).split('\n')):
|
|
52
|
+
entry = line.strip()
|
|
53
|
+
if entry.startswith('- '):
|
|
54
|
+
entry = entry[2:].strip()
|
|
55
|
+
elif entry == '-':
|
|
56
|
+
entry = ''
|
|
57
|
+
else:
|
|
58
|
+
continue
|
|
59
|
+
if entry == name:
|
|
60
|
+
return i
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# An FBX whose bounds are junk — LOD or pivot geometry that swamps the real mesh.
|
|
64
|
+
BAD_TAGS = set(_CFG['badTags'])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def qrot(q, v):
|
|
68
|
+
x, y, z, w = q
|
|
69
|
+
vx, vy, vz = v
|
|
70
|
+
tx = 2 * (y * vz - z * vy)
|
|
71
|
+
ty = 2 * (z * vx - x * vz)
|
|
72
|
+
tz = 2 * (x * vy - y * vx)
|
|
73
|
+
return (vx + w * tx + (y * tz - z * ty),
|
|
74
|
+
vy + w * ty + (z * tx - x * tz),
|
|
75
|
+
vz + w * tz + (x * ty - y * tx))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def to_aabb(colliders):
|
|
79
|
+
out = []
|
|
80
|
+
for c in colliders:
|
|
81
|
+
sx, sy, sz = c['size']
|
|
82
|
+
scx, scy, scz = c['scale']
|
|
83
|
+
cx, cy, cz = c['center']
|
|
84
|
+
hx, hy, hz = sx * scx / 2, sy * scy / 2, sz * scz / 2
|
|
85
|
+
q = c['quat']
|
|
86
|
+
p = c['pos']
|
|
87
|
+
wc = qrot(q, (cx * scx, cy * scy, cz * scz))
|
|
88
|
+
ctr = (p[0] + wc[0], p[1] + wc[1], p[2] + wc[2])
|
|
89
|
+
mn = [1e9] * 3
|
|
90
|
+
mx = [-1e9] * 3
|
|
91
|
+
for ix in (-1, 1):
|
|
92
|
+
for iy in (-1, 1):
|
|
93
|
+
for iz in (-1, 1):
|
|
94
|
+
v = qrot(q, (ix * hx, iy * hy, iz * hz))
|
|
95
|
+
for k in range(3):
|
|
96
|
+
w = ctr[k] + v[k]
|
|
97
|
+
mn[k] = min(mn[k], w)
|
|
98
|
+
mx[k] = max(mx[k], w)
|
|
99
|
+
out.append(dict(tag=c['tag'], name=c.get('name', ''), layer=c['layer'],
|
|
100
|
+
trigger=c['trigger'],
|
|
101
|
+
min=[round(v, 3) for v in mn], max=[round(v, 3) for v in mx]))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def merge(rects):
|
|
106
|
+
"""Fuse rectangles that share an edge, so the wall list stays short.
|
|
107
|
+
|
|
108
|
+
A fused rectangle keeps the first source's name and tag; the pieces being merged are runs of
|
|
109
|
+
one object's cross-section, so that is the object either way.
|
|
110
|
+
"""
|
|
111
|
+
rs = [dict(r) for r in rects]
|
|
112
|
+
changed = True
|
|
113
|
+
while changed:
|
|
114
|
+
changed = False
|
|
115
|
+
for i in range(len(rs)):
|
|
116
|
+
for j in range(i + 1, len(rs)):
|
|
117
|
+
a, b = rs[i], rs[j]
|
|
118
|
+
if (abs(a['y0'] - b['y0']) < 0.02 and abs(a['y1'] - b['y1']) < 0.02
|
|
119
|
+
and a['x0'] <= b['x1'] + 0.02 and b['x0'] <= a['x1'] + 0.02):
|
|
120
|
+
a['x0'] = min(a['x0'], b['x0'])
|
|
121
|
+
a['x1'] = max(a['x1'], b['x1'])
|
|
122
|
+
rs.pop(j)
|
|
123
|
+
changed = True
|
|
124
|
+
break
|
|
125
|
+
if (abs(a['x0'] - b['x0']) < 0.02 and abs(a['x1'] - b['x1']) < 0.02
|
|
126
|
+
and a['y0'] <= b['y1'] + 0.02 and b['y0'] <= a['y1'] + 0.02):
|
|
127
|
+
a['y0'] = min(a['y0'], b['y0'])
|
|
128
|
+
a['y1'] = max(a['y1'], b['y1'])
|
|
129
|
+
rs.pop(j)
|
|
130
|
+
changed = True
|
|
131
|
+
break
|
|
132
|
+
if changed:
|
|
133
|
+
break
|
|
134
|
+
return rs
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
colliders = json.load(open(os.path.join(SC, 'colliders.json')))
|
|
139
|
+
markers_path = os.path.join(SC, 'markers.json')
|
|
140
|
+
markers = json.load(open(markers_path)) if os.path.exists(markers_path) else []
|
|
141
|
+
|
|
142
|
+
boxes = to_aabb(colliders)
|
|
143
|
+
in_plane = [o for o in boxes if o['min'][2] <= PLANE_HI and o['max'][2] >= PLANE_LO]
|
|
144
|
+
|
|
145
|
+
terrain_layer = layer_index('Terrain')
|
|
146
|
+
if terrain_layer is None:
|
|
147
|
+
counts = collections.Counter(o['layer'] for o in in_plane if not o['trigger'])
|
|
148
|
+
terrain_layer = counts.most_common(1)[0][0] if counts else 0
|
|
149
|
+
print(f'warning: no TagManager under {UNITY_ROOT}; inferred terrain layer {terrain_layer}',
|
|
150
|
+
file=sys.stderr)
|
|
151
|
+
# Solid is Terrain plus Default — Unity's collision matrix is the default all-on, so a ship
|
|
152
|
+
# hits both, and a level's walls really can be spread across the two (in the origin game a
|
|
153
|
+
# base was enclosed by Default-layer facades, one of which carried its doorway).
|
|
154
|
+
#
|
|
155
|
+
# SHADOWS is never solid. Those are the low-poly stand-ins the original rendered only into its
|
|
156
|
+
# line-of-sight mask; they sit inside the geometry they stand in for, and making them solid
|
|
157
|
+
# seals a base shut. Unity records which copy is which as a per-instance m_Layer override, so
|
|
158
|
+
# this only became visible once those were honoured.
|
|
159
|
+
shadow_layer = layer_index('Shadows')
|
|
160
|
+
solid_layers = {terrain_layer, 0}
|
|
161
|
+
|
|
162
|
+
bases = [m for m in markers if m['kind'] == 'Base']
|
|
163
|
+
if bases:
|
|
164
|
+
bx = [b['pos'][0] for b in bases]
|
|
165
|
+
by = [b['pos'][1] for b in bases]
|
|
166
|
+
pad = 14
|
|
167
|
+
X0, X1 = min(bx) - pad, max(bx) + pad
|
|
168
|
+
Y0, Y1 = min(by) - pad, max(by) + pad
|
|
169
|
+
else:
|
|
170
|
+
xs = [o['min'][0] for o in in_plane] + [o['max'][0] for o in in_plane]
|
|
171
|
+
ys = [o['min'][1] for o in in_plane] + [o['max'][1] for o in in_plane]
|
|
172
|
+
X0, X1, Y0, Y1 = min(xs), max(xs), min(ys), max(ys)
|
|
173
|
+
|
|
174
|
+
def inside(o, pad=6):
|
|
175
|
+
return (o['max'][0] > X0 - pad and o['min'][0] < X1 + pad
|
|
176
|
+
and o['max'][1] > Y0 - pad and o['min'][1] < Y1 + pad)
|
|
177
|
+
|
|
178
|
+
def rect(o, pad=6):
|
|
179
|
+
return dict(x0=round(max(o['min'][0], X0 - pad), 2), y0=round(max(o['min'][1], Y0 - pad), 2),
|
|
180
|
+
x1=round(min(o['max'][0], X1 + pad), 2), y1=round(min(o['max'][1], Y1 + pad), 2))
|
|
181
|
+
|
|
182
|
+
def slab(o):
|
|
183
|
+
r = rect(o, 14)
|
|
184
|
+
r['z0'] = round(o['min'][2], 2)
|
|
185
|
+
r['z1'] = round(min(o['max'][2], 40), 2)
|
|
186
|
+
return r
|
|
187
|
+
|
|
188
|
+
walls = []
|
|
189
|
+
for o in boxes:
|
|
190
|
+
if o['trigger'] or o['tag'] in BAD_TAGS or o['layer'] not in solid_layers:
|
|
191
|
+
continue
|
|
192
|
+
if shadow_layer is not None and o['layer'] == shadow_layer:
|
|
193
|
+
continue
|
|
194
|
+
if not (o['min'][2] <= PLANE_HI and o['max'][2] >= PLANE_LO) or not inside(o):
|
|
195
|
+
continue
|
|
196
|
+
r = rect(o)
|
|
197
|
+
if r['x1'] - r['x0'] < 0.15 or r['y1'] - r['y0'] < 0.15:
|
|
198
|
+
continue
|
|
199
|
+
r['tag'] = o['tag'] or 'wall'
|
|
200
|
+
# The Unity GameObject this rectangle came from. Carried all the way into ArenaData so a
|
|
201
|
+
# wall the player hits can be named in the console instead of guessed at.
|
|
202
|
+
r['name'] = o.get('name') or ''
|
|
203
|
+
walls.append(r)
|
|
204
|
+
walls = merge(walls)
|
|
205
|
+
|
|
206
|
+
back = [slab(o) for o in boxes
|
|
207
|
+
if not o['trigger'] and o['tag'] not in BAD_TAGS and o['min'][2] > PLANE_HI and inside(o)
|
|
208
|
+
and (o['max'][0] - o['min'][0]) > 0.3 and (o['max'][1] - o['min'][1]) > 0.3]
|
|
209
|
+
fore = [slab(o) for o in boxes
|
|
210
|
+
if not o['trigger'] and o['tag'] not in BAD_TAGS and o['max'][2] < PLANE_LO and inside(o)
|
|
211
|
+
and (o['max'][0] - o['min'][0]) > 0.15 and (o['max'][1] - o['min'][1]) > 0.15]
|
|
212
|
+
|
|
213
|
+
# "Over land": the origin game's land check sphere-casts from the player along +Z with
|
|
214
|
+
# radius 0.25 for 3 units, against the TERRAIN layer. So a ship is over land wherever solid
|
|
215
|
+
# terrain sits within 3 units BEHIND the gameplay plane — the raised decks the level is flown
|
|
216
|
+
# over — not where a trigger volume is. The cast is masked to TERRAIN alone, so Default-layer
|
|
217
|
+
# decoration does not count. Reduced here to the XY footprint of every terrain box whose Z span
|
|
218
|
+
# the cast would reach, which is what `Arena.isOverLand` tests against.
|
|
219
|
+
LAND_CAST_RADIUS = 0.25
|
|
220
|
+
LAND_CAST_DISTANCE = 3.0
|
|
221
|
+
# the cast starts at the player, which is at PLANE_CENTER, not at zero
|
|
222
|
+
floors = [rect(o) for o in boxes
|
|
223
|
+
if not o['trigger'] and o['tag'] not in BAD_TAGS and o['layer'] == terrain_layer
|
|
224
|
+
and o['min'][2] <= PLANE_CENTER + LAND_CAST_DISTANCE + LAND_CAST_RADIUS
|
|
225
|
+
and o['max'][2] >= PLANE_CENTER - LAND_CAST_RADIUS
|
|
226
|
+
and inside(o)]
|
|
227
|
+
floors = merge(floors)
|
|
228
|
+
|
|
229
|
+
result = dict(walls=walls, plates=[], back=back, fore=fore, floors=floors,
|
|
230
|
+
bounds=dict(x0=round(X0, 2), y0=round(Y0, 2), x1=round(X1, 2), y1=round(Y1, 2)),
|
|
231
|
+
terrainLayer=terrain_layer)
|
|
232
|
+
json.dump(result, open(os.path.join(SC, 'arena_parts.json'), 'w'))
|
|
233
|
+
print(f"terrain layer {terrain_layer} | walls {len(walls)} | back {len(back)} | "
|
|
234
|
+
f"fore {len(fore)} | floors {len(floors)} | bases {len(bases)}", file=sys.stderr)
|
|
235
|
+
|
|
236
|
+
if '--map' in sys.argv:
|
|
237
|
+
W, H = 118, 46
|
|
238
|
+
grid = [[' '] * W for _ in range(H)]
|
|
239
|
+
|
|
240
|
+
def paint(items, ch):
|
|
241
|
+
for o in items:
|
|
242
|
+
for gx in range(W):
|
|
243
|
+
wx = X0 + (gx + 0.5) * (X1 - X0) / W
|
|
244
|
+
if not (o['x0'] <= wx <= o['x1']):
|
|
245
|
+
continue
|
|
246
|
+
for gy in range(H):
|
|
247
|
+
wy = Y0 + (gy + 0.5) * (Y1 - Y0) / H
|
|
248
|
+
if o['y0'] <= wy <= o['y1']:
|
|
249
|
+
grid[gy][gx] = ch
|
|
250
|
+
paint(back, '-')
|
|
251
|
+
paint(floors, '.')
|
|
252
|
+
paint(walls, '#')
|
|
253
|
+
for i, b in enumerate(bases):
|
|
254
|
+
gx = int((b['pos'][0] - X0) / (X1 - X0) * W)
|
|
255
|
+
gy = int((b['pos'][1] - Y0) / (Y1 - Y0) * H)
|
|
256
|
+
if 0 <= gx < W and 0 <= gy < H:
|
|
257
|
+
grid[gy][gx] = str((i + 1) % 10)
|
|
258
|
+
for row in reversed(grid):
|
|
259
|
+
print(''.join(row), file=sys.stderr)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == '__main__':
|
|
263
|
+
main()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Convert the original Unity level and ship models into GLBs the engine can load.
|
|
2
|
+
|
|
3
|
+
python3 tools/convert_assets.py [prefab name ...]
|
|
4
|
+
|
|
5
|
+
For each prefab: walk it for renderers (unity_renderers), resolve every material to its real
|
|
6
|
+
texture files (unity_assets), then hand the lot to Blender, which imports the source FBX models
|
|
7
|
+
and writes one GLB per prefab into build/glb/.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
|
|
17
|
+
from import_config import CFG as _CFG, unity_root as _unity_root # noqa: E402
|
|
18
|
+
UNITY = _unity_root()
|
|
19
|
+
ASSETS = os.path.join(UNITY, 'Assets')
|
|
20
|
+
BLENDER = '/Applications/Blender.app/Contents/MacOS/Blender'
|
|
21
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
22
|
+
PROJECT = os.path.dirname(HERE)
|
|
23
|
+
OUT_DIR = os.path.join(PROJECT, 'build', 'glb')
|
|
24
|
+
SPEC_DIR = os.path.join(PROJECT, 'build', 'specs')
|
|
25
|
+
|
|
26
|
+
# unity_renderers resolves the Unity project root from argv[1] at import time; take a copy of the
|
|
27
|
+
# real arguments first, since that import consumes them.
|
|
28
|
+
_ARGS = sys.argv[1:]
|
|
29
|
+
sys.argv = [sys.argv[0], os.path.join(ASSETS, 'Scenes')]
|
|
30
|
+
from fbx_axis import build_axis_map, permutation_for # noqa: E402
|
|
31
|
+
from fbx_bounds import read_fbx # noqa: E402
|
|
32
|
+
from unity_assets import build_guid_index, parse_material # noqa: E402
|
|
33
|
+
from unity_renderers import extract # noqa: E402
|
|
34
|
+
import unity_extract as _ue # noqa: E402
|
|
35
|
+
|
|
36
|
+
# Prefabs to convert when not run in scene mode (`prefabs` in unity-import.json). Marker-only
|
|
37
|
+
# prefabs (spawners, force walls) are Unity primitives with no
|
|
38
|
+
# model of their own, so they stay procedural.
|
|
39
|
+
PREFABS = list(_CFG['prefabs'])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# The arena is 68 units across; a few stray prefab copies sit far outside it in the scene file
|
|
43
|
+
# and are never seen in play.
|
|
44
|
+
ARENA_LIMIT = float(_CFG['arenaLimit'])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_scene(name):
|
|
48
|
+
path = os.path.join(ASSETS, 'Scenes', name + '.unity')
|
|
49
|
+
return path if os.path.exists(path) else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_prefab(name):
|
|
53
|
+
for base, _dirs, files in os.walk(ASSETS):
|
|
54
|
+
if name + '.prefab' in files:
|
|
55
|
+
return os.path.join(base, name + '.prefab')
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_meta_cache = {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def mesh_name_for(fbx, file_id):
|
|
63
|
+
"""Which mesh inside a multi-mesh FBX a Unity fileID refers to.
|
|
64
|
+
|
|
65
|
+
Unity records the mapping in the model's .meta, under either the modern
|
|
66
|
+
internalIDToNameTable or the older fileIDToRecycleName.
|
|
67
|
+
"""
|
|
68
|
+
if fbx not in _meta_cache:
|
|
69
|
+
table = {}
|
|
70
|
+
try:
|
|
71
|
+
with open(fbx + '.meta', encoding='utf-8', errors='ignore') as fh:
|
|
72
|
+
text = fh.read()
|
|
73
|
+
except OSError:
|
|
74
|
+
text = ''
|
|
75
|
+
for m in re.finditer(r'second: (-?\d+)\s*\n\s*second: (.+)', text):
|
|
76
|
+
table[m.group(1)] = m.group(2).strip()
|
|
77
|
+
for m in re.finditer(r'- first:\s*\n\s*(?:\d+): (-?\d+)\s*\n\s*second: (.+)', text):
|
|
78
|
+
table[m.group(1)] = m.group(2).strip()
|
|
79
|
+
for m in re.finditer(r'^\s{4}(-?\d+): (.+)$', text, re.M):
|
|
80
|
+
table[m.group(1)] = m.group(2).strip()
|
|
81
|
+
_meta_cache[fbx] = table
|
|
82
|
+
name = _meta_cache[fbx].get(str(file_id))
|
|
83
|
+
if not name or name.startswith('//'):
|
|
84
|
+
return None
|
|
85
|
+
return name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def slug(name):
|
|
89
|
+
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
wanted = _ARGS if _ARGS else PREFABS
|
|
94
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
95
|
+
os.makedirs(SPEC_DIR, exist_ok=True)
|
|
96
|
+
print('indexing Unity GUIDs...')
|
|
97
|
+
guid_index = build_guid_index(ASSETS)
|
|
98
|
+
print(f' {len(guid_index)} assets')
|
|
99
|
+
# Unity's mesh axes are a per-pack permutation of the raw FBX axes; derive it from Unity's own
|
|
100
|
+
# BoxCollider sizes so the GLB and the extracted colliders agree.
|
|
101
|
+
print('deriving FBX axis corrections...')
|
|
102
|
+
axis_map = build_axis_map(_ue, ASSETS, verbose=True)
|
|
103
|
+
|
|
104
|
+
manifest = []
|
|
105
|
+
for name in wanted:
|
|
106
|
+
is_scene = name.startswith('scene:')
|
|
107
|
+
if is_scene:
|
|
108
|
+
label = name[6:]
|
|
109
|
+
path = find_scene(label)
|
|
110
|
+
if not path:
|
|
111
|
+
print(f'!! {label}: no scene found')
|
|
112
|
+
continue
|
|
113
|
+
else:
|
|
114
|
+
label = name
|
|
115
|
+
path = find_prefab(name)
|
|
116
|
+
if not path:
|
|
117
|
+
print(f'!! {name}: no prefab found')
|
|
118
|
+
continue
|
|
119
|
+
renderers = extract(path)
|
|
120
|
+
if is_scene:
|
|
121
|
+
renderers = [r for r in renderers
|
|
122
|
+
if abs(r['pos'][0]) <= ARENA_LIMIT and abs(r['pos'][1]) <= ARENA_LIMIT]
|
|
123
|
+
# Layer 18 is Unity's SHADOWS layer: low-poly stand-ins the original rendered only into
|
|
124
|
+
# the line-of-sight mask, never to the screen.
|
|
125
|
+
visible = [r for r in renderers if r.get('layer') != 18]
|
|
126
|
+
if not visible:
|
|
127
|
+
print(f'-- {label}: nothing visible')
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
mat_paths = sorted({m for r in visible for m in r['materials']})
|
|
131
|
+
materials = []
|
|
132
|
+
for mp in mat_paths:
|
|
133
|
+
parsed = parse_material(mp, guid_index)
|
|
134
|
+
if parsed:
|
|
135
|
+
parsed['path'] = mp
|
|
136
|
+
materials.append(parsed)
|
|
137
|
+
|
|
138
|
+
for r in visible:
|
|
139
|
+
r['meshName'] = mesh_name_for(r['fbx'], r['meshFileId'])
|
|
140
|
+
|
|
141
|
+
out_glb = os.path.join(OUT_DIR, slug(label) + '.glb')
|
|
142
|
+
axis = {}
|
|
143
|
+
geometric = {}
|
|
144
|
+
for r in visible:
|
|
145
|
+
perm, signs = permutation_for(r['fbx'], ASSETS, axis_map)
|
|
146
|
+
axis[r['fbx']] = {'perm': list(perm), 'signs': list(signs)}
|
|
147
|
+
if r['fbx'] not in geometric:
|
|
148
|
+
# The Model node's geometric transform, per mesh, in the file's own units. Unity
|
|
149
|
+
# bakes this into the mesh; Blender folds it into the object matrix we discard, so
|
|
150
|
+
# the GLB build has to apply it to the mesh data itself.
|
|
151
|
+
try:
|
|
152
|
+
ms, _us = read_fbx(r['fbx'])
|
|
153
|
+
except Exception:
|
|
154
|
+
ms = None
|
|
155
|
+
geometric[r['fbx']] = [
|
|
156
|
+
({k: list(v) for k, v in e[5].items() if k != '_model'} if (ms and e[5]) else None)
|
|
157
|
+
for e in (ms or [])
|
|
158
|
+
]
|
|
159
|
+
spec = {'name': label, 'out': out_glb, 'renderers': visible, 'materials': materials,
|
|
160
|
+
'axis': axis, 'geometric': geometric}
|
|
161
|
+
spec_path = os.path.join(SPEC_DIR, slug(label) + '.json')
|
|
162
|
+
with open(spec_path, 'w') as fh:
|
|
163
|
+
json.dump(spec, fh)
|
|
164
|
+
|
|
165
|
+
print(f'>> {label}: {len(visible)} renderers, {len(materials)} materials -> blender')
|
|
166
|
+
res = subprocess.run(
|
|
167
|
+
[BLENDER, '--background', '--python', os.path.join(HERE, 'blender_build.py'),
|
|
168
|
+
'--', spec_path],
|
|
169
|
+
capture_output=True, text=True,
|
|
170
|
+
)
|
|
171
|
+
tail = [ln for ln in res.stdout.splitlines()
|
|
172
|
+
if ln.startswith(('PLACED', 'EXPORTED', 'FBXFAIL'))]
|
|
173
|
+
for ln in tail:
|
|
174
|
+
print(' ', ln)
|
|
175
|
+
if not os.path.exists(out_glb):
|
|
176
|
+
print(' !! no GLB produced')
|
|
177
|
+
print(res.stdout[-2500:])
|
|
178
|
+
print(res.stderr[-2500:])
|
|
179
|
+
continue
|
|
180
|
+
manifest.append({'name': label, 'glb': out_glb, 'size': os.path.getsize(out_glb)})
|
|
181
|
+
|
|
182
|
+
print()
|
|
183
|
+
total = 0
|
|
184
|
+
for m in manifest:
|
|
185
|
+
print(f" {m['size']/1e6:7.2f} MB {os.path.basename(m['glb'])}")
|
|
186
|
+
total += m['size']
|
|
187
|
+
print(f" {total/1e6:7.2f} MB TOTAL")
|
|
188
|
+
with open(os.path.join(OUT_DIR, 'manifest.json'), 'w') as fh:
|
|
189
|
+
json.dump(manifest, fh, indent=1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == '__main__':
|
|
193
|
+
main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Reader for ASCII FBX 6100 — an old text format some model packs still use (the origin game's ship packs, for one).
|
|
2
|
+
|
|
3
|
+
Blender dropped ASCII FBX support, and all three ship packs were exported from Blender 2.71 in
|
|
4
|
+
FBX 6.1.0 text form, so `import_scene.fbx` refuses them outright. The format is plain text and the
|
|
5
|
+
meshes are simple (one geometry, one material, no rig), so it is cheaper to read the handful of
|
|
6
|
+
arrays that matter than to bring in a converter.
|
|
7
|
+
|
|
8
|
+
Returns meshes in FBX axes and units; the caller converts. `blender_build.py` maps them the same
|
|
9
|
+
way Blender's own importer maps a binary FBX, which measurement showed to be (x, z, y) scaled by
|
|
10
|
+
the file's UnitScaleFactor/100 — no sign flips.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# A value block runs from `Key:` to the next key or the closing brace of its parent.
|
|
15
|
+
_UNTIL_NEXT_KEY = r'(?=\n\s*[A-Za-z_][\w ]*:|\n\s*\})'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _array(text, key):
|
|
19
|
+
m = re.search(r'\n\s*' + re.escape(key) + r':[ \t]*(.*?)' + _UNTIL_NEXT_KEY, text, re.S)
|
|
20
|
+
if not m:
|
|
21
|
+
return []
|
|
22
|
+
out = []
|
|
23
|
+
for tok in m.group(1).split(','):
|
|
24
|
+
tok = tok.strip()
|
|
25
|
+
if not tok:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
out.append(float(tok))
|
|
29
|
+
except ValueError:
|
|
30
|
+
pass
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _string(text, key):
|
|
35
|
+
m = re.search(r'\n\s*' + re.escape(key) + r':[ \t]*"([^"]*)"', text)
|
|
36
|
+
return m.group(1) if m else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _blocks(text, header_re):
|
|
40
|
+
"""Every `Name ... {` block matching header_re, sliced by brace depth."""
|
|
41
|
+
for m in re.finditer(header_re, text):
|
|
42
|
+
start = text.find('{', m.end() - 1)
|
|
43
|
+
if start < 0:
|
|
44
|
+
continue
|
|
45
|
+
depth = 0
|
|
46
|
+
for i in range(start, len(text)):
|
|
47
|
+
c = text[i]
|
|
48
|
+
if c == '{':
|
|
49
|
+
depth += 1
|
|
50
|
+
elif c == '}':
|
|
51
|
+
depth -= 1
|
|
52
|
+
if depth == 0:
|
|
53
|
+
yield m, text[start + 1:i]
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prop(text, name, count):
|
|
58
|
+
"""A Properties60 entry: Property: "Lcl Translation", "...", "A+",x,y,z"""
|
|
59
|
+
m = re.search(r'Property:\s*"' + re.escape(name) + r'"\s*,[^,]*,[^,]*,(.*)', text)
|
|
60
|
+
if not m:
|
|
61
|
+
return None
|
|
62
|
+
vals = []
|
|
63
|
+
for tok in m.group(1).split(','):
|
|
64
|
+
tok = tok.strip()
|
|
65
|
+
try:
|
|
66
|
+
vals.append(float(tok))
|
|
67
|
+
except ValueError:
|
|
68
|
+
pass
|
|
69
|
+
return vals[:count] if len(vals) >= count else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unit_scale(text):
|
|
73
|
+
v = _prop(text, 'UnitScaleFactor', 1)
|
|
74
|
+
return v[0] if v else 1.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_ascii_fbx(path):
|
|
78
|
+
"""Parse the mesh models out of an ASCII FBX. Returns (meshes, unit_scale_factor)."""
|
|
79
|
+
with open(path, encoding='utf-8', errors='ignore') as fh:
|
|
80
|
+
text = fh.read()
|
|
81
|
+
if 'Kaydara' in text[:64]:
|
|
82
|
+
raise ValueError('binary FBX passed to the ASCII reader')
|
|
83
|
+
|
|
84
|
+
meshes = []
|
|
85
|
+
seen = set()
|
|
86
|
+
for m, body in _blocks(text, r'\n\s*Model:\s*"Model::([^"]+)"\s*,\s*"Mesh"\s*'):
|
|
87
|
+
name = m.group(1)
|
|
88
|
+
verts = _array(body, 'Vertices')
|
|
89
|
+
idx = _array(body, 'PolygonVertexIndex')
|
|
90
|
+
# The Relations section repeats every Model header with an empty body.
|
|
91
|
+
if not verts or not idx or name in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(name)
|
|
94
|
+
|
|
95
|
+
polys = []
|
|
96
|
+
cur = []
|
|
97
|
+
for raw in idx:
|
|
98
|
+
i = int(raw)
|
|
99
|
+
if i < 0:
|
|
100
|
+
cur.append(-i - 1)
|
|
101
|
+
if len(cur) >= 3:
|
|
102
|
+
polys.append(cur)
|
|
103
|
+
cur = []
|
|
104
|
+
else:
|
|
105
|
+
cur.append(i)
|
|
106
|
+
|
|
107
|
+
normals = []
|
|
108
|
+
nmap = nref = None
|
|
109
|
+
for _nm, nbody in _blocks(body, r'\n\s*LayerElementNormal:\s*\d+\s*'):
|
|
110
|
+
normals = _array(nbody, 'Normals')
|
|
111
|
+
nmap = _string(nbody, 'MappingInformationType')
|
|
112
|
+
nref = _string(nbody, 'ReferenceInformationType')
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
uvs = []
|
|
116
|
+
uv_index = []
|
|
117
|
+
umap = uref = None
|
|
118
|
+
for _um, ubody in _blocks(body, r'\n\s*LayerElementUV:\s*\d+\s*'):
|
|
119
|
+
uvs = _array(ubody, 'UV')
|
|
120
|
+
uv_index = [int(v) for v in _array(ubody, 'UVIndex')]
|
|
121
|
+
umap = _string(ubody, 'MappingInformationType')
|
|
122
|
+
uref = _string(ubody, 'ReferenceInformationType')
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
meshes.append({
|
|
126
|
+
'name': name,
|
|
127
|
+
'verts': [(verts[i], verts[i + 1], verts[i + 2]) for i in range(0, len(verts) - 2, 3)],
|
|
128
|
+
'polys': polys,
|
|
129
|
+
'normals': normals,
|
|
130
|
+
'normalMapping': nmap,
|
|
131
|
+
'normalReference': nref,
|
|
132
|
+
'uvs': uvs,
|
|
133
|
+
'uvIndex': uv_index,
|
|
134
|
+
'uvMapping': umap,
|
|
135
|
+
'uvReference': uref,
|
|
136
|
+
'translation': _prop(body, 'Lcl Translation', 3) or [0.0, 0.0, 0.0],
|
|
137
|
+
'rotation': _prop(body, 'Lcl Rotation', 3) or [0.0, 0.0, 0.0],
|
|
138
|
+
'scaling': _prop(body, 'Lcl Scaling', 3) or [1.0, 1.0, 1.0],
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return meshes, unit_scale(text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == '__main__':
|
|
145
|
+
import sys
|
|
146
|
+
ms, us = read_ascii_fbx(sys.argv[1])
|
|
147
|
+
print(f'unit scale {us}, {len(ms)} meshes')
|
|
148
|
+
for mesh in ms:
|
|
149
|
+
vs = mesh['verts']
|
|
150
|
+
if not vs:
|
|
151
|
+
continue
|
|
152
|
+
mn = [min(v[i] for v in vs) for i in range(3)]
|
|
153
|
+
mx = [max(v[i] for v in vs) for i in range(3)]
|
|
154
|
+
print(f" {mesh['name']}: {len(vs)} verts, {len(mesh['polys'])} polys, "
|
|
155
|
+
f"uv={len(mesh['uvs'])//2} ({mesh['uvMapping']}/{mesh['uvReference']}), "
|
|
156
|
+
f"bounds {[round(a,2) for a in mn]}..{[round(a,2) for a in mx]}")
|