| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354 |
- // 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;i<indices.length;i+=3){const tri=indices.slice(i,i+3);if(Math.abs(signedArea(tri.map(j=>ring[j])))>1e-10)caps.push(tri)}
- const used=new Set(caps.flat())
- for(let p=0;p<ring.length;p++)if(!used.has(p)){
- let restored=false
- for(let t=0;t<caps.length&&!restored;t++)for(let e=0;e<3&&!restored;e++){
- const tri=caps[t],a=tri[e],b=tri[(e+1)%3],c=tri[(e+2)%3],u=ring[a],v=ring[b],q=ring[p],dx=v[0]-u[0],dy=v[1]-u[1],length2=dx*dx+dy*dy
- const along=((q[0]-u[0])*dx+(q[1]-u[1])*dy)/length2
- const cross=dx*(q[1]-u[1])-dy*(q[0]-u[0])
- if(along>0&&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;i<ring.length;i++){
- const a=ring[i],b=ring[(i+1)%ring.length],lowA=[...a,bottom],lowB=[...b,bottom],highA=[...a,top],highB=[...b,top]
- body.push([lowA,lowB,highB],[lowA,highB,highA])
- }
- return {ring,top,bottom,roof,body,area,triangles:body.length+roof.length,perimeter}
- }
- export function verifyClosedTriangles(triangles) {
- const edges=new Map()
- for(const tri of triangles){
- assert.equal(tri.length,3)
- const ids=tri.map(p=>p.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+=a<b?1:-1;edges.set(key,value)}
- }
- assert.ok([...edges.values()].every(e=>e.count===2&&e.orientation===0),'shell must be closed with opposite shared edge winding')
- }
|