Pipe Assembly Lab: task-led thermal and damper comparison, automatic baselines and matched views
8122f59 verified Download app.js from HaomingLuo/AgentFEM-Pipe-Assembly-Lab: direct link, hf CLI and curl.
- Browser
- Download file 61.1 kB
-
https://huggingface.co/spaces/HaomingLuo/AgentFEM-Pipe-Assembly-Lab/resolve/main/app.js
- Command line
-
hf download hf://spaces/HaomingLuo/AgentFEM-Pipe-Assembly-Lab/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HaomingLuo/AgentFEM-Pipe-Assembly-Lab/resolve/main/app.js
61.1 kB
| import * as THREE from 'three'; | |
| import {OrbitControls} from './vendor/OrbitControls.js'; | |
| import {VERSION,validateProject,removeComponent} from './project.js'; | |
| import {setupEngineering} from './engineering.js'; | |
| import {setupPower} from './power.js'; | |
| import {setupJourney} from './journey.js'; | |
| let journey; | |
| let power; | |
| const $=id=>document.getElementById(id); | |
| const words={en:{title:'Pipe Assembly Lab',subtitle:'Thermal movement · support loads · vibration',method:'How it works',route:'01 / ROUTE',reset:'Reset',spatial:'3D',diameter:'Outer diameter',wall:'Wall thickness',addStraight:'+ Straight',addElbow:'+ Elbow',remove:'Remove',bendPlane:'Bend direction',left:'Left',right:'Right',up:'Up',down:'Down',supports:'02 / SUPPORTS',station:'Connection',constraint:'Support',free:'Free',spring:'Vertical spring',guide:'Vertical restraint',fixed:'Fixed',stiffness:'Spring stiffness',rootFixed:'The inlet is fixed. Select a joint to add a support.',loading:'03 / LOADING',gravity:'Gravity',temperature:'Uniform temperature rise',material:'Steel · E 200 GPa · α 12 × 10⁻⁶/K',live:'COMPONENT ROM / LIVE SOLVE',reference:'Original shape',thermalView:'Expansion',scale:'Display',displacement:'Max. centreline displacement',reaction:'Largest support force',frequency:'First natural frequency',mass:'Pipe mass',gesture:'Drag to orbit · Scroll to zoom',footer:'AgentFEM · Component reduced-order models',aboutTitle:'Model and assumptions',sceneTitle:'Thermal displacement',sceneCaption:'Displacement and support reactions under heating and gravity.',modeTitle:'Natural frequencies and modes',modeCaption:'Mode shapes for the current route and supports.',deformed:'Deformed shape',modeShape:'Mode shape · normalized amplitude',loadingData:'Loading components…',solving:'Solving…',ready:'',component:'Component',length:'Length',radius:'Bend radius',joint:'Joint',inlet:'Inlet · fixed',limit:'Up to 10 components per route.',error:'Unable to solve. Check the route and supports.',overlap:'Parts of this route intersect. Contact is not modeled.',large:'Large displacement relative to pipe size; small-displacement model may not apply.',endpoint:'End',about:`<p>Each part starts as a three-dimensional hollow-pipe finite-element model, assembled with AgentFEM. Static constraint modes and 12 fixed-interface modes reduce it to a compact, reusable component.</p><p>Your browser connects those components, enforces support conditions, and solves equilibrium and the first three natural frequencies. This is a <b>physics-based reduced-order model</b>, not a neural network or a nearest-neighbour replay.</p><p><b>What you can explore</b><br>Uniform heating, gravity, fixed supports, vertical restraints and linear vertical springs. The animation scales a static solution; it is not a transient heat-transfer calculation. Mode amplitudes are normalized, not forced-vibration predictions.</p><p><b>Model</b><br>Linear elastic steel; small displacement; dry pipe. Bend components include straight tangents of 2 diameters at both ends. Ports enforce rigid mechanical cross-sections and allow prescribed radial thermal expansion. Centreline motion is displayed, not a local stress assessment. Pressure, fluid mass, contact, support preload and fatigue are outside this release.</p><p><a href="https://huggingface.co/datasets/HaomingLuo/AgentFEM-Pipe-Components" target="_blank" rel="noopener">Data & validation ↗</a> · <a href="https://huggingface.co/HaomingLuo/AgentFEM-Pipe-ROM" target="_blank" rel="noopener">Operators & source ↗</a></p>`},zh:{title:'管道搭建实验室',subtitle:'热位移 · 支吊架载荷 · 振动响应',method:'数据与方法',route:'01 / 搭建管路',reset:'重置',spatial:'空间管路',diameter:'管道外径',wall:'管壁厚度',addStraight:'+ 直管',addElbow:'+ 弯管',remove:'删除',bendPlane:'弯曲方向',left:'向左',right:'向右',up:'向上',down:'向下',supports:'02 / 设置支撑',station:'连接位置',constraint:'支撑形式',free:'无支撑',spring:'竖向弹簧',guide:'限制竖向位移',fixed:'固定',stiffness:'弹簧刚度',rootFixed:'入口固定。选择连接点,可增设支撑。',loading:'03 / 施加载荷',gravity:'自重',temperature:'管壁均匀温升',material:'钢 · 弹性模量 200 GPa · 热膨胀系数 12 × 10⁻⁶/K',live:'实体有限元组件降阶 / 浏览器实时求解',reference:'原始轮廓',thermalView:'热膨胀',scale:'显示',displacement:'中心线最大位移',reaction:'最大支撑力',frequency:'一阶固有频率',mass:'管道质量',gesture:'拖动旋转 · 滚动缩放',footer:'AgentFEM · 管道组件降阶模型',aboutTitle:'模型与计算条件',sceneTitle:'热位移',sceneCaption:'均匀受热与自重下的位移、支撑力。',modeTitle:'固有频率与振型',modeCaption:'当前管路与支撑条件下的模态。',deformed:'变形轮廓',modeShape:'振型 · 幅值归一化显示',loadingData:'正在载入组件…',solving:'正在计算…',ready:'',component:'组件',length:'直管长度',radius:'弯曲半径',joint:'连接点',inlet:'入口 · 固定',limit:'每条管路最多 10 个组件。',error:'暂时无法求解,请检查管路与支撑。',overlap:'当前管路存在相交部分,本模型不计算接触。',large:'位移相对管径较大,小变形假设可能不再适用。',endpoint:'末端',about:`<p>每个组件都来自 AgentFEM 装配的三维空心管实体有限元模型。保留静力约束模态与 12 阶内部模态,将大模型缩减为可重复拼接的小模型。</p><p>浏览器连接组件、施加支撑,实时计算静力平衡和前三阶固有频率。这里使用的是<b>基于力学的组件降阶模型</b>,不是神经网络,也不是查找相近结果。</p><p><b>可以观察什么</b><br>均匀受热、自重、固定支撑、竖向约束与线性弹簧下的管道响应。热膨胀动画仅缩放静力结果,不代表瞬态传热过程;振型幅值经过归一化,不代表实际受迫振动幅值。</p><p><b>模型条件</b><br>线弹性钢材、小变形、空管。弯管两端各含 2 倍管径的真实直管过渡段;接口采用刚性机械截面,并允许规定的径向热膨胀。显示中心线运动,不作局部应力评定。暂不包含内压、流体质量、接触、支撑预载和疲劳。</p><p><a href="https://huggingface.co/datasets/HaomingLuo/AgentFEM-Pipe-Components" target="_blank" rel="noopener">数据与验证 ↗</a> · <a href="https://huggingface.co/HaomingLuo/AgentFEM-Pipe-ROM" target="_blank" rel="noopener">模型与源码 ↗</a></p>`}}; | |
| let lang=new URLSearchParams(location.search).get('lang')==='zh'?'zh':'en'; | |
| const t=k=>words[lang][k]||k; | |
| let route=[],selected=0,station=0,supports={},diameter=.2,wall=.06,result=null,view='static',playing=false,phase=Math.PI/1.4,requestId=0,needsFit=true,components=[]; | |
| let damper={node:3,axis:2,c:10000},baseline=null,pending=false,solvedDesign=null,dynamicTime=0,previewKind=null; | |
| let excitation={node:3,axis:2,force:100,frequency:10,zeta:.02},engineering; | |
| const undoStack=[],redoStack=[];let lastDesign=null,historyMuted=false; | |
| Object.assign(words.en,{undo:'Undo',redo:'Redo',baseline:'Keep baseline',review:'Compare & export',save:'Save',open:'Open',damperTitle:'04 / VIBRATION & DAMPER',releaseHint:'First-mode release · initial peak 1 mm · zero initial velocity',axis:'Global direction',damping:'Damping coefficient',release:'Release & compare',extend:'Extend this end',confirm:'Connect',cancel:'Cancel',report:'Print / PDF',decayTitle:'Free vibration decay',decayCaption:'Same initial displacement. One grounded viscous damper.',without:'No damper',with:'With damper',custom:'Custom route',saved:'Project downloaded',invalid:'Invalid or incompatible project file.',baselineSaved:'Baseline retained',removed:'Segment removed; attachments on its downstream joint were removed.',noBaseline:'Keep a baseline, then change the route or supports.',current:'Current',comparison:'Baseline',modelScope:'Linear elastic dry pipe · small displacement · preliminary comparison, not a code compliance assessment.',blockedDamper:'The selected direction is restrained: the damper cannot move.',probe:'Measurement',peakForce:'Peak damper force',energyLeft:'Energy remaining',pending:'Updating…',reportHint:'Static values are unamplified. Reactions are forces acting on the pipe; moments are listed separately.',autoSaved:'Restored local draft',resetHint:'Choose a template',staticTitle:'Thermal displacement',jointSelect:'Select joint'}); | |
| Object.assign(words.zh,{undo:'撤销',redo:'重做',baseline:'保留为对照',review:'对比与导出',save:'保存',open:'打开',damperTitle:'04 / 振动与阻尼器',releaseHint:'一阶振型释放 · 初始峰值 1 mm · 初速度为零',axis:'全局方向',damping:'阻尼系数',release:'释放并对比',extend:'从末端继续搭建',confirm:'接上',cancel:'取消',report:'打印 / PDF',decayTitle:'自由振动衰减',decayCaption:'相同初始变形,对比接地黏性阻尼器的作用。',without:'无阻尼器',with:'有阻尼器',custom:'自定义管路',saved:'方案已下载',invalid:'方案文件无效或模型版本不兼容。',baselineSaved:'已保留对照方案',removed:'已删除管段及其下游连接点上的附件,其余支撑保留。',noBaseline:'先保留对照,再调整管路或支撑。',current:'当前方案',comparison:'对照方案',modelScope:'线弹性空管 · 小变形 · 用于方案初步比选,不作规范合格判定。',blockedDamper:'该方向已被约束,阻尼器无法运动。',probe:'测点',peakForce:'阻尼器峰值作用力',energyLeft:'剩余振动能量',pending:'正在更新…',reportHint:'静力数值均为真实量,未乘显示倍数。反力为支撑对管道的作用力,力矩单独列出。',autoSaved:'已恢复本地草稿',resetHint:'选择初始管路',staticTitle:'受热位移',jointSelect:'选择连接点'}); | |
| const cache=new Map(),worker=new Worker('./worker.js');let catalog=[]; | |
| words.zh.energyLeft='结束时剩余能量';words.en.energyLeft='Energy at end of window'; | |
| function translate(){document.documentElement.lang=lang;for(const [id,zh,en] of [['fit','适应视图','Fit view'],['play','播放或暂停','Play or pause'],['toggle-tools','展开或收起工具','Toggle controls'],['segments','管道组件','Pipe components'],['scene','交互式三维管道模型','Interactive three-dimensional pipe model']]){$(id).setAttribute('aria-label',lang==='zh'?zh:en);if(id==='fit')$(id).title=lang==='zh'?zh:en;}document.querySelectorAll('[data-t]').forEach(e=>e.textContent=t(e.dataset.t));$('language').textContent=lang==='en'?'中文':'EN';$('about-copy').innerHTML=t('about')+(lang==='zh'?'<p><b>振动衰减</b><br>相同的一阶振型初始变形(最大坐标分量 1 mm),初速度为零;围绕线性平衡位置释放。单个接地黏性阻尼器 F = −c v,保持完整的非比例模态阻尼矩阵。保留 12 阶系统模态,采用 Newmark 平均加速度法,不加背景阻尼。动画放慢至 8 秒,图中时间为物理时间;不模拟瞬态升温、地震或实际振源。</p>':'<p><b>Free decay</b><br>First-mode release: largest coordinate component 1 mm, zero initial velocity, about linear equilibrium. One grounded viscous damper F = −c v, full non-proportional modal damping, 12 system modes, Newmark average acceleration, no background damping. Playback is stretched to 8 seconds; the chart uses physical time. No transient heating, seismic input or operating excitation is modeled.</p>');updateSceneLabels();renderEditor();syncExtras();geometryStamp='';} | |
| function updateSceneLabels(){const d=view==='decay',m=view!=='static'&&!d;$('scene-title').textContent=t(d?'decayTitle':m?'modeTitle':'sceneTitle');$('scene-caption').textContent=t(d?'decayCaption':m?'modeCaption':'sceneCaption');$('reference-label').textContent=t(d?'without':'reference');$('result-label').textContent=t(d?'with':m?'modeShape':'deformed');$('scale').disabled=m;$('play').textContent=playing?'Ⅱ':'▶';$('trace-panel').hidden=!d;document.querySelectorAll('[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===view));updateMetrics();} | |
| function updateMetrics(){if(!result)return;const cards=[...document.querySelectorAll('.metrics article')];const staticValues=[(result.maxDisplacement*1000).toFixed(2),(Math.max(...result.supportForces.map(v=>Math.hypot(...v)))/1000).toFixed(2),result.frequency[0].toFixed(2)];for(let i=0;i<3;i++){const modal=view!=='static'&&view!=='decay';cards[i].querySelector('span').textContent=modal?(lang==='zh'?['一阶固有频率','二阶固有频率','三阶固有频率'][i]:['First natural frequency','Second natural frequency','Third natural frequency'][i]):t(['displacement','reaction','frequency'][i]);cards[i].querySelector('strong').textContent=modal?result.frequency[i].toFixed(2):staticValues[i];cards[i].querySelector('strong').style.color=modal&&+view===i?'var(--accent)':'';cards[i].querySelector('small').textContent=modal?'Hz':['mm','kN','Hz'][i];}if(view==='decay'&&result.dynamics){const d=result.dynamics;cards[0].querySelector('span').textContent=t('peakForce');cards[0].querySelector('strong').textContent=(d.peakForce/1000).toFixed(3);cards[0].querySelector('small').textContent='kN';cards[1].querySelector('span').textContent=t('energyLeft');cards[1].querySelector('strong').textContent=(100*d.energy.at(-1)/d.initialEnergy).toFixed(1);cards[1].querySelector('small').textContent='%';} } | |
| function preset(name){const s=()=>({kind:'straight',ratio:5,roll:0}),e=(roll=0)=>({kind:'elbow',ratio:2,roll});route=name==='U'?[s(),e(),s(),e(),s()]:name==='spatial'?[s(),e(),s(),e(90),s()]:[s(),e(),s()];selected=0;station=0;supports={};needsFit=true;document.querySelectorAll('[data-preset]').forEach(b=>b.classList.toggle('active',b.dataset.preset===name));renderEditor();schedule();} | |
| function renderEditor(){if(!route.length)return;selected=Math.min(selected,route.length-1);station=Math.min(station,route.length);const c=route[selected];$('segments').replaceChildren(...route.map((r,i)=>{let b=document.createElement('button');b.textContent=`${i+1} ${r.kind==='straight'?'━':'╰'}`;b.className=i===selected?'active':'';b.title=`${t('component')} ${i+1}`;b.onclick=()=>{selected=i;renderEditor();highlight();};return b;}));$('selected-label').textContent=`${t('component')} ${selected+1}`;$('dimension-label').textContent=t(c.kind==='straight'?'length':'radius');$('dimension').replaceChildren(...(c.kind==='straight'?[3,5,8]:[1.5,2,3]).map(r=>new Option(`${Math.round(r*diameter*1000)} mm`,r)));$('dimension').value=c.ratio;$('roll-field').hidden=c.kind!=='elbow';$('roll').value=c.roll;$('remove').disabled=route.length===1;$('station').replaceChildren(...Array.from({length:route.length+1},(_,i)=>new Option(i===0?t('inlet'):`${t('joint')} ${i}${i===route.length?' · '+t('endpoint'):''}`,i)));$('station').value=station;$('support').disabled=station===0;$('support').value=station===0?'fixed':supports[station]?.type||'none';$('spring-field').hidden=$('support').value!=='spring';$('spring').value=Math.log10((supports[station]?.k||100000)/1000);$('spring-value').textContent=`${Math.round(10**+$('spring').value)} kN/m`;[...$('wall').options].forEach(o=>o.textContent=`${+(diameter*+o.value*1000).toFixed(1)} mm`);$('add-straight').disabled=$('add-elbow').disabled=route.length>=10;} | |
| let timer;function schedule(){requestId++;pending=true;document.body.classList.add('solving');$('status').textContent=t('pending');['baseline','review','save-project'].forEach(id=>$(id).disabled=true);clearTimeout(timer);clearPreview();timer=setTimeout(calculate,90);} | |
| async function calculate(){const id=++requestId;$('status').textContent=t('loadingData');try{const design=getDesign(),snapshot=design.route; | |
| const loaded=await Promise.all(snapshot.map(r=>loadComponent(r,design.diameter,design.wall)));if(id!==requestId)return;components=loaded; | |
| $('status').textContent=t('solving');const ss=[{node:0,fixed:[0,1,2,3,4,5]}];Object.entries(design.supports).forEach(([node,s])=>{if(+node>snapshot.length||+node===0)return;if(s.type==='fixed')ss.push({node:+node,fixed:[0,1,2,3,4,5]});if(s.type==='guide')ss.push({node:+node,fixed:[2]});if(s.type==='spring')ss.push({node:+node,spring:[0,0,s.k,0,0,0]});}); | |
| solvedDesign=design;worker.postMessage({id,request:{components,rolls:snapshot.map(r=>r.roll*Math.PI/180),deltaT:design.temperature,gravity:design.gravity?[0,0,-9.81]:[0,0,0],supports:ss,...(view==='decay'?{dynamic:design.damper}:view==='forced'?{harmonic:{...design.excitation,damper:design.damper}}:{})}}); | |
| }catch(e){if(id===requestId){$('status').textContent=t('error');console.error(e);}}} | |
| async function loadComponent(r,d=diameter,w=wall){const entry=catalog.find(c=>c.kind===r.kind&&Math.abs(c.diameter-d)<1e-9&&Math.abs(c.thickness/d-w)<1e-9&&Math.abs((r.kind==='straight'?c.length:c.radius)/d-r.ratio)<1e-9);if(!entry)throw Error('COMPONENT');if(!cache.has(entry.id))cache.set(entry.id,fetch('./data/'+entry.file).then(r=>{if(!r.ok)throw Error('FETCH');return r.json();}).catch(e=>{cache.delete(entry.id);throw e;}));return cache.get(entry.id);} | |
| worker.onmessage=({data})=>{if(data.id!==requestId)return;if(data.error){$('status').textContent=t('error');console.error(data.error);return;}result=data.result;pending=false;['baseline','review','save-project'].forEach(id=>$(id).disabled=false);dynamicTime=0; | |
| document.body.classList.remove('solving');$('mass').textContent=result.mass.toFixed(1);$('solve-time').textContent=`${Math.round(result.elapsedMs)} ms · ${route.length} ${lang==='zh'?'个组件':'components'}`;$('status').textContent=overlap(result.paths)?t('overlap'):result.maxDisplacement>diameter*.15?t('large'):result.dynamics?.blocked?t('blockedDamper'):'';rebuild();syncExtras();updateSceneLabels();try{localStorage.setItem('agentfem-pipe-draft',JSON.stringify(projectEnvelope(solvedDesign)));}catch{}if(needsFit){fit();needsFit=false;}journey?.solved();}; | |
| function overlap(paths){for(let a=0;a<paths.length;a++)for(let b=a+2;b<paths.length;b++)for(let i=0;i<paths[a].length;i+=3)for(let j=0;j<paths[b].length;j+=3)if(Math.hypot(...paths[a][i].map((v,k)=>v-paths[b][j][k]))<diameter*.92)return true;return false;} | |
| $('language').onclick=()=>{lang=lang==='en'?'zh':'en';translate();journey?.sync();if(result)$('solve-time').textContent=`${Math.round(result.elapsedMs)} ms · ${route.length} ${lang==='zh'?'个组件':'components'}`;};$('method').onclick=()=>$('about').showModal();$('close-about').onclick=()=>$('about').close();$('about').onclick=e=>{if(e.target===$('about')){const r=$('about').getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)$('about').close();}}; | |
| document.querySelectorAll('[data-preset]').forEach(b=>b.onclick=()=>{preset(b.dataset.preset);damper.node=route.length;syncExtras();});$('reset').onclick=()=>{preset('L');damper.node=route.length;syncExtras();};$('diameter').onchange=()=>{diameter=+$('diameter').value;needsFit=true;renderEditor();schedule();};$('wall').onchange=()=>{wall=+$('wall').value;schedule();};$('dimension').onchange=()=>{route[selected].ratio=+$('dimension').value;needsFit=true;schedule();};$('roll').onchange=()=>{route[selected].roll=+$('roll').value;needsFit=true;schedule();};$('remove').onclick=()=>{if(route.length>1){const next=removeComponent(getDesign(),selected);applyDesign(next);toast(t('removed'));}}; | |
| for(const kind of ['straight','elbow'])$('add-'+kind).onclick=()=>{if(route.length>=10)return;route.push({kind,ratio:kind==='straight'?5:2,roll:0});selected=route.length-1;needsFit=true;renderEditor();schedule();};$('station').onchange=()=>{station=+$('station').value;renderEditor();highlight();};$('support').onchange=()=>{supports[station]={type:$('support').value,k:100000};renderEditor();schedule();};$('spring').oninput=()=>{supports[station]={type:'spring',k:1000*10**+$('spring').value};$('spring-value').textContent=`${Math.round(10**+$('spring').value)} kN/m`;schedule();};$('temperature').oninput=()=>{$('temperature-value').textContent=$('temperature').value+' K';schedule();};$('gravity').onchange=schedule; | |
| document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{view=b.dataset.view;playing=view!=='static';phase=view==='static'?Math.PI/1.4:Math.PI/5.2;updateSceneLabels();});$('play').onclick=()=>{if(view==='decay'&&result?.dynamics&&dynamicTime>=result.dynamics.time.at(-1))dynamicTime=0;playing=!playing;updateSceneLabels();};$('fit').onclick=()=>fit();$('toggle-tools').onclick=()=>document.body.classList.toggle('tools-open'); | |
| // A small local studio provides neutral metallic reflections, without HDR downloads. | |
| const canvas=$('scene'),renderer=new THREE.WebGLRenderer({canvas,antialias:true,alpha:true,preserveDrawingBuffer:true});renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.25; | |
| const scene=new THREE.Scene(),camera=new THREE.PerspectiveCamera(36,1,.01,1000);camera.up.set(0,0,1);const controls=new OrbitControls(camera,canvas);controls.enableDamping=true;controls.dampingFactor=.07;controls.minDistance=.3;controls.maxDistance=100;controls.target.set(1,1,0);camera.position.set(5,-6,5); | |
| const studio=new THREE.Scene();studio.background=new THREE.Color('#536461'); | |
| for(const [pos,size,color,intensity] of [[[0,5,2],[8,4],0xffffff,4],[[3,-4,1],[3,7],0xbbe3e0,2],[[-4,0,4],[5,5],0xffe7c6,3]]){ | |
| const p=new THREE.Mesh(new THREE.PlaneGeometry(...size),new THREE.MeshBasicMaterial({color,side:THREE.DoubleSide})); | |
| p.material.color.multiplyScalar(intensity);p.position.set(...pos);p.lookAt(0,0,0);studio.add(p); | |
| } | |
| const pmrem=new THREE.PMREMGenerator(renderer),env=pmrem.fromScene(studio,.03);scene.environment=env.texture; | |
| studio.traverse(o=>{o.geometry?.dispose();o.material?.dispose();});pmrem.dispose(); | |
| scene.add(new THREE.HemisphereLight(0xe3fffa,0x18333d,2));const light=new THREE.DirectionalLight(0xffecd6,3);light.position.set(1,-2,5);scene.add(light); | |
| const pipeGroup=new THREE.Group(),supportGroup=new THREE.Group(),damperGroup=new THREE.Group();scene.add(pipeGroup,supportGroup,damperGroup);let pipes=[],markers=[],ghosts=[]; | |
| const material=new THREE.MeshStandardMaterial({color:0xb9c9c3,metalness:.82,roughness:.26});const ghostMat=new THREE.MeshBasicMaterial({color:0x688783,transparent:true,opacity:.17,depthWrite:false});const supportMat=new THREE.MeshStandardMaterial({color:0xd6b57d,metalness:.55,roughness:.35}); | |
| const grid=new THREE.GridHelper(30,60,0x345a55,0x24433f);grid.rotation.x=Math.PI/2;grid.position.z=-.7;grid.material.transparent=true;grid.material.opacity=.3;scene.add(grid); | |
| function tube(points,radius){const n=points.length,around=16,pos=new Float32Array(n*around*3),idx=[];for(let i=0;i<n-1;i++)for(let j=0;j<around;j++){let a=i*around+j,b=i*around+(j+1)%around,c=a+around,d=b+around;idx.push(a,b,c,b,d,c);}const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3).setUsage(THREE.DynamicDrawUsage));g.setIndex(idx);g.userData={n,around};updateTube(g,points,radius);return g;} | |
| function updateTube(g,points,radius){const {n,around}=g.userData,arr=g.attributes.position.array;let prev=null;for(let i=0;i<n;i++){const p=new THREE.Vector3(...points[i]),a=new THREE.Vector3(...points[Math.max(0,i-1)]),b=new THREE.Vector3(...points[Math.min(n-1,i+1)]),dir=b.sub(a).normalize();let normal=prev?prev.clone().addScaledVector(dir,-prev.dot(dir)):new THREE.Vector3(0,0,1).addScaledVector(dir,-dir.z);if(normal.lengthSq()<.00001)normal.set(0,1,0).addScaledVector(dir,-dir.y);normal.normalize();prev=normal;const bin=new THREE.Vector3().crossVectors(dir,normal).normalize();for(let j=0;j<around;j++){let angle=j/around*Math.PI*2,point=p.clone().addScaledVector(normal,Math.cos(angle)*radius).addScaledVector(bin,Math.sin(angle)*radius);point.toArray(arr,(i*around+j)*3);}}g.attributes.position.needsUpdate=true;g.computeVertexNormals();g.computeBoundingSphere();} | |
| function clear(group){for(const o of [...group.children]){group.remove(o);o.traverse(v=>{v.geometry?.dispose();if(v.userData.ownMaterial)v.material?.dispose();});}} | |
| function rebuild(){clear(pipeGroup);clear(supportGroup);pipes=[];ghosts=[];markers=[];result.paths.forEach((p,i)=>{let m=material.clone();let mesh=new THREE.Mesh(tube(p,diameter/2),m);mesh.userData={segment:i,ownMaterial:true};pipeGroup.add(mesh);pipes.push(mesh);const ghost=new THREE.Mesh(tube(p,diameter/2),ghostMat);pipeGroup.add(ghost);ghosts.push(ghost);});result.nodes.forEach((p,i)=>{let marker=new THREE.Mesh(new THREE.SphereGeometry(diameter*.14,12,8),new THREE.MeshBasicMaterial({color:0x73ded1}));marker.position.set(...p);marker.userData={node:i,ownMaterial:true};supportGroup.add(marker);markers.push(marker);const type=i===0?'fixed':supports[i]?.type;if(type&&type!=='none'){const base=new THREE.Vector3(...p),height=diameter*2.6;const foot=new THREE.Mesh(new THREE.BoxGeometry(diameter*1.4,diameter*1.4,diameter*.15),supportMat);foot.position.copy(base).add(new THREE.Vector3(0,0,-height));supportGroup.add(foot);if(type==='spring'){let pts=[];for(let j=0;j<=100;j++){let a=j/100*Math.PI*12;pts.push([p[0]+Math.cos(a)*diameter*.26,p[1]+Math.sin(a)*diameter*.26,p[2]-diameter*.5-j/100*(height-diameter*.6)]);}supportGroup.add(new THREE.Mesh(tube(pts,diameter*.035),supportMat));}else{const leg=new THREE.Mesh(new THREE.CylinderGeometry(diameter*.08,diameter*.08,height,10),supportMat);leg.rotation.x=Math.PI/2;leg.position.copy(base).add(new THREE.Vector3(0,0,-height/2));supportGroup.add(leg);}const collar=new THREE.Mesh(new THREE.TorusGeometry(diameter*.54,diameter*.055,8,32),supportMat);const adjacent=result.paths[Math.min(i,result.paths.length-1)];const tangent=i===result.paths.length?new THREE.Vector3(...adjacent.at(-1)).sub(new THREE.Vector3(...adjacent.at(-2))):new THREE.Vector3(...adjacent[1]).sub(new THREE.Vector3(...adjacent[0]));collar.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),tangent.normalize());collar.position.copy(base);supportGroup.add(collar);}});highlight();} | |
| function highlight(){geometryStamp='';updateMetrics();syncExtras();drawDamper();pipes.forEach((m,i)=>{m.material.color.setHex(i===selected?0xd1bd93:0xb9c9c3);});markers.forEach((m,i)=>{m.position.set(...result.nodes[i]);m.position.z+=diameter*.75;m.scale.setScalar(i===station?1.5:1);m.material.color.setHex(i===station?0xffd491:0x73ded1);});} | |
| function fit(){if(!result)return;geometryStamp='';const box=new THREE.Box3();result.paths.flat().forEach(p=>box.expandByPoint(new THREE.Vector3(...p)));const center=box.getCenter(new THREE.Vector3()),size=box.getSize(new THREE.Vector3()).length();controls.target.copy(center).add(new THREE.Vector3(0,0,-size*.035));camera.position.copy(center).add(new THREE.Vector3(1,-1.4,1.1).normalize().multiplyScalar(Math.max(2,size*2.25)*Math.max(1,1/camera.aspect)));camera.near=.01;camera.far=Math.max(100,size*20);camera.updateProjectionMatrix();grid.position.z=box.min.z-diameter*3;controls.update();} | |
| let pointerStart;canvas.addEventListener('pointerdown',e=>{pointerStart=[e.clientX,e.clientY];if(document.body.classList.contains('tools-open'))document.body.classList.remove('tools-open');});canvas.addEventListener('pointerup',e=>{if(!pointerStart||Math.hypot(e.clientX-pointerStart[0],e.clientY-pointerStart[1])>5)return;const rect=canvas.getBoundingClientRect(),ray=new THREE.Raycaster();ray.setFromCamera(new THREE.Vector2((e.clientX-rect.left)/rect.width*2-1,-(e.clientY-rect.top)/rect.height*2+1),camera);const hit=ray.intersectObjects([...markers,...pipes])[0];if(hit){if(hit.object.userData.node!==undefined)station=hit.object.userData.node;else selected=hit.object.userData.segment;renderEditor();highlight();}}); | |
| let last=performance.now(),geometryStamp='';const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| function frame(now){ | |
| requestAnimationFrame(frame);const dt=Math.min(.05,(now-last)/1000);last=now;if(document.hidden)return; | |
| const rect=canvas.getBoundingClientRect();let draw=false; | |
| if(canvas.width!==Math.floor(rect.width*renderer.getPixelRatio())||canvas.height!==Math.floor(rect.height*renderer.getPixelRatio())){ | |
| renderer.setSize(rect.width,rect.height,false);camera.aspect=rect.width/rect.height; | |
| camera.setViewOffset(rect.width,rect.height,rect.width*.06,rect.height*.13,rect.width,rect.height);camera.updateProjectionMatrix();fit();draw=true; | |
| } | |
| if(playing&&!reduced&&!pending){phase+=dt;if(view==='static'&&phase>=Math.PI/1.4){phase=Math.PI/1.4;playing=false;updateSceneLabels();}if(view==='decay'&&result?.dynamics){dynamicTime=Math.min(result.dynamics.time.at(-1),dynamicTime+dt*result.dynamics.time.at(-1)/8);if(dynamicTime>=result.dynamics.time.at(-1)){playing=false;updateSceneLabels();}}}draw=controls.update()||draw; | |
| const stamp=[requestId,view,$('scale').value,phase,dynamicTime].join('|'); | |
| if(result&&!pending&&stamp!==geometryStamp){ | |
| geometryStamp=stamp;draw=true;let factor=view==='static'?(1-Math.cos(phase*1.4))/2:Math.sin(phase*2.6); | |
| const mode=+view;let amplitude=1; | |
| if(view!=='static'&&view!=='decay'&&view!=='forced'){const max=Math.max(...result.modeDisplacements.flat().map(m=>Math.hypot(m[0][mode],m[1][mode],m[2][mode])));amplitude=diameter*.8/Math.max(max,1e-30);if(reduced)factor=1;} | |
| const dyn=view==='decay'?result.dynamics:null,frameIndex=dyn?Math.min(600,Math.floor(dynamicTime/dyn.time.at(-1)*600)):0; | |
| for(let i=0;i<pipes.length;i++){ | |
| if(view==='forced'){ | |
| const h=result.harmonic,angle=reduced?0:phase*2.6,points=field=>result.paths[i].map((p,j)=>p.map((v,k)=>v+(field?field[i][j].real[k]*Math.cos(angle)-field[i][j].imag[k]*Math.sin(angle):0)*+$('scale').value)); | |
| const pts=points(h?.field);updateTube(pipes[i].geometry,pts,diameter/2);updateTube(ghosts[i].geometry,points(h?.referenceField),diameter/2); | |
| for(const end of [0,1]){const rim=pipes[i].userData['rim'+end];if(rim){const j=end?pts.length-1:0,other=end?j-1:1;rim.position.set(...pts[j]);rim.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),new THREE.Vector3(...pts[j]).sub(new THREE.Vector3(...pts[other])).normalize());}} | |
| continue; | |
| } | |
| const pts=result.paths[i].map((p,j)=>p.map((v,k)=>v+(view==='decay'?(dyn?result.modeDisplacements[i][j][k].reduce((s,x,m)=>s+x*dyn.coordinates[frameIndex][m],0)*+$('scale').value:0):view==='static'?((referenceCompatible()?baseline.result.displacements[i][j][k]:0)*(1-factor)+result.displacements[i][j][k]*factor)*+$('scale').value:result.modeDisplacements[i][j][k][mode]*amplitude*factor))); | |
| const reference=dyn?result.paths[i].map((p,j)=>p.map((v,k)=>v+result.modeDisplacements[i][j][k][0]*dyn.coordinates[0][0]*Math.cos(2*Math.PI*dyn.frequency[0]*dynamicTime)*+$('scale').value)):view==='static'&&referenceCompatible()?baseline.result.paths[i].map((p,j)=>p.map((v,k)=>v+baseline.result.displacements[i][j][k]*+$('scale').value)):result.paths[i]; | |
| updateTube(ghosts[i].geometry,reference,diameter/2); | |
| updateTube(pipes[i].geometry,pts,diameter/2); | |
| // Show actual wall thickness at the two open external ports. | |
| for(const end of [0,1]){ | |
| if((end===0&&i!==0)||(end===1&&i!==pipes.length-1))continue; | |
| const key='rim'+end; | |
| if(!pipes[i].userData[key]){ | |
| const rimMaterial=material.clone();rimMaterial.side=THREE.DoubleSide; | |
| const rim=new THREE.Mesh(new THREE.RingGeometry(diameter*(.5-wall),diameter*.5,32),rimMaterial); | |
| rim.userData={ownMaterial:true,segment:i};pipes[i].add(rim);pipes[i].userData[key]=rim; | |
| } | |
| const rim=pipes[i].userData[key],j=end?pts.length-1:0,other=end?j-1:1; | |
| rim.position.set(...pts[j]);rim.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),new THREE.Vector3(...pts[j]).sub(new THREE.Vector3(...pts[other])).normalize()); | |
| rim.material.color.copy(pipes[i].material.color); | |
| } | |
| } | |
| } | |
| if(draw){updateCallout();renderPresentation();positionLabels();if(view==='decay'&&result?.dynamics)drawTrace();if(view==='forced'&&result?.harmonic)drawFrequencyResponse();} | |
| }requestAnimationFrame(frame); | |
| // Editing, persistence and reporting are independent of the operator assembler. | |
| function getDesign(){return structuredClone({route,diameter,wall,supports,temperature:+$('temperature').value,gravity:$('gravity').checked,damper,excitation});} | |
| function projectEnvelope(design){return {schema:'agentfem.pipe-project/1',modelVersion:VERSION,units:'SI',design};} | |
| function applyDesign(p){document.querySelectorAll('[data-preset]').forEach(b=>b.classList.remove('active'));route=structuredClone(p.route);diameter=p.diameter;wall=p.wall;supports=structuredClone(p.supports);damper={...p.damper};selected=Math.min(selected,route.length-1);station=Math.min(station,route.length);$('diameter').value=diameter;$('wall').value=wall;$('temperature').value=p.temperature;$('temperature-value').textContent=p.temperature+' K';$('gravity').checked=p.gravity;needsFit=true;renderEditor();syncExtras();schedule();} | |
| function toast(message){$('project-state').textContent=message;} | |
| function syncExtras(){if(!route.length)return;damper.node=Math.min(route.length,Math.max(1,damper.node));$('damper-node').replaceChildren(...route.map((_,i)=>new Option(`${t('joint')} ${i+1}`,i+1)));$('damper-node').value=damper.node;$('damper-axis').value=damper.axis;$('damper').value=damper.c/1000;$('damper-value').textContent=`${damper.c/1000} kN·s/m`;$('end-actions').hidden=station!==route.length||route.length>=10||pending||view==='decay';$('undo').disabled=!undoStack.length;$('redo').disabled=!redoStack.length;} | |
| function drawDamper(){clear(damperGroup);if(!result||!damper.c||!result.nodes[damper.node])return;const joint=new THREE.Vector3(...result.nodes[damper.node]),p=joint.clone(),axis=new THREE.Vector3(...[0,1,2].map(i=>i===damper.axis?-1:0)),length=diameter*3; | |
| // Offset the schematic attachment symbol so an axial damper is not hidden inside the pipe. | |
| p.setComponent(damper.axis===0?1:0,p.getComponent(damper.axis===0?1:0)+diameter*1.5); | |
| const link=new THREE.Line(new THREE.BufferGeometry().setFromPoints([joint,p]),new THREE.LineBasicMaterial({color:0x80ded0}));link.userData.ownMaterial=true;damperGroup.add(link); | |
| const cylinder=(radius,len,center,color)=>{const m=new THREE.Mesh(new THREE.CylinderGeometry(radius,radius,len,16),new THREE.MeshStandardMaterial({color,metalness:.7,roughness:.25}));m.userData.ownMaterial=true;m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),axis);m.position.copy(center);damperGroup.add(m);}; | |
| cylinder(diameter*.055,length,p.clone().addScaledVector(axis,length/2),0xd9e6e2);cylinder(diameter*.19,length*.48,p.clone().addScaledVector(axis,length*.65),0x67c2b3);const base=new THREE.Mesh(new THREE.BoxGeometry(diameter*.65,diameter*.65,diameter*.14),supportMat);base.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),axis);base.position.copy(p).addScaledVector(axis,length);damperGroup.add(base); | |
| } | |
| const editIds=new Set(['service','diameter','wall','dimension','roll','support','spring','temperature','gravity','damper-node','damper-axis','damper','remove','reset','add-straight','add-elbow','confirm-part']); | |
| const historyEvents=new WeakMap(); | |
| for(const event of ['click','change','input']){document.addEventListener(event,e=>{ | |
| const edit=editIds.has(e.target.id)||e.target.dataset.preset; | |
| if(!edit||historyMuted||!lastDesign)return; | |
| if(event==='input'&&e.target.tagName==='SELECT')return; | |
| if((event==='click'&&e.target.tagName!=='BUTTON')||(event!=='click'&&e.target.tagName==='BUTTON'))return; | |
| if(['support','spring','gravity','temperature'].includes(e.target.id))journey?.beforeEdit();historyEvents.set(e,structuredClone(lastDesign)); | |
| },true);document.addEventListener(event,e=>{const before=historyEvents.get(e);if(!before)return;const now=getDesign();if(JSON.stringify(before)!==JSON.stringify(now)){undoStack.push(before);if(undoStack.length>80)undoStack.shift();redoStack.length=0;lastDesign=now;document.querySelectorAll('[data-preset]').forEach(b=>b.classList.remove('active'));if(e.target.id!=='remove')toast(t('custom'));}syncExtras();});} | |
| function restore(stack,other){if(!stack.length)return;other.push(getDesign());historyMuted=true;applyDesign(stack.pop());lastDesign=getDesign();historyMuted=false;syncExtras();toast(t('custom'));} | |
| $('undo').onclick=()=>restore(undoStack,redoStack);$('redo').onclick=()=>restore(redoStack,undoStack); | |
| function download(name,text,type='application/json'){const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);} | |
| $('save-project').onclick=()=>{if(!pending){download('AgentFEM-pipe-project.json',JSON.stringify({...projectEnvelope(getDesign()),baseline:baseline?projectEnvelope(baseline.design):null},null,2));toast(t('saved'));}}; | |
| $('open-project').onclick=()=>$('project-file').click();$('project-file').onchange=async()=>{const file=$('project-file').files[0];if(!file)return;try{if(file.size>100000)throw Error('SIZE');const raw=JSON.parse(await file.text()),p=validateProject(raw),bp=raw.baseline?validateProject(raw.baseline):null;const revision=requestId;const br=bp?await computeSnapshot(bp):null;if(revision!==requestId)throw Error('CHANGED');undoStack.push(getDesign());redoStack.length=0;applyDesign(p);lastDesign=getDesign();baseline=bp?{design:bp,result:br}:null;journey?.restoreBaseline(bp,br);toast(file.name);}catch{toast(t('invalid'));}$('project-file').value='';}; | |
| async function computeSnapshot(p){const cs=await Promise.all(p.route.map(r=>loadComponent(r,p.diameter,p.wall))),ss=[{node:0,fixed:[0,1,2,3,4,5]}];for(const [node,s] of Object.entries(p.supports)){if(s.type==='fixed')ss.push({node:+node,fixed:[0,1,2,3,4,5]});if(s.type==='guide')ss.push({node:+node,fixed:[2]});if(s.type==='spring')ss.push({node:+node,spring:[0,0,s.k,0,0,0]});}return new Promise((resolve,reject)=>{const w=new Worker('./worker.js'),timeout=setTimeout(()=>{w.terminate();reject(Error('TIMEOUT'));},30000);w.onmessage=({data})=>{clearTimeout(timeout);w.terminate();data.error?reject(Error(data.error)):resolve(data.result);};w.onerror=()=>{clearTimeout(timeout);w.terminate();reject(Error('WORKER'));};w.postMessage({id:0,request:{components:cs,rolls:p.route.map(r=>r.roll*Math.PI/180),supports:ss,deltaT:p.temperature,gravity:p.gravity?[0,0,-9.81]:[0,0,0]}});});} | |
| $('baseline').onclick=()=>{if(result&&!pending){baseline={design:getDesign(),result:structuredClone(result)};toast(t('baselineSaved'));}}; | |
| $('close-review').onclick=()=>$('review-dialog').close(); | |
| $('damper-node').onchange=()=>{damper.node=+$('damper-node').value;schedule();};$('damper-axis').onchange=()=>{damper.axis=+$('damper-axis').value;schedule();};$('damper').oninput=()=>{damper.c=+$('damper').value*1000;syncExtras();schedule();}; | |
| $('release').onclick=()=>{view='decay';dynamicTime=0;playing=!reduced;document.body.classList.remove('tools-open');updateSceneLabels();schedule();}; | |
| let previewMesh=null,previewSerial=0; | |
| function clearPreview(){previewSerial++;previewKind=null;if(previewMesh){scene.remove(previewMesh);previewMesh.geometry.dispose();previewMesh.material.dispose();previewMesh=null;}$('confirm-part').hidden=$('cancel-part').hidden=true;} | |
| async function previewPart(kind){clearPreview();if(!result||pending||route.length>=10)return;const serial=previewSerial;previewKind=kind;const c=await loadComponent({kind,ratio:kind==='straight'?5:2});if(serial!==previewSerial)return;const path=result.paths.at(-1),end=new THREE.Vector3(...path.at(-1)),tangent=end.clone().sub(new THREE.Vector3(...path.at(-2))).normalize(); | |
| // Carry the exact route frame, including accumulated bend rolls. | |
| const q=new THREE.Quaternion();for(const r of route){q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),r.roll*Math.PI/180));if(r.kind==='elbow')q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,0,1),Math.PI/2));} | |
| const points=c.path.map(p=>new THREE.Vector3(...p).applyQuaternion(q).add(end).toArray());previewMesh=new THREE.Mesh(tube(points,diameter/2),new THREE.MeshBasicMaterial({color:0x80ded0,transparent:true,opacity:.35,depthWrite:false}));scene.add(previewMesh);geometryStamp='';$('confirm-part').hidden=$('cancel-part').hidden=false; | |
| } | |
| $('preview-straight').onclick=()=>previewPart('straight');$('preview-elbow').onclick=()=>previewPart('elbow');$('cancel-part').onclick=()=>{clearPreview();geometryStamp='';};$('confirm-part').onclick=()=>{if(!previewKind)return;const kind=previewKind;route.push({kind,ratio:kind==='straight'?5:2,roll:0});selected=route.length-1;station=route.length;clearPreview();renderEditor();schedule();}; | |
| function positionLabels(){if(!result)return;const box=canvas.getBoundingClientRect();if($('joint-labels').children.length!==result.nodes.length){$('joint-labels').replaceChildren(...result.nodes.map((_,i)=>{const b=document.createElement('button');b.textContent=i;b.title=`${t('jointSelect')} ${i}`;b.onclick=()=>{station=i;renderEditor();highlight();};return b;}));}result.nodes.forEach((p,i)=>{const v=new THREE.Vector3(...p).add(new THREE.Vector3(0,0,diameter*.8)).project(camera),b=$('joint-labels').children[i];b.style.left=(v.x*.5+.5)*box.width+'px';b.style.top=(-v.y*.5+.5)*box.height+'px';b.title=`${t('jointSelect')} ${i}`;b.hidden=v.z>1;b.classList.toggle('selected',i===station);});} | |
| function drawTrace(){const d=result.dynamics,c=$('trace'),r=c.getBoundingClientRect(),ratio=Math.min(devicePixelRatio,2);c.width=r.width*ratio;c.height=r.height*ratio;const ctx=c.getContext('2d');ctx.scale(ratio,ratio);const w=r.width,h=r.height,pad=22,amp=Math.max(.001,...d.signal.map(Math.abs)),T=d.time.at(-1),x=t=>pad+t/T*(w-2*pad),y=v=>h/2-v/amp*(h*.34);ctx.strokeStyle='#39514f';ctx.beginPath();ctx.moveTo(pad,h/2);ctx.lineTo(w-pad,h/2);ctx.stroke();for(const [values,color,dashed] of [[d.reference,'#8da6a6',true],[d.signal,'#80ded0',false]]){ctx.strokeStyle=color;ctx.lineWidth=1.4;ctx.setLineDash(dashed?[3,3]:[]);ctx.beginPath();values.forEach((v,i)=>i?ctx.lineTo(x(d.time[i]),y(v)):ctx.moveTo(x(0),y(v)));ctx.stroke();}ctx.setLineDash([]);ctx.strokeStyle='#dbb67d';ctx.beginPath();ctx.moveTo(x(dynamicTime),8);ctx.lineTo(x(dynamicTime),h-12);ctx.stroke();ctx.fillStyle='#9fb8b1';ctx.font='10px sans-serif';ctx.fillText('mm',0,11);ctx.fillText(T.toFixed(2)+' s',w-48,h-1);ctx.fillText('0',pad,h-1);$('trace-clock').textContent=dynamicTime.toFixed(2)+' s';$('trace-caption').textContent=`${t('without')} / ${t('with')}`;$('trace-info').textContent=`${t('probe')}: ${d.probe.component+1} · ${'XYZ'[d.probe.axis]} · ${lang==='zh'?'初始峰值 1 mm,播放时长 8 秒':'Initial peak 1 mm; playback 8 s'}`;} | |
| function reportHTML(){const r=result,b=baseline?.result;const values=x=>[x.maxDisplacement*1000,Math.max(...x.supportForces.map(v=>Math.hypot(...v)))/1000,x.frequency[0],x.mass];const rows=[['displacement','mm'],['reaction','kN'],['frequency','Hz'],['mass','kg']].map(([key,unit],i)=>`<tr><td>${t(key)} / ${unit}</td><td>${b?values(b)[i].toFixed(3):'—'}</td><td>${values(r)[i].toFixed(3)}</td><td>${b?(values(r)[i]-values(b)[i]).toFixed(3):'—'}</td></tr>`).join(''); | |
| const supportsRows=r.supportForces.map((f,i)=>{const node=r.supportNodes[i],mom=r.reaction.slice(node*6+3,node*6+6);return `<tr><td>${node}</td>${[...f,...mom].map(x=>`<td>${(x/1000).toFixed(3)}</td>`).join('')}</tr>`;}).join(''); | |
| return `<p>${t('modelScope')}</p><table><thead><tr><th></th><th>${t('comparison')}</th><th>${t('current')}</th><th>Δ</th></tr></thead><tbody>${rows}</tbody></table>${b?'':`<p>${t('noBaseline')}</p>`}<h3>${t('supports').split('/').at(-1)}</h3><div class="table-scroll"><table><tr><th>#</th><th>Fx / kN</th><th>Fy / kN</th><th>Fz / kN</th><th>Mx / kN·m</th><th>My / kN·m</th><th>Mz / kN·m</th></tr>${supportsRows}</table></div><p>${t('reportHint')}</p><p>ROM ${VERSION} · ΔT ${solvedDesign.temperature} K · g ${solvedDesign.gravity?'9.81':'0'} m/s² · E ${(components[0]?.spec.young||200e9)/1e9} GPa · α ${+((components[0]?.spec.alpha||12e-6)*1e6).toFixed(2)}×10⁻⁶/K · ρ 7850 kg/m³<br>D ${diameter*1000} mm · t ${(diameter*wall*1000).toFixed(1)} mm</p>${r.dynamics?`<p>${t('peakForce')}: ${(r.dynamics.peakForce/1000).toFixed(4)} kN · c ${r.dynamics.c/1000} kN·s/m · ${t('joint')} ${r.dynamics.node} / ${'XYZ'[r.dynamics.axis]}<br>Newmark β=¼, γ=½ · Δt ${r.dynamics.dt.toExponential(3)} s · ${r.dynamics.modeCount} modes · initial peak 1 mm · zero initial velocity · no background damping</p>`:''}`; | |
| } | |
| $('review').onclick=()=>{if(pending||!result)return;$('review-copy').innerHTML=reportHTML();$('review-dialog').showModal();}; | |
| $('export-csv').onclick=()=>{ | |
| const rows=[['scenario','quantity','location','time_s','value','unit']]; | |
| for(const [name,r] of [['current',result],['baseline',baseline?.result]]){if(!r)continue; | |
| rows.push([name,'max_displacement','centreline','',r.maxDisplacement,'m']);r.frequency.forEach((f,i)=>rows.push([name,'frequency',i+1,'',f,'Hz'])); | |
| r.supportForces.forEach((v,i)=>{const node=r.supportNodes[i];v.forEach((x,k)=>rows.push([name,'support_F'+'xyz'[k],node,'',x,'N']));r.reaction.slice(node*6+3,node*6+6).forEach((x,k)=>rows.push([name,'support_M'+'xyz'[k],node,'',x,'N m']));}); | |
| r.nodes.forEach((_,i)=>r.q.slice(i*6,i*6+3).forEach((x,k)=>rows.push([name,'port_u'+'xyz'[k],i,'',x,'m']))); | |
| } | |
| if(result.dynamics){const d=result.dynamics;d.time.forEach((time,i)=>{for(const [key,values,unit] of [['undamped_displacement',d.reference,'m'],['damped_displacement',d.signal,'m'],['damper_force',d.force,'N'],['energy',d.energy,'J'],['dissipated_energy',d.dissipated,'J']])rows.push(['dynamic',key,key.includes('displacement')?'probe':d.node,time,values[i],unit]);});} | |
| download('AgentFEM-pipe-results.csv',rows.map(r=>r.join(',')).join('\n'),'text/csv'); | |
| }; | |
| $('export-report').onclick=()=>{ | |
| const design=JSON.stringify(projectEnvelope(solvedDesign),null,2).replaceAll('<','<'); | |
| const html=`<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>AgentFEM Pipe Comparison</title><style>body{font:14px Arial,"PingFang SC",sans-serif;max-width:960px;margin:30px auto;padding:20px;color:#172b2c}table{border-collapse:collapse;width:100%;font-size:12px}td,th{padding:9px;border-bottom:1px solid #b9c6c5;text-align:left}p{line-height:1.7}img{width:100%;max-height:340px;object-fit:contain;background:#102223}pre{white-space:pre-wrap;font-size:10px}button{padding:12px}@media print{button{display:none}h3{break-after:avoid}table{break-inside:avoid}}</style></head><body><h1>AgentFEM · ${t('review')}</h1><p>${new Date().toISOString()} · ROM ${VERSION}</p><button onclick="window.print()">${t('report')}</button><img src="${canvas.toDataURL('image/png')}" alt="Pipe geometry; deformation magnified ${$('scale').value} times"><p>${lang==='zh'?'画面显示放大':'Display scale'} ×${$('scale').value} · ${view}</p>${reportHTML()}<h3>${t('current')} / SI</h3><pre>${design}</pre>${baseline?`<h3>${t('comparison')} / SI</h3><pre>${JSON.stringify(projectEnvelope(baseline.design),null,2).replaceAll('<','<')}</pre>`:''}</body></html>`; | |
| download('AgentFEM-pipe-report.html',html,'text/html');toast(lang==='zh'?'报告已下载;打开后可打印为 PDF。':'Report downloaded; open it to print as PDF.'); | |
| }; | |
| // Engineering workflow: explicit force input, physically computed steady amplitudes. | |
| const baseLabels=updateSceneLabels,baseMetrics=updateMetrics,baseSync=syncExtras,baseApply=applyDesign,baseReport=reportHTML; | |
| applyDesign=p=>{$('temperature').max=p.diameter>=.45?600:200;excitation={...(p.excitation||{node:p.route.length,axis:2,force:100,frequency:10,zeta:.02})};baseApply(p);}; | |
| syncExtras=()=>{excitation.node=Math.min(route.length||1,excitation.node);baseSync();engineering?.sync();power?.sync();journey?.sync();}; | |
| updateSceneLabels=()=>{baseLabels();if(result&&!pending){$('status').textContent=overlap(result.paths)?t('overlap'):view==='static'&&result.maxDisplacement>diameter*.15?t('large'):view==='decay'&&result.dynamics?.blocked?t('blockedDamper'):view==='forced'&&result.harmonic?.blocked?(lang==='zh'?'施力方向已固定,该方向没有位移响应。':'The excitation direction is fixed; its displacement is zero.'):'';}engineering?.thermal(view);if(view==='static'&&referenceCompatible())$('reference-label').textContent=lang==='zh'?'对照方案':'Reference';$('thermal-task')?.classList.toggle('active',view==='static');$('vibration-task')?.classList.toggle('active',view!=='static');$('trace').setAttribute('aria-label',view==='forced'?'Frequency versus peak displacement amplitude':'Displacement versus time, without and with damper');if(view==='forced'){$('scene-title').textContent=lang==='zh'?'受迫振动':'Forced vibration';$('scene-caption').textContent=lang==='zh'?'测点振幅与扫频曲线 · 动画慢放':'Steady harmonic response · slowed playback, computed amplitudes.';$('scale').disabled=false;$('trace-panel').hidden=false;$('reference-label').textContent=lang==='zh'?'未加阻尼器':'No added damper';$('result-label').textContent=lang==='zh'?'已加阻尼器':'Added damper';}}; | |
| updateMetrics=()=>{baseMetrics();if(view==='forced'&&result?.harmonic){const h=result.harmonic,cards=[...document.querySelectorAll('.metrics article')],values=[h.current.amplitude*1000,h.reference.amplitude*1000,h.damperForce/1000],labels=lang==='zh'?['加装后测点振幅','加装前测点振幅','阻尼器力幅值']:['Probe amplitude · added damper','Probe amplitude · no added damper','Damper force amplitude'];for(let i=0;i<3;i++){cards[i].querySelector('span').textContent=labels[i];cards[i].querySelector('strong').textContent=values[i].toFixed(3);cards[i].querySelector('small').textContent=i===2?'kN':'mm';}if(h.blocked)$('status').textContent=lang==='zh'?'施力方向已固定,该方向没有位移响应。':'The excitation direction is fixed; its displacement is zero.';}}; | |
| reportHTML=()=>(view==='forced'&&result?.harmonic?'':baseReport())+(view==='forced'&&result?.harmonic?`<h3>${lang==='zh'?'阻尼器减振对比':'Damper comparison'}</h3><p>F = ${result.harmonic.force} N · f = ${result.harmonic.frequency} Hz · ζ = ${result.harmonic.zeta*100}% · ${t('joint')} ${result.harmonic.node} / ${'XYZ'[result.harmonic.axis]}<br>${lang==='zh'?'测点振幅(无 / 有附加阻尼器)':'Probe amplitude (without / with added damper)'}: ${(result.harmonic.reference.amplitude*1000).toFixed(5)} / ${(result.harmonic.current.amplitude*1000).toFixed(5)} mm<br>${lang==='zh'?'阻尼器力幅值':'Damper force amplitude'}: ${(result.harmonic.damperForce/1000).toFixed(4)} kN<br>c = ${result.harmonic.damper.c} N·s/m · ${result.harmonic.modeCount} modes</p><p>${t('modelScope')}</p>`:''); | |
| engineering=setupEngineering({getLanguage:()=>lang,getNodeCount:()=>route.length,getResult:()=>result,getExcitation:()=>excitation,setExcitation:p=>{undoStack.push(getDesign());redoStack.length=0;excitation=p;lastDesign=getDesign();},onChange:()=>schedule(),onThermal:()=>{view='static';playing=false;phase=Math.PI/1.4;updateSceneLabels();schedule();},onVibration:()=>{view='0';playing=!reduced;updateSceneLabels();},onForced:()=>{view='forced';playing=!reduced;phase=0;updateSceneLabels();schedule();}}); | |
| const baseTranslate=translate;translate=()=>{baseTranslate();engineering.sync();power?.sync();$('project-state').textContent='';$('about-copy').insertAdjacentHTML('beforeend',lang==='zh'?'<p><b>受迫振动</b><br>用户给定连接点简谐力;在 12 阶系统模态上求解复数动态刚度。背景模态阻尼比为用户假设,两条曲线使用相同值。扫频范围为一阶频率的 0.2–2 倍,共 161 点;离散峰值不等于精确共振峰。显示同一施力点、同一方向的峰值振幅,非 RMS。四大管道采用独立生成的研究截面组件,尺寸与材料假设见案例说明。内压、介质、保温重量、温度相关材料及支吊架预载仍待接入。</p>':'<p><b>Forced response</b><br>User-specified harmonic joint force; complex dynamic stiffness in 12 system modes. Both curves use the same assumed background modal damping. The sweep samples 161 points over 0.2–2 times the first natural frequency; sampled maxima are not exact resonance peaks. Peak (not RMS) amplitudes at the force joint/direction. Power-piping sections are independently generated research benchmarks; pressure, contents, insulation, temperature-dependent properties and hanger preload remain outside this release.</p>');}; | |
| Object.assign(words.zh,{decayTitle:'自由振动衰减',decayCaption:'一阶振型释放 · 比较加装阻尼器前后',thermalView:'热位移',reaction:'最大支撑力'}); | |
| Object.assign(words.en,{decayTitle:'Free vibration decay',decayCaption:'First-mode release · damper comparison',thermalView:'Thermal movement'}); | |
| const powerTranslate=translate;translate=()=>{powerTranslate();$('about-copy').insertAdjacentHTML('beforeend',lang==='zh'?'<p><b>四大管道研究截面</b><br>主蒸汽截面参考公开论文表 9:内径 330 mm、计算壁厚 60 mm(未含腐蚀裕量)。其余三类截面、管路布置、温升、支撑和有效弹性参数是研究设定,不是某电厂设计数据。各组件重新进行实体有限元装配,厚度方向划分两层单元。<a href="https://www.energychina.press/article/doi/10.16516/j.gedi.issn2095-8676.2019.04.007" target="_blank" rel="noopener">尺寸来源 ↗</a></p>':'<p><b>Power-piping research sections</b><br>The main-steam section follows published Table 9: 330 mm bore and 60 mm calculated wall, excluding corrosion allowance. The other three sections, routes, temperature rises, supports and effective elastic constants are benchmark assumptions, not plant design data. Each component has a newly assembled solid FE model with two radial element layers. <a href="https://www.energychina.press/article/doi/10.16516/j.gedi.issn2095-8676.2019.04.007" target="_blank" rel="noopener">Section source ↗</a></p>');}; | |
| function renderPresentation(){ | |
| const split=!!(journey&&view==='forced'&&result?.harmonic&&canvas.clientWidth>=720); | |
| document.body.classList.toggle('split-vibration',split); | |
| if(!split){renderer.setScissorTest(false);renderer.render(scene,camera);return;} | |
| const w=canvas.clientWidth,h=canvas.clientHeight,aspect=camera.aspect,zoom=camera.zoom,offset=camera.view?{...camera.view}:null; | |
| camera.clearViewOffset();camera.aspect=w/2/h;camera.zoom=.9;camera.updateProjectionMatrix(); | |
| renderer.setScissorTest(true); | |
| ghosts.forEach(g=>{g.visible=true;g.material=material;});pipes.forEach(p=>p.visible=false);damperGroup.visible=false; | |
| renderer.setViewport(0,0,w/2,h);renderer.setScissor(0,0,w/2,h);renderer.render(scene,camera); | |
| ghosts.forEach(g=>{g.visible=false;g.material=ghostMat;});pipes.forEach(p=>p.visible=true);damperGroup.visible=true; | |
| renderer.setViewport(w/2,0,w/2,h);renderer.setScissor(w/2,0,w/2,h);renderer.render(scene,camera); | |
| ghosts.forEach(g=>g.visible=true);renderer.setScissorTest(false);renderer.setViewport(0,0,w,h); | |
| camera.aspect=aspect;camera.zoom=zoom;if(offset?.enabled)camera.setViewOffset(offset.fullWidth,offset.fullHeight,offset.offsetX,offset.offsetY,offset.width,offset.height);camera.updateProjectionMatrix(); | |
| } | |
| function referenceCompatible(){return baseline&&JSON.stringify([baseline.design.route,baseline.design.diameter,baseline.design.wall])===JSON.stringify([route,diameter,wall]);} | |
| function drawFrequencyResponse(){const h=result.harmonic,c=$('trace'),r=c.getBoundingClientRect(),ratio=Math.min(devicePixelRatio,2);c.width=r.width*ratio;c.height=r.height*ratio;const ctx=c.getContext('2d');ctx.scale(ratio,ratio);const w=r.width,height=r.height,pad=22,maximum=Math.max(1e-12,...h.sweep.flatMap(s=>[s.withDamper,s.withoutDamper])),lo=h.sweep[0].frequency,hi=h.sweep.at(-1).frequency,x=f=>pad+(f-lo)/(hi-lo)*(w-2*pad),y=a=>height-18-a/maximum*(height-30);for(const [key,color,dash] of [['withoutDamper','#8da6a6',[3,3]],['withDamper','#80ded0',[]]]){ctx.strokeStyle=color;ctx.setLineDash(dash);ctx.beginPath();h.sweep.forEach((s,i)=>i?ctx.lineTo(x(s.frequency),y(s[key])):ctx.moveTo(x(s.frequency),y(s[key])));ctx.stroke();}ctx.setLineDash([]);if(h.frequency>=lo&&h.frequency<=hi){ctx.strokeStyle='#dbb67d';ctx.beginPath();ctx.moveTo(x(h.frequency),10);ctx.lineTo(x(h.frequency),height-18);ctx.stroke();}ctx.fillStyle='#9fb8b1';ctx.font='10px sans-serif';ctx.fillText((maximum*1000).toPrecision(3)+' mm',0,10);ctx.fillText(lo.toFixed(1),pad,height-2);ctx.fillText(hi.toFixed(1)+' Hz',w-52,height-2);$('trace-caption').textContent=lang==='zh'?'扫频:测点振幅':'Sweep: probe amplitude';$('trace-clock').textContent=h.frequency.toFixed(2)+' Hz';$('trace-info').textContent=`${h.force} N · ζ ${h.zeta*100}% · ${t('joint')} ${h.node}/${'XYZ'[h.axis]} · ${lang==='zh'?'虚线:未加阻尼器':'Dashed: no added damper'}`;} | |
| const csvOriginal=$('export-csv').onclick;$('export-csv').onclick=()=>{if(view!=='forced'||!result?.harmonic)return csvOriginal();const h=result.harmonic;download('AgentFEM-pipe-frequency-response.csv',`# force_N=${h.force},node=${h.node},axis=${h.axis},zeta=${h.zeta},damper_node=${h.damper.node},damper_axis=${h.damper.axis},damper_c_Ns_m=${h.damper.c}\nfrequency_Hz,without_added_damper_m,with_added_damper_m\n`+h.sweep.map(s=>[s.frequency,s.withoutDamper,s.withDamper].join(',')).join('\n'),'text/csv');}; | |
| Object.assign(words.zh,{guide:'竖向限位',spring:'弹簧支撑',fixed:'固定点',temperature:'比冷态升高',review:'结果与导出',report:'下载报告 / 打印 PDF',damperTitle:'阻尼器设置',release:'观察自由衰减',sceneTitle:'受热后的管道',sceneCaption:'自重与均匀温升下的总位移 · 动画仅展示变形',modeTitle:'管道振动特征',modeCaption:'振型幅值归一化;运行左侧演示查看真实振幅。'}); | |
| Object.assign(words.en,{review:'Results & export',report:'Download report / print PDF',sceneTitle:'The heated pipe',sceneCaption:'Total movement under gravity and uniform heating · deformation playback',damperTitle:'Damper settings',release:'Explore free decay'}); | |
| const annotationArrow=new THREE.ArrowHelper(new THREE.Vector3(0,0,1),new THREE.Vector3(),1,0xe3bd7a);scene.add(annotationArrow);annotationArrow.visible=false; | |
| const callout=document.createElement('div');callout.id='scene-callout';$('viewport').append(callout); | |
| function updateCallout(){ | |
| if(!result||pending||!journey){annotationArrow.visible=false;callout.hidden=true;return;} | |
| let start,delta,label; | |
| if(view==='static'){ | |
| let peak=0,at=[0,0];result.displacements.forEach((path,i)=>path.forEach((v,j)=>{const mag=Math.hypot(...v);if(mag>peak){peak=mag;at=[i,j];}})); | |
| start=new THREE.Vector3(...result.paths[at[0]][at[1]]);delta=new THREE.Vector3(...result.displacements[at[0]][at[1]]).multiplyScalar(+$('scale').value); | |
| label=(lang==='zh'?'最大总位移 ':'Max. total movement ')+(peak*1000).toFixed(2)+' mm'; | |
| }else if(view==='forced'&&result.harmonic){ | |
| const h=result.harmonic;start=new THREE.Vector3(...result.nodes[h.node]);delta=new THREE.Vector3(...[0,1,2].map(k=>k===h.axis?-diameter*3:0));start.sub(delta); | |
| label=(lang==='zh'?'周期力 / 测点 ':'Force / probe ')+h.node+' · '+h.force+' N · '+h.frequency.toFixed(3)+' Hz'; | |
| }else {annotationArrow.visible=false;callout.hidden=true;return;} | |
| const length=delta.length();annotationArrow.visible=length>1e-8;if(length>1e-8){annotationArrow.position.copy(start);annotationArrow.setDirection(delta.clone().normalize());annotationArrow.setLength(length,Math.min(diameter*.6,length*.25),Math.min(diameter*.25,length*.12));} | |
| const end=start.clone().add(delta).project(camera),box=canvas.getBoundingClientRect();callout.hidden=end.z>1;callout.textContent=label;callout.style.left=Math.max(10,Math.min(box.width-260,(end.x*.5+.5)*box.width))+'px';callout.style.top=Math.max(180,Math.min(box.height-110,(-end.y*.5+.5)*box.height))+'px'; | |
| } | |
| try{const response=await fetch('./data/catalog.json');if(!response.ok)throw Error('CATALOG');const library=await response.json();catalog=library.components;power=setupPower({families:library.powerFamilies||[],getLanguage:()=>lang,getDesign,applyDesign:p=>applyDesign(p)});translate();let draft;try{draft=validateProject(JSON.parse(localStorage.getItem('agentfem-pipe-draft')));}catch{}if(draft){applyDesign(draft);toast(t('autoSaved'));}else {preset('L');power.select('main-steam');}lastDesign=getDesign(); | |
| journey=setupJourney({language:()=>lang,design:getDesign,result:()=>result,pending:()=>pending,compute:computeSnapshot,baseline:(p,r)=>{baseline={design:structuredClone(p),result:structuredClone(r)};geometryStamp='';$('reference-label').textContent=lang==='zh'?'对照方案':'Reference';},edit:p=>{undoStack.push(getDesign());redoStack.length=0;applyDesign(p);lastDesign=getDesign();},station:n=>{station=n;renderEditor();highlight();},view:v=>{view=v;phase=v==='static'?Math.PI/1.4:0;playing=v!=='static'&&!reduced;updateSceneLabels();geometryStamp='';},animateHeat:()=>{phase=reduced?Math.PI/1.4:0;playing=!reduced;geometryStamp='';},report:()=>$('review').click()});$('scale').value='10';journey.init();syncExtras();}catch(e){$('status').textContent=t('error');console.error(e);} | |