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
"""Bake a quiet, hero-only state demonstration; never a telemetry/controller asset."""
from pathlib import Path
import json,struct,math,hashlib
P=Path(__file__).resolve().parents[1]
src=P/'cad/harness/fan-adom-harness.glb';b=src.read_bytes();n=struct.unpack_from('<I',b,12)[0];g=json.loads(b[20:20+n]);off=20+n;nb=struct.unpack_from('<I',b,off)[0];blob=bytearray(b[off+8:off+8+nb]);rotor=next(i for i,v in enumerate(g['nodes'])if v.get('name')=='Fan.Rotor')
# Integral of smoothstep velocity. Peak 0.25 visual revolutions/sec; two turns total.
def smooth_int(s):return s**3-.5*s**4
def turns(t):
if t<=4:return 0.
if t<8:return smooth_int((t-4)/4)
if t<12:return .5+.25*(t-8)
if t<16:
s=(t-12)/4;return 1.5+s-smooth_int(s)
return 2.
times=[i/20 for i in range(401)];angles=[-2*math.pi*turns(t)for t in times];quats=[[0,0,math.sin(v/2),math.cos(v/2)]for v in angles]
def accessor(v,kind):
blob.extend(b'\0'*(-len(blob)%4));start=len(blob);flat=[x for r in v for x in(r if isinstance(r,list)else[r])];blob.extend(struct.pack('<'+'f'*len(flat),*flat));view=len(g['bufferViews']);g['bufferViews'].append({'buffer':0,'byteOffset':start,'byteLength':len(flat)*4});a={'bufferView':view,'componentType':5126,'count':len(v),'type':kind}
if kind=='SCALAR':a.update(min=[min(v)],max=[max(v)])
i=len(g['accessors']);g['accessors'].append(a);return i
ti=accessor(times,'SCALAR');qi=accessor(quats,'VEC4');name='Fan.Hero.GentleStateCycle'
g['animations']=[{'name':name,'samplers':[{'input':ti,'output':qi,'interpolation':'LINEAR'}],'channels':[{'sampler':0,'target':{'node':rotor,'path':'rotation'}}],'extras':{'presentationOnly':True,'source':'simulated','speedScale':15/7000,'durationSeconds':20}}]
meta={'schema':'adom/hero-state-presentation@1','status':'authored-presentation-metadata','model':'mf40200v1-1000u-g99-hero.glb','animation':name,'node':'Fan.Rotor','source':'simulated','controlsHardware':False,'durationSeconds':20,'loop':True,'schedule':[{'from':0,'to':4,'state':'off'},{'from':4,'to':8,'state':'spin-up','easing':'smoothstep velocity'},{'from':8,'to':12,'state':'on'},{'from':12,'to':16,'state':'spin-down','easing':'smoothstep velocity'},{'from':16,'to':20,'state':'off'}],'peakRepresentedRpm':7000,'peakVisualRpm':15,'presentationTimeScale':15/7000,'meaning':'Illustrative slow-motion state demonstration, not measured speed or a physical inertia model','animationOff':'first frame stopped','repeatSeam':'two complete turns; stopped at both ends','stationary':['Fan.Housing','Fan.Connection']}
g['asset'].setdefault('extras',{})['adomHeroStatePresentation']=meta
g['nodes'][rotor].setdefault('extras',{})['tooltip']='Simulated fan state cycle; rotation slowed for presentation (peak visual 15 RPM, represents 7000 RPM)'
g['buffers'][0]['byteLength']=len(blob);j=json.dumps(g,separators=(',',':')).encode();j+=b' '*(-len(j)%4);blob+=b'\0'*(-len(blob)%4);out=P/'cad/harness/fan-hero-cycle-base.glb';out.write_bytes(struct.pack('<III',0x46546c67,2,28+len(j)+len(blob))+struct.pack('<II',len(j),0x4e4f534a)+j+struct.pack('<II',len(blob),0x004e4942)+blob)
s=json.loads((P/'states.json').read_text());s['heroPresentation']=meta;(P/'states.json').write_text(json.dumps(s,indent=2)+'\n')
assert all(turns(t)==0 for t in times if t<=4) and all(turns(t)==2 for t in times if t>=16)
assert max(abs((angles[i+1]-angles[i])/(times[i+1]-times[i]))for i in range(400))<=math.pi/2+1e-8
assert abs(sum(x*y for x,y in zip(quats[0],quats[-1]))-1)<1e-8
(P/'provenance/hero-cycle-verification.json').write_text(json.dumps({'status':'structural-check-passed-visual-review-pending','baseGlbSha256':hashlib.sha256(out.read_bytes()).hexdigest(),'animationCount':1,'targetNode':'Fan.Rotor','keyframes':401,'durationSeconds':20,'stationaryLeadAndHousingBranches':True,'totalTurns':2,'maximumVisualRpm':15,'stoppedAtRepeatSeam':True},indent=2)+'\n');print('Hero cycle generated; actual viewer review required.')