"""Four original, editable face-wrap concepts. Run with Blender 5.2. 1 Blender unit = 100 mm. Dimensions are concept geometry, not sewing patterns. Reuses the locally supplied MakeHuman CC0 adult mannequin workflow. """ from pathlib import Path import sys, json, math from mathutils import Matrix ROOT = Path(__file__).resolve().parent base = (ROOT/'source/base_study_reference.py').read_text().split('\ndef make_mask')[0] exec(compile(base, str(ROOT/'source/base_study_reference.py'), 'exec'), globals()) QUICK = '--preview' in ARGS SC.cycles.samples = 16 if QUICK else 40 SC.cycles.use_adaptive_sampling = True SC.cycles.adaptive_threshold = .025 SC.render.image_settings.color_mode = 'RGB' SC.render.image_settings.color_depth = '8' SC.render.image_settings.compression = 55 SC.world.node_tree.nodes['Background'].inputs['Strength'].default_value = .24 CONCEPTS = [ dict(id='A01',name='轻风透气',en='AIR / VENTED',kind='air',color=(.19,.43,.44),trim=(.07,.23,.25),thickness=.010,crown=[(.06,.36)],rear=.18), dict(id='B02',name='双轨下颌',en='DUO / JAW SUPPORT',kind='duo',color=(.44,.30,.22),trim=(.21,.13,.09),thickness=.014,crown=[(-.18,.17),(.22,.19)],rear=.25), dict(id='C03',name='换洗贴合',en='LINER / MODULAR',kind='liner',color=(.39,.29,.46),trim=(.22,.14,.29),thickness=.012,crown=[(.07,.38)],rear=.22), dict(id='D04',name='开放蝶翼',en='WING / OPEN CENTRE',kind='wing',color=(.68,.51,.37),trim=(.32,.23,.15),thickness=.011,crown=[(-.14,.18),(.19,.18)],rear=.20), ] FACESPECS = [ dict(id='F',label='女性鹅蛋脸',gender='female',width=.97,length=1.02,jaw=-.045,cheek=0.,landmarks=dict(nose_z=.996,mouth_z=.704,chin_z=.336)), dict(id='M',label='男性方脸',gender='male',width=1.10,length=.995,jaw=.09,cheek=.015,landmarks=dict(nose_z=.972,mouth_z=.62,chin_z=.192)), ] LINING=material('可拆接触层 / warm ivory',(.78,.75,.69),.88,True) HARDWARE=material('调节扣 / matte acetal',(.20,.22,.22),.5) LABELMAT=material('标识 / charcoal',(.10,.12,.12),.7) MODELS=[] def mark(ob,role,code): if ob.type=='CURVE':ob.data.use_fill_caps=True ob['component_role']=role ob['concept_id']=code return ob def sampler(head): bvh=bvh_for(head) def sample(th,z,gap=.029): d=Vector((sin(th),-cos(th),0));o=d*4;o.z=z p,n,_,_=bvh.ray_cast(o,-d,8) if p is None:p,n,_,_=bvh.find_nearest(Vector((sin(th)*.75,-cos(th)*.85,z))) if n.dot(d)<0:n=-n return p+n*gap def crown(t,y,gap=.032): d=Vector((sin(t),0,cos(t)));o=Vector((0,y,1.25))+d*3 p,n,_,_=bvh.ray_cast(o,-d,6) if p is None:p,n,_,_=bvh.find_nearest(o-d*2) if n.dot(d)<0:n=-n return p+n*gap return sample,crown,bvh def finish_surface(ob,head,gap,thickness,role,code,smooth=True): bm=bmesh.new();bm.from_mesh(ob.data) bmesh.ops.delete(bm,geom=[v for v in bm.verts if not v.link_faces],context='VERTS') bmesh.ops.recalc_face_normals(bm,faces=bm.faces) bm.to_mesh(ob.data);bm.free() if smooth: mod=ob.modifiers.new('Editable surface smoothing','SUBSURF');mod.levels=1;mod.render_levels=1 mod=ob.modifiers.new('Fit to mannequin surface','SHRINKWRAP');mod.target=head;mod.wrap_method='NEAREST_SURFACEPOINT';mod.offset=gap mod=ob.modifiers.new('Material thickness in concept','SOLIDIFY');mod.thickness=thickness;mod.offset=0;mod.use_even_offset=False;mod.use_quality_normals=True mod=ob.modifiers.new('Keep outside mannequin','SHRINKWRAP');mod.target=head;mod.wrap_method='NEAREST_SURFACEPOINT';mod.wrap_mode='OUTSIDE';mod.offset=.006 mark(ob,role,code);ob['nominal_thickness_mm']=thickness*100 return ob def bind_boundaries(ob,mat,col,code,role='binding',radius=.009): bm=bmesh.new();bm.from_mesh(ob.data) left={e for e in bm.edges if e.is_boundary};loops=[] while left: edge=next(iter(left));first=edge.verts[0];v=first;points=[] while True: points.append(v.co.copy()) es=[e for e in v.link_edges if e in left] if not es:break e=es[0];left.remove(e);v=e.other_vert(v) if v==first:break if len(points)>3:loops.append(points) bm.free() cu=bpy.data.curves.new(ob.name+'_binding','CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=radius;cu.bevel_resolution=2 for points in loops: sp=cu.splines.new('POLY');sp.points.add(len(points)-1);sp.use_cyclic_u=True for p,co in zip(sp.points,points):p.co=(*co,1) out=bpy.data.objects.new(ob.name+'_连续包边',cu);col.objects.link(out);cu.materials.append(mat) return mark(out,role,code) def param_surface(name,col,mat,head,point,nu,nv,gap,thick,code,role='shell',keep=None,trim=None): vv=[point(i/nu,j/nv) for i in range(nu+1) for j in range(nv+1)] ff=[] for i in range(nu): for j in range(nv): if keep is None or keep((i+.5)/nu,(j+.5)/nv): a=i*(nv+1)+j;ff.append((a,a+nv+1,a+nv+2,a+1)) ob=mesh(name,vv,ff,mat,col) # Relax clipped boundaries in the control cage before surface fitting. bm=bmesh.new();bm.from_mesh(ob.data) unused=[v for v in bm.verts if not v.link_faces] if unused:bmesh.ops.delete(bm,geom=unused,context='VERTS') for _ in range(4): changes={} for v in bm.verts: es=[e for e in v.link_edges if e.is_boundary] if len(es)==2:changes[v]=v.co*.4+(es[0].other_vert(v).co+es[1].other_vert(v).co)*.3 for v,p in changes.items():v.co=p bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free() bvh=bvh_for(head) for v in ob.data.vertices: p,n,_,_=bvh.find_nearest(v.co);v.co=p+n*gap finish_surface(ob,head,gap,thick,role,code) if trim:bind_boundaries(ob,trim,col,code,'liner_binding' if role=='liner' else 'binding',.007 if role=='liner' else .009) return ob def make_concept(cfg,face,col,head): code=cfg['id']+'_'+face['id'];sample,crown,bvh=sampler(head) mainmat=material(code+'_弹力织物',cfg['color'],.8,True) trim=material(code+'_软包边',cfg['trim'],.74,True) accent=material(code+'_加强层',tuple(min(1,x*1.30+.04) for x in cfg['color']),.77,True) nz,mz,cz=[face['landmarks'][k] for k in ['nose_z','mouth_z','chin_z']] limit=1.76 gap=.051 if cfg['kind']=='liner' else .031 def bounds(th): t=abs(th)/limit lo=cz+.016+.245*t**1.7 if cfg['kind']=='duo':hi=mz-.165+(2.05-(mz-.165))*t**1.57 elif cfg['kind']=='wing':hi=1.23+.83*t**2.5 else:hi=1.205+.86*t**3.0 return lo,hi def uv(u,v): th=-limit+2*limit*u;lo,hi=bounds(th);return th,lo+(hi-lo)*v def keep(u,v): th,z=uv(u,v) ear=((abs(th)-1.64)/.39)**2+((z-1.175)/.46)**2<1 if ear:return False if cfg['kind']=='duo':return True if cfg['kind']=='wing': # Open at the bridge: one unobstructed nose-and-mouth window. low=mz-.20;high=nz+.28;mid=(low+high)/2;half=(high-low)/2 opening=(th/.415)**2+((z-mid)/half)**4<1 return not opening nose=(th/.218)**2+((z-(nz+.020))/.140)**2<1 mouth=(th/.345)**2+((z-mz)/.148)**2<1 if nose or mouth:return False if cfg['kind']=='air': for a in [.55,.76,.97,1.18]: for zz in [mz+.085,mz+.255,mz+.415]: if ((abs(th)-a)/.046)**2+((z-zz)/.043)**2<1:return False return True panel=param_surface(code+'_01_主体包覆',col,mainmat,head,lambda u,v:sample(*uv(u,v),gap),176 if cfg['kind']=='air' else 132,126 if cfg['kind']=='air' else 100,gap,cfg['thickness'],code,keep=keep,trim=trim) panel['design_intent']=cfg['name'] # A separate broad lower support panel, not an increase in proven pressure. bottomgap=gap+.015 def cup(u,v): th=-1.17+2.34*u;lo,hi=bounds(th) height=min(.17,max(.085,hi-lo-.018)) return sample(th,lo+.022+v*height,bottomgap) param_surface(code+'_02_立体下颌承托片',col,accent,head,cup,80,12,bottomgap,.012,code,'jaw_support',trim=trim) # Crown paths attach above the ears. Each strap remains independently editable. for k,(yc,width) in enumerate(cfg['crown']): bgap=.039+k*.002 point=lambda u,v,yc=yc,width=width,bgap=bgap:crown(-1.12+2.24*u,yc+(v-.5)*width,bgap) param_surface(code+f'_03_头顶调节带_{k+1}',col,mainmat,head,point,104,12,bgap,.015,code,'crown_strap',trim=trim) # Contrasting hook-and-loop overlap pad visibly communicates adjustment. param_surface(code+f'_04_顶部搭接片_{k+1}',col,accent,head,lambda u,v,yc=yc,width=width:crown(-.20+.40*u,yc+(v-.5)*(width-.025),.060+k*.002),22,8,.060+k*.002,.008,code,'adjustment_tab',trim=trim) for t in [-.12,.00,.12]: pts=[crown(t,yc+(j/18-.5)*(width-.050),.068+k*.002) for j in range(19)] mark(curve(code+'_顶部调节刻线',pts,.0023,LINING,col),'stitch',code) # Independent occipital band, above the ears, distributes the anchor locations. width=cfg['rear'] def rear(u,v): th=1.60+(2*pi-3.20)*u;z=1.705+.035*cos(th)+(v-.5)*width return sample(th,z,.042) param_surface(code+'_05_后脑调节带',col,mainmat,head,rear,112,12,.042,.014,code,'rear_strap',trim=trim) param_surface(code+'_06_后脑搭接片',col,accent,head,lambda u,v:sample(pi-.27+.54*u,1.675+(v-.5)*(width-.035),.064),24,8,.064,.008,code,'adjustment_tab',trim=trim) if cfg['kind']=='duo': for sign in [-1,1]: def track(u,v,sign=sign): th=sign*(.34+.93*u);lo,hi=bounds(th);z=lo+(hi-lo)*(.44+.20*u)+(v-.5)*.072 return sample(th,z,.058) param_surface(code+'_07_独立斜向承托轨_'+str(sign),col,trim,head,track,48,6,.058,.011,code,'load_path',trim=accent) if cfg['kind']=='liner': # Three removable skin-side modules. They are separate solids, not decals. for sign in [-1,1]: def linerpoint(u,v,sign=sign): th=sign*(.46+.79*u);lo,hi=bounds(th) return sample(th,lo+.14+(hi-lo-.27)*v,.024) ob=param_surface(code+'_07_可拆脸颊内衬_'+('L' if sign<0 else 'R'),col,LINING,head,linerpoint,48,30,.024,.009,code,'liner',trim=accent) ob['detach_direction']='Pull away from interior snap points; concept module' for th,z in [(sign*.70,mz+.30),(sign*1.12,cz+.26)]: p=sample(th,z,.073);n=(p-bvh.find_nearest(p)[0]).normalized() bpy.ops.mesh.primitive_uv_sphere_add(segments=20,ring_count=12,location=p) snap=bpy.context.object;snap.name=code+'_08_内衬按扣';link(snap,col) snap.scale=(.029,.029,.010);snap.rotation_euler=n.to_track_quat('Z','Y').to_euler();snap.data.materials.append(accent);mark(snap,'snap',code) param_surface(code+'_07_可拆下巴内衬',col,LINING,head,lambda u,v:sample(-.43+.86*u,cz+.055+.10*v,.024),42,12,.024,.009,code,'liner',trim=accent) if cfg['kind']=='wing': for sign in [-1,1]: def wingrib(u,v,sign=sign): th=sign*(.52+.65*u);lo,hi=bounds(th) return sample(th,lo+(hi-lo)*(.30+.42*u)+(v-.5)*.055,.054) param_surface(code+'_07_蝶翼稳定弧_'+str(sign),col,accent,head,wingrib,46,6,.054,.009,code,'wing_rib',trim=trim) return panel # Create each anatomical mesh once, and reuse its geometry across four concepts. PROTOS=[] for f in FACESPECS: c=collection('REF_'+f['label']);h=make_head(f,c);PROTOS.append((f,c,h)) for ci,cfg in enumerate(CONCEPTS): for face,proto,h in PROTOS: code=cfg['id']+'_'+face['id'];col=collection(code+'_'+cfg['name']+'_'+face['label']) head=None for old in list(proto.objects): ob=old.copy();ob.data=old.data;ob.name=code+'_'+old.name;col.objects.link(ob);mark(ob,'mannequin',code) if old==h:head=ob panel=make_concept(cfg,face,col,head) plinth=bevel_cube(code+'_底座',(0,0,-1.015),(3.14,2.06,.31),PLINTH,col,.07);mark(plinth,'display',code) td=bpy.data.curves.new(code+'_标签','FONT');td.body=cfg['id']+' / '+cfg['en'];td.align_x='CENTER';td.size=.109;td.extrude=.0006;td.materials.append(LABELMAT) label=bpy.data.objects.new(code+'_标签',td);col.objects.link(label);label.location=(0,-1.035,-.997);label.rotation_euler=(pi/2,0,0);mark(label,'display',code) root=bpy.data.objects.new(code+'_整体位姿',None);col.objects.link(root) for ob in list(col.objects): if ob!=root:ob.parent=root root.location.x=(ci-1.5)*3.65 MODELS.append(dict(code=code,cfg=cfg,face=face,collection=col,head=head,panel=panel,root=root,x=root.location.x)) print('MODEL_READY',code,flush=True) for f,c,h in PROTOS: for ob in list(c.objects):bpy.data.objects.remove(ob,do_unlink=True) bpy.data.collections.remove(c) def area(name,loc,power,size,color): ld=bpy.data.lights.new(name,'AREA');ld.energy=power;ld.shape='DISK';ld.size=size;ld.color=color ob=bpy.data.objects.new(name,ld);STUDIO.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector((0,0,.9))-ob.location).to_track_quat('-Z','Y').to_euler() area('Key',(-4,-5,7),1250,5,(1,.95,.88)) area('Fill',(6,-3,5),550,5,(.86,.94,1)) area('Rim',(0,4,7),1350,4,(1,.94,.84)) bevel_cube('Ground',(0,0,-1.255),(200,200,.2),FLOOR,STUDIO,.01) cd=bpy.data.cameras.new('CAM_Product');cd.type='ORTHO';CAM=bpy.data.objects.new('CAM_Product',cd);STUDIO.objects.link(CAM);SC.camera=CAM def setcam(loc,target,scale): CAM.location=loc;CAM.rotation_euler=(Vector(target)-CAM.location).to_track_quat('-Z','Y').to_euler();cd.ortho_scale=scale def show_one(code,product=False): for m in MODELS: m['collection'].hide_render=m['code']!=code;m['collection'].hide_viewport=False m['root'].location=(0,0,0);m['root'].rotation_euler=(0,0,0) for ob in m['collection'].objects: ob.hide_render=product and ob.get('component_role') in ['mannequin','display'] def show_lineup(gender='F'): for m in MODELS: m['collection'].hide_render=m['face']['id']!=gender;m['collection'].hide_viewport=m['face']['id']!=gender m['root'].location=(m['x'],0,0);m['root'].rotation_euler=(0,0,-.43) for ob in m['collection'].objects:ob.hide_render=False def render(name,w,h): SC.render.resolution_x=w;SC.render.resolution_y=h;SC.render.resolution_percentage=100 SC.render.filepath=str(ROOT/'renders'/name);bpy.ops.render.render(write_still=True) print('RENDER_SAVED',name,flush=True) def export_model(m,part): show_one(m['code']);bpy.context.view_layer.update();deps=bpy.context.evaluated_depsgraph_get();temp=collection('TEMP_EXPORT');copies=[] for ob in list(m['collection'].objects): if ob.type not in {'MESH','CURVE'}:continue role=ob.get('component_role') if role=='display' or (part=='mask' and role=='mannequin'):continue ev=ob.evaluated_get(deps);me=bpy.data.meshes.new_from_object(ev,preserve_all_data_layers=True,depsgraph=deps) if ob.type=='CURVE': bm=bmesh.new();bm.from_mesh(me);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-7);bm.to_mesh(me);bm.free() me.transform(Matrix.Scale(SC.unit_settings.scale_length,4) @ ob.matrix_world) cp=bpy.data.objects.new(ob.name,me);temp.objects.link(cp);cp['component_role']=role;copies.append(cp) bpy.ops.object.select_all(action='DESELECT') for ob in copies:ob.select_set(True) bpy.ops.export_scene.gltf(filepath=str(ROOT/'exports'/(m['code']+'_'+part+'.glb')),use_selection=True,export_format='GLB',export_apply=False,export_extras=True) for ob in copies: me=ob.data;bpy.data.objects.remove(ob,do_unlink=True);bpy.data.meshes.remove(me) bpy.data.collections.remove(temp) show_lineup();setcam((0,-22,4.6),(0,0,.85),15.6) SC['project_notes']='Original visual design concepts. Two independent anatomical fittings per concept. No cloth tension, pressure, efficacy, sewing-pattern or production validation.' SC['unit_note']='1 Blender unit = 100 mm; scene unit scale = 0.1 m' for area_ui in bpy.context.screen.areas: if area_ui.type=='VIEW_3D': v=area_ui.spaces.active;v.region_3d.view_distance=18;v.region_3d.view_location=(0,0,.8);v.region_3d.view_rotation=CAM.rotation_euler.to_quaternion();v.shading.type='MATERIAL' SC.render.resolution_x=3200;SC.render.resolution_y=1400 bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'提拉面罩_四款原创设计_v1.blend'),compress=True) (ROOT/'source/design_parameters.json').write_text(json.dumps({'concepts':CONCEPTS,'faces':FACESPECS,'units':'1 unit = 100 mm','phase':'visual concept geometry, not measured manufacturing dimensions'},ensure_ascii=False,indent=2)) if '--build-only' not in ARGS: render('00_四款设计总览.png',2000 if QUICK else 3200,900 if QUICK else 1440) for cfg in CONCEPTS: code=cfg['id']+'_F';show_one(code);setcam((3.6,-8,2.5),(0,-.08,.78),4.32) render(cfg['id']+'_01_女性佩戴.png',900 if QUICK else 1400,1000 if QUICK else 1550) if not QUICK: for cfg in CONCEPTS: show_one(cfg['id']+'_F');setcam((8,-1.5,2.1),(0,0,.8),4.25);render(cfg['id']+'_02_侧面结构.png',1400,1550) show_one(cfg['id']+'_F',True);setcam((3.8,-8,3.1),(0,-.04,1.20),3.4);render(cfg['id']+'_03_独立模型.png',1600,1600) show_one(cfg['id']+'_M');setcam((3.6,-8,2.5),(0,-.08,.72),4.45);render(cfg['id']+'_04_男性适配.png',1400,1550) show_lineup('M');setcam((0,-22,4.6),(0,0,.8),15.6);render('01_男性四款适配总览.png',3200,1440) # True separated interior modules in the exploded product view. show_one('C03_F',True);cm=next(m for m in MODELS if m['code']=='C03_F') shifted=[];bpy.context.view_layer.update();deps=bpy.context.evaluated_depsgraph_get() for ob in list(cm['collection'].objects): if ob.get('component_role') in ['liner','liner_binding']: ev=ob.evaluated_get(deps);me=bpy.data.meshes.new_from_object(ev,depsgraph=deps) cp=bpy.data.objects.new(ob.name+'_exploded',me);cm['collection'].objects.link(cp) cp.matrix_world=ob.matrix_world.copy();cp.location.x-=1.85;cp.location.y-=.25 ob.hide_render=True;shifted.append((ob,cp)) setcam((3.2,-9,3),( -.5,-.05,1.12),5.3);render('C03_05_可拆内衬结构.png',2000,1500) for ob,cp in shifted: ob.hide_render=False;me=cp.data;bpy.data.objects.remove(cp,do_unlink=True);bpy.data.meshes.remove(me) for m in MODELS: export_model(m,'mask');export_model(m,'worn') show_lineup();setcam((0,-22,4.6),(0,0,.85),15.6) SC.render.resolution_x=3200;SC.render.resolution_y=1440 bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'提拉面罩_四款原创设计_v1.blend'),compress=True) print('CONCEPT_COLLECTION_COMPLETE',flush=True)