"""Breton reusable atoms. Blender 5.2, deterministic, no external Python packages.
Run on gex44: blender -b --python-exit-code 1 --python /root/fal-author/breton-atom-kit-v1/build_kit.py
Reads the earlier house textures/ and baked/ without modifying them. All delivery outputs
are rebuilt in this script's directory. -- --preflight exercises a tiny render/export/import.
Authored coordinates are already glTF Y-up; exporter axis conversion is intentionally OFF.
"""
import bpy, bmesh, math, json, sys, struct, hashlib, time
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent
SOURCE=Path('/root/fal-author/breton-stone-house-v1')
START=time.time()
NAMES=['Granite_lime_moss','Dressed_granite','Blue_grey_slate','Breton_blue_timber','Aged_ivory_frames','Weathered_zinc_iron','Old_window_glass','Terracotta','Granite_lime_moss_base']
TILES=[2.25,1.6,3.57,1.0,1.0,1.0,1.0,1.0,2.25]
LIMITS={'slate_a':40,'slate_b':40,'slate_c':40,'ridge_tile':120,'quoin_long':150,'quoin_short':150,'window_ground':3000,'window_ground_shutters':3000,'window_upper':3000,'window_upper_shutters':3000,'window_small':3000,'door':4000,'dormer':8000,'chimney_stack':2500,'gutter_1m':250,'downpipe_1m':150,'eaves_board_1m':60,'verge_board_1m':60,'lantern':600}
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=48;scene.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'
 scene.cycles.device='GPU'
except Exception:scene.cycles.device='CPU'
scene.unit_settings.system='METRIC';scene.render.image_settings.file_format='PNG';scene.view_settings.view_transform='AgX'
PROVENANCE={};seams={}
def read_image(path,size,color=False):
 path=Path(path);PROVENANCE[str(path)]=hashlib.sha256(path.read_bytes()).hexdigest()
 im=bpy.data.images.load(str(path),check_existing=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 im.scale(size,size);a=np.array(im.pixels[:],dtype=np.float32).reshape(size,size,4)[:,:,:3].copy();bpy.data.images.remove(im);return a

def periodic(a):
 """Moisan periodic-plus-smooth decomposition: remove only nonperiodic low frequencies.
 Then match the outer texels exactly with a narrow cubic correction (no reflected tiles).
 """
 h,w,c=a.shape;b=np.zeros_like(a)
 b[0,:,:]=a[-1,:,:]-a[0,:,:];b[-1,:,:]=-b[0,:,:]
 b[:,0,:]+=a[:,-1,:]-a[:,0,:];b[:,-1,:]-=a[:,-1,:]-a[:,0,:]
 yy,xx=np.meshgrid(np.arange(h),np.arange(w),indexing='ij')
 den=2*np.cos(2*np.pi*xx/w)+2*np.cos(2*np.pi*yy/h)-4;den[0,0]=1
 f=np.fft.fft2(b,axes=(0,1))/den[:,:,None];f[0,0,:]=0
 out=a-np.fft.ifft2(f,axes=(0,1)).real
 for axis in (0,1):
  t=np.swapaxes(out,0,axis);delta=(t[-1]-t[0])*.5
  for i in range(4):
   weight=(1-i/4)**2*(1+2*i/4);t[i]+=delta*weight;t[-1-i]-=delta*weight
 return np.clip(out,0,1).astype(np.float32)

def save_image(path,a,color=False,grey=False):
 path.parent.mkdir(parents=True,exist_ok=True);h,w=a.shape[:2]
 if a.ndim==2:a=np.repeat(a[:,:,None],3,axis=2)
 im=bpy.data.images.new(path.stem,width=w,height=h,alpha=False,float_buffer=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 rgba=np.concatenate((a,np.ones((h,w,1),np.float32)),axis=2).astype(np.float32)
 im.pixels.foreach_set(rgba.ravel());im.filepath_raw=str(path);im.file_format='PNG';im.save();bpy.data.images.remove(im)
 # PNG grayscale encoding for the height contract, using standard library only.
 if grey:
  import zlib
  rows=(np.clip(a[:,:,0],0,1)*65535+.5).astype('>u2')[::-1]
  def chunk(tag,data):return struct.pack('>I',len(data))+tag+data+struct.pack('>I',zlib.crc32(tag+data)&0xffffffff)
  raw=b''.join(b'\0'+row.tobytes() for row in rows)
  path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,16,0,0,0,0))+chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b''))
 seams[str(path.relative_to(ROOT))]={'edge_max_abs':float(max(abs(a[0]-a[-1]).max(),abs(a[:,0]-a[:,-1]).max())),'size':[w,h]}

if '--preflight' in sys.argv:
 bpy.ops.mesh.primitive_cube_add();probe=bpy.context.object
 pm=bpy.data.materials.new('PREFLIGHT');pm.use_nodes=True;probe.data.materials.append(pm)
 bake=bpy.data.images.new('probe',width=16,height=16);tex=pm.node_tree.nodes.new('ShaderNodeTexImage');tex.image=bake;pm.node_tree.nodes.active=tex
 scene.cycles.samples=1;bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True,export_yup=False)
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False);bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
 bpy.ops.object.camera_add(location=(4,-5,3));scene.camera=bpy.context.object;scene.camera.rotation_euler=(-scene.camera.location).to_track_quat('-Z','Y').to_euler()
 scene.world.color=(.5,.5,.5);scene.render.resolution_x=64;scene.render.resolution_y=64;scene.render.resolution_percentage=100;scene.render.filepath=str(ROOT/'preflight.png');bpy.ops.render.render(write_still=True)
 print('PREFLIGHT_OK',flush=True);sys.exit(0)

