Add Blender scenes for BritX, Evidence, Orbit 2, Gold and Sci-Fi, and the Drive-In fanfare
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m45s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:34:16 +00:00
co-authored by Claude Opus 5.5
parent 465bbd653d
commit ff415434aa
3 changed files with 273 additions and 0 deletions
+184
View File
@@ -670,6 +670,120 @@ def cinema(logo):
data = bpy.data.lights.new(f'spot{x}', 'SPOT'); data.energy = 20000; data.spot_size = math.radians(60); data.spot_blend = .8; data.color = hexrgb('#FFD8C0')[:3]
spot = bpy.data.objects.new(f'spot{x}', data); bpy.context.collection.objects.link(spot); spot.location = (x, -26, 15); spot.rotation_euler = (Vector((x * .6, 0, 6)) - Vector(spot.location)).to_track_quat('-Z', 'Y').to_euler()
def stone(name, colour, rough=.8, metal=0):
m, n, l = material(name); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb(colour); bsdf.inputs['Roughness'].default_value = rough; bsdf.inputs['Metallic'].default_value = metal
return m
def spire(name, x, y, z, width, height, mat):
bpy.ops.mesh.primitive_cone_add(vertices=4, radius1=width * .71, radius2=0, depth=height, location=(x, y, z + height / 2), rotation=(0, 0, math.pi / 4))
obj = bpy.context.active_object; obj.name = name; obj.data.materials.append(mat)
return obj
def glowing(name, colour, strength):
m, n, l = material(name); out = n['Material Output']; n.remove(n['Principled BSDF'])
glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb(colour); glow.inputs['Strength'].default_value = strength; l.new(glow.outputs[0], out.inputs['Surface'])
return m
def sky_world(stops):
"""A plain sky graded from the horizon up, no stars."""
scene.world = bpy.data.worlds.new('sky'); scene.world.use_nodes = True
bg = scene.world.node_tree.nodes['Background']; w = scene.world.node_tree; tex = w.nodes.new('ShaderNodeTexCoord'); sep = w.nodes.new('ShaderNodeSeparateXYZ'); w.links.new(tex.outputs['Generated'], sep.inputs[0])
sky = ramp(w.nodes, stops); w.links.new(sep.outputs['Z'], sky.inputs['Fac']); w.links.new(sky.outputs['Color'], bg.inputs['Color'])
def tower_bridge(y, mat, dark, steel):
"""Tower Bridge: two towers with their turrets and roofs, the high walkway and the road, and the chains out to each bank."""
window = glowing('window', '#FFC873', 3)
for side in (-1, 1):
x = side * 17
box(f'pier{side}', x, y, 13, 11, 4, dark, -1)
box(f'tower{side}', x, y, 9.5, 8, 38, mat, 3)
for cx, cy in ((-4.4, -3.6), (4.4, -3.6), (-4.4, 3.6), (4.4, 3.6)):
box(f'turret{side}{cx}{cy}', x + cx, y + cy, 2.2, 2.2, 42, mat, 3)
spire(f'turretcap{side}{cx}{cy}', x + cx, y + cy, 45, 2.8, 6, dark)
spire(f'roof{side}', x, y, 41, 9, 14, dark)
for z in range(10, 38, 8):
box(f'towerwindow{side}{z}', x, y - 4.05, 2, .1, 3.5, window, z)
# The chains, swooping down to each bank.
curve = bpy.data.curves.new(f'chain{side}', 'CURVE'); curve.dimensions = '3D'; curve.bevel_depth = .35
spline = curve.splines.new('POLY'); spline.points.add(14)
for i in range(15):
u = i / 14; spline.points[i].co = (x + side * 58 * u, y, 34 - 28 * math.sin(u * math.pi / 2) ** .8, 1)
chain = bpy.data.objects.new(f'chain{side}', curve); bpy.context.collection.objects.link(chain); curve.materials.append(steel)
box('walkway', 0, y, 26, 4.5, 3.4, steel, 30)
box('road', 0, y, 150, 9, 1.4, dark, 8)
box('roadlights', 0, y - 4.6, 150, .2, .3, glowing('roadglow', '#FFD27A', 4), 9.5)
def big_ben(x, y, mat, dark, scale=1.0):
"""The Elizabeth Tower, with its lit clock."""
w = 11 * scale
box('bigbenshaft', x, y, w, w, 62 * scale, mat)
box('bigbenbelfry', x, y, w * 1.12, w * 1.12, 17 * scale, mat, 62 * scale)
box('bigbencap', x, y, w * 1.2, w * 1.2, 2 * scale, dark, 79 * scale)
spire('bigbenroof', x, y, 81 * scale, w * 1.1, 22 * scale, dark)
spire('bigbenspire', x, y, 94 * scale, w * .32, 22 * scale, mat)
face = glowing('clock', '#FFE9B8', 3.2); black = glowing('hands', '#090705', .02)
centre = (x, y - w * .561 - .02, 70.5 * scale)
r = w * .43
bpy.ops.mesh.primitive_cylinder_add(vertices=64, radius=r, depth=.1, location=centre, rotation=(math.pi / 2, 0, 0)); bpy.context.active_object.data.materials.append(face)
for length, angle, thick in ((.56, 2.3, .07), (.86, -.5, .04)):
bpy.ops.mesh.primitive_cube_add(size=1, location=(centre[0] + math.sin(angle) * r * length / 2, centre[1] - .1, centre[2] + math.cos(angle) * r * length / 2), rotation=(0, angle, 0))
hand = bpy.context.active_object; hand.scale = (r * thick * 2, .05, r * length); hand.data.materials.append(black)
def gas_bands(n, l):
"""Texture space squashed along the axis, so noise reads as a gas giant's bands."""
squash = n.new('ShaderNodeVectorMath'); squash.operation = 'MULTIPLY'; squash.inputs[1].default_value = (.25, .25, 3.2)
l.new(n.new('ShaderNodeTexCoord').outputs['Object'], squash.inputs[0])
return squash.outputs[0]
def rock(name, seed, radius, mat):
"""An asteroid, an icosphere knocked out of shape."""
rng = random.Random(seed)
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=4, radius=radius); obj = bpy.context.active_object; obj.name = name
waves = [(Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), rng.uniform(-1, 1))).normalized() * rng.uniform(1.5, 6), rng.uniform(0, 6), rng.uniform(.05, .17)) for _ in range(9)]
for v in obj.data.vertices:
d = v.co.normalized()
v.co *= 1 + sum(math.sin(d.dot(k) + p) * a for k, p, a in waves)
bpy.ops.object.shade_smooth(); obj.data.materials.append(mat)
return obj
def case_board():
"""A detective's wall: photos and notes pinned up, joined by red string."""
rng = random.Random(44)
box('wall', 0, .6, 220, 1, 90, stone('wall', '#3A3026', .9), -30)
card, shade, photo = stone('card', '#D8D0BC', .7), stone('faces', '#1A1816', .8), stone('photo', '#6E6A62', .6)
note = stone('note', '#E8C860', .8); pin = glowing('pin', '#FF2028', 3)
points = []
for i, (x, z) in enumerate(((-52, 16), (-40, -4), (-26, 19), (-14, 1), (-1, 17), (9, -8), (15, 9), (27, 19), (31, -4), (41, 11), (-34, 8), (-6, -11), (22, -14), (49, -9), (-52, -12))):
w, h = rng.uniform(5.4, 7.6), rng.uniform(6.4, 8.4)
kind = card if i % 4 != 3 else note
tilt = rng.uniform(-.08, .08)
obj = box(f'card{i}', 0, 0, w, .1, h, kind, 0); obj.data.transform(__import__('mathutils').Matrix.Translation((0, 0, -h / 2))); obj.location = (x, 0, z); obj.rotation_euler = (0, tilt, 0)
if kind == card:
r = w * .17
head = [(r * math.cos(a / 16 * math.tau), h * .1 + r * math.sin(a / 16 * math.tau)) for a in range(16)]
body = [(w * .36 * math.cos(a / 24 * math.pi), -h * .36 + h * .3 * math.sin(a / 24 * math.pi)) for a in range(25)]
flat(f'photo{i}', [[(-w * .42, -h * .36), (w * .42, -h * .36), (w * .42, h * .34), (-w * .42, h * .34)]], (x, -.07, z), (x, -50, z), photo).rotation_euler = (0, tilt, 0)
flat(f'face{i}', [head, body], (x, -.1, z), (x, -50, z), shade).rotation_euler = (0, tilt, 0)
ball = sphere(f'pin{i}', .42, 16); ball.location = (x, -.5, z + h * .42); ball.data.materials.append(pin)
points.append(ball.location.copy())
curve = bpy.data.curves.new('strings', 'CURVE'); curve.dimensions = '3D'; curve.bevel_depth = .07
for a, b in ((0, 2), (2, 4), (4, 7), (7, 9), (2, 3), (3, 5), (5, 6), (6, 7), (1, 10), (10, 2), (5, 11), (6, 8), (8, 12), (9, 13), (0, 14), (14, 1), (3, 11)):
spline = curve.splines.new('POLY'); spline.points.add(1)
spline.points[0].co = (*points[a], 1); spline.points[1].co = (*points[b], 1)
strings = bpy.data.objects.new('strings', curve); bpy.context.collection.objects.link(strings); curve.materials.append(stone('string', '#C81018', .6))
# The lamps above, swinging a little, and low light through blinds from the side.
for i, x in enumerate((-30, 30)):
data = bpy.data.lights.new(f'lamp{i}', 'SPOT'); data.energy = 42000; data.spot_size = math.radians(85); data.spot_blend = .55; data.color = hexrgb('#FFD8A0')[:3]; data.shadow_soft_size = .4
lamp = bpy.data.objects.new(f'lamp{i}', data); bpy.context.collection.objects.link(lamp); lamp.location = (x, -30, 30)
for frame, sway in ((1, -16), (88, 16), (175, -12), (262, 11), (350, -8)):
lamp.rotation_euler = (Vector((x + sway, 0, 2)) - Vector((x, -30, 30))).to_track_quat('-Z', 'Y').to_euler(); lamp.keyframe_insert('rotation_euler', frame=frame)
data = bpy.data.lights.new('blinds', 'SPOT'); data.energy = 500000; data.spot_size = math.radians(34); data.spot_blend = .1; data.color = hexrgb('#9FD8E8')[:3]; data.shadow_soft_size = .1
light = bpy.data.objects.new('blinds', data); bpy.context.collection.objects.link(light); light.location = (-90, -60, 40)
light.rotation_euler = (Vector((30, 0, 6)) - Vector(light.location)).to_track_quat('-Z', 'Y').to_euler()
slat = stone('slat', '#000000')
for i in range(30):
box(f'slat{i}', -70, -50, 40, 1, .6, slat, 16 + i * 1.5).visible_camera = False
if SCENE == 'orbit-1':
world_tint = ('#061A3C', '#0E3A6E')
stars_world()
@@ -764,6 +878,76 @@ elif SCENE == 'drive-in':
cinema(str(__import__('pathlib').Path(__file__).resolve().parents[2] / 'drive-in' / 'drive_in_marquee.png'))
# Settles in from the back of the house, then glides down the aisle towards the screen as it opens.
camera_path([(1, (0, -40, 10), (0, 0, 7)), (120, (0, -36, 9.6), (0, 0, 7)), (350, (0, -16, 7.5), (0, 0, 7))], lens=30)
elif SCENE == 'orbit-2':
world_tint = ('#14082E', '#2C1460')
stars_world()
sun((.7, .62, -.2), 4.4, '#FFF0F4')
body, ring = planet([(.36, '#1E0A54'), (.46, '#4A22A8'), (.54, '#8A5AE0'), (.62, '#D2B8F8'), (.69, '#6A36C8'), (.78, '#28106A')], ('#C69CFF', '#6A3CC8'))
# A second ring for the two, wider and on its own tilt.
outer = ring.copy(); outer.data = ring.data.copy(); bpy.context.collection.objects.link(outer); outer.scale = (1.42, 1.42, 1); outer.rotation_euler = (math.radians(-24), math.radians(22), 0)
camera_path([(1, (34, -36, 12), (0, 0, 0)), (260, (-33, -88, -8), (-30, 0, 0)), (350, (-33, -88, -8), (-30, 0, 0))])
elif SCENE == 'orbit-sci-fi':
world_tint = ('#04142C', '#0A3050')
stars_world()
sun((-.7, .5, -.35), 7, '#E8F6FF')
world = sphere('gas', 60); world.location = (70, 330, 10)
m, n, l = material('gas'); bsdf = n['Principled BSDF']; bsdf.inputs['Roughness'].default_value = .7
tone = noise_ramp(n, l, gas_bands(n, l), .05, 9, [(.34, '#03202E'), (.48, '#0E7088'), (.6, '#58D6E0'), (.7, '#0A4A60')], .4)
l.new(tone.outputs['Color'], bsdf.inputs['Base Color']); world.data.materials.append(m)
glow_shell('gasglow', 61.5, '#6BE8FF', 2.4).location = world.location
grey, n, l = material('rock'); bsdf = n['Principled BSDF']; bsdf.inputs['Roughness'].default_value = .95
coord = n.new('ShaderNodeTexCoord').outputs['Object']
l.new(noise_ramp(n, l, coord, .9, 8, [(.3, '#2E2A28'), (.62, '#8A8278')]).outputs['Color'], bsdf.inputs['Base Color'])
crags = n.new('ShaderNodeTexNoise'); crags.inputs['Scale'].default_value = 1.6; crags.inputs['Detail'].default_value = 12; crags.inputs['Roughness'].default_value = .68; l.new(coord, crags.inputs['Vector'])
bump = n.new('ShaderNodeBump'); bump.inputs['Strength'].default_value = 1; bump.inputs['Distance'].default_value = 1.2; l.new(crags.outputs['Fac'], bump.inputs['Height']); l.new(bump.outputs[0], bsdf.inputs['Normal'])
rng = random.Random(9)
for i in range(70):
x, y, z = rng.uniform(-60, 75), rng.uniform(-40, 260), rng.uniform(-30, 30)
if abs(x) < 9 and abs(z) < 8: x += 18 if x > 0 else -18
lump = rock(f'rock{i}', i, rng.uniform(1.5, 8), grey); lump.location = (x, y, z); lump.rotation_euler = (rng.random() * 6, rng.random() * 6, 0)
spin(lump, 'z', rng.uniform(.03, .1) * rng.choice((-1, 1)))
camera_path([(1, (0, -70, 0), (4, 40, 0)), (280, (-16, 96, 4), (-4, 330, 8)), (350, (-17, 102, 4), (-4, 330, 8))], lens=30)
elif SCENE == 'orbit-gold':
world_tint = ('#120C04', '#2A1C08')
stars_world(.5, '#FFE2A0')
sun((.72, .62, -.2), 8, '#FFE6B4')
body, ring = planet([(.36, '#7A4E10'), (.46, '#C88A24'), (.54, '#F0C050'), (.62, '#FFE6A0'), (.69, '#C08020'), (.78, '#6A4210')], ('#FFD060', '#A87420'))
gilt = body.data.materials[0].node_tree.nodes['Principled BSDF']; gilt.inputs['Metallic'].default_value = .55; gilt.inputs['Roughness'].default_value = .42
# Gold dust drifting through it all.
rng = random.Random(21)
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=.09); seed = bpy.context.active_object; seed.data.materials.append(glowing('dust', '#FFCB5A', 5))
for i in range(420):
speck = seed.copy(); bpy.context.collection.objects.link(speck); speck.location = (rng.uniform(-70, 40), rng.uniform(-40, 60), rng.uniform(-30, 30)); speck.scale = (rng.uniform(.5, 2.2),) * 3
speck.keyframe_insert('location', frame=1); speck.location.z += rng.uniform(2, 7); speck.location.x += rng.uniform(-3, 3); speck.keyframe_insert('location', frame=350)
camera_path([(1, (34, -46, -14), (6, 0, 0)), (260, (-27, -78, 8), (-24, 0, 1)), (350, (-27, -78, 8), (-24, 0, 1))])
elif SCENE == 'evidence':
scene.world = bpy.data.worlds.new('dark'); scene.world.use_nodes = True; scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.002, .002, .003, 1)
case_board()
# Slides along the wall, close in on the photos, and backs off to the end of the trail.
camera_path([(1, (-46, -30, 8), (-42, 0, 6)), (150, (-12, -44, 6), (-6, 0, 5)), (280, (18, -58, 3), (18, 0, 3)), (350, (18, -60, 3), (18, 0, 3))], lens=32)
elif SCENE == 'orbit-britx':
sky_world([(0, '#E89850'), (.1, '#B05A58'), (.24, '#3C3268'), (.5, '#0C1230')])
sun((.42, .82, -.38), 3.2, '#FFC890', 2)
water, n, l = material('thames'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb('#0A1420'); bsdf.inputs['Roughness'].default_value = .16; bsdf.inputs['Metallic'].default_value = .3
ripple = n.new('ShaderNodeTexNoise'); ripple.inputs['Scale'].default_value = .5; ripple.inputs['Detail'].default_value = 6
tiny = n.new('ShaderNodeBump'); tiny.inputs['Strength'].default_value = .16; l.new(ripple.outputs['Fac'], tiny.inputs['Height']); l.new(tiny.outputs[0], bsdf.inputs['Normal'])
box('river', 0, 300, 900, 1200, .1, water, -.1)
lime, slate, steel = stone('portland', '#C8B08A', .7), stone('slate', '#10141C', .6), stone('steel', '#22384E', .35, .8)
tower_bridge(70, lime, slate, steel)
big_ben(30, 262, lime, slate)
# Parliament running back along the bank from the clock, and the city's lights on the far side.
box('parliament', 42, 345, 26, 150, 24, window_wall('#C8B08A', 2, ('#FFD27A', '#FFC873', '#FFE0A0'), .55, (2.2, 4)), 0)
for y in range(275, 420, 12):
spire(f'pinnacle{y}', 30, y, 24, 2.4, 9, slate)
wall = window_wall('#1A2030', 1.4, ('#FFC873', '#FFD99A', '#9AC8FF'), .2, (1.3, 2))
rng = random.Random(5)
for y in range(-60, 520, 26):
h = rng.uniform(8, 34)
box(f'leftbank{y}', -96 - rng.uniform(0, 26), y, rng.uniform(22, 40), 22, h, wall)
if y < 200: box(f'rightbank{y}', 96 + rng.uniform(0, 26), y, rng.uniform(22, 40), 22, h * .9, wall)
box('southbank', -160, 300, 170, 800, 3, slate); box('northbank', 180, 300, 230, 800, 3, slate)
# Skims the water, climbs over the walkway of the bridge, and comes down the river to Big Ben.
camera_path([(1, (-6, -60, 7), (0, 70, 24)), (130, (-2, 62, 44), (10, 200, 42)), (285, (-32, 112, 30), (-8, 262, 58)), (350, (-32, 116, 30), (-8, 262, 58))], lens=30)
else:
raise SystemExit('unknown scene')
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""An original curtain-up fanfare for the Drive-In ident, written and synthesised here, so there's no licence to clear.
The room hushes, the drapes swish as they part, and brass builds to a full chord as the logo fills the screen.
build-fanfare.py <out.wav>
"""
import math, random, struct, sys, wave
RATE, LENGTH = 32000, 7.0
SIZE = int(RATE * LENGTH)
OPEN, HIT = 2.36, 5.2
rng = random.Random(7)
left, right = [0.0] * SIZE, [0.0] * SIZE
def add(i, value, pan=0.0):
left[i] += value * (1 - pan) * .5
right[i] += value * (1 + pan) * .5
def smooth(x): return 0.0 if x <= 0 else 1.0 if x >= 1 else x * x * (3 - 2 * x)
def freq(note): return 440 * 2 ** ((note - 69) / 12)
# Brass: detuned saws, entering from the bottom up, opening out as they swell.
CHORD = (39, 46, 51, 55, 58, 63, 67, 70, 75, 79)
for order, note in enumerate(CHORD):
enter = 1.5 + order * .3
for detune in (-.09, 0, .08):
f, pan = freq(note + detune), rng.uniform(-.7, .7)
phase, low = rng.random(), 0.0
for i in range(int(enter * RATE), SIZE):
t = i / RATE
phase = (phase + f / RATE) % 1
# Quiet on entry, then building to the hit.
level = smooth((t - enter) / 1.4) * (.14 + .86 * smooth((t - 3.2) / (HIT - 3.2))) * (1 - smooth((t - 6.2) / .8))
cutoff = .03 + .32 * smooth((t - 2.6) / (HIT - 2.6))
low += ((2 * phase - 1) - low) * cutoff
add(i, low * level * .075, pan)
# The drapes: noise, shaped like heavy cloth pulled across a stage.
low, band = 0.0, 0.0
for i in range(int(OPEN * RATE), int((OPEN + 3.2) * RATE)):
t = i / RATE - OPEN
noise = rng.uniform(-1, 1)
low += (noise - low) * .09
band += (low - band) * .012
level = smooth(t / .9) * (1 - smooth((t - 1.8) / 1.4)) * (.72 + .28 * math.sin(t * 7.1) * math.sin(t * 2.3))
add(i, (low - band) * level * .85, math.sin(t * 1.1) * .5)
# Timpani rolling up to the hit, then the hit itself.
def drum(start, loud):
for i in range(int(start * RATE), min(SIZE, int((start + 1.4) * RATE))):
t = i / RATE - start
add(i, math.sin(math.tau * (62 + 22 * math.exp(-t * 26)) * t) * math.exp(-t * 3.6) * loud)
t = 3.5
while t < HIT - .02:
drum(t, .05 + .2 * smooth((t - 3.5) / (HIT - 3.5)))
t += .085
drum(HIT, .5)
# A cymbal swell that breaks on the hit.
high = 0.0
for i in range(int(3.7 * RATE), SIZE):
t = i / RATE
noise = rng.uniform(-1, 1); high += (noise - high) * .4
swell = smooth((t - 3.7) / (HIT - 3.7)) ** 2 if t < HIT else math.exp(-(t - HIT) * 1.7)
add(i, (noise - high) * swell * .1, .2)
# The size of the room.
for channel, delays in ((left, (1499, 2111, 2789, 3253)), (right, (1613, 2239, 2903, 3389))):
wet = [0.0] * SIZE
for delay in delays:
line = [0.0] * delay
for i in range(SIZE):
out = line[i % delay]
line[i % delay] = channel[i] + out * .72
wet[i] += out * .17
for i in range(SIZE): channel[i] += wet[i]
peak = max(max(abs(a), abs(b)) for a, b in zip(left, right))
gain = .89 / peak
with wave.open(sys.argv[1], 'wb') as out:
out.setnchannels(2); out.setsampwidth(2); out.setframerate(RATE)
frames = bytearray()
for i in range(SIZE):
fade = 1 - smooth((i / RATE - 6.7) / .3)
frames += struct.pack('<hh', int(left[i] * gain * fade * 32767), int(right[i] * gain * fade * 32767))
out.writeframes(bytes(frames))
+4
View File
@@ -59,3 +59,7 @@ Inside the app's WebView on the box, `100vh` came out as 0, so the game's canvas
## Drive-In tune time
Tickets, reminders and the 20:00 start all worked on the TV. The film took 7.9 s to be ready after tuning. Worth preloading it from the reminder, as the idents do for the next show.
## Drive-In "next" label
The player's NEXT line asks the channel for the following programme, and between bookings a channel fills from its loop. On the Drive-In that named a film that would never play (Toy Story 2 instead of 23:00 Misery). The film itself ends and returns to the lot. Show the next booking instead.