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:`
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.
Your browser connects those components, enforces support conditions, and solves equilibrium and the first three natural frequencies. This is a physics-based reduced-order model, not a neural network or a nearest-neighbour replay.
What you can explore 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.
Model 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.
`}};
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'?'
Free decay 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.
');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;av-paths[b][j][k])){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.clientXr.right||e.clientYr.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{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;iresult.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)=>`
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³ D ${diameter*1000} mm · t ${(diameter*wall*1000).toFixed(1)} mm
${r.dynamics?`
${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]} Newmark β=¼, γ=½ · Δt ${r.dynamics.dt.toExponential(3)} s · ${r.dynamics.modeCount} modes · initial peak 1 mm · zero initial velocity · no background damping
`:''}`;
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?`
${lang==='zh'?'阻尼器减振对比':'Damper comparison'}
F = ${result.harmonic.force} N · f = ${result.harmonic.frequency} Hz · ζ = ${result.harmonic.zeta*100}% · ${t('joint')} ${result.harmonic.node} / ${'XYZ'[result.harmonic.axis]} ${lang==='zh'?'测点振幅(无 / 有附加阻尼器)':'Probe amplitude (without / with added damper)'}: ${(result.harmonic.reference.amplitude*1000).toFixed(5)} / ${(result.harmonic.current.amplitude*1000).toFixed(5)} mm ${lang==='zh'?'阻尼器力幅值':'Damper force amplitude'}: ${(result.harmonic.damperForce/1000).toFixed(4)} kN c = ${result.harmonic.damper.c} N·s/m · ${result.harmonic.modeCount} modes
Forced response 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.
Power-piping research sections 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. Section source ↗
');};
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);}