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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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()
@@ -16,12 +16,17 @@
16
16
  * The surface, the pills and the modal backdrop come from `design.ts` (`.bm-overlay`,
17
17
  * `.bm-dialog`, `.bm-pill`); only what is specific to this dialog's layout lives here.
18
18
  */
19
- export declare const ASSET_DETAIL_CSS = "\n /* Above #hq-overlay, which sits at 10 \u2014 a preview opened from a row must cover it. */\n #asset-overlay { z-index: 11; }\n #asset-dialog { display: flex; width: min(1100px, 94vw); height: min(760px, 88vh);\n overflow: hidden; }\n /* The viewer takes the room. Everything else is a caption. The near-black ground is the\n brand's: a creation is the only thing on this surface that carries colour. */\n #asset-view { flex: 1; min-width: 0; position: relative; background: var(--bm-bg); }\n #asset-view iframe { display: block; width: 100%; height: 100%; border: 0; }\n #asset-view img { display: block; width: 100%; height: 100%; object-fit: contain;\n background: var(--bm-bg); }\n #asset-view audio { position: absolute; left: 50%; top: 50%; width: min(420px, 80%);\n transform: translate(-50%, -50%); }\n /* A note here is always a failure or a wait that has gone on too long \u2014 the negative accent. */\n #asset-note { position: absolute; inset: auto 0 0 0; padding: 10px 12px;\n background: var(--bm-scrim); color: var(--bm-pink-hover); font-size: 12px; }\n #asset-note.hidden, #asset-view > .hidden { display: none; }\n /* Wide enough for the action pills to sit on one row (wrapping to two once Edit voxels shows):\n uppercase tracked labels are wider than the sentence-case ones this panel used to have. */\n #asset-meta { width: 360px; flex: none; padding: 16px; overflow-y: auto;\n border-left: 1px solid var(--bm-border-muted); }\n #asset-meta h2 { margin: 0 0 4px; word-break: break-word; }\n #asset-meta .sub { color: var(--bm-text-dim); font-size: 12px; margin-bottom: 16px; }\n #asset-facts { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; font-size: 12px; }\n #asset-facts dt { color: var(--bm-text-dim); }\n #asset-facts dd { margin: 0; color: var(--bm-text); word-break: break-word; }\n #asset-facts dd.mono { font-family: var(--bm-font-mono); }\n #asset-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }\n /* The .hidden rules above are scoped to the viewer side; the actions row needs its own. */\n #asset-actions .hidden { display: none; }\n #asset-close { margin-left: auto; }\n";
19
+ export declare const ASSET_DETAIL_CSS = "\n /* Above #hq-overlay, which sits at 10 \u2014 a preview opened from a row must cover it. */\n #asset-overlay { z-index: 11; }\n #asset-dialog { display: flex; width: min(1100px, 94vw); height: min(760px, 88vh);\n overflow: hidden; }\n /* The viewer takes the room. Everything else is a caption. The near-black ground is the\n brand's: a creation is the only thing on this surface that carries colour. */\n #asset-view { flex: 1; min-width: 0; position: relative; background: var(--bm-bg); }\n #asset-view iframe { display: block; width: 100%; height: 100%; border: 0; }\n #asset-view img { display: block; width: 100%; height: 100%; object-fit: contain;\n background: var(--bm-bg); }\n #asset-view audio { position: absolute; left: 50%; top: 50%; width: min(420px, 80%);\n transform: translate(-50%, -50%); }\n /* Fitted rather than filled, like the image rule above: a generated clip is 16:9 by default but\n follows its first frame when one was given, so the dialog cannot assume a shape. */\n #asset-view video { display: block; width: 100%; height: 100%; object-fit: contain;\n background: var(--bm-bg); }\n /* A note here is always a failure or a wait that has gone on too long \u2014 the negative accent. */\n #asset-note { position: absolute; inset: auto 0 0 0; padding: 10px 12px;\n background: var(--bm-scrim); color: var(--bm-pink-hover); font-size: 12px; }\n #asset-note.hidden, #asset-view > .hidden { display: none; }\n /* Wide enough for the action pills to sit on one row (wrapping to two once Edit voxels shows):\n uppercase tracked labels are wider than the sentence-case ones this panel used to have. */\n #asset-meta { width: 360px; flex: none; padding: 16px; overflow-y: auto;\n border-left: 1px solid var(--bm-border-muted); }\n #asset-meta h2 { margin: 0 0 4px; word-break: break-word; }\n #asset-meta .sub { color: var(--bm-text-dim); font-size: 12px; margin-bottom: 16px; }\n #asset-facts { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; font-size: 12px; }\n #asset-facts dt { color: var(--bm-text-dim); }\n #asset-facts dd { margin: 0; color: var(--bm-text); word-break: break-word; }\n #asset-facts dd.mono { font-family: var(--bm-font-mono); }\n #asset-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }\n /* The .hidden rules above are scoped to the viewer side; the actions row needs its own. */\n #asset-actions .hidden { display: none; }\n #asset-close { margin-left: auto; }\n";
20
20
  /**
21
21
  * The overlay itself.
22
22
  *
23
23
  * `<audio controls>` rather than a play button of our own: the web Creator withholds native
24
24
  * controls because its player sits in a 46px row, and here there is a whole dialog. A creator
25
- * checking a sound effect wants the scrubber.
25
+ * checking a sound effect wants the scrubber. `<video controls>` is here for the same reason —
26
+ * a cutscene is watched, scrubbed and watched again.
27
+ *
28
+ * `preload="metadata"` on the video, not `none`: the dialog is opened BY a click on the row, so
29
+ * the creator has already asked for it, and a poster frame beats a black rectangle. Muted is
30
+ * deliberately absent — a generated clip may carry audio and this is where you would check.
26
31
  */
27
- export declare const ASSET_DETAIL_HTML = "\n <div id=\"asset-overlay\" class=\"bm-overlay\">\n <div id=\"asset-dialog\" class=\"bm-dialog\">\n <div id=\"asset-view\">\n <iframe id=\"asset-frame\" class=\"hidden\" title=\"Asset preview\"></iframe>\n <img id=\"asset-image\" class=\"hidden\" alt=\"\">\n <audio id=\"asset-audio\" class=\"hidden\" controls preload=\"none\"></audio>\n <div id=\"asset-note\" class=\"hidden\"></div>\n </div>\n <aside id=\"asset-meta\">\n <h2 id=\"asset-name\"></h2>\n <div class=\"sub\" id=\"asset-sub\"></div>\n <dl id=\"asset-facts\"></dl>\n <div id=\"asset-actions\">\n <button id=\"asset-edit-voxels\" class=\"bm-pill bm-pill--primary hidden\" type=\"button\" title=\"Open the 3D voxel editor: add, delete and recolor voxels, and set which ones glow\">Edit voxels</button>\n <button id=\"asset-reset\" class=\"bm-pill\" type=\"button\" title=\"Back to the angle the thumbnail uses\">Reset view</button>\n <button id=\"asset-copy\" class=\"bm-pill\" type=\"button\" title=\"Copy this asset's id\">Copy id</button>\n <button id=\"asset-close\" class=\"bm-pill bm-pill--ghost\" type=\"button\">Close</button>\n </div>\n </aside>\n </div>\n </div>\n";
32
+ export declare const ASSET_DETAIL_HTML = "\n <div id=\"asset-overlay\" class=\"bm-overlay\">\n <div id=\"asset-dialog\" class=\"bm-dialog\">\n <div id=\"asset-view\">\n <iframe id=\"asset-frame\" class=\"hidden\" title=\"Asset preview\"></iframe>\n <img id=\"asset-image\" class=\"hidden\" alt=\"\">\n <audio id=\"asset-audio\" class=\"hidden\" controls preload=\"none\"></audio>\n <video id=\"asset-video\" class=\"hidden\" controls playsinline preload=\"metadata\"></video>\n <div id=\"asset-note\" class=\"hidden\"></div>\n </div>\n <aside id=\"asset-meta\">\n <h2 id=\"asset-name\"></h2>\n <div class=\"sub\" id=\"asset-sub\"></div>\n <dl id=\"asset-facts\"></dl>\n <div id=\"asset-actions\">\n <button id=\"asset-edit-voxels\" class=\"bm-pill bm-pill--primary hidden\" type=\"button\" title=\"Open the 3D voxel editor: add, delete and recolor voxels, and set which ones glow\">Edit voxels</button>\n <button id=\"asset-reset\" class=\"bm-pill\" type=\"button\" title=\"Back to the angle the thumbnail uses\">Reset view</button>\n <button id=\"asset-copy\" class=\"bm-pill\" type=\"button\" title=\"Copy this asset's id\">Copy id</button>\n <button id=\"asset-close\" class=\"bm-pill bm-pill--ghost\" type=\"button\">Close</button>\n </div>\n </aside>\n </div>\n </div>\n";
@@ -29,6 +29,10 @@ export const ASSET_DETAIL_CSS = `
29
29
  background: var(--bm-bg); }
30
30
  #asset-view audio { position: absolute; left: 50%; top: 50%; width: min(420px, 80%);
31
31
  transform: translate(-50%, -50%); }
32
+ /* Fitted rather than filled, like the image rule above: a generated clip is 16:9 by default but
33
+ follows its first frame when one was given, so the dialog cannot assume a shape. */
34
+ #asset-view video { display: block; width: 100%; height: 100%; object-fit: contain;
35
+ background: var(--bm-bg); }
32
36
  /* A note here is always a failure or a wait that has gone on too long — the negative accent. */
33
37
  #asset-note { position: absolute; inset: auto 0 0 0; padding: 10px 12px;
34
38
  background: var(--bm-scrim); color: var(--bm-pink-hover); font-size: 12px; }
@@ -53,7 +57,12 @@ export const ASSET_DETAIL_CSS = `
53
57
  *
54
58
  * `<audio controls>` rather than a play button of our own: the web Creator withholds native
55
59
  * controls because its player sits in a 46px row, and here there is a whole dialog. A creator
56
- * checking a sound effect wants the scrubber.
60
+ * checking a sound effect wants the scrubber. `<video controls>` is here for the same reason —
61
+ * a cutscene is watched, scrubbed and watched again.
62
+ *
63
+ * `preload="metadata"` on the video, not `none`: the dialog is opened BY a click on the row, so
64
+ * the creator has already asked for it, and a poster frame beats a black rectangle. Muted is
65
+ * deliberately absent — a generated clip may carry audio and this is where you would check.
57
66
  */
58
67
  export const ASSET_DETAIL_HTML = `
59
68
  <div id="asset-overlay" class="bm-overlay">
@@ -62,6 +71,7 @@ export const ASSET_DETAIL_HTML = `
62
71
  <iframe id="asset-frame" class="hidden" title="Asset preview"></iframe>
63
72
  <img id="asset-image" class="hidden" alt="">
64
73
  <audio id="asset-audio" class="hidden" controls preload="none"></audio>
74
+ <video id="asset-video" class="hidden" controls playsinline preload="metadata"></video>
65
75
  <div id="asset-note" class="hidden"></div>
66
76
  </div>
67
77
  <aside id="asset-meta">
@@ -1 +1 @@
1
- {"version":3,"file":"asset-detail-panel.js","sourceRoot":"","sources":["../../src/editor/asset-detail-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+B/B,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;CAsBhC,CAAC"}
1
+ {"version":3,"file":"asset-detail-panel.js","sourceRoot":"","sources":["../../src/editor/asset-detail-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmC/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;CAuBhC,CAAC"}