"""Harbour trawler: deterministic metric build, Cycles atlases, portable GLB.
Run Blender 5.2 -b --python-exit-code 1 --python this_file.py
Optional -- --draft makes geometry/source-material review images only.
Only external images: Poly Haven CC0; source URLs recorded below.
"""
import bpy, bmesh, math, os, sys, json, random, subprocess, time
from mathutils import Vector
from pathlib import Path
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT); random.seed(47)
DRAFT='--draft' in sys.argv
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
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)
s=bpy.context.scene;s.unit_settings.system='METRIC';s.unit_settings.scale_length=1
s.render.engine='CYCLES';s.cycles.samples=32 if DRAFT else 96
p=bpy.context.preferences.addons['cycles'].preferences
p.compute_device_type='OPTIX';p.get_devices()
for d in p.devices:d.use=d.type=='OPTIX'
s.cycles.device='GPU';s.cycles.use_denoising=True
s.render.image_settings.file_format='PNG';s.render.resolution_x=1920;s.render.resolution_y=1080;s.render.resolution_percentage=65 if DRAFT else 100
s.view_settings.view_transform='AgX'
PH=['blue_metal_plate','rusty_metal','metal_plate','rubber_tiles']
for ident in PH:
 for mp in ['diff','nor_gl','arm']:
  f=TEX/f'{ident}_{mp}_1k.jpg'
  if not f.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{mp}_1k.jpg'
   subprocess.run(['curl','-fL','--retry','3','-A','Mozilla/5.0',url,'-o',str(f)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('TEXTURES_READY',flush=True)
M=[];sources=[]
def node(nt,kind):return nt.nodes.new(kind)
def link(nt,a,b):nt.links.new(a,b)
def noise(nt,coord,scale,detail=3):
 n=node(nt,'ShaderNodeTexNoise');n.inputs['Scale'].default_value=scale;n.inputs['Detail'].default_value=detail;link(nt,coord,n.inputs['Vector']);return n.outputs['Fac']
def mathn(nt,op,a,b):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for sock,val in zip(n.inputs,(a,b)):
  if isinstance(val,(int,float)):sock.default_value=val
  else:link(nt,val,sock)
 return n.outputs[0]
def mix(nt,fac,a,b):
 n=node(nt,'ShaderNodeMixRGB');
 if isinstance(fac,(int,float)):n.inputs[0].default_value=fac
 else:link(nt,fac,n.inputs[0])
 for sock,val in zip([n.inputs[1],n.inputs[2]],[a,b]):
  if isinstance(val,tuple):sock.default_value=(*val[:3],1)
  else:link(nt,val,sock)
 return n.outputs[0]
def mat(name,base,ph='metal_plate',metal=.25,rough=.55,weather=.15,hull=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs[0])
 tc=node(nt,'ShaderNodeTexCoord');coord=tc.outputs['Object']
 tex=[]
 for mp in ['diff','nor_gl','arm']:
  n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(TEX/f'{ph}_{mp}_1k.jpg'),check_existing=True);n.projection='BOX';n.projection_blend=.2
  if mp!='diff':n.image.colorspace_settings.name='Non-Color'
  link(nt,coord,n.inputs['Vector']);tex.append(n)
 sep=node(nt,'ShaderNodeSeparateXYZ');link(nt,tex[2].outputs['Color'],sep.inputs[0])
 if hull:
  xyz=node(nt,'ShaderNodeSeparateXYZ');link(nt,tc.outputs['Object'],xyz.inputs[0]);z=xyz.outputs['Z']
  # Antifouling below the waterline, a narrow ivory boot stripe, blue topsides.
  c=mix(nt,mathn(nt,'GREATER_THAN',z,-.10),(.22,.026,.018),base)
  stripe=mathn(nt,'MULTIPLY',mathn(nt,'GREATER_THAN',z,-.10),mathn(nt,'LESS_THAN',z,.10))
  c=mix(nt,stripe,c,(.72,.74,.69))
 else:c=base
 bw=node(nt,'ShaderNodeRGBToBW');link(nt,tex[0].outputs[0],bw.inputs[0])
 mult=node(nt,'ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=.62
 if isinstance(c,tuple):mult.inputs[1].default_value=(*c,1)
 else:link(nt,c,mult.inputs[1])
 link(nt,mix(nt,.72,(.62,.62,.62),tex[0].outputs[0]),mult.inputs[2]);c=mult.outputs[0]
 # Fine chipped coating and vertical rain/rust tracks, with independently scaled noise.
 vm=node(nt,'ShaderNodeVectorMath');vm.operation='MULTIPLY';vm.inputs[1].default_value=(2.4,2.4,.14);link(nt,coord,vm.inputs[0])
 streak=noise(nt,vm.outputs[0],3.3)
 patch=mathn(nt,'MULTIPLY',mathn(nt,'MAXIMUM',mathn(nt,'SUBTRACT',streak,.615),0),weather*9)
 if hull:patch=mathn(nt,'MULTIPLY',patch,mathn(nt,'GREATER_THAN',z,.12))
 rusty=node(nt,'ShaderNodeTexImage');rusty.image=bpy.data.images.load(str(TEX/'rusty_metal_diff_1k.jpg'),check_existing=True);rusty.projection='BOX';rusty.projection_blend=.2;link(nt,coord,rusty.inputs[0])
 c=mix(nt,patch,c,mix(nt,.6,(.14,.046,.018),rusty.outputs[0]))
 if hull:
  faded=noise(nt,coord,1.8,4);fade=mathn(nt,'MULTIPLY',faded,.13)
  c=mix(nt,fade,c,(.11,.15,.17))
 fine=noise(nt,coord,95,2)
 geo=node(nt,'ShaderNodeNewGeometry');edge=mathn(nt,'MULTIPLY',mathn(nt,'MAXIMUM',mathn(nt,'SUBTRACT',geo.outputs['Pointiness'],.505),0),weather*3)
 c=mix(nt,edge,c,(.30,.32,.29));link(nt,c,bs.inputs['Base Color'])
 r=mathn(nt,'MULTIPLY_ADD',sep.outputs['Y'],.22) if False else mathn(nt,'ADD',rough,mathn(nt,'MULTIPLY',sep.outputs['Y'],.16))
 link(nt,r,bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.28;link(nt,tex[1].outputs[0],nm.inputs['Color'])
 bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.2;bump.inputs['Distance'].default_value=.012;link(nt,fine,bump.inputs['Height']);link(nt,nm.outputs[0],bump.inputs['Normal']);link(nt,bump.outputs[0],bs.inputs['Normal'])
 M.append(m);sources.append((bs,c,r,metal,out));return len(M)-1
H=mat('01 Painted hull | salt and oxide',(.035,.075,.14),'blue_metal_plate',.3,.4,.26,True)
W=mat('02 Chalk white marine enamel',(.71,.73,.69),'metal_plate',.15,.4,.13)
S=mat('03 Galvanised and worn steel',(.24,.28,.26),'metal_plate',.8,.3,.26)
B=mat('04 Tyre rubber and cable',(.017,.021,.021),'rubber_tiles',.0,.61,.035)
O=mat('05 Ochre orange fishing plastics',(.9,.185,.018),'metal_plate',.02,.45,.1)
G=mat('06 Green trawl net',(.026,.15,.077),'rubber_tiles',0,.75,.08)
R=mat('07 Hemp mooring rope',(.39,.29,.14),'rubber_tiles',0,.77,.06)
V=mat('08 Smoked blue marine glazing',(.045,.10,.125),'metal_plate',.65,.025,0)
# Geometry is authored X beam, +Y bow, Z up. Mirrored to Blender -Y bow before export.
objects=[];group='Hull'
def finish(o,name,ma,bev=0,smooth=False):
 o.name=name;o['assembly']=group;o.data.materials.append(M[ma]);objects.append(o)
 if bev:
  mod=o.modifiers.new('Manufactured edge radius','BEVEL');mod.width=bev;mod.segments=3
 if smooth:
  for f in o.data.polygons:f.use_smooth=True
 return o
def mesh(name,vs,fs,ma,bev=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return finish(o,name,ma,bev,smooth)
def box(name,loc,dim,ma,bev=.025):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,name,ma,bev)
def cyl(name,a,b,r,ma,n=16,r2=None):
 a,b=Vector(a),Vector(b);d=b-a
 if r2 is None:bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=d.length,location=(a+b)/2)
 else:bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r2,depth=d.length,location=(a+b)/2)
 o=bpy.context.object;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();return finish(o,name,ma,.007 if r>.04 else 0,True)
def tube(name,pts,r,ma,sides=8):
 # mesh tube with parallel per-segment frames; no curve helpers in delivered model
 vs=[];fs=[]
 for i,p in enumerate(pts):
  tang=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(0,i-1)])
  tang.normalize();u=tang.cross(Vector((0,0,1)))
  if u.length<.01:u=tang.cross(Vector((0,1,0)))
  u.normalize();v=tang.cross(u).normalized()
  for j in range(sides):vs.append(Vector(p)+r*(u*cos(j*2*pi/sides)+v*sin(j*2*pi/sides)))
 for i in range(len(pts)-1):
  for j in range(sides):a=i*sides+j;b=i*sides+(j+1)%sides;fs.append((a,b,b+sides,a+sides))
 fs+=[tuple(reversed(range(sides))),tuple((len(pts)-1)*sides+j for j in range(sides))]
 return mesh(name,vs,fs,ma,0,True)
def torus(name,loc,major,minor,ma,axis='Z',n=40,m=10):
 bpy.ops.mesh.primitive_torus_add(major_segments=n,minor_segments=m,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 return finish(o,name,ma,0,True)
def ball(name,loc,r,ma,scale=(1,1,1)):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=20,ring_count=12,radius=r,location=loc);o=bpy.context.object;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,name,ma,0,True)
def bolt(loc,axis='Z',ma=S):
 a=Vector(loc);d=Vector({'Z':(0,0,.022),'X':(.022,0,0),'Y':(0,.022,0)}[axis]);return cyl('Hex fastener',a-d,a+d,.028,ma,6)
def sag(name,a,b,sag,ma=B,r=.016):
 pts=[]
 for i in range(33):
  t=i/32;p=Vector(a).lerp(Vector(b),t);p.z-=4*sag*t*(1-t);pts.append(p)
 return tube(name,pts,r,ma,6)
# Fair hull, interpolated stations and round bilge sections.
stations=[(-9,2.20,-1.55,1.8),(-8,2.53,-2.12,1.85),(-5,2.75,-2.63,1.94),(-1,2.73,-2.8,2.08),(2.7,2.50,-2.7,2.35),(5,2.02,-2.1,2.65),(6.8,1.30,-1.02,2.9),(8,.62,.4,3.0),(8.7,.23,1.65,3.05),(9,.015,2.99,3.05)]
def interp(y):
 for j in range(len(stations)-1):
  if stations[j][0]<=y<=stations[j+1][0]:
   a=stations[j];b=stations[j+1];t=(y-a[0])/(b[0]-a[0]);res=[]
   for k in range(1,4):
    slopea=(b[k]-stations[max(0,j-1)][k])/(b[0]-stations[max(0,j-1)][0])
    slopeb=(stations[min(len(stations)-1,j+2)][k]-a[k])/(stations[min(len(stations)-1,j+2)][0]-a[0])
    L=b[0]-a[0];res.append((2*t**3-3*t*t+1)*a[k]+(t**3-2*t*t+t)*L*slopea+(-2*t**3+3*t*t)*b[k]+(t**3-t*t)*L*slopeb)
   res[0]=min(res[0],2.75);res[1]=max(res[1],-2.8)
   return res
 return stations[-1][1:]
# Section from keel through rounded bilge to nearly vertical topsides.
profile=[(0,0),(.19,.025),(.40,.08),(.61,.17),(.79,.31),(.90,.47),(.97,.66),(1,.83),(1,1)]
ys=[-9+18*i/96 for i in range(97)]
vs=[];fs=[]
for y in ys:
 w,k,z=interp(y)
 pr=[(-x,h) for x,h in reversed(profile[1:])]+profile
 for x,h in pr:vs.append((x*w,y,k+(z-k)*h))
N=17
for i in range(96):
 for j in range(N-1):a=i*N+j;fs.append((a,a+1,a+N+1,a+N))
fs.append(tuple(reversed(range(N))));fs.append(tuple(96*N+j for j in range(N)))
hull=mesh('Fair welded shell',vs,fs,H,0,True)
# Inner bulwarks and gunwale caps follow the exact loft edge.
for sign in [-1,1]:
 pts=[];inner=[]
 for y in ys:
  w,k,z=interp(y);pts.append((sign*w,y,z));inner.append((sign*max(.01,w-.105),y,z-.09))
 tube('Heavy gunwale cap',pts,.055,H,10)
 vv=[];ff=[]
 for y in ys:
  w,k,z=interp(y);vv.extend([(sign*max(.005,w-.075),y,z-.045),(sign*max(.005,w-.09),y,z-.62)])
 for i in range(96):ff.append((2*i,2*i+1,2*i+3,2*i+2))
 mesh('Inner bulwark steel',vv,ff,H)
# Deck polygon with solid rim; foredeck slopes upward.
def deckz(y):return 1.27+max(0,y-1)*.20
v=[]
for y in ys:
 w,k,z=interp(y);v.extend([(-max(.005,w-.09),y,deckz(y)),(max(.005,w-.09),y,deckz(y))])
mesh('Worn working deck',v,[(2*i,2*i+1,2*i+3,2*i+2) for i in range(96)],S)
box('Transom inner panel',(0,-8.92,1.53),(4.25,.13,.63),H)
# Keel, bilge keels, rudder and bronze-toned screw.
tube('Centreline keel',[(0,-7.8,-2.10),(0,-5,-2.72),(0,-1,-2.75),(0,2.8,-2.69),(0,5,-2.1)],.05,H)
for sign in [-1,1]:
 tube('Bilge rubbing strake',[(sign*1.7,-6,-1.55),(sign*1.95,-3,-1.91),(sign*1.95,1,-1.98),(sign*1.73,3.5,-1.65)],.065,H)
box('Rudder blade',(0,-8.66,-1.93),(.12,.78,1.30),H,.1)
cyl('Propeller shaft',(0,-6.6,-1.85),(0,-8.4,-1.85),.115,S)
ball('Propeller hub',(0,-8.0,-1.85),.21,S,scale=(1,1.7,1))
for i in range(4):
 a=i*pi/2;o=ball('Propeller blade',(.41*cos(a),-7.96,-1.85+.41*sin(a)),.35,S,(.55,.15,1.35));o.rotation_euler[1]=pi/2-a
# Hull plating seams and freeing ports, kept subtle.
for sign in [-1,1]:
 for y in [-7.8,-5.3,-2.8,-.3,2.2,4.6]:
  w,k,z=interp(y);box('Freeing port dark recess',(sign*(w+.008),y,z-.38),(.026,.49,.14),B,.045)
  box('Freeing port lower lip',(sign*(w+.028),y,z-.465),(.045,.56,.045),S,.018)
 for y in [-7,-3,1,4.5]:
  w,k,z=interp(y)
  tube('Plate butt weld',[(sign*w,y,z-.13),(sign*w*.985,y,.65),(sign*w*.945,y,-.15)],.008,H,6)
# Raised foredeck ivory edge and handrail, curved around the bow.
group='DeckFittings'
for sign in [-1,1]:
 rail=[]
 for i in range(46):
  y=2.1+6.65*i/45;w,k,z=interp(y);rail.append((sign*max(.05,w-.11),y,z+.79))
 tube('Foredeck rail top',rail,.042,S,10)
 tube('Foredeck rail middle',[(x,y,z-.39) for x,y,z in rail],.029,S,8)
 for y in [2.1,3.4,4.7,5.8,6.8,7.6,8.3,8.75]:
  w,k,z=interp(y);x=sign*max(.05,w-.11);cyl('Rail stanchion',(x,y,z),(x,y,z+.79),.037,S,10);box('Stanchion foot',(x,y,z),(.14,.13,.045),S,.014)
 vv=[];ff=[]
 for j in range(61):
  y=2.3+6.55*j/60;w,k,z=interp(y);vv.extend([(sign*(w+.009),y,z-.025),(sign*(w+.007),y,z-.48)])
 for j in range(60):ff.append((2*j,2*j+1,2*j+3,2*j+2))
 mesh('Foredeck white painted band',vv,ff,W)
# Wheelhouse shell with bevelled, raked front, wall panels and recessed windows.
group='Wheelhouse'
xw=1.66;yback=.0;yfront=3.32;zb=1.47;zt=5.82
# Lower body includes an angled forward windshield band.
verts=[(-xw,yback,zb),(xw,yback,zb),(xw,yfront,zb),(-xw,yfront,zb),(-xw,yback,zt),(xw,yback,zt),(xw,2.97,zt),(-xw,2.97,zt)]
mesh('Wheelhouse welded cabin',verts,[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],W,.065)
box('Roof overhang',(0,1.55,5.91),(3.73,3.76,.21),H,.045)
box('Roof weathering lip',(0,1.55,6.02),(3.62,3.65,.045),W,.025)
box('Roof gasket',(0,1.55,5.775),(3.53,3.49,.055),B,.015)
# Window overlays are nested bevelled frame/gasket/glass layers; glass sits recessed in the raised frame.
def side_window(sign,y,z,width,height,door=False):
 x=sign*(1.758 if door else 1.668)
 box('Side window steel frame',(x,y,z),(.082,width+.14,height+.14),S,.09)
 box('Window rubber seal',(x+sign*.052,y,z),(.036,width+.065,height+.065),B,.07)
 box('Side glazing',(x+sign*.073,y,z),(.024,width,height),V,.055)
 # Raised metal edge surrounds the inset glass plane, with corner radii.
 pts=[]
 for cy,cz,start in [(y+width/2-.055,z+height/2-.055,0),(y-width/2+.055,z+height/2-.055,90),(y-width/2+.055,z-height/2+.055,180),(y+width/2-.055,z-height/2+.055,270)]:
  for j in range(5):
   a=math.radians(start+j*22.5);pts.append((x+sign*.102,cy+.09*cos(a),cz+.09*sin(a)))
 tube('Raised glazing frame',pts+[pts[0]],.022,S,8)
for sign in [-1,1]:
 side_window(sign,2.40,4.85,.78,1.17);side_window(sign,.60,4.85,.72,1.17)
 box('Weather sealed wheelhouse door',(sign*1.708,1.48,3.40),(.075,.84,3.50),S,.055)
 box('Door enamel face',(sign*1.752,1.48,3.41),(.034,.76,3.38),W,.045)
 side_window(sign,1.48,4.86,.55,.96,True)
 cyl('Door handle',(sign*1.79,1.18,3.58),(sign*1.79,1.18,3.83),.033,S,10)
 for z in [2.10,4.1]:cyl('Door hinge',(sign*1.79,1.88,z),(sign*1.79,1.88,z+.19),.035,S,10)
 box('Cabin step',(sign*1.99,1.44,1.50),(.61,1.02,.11),S,.025)
# Front windows: same rake as forward shell. offset each outward toward bow.
for x in [-1.06,0,1.06]:
 for name,dim,yoff,ma in [('Windshield frame',(.96,.085,1.29),.047,S),('Windshield gasket',(.87,.04,1.20),.100,B),('Windshield glass',(.81,.025,1.13),.132,V)]:
  z=4.88;y=3.32-(z-zb)*.35/(zt-zb)+yoff;o=box(name,(x,y,z),dim,ma,.055);o.rotation_euler[0]=math.atan(.35/(zt-zb))
 # Working wiper arm and blade
 cyl('Windshield wiper',(x-.2,3.17,4.40),(x+.12,3.21,4.97),.015,B,8)
 cyl('Wiper blade',(x+.12,3.235,4.86),(x+.12,3.20,5.34),.023,B,8)
# Lower cabin plating seams, service cover and fasteners.
for sign in [-1,1]:
 tube('Cabin horizontal welded seam',[(sign*1.674,.05,3.74),(sign*1.674,2.98,3.74)],.009,W,6)
 for y in [.25,2.72]:
  for z in [1.80,3.5]:bolt((sign*1.708,y,z),'X')
box('Forward service hatch rim',(0,3.23,2.84),(1.28,.09,.89),S,.045)
box('Forward service hatch face',(0,3.287,2.84),(1.19,.045,.80),W,.03)
for x in [-.5,.5]:
 for z in [2.56,3.12]:bolt((x,3.323,z),'Y')
# Aft window, vents, roof fittings and interior helm under roof.
box('Aft window frame',(.63,-.055,4.83),(1.34,.10,1.20),S,.08)
box('Aft glass',(.63,-.12,4.83),(1.16,.035,1.03),V,.065)
for i in range(8):box('Cabin ventilation louvre',(-1.19,-.065,2.02+i*.075),(.50,.08,.029),S,.01)
box('Helm console',(0,2.35,3.1),(1.75,.55,.95),S,.075)
torus('Helm wheel',(0,2.19,3.67),.30,.025,B,'Y',32,8)
for a in [0,2*pi/3,4*pi/3]:cyl('Helm spoke',(0,2.19,3.67),(.28*cos(a),2.19,3.67+.28*sin(a)),.014,S,8)
for x in [-1.25,1.2]:box('Roof junction box',(x,1.15,6.12),(.40,.58,.22),W,.035)
# Lifebuoys on cabin sides, with four white bands.
for sign in [-1,1]:
 x=sign*1.83;y=.4;z=3.05;torus('Lifebuoy',(x,y,z),.36,.10,O,'X',44,12)
 for a in [0,pi/2,pi,3*pi/2]:
  pts=[(x,y+.36*cos(a+t),z+.36*sin(a+t)) for t in [-.14,-.07,0,.07,.14]];tube('Lifebuoy reflective band',pts,.104,W,10)
# Roof mast, radar, lamps, aerials and exhaust.
group='MastRigging'
cyl('Mast foot',(0,1.25,6.03),(0,1.25,6.40),.26,W,16,r2=.15)
cyl('Radar mast',(0,1.25,6.25),(0,1.25,9.58),.105,W,16,r2=.068)
cyl('Masthead antenna',(0,1.25,9.55),(0,1.25,11),.019,S,10)
for z,width in [(7.5,1.7),(8.7,1.1)]:cyl('Navigation cross tree',(-width/2,1.25,z),(width/2,1.25,z),.065,W)
box('Radar pedestal',(0,1.4,6.45),(.42,.5,.39),W,.09)
cyl('Radar spindle',(0,1.4,6.6),(0,1.4,6.95),.13,S)
box('Radar scanner',(0,1.4,6.96),(2.12,.26,.18),W,.065)
for x,z,ma in [(-.84,7.60,G),(.84,7.60,O),(-.54,8.81,W),(.54,8.81,O)]:
 cyl('Lamp base',(x,1.25,z-.08),(x,1.25,z),.095,S)
 cyl('Navigation lamp lens',(x,1.25,z),(x,1.25,z+.16),.074,ma,16)
 cyl('Lamp cap',(x,1.25,z+.16),(x,1.25,z+.19),.084,S)
for x,y,h in [(-1.2,1.2,2.1),(1.2,.6,2.5)]:
 cyl('Radio aerial socket',(x,y,6.04),(x,y,6.30),.05,W)
 cyl('VHF aerial',(x,y,6.25),(x,y,6.25+h),.016,S,8)
tube('Diesel exhaust',[(-1.15,.15,5.95),(-1.15,.15,6.9),(-1.15,-.08,7.13)],.105,S,14)
cyl('Exhaust dark mouth',(-1.15,-.07,7.13),(-1.15,-.13,7.13),.09,B)
# Searchlight on roof front.
cyl('Searchlight post',(-1.25,2.9,6.03),(-1.25,2.9,6.27),.06,S)
cyl('Searchlight casing',(-1.25,2.70,6.41),(-1.25,3.1,6.41),.20,W,24)
cyl('Searchlight lens',(-1.25,3.105,6.41),(-1.25,3.12,6.41),.17,V,24)
# Stern A-frame, foundations, braces and sheaves.
group='Gantry'
for sign in [-1,1]:
 x=sign*2.24
 box('Gantry deck foundation',(x,-6.42,1.37),(.55,1.6,.19),S,.035)
 for xx in [-.17,.17]:
  for yy in [-.56,.56]:bolt((x+xx,-6.42+yy,1.48))
 cyl('Gantry main leg',(x,-6.9,1.48),(sign*1.96,-7.52,8.82),.125,W,12)
 cyl('Gantry forward brace',(x,-4.55,1.37),(sign*1.96,-7.52,8.82),.096,W,12)
 cyl('Gantry lower diagonal',(x,-7.15,2.1),(x,-5.6,4.15),.063,S,10)
box('A frame top beam',(0,-7.52,8.82),(4.33,.31,.29),W,.045)
cyl('Gantry transverse lower tie',(-2.12,-7.15,3.5),(2.12,-7.15,3.5),.087,W,12)
def pulley(x,y,z,r=.19):
 torus('Sheave groove',(x,y,z),r,.042,B,'Y',32,8)
 for yy in [y-.061,y+.061]:cyl('Sheave cheek',(x,yy-.018,z),(x,yy+.018,z),r+.028,S,24)
 cyl('Sheave axle',(x,y-.115,z),(x,y+.115,z),.05,S,12)
 torus('Sheave hanger',(x,y,z+.30),.073,.02,S,'Y',20,8)
for x in [-1.27,0,1.27]:
 pulley(x,-7.53,8.31,.20)
 sag('Trawl running cable',(x-.12,-7.53,8.31),(x-.12,-6.48,4.05),.035,B,.019)
 sag('Trawl return cable',(x+.12,-7.53,8.31),(x+.12,-6.48,4.05),.035,B,.019)
 pulley(x,-6.48,4.05,.15)
sag('Mast to gantry standing cable',(0,1.25,9.15),(0,-7.52,8.98),.55,B,.016)
# Auxiliary derrick on aft side of mast.
group='MastRigging'
cyl('Derrick boom',(0,.92,6.48),(0,-2.43,7.17),.066,W,12)
cyl('Derrick diagonal',(0,.60,4.95),(0,-2.43,7.17),.06,W,12)
sag('Derrick topping lift',(0,1.25,8.7),(0,-2.43,7.17),.08,B,.014)
pulley(0,-2.43,6.98,.115)
sag('Derrick hoist',(0,-2.43,7.0),(0,-2.43,6.14),0,B,.013)
tube('Hoist hook',[(0,-2.43,6.18),(0,-2.43,6.02),(0,-2.38,5.94),(0,-2.27,5.97),(0,-2.25,6.07)],.034,S,10)
# Net drum across the aft deck.
group='NetDrum'
for x in [-1.53,1.53]:
 box('Drum bed',(x,-6.24,1.39),(.54,1.95,.19),S,.04)
 # tapered bearing pedestal
 mesh('Drum bearing pedestal',[(x-.22,-6.85,1.47),(x+.22,-6.85,1.47),(x+.15,-6.30,3.17),(x-.15,-6.30,3.17),(x-.22,-5.6,1.47),(x+.22,-5.6,1.47),(x+.15,-6.05,3.17),(x-.15,-6.05,3.17)],[(0,1,2,3),(4,7,6,5),(0,4,5,1),(2,6,7,3),(0,3,7,4),(1,5,6,2)],W,.03)
 cyl('Drum flange',(x-.07,-6.23,2.91),(x+.07,-6.23,2.91),1.12,S,64)
 torus('Drum flange rolled rim',(x,-6.23,2.91),1.1,.038,S,'X',64,8)
cyl('Drum shaft',(-1.93,-6.23,2.91),(1.93,-6.23,2.91),.16,S,24)
cyl('Wound trawl net',(-1.40,-6.23,2.91),(1.40,-6.23,2.91),.94,G,64)
# Diagonal woven cords wrapped around the drum, visible genuine geometry.
for direction in [-1,1]:
 for k in range(24):
  pts=[]
  for j in range(65):
   x=-1.39+2.78*j/64;a=k*2*pi/24+direction*(x+1.39)*1.0
   rr=.952+.012*sin(j*.7+k);pts.append((x,-6.23+rr*cos(a),2.91+rr*sin(a)))
  tube('Net diamond strand',pts,.014,G,5)
for x in [-1.04,-.15,.72]:torus('Net binding cord',(x,-6.23,2.91),.976,.026,R,'X',64,8)
# Hydraulic gear and hoses.
box('Drum hydraulic motor',(1.86,-6.23,2.92),(.37,.59,.55),S,.09)
for dz in [-.12,.12]:tube('Hydraulic hose',[(2.03,-6.23,2.9+dz),(2.15,-5.96,2.72+dz),(2.14,-5.8,1.5),(1.9,-5.4,1.35)],.025,B,8)
# Deck winch, aft starboard side.
group='Winch'
box('Winch skid',(1.40,-3.92,1.38),(1.85,1.21,.20),S,.04)
for x in [.61,2.14]:
 box('Winch bearing bracket',(x,-3.92,1.81),(.14,.87,.81),W,.065)
 cyl('Winch spool cheek',(x-.06,-3.92,2.04),(x+.06,-3.92,2.04),.46,S,32)
cyl('Winch core',(.65,-3.92,2.04),(2.1,-3.92,2.04),.27,B,32)
for j in range(34):torus('Steel warp on spool',(.71+j*.039,-3.92,2.04),.305,.020,S,'X',32,6)
box('Winch reduction gear',(.36,-3.92,1.96),(.4,.57,.50),S,.06)
cyl('Winch lever',(2.24,-3.5,1.5),(2.24,-3.5,2.30),.023,S,10);ball('Winch lever grip',(2.24,-3.5,2.32),.055,B)
# Trawl doors hung outside both stern quarters, steel framed curved panels.
for sign in [-1,1]:
 group='TrawlDoor_L' if sign<0 else 'TrawlDoor_R'
 x=sign*2.69;y=-7.25;z=2.13
 o=box('Otter board plate',(x,y,z),(.12,1.30,1.88),S,.13)
 for yy in [-.62,.62]:cyl('Door perimeter rail',(x+sign*.08,y+yy,z-.84),(x+sign*.08,y+yy,z+.84),.055,R,10)
 for zz in [-.86,.86]:cyl('Door perimeter rail',(x+sign*.08,y-.61,z+zz),(x+sign*.08,y+.61,z+zz),.055,R,10)
 for zz in [-.37,.37]:box('Door transverse brace',(x+sign*.10,y,z+zz),(.08,1.25,.075),S,.019)
 for yy in [-.39,.39]:box('Door vertical stiffener',(x+sign*.11,y+yy,z),(.09,.065,1.69),S,.02)
 for yy in [-.48,.48]:
  for zz in [-.7,.7]:bolt((x+sign*.18,y+yy,z+zz),'X')
 sag('Otter board hanging strop',(x,y-.47,z+.84),(sign*2.24,-7.04,4.0),.08,R,.035)
 sag('Otter board hanging strop',(x,y+.47,z+.84),(sign*2.24,-7.04,4.0),.08,R,.035)
# Hanging otter boards cant naturally in their lifting strops.
for o in objects:
 if o.get('assembly') in ['TrawlDoor_L','TrawlDoor_R'] and 'strop' not in o.name:
  from mathutils import Matrix
  pivot=Vector((0,-7.25,2.13)); rot=Matrix.Rotation(math.radians(-14),4,'X')
  o.matrix_world=Matrix.Translation(pivot) @ rot @ Matrix.Translation(-pivot) @ o.matrix_world
# Tyre fenders and attached lanyards.
group='DeckFittings'
for sign in [-1,1]:
 for j,y in enumerate([-5.15,-2.5,.1,3.35]):
  w,k,top=interp(y);x=sign*(w+.18);z=top-.88
  torus('Hanging tyre fender',(x,y,z),.40,.135,B,'X',48,12)
  for xx in [-.075,.075]:torus('Tyre sidewall mould ring',(x+xx,y,z),.36,.028,B,'X',48,8)
  # Subtle raised radial tread bars.
  for a in range(22):
   t=a*2*pi/22;tube('Tyre tread',[(x-.088,y+.528*cos(t-.025),z+.528*sin(t-.025)),(x+.088,y+.528*cos(t+.025),z+.528*sin(t+.025))],.012,B,5)
  sag('Tyre suspension rope',(sign*(w-.06),y,top+.05),(x,y,z+.22),.075,R,.035)
# Deck hatch coamings, fish crates, buoys and bins.
for x,y in [(-.65,-2.7),(-.50,-4.2),(.6,-1.55)]:
 box('Hatch raised coaming',(x,y,1.31),(1.04,1.04,.13),B,.055)
 box('Steel deck hatch',(x,y,1.39),(.98,.98,.07),S,.045)
 for xx in [-.30,.3]:cyl('Hatch hinge',(x+xx-.07,y+.48,1.45),(x+xx+.07,y+.48,1.45),.03,S,10)
 tube('Hatch flush handle',[(x-.12,y,1.44),(x-.12,y,1.48),(x+.12,y,1.48),(x+.12,y,1.44)],.02,S,8)
def crate(x,y,z):
 # Open interior, floor, rim, pressed strengthening ribs.
 box('Fish box floor',(x,y,z+.035),(.79,.53,.07),O,.024)
 for dx in [-.38,.38]:box('Fish box end',(x+dx,y,z+.21),(.045,.53,.36),O,.017)
 for dy in [-.25,.25]:box('Fish box side',(x,y+dy,z+.21),(.79,.042,.36),O,.017)
 for h in [.12,.32,.40]:
  for dy in [-.276,.276]:box('Fish box moulded rim',(x,y+dy,z+h),(.85,.035,.043),O,.012)
  for dx in [-.406,.406]:box('Fish box moulded rim',(x+dx,y,z+h),(.035,.55,.043),O,.012)
 for dx in [-.25,0,.25]:
  for dy in [-.28,.28]:box('Fish box rib',(x+dx,y+dy,z+.23),(.025,.025,.24),O,.006)
for x,y,n in [(-1.65,-3.8,4),(-1.70,-4.52,3),(-1.70,-2.95,2),(1.8,-1.25,2),(1.75,-2.03,1)]:
 for k in range(n):crate(x,y,1.29+k*.41)
for sign in [-1,1]:
 for j in range(5):
  y=-.7-j*.5;x=sign*(2.08+random.uniform(-.13,.12));z=1.61+(.18 if j%2 else 0)
  ball('Orange net float',(x,y,z),.25,O,(1,1,1.14));cyl('Float rope eye',(x,y,z+.23),(x,y,z+.32),.046,B,10)
 sag('Buoy string',[(sign*2.1),-.7,1.94],[(sign*2.1),-2.7,1.93],.2,R,.021)
# Foredeck liferaft canister, lashings and mooring rope coils.
x=0;y=4.12;z=deckz(y)+.45
box('Liferaft mounting cradle',(x,y,z-.30),(1.54,.86,.18),S,.055)
cyl('Liferaft canister',(-.57,y,z),(.57,y,z),.39,W,40)
for xx in [-.57,.57]:ball('Liferaft moulded end',(xx,y,z),.39,W,(.45,1,1))
for xx in [-.44,.44]:torus('Liferaft retaining strap',(xx,y,z),.402,.035,S,'X',40,8)
box('Liferaft identification panel',(0,y-.395,z),(.43,.016,.19),O,.01)
for x,y in [(-.65,5.75),(.42,6.84)]:
 for k in range(5):
  pts=[]
  for i in range(101):
   a=i*2*pi/100;r=.33+k*.039;pts.append((x+r*cos(a),y+r*sin(a),deckz(y)+.07+k*.027))
  tube('Coiled mooring warp',pts,.026,R,6)
 sag('Mooring coil tail',(x+.47,y,deckz(y)+.06),(x+.66,y-.72,deckz(y-.72)+.06),0,R,.027)
# Bollards and anchor pockets, welded shanks and flukes.
for x,y in [(-.86,6.45),(.86,6.45),(-1.72,-8.02),(1.72,-8.02)]:
 z=deckz(y);box('Bollard base',(x,y,z+.07),(.42,.70,.12),S,.035)
 for yy in [-.2,.2]:
  cyl('Mooring bollard',(x,y+yy,z+.1),(x,y+yy,z+.47),.087,S,16)
  cyl('Bollard top lip',(x,y+yy,z+.44),(x,y+yy,z+.49),.117,S,16)
 for dx in [-.13,.13]:
  for dy in [-.25,.25]:bolt((x+dx,y+dy,z+.145))
for sign in [-1,1]:
 x=sign*1.51;y=6.38;z=1.48
 box('Anchor hawse backing',(x,y,z),(.14,.58,.58),S,.14)
 torus('Hawse lip',(x+sign*.08,y,z+.11),.13,.032,S,'X',24,8)
 tube('Anchor shank',[(x+sign*.18,y,z+.13),(x+sign*.24,y,z-.54)],.063,S,12)
 tube('Anchor crown',[(x+sign*.24,y-.32,z-.30),(x+sign*.25,y-.29,z-.55),(x+sign*.24,y,z-.63),(x+sign*.25,y+.29,z-.55),(x+sign*.24,y+.32,z-.30)],.063,S,12)
 for dy in [-.31,.31]:ball('Anchor fluke',(x+sign*.24,y+dy,z-.33),.14,S,(.35,.67,1.6))
# Deck edge knees and drains, stern cleats and deck plates.
for sign in [-1,1]:
 for y in [-8,-6.8,-5.5,-4.2,-2.9,-1.6]:
  w,k,z=interp(y)
  cyl('Bulwark support knee',(sign*(w-.32),y,1.29),(sign*(w-.11),y,z-.12),.033,S,8)
# Apply bevels, mirror longitudinal coordinate, join rigid assemblies.
print('GEOMETRY_OBJECTS',len(objects),flush=True)
bpy.ops.object.select_all(action='DESELECT')
for o in objects:
 bpy.context.view_layer.objects.active=o;o.select_set(True)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
 bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
 for v in o.data.vertices:v.co.y=-v.co.y
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
 o.select_set(False)
assemblies={}
for o in objects:assemblies.setdefault(o['assembly'],[]).append(o)
joined=[]
for name,obs in assemblies.items():
 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();o=bpy.context.object;o.name=name;joined.append(o)
 # Weighted corner normals preserve plate faces, smooth curved surfaces retain their authored shading.
 mod=o.modifiers.new('Weighted manufacturing normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=35
 bpy.ops.object.modifier_apply(modifier=mod.name)
# Measure real evaluated geometry before committing to an atlas bake.
count=sum(len(o.data.polygons) for o in joined)
for o in joined:o.data.calc_loop_triangles()
tri_pre=sum(len(o.data.loop_triangles) for o in joined)
print('GEOMETRY_TRIANGLES',tri_pre,flush=True)
if tri_pre>116000:
 for o in joined:
  bpy.context.view_layer.objects.active=o
  dec=o.modifiers.new('Budget preserving silhouette','DECIMATE');dec.ratio=112000/tri_pre
  bpy.ops.object.modifier_apply(modifier=dec.name)
 print('BUDGET_DECIMATION',112000/tri_pre,flush=True)
# Triangulate explicitly and remove collapsed bevel remnants before UVs and baking.
for o in joined:
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.triangulate(bm,faces=list(bm.faces),quad_method='BEAUTY',ngon_method='BEAUTY')
 bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update()
# Named attachment nodes, export Y-up coordinates are (x,z,+authored-y).
sockets={'Helm':(0,2.19,3.67),'Deck_Fore':(0,5.5,deckz(5.5)),'Deck_Aft':(0,-2.5,1.30),'Bollard_Bow':(0,6.45,deckz(6.45)+.3),'Bollard_Stern':(1.72,-8.02,1.6),'Light_Mast':(0,1.25,9.6)}
for name,(x,y,z) in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=(x,-y,z);o.empty_display_size=.2
# Render rig excluded from export.
def camera(loc,target,ortho):
 bpy.ops.object.camera_add(location=loc);o=bpy.context.object;o.name='QA camera';o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();o.data.type='ORTHO';o.data.ortho_scale=ortho;s.camera=o;return o
cam=camera((34,-21,15),(0,0,3.4),29)
def fit_camera(cam,obs,pad=1.08):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for o in obs for c in o.bound_box]
 right=cam.rotation_euler.to_matrix()@Vector((1,0,0));up=cam.rotation_euler.to_matrix()@Vector((0,1,0))
 xx=[p.dot(right) for p in pts];yy=[p.dot(up) for p in pts]
 cam.location+=right*((min(xx)+max(xx))/2-cam.location.dot(right))+up*((min(yy)+max(yy))/2-cam.location.dot(up))
 cam.data.ortho_scale=max(max(xx)-min(xx),(max(yy)-min(yy))*1920/1080)*pad
fit_camera(cam,joined)
world=bpy.data.worlds.new('Soft maritime daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.63,.72,.82,1);world.node_tree.nodes['Background'].inputs[1].default_value=.65;s.world=world
wn=world.node_tree;sky=node(wn,'ShaderNodeTexSky');sky.sky_type='MULTIPLE_SCATTERING';sky.sun_elevation=math.radians(38);sky.sun_rotation=math.radians(125);sky.sun_disc=False
link(wn,sky.outputs[0],wn.nodes['Background'].inputs[0]);wn.nodes['Background'].inputs[1].default_value=.14
bpy.ops.object.light_add(type='AREA',location=(-8,-8,18));o=bpy.context.object;o.data.energy=2800;o.data.shape='DISK';o.data.size=12;o.rotation_euler=(Vector((0,0,2))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.light_add(type='AREA',location=(7,7,12));o=bpy.context.object;o.data.energy=2100;o.data.size=10;o.rotation_euler=(Vector((0,0,2))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.light_add(type='SUN',location=(-5,-8,14));o=bpy.context.object;o.data.energy=1.2;o.data.angle=.22;o.rotation_euler=(.4,-.5,-.5)
# Ground only in QA renders, kept far enough below the keel.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-3.04));ground=bpy.context.object;ground.name='QA ground'
gm=bpy.data.materials.new('QA background');gm.diffuse_color=(.23,.27,.29,1);gm.use_nodes=True;gm.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.23,.27,.29,1);gm.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8;ground.data.materials.append(gm)
clay=bpy.data.materials.new('QA plain clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.67
# Clay evidence before any bake.
s.view_layers[0].material_override=clay;s.render.filepath=str(ROOT/('draft-clay.png' if DRAFT else 'clay.png'));bpy.ops.render.render(write_still=True);s.view_layers[0].material_override=None
if DRAFT:
 s.render.filepath=str(ROOT/'draft.png');bpy.ops.render.render(write_still=True)
 cam.location=(25,0,5);cam.rotation_euler=(Vector((0,0,3.5))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=24
 fit_camera(cam,joined);s.render.filepath=str(ROOT/'draft-side.png');bpy.ops.render.render(write_still=True)
 print('DRAFT_DONE',time.time()-START,flush=True);sys.exit(0)
# Temporary bake mesh carries assembly ID so required mesh nodes are reconstructed exactly.
for idx,o in enumerate(joined):
 at=o.data.attributes.new('assembly_id','INT','FACE')
 for d in at.data:d.value=idx
names=[o.name for o in joined]
bpy.ops.object.select_all(action='DESELECT')
for o in joined:o.select_set(True)
bpy.context.view_layer.objects.active=joined[0];bpy.ops.object.join();bakeob=bpy.context.object;bakeob.name='BAKE_ASSEMBLY'
# Consolidate material slots so each atlas corresponds exactly to one source shader.
old=[slot.material for slot in bakeob.material_slots]
indices=[M.index(old[f.material_index]) for f in bakeob.data.polygons]
bakeob.data.materials.clear()
for m in M:bakeob.data.materials.append(m)
for f,i in zip(bakeob.data.polygons,indices):f.material_index=i
# Atlas each material separately at its full texture extent.
bpy.context.tool_settings.mesh_select_mode=(False,False,True)
for idx in range(len(M)):
 bpy.ops.object.mode_set(mode='OBJECT')
 for f in bakeob.data.polygons:f.select=f.material_index==idx
 bpy.ops.object.mode_set(mode='EDIT')
 bpy.ops.uv.smart_project(angle_limit=math.radians(66),island_margin=.008,area_weight=.5,correct_aspect=True,scale_to_bounds=True)
 bpy.ops.object.mode_set(mode='OBJECT')
print('UV_READY',time.time()-START,flush=True)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'source-before-bake.blend'))
# Keep other geometry out of bake rays, except the complete assembled trawler itself.
ground.hide_render=True
s.cycles.samples=24;s.render.bake.margin=12;s.render.bake.use_clear=True;s.render.bake.use_selected_to_active=False
sizes=[2048,2048,2048,1024,1024,1024,1024,512]
images={};targets=[]
for idx,m in enumerate(M):
 n=m.node_tree.nodes.new('ShaderNodeTexImage');n.name='BAKE_TARGET';m.node_tree.nodes.active=n;targets.append(n)
def targets_for(channel):
 for idx,m in enumerate(M):
  res=min(sizes[idx],1024) if channel=='orm' else sizes[idx]
  im=bpy.data.images.new(f'{idx+1:02}_{channel}',res,res,alpha=False)
  if channel!='basecolor':im.colorspace_settings.name='Non-Color'
  targets[idx].image=im;m.node_tree.nodes.active=targets[idx];images[idx,channel]=im
 print('BAKING',channel,flush=True)
def save_images(channel):
 for idx in range(8):
  im=images[idx,channel];im.filepath_raw=str(TEX/f'baked_{idx+1:02}_{channel}.jpg');im.file_format='JPEG';im.save(quality=87 if channel=='orm' else 91)
targets_for('basecolor');s.render.bake.use_pass_direct=False;s.render.bake.use_pass_indirect=False;s.render.bake.use_pass_color=True
bpy.ops.object.bake(type='DIFFUSE');save_images('basecolor')
targets_for('normal');bpy.ops.object.bake(type='NORMAL');save_images('normal')
# ORM is baked as linear emission: AO red, authored roughness green, metallic blue.
for idx,m in enumerate(M):
 nt=m.node_tree;bs,c,r,metal,out=sources[idx]
 ao=node(nt,'ShaderNodeAmbientOcclusion');ao.inputs['Distance'].default_value=.65;ao.samples=16
 comb=node(nt,'ShaderNodeCombineXYZ');link(nt,mathn(nt,'ADD',.28,mathn(nt,'MULTIPLY',ao.outputs['AO'],.72)),comb.inputs['X']);link(nt,r,comb.inputs['Y']);comb.inputs['Z'].default_value=metal
 em=node(nt,'ShaderNodeEmission');link(nt,comb.outputs[0],em.inputs[0]);link(nt,em.outputs[0],out.inputs[0])
targets_for('orm');bpy.ops.object.bake(type='EMIT');save_images('orm')
# Replace every procedural graph with the actual exported PBR maps.
for idx,m in enumerate(M):
 nt=m.node_tree;nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs[0])
 tex={}
 for ch in ['basecolor','normal','orm']:
  n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(TEX/f'baked_{idx+1:02}_{ch}.jpg'),check_existing=False)
  if ch!='basecolor':n.image.colorspace_settings.name='Non-Color'
  tex[ch]=n
 link(nt,tex['basecolor'].outputs[0],bs.inputs['Base Color'])
 nm=node(nt,'ShaderNodeNormalMap');link(nt,tex['normal'].outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],bs.inputs['Normal'])
 sep=node(nt,'ShaderNodeSeparateColor');sep.mode='RGB';link(nt,tex['orm'].outputs[0],sep.inputs[0]);link(nt,sep.outputs['Green'],bs.inputs['Roughness']);link(nt,sep.outputs['Blue'],bs.inputs['Metallic'])
 # Standard glTF occlusion hook understood by Blender's exporter.
 if 'glTF Material Output' not in bpy.data.node_groups:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=bpy.data.node_groups['glTF Material Output'];link(nt,sep.outputs['Red'],gn.inputs['Occlusion'])
# Split by persistent face ID, not loose parts (preserves named functional assemblies).
result=[]
for idx,name in enumerate(names):
 me=bakeob.data.copy();ob=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(ob)
 bm=bmesh.new();bm.from_mesh(me);layer=bm.faces.layers.int.get('assembly_id');bmesh.ops.delete(bm,geom=[f for f in bm.faces if f[layer]!=idx],context='FACES');bm.to_mesh(me);bm.free();me.update();result.append(ob)
bpy.data.objects.remove(bakeob,do_unlink=True)
# Final evaluated mesh counts and glTF node positions.
triangles=0;points=[]
for ob in result:
 ob.data.calc_loop_triangles();triangles+=len(ob.data.loop_triangles)
 points.extend([ob.matrix_world@v.co for v in ob.data.vertices])
lo=[min(v[i] for v in points) for i in range(3)];hi=[max(v[i] for v in points) for i in range(3)]
# Y-up conversion: Blender (x,y,z) -> glTF (x,z,-y).
bounds={'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]}
exportobs=result+[bpy.data.objects[n] for n in sockets]
nodedata=[{'name':o.name,'type':'mesh' if o.type=='MESH' else 'empty','world_position':[o.location.x,o.location.z,-o.location.y]} for o in exportobs]
(ROOT/'nodes.json').write_text(json.dumps({'coordinate_system':'glTF Y up, +Z bow, metres','nodes':nodedata,'bounding_box':bounds,'triangles':triangles},indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in exportobs:o.select_set(True)
bpy.context.view_layer.objects.active=result[0]
bpy.ops.export_scene.gltf(filepath=str(ROOT/'harbour-trawler-real.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_image_format='AUTO',export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=False)
print('EXPORT_STATS',triangles,(ROOT/'harbour-trawler-real.glb').stat().st_size,flush=True)
# Final images use the same baked materials as the GLB.
ground.hide_render=False;s.cycles.samples=96
s.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
cam.location=(25,0,5);cam.rotation_euler=(Vector((0,0,3.6))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=23.5
fit_camera(cam,result);s.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
(ROOT/'NOTES.md').write_text('18 m European steel stern trawler with working gear, rigging, cabin fittings and waterline origin.\n'+f'{triangles:,} triangles; GLB {(ROOT/"harbour-trawler-real.glb").stat().st_size/1e6:.2f} MB; +Z bow, +Y up, metres.\n'+'8 PBR materials; Cycles baked base colour, tangent normal and packed ORM including AO.\n'+'Textures: base/normal 3 × 2048, 4 × 1024, 1 × 512; ORM ≤1024; JPEG; source maps 1k.\n'+'Poly Haven CC0: '+', '.join(PH)+'; https://polyhaven.com/license ; https://polyhaven.com/a/<id>.\n'+'Differences: glazing is opaque reflective game glass; simplified interior and net weave; no readable hull registration in the reference.\n')
assert 60000<=triangles<=120000,triangles
assert (ROOT/'harbour-trawler-real.glb').stat().st_size<=12_000_000
print('COMPLETE_SECONDS',time.time()-START,flush=True)
