"""Tideglass ruin portal. Blender 5.2; self-contained deterministic clean builder.
Run: blender -b --python-exit-code 1 --python this.py
Optional -- --preflight or --clay-only. All photographic inputs Poly Haven CC0.
"""
import bpy, bmesh, math, random, os, sys, json, urllib.request, time
from pathlib import Path
from mathutils import Vector
from mathutils.noise import noise_vector
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(7391)
START=time.time()
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for dat in list(bpy.data.materials): bpy.data.materials.remove(dat)
sc=bpy.context.scene
sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES'; sc.cycles.samples=40
sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try:
 prefs.compute_device_type='OPTIX'; prefs.get_devices()
 for d in prefs.devices: d.use=(d.type=='OPTIX')
 sc.cycles.device='GPU'
except Exception: sc.cycles.device='CPU'
sc.render.image_settings.file_format='PNG'
sc.render.resolution_x=1920; sc.render.resolution_y=1080; sc.render.resolution_percentage=100
sc.view_settings.view_transform='AgX'
sc.render.film_transparent=False
ASSETS=[]; PARTS=[]

def activate(ob):
 bpy.ops.object.select_all(action='DESELECT'); ob.select_set(True); bpy.context.view_layer.objects.active=ob

def mesh(name,v,f,mat,smooth=False,bevel=0):
 me=bpy.data.meshes.new(name); me.from_pydata(v,[],f); me.update()
 ob=bpy.data.objects.new(name,me); sc.collection.objects.link(ob)
 bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(me); bm.free()
 ob.data.materials.append(mat)
 if smooth:
  for p in me.polygons: p.use_smooth=True
 if bevel:
  activate(ob); m=ob.modifiers.new('Rounded worn arrises','BEVEL'); m.width=bevel; m.segments=3
  bpy.ops.object.modifier_apply(modifier=m.name)
 ASSETS.append(ob); return ob

def box(name,loc,scale,mat,bev=.04,damage=False):
 bpy.ops.mesh.primitive_cube_add(size=1, location=loc); o=bpy.context.object; o.name=name; o.scale=scale
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if damage:
  bm=bmesh.new(); bm.from_mesh(o.data)
  # Localised fractured corners: closed plane cuts, not rounded random rocks.
  for idx in random.sample(range(8),random.randint(3,5)):
   sx=1 if idx&1 else -1; sy=1 if idx&2 else -1; sz=1 if idx&4 else -1
   co=Vector((sx*scale[0]/2,sy*scale[1]/2,sz*scale[2]/2))
   normal=Vector((sx,sy,sz)).normalized(); co-=normal*random.uniform(.06,.17)
   res=bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.00001,plane_co=co,plane_no=normal,clear_outer=True)
   edges=[e for e in res['geom_cut'] if isinstance(e,bmesh.types.BMEdge) and e.is_boundary]
   if edges: bmesh.ops.holes_fill(bm,edges=edges,sides=0)
  bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(o.data); bm.free()
 o.data.materials.append(mat)
 m=o.modifiers.new('Eroded stone edges','BEVEL'); m.width=bev; m.segments=3
 bpy.ops.object.modifier_apply(modifier=m.name)
 ASSETS.append(o); return o

def lathe(name,x,y,profile,mat,n=64):
 v=[]; f=[]
 for z,r in profile:
  for j in range(n):
   a=j*2*pi/n; v.append((x+r*cos(a),y+r*sin(a),z))
 for k in range(len(profile)-1):
  for j in range(n):
   a=k*n+j;b=k*n+(j+1)%n;f.append((a,b,b+n,a+n))
 f.append(tuple(reversed(range(n)))); f.append(tuple((len(profile)-1)*n+j for j in range(n)))
 return mesh(name,v,f,mat,True)

def source(id,map):
 p=TEX/(id+'_'+map+'_1k.jpg')
 if not p.exists():
  u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  req=urllib.request.Request(u,headers={'User-Agent':'Mozilla/5.0'})
  for attempt in range(3):
   try:
    with urllib.request.urlopen(req,timeout=60) as r: p.write_bytes(r.read())
    break
   except Exception:
    if attempt==2: raise
    time.sleep(2)
 return bpy.data.images.load(str(p),check_existing=True)

def material(name,id,kind):
 m=bpy.data.materials.new(name); m.use_nodes=True
 nt=m.node_tree; nt.nodes.clear(); n=nt.nodes; l=nt.links
 def node(t): return n.new(t)
 out=node('ShaderNodeOutputMaterial'); bs=node('ShaderNodeBsdfPrincipled'); l.new(bs.outputs[0],out.inputs['Surface'])
 tex=node('ShaderNodeTexCoord'); scale=node('ShaderNodeVectorMath'); scale.operation='SCALE'; scale.inputs[3].default_value=1.25
 l.new(tex.outputs['Geometry'] if 'Geometry' in tex.outputs else tex.outputs['Object'],scale.inputs[0])
 maps={}
 for mp in ['diff','nor_gl','arm']:
  t=node('ShaderNodeTexImage'); t.image=source(id,mp); t.projection='BOX'; t.projection_blend=.25
  if mp!='diff': t.image.colorspace_settings.name='Non-Color'
  l.new(scale.outputs['Vector'],t.inputs['Vector']); maps[mp]=t
 noise=node('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=3.3; noise.inputs['Detail'].default_value=5; noise.inputs['Roughness'].default_value=.72
 l.new(tex.outputs['Object'],noise.inputs['Vector'])
 ramp=node('ShaderNodeValToRGB')
 if kind=='glass':
  ramp.color_ramp.elements[0].position=.17; ramp.color_ramp.elements[0].color=(.022,.11,.095,1)
  ramp.color_ramp.elements[1].position=.8; ramp.color_ramp.elements[1].color=(.25,.63,.52,1)
 elif kind=='bronze':
  ramp.color_ramp.elements[0].position=.36; ramp.color_ramp.elements[0].color=(.035,.13,.095,1)
  ramp.color_ramp.elements[1].position=.62; ramp.color_ramp.elements[1].color=(.35,.18,.065,1)
 elif kind=='shell':
  ramp.color_ramp.elements[0].color=(.22,.205,.16,1); ramp.color_ramp.elements[1].color=(.7,.65,.5,1)
 else:
  ramp.color_ramp.elements[0].color=(.18,.22,.11,1); ramp.color_ramp.elements[1].color=(.56,.53,.42,1)
 l.new(noise.outputs['Fac'],ramp.inputs[0])
 mix=node('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.42 if kind!='stone' else .66
 l.new(ramp.outputs['Color'],mix.inputs[1]); l.new(maps['diff'].outputs['Color'],mix.inputs[2])
 # Granular lime deposits and moss on the material, spatially coherent at metre scale.
 fine=node('ShaderNodeTexNoise'); fine.inputs['Scale'].default_value=27; fine.inputs['Detail'].default_value=3
 l.new(tex.outputs['Object'],fine.inputs['Vector'])
 grime=node('ShaderNodeMixRGB'); grime.blend_type='MULTIPLY'; grime.inputs[0].default_value=.14
 l.new(mix.outputs[0],grime.inputs[1]); l.new(fine.outputs['Fac'],grime.inputs[2])
 # Waterline deposits: broad colonies with broken edges; fine runoff aligns with gravity.
 position=node('ShaderNodeSeparateXYZ');l.new(tex.outputs['Object'],position.inputs[0])
 height=node('ShaderNodeMapRange');height.inputs['From Min'].default_value=.55;height.inputs['From Max'].default_value=3.9
 l.new(position.outputs['Z'],height.inputs['Value'])
 inv=node('ShaderNodeMath');inv.operation='SUBTRACT';inv.inputs[0].default_value=1;l.new(height.outputs[0],inv.inputs[1])
 growth=node('ShaderNodeTexNoise');growth.inputs['Scale'].default_value=2.1;growth.inputs['Detail'].default_value=5;growth.inputs['Roughness'].default_value=.8
 stretch=node('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(1.5,1.5,.65);l.new(tex.outputs['Object'],stretch.inputs[0]);l.new(stretch.outputs[0],growth.inputs['Vector'])
 growthmask=node('ShaderNodeMapRange');growthmask.inputs['From Min'].default_value=.43;growthmask.inputs['From Max'].default_value=.55;l.new(growth.outputs['Fac'],growthmask.inputs['Value'])
 damp=node('ShaderNodeMath');damp.operation='MULTIPLY';l.new(growthmask.outputs[0],damp.inputs[0])
 if kind=='glass':l.new(inv.outputs[0],damp.inputs[1])
 else:damp.inputs[1].default_value=.94 if kind=='stone' else .18
 moss=node('ShaderNodeMixRGB');l.new(damp.outputs[0],moss.inputs[0]);l.new(grime.outputs[0],moss.inputs[1]);moss.inputs[2].default_value=(.07,.10,.025,1) if kind!='shell' else (.3,.285,.21,1)
 l.new(moss.outputs[0],bs.inputs['Base Color'])
 sep=node('ShaderNodeSeparateColor'); l.new(maps['arm'].outputs['Color'],sep.inputs['Color'])
 rough=node('ShaderNodeMapRange'); rough.inputs['To Min'].default_value={'glass':.095,'bronze':.35,'stone':.72,'shell':.65}[kind]; rough.inputs['To Max'].default_value={'glass':.29,'bronze':.7,'stone':.95,'shell':.94}[kind]
 l.new(sep.outputs['Green'],rough.inputs['Value']); weatherrough=node('ShaderNodeMixRGB');l.new(damp.outputs[0],weatherrough.inputs[0]);l.new(rough.outputs['Result'],weatherrough.inputs[1]);weatherrough.inputs[2].default_value=(.85,.85,.85,1);l.new(weatherrough.outputs[0],bs.inputs['Roughness'])
 metal=node('ShaderNodeValue'); metal.outputs[0].default_value=.7 if kind=='bronze' else .0
 l.new(metal.outputs[0],bs.inputs['Metallic'])
 norm=node('ShaderNodeNormalMap'); norm.inputs['Strength'].default_value=.23 if kind=='glass' else .65
 l.new(maps['nor_gl'].outputs['Color'],norm.inputs['Color'])
 bump=node('ShaderNodeBump'); bump.inputs['Strength'].default_value=.18; bump.inputs['Distance'].default_value=.012 if kind=='glass' else .024
 l.new(norm.outputs[0],bump.inputs['Normal']); l.new(fine.outputs['Fac'],bump.inputs['Height']); l.new(bump.outputs[0],bs.inputs['Normal'])
 if kind=='glass':
  bs.inputs['Transmission Weight'].default_value=.63; bs.inputs['IOR'].default_value=1.46; bs.inputs['Coat Weight'].default_value=.22; bs.inputs['Coat Roughness'].default_value=.22
 m['kind']=kind
 return m

M={}
for k,id in [('glass','marble_rock_01'),('stone','rock_pitted_mossy'),('bronze','green_metal_rust'),('shell','rock_pitted_mossy')]:
 M[k]=material({'glass':'Tideglass • sea-worn translucent mineral','stone':'Mossy limestone • fractured masonry','bronze':'Bronze • verdigris and salt','shell':'Calcareous barnacle colonies'}[k],id,k)

def shaft(name,x,y,r,z0,z1,n=160,flutes=20):
 # Three keyed drums share matching irregular fracture rings. Joints read as narrow old cracks.
 def cut(a,k): return [z0,z0+(z1-z0)*(.40+.035*sin(x+y))+.34*cos(a+x+y)+.047*sin(a*9+.7),z0+(z1-z0)*(.77+.035*cos(x-y))+.18*sin(a*2+x-y)+.028*sin(a*13),z1][k]
 for drum in range(3):
  v=[];f=[]; steps=6
  for h in range(steps+1):
   t=h/steps
   for j in range(n):
    a=j*2*pi/n; z=(1-t)*cut(a,drum)+t*cut(a,drum+1)
    if drum>0 and h==0:z+=.004
    if drum<2 and h==steps:z-=.004
    q=(z-z0)/(z1-z0)
    fade=min(1,q*18,(1-q)*18)
    rr=r*(1-.065*q+.023*sin(pi*q))-.061*fade*(.5+.5*cos(flutes*a))**1.4
    rr+=.0025*sin(37*a+z*12)
    v.append((x+rr*cos(a),y+rr*sin(a),z))
  for h in range(steps):
   for j in range(n):
    a=h*n+j;b=h*n+(j+1)%n;f.append((a,b,b+n,a+n))
  f.extend([tuple(reversed(range(n))),tuple(steps*n+j for j in range(n))])
  ob=mesh(f'{name} fractured drum {drum+1}',v,f,M['glass'],True)

def arch_segment(name,a0,a1,profile,mat,steps=10):
 # Profile is a closed list of (normalized radial thickness, depth).
 v=[];f=[]; np=len(profile)
 for i in range(steps+1):
  a=a0+(a1-a0)*i/steps
  for t,y in profile:
   rx=2.12+1.05*t; rz=1.78+.67*t
   chip=0.0
   if t>.80 and (abs(y)>.75):
    chip=.014*(.5+.5*sin(a*47+y*12))**6+.022*(.5+.5*sin(a*19+1.7))**18
    if t>.89:
     chip+=sum(.12*math.exp(-((a-c)/.042)**2) for c in [.58,1.94,2.66])
   v.append(((rx-chip)*cos(a),y,4.25+(rz-chip)*sin(a)))
 for i in range(steps):
  for j in range(np): f.append((i*np+j,i*np+(j+1)%np,(i+1)*np+(j+1)%np,(i+1)*np+j))
 f.append(tuple(reversed(range(np)))); f.append(tuple(steps*np+j for j in range(np)))
 return mesh(name,v,f,mat,True,.014)

for side,x in [('West',-2.6),('East',2.6)]:
 # Two ashlar courses, staggered seams, shared footprint 2.20 x 2.60 metres.
 for row in range(2):
  for j in range(2):
   w=1.10 if row==0 else 1.04
   box(f'{side} ruin base course {row+1} block {j+1}',(x+(j-.5)*w,0,.145+row*.27),(w-.008,2.60 if row==0 else 2.42,.29),M['stone'],.035,True)
 for rear,y,r in [(False,-.52,.60),(True,.59,.49)]:
  label=f'{side} '+('rear engaged column' if rear else 'Tideglass Column A')
  lathe(label+' stepped bronze base',x,y,[(.52,r+ .16),(.56,r+.16),(.60,r+.11),(.66,r+.10),(.70,r+.13),(.76,r+.13),(.80,r+.07),(.88,r+.025),(.91,r+.025)],M['bronze'])
  shaft(label+' fluted shaft',x,y,r,.89,3.69,n=112 if rear else 128,flutes=18 if rear else 20)
  lathe(label+' weathered capital',x,y,[(3.67,r*.94),(3.71,r*1.02),(3.76,r*1.03),(3.80,r*1.11),(3.85,r*1.11),(3.89,r*1.02),(3.95,r*1.06),(4.03,r*1.21),(4.09,r*1.24),(4.12,r*1.24)],M['bronze'])
 box(side+' weathered abacus bearing',(x,.02,4.17),(1.43,2.25,.16),M['stone'],.035,True)
 box(side+' bronze abacus rim',(x,.02,4.10),(1.45,2.26,.052),M['bronze'],.013)

# Closed continuous elliptic crown: true radial joints and central keystone.
profile=[(0,-.78),(.03,-.83),(.11,-.83),(.15,-.77),(.85,-.77),(.9,-.85),(1,-.85),(1,.87),(.9,.87),(.85,.79),(.15,.79),(.10,.85),(0,.85)]
N=17
for i in range(N):
 gap=.0015
 a0=pi*i/N+gap; a1=pi*(i+1)/N-gap
 arch_segment(f'Arch voussoir {i+1:02d}',a0,a1,profile,M['glass'],8)
 # Moulding surrounds both sides of the opening, with small real masonry seams.
 for yy in [-.89,.91]:
  for t0,t1 in [(.018,.10),(.89,.98)]:
   arch_segment(f'Archivolt bronze {i:02d} {yy} {t0}',a0,a1,[(t0,yy+.025),(t0,yy-.02),(t0+.016,yy-.05),(t1-.016,yy-.05),(t1,yy-.02),(t1,yy+.025)],M['bronze'],5)
 # Small worn limestone edge caps bind the mineral blocks together.
 arch_segment(f'Outer coping wedge {i+1:02d}',a0,a1,[(1,-.86),(1.055,-.86),(1.055,.88),(1,.88)],M['stone'],6)
# Keystone has wedge form and sits in the centre of the load path.
arch_segment('Crown central keystone front',pi*8/N+.002,pi*9/N-.002,[(-.06,-.96),(-.06,-.82),(1.15,-.82),(1.15,-.96)],M['bronze'],12)
# Inlaid vertical crest on keystone, restrained historic ornament.
for yy in [-.987]:
 v=[(-.08,yy,6.12),(0,yy,6.00),(.08,yy,6.12),(0,yy,6.58),(-.08,yy+.032,6.12),(0,yy+.032,6.0),(.08,yy+.032,6.12),(0,yy+.032,6.58)]
 mesh('Keystone tide crest',v,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],M['glass'],False,.012)

# Barnacles: explicitly hollow shells, seven to twelve radial ribs and worn lips.
def barnacle(center,normal,r,name):
 normal=Vector(normal).normalized(); axis=normal.cross(Vector((0,0,1)))
 if axis.length<.01:axis=normal.cross(Vector((1,0,0)))
 axis.normalize(); cross=normal.cross(axis); center=Vector(center)
 n=11; v=[];f=[]
 phase=random.uniform(0,pi)
 for h,rad in [(0,1),(.38,.86),(.87,.53),(.91,.30),(.23,.23)]:
  for j in range(n):
   a=2*pi*j/n; ridges=(1+.11*cos(a*6+phase))
   vec=center+normal*(h*r)+r*rad*ridges*(axis*cos(a)+cross*sin(a))
   v.append(vec)
 for k in range(4):
  for j in range(n): f.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 f.append(tuple(4*n+j for j in range(n))); f.append(tuple(reversed(range(n))))
 return mesh(name,v,f,M['shell'],True)

for side,x in [('West',-2.6),('East',2.6)]:
 for cluster in range(5):
  a_center=random.uniform(-2.75,-.4)
  z_center=random.choice([1.0,1.2,1.5,3.55])
  for j in range(26 if cluster<3 else 14):
   a=a_center+random.gauss(0,.20); z=max(.94,min(3.65,z_center+random.gauss(0,.20)))
   r=.6*(1-.065*(z-.89)/2.8)-.022
   rad=random.uniform(.030,.075)*(1.2 if cluster<2 else 1)
   barnacle((x+r*cos(a),-.52+r*sin(a),z),(cos(a),sin(a),random.uniform(-.08,.16)),rad,f'{side} barnacle colony {cluster:02d} shell {j:02d}')
 # Shells on the horizontal plinth shoulders grow upward.
 for j in range(27):
  px=x+random.choice([-1,1])*random.uniform(.68,.98); py=random.uniform(-1.05,1.05)
  barnacle((px,py,.56),(0,0,1),random.uniform(.035,.085),f'{side} plinth shell {j:02d}')

# Metadata before batching, retained to describe named architectural components.
bpy.context.view_layer.update()
for ob in ASSETS:
 pts=[ob.matrix_world@Vector(c) for c in ob.bound_box]; cen=sum(pts,Vector())/8
 PARTS.append({'name':ob.name,'world_position':[cen.x,cen.z,-cen.y]})
# Join material assemblies, apply all transforms, remove degeneracies, triangulate.
joined=[]
groups={kind:[o for o in ASSETS if o.data.materials[0]==mat] for kind,mat in M.items()}
for kind,mat in M.items():
 obs=groups[kind]
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0]; bpy.ops.object.join(); ob=bpy.context.object; ob.name='Portal / '+kind
 bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
 bm=bmesh.new();bm.from_mesh(ob.data)
 bmesh.ops.dissolve_degenerate(bm,dist=.000001,edges=list(bm.edges))
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 for e in bm.edges:
  if e.is_manifold:e.smooth=(e.calc_face_angle()<(1.25 if kind=='shell' else .65))
 bm.to_mesh(ob.data);bm.free()
 joined.append(ob)
# Scale crown to exactly 6.8 m without altering footprint.
maxz=max(v.co.z for o in joined for v in o.data.vertices)
for o in joined:
 for v in o.data.vertices:v.co.z*=6.8/maxz
for part in PARTS:part['world_position'][1]*=6.8/maxz

# Daylight studio, ground and all helpers are excluded from export.
world=bpy.data.worlds.new('Soft maritime daylight'); sc.world=world;world.use_nodes=True
world.node_tree.nodes.get('Background').inputs[0].default_value=(.68,.76,.82,1)
world.node_tree.nodes.get('Background').inputs[1].default_value=.45

def aim(ob,p):ob.rotation_euler=(Vector(p)-ob.location).to_track_quat('-Z','Y').to_euler()
def light(name,loc,power,size,color):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=color
 o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;aim(o,(0,0,3));return o
light('Large soft sky key',(-5,-8,11),1700,7,(1,.91,.78))
light('Cool sea fill',(6,-1,8),1150,6,(.73,.87,1))
light('Sky rim',(-2,5,10),1900,5,(.86,.94,1))
bpy.ops.mesh.primitive_plane_add(size=200); ground=bpy.context.object;ground.name='Render ground only';ground.location.z=-.018
mat=bpy.data.materials.new('Ground only');mat.diffuse_color=(.24,.28,.285,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.24,.28,.285,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9;ground.data.materials.append(mat)
d=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',d);sc.collection.objects.link(cam);sc.camera=cam
cam.location=(10,-17,10);aim(cam,(0,0,3.3));d.type='ORTHO';d.ortho_scale=14.8;d.lens=50
hero_loc=cam.location.copy();hero_rot=cam.rotation_euler.copy()

def render(name):
 sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)

def clay_render(name):
 clay=bpy.data.materials.new('QA grey');clay.use_nodes=True; bs=clay.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(.38,.38,.38,1);bs.inputs['Roughness'].default_value=.72
 original=[o.data.materials[0] for o in joined]
 for o in joined:o.data.materials[0]=clay
 render(name)
 for o,m in zip(joined,original):o.data.materials[0]=m

if '--clay-only' in sys.argv:
 clay_render('clay-preview.png');bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'geometry.blend'));sys.exit(0)

# Unwrap one unique UV atlas per material. The procedural photographic graph is
# baked on the actual architecture (not a proxy swatch), including local AO.
BAKE_LOG=[]
def bake_material(ob,kind):
 activate(ob); bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT')
 bpy.ops.uv.smart_project(angle_limit=math.radians(68),island_margin=.012,area_weight=.45)
 bpy.ops.object.mode_set(mode='OBJECT')
 mat=ob.data.materials[0];nt=mat.node_tree;n=nt.nodes;l=nt.links
 bs=next(x for x in n if x.type=='BSDF_PRINCIPLED');out=next(x for x in n if x.type=='OUTPUT_MATERIAL')
 sz=2048 if kind in ['glass','stone'] else 1024
 target=n.new('ShaderNodeTexImage');target.name='BAKE_TARGET';n.active=target;target.select=True
 emission=n.new('ShaderNodeEmission')
 base=bs.inputs['Base Color'].links[0].from_socket
 rough=bs.inputs['Roughness'].links[0].from_socket;metal=bs.inputs['Metallic'].links[0].from_socket
 ao=n.new('ShaderNodeAmbientOcclusion');ao.inputs['Distance'].default_value=.63;ao.samples=16
 floor=n.new('ShaderNodeMapRange');floor.inputs['To Min'].default_value=.38;floor.inputs['To Max'].default_value=1;l.new(ao.outputs['AO'],floor.inputs[0])
 combine=n.new('ShaderNodeCombineColor');l.new(floor.outputs[0],combine.inputs['Red']);l.new(rough,combine.inputs['Green']);l.new(metal,combine.inputs['Blue'])
 images={}
 for mode in ['base','normal','orm']:
  im=bpy.data.images.new(kind+'_'+mode,width=sz,height=sz,alpha=False)
  im.colorspace_settings.name='sRGB' if mode=='base' else 'Non-Color';target.image=im;n.active=target
  for link in list(out.inputs['Surface'].links):l.remove(link)
  if mode=='normal':
   l.new(bs.outputs[0],out.inputs['Surface']);typ='NORMAL'
  else:
   for link in list(emission.inputs['Color'].links):l.remove(link)
   l.new(base if mode=='base' else combine.outputs[0],emission.inputs['Color']);l.new(emission.outputs[0],out.inputs['Surface']);typ='EMIT'
  sc.render.bake.use_selected_to_active=False;sc.render.bake.margin=12;sc.render.bake.use_clear=True
  sc.cycles.samples=24 if mode=='orm' else 8
  t=time.time();bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/(kind+'_'+mode+'.jpg'))
  im.file_format='JPEG';sc.render.image_settings.quality=95;im.save()
  loaded=bpy.data.images.load(im.filepath_raw,check_existing=False);loaded.colorspace_settings.name=im.colorspace_settings.name
  images[mode]=loaded
  BAKE_LOG.append({'material':kind,'pass':mode,'type':typ,'size':sz,'seconds':round(time.time()-t,2)})
  print('BAKED',kind,mode,flush=True)
 # The shipped and rendered material uses only the baked images.
 nt.nodes.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs['Surface'])
 for mode,im in images.items():
  t=n.new('ShaderNodeTexImage');t.image=im
  if mode=='base':l.new(t.outputs['Color'],p.inputs['Base Color'])
  elif mode=='normal':
   nm=n.new('ShaderNodeNormalMap');l.new(t.outputs['Color'],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs['Color'],sep.inputs['Color']);l.new(sep.outputs['Green'],p.inputs['Roughness']);l.new(sep.outputs['Blue'],p.inputs['Metallic'])
   group=bpy.data.node_groups.get('glTF Material Output')
   if not group:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat');group.interface.new_socket(name='Thickness',in_out='INPUT',socket_type='NodeSocketFloat')
   g=n.new('ShaderNodeGroup');g.node_tree=group;l.new(sep.outputs['Red'],g.inputs['Occlusion'])
 if kind=='glass':
  p.inputs['Transmission Weight'].default_value=.63;p.inputs['IOR'].default_value=1.46;p.inputs['Coat Weight'].default_value=.28;p.inputs['Coat Roughness'].default_value=.15
  absorption=n.new('ShaderNodeVolumeAbsorption');absorption.inputs['Color'].default_value=(.19,.65,.48,1);absorption.inputs['Density'].default_value=.28;l.new(absorption.outputs[0],out.inputs['Volume'])
  g.inputs['Thickness'].default_value=.75
 return images

if '--preflight' in sys.argv:
 # Tiny real bake/export/import probe through the same entry point.
 ob=joined[0]
 for other in joined[1:]:other.hide_render=True
 activate(ob);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.smart_project();bpy.ops.object.mode_set(mode='OBJECT')
 im=bpy.data.images.new('probe',width=32,height=32);nt=ob.data.materials[0].node_tree;t=nt.nodes.new('ShaderNodeTexImage');t.image=im;nt.nodes.active=t
 sc.cycles.samples=1;bpy.ops.object.bake(type='NORMAL')
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'probe.glb'),export_format='GLB',use_selection=True,export_yup=True)
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'probe.glb'))
 sc.render.resolution_percentage=10;sc.cycles.samples=1;render('probe.png');print('PREFLIGHT_OK',flush=True);sys.exit(0)

clay_render('clay.png')
for ob in joined:bake_material(ob,ob.data.materials[0]['kind'])
# All export modifiers are applied; triangulate explicitly for stable auditing.
for ob in joined:
 activate(ob);m=ob.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=m.name)
triangles=sum(len(o.data.polygons) for o in joined)
pts=[o.matrix_world@v.co for o in joined for v in o.data.vertices]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
manifest={'coordinate_system':'glTF Y-up, front +Z, metres','overall_bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'nodes':[{'name':o.name,'world_position':[0,0,0]} for o in joined],'source_components':PARTS,'triangles':triangles,'materials':len(joined)}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'bake-log.json').write_text(json.dumps(BAKE_LOG,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for ob in joined:ob.select_set(True)
bpy.context.view_layer.objects.active=joined[0]
path=ROOT/'tideglass-ruin-arch-portal.glb'
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
assert 50000<=triangles<=120000,triangles
assert path.stat().st_size<=12*1024*1024,path.stat().st_size
sc.cycles.samples=64
render('final.png')
cam.location=(15,0,3.4);aim(cam,(0,0,3.4));cam.data.ortho_scale=14.0;render('side.png')
cam.location=hero_loc;cam.rotation_euler=hero_rot;cam.data.ortho_scale=14.8
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'tideglass-source.blend'))
notes=[
'Rebuilt tideglass ruin portal: true fitted elliptic arch, keystone, paired fluted columns, bronze mouldings, fractured mossy bases and hollow barnacle colonies; 7.40 × 2.60 × 6.80 m.',
f'Triangles: {triangles:,}; four merged static mesh nodes; GLB {path.stat().st_size/1048576:.2f} MiB; metres, Y-up, front +Z, ground-centred origin.',
'Materials: translucent tideglass, verdigris bronze, mossy limestone, calcareous shells; all colour, tangent normal and ORM results baked with Cycles on the asset.',
'Textures: tideglass/stone 2048²; bronze/shell 1024²; base colour JPEG, tangent normal JPEG, packed occlusion-roughness-metallic JPEG.',
'Poly Haven CC0: marble_rock_01, rock_pitted_mossy, green_metal_rust (https://polyhaven.com/license); 1k registered diff/nor_gl/arm inputs.',
'Differences: deliberately replaces the rejected broken pillar lintel with a complete structural arch; damage and marine growth are authored interpretations rather than exact copies of the illustration.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('COMPLETE',triangles,path.stat().st_size,'elapsed',time.time()-START,flush=True)
