feat: evolve portfolio into an authored midnight grand tour

This commit is contained in:
2026-09-05 03:55:18 +03:00
parent 642f5b583d
commit c9ac36e249
41 changed files with 812 additions and 65 deletions

View File

@@ -0,0 +1,13 @@
# Grand Tourer — Evolution
Open `nightdrive-evolution.blend` directly to edit the final source. The `Portfolio Evolution` scene contains individually editable bodywork, louvers, wheel vanes, wing and marker housings. The original source in the parent folder was not overwritten. No external assets or paid services were used.
`export-grand-tourer.py` exports the loaded `Portfolio Evolution` scene into eight joined material groups, without lights, cameras, textures or animation. Execute it inside Blender with `__file__` set to the script path. The web renderer sanitizes Blender's duplicate-name suffixes before assigning its runtime lighting behavior.
Design history:
1. The original source scene was appended from `../nightdrive-grand-tourer.blend` and named `Portfolio Evolution`.
2. `refine-grand-tourer.py` applied the first paint, hub, louver and deck-lip pass. This first pass is historical and should not be reapplied to the finished source.
3. `add-personality.py` added the swept wing, mounted cyan guides and turbine wheel faces. This second pass replaces its own `signature-` objects on repeat runs.
To reproduce from the original, follow that order in a separate scene. For ordinary editing, use the finished `.blend` and export script. Asset counts and provenance are recorded in `nightdrive-evolution.asset.json`.

View File

@@ -0,0 +1,66 @@
"""Second design pass; run inside Blender with Portfolio Evolution loaded.
Re-running replaces only this pass's objects. Keeps the eight material groups.
"""
import bpy
import math
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes['Portfolio Evolution']
bpy.context.window.scene = scene
car = next(o for o in scene.objects if o.name.split('.')[0] == 'chase-car')
materials = {m.name.split('.')[0]: m for o in scene.objects if o.type == 'MESH' for m in o.data.materials}
for obj in list(scene.objects):
if obj.name.startswith('signature-'):
bpy.data.objects.remove(obj, do_unlink=True)
def finish(obj, name, material, bevel=0.008):
obj.name = 'signature-' + name
obj.parent = car
obj.data.materials.append(materials[material])
if bevel:
mod = obj.modifiers.new('Soft machined edge', 'BEVEL')
mod.width = bevel
mod.segments = 2
obj.modifiers.new('Weighted normals', 'WEIGHTED_NORMAL')
return obj
def box(name, location, dimensions, material, bevel=0.008):
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
obj = bpy.context.object
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return finish(obj, name, material, bevel)
# A swept floating wing. Its high tips and low center echo the body haunches.
outline = [(-1.22, 2.28), (-0.98, 2.08), (-0.72, 2.13),
(0.72, 2.13), (0.98, 2.08), (1.22, 2.28),
(1.18, 2.51), (0.72, 2.42), (-0.72, 2.42), (-1.18, 2.51)]
n = len(outline)
verts = [(x, y, 1.20 + (0.06 if abs(x) > 0.95 else 0) + dz)
for dz in [0, 0.065] for x, y in outline]
faces = [tuple(reversed(range(n))), tuple(range(n, 2*n))]
faces += [(i, (i+1)%n, (i+1)%n+n, i+n) for i in range(n)]
mesh = bpy.data.meshes.new('Swept aerofoil')
mesh.from_pydata(verts, [], faces)
mesh.update()
wing = bpy.data.objects.new('Swept wing', mesh)
scene.collection.objects.link(wing)
finish(wing, 'floating-wing', 'M_Body_Haunch', 0.018)
for side in [-1, 1]:
box('wing-pylon', (side*0.66, 2.27, 1.045), (0.075, 0.14, 0.35), 'M_Lower_Graphite')
box('wing-endplate', (side*1.185, 2.35, 1.245), (0.045, 0.30, 0.20), 'M_Body_Gunmetal')
box('wing-marker', (side*1.212, 2.37, 1.28), (0.014, 0.19, 0.025), 'M_Cyan_Accent', 0.003)
# Illuminated outer guides frame the dark diffuser, below the coral lamp.
box('rear-guide-housing', (side*0.88, 2.56, 0.50), (0.07, 0.23, 0.235), 'M_Lower_Graphite')
box('rear-guide', (side*0.88, 2.678, 0.50), (0.022, 0.02, 0.19), 'M_Cyan_Accent', 0.004)
for y, z in [(-1.62, 0.42), (1.62, 0.46)]:
# Eight swept metallic vanes sit inside each original rim ring.
for index in range(8):
angle = index * math.tau / 8
obj = box('turbine-vane', (side*1.186, y+math.sin(angle)*0.19, z+math.cos(angle)*0.19),
(0.022, 0.064, 0.19), 'M_Wheel', 0.005)
obj.rotation_euler.x = -angle + side*0.3
bpy.data.libraries.write(str(root_path / 'assets/blender/evolution/nightdrive-evolution.blend'), {scene}, fake_user=True, compress=True)
exec(compile((root_path / 'assets/blender/evolution/export-grand-tourer.py').read_text(), str(root_path / 'assets/blender/evolution/export-grand-tourer.py'), 'exec'))

View File

@@ -0,0 +1,39 @@
"""Export the current authored Portfolio Evolution scene. Run inside Blender."""
import bpy
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes["Portfolio Evolution"]
# A separate export scene combines geometry by material: eight runtime draw groups.
export_scene = bpy.data.scenes.new('Evolution Export')
bpy.context.window.scene = export_scene
root = bpy.data.objects.new('evolution-chase-car', None)
export_scene.collection.objects.link(root)
names = {'M_Body_Gunmetal': 'car-body', 'M_Body_Haunch': 'body-haunches', 'M_Glass_Smoke': 'rear-glass', 'M_Rear_Light': 'rear-light-bar', 'M_Cyan_Accent': 'car-underglow-source', 'M_Lower_Graphite': 'body-trim', 'M_Wheel': 'wheel-machining', 'M_Tire': 'tires'}
bpy.context.window.scene = scene
depsgraph = bpy.context.evaluated_depsgraph_get()
groups = {}
for obj in scene.objects:
if obj.type != 'MESH' or obj.name.startswith('LOOKDEV'):
continue
evaluated = obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
mesh.transform(obj.matrix_world)
clone = bpy.data.objects.new('export-' + obj.name, mesh)
export_scene.collection.objects.link(clone)
key = obj.data.materials[0].name.split('.')[0]
groups.setdefault(key, []).append(clone)
bpy.context.window.scene = export_scene
for key, objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
joined = bpy.context.object
joined.name = 'evolution-' + names[key]
joined.parent = root
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(filepath=str(root_path / 'src/assets/nightdrive-grand-tourer-evolution.glb'), export_format='GLB', use_selection=True, use_active_scene=True, export_apply=True, export_animations=False, export_cameras=False, export_lights=False)
print('Exported', len(groups), 'material groups')
bpy.context.window.scene = scene

View File

@@ -0,0 +1,32 @@
{
"name": "Nightdrive Grand Tourer \u2014 Evolution",
"source": "Derivative refinement of the original portfolio-authored Nightdrive Grand Tourer, with user-authorized Blender modeling",
"license": "Original work",
"thirdPartyAssets": [],
"externalProviders": [],
"sourceFile": "nightdrive-evolution.blend",
"parentSourceFile": "../nightdrive-grand-tourer.blend",
"exportFile": "../../../src/assets/nightdrive-grand-tourer-evolution.glb",
"exportProfile": {
"format": "GLB",
"meshObjects": 8,
"triangles": 18564,
"materials": 8,
"textures": 0,
"animations": 0,
"bytes": 754228
},
"exportSha256": "57c5f4250eab89145e3d7a026d21b12d510e47fade248e481335199d3b963d2d",
"details": [
"Silver-blue metallic paint",
"Recessed wheel hubs and eight-vane turbine wheel faces",
"Six rear-window louvers",
"Low deck lip and swept floating wing with cyan endplate markers",
"Segmented coral tail light and mounted cyan rear guide lamps"
],
"posterSources": [
"src/assets/hero-evolution.jpg",
"src/assets/hero-evolution-mobile.jpg"
],
"posterProvenance": "Canvas-only captures of the real Three.js world at desktop and phone viewport sizes. Decorative fallback only; not project evidence."
}

Binary file not shown.

View File

@@ -0,0 +1,94 @@
"""Run in Blender after loading the original into the isolated Portfolio Evolution scene.
Original scene and other worktrees are never saved or overwritten.
"""
import bpy
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes['Portfolio Evolution']
bpy.context.window.scene = scene
car = next(o for o in scene.objects if o.name.split('.')[0] == 'chase-car')
materials = {m.name.split('.')[0]: m for o in scene.objects if o.type == 'MESH' for m in o.data.materials}
for name, color, metallic, roughness in [
('M_Body_Gunmetal', (0.14, 0.21, 0.28, 1), 0.78, 0.28),
('M_Body_Haunch', (0.19, 0.28, 0.34, 1), 0.8, 0.24),
('M_Wheel', (0.28, 0.34, 0.39, 1), 0.9, 0.23),
]:
bsdf = materials[name].node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Base Color'].default_value = color
bsdf.inputs['Metallic'].default_value = metallic
bsdf.inputs['Roughness'].default_value = roughness
bsdf.inputs['Coat Weight'].default_value = 0.28 if 'Body' in name else 0
bsdf.inputs['Coat Roughness'].default_value = 0.23
# Recess the old projecting cylinders into the tire face.
for o in scene.objects:
stem = o.name.split('.')[0]
if '-rim-' in stem and '-ring-' not in stem:
o.scale.z *= 0.16
o.location.x = (1 if o.location.x > 0 else -1) * 1.145
if stem.startswith('diffuser-fin-'):
o.scale.z *= 0.65
def box(name, location, dimensions, material, bevel=0.012):
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.parent = car
obj.data.materials.append(material)
mod = obj.modifiers.new('Machined edge', 'BEVEL')
mod.width = bevel
mod.segments = 3
obj.modifiers.new('Weighted normals', 'WEIGHTED_NORMAL')
return obj
# Six individually editable louvers, following the slope of the rear glass.
for index in range(6):
t = index / 5
y = 0.30 + t * 0.74
z = 1.245 - t * 0.275
box('evolution-louver-%02d' % index, (0, y, z), (1.25 + t * 0.18, 0.052, 0.026), materials['M_Lower_Graphite'], 0.008)
box('evolution-deck-lip', (0, 2.07, 0.89), (1.72, 0.095, 0.055), materials['M_Body_Haunch'], 0.018)
# Dark dividers articulate a continuous lamp without increasing emissive energy.
for x in [-0.54, -0.27, 0, 0.27, 0.54]:
box('evolution-lamp-divider', (x, 2.68, 0.694), (0.012, 0.012, 0.047), materials['M_Lower_Graphite'], 0.002)
# Write just this authored scene and its dependencies, not other open projects.
bpy.data.libraries.write(str(root_path / 'assets/blender/evolution/nightdrive-evolution.blend'), {scene}, fake_user=True, compress=True)
# A separate export scene combines geometry by material: eight runtime draw groups.
export_scene = bpy.data.scenes.new('Evolution Export')
bpy.context.window.scene = export_scene
root = bpy.data.objects.new('evolution-chase-car', None)
export_scene.collection.objects.link(root)
names = {'M_Body_Gunmetal': 'car-body', 'M_Body_Haunch': 'body-haunches', 'M_Glass_Smoke': 'rear-glass', 'M_Rear_Light': 'rear-light-bar', 'M_Cyan_Accent': 'car-underglow-source', 'M_Lower_Graphite': 'body-trim', 'M_Wheel': 'wheel-machining', 'M_Tire': 'tires'}
bpy.context.window.scene = scene
depsgraph = bpy.context.evaluated_depsgraph_get()
groups = {}
for obj in scene.objects:
if obj.type != 'MESH' or obj.name.startswith('LOOKDEV'):
continue
evaluated = obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
mesh.transform(obj.matrix_world)
clone = bpy.data.objects.new('export-' + obj.name, mesh)
export_scene.collection.objects.link(clone)
key = obj.data.materials[0].name.split('.')[0]
groups.setdefault(key, []).append(clone)
bpy.context.window.scene = export_scene
for key, objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
joined = bpy.context.object
joined.name = 'evolution-' + names[key]
joined.parent = root
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(filepath=str(root_path / 'src/assets/nightdrive-grand-tourer-evolution.glb'), export_format='GLB', use_selection=True, use_active_scene=True, export_apply=True, export_animations=False, export_cameras=False, export_lights=False)
print('Exported', len(groups), 'material groups')
bpy.context.window.scene = scene