@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 CHANGED
@@ -1209,6 +1209,16 @@ booting one level's terrain under another's objects).
1209
1209
  Runtime level switching is engine API — `engine.loadLevel(levelId)` from game code. These commands
1210
1210
  manage the registry, not gameplay.
1211
1211
 
1212
+ ## Building a level in Blender
1213
+
1214
+ A hand-designed low-poly world — an interior, a station, a stylised set — can be written as a short
1215
+ Python script against the `bmlevel` library that ships in this package (`assets/blender-level/`),
1216
+ built by Blender into one GLB plus the collider / door / light / landmark JSON, audited with ray
1217
+ casts and rendered for review, then registered with `bitmagic assets add … --keep-glb` and loaded
1218
+ by the engine's `MeshLevel`. The scaffolded `building-levels-in-blender` skill walks through it;
1219
+ its first section says when that path applies (a frontier model, and Blender installed) and sends
1220
+ everything else to `bitmagic forge`.
1221
+
1212
1222
  ## Publishing
1213
1223
 
1214
1224
  `bitmagic publish` is two gated steps in one command:
@@ -0,0 +1,25 @@
1
+ # blender-level
2
+
3
+ The Python and Blender scripts behind the `building-levels-in-blender` skill (see
4
+ `cli/src/scaffold/blender-level-skill-files.ts`). A level is a short Python script written against
5
+ `bmlevel` — boxes, prisms, walls, doors, stairs, signage, rooms, lights, landmarks, all in game
6
+ coordinates — that Blender runs to produce one GLB (joined per material), an editable `.blend`
7
+ (named parts), a `bitmagic-mesh-level` JSON the engine's `MeshLevel` loads, and a `doors.json` in
8
+ the shape of `worldProfileData.doors[]`. Shipped here (package.json `files`) rather than rendered
9
+ into every project: most games never build a level this way, and the scripts are large. The
10
+ skill's step 1 tells an agent how to copy them into a project's `tools/blender-level/`.
11
+
12
+ | File | Runs under | Does |
13
+ |---|---|---|
14
+ | `find_blender.py` | python3 | prints the Blender executable, or how to install one |
15
+ | `bmlevel/` | Blender, or python3 for a dry run | the authoring vocabulary (`__init__.py`), the bpy-free maths and JSON writers (`core.py`), the Blender backend (`scene_bpy.py`) and the recording backend (`scene_null.py`) |
16
+ | `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
17
+ | `audit.py` | Blender | ray-cast audit of the COLLIDERS: floor continuity, door and route clearance, sealed envelopes, z-fighting candidates |
18
+ | `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet |
19
+ | `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
20
+ | `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
21
+
22
+ The JSON contract is owned by the engine (`game/src/engine/meshlevel/MeshLevelSchema.ts`,
23
+ documented in `game/agent-docs/mesh-level.md`); `core.py` writes exactly that shape, and the
24
+ engine's validator is the arbiter when the two disagree. The intended end state is a
25
+ `bitmagic level build` command wrapping these once a second game has been through them.
@@ -0,0 +1,232 @@
1
+ """Ray-cast audit of a built level, run inside Blender against the COLLIDERS the engine will get
2
+ (not the art — decoration is exactly what must not be solid):
3
+
4
+ "$BLENDER" -b --python tools/blender-level/audit.py -- build/level/hut.mesh-level.json [--doors closed] [--json out.json]
5
+
6
+ Checks, all driven by the level JSON:
7
+ floor_continuity every volume: a ray down from 1 m above its floor must hit within 1.5 m on a 0.5 m grid
8
+ door_clearance every door (doors open): nothing solid across the opening at three heights
9
+ link_clearance every link: a player capsule (r 0.4, h 1.8) marched from A through the door to B never
10
+ hits a wall and always has a floor under it
11
+ sealed_envelope (--doors closed only) every roofed volume: rays up and outward from inside hit something —
12
+ no hole in a roof or wall the player could fall through
13
+ coplanar_overlap pairs of box colliders with a shared face plane and overlapping extents — z-fighting
14
+ candidates; a WARNING, not a failure
15
+
16
+ Exit 1 on any failure. `--json` also writes the full result for a script to read.
17
+ """
18
+ import json
19
+ import math
20
+ import os
21
+ import sys
22
+
23
+ import bmesh
24
+ from mathutils import Vector
25
+ from mathutils.bvhtree import BVHTree
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ from bmlevel.core import box_corners, flat_to_points, rotate_xz, to_blender # noqa: E402
29
+
30
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
31
+ if not argv:
32
+ print(__doc__)
33
+ sys.exit(2)
34
+ LEVEL_PATH = argv[0]
35
+ DOORS_CLOSED = '--doors' in argv and argv[argv.index('--doors') + 1] == 'closed'
36
+ JSON_OUT = argv[argv.index('--json') + 1] if '--json' in argv else None
37
+ DOORS_PATH = LEVEL_PATH.replace('.mesh-level.json', '.doors.json')
38
+
39
+ level = json.load(open(LEVEL_PATH))
40
+ doors = json.load(open(DOORS_PATH)) if os.path.exists(DOORS_PATH) else []
41
+
42
+ # ---------------------------------------------------------------- BVH from colliders
43
+
44
+ verts: list[Vector] = []
45
+ faces: list[tuple] = []
46
+ BOX_FACES = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
47
+
48
+
49
+ def add_box(center, size, yaw) -> None:
50
+ base = len(verts)
51
+ verts.extend(Vector(to_blender(p)) for p in box_corners(center, size, yaw))
52
+ faces.extend(tuple(base + i for i in f) for f in BOX_FACES)
53
+
54
+
55
+ def add_hull(flat) -> None:
56
+ bm = bmesh.new()
57
+ for p in flat_to_points(flat):
58
+ bm.verts.new(to_blender(p))
59
+ bm.verts.ensure_lookup_table()
60
+ bmesh.ops.convex_hull(bm, input=list(bm.verts))
61
+ base = len(verts)
62
+ index = {v: i for i, v in enumerate(bm.verts)}
63
+ verts.extend(v.co.copy() for v in bm.verts)
64
+ faces.extend(tuple(base + index[v] for v in f.verts) for f in bm.faces)
65
+ bm.free()
66
+
67
+
68
+ boxes = []
69
+ for c in level['colliders']:
70
+ if c['shape'] == 'box':
71
+ add_box(c['position'], c['size'], c.get('yaw', 0.0))
72
+ boxes.append(c)
73
+ else:
74
+ add_hull(c['vertices'])
75
+ if DOORS_CLOSED:
76
+ for d in doors:
77
+ p = d['position']
78
+ add_box((p['x'], p['y'], p['z']), (d['width'], d['height'], d['thickness']), d.get('rotationY', 0.0))
79
+ bvh = BVHTree.FromPolygons(verts, faces) if faces else None
80
+
81
+
82
+ def cast(origin, direction, limit):
83
+ """Ray in GAME coordinates; returns hit distance or None."""
84
+ if bvh is None:
85
+ return None
86
+ o = Vector(to_blender(origin))
87
+ d = Vector(to_blender(direction))
88
+ hit = bvh.ray_cast(o, d, limit)
89
+ return hit[3] if hit[0] is not None else None
90
+
91
+
92
+ # ---------------------------------------------------------------- checks
93
+
94
+ results: dict[str, dict] = {}
95
+
96
+
97
+ def record(check: str, ok: bool, detail: dict) -> None:
98
+ r = results.setdefault(check, {'checks': 0, 'failures': []})
99
+ r['checks'] += 1
100
+ if not ok:
101
+ r['failures'].append(detail)
102
+
103
+
104
+ def volume_grid(v, step=0.5, inset=0.35):
105
+ px, py, pz = v['position']
106
+ sx, sy, sz = v['size']
107
+ yaw = v.get('yaw', 0.0)
108
+ xs = [x for x in frange(-sx / 2 + inset, sx / 2 - inset, step)]
109
+ zs = [z for z in frange(-sz / 2 + inset, sz / 2 - inset, step)]
110
+ for lx in xs:
111
+ for lz in zs:
112
+ wx, wz = rotate_xz(lx, lz, yaw)
113
+ yield (px + wx, py - sy / 2, pz + wz), (lx, lz)
114
+
115
+
116
+ def frange(a, b, step):
117
+ n = max(1, int(round((b - a) / step)))
118
+ return [a + (b - a) * i / n for i in range(n + 1)] if b > a else [(a + b) / 2]
119
+
120
+
121
+ volumes = {v['name']: v for v in level.get('volumes', [])}
122
+ for v in level.get('volumes', []):
123
+ for (x, floor_y, z), local in volume_grid(v):
124
+ hit = cast((x, floor_y + 1.0, z), (0, -1, 0), 2.5)
125
+ record('floor_continuity', hit is not None and hit <= 1.5, {'volume': v['name'], 'at': [round(x, 2), round(z, 2)], 'local': [round(local[0], 2), round(local[1], 2)]})
126
+
127
+ if DOORS_CLOSED:
128
+ for v in level.get('volumes', []):
129
+ if 'unroofed' in v.get('tags', []):
130
+ continue
131
+ sx, sy, sz = v['size']
132
+ yaw = v.get('yaw', 0.0)
133
+ for (x, floor_y, z), local in volume_grid(v, step=1.0):
134
+ up = cast((x, floor_y + 0.5, z), (0, 1, 0), sy + 1.0)
135
+ record('sealed_envelope', up is not None, {'volume': v['name'], 'kind': 'roof', 'at': [round(x, 2), round(z, 2)]})
136
+ for dx, dz, limit in ((1, 0, sx / 2 - local[0] + 1.0), (-1, 0, sx / 2 + local[0] + 1.0), (0, 1, sz / 2 - local[1] + 1.0), (0, -1, sz / 2 + local[1] + 1.0)):
137
+ wx, wz = rotate_xz(dx, dz, yaw)
138
+ side = cast((x, floor_y + sy / 2, z), (wx, 0, wz), limit)
139
+ record('sealed_envelope', side is not None, {'volume': v['name'], 'kind': 'wall', 'at': [round(x, 2), round(z, 2)], 'dir': [dx, dz]})
140
+ else:
141
+ for d in doors:
142
+ p = d['position']
143
+ yaw = d.get('rotationY', 0.0)
144
+ fx, fz = rotate_xz(0.0, 1.0, yaw)
145
+ rx, rz = rotate_xz(1.0, 0.0, yaw)
146
+ base_y = p['y'] - d['height'] / 2
147
+ span = d['thickness'] + 1.2
148
+ for lateral in frange(-d['width'] / 2 + 0.12, d['width'] / 2 - 0.12, 0.25):
149
+ for h in (0.3, 1.0, d['height'] - 0.25):
150
+ origin = (p['x'] + rx * lateral - fx * span / 2, base_y + h, p['z'] + rz * lateral - fz * span / 2)
151
+ hit = cast(origin, (fx, 0, fz), span)
152
+ record('door_clearance', hit is None, {'door': d['id'], 'lateral': round(lateral, 2), 'height': round(h, 2)})
153
+
154
+ door_by_id = {d['id']: d for d in doors}
155
+ for l in level.get('extras', {}).get('links', []):
156
+ a, b = volumes.get(l['from']), volumes.get(l['to'])
157
+ if not a or not b:
158
+ record('link_clearance', False, {'link': l, 'reason': 'unknown volume'})
159
+ continue
160
+ waypoints = [tuple(a['position'])]
161
+ if l.get('door'):
162
+ d = door_by_id.get(l['door'])
163
+ if not d:
164
+ record('link_clearance', False, {'link': l, 'reason': 'unknown door'})
165
+ continue
166
+ waypoints.append((d['position']['x'], d['position']['y'] - d['height'] / 2 + 0.9, d['position']['z']))
167
+ waypoints.append(tuple(b['position']))
168
+ for i in range(len(waypoints) - 1):
169
+ (x0, y0, z0), (x1, y1, z1) = waypoints[i], waypoints[i + 1]
170
+ n = max(1, int(math.hypot(x1 - x0, z1 - z0) / 0.5))
171
+ for k in range(n + 1):
172
+ t = k / n
173
+ x, z = x0 + (x1 - x0) * t, z0 + (z1 - z0) * t
174
+ # Probe for the floor from just above waist height: a door waypoint sits under its
175
+ # header, and a ray from higher up would start inside the lintel.
176
+ probe_y = y0 + (y1 - y0) * t + 0.5
177
+ floor = cast((x, probe_y, z), (0, -1, 0), 3.0)
178
+ record('link_clearance', floor is not None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'reason': 'no floor'})
179
+ if floor is None:
180
+ continue
181
+ fy = probe_y - floor
182
+ for h in (0.5, 1.5):
183
+ for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1)):
184
+ # Rays run along the march direction's perpendiculars and along it; a wall
185
+ # within capsule radius means the route squeezes through something.
186
+ blocked = cast((x, fy + h, z), (dx, 0, dz), 0.4)
187
+ record('link_clearance', blocked is None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'height': h, 'dir': [dx, dz], 'reason': 'wall within 0.4 m'})
188
+
189
+ # Z-fighting candidates: two boxes sharing a face plane with overlapping extents (axis-aligned only).
190
+ warnings = []
191
+ def faces_of(b):
192
+ if abs(b.get('yaw', 0.0)) > 1e-6:
193
+ return []
194
+ px, py, pz = b['position']
195
+ sx, sy, sz = b['size']
196
+ out = []
197
+ rect = [(px - sx / 2, px + sx / 2), (py - sy / 2, py + sy / 2), (pz - sz / 2, pz + sz / 2)]
198
+ for axis in range(3):
199
+ for sign, plane in ((-1, rect[axis][0]), (1, rect[axis][1])):
200
+ out.append((axis, sign, plane, rect))
201
+ return out
202
+ for i in range(len(boxes)):
203
+ for j in range(i + 1, len(boxes)):
204
+ for axis, sign, plane, rect in faces_of(boxes[i]):
205
+ for axis2, sign2, plane2, rect2 in faces_of(boxes[j]):
206
+ # Same plane AND same facing: two visible faces fighting. A wall standing on a floor
207
+ # (floor top against wall bottom) is a contact, not a fight.
208
+ if axis != axis2 or sign != sign2 or abs(plane - plane2) > 0.001:
209
+ continue
210
+ overlap = all(min(rect[k][1], rect2[k][1]) - max(rect[k][0], rect2[k][0]) > 0.01 for k in range(3) if k != axis)
211
+ if overlap:
212
+ warnings.append({'a': boxes[i]['name'], 'b': boxes[j]['name'], 'axis': 'xyz'[axis], 'plane': round(plane, 3)})
213
+ break
214
+
215
+ # ---------------------------------------------------------------- report
216
+
217
+ failed = 0
218
+ mode = 'doors closed' if DOORS_CLOSED else 'doors open'
219
+ print(f'AUDIT {os.path.basename(LEVEL_PATH)} ({mode}): {len(level["colliders"])} colliders, {len(doors)} doors')
220
+ for check, r in results.items():
221
+ n = len(r['failures'])
222
+ failed += n
223
+ print(f' {check:18s} {r["checks"]:6d} rays {n:4d} failed' + ('' if n == 0 else ' e.g. ' + json.dumps(r['failures'][0])))
224
+ if not results:
225
+ print(' (no volumes, doors or links to audit — declare volume()/link() in the level script)')
226
+ if warnings:
227
+ print(f' coplanar_overlap {len(warnings):4d} warning(s) — shared face planes, likely z-fighting: ' + ', '.join(f"{w['a']} / {w['b']}" for w in warnings[:6]))
228
+ if JSON_OUT:
229
+ json.dump({'mode': mode, 'results': results, 'coplanar_warnings': warnings}, open(JSON_OUT, 'w'), indent=1)
230
+ print(f' wrote {JSON_OUT}')
231
+ print('AUDIT_RESULT ' + ('PASS' if failed == 0 else f'FAIL {failed}'))
232
+ sys.exit(0 if failed == 0 else 1)
@@ -0,0 +1,294 @@
1
+ """bmlevel — write a low-poly game level as a short Python script, build it in Blender, and get
2
+ one GLB plus the collider / door / light JSON the Bitmagic engine's `MeshLevel` loads.
3
+
4
+ from bmlevel import *
5
+ mat('stone', (0.5, 0.5, 0.48))
6
+ with context('hut', origin=(0, 0, 0)):
7
+ slab('floor', (0, 0), (8, 6), material='stone')
8
+ wall('north', (-4, -3), (4, -3), height=3, material='stone', opening={'at': 4, 'width': 1.4, 'height': 2.2})
9
+ door('front', (0, 0, -3), yaw=0)
10
+ spawn((0, 0.1, 0))
11
+ export('build/level', 'hut')
12
+
13
+ Every position is in GAME coordinates (metres, Y up, +Z forward, yaw about +Y) inside the current
14
+ `context()`; the Blender axis flip happens once, in the scene backend. Anything built with
15
+ `solid=True` (the default) also becomes a collider record; `decor()` is the same call for things
16
+ the player must never bump into. See core.py for the coordinate argument and the JSON shapes.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import math
22
+ import os
23
+ import sys
24
+ from typing import Sequence
25
+
26
+ from bmlevel.core import (
27
+ BoxCollider, DoorRecord, Frame, HullCollider, Landmark, LevelData, Link, MaterialRecord,
28
+ PointLight, SpotLight, Stairs, Volume, box_corners, hex_color, prism_vertices, ramp_vertices,
29
+ require_convex_outline, rotate_xz,
30
+ )
31
+
32
+ try: # inside Blender
33
+ import bpy # noqa: F401
34
+ from bmlevel.scene_bpy import BpyScene as Scene
35
+ except ImportError: # plain python3: record only
36
+ from bmlevel.scene_null import NullScene as Scene
37
+
38
+ __all__ = [
39
+ 'mat', 'context', 'box', 'decor', 'prism', 'slab', 'wall', 'glazing', 'door', 'stairs', 'text',
40
+ 'volume', 'link', 'lamp', 'spot', 'landmark', 'spawn', 'export', 'level', 'scene', 'reset', 'DRY_RUN',
41
+ ]
42
+
43
+ DRY_RUN = '--dry-run' in sys.argv or not Scene.available
44
+
45
+ level = LevelData()
46
+ scene = Scene()
47
+ _frames: list[Frame] = [Frame()]
48
+
49
+
50
+ def reset() -> None:
51
+ """Start over (a script that builds several levels calls this between them)."""
52
+ global level
53
+ level = LevelData()
54
+ scene.reset()
55
+ _frames[:] = [Frame()]
56
+
57
+
58
+ def _frame() -> Frame:
59
+ return _frames[-1]
60
+
61
+
62
+ def _vec3(p: Sequence[float], what: str) -> tuple[float, float, float]:
63
+ if len(p) != 3:
64
+ raise ValueError(f'{what} must be (x, y, z), got {p!r}')
65
+ return (float(p[0]), float(p[1]), float(p[2]))
66
+
67
+
68
+ # ---------------------------------------------------------------- materials
69
+
70
+ def mat(name: str, rgb: Sequence[float], metal: float = 0.0, roughness: float = 0.5, glow: float = 0.0, alpha: float = 1.0) -> None:
71
+ """Declare a material. Name conventions the engine reads: `glass*` renders as a thin
72
+ transparent pane, `emissive*` is guaranteed to glow (glow > 0 also exports emission)."""
73
+ if len(rgb) != 3:
74
+ raise ValueError(f'mat({name!r}): rgb must be (r, g, b) in 0..1')
75
+ level.materials[name] = MaterialRecord(name, tuple(float(c) for c in rgb), metal, roughness, glow, alpha)
76
+ scene.material(name, rgb, metal, roughness, glow, alpha)
77
+
78
+
79
+ # ---------------------------------------------------------------- frames
80
+
81
+ @contextlib.contextmanager
82
+ def context(name: str, origin: Sequence[float] = (0, 0, 0), yaw: float = 0.0):
83
+ """A named part of the level with its own local frame (a room, a module, a corridor). Objects
84
+ made inside land in a Blender collection of that name. Contexts nest."""
85
+ _frames.append(_frame().child(_vec3(origin, 'context origin'), yaw))
86
+ scene.begin_collection(name)
87
+ try:
88
+ yield
89
+ finally:
90
+ scene.end_collection()
91
+ _frames.pop()
92
+
93
+
94
+ # ---------------------------------------------------------------- solids
95
+
96
+ def box(name: str, pos: Sequence[float], size: Sequence[float], material: str, solid: bool = True, yaw: float = 0.0, bevel: float = 0.0, group: str | None = None) -> None:
97
+ """An oriented box: `pos` is its centre, `size` its full (width, height, depth) in metres."""
98
+ p = _frame().world(_vec3(pos, f'box {name!r} pos'))
99
+ s = _vec3(size, f'box {name!r} size')
100
+ if min(s) <= 0:
101
+ raise ValueError(f'box {name!r}: every size must be > 0')
102
+ w_yaw = _frame().yaw + yaw
103
+ scene.box(name, p, s, w_yaw, material, bevel)
104
+ if solid:
105
+ level.colliders.append(BoxCollider(name, p, s, w_yaw, group))
106
+ else:
107
+ level.visual_points.extend(box_corners(p, s, w_yaw))
108
+
109
+
110
+ def decor(name: str, pos: Sequence[float], size: Sequence[float], material: str, yaw: float = 0.0, bevel: float = 0.0) -> None:
111
+ """A box the player never collides with: cladding, trim, lamps, fasteners, signage plates."""
112
+ box(name, pos, size, material, solid=False, yaw=yaw, bevel=bevel)
113
+
114
+
115
+ def prism(name: str, outline: Sequence[tuple[float, float]], bottom: float, top: float, material: str, solid: bool = True, group: str | None = None) -> None:
116
+ """A polygon in local XZ extruded from `bottom` to `top`. Solid prisms must be convex (a
117
+ concave one raises — split it), because the collider is a convex hull."""
118
+ if top <= bottom:
119
+ raise ValueError(f'prism {name!r}: top must be above bottom')
120
+ if solid:
121
+ require_convex_outline(name, outline)
122
+ verts = prism_vertices(_frame(), outline, bottom, top)
123
+ scene.prism(name, verts, material)
124
+ if solid:
125
+ level.colliders.append(HullCollider(name, verts, group))
126
+ else:
127
+ level.visual_points.extend((verts[i], verts[i + 1], verts[i + 2]) for i in range(0, len(verts), 3))
128
+
129
+
130
+ def slab(name: str, pos_xz: Sequence[float], size_xz: Sequence[float], material: str, y: float = 0.0, thickness: float = 0.5, yaw: float = 0.0) -> None:
131
+ """A floor or ceiling plate whose TOP surface is at `y`."""
132
+ box(name, (pos_xz[0], y - thickness / 2, pos_xz[1]), (size_xz[0], thickness, size_xz[1]), material, yaw=yaw)
133
+
134
+
135
+ def wall(name: str, start: Sequence[float], end: Sequence[float], height: float, material: str, thickness: float = 0.3, base: float = 0.0, opening: dict | None = None) -> None:
136
+ """A wall from local (x, z) `start` to `end`. `opening={'at': m, 'width': w, 'height': h}` cuts
137
+ a doorway centred `at` metres from the start, leaving a lintel above it."""
138
+ (x0, z0), (x1, z1) = (float(start[0]), float(start[1])), (float(end[0]), float(end[1]))
139
+ length = math.hypot(x1 - x0, z1 - z0)
140
+ if length <= 0:
141
+ raise ValueError(f'wall {name!r}: start and end coincide')
142
+ yaw = math.atan2(x1 - x0, z1 - z0) # local +Z along the wall at yaw 0
143
+
144
+ def segment(seg_name: str, t0: float, t1: float, seg_base: float, seg_top: float) -> None:
145
+ if t1 - t0 <= 1e-6 or seg_top - seg_base <= 1e-6:
146
+ return
147
+ tm = (t0 + t1) / 2
148
+ cx, cz = x0 + (x1 - x0) * tm / length, z0 + (z1 - z0) * tm / length
149
+ box(seg_name, (cx, (seg_base + seg_top) / 2, cz), (thickness, seg_top - seg_base, t1 - t0), material, yaw=yaw)
150
+
151
+ if opening is None:
152
+ segment(name, 0.0, length, base, base + height)
153
+ return
154
+ unknown = set(opening) - {'at', 'width', 'height'}
155
+ if unknown:
156
+ raise ValueError(f'wall {name!r}: opening keys must be at/width/height, got {sorted(unknown)}')
157
+ at, w, h = float(opening['at']), float(opening['width']), float(opening['height'])
158
+ if not (0 < at - w / 2 and at + w / 2 < length):
159
+ raise ValueError(f'wall {name!r}: opening {at}±{w / 2} falls outside the wall length {length:.2f}')
160
+ if h >= height:
161
+ raise ValueError(f'wall {name!r}: opening height {h} must be below the wall height {height}')
162
+ segment(f'{name} left', 0.0, at - w / 2, base, base + height)
163
+ segment(f'{name} right', at + w / 2, length, base, base + height)
164
+ segment(f'{name} lintel', at - w / 2, at + w / 2, base + h, base + height)
165
+
166
+
167
+ def glazing(name: str, start: Sequence[float], end: Sequence[float], height: float, glass: str, frame: str, sill: float = 0.9, head: float = 0.4, base: float = 0.0, thickness: float = 0.3) -> None:
168
+ """A window wall: solid sill and head bands in `frame`, a thin pane in `glass` between them.
169
+ The pane is not a collider — the sill and head are, which is what stops the player."""
170
+ wall(f'{name} sill', start, end, sill, frame, thickness, base)
171
+ wall(f'{name} head', start, end, head, frame, thickness, base + height - head)
172
+ (x0, z0), (x1, z1) = start, end
173
+ cx, cz = (x0 + x1) / 2, (z0 + z1) / 2
174
+ yaw = math.atan2(x1 - x0, z1 - z0)
175
+ pane_h = height - sill - head
176
+ box(f'{name} pane', (cx, base + sill + pane_h / 2, cz), (0.04, pane_h, math.hypot(x1 - x0, z1 - z0)), glass, solid=False, yaw=yaw)
177
+ # An invisible-to-decor but solid pane keeps the player from walking through the glass.
178
+ level.colliders.append(BoxCollider(f'{name} pane collider', _frame().world((cx, base + sill + pane_h / 2, cz)), (max(0.1, thickness / 2), pane_h, math.hypot(x1 - x0, z1 - z0)), _frame().yaw + yaw))
179
+
180
+
181
+ def door(id: str, pos: Sequence[float], yaw: float = 0.0, width: float = 1.4, height: float = 2.2, thickness: float = 0.1, kind: str = 'plain', animation: str = 'slide', jamb: str | None = None, jamb_depth: float = 0.3, auto_open_radius: float | None = None) -> None:
182
+ """A doorway: `pos` is the floor point at its centre, the leaf spans `width` across local X at
183
+ `yaw`. Writes a `DoorDefinition` for world.json (the engine builds and animates the leaf) and,
184
+ when `jamb` names a material, solid jambs and a header around it."""
185
+ if kind not in ('plain', 'locked') or animation not in ('hinge', 'slide', 'dissolve'):
186
+ raise ValueError(f'door {id!r}: kind is plain|locked, animation is hinge|slide|dissolve')
187
+ p = _vec3(pos, f'door {id!r} pos')
188
+ w_yaw = _frame().yaw + yaw
189
+ centre = _frame().world((p[0], p[1] + height / 2, p[2]))
190
+ level.doors.append(DoorRecord(id, centre, w_yaw, width, height, thickness, kind, animation, auto_open_radius))
191
+ if jamb:
192
+ for side in (-1, 1):
193
+ ox, oz = rotate_xz(side * (width / 2 + 0.08), 0.0, yaw) # the jambs sit across the leaf, whatever the door's yaw
194
+ box(f'{id} jamb {"L" if side < 0 else "R"}', (p[0] + ox, p[1] + height / 2 + 0.1, p[2] + oz), (0.16, height + 0.2, jamb_depth), jamb, yaw=yaw)
195
+ # The header is a little deeper and taller than the jambs, so no two faces share a plane.
196
+ box(f'{id} header', (p[0], p[1] + height + 0.15, p[2]), (width + 0.36, 0.3, jamb_depth + 0.04), jamb, yaw=yaw)
197
+
198
+
199
+ def stairs(name: str, foot: Sequence[float], rise: float, run: float, material: str, width: float = 2.0, yaw: float = 0.0, steps: int | None = None, rail: str | None = None, treads_solid: bool = False) -> None:
200
+ """A straight flight climbing `rise` over `run` along local +Z from `foot` (the bottom edge's
201
+ centre, at floor level). Visual treads, one smooth ramp collider (so walking up is not a
202
+ stutter), optional rails in `rail`. `treads_solid=True` makes every tread a collider too."""
203
+ f = _vec3(foot, f'stairs {name!r} foot')
204
+ n = steps or max(2, math.ceil(rise / 0.18))
205
+ tread, riser = run / n, rise / n
206
+ for i in range(n):
207
+ top = f[1] + (i + 1) * riser
208
+ box(f'{name} tread {i:02d}', (f[0], (f[1] + top) / 2, f[2] + (i + 0.5) * tread), (width, top - f[1], tread), material, solid=treads_solid, yaw=yaw)
209
+ frame = _frame().child((0, 0, 0), yaw) if yaw else _frame()
210
+ level.colliders.append(HullCollider(f'{name} ramp', ramp_vertices(frame, f, width, rise, run)))
211
+ if rail:
212
+ for side in (-1, 1):
213
+ x = f[0] + side * (width / 2 + 0.05)
214
+ pts = []
215
+ for z, y in ((f[2], f[1]), (f[2] + run, f[1] + rise)):
216
+ for dy in (0.85, 0.95):
217
+ for dx in (-0.03, 0.03):
218
+ pts.extend(frame.world((x + dx, y + dy, z)))
219
+ level.colliders.append(HullCollider(f'{name} rail {"L" if side < 0 else "R"}', pts))
220
+ scene.hull(f'{name} rail {"L" if side < 0 else "R"}', pts, rail)
221
+ for i in range(0, n, max(1, n // 3)):
222
+ box(f'{name} post {"L" if side < 0 else "R"} {i}', (x, f[1] + (i + 1) * riser + 0.45, f[2] + (i + 0.5) * tread), (0.06, 0.9, 0.06), rail, solid=False, yaw=yaw)
223
+ level.stairs.append(Stairs(name, _frame().world(f), frame.world((f[0], f[1] + rise, f[2] + run))))
224
+
225
+
226
+ def text(name: str, string: str, pos: Sequence[float], material: str, yaw: float = 0.0, size: float = 0.5, extrude: float = 0.01, both_faces: bool = False) -> None:
227
+ """3D signage, readable from local -Z at yaw 0 (facing an approaching player). Blender's
228
+ bundled font, no font file. `both_faces` adds a mirrored copy for the other approach."""
229
+ p = _frame().world(_vec3(pos, f'text {name!r} pos'))
230
+ scene.text(name, string, p, _frame().yaw + yaw, size, material, extrude, both_faces)
231
+ level.visual_points.append(p)
232
+
233
+
234
+ # ---------------------------------------------------------------- data only
235
+
236
+ def volume(name: str, pos: Sequence[float], size: Sequence[float], yaw: float = 0.0, tags: Sequence[str] = (), roofed: bool = True) -> None:
237
+ """A named region (a room, an airlock, a trigger area): `pos` is its centre. The audit checks
238
+ floor continuity inside it and, when `roofed`, a sealed envelope. Tags reach the game."""
239
+ p = _frame().world(_vec3(pos, f'volume {name!r} pos'))
240
+ t = [str(x) for x in tags]
241
+ if not roofed:
242
+ t.append('unroofed')
243
+ level.volumes.append(Volume(name, p, _vec3(size, f'volume {name!r} size'), _frame().yaw + yaw, t))
244
+
245
+
246
+ def link(a: str, b: str, door: str | None = None) -> None:
247
+ """Declare that a player can walk from volume `a` to volume `b` (through `door`, if named).
248
+ The audit marches the route with a player-sized capsule."""
249
+ level.links.append(Link(a, b, door))
250
+
251
+
252
+ def lamp(name: str, pos: Sequence[float], color, intensity: float = 8.0, distance: float = 20.0, decay: float = 2.0) -> None:
253
+ """A point light. Point lights share a small pool in the engine — place them where it matters."""
254
+ level.lights.append(PointLight(name, _frame().world(_vec3(pos, f'lamp {name!r} pos')), hex_color(color), intensity, distance, decay))
255
+
256
+
257
+ def spot(name: str, pos: Sequence[float], target: Sequence[float], color, intensity: float = 20.0, angle: float = math.pi / 4, penumbra: float = 0.3, cast_shadow: bool = True) -> None:
258
+ """A spot light; the first two with `cast_shadow` bake shadows from the static level."""
259
+ level.lights.append(SpotLight(name, _frame().world(_vec3(pos, f'spot {name!r} pos')), _frame().world(_vec3(target, f'spot {name!r} target')), hex_color(color), intensity, angle, penumbra, cast_shadow))
260
+
261
+
262
+ def landmark(name: str, pos: Sequence[float], yaw: float = 0.0, tags: Sequence[str] = ()) -> None:
263
+ """A named point the game asks for by name (`level.landmark('exit')`, `landmarksTagged('guard')`)."""
264
+ level.landmarks.append(Landmark(name, _frame().world(_vec3(pos, f'landmark {name!r} pos')), _frame().yaw + yaw, [str(t) for t in tags]))
265
+
266
+
267
+ def spawn(pos: Sequence[float], yaw: float = 0.0) -> None:
268
+ """Where the player starts: the `spawn` landmark, which sync_world.py also writes into world.json."""
269
+ landmark('spawn', pos, yaw)
270
+
271
+
272
+ # ---------------------------------------------------------------- output
273
+
274
+ def export(out_dir: str, name: str) -> dict:
275
+ """Write `<name>.mesh-level.json`, `<name>.doors.json`, `<name>.blend` (named parts, editable)
276
+ and `<name>.glb` (joined per material) into `out_dir`, and print the summary — including the
277
+ exact `bitmagic assets add` line, whose `--height` must be the level's native height."""
278
+ for l in level.links:
279
+ names = {v.name for v in level.volumes}
280
+ for end in (l.a, l.b):
281
+ if end not in names:
282
+ raise ValueError(f'link({l.a!r}, {l.b!r}): no volume named {end!r}')
283
+ if l.door and l.door not in {d.id for d in level.doors}:
284
+ raise ValueError(f'link({l.a!r}, {l.b!r}, door={l.door!r}): no door with that id')
285
+ level_path, doors_path = level.write(out_dir, name)
286
+ glb_path = os.path.join(out_dir, f'{name}.glb')
287
+ blend_path = os.path.join(out_dir, f'{name}.blend')
288
+ size = 0
289
+ if not DRY_RUN:
290
+ scene.save_blend(blend_path)
291
+ size = scene.export_glb(glb_path)
292
+ print(level.summary(name, glb_path), flush=True)
293
+ print(f'WROTE {level_path} {doors_path}' + ('' if DRY_RUN else f' {blend_path} {glb_path} ({size} bytes)'), flush=True)
294
+ return {'level': level_path, 'doors': doors_path, 'glb': glb_path, 'blend': blend_path, 'dry_run': DRY_RUN}