공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음. - 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용), 프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외 - 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정 - 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결 - 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등) 정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
49 lines
3.3 KiB
TypeScript
49 lines
3.3 KiB
TypeScript
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
|
|
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
|
|
const width=source.width,height=source.height,scale=width/2172;
|
|
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
|
|
const tops:number[]=[],bottoms:number[]=[];
|
|
for(let x=0;x<width;x++) {
|
|
let top=height,bottom=-1;
|
|
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
|
|
tops.push(top);bottoms.push(bottom);
|
|
}
|
|
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
|
|
if(!support.length)throw new Error('No continuous terrain silhouette');
|
|
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
|
|
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
|
|
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
|
|
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
|
|
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
|
|
});
|
|
const upper=eroded(tops,true),lower=eroded(bottoms,false);
|
|
const mean=(values:number[],x:number)=>{
|
|
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
|
|
return samples.reduce((a,b)=>a+b,0)/samples.length;
|
|
};
|
|
const points:[number,number][]=[];
|
|
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
|
|
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
|
|
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
|
|
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
|
|
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
|
|
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
|
const first=mid(points.at(-1)!,points[0]);
|
|
let path=`M${first[0]} ${first[1]}`;
|
|
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
|
|
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
|
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
|
|
const original=ctx.getImageData(0,0,width,height).data;
|
|
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
|
|
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
|
|
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
|
|
if(preserveExposedStructures){
|
|
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
|
|
ctx.putImageData(outputImage,0,0);
|
|
}
|
|
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
|
|
let before=0,after=0,addedPixels=0;
|
|
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
|
|
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
|
|
}
|