@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/editor/asset-detail-panel.d.ts +8 -3
- package/dist/editor/asset-detail-panel.js +11 -1
- package/dist/editor/asset-detail-panel.js.map +1 -1
- package/dist/editor/shell-page.js +117 -7
- package/dist/editor/shell-page.js.map +1 -1
- 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()
|
|
@@ -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
|
|
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"}
|