feat: evolve portfolio into an authored midnight grand tour
This commit is contained in:
13
assets/blender/evolution/README.md
Normal file
13
assets/blender/evolution/README.md
Normal 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`.
|
||||
66
assets/blender/evolution/add-personality.py
Normal file
66
assets/blender/evolution/add-personality.py
Normal 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'))
|
||||
39
assets/blender/evolution/export-grand-tourer.py
Normal file
39
assets/blender/evolution/export-grand-tourer.py
Normal 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
|
||||
32
assets/blender/evolution/nightdrive-evolution.asset.json
Normal file
32
assets/blender/evolution/nightdrive-evolution.asset.json
Normal 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."
|
||||
}
|
||||
BIN
assets/blender/evolution/nightdrive-evolution.blend
Normal file
BIN
assets/blender/evolution/nightdrive-evolution.blend
Normal file
Binary file not shown.
94
assets/blender/evolution/refine-grand-tourer.py
Normal file
94
assets/blender/evolution/refine-grand-tourer.py
Normal 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
|
||||
Reference in New Issue
Block a user