// Closed surveyed far shells. No facade/interior details, no outline or height approximation. import assert from 'node:assert/strict' import earcut from 'earcut' const signedArea = ring => { const [x,y]=ring[0] return ring.reduce((s,p,i)=>{const q=ring[(i+1)%ring.length];return s+(p[0]-x)*(q[1]-y)-(q[0]-x)*(p[1]-y)},0)/2 } export function surveyShell(building) { const ring=[] for(const p of building.footprintENU)if(!ring.length||Math.hypot(p[0]-ring.at(-1)[0],p[1]-ring.at(-1)[1])>0.011)ring.push([...p]) if(ring.length>1&&Math.hypot(ring[0][0]-ring.at(-1)[0],ring[0][1]-ring.at(-1)[1])<=0.011)ring.pop() if(signedArea(ring)<0)ring.reverse() assert.ok(ring.length>=3&&signedArea(ring)>0,building.id+' degenerate ring') const bottom=building.foundationBottom,top=building.positionENU[2]+building.heightM assert.ok(Number.isFinite(bottom)&&Number.isFinite(top)&&top>bottom) const roof=[],body=[] const indices=earcut(ring.flat()),caps=[] for(let i=0;iring[j])))>1e-10)caps.push(tri)} const used=new Set(caps.flat()) for(let p=0;p0&&along<1&&Math.abs(cross)<1e-8){caps.splice(t,1,[a,p,c],[p,b,c]);restored=true} } assert.ok(restored,building.id+' omitted non-collinear roof vertex') } let area=0 for(const tri of caps){ const ps=tri.map(index=>ring[index]);let a=signedArea(ps) if(Math.abs(a)<1e-10)continue if(a<0)ps.reverse() area+=Math.abs(a) roof.push(ps.map(p=>[...p,top]));body.push(ps.toReversed().map(p=>[...p,bottom])) } const perimeter=ring.reduce((s,p,i)=>s+Math.hypot(p[0]-ring[(i+1)%ring.length][0],p[1]-ring[(i+1)%ring.length][1]),0) assert.ok(Math.abs(area-signedArea(ring))<=Math.max(0.001,perimeter*0.01),building.id+' roof coverage') for(let i=0;ip.join(',')) for(let i=0;i<3;i++){const a=ids[i],b=ids[(i+1)%3],key=[a,b].sort().join('|');const value=edges.get(key)||{count:0,orientation:0};value.count++;value.orientation+=ae.count===2&&e.orientation===0),'shell must be closed with opposite shared edge winding') }