bl_info = {'name': 'ECG Studio volume I/O', 'author': 'ECG Studio', 'version': (0, 3, 0), 'blender': (4, 2, 0), 'location': 'File > Import / Export', 'category': 'Import-Export'}
import bpy, bmesh, json, math, itertools
from mathutils import Vector
from mathutils.bvhtree import BVHTree
from mathutils.kdtree import KDTree
from bpy_extras.io_utils import ImportHelper, ExportHelper
from bpy.props import StringProperty, FloatProperty

REGIONS = {1: 'LV', 2: 'RV', 3: 'LA', 4: 'RA'}
LANDMARKS = {'sa': 4, 'av': 4, 'leftBundle': 1, 'rightBundle': 2, 'bachmannRA': 4, 'bachmannLA': 3}
def model_point(p, scale=1): return Vector((p.x*scale, p.z*scale, -p.y*scale))
def blender_point(p): return (p[0], -p[2], p[1])

def import_asset(filepath):
    with open(filepath) as f: d=json.load(f)
    if d.get('schemaVersion')!=1: raise ValueError('Use an ECG Studio schemaVersion 1 file, not the old shell export.')
    # Import into a new collection; existing objects are retained.
    collection=bpy.data.collections.new('ECG Studio - '+d.get('name','model'));bpy.context.scene.collection.children.link(collection)
    collection['ecg_source']=json.dumps(d.get('source',{}))
    bpy.context.scene.unit_settings.system='METRIC';bpy.context.scene.unit_settings.scale_length=.001
    bpy.context.scene.unit_settings.length_unit='MILLIMETERS'
    for s in d['surfaces']:
        mesh=bpy.data.meshes.new(s.get('name','Myocardium'))
        mesh.from_pydata([blender_point(s['positions'][i:i+3]) for i in range(0,len(s['positions']),3)],[],[s['indices'][i:i+3] for i in range(0,len(s['indices']),3)]);mesh.update()
        obj=bpy.data.objects.new('ECG_'+REGIONS[s['region']],mesh);collection.objects.link(obj);obj['ecg_region']=s['region'];obj['ecg_kind']=d.get('simulation',{}).get('kind','heart')
        endo=obj.vertex_groups.new(name='ECG_ENDO');epi=obj.vertex_groups.new(name='ECG_EPI')
        fiber=mesh.attributes.new(name='ecg_fiber',type='FLOAT_VECTOR',domain='POINT')
        for i in range(len(mesh.vertices)):
            ids=s['nodes'][3*i:3*i+3];weights=s['weights'][3*i:3*i+3]
            rho=sum(d['rho'][n]*w for n,w in zip(ids,weights))
            if rho<.4: endo.add([i],1,'REPLACE')
            if rho>.6: epi.add([i],1,'REPLACE')
            f=[sum(d['fibers'][3*n+k]*w for n,w in zip(ids,weights)) for k in range(3)]
            fiber.data[i].vector=blender_point(f)
        obj.select_set(True)
        if obj['ecg_kind']=='tissue':
            bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-5);bm.to_mesh(mesh);bm.free();mesh.update()
    landmarks=d.get('simulation',{}).get('landmarks',{})
    # Atlas heuristics can be supplied by the companion starter-generation script.
    for key,node in landmarks.items():
        obj=bpy.data.objects.new('ECG_'+key,None);collection.objects.link(obj);obj.empty_display_type='SPHERE';obj.empty_display_size=2;obj.location=blender_point(d['positions'][3*node:3*node+3]);obj['ecg_landmark']=key
    return collection

def export_asset(filepath, step=2.0):
    if not math.isfinite(step) or not 1 <= step <= 10: raise ValueError('Voxel size must be between 1 and 10 mm.')
    scale=bpy.context.scene.unit_settings.scale_length*1000
    objects=[o for o in bpy.context.selected_objects if o.type=='MESH' and 'ecg_region' in o]
    if not objects: raise ValueError('Select the tissue meshes and set their ecg_region property (1 LV, 2 RV, 3 LA, 4 RA).')
    regions={int(o['ecg_region']) for o in objects};kind='heart' if regions=={1,2,3,4} else 'tissue'
    if kind=='tissue' and len(objects)!=1: raise ValueError('A teaching tissue export must contain one mesh; a heart needs all four regions.')
    if len(objects)!=len(regions): raise ValueError('Use exactly one mesh object per chamber; join its components first.')
    if any(o.modifiers for o in objects): raise ValueError('Apply mesh modifiers on a copy before export; unapplied modifiers are rejected to protect boundary and fiber labels.')
    surfaces=[];bodies={};source={'description':'Blender volume resampling; experimental geometry', 'insideTest':'Majority of three oriented first-ray crossings on closed boundary', 'gridMm':step,'subsamplesPerCell':8,'fiberField':'Surface ecg_fiber attribute, nearest vertex; uniform fallback for teaching tissue'}
    source['provenance']=[json.loads(c['ecg_source']) for c in bpy.data.collections if 'ecg_source' in c and any(o in c.objects.values() for o in objects)]
    for obj in objects:
        r=int(obj['ecg_region'])
        if r not in REGIONS: raise ValueError('ecg_region must be 1, 2, 3 or 4.')
        mesh=obj.data;mesh.calc_loop_triangles()
        vs=[model_point(obj.matrix_world@v.co,scale) for v in mesh.vertices];tris=[tuple(t.vertices) for t in mesh.loop_triangles]
        if len(tris)>150000: raise ValueError('Reduce the selected display mesh to at most 150,000 triangles per chamber.')
        counts={};directions={}
        for tri in tris:
            for a,b in zip(tri,tri[1:]+tri[:1]):
                edge=tuple(sorted((a,b)));counts[edge]=counts.get(edge,0)+1;directions[edge]=directions.get(edge,0)+(1 if a<b else -1)
        if any(v!=2 for v in counts.values()) or any(directions.values()): raise ValueError(obj.name+': tissue boundary must be closed, manifold and consistently oriented. Close the wall rim, not the blood cavity.')
        signed=sum(vs[a].dot(vs[b].cross(vs[c]))/6 for a,b,c in tris)
        if signed<=0: raise ValueError(obj.name+': normals point inward or tissue volume is non-positive. Outer normals face out; cavity normals face the blood.')
        tree=BVHTree.FromPolygons(vs,tris,all_triangles=True)
        def group_tree(name):
            group=obj.vertex_groups.get(name)
            if group is None:return None
            ids={v.index for v in mesh.vertices if any(g.group==group.index and g.weight>.5 for g in v.groups)}
            faces=[t for t in tris if all(i in ids for i in t)]
            return BVHTree.FromPolygons(vs,faces,all_triangles=True) if faces else None
        endo,epi=group_tree('ECG_ENDO'),group_tree('ECG_EPI')
        if kind=='heart' and r<=2 and (not endo or not epi):raise ValueError(obj.name+': ventricular ECG_ENDO and ECG_EPI face groups are required.')
        kd=KDTree(len(vs))
        for i,p in enumerate(vs):kd.insert(p,i)
        kd.balance()
        attr=mesh.attributes.get('ecg_fiber')
        if kind=='heart' and (not attr or attr.domain!='POINT' or attr.data_type!='FLOAT_VECTOR'):raise ValueError(obj.name+': heart export requires a POINT/FLOAT_VECTOR ecg_fiber attribute. Use the imported starter or provide a reviewed fiber field.')
        fibers=[]
        for i in range(len(vs)):
            f=model_point(obj.matrix_world.to_3x3()@(attr.data[i].vector if attr else Vector((1,0,0))))
            if f.length<1e-8:raise ValueError(obj.name+': zero fiber vector.')
            fibers.append(f.normalized())
        mn=[min(p[k] for p in vs) for k in range(3)];mx=[max(p[k] for p in vs) for k in range(3)]
        bodies[r]={'tree':tree,'endo':endo,'epi':epi,'kd':kd,'fibers':fibers,'min':mn,'max':mx,'signed':signed,'cells':{}}
        surfaces.append({'name':obj.name,'region':r,'positions':[x for p in vs for x in p],'indices':[i for t in tris for i in t],'nodes':[],'weights':[]})
    directions=[Vector(x).normalized() for x in [(1,.371390676,.529113783),(-.217138,1,.431731),(.317319,-.219731,1)]]
    def inside(body,p):
        if any(p[k]<body['min'][k] or p[k]>body['max'][k] for k in range(3)):return False
        # On an oriented closed boundary, the first forward hit exits iff inside.
        # Three nonparallel rays avoid nearest-face errors at concave contacts.
        votes=0
        for direction in directions:
            q,n,_,dist=body['tree'].ray_cast(p,direction)
            if q is not None and dist>1e-4 and n.dot(direction)>1e-6:votes+=1
        return votes>=2
    positions=[];volumes=[];labels=[];rho=[];ab=[];fib=[];edges=[]
    for r,b in bodies.items():
        spans=[range(math.floor(b['min'][k]/step),math.ceil(b['max'][k]/step)) for k in range(3)]
        if math.prod(map(len,spans))>500000:raise ValueError('Too many candidate cells; check scene units or increase voxel size.')
        for cell in itertools.product(*spans):
            points=[]
            for delta in itertools.product((.25,.75),repeat=3):
                p=Vector(tuple((cell[k]+delta[k])*step for k in range(3)))
                if inside(b,p):
                    for rr,other in bodies.items():
                        if rr!=r and inside(other,p):raise ValueError('Chamber muscle volumes overlap: '+str(r)+'/'+str(rr)+' at '+str(tuple(p))+'. Remove overlapping tissue before export.')
                    points.append(p)
            if not points:continue
            p=sum(points,Vector())/len(points)
            if not inside(b,p):p=min(points,key=lambda q:(q-p).length_squared)
            i=len(labels);b['cells'][cell]=i;positions.extend(p);labels.append(r);volumes.append(step**3*len(points)/8)
            dep=b['endo'].find_nearest(p)[3] if b['endo'] else 1;epi=b['epi'].find_nearest(p)[3] if b['epi'] else 1
            rho.append(dep/max(dep+epi,1e-9));ab.append((p.y-b['min'][1])/max(b['max'][1]-b['min'][1],1e-9));fib.extend(b['fibers'][b['kd'].find(p)[1]])
        if not b['cells']:raise ValueError('No tissue captured in '+REGIONS[r]+'. Decrease voxel size or check normals/units.')
    if len(labels)>100000:raise ValueError('More than 100,000 tissue nodes. Increase voxel size for interactive use.')
    adj=[[] for _ in labels]
    for r,b in bodies.items():
        for cell,i in b['cells'].items():
            for off in itertools.product((-1,0,1),repeat=3):
                key=tuple(cell[k]+off[k] for k in range(3))
                for rr,other in bodies.items():
                    if rr!=r and {r,rr}!={1,2}:continue
                    j=other['cells'].get(key,-1)
                    if j<=i:continue
                    a=Vector(positions[3*i:3*i+3]);c=Vector(positions[3*j:3*j+3]);mid=(a+c)/2
                    if not (inside(b,mid) or inside(other,mid)):continue
                    edges.extend((i,j));adj[i].append(j);adj[j].append(i)
    # Reject disconnected material; silently deleting small islands changes muscle volume.
    for r,b in bodies.items():
        ids=set(b['cells'].values());seen=set();todo=[next(iter(ids))]
        while todo:
            i=todo.pop()
            if i in seen:continue
            seen.add(i);todo.extend(j for j in adj[i] if j in ids and j not in seen)
        if seen!=ids:raise ValueError(REGIONS[r]+': disconnected voxel tissue ('+str(len(ids-seen))+' nodes outside seed component, '+str(round(sum(volumes[i] for i in ids-seen),3))+' mm3). Repair the mesh or decrease voxel size.')
    trees={}
    for r,b in bodies.items():
        kd=KDTree(len(b['cells']))
        for i in b['cells'].values():kd.insert(Vector(positions[3*i:3*i+3]),i)
        kd.balance();trees[r]=kd
        voxel=sum(volumes[i] for i in b['cells'].values());err=abs(voxel/b['signed']-1)
        if err>.15:raise ValueError(REGIONS[r]+': sampled volume differs from mesh volume by more than 15%; refine the voxel size.')
        source[REGIONS[r]+'VolumeCheck']={'surfaceMm3':b['signed'],'sampledMm3':voxel,'relativeError':err}
    for s in surfaces:
        for k in range(0,len(s['positions']),3):
            near=trees[s['region']].find_n(Vector(s['positions'][k:k+3]),3)
            if len(near)<3:raise ValueError('Too few volume nodes for surface mapping.')
            weights=[1/max(v[2],1e-6) for v in near];total=sum(weights)
            s['nodes'].extend(v[1] for v in near);s['weights'].extend(w/total for w in weights)
    landmarks={}
    for name,r in (LANDMARKS.items() if kind=='heart' else [('sa',next(iter(regions)))]):
        candidates=[o for o in bpy.context.scene.objects if o.get('ecg_landmark')==name]
        if len(candidates)!=1:raise ValueError('Provide exactly one landmark Empty with ecg_landmark="'+name+'" (remove duplicates from other imported collections).')
        p=model_point(candidates[0].matrix_world.translation,scale);_,node,dist=trees[r].find(p)
        if dist>max(10,3*step):raise ValueError(name+': landmark is too far from its chamber.')
        landmarks[name]=node
    d={'schemaVersion':1,'name':bpy.path.display_name_from_filepath(filepath),'units':{'length':'mm','volume':'mm3'},'positions':positions,'volume':volumes,'regions':labels,'rho':rho,'apicobasal':ab,'fibers':fib,'edges':edges,'surfaces':surfaces,'simulation':{'kind':kind,'landmarks':landmarks},'source':source}
    encoded=json.dumps(d,separators=(',',':'))
    if len(encoded.encode())>40*1024*1024:raise ValueError('Export exceeds the 40 MB browser import limit; simplify the display mesh on a copy.')
    with open(filepath,'w') as f:f.write(encoded)
    return d

class ECGImport(bpy.types.Operator,ImportHelper):
    bl_idname='import_scene.ecg_studio';bl_label='ECG Studio model (.json)';filename_ext='.json'
    filter_glob:StringProperty(default='*.json',options={'HIDDEN'})
    def execute(self,context):
        try:import_asset(self.filepath)
        except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
        return {'FINISHED'}
class ECGExport(bpy.types.Operator,ExportHelper):
    bl_idname='export_scene.ecg_studio';bl_label='ECG Studio volume model (.json)';filename_ext='.json'
    filter_glob:StringProperty(default='*.json',options={'HIDDEN'})
    voxel_mm:FloatProperty(name='Voxel size (mm)',default=2,min=1,max=10,description='Use smaller cells for thin walls; compare exports at two resolutions')
    def execute(self,context):
        try:d=export_asset(self.filepath,self.voxel_mm)
        except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
        self.report({'INFO'},str(len(d['regions']))+' tissue nodes exported; scientific validation still required')
        return {'FINISHED'}
def menu_import(self,context):self.layout.operator(ECGImport.bl_idname)
def menu_export(self,context):self.layout.operator(ECGExport.bl_idname)
def register():
    bpy.utils.register_class(ECGImport);bpy.utils.register_class(ECGExport);bpy.types.TOPBAR_MT_file_import.append(menu_import);bpy.types.TOPBAR_MT_file_export.append(menu_export)
def unregister():
    bpy.types.TOPBAR_MT_file_import.remove(menu_import);bpy.types.TOPBAR_MT_file_export.remove(menu_export);bpy.utils.unregister_class(ECGExport);bpy.utils.unregister_class(ECGImport)
if __name__=='__main__':register()
