@bitmagic/cli 0.1.61 → 0.1.62-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +27 -3
  2. package/assets/unity-import/README.md +13 -0
  3. package/assets/unity-import/blender_build.py +462 -0
  4. package/assets/unity-import/build_parts.py +263 -0
  5. package/assets/unity-import/convert_assets.py +193 -0
  6. package/assets/unity-import/fbx_ascii.py +156 -0
  7. package/assets/unity-import/fbx_axis.py +174 -0
  8. package/assets/unity-import/fbx_bounds.py +202 -0
  9. package/assets/unity-import/import_config.py +86 -0
  10. package/assets/unity-import/level_patches.example.json +23 -0
  11. package/assets/unity-import/mesh_walls.py +78 -0
  12. package/assets/unity-import/render_topdown.py +33 -0
  13. package/assets/unity-import/unity-import.example.json +27 -0
  14. package/assets/unity-import/unity_assets.py +114 -0
  15. package/assets/unity-import/unity_colliders2d.py +127 -0
  16. package/assets/unity-import/unity_extract.py +774 -0
  17. package/assets/unity-import/unity_markers.py +249 -0
  18. package/assets/unity-import/unity_renderers.py +279 -0
  19. package/assets/unity-import/write_arena_data.py +198 -0
  20. package/dist/cli.d.ts +12 -0
  21. package/dist/commands/import.d.ts +4 -0
  22. package/dist/commands/import.js +5 -0
  23. package/dist/commands/import.js.map +1 -1
  24. package/dist/commands/init.d.ts +4 -0
  25. package/dist/commands/init.js +20 -0
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/upgrade.d.ts +4 -0
  28. package/dist/commands/upgrade.js +31 -5
  29. package/dist/commands/upgrade.js.map +1 -1
  30. package/dist/editor/server.js +3 -3
  31. package/dist/editor/shell-page.js +16 -1
  32. package/dist/editor/shell-page.js.map +1 -1
  33. package/dist/project/context.d.ts +9 -0
  34. package/dist/project/context.js +4 -1
  35. package/dist/project/context.js.map +1 -1
  36. package/dist/scaffold/agent-targets.d.ts +126 -0
  37. package/dist/scaffold/agent-targets.js +180 -0
  38. package/dist/scaffold/agent-targets.js.map +1 -0
  39. package/dist/scaffold/agents-md.d.ts +4 -1
  40. package/dist/scaffold/agents-md.js +6 -2
  41. package/dist/scaffold/agents-md.js.map +1 -1
  42. package/dist/scaffold/project-files.d.ts +40 -5
  43. package/dist/scaffold/project-files.js +67 -25
  44. package/dist/scaffold/project-files.js.map +1 -1
  45. package/dist/scaffold/project.d.ts +42 -15
  46. package/dist/scaffold/project.js +100 -38
  47. package/dist/scaffold/project.js.map +1 -1
  48. package/dist/scaffold/reference-guidance.d.ts +1 -1
  49. package/dist/scaffold/reference-guidance.js +2 -1
  50. package/dist/scaffold/reference-guidance.js.map +1 -1
  51. package/dist/scaffold/skill-files.d.ts +32 -9
  52. package/dist/scaffold/skill-files.js +71 -15
  53. package/dist/scaffold/skill-files.js.map +1 -1
  54. package/dist/scaffold/unity-skill-files.d.ts +30 -0
  55. package/dist/scaffold/unity-skill-files.js +474 -0
  56. package/dist/scaffold/unity-skill-files.js.map +1 -0
  57. package/dist/scaffold/upgrade-project.d.ts +31 -11
  58. package/dist/scaffold/upgrade-project.js +35 -23
  59. package/dist/scaffold/upgrade-project.js.map +1 -1
  60. package/package.json +4 -4
package/README.md CHANGED
@@ -406,11 +406,14 @@ my-game/
406
406
  engine/ # vendored engine, read-only, replaced wholesale by `bitmagic upgrade`
407
407
  LICENSE.md # the engine's licence — see Licensing below
408
408
  THIRD-PARTY-NOTICES.md # three.js, Rapier and the rest
409
- AGENTS.md # guidance for the agent editing this project
409
+ AGENTS.md # guidance for the agent editing this project — read by Cursor, Codex and the rest
410
+ CLAUDE.md # just `@AGENTS.md`, because Claude Code does not read AGENTS.md itself
410
411
  GAME-DESIGN.md # what the game is — yours to write; bitmagic cover reads it
412
+ .agents/skills/ # the shipped skills — Cursor, Codex, Copilot, Cline, OpenCode read this
411
413
  .claude/
412
414
  settings.json # yours; seeded with a Stop hook that runs `bitmagic reload`
413
- skills/ # generating-assets, making-a-trailer, … are shipped by the CLI; your own are untouched
415
+ skills/ # the same shipped skills, where Claude Code looks; your own are untouched
416
+ .kiro/ # only with `--agent kiro`: skills/ plus a steering file pointing at AGENTS.md
414
417
  trailer/
415
418
  shots.json # the trailer's cut — which moments, how long, the end card; bitmagic trailer make re-cuts from it
416
419
  .bitmagic/
@@ -1332,7 +1335,8 @@ refused is a bundle that is not a Bitmagic engine build at all (exit 1).
1332
1335
  ## Upgrading
1333
1336
 
1334
1337
  `bitmagic upgrade` refreshes the vendored `engine/`, the generated config files, `index.html`, and
1335
- **the skills this CLI ships** (`.claude/skills/`) to the currently published engine version. It
1338
+ **the skills this CLI ships** — in every skills directory the project holds — to the currently
1339
+ published engine version. It
1336
1340
  refuses to run against a dirty git working tree unless `--force`, so the upgrade always shows up as
1337
1341
  its own reviewable diff. When the engine version actually moves it also checks npm for a newer CLI —
1338
1342
  see [Staying current](#staying-current).
@@ -1366,6 +1370,26 @@ that cannot discover anything added since — which is exactly what happened whe
1366
1370
  shipped. The cost is that edits to *our* skill files are lost on upgrade; put your own guidance in
1367
1371
  `AGENTS.md` or a skill of your own, both of which are left alone.
1368
1372
 
1373
+ ### Which agent your project is set up for
1374
+
1375
+ Agents do not agree on where project skills live, so `bitmagic init` writes the shipped set into
1376
+ `.agents/skills/` — read by Cursor, Codex, GitHub Copilot, Cline and OpenCode — **and**
1377
+ `.claude/skills/`, whatever agent you used. Both are written every time and both are refreshed by
1378
+ `upgrade`, so switching agents costs you nothing.
1379
+
1380
+ Kiro is the exception, because an unasked-for `.kiro/` is just clutter: pass `--agent kiro` to
1381
+ `init` (or to `upgrade` on an existing project) and it also writes `.kiro/skills/` and
1382
+ `.kiro/steering/bitmagic.md`, which points Kiro at `AGENTS.md` — the one supported agent that does
1383
+ not read it on its own. The choice is recorded as `agents` in `bitmagic.json`, so later upgrades
1384
+ refresh the same set without the flag. `--agent` only ever ADDS; nothing is removed.
1385
+
1386
+ `--agent cursor` and `--agent codex` are accepted and reported as doing nothing, because both read
1387
+ `AGENTS.md` and `.agents/skills/` and your project already has them.
1388
+
1389
+ For an agent with no entry of its own, install the skills into its directory yourself from inside
1390
+ the project — `npx skills add .agents/skills -a <agent>` covers around seventy of them — bearing in
1391
+ mind that `bitmagic upgrade` will not refresh what that puts there.
1392
+
1369
1393
  Where an upgrade leaves a project unable to use something the CLI can now do, it says so rather
1370
1394
  than fixing it silently: a project with no `GAME-DESIGN.md` is told what to write and that
1371
1395
  `bitmagic cover` needs it.
@@ -0,0 +1,13 @@
1
+ # unity-import
2
+
3
+ The Python and Blender scripts behind the `converting-unity-levels` skill (see
4
+ `cli/src/scaffold/unity-skill-files.ts`). They read Unity scene and prefab YAML, resolve FBX
5
+ meshes, materials and colliders, rebuild the level in Blender as one GLB, and emit collider data.
6
+ Shipped here (package.json `files`) rather than rendered into every project: most games never port
7
+ a Unity level, and these are large. The skill's step 0 tells an agent how to copy them into a
8
+ project's `tools/`.
9
+
10
+ Nothing game-specific is in the code: every per-project value (Unity tree, scene, play plane,
11
+ marker script names, wall families, output paths) comes from `tools/unity-import.json`, read by
12
+ `import_config.py`; `unity-import.example.json` is the template. The intended end state is a
13
+ `bitmagic import unity` command wrapping these once a second game has been through them.
@@ -0,0 +1,462 @@
1
+ """Runs INSIDE Blender. Rebuilds a Unity prefab from its source FBX models and exports one GLB.
2
+
3
+ blender --background --python tools/blender_build.py -- spec.json
4
+
5
+ The spec (written by convert_assets.py) lists every renderer the prefab places: which FBX and
6
+ which mesh inside it, the resolved Unity material, and the world transform. This script imports
7
+ each FBX once, then instances the mesh datablock per renderer so a wall repeated 180 times costs
8
+ one mesh, not 180.
9
+
10
+ Coordinates: Blender's FBX importer lands Unity's models at (x, z, y) — Y and Z swapped — and the
11
+ glTF exporter maps Blender (x, y, z) back to (x, z, -y). The round trip therefore delivers
12
+ (unity_x, unity_y, -unity_z), which is exactly the convention Arena.ts already uses for depth. So
13
+ transforms are converted into Blender's basis here and need no fixing up on the JavaScript side.
14
+ """
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ import math
20
+ import bpy
21
+ from mathutils import Matrix, Quaternion, Vector
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+ from fbx_ascii import read_ascii_fbx # noqa: E402
25
+
26
+ # ---------------------------------------------------------------- coordinates
27
+
28
+ def to_blender_pos(p):
29
+ return Vector((p[0], p[2], p[1]))
30
+
31
+
32
+ def to_blender_quat(q):
33
+ """Swapping two axes is a reflection, so conjugating by it reverses the sense of rotation:
34
+ the axis maps (x, y, z) -> (x, z, y) and the angle negates."""
35
+ return Quaternion((q[3], -q[0], -q[2], -q[1]))
36
+
37
+
38
+ def to_blender_scale(s):
39
+ return Vector((s[0], s[2], s[1]))
40
+
41
+
42
+ # The same axis swap, as a matrix, for baking into mesh data. Its determinant is -1 — Unity is
43
+ # left-handed and Blender is not — so every mesh it touches needs its winding reversed afterwards
44
+ # or the faces end up inside out.
45
+ UNITY_TO_BLENDER = Matrix(((1, 0, 0, 0),
46
+ (0, 0, 1, 0),
47
+ (0, 1, 0, 0),
48
+ (0, 0, 0, 1)))
49
+
50
+
51
+ def permutation_matrix(perm, signs):
52
+ """Raw FBX axes -> Unity mesh axes. `unity[i] = signs[i] * raw[perm[i]]`.
53
+
54
+ The signs are not decoration: Unity negates X converting a right-handed FBX into its own
55
+ left-handed space, so a transform without them mirrors every mesh. That is invisible on a
56
+ symmetric wall panel and very visible once those panels are assembled into a structure.
57
+ """
58
+ m = Matrix.Identity(4)
59
+ for i in range(3):
60
+ for j in range(3):
61
+ m[i][j] = float(signs[i]) if j == perm[i] else 0.0
62
+ return m
63
+
64
+
65
+ def geometric_matrix(g):
66
+ """An FBX Model node's geometric transform as a 4x4, in the file's own units.
67
+
68
+ Scaling, then an XYZ Euler rotation (Rz*Ry*Rx, degrees), then translation — the FBX SDK's
69
+ definition, and the one Unity bakes into the mesh. This is what the raw vertex array is
70
+ missing: Blender folds it into the object matrix, which this build deliberately discards.
71
+ """
72
+ if not g:
73
+ return Matrix.Identity(4)
74
+ sc = g.get('GeometricScaling', (1.0, 1.0, 1.0))
75
+ rot = g.get('GeometricRotation', (0.0, 0.0, 0.0))
76
+ tr = g.get('GeometricTranslation', (0.0, 0.0, 0.0))
77
+ m = Matrix.Diagonal((sc[0], sc[1], sc[2], 1.0))
78
+ rx = Matrix.Rotation(math.radians(rot[0]), 4, 'X')
79
+ ry = Matrix.Rotation(math.radians(rot[1]), 4, 'Y')
80
+ rz = Matrix.Rotation(math.radians(rot[2]), 4, 'Z')
81
+ return Matrix.Translation(tr) @ rz @ ry @ rx @ m
82
+
83
+
84
+ def bake_to_blender(mesh, matrix_world, perm, signs, geometric=None):
85
+ """Put a mesh into Blender's basis, from the raw FBX vertices, once and for all.
86
+
87
+ Three things happen here, and all three were learned the hard way:
88
+
89
+ * Only the import SCALE is taken from the object. An FBX node's own transform becomes a
90
+ GameObject Transform on Unity's side, and the prefab walk already carries that Transform in
91
+ the matrix each instance is placed with; baking it here too applies it twice.
92
+ * `perm` corrects the raw FBX axes to the ones Unity actually stores. It is not the same for
93
+ every asset pack and cannot be read off the file's declared up-axis — `fbx_axis.py` derives it
94
+ from Unity's own BoxCollider sizes. Without it one 3ds Max kit's models, which were the whole
95
+ arena, arrive rotated: floors stand on edge and walls lie flat.
96
+ * `UNITY_TO_BLENDER` then swaps into Blender's basis, which mirrors, so the winding is reversed.
97
+ """
98
+ sc = matrix_world.to_scale()
99
+ # Blender folds the node's GeometricScaling into the object scale it imports, so `sc` is
100
+ # (unit scale x GeometricScaling). The geometric matrix below applies that scaling itself —
101
+ # taking `sc` as-is applied it twice, which is why the centre statue (GeometricScaling 0.28,
102
+ # a statue pack) came out at a quarter of its size while the kit pieces, whose
103
+ # GeometricScaling is 1, were unaffected. Divide it back out: what remains is the unit scale
104
+ # Unity applies with useFileScale (2.54 -> 0.0254 for that file), and the node's own Lcl
105
+ # scaling, which is 1 across these packs and which Unity ignores for a mesh anyway.
106
+ if geometric:
107
+ gs = geometric.get('GeometricScaling', (1.0, 1.0, 1.0))
108
+ sc = Vector((sc.x / gs[0] if abs(gs[0]) > 1e-9 else sc.x,
109
+ sc.y / gs[1] if abs(gs[1]) > 1e-9 else sc.y,
110
+ sc.z / gs[2] if abs(gs[2]) > 1e-9 else sc.z))
111
+ # Geometric transform is in file units, so it goes on BEFORE the import scale.
112
+ total = (UNITY_TO_BLENDER @ permutation_matrix(perm, signs)
113
+ @ Matrix.Diagonal((sc.x, sc.y, sc.z, 1.0)) @ geometric_matrix(geometric))
114
+ mesh.transform(total)
115
+ # Winding reverses once per reflection. UNITY_TO_BLENDER is one; a negated axis in the
116
+ # permutation is another, and two of them cancel — so flip only when the total is a mirror.
117
+ if total.to_3x3().determinant() < 0:
118
+ mesh.flip_normals()
119
+
120
+
121
+ # ---------------------------------------------------------------- textures
122
+
123
+ # The source art is 2048x2048 TIFF, which is far more than a web build can carry and far more
124
+ # than a top-down game shows: the level is lit by one moving point light, most surfaces are seen at a
125
+ # distance, and the whole thing renders in the dark. Masks carry no visible detail of their own,
126
+ # so they drop further than colour and normals do.
127
+ MAX_TEX = {'baseColor': 512, 'normal': 512, 'mask': 256, 'emissive': 256}
128
+
129
+ _img_cache = {}
130
+
131
+
132
+ def load_image(path, non_color, slot=None):
133
+ key = (path, non_color)
134
+ if key in _img_cache:
135
+ return _img_cache[key]
136
+ try:
137
+ img = bpy.data.images.load(path, check_existing=True)
138
+ except RuntimeError:
139
+ _img_cache[key] = None
140
+ return None
141
+ if non_color:
142
+ img.colorspace_settings.name = 'Non-Color'
143
+ limit = MAX_TEX.get(slot or '', 512)
144
+ w, h = img.size
145
+ if w > limit or h > limit:
146
+ img.scale(min(w, limit), min(h, limit))
147
+ _img_cache[key] = img
148
+ return img
149
+
150
+
151
+ _orm_cache = {}
152
+
153
+
154
+ def repack_mask_to_orm(path):
155
+ """Unity packs metallic in R and smoothness in A; glTF wants roughness in G, metallic in B.
156
+
157
+ Rewriting the channels here means the exporter can point Roughness and Metallic straight at
158
+ one image, which is what glTF stores natively — no bake step, no guessing on import.
159
+ """
160
+ if path in _orm_cache:
161
+ return _orm_cache[path]
162
+ src = load_image(path, True, 'mask')
163
+ if src is None:
164
+ _orm_cache[path] = None
165
+ return None
166
+ import numpy as np
167
+ w, h = src.size
168
+ if w == 0 or h == 0:
169
+ _orm_cache[path] = None
170
+ return None
171
+ buf = np.empty(w * h * 4, dtype=np.float32)
172
+ src.pixels.foreach_get(buf)
173
+ buf = buf.reshape(-1, 4)
174
+ out = np.ones_like(buf)
175
+ out[:, 0] = buf[:, 1] # AO, where Unity's mask keeps it
176
+ out[:, 1] = 1.0 - buf[:, 3] # roughness = 1 - smoothness
177
+ out[:, 2] = buf[:, 0] # metallic
178
+ orm = bpy.data.images.new(os.path.basename(path) + '_orm', w, h, alpha=True, float_buffer=False)
179
+ orm.colorspace_settings.name = 'Non-Color'
180
+ orm.pixels.foreach_set(out.reshape(-1))
181
+ orm.pack()
182
+ _orm_cache[path] = orm
183
+ return orm
184
+
185
+
186
+ # ---------------------------------------------------------------- materials
187
+
188
+ _mat_cache = {}
189
+
190
+
191
+ def build_material(spec):
192
+ name = spec['name']
193
+ if name in _mat_cache:
194
+ return _mat_cache[name]
195
+
196
+ mat = bpy.data.materials.new(name)
197
+ mat.use_nodes = True
198
+ nt = mat.node_tree
199
+ bsdf = nt.nodes['Principled BSDF']
200
+ tex = spec.get('textures', {})
201
+ cols = spec.get('colors', {})
202
+ floats = spec.get('floats', {})
203
+
204
+ def place(node, x, y):
205
+ node.location = (x, y)
206
+ return node
207
+
208
+ base = cols.get('baseColor')
209
+ if base:
210
+ bsdf.inputs['Base Color'].default_value = (base[0], base[1], base[2], 1.0)
211
+
212
+ if tex.get('baseColor'):
213
+ img = load_image(tex['baseColor'], False, 'baseColor')
214
+ if img:
215
+ n = place(nt.nodes.new('ShaderNodeTexImage'), -700, 300)
216
+ n.image = img
217
+ if base and (base[0] < 0.99 or base[1] < 0.99 or base[2] < 0.99):
218
+ # Unity multiplies the map by the base colour; keep that tint.
219
+ mix = place(nt.nodes.new('ShaderNodeMixRGB'), -400, 300)
220
+ mix.blend_type = 'MULTIPLY'
221
+ mix.inputs['Fac'].default_value = 1.0
222
+ mix.inputs['Color2'].default_value = (base[0], base[1], base[2], 1.0)
223
+ nt.links.new(n.outputs['Color'], mix.inputs['Color1'])
224
+ nt.links.new(mix.outputs['Color'], bsdf.inputs['Base Color'])
225
+ else:
226
+ nt.links.new(n.outputs['Color'], bsdf.inputs['Base Color'])
227
+
228
+ if tex.get('normal'):
229
+ img = load_image(tex['normal'], True, 'normal')
230
+ if img:
231
+ n = place(nt.nodes.new('ShaderNodeTexImage'), -700, -100)
232
+ n.image = img
233
+ nm = place(nt.nodes.new('ShaderNodeNormalMap'), -400, -100)
234
+ nt.links.new(n.outputs['Color'], nm.inputs['Color'])
235
+ nt.links.new(nm.outputs['Normal'], bsdf.inputs['Normal'])
236
+
237
+ if tex.get('mask'):
238
+ orm = repack_mask_to_orm(tex['mask'])
239
+ if orm:
240
+ n = place(nt.nodes.new('ShaderNodeTexImage'), -700, -450)
241
+ n.image = orm
242
+ sep = place(nt.nodes.new('ShaderNodeSeparateColor'), -400, -450)
243
+ nt.links.new(n.outputs['Color'], sep.inputs['Color'])
244
+ nt.links.new(sep.outputs['Green'], bsdf.inputs['Roughness'])
245
+ nt.links.new(sep.outputs['Blue'], bsdf.inputs['Metallic'])
246
+ else:
247
+ if 'metallic' in floats:
248
+ bsdf.inputs['Metallic'].default_value = floats['metallic']
249
+ if 'smoothness' in floats:
250
+ bsdf.inputs['Roughness'].default_value = 1.0 - floats['smoothness']
251
+
252
+ # Only light a material up when Unity actually had emission switched on. These shaders keep a
253
+ # colour in the emissive slot regardless, so trusting the value alone turns most of the level
254
+ # into a white light source.
255
+ emis = cols.get('emissive') if spec.get('emissiveEnabled') else None
256
+ if tex.get('emissive'):
257
+ img = load_image(tex['emissive'], False, 'emissive')
258
+ if img:
259
+ n = place(nt.nodes.new('ShaderNodeTexImage'), -700, 650)
260
+ n.image = img
261
+ nt.links.new(n.outputs['Color'], bsdf.inputs['Emission Color'])
262
+ bsdf.inputs['Emission Strength'].default_value = 1.0
263
+ elif emis and max(emis[:3]) > 0.01:
264
+ bsdf.inputs['Emission Color'].default_value = (emis[0], emis[1], emis[2], 1.0)
265
+ bsdf.inputs['Emission Strength'].default_value = 1.0
266
+
267
+ _mat_cache[name] = mat
268
+ return mat
269
+
270
+
271
+ # ---------------------------------------------------------------- meshes
272
+
273
+ _fbx_cache = {}
274
+
275
+
276
+ def is_ascii_fbx(path):
277
+ with open(path, 'rb') as fh:
278
+ return b'Kaydara' not in fh.read(32)
279
+
280
+
281
+ def build_ascii_meshes(path):
282
+ """Build mesh datablocks from an ASCII FBX, which Blender's importer will not read.
283
+
284
+ Axes and scale are matched to what the binary importer does — Blender (x, y, z) takes FBX
285
+ (x, z, y), scaled by UnitScaleFactor/100 — so ASCII and binary models land in the same space.
286
+ """
287
+ try:
288
+ meshes, unit = read_ascii_fbx(path)
289
+ except Exception as exc: # noqa: BLE001
290
+ print(f'FBXFAIL {path}: {exc}')
291
+ return {}
292
+
293
+ scale = unit / 100.0
294
+ out = {}
295
+ for src in meshes:
296
+ # Same as the binary path: Unity negates X on import, then Blender's basis swaps Y/Z.
297
+ verts = [(-v[0] * scale, v[2] * scale, v[1] * scale) for v in src['verts']]
298
+ polys = [p for p in src['polys'] if len(p) >= 3 and max(p) < len(verts)]
299
+ if not verts or not polys:
300
+ continue
301
+
302
+ mesh = bpy.data.meshes.new(src['name'])
303
+ mesh.from_pydata(verts, [], polys)
304
+ mesh.validate()
305
+ # verts were swapped from Unity axes above, which mirrors them.
306
+ mesh.flip_normals()
307
+
308
+ uvs = src['uvs']
309
+ if uvs:
310
+ layer = mesh.uv_layers.new(name='UVMap')
311
+ index = src['uvIndex']
312
+ direct = src['uvReference'] != 'IndexToDirect'
313
+ loop = 0
314
+ for poly in mesh.polygons:
315
+ for _ in poly.loop_indices:
316
+ at = loop if direct else (index[loop] if loop < len(index) else 0)
317
+ if 0 <= at * 2 + 1 < len(uvs):
318
+ layer.data[loop].uv = (uvs[at * 2], uvs[at * 2 + 1])
319
+ loop += 1
320
+
321
+ normals = src['normals']
322
+ if normals and src['normalMapping'] == 'ByPolygonVertex' and len(normals) >= len(mesh.loops) * 3:
323
+ try:
324
+ mesh.normals_split_custom_set([
325
+ (normals[i * 3], normals[i * 3 + 2], normals[i * 3 + 1])
326
+ for i in range(len(mesh.loops))
327
+ ])
328
+ for poly in mesh.polygons:
329
+ poly.use_smooth = True
330
+ except (RuntimeError, TypeError) as exc:
331
+ print(f'NORMALS {path}: {exc}')
332
+
333
+ out[src['name']] = mesh
334
+ return out
335
+
336
+
337
+ def import_fbx(path, perm=(0, 1, 2), signs=(-1, 1, 1), geometric=None):
338
+ """Import once and hand back {mesh name: mesh datablock}, in final local units."""
339
+ if path in _fbx_cache:
340
+ return _fbx_cache[path]
341
+ if is_ascii_fbx(path):
342
+ built = build_ascii_meshes(path)
343
+ _fbx_cache[path] = built
344
+ return built
345
+ before = set(bpy.data.objects)
346
+ try:
347
+ bpy.ops.import_scene.fbx(filepath=path)
348
+ except Exception as exc: # noqa: BLE001 - a bad model should not sink the whole prefab
349
+ print(f'FBXFAIL {path}: {exc}')
350
+ _fbx_cache[path] = {}
351
+ return {}
352
+ new = [o for o in bpy.data.objects if o not in before]
353
+
354
+ meshes = {}
355
+ baked = set()
356
+ mesh_index = 0 # pairs with read_fbx's per-mesh geometric list, both in file order
357
+ for o in new:
358
+ if o.type != 'MESH':
359
+ continue
360
+ if o.data.name not in baked:
361
+ gi = geometric[mesh_index] if (geometric and mesh_index < len(geometric)) else None
362
+ bake_to_blender(o.data, o.matrix_world, perm, signs, gi)
363
+ mesh_index += 1
364
+ baked.add(o.data.name)
365
+ meshes[o.name] = o.data
366
+ for o in new:
367
+ bpy.data.objects.remove(o, do_unlink=True)
368
+ _fbx_cache[path] = meshes
369
+ return meshes
370
+
371
+
372
+ _variant_cache = {}
373
+
374
+
375
+ def variant_for(data, mats):
376
+ """Material slots live on the mesh, and instances share it — so one mesh worn two different
377
+ ways needs two datablocks. Everything using the same combination still shares one."""
378
+ key = (data.name, tuple(m['name'] for m in mats))
379
+ hit = _variant_cache.get(key)
380
+ if hit is not None:
381
+ return hit
382
+ if not mats:
383
+ _variant_cache[key] = data
384
+ return data
385
+ if _variant_cache and any(k[0] == data.name for k in _variant_cache):
386
+ data = data.copy()
387
+ data.materials.clear()
388
+ for m in mats:
389
+ data.materials.append(build_material(m))
390
+ _variant_cache[key] = data
391
+ return data
392
+
393
+
394
+ def pick_mesh(meshes, wanted):
395
+ if not meshes:
396
+ return None
397
+ if wanted and wanted in meshes:
398
+ return meshes[wanted]
399
+ if wanted:
400
+ for k, v in meshes.items():
401
+ if k.split('.')[0] == wanted:
402
+ return v
403
+ if len(meshes) == 1:
404
+ return next(iter(meshes.values()))
405
+ return next(iter(meshes.values()))
406
+
407
+
408
+ # ---------------------------------------------------------------- build
409
+
410
+ def main():
411
+ argv = sys.argv[sys.argv.index('--') + 1:]
412
+ spec_path = argv[0]
413
+ with open(spec_path) as fh:
414
+ spec = json.load(fh)
415
+
416
+ bpy.ops.wm.read_factory_settings(use_empty=True)
417
+ coll = bpy.context.scene.collection
418
+
419
+ materials = {m['path']: m for m in spec.get('materials', [])}
420
+ placed = 0
421
+ skipped = 0
422
+
423
+ axis = spec.get('axis', {})
424
+ for r in spec['renderers']:
425
+ ax = axis.get(r['fbx']) or {}
426
+ meshes = import_fbx(r['fbx'],
427
+ tuple(ax.get('perm', (0, 1, 2))),
428
+ tuple(ax.get('signs', (-1, 1, 1))),
429
+ spec.get('geometric', {}).get(r['fbx']))
430
+ data = pick_mesh(meshes, r.get('meshName'))
431
+ if data is None:
432
+ skipped += 1
433
+ continue
434
+ mats = [materials.get(p) for p in r.get('materials', [])]
435
+ mats = [m for m in mats if m]
436
+ data = variant_for(data, mats)
437
+
438
+ obj = bpy.data.objects.new(r['name'][:60], data)
439
+ coll.objects.link(obj)
440
+ obj.rotation_mode = 'QUATERNION'
441
+ obj.location = to_blender_pos(r['pos'])
442
+ obj.rotation_quaternion = to_blender_quat(r['quat'])
443
+ obj.scale = to_blender_scale(r['scale'])
444
+ placed += 1
445
+
446
+ print(f'PLACED {placed} SKIPPED {skipped}')
447
+
448
+ out = spec['out']
449
+ os.makedirs(os.path.dirname(out), exist_ok=True)
450
+ bpy.ops.export_scene.gltf(
451
+ filepath=out,
452
+ export_format='GLB',
453
+ export_apply=False,
454
+ export_materials='EXPORT',
455
+ export_image_format='JPEG',
456
+ export_jpeg_quality=80,
457
+ export_yup=True,
458
+ )
459
+ print(f'EXPORTED {out} {os.path.getsize(out)}')
460
+
461
+
462
+ main()