@bitmagic/cli 0.1.64-dev.2 → 0.1.64-dev.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/assets/blender-level/README.md +25 -0
- package/assets/blender-level/audit.py +232 -0
- package/assets/blender-level/bmlevel/__init__.py +294 -0
- package/assets/blender-level/bmlevel/core.py +438 -0
- package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
- package/assets/blender-level/bmlevel/scene_null.py +44 -0
- package/assets/blender-level/example_level.py +80 -0
- package/assets/blender-level/find_blender.py +62 -0
- package/assets/blender-level/review.py +117 -0
- package/assets/blender-level/sync_world.py +85 -0
- package/assets/blender-level/tests/test_core.py +189 -0
- package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
- package/dist/scaffold/blender-level-skill-files.js +548 -0
- package/dist/scaffold/blender-level-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +13 -6
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +6 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `building-levels-in-blender` skill: a hand-designed low-poly world written as a Python script
|
|
3
|
+
* against the shipped `bmlevel` library, built by Blender into one GLB plus the collider / door /
|
|
4
|
+
* light / landmark JSON, audited with ray casts, reviewed from rendered stills, and loaded by the
|
|
5
|
+
* engine's `MeshLevel`. Distilled from a creator's space-station game whose agent installed Blender
|
|
6
|
+
* and hand-rolled every part of this — the build vocabulary, the audits, the review renders, the
|
|
7
|
+
* level loader — and hit the two failures the skill now names up front (a GLB fetched from a local
|
|
8
|
+
* path that 404s after publish; NPC spawns routed into an absent voxel grid).
|
|
9
|
+
*
|
|
10
|
+
* Same contract as `unity-skill-files.ts`: pure functions returning file contents, registered in
|
|
11
|
+
* `SHIPPED_SKILLS` (project.ts). The Python and Blender scripts ship in the package's
|
|
12
|
+
* `assets/blender-level/` (see `files` in package.json) rather than being rendered into every
|
|
13
|
+
* project; the skill's step 1 copies them into `tools/blender-level/`.
|
|
14
|
+
*
|
|
15
|
+
* The skill's first section is the gate: the path is for frontier models with Blender available,
|
|
16
|
+
* and everything else goes to `bitmagic forge`. The CLI cannot tell which model drives it, so the
|
|
17
|
+
* gate is the agent's own call — stated once, plainly, where the planning skill sends it.
|
|
18
|
+
*
|
|
19
|
+
* Backticks and `${` are escaped for the template literal; `project-files.test.ts` checks the
|
|
20
|
+
* rendered frontmatter and that no escape leaks into the output.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderBlenderLevelsSkill(): string;
|
|
23
|
+
export declare function renderBlenderConventionsReference(): string;
|
|
24
|
+
export declare function renderBlenderAuditsReference(): string;
|
|
25
|
+
export declare function renderBlenderPassesReference(): string;
|
|
26
|
+
export declare function renderBlenderEngineHandoffReference(): string;
|