import {forward,resample} from './physics.mjs?v=motor1'; const $=id=>document.getElementById(id),canvas=$('canvas'),ctx=canvas.getContext('2d'); let lang='en';try{lang=localStorage.getItem('morph-language')||'en';}catch{} const translations={ en:{methods:'Method',reach:'Reach a point',shape:'Draw a shape',branch:'Explore branches',eyebrow:'PROGRAMMABLE FLEXIBLE STRUCTURES',live:'Live mechanics',rod:'Rod',target:'Target',scale:'Coordinates / reference length L',experiment:'TRY A SHAPE',load:'End load p',up:'Up',down:'Down',straight:'Straight','branch-help':'Dashed shapes: other equilibria at the same loads.',tip:'Endpoint',play:'Play loading',quasistatic:'Quasi-static loading · not physical time',controls:'Actuation · three zones','curvature-help':'Intrinsic curvature in each third of the rod.',reset:'Reset',export:'Export design ↗',footer:'Built with verified rod mechanics. Part of AgentFEM Morph Lab.',dataset:'Dataset ↗',models:'Models & code ↗','method-title':'How it works','method-body':'The browser solves a 48-element, extensible planar rod. Point and curve matching optimize three bounded intrinsic-curvature controls, with a small fixed end load. Results are calculated, not selected from a catalog. This demo uses the mechanics solver; the neural baselines are downloadable separately.','method-path':'Playback samples a ramp from zero actuation. Each displayed state is solved and checked for local stability; intervals between samples are not certified. No contact or dynamic snap-through is modeled.','method-paper':'The branch example implements the microforce mechanism of Ye, Piccolroaz & Bigoni (JMPS): χ=10⁻⁴, h=0.01, δ=0.1. Branch selection initializes a separate equilibrium solve; it is not a simulated transition between branches.',reachTitle:'Where should it go?',shapeTitle:'Give it a shape.',branchTitle:'One load. Different shapes.',reachSub:'Drag the target. Find the actuation.',shapeSub:'Draw from the clamp, or try a curve below.',branchSub:'Explore the microforce-driven rod of Ye et al.',reachHint:'Click or drag · arrow keys to fine-tune',shapeHint:'Draw from the white clamp',branchHint:'Change the load to explore the branches',error:'TARGET ERROR',shapeError:'CURVE RMS ERROR',stable:'Locally stable equilibrium',unstable:'Unstable equilibrium',critical:'Near a critical state',matched:'Target matched',approx:'Closest fit found · target not matched',busy:'Calculating…',failed:'Could not solve this request. Try a nearby target.',paused:'Pause',choose:'SOLVE A BRANCH',margin:'MIN. EIGENVALUE / χ',manual:'Manual actuation',drawing:'Draw your curve from the clamp.',playError:'Loading path not verified. Try another design.',preset:'Preset',other:'Other branches',actual:'Actual endpoint',targetLabel:'Target',clamp:'Clamp'}, zh:{methods:'方法',reach:'拖动目标',shape:'绘制曲线',branch:'力学分支',eyebrow:'可编程柔性结构',live:'实时力学计算',rod:'杆形状',target:'目标',scale:'坐标 / 初始杆长 L',experiment:'试一个形状',load:'端部载荷 p',up:'上弯',down:'下弯',straight:'直线','branch-help':'虚线表示同一加载下的其他平衡形状。',tip:'末端坐标',play:'播放加载',quasistatic:'准静态加载 · 不代表实际运动时间',controls:'驱动参数 · 三个分段','curvature-help':'各段的本征曲率,控制局部弯曲。',reset:'重置',export:'导出方案 ↗',footer:'以力学计算为基础,探索柔性结构的设计与驱动。',dataset:'数据集 ↗',models:'模型与代码 ↗','method-title':'计算方法','method-body':'浏览器直接求解 48 单元的平面可伸长杆,通过调整三段本征曲率匹配目标点或曲线,同时施加小幅固定端部载荷。结果来自计算,不是从案例库挑选。本网页使用力学求解器,神经网络基线可单独下载。','method-path':'播放的是从零驱动逐步加载的平衡形状,每个显示状态均求解并检查局部稳定性;采样点之间尚未作严格认证。当前不含接触和真实跳跃动力学。','method-paper':'分支案例采用 Ye、Piccolroaz 与 Bigoni 的 JMPS 微结构驱动模型:χ=10⁻⁴、h=0.01、δ=0.1。选择分支会初始化独立的平衡求解,不代表结构会自行跳到该分支。',reachTitle:'让它去哪里?',shapeTitle:'画出你想要的形状。',branchTitle:'同样加载,不同形状。',reachSub:'拖动目标点,寻找对应的驱动方案。',shapeSub:'从固定端开始画,也可以试试下方的曲线。',branchSub:'探索 JMPS 微结构驱动杆的平衡分支。',reachHint:'点击或拖动 · 方向键微调',shapeHint:'从白色固定端开始画',branchHint:'调整载荷,观察不同分支',error:'目标偏差',shapeError:'曲线均方根偏差',stable:'局部稳定平衡',unstable:'不稳定平衡',critical:'接近临界状态',matched:'已匹配目标',approx:'当前最佳拟合 · 尚未匹配目标',busy:'计算中…',failed:'这次未能求解,请试试附近的目标。',paused:'暂停',choose:'选择平衡分支',margin:'最小特征值 / χ',manual:'手动驱动',drawing:'请从白色固定端开始绘制。',playError:'加载路径尚未通过检查,请尝试其他方案。',preset:'预设',other:'其他分支',actual:'实际末端',targetLabel:'目标',clamp:'固定端'}}; translations.en.paused='Stop playback';translations.zh.paused='结束播放'; Object.assign(translations.en,{controls:'Actuation parameters',zones:'Actuation zones',shapeSub:'Start at the white clamp. Hold and drag; release to fit.',shapeHint:'Hold and draw · release to fit',drawing:'Start at the white clamp; hold and drag.',start:'Start here','curvature-help':'Intrinsic curvature controls the bending of each zone.'}); Object.assign(translations.zh,{controls:'驱动参数',zones:'驱动分区',shapeSub:'从白色固定端起笔,按住拖动绘制,松开计算。',shapeHint:'按住拖动绘制 · 松开计算',drawing:'从白色固定端起笔,按住鼠标拖动画线。',start:'从这里起笔','curvature-help':'各分区的本征曲率,控制局部弯曲。'}); translations.en['method-body']=translations.en['method-body'].replace('three bounded','3, 6, 12 or 24 bounded')+' The optional motor sets the base angle (±180°); the end force stays fixed in world coordinates. Stability is checked with the motor angle held. Branch mode retains its fixed clamp.'; translations.zh['method-body']=translations.zh['method-body'].replace('三段本征曲率','3、6、12 或 24 个分区的本征曲率')+' 可控底座角度范围为 ±180°,端部力保持世界坐标方向;稳定性检查时底座角度锁定。力学分支模式仍使用固定底座。'; Object.assign(translations.en,{base:'Base',fixed:'Fixed',motor:'Motorized',angle:'Base angle',complex:'Alternating bends',motorHelp:'Target matching also adjusts the base angle.',view:'Reset view',zoomIn:'Zoom in',zoomOut:'Zoom out'}); Object.assign(translations.zh,{base:'底座',fixed:'固定',motor:'可控旋转',angle:'底座角度',complex:'多次转弯',motorHelp:'匹配目标时,底座角度与各段弯曲一起调整。',view:'复位视野',zoomIn:'放大',zoomOut:'缩小'}); const T=k=>translations[lang][k]||k; let mode='reach',state=forward([1,.6,-.3]),k=[1,.6,-.3],target=[.78,.48],curve=null,branchStates=null,branch='down',error=null,manual=false; let rotating=false,baseAngle=0,zoom=1; let currentId=0,busy=false,pending=null,active=null,playing=false,frames=null,playStart=0,raf=0,stroke=null,dragging=false; const worker=new Worker('./worker.mjs?v=motor1',{type:'module'}); const stop=()=>{playing=false;cancelAnimationFrame(raf);$('progress').style.width='0%';updatePlay();}; function updatePlay(){$('play').innerHTML=` ${T(playing?'paused':'play')}`;} function queue(task,args={}){const job={id:++currentId,task,rotating:mode!=='branch'&&rotating,baseAngle:mode==='branch'?0:baseAngle,...args};pending=job;stop();$('status').textContent=T('busy');$('play').disabled=true;$('export').disabled=true;if(!busy)dispatch();} function dispatch(){if(!pending)return;active=pending;pending=null;busy=true;worker.postMessage(active);} worker.onmessage=({data})=>{busy=false;if(data.id===currentId){if(data.error){$('status').textContent=T('failed');$('play').disabled=true;$('export').disabled=true;}else{ const r=data.result; if(data.task==='design'){state=r.state;k=r.k;baseAngle=r.baseAngle;error=r.error;manual=false;frames=null;} if(data.task==='forward'){state=r.state;k=r.k;error=null;manual=true;frames=null;} if(data.task==='branches'){branchStates=r;state=r[branch];error=null;frames=null;} if(data.task==='path'){frames=r;playing=true;playStart=performance.now();animate();} renderResult();draw(); }}if(pending)dispatch();}; worker.onerror=()=>{busy=false;pending=null;$('status').textContent=T('failed');$('play').disabled=true;}; function renderResult(){ $('metric-label').textContent=T(mode==='branch'?'margin':mode==='shape'?'shapeError':'error'); $('error-unit').textContent=mode==='branch'?'':'% L'; $('error-value').textContent=mode==='branch'?(state.lambda?.toFixed(2)||'—'):error===null?'—':(100*error).toFixed(2); $('status').textContent=mode==='branch'?T(state.stability):manual?T('manual'):T(error<.005?'matched':'approx'); $('status').style.color=mode==='branch'&&state.stability!=='stable'||error>=.005?'#dfad7b':'#87d5c7'; $('tip-value').textContent=`${state.tip[0].toFixed(3)}, ${state.tip[1].toFixed(3)}`; for(let i=1;i<=k.length;i++){$('k'+i).value=k[i-1];$('k'+i+'-value').textContent=k[i-1].toFixed(2);} $('zone-select').hidden=mode==='branch'; $('base-control').hidden=mode==='branch';$('angle-row').hidden=!rotating||mode==='branch'; $('base-angle').value=baseAngle*180/Math.PI;$('angle-value').textContent=`${(baseAngle*180/Math.PI).toFixed(0)}°`; $('play').disabled=busy||mode==='branch'||!state.accepted;$('export').disabled=busy||!state.accepted;updatePlay(); } function animate(){if(!playing||!frames)return;const t=Math.min(1,(performance.now()-playStart)/3000);state=frames[Math.round(t*(frames.length-1))];$('progress').style.width=100*t+'%';$('tip-value').textContent=`${state.tip[0].toFixed(3)}, ${state.tip[1].toFixed(3)}`;const liveError=target?Math.hypot(state.tip[0]-target[0],state.tip[1]-target[1]):curve?Math.sqrt(state.xy.reduce((v,p,i)=>v+(p[0]-curve[i][0])**2+(p[1]-curve[i][1])**2,0)/curve.length):null;$('error-value').textContent=liveError===null?'—':(100*liveError).toFixed(2);$('status').textContent=T(liveError===null?'manual':liveError<.005?'matched':'approx');draw();if(t<1)raf=requestAnimationFrame(animate);else{playing=false;renderResult();}} $('play').onclick=()=>{if(playing){stop();state=frames.at(-1);renderResult();draw();return;}if(frames){playing=true;playStart=performance.now();animate();}else queue('path',{k});}; const presetK=[[1.4,1.4,1.4],[1.8,-1.8,-1.2],[-.9,1.6,1.7],Array.from({length:24},(_,i)=>2.3*Math.cos((i+.5)*Math.PI/6))]; function presets(){const p=$('presets');p.replaceChildren();presetK.forEach((ks,i)=>{const b=document.createElement('button'),xy=forward(ks).xy;const path=xy.map((p,j)=>`${j?'L':'M'}${5+p[0]*32},${19-p[1]*13}`).join(' ');b.innerHTML=``;b.title=i===3?T('complex'):`${T('preset')} ${i+1}`;b.setAttribute('aria-label',b.title);b.onclick=()=>{if(i===3&&mode!=='shape')setMode('shape');if(mode==='reach'){target=forward(ks).tip;curve=null;queue('design',{target,warm:k});}else{curve=forward(ks).xy;target=null;queue('design',{target:curve,shape:true,warm:k});}draw();};p.append(b);});} function translate(){document.documentElement.lang=lang==='zh'?'zh-CN':'en';document.querySelectorAll('[data-i18n]').forEach(e=>e.textContent=T(e.dataset.i18n));$('language').textContent=lang==='en'?'中文':'EN';$('title').textContent=T(mode+'Title');$('subtitle').textContent=T(mode+'Sub');$('interaction-hint').textContent=T(mode+'Hint');$('legend-target').textContent=T(mode==='branch'?'other':'target');canvas.setAttribute('aria-label',T(mode+'Hint'));presets();renderResult();draw();} function setMode(next){mode=next;stop();curve=null;target=null;error=null;branchStates=null;manual=false;document.querySelectorAll('[data-mode]').forEach(b=>{const on=b.dataset.mode===mode;b.classList.toggle('active',on);b.setAttribute('aria-pressed',on);});$('branch-controls').hidden=mode!=='branch';$('presets').hidden=mode==='branch';$('controls').hidden=mode==='branch';$('motion-controls').hidden=mode==='branch';translate();resize();document.querySelector('.section-caption').textContent=T(mode==='branch'?'choose':'experiment');if(mode==='branch')queue('branches',{p:Number($('load').value)});else if(mode==='reach'){target=[.78,.48];queue('design',{target,warm:k});}else{curve=forward(presetK[1]).xy;queue('design',{target:curve,shape:true,warm:k});}} document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode)); document.querySelectorAll('[data-branch]').forEach(b=>b.onclick=()=>{branch=b.dataset.branch;document.querySelectorAll('[data-branch]').forEach(v=>v.classList.toggle('active',v===b));if(branchStates){state=branchStates[branch];renderResult();draw();}}); $('load').oninput=()=>{$('load-value').textContent=Number($('load').value).toFixed(6);queue('branches',{p:Number($('load').value)});}; const zoneSelect=document.createElement('div');zoneSelect.id='zone-select';zoneSelect.innerHTML='';$('presets').after(zoneSelect); const baseControl=document.createElement('div');baseControl.id='base-control';baseControl.innerHTML='';zoneSelect.after(baseControl); const angleRow=document.createElement('div');angleRow.id='angle-row';angleRow.innerHTML='0°

';$('controls').append(angleRow); $('base-mode').onchange=()=>{rotating=$('base-mode').value==='motor';if(!rotating)baseAngle=0;zoom=1;resize();if(target||curve)queue('design',{target:curve||target,shape:!!curve,warm:k});else queue('forward',{k});renderResult();}; $('base-angle').oninput=()=>{baseAngle=Number($('base-angle').value)*Math.PI/180;target=null;curve=null;queue('forward',{k});}; const viewTools=document.createElement('div');viewTools.className='view-tools';viewTools.innerHTML='';document.querySelector('.stage').append(viewTools); for(const [id,factor] of [['zoom-in',1.2],['zoom-out',1/1.2],['zoom-reset',0]])$(id).onclick=()=>{zoom=factor?Math.max(.65,Math.min(2,zoom*factor)):1;resize();}; function buildControls(){document.querySelectorAll('.zone-row').forEach(e=>e.remove());const fragment=document.createDocumentFragment();k.forEach((v,j)=>{const i=j+1,row=document.createElement('div');row.className='zone-row';row.innerHTML=`${v.toFixed(2)}`;fragment.append(row);row.querySelector('input').oninput=()=>{k=k.map((_,a)=>Number($('k'+(a+1)).value));target=null;curve=null;queue('forward',{k});};});$('controls').querySelector('summary').after(fragment);} $('zones').onchange=()=>{const count=Number($('zones').value),old=k.slice();k=Array.from({length:count},(_,i)=>old[Math.min(old.length-1,Math.floor((i+.5)*old.length/count))]);buildControls();if(target||curve)queue('design',{target:curve||target,shape:!!curve,warm:k});else queue('forward',{k});draw();}; buildControls(); $('language').onclick=()=>{lang=lang==='en'?'zh':'en';try{localStorage.setItem('morph-language',lang);}catch{}translate();document.querySelector('.section-caption').textContent=T(mode==='branch'?'choose':'experiment');}; $('reset').onclick=()=>setMode(mode); $('methods').onclick=()=>$('method-dialog').showModal();$('close-method').onclick=()=>$('method-dialog').close();$('method-dialog').onclick=e=>{if(e.target===$('method-dialog')){const b=e.target.getBoundingClientRect();if(e.clientXb.right||e.clientYb.bottom)e.target.close();}}; $('export').onclick=()=>{const finalState=mode==='branch'?state:forward(k,true,baseAngle);const record={version:'0.3.0',mode,source:'browser_48_element_mechanics',dimensionless:true,target,curve,controls:mode==='branch'?{chi:.0001,h:.01,delta:.1,p:Number($('load').value),branch}:{chi:.001,fx:.0003,fy:-.0001,k,base_mode:rotating?'motor':'fixed',base_angle_rad:baseAngle,force_frame:'world'},error_over_L:error,result:finalState,playback:'sampled_quasistatic_equilibria_not_physical_time'};const url=URL.createObjectURL(new Blob([JSON.stringify(record,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='morph-design.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),5000);}; let w=0,h=0,scale=1,ox=0,oy=0; function resize(){const r=canvas.getBoundingClientRect();w=r.width;h=r.height;const d=Math.min(devicePixelRatio||1,2);canvas.width=w*d;canvas.height=h*d;ctx.setTransform(d,0,0,d,0,0);const allAround=rotating&&mode!=='branch';scale=zoom*Math.min((w-100)/(allAround?2.2:1.35),(h-105)/(allAround?2.2:1.3));ox=allAround?w/2:w/2-.45*scale;oy=allAround||mode==='branch'?h/2+5:h*.61;draw();} const screen=p=>[ox+p[0]*scale,oy-p[1]*scale]; function line(points,color,width,dash=[]){ctx.beginPath();points.forEach((p,i)=>{const [x,y]=screen(p);if(i)ctx.lineTo(x,y);else ctx.moveTo(x,y);});ctx.strokeStyle=color;ctx.lineWidth=width;ctx.setLineDash(dash);ctx.lineCap='round';ctx.lineJoin='round';ctx.stroke();ctx.setLineDash([]);} function draw(){if(!w)return;ctx.clearRect(0,0,w,h); ctx.strokeStyle='#26404755';ctx.lineWidth=.6;for(let x=-.5;x<=1.25;x+=.25){const a=screen([x,-1]),b=screen([x,1]);ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.stroke();}for(let y=-1;y<=1;y+=.25){const a=screen([-.5,y]),b=screen([1.25,y]);ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.stroke();} ctx.font='10px sans-serif';ctx.fillStyle='#66858b';for(const x of [.5,1]){const p=screen([x,0]);ctx.fillText(String(x),p[0]-8,p[1]+21);}ctx.fillText('x/L',w-44,oy-9);ctx.fillText('y/L',ox+8,55); line([[0,0],[1,0]],'#6b828744',1,[4,6]); if(mode==='branch'&&branchStates)for(const [key,s] of Object.entries(branchStates))if(key!==branch)line(s.xy,key==='straight'?'#dfad7b88':'#87d5c766',2,[5,6]); if(curve)line(curve,'#dfad7bbb',2,[4,5]);if(stroke)line(stroke,'#efc59a',2,[3,4]); if(state){ line(state.xy,'#020b0e',18); const colors=['#a4e5d7','#79c9c2','#56a6b3'],count=mode==='branch'?1:k.length,step=48/count;for(let j=0;j.005){ctx.setLineDash([3,4]);ctx.strokeStyle='#dfad7b88';ctx.beginPath();ctx.moveTo(...p);ctx.lineTo(...a);ctx.stroke();ctx.setLineDash([]);}ctx.strokeStyle='#efbc88';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...p,13,0,Math.PI*2);ctx.stroke();ctx.beginPath();ctx.arc(...p,3,0,Math.PI*2);ctx.fillStyle='#efbc88';ctx.fill();ctx.fillStyle='#e1ba91';ctx.font='11px sans-serif';ctx.fillText(T('targetLabel'),Math.min(w-60,p[0]+19),Math.max(65,p[1]-16));} } function point(e){const r=canvas.getBoundingClientRect();return [(e.clientX-r.left-ox)/scale,-(e.clientY-r.top-oy)/scale];} let lastMove=0; document.addEventListener('pointerdown',()=>canvas.classList.remove('keyboard-focus'),true); document.addEventListener('keydown',e=>{if(e.key==='Tab')canvas.classList.add('keyboard-focus');},true); canvas.onpointerdown=e=>{if(mode==='branch')return;stop();canvas.focus({preventScroll:true});const p=point(e);if(mode==='shape'){if(Math.hypot(...p)>.22){$('status').textContent=T('drawing');return;}stroke=[[0,0],p];curve=null;}else{target=p;curve=null;queue('design',{target,warm:k});}dragging=true;canvas.setPointerCapture(e.pointerId);draw();}; canvas.onpointermove=e=>{if(!dragging)return;const p=point(e);if(mode==='shape'){if(Math.hypot(p[0]-stroke.at(-1)[0],p[1]-stroke.at(-1)[1])>.007)stroke.push(p);}else{target=p;if(performance.now()-lastMove>90){lastMove=performance.now();queue('design',{target,warm:k});}}draw();}; function finish(e){if(!dragging)return;dragging=false;if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId);if(mode==='shape'){if(stroke.length>4){curve=resample(stroke);target=null;queue('design',{target:curve,shape:true,warm:k});}stroke=null;}else queue('design',{target,warm:k});draw();} canvas.onpointerup=finish;canvas.onpointercancel=finish; canvas.onkeydown=e=>{if(mode!=='reach'||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();target=(target||state.tip).slice();const d=e.shiftKey?.005:.025;target[0]+=e.key==='ArrowLeft'?-d:e.key==='ArrowRight'?d:0;target[1]+=e.key==='ArrowUp'?d:e.key==='ArrowDown'?-d:0;queue('design',{target,warm:k});draw();}; new ResizeObserver(resize).observe(canvas);translate();setMode('reach'); // Read-only diagnostic hook for numerical/browser regression checks. window.morphSnapshot=()=>({mode,k,target,curve,error,busy,pending:!!pending,state,playing,rotating,baseAngle,view:{scale,ox,oy}});