@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,80 @@
1
+ """A complete small level — a two-room hut with a porch and a walled courtyard — exercising every
2
+ bmlevel helper once. It is the reference a level script starts from, and the fixture the tests
3
+ run through the null backend.
4
+
5
+ "$BLENDER" -b --python tools/blender-level/example_level.py -- --out build/level
6
+ python3 tools/blender-level/example_level.py --dry-run # counts and JSON only
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+ from bmlevel import ( # noqa: E402
13
+ box, context, decor, door, export, glazing, lamp, landmark, link, mat, prism, slab, spawn, spot, stairs, text, volume, wall,
14
+ )
15
+
16
+
17
+ def build() -> None:
18
+ mat('plaster', (0.82, 0.78, 0.7))
19
+ mat('timber', (0.45, 0.3, 0.16), roughness=0.7)
20
+ mat('stone', (0.42, 0.42, 0.4), roughness=0.9)
21
+ mat('deck', (0.3, 0.25, 0.18), roughness=0.8)
22
+ mat('glass_pane', (0.55, 0.75, 0.9), alpha=0.25)
23
+ mat('emissive_lamp', (1.0, 0.85, 0.55), glow=4.0)
24
+ mat('brass', (0.75, 0.6, 0.3), metal=0.8, roughness=0.35)
25
+
26
+ with context('hut', origin=(0, 0, 0)):
27
+ # Two rooms, 8 x 6 m and 5 x 6 m, sharing an internal wall with a doorway.
28
+ slab('hut floor', (2.5, 0), (13, 6), 'deck', y=0.0, thickness=0.4)
29
+ slab('hut ceiling', (2.5, 0), (13, 6), 'timber', y=3.2, thickness=0.2)
30
+ wall('west', (-4, -3), (-4, 3), 3.0, 'plaster')
31
+ wall('east', (9, -3), (9, 3), 3.0, 'plaster')
32
+ wall('south', (-4, 3), (9, 3), 3.0, 'plaster')
33
+ wall('north', (-4, -3), (9, -3), 3.0, 'plaster', opening={'at': 4, 'width': 1.4, 'height': 2.2})
34
+ door('front', (0, 0, -3), yaw=0, jamb='timber')
35
+ wall('partition', (4, -3), (4, 3), 3.0, 'plaster', opening={'at': 3, 'width': 1.2, 'height': 2.1})
36
+ door('inner', (4, 0, 0), yaw=1.5707963, width=1.2, height=2.1, animation='hinge', jamb='timber')
37
+ glazing('south window', (5, 3.01), (8, 3.01), 3.0, 'glass_pane', 'plaster', thickness=0.28)
38
+ box('table', (-2.2, 0.4, 1.6), (1.6, 0.8, 0.9), 'timber', group='environment', bevel=0.02) # off the walk line: the audit marches room centre to door
39
+ decor('rug', (0, 0.006, 0.5), (3, 0.012, 2.4), 'stone')
40
+ decor('lamp shade', (0, 2.9, 0), (0.5, 0.15, 0.5), 'emissive_lamp')
41
+ lamp('hut lamp', (0, 2.7, 0), '#ffd9a0', intensity=10, distance=12)
42
+ lamp('back room lamp', (6.5, 2.7, 0), '#ffe0b0', intensity=8, distance=10)
43
+ text('sign', 'HUT', (0, 2.55, -3.2), 'brass', size=0.35, both_faces=True)
44
+ volume('front room', (0, 1.6, 0), (8, 3.2, 6), tags=['indoors'])
45
+ volume('back room', (6.5, 1.6, 0), (5, 3.2, 6), tags=['indoors'])
46
+ link('front room', 'back room', door='inner')
47
+ landmark('table', (-2.2, 0.8, 1.6), tags=['prop'])
48
+
49
+ with context('porch', origin=(0, 0, -3)):
50
+ # Porch deck 1.2 m below the hut floor, four steps up to the door.
51
+ slab('porch deck', (0, -4.5), (6, 3), 'deck', y=-1.2, thickness=0.3)
52
+ stairs('porch steps', (0, -1.2, -3.05), rise=1.2, run=3.05, material='stone', width=2.0, rail='brass') # overlaps the hut floor edge by 5 cm: abutting edges leave a seam
53
+ # A mitred corner piece: a convex prism, the way angled junctions are built.
54
+ prism('porch corner', [(-3, -3), (-3, -6), (-4.5, -6)], -1.5, -0.9, 'stone')
55
+ spot('porch spot', (0, 2.8, -4.5), (0, 0, -1.5), '#cfe6ff', intensity=18)
56
+ volume('porch', (0, -0.3, -4.5), (6, 2.6, 3), roofed=False)
57
+ link('porch', 'front room', door='front')
58
+
59
+ with context('courtyard', origin=(0, -1.2, -15)): # starts where the porch deck ends (z = -9), no overlapping slabs
60
+ slab('courtyard ground', (0, 0), (16, 12), 'stone', y=0.0, thickness=0.4)
61
+ for name, a, b in (('yard west', (-8, -6), (-8, 6)), ('yard east', (8, -6), (8, 6)), ('yard south', (-8, -6), (8, -6))):
62
+ wall(name, a, b, 1.2, 'stone', thickness=0.4)
63
+ box('well', (5, 0.5, -2), (1.4, 1.0, 1.4), 'stone', group='environment', bevel=0.05)
64
+ lamp('yard lamp', (-4, 2.5, 0), '#ffffff', intensity=6, distance=16)
65
+ volume('courtyard', (0, 1.0, 0), (16, 2.0, 12), roofed=False)
66
+ link('courtyard', 'porch')
67
+ landmark('well', (5, 1.0, -2), tags=['prop'])
68
+ landmark('guard post', (-5, 0.1, 3), yaw=0.0, tags=['guard'])
69
+ spawn((0, 0.1, 4), yaw=3.14159)
70
+
71
+
72
+ def main() -> None:
73
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
74
+ out = argv[argv.index('--out') + 1] if '--out' in argv else 'build/level'
75
+ build()
76
+ export(out, 'hut')
77
+
78
+
79
+ if __name__ == '__main__':
80
+ main()
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Print the Blender executable to use, or say how to install one and exit 1.
3
+
4
+ BLENDER="$(python3 tools/blender-level/find_blender.py)"
5
+ python3 tools/blender-level/find_blender.py --check # also runs `blender -b --version`
6
+
7
+ Order: $BLENDER, `blender` on PATH, the macOS app bundle, the Windows install directory.
8
+ """
9
+ import glob
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+
15
+ CANDIDATES = [
16
+ '/Applications/Blender.app/Contents/MacOS/Blender',
17
+ os.path.expanduser('~/Applications/Blender.app/Contents/MacOS/Blender'),
18
+ '/usr/bin/blender',
19
+ '/snap/bin/blender',
20
+ ]
21
+ INSTALL_HINT = (
22
+ 'Blender was not found. `building-levels-in-blender` builds the level with it — install it '
23
+ '(macOS: brew install --cask blender · Debian/Ubuntu: sudo apt install blender · '
24
+ 'Windows: winget install BlenderFoundation.Blender) and set BLENDER=<path> if it lives somewhere else.'
25
+ )
26
+
27
+
28
+ def find_blender() -> str | None:
29
+ env = os.environ.get('BLENDER')
30
+ if env and os.path.isfile(env):
31
+ return env
32
+ on_path = shutil.which('blender')
33
+ if on_path:
34
+ return on_path
35
+ for c in CANDIDATES:
36
+ if os.path.isfile(c):
37
+ return c
38
+ for pattern in ('C:/Program Files/Blender Foundation/Blender*/blender.exe',):
39
+ hits = sorted(glob.glob(pattern))
40
+ if hits:
41
+ return hits[-1]
42
+ return None
43
+
44
+
45
+ def main() -> int:
46
+ path = find_blender()
47
+ if not path:
48
+ print(INSTALL_HINT, file=sys.stderr)
49
+ return 1
50
+ if '--check' in sys.argv:
51
+ probe = subprocess.run([path, '-b', '--version'], capture_output=True, text=True)
52
+ if probe.returncode != 0:
53
+ print(f'{path} exists but `-b --version` failed:\n{probe.stderr.strip()}', file=sys.stderr)
54
+ return 1
55
+ version = next((line for line in probe.stdout.splitlines() if line.startswith('Blender')), '').strip()
56
+ print(f'{version} at {path}', file=sys.stderr)
57
+ print(path)
58
+ return 0
59
+
60
+
61
+ if __name__ == '__main__':
62
+ sys.exit(main())
@@ -0,0 +1,117 @@
1
+ """Headless review renders of a built level, run inside Blender:
2
+
3
+ "$BLENDER" -b --python tools/blender-level/review.py -- build/level/hut.blend build/level/hut.mesh-level.json build/level/review/
4
+
5
+ Opens the .blend (named parts intact) and renders a deterministic set of shots from the level
6
+ JSON — `overview` (top-down), `door-<id>-a/b` (both approaches of every door), `stairs-<name>`
7
+ (from the foot), `volume-<name>` (inside every room) — into the output directory, plus
8
+ `contact-sheet.png` with all of them. Read the sheet, then the shots that look wrong.
9
+
10
+ EEVEE, 960 x 540, a camera-mounted fill light so interiors are legible whatever the JSON lights
11
+ say. This is a look at the geometry, not the game's lighting — `bitmagic verify` shows that.
12
+ """
13
+ import json
14
+ import math
15
+ import os
16
+ import sys
17
+
18
+ import bpy
19
+ from mathutils import Vector
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ from bmlevel.core import review_cameras, to_blender # noqa: E402
23
+
24
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
25
+ if len(argv) < 3:
26
+ print(__doc__)
27
+ sys.exit(2)
28
+ BLEND, LEVEL_PATH, OUT = argv[0], argv[1], argv[2]
29
+ WIDTH, HEIGHT = 960, 540
30
+
31
+ level = json.load(open(LEVEL_PATH))
32
+ doors_path = LEVEL_PATH.replace('.mesh-level.json', '.doors.json')
33
+ doors = json.load(open(doors_path)) if os.path.exists(doors_path) else []
34
+ os.makedirs(OUT, exist_ok=True)
35
+
36
+ bpy.ops.wm.open_mainfile(filepath=os.path.abspath(BLEND))
37
+ scene = bpy.context.scene
38
+ engines = [e.identifier for e in bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items]
39
+ scene.render.engine = 'BLENDER_EEVEE_NEXT' if 'BLENDER_EEVEE_NEXT' in engines else 'BLENDER_EEVEE'
40
+ if hasattr(scene, 'eevee'):
41
+ scene.eevee.taa_render_samples = 16
42
+ scene.render.resolution_x, scene.render.resolution_y = WIDTH, HEIGHT
43
+ scene.render.resolution_percentage = 100
44
+ scene.render.image_settings.file_format = 'PNG'
45
+ if scene.world is None:
46
+ scene.world = bpy.data.worlds.new('review')
47
+ scene.world.use_nodes = True
48
+ bg = scene.world.node_tree.nodes['Background']
49
+ bg.inputs[0].default_value = (0.35, 0.4, 0.5, 1)
50
+ bg.inputs[1].default_value = 0.6
51
+
52
+ cam_data = bpy.data.cameras.new('review')
53
+ cam = bpy.data.objects.new('review', cam_data)
54
+ scene.collection.objects.link(cam)
55
+ scene.camera = cam
56
+ fill_data = bpy.data.lights.new('review-fill', 'POINT')
57
+ fill_data.energy = 400
58
+ fill_data.shadow_soft_size = 2
59
+ fill = bpy.data.objects.new('review-fill', fill_data)
60
+ scene.collection.objects.link(fill)
61
+ sun_data = bpy.data.lights.new('review-sun', 'SUN')
62
+ sun_data.energy = 2.5
63
+ sun = bpy.data.objects.new('review-sun', sun_data)
64
+ scene.collection.objects.link(sun)
65
+ sun.rotation_euler = (math.radians(50), math.radians(15), math.radians(30))
66
+
67
+
68
+ def render(shot: dict) -> str:
69
+ pos = Vector(to_blender(shot['position']))
70
+ target = Vector(to_blender(shot['target']))
71
+ cam.location = pos
72
+ cam.rotation_euler = (target - pos).to_track_quat('-Z', 'Y').to_euler()
73
+ if shot['kind'] == 'ortho':
74
+ cam_data.type = 'ORTHO'
75
+ sx, sz = shot['extent']
76
+ cam_data.ortho_scale = max(sx, sz * WIDTH / HEIGHT) * 1.15 # fit both axes into a 16:9 frame
77
+ fill.hide_render = True
78
+ else:
79
+ cam_data.type = 'PERSP'
80
+ cam_data.lens = 24
81
+ fill.hide_render = False
82
+ fill.location = pos + Vector((0, 0, 0.6))
83
+ path = os.path.join(OUT, f"{shot['name']}.png")
84
+ scene.render.filepath = path
85
+ bpy.ops.render.render(write_still=True)
86
+ return path
87
+
88
+
89
+ shots = review_cameras(level, doors)
90
+ paths = [render(s) for s in shots]
91
+ for s, p in zip(shots, paths):
92
+ print(f'SHOT {s["name"]} -> {p}')
93
+
94
+ # Contact sheet: every shot at half size, four per row, labelled by file name in the log.
95
+ import numpy as np # Blender bundles numpy # noqa: E402
96
+
97
+ cols = 4
98
+ thumb_w, thumb_h = WIDTH // 2, HEIGHT // 2
99
+ rows = math.ceil(len(paths) / cols)
100
+ sheet = np.zeros((rows * thumb_h, cols * thumb_w, 4), dtype=np.float32)
101
+ sheet[..., 3] = 1
102
+ for i, p in enumerate(paths):
103
+ img = bpy.data.images.load(p)
104
+ px = np.array(img.pixels[:], dtype=np.float32).reshape(img.size[1], img.size[0], 4)
105
+ small = px[::2, ::2][:thumb_h, :thumb_w]
106
+ r, c = divmod(i, cols)
107
+ y0 = (rows - 1 - r) * thumb_h # Blender image rows run bottom-up
108
+ sheet[y0:y0 + small.shape[0], c * thumb_w:c * thumb_w + small.shape[1]] = small
109
+ bpy.data.images.remove(img)
110
+ out_img = bpy.data.images.new('contact', cols * thumb_w, rows * thumb_h, alpha=True)
111
+ out_img.pixels = sheet.ravel().tolist()
112
+ sheet_path = os.path.join(OUT, 'contact-sheet.png')
113
+ out_img.filepath_raw = sheet_path
114
+ out_img.file_format = 'PNG'
115
+ out_img.save()
116
+ order = ', '.join(f'{i + 1}:{s["name"]}' for i, s in enumerate(shots))
117
+ print(f'CONTACT_SHEET {sheet_path} ({cols} per row, left to right, top to bottom: {order})')
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Carry a built level's doors and spawn into the game's world.json.
3
+
4
+ python3 tools/blender-level/sync_world.py build/level/hut.doors.json build/level/hut.mesh-level.json src/work/world.json
5
+ [--level-id <id>] [--keep-spawn] [--keep-terrain]
6
+
7
+ - doors: upserts `worldProfileData.doors[]` by id. The build owns the geometry fields (position,
8
+ rotationY, width, height, thickness) and the animation; creator-owned fields on an existing
9
+ entry — kind, keyId, assetId, color, autoOpenRadius, maxOpenAngleDeg — survive unless the
10
+ build sets them. Doors in world.json that the build no longer has are reported, never deleted.
11
+ - spawn: the `spawn` landmark becomes playerSpawnPosition / playerSpawnRotation (skip: --keep-spawn).
12
+ - terrain: sets `terrain.shape` to 'none' so no voxel ground is built under the level (skip:
13
+ --keep-terrain).
14
+
15
+ A script rather than jq because the merge rule IS the point.
16
+ """
17
+ import json
18
+ import sys
19
+
20
+ BUILD_OWNED = ('position', 'rotationY', 'width', 'height', 'thickness', 'animation')
21
+
22
+
23
+ def merge_doors(existing: list, built: list, level_id: str | None) -> tuple[list, dict]:
24
+ by_id = {d['id']: dict(d) for d in existing}
25
+ added, updated = [], []
26
+ for door in built:
27
+ entry = by_id.get(door['id'])
28
+ if entry is None:
29
+ entry = dict(door)
30
+ added.append(door['id'])
31
+ else:
32
+ for key in BUILD_OWNED:
33
+ entry[key] = door[key]
34
+ for key in ('kind', 'autoOpenRadius'):
35
+ if key in door and key not in entry:
36
+ entry[key] = door[key]
37
+ updated.append(door['id'])
38
+ if level_id:
39
+ entry['levelId'] = level_id
40
+ by_id[door['id']] = entry
41
+ built_ids = {d['id'] for d in built}
42
+ orphans = [d['id'] for d in existing if d['id'] not in built_ids]
43
+ return list(by_id.values()), {'added': added, 'updated': updated, 'orphans': orphans}
44
+
45
+
46
+ def main(argv: list[str]) -> int:
47
+ if len(argv) < 3:
48
+ print(__doc__, file=sys.stderr)
49
+ return 2
50
+ doors_path, level_path, world_path = argv[:3]
51
+ level_id = argv[argv.index('--level-id') + 1] if '--level-id' in argv else None
52
+ with open(doors_path) as f:
53
+ built_doors = json.load(f)
54
+ with open(level_path) as f:
55
+ level = json.load(f)
56
+ with open(world_path) as f:
57
+ world = json.load(f)
58
+ profile = world.setdefault('worldProfileData', {})
59
+
60
+ profile['doors'], report = merge_doors(profile.get('doors', []), built_doors, level_id)
61
+ print(f"doors: {len(report['added'])} added, {len(report['updated'])} updated" + (f", not in this build: {', '.join(report['orphans'])}" if report['orphans'] else ''))
62
+
63
+ spawn = next((l for l in level.get('landmarks', []) if l['name'] == 'spawn'), None)
64
+ if spawn and '--keep-spawn' not in argv:
65
+ x, y, z = spawn['position']
66
+ profile['playerSpawnPosition'] = {'x': x, 'y': y, 'z': z}
67
+ profile['playerSpawnRotation'] = spawn.get('yaw', 0.0)
68
+ print(f'spawn: playerSpawnPosition ({x}, {y}, {z}) yaw {spawn.get("yaw", 0.0)}')
69
+ elif not spawn:
70
+ print('spawn: no `spawn` landmark in the level — playerSpawnPosition left as is')
71
+
72
+ if '--keep-terrain' not in argv:
73
+ terrain = profile.setdefault('terrain', {})
74
+ if terrain.get('shape') != 'none':
75
+ terrain['shape'] = 'none'
76
+ print("terrain: shape set to 'none' (no voxel ground under the level)")
77
+
78
+ with open(world_path, 'w') as f:
79
+ json.dump(world, f, indent=2)
80
+ f.write('\n')
81
+ return 0
82
+
83
+
84
+ if __name__ == '__main__':
85
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,189 @@
1
+ """Blender-free checks of bmlevel: `python3 -m unittest discover -s tools/blender-level/tests`.
2
+
3
+ Runs the example level through the null backend and checks the invariants the engine relies on:
4
+ the axis round trip, collider records that match what was built, the door records' shape
5
+ (exactly the engine's DoorDefinition), the JSON the exporter writes, review cameras, and the
6
+ world.json merge.
7
+ """
8
+ import importlib
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ ROOT = os.path.dirname(HERE)
18
+ sys.path.insert(0, ROOT)
19
+
20
+ from bmlevel import core # noqa: E402
21
+
22
+
23
+ def fresh_bmlevel():
24
+ import bmlevel
25
+ bmlevel.reset()
26
+ return bmlevel
27
+
28
+
29
+ class CoordinateTests(unittest.TestCase):
30
+ def test_axis_round_trip(self):
31
+ for p in ((1, 2, 3), (-4.5, 0, 9), (0, -1, 0)):
32
+ self.assertEqual(core.from_blender(core.to_blender(p)), tuple(float(v) for v in p))
33
+
34
+ def test_forward_at_yaw_matches_the_engine_convention(self):
35
+ x, z = core.rotate_xz(0, 1, math.pi / 2)
36
+ self.assertAlmostEqual(x, 1.0)
37
+ self.assertAlmostEqual(z, 0.0)
38
+
39
+ def test_frames_compose(self):
40
+ outer = core.Frame((10, 0, 0), math.pi / 2)
41
+ inner = outer.child((0, 0, 2), 0.0)
42
+ wx, wy, wz = inner.world((0, 1, 0))
43
+ self.assertAlmostEqual(wx, 12.0)
44
+ self.assertAlmostEqual(wy, 1.0)
45
+ self.assertAlmostEqual(wz, 0.0)
46
+
47
+ def test_box_corners_rotate(self):
48
+ corners = core.box_corners((0, 0, 0), (2, 2, 4), math.pi / 2)
49
+ xs = sorted(round(c[0], 6) for c in corners)
50
+ self.assertEqual(xs[0], -2.0)
51
+ self.assertEqual(xs[-1], 2.0)
52
+
53
+ def test_concave_outline_is_rejected(self):
54
+ with self.assertRaises(ValueError):
55
+ core.require_convex_outline('L', [(0, 0), (2, 0), (2, 1), (1, 1), (1, 2), (0, 2)])
56
+ core.require_convex_outline('tri', [(0, 0), (2, 0), (1, 2)])
57
+
58
+ def test_hex_color(self):
59
+ self.assertEqual(core.hex_color((1, 0.5, 0)), '#ff8000')
60
+ self.assertEqual(core.hex_color('#ABCDEF'), '#abcdef')
61
+ with self.assertRaises(ValueError):
62
+ core.hex_color('red')
63
+
64
+
65
+ class ExampleLevelTests(unittest.TestCase):
66
+ @classmethod
67
+ def setUpClass(cls):
68
+ cls.bm = fresh_bmlevel()
69
+ example = importlib.import_module('example_level')
70
+ example.build()
71
+ cls.level = cls.bm.level
72
+ cls.out = tempfile.mkdtemp()
73
+ cls.paths = cls.bm.export(cls.out, 'hut')
74
+ with open(cls.paths['level']) as f:
75
+ cls.level_json = json.load(f)
76
+ with open(cls.paths['doors']) as f:
77
+ cls.doors_json = json.load(f)
78
+
79
+ def test_counts(self):
80
+ self.assertEqual(len(self.level.doors), 2)
81
+ self.assertEqual(len(self.level.volumes), 4)
82
+ self.assertEqual(len(self.level.links), 3)
83
+ self.assertGreater(len(self.level.colliders), 20)
84
+ self.assertTrue(any(l.name == 'spawn' for l in self.level.landmarks))
85
+
86
+ def test_solid_boxes_become_colliders_and_decor_does_not(self):
87
+ names = {c.name for c in self.level.colliders}
88
+ self.assertIn('table', names)
89
+ self.assertIn('west', names)
90
+ self.assertNotIn('rug', names)
91
+ self.assertNotIn('lamp shade', names)
92
+ for c in self.level.colliders:
93
+ if c.name == 'table':
94
+ self.assertEqual(c.group, 'environment')
95
+
96
+ def test_stairs_emit_a_ramp_hull_and_rails(self):
97
+ hulls = {c.name for c in self.level.colliders if isinstance(c, core.HullCollider)}
98
+ self.assertIn('porch steps ramp', hulls)
99
+ self.assertIn('porch steps rail L', hulls)
100
+ self.assertEqual(len(self.level.stairs), 1)
101
+
102
+ def test_door_records_are_door_definitions(self):
103
+ for d in self.doors_json:
104
+ self.assertEqual(set(d), {'id', 'position', 'rotationY', 'width', 'height', 'thickness', 'kind', 'animation'})
105
+ self.assertEqual(set(d['position']), {'x', 'y', 'z'})
106
+ inner = next(d for d in self.doors_json if d['id'] == 'inner')
107
+ self.assertAlmostEqual(inner['rotationY'], 1.5707963)
108
+ self.assertEqual(inner['animation'], 'hinge')
109
+
110
+ def test_yawed_door_jambs_flank_the_opening(self):
111
+ jambs = [c for c in self.level.colliders if c.name.startswith('inner jamb')]
112
+ self.assertEqual(len(jambs), 2)
113
+ zs = sorted(c.position[2] for c in jambs)
114
+ self.assertLess(zs[0], -0.6)
115
+ self.assertGreater(zs[1], 0.6)
116
+ for c in jambs:
117
+ self.assertAlmostEqual(c.position[0], 4.0)
118
+
119
+ def test_mesh_level_json_shape(self):
120
+ j = self.level_json
121
+ self.assertEqual(j['format'], core.MESH_LEVEL_FORMAT)
122
+ self.assertEqual(j['version'], core.MESH_LEVEL_VERSION)
123
+ self.assertEqual(j['units'], 'meters')
124
+ self.assertEqual(set(j), {'format', 'version', 'units', 'colliders', 'volumes', 'landmarks', 'lights', 'extras'})
125
+ for c in j['colliders']:
126
+ self.assertIn(c['shape'], ('box', 'convexHull'))
127
+ if c['shape'] == 'convexHull':
128
+ self.assertEqual(len(c['vertices']) % 3, 0)
129
+ self.assertGreaterEqual(len(c['vertices']), 12)
130
+ self.assertEqual(len(j['extras']['links']), 3)
131
+ self.assertIn('unroofed', next(v for v in j['volumes'] if v['name'] == 'porch')['tags'])
132
+ spot = next(l for l in j['lights'] if l['type'] == 'spot')
133
+ self.assertEqual(set(spot), {'name', 'type', 'position', 'target', 'color', 'intensity', 'angle', 'penumbra', 'castShadow'})
134
+
135
+ def test_native_height_and_summary(self):
136
+ self.assertAlmostEqual(self.level.native_height(), 4.8, places=2)
137
+ summary = self.level.summary('hut', 'build/level/hut.glb')
138
+ self.assertIn('--keep-glb --asset-id level-hut --height 4.8', summary)
139
+
140
+ def test_review_cameras(self):
141
+ shots = core.review_cameras(self.level_json, self.doors_json)
142
+ names = [s['name'] for s in shots]
143
+ self.assertEqual(names[0], 'overview')
144
+ self.assertIn('door-front-a', names)
145
+ self.assertIn('door-inner-b', names)
146
+ self.assertIn('stairs-porch steps', names)
147
+ self.assertEqual(sum(1 for n in names if n.startswith('volume-')), 4)
148
+
149
+ def test_duplicate_names_are_refused(self):
150
+ bm = fresh_bmlevel()
151
+ bm.mat('m', (1, 1, 1))
152
+ bm.box('twice', (0, 0, 0), (1, 1, 1), 'm')
153
+ bm.box('twice', (3, 0, 0), (1, 1, 1), 'm')
154
+ with self.assertRaises(ValueError):
155
+ bm.level.mesh_level_json()
156
+
157
+ def test_link_to_unknown_volume_is_refused_at_export(self):
158
+ bm = fresh_bmlevel()
159
+ bm.mat('m', (1, 1, 1))
160
+ bm.volume('a', (0, 1, 0), (2, 2, 2))
161
+ bm.link('a', 'nowhere')
162
+ with self.assertRaises(ValueError):
163
+ bm.export(tempfile.mkdtemp(), 'x')
164
+
165
+
166
+ class SyncWorldTests(unittest.TestCase):
167
+ def test_merge_keeps_creator_fields_and_reports_orphans(self):
168
+ sync = importlib.import_module('sync_world')
169
+ existing = [
170
+ {'id': 'front', 'position': {'x': 0, 'y': 0, 'z': 0}, 'rotationY': 0, 'width': 1, 'height': 2, 'thickness': 0.1, 'kind': 'locked', 'keyId': 'brass', 'animation': 'slide', 'color': '#123456'},
171
+ {'id': 'gone', 'position': {'x': 9, 'y': 0, 'z': 0}, 'rotationY': 0, 'width': 1, 'height': 2, 'thickness': 0.1, 'kind': 'plain', 'animation': 'slide'},
172
+ ]
173
+ built = [
174
+ {'id': 'front', 'position': {'x': 1, 'y': 1.1, 'z': -3}, 'rotationY': 0.5, 'width': 1.4, 'height': 2.2, 'thickness': 0.1, 'kind': 'plain', 'animation': 'hinge'},
175
+ {'id': 'new', 'position': {'x': 4, 'y': 1, 'z': 0}, 'rotationY': 0, 'width': 1.2, 'height': 2, 'thickness': 0.1, 'kind': 'plain', 'animation': 'slide'},
176
+ ]
177
+ merged, report = sync.merge_doors(existing, built, None)
178
+ front = next(d for d in merged if d['id'] == 'front')
179
+ self.assertEqual(front['kind'], 'locked')
180
+ self.assertEqual(front['keyId'], 'brass')
181
+ self.assertEqual(front['color'], '#123456')
182
+ self.assertEqual(front['position'], {'x': 1, 'y': 1.1, 'z': -3})
183
+ self.assertEqual(front['animation'], 'hinge')
184
+ self.assertEqual(report, {'added': ['new'], 'updated': ['front'], 'orphans': ['gone']})
185
+ self.assertEqual({d['id'] for d in merged}, {'front', 'gone', 'new'})
186
+
187
+
188
+ if __name__ == '__main__':
189
+ unittest.main()
@@ -0,0 +1,20 @@
1
+ import type { Environment } from '../config/environments.js';
2
+ export interface KeepGlbRequest {
3
+ environment: Environment;
4
+ token: string;
5
+ gameId: string;
6
+ /** The forger's URL for the generated mesh. */
7
+ glbUrl: string;
8
+ /** The filename to store it under — the asset name plus `.glb`. */
9
+ filename: string;
10
+ fetchImpl: typeof globalThis.fetch;
11
+ log: (message: string) => void;
12
+ }
13
+ export interface KeptGlb {
14
+ /** The project-storage URL the asset now points at. */
15
+ url: string;
16
+ size: number;
17
+ }
18
+ /** Injectable in tests so the lane runs without a network, matching the `voxelize` seam. */
19
+ export type KeepGlb = (request: KeepGlbRequest) => Promise<KeptGlb>;
20
+ export declare function reuploadGeneratedGlb(request: KeepGlbRequest): Promise<KeptGlb>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keep a GENERATED mesh as a `glb` asset instead of voxelizing it — the low-poly project's half of
3
+ * `generate model` and `generate prop` (`artStyle: "low-poly"` in game.json, or `--keep-glb`).
4
+ *
5
+ * The Asset Forger's URL is a job output, not a home: the voxelize path only ever reads it once
6
+ * (in the headless engine, minutes later) and stores the `.vxl` it made, so nothing in a published
7
+ * game depends on that URL surviving. A kept mesh would — every player's browser fetches it — so
8
+ * the bytes are re-uploaded into the project's own storage first, exactly as `assets add
9
+ * --keep-glb` stores a file the creator already had. The entry is then `glbAssetEntry`, the same
10
+ * record the Creator writes for a kept import, with the forger URL recorded as the generation's
11
+ * `production.sourceUrl` so a later "make this again" knows where the mesh came from.
12
+ */
13
+ import { CliError } from '../errors.js';
14
+ import { uploadProjectFile } from '../http/signed-upload.js';
15
+ import { isGlb } from './vxl-summary.js';
16
+ export async function reuploadGeneratedGlb(request) {
17
+ const { glbUrl, filename, log } = request;
18
+ log(`Keeping the mesh: fetching ${glbUrl}…`);
19
+ let bytes;
20
+ try {
21
+ const response = await request.fetchImpl(glbUrl);
22
+ if (!response.ok) {
23
+ throw new CliError(`Fetching the generated mesh failed: HTTP ${response.status}. ${retryHint(glbUrl)}`);
24
+ }
25
+ bytes = new Uint8Array(await response.arrayBuffer());
26
+ }
27
+ catch (error) {
28
+ if (error instanceof CliError)
29
+ throw error;
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ throw new CliError(`Fetching the generated mesh failed: ${message}. ${retryHint(glbUrl)}`);
32
+ }
33
+ if (!isGlb(bytes)) {
34
+ throw new CliError(`The generator's file at ${glbUrl} is not a binary GLB (no glTF magic), so it cannot be kept as a mesh. `
35
+ + 'world.json is unchanged.');
36
+ }
37
+ log(`Uploading ${filename} (${Math.round(bytes.byteLength / 1024)} KB) into the project…`);
38
+ const upload = await uploadProjectFile(request.environment, request.token, { gameId: request.gameId, filename, contentType: 'model/gltf-binary', bytes }, { fetch: request.fetchImpl });
39
+ log(`Uploaded: ${upload.url}`);
40
+ return { url: upload.url, size: bytes.byteLength };
41
+ }
42
+ function retryHint(glbUrl) {
43
+ return `The generated mesh is kept at ${glbUrl} — retry with \`--glb-url\` to skip the paid generation. world.json is unchanged.`;
44
+ }
45
+ //# sourceMappingURL=keep-glb.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keep-glb.js","sourceRoot":"","sources":["../../src/assets/keep-glb.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAExC,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAuBzC,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,OAAuB;IAChE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC;IAC1C,GAAG,CAAC,8BAA8B,MAAM,GAAG,CAAC,CAAC;IAC7C,IAAI,KAAiB,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,QAAQ,CAAC,4CAA4C,QAAQ,CAAC,MAAM,KAAK,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC1G,CAAC;QACD,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC;IACvD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,QAAQ;YAAE,MAAM,KAAK,CAAC;QAC3C,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACvE,MAAM,IAAI,QAAQ,CAAC,uCAAuC,OAAO,KAAK,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAC7F,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QAClB,MAAM,IAAI,QAAQ,CAChB,2BAA2B,MAAM,wEAAwE;cACvG,0BAA0B,CAC7B,CAAC;IACJ,CAAC;IACD,GAAG,CAAC,aAAa,QAAQ,KAAK,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC,wBAAwB,CAAC,CAAC;IAC3F,MAAM,MAAM,GAAG,MAAM,iBAAiB,CACpC,OAAO,CAAC,WAAW,EACnB,OAAO,CAAC,KAAK,EACb,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,mBAAmB,EAAE,KAAK,EAAE,EAC7E,EAAE,KAAK,EAAE,OAAO,CAAC,SAAS,EAAE,CAC7B,CAAC;IACF,GAAG,CAAC,aAAa,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/B,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC;AACrD,CAAC;AAED,SAAS,SAAS,CAAC,MAAc;IAC/B,OAAO,iCAAiC,MAAM,mFAAmF,CAAC;AACpI,CAAC"}
package/dist/cli.d.ts CHANGED
@@ -141,6 +141,10 @@ declare const rawCommands: {
141
141
  readonly type: "string";
142
142
  readonly description: "Game name.";
143
143
  };
144
+ readonly 'art-style': {
145
+ readonly type: "string";
146
+ readonly description: string;
147
+ };
144
148
  readonly idea: {
145
149
  readonly type: "string";
146
150
  readonly description: "One-line pitch. Seeds GAME-DESIGN.md and picks the template whose camera and controls fit it. Optional — you can write it yourself.";
@@ -515,6 +519,7 @@ declare const rawCommands: {
515
519
  };
516
520
  }>;
517
521
  ios: CommandDef<ArgsDef>;
522
+ tools: CommandDef<ArgsDef>;
518
523
  };
519
524
  type CommandName = keyof typeof rawCommands;
520
525
  export declare const subCommands: Record<CommandName, CommandDef<ArgsDef>>;
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { judgeCommand } from './commands/judge.js';
32
32
  import { forgeCommand } from './commands/forge.js';
33
33
  import { publishCommand } from './commands/publish.js';
34
34
  import { iosCommand } from './commands/ios.js';
35
+ import { toolsCommand } from './commands/tools.js';
35
36
  // citty's runMain() catches every error thrown from a command's `run()` internally and calls
36
37
  // process.exit() itself — it never rejects the promise it returns, so the `.catch()` below
37
38
  // never runs for those. For anything that isn't citty's own internal CLIError class, its catch
@@ -223,6 +224,7 @@ const rawCommands = {
223
224
  forge: forgeCommand,
224
225
  publish: publishCommand,
225
226
  ios: iosCommand,
227
+ tools: toolsCommand,
226
228
  };
227
229
  export const subCommands = Object.fromEntries(Object.entries(rawCommands).map(([name, command]) => [name, withCleanErrors(command, name)]));
228
230
  // This module only builds `main` and exports it (along with `subCommands` and `withCleanErrors`)