ludic/tools/lab/plate_build.py
Orkuncakilkaya b401f7acc1 feat(ludic.lab): the visual lab as a package - a scene on a plate, never a world
A scene is an entry in the open registry LabScenes, shown on a plate: a
flat lit disc, the package's own small sky with the sun at one hour, and
a frame clock. Headless each camera (lab_shot, lab_shot_at) settles and
is written as build/lab/<scene>/<shot>.png (a stored PNG, written here);
in a window N and P step through them. tools/lab/run.sh and sheet.py
make the contact sheet; tools/lab/plate_build.py makes plate/.
ludic.render3d gains r3d_plate_mode (no terrain, grass or water is made)
and r3d_sky_path. quit() in a program with no frame loop links. The
example takes ~120 MB resident, 417 MiB peak footprint. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:48:43 +03:00

181 lines
8.2 KiB
Python
Executable file

#!/usr/bin/env python3
"""plate_build.py - the lab's stage, built here so the package needs no downloads.
Writes packages/ludic.lab/plate/:
plate.gltf / plate.bin a flat disc, 40 m across, normals up, uv in metres / 4
plate_diff.png a pale grey-green ground with a line every metre, a heavier one every 5
plate_arm.png AO 1, roughness 0.9, metal 0
sky.hdr a small clear sky (512 x 256 RGBE): blue overhead, pale at the horizon,
a dim ground below it, and a sun 40 degrees up - the plate's one fixed hour
Deterministic: the same bytes every run.
"""
import json, math, os, struct, sys
from PIL import Image
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), '..', '..', 'packages', 'ludic.lab', 'plate')
os.makedirs(OUT, exist_ok=True)
# ---- the disc -------------------------------------------------------------
R, SEG = 20.0, 96
pos, nrm, uv, idx = [], [], [], []
pos += [0.0, 0.0, 0.0]; nrm += [0.0, 1.0, 0.0]; uv += [0.5, 0.5]
for i in range(SEG):
a = 2.0 * math.pi * i / SEG
x, z = R * math.cos(a), R * math.sin(a)
pos += [x, 0.0, z]; nrm += [0.0, 1.0, 0.0]; uv += [x / 4.0, z / 4.0]
uv[0], uv[1] = 0.0, 0.0
for i in range(SEG):
a, b = 1 + i, 1 + (i + 1) % SEG
idx += [0, b, a]
pb = struct.pack(f'<{len(pos)}f', *pos)
nb = struct.pack(f'<{len(nrm)}f', *nrm)
ub = struct.pack(f'<{len(uv)}f', *uv)
ib = struct.pack(f'<{len(idx)}I', *idx)
blob = pb + nb + ub + ib
with open(os.path.join(OUT, 'plate.bin'), 'wb') as f:
f.write(blob)
n = len(pos) // 3
views, off = [], 0
for b in (pb, nb, ub, ib):
views.append({'buffer': 0, 'byteOffset': off, 'byteLength': len(b)}); off += len(b)
doc = {
'asset': {'version': '2.0', 'generator': 'ludic tools/lab/plate_build.py'},
'scene': 0, 'scenes': [{'nodes': [0]}],
'nodes': [{'name': 'plate', 'mesh': 0}],
'meshes': [{'name': 'plate', 'primitives': [{'attributes': {'POSITION': 0, 'NORMAL': 1, 'TEXCOORD_0': 2}, 'indices': 3, 'material': 0}]}],
'materials': [{'name': 'plate', 'pbrMetallicRoughness': {'baseColorTexture': {'index': 0}, 'metallicRoughnessTexture': {'index': 1}}}],
'textures': [{'source': 0}, {'source': 1}],
'images': [{'uri': 'plate_diff.png'}, {'uri': 'plate_arm.png'}],
'buffers': [{'uri': 'plate.bin', 'byteLength': len(blob)}],
'bufferViews': views,
'accessors': [
{'bufferView': 0, 'componentType': 5126, 'count': n, 'type': 'VEC3', 'min': [-R, 0.0, -R], 'max': [R, 0.0, R]},
{'bufferView': 1, 'componentType': 5126, 'count': n, 'type': 'VEC3'},
{'bufferView': 2, 'componentType': 5126, 'count': n, 'type': 'VEC2'},
{'bufferView': 3, 'componentType': 5125, 'count': len(idx), 'type': 'SCALAR'},
],
}
with open(os.path.join(OUT, 'plate.gltf'), 'w') as f:
json.dump(doc, f, indent=1)
# ---- its material: 4 m of ground per tile, a line a metre -----------------
T = 256
im = Image.new('RGB', (T, T), (142, 146, 132))
px = im.load()
for y in range(T):
for x in range(T):
heavy = x == 0 or y == 0
light = x % (T // 4) == 0 or y % (T // 4) == 0
if heavy:
px[x, y] = (96, 100, 90)
elif light:
px[x, y] = (122, 126, 114)
im.save(os.path.join(OUT, 'plate_diff.png'))
Image.new('RGB', (4, 4), (255, 230, 0)).save(os.path.join(OUT, 'plate_arm.png'))
# ---- the sky: one fixed hour ----------------------------------------------
W, H = 512, 256
SUN_EL, SUN_AZ = math.radians(40.0), math.radians(135.0)
sx, sy, sz = math.cos(SUN_EL) * math.sin(SUN_AZ), math.sin(SUN_EL), math.cos(SUN_EL) * math.cos(SUN_AZ)
def rgbe(r, g, b):
m = max(r, g, b)
if m < 1e-32:
return bytes((0, 0, 0, 0))
e = math.frexp(m)[1]
s = 256.0 / (2.0 ** e)
return bytes((min(255, int(r * s)), min(255, int(g * s)), min(255, int(b * s)), e + 128))
rows = []
for y in range(H):
th = (y + 0.5) / H * math.pi # 0 at the zenith
el = math.pi / 2 - th
row = bytearray()
for x in range(W):
ph = (x + 0.5) / W * 2.0 * math.pi
dx, dy, dz = math.sin(th) * math.sin(ph), math.cos(th), math.sin(th) * math.cos(ph)
if el >= 0:
t = 1.0 - min(1.0, el / (math.pi / 2)) ** 0.6
r, g, b = 0.25 + 0.75 * t, 0.45 + 0.55 * t, 1.0
k = 1.1
r, g, b = r * k, g * k, b * k
else:
r, g, b = 0.22, 0.21, 0.18
c = dx * sx + dy * sy + dz * sz
if c > math.cos(math.radians(1.2)):
r, g, b = 2600.0, 2450.0, 2250.0 # the sun: integrated past the clip, lights the plate
elif c > math.cos(math.radians(8.0)):
glow = (c - math.cos(math.radians(8.0))) / (1.0 - math.cos(math.radians(8.0)))
r, g, b = r + 6.0 * glow, g + 5.5 * glow, b + 4.5 * glow
row += rgbe(r, g, b)
rows.append(bytes(row))
with open(os.path.join(OUT, 'sky.hdr'), 'wb') as f:
f.write(b'#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n')
f.write(f'-Y {H} +X {W}\n'.encode())
for r in rows:
f.write(r)
print(f'plate: {OUT}')
# ---- a probe to look at: a box with a ball on it, two materials --------------
def box(s):
P, N, U, I = [], [], [], []
faces = [((1,0,0),(0,0,-1),(0,1,0)), ((-1,0,0),(0,0,1),(0,1,0)), ((0,1,0),(1,0,0),(0,0,-1)),
((0,-1,0),(1,0,0),(0,0,1)), ((0,0,1),(1,0,0),(0,1,0)), ((0,0,-1),(-1,0,0),(0,1,0))]
for n, u, v in faces:
base = len(P) // 3
for a, b in ((-1,-1),(1,-1),(1,1),(-1,1)):
p = [s * (n[k] + a * u[k] + b * v[k]) for k in range(3)]
p[1] += s
P += p; N += list(n); U += [(a + 1) / 2, (b + 1) / 2]
I += [base, base + 1, base + 2, base, base + 2, base + 3]
return P, N, U, I
def ball(r, cy, seg=32, rings=16):
P, N, U, I = [], [], [], []
for j in range(rings + 1):
th = math.pi * j / rings
for i in range(seg + 1):
ph = 2 * math.pi * i / seg
n = (math.sin(th) * math.cos(ph), math.cos(th), math.sin(th) * math.sin(ph))
P += [r * n[0], cy + r * n[1], r * n[2]]; N += list(n); U += [i / seg, j / rings]
for j in range(rings):
for i in range(seg):
a = j * (seg + 1) + i; b = a + seg + 1
I += [a, a + 1, b, a + 1, b + 1, b]
return P, N, U, I
parts = [box(0.4), ball(0.3, 1.1)]
blob2, views2, accs2, prims2 = b'', [], [], []
for m, (P, N, U, I) in enumerate(parts):
for data, fmt, typ, comp in ((P, 'f', 'VEC3', 5126), (N, 'f', 'VEC3', 5126), (U, 'f', 'VEC2', 5126), (I, 'I', 'SCALAR', 5125)):
b = struct.pack(f'<{len(data)}{fmt}', *data)
views2.append({'buffer': 0, 'byteOffset': len(blob2), 'byteLength': len(b)}); blob2 += b
cnt = len(data) // {'VEC3': 3, 'VEC2': 2, 'SCALAR': 1}[typ]
acc = {'bufferView': len(views2) - 1, 'componentType': comp, 'count': cnt, 'type': typ}
if typ == 'VEC3' and len(accs2) % 4 == 0:
acc['min'] = [min(P[k::3]) for k in range(3)]; acc['max'] = [max(P[k::3]) for k in range(3)]
accs2.append(acc)
k = m * 4
prims2.append({'attributes': {'POSITION': k, 'NORMAL': k + 1, 'TEXCOORD_0': k + 2}, 'indices': k + 3, 'material': m})
with open(os.path.join(OUT, 'probe.bin'), 'wb') as f:
f.write(blob2)
Image.new('RGB', (4, 4), (196, 110, 52)).save(os.path.join(OUT, 'probe_box.png'))
Image.new('RGB', (4, 4), (225, 225, 220)).save(os.path.join(OUT, 'probe_ball.png'))
Image.new('RGB', (4, 4), (255, 140, 0)).save(os.path.join(OUT, 'probe_arm.png'))
doc2 = {
'asset': {'version': '2.0', 'generator': 'ludic tools/lab/plate_build.py'},
'scene': 0, 'scenes': [{'nodes': [0]}],
'nodes': [{'name': 'probe', 'mesh': 0}],
'meshes': [{'name': 'probe', 'primitives': prims2}],
'materials': [
{'name': 'probe_box', 'pbrMetallicRoughness': {'baseColorTexture': {'index': 0}, 'metallicRoughnessTexture': {'index': 2}}},
{'name': 'probe_ball', 'pbrMetallicRoughness': {'baseColorTexture': {'index': 1}, 'metallicRoughnessTexture': {'index': 2}}},
],
'textures': [{'source': 0}, {'source': 1}, {'source': 2}],
'images': [{'uri': 'probe_box.png'}, {'uri': 'probe_ball.png'}, {'uri': 'probe_arm.png'}],
'buffers': [{'uri': 'probe.bin', 'byteLength': len(blob2)}],
'bufferViews': views2,
'accessors': accs2,
}
with open(os.path.join(OUT, 'probe.gltf'), 'w') as f:
json.dump(doc2, f, indent=1)