@bitmagic/cli 0.1.64-dev.2 → 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.
@@ -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