209 lines
11 KiB
Python
209 lines
11 KiB
Python
import bpy, math, os
|
||
from mathutils import Vector
|
||
from pathlib import Path
|
||
|
||
ROOT = str(Path(__file__).resolve().parents[1])
|
||
scene = bpy.data.scenes.new('Rhine_Archive_Work')
|
||
bpy.context.window.scene = scene
|
||
for old in list(bpy.data.scenes):
|
||
if old != scene and old.name.startswith('Rhine_Archive_Asset'):
|
||
for obj in list(old.objects):
|
||
if len(obj.users_scene)==1:bpy.data.objects.remove(obj,do_unlink=True)
|
||
bpy.data.scenes.remove(old)
|
||
scene.name='Rhine_Archive_Asset'
|
||
for m in list(bpy.data.materials):
|
||
if m.users==0:bpy.data.materials.remove(m)
|
||
|
||
def material(name, color, rough=.3, metal=0, transmission=0):
|
||
m=bpy.data.materials.new(name); m.diffuse_color=(*color,1); m.use_nodes=True
|
||
p=m.node_tree.nodes.get('Principled BSDF')
|
||
p.inputs['Base Color'].default_value=(*color,1)
|
||
p.inputs['Roughness'].default_value=rough
|
||
p.inputs['Metallic'].default_value=metal
|
||
p.inputs['Transmission Weight'].default_value=transmission
|
||
p.inputs['IOR'].default_value=1.46
|
||
return m
|
||
|
||
shell=material('Frosted_Polymer',(.985,.975,.963),.36,0,.78)
|
||
edge=material('Ivory_Edges',(.94,.916,.892),.28,.02,.72)
|
||
core=material('Internal_Ceramic',(.74,.705,.68),.52,.06)
|
||
metal=material('Titanium_Fasteners',(.58,.60,.61),.19,.82)
|
||
gold=material('Champagne_Index',(.64,.46,.29),.33,.48)
|
||
paper=material('Printed_Label',(.91,.89,.84),.65)
|
||
diffuser=material('Optical_Diffuser',(.925,.902,.881),.67,0,0)
|
||
optics=material('Subsurface_Optics',(.70,.675,.66),.39,.12)
|
||
optical_edge=material('Optical_Edges',(.94,.92,.90),.3,.03,.45)
|
||
ink=material('Carbon_Ink',(.025,.026,.023),.75)
|
||
|
||
def cube(name, loc, size, mat, bevel=.015):
|
||
bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
|
||
o=bpy.context.object; o.name=name; o.dimensions=size
|
||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||
o.data.materials.append(mat)
|
||
if bevel:
|
||
m=o.modifiers.new('Precision radiused edge','BEVEL'); m.width=bevel; m.segments=3
|
||
bpy.context.view_layer.objects.active=o; bpy.ops.object.modifier_apply(modifier=m.name)
|
||
o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL')
|
||
return o
|
||
|
||
def torus(name,x,z,radius,tube,mat,y=-.091):
|
||
bpy.ops.mesh.primitive_torus_add(major_radius=radius,minor_radius=tube,major_segments=64,minor_segments=10,location=(x,y,z),rotation=(math.pi/2,0,0))
|
||
o=bpy.context.object; o.name=name; o.data.materials.append(mat)
|
||
for p in o.data.polygons:p.use_smooth=True
|
||
return o
|
||
|
||
def text(name, body,x,z,size,mat=ink):
|
||
c=bpy.data.curves.new(name,'FONT'); c.body=body;c.size=size;c.extrude=.0002;c.space_character=1.05
|
||
o=bpy.data.objects.new(name,c);scene.collection.objects.link(o)
|
||
o.location=(x,-.123,z);o.rotation_euler=(math.pi/2,0,0);c.materials.append(mat)
|
||
return o
|
||
|
||
def annular_profile(name, x, z, profile, mat, segments=128, start=0, end=2*math.pi, sharp=False):
|
||
# Closed revolved cross-section: a shallow moulded lens, not a round tube.
|
||
vertices=[]; faces=[]; n=len(profile)
|
||
closed=abs(end-start-2*math.pi)<1e-6
|
||
segments=max(8,math.ceil(segments*(end-start)/(2*math.pi)))
|
||
rows=segments if closed else segments+1
|
||
for i in range(rows):
|
||
a=start+(end-start)*i/segments
|
||
for r,y in profile: vertices.append((x+r*math.cos(a),y,z+r*math.sin(a)))
|
||
for i in range(segments):
|
||
for j in range(n):
|
||
faces.append((i*n+j,((i+1)%rows)*n+j,((i+1)%rows)*n+(j+1)%n,i*n+(j+1)%n))
|
||
if not closed:
|
||
faces.extend([tuple(reversed(range(n))),tuple(segments*n+j for j in range(n))])
|
||
mesh=bpy.data.meshes.new(name);mesh.from_pydata(vertices,[],faces);mesh.update()
|
||
obj=bpy.data.objects.new(name,mesh);scene.collection.objects.link(obj);mesh.materials.append(mat)
|
||
# The clockwise section above yields outward normals, including the bore.
|
||
for p in mesh.polygons:p.use_smooth=len(p.vertices)==4
|
||
if sharp:
|
||
# Keep each section edge hard, but interpolate around the circumference.
|
||
# Flat shading alone would facet the circle; all-smooth shading balloons
|
||
# the roof/wall junction into a rounded tube.
|
||
normals=[]
|
||
for face in mesh.polygons:
|
||
if face.index>=segments*n:
|
||
face.use_smooth=False
|
||
normals.extend([tuple(face.normal)]*len(face.loop_indices))
|
||
continue
|
||
j=face.index%n
|
||
dr=profile[(j+1)%n][0]-profile[j][0]
|
||
dy=profile[(j+1)%n][1]-profile[j][1]
|
||
for loop in face.loop_indices:
|
||
row=mesh.loops[loop].vertex_index//n
|
||
angle=start+(end-start)*row/segments
|
||
normal=Vector((-dy*math.cos(angle),dr,-dy*math.sin(angle))).normalized()
|
||
normals.append(tuple(normal))
|
||
mesh.normals_split_custom_set(normals)
|
||
return obj
|
||
|
||
def channel(name, points, depth, radius, mat):
|
||
curve=bpy.data.curves.new(name,'CURVE');curve.dimensions='3D'
|
||
curve.resolution_u=8;curve.bevel_depth=radius;curve.bevel_resolution=2
|
||
spline=curve.splines.new('POLY');spline.points.add(len(points)-1)
|
||
for point,(x,z) in zip(spline.points,points):point.co=(x,depth,z,1)
|
||
obj=bpy.data.objects.new(name,curve);scene.collection.objects.link(obj);curve.materials.append(mat)
|
||
return obj
|
||
|
||
front_cover=cube('Front frosted optical cover',(0,-.095,1.85),(5,.016,3.7),shell,.007)
|
||
cube('Rear translucent carrier',(0,.055,1.85),(4.97,.02,3.68),edge,.009)
|
||
cube('Information substrate',(0,.025,1.86),(4.80,.012,3.47),diffuser,.006)
|
||
for z in [.028,3.672]:cube('Polished perimeter rail',(0,-.021,z),(4.95,.155,.034),edge,.009)
|
||
for x in [-2.476,2.476]:cube('Polished perimeter rail',(x,-.021,1.85),(.034,.155,3.66),edge,.009)
|
||
# Wide optical cavities sit BEHIND the frosted cover. Their lenticular profiles
|
||
# are shallow; no torus protrudes from the exterior face.
|
||
for x,z,r in [(-.44,1.92,.79),(1.13,2.48,.435)]:
|
||
width=.145 if r>.5 else .102
|
||
annular_profile('Embedded optical cavity',x,z,[
|
||
(r-width,.016),(r+width,.016),(r+width+.012,.003),
|
||
(r+width,-.014),(r+width-.022,-.027),(r+.032,-.040),
|
||
(r-.012,-.039),(r-.045,-.052),(r-width+.025,-.054),
|
||
(r-width,-.036)],optics)
|
||
annular_profile('Subsurface refractive shoulder',x,z,[
|
||
(r+.028,-.031),(r+width+.025,-.009),(r+width+.029,-.020),
|
||
(r+width+.012,-.032),(r+.056,-.053),(r+.028,-.049)],optical_edge)
|
||
annular_profile('Inner optical bevel',x,z,[
|
||
(r-width-.012,-.017),(r-width+.036,-.041),
|
||
(r-width+.041,-.057),(r-width+.023,-.064),
|
||
(r-width+.004,-.061),(r-width-.012,-.036)],optical_edge)
|
||
for offset,tube,y in [(width+.009,.006,-.031),(.030,.007,-.054),(-width+.022,.006,-.064)]:
|
||
o=torus('Concentric optical machining',x,z,r+offset,tube,optical_edge,y)
|
||
o.scale.z=.42
|
||
if r<.5:
|
||
annular_profile('Embedded amber annulus',x,z,[
|
||
(r-.170,-.047),(r-.070,-.047),(r-.067,-.060),
|
||
(r-.077,-.071),(r-.156,-.071),(r-.170,-.060)],gold)
|
||
cube('Serial label',(-1.36,-.099,3.04),(.99,.02,.41),paper,.003)
|
||
cube('Label top rule',(-1.36,-.116,3.23),(.98,.004,.008),ink,0)
|
||
cube('Label bottom rule',(-1.36,-.116,2.847),(.98,.004,.005),ink,0)
|
||
text('Company label','RHINE LAB, LLC.',-1.825,3.105,.105)
|
||
text('Database label','INTERNAL DATABASE',-1.825,3.017,.042,core)
|
||
text('Serial number','NO.001',-1.825,2.875,.148)
|
||
text('Information label','INFO',-.99,3.075,.076)
|
||
text('Symbol label','+ / -',-.99,2.9,.095)
|
||
for i in range(16):
|
||
o=cube('Laser etched vent',(1.04+i*.054,-.111,.57),(.023,.009,.1),core,.003);o.rotation_euler.y=.4
|
||
for i in range(25):cube('Calibration mark',(-2.23,-.108,.61+i*.052),(.035 if i%5 else .075,.005,.006),core,0)
|
||
text('Edge inscription','R H I N E L A B',-1.81,.28,.063,core)
|
||
for z,x1,x2 in [(3.36,-1.8,.2),(3.36,.4,1.55),(.4,-1.45,1.75)]:
|
||
cube('Engraved circuit trace',((x1+x2)/2,-.108,z),(x2-x1,.004,.005),core,.002)
|
||
# Two sides of a shallow pressed channel, observed in the 37–39 second close-up.
|
||
# Keep the entire channel behind the front cover to avoid coplanar stippling.
|
||
top=[(-2.27,3.06),(-2.27,3.30),(-2.10,3.45),(-1.72,3.45),(-1.61,3.51),
|
||
(-.65,3.51),(-.55,3.46),(.37,3.46),(.43,3.52),(.49,3.46),
|
||
(1.08,3.46),(1.17,3.52),(1.20,3.50),(1.13,3.42),
|
||
(1.79,3.42),(1.89,3.51),(2.19,3.51),(2.29,3.41),(2.29,3.03)]
|
||
channel('Moulded circuit channel shadow',top,-.071,.005,core)
|
||
channel('Moulded circuit channel lip',[(x,z-.018) for x,z in top],-.075,.008,optical_edge)
|
||
perimeter=[(-2.18,2.85),(-2.23,2.72),(-2.23,.43),(-2.12,.30),
|
||
(2.08,.30),(2.24,.44),(2.24,2.97)]
|
||
channel('Moulded inner perimeter',perimeter,-.067,.008,optical_edge)
|
||
for z in [.080,3.620]:
|
||
cube('Carrier mating seam',(0,.002,z),(4.82,.012,.010),optical_edge,.003)
|
||
for x in [-2.420,2.420]:
|
||
cube('Carrier mating seam',(x,.002,1.85),(.010,.012,3.54),optical_edge,.003)
|
||
# Small raised pads under the diagonal calibration vents.
|
||
for i in range(16):
|
||
cube('Moulded vent footing',(1.04+i*.054,-.071,.435),(.015,.014,.018),optical_edge,.004)
|
||
|
||
detail_script=Path(ROOT)/'art/clear_reference_details.py'
|
||
exec(compile(detail_script.read_text(encoding='utf-8-sig'),str(detail_script),'exec'))
|
||
|
||
# Convert text, bake modifiers, and group by material for efficient instancing.
|
||
bpy.ops.object.select_all(action='SELECT')
|
||
for o in list(scene.objects):
|
||
bpy.context.view_layer.objects.active=o
|
||
if o.type in ['FONT','CURVE']:bpy.ops.object.convert(target='MESH')
|
||
for m in list(o.modifiers):
|
||
try:bpy.ops.object.modifier_apply(modifier=m.name)
|
||
except:pass
|
||
for mat in list(dict.fromkeys(o.data.materials[0] for o in scene.objects if o.type=='MESH' and o.data.materials)):
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obs=[o for o in scene.objects if o.type=='MESH' and o.data.materials and o.data.materials[0]==mat]
|
||
if not obs:continue
|
||
for o in obs:o.select_set(True)
|
||
bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join()
|
||
o=bpy.context.object;o.name=mat.name
|
||
scene.cursor.location=(0,0,0);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
|
||
# Bake orientation to make every exported group share the same coordinate frame.
|
||
bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
|
||
# Annotated reference: the visible end face occupies roughly half a row pitch.
|
||
for obj in scene.objects:
|
||
if obj.type=='MESH':
|
||
if obj.data.materials[0].name.startswith(('Optical_Glass_', 'Optical_Bridge_Glass', 'Amber_Optical_Inlay')):
|
||
bpy.context.view_layer.objects.active=obj
|
||
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True)
|
||
obj.scale.y *= 2.0
|
||
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
|
||
else:
|
||
for vertex in obj.data.vertices:vertex.co.y *= 2.0
|
||
bpy.ops.object.select_all(action='SELECT')
|
||
export_path=Path(ROOT)/'art/.cache/archive-cassette.glb'
|
||
export_path.parent.mkdir(parents=True,exist_ok=True)
|
||
bpy.ops.export_scene.gltf(filepath=str(export_path),export_format='GLB',use_selection=True,use_active_scene=True,export_apply=True)
|
||
os.replace(str(export_path),ROOT+'/public/assets/archive-cassette.glb')
|
||
bpy.data.libraries.write(ROOT+'/art/rhine-archive.blend', {scene}, fake_user=True)
|
||
print('Exported archive cassette:',len(scene.objects),'material groups')
|
||
|
||
|