import {distance,angleDegrees,signedLineAngle,lineIntersection,polygonArea,pointInPolygon,splitPolygon,transformPoint,inverseTransformPoint,rebasePivot} from './geometry.mjs'; import {demoDataURL} from './demo.mjs'; import {validateProject} from './project.mjs'; const $ = id => document.getElementById(id); const canvas=$('canvas'),ctx=canvas.getContext('2d'),stage=$('stage'); const palette=['#72e7b8','#e3c17c','#80bcf0','#df9cba','#b4a1ed']; const instructions={select:'Select a measurement or drag a fragment. Drag empty space to pan.',length:'Click the two endpoints of a length measurement.',angle:'Click three points. The second point is the angle vertex.',axis:'Click two points along an alignment axis. Axes are extended as dashed lines.',calibrate:'Click the ends of the known calibration distance.',polygon:'Click around a bone region. Press Enter, double-click, or Finish outline to close.',cut:'Click two points defining a cut across the selected fragment. With no fragments, the image is cut.',pivot:'Select a fragment, then click its new rotation pivot.'}; let image=null,imageInfo=null,state=freshState(),tool='select',pending=[],hover=null,selectedFragment=null,selectedMeasurement=null,compare=false; let view={scale:1,x:0,y:0,fitScale:1},undoStack=[],redoStack=[],pointer=null,sliderBefore=null,busy=false; let screen={width:600,height:700},toastTimer; function freshState(){return{fragments:[],measurements:[],calibration:null,axisPair:[],display:{brightness:100,contrast:100,showMeasurements:true}};} const clone = obj=>JSON.parse(JSON.stringify(obj)); const snapshot=()=>clone(state); const uid=()=>crypto.randomUUID?.() ?? `p-${Date.now()}-${Math.random().toString(36).slice(2)}`; function toast(message){$('toast').textContent=message;$('toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('visible'),3700);} function record(before=snapshot()){undoStack.push(before);if(undoStack.length>60)undoStack.shift();redoStack=[];} function commit(action){record();action();update();} function cleanSelection(){if(!state.fragments.some(f=>f.id===selectedFragment))selectedFragment=null;if(!state.measurements.some(m=>m.id===selectedMeasurement))selectedMeasurement=null;} function undo(){if(!undoStack.length)return;redoStack.push(snapshot());state=undoStack.pop();pending=[];cleanSelection();update();} function redo(){if(!redoStack.length)return;undoStack.push(snapshot());state=redoStack.pop();pending=[];cleanSelection();update();} const fragment=()=>state.fragments.find(f=>f.id===selectedFragment); const worldPoint=p=>p.fragmentId?transformPoint(p,state.fragments.find(f=>f.id===p.fragmentId)):p; function anchorPoint(p){const f=fragment();if(f&&pointInPolygon(inverseTransformPoint(p,f),f.points))return{...inverseTransformPoint(p,f),fragmentId:f.id};return{x:p.x,y:p.y};} function imagePoint(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left-view.x)/view.scale,y:(e.clientY-r.top-view.y)/view.scale};} function screenPoint(e){const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top};} function insideImage(p){return image&&p.x>=0&&p.y>=0&&p.x<=image.width&&p.y<=image.height;} function setTool(value){tool=value;pending=[];hover=null;compare=false;$('original').setAttribute('aria-pressed','false');$('original').textContent='Compare original';canvas.style.cursor=value==='select'?'default':'crosshair';update();} function path(c,points){c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();} function line(c,a,b){c.beginPath();c.moveTo(a.x,a.y);c.lineTo(b.x,b.y);c.stroke();} function centroid(points){return{x:points.reduce((n,p)=>n+p.x,0)/points.length,y:points.reduce((n,p)=>n+p.y,0)/points.length};} function drawImage(c){c.filter=`brightness(${state.display.brightness}%) contrast(${state.display.contrast}%)`;c.drawImage(image,0,0);c.filter='none';} function drawFragment(c,f){c.save();c.translate(f.pivot.x+f.tx,f.pivot.y+f.ty);c.rotate(f.rotation*Math.PI/180);c.translate(-f.pivot.x,-f.pivot.y);path(c,f.points);c.clip('evenodd');drawImage(c);c.restore();} function lengthText(value){return state.calibration?`${(value/state.calibration.pxPerMm).toFixed(1)} mm`:`${value.toFixed(1)} px`;} function measurementValue(m){const p=m.points.map(worldPoint);if(m.type==='length')return lengthText(distance(...p));if(m.type==='angle'){const a=angleDegrees(...p);return a===null?'Undefined':`${a.toFixed(1)}°`;}return 'Axis';} function label(c,text,p,color,scale){const s=1/scale;c.font=`${11*s}px -apple-system,BlinkMacSystemFont,sans-serif`;const w=c.measureText(text).width+14*s;c.fillStyle='#122126e8';c.strokeStyle=color+'66';c.lineWidth=s;c.beginPath();c.roundRect(p.x-w/2,p.y-11*s,w,22*s,4*s);c.fill();c.stroke();c.fillStyle=color;c.textAlign='center';c.textBaseline='middle';c.fillText(text,p.x,p.y);} function handle(c,p,color,s,selected=false){c.beginPath();c.arc(p.x,p.y,(selected?4.7:3.4)/s,0,Math.PI*2);c.fillStyle='#102127';c.fill();c.strokeStyle=color;c.lineWidth=1.4/s;c.stroke();} function axisComparison(){const axes=state.measurements.filter(m=>m.type==='axis');if(axes.length<2)return null;const a=axes.find(m=>m.id===state.axisPair[0])||axes[0],b=axes.find(m=>m.id===state.axisPair[1]&&m.id!==a.id)||axes.find(m=>m.id!==a.id);if(!b)return null;const [a1,a2]=a.points.map(worldPoint),[b1,b2]=b.points.map(worldPoint),angle=signedLineAngle(a1,a2,b1,b2);return{a,b,point:lineIntersection(a1,a2,b1,b2),angle:angle===null?null:Math.min(Math.abs(angle),180-Math.abs(angle))};} function drawMeasurement(c,m,index,scale){const p=m.points.map(worldPoint),color=m.type==='axis'?palette[index%palette.length]:m.type==='angle'?'#e3c17c':'#a9d3f4';c.strokeStyle=color;c.lineWidth=(m.id===selectedMeasurement?2.2:1.5)/scale;c.setLineDash([]); if(m.type==='length'){line(c,...p);label(c,measurementValue(m),{x:(p[0].x+p[1].x)/2+12/scale,y:(p[0].y+p[1].y)/2-15/scale},color,scale);} if(m.type==='angle'){line(c,p[0],p[1]);line(c,p[1],p[2]);const a=Math.atan2(p[0].y-p[1].y,p[0].x-p[1].x),b=Math.atan2(p[2].y-p[1].y,p[2].x-p[1].x);let d=Math.atan2(Math.sin(b-a),Math.cos(b-a));let radius=Math.min(38/scale,distance(p[0],p[1])*.3,distance(p[2],p[1])*.3);c.beginPath();c.arc(p[1].x,p[1].y,radius,a,a+d,d<0);c.stroke();const mid=a+d/2;label(c,measurementValue(m),{x:p[1].x+Math.cos(mid)*(radius+24/scale),y:p[1].y+Math.sin(mid)*(radius+24/scale)},color,scale);} if(m.type==='axis'){const d=distance(...p);if(d>0){const dx=(p[1].x-p[0].x)/d,dy=(p[1].y-p[0].y)/d,ext=1800;c.globalAlpha=.48;c.setLineDash([7/scale,6/scale]);line(c,{x:p[0].x-dx*ext,y:p[0].y-dy*ext},{x:p[1].x+dx*ext,y:p[1].y+dy*ext});c.globalAlpha=1;c.setLineDash([]);line(c,...p);label(c,m.name,{x:p[0].x+40/scale,y:p[0].y},color,scale);}} p.forEach(q=>handle(c,q,color,scale,m.id===selectedMeasurement));c.setLineDash([]); } function drawScene(c,scale,{original=false,exporting=false}={}){if(!image)return;drawImage(c);if(original)return; state.fragments.forEach(f=>{path(c,f.points);c.fillStyle='#0b1418';c.fill('evenodd');});state.fragments.forEach(f=>drawFragment(c,f)); if(state.display.showMeasurements){state.measurements.forEach((m,i)=>drawMeasurement(c,m,i,scale));const comp=axisComparison();if(comp?.point&&comp.angle!==null){const p=comp.point;c.strokeStyle='#f1db9b';c.lineWidth=1.3/scale;line(c,{x:p.x-7/scale,y:p.y},{x:p.x+7/scale,y:p.y});line(c,{x:p.x,y:p.y-7/scale},{x:p.x,y:p.y+7/scale});if(p.x>-image.width&&p.x-image.height&&p.ytransformPoint(p,f)));c.strokeStyle=f.color;c.lineWidth=1.4/scale;c.setLineDash([5/scale,4/scale]);c.stroke();c.setLineDash([]);const p=transformPoint(f.pivot,f);c.strokeStyle=f.color;c.lineWidth=1.4/scale;c.beginPath();c.arc(p.x,p.y,8/scale,0,Math.PI*2);c.stroke();line(c,{x:p.x-12/scale,y:p.y},{x:p.x+12/scale,y:p.y});line(c,{x:p.x,y:p.y-12/scale},{x:p.x,y:p.y+12/scale});} if(pending.length){const points=pending.concat(hover?[hover]:[]);c.strokeStyle=tool==='cut'?'#ffb6a1':'#a9e8d1';c.lineWidth=1.5/scale;c.setLineDash([5/scale,4/scale]);c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));if(tool==='polygon'&&points.length>2)c.closePath();c.stroke();c.setLineDash([]);pending.forEach(p=>handle(c,p,'#a9e8d1',scale));}} function draw(){const dpr=Math.min(window.devicePixelRatio||1,2);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle='#10191e';ctx.fillRect(0,0,screen.width,screen.height);if(image){ctx.save();ctx.translate(view.x,view.y);ctx.scale(view.scale,view.scale);drawScene(ctx,view.scale,{original:compare});ctx.restore();}$('zoomLabel').textContent=`${Math.round(view.scale/view.fitScale*100)}%`;} function resize(){const r=stage.getBoundingClientRect(),dpr=Math.min(window.devicePixelRatio||1,2);const old={...screen};screen={width:r.width,height:r.height};canvas.width=Math.round(r.width*dpr);canvas.height=Math.round(r.height*dpr);if(image){if(!old.width||!view.fitScale)fit();else{view.x+=(screen.width-old.width)/2;view.y+=(screen.height-old.height)/2;}}draw();} function fit(){if(!image)return;const s=Math.min((screen.width-55)/image.width,(screen.height-85)/image.height);view={scale:s,fitScale:s,x:(screen.width-image.width*s)/2,y:(screen.height-image.height*s)/2};draw();} function zoom(factor,at={x:screen.width/2,y:screen.height/2}){const s=Math.max(view.fitScale*.25,Math.min(view.fitScale*12,view.scale*factor));view.x=at.x-(at.x-view.x)*s/view.scale;view.y=at.y-(at.y-view.y)*s/view.scale;view.scale=s;draw();} function update(){cleanSelection();document.querySelectorAll('[data-tool]').forEach(b=>{b.classList.toggle('active',b.dataset.tool===tool);b.setAttribute('aria-pressed',b.dataset.tool===tool?'true':'false');});$('instruction').textContent=instructions[tool]+(pending.length?` (${pending.length} point${pending.length===1?'':'s'} placed)`:'');$('cancel').hidden=!pending.length;$('finishPolygon').hidden=tool!=='polygon'||pending.length<3;$('undo').disabled=!undoStack.length;$('redo').disabled=!redoStack.length; $('brightness').value=state.display.brightness;$('contrast').value=state.display.contrast;$('showMeasurements').checked=state.display.showMeasurements; const cal=state.calibration;$('scaleBadge').textContent=cal?'MM':'PIXELS';$('scaleValue').textContent=cal?`${cal.pxPerMm.toFixed(3)} px / mm`:'Not calibrated';$('scaleHelp').textContent=cal?(imageInfo?.demo?'Practice scale: 100 mm = 250 pixels.':'Scale applies to this image. Check the calibration marker and magnification.'):'Use a known distance in the image to measure in mm.';$('clearCalibration').disabled=!cal; $('measurementCount').textContent=state.measurements.length;const list=$('measurements');list.replaceChildren();if(!state.measurements.length){const e=document.createElement('div');e.className='empty-note';e.textContent='Add a length, angle, or alignment axis to begin.';list.append(e);} state.measurements.forEach((m,i)=>{const row=document.createElement('div');row.className='measurement-row'+(m.id===selectedMeasurement?' selected':'');const dot=document.createElement('i');dot.className='measure-dot';dot.style.background=m.type==='axis'?palette[i%palette.length]:m.type==='angle'?'#c2a66a':'#80adc6';const button=document.createElement('button');button.className='measure-select';button.textContent=m.name;button.title='Select, then drag its endpoint handles in the image';const small=document.createElement('span');small.textContent=m.points.some(p=>p.fragmentId)?'Follows fragment points':m.type==='axis'?'Manually placed axis':m.type==='angle'?'Interior angle':'Endpoint distance';button.append(small);button.onclick=()=>{selectedMeasurement=m.id;selectedFragment=null;setTool('select');};const value=document.createElement('strong');value.textContent=measurementValue(m);const remove=document.createElement('button');remove.className='remove-measure';remove.textContent='×';remove.setAttribute('aria-label',`Remove ${m.name}`);remove.onclick=()=>commit(()=>{state.measurements=state.measurements.filter(x=>x.id!==m.id);});row.append(dot,button,value,remove);list.append(row);}); const axes=state.measurements.filter(m=>m.type==='axis'),comp=axisComparison();$('coraEmpty').hidden=!!comp;$('coraPanel').hidden=!comp;for(const [id,key]of[['axisA','a'],['axisB','b']]){const s=$(id);s.replaceChildren();axes.forEach(m=>{const o=document.createElement('option');o.value=m.id;o.textContent=m.name;s.append(o);});if(comp)s.value=comp[key].id;} if(comp){$('coraAngle').textContent=comp.angle===null?'Undefined':`${comp.angle.toFixed(1)}°`;$('coraPosition').textContent=comp.point?`${Math.round(comp.point.x)}, ${Math.round(comp.point.y)} px${insideImage(comp.point)?'':' · outside image'}`:comp.angle===null?'Degenerate axis':'Parallel axes';$('pivotCora').disabled=!fragment()||!comp.point;} $('fragmentCount').textContent=state.fragments.length;$('fragmentEmpty').hidden=state.fragments.length>0;const fl=$('fragmentList');fl.replaceChildren();state.fragments.forEach(f=>{const b=document.createElement('button');b.className='fragment-chip'+(f.id===selectedFragment?' selected':'');const dot=document.createElement('i');dot.style.background=f.color;b.append(dot,document.createTextNode(f.name));b.onclick=()=>{selectedFragment=f.id;selectedMeasurement=null;setTool('select');};fl.append(b);});const f=fragment();$('transformPanel').hidden=!f;if(f){$('rotation').value=f.rotation;$('rotationNumber').value=f.rotation.toFixed(1);$('rotationValue').textContent=`${f.rotation.toFixed(1)}°`;const unit=cal?'mm':'px',scale=cal?.pxPerMm||1;$('txLabel').firstChild.textContent=`X · ${unit}`;$('tyLabel').firstChild.textContent=`Y · ${unit}`;$('translationX').value=(f.tx/scale).toFixed(1);$('translationY').value=(f.ty/scale).toFixed(1);} $('practiceCut').disabled=!imageInfo?.demo;$('stageTag').textContent=compare?'ORIGINAL IMAGE':imageInfo?.demo?'SYNTHETIC PRACTICE IMAGE':'MANUAL X-RAY WORKSPACE';draw();} function nearHandle(p){let best=null,bestDist=12/view.scale;if(!state.display.showMeasurements)return null;for(const m of state.measurements)for(let i=0;ipointInPolygon(inverseTransformPoint(p,f),f.points));} function segmentsIntersect(a,b,c,d){const cross=(p,q,r)=>(q.x-p.x)*(r.y-p.y)-(q.y-p.y)*(r.x-p.x);const on=(p,q,r)=>Math.abs(cross(p,q,r))<1e-8&&r.x>=Math.min(p.x,q.x)-1e-8&&r.x<=Math.max(p.x,q.x)+1e-8&&r.y>=Math.min(p.y,q.y)-1e-8&&r.y<=Math.max(p.y,q.y)+1e-8;const u=cross(a,b,c),v=cross(a,b,d),w=cross(c,d,a),z=cross(c,d,b);return((u>0&&v<0||u<0&&v>0)&&(w>0&&z<0||w<0&&z>0))||on(a,b,c)||on(a,b,d)||on(c,d,a)||on(c,d,b);} function simplePolygon(points){for(let i=0;ipointInPolygon(p,b))||b.some(p=>pointInPolygon(p,a)))return true;for(let i=0;im.points=m.points.map(p=>!p.fragmentId&&pointInPolygon(p,f.points)?{...p,fragmentId:f.id}:p));} function detachPoints(id){state.measurements.forEach(m=>m.points=m.points.map(p=>p.fragmentId===id?{...worldPoint(p),fragmentId:undefined}:p));} function finishPolygon(){if(state.fragments.length>=128){toast('This plan has reached the 128-fragment limit. Restore a region first.');return;}if(pending.length<3){toast('Place at least three outline points.');return;}const points=pending.filter((p,i)=>!i||distance(p,pending[i-1])>.01);if(distance(points[0],points.at(-1))<.01)points.pop();if(points.length<3||Math.abs(polygonArea(points))<25){toast('Trace a larger region with at least three distinct points.');return;}if(!simplePolygon(points)){toast('The outline crosses itself. Cancel and trace a simple outline.');return;}if(state.fragments.some(f=>overlapping(points,f.points))){toast('That region already belongs to a fragment. Select it and use Osteotomy line.');return;}commit(()=>{const f={id:uid(),name:`Fragment ${state.fragments.length+1}`,points:clone(points),pivot:centroid(points),tx:0,ty:0,rotation:0,color:palette[state.fragments.length%palette.length]};state.fragments.push(f);attachPoints(f);selectedFragment=f.id;selectedMeasurement=null;});setTool('select');toast('Fragment created. Drag to move, or use the rotation control.');} function performCut(a,b){if(state.fragments.length>=128){pending=[];update();toast('This plan has reached the 128-fragment limit.');return;}if(distance(a,b)<2){toast('Place two different cut-line points.');pending=[];update();return;}let f=fragment();if(!f&&state.fragments.length){pending=[];update();toast('Select a fragment before drawing the cut line.');return;}const original=f||{id:uid(),name:'Image',points:[{x:0,y:0},{x:image.width,y:0},{x:image.width,y:image.height},{x:0,y:image.height}],pivot:{x:0,y:0},tx:0,ty:0,rotation:0,color:palette[0]};const localA=inverseTransformPoint(a,original),localB=inverseTransformPoint(b,original),parts=splitPolygon(original.points,localA,localB);if(parts.some(p=>p.length<3||p.length>4096||Math.abs(polygonArea(p))<25)){pending=[];update();toast('The cut must cross the fragment and leave two usable regions.');return;}commit(()=>{const p={x:(localA.x+localB.x)/2,y:(localA.y+localB.y)/2},pivoted=rebasePivot(original,p);const pieces=parts.map((points,i)=>({...pivoted,id:uid(),name:`${original.name==='Image'?'Region':original.name.slice(0,198)} ${i+1}`,points,color:palette[(state.fragments.length+i)%palette.length]}));state.measurements.forEach(m=>{m.points=m.points.map(q=>{if(q.fragmentId!==original.id&&q.fragmentId)return q;if(!f&&!q.fragmentId||q.fragmentId===original.id){const part=pieces.find(piece=>pointInPolygon(q,piece.points));if(part)return{...q,fragmentId:part.id};if(q.fragmentId===original.id)return transformPoint(q,original);}return q;});});state.fragments=state.fragments.filter(x=>x.id!==original.id).concat(pieces);selectedFragment=pieces[1].id;selectedMeasurement=null;});setTool('select');toast('Two fragments created. Select a region to move or rotate it.');} function placePoint(p){if(!image)return;if(Math.abs(p.x)>1e7||Math.abs(p.y)>1e7){toast('Place points closer to the image.');return;}if(['length','angle','axis'].includes(tool)&&state.measurements.length>=500){toast('This plan has reached the 500-measurement limit.');return;}if(tool==='polygon'&&pending.length>=4096){toast('Finish the outline before adding more points.');return;}if(tool==='pivot'){const f=fragment();if(!f){toast('Select a fragment first.');return;}commit(()=>{Object.assign(f,rebasePivot(f,inverseTransformPoint(p,f)));});toast('Pivot moved; fragment position preserved.');setTool('select');return;} if(tool==='polygon'&&!insideImage(p)){toast('Outline points must be inside the original image.');return;} if((tool==='length'||tool==='axis'||tool==='calibrate')&&pending.length&&distance(p,pending[0])<1){toast('Place a second point away from the first.');return;} if(tool==='angle'&&pending.length&&distance(p,pending.at(-1))<1){toast('Place a distinct angle point.');return;} if(tool==='polygon'&&pending.length&&distance(p,pending.at(-1))<2/view.scale)return; pending.push(p);const needed={length:2,axis:2,calibrate:2,angle:3,cut:2}[tool];if(tool==='polygon'){update();return;}if(pending.length100000||distance(...pending)/known>=1e7){pending=[];update();toast('Enter a calibration distance between 0.01 and 100,000 mm.');return;}commit(()=>{state.calibration={pxPerMm:distance(...pending)/known,knownLength:known,points:clone(pending)};});setTool('select');toast('Scale calibrated. Lengths and translation now use mm.');return;} if(tool==='cut'){performCut(...pending);return;} if(needed){const type=tool;commit(()=>{const n=state.measurements.filter(m=>m.type===type).length+1,m={id:uid(),type,name:`${type==='axis'?'Axis':type==='angle'?'Angle':'Length'} ${n}`,points:pending.map(anchorPoint)};state.measurements.push(m);selectedMeasurement=m.id;if(type==='axis')state.axisPair=state.measurements.filter(x=>x.type==='axis').slice(-2).map(x=>x.id);});pending=[];update();}} canvas.addEventListener('pointerdown',e=>{if(!image||busy||e.button!==0)return;e.preventDefault();canvas.focus();const p=imagePoint(e),s=screenPoint(e);canvas.setPointerCapture(e.pointerId);if(compare){pointer={kind:'pan',start:s,view:{...view}};return;}if(tool==='select'){const h=nearHandle(p);if(h){selectedMeasurement=h.m.id;selectedFragment=null;pointer={kind:'handle',handle:h,before:snapshot(),start:s};}else{const f=hitFragment(p);selectedFragment=f?.id||null;selectedMeasurement=null;pointer=f?{kind:'fragment',f,start:p,startScreen:s,tx:f.tx,ty:f.ty,before:snapshot()}:{kind:'pan',start:s,view:{...view}};}update();}else{pointer={kind:'place',start:s,point:p};}}); canvas.addEventListener('pointermove',e=>{const p=imagePoint(e),s=screenPoint(e);hover=p;if(pointer?.kind==='pan'){view.x=pointer.view.x+s.x-pointer.start.x;view.y=pointer.view.y+s.y-pointer.start.y;draw();}else if(pointer?.kind==='fragment'){pointer.f.tx=pointer.tx+p.x-pointer.start.x;pointer.f.ty=pointer.ty+p.y-pointer.start.y;draw();}else if(pointer?.kind==='handle'){const{m,index}=pointer.handle,q=m.points[index];m.points[index]=q.fragmentId?{...inverseTransformPoint(p,state.fragments.find(f=>f.id===q.fragmentId)),fragmentId:q.fragmentId}:p;draw();}else if(pending.length)draw();}); function pointerDone(e){if(!pointer)return;const data=pointer;pointer=null;if(data.kind==='place'&&distance(data.start,screenPoint(e))<5)placePoint(imagePoint(e));else if((data.kind==='fragment'||data.kind==='handle')&&JSON.stringify(data.before)!==JSON.stringify(state)){record(data.before);update();}else update();} canvas.addEventListener('pointerup',pointerDone);canvas.addEventListener('pointercancel',()=>{if(pointer?.before)state=pointer.before;pointer=null;update();}); canvas.addEventListener('dblclick',e=>{if(tool==='polygon'){e.preventDefault();finishPolygon();}}); canvas.addEventListener('wheel',e=>{e.preventDefault();zoom(Math.exp(-e.deltaY*.0017),screenPoint(e));},{passive:false}); document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>setTool(b.dataset.tool)); $('cancel').onclick=()=>{pending=[];update();};$('finishPolygon').onclick=finishPolygon;$('fit').onclick=fit;$('zoomIn').onclick=()=>zoom(1.25);$('zoomOut').onclick=()=>zoom(.8);$('undo').onclick=undo;$('redo').onclick=redo; $('original').onclick=()=>{compare=!compare;pending=[];$('original').setAttribute('aria-pressed',String(compare));$('original').textContent=compare?'Return to plan':'Compare original';update();}; $('startCalibration').onclick=()=>setTool('calibrate');$('clearCalibration').onclick=()=>commit(()=>{state.calibration=null;}); $('showMeasurements').onchange=e=>commit(()=>{state.display.showMeasurements=e.target.checked;}); for(const id of['brightness','contrast','rotation']){$(id).addEventListener('input',e=>{if(!sliderBefore)sliderBefore=snapshot();if(id==='rotation'){const f=fragment();if(f)f.rotation=Number(e.target.value);$('rotationNumber').value=e.target.value;$('rotationValue').textContent=`${Number(e.target.value).toFixed(1)}°`;}else state.display[id]=Number(e.target.value);draw();});$(id).addEventListener('change',()=>{if(sliderBefore){record(sliderBefore);sliderBefore=null;}update();});} $('resetDisplay').onclick=()=>commit(()=>{state.display.brightness=100;state.display.contrast=100;}); const numberBefore=new Map(); for(const[id,key]of[['rotationNumber','rotation'],['translationX','tx'],['translationY','ty']]){ const input=$(id),value=()=>Number(input.value),valid=()=>input.value.trim()!==''&&Number.isFinite(value())&&(key==='rotation'?Math.abs(value())<=180:Math.abs(value()*(state.calibration?.pxPerMm||1))<=1e7); const apply=()=>{const f=fragment();if(!f||!valid())return false;f[key]=key==='rotation'?value():value()*(state.calibration?.pxPerMm||1);return true;}; input.addEventListener('input',()=>{if(!fragment())return;if(!numberBefore.has(id))numberBefore.set(id,snapshot());if(apply()){$('rotation').value=fragment().rotation;$('rotationValue').textContent=`${fragment().rotation.toFixed(1)}°`;const c=axisComparison();if(c){$('coraAngle').textContent=c.angle===null?'Undefined':`${c.angle.toFixed(1)}°`;$('coraPosition').textContent=c.point?`${Math.round(c.point.x)}, ${Math.round(c.point.y)} px${insideImage(c.point)?'':' · outside image'}`:'Parallel axes';}draw();}}); const finish=()=>{if(!fragment())return;const before=numberBefore.get(id)||snapshot();if(!apply()){if(numberBefore.has(id))state=before;toast(key==='rotation'?'Enter a rotation between −180° and 180°.':'Enter a finite translation distance within the image coordinate limits.');}else if(JSON.stringify(before)!==JSON.stringify(state))record(before);numberBefore.delete(id);update();}; input.addEventListener('change',finish);input.addEventListener('blur',finish); } $('resetFragment').onclick=()=>{const f=fragment();if(f)commit(()=>{f.tx=0;f.ty=0;f.rotation=0;});}; $('removeFragment').onclick=()=>{const f=fragment();if(f)commit(()=>{detachPoints(f.id);state.fragments=state.fragments.filter(x=>x.id!==f.id);selectedFragment=null;});}; for(const id of['axisA','axisB'])$(id).onchange=()=>{const a=$('axisA').value,b=$('axisB').value;if(a===b){update();toast('Choose two different axes.');return;}commit(()=>{state.axisPair=[a,b];});}; $('pivotCora').onclick=()=>{const f=fragment(),p=axisComparison()?.point;if(f&&p)commit(()=>{Object.assign(f,rebasePivot(f,inverseTransformPoint(p,f)));});}; document.addEventListener('keydown',e=>{if(['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo();return;}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='y'){e.preventDefault();redo();return;}if(e.key==='Escape'){pending=[];setTool('select');return;}if(e.key==='Enter'&&tool==='polygon'){e.preventDefault();finishPolygon();return;}if(e.ctrlKey||e.metaKey||e.altKey)return;const map={v:'select',l:'length',a:'angle',x:'axis',c:'calibrate',f:'polygon',o:'cut',p:'pivot'};if(map[e.key.toLowerCase()]){e.preventDefault();setTool(map[e.key.toLowerCase()]);}if(e.key==='Delete'||e.key==='Backspace'){if(selectedMeasurement){e.preventDefault();commit(()=>{state.measurements=state.measurements.filter(m=>m.id!==selectedMeasurement);selectedMeasurement=null;});}else if(fragment()){e.preventDefault();$('removeFragment').click();}}}); function loadImage(src){return new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('This image could not be decoded. Use PNG, JPEG, or WebP.'));img.src=src;});} async function setImage(src,name,{demo=false,project=null}={}){const next=await loadImage(src);if(next.naturalWidth*next.naturalHeight>40000000||next.naturalWidth>12000||next.naturalHeight>12000)throw new Error('Use an image up to 12,000 pixels per side and 40 megapixels.');if(project&&(next.naturalWidth!==project.image.width||next.naturalHeight!==project.image.height))throw new Error('Saved image dimensions do not match the embedded image.');image=next;imageInfo={src,name,width:next.naturalWidth,height:next.naturalHeight,demo};state=project?{fragments:project.fragments,measurements:project.measurements,calibration:project.calibration,axisPair:project.axisPair,display:project.display}:freshState();undoStack=[];redoStack=[];selectedFragment=null;selectedMeasurement=null;pending=[];compare=false;$('original').textContent='Compare original';$('original').setAttribute('aria-pressed','false');$('imageName').textContent=name;$('imageMeta').textContent=`${imageInfo.width} × ${imageInfo.height} px · ${demo?'synthetic practice case':'local image'}`;setTool('select');fit();} async function loadDemo(){if(busy)return;busy=true;try{const svg=await loadImage(demoDataURL()),c=document.createElement('canvas');c.width=800;c.height=1150;c.getContext('2d').drawImage(svg,0,0);await setImage(c.toDataURL('image/png'),'Lower leg · practice case',{demo:true});state.calibration={pxPerMm:2.5,knownLength:100,points:[{x:100,y:750},{x:100,y:1000}]};state.measurements=[{id:uid(),type:'axis',name:'Proximal axis',points:[{x:360,y:230},{x:358,y:520}]},{id:uid(),type:'axis',name:'Distal axis',points:[{x:365,y:545},{x:480,y:975}]}];state.axisPair=state.measurements.map(m=>m.id);update();}catch(e){toast(e.message);}finally{busy=false;}} $('demo').onclick=loadDemo; $('practiceCut').onclick=()=>{if(!imageInfo?.demo)return;if(state.fragments.length){toast('Reload the practice image to start a fresh demonstration.');return;}commit(()=>{const f={id:uid(),name:'Distal tibia',points:[{x:322,y:525},{x:407,y:525},{x:519,y:965},{x:536,y:1065},{x:447,y:1090},{x:408,y:985},{x:328,y:630}],pivot:{x:362,y:535},tx:0,ty:0,rotation:0,color:palette[0]};state.fragments.push(f);attachPoints(f);selectedFragment=f.id;selectedMeasurement=null;});setTool('select');toast('Rotate the distal fragment to explore alignment. Its axis moves with it.');}; const readFile=file=>new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=()=>reject(new Error('File could not be read.'));reader.readAsDataURL(file);}); async function importFile(file){if(!file||busy)return;if(!/^image\/(png|jpeg|webp)$/.test(file.type)){toast('Choose PNG, JPEG, or WebP. DICOM is not supported yet.');return;}if(file.size>25000000){toast('Use an image file smaller than 25 MB.');return;}busy=true;try{await setImage(await readFile(file),file.name);toast('Image loaded. Calibrate the scale before measuring in mm.');}catch(e){toast(e.message);}finally{busy=false;}} $('importImage').onclick=()=>$('imageFile').click();$('imageFile').onchange=async e=>{await importFile(e.target.files[0]);e.target.value='';}; stage.addEventListener('dragover',e=>{e.preventDefault();});stage.addEventListener('drop',e=>{e.preventDefault();importFile(e.dataTransfer.files[0]);}); function download(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),30000);} function slug(){return(imageInfo?.name||'bone-plan').replace(/\.[a-z0-9]+$/i,'').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase()||'bone-plan';} $('saveProject').onclick=()=>{if(!image)return;const project={format:'bone-planning-studio',version:1,image:imageInfo,...snapshot()};try{const valid=validateProject(project);const blob=new Blob([JSON.stringify(valid)],{type:'application/json'});if(blob.size>40000000)throw new Error('This plan exceeds the 40 MB project-file limit. Use a smaller source image.');download(blob,`${slug()}-plan.json`);toast('Plan saved with the image, measurements, and fragments.');}catch(e){toast(e.message);}}; $('loadProject').onclick=()=>$('projectFile').click();$('projectFile').onchange=async e=>{const file=e.target.files[0];e.target.value='';if(!file||busy)return;if(file.size>40000000){toast('Use a project file smaller than 40 MB.');return;}busy=true;try{const p=validateProject(JSON.parse(await file.text()));await setImage(p.image.src,p.image.name,{demo:p.image.demo,project:p});toast('Saved plan restored.');}catch(err){toast(err instanceof SyntaxError?'This file is not valid project JSON.':err.message);}finally{busy=false;}}; $('exportImage').onclick=()=>{if(!image)return;const all=state.fragments.flatMap(f=>f.points.map(p=>transformPoint(p,f))),bounds=all.reduce((b,p)=>({minX:Math.min(b.minX,p.x),minY:Math.min(b.minY,p.y),maxX:Math.max(b.maxX,p.x),maxY:Math.max(b.maxY,p.y)}),{minX:0,minY:0,maxX:image.width,maxY:image.height}),minX=bounds.minX-35,minY=bounds.minY-45,maxX=bounds.maxX+35,maxY=bounds.maxY+65,w=maxX-minX,h=maxY-minY,s=Math.min(1,Math.sqrt(16000000/(w*h)),12000/w,12000/h);if(s<.02){toast('Bring fragments closer to the image before exporting.');return;}const c=document.createElement('canvas');c.width=Math.ceil(w*s);c.height=Math.ceil(h*s);const out=c.getContext('2d');out.fillStyle='#10191e';out.fillRect(0,0,c.width,c.height);out.save();out.scale(s,s);out.translate(-minX,-minY);drawScene(out,s,{exporting:true});out.restore();out.fillStyle='#aac2b5';out.font='12px sans-serif';out.fillText(`Bone Planning Studio · ${imageInfo.demo?'synthetic practice image':'manual plan'} · educational prototype`,14,21);out.fillStyle='#7b9689';out.font='10px sans-serif';out.fillText(state.calibration?`Image calibration: ${state.calibration.pxPerMm.toFixed(3)} px/mm · original image coordinates`:'Uncalibrated · measurements in pixels',14,c.height-15);c.toBlob(blob=>{if(blob){download(blob,`${slug()}-plan.png`);toast('PNG exported with the current plan.');}else toast('The image could not be exported.');},'image/png');}; new ResizeObserver(resize).observe(stage);resize();loadDemo();