@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.3
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 +10 -0
- package/assets/blender-level/README.md +25 -0
- package/assets/blender-level/audit.py +232 -0
- package/assets/blender-level/bmlevel/__init__.py +294 -0
- package/assets/blender-level/bmlevel/core.py +438 -0
- package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
- package/assets/blender-level/bmlevel/scene_null.py +44 -0
- package/assets/blender-level/example_level.py +80 -0
- package/assets/blender-level/find_blender.py +62 -0
- package/assets/blender-level/review.py +117 -0
- package/assets/blender-level/sync_world.py +85 -0
- package/assets/blender-level/tests/test_core.py +189 -0
- package/dist/editor/asset-detail-panel.d.ts +8 -3
- package/dist/editor/asset-detail-panel.js +11 -1
- package/dist/editor/asset-detail-panel.js.map +1 -1
- package/dist/editor/shell-page.js +117 -7
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
- package/dist/scaffold/blender-level-skill-files.js +548 -0
- package/dist/scaffold/blender-level-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +13 -6
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +6 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""The bpy-free half of bmlevel: coordinates, records, collider math and the JSON writers.
|
|
2
|
+
|
|
3
|
+
Everything a level script says is in GAME coordinates — metres, Y up, +Z forward, yaw in radians
|
|
4
|
+
about +Y so that an entity's forward at yaw θ is (sin θ, 0, cos θ) (engine/agent-docs/
|
|
5
|
+
coordinate-system.md). Blender is Z up, so a game point (x, y, z) is placed at Blender
|
|
6
|
+
(x, -z, y). That map is a rotation (90° about X, determinant +1), not a reflection, so a yaw
|
|
7
|
+
about game +Y is the SAME angle about Blender +Z. glTF export with `export_yup=True` maps
|
|
8
|
+
Blender (x, y, z) back to (x, z, -y), which undoes the placement exactly: what the script said
|
|
9
|
+
in game coordinates is what the engine reads from the GLB, and the collider records written
|
|
10
|
+
here (in game coordinates, untouched) line up with the art without any fixing up.
|
|
11
|
+
|
|
12
|
+
This module never imports bpy, so `python3 -m unittest` covers it without Blender and a level
|
|
13
|
+
script can be dry-run (`bmlevel.NullScene`) to check its counts and JSON before a real build.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Iterable, Sequence
|
|
22
|
+
|
|
23
|
+
MESH_LEVEL_FORMAT = 'bitmagic-mesh-level'
|
|
24
|
+
MESH_LEVEL_VERSION = 1
|
|
25
|
+
|
|
26
|
+
Vec3 = tuple[float, float, float]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------- coordinates
|
|
30
|
+
|
|
31
|
+
def to_blender(p: Sequence[float]) -> Vec3:
|
|
32
|
+
"""Game (x, y, z) -> Blender (x, -z, y)."""
|
|
33
|
+
return (float(p[0]), -float(p[2]), float(p[1]))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def from_blender(p: Sequence[float]) -> Vec3:
|
|
37
|
+
"""Blender (x, y, z) -> game (x, z, -y)."""
|
|
38
|
+
return (float(p[0]), float(p[2]), -float(p[1]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def blender_yaw(game_yaw: float) -> float:
|
|
42
|
+
"""A yaw about game +Y is the same angle about Blender +Z (the axis map is a proper rotation)."""
|
|
43
|
+
return float(game_yaw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rotate_xz(x: float, z: float, yaw: float) -> tuple[float, float]:
|
|
47
|
+
"""Rotate a local (x, z) offset by `yaw` about +Y: local +Z lands on (sin yaw, cos yaw)."""
|
|
48
|
+
c, s = math.cos(yaw), math.sin(yaw)
|
|
49
|
+
return (c * x + s * z, -s * x + c * z)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Frame:
|
|
54
|
+
"""A local frame: `world()` maps a local game point into the parent frame. Frames compose."""
|
|
55
|
+
origin: Vec3 = (0.0, 0.0, 0.0)
|
|
56
|
+
yaw: float = 0.0
|
|
57
|
+
|
|
58
|
+
def world(self, p: Sequence[float]) -> Vec3:
|
|
59
|
+
x, z = rotate_xz(float(p[0]), float(p[2]), self.yaw)
|
|
60
|
+
return (self.origin[0] + x, self.origin[1] + float(p[1]), self.origin[2] + z)
|
|
61
|
+
|
|
62
|
+
def child(self, origin: Sequence[float], yaw: float) -> 'Frame':
|
|
63
|
+
return Frame(self.world(origin), self.yaw + yaw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------- records
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class BoxCollider:
|
|
70
|
+
name: str
|
|
71
|
+
position: Vec3
|
|
72
|
+
size: Vec3
|
|
73
|
+
yaw: float
|
|
74
|
+
group: str | None = None
|
|
75
|
+
|
|
76
|
+
def to_json(self) -> dict:
|
|
77
|
+
d = {'name': self.name, 'shape': 'box', 'position': list(self.position), 'size': list(self.size), 'yaw': self.yaw}
|
|
78
|
+
if self.group:
|
|
79
|
+
d['group'] = self.group
|
|
80
|
+
return d
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class HullCollider:
|
|
85
|
+
name: str
|
|
86
|
+
vertices: list[float] # flat world xyz
|
|
87
|
+
group: str | None = None
|
|
88
|
+
|
|
89
|
+
def to_json(self) -> dict:
|
|
90
|
+
d = {'name': self.name, 'shape': 'convexHull', 'vertices': list(self.vertices)}
|
|
91
|
+
if self.group:
|
|
92
|
+
d['group'] = self.group
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class DoorRecord:
|
|
98
|
+
"""Exactly the engine's `DoorDefinition` (worldProfileData.doors[]), so sync_world.py copies it."""
|
|
99
|
+
id: str
|
|
100
|
+
position: Vec3
|
|
101
|
+
rotation_y: float
|
|
102
|
+
width: float
|
|
103
|
+
height: float
|
|
104
|
+
thickness: float
|
|
105
|
+
kind: str = 'plain'
|
|
106
|
+
animation: str = 'slide'
|
|
107
|
+
auto_open_radius: float | None = None
|
|
108
|
+
|
|
109
|
+
def to_json(self) -> dict:
|
|
110
|
+
d = {
|
|
111
|
+
'id': self.id,
|
|
112
|
+
'position': {'x': self.position[0], 'y': self.position[1], 'z': self.position[2]},
|
|
113
|
+
'rotationY': self.rotation_y,
|
|
114
|
+
'width': self.width,
|
|
115
|
+
'height': self.height,
|
|
116
|
+
'thickness': self.thickness,
|
|
117
|
+
'kind': self.kind,
|
|
118
|
+
'animation': self.animation,
|
|
119
|
+
}
|
|
120
|
+
if self.auto_open_radius is not None:
|
|
121
|
+
d['autoOpenRadius'] = self.auto_open_radius
|
|
122
|
+
return d
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Volume:
|
|
127
|
+
name: str
|
|
128
|
+
position: Vec3
|
|
129
|
+
size: Vec3
|
|
130
|
+
yaw: float
|
|
131
|
+
tags: list[str]
|
|
132
|
+
|
|
133
|
+
def to_json(self) -> dict:
|
|
134
|
+
return {'name': self.name, 'position': list(self.position), 'size': list(self.size), 'yaw': self.yaw, 'tags': list(self.tags)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Landmark:
|
|
139
|
+
name: str
|
|
140
|
+
position: Vec3
|
|
141
|
+
yaw: float
|
|
142
|
+
tags: list[str]
|
|
143
|
+
|
|
144
|
+
def to_json(self) -> dict:
|
|
145
|
+
return {'name': self.name, 'position': list(self.position), 'yaw': self.yaw, 'tags': list(self.tags)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class PointLight:
|
|
150
|
+
name: str
|
|
151
|
+
position: Vec3
|
|
152
|
+
color: str
|
|
153
|
+
intensity: float
|
|
154
|
+
distance: float
|
|
155
|
+
decay: float
|
|
156
|
+
|
|
157
|
+
def to_json(self) -> dict:
|
|
158
|
+
return {'name': self.name, 'type': 'point', 'position': list(self.position), 'color': self.color,
|
|
159
|
+
'intensity': self.intensity, 'distance': self.distance, 'decay': self.decay}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class SpotLight:
|
|
164
|
+
name: str
|
|
165
|
+
position: Vec3
|
|
166
|
+
target: Vec3
|
|
167
|
+
color: str
|
|
168
|
+
intensity: float
|
|
169
|
+
angle: float
|
|
170
|
+
penumbra: float
|
|
171
|
+
cast_shadow: bool
|
|
172
|
+
|
|
173
|
+
def to_json(self) -> dict:
|
|
174
|
+
return {'name': self.name, 'type': 'spot', 'position': list(self.position), 'target': list(self.target),
|
|
175
|
+
'color': self.color, 'intensity': self.intensity, 'angle': self.angle, 'penumbra': self.penumbra,
|
|
176
|
+
'castShadow': self.cast_shadow}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class Link:
|
|
181
|
+
"""A walkable connection the audit marches: from volume `a` to volume `b`, through `door` if named."""
|
|
182
|
+
a: str
|
|
183
|
+
b: str
|
|
184
|
+
door: str | None = None
|
|
185
|
+
|
|
186
|
+
def to_json(self) -> dict:
|
|
187
|
+
d = {'from': self.a, 'to': self.b}
|
|
188
|
+
if self.door:
|
|
189
|
+
d['door'] = self.door
|
|
190
|
+
return d
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class Stairs:
|
|
195
|
+
"""Review-camera hint: where a staircase starts and ends (game coordinates)."""
|
|
196
|
+
name: str
|
|
197
|
+
foot: Vec3
|
|
198
|
+
top: Vec3
|
|
199
|
+
|
|
200
|
+
def to_json(self) -> dict:
|
|
201
|
+
return {'name': self.name, 'foot': list(self.foot), 'top': list(self.top)}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class MaterialRecord:
|
|
206
|
+
name: str
|
|
207
|
+
rgb: tuple[float, float, float]
|
|
208
|
+
metal: float
|
|
209
|
+
roughness: float
|
|
210
|
+
glow: float
|
|
211
|
+
alpha: float
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------- colours
|
|
215
|
+
|
|
216
|
+
def hex_color(color) -> str:
|
|
217
|
+
"""Accept '#rrggbb' or an (r, g, b) tuple in 0..1 and return '#rrggbb'."""
|
|
218
|
+
if isinstance(color, str):
|
|
219
|
+
if len(color) == 7 and color[0] == '#':
|
|
220
|
+
int(color[1:], 16)
|
|
221
|
+
return color.lower()
|
|
222
|
+
raise ValueError(f'colour {color!r} must be #rrggbb or an (r, g, b) tuple in 0..1')
|
|
223
|
+
r, g, b = (max(0.0, min(1.0, float(c))) for c in color)
|
|
224
|
+
return '#%02x%02x%02x' % (round(r * 255), round(g * 255), round(b * 255))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------- geometry
|
|
228
|
+
|
|
229
|
+
def box_corners(center: Sequence[float], size: Sequence[float], yaw: float) -> list[Vec3]:
|
|
230
|
+
"""The eight world corners of an oriented box (game coordinates)."""
|
|
231
|
+
hx, hy, hz = (float(s) / 2 for s in size)
|
|
232
|
+
out = []
|
|
233
|
+
for sx in (-1, 1):
|
|
234
|
+
for sy in (-1, 1):
|
|
235
|
+
for sz in (-1, 1):
|
|
236
|
+
x, z = rotate_xz(sx * hx, sz * hz, yaw)
|
|
237
|
+
out.append((center[0] + x, center[1] + sy * hy, center[2] + z))
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def convex_hull_2d(points: Sequence[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
242
|
+
"""Andrew's monotone chain; returns the hull counter-clockwise without collinear points."""
|
|
243
|
+
pts = sorted(set((float(x), float(z)) for x, z in points))
|
|
244
|
+
if len(pts) < 3:
|
|
245
|
+
return pts
|
|
246
|
+
|
|
247
|
+
def cross(o, a, b):
|
|
248
|
+
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
|
249
|
+
|
|
250
|
+
lower: list = []
|
|
251
|
+
for p in pts:
|
|
252
|
+
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
|
|
253
|
+
lower.pop()
|
|
254
|
+
lower.append(p)
|
|
255
|
+
upper: list = []
|
|
256
|
+
for p in reversed(pts):
|
|
257
|
+
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
|
|
258
|
+
upper.pop()
|
|
259
|
+
upper.append(p)
|
|
260
|
+
return lower[:-1] + upper[:-1]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def require_convex_outline(name: str, outline: Sequence[tuple[float, float]]) -> None:
|
|
264
|
+
"""A prism collider is a convex hull; a concave outline would silently become its hull."""
|
|
265
|
+
if len(outline) < 3:
|
|
266
|
+
raise ValueError(f'{name}: an outline needs at least 3 points')
|
|
267
|
+
hull = convex_hull_2d(outline)
|
|
268
|
+
if len(hull) != len(set((float(x), float(z)) for x, z in outline)):
|
|
269
|
+
raise ValueError(f'{name}: outline is not convex — split it into convex parts (its hull would drop {len(outline) - len(hull)} point(s))')
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def prism_vertices(frame: Frame, outline: Sequence[tuple[float, float]], bottom: float, top: float) -> list[float]:
|
|
273
|
+
"""Flat world xyz list for an extruded polygon given in the frame's local XZ."""
|
|
274
|
+
out: list[float] = []
|
|
275
|
+
for y in (bottom, top):
|
|
276
|
+
for x, z in outline:
|
|
277
|
+
out.extend(frame.world((x, y, z)))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def ramp_vertices(frame: Frame, foot: Sequence[float], width: float, rise: float, run: float) -> list[float]:
|
|
282
|
+
"""A wedge from the foot (local, at the bottom edge centre) rising `rise` over `run` along local +Z."""
|
|
283
|
+
fx, fy, fz = (float(v) for v in foot)
|
|
284
|
+
hw = width / 2
|
|
285
|
+
pts = [(fx - hw, fy, fz), (fx + hw, fy, fz), (fx - hw, fy, fz + run), (fx + hw, fy, fz + run),
|
|
286
|
+
(fx - hw, fy + rise, fz + run), (fx + hw, fy + rise, fz + run)]
|
|
287
|
+
out: list[float] = []
|
|
288
|
+
for p in pts:
|
|
289
|
+
out.extend(frame.world(p))
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def aabb(points: Iterable[Sequence[float]]) -> tuple[Vec3, Vec3] | None:
|
|
294
|
+
lo = [math.inf] * 3
|
|
295
|
+
hi = [-math.inf] * 3
|
|
296
|
+
any_point = False
|
|
297
|
+
for p in points:
|
|
298
|
+
any_point = True
|
|
299
|
+
for i in range(3):
|
|
300
|
+
lo[i] = min(lo[i], float(p[i]))
|
|
301
|
+
hi[i] = max(hi[i], float(p[i]))
|
|
302
|
+
return ((lo[0], lo[1], lo[2]), (hi[0], hi[1], hi[2])) if any_point else None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def flat_to_points(flat: Sequence[float]) -> list[Vec3]:
|
|
306
|
+
return [(float(flat[i]), float(flat[i + 1]), float(flat[i + 2])) for i in range(0, len(flat) - 2, 3)]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------- the level
|
|
310
|
+
|
|
311
|
+
@dataclass
|
|
312
|
+
class LevelData:
|
|
313
|
+
colliders: list = field(default_factory=list)
|
|
314
|
+
doors: list[DoorRecord] = field(default_factory=list)
|
|
315
|
+
volumes: list[Volume] = field(default_factory=list)
|
|
316
|
+
landmarks: list[Landmark] = field(default_factory=list)
|
|
317
|
+
lights: list = field(default_factory=list)
|
|
318
|
+
links: list[Link] = field(default_factory=list)
|
|
319
|
+
stairs: list[Stairs] = field(default_factory=list)
|
|
320
|
+
materials: dict[str, MaterialRecord] = field(default_factory=dict)
|
|
321
|
+
visual_points: list[Vec3] = field(default_factory=list) # decoration corners, for the extent
|
|
322
|
+
|
|
323
|
+
def require_unique(self, kind: str, items: Sequence, key: str) -> None:
|
|
324
|
+
seen: set = set()
|
|
325
|
+
for item in items:
|
|
326
|
+
k = getattr(item, key)
|
|
327
|
+
if k in seen:
|
|
328
|
+
raise ValueError(f'duplicate {kind} {key} {k!r} — every {kind} needs its own name')
|
|
329
|
+
seen.add(k)
|
|
330
|
+
|
|
331
|
+
def bounds(self) -> tuple[Vec3, Vec3] | None:
|
|
332
|
+
pts: list[Vec3] = list(self.visual_points)
|
|
333
|
+
for c in self.colliders:
|
|
334
|
+
if isinstance(c, BoxCollider):
|
|
335
|
+
pts.extend(box_corners(c.position, c.size, c.yaw))
|
|
336
|
+
else:
|
|
337
|
+
pts.extend(flat_to_points(c.vertices))
|
|
338
|
+
return aabb(pts)
|
|
339
|
+
|
|
340
|
+
def native_height(self) -> float:
|
|
341
|
+
b = self.bounds()
|
|
342
|
+
return round(b[1][1] - b[0][1], 3) if b else 0.0
|
|
343
|
+
|
|
344
|
+
def mesh_level_json(self) -> dict:
|
|
345
|
+
self.require_unique('collider', self.colliders, 'name')
|
|
346
|
+
self.require_unique('volume', self.volumes, 'name')
|
|
347
|
+
self.require_unique('landmark', self.landmarks, 'name')
|
|
348
|
+
self.require_unique('light', self.lights, 'name')
|
|
349
|
+
self.require_unique('door', self.doors, 'id')
|
|
350
|
+
return {
|
|
351
|
+
'format': MESH_LEVEL_FORMAT,
|
|
352
|
+
'version': MESH_LEVEL_VERSION,
|
|
353
|
+
'units': 'meters',
|
|
354
|
+
'colliders': [c.to_json() for c in self.colliders],
|
|
355
|
+
'volumes': [v.to_json() for v in self.volumes],
|
|
356
|
+
'landmarks': [l.to_json() for l in self.landmarks],
|
|
357
|
+
'lights': [l.to_json() for l in self.lights],
|
|
358
|
+
'extras': {
|
|
359
|
+
'links': [l.to_json() for l in self.links],
|
|
360
|
+
'stairs': [s.to_json() for s in self.stairs],
|
|
361
|
+
'doors': [d.id for d in self.doors],
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def doors_json(self) -> list[dict]:
|
|
366
|
+
return [d.to_json() for d in self.doors]
|
|
367
|
+
|
|
368
|
+
def write(self, out_dir: str, name: str) -> tuple[str, str]:
|
|
369
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
370
|
+
level_path = os.path.join(out_dir, f'{name}.mesh-level.json')
|
|
371
|
+
doors_path = os.path.join(out_dir, f'{name}.doors.json')
|
|
372
|
+
with open(level_path, 'w') as f:
|
|
373
|
+
json.dump(self.mesh_level_json(), f, indent=1)
|
|
374
|
+
with open(doors_path, 'w') as f:
|
|
375
|
+
json.dump(self.doors_json(), f, indent=1)
|
|
376
|
+
return level_path, doors_path
|
|
377
|
+
|
|
378
|
+
def summary(self, name: str, glb_path: str) -> str:
|
|
379
|
+
boxes = sum(1 for c in self.colliders if isinstance(c, BoxCollider))
|
|
380
|
+
hulls = len(self.colliders) - boxes
|
|
381
|
+
b = self.bounds()
|
|
382
|
+
extent = 'no geometry' if not b else 'x %.1f..%.1f y %.1f..%.1f z %.1f..%.1f' % (b[0][0], b[1][0], b[0][1], b[1][1], b[0][2], b[1][2])
|
|
383
|
+
has_spawn = any(l.name == 'spawn' for l in self.landmarks)
|
|
384
|
+
lines = [
|
|
385
|
+
f'LEVEL {name}: {len(self.colliders)} colliders ({boxes} box, {hulls} hull), {len(self.doors)} doors, '
|
|
386
|
+
f'{len(self.volumes)} volumes, {len(self.landmarks)} landmarks, {len(self.lights)} lights, {len(self.links)} links',
|
|
387
|
+
f'BOUNDS {extent} native height {self.native_height()} m',
|
|
388
|
+
'REGISTER bitmagic assets add %s --name %s --keep-glb --asset-id level-%s --height %s' % (glb_path, name, name, self.native_height()),
|
|
389
|
+
]
|
|
390
|
+
if not has_spawn:
|
|
391
|
+
lines.append('WARNING no spawn(): the player spawn stays whatever world.json says')
|
|
392
|
+
if not self.colliders:
|
|
393
|
+
lines.append('WARNING no solid geometry: nothing in this level can be stood on')
|
|
394
|
+
return '\n'.join(lines)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------- review cameras
|
|
398
|
+
|
|
399
|
+
def review_cameras(level: dict, doors: Sequence[dict] = ()) -> list[dict]:
|
|
400
|
+
"""Deterministic review shots (game coordinates): overview, both sides of every door, every
|
|
401
|
+
staircase from its foot, every volume from inside. `level` is the mesh-level JSON dict."""
|
|
402
|
+
shots: list[dict] = []
|
|
403
|
+
pts: list[Vec3] = []
|
|
404
|
+
for c in level.get('colliders', []):
|
|
405
|
+
if c['shape'] == 'box':
|
|
406
|
+
pts.extend(box_corners(c['position'], c['size'], c.get('yaw', 0.0)))
|
|
407
|
+
else:
|
|
408
|
+
pts.extend(flat_to_points(c['vertices']))
|
|
409
|
+
b = aabb(pts)
|
|
410
|
+
if b:
|
|
411
|
+
cx, cz = (b[0][0] + b[1][0]) / 2, (b[0][2] + b[1][2]) / 2
|
|
412
|
+
sx, sz = max(b[1][0] - b[0][0], 1.0), max(b[1][2] - b[0][2], 1.0)
|
|
413
|
+
span = max(sx, sz)
|
|
414
|
+
# `scale` fits the wider axis into the frame's WIDTH; a renderer with a 16:9 frame fits the
|
|
415
|
+
# taller axis with `extent` (see review.py).
|
|
416
|
+
shots.append({'name': 'overview', 'kind': 'ortho', 'position': (cx, b[1][1] + span, cz), 'target': (cx, b[0][1], cz), 'scale': span * 1.15, 'extent': (sx, sz)})
|
|
417
|
+
for d in doors:
|
|
418
|
+
p = d['position']
|
|
419
|
+
px, py, pz = (p['x'], p['y'], p['z']) if isinstance(p, dict) else tuple(p)
|
|
420
|
+
yaw = d.get('rotationY', 0.0)
|
|
421
|
+
eye_y = py - d['height'] / 2 + 1.6
|
|
422
|
+
for side, sign in (('a', 1), ('b', -1)):
|
|
423
|
+
ox, oz = rotate_xz(0.0, sign * 4.0, yaw)
|
|
424
|
+
shots.append({'name': f"door-{d['id']}-{side}", 'kind': 'persp', 'position': (px + ox, eye_y, pz + oz), 'target': (px, eye_y - 0.2, pz)})
|
|
425
|
+
for s in level.get('extras', {}).get('stairs', []):
|
|
426
|
+
foot, top = tuple(s['foot']), tuple(s['top'])
|
|
427
|
+
dx, dz = top[0] - foot[0], top[2] - foot[2]
|
|
428
|
+
n = math.hypot(dx, dz) or 1.0
|
|
429
|
+
shots.append({'name': f"stairs-{s['name']}", 'kind': 'persp', 'position': (foot[0] - dx / n * 4, foot[1] + 1.7, foot[2] - dz / n * 4), 'target': (top[0], top[1] + 0.5, top[2])})
|
|
430
|
+
for v in level.get('volumes', []):
|
|
431
|
+
p, size, yaw = v['position'], v['size'], v.get('yaw', 0.0)
|
|
432
|
+
long_axis_z = size[2] >= size[0]
|
|
433
|
+
back = (0.0, -size[2] / 2 + 0.8) if long_axis_z else (-size[0] / 2 + 0.8, 0.0)
|
|
434
|
+
ox, oz = rotate_xz(back[0], back[1], yaw)
|
|
435
|
+
eye_y = p[1] - size[1] / 2 + 1.6
|
|
436
|
+
fx, fz = rotate_xz(0.0, 1.0, yaw) if long_axis_z else rotate_xz(1.0, 0.0, yaw)
|
|
437
|
+
shots.append({'name': f"volume-{v['name']}", 'kind': 'persp', 'position': (p[0] + ox, eye_y, p[2] + oz), 'target': (p[0] + ox + fx * 4, eye_y - 0.3, p[2] + oz + fz * 4)})
|
|
438
|
+
return shots
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The Blender backend: real meshes, materials, text and the GLB export. Runs only inside Blender.
|
|
2
|
+
|
|
3
|
+
Every object is named and lives in the collection of the `context()` that made it, so the saved
|
|
4
|
+
.blend stays editable part by part. The GLB is the opposite: `export_glb` duplicates every mesh
|
|
5
|
+
into a scratch collection, joins the copies per material (one draw call each) and exports only
|
|
6
|
+
those — the named parts are untouched, which is why the .blend is saved BEFORE the export."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import bmesh
|
|
13
|
+
import bpy
|
|
14
|
+
from mathutils import Vector
|
|
15
|
+
|
|
16
|
+
from bmlevel.core import blender_yaw, flat_to_points, to_blender
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BpyScene:
|
|
20
|
+
available = True
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.materials: dict[str, bpy.types.Material] = {}
|
|
24
|
+
self._collection_stack: list[bpy.types.Collection] = []
|
|
25
|
+
self.reset()
|
|
26
|
+
|
|
27
|
+
# ---- scene lifecycle --------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def reset(self) -> None:
|
|
30
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
31
|
+
self.materials.clear()
|
|
32
|
+
self._collection_stack.clear()
|
|
33
|
+
bpy.context.preferences.filepaths.save_version = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def collection(self) -> bpy.types.Collection:
|
|
37
|
+
return self._collection_stack[-1] if self._collection_stack else bpy.context.scene.collection
|
|
38
|
+
|
|
39
|
+
def begin_collection(self, name: str) -> None:
|
|
40
|
+
col = bpy.data.collections.new(name)
|
|
41
|
+
self.collection.children.link(col)
|
|
42
|
+
self._collection_stack.append(col)
|
|
43
|
+
|
|
44
|
+
def end_collection(self) -> None:
|
|
45
|
+
self._collection_stack.pop()
|
|
46
|
+
|
|
47
|
+
# ---- materials --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def material(self, name, rgb, metal, roughness, glow, alpha) -> None:
|
|
50
|
+
m = bpy.data.materials.new(name)
|
|
51
|
+
m.use_nodes = True
|
|
52
|
+
m.use_backface_culling = alpha >= 1
|
|
53
|
+
p = m.node_tree.nodes['Principled BSDF']
|
|
54
|
+
p.inputs['Base Color'].default_value = (*rgb, 1)
|
|
55
|
+
p.inputs['Metallic'].default_value = metal
|
|
56
|
+
p.inputs['Roughness'].default_value = roughness
|
|
57
|
+
p.inputs['Alpha'].default_value = alpha
|
|
58
|
+
if glow:
|
|
59
|
+
p.inputs['Emission Color'].default_value = (*rgb, 1)
|
|
60
|
+
p.inputs['Emission Strength'].default_value = glow
|
|
61
|
+
if alpha < 1:
|
|
62
|
+
m.surface_render_method = 'BLENDED'
|
|
63
|
+
m.diffuse_color = (*rgb, alpha)
|
|
64
|
+
self.materials[name] = m
|
|
65
|
+
|
|
66
|
+
def _mat(self, name: str) -> bpy.types.Material:
|
|
67
|
+
if name not in self.materials:
|
|
68
|
+
raise KeyError(f'material {name!r} was never declared — call mat({name!r}, (r, g, b)) first')
|
|
69
|
+
return self.materials[name]
|
|
70
|
+
|
|
71
|
+
# ---- meshes -----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _add_mesh(self, name: str, verts, faces, material: str) -> bpy.types.Object:
|
|
74
|
+
mesh = bpy.data.meshes.new(name)
|
|
75
|
+
mesh.from_pydata(verts, [], faces)
|
|
76
|
+
mesh.update()
|
|
77
|
+
obj = bpy.data.objects.new(name, mesh)
|
|
78
|
+
self.collection.objects.link(obj)
|
|
79
|
+
obj.data.materials.append(self._mat(material))
|
|
80
|
+
return obj
|
|
81
|
+
|
|
82
|
+
def box(self, name, world_pos, size, world_yaw, material, bevel) -> None:
|
|
83
|
+
w, h, d = (float(s) for s in size)
|
|
84
|
+
hx, hy, hz = w / 2, d / 2, h / 2 # Blender x = game x, Blender y = -game z (depth), Blender z = game y
|
|
85
|
+
verts = [(-hx, -hy, -hz), (hx, -hy, -hz), (hx, hy, -hz), (-hx, hy, -hz), (-hx, -hy, hz), (hx, -hy, hz), (hx, hy, hz), (-hx, hy, hz)]
|
|
86
|
+
faces = [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]
|
|
87
|
+
obj = self._add_mesh(name, verts, faces, material)
|
|
88
|
+
obj.location = to_blender(world_pos)
|
|
89
|
+
obj.rotation_euler = (0.0, 0.0, blender_yaw(world_yaw))
|
|
90
|
+
if bevel:
|
|
91
|
+
bm = bmesh.new()
|
|
92
|
+
bm.from_mesh(obj.data)
|
|
93
|
+
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=min(bevel, min(w, h, d) / 2.5), segments=2, affect='EDGES', clamp_overlap=True)
|
|
94
|
+
bm.to_mesh(obj.data)
|
|
95
|
+
bm.free()
|
|
96
|
+
obj.data.update()
|
|
97
|
+
|
|
98
|
+
def prism(self, name, world_vertices, material) -> None:
|
|
99
|
+
pts = flat_to_points(world_vertices)
|
|
100
|
+
n = len(pts) // 2
|
|
101
|
+
verts = [to_blender(p) for p in pts]
|
|
102
|
+
faces = [tuple(reversed(range(n))), tuple(range(n, 2 * n))] + [(i, (i + 1) % n, (i + 1) % n + n, i + n) for i in range(n)]
|
|
103
|
+
self._add_mesh(name, verts, faces, material)
|
|
104
|
+
|
|
105
|
+
def hull(self, name, world_vertices, material) -> None:
|
|
106
|
+
bm = bmesh.new()
|
|
107
|
+
for p in flat_to_points(world_vertices):
|
|
108
|
+
bm.verts.new(to_blender(p))
|
|
109
|
+
bm.verts.ensure_lookup_table()
|
|
110
|
+
res = bmesh.ops.convex_hull(bm, input=list(bm.verts))
|
|
111
|
+
bmesh.ops.delete(bm, geom=[g for g in res['geom_interior'] if isinstance(g, bmesh.types.BMVert)], context='VERTS')
|
|
112
|
+
mesh = bpy.data.meshes.new(name)
|
|
113
|
+
bm.to_mesh(mesh)
|
|
114
|
+
bm.free()
|
|
115
|
+
mesh.update()
|
|
116
|
+
obj = bpy.data.objects.new(name, mesh)
|
|
117
|
+
self.collection.objects.link(obj)
|
|
118
|
+
obj.data.materials.append(self._mat(material))
|
|
119
|
+
|
|
120
|
+
def text(self, name, string, world_pos, world_yaw, size, material, extrude, both_faces) -> None:
|
|
121
|
+
faces = [world_yaw, world_yaw + math.pi] if both_faces else [world_yaw]
|
|
122
|
+
for i, yaw in enumerate(faces):
|
|
123
|
+
curve = bpy.data.curves.new(f'{name}-{i}', type='FONT') # Blender's bundled Bfont — no font file needed
|
|
124
|
+
curve.body = string
|
|
125
|
+
curve.size = size
|
|
126
|
+
curve.extrude = extrude
|
|
127
|
+
curve.align_x = 'CENTER'
|
|
128
|
+
obj = bpy.data.objects.new(name if i == 0 else f'{name} (reverse)', curve)
|
|
129
|
+
self.collection.objects.link(obj)
|
|
130
|
+
obj.location = to_blender(world_pos)
|
|
131
|
+
# A FONT lies in Blender XY facing +Z; stand it up so it faces game +Z at yaw 0, then yaw it.
|
|
132
|
+
obj.rotation_euler = (math.pi / 2, 0.0, blender_yaw(yaw) + math.pi)
|
|
133
|
+
obj.data.materials.append(self._mat(material))
|
|
134
|
+
bpy.context.view_layer.objects.active = obj
|
|
135
|
+
obj.select_set(True)
|
|
136
|
+
bpy.ops.object.convert(target='MESH')
|
|
137
|
+
obj.select_set(False)
|
|
138
|
+
|
|
139
|
+
# ---- output -----------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def save_blend(self, path: str) -> None:
|
|
142
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
143
|
+
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(path))
|
|
144
|
+
|
|
145
|
+
def export_glb(self, path: str) -> int:
|
|
146
|
+
"""Join duplicates per material and export those alone; the named parts stay as they are."""
|
|
147
|
+
scratch = bpy.data.collections.new('__export__')
|
|
148
|
+
bpy.context.scene.collection.children.link(scratch)
|
|
149
|
+
by_material: dict[str, list[bpy.types.Object]] = {}
|
|
150
|
+
for obj in list(bpy.data.objects):
|
|
151
|
+
if obj.type != 'MESH' or obj.users_collection[0] is scratch:
|
|
152
|
+
continue
|
|
153
|
+
copy = obj.copy()
|
|
154
|
+
copy.data = obj.data.copy()
|
|
155
|
+
scratch.objects.link(copy)
|
|
156
|
+
key = obj.data.materials[0].name if obj.data.materials else '__none__'
|
|
157
|
+
by_material.setdefault(key, []).append(copy)
|
|
158
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
159
|
+
joined: list[bpy.types.Object] = []
|
|
160
|
+
for key, objs in by_material.items():
|
|
161
|
+
for o in objs:
|
|
162
|
+
o.select_set(True)
|
|
163
|
+
bpy.context.view_layer.objects.active = objs[0]
|
|
164
|
+
if len(objs) > 1:
|
|
165
|
+
bpy.ops.object.join()
|
|
166
|
+
active = bpy.context.view_layer.objects.active
|
|
167
|
+
active.name = f'Level_{key}'
|
|
168
|
+
joined.append(active)
|
|
169
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
170
|
+
for o in joined:
|
|
171
|
+
o.select_set(True)
|
|
172
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
173
|
+
bpy.ops.export_scene.gltf(
|
|
174
|
+
filepath=os.path.abspath(path),
|
|
175
|
+
export_format='GLB',
|
|
176
|
+
use_selection=True,
|
|
177
|
+
export_apply=True,
|
|
178
|
+
export_yup=True,
|
|
179
|
+
export_lights=False,
|
|
180
|
+
export_cameras=False,
|
|
181
|
+
export_materials='EXPORT',
|
|
182
|
+
)
|
|
183
|
+
for o in joined:
|
|
184
|
+
bpy.data.objects.remove(o, do_unlink=True)
|
|
185
|
+
bpy.data.collections.remove(scratch)
|
|
186
|
+
return os.path.getsize(path)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The recording backend: same surface as BpyScene, builds nothing. Used by `--dry-run` and by
|
|
2
|
+
the unit tests, so a level script's counts, colliders and JSON can be checked without Blender."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NullScene:
|
|
7
|
+
available = False
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.objects: list[dict] = []
|
|
11
|
+
self.materials: dict[str, dict] = {}
|
|
12
|
+
self.collections: list[str] = []
|
|
13
|
+
|
|
14
|
+
def reset(self) -> None:
|
|
15
|
+
self.objects.clear()
|
|
16
|
+
self.materials.clear()
|
|
17
|
+
self.collections.clear()
|
|
18
|
+
|
|
19
|
+
def material(self, name, rgb, metal, roughness, glow, alpha) -> None:
|
|
20
|
+
self.materials[name] = {'rgb': tuple(rgb), 'metal': metal, 'roughness': roughness, 'glow': glow, 'alpha': alpha}
|
|
21
|
+
|
|
22
|
+
def begin_collection(self, name: str) -> None:
|
|
23
|
+
self.collections.append(name)
|
|
24
|
+
|
|
25
|
+
def end_collection(self) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def box(self, name, world_pos, size, world_yaw, material, bevel) -> None:
|
|
29
|
+
self.objects.append({'kind': 'box', 'name': name, 'position': tuple(world_pos), 'size': tuple(size), 'yaw': world_yaw, 'material': material, 'bevel': bevel})
|
|
30
|
+
|
|
31
|
+
def prism(self, name, world_vertices, material) -> None:
|
|
32
|
+
self.objects.append({'kind': 'prism', 'name': name, 'vertices': list(world_vertices), 'material': material})
|
|
33
|
+
|
|
34
|
+
def hull(self, name, world_vertices, material) -> None:
|
|
35
|
+
self.objects.append({'kind': 'hull', 'name': name, 'vertices': list(world_vertices), 'material': material})
|
|
36
|
+
|
|
37
|
+
def text(self, name, string, world_pos, world_yaw, size, material, extrude, both_faces) -> None:
|
|
38
|
+
self.objects.append({'kind': 'text', 'name': name, 'text': string, 'position': tuple(world_pos), 'yaw': world_yaw, 'size': size, 'material': material, 'both_faces': both_faces})
|
|
39
|
+
|
|
40
|
+
def save_blend(self, path: str) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def export_glb(self, path: str) -> int:
|
|
44
|
+
return 0
|