Files
Pier_v1/scripts/render-landing-hero.ts
T
ypshimandClaude Haiku 5.5 fc520ec03d feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(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>
2026-11-09 18:01:02 +09:00

124 lines
9.1 KiB
TypeScript

import * as THREE from 'three';
import type { ProjectFile } from '../src/core/project';
import { buildPier } from '../src/core/geometry';
import { buildAllPiers } from '../src/view/all-piers';
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
import { buildAbut } from '../src/core/abut/geometry';
import { toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
import { bridgePairs } from '../src/core/bridge-pairs';
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
import { smoothLandingOutline } from './landing-smooth-outline';
/** Actual structure/terrain capture with optional landing-only road extensions. */
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
for (const rec of project.abutments ?? []) {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
pairs,slopeI:rec.slopeI,
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
});
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
}
const layers = project.groundModel?.mapLayers ?? [];
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
let mapError = false;
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
const deadline=performance.now()+15000;
while (!maps.children.every(m => {
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
return image?.complete && image.naturalWidth>0;
})) {
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
await new Promise(resolve=>setTimeout(resolve,20));
}
if (mapError) throw new Error('Map texture loading failed');
scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
if (endMarginM !== undefined) {
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
if(ends.length!==2)throw new Error('End margin requires both abutments');
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
}
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
if(road)scene.add(road.group);
if(cutawayFrontM!==undefined){
const front=new THREE.Vector3(-axis.z,0,axis.x);
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
for(const mesh of [all.terrain,...maps.children])if(mesh){
const material=(mesh as THREE.Mesh).material as THREE.Material;
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
}
}
scene.updateMatrixWorld(true);
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
const bounds = new THREE.Box3();
scene.traverse(obj => {
if (!(obj instanceof THREE.Mesh)) return;
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
const pos = obj.geometry.getAttribute('position');
for (let i=0;i<pos.count;i++) {
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
}
});
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
let terrainCanvas:HTMLCanvasElement|undefined;
if(smoothOutline){
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
hidden.forEach(o=>{o.visible=true;});
}
renderer.render(scene, camera);
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
for(let i=0;i<ordered.length-1;i++){
const from=ordered[i].point,to=ordered[i+1].point;
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
for(let j=i===0?0:1;j<=steps;j++){
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
if(!hit){terrainFlow.push(null);continue;}
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
}
}
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
const output=smooth?.canvas??renderer.domElement;
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
disposeTerrainMapGroup(maps);
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
renderer.dispose();renderer.forceContextLoss();return result;
}