"""Harbour rowboat. Blender 5.2, clean deterministic build, metres.
Run: blender -b --python-exit-code 1 --python build_harbour-rowboat.py
Optional -- --preflight tests Cycles bake / GLB roundtrip; --draft uses smaller renders.
All photographs from Poly Haven CC0; downloaded on demand. Blender Z-up/-Y bow
is converted by the glTF exporter to Y-up/+Z bow. Waterline = Z 0 here.
"""
import bpy, bmesh, math, json, os, sys, urllib.request, random, time
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi, sqrt
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
BAKE=ROOT/'baked'; BAKE.mkdir(exist_ok=True)
random.seed(142)
DRAFT='--draft' in sys.argv
PREFLIGHT='--preflight' in sys.argv
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials): bpy.data.materials.remove(d)
sc=bpy.context.scene
sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES'; sc.cycles.samples=32 if DRAFT else 96
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!='CPU'
    sc.cycles.device='GPU'
except Exception: sc.cycles.device='CPU'
sc.world.color=(.3,.3,.3)
sc.view_settings.view_transform='AgX'
ASSET=[]

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

def bevel(o,amount=.003,segments=2):
    active(o); m=o.modifiers.new('Rounded worn arrises','BEVEL'); m.width=amount; m.segments=segments
    bpy.ops.object.modifier_apply(modifier=m.name)
    for p in o.data.polygons: p.use_smooth=True
    m=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL'); m.keep_sharp=True; m.weight=40
    bpy.ops.object.modifier_apply(modifier=m.name)

def mesh(name,vs,fs,mat,uvs=None,bev=.002):
    me=bpy.data.meshes.new(name); me.from_pydata(vs,[],fs); me.update()
    o=bpy.data.objects.new(name,me); sc.collection.objects.link(o); ASSET.append(o)
    if mat: me.materials.append(mat)
    uv=me.uv_layers.new(name='GrainUV')
    for p in me.polygons:
        for li in p.loop_indices:
            idx=me.loops[li].vertex_index; v=vs[idx]
            uv.data[li].uv=uvs[idx] if uvs else (v[0],v[1])
    bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
    if bev: bevel(o,bev)
    return o

def box(name,loc,dims,mat,grain='X',bev=.004):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.name=name
    o.dimensions=dims; bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    o.data.materials.append(mat); ASSET.append(o)
    off=random.random()
    for p in o.data.polygons:
        for li in p.loop_indices:
            v=o.data.vertices[o.data.loops[li].vertex_index].co
            a,b=(v.x,v.y if abs(p.normal.z)>.5 else v.z) if grain=='X' else (v.y,v.x if abs(p.normal.z)>.5 else v.z)
            o.data.uv_layers.active.data[li].uv=(a/1.7+off,b/1.7+off)
    bevel(o,bev); return o

def sweep(name,points,width,height,mat,bev=.003):
    vs=[]; uv=[]; dist=0
    for i,p in enumerate(points):
        p=Vector(p)
        tangent=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(i-1,0)])
        tangent.normalize()
        side=tangent.cross(Vector((0,0,1)))
        if side.length<.01: side=Vector((1,0,0))
        side.normalize(); up=side.cross(tangent).normalized()
        if i: dist+=(p-Vector(points[i-1])).length
        for j,(a,b) in enumerate([(-1,-1),(1,-1),(1,1),(-1,1)]):
            vs.append(tuple(p+side*a*width/2+up*b*height/2)); uv.append((dist/1.7,j*(width+height)/4/1.7+.13))
    fs=[(3,2,1,0)]
    for i in range(len(points)-1):
        for j in range(4): fs.append((i*4+j,i*4+(j+1)%4,(i+1)*4+(j+1)%4,(i+1)*4+j))
    fs.append(tuple((len(points)-1)*4+j for j in range(4)))
    return mesh(name,vs,fs,mat,uv,bev)

def tube(name,points,r,mat,sides=10):
    vs=[]; uv=[]; length=0
    for i,p in enumerate(points):
        p=Vector(p); tangent=(Vector(points[min(i+1,len(points)-1)])-Vector(points[max(i-1,0)])).normalized()
        axis=Vector((0,0,1)) if abs(tangent.z)<.9 else Vector((0,1,0))
        a=tangent.cross(axis).normalized(); b=tangent.cross(a).normalized()
        if i: length+=(p-Vector(points[i-1])).length
        for k in range(sides):
            vs.append(tuple(p+r*(a*cos(k*2*pi/sides)+b*sin(k*2*pi/sides)))); uv.append((length*3,k/sides))
    fs=[tuple(reversed(range(sides)))]
    for i in range(len(points)-1):
        for k in range(sides): fs.append((i*sides+k,i*sides+(k+1)%sides,(i+1)*sides+(k+1)%sides,(i+1)*sides+k))
    fs.append(tuple((len(points)-1)*sides+k for k in range(sides)))
    o=mesh(name,vs,fs,mat,uv,0)
    for p in o.data.polygons: p.use_smooth=len(p.vertices)==4
    return o

def bolt(name,p,normal,r=.006):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=10,ring_count=6,radius=1,location=p)
    o=bpy.context.object; o.name=name; o.scale=(r,r,r*.38)
    o.rotation_euler=Vector(normal).to_track_quat('Z','Y').to_euler()
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    o.data.materials.append(MATS['Iron']); ASSET.append(o)
    for f in o.data.polygons:f.use_smooth=True
    return o

def download(asset,res):
    out={}
    for kind in ('diff','nor_gl','arm'):
        name=f'{asset}_{kind}_{res}.jpg'; path=TEX/name
        if not path.exists():
            print('DOWNLOAD',name,flush=True)
            urllib.request.urlretrieve(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/{res}/{asset}/{name}',path)
        im=bpy.data.images.load(str(path),check_existing=True)
        if kind!='diff': im.colorspace_settings.name='Non-Color'
        out[kind]=im
    return out

def material(name,asset,res,kind):
    m=bpy.data.materials.new(name); m.use_nodes=True
    n=m.node_tree.nodes; l=m.node_tree.links; n.clear()
    def node(t):return n.new(t)
    out=node('ShaderNodeOutputMaterial'); bs=node('ShaderNodeBsdfPrincipled'); l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    tc=node('ShaderNodeTexCoord'); vec=tc.outputs['UV']
    if kind in ('wood','rope'):
        sep=node('ShaderNodeSeparateXYZ'); l.new(vec,sep.inputs[0]); co=node('ShaderNodeCombineXYZ'); l.new(sep.outputs['X'],co.inputs['Y']); l.new(sep.outputs['Y'],co.inputs['X']); vec=co.outputs[0]
    imgs=download(asset,res)
    tx={}
    for k,im in imgs.items():
        t=node('ShaderNodeTexImage'); t.image=im; l.new(vec,t.inputs['Vector']); tx[k]=t
    color=tx['diff'].outputs['Color']
    if kind=='paint':
        sep=node('ShaderNodeSeparateColor'); l.new(color,sep.inputs[0])
        sub=node('ShaderNodeMath'); sub.operation='SUBTRACT'; l.new(sep.outputs['Red'],sub.inputs[0]); l.new(sep.outputs['Blue'],sub.inputs[1])
        ramp=node('ShaderNodeValToRGB'); l.new(sub.outputs[0],ramp.inputs[0])
        ramp.color_ramp.elements[0].position=.02; ramp.color_ramp.elements[0].color=(.20,.245,.265,1)
        ramp.color_ramp.elements[1].position=.057; ramp.color_ramp.elements[1].color=(.31,.28,.23,1)
        bw=node('ShaderNodeRGBToBW'); l.new(color,bw.inputs[0])
        levels=node('ShaderNodeMapRange'); l.new(bw.outputs[0],levels.inputs['Value']); levels.inputs['From Min'].default_value=.02; levels.inputs['From Max'].default_value=.30; levels.inputs['To Min'].default_value=.6; levels.inputs['To Max'].default_value=1.3
        mix=node('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1
        l.new(ramp.outputs[0],mix.inputs[1]); l.new(levels.outputs[0],mix.inputs[2]); color=mix.outputs[0]
    elif kind=='iron':
        mix=node('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.93
        l.new(color,mix.inputs[1]); mix.inputs[2].default_value=(.032,.038,.045,1); color=mix.outputs[0]
    elif kind=='rope':
        mix=node('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.45
        l.new(color,mix.inputs[1]); mix.inputs[2].default_value=(.5,.37,.20,1); color=mix.outputs[0]
    # Small mineral staining and fine roughness variation, flattened by Cycles below.
    noise=node('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=37; noise.inputs['Detail'].default_value=3; l.new(vec,noise.inputs['Vector'])
    stain=node('ShaderNodeMixRGB'); stain.blend_type='MULTIPLY'; stain.inputs[0].default_value=.15
    l.new(color,stain.inputs[1]); l.new(noise.outputs['Fac'],stain.inputs[2]); color=stain.outputs[0]
    l.new(color,bs.inputs['Base Color'])
    sep=node('ShaderNodeSeparateColor'); l.new(tx['arm'].outputs[0],sep.inputs[0])
    rough=node('ShaderNodeMath'); rough.operation='MULTIPLY_ADD'; l.new(sep.outputs['Green'],rough.inputs[0]); rough.inputs[1].default_value=.35; rough.inputs[2].default_value=.43 if kind=='iron' else .5
    l.new(rough.outputs[0],bs.inputs['Roughness']); bs.inputs['Metallic'].default_value=.75 if kind=='iron' else 0
    nm=node('ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=.65 if kind=='paint' else .4; l.new(tx['nor_gl'].outputs[0],nm.inputs['Color'])
    bump=node('ShaderNodeBump'); bump.inputs['Strength'].default_value=.12; bump.inputs['Distance'].default_value=.0004
    l.new(noise.outputs['Fac'],bump.inputs['Height']); l.new(nm.outputs[0],bump.inputs['Normal']); l.new(bump.outputs[0],bs.inputs['Normal'])
    # Packed ORM, with scan AO retained. No directional light baked in.
    comb=node('ShaderNodeCombineColor'); l.new(sep.outputs['Red'],comb.inputs['Red']); l.new(rough.outputs[0],comb.inputs['Green']); comb.inputs['Blue'].default_value=.75 if kind=='iron' else 0
    return m,{'color':color,'orm':comb.outputs[0],'bs':bs,'out':out}

MATS={}; SOURCES={}
for name,asset,res,kind in [('Paint','wood_peeling_paint_weathered','2k','paint'),('Timber','rough_wood','2k','wood'),('Iron','rusty_metal_03','1k','iron'),('Rope','rough_wood','2k','rope')]:
    MATS[name],SOURCES[name]=material(name,asset,res,kind)

def bake_material(name,size):
    m=MATS[name]; source=SOURCES[name]; n=m.node_tree.nodes; l=m.node_tree.links
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,5)); plane=bpy.context.object; plane.name='BAKE_fixture'; plane.data.materials.append(m)
    sc.cycles.samples=16
    maps={}; emit=n.new('ShaderNodeEmission'); target=n.new('ShaderNodeTexImage')
    for kind in ('color','normal','orm'):
        im=bpy.data.images.new(f'{name}_{kind}',width=size,height=size,alpha=False)
        if kind!='color':im.colorspace_settings.name='Non-Color'
        target.image=im; n.active=target; active(plane)
        if kind=='normal':l.new(source['bs'].outputs[0],source['out'].inputs['Surface'])
        else:
            l.new(source[kind],emit.inputs[0]); l.new(emit.outputs[0],source['out'].inputs['Surface'])
        bpy.ops.object.bake(type='NORMAL' if kind=='normal' else 'EMIT',margin=8,use_clear=True)
        im.filepath_raw=str(BAKE/f'{name}_{kind}.jpg')
        im.file_format='JPEG'; sc.render.image_settings.quality=95; im.save(); im.reload(); maps[kind]=im
        print('BAKED',name,kind,size,flush=True)
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear(); out=n.new('ShaderNodeOutputMaterial'); bs=n.new('ShaderNodeBsdfPrincipled'); l.new(bs.outputs[0],out.inputs[0])
    tex={}
    for kind,im in maps.items():
        t=n.new('ShaderNodeTexImage'); t.image=im; tex[kind]=t
    l.new(tex['color'].outputs[0],bs.inputs['Base Color'])
    nm=n.new('ShaderNodeNormalMap'); l.new(tex['normal'].outputs[0],nm.inputs['Color']); l.new(nm.outputs[0],bs.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor'); l.new(tex['orm'].outputs[0],sep.inputs[0]); l.new(sep.outputs['Green'],bs.inputs['Roughness']); l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
    group=bpy.data.node_groups.get('glTF Material Output') or bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree')
    if not group.interface.items_tree: group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    g=n.new('ShaderNodeGroup'); g.node_tree=group; l.new(sep.outputs['Red'],g.inputs['Occlusion'])

if PREFLIGHT:
    bake_material('Paint',64)
    o=box('preflight',(0,0,0),(1,1,1),MATS['Paint'])
    active(o); bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True)
    bpy.ops.object.delete(use_global=False)
    bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True)
    bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
    print('PREFLIGHT_OK',len(bpy.context.selected_objects),flush=True)
    sys.exit(0)

# A rounded lapstrake dinghy: longitudinal stations, broad stern and fine bow.
# s is the forward axis. All fitted members use this same moulded envelope.
def shape(s):
    t=(s+1.79)/3.58
    w=.50+.26*sin(pi*t)-.49*t**4
    keel=-.265+.075*(max(-s,0)/1.79)**4+.54*(max(s,0)/1.79)**5
    top=.405+.065*(abs(s)/1.79)**2
    return w,keel,top

def surface(s,t,side=1,inset=0):
    w,k,h=shape(s); a=t*pi/2
    return (side*(w*sin(a)-inset*sin(a)),-s-.11*(1-t)*(max(-s,0)/1.79)**6,k+(h-k)*(1-cos(a))+inset*cos(a))

stations=[-1.79+3.58*i/72 for i in range(73)]
for side in (-1,1):
    for j in range(8):
        vs=[]; uv=[]
        t0=max(0,j/8-.012); t1=(j+1)/8
        for i,s in enumerate(stations):
            # Lower edge stands proud, defining a real overlapping clinker lip.
            for t,ins in [(t0,-.007),(t1,0),(t1,.025),(t0,.025)]:
                vs.append(surface(s,t,side,ins)); uv.append(((s+1.8)/1.65+.17*j,t*.48+.137*j+(.31 if side<0 else 0)))
        fs=[(3,2,1,0)]
        for i in range(72):
            for c in range(4): fs.append((4*i+c,4*i+(c+1)%4,4*(i+1)+(c+1)%4,4*(i+1)+c))
        fs.append(tuple(4*72+c for c in range(4)))
        mesh(f'{"Port" if side<0 else "Starboard"}_clinker_strake_{j+1:02}',vs,fs,MATS['Paint'],uv,.0018)
    # Continuous solid timber cap, not intermittent sticks.
    sweep(f'Gunwale_{side}',[surface(s,1,side,.006) for s in stations],.075,.051,MATS['Timber'],.009)
    sweep(f'Inwale_{side}',[surface(s,.92,side,.045) for s in stations[:-3]],.028,.045,MATS['Timber'],.004)
    # Longitudinal riser: supports the thwart ends at actual hull contact.
    sweep(f'Seat_riser_{side}',[surface(s,.84,side,.045) for s in stations[3:-10]],.033,.036,MATS['Timber'],.004)
# Keel follows the outside of the bottom; never a straight bar through a curved shell.
sweep('External_keel',[(0,-s-.11*(max(-s,0)/1.79)**6,shape(s)[1]-.018) for s in stations],.053,.047,MATS['Timber'],.006)
# Stem at the pointed bow, following forefoot to the projecting head.
sweep('Solid_oak_stem',[(0,-s-.02,shape(s)[1]+.015) for s in [1.0+i*.79/32 for i in range(33)]]+[(0,-1.81,.38),(0,-1.825,.49)],.065,.07,MATS['Timber'],.006)
# Internal ribs are bent to the inside surface, with their whole cross-section inboard.
for i in range(12):
    s=-1.53+i*.264
    pts=[surface(s,abs(t),-1 if t<0 else 1,.047) for t in [-.98+k*1.96/48 for k in range(49)]]
    sweep(f'Bent_frame_{i+1:02}',pts,.038,.039,MATS['Timber'],.005)
# Transom: horizontal solid boards bounded by the same section; no rectangular box end.
s=-1.79; w,k,h=shape(s)
for j in range(6):
    z0=k+(h-k)*j/6; z1=k+(h-k)*(j+1)/6
    def ww(z):return w*sqrt(max(0,1-(1-(z-k)/(h-k))**2))
    n=12; vs=[]; uv=[]
    for z in [z0+(z1-z0)*i/n for i in range(n+1)]:
        width=max(.012,ww(z)-.008)
        for x,y in [(-width,1.768),(width,1.768),(width,1.806),(-width,1.806)]:
            rake=.11*(1-math.acos(max(-1,min(1,1-(z-k)/(h-k))))/(pi/2))
            vs.append((x,y-rake,z)); uv.append((x/1.7+.31,z/1.7+j*.09))
    fs=[(3,2,1,0)]
    for i in range(n):
        for c in range(4):fs.append((4*i+c,4*i+(c+1)%4,4*(i+1)+(c+1)%4,4*(i+1)+c))
    fs.append(tuple(4*n+c for c in range(4)))
    mesh(f'Transom_board_{j+1}',vs,fs,MATS['Paint'],uv,.002)
sweep('Transom_cap',[(-w+2*w*i/24,1.782,h+.004-.018*(1-abs(-1+2*i/24))) for i in range(25)],.062,.055,MATS['Timber'],.007)
sweep('Transom_centre_stiffener',[(0,1.729-.11*(1-i/20),k+(h-k)*(1-cos(i*pi/40))+.015) for i in range(21)],.055,.04,MATS['Timber'],.005)
# Cross seats shaped to the hull at their lowest corner, with ends resting on risers.
for idx,s in enumerate([-.63,.22,1.03]):
    z=.27 if idx<2 else .315
    half=[]
    for ss in (s-.135,s+.135):
        w,k,h=shape(ss); q=(z-.022-k)/(h-k)
        half.append(w*sqrt(max(0,1-(1-q)**2))-.061)
    hw=min(half)
    box(f'Thwart_{idx+1}',(0,-s,z),(2*hw,.255,.047),MATS['Paint'],bev=.008)
    for side in (-1,1):
        # Small knees tie seat to inner gunwale; all points stay within shell.
        pts=[(side*(hw-.09),-s,z-.05),(side*(hw-.015),-s,z-.055),surface(s,.955,side,.075)]
        sweep(f'Thwart_knee_{idx}_{side}',pts,.053,.032,MATS['Timber'],.004)
        for ds in (-.07,.07):bolt('Thwart_fastener',(side*(hw-.048),-s+ds,z+.024),(0,0,1),.005)
# Stern seat fitted with separate fore/aft boards.
for j in range(4):
    s=-1.62+j*.108; z=.295
    w,k,h=shape(s); q=(z-.02-k)/(h-k); hw=w*sqrt(max(0,1-(1-q)**2))-.053
    box(f'Stern_bench_plank_{j}',(0,-s,z),(2*hw,.104,.041),MATS['Paint'],bev=.004)
# Floorboards above the inside of the bilge. Ends individually cut back from rising forefoot.
for j in range(5):
    x=(j-2)*.145; z=-.083
    valid=[]
    for a in range(250):
        s=-1.5+a*2.9/249; w,k,h=shape(s)
        xx=abs(x)+.071
        if xx<w:
            shell=k+(h-k)*(1-sqrt(1-(xx/w)**2))
            if shell+.045<z-.019:valid.append(s)
    lo,hi=min(valid)+.025,max(valid)-.025
    box(f'Bilge_floorboard_{j+1}',(x,-(lo+hi)/2,z),(.137,hi-lo,.037),MATS['Timber'],'Y',.004)
# Fasteners down real frame lines and clinker lands.
for s in [-1.48,-1.22,-.96,-.7,-.44,-.18,.08,.34,.6,.86,1.12,1.38]:
    for side in (-1,1):
        for j in range(1,8):
            t=j/8+.017; p=surface(s,t,side,-.010)
            bolt('Copper_clench_nail',p,(side*sin(t*pi/2),0,-cos(t*pi/2)),.0038)
# Two oarlock sockets and forged U-shaped rowlocks mounted on the cap.
for side in (-1,1):
    s=-.13; w,k,h=shape(s); x=side*(w-.006)
    box(f'Rowlock_socket_plate_{side}',(x,-s,h+.035),(.077,.137,.016),MATS['Iron'],bev=.006)
    tube(f'Rowlock_pin_{side}',[(x,-s,h+.038),(x,-s,h+.099)],.011,MATS['Iron'],12)
    # U opens upwards, in the transverse plane.
    pts=[(x+.047*cos(pi+.0+i*pi/24),-s,h+.143+.053*sin(pi+i*pi/24)) for i in range(25)]
    tube(f'Forged_rowlock_U_{side}',pts,.008,MATS['Iron'],12)
    for ds in (-.046,.046):bolt('Socket_screw',(x,-s+ds,h+.044),(0,0,1),.0045)
# Bow breast hook, a shaped triangular timber under the cap.
mesh('Bow_breasthook',[(-.17,-1.56,.415),(.17,-1.56,.415),(0,-1.79,.46),(-.17,-1.56,.385),(.17,-1.56,.385),(0,-1.79,.43)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],MATS['Timber'],bev=.007)
# Rope painter: three helical strands following a loop around the stem and an inboard tail.
path=[]
for i in range(90):
    a=2*pi*i/89; path.append((.094*cos(a),-1.69+.086*sin(a),.486+.016*sin(2*a)))
for i in range(1,70):
    t=i/69; path.append((.094*(1-t)+.042*sin(t*pi*3),-1.69+.65*t,.486-.548*t+.06*sin(t*pi)))
# A neat small resting coil on the forward floor, joined to the tail.
end=Vector(path[-1])
for i in range(1,180):
    t=i/179; a=t*2*pi*2.6; rad=.08+.038*t
    path.append((end.x+.08-rad*cos(a),end.y+.11*sin(a),-.052+.012*t))
for strand in range(3):
    pts=[]; length=0
    for i,p in enumerate(path):
        if i:length+=(Vector(p)-Vector(path[i-1])).length
        tg=(Vector(path[min(i+1,len(path)-1)])-Vector(path[max(0,i-1)])).normalized(); a=tg.cross(Vector((0,0,1)))
        if a.length<.01:a=Vector((1,0,0))
        a.normalize(); b=tg.cross(a).normalized(); ph=length*2*pi/.041+strand*2*pi/3
        pts.append(tuple(Vector(p)+.0045*(a*cos(ph)+b*sin(ph))))
    tube(f'Painter_hemp_strand_{strand+1}',pts,.0045,MATS['Rope'],7)
# Normalize the complete fitting envelope to the metre contract, preserving waterline Z=0.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in ASSET for c in o.bound_box]
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)]
metric=Matrix.Diagonal((1.52/(hi[0]-lo[0]),3.69/(hi[1]-lo[1]),.77/(hi[2]-lo[2]),1)) @ Matrix.Translation((-(lo[0]+hi[0])/2,-(lo[1]+hi[1])/2,0))
for o in ASSET:o.matrix_world=metric @ o.matrix_world
# Origin on waterline at longitudinal centre, all matrices applied prior to export.
for o in ASSET:
    active(o); bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
# Preview stage and final capture setup.
def setup_render():
    sc.world.use_nodes=True; sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1); sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
    def area(name,loc,power,size):
        bpy.ops.object.light_add(type='AREA',location=loc); o=bpy.context.object; o.name=name; o.data.energy=power; o.data.shape='DISK'; o.data.size=size; o.rotation_euler=(Vector((0,0,0))-o.location).to_track_quat('-Z','Y').to_euler()
    area('Soft_daylight',(-3,-4,7),950,5); area('Sky_fill',(4,2,5),650,4)
    bpy.ops.object.camera_add(); cam=bpy.context.object; cam.name='Review_camera'; sc.camera=cam
    cam.data.type='ORTHO'; cam.data.ortho_scale=4.8
    cam.location=(5.5,-3.5,3.3); cam.rotation_euler=(Vector((0,0,.08))-cam.location).to_track_quat('-Z','Y').to_euler()
    sc.render.resolution_x=1280 if DRAFT else 1920; sc.render.resolution_y=720 if DRAFT else 1080; sc.render.resolution_percentage=100
    sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
    # Neutral seamless backdrop is camera-only, so the hull remains unoccluded in export.
    sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.69,.73,1)
    return cam
cam=setup_render()
clay=bpy.data.materials.new('QA_clay'); clay.diffuse_color=(.36,.36,.36,1); clay.use_nodes=True; clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.72; clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.32,.32,.32,1)
saved={o:list(o.data.materials) for o in ASSET}
for o in ASSET:o.data.materials.clear(); o.data.materials.append(clay)
sc.render.filepath=str(ROOT/'clay.png'); sc.cycles.samples=32 if DRAFT else 64; bpy.ops.render.render(write_still=True)
for o,mats in saved.items():
    o.data.materials.clear()
    for m in mats:o.data.materials.append(m)
for name in MATS:bake_material(name,2048 if name in ('Paint','Timber') else 1024)
sc.cycles.samples=32 if DRAFT else 96
sc.render.filepath=str(ROOT/'final.png'); bpy.ops.render.render(write_still=True)
cam.location=(5.8,0,.07); cam.rotation_euler=(Vector((0,0,.07))-cam.location).to_track_quat('-Z','Y').to_euler(); cam.data.ortho_scale=4.35
sc.render.filepath=str(ROOT/'side.png'); bpy.ops.render.render(write_still=True)
# Preserve named construction pieces in metadata, combine static parts into four draw groups.
parts=[]
for o in ASSET:
    vs=[o.matrix_world@Vector(c) for c in o.bound_box]
    mn=[min(v[i] for v in vs) for i in range(3)]; mx=[max(v[i] for v in vs) for i in range(3)]
    parts.append({'name':o.name,'world_position':[o.location.x,o.location.z,-o.location.y],'bbox_min':[mn[0],mn[2],-mx[1]],'bbox_max':[mx[0],mx[2],-mn[1]]})
# All source primitives must share the same UV layer name before joining.
for o in ASSET:
    if o.data.uv_layers.active:o.data.uv_layers.active.name='GrainUV'
joined=[]
groups={name:[o for o in ASSET if o.data.materials[0]==m] for name,m in MATS.items()}
for name,m in MATS.items():
    group=groups[name]
    bpy.ops.object.select_all(action='DESELECT')
    for o in group:o.select_set(True)
    bpy.context.view_layer.objects.active=group[0]; bpy.ops.object.join(); o=bpy.context.object; o.name='Harbour_rowboat_'+name.lower(); joined.append(o)
    # Triangulation explicit for portable evaluated counts.
    mod=o.modifiers.new('Final_triangles','TRIANGULATE'); bpy.ops.object.modifier_apply(modifier=mod.name)
    bm=bmesh.new(); bm.from_mesh(o.data)
    def area64(f):
        a,b,c=[tuple(v.co) for v in f.verts]
        u=[b[i]-a[i] for i in range(3)]; v=[c[i]-a[i] for i in range(3)]
        return sum((u[(i+1)%3]*v[(i+2)%3]-u[(i+2)%3]*v[(i+1)%3])**2 for i in range(3))**.5
    collapsed=[f for f in bm.faces if area64(f)<1e-10]
    if collapsed:bmesh.ops.delete(bm,geom=collapsed,context='FACES_ONLY')
    bm.to_mesh(o.data); bm.free()
triangles=sum(len(o.data.polygons) for o in joined)
assert 40000<=triangles<=110000,triangles
vs=[o.matrix_world@v.co for o in joined for v in o.data.vertices]
mn=[min(v[i] for v in vs) for i in range(3)]; mx=[max(v[i] for v in vs) for i in range(3)]
bbox={'min':[mn[0],mn[2],-mx[1]],'max':[mx[0],mx[2],-mn[1]],'dimensions':[mx[0]-mn[0],mx[2]-mn[2],mx[1]-mn[1]]}
nodes=[{'name':o.name,'world_position':[0,0,0]} for o in joined]
(ROOT/'nodes.json').write_text(json.dumps({'units':'metres','up':'+Y','bow':'+Z','origin':'waterline, middle of hull length','nodes':nodes,'overall_bounding_box':bbox,'construction_parts':parts},indent=2))
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.export_scene.gltf(filepath=str(ROOT/'harbour-rowboat.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_cameras=False,export_lights=False,export_extras=True)
size=(ROOT/'harbour-rowboat.glb').stat().st_size
assert size<=12*1024*1024,size
(ROOT/'NOTES.md').write_text(f'Realistic 3.69 m harbour clinker rowboat; fitted ribs, thwarts, floor, transom, keel, rowlocks and three-strand painter.\nTriangles: {triangles:,}; dimensions X/Y/Z: {bbox["dimensions"][0]:.3f}/{bbox["dimensions"][1]:.3f}/{bbox["dimensions"][2]:.3f} m; GLB {size/1048576:.2f} MiB.\nMaterials: 4 (weathered blue-grey paint, aged timber, oxidised iron, hemp); Cycles-baked base colour, tangent normal and ORM.\nTexture sizes: Paint/Timber 2048 square; Iron/Rope 1024 square; tiled grain UVs; four static draw groups.\nPoly Haven CC0 IDs: wood_peeling_paint_weathered, rough_wood, rusty_metal_03; https://polyhaven.com/license .\nDifferences: reconstructed wear and nail positions, three thwarts following plan view; no detached exterior timbers from the old asset.\n')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'harbour-rowboat.blend'))
print('BUILD_OK',triangles,size,bbox,flush=True)
