@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.
- 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
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/ #
|
|
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**
|
|
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()
|