feat: Blender-authored roadside districts that rise per chapter

This commit is contained in:
2026-10-10 18:25:55 +03:00
parent 71959f7541
commit a170abc093
10 changed files with 693 additions and 1 deletions

View File

@@ -0,0 +1,239 @@
"""Author the roadside district kit in its own scene and library.
Run headless only, so no open session is touched:
blender -b --factory-startup --python assets/blender/districts/author-districts.py
It builds the "Roadside Districts" scene from scratch, drops the factory scene and
saves nightdrive-districts.blend. Convention: Blender +X faces the road (three.js +X), +Y runs
down the road (three.js -Z), Z is up; every piece stands on z=0 at its origin.
Each piece is two meshes: <piece>_body (graphite) and <piece>_light (emissive).
"""
import bpy, bmesh, math
from mathutils import Matrix, Euler, Vector
from pathlib import Path
if not bpy.app.background:
raise SystemExit('Run author-districts.py headless (blender -b); it saves a new file.')
SCENE = 'Roadside Districts'
LIB = Path(__file__).resolve().parent / 'nightdrive-districts.blend'
if SCENE in bpy.data.scenes:
old = bpy.data.scenes[SCENE]
for obj in list(old.objects):
mesh = obj.data
bpy.data.objects.remove(obj)
if mesh and mesh.users == 0:
bpy.data.meshes.remove(mesh)
bpy.data.scenes.remove(old)
scene = bpy.data.scenes.new(SCENE)
class Part:
def __init__(self):
self.bm = bmesh.new()
def box(self, c, s, r=(0, 0, 0)):
geom = bmesh.ops.create_cube(self.bm, size=1.0)
m = Matrix.Translation(Vector(c)) @ Euler([math.radians(a) for a in r]).to_matrix().to_4x4() @ Matrix.Diagonal(Vector((*s, 1.0)))
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
def beam(self, a, b, t):
"""Square beam of thickness t between points a and b."""
a, b = Vector(a), Vector(b)
d = b - a
geom = bmesh.ops.create_cube(self.bm, size=1.0)
rot = d.to_track_quat('Z', 'Y').to_matrix().to_4x4()
m = Matrix.Translation((a + b) / 2) @ rot @ Matrix.Diagonal(Vector((t, t, d.length, 1.0)))
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
def piece(name, body, light):
for suffix, part in (('body', body), ('light', light)):
bmesh.ops.remove_doubles(part.bm, verts=part.bm.verts, dist=1e-4)
mesh = bpy.data.meshes.new(f'{name}_{suffix}')
part.bm.to_mesh(mesh)
part.bm.free()
obj = bpy.data.objects.new(f'{name}_{suffix}', mesh)
scene.collection.objects.link(obj)
# Overhead gantry spanning the carriageway (x -11..11 in three.js units).
b, l = Part(), Part()
for x in (-11.2, 11.2):
b.box((x, 0, 0.2), (1.2, 1.6, 0.4))
b.box((x, -0.45, 3.9), (0.34, 0.34, 7.4))
b.box((x, 0.45, 3.9), (0.34, 0.34, 7.4))
for z in (1.6, 3.4, 5.2):
b.beam((x, -0.45, z), (x, 0.45, z + 1.6), 0.12)
for y in (-0.45, 0.45):
for z in (6.3, 7.5):
b.box((0, y, z), (22.8, 0.22, 0.22))
for i in range(12):
x = -10.4 + i * 1.9
b.beam((x, -0.45, 6.3), (x + 1.9, -0.45, 7.5), 0.1)
b.beam((x, 0.45, 6.3), (x + 1.9, 0.45, 7.5), 0.1)
b.box((x, 0, 6.3), (0.1, 0.9, 0.1))
for cx, w in ((-5.2, 7.0), (4.6, 6.0)):
b.box((cx, -0.62, 5.0), (w, 0.14, 2.6))
l.box((cx, -0.71, 6.32), (w, 0.04, 0.07))
l.box((cx, -0.71, 3.68), (w, 0.04, 0.07))
for k, f in enumerate((0.62, 0.4, 0.5)):
l.box((cx - w * (1 - f) / 2 + 0.4, -0.71, 5.6 - k * 0.55), (w * f - 0.8, 0.04, 0.16))
l.box((-1.4, -0.71, 4.4), (0.9, 0.04, 0.16), (0, 45, 0))
l.box((-1.65, -0.71, 4.6), (0.16, 0.04, 0.7))
for x in range(-10, 11, 4):
l.box((x, 0, 6.15), (0.6, 0.12, 0.05))
piece('gantry', b, l)
# Open-air server row with an overhead cable tray (Infrastructure).
b, l = Part(), Part()
for i in range(6):
y = -2.75 + i * 1.1
b.box((0, y, 1.15), (1.2, 1.0, 2.3))
b.box((0, y, 2.36), (1.0, 0.9, 0.12))
for k in range(9):
width = (0.82, 0.62, 0.74, 0.4, 0.82, 0.55, 0.7, 0.3, 0.6)[(k + i * 2) % 9]
l.box((0.61, y - (0.82 - width) / 2, 0.45 + k * 0.2), (0.03, width, 0.045))
l.box((0.61, y + 0.38, 2.12), (0.03, 0.08, 0.08))
for y in (-3.4, 3.4):
b.box((-0.3, y, 1.6), (0.18, 0.18, 3.2))
b.box((0.2, y, 3.15), (1.2, 0.16, 0.12))
b.box((0.35, 0, 3.05), (0.04, 7.0, 0.16))
b.box((-0.35, 0, 3.05), (0.04, 7.0, 0.16))
for k in range(12):
b.box((0, -3.3 + k * 0.6, 3.0), (0.7, 0.05, 0.04))
l.box((0, 0, 2.94), (0.5, 6.8, 0.03))
piece('rack-row', b, l)
# Lattice radio mast with dishes (Toolkit / Infrastructure horizon).
b, l = Part(), Part()
H = 26.0
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1)]
def half(z):
return 1.6 - z * 0.045
for cx, cy in corners:
b.beam((cx * half(0), cy * half(0), 0), (cx * half(H), cy * half(H), H), 0.16)
for lv in range(9):
z0, z1 = lv * H / 9, (lv + 1) * H / 9
for j in range(4):
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
b.beam((ax * half(z1), ay * half(z1), z1), (bx * half(z1), by * half(z1), z1), 0.09)
b.beam((ax * half(z0), ay * half(z0), z0), (bx * half(z1), by * half(z1), z1), 0.07)
b.box((0, 0, H + 3), (0.12, 0.12, 6))
for z, ang in ((18, 30), (13, -40), (21, 160)):
d = Vector((math.cos(math.radians(ang)), math.sin(math.radians(ang)), 0))
b.box(tuple(d * 1.4 + Vector((0, 0, z))), (0.18, 1.4, 1.4), (0, 0, ang))
for z in (H + 6, H, 17, 9):
l.box((0, 0, z), (0.34, 0.34, 0.34))
piece('mast', b, l)
# Workshop with half-open roller door and a lift (Selected builds).
b, l = Part(), Part()
W, D, Hh = 8.0, 6.0, 4.2
b.box((-D, 0, Hh / 2), (0.2, W, Hh))
b.box((-D / 2, -W / 2, Hh / 2), (D, 0.2, Hh))
b.box((-D / 2, W / 2, Hh / 2), (D, 0.2, Hh))
for s in (-1, 1):
b.box((-D / 2, s * W / 4, Hh + 0.75), (D + 0.4, W / 2 + 0.25, 0.14), (s * 20.5, 0, 0))
b.box((0, -W / 2 + 0.3, Hh / 2), (0.3, 0.6, Hh))
b.box((0, W / 2 - 0.3, Hh / 2), (0.3, 0.6, Hh))
for k in range(5):
b.box((0.05, 0, Hh - 0.25 - k * 0.32), (0.1, W - 1.2, 0.26))
b.box((-4.8, -2.2, 0.45), (1.6, 1.0, 0.9))
for y in (0.4, 3.0):
b.box((-2.6, y, 1.1), (0.25, 0.25, 2.2))
b.box((-2.6, 1.7, 1.15), (2.4, 2.8, 0.08))
l.box((-D / 2, 0, Hh + 0.1), (D - 0.4, 0.06, 0.05))
l.box((0.25, 0, Hh + 0.35), (0.04, W - 1.4, 0.32))
l.box((0.22, -W / 2 + 0.62, Hh / 2), (0.04, 0.05, Hh))
l.box((0.22, W / 2 - 0.62, Hh / 2), (0.04, 0.05, Hh))
l.box((-4.0, -2.2, 0.96), (0.9, 0.5, 0.03))
piece('workshop', b, l)
# Tower crane (Selected builds / Additional work horizon).
b, l = Part(), Part()
Hc = 30.0
for cx, cy in corners:
b.box((cx * 0.75, cy * 0.75, Hc / 2), (0.18, 0.18, Hc))
for lv in range(10):
z = (lv + 1) * Hc / 10
for j in range(4):
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
b.beam((ax * 0.75, ay * 0.75, z), (bx * 0.75, by * 0.75, z), 0.08)
b.beam((ax * 0.75, ay * 0.75, z - Hc / 10), (bx * 0.75, by * 0.75, z), 0.06)
b.box((0, 0, Hc + 0.8), (2.2, 2.2, 1.6))
b.box((0, 0, Hc + 4), (0.3, 0.3, 4.8))
J = 26.0
for z in (Hc + 1.6, Hc + 2.8):
b.box((J / 2 - 3, 0.5 if z < Hc + 2 else 0, z), (J, 0.18, 0.18))
b.box((J / 2 - 3, -0.5, Hc + 1.6), (J, 0.18, 0.18))
for k in range(9):
x = -3 + k * J / 9
b.beam((x, 0, Hc + 2.8), (x + J / 9, 0.5, Hc + 1.6), 0.07)
b.beam((x, 0, Hc + 2.8), (x + J / 9, -0.5, Hc + 1.6), 0.07)
b.box((-7, 0, Hc + 1.7), (8, 0.8, 0.3))
b.box((-9.5, 0, Hc + 1.0), (2.2, 1.6, 1.4))
b.beam((J - 3, 0, Hc + 6.4), (-9, 0, Hc + 2.0), 0.05)
b.box((15, 0, Hc - 6), (0.05, 0.05, 12))
b.box((15, 0, Hc - 12.2), (0.8, 0.6, 0.5))
l.box((0, 0, Hc + 6.5), (0.4, 0.4, 0.4))
l.box((J - 3, 0, Hc + 2.0), (0.36, 0.36, 0.36))
l.box((15, 0, Hc - 12.6), (0.5, 0.3, 0.06))
piece('crane', b, l)
# Shipping container, stackable (Additional work).
b, l = Part(), Part()
b.box((0, 0, 1.3), (2.44, 6.06, 2.6))
for k in range(14):
y = -2.7 + k * 0.415
b.box((1.24, y, 1.3), (0.06, 0.18, 2.36))
b.box((-1.24, y, 1.3), (0.06, 0.18, 2.36))
for x in (-1.15, 1.15):
for y in (-3.0, 3.0):
b.box((x, y, 1.3), (0.16, 0.16, 2.62))
l.box((1.28, 0, 2.56), (0.03, 5.9, 0.05))
l.box((0.0, -3.06, 1.3), (1.8, 0.03, 0.05))
piece('container', b, l)
# Roadside billboard (Profile / Experience).
b, l = Part(), Part()
for y in (-2.5, 2.5):
b.box((0, y, 3.0), (0.3, 0.3, 6.0))
b.box((0.1, 0, 7.0), (0.24, 8.4, 3.6))
b.box((0.6, 0, 5.05), (1.0, 8.2, 0.08))
l.box((0.24, 0, 8.86), (0.04, 8.4, 0.08))
l.box((0.24, 0, 5.14), (0.04, 8.4, 0.08))
l.box((0.24, -4.2, 7.0), (0.04, 0.08, 3.8))
l.box((0.24, 4.2, 7.0), (0.04, 0.08, 3.8))
for k, f in enumerate((0.7, 0.45, 0.55)):
l.box((0.24, -4.2 + 8.4 * f / 2 + 0.6, 8.0 - k * 0.75), (0.04, 8.4 * f - 1.2, 0.22))
for y in (-3, 0, 3):
l.box((1.05, y, 5.15), (0.2, 0.5, 0.08))
piece('billboard', b, l)
# Destination arch over the road (Contact).
b, l = Part(), Part()
for s in (-1, 1):
b.box((s * 12.5, 0, 7.0), (1.4, 1.4, 14.0))
b.box((s * 12.5, 0, 0.3), (2.4, 2.4, 0.6))
l.box((s * 11.78, 0, 7.0), (0.05, 0.12, 12.4))
b.box((0, 0, 14.6), (27.4, 1.6, 1.4))
b.box((0, 0, 16.0), (18.0, 1.0, 1.4))
l.box((0, -0.82, 14.2), (25.0, 0.04, 0.12))
l.box((0, -0.82, 15.0), (25.0, 0.04, 0.05))
l.box((0, -0.52, 16.5), (16.0, 0.04, 0.1))
for k in range(9):
l.box((-8 + k * 2, 0, 13.88), (0.9, 0.5, 0.04))
piece('arch', b, l)
tris = 0
for obj in scene.objects:
obj.data.calc_loop_triangles()
tris += len(obj.data.loop_triangles)
for other in [s for s in bpy.data.scenes if s != scene]:
bpy.data.scenes.remove(other)
for block in (bpy.data.objects, bpy.data.meshes, bpy.data.cameras, bpy.data.lights, bpy.data.materials):
for item in list(block):
if item.users == 0:
block.remove(item)
bpy.ops.wm.save_as_mainfile(filepath=str(LIB), compress=True)
print('pieces', len(scene.objects) // 2, 'triangles', tris, 'library', LIB)

View File

@@ -0,0 +1,67 @@
"""Export the district kit to the runtime GLB (positions + indices only).
blender -b assets/blender/districts/nightdrive-districts.blend --python assets/blender/districts/export-districts.py [-- --preview out.png]
Normals are derived per fragment at runtime (flat shading from screen-space
derivatives), so the GLB carries no normals, UVs or materials and shared box
corners stay welded. Writes provenance next to this script.
"""
import bpy, json, hashlib, sys, math
from pathlib import Path
here = Path(__file__).resolve().parent
root = here.parents[2]
scene = bpy.data.scenes['Roadside Districts']
output = root / 'src/assets/nightdrive-districts.glb'
for obj in scene.objects:
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True,
export_apply=True, export_normals=False, export_texcoords=False, export_materials='NONE',
export_animations=False, export_cameras=False, export_lights=False, export_yup=True,
)
triangles = 0
for obj in scene.objects:
obj.data.calc_loop_triangles()
triangles += len(obj.data.loop_triangles)
provenance = {
'asset': 'Nightdrive roadside districts',
'source': 'nightdrive-districts.blend',
'authorship': 'Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.',
'editable_scene': 'Roadside Districts',
'pieces': sorted({o.name.rsplit('_', 1)[0] for o in scene.objects}),
'triangles': triangles,
'attributes': 'POSITION + indices; flat normals derived at runtime',
'export': '../../../src/assets/nightdrive-districts.glb',
'sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
}
(here / 'nightdrive-districts.asset.json').write_text(json.dumps(provenance, indent=2) + '\n')
print(json.dumps(provenance))
if '--preview' in sys.argv:
target = sys.argv[sys.argv.index('--preview') + 1]
names = provenance['pieces']
for i, name in enumerate(names):
for suffix in ('body', 'light'):
obj = scene.objects[f'{name}_{suffix}']
obj.location = ((i % 4) * 34 - 50, (i // 4) * 40, 0)
mat = bpy.data.materials.new(f'preview-{suffix}')
mat.diffuse_color = (0.05, 0.05, 0.08, 1) if suffix == 'body' else (0.1, 0.9, 1.0, 1)
obj.data.materials.append(mat)
cam = bpy.data.objects.new('preview-cam', bpy.data.cameras.new('preview-cam'))
scene.collection.objects.link(cam)
cam.location = (95, -95, 70)
cam.rotation_euler = (math.radians(58), 0, math.radians(45))
cam.data.lens = 30
scene.camera = cam
try:
scene.render.engine = 'BLENDER_WORKBENCH'
except TypeError as e:
print(e)
scene.display.shading.color_type = 'MATERIAL'
scene.display.shading.light = 'STUDIO'
scene.display.shading.show_cavity = True
scene.render.resolution_x, scene.render.resolution_y = 1600, 1000
scene.render.filepath = target
bpy.ops.render.render(write_still=True)

View File

@@ -0,0 +1,20 @@
{
"asset": "Nightdrive roadside districts",
"source": "nightdrive-districts.blend",
"authorship": "Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.",
"editable_scene": "Roadside Districts",
"pieces": [
"arch",
"billboard",
"container",
"crane",
"gantry",
"mast",
"rack-row",
"workshop"
],
"triangles": 5424,
"attributes": "POSITION + indices; flat normals derived at runtime",
"export": "../../../src/assets/nightdrive-districts.glb",
"sha256": "428a212792ac4d8dbf86b9224e9788f49cfc22b3d0dd1b72dfaf33c89f08f189"
}

Binary file not shown.