component
Sunon MF40200V1-1000U-G99 · 5 V tachometer fan
Public Made by Adomby adom
Tachometer fan with factory flying leads, an explicit Adom connector harness and gentle GLB state demonstration.
main
John Lauer
Fan component engineering assets and verified simulated rotor motion prototype
422c1bc
12d ago
"""Add a rigid rotor hierarchy and one-turn glTF animation to our authored model."""
from pathlib import Path
import json,struct,math,hashlib
r=Path(__file__).resolve().parents[1];src=r/'cad/MF40200V1-1000U-G99-ai-nominal.glb';b=src.read_bytes();n=struct.unpack_from('<I',b,12)[0];g=json.loads(b[20:20+n]);start=20+n;nb=struct.unpack_from('<I',b,start)[0];binary=bytearray(b[start+8:start+8+nb])
rotor=[i for i,v in enumerate(g['nodes']) if v.get('name','').startswith(('motor_hub_','illustrative_blade_'))]
assert len(rotor)==8
static=[i for i in g['nodes'][0]['children'] if i not in rotor]
ri=len(g['nodes']);g['nodes']+=[{'name':'Fan.Rotor','children':rotor,'rotation':[0,0,0,1],'extras':{'role':'rotor','axisLocal':[0,0,1],'pivotLocalMeters':[0,0,0]}},{'name':'Fan.Housing','children':static}]
g['nodes'][0]['name']='Fan.Assembly';g['nodes'][0]['children']=[ri,ri+1]
def accessor(values,kind):
while len(binary)%4:binary.append(0)
off=len(binary);flat=[x for v in values for x in (v if isinstance(v,list) else [v])];binary.extend(struct.pack('<'+'f'*len(flat),*flat));vi=len(g.setdefault('bufferViews',[]));g['bufferViews'].append({'buffer':0,'byteOffset':off,'byteLength':len(flat)*4});ai=len(g['accessors']);a={'bufferView':vi,'componentType':5126,'count':len(values),'type':kind};
if kind=='SCALAR':a.update(min=[min(values)],max=[max(values)])
g['accessors'].append(a);return ai
times=[0,.25,.5,.75,1];quats=[[0,0,math.sin(-math.pi*t),math.cos(-math.pi*t)] for t in times]
t=accessor(times,'SCALAR');q=accessor(quats,'VEC4')
g['animations']=[{'name':'Fan.Rotor.Revolution','samplers':[{'input':t,'output':q,'interpolation':'LINEAR'}],'channels':[{'sampler':0,'target':{'node':ri,'path':'rotation'}}],'extras':{'revolutionsPerCycle':1,'cycleDurationSeconds':1,'defaultPlayback':'paused'}}]
g['buffers'][0]['byteLength']=len(binary)
g['asset'].setdefault('extras',{})['adomComponentMotion']={'schema':'adom/component-motion@1','states':'../states.json','authoredUpAxis':'Z','defaultState':'off','geometryFidelity':'nominal envelope; rotor illustrative'}
js=json.dumps(g,separators=(',',':')).encode();js+=b' '*((-len(js))%4);binary+=b'\0'*((-len(binary))%4)
out=struct.pack('<III',0x46546c67,2,12+8+len(js)+8+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+struct.pack('<II',len(binary),0x004e4942)+binary
p=r/'states/fan-animated.glb';p.write_bytes(out)
s={'schema':'adom/component-states@1','mpn':'MF40200V1-1000U-G99','kind':'fan','default':'off','variables':{'speed':{'type':'number','unit':'rpm','min':0,'max':10000,'default':0,'description':'Actual rotor speed in the declared data source, not requested supply or nominal speed.'}},'states':[{'id':'off','label':'Off','summary':'Rotor stopped. No implicit coast-down model.','models':{'step':'cad/MF40200V1-1000U-G99-ai-nominal.step','glb':'states/fan-animated.glb'},'values':{'speed':0},'motion':{'playing':False},'image':'artwork/fan-off.png','when':{'text':'Explicit stopped state; unknown telemetry must not be presented as verified off.'}},{'id':'on','label':'On','summary':'Rotating at the supplied speed variable.','models':{'step':'cad/MF40200V1-1000U-G99-ai-nominal.step','glb':'states/fan-animated.glb'},'values':{'speed':7000},'motion':{'playing':True},'image':'artwork/fan-on.png','when':{'text':'Explicit simulated rotor speed or valid measured tachometer RPM; supply presence alone is insufficient.'}}], 'motion':{'schema':'adom/component-motion@1','status':'proposed-extension','model':'states/fan-animated.glb','sha256':hashlib.sha256(out).hexdigest(),'requiredCapability':'component-motion@1','nodes':{'assembly':'Fan.Assembly','stationary':'Fan.Housing','rotor':'Fan.Rotor'},'bindings':[{'id':'rotor','node':'Fan.Rotor','animation':'Fan.Rotor.Revolution','variable':'speed','unit':'rpm','cycleDurationSeconds':1,'revolutionsPerCycle':1,'axisLocal':[0,0,1],'pivotLocalMeters':[0,0,0],'direction':'negative right-hand rotation about local Z; illustrative orientation','mapping':'rpm-to-cycle-rate','offBehavior':'pause-preserve-phase'}],'presentation':{'defaultTimeScale':1,'previewTimeScale':0.02,'labelRequiredWhenScaled':True,'highRpmAliasingWarning':True},'telemetry':{'defaultSource':'simulated','sources':['simulated','measured'],'validRequired':True,'staleBehavior':'pause-and-label-unknown','doesNotControlHardware':True}},'electrical':{'supply_V':5,'nominal_rpm':7000,'tach_pulses_per_revolution':2,'source':'Sunon D04114960G-01 pages 3 and 8'},'pins':{'supply':'1 (red lead, library convention)','ground':'2 (black lead)','tach':'3 (yellow lead)'},'limitations':['Motion extension pending ecosystem adoption','STEP is static mechanical geometry; playback lives in GLB/runtime','Rotor blades and supports are illustrative, not exact vendor geometry','Rotation direction requires physical verification','No airflow, inertia, thermal or safety proof from animation']}
s['states'][0]['models']['glb']='cad/MF40200V1-1000U-G99-ai-nominal.glb'
(r/'states.json').write_text(json.dumps(s,indent=2)+'\n')
print('Rotor node',ri,'animated GLB',len(out),'bytes')