@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.11

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.
Files changed (151) hide show
  1. package/README.md +80 -5
  2. package/assets/blender-level/README.md +25 -0
  3. package/assets/blender-level/audit.py +232 -0
  4. package/assets/blender-level/bmlevel/__init__.py +294 -0
  5. package/assets/blender-level/bmlevel/core.py +438 -0
  6. package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
  7. package/assets/blender-level/bmlevel/scene_null.py +44 -0
  8. package/assets/blender-level/example_level.py +80 -0
  9. package/assets/blender-level/find_blender.py +62 -0
  10. package/assets/blender-level/review.py +117 -0
  11. package/assets/blender-level/sync_world.py +85 -0
  12. package/assets/blender-level/tests/test_core.py +189 -0
  13. package/dist/assets/keep-glb.d.ts +20 -0
  14. package/dist/assets/keep-glb.js +45 -0
  15. package/dist/assets/keep-glb.js.map +1 -0
  16. package/dist/cli.d.ts +5 -0
  17. package/dist/cli.js +2 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/assets.d.ts +3 -0
  20. package/dist/commands/assets.js +33 -3
  21. package/dist/commands/assets.js.map +1 -1
  22. package/dist/commands/cover.js +7 -1
  23. package/dist/commands/cover.js.map +1 -1
  24. package/dist/commands/forge.d.ts +2 -0
  25. package/dist/commands/forge.js +76 -12
  26. package/dist/commands/forge.js.map +1 -1
  27. package/dist/commands/generate.d.ts +88 -1
  28. package/dist/commands/generate.js +400 -21
  29. package/dist/commands/generate.js.map +1 -1
  30. package/dist/commands/init.d.ts +11 -0
  31. package/dist/commands/init.js +33 -1
  32. package/dist/commands/init.js.map +1 -1
  33. package/dist/commands/judge.d.ts +0 -11
  34. package/dist/commands/judge.js +15 -2
  35. package/dist/commands/judge.js.map +1 -1
  36. package/dist/commands/levels.d.ts +1 -1
  37. package/dist/commands/levels.js +14 -3
  38. package/dist/commands/levels.js.map +1 -1
  39. package/dist/commands/reference.js +5 -1
  40. package/dist/commands/reference.js.map +1 -1
  41. package/dist/commands/reset-account.js +2 -11
  42. package/dist/commands/reset-account.js.map +1 -1
  43. package/dist/commands/tools.d.ts +1 -0
  44. package/dist/commands/tools.js +73 -0
  45. package/dist/commands/tools.js.map +1 -0
  46. package/dist/commands/trailer.js +18 -4
  47. package/dist/commands/trailer.js.map +1 -1
  48. package/dist/editor/asset-detail-panel.d.ts +8 -3
  49. package/dist/editor/asset-detail-panel.js +11 -1
  50. package/dist/editor/asset-detail-panel.js.map +1 -1
  51. package/dist/editor/hud-replay-page.js +317 -22
  52. package/dist/editor/hud-replay-page.js.map +1 -1
  53. package/dist/editor/shell-page.js +117 -7
  54. package/dist/editor/shell-page.js.map +1 -1
  55. package/dist/forge/apply-modifications.js +70 -2
  56. package/dist/forge/apply-modifications.js.map +1 -1
  57. package/dist/forge/progress.d.ts +10 -0
  58. package/dist/forge/progress.js +7 -6
  59. package/dist/forge/progress.js.map +1 -1
  60. package/dist/forge/run-pipeline.d.ts +8 -0
  61. package/dist/forge/run-pipeline.js +37 -2
  62. package/dist/forge/run-pipeline.js.map +1 -1
  63. package/dist/forge/stream.d.ts +7 -1
  64. package/dist/forge/stream.js +5 -2
  65. package/dist/forge/stream.js.map +1 -1
  66. package/dist/forge/two-d-gate.d.ts +82 -0
  67. package/dist/forge/two-d-gate.js +232 -0
  68. package/dist/forge/two-d-gate.js.map +1 -0
  69. package/dist/generate/animation-from-video.d.ts +11 -3
  70. package/dist/generate/animation-from-video.js +12 -2
  71. package/dist/generate/animation-from-video.js.map +1 -1
  72. package/dist/generate/model.d.ts +11 -0
  73. package/dist/generate/model.js +41 -1
  74. package/dist/generate/model.js.map +1 -1
  75. package/dist/generate/prop.d.ts +9 -0
  76. package/dist/generate/prop.js +59 -9
  77. package/dist/generate/prop.js.map +1 -1
  78. package/dist/generate/video-review.d.ts +28 -0
  79. package/dist/generate/video-review.js +120 -0
  80. package/dist/generate/video-review.js.map +1 -0
  81. package/dist/project/art-style.d.ts +34 -0
  82. package/dist/project/art-style.js +63 -0
  83. package/dist/project/art-style.js.map +1 -0
  84. package/dist/prompt.d.ts +9 -0
  85. package/dist/prompt.js +36 -0
  86. package/dist/prompt.js.map +1 -0
  87. package/dist/publish/bundle.d.ts +12 -0
  88. package/dist/publish/bundle.js +32 -0
  89. package/dist/publish/bundle.js.map +1 -1
  90. package/dist/render/qr-png.d.ts +6 -3
  91. package/dist/render/qr-png.js +8 -8
  92. package/dist/render/qr-png.js.map +1 -1
  93. package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
  94. package/dist/scaffold/blender-level-skill-files.js +570 -0
  95. package/dist/scaffold/blender-level-skill-files.js.map +1 -0
  96. package/dist/scaffold/claude-settings.js +2 -0
  97. package/dist/scaffold/claude-settings.js.map +1 -1
  98. package/dist/scaffold/project-files.d.ts +2 -1
  99. package/dist/scaffold/project-files.js +125 -42
  100. package/dist/scaffold/project-files.js.map +1 -1
  101. package/dist/scaffold/project.d.ts +9 -0
  102. package/dist/scaffold/project.js +29 -3
  103. package/dist/scaffold/project.js.map +1 -1
  104. package/dist/scaffold/reference-guidance.d.ts +1 -1
  105. package/dist/scaffold/reference-guidance.js +2 -1
  106. package/dist/scaffold/reference-guidance.js.map +1 -1
  107. package/dist/scaffold/skill-files.js +3 -1
  108. package/dist/scaffold/skill-files.js.map +1 -1
  109. package/dist/scaffold/unity-skill-files.js +1 -2
  110. package/dist/scaffold/unity-skill-files.js.map +1 -1
  111. package/dist/tools/bundles.d.ts +21 -0
  112. package/dist/tools/bundles.js +67 -0
  113. package/dist/tools/bundles.js.map +1 -0
  114. package/dist/trailer/ffmpeg.d.ts +10 -0
  115. package/dist/trailer/ffmpeg.js +51 -6
  116. package/dist/trailer/ffmpeg.js.map +1 -1
  117. package/dist/trailer/hud-raster.d.ts +12 -12
  118. package/dist/trailer/hud-raster.js +85 -32
  119. package/dist/trailer/hud-raster.js.map +1 -1
  120. package/dist/trailer/hud-state.d.ts +75 -4
  121. package/dist/trailer/hud-state.js +132 -17
  122. package/dist/trailer/hud-state.js.map +1 -1
  123. package/dist/trailer/io.d.ts +2 -0
  124. package/dist/trailer/io.js +6 -1
  125. package/dist/trailer/io.js.map +1 -1
  126. package/dist/trailer/link.d.ts +1 -0
  127. package/dist/trailer/link.js +15 -0
  128. package/dist/trailer/link.js.map +1 -0
  129. package/dist/trailer/make.d.ts +2 -0
  130. package/dist/trailer/make.js +1 -0
  131. package/dist/trailer/make.js.map +1 -1
  132. package/dist/trailer/merge.js +32 -11
  133. package/dist/trailer/merge.js.map +1 -1
  134. package/dist/trailer/music-beats.js +4 -39
  135. package/dist/trailer/music-beats.js.map +1 -1
  136. package/dist/trailer/render.d.ts +2 -0
  137. package/dist/trailer/render.js +94 -48
  138. package/dist/trailer/render.js.map +1 -1
  139. package/dist/trailer/timeline-types.d.ts +19 -0
  140. package/dist/trailer/timeline-types.js +42 -2
  141. package/dist/trailer/timeline-types.js.map +1 -1
  142. package/dist/trailer/title-raster.js +2 -1
  143. package/dist/trailer/title-raster.js.map +1 -1
  144. package/dist/trailer/titles.js +6 -1
  145. package/dist/trailer/titles.js.map +1 -1
  146. package/dist/trailer/video-chain.d.ts +96 -8
  147. package/dist/trailer/video-chain.js +183 -0
  148. package/dist/trailer/video-chain.js.map +1 -1
  149. package/dist/verify/browser.js +4 -1
  150. package/dist/verify/browser.js.map +1 -1
  151. package/package.json +4 -4
@@ -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}