import * as THREE from 'three'; import { OrbitControls } from './vendor/OrbitControls.js'; import { buildTubeMeshData } from './tube-mesh.mjs'; import { createARController } from './ar.mjs'; import { sampleRows, buildCenterline, validateRows, parseTable } from './geometry.mjs'; const $ = id => document.getElementById(id); const clone = x => JSON.parse(JSON.stringify(x)); const format = (n, digits=2) => new Intl.NumberFormat('es-ES',{minimumFractionDigits:digits,maximumFractionDigits:digits}).format(Math.abs(n)<1e-8?0:n); const num = n => String(Math.round(n * 1000) / 1000); const pad = n => String(n).padStart(2,'0'); const icons = { ar:'', phone:'', apple:'', cube:'', upload:'', download:'', sliders:'', help:'', info:'', tube:'', chevron:'', plus:'', minus:'', close:'', fit:'', grid:'', mouse:'', pointer:'', spark:'', reset:'', undo:'', redo:'', flag:'', trash:'', arrow:'' }; function icon(name) {return ``;} function hydrateIcons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>el.innerHTML=icon(el.dataset.icon));} hydrateIcons(); const STORAGE='tubolab.project.v1'; let state={schemaVersion:1,name:'Tubo de referencia',rows:clone(sampleRows),baseline:clone(sampleRows),diameter:50,thickness:1.2,rollSign:1}; function checkProject(p){ if(!p||typeof p!=='object') throw Error('El proyecto JSON no es válido.'); const diameter=Number(p.diameter??50),thickness=Number(p.thickness??1.2),rollSign=Number(p.rollSign??1); const errors=validateRows(p.rows,diameter).concat(validateRows(p.baseline??p.rows,diameter)); if(!Number.isFinite(diameter)||diameter<1||diameter>1000) errors.push('El diámetro debe estar entre 1 y 1000 mm.'); if(!Number.isFinite(thickness)||thickness<=0||thickness>=diameter/2) errors.push('El espesor debe ser positivo y menor que la mitad del diámetro.'); if(![1,-1].includes(rollSign)) errors.push('El sentido de giro debe ser +1 o −1.'); if(errors.length) throw Error(errors[0]); const withIds=rows=>rows.map((r,i)=>({...r,id:typeof r.id==='string'&&r.id.length<100?r.id:'legacy-'+i})); const rows=withIds(p.rows),baseline=withIds(p.baseline??p.rows); if(new Set(rows.map(r=>r.id)).size!==rows.length||new Set(baseline.map(r=>r.id)).size!==baseline.length)throw Error('El proyecto contiene identificadores de fila repetidos.'); return {schemaVersion:1,name:String(p.name||'Pieza importada').slice(0,100),rows,baseline,diameter,thickness,rollSign}; } try{const stored=localStorage.getItem(STORAGE);if(stored)state=checkProject(JSON.parse(stored));}catch{} state=checkProject(state); let selected=state.rows.length>1?1:0,history=[clone(state)],historyIndex=0,compare=false,currentModel,baselineModel; let renderer,scene,camera,controls,modelGroup,originalGroup,grid,extent=900,currentView='iso',pickables=[],labelItems=[]; let toastTimer,arController; function toast(msg){$('toast').textContent=msg;$('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('show'),3200);} function save(){try{localStorage.setItem(STORAGE,JSON.stringify(state));$('saveState').innerHTML=' Guardado en este navegador';}catch{$('saveState').textContent='Sin guardado automático';}} function snapshot(){history=history.slice(0,historyIndex+1);history.push(clone(state));if(history.length>60)history.shift();historyIndex=history.length-1;save();updateHistory();} function updateHistory(){$('undoBtn').disabled=historyIndex===0;$('redoBtn').disabled=historyIndex===history.length-1;} function baseRow(i){return state.baseline.find(row=>row.id===state.rows[i]?.id);} function rowChanged(i){const base=baseRow(i);return !base||['x','y','z','r'].some(k=>Math.abs(state.rows[i][k]-base[k])>1e-7);} function valid(next){try{checkProject(next);$('validationBanner').hidden=true;return true;}catch(e){$('validationBanner').textContent=e.message;$('validationBanner').hidden=false;setTimeout(()=>$('validationBanner').hidden=true,6000);return false;}} function commit(next,{fit=false,autocompare=true}={}){if(!valid(next)){renderUI();return false;}state=checkProject(next);selected=Math.min(Math.max(selected,0),state.rows.length-1);if(autocompare)compare=true;snapshot();refresh({fit});return true;} function changeField(k,value){if(!Number.isFinite(value)){toast('Introduce una medida numérica válida.');renderUI();return false;}if(state.rows[selected][k]===value)return true;const next=clone(state);next.rows[selected][k]=value;return commit(next);} function renderUI(){ $('projectName').value=state.name;$('diameter').value=num(state.diameter);$('thickness').value=num(state.thickness);$('rollSign').value=state.rollSign; $('compareToggle').checked=compare;$('originalLegend').hidden=!compare; const row=state.rows[selected],base=baseRow(selected); const isBend=row.y!==0; $('selectedTitle').textContent=(isBend?'Curva ':'Tramo ')+pad(selected+1);$('selectedChip').textContent=pad(selected+1); for(const key of ['x','y','z','r']){ const field=$('edit'+key.toUpperCase());field.value=num(row[key]);field.classList.toggle('changed',!!base&&Math.abs(row[key]-base[key])>1e-7); } $('rotationRange').value=Math.max(-180,Math.min(180,row.z)); for(const [key,unit] of [['x','mm'],['z','°']]){ const el=$('diff'+key.toUpperCase());const diff=base?row[key]-base[key]:0; el.textContent=base?`Original: ${format(base[key],key==='x'?3:1)} ${unit}${Math.abs(diff)>1e-7?' · '+(diff>0?'+':'')+format(diff,2)+' '+unit:''}`:'Fila añadida a la propuesta';el.classList.toggle('changed',Math.abs(diff)>1e-7); } $('resetRow').disabled=!base||!rowChanged(selected); $('rowsBadge').textContent=state.rows.length+' tramos'; const tbody=$('rowsBody'),existing=[...tbody.children]; const reuse=existing.length===state.rows.length&&existing.every((tr,i)=>tr.dataset.rowId===state.rows[i].id); if(!reuse)tbody.replaceChildren(); state.rows.forEach((r,i)=>{ const base=baseRow(i); if(reuse){ const tr=existing[i];tr.className=i===selected?'selected':''; for(const input of tr.querySelectorAll('input')){const k=input.dataset.key;if(input!==document.activeElement)input.value=num(r[k]);input.classList.toggle('changed',!!base&&Math.abs(r[k]-base[k])>1e-7);} const badge=tr.querySelector('.row-badge');badge.className='row-badge'+(rowChanged(i)?' changed':r.y===0?' tail':'');badge.textContent=rowChanged(i)?'Modificado':r.y===0?(i===state.rows.length-1?'Recta final':'Recta'):'Original';return; } const tr=document.createElement('tr');tr.className=i===selected?'selected':'';tr.dataset.index=i;tr.dataset.rowId=r.id; const first=document.createElement('td');first.textContent=pad(i+1);first.tabIndex=0;first.setAttribute('aria-label','Seleccionar fila '+(i+1));tr.append(first); for(const [k,name] of [['x','Xc'],['y','Yc'],['z','Zc'],['r','CLR']]){ const td=document.createElement('td'),input=document.createElement('input');input.type='number';input.step='0.001';input.value=num(r[k]);input.dataset.key=k;input.setAttribute('aria-label',`${name} de la fila ${i+1}`);if(base&&Math.abs(r[k]-base[k])>1e-7)input.classList.add('changed');td.append(input);tr.append(td); } const td=document.createElement('td'),badge=document.createElement('span');badge.className='row-badge'+(rowChanged(i)?' changed':r.y===0?' tail':'');badge.textContent=rowChanged(i)?'Modificado':r.y===0?(i===state.rows.length-1?'Recta final':'Recta'):'Original';td.append(badge);tr.append(td); const del=document.createElement('td');del.innerHTML=``;tr.append(del);tbody.append(tr); }); updateHistory(); } function updateMetrics(){ const delta=currentModel.length-baselineModel.length; const endDelta=Math.hypot(...currentModel.end.map((v,i)=>v-baselineModel.end[i])); $('lengthMetric').innerHTML=format(currentModel.length)+'mm'; $('bendsMetric').innerHTML=state.rows.filter(r=>r.y!==0).length+'curvas'; $('lengthDeltaMetric').innerHTML=(delta>1e-7?'+':'')+format(delta)+'mm'; $('lengthDeltaMetric').classList.toggle('changed',Math.abs(delta)>1e-7); $('endpointMetric').innerHTML=format(endDelta)+'mm';$('endpointMetric').classList.toggle('changed',endDelta>1e-7); $('viewSubtitle').textContent=state.rows.some((r,i)=>rowChanged(i))||state.rows.length!==state.baseline.length?'Propuesta modificada':'Geometría nominal'; } function refresh({fit=false}={}){ renderUI();currentModel=buildCenterline(state.rows,{rollSign:state.rollSign});baselineModel=buildCenterline(state.baseline,{rollSign:state.rollSign});updateMetrics(); if(renderer){drawModel();if(fit)fitCamera(currentView);}else arController?.sync(); } function selectRow(index){selected=index;renderUI();if(renderer)drawModel();else arController?.sync();} function undo(delta){const n=historyIndex+delta;if(n<0||n>=history.length)return;historyIndex=n;state=clone(history[n]);selected=Math.min(selected,state.rows.length-1);save();refresh({fit:true});} // Analytic frames generate hollow tube walls directly; centerline data stay in millimeters. function sectionGeometry(samples,outer,inner){ const {positions,normals,indices}=buildTubeMeshData(samples,outer,inner); const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('normal',new THREE.Float32BufferAttribute(normals,3));geometry.setIndex(indices);geometry.computeBoundingSphere();return geometry; } const materials={ straight:new THREE.MeshStandardMaterial({color:0xbccbd2,metalness:.6,roughness:.31,side:THREE.DoubleSide}), bend:new THREE.MeshStandardMaterial({color:0x8faab6,metalness:.57,roughness:.34,side:THREE.DoubleSide}), selected:new THREE.MeshStandardMaterial({color:0xef996c,metalness:.38,roughness:.3,side:THREE.DoubleSide}), ghost:new THREE.MeshBasicMaterial({color:0x7cbbd5,transparent:true,opacity:.11,depthWrite:false,side:THREE.DoubleSide}), line:new THREE.LineDashedMaterial({color:0x87c3db,dashSize:8,gapSize:5,transparent:true,opacity:.8,depthTest:false}), }; function clearGroup(group){while(group.children.length){const child=group.children[0];group.remove(child);child.geometry?.dispose();if(child.userData.disposableMaterial)child.material?.dispose();}} function drawModel(){ clearGroup(modelGroup);clearGroup(originalGroup);pickables=[]; const outer=state.diameter/2,inner=outer-state.thickness; currentModel.sections.forEach(section=>{ const mesh=new THREE.Mesh(sectionGeometry(section.samples,outer,inner),section.rowIndex===selected?materials.selected:materials[section.type]);mesh.userData.rowIndex=section.rowIndex;modelGroup.add(mesh);pickables.push(mesh); }); if(compare){ baselineModel.sections.forEach(section=>originalGroup.add(new THREE.Mesh(sectionGeometry(section.samples,outer,inner),materials.ghost))); const line=new THREE.Line(new THREE.BufferGeometry().setFromPoints(baselineModel.points.map(s=>new THREE.Vector3(...s.p))),materials.line);line.computeLineDistances();line.renderOrder=3;originalGroup.add(line); const a=new THREE.Vector3(...baselineModel.end),b=new THREE.Vector3(...currentModel.end); if(a.distanceTo(b)>.05){const line=new THREE.Line(new THREE.BufferGeometry().setFromPoints([a,b]),new THREE.LineDashedMaterial({color:0xe7a283,dashSize:4,gapSize:4,transparent:true,opacity:.65,depthTest:false}));line.computeLineDistances();line.userData.disposableMaterial=true;originalGroup.add(line);} } const minZ=Math.min(currentModel.bounds.min[2],compare?baselineModel.bounds.min[2]:Infinity)-outer-15; const center=new THREE.Vector3(...currentModel.bounds.min.map((v,i)=>(v+currentModel.bounds.max[i])/2));grid.position.set(center.x,center.y,minZ); $('labels').replaceChildren();labelItems=[]; state.rows.forEach((row,i)=>{ const sections=currentModel.sections.filter(s=>s.rowIndex===i);const s=sections.find(s=>s.type==='bend')||sections[0];if(!s)return; let p=s.samples[Math.floor((s.samples.length-1)/2)];if(s.type==='straight')p={p:s.samples[0].p.map((v,j)=>(v+s.samples[1].p[j])/2),n:s.samples[0].n}; const position=new THREE.Vector3(...p.p.map((v,j)=>v+(outer+19)*p.n[j]));const el=document.createElement('button');el.className='curve-label'+(i===selected?' selected':'');el.textContent=pad(i+1);el.title=(row.y?'Curva ':'Tramo ')+(i+1);el.setAttribute('aria-label',el.title);el.addEventListener('click',()=>selectRow(i));$('labels').append(el);labelItems.push({el,position}); }); for(const [text,p] of [['INICIO',currentModel.points[0].p],['FIN',currentModel.end]]){ const el=document.createElement('span');el.className='endpoint-label';el.textContent=text;$('labels').append(el);labelItems.push({el,position:new THREE.Vector3(...p)}); } positionLabels();arController?.sync(); } function cameraBounds(){ const box=new THREE.Box3();for(const m of compare?[currentModel,baselineModel]:[currentModel]){box.expandByPoint(new THREE.Vector3(...m.bounds.min));box.expandByPoint(new THREE.Vector3(...m.bounds.max));}box.expandByScalar(state.diameter/2+35);return box; } function fitCamera(view='iso'){ if(!camera)return;currentView=view; document.querySelectorAll('[data-view]').forEach(btn=>btn.classList.toggle('active',btn.dataset.view===view)); const box=cameraBounds(),center=box.getCenter(new THREE.Vector3()),size=box.getSize(new THREE.Vector3()); extent=Math.max(size.x,size.y,size.z,100); let direction=new THREE.Vector3(...({iso:[1,-1.5,1.1],front:[0,-1,0],top:[0,0,1],side:[1,0,0]}[view]||[1,-1.5,1.1])).normalize(); camera.up.set(...(view==='top'?[0,1,0]:[0,0,1])); camera.position.copy(center).addScaledVector(direction,extent*2);camera.lookAt(center);camera.updateMatrixWorld(); const right=new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld,0),up=new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld,1); let width=0,height=0,depth=0; for(let i=0;i<8;i++){ const corner=new THREE.Vector3(i&1?box.max.x:box.min.x,i&2?box.max.y:box.min.y,i&4?box.max.z:box.min.z).sub(center); width=Math.max(width,Math.abs(corner.dot(right))*2);height=Math.max(height,Math.abs(corner.dot(up))*2);depth=Math.max(depth,Math.abs(corner.dot(direction))); } const vFov=THREE.MathUtils.degToRad(camera.fov),distance=Math.max(height/2/Math.tan(vFov/2),width/2/(Math.tan(vFov/2)*camera.aspect))*1.27+depth*.5; camera.position.copy(center).addScaledVector(direction,Math.max(distance,100));camera.near=.05;camera.far=Math.max(100000,distance*10);camera.updateProjectionMatrix();controls.target.copy(center);controls.minDistance=5;controls.maxDistance=Math.max(20000,extent*30);controls.update(); } function positionLabels(){if(!camera)return; const inverse=camera.quaternion.clone().invert(),axis=document.querySelector('.axis'); [[0,0,1,'z'],[0,1,0,'y'],[1,0,0,'x']].forEach(([x,y,z,name],i)=>{const v=new THREE.Vector3(x,y,z).applyQuaternion(inverse);axis.querySelectorAll('path')[i].setAttribute('d',`M29 34L${29+v.x*23} ${34-v.y*23}`);const label=axis.querySelector('.axis-'+name);label.style.left=(26+v.x*31)+'px';label.style.top=(29-v.y*31)+'px';label.style.right='auto';label.style.bottom='auto';}); const w=$('viewport').clientWidth,h=$('viewport').clientHeight;for(const item of labelItems){const p=item.position.clone().project(camera);item.el.style.left=(p.x*.5+.5)*w+'px';item.el.style.top=(-p.y*.5+.5)*h+'px';item.el.style.visibility=p.z>1||p.z< -1||Math.abs(p.x)>.95||Math.abs(p.y)>.87?'hidden':'visible';}} function init3D(){ try{ const viewport=$('viewport');renderer=new THREE.WebGLRenderer({alpha:true,antialias:true,powerPreference:'high-performance'});renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setSize(viewport.clientWidth,viewport.clientHeight);renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.25;renderer.domElement.setAttribute('aria-label','Modelo tridimensional del tubo. Arrastra para girar; utiliza la tabla para editar.');viewport.prepend(renderer.domElement); scene=new THREE.Scene();camera=new THREE.PerspectiveCamera(33,viewport.clientWidth/viewport.clientHeight,.05,100000);camera.up.set(0,0,1); controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true;controls.dampingFactor=.085;controls.enablePan=true;controls.zoomSpeed=.8; scene.add(new THREE.HemisphereLight(0xe0edf5,0x223b49,3)); for(const [color,intensity,pos] of [[0xffffff,3,[500,-300,1200]],[0x99bcd5,2,[-600,300,700]],[0xf2c7a8,2,[200,1200,400]]]){const light=new THREE.DirectionalLight(color,intensity);light.position.set(...pos);scene.add(light);} grid=new THREE.GridHelper(5000,100,0x577184,0x3c5364);grid.rotation.x=Math.PI/2;grid.material.transparent=true;grid.material.opacity=.28;grid.material.depthWrite=false;scene.add(grid); modelGroup=new THREE.Group();originalGroup=new THREE.Group();scene.add(modelGroup,originalGroup); const raycaster=new THREE.Raycaster();let down; renderer.domElement.addEventListener('pointerdown',e=>down=[e.clientX,e.clientY]); renderer.domElement.addEventListener('pointerup',e=>{if(!down||Math.hypot(e.clientX-down[0],e.clientY-down[1])>5)return;const r=renderer.domElement.getBoundingClientRect();raycaster.setFromCamera(new THREE.Vector2((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1),camera);const hits=raycaster.intersectObjects(pickables);if(hits.length)selectRow(hits[0].object.userData.rowIndex);}); renderer.domElement.addEventListener('webglcontextlost',e=>{e.preventDefault();$('webglMessage').hidden=false;$('webglMessage').textContent='Se ha interrumpido la vista 3D. Recarga la página para recuperarla. Tus medidas siguen guardadas.';}); new ResizeObserver(()=>{const w=viewport.clientWidth,h=viewport.clientHeight;if(!w||!h)return;renderer.setSize(w,h);camera.aspect=w/h;camera.updateProjectionMatrix();fitCamera(currentView);positionLabels();}).observe(viewport); renderer.setAnimationLoop(()=>{if(arController?.active)return;controls.update();renderer.render(scene,camera);positionLabels();}); }catch(e){renderer=null;$('webglMessage').hidden=false;$('webglMessage').textContent='La vista 3D necesita WebGL 2. Prueba con Chrome, Edge o Firefox y activa la aceleración gráfica. Puedes seguir editando y exportando las medidas.';console.error(e);} } // Editing, importing, comparison and project persistence. for(const field of document.querySelectorAll('[data-field]'))field.addEventListener('change',()=>changeField(field.dataset.field,field.value===''?NaN:Number(field.value))); for(const btn of document.querySelectorAll('[data-step]'))btn.addEventListener('click',()=>{const [key,delta]=btn.dataset.step.split(':');changeField(key,Math.round((state.rows[selected][key]+Number(delta))*1000)/1000);}); $('rotationRange').addEventListener('input',e=>{const next=clone(state);next.rows[selected].z=Number(e.target.value);if(!valid(next))return;state=next;compare=true;refresh();}); $('rotationRange').addEventListener('change',()=>{snapshot();}); $('rowsBody').addEventListener('focusin',e=>{const tr=e.target.closest('tr');if(tr&&selected!==Number(tr.dataset.index)){selected=Number(tr.dataset.index);renderUI();if(renderer)drawModel();}}); $('rowsBody').addEventListener('change',e=>{if(e.target.dataset.key){selected=Number(e.target.closest('tr').dataset.index);changeField(e.target.dataset.key,e.target.value===''?NaN:Number(e.target.value));}}); $('rowsBody').addEventListener('click',e=>{const tr=e.target.closest('tr');if(!tr)return;const i=Number(tr.dataset.index);if(e.target.closest('.delete-row')){if(state.rows.length===1){toast('Conserva al menos un tramo.');return;}const next=clone(state);next.rows.splice(i,1);selected=Math.min(i,next.rows.length-1);commit(next,{fit:true});}else if(!e.target.matches('input'))selectRow(i);}); $('rowsBody').addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.tagName==='TD'){e.preventDefault();selectRow(Number(e.target.closest('tr').dataset.index));}}); $('addRow').addEventListener('click',()=>{if(state.rows.length>=100){toast('El máximo es de 100 filas.');return;}const next=clone(state),index=next.rows.at(-1).y===0?next.rows.length-1:next.rows.length;next.rows.splice(index,0,{id:'row-'+crypto.randomUUID(),x:100,y:90,z:0,r:Math.max(75,state.diameter)});selected=index;commit(next,{fit:true});}); $('resetRow').addEventListener('click',()=>{const base=baseRow(selected);if(!base)return;const next=clone(state);next.rows[selected]=clone(base);commit(next);}); $('undoBtn').addEventListener('click',()=>undo(-1));$('redoBtn').addEventListener('click',()=>undo(1)); window.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'&&!e.target.matches('input,textarea')){e.preventDefault();undo(e.shiftKey?1:-1);}if(e.key==='Escape')$('exportMenu').hidden=true;}); $('projectName').addEventListener('change',e=>{const next=clone(state);next.name=e.target.value.trim().slice(0,100)||'Pieza sin nombre';commit(next,{autocompare:false});}); for(const key of ['diameter','thickness','rollSign'])$(key).addEventListener('change',e=>{const next=clone(state);next[key]=e.target.value===''?NaN:Number(e.target.value);commit(next,{fit:key==='rollSign',autocompare:false});}); function setCompare(value){compare=!!value;$('compareToggle').checked=compare;$('originalLegend').hidden=!compare;if(renderer){drawModel();fitCamera(currentView);}else arController?.sync();} $('compareToggle').addEventListener('change',e=>setCompare(e.target.checked)); $('setBaseline').addEventListener('click',()=>{const next=clone(state);next.baseline=clone(next.rows);commit(next,{autocompare:false});toast('La propuesta es ahora el original. Puedes deshacer este cambio.');}); for(const el of document.querySelectorAll('[data-view]'))el.addEventListener('click',()=>fitCamera(el.dataset.view)); $('fitBtn').addEventListener('click',()=>fitCamera(currentView)); for(const [id,factor] of [['zoomIn',.8],['zoomOut',1.25]])$(id).addEventListener('click',()=>{if(!camera)return;camera.position.sub(controls.target).multiplyScalar(factor).add(controls.target);controls.update();}); $('gridToggle').addEventListener('click',e=>{if(!grid)return;grid.visible=!grid.visible;$('gridToggle').classList.toggle('active',grid.visible);$('gridToggle').setAttribute('aria-pressed',grid.visible);}); function openHelp(){$('helpDialog').showModal();} for(const id of ['helpBtn','topHelp','modelHelp'])$(id).addEventListener('click',openHelp); $('railSettings').addEventListener('click',()=>{$('specCard').scrollIntoView({behavior:'smooth',block:'center'});$('diameter').focus({preventScroll:true});}); for(const btn of document.querySelectorAll('.close-modal'))btn.addEventListener('click',()=>btn.closest('dialog').close()); for(const dialog of document.querySelectorAll('dialog'))dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)dialog.close();}}); let jsonImport=null; function openImport(){jsonImport=null;$('pasteData').value='';$('fileInput').value='';$('importError').hidden=true;$('importDialog').showModal();} for(const id of ['importBtn','railImport'])$(id).addEventListener('click',openImport); $('pasteData').addEventListener('input',()=>{jsonImport=null;$('importError').hidden=true;}); $('fileInput').addEventListener('change',async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>1000000)throw Error('El archivo debe pesar menos de 1 MB.');const text=await file.text();if(file.name.toLowerCase().endsWith('.json')){jsonImport=checkProject(JSON.parse(text));$('pasteData').value='Xc;Yc;Zc;CLR\n'+jsonImport.rows.map(r=>[r.x,r.y,r.z,r.r].join(';')).join('\n');}else{jsonImport=null;$('pasteData').value=text;}$('importError').hidden=true;}catch(e){jsonImport=null;$('importError').textContent=e.message;$('importError').hidden=false;}}); $('importConfirm').addEventListener('click',()=>{try{let next;if(jsonImport)next=checkProject(jsonImport);else{const rows=parseTable($('pasteData').value);next=checkProject({...state,name:'Pieza importada',rows,baseline:clone(rows)});}selected=0;compare=false;if(commit(next,{fit:true,autocompare:false})){$('importDialog').close();toast('Pieza cargada. El original está listo para comparar.');}else throw Error($('validationBanner').textContent);}catch(e){$('importError').textContent=e.message;$('importError').hidden=false;}}); $('exampleSelect').addEventListener('change',e=>{if(!e.target.value)return;const five=e.target.value==='five';const rows=five?[{x:120,y:65,z:0,r:80},{x:90,y:80,z:85,r:120},{x:75,y:70,z:-160,r:120},{x:150,y:70,z:85,r:80},{x:80,y:45,z:45,r:90},{x:100,y:0,z:0,r:0}]:clone(sampleRows);const next={schemaVersion:1,name:five?'Pieza de prueba · 5 curvas':'Tubo de referencia',rows,baseline:clone(rows),diameter:50,thickness:1.2,rollSign:1};selected=five?2:1;compare=false;commit(next,{fit:true,autocompare:false});e.target.value='';toast(five?'Ejemplo de cinco curvas cargado.':'Datos de la captura VGP3D cargados.');}); $('exportBtn').addEventListener('click',e=>{e.stopPropagation();$('exportMenu').hidden=!$('exportMenu').hidden;});document.addEventListener('click',e=>{if(!e.target.closest('.export-wrap'))$('exportMenu').hidden=true;}); function download(content,type,extension){const blob=new Blob([content],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=(state.name.replace(/[^a-zA-Z0-9áéíóúñ_-]/g,'_')||'tubo')+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);$('exportMenu').hidden=true;} $('exportCSV').addEventListener('click',()=>{download('\uFEFFXc;Yc;Zc;CLR\r\n'+state.rows.map(r=>[r.x,r.y,r.z,r.r].join(';')).join('\r\n'),'text/csv;charset=utf-8','.csv');toast('Tabla exportada para revisión.');}); $('exportJSON').addEventListener('click',()=>{download(JSON.stringify({...state,conventions:{length:'mm',angle:'degrees',xc:'tangent-to-tangent straight',yc:'bend angle',zc:'relative roll before bend',clr:'centerline radius'}},null,2),'application/json','.tubolab.json');toast('Proyecto exportado con original y propuesta.');}); refresh();init3D();if(renderer){drawModel();fitCamera('iso');} function physicalModels(){ const proposal=new THREE.Group(),original=new THREE.Group();proposal.name='proposal';original.name='original'; const outer=state.diameter/2,inner=outer-state.thickness; currentModel.sections.forEach(s=>proposal.add(new THREE.Mesh(sectionGeometry(s.samples,outer,inner),(s.rowIndex===selected?materials.selected:materials[s.type]).clone()))); baselineModel.sections.forEach(s=>original.add(new THREE.Mesh(sectionGeometry(s.samples,outer,inner),new THREE.MeshStandardMaterial({color:0x63b1dc,metalness:.15,roughness:.5,transparent:true,opacity:.23,depthWrite:false,side:THREE.DoubleSide})))); return {proposal,original}; } arController=createARController({getProject:()=>clone(state),getModels:physicalModels,getSelected:()=>selected,getCompare:()=>compare,onEdit:changeField,onSelect:selectRow,onCompare:setCompare,notify:toast,onExit:()=>fitCamera(currentView)}); for(const button of document.querySelectorAll('[data-mobile-target]'))button.addEventListener('click',()=>{ document.body.dataset.mobilePanel=button.dataset.mobileTarget; document.querySelectorAll('[data-mobile-target]').forEach(b=>{const active=b===button;b.setAttribute('aria-selected',active);b.setAttribute('aria-pressed',active);b.tabIndex=active?0:-1;}); if(button.dataset.mobileTarget==='model')requestAnimationFrame(()=>fitCamera(currentView)); }); document.querySelector('.mobile-work-tabs').addEventListener('keydown',e=>{if(e.key==='ArrowLeft'||e.key==='ArrowRight'){e.preventDefault();const target=document.querySelector(`[data-mobile-target="${document.body.dataset.mobilePanel==='model'?'sequence':'model'}"]`);target.click();target.focus();}}); // Read-only diagnostics support reproducible geometry checks. window.tubolab={getProject:()=>clone(state),getMetrics:()=>({length:currentModel.length,end:clone(currentModel.end),baselineLength:baselineModel.length}),is3DReady:()=>!!renderer};