@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Work out, per asset pack, how Unity's mesh axes relate to the raw FBX vertex axes.
|
|
2
|
+
|
|
3
|
+
They are not always the same. One pack's models import with their axes untouched, while another
|
|
4
|
+
kit's come in cyclically permuted — Unity's mesh is `(raw_y, raw_z, raw_x)`. The
|
|
5
|
+
difference lives in the exporter's node/geometric transforms and is a property of how each pack was
|
|
6
|
+
authored, so it cannot be read off the file's declared up-axis (both packs declare Y-up).
|
|
7
|
+
|
|
8
|
+
Rather than guess, this derives the permutation from Unity's own data: a GameObject carrying both a
|
|
9
|
+
MeshFilter and a BoxCollider states the mesh's dimensions outright in `m_Size`, with no FBX parsing
|
|
10
|
+
involved. Comparing that against the raw bounds identifies the permutation, and the answer is
|
|
11
|
+
consistent across a pack, so a pack with any evidence at all resolves every model in it.
|
|
12
|
+
|
|
13
|
+
Both the collider extractor and the GLB build must apply the same correction, or the geometry a
|
|
14
|
+
player sees and the geometry they collide with disagree.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# Candidate axis permutations, as index tuples into the raw FBX bounds.
|
|
20
|
+
PERMUTATIONS = {
|
|
21
|
+
'identity': (0, 1, 2),
|
|
22
|
+
'swapYZ': (0, 2, 1),
|
|
23
|
+
'swapXY': (1, 0, 2),
|
|
24
|
+
'swapXZ': (2, 1, 0),
|
|
25
|
+
'cycYZX': (1, 2, 0),
|
|
26
|
+
'cycZXY': (2, 0, 1),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Unity converts a right-handed FBX into its own left-handed space by negating X, so a model's
|
|
30
|
+
# axes are not merely permuted — one of them is flipped. Sizes alone cannot see that (a box is the
|
|
31
|
+
# same size mirrored), which is why deriving from `m_Size` produced meshes that looked right on
|
|
32
|
+
# their own and assembled wrongly: every one was mirrored. `m_Center` does see it, because a mesh
|
|
33
|
+
# whose geometry sits off its own origin lands on the opposite side when flipped.
|
|
34
|
+
#
|
|
35
|
+
# Used when a pack has no BoxCollider evidence at all, in place of the old plain identity.
|
|
36
|
+
# Unity negates X converting a right-handed FBX into its left-handed space — on every import.
|
|
37
|
+
# This is the second half of the orientation fix (the first is the geometric transform, applied in
|
|
38
|
+
# fbx_bounds). It was found, lost and found again: m_Center fits said (-1,1,1) for 157 of 181
|
|
39
|
+
# models, an arena-symmetry metric that cannot see a uniform mirror said "worse", and the metric
|
|
40
|
+
# was wrongly trusted. A pair of end caps settled it: two quarter-rounds only join into one
|
|
41
|
+
# smooth half-round with the negation applied. Proven afterwards with no extra sign needed on top
|
|
42
|
+
# of the reader's output for 157/181 boxes, 137 of which are X-offset and can see a mirror.
|
|
43
|
+
DEFAULT_TRANSFORM = ((0, 1, 2), (-1, 1, 1))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pack_of(path, assets_root):
|
|
47
|
+
"""The top-level folder under Assets/ a model belongs to."""
|
|
48
|
+
rel = os.path.relpath(path, assets_root)
|
|
49
|
+
return rel.split(os.sep)[0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ground_truth(ue, assets_root):
|
|
53
|
+
"""Every (fbx path -> (mesh fileID, Unity m_Size, m_Center)) a mesh+BoxCollider pair gives us."""
|
|
54
|
+
out = {}
|
|
55
|
+
for base, _dirs, files in os.walk(assets_root):
|
|
56
|
+
for f in files:
|
|
57
|
+
if not f.endswith('.prefab'):
|
|
58
|
+
continue
|
|
59
|
+
docs = ue.parse_file(os.path.join(base, f))
|
|
60
|
+
for _goid, (cls, body) in docs.items():
|
|
61
|
+
if cls != '1':
|
|
62
|
+
continue
|
|
63
|
+
mesh = box = None
|
|
64
|
+
for m in re.finditer(r'- component: \{fileID: (\d+)\}', body):
|
|
65
|
+
cd = docs.get(m.group(1))
|
|
66
|
+
if not cd:
|
|
67
|
+
continue
|
|
68
|
+
if cd[0] == '33':
|
|
69
|
+
mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cd[1])
|
|
70
|
+
if mm:
|
|
71
|
+
mesh = (mm.group(2), mm.group(1))
|
|
72
|
+
elif cd[0] == '65':
|
|
73
|
+
box = cd[1]
|
|
74
|
+
if mesh and box:
|
|
75
|
+
mp = ue.guid2path.get(mesh[0]) if isinstance(mesh, tuple) else ue.guid2path.get(mesh)
|
|
76
|
+
fid = mesh[1] if isinstance(mesh, tuple) else None
|
|
77
|
+
if mp and mp.lower().endswith('.fbx') and mp not in out:
|
|
78
|
+
out[mp] = (fid,
|
|
79
|
+
ue.vec3(box, 'm_Size', (0, 0, 0)),
|
|
80
|
+
ue.vec3(box, 'm_Center', (0, 0, 0)))
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_axis_map(ue, assets_root, verbose=False):
|
|
85
|
+
"""{pack name: (permutation, signs)}, derived from Unity's own collider boxes.
|
|
86
|
+
|
|
87
|
+
Both halves matter. The permutation says which raw axis becomes which Unity axis; the signs say
|
|
88
|
+
which of them are flipped. Matching `m_Size` alone fixes only the first and silently leaves every
|
|
89
|
+
mesh mirrored.
|
|
90
|
+
"""
|
|
91
|
+
import itertools
|
|
92
|
+
votes = {}
|
|
93
|
+
counts = {}
|
|
94
|
+
for mp, (fid, size, center) in _ground_truth(ue, assets_root).items():
|
|
95
|
+
guid = next((g for g, p in ue.guid2path.items() if p == mp), None)
|
|
96
|
+
bounds = ue.mesh_bounds_raw(guid, ue.guid2path, fid) if guid else None
|
|
97
|
+
if not bounds:
|
|
98
|
+
continue
|
|
99
|
+
mn, mx = bounds
|
|
100
|
+
if max(mx[i] - mn[i] for i in range(3)) < 1e-6:
|
|
101
|
+
continue
|
|
102
|
+
lo = [center[i] - size[i] / 2 for i in range(3)]
|
|
103
|
+
hi = [center[i] + size[i] / 2 for i in range(3)]
|
|
104
|
+
pack = pack_of(mp, assets_root)
|
|
105
|
+
counts[pack] = counts.get(pack, 0) + 1
|
|
106
|
+
bucket = votes.setdefault(pack, {})
|
|
107
|
+
for name, perm in PERMUTATIONS.items():
|
|
108
|
+
for signs in ((1, 1, 1),): # see DEFAULT_TRANSFORM: signed fits measured worse
|
|
109
|
+
ok = True
|
|
110
|
+
for i in range(3):
|
|
111
|
+
a = signs[i] * mn[perm[i]]
|
|
112
|
+
b = signs[i] * mx[perm[i]]
|
|
113
|
+
if abs(min(a, b) - lo[i]) > 0.15 or abs(max(a, b) - hi[i]) > 0.15:
|
|
114
|
+
ok = False
|
|
115
|
+
break
|
|
116
|
+
if ok:
|
|
117
|
+
key = (perm, signs)
|
|
118
|
+
bucket[key] = bucket.get(key, 0) + 1
|
|
119
|
+
|
|
120
|
+
axis_map = {}
|
|
121
|
+
for pack, bucket in votes.items():
|
|
122
|
+
if not bucket:
|
|
123
|
+
continue
|
|
124
|
+
best, n = max(bucket.items(), key=lambda kv: kv[1])
|
|
125
|
+
axis_map[pack] = best
|
|
126
|
+
if verbose:
|
|
127
|
+
print(f' axis: {pack:24s} -> perm {best[0]} signs {best[1]} '
|
|
128
|
+
f'({n} of {counts.get(pack, 0)} models)')
|
|
129
|
+
return axis_map
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _override():
|
|
133
|
+
"""`axisOverride` in unity-import.json (or UNITY_AXIS_OVERRIDE): "Pack=swapYZ:-1,1,1;Sci-Fi Base=identity" — for experiments."""
|
|
134
|
+
from import_config import CFG as _CFG
|
|
135
|
+
raw = _CFG['axisOverride']
|
|
136
|
+
out = {}
|
|
137
|
+
for item in raw.split(';'):
|
|
138
|
+
if '=' not in item:
|
|
139
|
+
continue
|
|
140
|
+
pack, spec = item.split('=', 1)
|
|
141
|
+
name, _, signs = spec.partition(':')
|
|
142
|
+
perm = PERMUTATIONS.get(name.strip())
|
|
143
|
+
if not perm:
|
|
144
|
+
continue
|
|
145
|
+
sg = tuple(int(v) for v in signs.split(',')) if signs else (1, 1, 1)
|
|
146
|
+
out[pack.strip()] = (perm, sg)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# No pins. What this file used to guess per pack is stated outright in the FBX itself
|
|
151
|
+
# (see fbx_bounds.apply_geometric); permutation_for returns identity.
|
|
152
|
+
PINNED = {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def permutation_for(path, assets_root, axis_map):
|
|
156
|
+
"""Always identity now.
|
|
157
|
+
|
|
158
|
+
The per-pack permutation this file derived was a stand-in for something the FBX states
|
|
159
|
+
outright: the Model node's GeometricRotation/GeometricTranslation, which Unity bakes into the
|
|
160
|
+
mesh and which `fbx_bounds.read_fbx` now applies to the raw vertices. With that in place, no
|
|
161
|
+
axis guessing is needed for any pack — a 3ds Max kit and a Maya kit both came out right from
|
|
162
|
+
the same code path. Kept so callers keep working; the
|
|
163
|
+
env override remains for experiments.
|
|
164
|
+
"""
|
|
165
|
+
pack = pack_of(path, assets_root)
|
|
166
|
+
over = _override()
|
|
167
|
+
if pack in over:
|
|
168
|
+
return over[pack]
|
|
169
|
+
return DEFAULT_TRANSFORM
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def apply_permutation(vec, transform):
|
|
173
|
+
perm, signs = transform
|
|
174
|
+
return [signs[0] * vec[perm[0]], signs[1] * vec[perm[1]], signs[2] * vec[perm[2]]]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import struct, zlib, sys, os, json
|
|
2
|
+
|
|
3
|
+
# A MeshCollider collides with triangles, not with its bounding box. Reading the vertices lets the
|
|
4
|
+
# caller ask whether a mesh actually reaches the gameplay plane, instead of assuming its box does.
|
|
5
|
+
KEEP_VERTICES = False
|
|
6
|
+
MAX_VERTICES = 4000
|
|
7
|
+
UNITY_NEGATE_X = True
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _rx(a):
|
|
11
|
+
import math
|
|
12
|
+
c, s_ = math.cos(a), math.sin(a); return ((1, 0, 0), (0, c, -s_), (0, s_, c))
|
|
13
|
+
def _ry(a):
|
|
14
|
+
import math
|
|
15
|
+
c, s_ = math.cos(a), math.sin(a); return ((c, 0, s_), (0, 1, 0), (-s_, 0, c))
|
|
16
|
+
def _rz(a):
|
|
17
|
+
import math
|
|
18
|
+
c, s_ = math.cos(a), math.sin(a); return ((c, -s_, 0), (s_, c, 0), (0, 0, 1))
|
|
19
|
+
def _mm(a, b):
|
|
20
|
+
return tuple(tuple(sum(a[i][k]*b[k][j] for k in range(3)) for j in range(3)) for i in range(3))
|
|
21
|
+
def _mv(m, v):
|
|
22
|
+
return tuple(sum(m[i][k]*v[k] for k in range(3)) for i in range(3))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply_geometric(v, g):
|
|
26
|
+
"""Apply a Model node's geometric transform to one raw vertex, the way Unity bakes it.
|
|
27
|
+
|
|
28
|
+
FBX geometric rotation is an XYZ Euler (rotate about X, then Y, then Z, i.e. Rz*Ry*Rx), in
|
|
29
|
+
degrees; scaling is applied first and translation last. Verified against Unity's own
|
|
30
|
+
BoxCollider for Wall_7.FBX: raw vertices land a full unit away with the axes cycled, this
|
|
31
|
+
lands on the collider box to the millimetre.
|
|
32
|
+
"""
|
|
33
|
+
import math
|
|
34
|
+
sc = g.get('GeometricScaling', (1.0, 1.0, 1.0))
|
|
35
|
+
rot = g.get('GeometricRotation', (0.0, 0.0, 0.0))
|
|
36
|
+
tr = g.get('GeometricTranslation', (0.0, 0.0, 0.0))
|
|
37
|
+
x, y, z = v[0]*sc[0], v[1]*sc[1], v[2]*sc[2]
|
|
38
|
+
if any(abs(r) > 1e-9 for r in rot):
|
|
39
|
+
R = _mm(_rz(math.radians(rot[2])), _mm(_ry(math.radians(rot[1])), _rx(math.radians(rot[0]))))
|
|
40
|
+
x, y, z = _mv(R, (x, y, z))
|
|
41
|
+
x, y, z = x + tr[0], y + tr[1], z + tr[2]
|
|
42
|
+
# Unity converts the right-handed FBX into its left-handed space by negating X — on every
|
|
43
|
+
# import, geometric transform or not. A bounding box cannot see a mirror, so this was invisible
|
|
44
|
+
# to every collider-box check; a pair of quarter-round end caps could see it, and
|
|
45
|
+
# curved the wrong way until it was applied.
|
|
46
|
+
return (-x, y, z) if UNITY_NEGATE_X else (x, y, z)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_fbx(path, keep_vertices=False):
|
|
50
|
+
global KEEP_VERTICES
|
|
51
|
+
KEEP_VERTICES = keep_vertices
|
|
52
|
+
data = open(path,'rb').read()
|
|
53
|
+
if not data.startswith(b'Kaydara FBX Binary'):
|
|
54
|
+
return None
|
|
55
|
+
version = struct.unpack_from('<I', data, 23)[0]
|
|
56
|
+
off = 27
|
|
57
|
+
wide = version >= 7500
|
|
58
|
+
meshes = []
|
|
59
|
+
_pending_verts = [] # list of (name, [ (minx,miny,minz),(maxx,maxy,maxz) ])
|
|
60
|
+
# FBX geometric transforms live on the Model node and apply to that node's mesh vertices
|
|
61
|
+
# ONLY. Unity bakes them into the mesh data; reading the raw vertex array skips them and
|
|
62
|
+
# leaves every 3ds Max export cycled by two right angles and displaced.
|
|
63
|
+
geo_list = []
|
|
64
|
+
_geo_cur = {}
|
|
65
|
+
|
|
66
|
+
def read_record(off, path_names):
|
|
67
|
+
if wide:
|
|
68
|
+
end, nprops, plen = struct.unpack_from('<QQQ', data, off); off += 24
|
|
69
|
+
else:
|
|
70
|
+
end, nprops, plen = struct.unpack_from('<III', data, off); off += 12
|
|
71
|
+
nlen = data[off]; off += 1
|
|
72
|
+
name = data[off:off+nlen].decode('utf-8','replace'); off += nlen
|
|
73
|
+
if end == 0:
|
|
74
|
+
return 0, None
|
|
75
|
+
props = []
|
|
76
|
+
pend = off + plen
|
|
77
|
+
for _ in range(nprops):
|
|
78
|
+
t = chr(data[off]); off += 1
|
|
79
|
+
if t in 'SR':
|
|
80
|
+
L = struct.unpack_from('<I', data, off)[0]; off += 4
|
|
81
|
+
props.append(data[off:off+L].decode('utf-8', 'replace') if t == 'S' else b''); off += L
|
|
82
|
+
continue
|
|
83
|
+
if t == 'Y': props.append(struct.unpack_from('<h', data, off)[0]); off += 2
|
|
84
|
+
elif t == 'C': props.append(data[off] != 0); off += 1
|
|
85
|
+
elif t == 'I': props.append(struct.unpack_from('<i', data, off)[0]); off += 4
|
|
86
|
+
elif t == 'F': props.append(struct.unpack_from('<f', data, off)[0]); off += 4
|
|
87
|
+
elif t == 'D': props.append(struct.unpack_from('<d', data, off)[0]); off += 8
|
|
88
|
+
elif t == 'L': props.append(struct.unpack_from('<q', data, off)[0]); off += 8
|
|
89
|
+
elif t in 'fdlib':
|
|
90
|
+
alen, enc, clen = struct.unpack_from('<III', data, off); off += 12
|
|
91
|
+
raw = data[off:off+clen]; off += clen
|
|
92
|
+
if enc == 1:
|
|
93
|
+
raw = zlib.decompress(raw)
|
|
94
|
+
fmt = {'f':'f','d':'d','l':'q','i':'i','b':'b'}[t]
|
|
95
|
+
if t in 'fd' or t in 'li':
|
|
96
|
+
props.append(('array', fmt, alen, raw))
|
|
97
|
+
else:
|
|
98
|
+
props.append(('array', fmt, alen, raw))
|
|
99
|
+
elif t in 'SR':
|
|
100
|
+
slen = struct.unpack_from('<I', data, off)[0]; off += 4
|
|
101
|
+
props.append(data[off:off+slen]); off += slen
|
|
102
|
+
else:
|
|
103
|
+
off = pend
|
|
104
|
+
break
|
|
105
|
+
off = pend
|
|
106
|
+
if name == 'Model':
|
|
107
|
+
if _geo_cur:
|
|
108
|
+
geo_list.append(dict(_geo_cur))
|
|
109
|
+
_geo_cur.clear()
|
|
110
|
+
_geo_cur['_model'] = True
|
|
111
|
+
if name == 'P' and props and _geo_cur.get('_model') and isinstance(props[0], str) \
|
|
112
|
+
and props[0] in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'):
|
|
113
|
+
vals = [v for v in props[4:] if isinstance(v, (int, float))]
|
|
114
|
+
if len(vals) == 3:
|
|
115
|
+
_geo_cur[props[0]] = tuple(float(v) for v in vals)
|
|
116
|
+
if name == 'Vertices' and props and isinstance(props[0], tuple):
|
|
117
|
+
_, fmt, alen, raw = props[0]
|
|
118
|
+
vals = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
|
|
119
|
+
mn = [1e30]*3; mx = [-1e30]*3
|
|
120
|
+
for i in range(0, len(vals)-2, 3):
|
|
121
|
+
for k in range(3):
|
|
122
|
+
v = vals[i+k]
|
|
123
|
+
if v < mn[k]: mn[k] = v
|
|
124
|
+
if v > mx[k]: mx[k] = v
|
|
125
|
+
_pending_verts.append(len(meshes))
|
|
126
|
+
verts = None
|
|
127
|
+
if KEEP_VERTICES:
|
|
128
|
+
# Kept whole: PolygonVertexIndex refers to these by position, so subsampling
|
|
129
|
+
# would silently scramble the faces.
|
|
130
|
+
verts = [(vals[i], vals[i + 1], vals[i + 2])
|
|
131
|
+
for i in range(0, len(vals) - 2, 3)]
|
|
132
|
+
meshes.append((path_names[-1] if path_names else '', mn, mx, verts, None, None))
|
|
133
|
+
if name == 'PolygonVertexIndex' and KEEP_VERTICES and props and isinstance(props[0], tuple):
|
|
134
|
+
_, fmt, alen, raw = props[0]
|
|
135
|
+
idx = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
|
|
136
|
+
if _pending_verts:
|
|
137
|
+
at = _pending_verts[-1]
|
|
138
|
+
if at < len(meshes):
|
|
139
|
+
e = meshes[at]
|
|
140
|
+
meshes[at] = (e[0], e[1], e[2], e[3], idx, e[5])
|
|
141
|
+
# nested
|
|
142
|
+
nullsz = 25 if wide else 13
|
|
143
|
+
while off < end - nullsz:
|
|
144
|
+
off, _ = read_record(off, path_names + [name])
|
|
145
|
+
if off < end:
|
|
146
|
+
off = end
|
|
147
|
+
return off, name
|
|
148
|
+
|
|
149
|
+
unit_scale = None
|
|
150
|
+
nullsz = 25 if wide else 13
|
|
151
|
+
while off < len(data) - nullsz:
|
|
152
|
+
noff, nm = read_record(off, [])
|
|
153
|
+
if noff == 0 or noff <= off: break
|
|
154
|
+
off = noff
|
|
155
|
+
idx = data.find(b'UnitScaleFactor')
|
|
156
|
+
if idx > 0:
|
|
157
|
+
seg = data[idx:idx+120]
|
|
158
|
+
j = seg.find(b'\x04', 15) # 'D' type marker is 0x44; properties are S,S,S,S,D
|
|
159
|
+
k = seg.find(b'D', 15)
|
|
160
|
+
if k > 0 and k + 9 <= len(seg):
|
|
161
|
+
try:
|
|
162
|
+
unit_scale = struct.unpack_from('<d', seg, k+1)[0]
|
|
163
|
+
except Exception:
|
|
164
|
+
unit_scale = None
|
|
165
|
+
if _geo_cur:
|
|
166
|
+
geo_list.append(dict(_geo_cur))
|
|
167
|
+
geos = [g for g in geo_list if any(k in g for k in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'))]
|
|
168
|
+
# Only Model records that are meshes carry geometry; pair in order when the counts line up,
|
|
169
|
+
# and otherwise only when there is exactly one of each.
|
|
170
|
+
paired = []
|
|
171
|
+
for i, e in enumerate(meshes):
|
|
172
|
+
g = None
|
|
173
|
+
if len(geos) == len(meshes):
|
|
174
|
+
g = geos[i]
|
|
175
|
+
elif len(geos) == 1 and len(meshes) == 1:
|
|
176
|
+
g = geos[0]
|
|
177
|
+
paired.append((e[0], e[1], e[2], e[3], e[4], g))
|
|
178
|
+
meshes = paired
|
|
179
|
+
# Bounds are reported AFTER the geometric transform, so every consumer sees Unity's mesh.
|
|
180
|
+
fixed = []
|
|
181
|
+
for (nm, mn, mx, verts, idx, g) in meshes:
|
|
182
|
+
if not g:
|
|
183
|
+
g = {} # still run apply_geometric: the X negation applies to every file
|
|
184
|
+
if True:
|
|
185
|
+
corners = [(x, y, z) for x in (mn[0], mx[0]) for y in (mn[1], mx[1]) for z in (mn[2], mx[2])]
|
|
186
|
+
tc = [apply_geometric(c, g) for c in corners]
|
|
187
|
+
mn = [min(c[k] for c in tc) for k in range(3)]
|
|
188
|
+
mx = [max(c[k] for c in tc) for k in range(3)]
|
|
189
|
+
if verts:
|
|
190
|
+
verts = [apply_geometric(v, g) for v in verts]
|
|
191
|
+
fixed.append((nm, mn, mx, verts, idx, g))
|
|
192
|
+
return fixed, unit_scale
|
|
193
|
+
|
|
194
|
+
if __name__ == '__main__':
|
|
195
|
+
for p in sys.argv[1:]:
|
|
196
|
+
r = read_fbx(p)
|
|
197
|
+
ms, us = (r if r else (None, None))
|
|
198
|
+
if not ms:
|
|
199
|
+
print(os.path.basename(p), 'FAILED'); continue
|
|
200
|
+
mn = [min(m[1][k] for m in ms) for k in range(3)]
|
|
201
|
+
mx = [max(m[2][k] for m in ms) for k in range(3)]
|
|
202
|
+
print(os.path.basename(p), 'meshes=%d' % len(ms), 'unit=', us, 'min=', [round(v,3) for v in mn], 'max=', [round(v,3) for v in mx])
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Per-project settings for the Unity level import, read from `tools/unity-import.json`.
|
|
2
|
+
|
|
3
|
+
Every value that belongs to a particular game — where its Unity project lives, which scene is the
|
|
4
|
+
level, which MonoBehaviour scripts mark spawns and bases, where the play plane sits — comes from
|
|
5
|
+
this one file, so the scripts themselves never need editing to run on another game. Copy
|
|
6
|
+
`unity-import.example.json` to `unity-import.json` and fill it in; the keys and their defaults are
|
|
7
|
+
listed in DEFAULTS. `UNITY_IMPORT_CONFIG` names a different file; `UNITY_ROOT`, `UNITY_PLANE_Z` and
|
|
8
|
+
`UNITY_AXIS_OVERRIDE` override single values from the environment for experiments.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
|
|
15
|
+
DEFAULTS = {
|
|
16
|
+
# The Unity tree that holds Assets/ and ProjectSettings/ for the level (a repo can hold more
|
|
17
|
+
# than one; pick the one the creator's editor opens).
|
|
18
|
+
'unityRoot': '',
|
|
19
|
+
# Scene path relative to unityRoot, and the level name the game data is written under.
|
|
20
|
+
'scene': 'Assets/Scenes/Main.unity',
|
|
21
|
+
'levelName': 'Main',
|
|
22
|
+
# Gameplay plane for a 2D-on-a-plane game: Unity z of the plane and the half-thickness a
|
|
23
|
+
# collider must cross to count as a wall (the ship's radius is a good value).
|
|
24
|
+
'planeZ': -1.0,
|
|
25
|
+
'planeHalf': 0.8,
|
|
26
|
+
# Instances farther than this from the origin are stray copies parked outside the level.
|
|
27
|
+
'arenaLimit': 80.0,
|
|
28
|
+
# Output GLB (from convert_assets) and the asset name it is uploaded under.
|
|
29
|
+
'glb': 'build/glb/level.glb',
|
|
30
|
+
'assetName': 'level-arena',
|
|
31
|
+
# Where write_arena_data emits the game's level data.
|
|
32
|
+
'outputTs': 'src/work/ArenaData.ts',
|
|
33
|
+
# Prefabs to convert when convert_assets is not run in scene mode.
|
|
34
|
+
'prefabs': [],
|
|
35
|
+
# Prefab tags whose colliders are never walls (decoration that would seal a room).
|
|
36
|
+
'badTags': [],
|
|
37
|
+
# MonoBehaviour scripts (file names) and prefab files that mark spawns, bases and pickups.
|
|
38
|
+
'markerScripts': ['Spawner.cs', 'SpawnPoint.cs', 'PowerupSpawnPoint.cs'],
|
|
39
|
+
'markerPrefabs': ['Base.prefab', 'Spawner.prefab', 'SpawnPoint.prefab', 'PowerupSpawnPoint.prefab'],
|
|
40
|
+
# Render-mesh name families that must be solid although they carry no 2D collider
|
|
41
|
+
# (mesh_walls slices them at the play plane).
|
|
42
|
+
'wallFamilies': [],
|
|
43
|
+
# Per-pack axis override for experiments: "Pack=cycYZX:-1,1,1;Other=identity".
|
|
44
|
+
'axisOverride': '',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load():
|
|
49
|
+
path = os.environ.get('UNITY_IMPORT_CONFIG') or os.path.join(_HERE, 'unity-import.json')
|
|
50
|
+
cfg = dict(DEFAULTS)
|
|
51
|
+
if os.path.exists(path):
|
|
52
|
+
with open(path, encoding='utf-8') as fh:
|
|
53
|
+
data = json.load(fh)
|
|
54
|
+
unknown = sorted(set(data) - set(DEFAULTS) - {'_comment'})
|
|
55
|
+
if unknown:
|
|
56
|
+
raise SystemExit(f'{path}: unknown keys {unknown}; known keys are {sorted(DEFAULTS)}')
|
|
57
|
+
cfg.update({k: v for k, v in data.items() if k != '_comment'})
|
|
58
|
+
if os.environ.get('UNITY_ROOT'):
|
|
59
|
+
cfg['unityRoot'] = os.environ['UNITY_ROOT']
|
|
60
|
+
if os.environ.get('UNITY_PLANE_Z'):
|
|
61
|
+
cfg['planeZ'] = float(os.environ['UNITY_PLANE_Z'])
|
|
62
|
+
if os.environ.get('UNITY_AXIS_OVERRIDE'):
|
|
63
|
+
cfg['axisOverride'] = os.environ['UNITY_AXIS_OVERRIDE']
|
|
64
|
+
cfg['unityRoot'] = os.path.expanduser(cfg['unityRoot'])
|
|
65
|
+
cfg['_path'] = path
|
|
66
|
+
return cfg
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
CFG = _load()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unity_root():
|
|
73
|
+
root = CFG['unityRoot']
|
|
74
|
+
if not root or not os.path.isdir(os.path.join(root, 'Assets')):
|
|
75
|
+
raise SystemExit(f"unityRoot {root!r} has no Assets/ directory; set it in {CFG['_path']} "
|
|
76
|
+
f"(or UNITY_ROOT) to the Unity tree that holds the level")
|
|
77
|
+
return root
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def scene_path():
|
|
81
|
+
return os.path.join(unity_root(), CFG['scene'])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def project_root():
|
|
85
|
+
"""The game project (the directory that holds tools/)."""
|
|
86
|
+
return os.path.dirname(_HERE)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Declarative scene edits applied identically by unity_colliders2d.py and unity_renderers.py. EMPTY on purpose: every earlier entry turned out to be a walker bug (scene overrides that deactivate whole prefab instances or disable components were being ignored); the walkers honour those now. Keep for genuine gameplay edits only. move_objects: the four corner tower groups slide 3 units toward the centre on both axes so they overlap the tunnel runs (the scene leaves a 1-2 unit seam at the play plane).",
|
|
3
|
+
"add_instances": [],
|
|
4
|
+
"remove_instances": [],
|
|
5
|
+
"move_objects": [
|
|
6
|
+
{
|
|
7
|
+
"name": "CornerTowerTopLeft",
|
|
8
|
+
"toward_center": 3.0
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "CornerTowerTopRight",
|
|
12
|
+
"toward_center": 3.0
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "CornerTowerBottomLeft",
|
|
16
|
+
"toward_center": 3.0
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "CornerTowerBottomRight",
|
|
20
|
+
"toward_center": 3.0
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Slice render meshes at the play plane into 2D wall rectangles.
|
|
2
|
+
|
|
3
|
+
Some level geometry has no 2D collider in Unity yet must be solid for gameplay here: the
|
|
4
|
+
perimeter wall ring of the origin game was plain scene decoration, and a
|
|
5
|
+
Rigidbody2D ship flies straight through it. This runs inside Blender: it loads the built arena
|
|
6
|
+
GLB, samples every triangle of the named families that crosses the slab around the play plane,
|
|
7
|
+
rasterises the hits into a 0.5-unit occupancy grid and merges runs into axis-aligned rectangles.
|
|
8
|
+
|
|
9
|
+
blender -b --python tools/mesh_walls.py -- build/glb/room-for-9.glb tools/render_walls.json
|
|
10
|
+
|
|
11
|
+
Coordinates: the GLB is (ux, uy, -uz); Blender imports glTF Y-up as Z-up, so Blender x = Unity x,
|
|
12
|
+
Blender z = Unity y (the map's vertical axis), Blender y = Unity z (depth, negative toward the
|
|
13
|
+
camera). The play plane is `planeZ` in unity-import.json.
|
|
14
|
+
"""
|
|
15
|
+
import sys, os, json
|
|
16
|
+
import bpy, mathutils
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
from import_config import CFG as _CFG # noqa: E402
|
|
20
|
+
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
|
|
21
|
+
GLB = argv[0] if argv else _CFG['glb']
|
|
22
|
+
OUT = argv[1] if len(argv) > 1 else 'tools/render_walls.json'
|
|
23
|
+
FAMILIES = tuple(_CFG['wallFamilies'])
|
|
24
|
+
PLANE = float(_CFG['planeZ'])
|
|
25
|
+
HALF = 0.45 # slab half-depth: the tunnel floor sits at depth 0, keep it out
|
|
26
|
+
CELL = 0.5
|
|
27
|
+
LIMIT = 72.0
|
|
28
|
+
|
|
29
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
30
|
+
bpy.ops.import_scene.gltf(filepath=GLB)
|
|
31
|
+
N = int(2 * LIMIT / CELL)
|
|
32
|
+
grid = [[False] * N for _ in range(N)]
|
|
33
|
+
|
|
34
|
+
def mark(x, y):
|
|
35
|
+
i = int((x + LIMIT) / CELL); j = int((y + LIMIT) / CELL)
|
|
36
|
+
if 0 <= i < N and 0 <= j < N: grid[j][i] = True
|
|
37
|
+
|
|
38
|
+
count = 0
|
|
39
|
+
for o in bpy.data.objects:
|
|
40
|
+
if o.type != 'MESH' or not any(f in o.name for f in FAMILIES): continue
|
|
41
|
+
count += 1
|
|
42
|
+
m = o.matrix_world; me = o.data
|
|
43
|
+
vs = [m @ v.co for v in me.vertices]
|
|
44
|
+
for poly in me.polygons:
|
|
45
|
+
pts = [vs[i] for i in poly.vertices]
|
|
46
|
+
ds = [p.y for p in pts] # Blender y = Unity depth
|
|
47
|
+
if max(ds) < PLANE - HALF or min(ds) > PLANE + HALF: continue
|
|
48
|
+
for k in range(1, len(pts) - 1): # fan-triangulate
|
|
49
|
+
a, b, c = pts[0], pts[k], pts[k + 1]
|
|
50
|
+
steps = 14
|
|
51
|
+
for ui in range(steps + 1):
|
|
52
|
+
for vi in range(steps + 1 - ui):
|
|
53
|
+
u, v = ui / steps, vi / steps
|
|
54
|
+
q = a + (b - a) * u + (c - a) * v
|
|
55
|
+
if PLANE - HALF <= q.y <= PLANE + HALF: mark(q.x, q.z)
|
|
56
|
+
|
|
57
|
+
# close one-cell sampling holes (dilate then erode), then greedy maximal rectangles
|
|
58
|
+
def closed(g):
|
|
59
|
+
d = [[any(g[jj][ii] for jj in range(max(0, j - 1), min(N, j + 2)) for ii in range(max(0, i - 1), min(N, i + 2))) for i in range(N)] for j in range(N)]
|
|
60
|
+
return [[all(d[jj][ii] for jj in range(max(0, j - 1), min(N, j + 2)) for ii in range(max(0, i - 1), min(N, i + 2))) or g[j][i] for i in range(N)] for j in range(N)]
|
|
61
|
+
grid = closed(grid)
|
|
62
|
+
rects = []
|
|
63
|
+
free = [row[:] for row in grid]
|
|
64
|
+
for j in range(N):
|
|
65
|
+
for i in range(N):
|
|
66
|
+
if not free[j][i]: continue
|
|
67
|
+
i1 = i
|
|
68
|
+
while i1 < N and free[j][i1]: i1 += 1
|
|
69
|
+
j1 = j + 1
|
|
70
|
+
while j1 < N and all(free[j1][ii] for ii in range(i, i1)): j1 += 1
|
|
71
|
+
for jj in range(j, j1):
|
|
72
|
+
for ii in range(i, i1): free[jj][ii] = False
|
|
73
|
+
rects.append([i, j, i1, j1])
|
|
74
|
+
walls = [dict(x0=round(r[0] * CELL - LIMIT, 2), y0=round(r[1] * CELL - LIMIT, 2),
|
|
75
|
+
x1=round(r[2] * CELL - LIMIT, 2), y1=round(r[3] * CELL - LIMIT, 2), name='+'.join(FAMILIES) or 'sliced', tag='Sliced')
|
|
76
|
+
for r in rects]
|
|
77
|
+
json.dump(walls, open(OUT, 'w'), indent=1)
|
|
78
|
+
print(f"MESH_WALLS meshes {count} cells {sum(map(sum, grid))} rects {len(walls)} -> {OUT}")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Orthographic top-down render of the built arena GLB, for eyeballing the level layout.
|
|
2
|
+
|
|
3
|
+
blender -b --python tools/render_topdown.py -- build/glb/room-for-9.glb .bitmagic/topdown.png
|
|
4
|
+
|
|
5
|
+
Camera looks along Unity -z... in Blender terms: the GLB imports with Blender y = Unity depth
|
|
6
|
+
(negative toward the camera), Blender z = Unity y (map vertical). So the camera sits at large
|
|
7
|
+
negative Blender y looking toward +y, with Blender z as image up and Blender x as image right.
|
|
8
|
+
"""
|
|
9
|
+
import sys, os, bpy, math, mathutils
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
from import_config import CFG as _CFG # noqa: E402
|
|
12
|
+
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
|
|
13
|
+
GLB = argv[0] if argv else _CFG['glb']
|
|
14
|
+
OUT = argv[1] if len(argv) > 1 else '.bitmagic/topdown.png'
|
|
15
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
16
|
+
bpy.ops.import_scene.gltf(filepath=GLB)
|
|
17
|
+
sc = bpy.context.scene
|
|
18
|
+
cam_data = bpy.data.cameras.new('top'); cam_data.type = 'ORTHO'; cam_data.ortho_scale = 150
|
|
19
|
+
cam = bpy.data.objects.new('top', cam_data); sc.collection.objects.link(cam)
|
|
20
|
+
cam.location = (0, -200, 0)
|
|
21
|
+
cam.rotation_euler = (math.radians(90), 0, 0) # look along +y, z up
|
|
22
|
+
sc.camera = cam
|
|
23
|
+
sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 4.0
|
|
24
|
+
so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
|
|
25
|
+
so.rotation_euler = (math.radians(60), math.radians(20), 0)
|
|
26
|
+
sc.render.engine = 'BLENDER_EEVEE_NEXT' if hasattr(bpy.types, 'SceneEEVEE') and 'BLENDER_EEVEE_NEXT' in [e.identifier for e in bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items] else 'BLENDER_EEVEE'
|
|
27
|
+
sc.render.resolution_x = 1400; sc.render.resolution_y = 1400
|
|
28
|
+
sc.render.filepath = OUT
|
|
29
|
+
sc.world = bpy.data.worlds.new('w'); sc.world.use_nodes = True
|
|
30
|
+
sc.world.node_tree.nodes['Background'].inputs[0].default_value = (0.02, 0.02, 0.03, 1)
|
|
31
|
+
sc.world.node_tree.nodes['Background'].inputs[1].default_value = 1.0
|
|
32
|
+
bpy.ops.render.render(write_still=True)
|
|
33
|
+
print('TOPDOWN', OUT)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Copy to unity-import.json and fill in. Every key is documented in import_config.py; the values here are placeholders.",
|
|
3
|
+
"unityRoot": "~/path/to/UnityProject",
|
|
4
|
+
"scene": "Assets/Scenes/Main.unity",
|
|
5
|
+
"levelName": "Main",
|
|
6
|
+
"planeZ": -1.0,
|
|
7
|
+
"planeHalf": 0.8,
|
|
8
|
+
"arenaLimit": 80.0,
|
|
9
|
+
"glb": "build/glb/level.glb",
|
|
10
|
+
"assetName": "level-arena",
|
|
11
|
+
"outputTs": "src/work/ArenaData.ts",
|
|
12
|
+
"prefabs": [],
|
|
13
|
+
"badTags": [],
|
|
14
|
+
"markerScripts": [
|
|
15
|
+
"Spawner.cs",
|
|
16
|
+
"SpawnPoint.cs",
|
|
17
|
+
"PowerupSpawnPoint.cs"
|
|
18
|
+
],
|
|
19
|
+
"markerPrefabs": [
|
|
20
|
+
"Base.prefab",
|
|
21
|
+
"Spawner.prefab",
|
|
22
|
+
"SpawnPoint.prefab",
|
|
23
|
+
"PowerupSpawnPoint.prefab"
|
|
24
|
+
],
|
|
25
|
+
"wallFamilies": [],
|
|
26
|
+
"axisOverride": ""
|
|
27
|
+
}
|