feat: Blender-authored roadside districts that rise per chapter
This commit is contained in:
239
assets/blender/districts/author-districts.py
Normal file
239
assets/blender/districts/author-districts.py
Normal 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)
|
||||
67
assets/blender/districts/export-districts.py
Normal file
67
assets/blender/districts/export-districts.py
Normal 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)
|
||||
20
assets/blender/districts/nightdrive-districts.asset.json
Normal file
20
assets/blender/districts/nightdrive-districts.asset.json
Normal 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"
|
||||
}
|
||||
BIN
assets/blender/districts/nightdrive-districts.blend
Normal file
BIN
assets/blender/districts/nightdrive-districts.blend
Normal file
Binary file not shown.
Reference in New Issue
Block a user