"""Blender 5.2: derive compact anatomical contours from Lee Perry-Smith's CC BY 3.0 scan. Run from repo: blender --background --factory-startup --disable-autoexec --python scripts/build-lichen-head.py No downloaded Python or .blend scripts are executed. Live scenes are never accessed. """ import bpy, json, math, os from pathlib import Path from mathutils import Vector from mathutils.bvhtree import BVHTree ROOT=Path(__file__).resolve().parent.parent bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.gltf(filepath=str(ROOT/'assets/lichen-head/LeePerrySmith.glb')) obj=next(o for o in bpy.context.scene.objects if o.type=='MESH') bpy.context.view_layer.objects.active=obj;obj.select_set(True) bpy.ops.object.transform_apply(location=False,rotation=True,scale=True) # Narrow the shoulders into a short anatomical neck/bust; preserve scanned facial geometry. for v in obj.data.vertices: z=v.co.z if z<-1.3:v.co.x*=max(.35,1-(abs(z)-1.3)*.65) obj.data.update() # Cut at the base of the neck; the original scan includes a broad chest below it. import bmesh bm=bmesh.new();bm.from_mesh(obj.data) bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.00001,plane_co=(0,0,-1.75),plane_no=(0,0,1),clear_inner=True,clear_outer=False) bm.to_mesh(obj.data);bm.free();obj.data.update() # Decimation only supplies silhouette/depth normals. Curves sample the original scan. verts=[v.co.copy() for v in obj.data.vertices];faces=[tuple(p.vertices) for p in obj.data.polygons] bvh=BVHTree.FromPolygons(verts,faces) # Original Blender X/right, -Y/front, Z/up -> runtime +Z/front, Y/up. factor=.42;center=1.11 point=lambda v:[round(v.x*factor,4),round((v.z-center)*factor,4),round(-v.y*factor,4)] normal=lambda n:[round(n.x,4),round(n.z,4),round(-n.y,4)] curves=[];features=[] # Connected mesh-plane sections produce proper forehead/nose/cheek/jaw anatomy. def section(axis,level): segments=[] for face in faces: hits=[] for i,j in zip(face,face[1:]+face[:1]): a,b=verts[i],verts[j];da,db=a[axis]-level,b[axis]-level if da*db<0: hits.append(a+(b-a)*da/(da-db)) if len(hits)==2:segments.append(hits) # Quantized endpoint graph joins each closed curve rather than independent dashes. key=lambda v:tuple(round(x,4) for x in v) graph={};points={} for a,b in segments: ka,kb=key(a),key(b);graph.setdefault(ka,[]).append(kb);graph.setdefault(kb,[]).append(ka);points[ka]=a;points[kb]=b used=set() for start in graph: for neighbor in graph[start]: edge=tuple(sorted((start,neighbor))) if edge in used:continue line=[start];prev=start;current=neighbor;used.add(edge) while True: line.append(current);candidates=[n for n in graph[current] if tuple(sorted((current,n))) not in used] if not candidates:break nxt=candidates[0];used.add(tuple(sorted((current,nxt))));prev,current=current,nxt if current==start:line.append(start);break if len(line)<5:continue # Arc-length resample at ~2px in the widget, keeping facial turns legible. out=[];last=None for k in line: v=points[k] if last is not None and (v-last).length<.075:continue nearest,n,_,_=bvh.find_nearest(v);out.append(point(v)+normal(n));last=v if len(out)>7 and sum(math.dist(a[:3],b[:3]) for a,b in zip(out,out[1:]))>.3:curves.append(out) for z in [-1.74,-1.15,-.65,-.15,.5,1.0,1.6,2.15,2.75,3.35,3.8]:section(2,z) for x in [-1.5,-.8,0,.8,1.5]:section(0,x) # Anatomical landmarks become smooth, separately weighted feature curves. # Every point is ray-projected onto the original scan, retaining real surface # depth/normal and proper occlusion through rotation, rather than overlaying 2D art. def feature(coords): out=[] for x,z in coords: hit,n,_,_=bvh.ray_cast(Vector((x,-8,z)),Vector((0,1,0))) if hit is not None:out.append(point(hit)+normal(n)) if len(out)>2:features.append(out) def landmark(coords): # Catmull-Rom through anatomical guide landmarks, sampled before ray projection. samples=[];extended=[coords[0]]+coords+[coords[-1]] for i in range(1,len(extended)-2): a,b,c,d=[Vector(v) for v in extended[i-1:i+3]] for j in range(18): t=j/18;samples.append(tuple(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t))) samples.append(coords[-1]);feature(samples) for side in [-1,1]: # Closed socket and eyelid loops remain distinct at the 184x144 widget scale. feature([(side*(.78+.56*math.cos(t)),1.68+.30*math.sin(t)-.055*math.cos(t)) for t in [i*2*math.pi/80 for i in range(81)]]) feature([(side*(.78+.44*math.cos(t)),1.66+.105*math.sin(t)-.055*math.cos(t)) for t in [i*2*math.pi/64 for i in range(65)]]) feature([(side*(.78+.57*math.cos(t)),1.96+.17*math.sin(t)) for t in [i*math.pi/50 for i in range(51)]]) feature([(side*(.25+.14*math.cos(t)),.88+.075*math.sin(t)) for t in [i*2*math.pi/40 for i in range(41)]]) landmark([(side*.21,1.95),(side*.18,1.66),(side*.20,1.38),(side*.28,1.05),(side*.4,.88)]) # Cheek ridge and rounded muzzle; the outer guide joins cheek into the jaw. landmark([(side*1.44,1.33),(side*1.27,1.12),(side*.94,.95),(side*.55,.96)]) landmark([(side*1.45,1.18),(side*1.38,.65),(side*1.20,.10),(side*.87,-.35),(side*.47,-.54)]) landmark([(side*.39,.82),(side*.57,.63),(side*.70,.34),(side*.72,.11)]) # The jaw/chin rim is a U, distinct from the narrower neck behind it. Project # through the scanned chin so front and quarter views retain the same anatomy. landmark([(-1.50,.65),(-1.38,.22),(-1.09,-.20),(-.63,-.53),(0,-.64),(.63,-.53),(1.09,-.20),(1.38,.22),(1.50,.65)]) landmark([(-.74,-.10),(-.44,-.23),(0,-.29),(.44,-.23),(.74,-.10)]) for height in [.08,-.11]: feature([(x,.43+height*math.sin(math.pi*(x+.57)/1.14)+.025*math.cos(x*10)) for x in [-.57+i*1.14/60 for i in range(61)]]) # Simplify for inexpensive view-dependent silhouette extraction. mod=obj.modifiers.new('Widget silhouette','DECIMATE');mod.ratio=.24;bpy.ops.object.modifier_apply(modifier=mod.name) mesh=obj.data;mesh.calc_loop_triangles() vs=[point(v.co) for v in mesh.vertices];ns=[normal(p.normal) for p in mesh.polygons];edge_faces={} for p in mesh.polygons: f=tuple(p.vertices) for i,j in zip(f,f[1:]+f[:1]):edge_faces.setdefault(tuple(sorted((i,j))),[]).append(p.index) edges=[[a,b,*fs] for (a,b),fs in edge_faces.items() if len(fs)==2] model={'credit':'Infinite, 3D Head Scan by Lee Perry-Smith / triplegangers.com. CC BY 3.0. Blender-derived neck crop, surface contours and anatomical guides.','curves':curves,'features':features,'vertices':vs,'normals':ns,'edges':edges,'triangles':[list(t.vertices) for t in mesh.loop_triangles]} (ROOT/'lib/playground/v2/head-contours.json').write_text(json.dumps(model,separators=(',',':'))+'\n') # Keep a source scene with the real surface and separate 3D feature/contour curves. collection=bpy.data.collections.new('Lichen surface contours');bpy.context.scene.collection.children.link(collection) for i,line in enumerate(curves+features): data=bpy.data.curves.new('surface contour','CURVE');data.dimensions='3D';spline=data.splines.new('POLY');spline.points.add(len(line)-1) for p,q in zip(spline.points,line):p.co=(q[0]/factor,-q[2]/factor,q[1]/factor+center,1) ob=bpy.data.objects.new(f'{"Surface" if i