@bitmagic/cli 0.1.61-dev.9 → 0.1.62-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -3
  2. package/assets/unity-import/README.md +13 -0
  3. package/assets/unity-import/blender_build.py +462 -0
  4. package/assets/unity-import/build_parts.py +263 -0
  5. package/assets/unity-import/convert_assets.py +193 -0
  6. package/assets/unity-import/fbx_ascii.py +156 -0
  7. package/assets/unity-import/fbx_axis.py +174 -0
  8. package/assets/unity-import/fbx_bounds.py +202 -0
  9. package/assets/unity-import/import_config.py +86 -0
  10. package/assets/unity-import/level_patches.example.json +23 -0
  11. package/assets/unity-import/mesh_walls.py +78 -0
  12. package/assets/unity-import/render_topdown.py +33 -0
  13. package/assets/unity-import/unity-import.example.json +27 -0
  14. package/assets/unity-import/unity_assets.py +114 -0
  15. package/assets/unity-import/unity_colliders2d.py +127 -0
  16. package/assets/unity-import/unity_extract.py +774 -0
  17. package/assets/unity-import/unity_markers.py +249 -0
  18. package/assets/unity-import/unity_renderers.py +279 -0
  19. package/assets/unity-import/write_arena_data.py +198 -0
  20. package/dist/cli.d.ts +12 -0
  21. package/dist/commands/import.d.ts +4 -0
  22. package/dist/commands/import.js +5 -0
  23. package/dist/commands/import.js.map +1 -1
  24. package/dist/commands/init.d.ts +4 -0
  25. package/dist/commands/init.js +20 -0
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/upgrade.d.ts +4 -0
  28. package/dist/commands/upgrade.js +31 -5
  29. package/dist/commands/upgrade.js.map +1 -1
  30. package/dist/editor/server.js +3 -3
  31. package/dist/editor/shell-page.js +16 -1
  32. package/dist/editor/shell-page.js.map +1 -1
  33. package/dist/project/context.d.ts +9 -0
  34. package/dist/project/context.js +4 -1
  35. package/dist/project/context.js.map +1 -1
  36. package/dist/scaffold/agent-targets.d.ts +126 -0
  37. package/dist/scaffold/agent-targets.js +180 -0
  38. package/dist/scaffold/agent-targets.js.map +1 -0
  39. package/dist/scaffold/agents-md.d.ts +4 -1
  40. package/dist/scaffold/agents-md.js +6 -2
  41. package/dist/scaffold/agents-md.js.map +1 -1
  42. package/dist/scaffold/project-files.d.ts +40 -5
  43. package/dist/scaffold/project-files.js +67 -25
  44. package/dist/scaffold/project-files.js.map +1 -1
  45. package/dist/scaffold/project.d.ts +42 -15
  46. package/dist/scaffold/project.js +100 -38
  47. package/dist/scaffold/project.js.map +1 -1
  48. package/dist/scaffold/reference-guidance.d.ts +1 -1
  49. package/dist/scaffold/reference-guidance.js +2 -1
  50. package/dist/scaffold/reference-guidance.js.map +1 -1
  51. package/dist/scaffold/skill-files.d.ts +32 -9
  52. package/dist/scaffold/skill-files.js +71 -15
  53. package/dist/scaffold/skill-files.js.map +1 -1
  54. package/dist/scaffold/unity-skill-files.d.ts +30 -0
  55. package/dist/scaffold/unity-skill-files.js +474 -0
  56. package/dist/scaffold/unity-skill-files.js.map +1 -0
  57. package/dist/scaffold/upgrade-project.d.ts +31 -11
  58. package/dist/scaffold/upgrade-project.js +35 -23
  59. package/dist/scaffold/upgrade-project.js.map +1 -1
  60. package/package.json +4 -4
@@ -0,0 +1,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]}")