"""Extend the accepted kitchen scene into the complete loft.
Blender 4.5+: blender -b -t 8 -P build_loft.py
Optional: LOFT_RESOLUTION=1800 LOFT_SAMPLES=96 LOFT_RENDER=1 LOFT_SHOTS=01,02
Coordinates: x = plan north, y = plan west; south bay x < -1.81.
"""
import bpy, math, os, json
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent.parent
BASE=ROOT/'model/kitchen-base.blend'
if not BASE.exists():BASE=ROOT.parent/'kitchen/model/kitchen.blend'
bpy.ops.wm.open_mainfile(filepath=str(BASE))
scene=bpy.context.scene
cols={}
for key,name in [('shell','03_Loft_shell_and_parquet'),('south','05_South_lounge_OMHU'),('bed','06_Bedroom_and_dressing'),('bath','07_Bathroom'),('office','08_South_office'),('stairs','09_Stairs_and_services'),('roof','10_Roof_hide_for_cutaway'),('cameras','11_Loft_cameras_and_lights')]:
    c=bpy.data.collections.new(name);scene.collection.children.link(c);cols[key]=c
current='shell'
def reg(o,name,m=None):
    o.name=name
    for c in list(o.users_collection):c.objects.unlink(o)
    cols[current].objects.link(o)
    if m:o.data.materials.append(m)
    return o

def material(name,col,rough=.5,metal=0):
    m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
    m.diffuse_color=(*col,1);return m
white=bpy.data.materials['White painted walls | user specified'];oak=bpy.data.materials['Natural oak parquet | user specified, pattern assumed'];wood=bpy.data.materials['Dining table natural oak'];steel=bpy.data.materials['Welded bowl satin stainless'];black=bpy.data.materials['Soft black shadow gaps'];grout=bpy.data.materials['Fine parquet joints']
for m in bpy.data.materials:
    if m.use_nodes:
        p=m.node_tree.nodes.get('Principled BSDF')
        if p:m.diffuse_color=p.inputs['Base Color'].default_value
linen=material('Warm white washed linen',(.76,.72,.64),.91)
cream=material('Ivory woven rug',(.54,.49,.40),.96)
leather=material('Soft charcoal textile',(.055,.060,.062),.85)
ceramic=material('Glazed off-white ceramic',(.78,.77,.73),.16)
mirror=material('Polished mirror',(.93,.95,.98),.015,1)
chrome=material('OMHU chrome-plated steel',(.70,.74,.78),.13,1)
stone=material('Pale mineral shower tray',(.55,.52,.46),.67)
clearglass=material('Clear shower glass',(.98,.99,1),.06)
clearglass.node_tree.nodes['Principled BSDF'].inputs['Transmission Weight'].default_value=1
clearglass.node_tree.nodes['Principled BSDF'].inputs['IOR'].default_value=1.45
# Corduroy ribs are procedural; base and cushions use different orientations.
def cord(name,axis):
    m=material(name,(.21,.036,.012),.85);n=m.node_tree.nodes;l=m.node_tree.links;p=n.get('Principled BSDF');p.inputs['Sheen Weight'].default_value=.08
    tex=n.new('ShaderNodeTexCoord');wave=n.new('ShaderNodeTexWave');wave.wave_type='BANDS';wave.bands_direction=axis;wave.inputs['Scale'].default_value=82;wave.inputs['Distortion'].default_value=.18;wave.inputs['Detail Scale'].default_value=2
    l.new(tex.outputs['Generated'],wave.inputs['Vector']);b=n.new('ShaderNodeBump');b.inputs['Strength'].default_value=.27;b.inputs['Distance'].default_value=.0018;l.new(wave.outputs['Fac'],b.inputs['Height']);l.new(b.outputs[0],p.inputs['Normal'])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(.11,.012,.004,1);ramp.color_ramp.elements[1].color=(.27,.049,.015,1);l.new(wave.outputs['Fac'],ramp.inputs[0]);l.new(ramp.outputs[0],p.inputs['Base Color']);return m
rust_base=cord('OMHU Rust 5782 corduroy | horizontal mattress ribs','Z')
rust_back=cord('OMHU Rust 5782 corduroy | upright cushion ribs','Y')
for m in [linen,cream]:
    n=m.node_tree.nodes;l=m.node_tree.links;p=n.get('Principled BSDF');tex=n.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=320;tex.inputs['Detail'].default_value=2;b=n.new('ShaderNodeBump');b.inputs['Strength'].default_value=.22;b.inputs['Distance'].default_value=.0008;l.new(tex.outputs['Fac'],b.inputs['Height']);l.new(b.outputs[0],p.inputs['Normal'])

def box(name,loc,dim,m,bevel=.004,smooth=False):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=reg(bpy.context.object,name,m);o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    if bevel:
        mod=o.modifiers.new('Edge softness','BEVEL');mod.width=bevel;mod.segments=6 if smooth else 3
        o.modifiers.new('Surface normals','WEIGHTED_NORMAL')
    if smooth:
        for f in o.data.polygons:f.use_smooth=True
    return o

def tube(name,pts,r,m):
    c=bpy.data.curves.new(name,'CURVE');c.dimensions='3D';c.bevel_depth=r;c.bevel_resolution=4;s=c.splines.new('POLY');s.points.add(len(pts)-1)
    for p,v in zip(s.points,pts):p.co=(*v,1)
    o=bpy.data.objects.new(name,c);cols[current].objects.link(o);o.data.materials.append(m);return o

def cyl(name,loc,r,h,m,rot=None):
    bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=r,depth=h,location=loc);o=reg(bpy.context.object,name,m)
    if rot:o.rotation_euler=rot
    b=o.modifiers.new('Rounded cylinder edges','BEVEL');b.width=min(.006,h/5);b.segments=4;o.modifiers.new('Normals','WEIGHTED_NORMAL')
    for f in o.data.polygons:f.use_smooth=True
    return o

def mesh(name,vs,fs,m,bevel=0):
    data=bpy.data.meshes.new(name);data.from_pydata(vs,[],fs);data.update();o=bpy.data.objects.new(name,data);cols[current].objects.link(o);o.data.materials.append(m)
    if bevel:b=o.modifiers.new('Soft edges','BEVEL');b.width=bevel;b.segments=6;o.modifiers.new('Normals','WEIGHTED_NORMAL')
    return o

def extrude(name,points,z0,z1,m,bevel=0):
    n=len(points);vs=[(x,y,z) for z in [z0,z1] for x,y in points];fs=[tuple(reversed(range(n))),tuple(range(n,2*n))]
    fs += [(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    return mesh(name,vs,fs,m,bevel)

def remove_where(fn):
    for o in list(bpy.data.objects):
        if fn(o):bpy.data.objects.remove(o,do_unlink=True)
# Keep the quoted kitchen and dining furniture unchanged. Replace only provisional shell details.
remove_where(lambda o:o.name.startswith(('Oak parquet plank','Oak parquet floor foundation','Bathroom enclosure beyond','East wall low white storage')))
for o in list(bpy.data.objects):
    if o.name.startswith('White pitched ceiling') or 'triangular gable' in o.name or 'upper wall below opening' in o.name or 'upper left triangle' in o.name or 'upper right triangle' in o.name or 'upper head triangle' in o.name or 'high window' in o.name:
        for c in list(o.users_collection):c.objects.unlink(o)
        cols['roof'].objects.link(o)
# Repair the provisional south gable triangle with a single watertight panel.
remove_where(lambda o:o.name.startswith(('South gable upper wall','South gable upper left','South gable upper right','South gable upper head')))
current='roof'
vs=[(xx,yy,zz) for xx in [-4.91,-4.71] for yy,zz in [(-7.02,2.17),(.45,2.17),(-3.285,5.1)]]
g=mesh('South gable solid upper wall',vs,[(0,2,1),(3,4,5),(0,1,4,3),(1,2,5,4),(2,0,3,5)],white)
cut=box('Upper window opening cutter',(-4.81,-3.285,3.03),(.40,1.30,1.30),None,0)
bpy.context.view_layer.objects.active=g;mod=g.modifiers.new('Upper square window','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
# Quiet overcast sky outside the end windows, with no invented neighboring buildings.
current='cameras';sky=material('Soft exterior sky',(.76,.82,.89),1)
p=sky.node_tree.nodes['Principled BSDF'];p.inputs['Emission Color'].default_value=(.76,.82,.89,1);p.inputs['Emission Strength'].default_value=.65
for xx in [-12,12]:
    o=box('Exterior daylight backdrop',(xx,-3.285,5),(.05,24,18),sky,0)
    # Exterior backdrop is visible to the camera only, and absent from portable geometry.
    o.visible_shadow=False;o.visible_diffuse=False;o.visible_glossy=False
current='shell'
# Floor has an actual opening over the stair, rather than overlapping slabs.
XMIN=-4.71;XMAX=5.04;YMIN=-7.02;YMAX=.45
hole=(-4.64,-1.81,-.57,.45)
box('Main floor slab clear of stair opening',((XMIN+XMAX)/2,(YMIN-.57)/2,-.116),(XMAX-XMIN,-.57-YMIN,.20),white)
box('North and middle west floor slab',((XMAX-1.81)/2,(-.57+YMAX)/2,-.116),(XMAX+1.81,YMAX+.57,.20),white)
for row in range(43):
    yl=YMIN+row*.18;yh=min(yl+.18,YMAX)
    if yh<=yl:continue
    cuts=[yl,yh]
    if yl<-.57<yh:cuts.insert(1,-.57)
    for ya,yb in zip(cuts[:-1],cuts[1:]):
        for ix in range(-5,6):
            xl=max(XMIN,ix*1.20+(row%3)*.40);xh=min(XMAX,ix*1.20+(row%3)*.40+1.20)
            if ya>=-.57001 and xl<-1.81:xl=max(xl,-1.81)
            if xh>xl+.004:box('Oak parquet | finished floor',((xl+xh)/2,(ya+yb)/2,-.007), (xh-xl-.0015,yb-ya-.0015,.014),oak,.0005)
# Opposite structural returns shown in the plan.
for x,w in [(-1.63,.36),(1.60,.30)]:
    y0=YMIN;y1=YMIN+.94;z0=2.17;z1=2.17+.94/3.735*(5.1-2.17)
    vs=[(x-w/2,y0,0),(x+w/2,y0,0),(x+w/2,y1,0),(x-w/2,y1,0),(x-w/2,y0,z0),(x+w/2,y0,z0),(x+w/2,y1,z1),(x-w/2,y1,z1)]
    mesh('East structural return',vs,[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],white)
# Refined middle storage, leaving this bay free of a sofa.
for i in range(5):
    xx=-1.435+(i+.5)*.574
    box('Middle east storage carcass',(xx,-6.755,.48),(.572,.53,.96),white)
    box('Middle east storage door',(xx,-6.478,.49),(.568,.018,.92),white,.002)
    tube('Middle storage subtle pull',[(xx-.08,-6.46,.88),(xx+.08,-6.46,.88)],.003,steel)
box('Middle east storage oak top',(0,-6.73,.973),(2.87,.58,.026),wood,.005)
# Minimal skirting on eaves walls, with interruptions at openings and fitted joinery.
box('West skirting north',(3.4,.428,.045),(3.28,.035,.09),white,.002)
box('East white skirting',(.165,-6.998,.045),(9.75,.035,.09),white,.002)

# SOUTH LOUNGE. Original TEDDY, 200 x 100 x 70 cm, as requested.
current='south';Sx=-3.60;Sy=-3.34
for z in [.085,.255]:box('OMHU TEDDY stacked mattress',(Sx,Sy,z),(1.0,2.0,.17),rust_base,.045,True)
# Two characteristic continuous L-shaped corner cushions, each an arm plus half back.
for side in [-1,1]:
    # Width u maps onto world y; depth v maps onto world x. Front faces plan north.
    if side==-1:uv=[(-1,-.50),(0,-.50),(0,-.15),(-.65,-.15),(-.65,.50),(-1,.50)]
    else:uv=[(0,-.50),(1,-.50),(1,.50),(.65,.50),(.65,-.15),(0,-.15)]
    o=extrude('OMHU TEDDY continuous L corner cushion',[(Sx+v,Sy+u) for u,v in uv],.34,.70,rust_back,.115)
    for f in o.data.polygons:f.use_smooth=True
# Visible side chrome support hoops with gently rounded corners.
def rounded_rect_2d(a,b,c,d,r):
    pts=[]
    for x,y,start in [(b-r,d-r,0),(a+r,d-r,90),(a+r,c+r,180),(b-r,c+r,270)]:
        for k in range(9):
            th=math.radians(start+k*90/8);pts.append((x+r*math.cos(th),y+r*math.sin(th)))
    pts.append(pts[0]);return pts
for yy in [Sy-1.012,Sy+1.012]:
    ps=rounded_rect_2d(Sx-.40,Sx+.22,.016,.55,.065)
    tube('OMHU chrome side support',[(xx,yy,zz) for xx,zz in ps],.011,chrome)
for yy in [Sy-.5,Sy+.5]:
    ps=rounded_rect_2d(yy-.30,yy+.30,.016,.55,.065)
    tube('OMHU chrome rear support',[(Sx-.514,v,z) for v,z in ps],.011,chrome)
# Clockwise as seen from above: plan north (+x) rotates to plan east (-y).
# A 400 mm northward shift keeps the newly long north/south dimension clear of the gable.
sofa_rotation=Matrix.Translation((Sx,Sy,0)) @ Matrix.Rotation(-math.pi/2,4,'Z') @ Matrix.Translation((-Sx,-Sy,0))
for o in cols['south'].objects:
    if o.name.startswith('OMHU'):o.matrix_world=sofa_rotation @ o.matrix_world
box('South lounge wool rug',(-3.50,-3.40,.006),(2.28,3.10,.012),cream,.012)
cyl('South lounge round oak coffee table',(-3.50,-4.52,.34),.41,.045,wood)
for angle in [30,150,270]:
    a=math.radians(angle);cyl('Coffee table leg',(-3.50+.26*math.cos(a),-4.52+.26*math.sin(a),.16),.025,.32,wood)
# Sofa sits on rug; lift upholstery and supports by rug thickness.
for o in cols['south'].objects:
    if o.name.startswith('OMHU'):o.location.z+=.014

# NORTH BEDROOM, open to the loft as drawn; storage divides off the rear dressing aisle.
current='bed';Bx=3.18;By=-4.86
box('Bedroom oak platform bed',(Bx,By,.16),(1.90,2.08,.24),wood,.035)
box('Bedroom mattress',(Bx,By,.345),(1.80,2.00,.25),linen,.085,True)
box('Bedroom white linen duvet',(Bx,By+.04,.50),(1.83,1.88,.075),linen,.058,True)
throw=material('Bed cover warm flax',(.43,.35,.25),.92)
box('Folded flax bed cover',(Bx,By+.70,.548),(1.84,.47,.035),throw,.035,True)
for xx in [Bx-.45,Bx+.45]:
    o=box('Linen sleeping pillow',(xx,By-.68,.57),(.72,.43,.13),linen,.10,True);o.rotation_euler.z=.035 if xx<Bx else -.03
# Headboard/dressing cabinet shown immediately behind the bed.
box('Oak headboard front',(Bx,-5.94,1.01),(2.04,.06,2.02),wood,.009)
for xx in [Bx-1.00,Bx+1.00]:box('Dressing cabinet side',(xx,-6.11,1.025),(.035,.39,2.05),wood)
for zz in [.04,1.04,2.03]:box('Dressing cabinet shelf',(Bx,-6.11,zz),(2.04,.39,.035),wood)
box('Dressing cabinet central divider',(Bx,-6.11,1.04),(.025,.39,2.0),wood)
# Back of cabinet faces the 0.7 m dressing aisle; alternate doors and an open hanging bay.
box('Dressing left wardrobe rear door',(Bx-.50,-6.314,1.04),(.985,.020,1.94),white,.003)
tube('Dressing wardrobe pull',[(Bx-.10,-6.33,.91),(Bx-.10,-6.33,1.21)],.006,steel)
tube('Dressing clothes rail',[(Bx+.04,-6.12,1.86),(Bx+.96,-6.12,1.86)],.013,chrome)
for i,xx in enumerate([Bx+.20,Bx+.38,Bx+.57,Bx+.77]):
    garment=linen if i%2 else throw
    box('Dressing hanging garment',(xx,-6.12,1.56),(.11,.32,.52),garment,.035,True)
# Bedside shelves and reading lamps fit beside the 1800 mm bed.
for xx in [Bx-1.15,Bx+1.15]:
    box('Oak bedside floating shelf',(xx,-5.66,.53),(.26,.31,.035),wood,.006)
    lamp_x=Bx+(.82 if xx>Bx else -.82)
    cyl('Bedside reading light mount',(lamp_x,-5.89,1.10),.045,.025,black,(math.pi/2,0,0))
    tube('Bedside reading light stem',[(lamp_x,-5.87,1.10),(lamp_x,-5.69,1.00)],.008,black)
    cyl('Reading light head',(lamp_x,-5.67,.98),.027,.07,black)
# North-east service/chimney projection present on the plan.
box('North east existing service projection',(4.72,-6.72,1.08),(.62,.60,2.16),white,.002)

# NARROW WEST BATHROOM: 800 mm clear, shower at north end, basin midway, WC south.
current='bath';BH=2.08
# Side partition retains the 800 mm door gap drawn on the plan.
for lo,hi in [(1.75,2.77),(3.57,4.89)]:box('Bathroom white partition',((lo+hi)/2,-.40,BH/2),(hi-lo,.10,BH),white,.002)
box('Bathroom door header',(3.17,-.40,2.045),(.80,.10,.09),white,.002)
for xx in [2.77,3.57]:box('Bathroom door jamb',(xx,-.465,1.0),(.028,.035,2.0),white,.002)
box('Bathroom white ceiling lid',(3.32,.025,2.115),(3.14,.85,.07),white,.002)
# Pocket-style open door leaf within partition; opening remains visually clear.
box('Bathroom open sliding door',(3.98,-.466,1.0),(.79,.028,1.99),white,.002)
box('Shower tray',(4.29,.05,.025),(1.20,.80,.05),stone,.015)
box('Shower glass screen',(3.69,.05,1.05),(.008,.80,2.0),clearglass,.002)
tube('Shower screen top rail',[(3.69,-.35,2.05),(3.69,.45,2.05)],.007,chrome)
tube('Shower mixer riser',[(4.48,.416,.97),(4.48,.416,1.96),(4.48,.16,1.96)],.011,chrome)
cyl('Rain shower head',(4.48,.13,1.94),.09,.018,chrome)
cyl('Shower mixer',(4.48,.413,1.04),.04,.03,chrome,(math.pi/2,0,0))
box('Shower linear drain',(4.72,.05,.052),(.02,.60,.006),steel,.001)
# A 500 mm vanity mounted on the west wall, within the plan's narrow footprint.
box('Bathroom oak vanity',(3.12,.22,.59),(.50,.44,.38),wood,.01)
box('Vanity white countertop',(3.12,.20,.797),(.51,.46,.024),ceramic,.01)
# Basin hollowed with a true boolean recess.
basin=box('Vanity inset basin',(3.12,.16,.827),(.38,.30,.08),ceramic,.035)
cut=box('Basin cutter',(3.12,.135,.868),(.31,.235,.095),None,.032)
for mod in list(cut.modifiers):bpy.context.view_layer.objects.active=cut;bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.context.view_layer.objects.active=basin
for mod in list(basin.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
b=basin.modifiers.new('Basin recess','BOOLEAN');b.operation='DIFFERENCE';b.object=cut;bpy.ops.object.modifier_apply(modifier=b.name);bpy.data.objects.remove(cut,do_unlink=True)
tube('Vanity mixer',[(3.12,.363,.82),(3.12,.363,.96),(3.12,.25,.96)],.010,chrome)
box('Bathroom mirror',(3.12,.432,1.40),(.49,.013,.70),mirror,.025)
# Wall hung toilet: rounded pan and seat, cistern concealed in wall.
box('Concealed WC cistern panel',(2.14,.419,.59),(.58,.045,1.18),white,.007)
pan=box('Wall hung toilet pan',(2.14,.115,.35),(.37,.57,.26),ceramic,.12,True)
seat=box('Toilet seat',(2.14,.06,.493),(.37,.47,.048),ceramic,.11,True)
box('Flush plate',(2.14,.389,.95),(.18,.009,.12),steel,.012)
# Minimal towel bar beside the basin.
tube('Bathroom towel rail',[(2.60,.39,1.12),(2.91,.39,1.12)],.007,chrome)
box('Linen hand towel',(2.74,.36,.95),(.22,.035,.32),linen,.012)

# SOUTH OFFICE as drawn on the east wall; lounge remains separate from middle bay.
current='office'
box('South wall oak desk',(-3.26,-6.72,.753),(2.85,.60,.036),wood,.008)
for xx in [-4.43,-2.09]:box('Desk white side support',(xx,-6.73,.36),(.035,.51,.72),white,.004)
for xx in [-3.98,-2.58]:
    # Two understated task chairs.
    box('Office chair upholstered seat',(xx,-6.10,.465),(.47,.45,.075),leather,.055,True)
    box('Office chair back',(xx,-5.90,.75),(.47,.065,.45),leather,.055,True)
    cyl('Office chair column',(xx,-6.10,.23),.025,.44,chrome)
    for a in range(0,360,72):
        r=math.radians(a);tube('Office chair star base',[(xx,-6.1,.09),(xx+.29*math.cos(r),-6.1+.29*math.sin(r),.04)],.014,chrome)
# One monitor and restrained desk objects, purely illustrative.
box('Desk monitor',( -2.68,-6.90,1.105),(.68,.035,.41),black,.012)
box('Monitor screen',(-2.68,-6.877,1.105),(.64,.003,.37),leather,.004)
box('Monitor pedestal',(-2.68,-6.81,.795),(.23,.17,.025),steel,.004)
box('Monitor stem',(-2.68,-6.89,.86),(.04,.04,.18),black)
box('Desk keyboard',(-2.68,-6.57,.784),(.42,.15,.018),black,.009)
box('Closed notebook',(-3.68,-6.56,.786),(.24,.31,.025),linen,.004)
# Under-desk LED strip is shown on the electrical plan.
emit=material('Warm under-desk LED',(1,.68,.36),.35)
p=emit.node_tree.nodes['Principled BSDF'];p.inputs['Emission Color'].default_value=(1,.72,.42,1);p.inputs['Emission Strength'].default_value=3
box('Under-desk LED strip',(-3.26,-6.63,.728),(2.7,.012,.009),emit,.002)

# SOUTH-WEST STAIR OPENING AND SCHEMATIC WINDERS.
current='stairs'
# The overall footprint, seventeen risers and 177 mm rise follow the plan.
# Individual winder profiles are a modeled interpretation, not a shop drawing.
# First three lower winders turn into the straight flight.
pivot=(-4.18,-.04)
for i in range(3):
    a0=math.radians(-90+i*30);a1=math.radians(-90+(i+1)*30)
    pts=[pivot,(pivot[0]+.50*math.cos(a0),pivot[1]+.50*math.sin(a0)),(pivot[0]+.50*math.cos(a1),pivot[1]+.50*math.sin(a1))]
    z=-3.009+.177*(i+1);extrude('Lower oak stair winder',pts,z-.045,z,wood,.002)
# Eleven straight treads; footprint from the southern turn to the upper return.
for i in range(11):
    xx=-4.22+i*.20;z=-3.009+.177*(i+4)
    box('Oak stair tread 20 cm going',(xx,-.05,z-.0225),(.205,.89,.045),wood,.004)
    box('White stair riser',(xx-.10,-.05,z-.09),(.025,.89,.177),white,.001)
# Three top fan treads turn east into the loft's landing.
pivot=(-1.94,.34)
for i in range(3):
    a0=math.radians(180+i*30);a1=math.radians(180+(i+1)*30)
    pts=[pivot,(pivot[0]+.85*math.cos(a0),pivot[1]+.85*math.sin(a0)),(pivot[0]+.85*math.cos(a1),pivot[1]+.85*math.sin(a1))]
    z=-3.009+.177*(i+15);extrude('Upper oak stair winder',pts,z-.045,z,wood,.002)
# Light balustrade along open side, with a 900 mm arrival gap at the north end.
for i in range(17):
    xx=-4.60+i*.115
    tube('White stair guard spindle',[(xx,-.60,.0),(xx,-.60,1.0)],.008,white)
tube('Oak stair guard handrail',[(-4.62,-.60,1.02),(-2.72,-.60,1.02)],.023,wood)
# Top opening and landing edge are separately visible for editing.
box('Stair landing edge',(-1.76,-.04,-.075),(.10,1.02,.15),wood,.002)
# Boiler placement follows south-west services symbol; installation is schematic below floor.
cyl('Boiler below loft stair landing',(-4.30,.07,-1.50),.26,1.35,ceramic)

# RENDER SETUP. Keep kitchen cameras and add eight full-loft compositions.
current='cameras'
def area(name,loc,energy,color,size,target,sy=None):
    d=bpy.data.lights.new(name,'AREA');d.energy=energy;d.color=color;d.shape='RECTANGLE';d.size=size;d.size_y=sy or size;o=bpy.data.objects.new(name,d);cols[current].objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();return o
# Soft photographic fill supplements the actual gable-window daylight.
area('South lounge soft fill',(-1.6,-3.25,3.9),350,(1,.94,.86),3,(-4,-3.3,.5),2)
area('Bedroom soft fill',(2,-3.1,3.8),300,(1,.96,.91),2.4,(3.3,-5.4,.7),2)
area('Bathroom diffuse ceiling light',(3.36,.06,2.035),40,(1,.95,.86),1.6,(3.36,.06,.2),.4)
shots=[
 ('01_south_lounge',(-.85,-5.85,1.85),(-3.62,-3.30,1.12),22),
 ('02_omhu_rust_sofa',(-2.25,-5.15,1.30),(-3.60,-3.34,.48),36),
 ('03_loft_toward_bedroom',(-4.23,-1.65,1.70),(.75,-3.55,1.30),23),
 ('04_bedroom',(1.18,-2.3,1.62),(3.25,-5.20,1.00),29),
 ('05_south_workspace',(-1.98,-4.40,1.62),(-3.30,-6.59,1.00),28),
 ('06_bathroom',(2.04,-.10,1.53),(4.15,.08,1.02),20),
 ('07_dressing_aisle',(4.31,-6.77,1.65),(3.12,-6.20,1.15),21),
 ('08_floorplan_cutaway',(-10.5,-13.0,14.0),(.15,-3.25,0),48),
]
cam=[]
for name,pos,target,lens in shots:
    d=bpy.data.cameras.new(name);o=bpy.data.objects.new(name,d);cols[current].objects.link(o);o.location=pos;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();d.lens=lens;d.clip_start=.025;d.clip_end=100
    if name.startswith('08'):d.type='ORTHO';d.ortho_scale=16.4
    cam.append(o)
scene.camera=cam[0]
scene.cycles.samples=int(os.getenv('LOFT_SAMPLES','64'));scene.cycles.use_denoising=True
scene.render.resolution_x=int(os.getenv('LOFT_RESOLUTION','1536'));scene.render.resolution_y=int(scene.render.resolution_x*2/3);scene.render.resolution_percentage=100
scene.view_settings.exposure=-.85
scene.render.image_settings.file_format='PNG';scene.render.image_settings.color_mode='RGB'
# Enable Apple Metal when available in this standalone Blender process.
try:
    prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='METAL';prefs.get_devices()
    if any(d.type=='METAL' for d in prefs.devices):
        for d in prefs.devices:d.use=d.type=='METAL'
        scene.cycles.device='GPU'
except Exception:pass
# Correct version record, without changing the original kitchen scene file.
record={
 'source_plan':'ELEK plan 2026 03 20b.pdf, pages 1-2',
 'source_kitchen':'Lucas Coupez - 112639011_22510200900.pdf',
 'user_directions':['white walls','parquet floor','original OMHU TEDDY 200 x 100 cm in Rust 5782','sofa in SOUTH section, no sofa in middle section','sofa rotated 90 degrees clockwise in plan, facing east'],
 'sofa_source':'https://omhucph.com/products/teddy?variant=41248910508141',
 'sofa_dimensions_mm':[2000,1000,700], 'sofa_seat_height_mm':340,
 'south_bay_x_range_m':[-4.71,-1.81], 'sofa_center_xy_m':[Sx,Sy], 'sofa_rotation_clockwise_degrees':90, 'sofa_facing':'east',
 'room_envelope_m':[9.75,7.47], 'finished_floor_eaves_estimate_m':2.17,'ridge_estimate_m':5.10,
 'modeled_zones':['kitchen','dining','middle storage','south lounge','south office','stairs and boiler','north bedroom','dressing aisle','west bathroom with shower basin and toilet'],
 'assumptions':['Approximate architecture inferred from the electrical plan and section; floor level is marked to be determined.','Window locations use floor plan; heights use section.','Existing kitchen dimensions and original assumptions are preserved.','Non-sofa furniture, bed linen, lighting fixtures, bathroom fittings, joinery details and stair winder geometry are illustrative.','Bathroom pocket-door interpretation preserves the plan opening, though door mechanism is not specified.','Sofa geometry is hand modeled from official photos and dimensions, not an official manufacturer CAD asset.','The south service area is schematic below floor; this is a visualization, not a construction model.']}
(ROOT/'model/specification.json').write_text(json.dumps(record,indent=2)+'\n')
t=bpy.data.texts.new('FULL LOFT - sources and assumptions');t.write(json.dumps(record,indent=2))
# Save a clean, useful camera view in Blender; nothing is left selected.
bpy.ops.object.select_all(action='DESELECT')
for screen in bpy.data.screens:
    for a in screen.areas:
        if a.type=='VIEW_3D':
            a.spaces.active.region_3d.view_perspective='CAMERA';a.spaces.active.overlay.show_overlays=False;a.spaces.active.shading.type='MATERIAL'
# Bake curves into mesh in an export-only duplicate of the scene to preserve editable curves in .blend.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'model/loft.blend'))
# Render camera references. Cutaway removes roof, exterior walls and tall bathroom screen for visibility.
if os.getenv('LOFT_RENDER','1')=='1':
    selected=os.getenv('LOFT_SHOTS','')
    for c in cam:
        if selected and c.name[:2] not in selected.split(','):continue
        hidden=[];power_changes=[]
        if c.name.startswith('08'):
            for o in scene.objects:
                if o in cols['roof'].objects[:] or o.name.startswith(('West eaves wall','East eaves wall','South gable','North gable','Bathroom white partition','Bathroom door header','Bathroom open sliding','Bathroom door jamb','Bathroom white ceiling','Exterior daylight backdrop')):
                    if not o.hide_render:o.hide_render=True;hidden.append(o)
            # Extra overhead soft light only for the explanatory cutaway.
            light=area('Cutaway overhead',(0,-3,11),3000,(1,.98,.95),8,(0,-3,0),8)
        scene.camera=c;scene.render.filepath=str(ROOT/'reference-views'/f'{c.name}.png');bpy.ops.render.render(write_still=True)
        for o in hidden:o.hide_render=False
        if c.name.startswith('08'):bpy.data.objects.remove(light,do_unlink=True)
scene.camera=cam[0];bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'model/loft.blend'))
# Portable export of complete geometry; remove only lights/cameras, keep materials.
bpy.ops.object.select_all(action='DESELECT')
for o in list(scene.objects):
    if o.type in {'MESH','CURVE'} and not o.name.startswith('Exterior daylight backdrop'):o.select_set(True)
for o in list(bpy.context.selected_objects):
    if o.type=='CURVE':
        dg=bpy.context.evaluated_depsgraph_get();m=bpy.data.meshes.new_from_object(o.evaluated_get(dg),depsgraph=dg);n=bpy.data.objects.new(o.name,m)
        for col in o.users_collection:col.objects.link(n)
        n.matrix_world=o.matrix_world.copy();n.select_set(True);bpy.data.objects.remove(o,do_unlink=True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'model/loft.glb'),export_format='GLB',use_selection=True,export_apply=True)
print('LOFT_COMPLETE',ROOT,flush=True)
