far-shell-geometry.mjs 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
  1. // Closed surveyed far shells. No facade/interior details, no outline or height approximation.
  2. import assert from 'node:assert/strict'
  3. import earcut from 'earcut'
  4. const signedArea = ring => {
  5. const [x,y]=ring[0]
  6. 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
  7. }
  8. export function surveyShell(building) {
  9. const ring=[]
  10. 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])
  11. if(ring.length>1&&Math.hypot(ring[0][0]-ring.at(-1)[0],ring[0][1]-ring.at(-1)[1])<=0.011)ring.pop()
  12. if(signedArea(ring)<0)ring.reverse()
  13. assert.ok(ring.length>=3&&signedArea(ring)>0,building.id+' degenerate ring')
  14. const bottom=building.foundationBottom,top=building.positionENU[2]+building.heightM
  15. assert.ok(Number.isFinite(bottom)&&Number.isFinite(top)&&top>bottom)
  16. const roof=[],body=[]
  17. const indices=earcut(ring.flat()),caps=[]
  18. 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)}
  19. const used=new Set(caps.flat())
  20. for(let p=0;p<ring.length;p++)if(!used.has(p)){
  21. let restored=false
  22. for(let t=0;t<caps.length&&!restored;t++)for(let e=0;e<3&&!restored;e++){
  23. 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
  24. const along=((q[0]-u[0])*dx+(q[1]-u[1])*dy)/length2
  25. const cross=dx*(q[1]-u[1])-dy*(q[0]-u[0])
  26. if(along>0&&along<1&&Math.abs(cross)<1e-8){caps.splice(t,1,[a,p,c],[p,b,c]);restored=true}
  27. }
  28. assert.ok(restored,building.id+' omitted non-collinear roof vertex')
  29. }
  30. let area=0
  31. for(const tri of caps){
  32. const ps=tri.map(index=>ring[index]);let a=signedArea(ps)
  33. if(Math.abs(a)<1e-10)continue
  34. if(a<0)ps.reverse()
  35. area+=Math.abs(a)
  36. roof.push(ps.map(p=>[...p,top]));body.push(ps.toReversed().map(p=>[...p,bottom]))
  37. }
  38. 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)
  39. assert.ok(Math.abs(area-signedArea(ring))<=Math.max(0.001,perimeter*0.01),building.id+' roof coverage')
  40. for(let i=0;i<ring.length;i++){
  41. const a=ring[i],b=ring[(i+1)%ring.length],lowA=[...a,bottom],lowB=[...b,bottom],highA=[...a,top],highB=[...b,top]
  42. body.push([lowA,lowB,highB],[lowA,highB,highA])
  43. }
  44. return {ring,top,bottom,roof,body,area,triangles:body.length+roof.length,perimeter}
  45. }
  46. export function verifyClosedTriangles(triangles) {
  47. const edges=new Map()
  48. for(const tri of triangles){
  49. assert.equal(tri.length,3)
  50. const ids=tri.map(p=>p.join(','))
  51. 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)}
  52. }
  53. assert.ok([...edges.values()].every(e=>e.count===2&&e.orientation===0),'shell must be closed with opposite shared edge winding')
  54. }