Files
Pier_v1/pierzainer_ground/merge_sample_projects.mjs
T

100 lines
11 KiB
JavaScript

// Reuse the application's readers and geometry; never rewrite the source workbooks.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { createServer } from '../node_modules/vite/dist/node/index.js';
import * as XLSX from '../node_modules/xlsx/xlsx.mjs';
const root = path.dirname(fileURLToPath(import.meta.url));
const app = path.dirname(root);
const output = path.join(root, 'data/output/samples_20261102');
const landing = path.join(app, '랜딩페이지');
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const save = async (file, data) => { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n'); };
const server = await createServer({configFile: false, root: app, cacheDir: path.join(root, 'data/cache/vite-merge'), optimizeDeps:{noDiscovery:true,entries:[]}, server: {middlewareMode: true}, appType: 'custom'});
try {
const {parseWorkbook} = await server.ssrLoadModule('/src/core/parseWorkbook.ts');
const {checkGroundModel} = await server.ssrLoadModule('/src/core/ground-model.ts');
const {parseProject, SCHEMA_VERSION} = await server.ssrLoadModule('/src/core/project.ts');
const {buildPier} = await server.ssrLoadModule('/src/core/geometry.ts');
const {validateInput, validatePier} = await server.ssrLoadModule('/src/core/validate.ts');
const {rebarSummary} = await server.ssrLoadModule('/src/core/rebar/summary.ts');
const {DEFAULT_MATERIAL} = await server.ssrLoadModule('/src/core/rebar/common.ts');
const summary = JSON.parse(await fs.readFile(path.join(output, 'summary.json'), 'utf8'));
const results = [], manifest = [];
for (const job of summary.results) {
if (job.status !== 'generated') throw Error(`Sample ${job.sample}: ground missing`);
const workbookBytes = await fs.readFile(job.workbook);
const wb = XLSX.read(workbookBytes, {type:'buffer'});
const typeAliases = [];
// The extraction package calls a two-column pier π형; the app enum calls it 2주형.
// Normalize the identical meaning in memory only, with the original cell recorded.
for (const sheetName of wb.SheetNames) {
const ws=wb.Sheets[sheetName];
for (const [address,cell] of Object.entries(ws)) {
if (!address.startsWith('!') && cell?.v === 'π형') {
typeAliases.push({sheet:sheetName,cell:address,original:'π형',normalized:'2주형'});
cell.v='2주형'; delete cell.w;
}
}
}
const parsed = parseWorkbook(wb);
const appDefaults={columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:2100};
parsed.piers=parsed.piers.map(input=>({...appDefaults,...input}));
const rawText = await fs.readFile(job.json, 'utf8');
const raw = JSON.parse(rawText), ground = checkGroundModel(raw);
const issues = [...parsed.issues], checks = [];
if (parsed.piers.length !== job.pier_count && !(job.sample===5 && parsed.piers.length===0 && issues.every(i=>i.code==='E03'))) throw Error(`${job.bridge}: parser omitted piers: ${JSON.stringify(issues)}`);
for (const input of parsed.piers) {
const p = raw.piers.find(p => p.id === input.name);
if (!p || !Number.isFinite(p.ground.center)) throw Error(`${job.bridge}/${input.name}: ground missing`);
const ip1 = input.ips.find(p => p.name === 'IP1'), ip3 = input.ips.find(p => p.name === 'IP3');
const e = (ip1.x+ip3.x)/2, n = (ip1.y+ip3.y)/2;
const dE=p.Y-e, dN=p.X-n, dR=Math.hypot(dE,dN);
if (dR > .000708) throw Error(`${job.bridge}/${input.name}: coordinate mismatch ${dR}`);
if (p.ground.excel != null && Math.abs(p.ground.excel-input.groundEl)>.00051) throw Error(`${job.bridge}/${input.name}: design EL changed`);
ground.piers.find(p=>p.id===input.name).excelEl=input.groundEl;
let geometryBuilt=false, appRenderError=null;
if (validateInput(input, issues)) {
try { const model=buildPier(input); validatePier(input,model,issues); geometryBuilt=true;
try { rebarSummary(model,DEFAULT_MATERIAL,{}); } catch(error) { appRenderError=error.message; issues.push({code:'APP_RENDER',level:'E',pier:input.name,message:`앱 후속 화면 계산 오류: ${error.message}`}); }
}
catch(error) { issues.push({code:'BUILD',level:'E',pier:input.name,message:error.message}); }
}
checks.push({id:input.name, E:e, N:n, dE, dN, dR, excelEl:input.groundEl, terrainEl:p.ground.center, terrainMinusExcel:p.ground.center-input.groundEl, geometryBuilt,appRenderError});
}
if (!ground.mapLayers?.some(l=>l.id==='satellite') || !ground.mapLayers?.some(l=>l.id==='ground')) throw Error('Both maps required');
const excludedWorkbookRows = parsed.piers.length!==job.pier_count ? Object.fromEntries(wb.SheetNames.map(name=>[name,XLSX.utils.sheet_to_json(wb.Sheets[name],{header:1,defval:null})])) : undefined;
const provenance = {workbook:path.basename(job.workbook), workbookSha256:hash(workbookBytes), typeAliases, appDefaults, excludedWorkbookRows, groundSha256:hash(rawText), groundSource:raw.provenance??null, recoveredSource:job.recovered_source??null, crsAssumed:job.crs_assumed, crsStatus:job.map_status?.crs_status, coordinateConvention:'Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.', terrainChangesDesignElevation:false};
const project = {
schemaVersion:SCHEMA_VERSION,
project:{name:`샘플 ${String(job.sample).padStart(2,'0')} · ${job.bridge}`,bridge:job.bridge,client:'',author:'',crs:job.crs,memo:`도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(${job.map_status?.crs_status}).`},
source:{fileName:path.basename(job.workbook),sheets:parsed.sheets.map(s=>s.name)},
piers:parsed.piers.map(input=>({id:input.name,input,defaults:structuredClone(input),draft:structuredClone(input)})),
history:[{version:'sample-20261102',note:'교각 Excel과 원도면 지반·위성사진·2D 지도 통합. 좌표·스키마·형상 검증.',at:summary.updated}],
bearingBasis:'excel', groundModel:ground, sampleProvenance:provenance,
};
const text = JSON.stringify(project)+'\n';
const restored=parseProject(text);
if (restored.piers.length!==parsed.piers.length || restored.groundModel.mapLayers.length!==2) throw Error('Project round trip failed');
const filename=`sample${String(job.sample).padStart(2,'0')}.pzr.json`;
await fs.mkdir(path.join(output,'projects'),{recursive:true});
await fs.mkdir(path.join(landing,'assets/samples'),{recursive:true});
await fs.writeFile(path.join(output,'projects',filename),text);
await fs.writeFile(path.join(landing,'assets/samples',filename),text);
const builtCount=checks.filter(c=>c.geometryBuilt).length;
const blockReason=parsed.piers.length===0?'원본의 받침 방위각 12개가 미확인입니다. 지반·지도 자료는 생성되었으나 교각 모델은 방위각 확인 후 불러올 수 있습니다.':checks.some(c=>c.appRenderError)?'교각·지반 통합은 완료됐으나 현재 앱의 현장타설말뚝 후속 화면에 확대기초 참조 오류가 있습니다. 외부 모델러 수정 전까지 열기를 보류합니다.':'';
results.push({sample:job.sample,bridge:job.bridge,sourcePierCount:job.pier_count,pierCount:checks.length,builtCount,blockReason,maxCoordinateResidual:Math.max(0,...checks.map(c=>c.dR)),maxTerrainExcelDifference:Math.max(0,...checks.map(c=>Math.abs(c.terrainMinusExcel))),issues,checks,provenance,projectSha256:hash(text)});
manifest.push({sample:job.sample,bridge:job.bridge,sourcePierCount:job.pier_count,pierCount:checks.length,builtCount,blockReason,filename,bytes:Buffer.byteLength(text),sha256:hash(text),warningCount:issues.length,crsAssumed:job.crs_assumed});
console.log(`${job.sample} ${job.bridge}: ${checks.length} piers, ${issues.length} issues`);
}
await save(path.join(output,'combined_validation.json'),results);
await save(path.join(landing,'assets/samples/manifest.json'),manifest);
const ts=`// Generated by pierzainer_ground/merge_sample_projects.mjs. Assets fetched on selection only.\nexport const sampleProjects = [\n${manifest.map(m=>` {${JSON.stringify(m).slice(1,-1)}, url: new URL('./assets/samples/${m.filename}', import.meta.url).href},`).join('\n')}\n] as const;\n`;
await fs.writeFile(path.join(landing,'sample-projects.ts'),ts);
const report=['# 샘플 교각 + 지반 통합 검증','',`12개 교량 / 원본 64개 교각 중 ${results.reduce((n,r)=>n+r.pierCount,0)}개 입력 판독 / ${results.reduce((n,r)=>n+r.builtCount,0)}개 형상 생성. 실제 앱의 Excel·지반·프로젝트 판독 코드와 buildPier로 검증했습니다.`, '', '| 샘플 | 교량 | 입력 / 형상 | 최대 ΔR(m) | 최대 지반 EL 차(m) | 기존 검증 알림 |','|---|---|---:|---:|---:|---:|',...results.map(r=>`| ${r.sample} | ${r.bridge} | ${r.pierCount} / ${r.builtCount} | ${r.maxCoordinateResidual.toFixed(6)} | ${r.maxTerrainExcelDifference.toFixed(3)} | ${r.issues.length} |`),'','승천천교는 원본 받침 방위각 12개 미확인으로 교각 모델 열기를 보류합니다. 지반·지도와 원본 미확정 입력은 통합 파일에 보존했습니다. 원본 Excel은 변경하지 않았습니다. π형 → 2주형은 동일 형식명의 메모리 내 호환 변환이며 셀별 출처를 기록했습니다.','', 'ΔR은 Excel IP1/IP3 중점과 지반 JSON의 교각 중심 차이이며, JSON 반올림 정밀도 이내입니다. EL 차이는 원도면 지반면과 Excel 설계 입력의 차이이며 자동 보정하지 않았습니다.','', '지도 2종은 동일 측량 좌표 모서리를 사용합니다. 원본 CRS의 공인 검증은 미완료이며 추정 상태와 출처를 각 통합 JSON의 sampleProvenance에 보존했습니다. 대호지교는 원본 PDF 벡터에서 복구한 지형으로 PDF 도식 정밀도 한계가 있습니다. DEM·가상 표고는 사용하지 않았습니다.','','## 기존 형상 검증 알림','',...results.flatMap(r=>r.issues.map(i=>`- ${r.bridge} ${i.pier??''}: [${i.level}/${i.code}] ${i.message}`)),'','## 실행','', '`node pierzainer_ground/merge_sample_projects.mjs` (프로젝트 루트 Pier_v1에서 실행). 원본 Excel/JSON은 읽기만 하고 두 허용 폴더에만 결과를 저장합니다.',''];
await fs.writeFile(path.join(output,'COMBINED_VALIDATION.md'),report.join('\n'));
await fs.appendFile(path.join(output,'COMBINED_VALIDATION.md'), '\n## 랜딩페이지 실행 판정\n\n'+results.map(r=>`- ${r.bridge}: ${r.blockReason || '샘플 열기 연결. 기존 검증 알림과 생성 불가 교각은 그대로 표시.'}`).join('\n')+'\n\n대호지교·감북교 오류는 src/core/rebar/column.ts의 columnInputFor가 현장타설말뚝에도 model.footings[0].top을 읽는 데서 발생합니다. 이 파일은 현재 작업 범위 밖이므로 수정하지 않았습니다.\n');
} finally { await server.close(); }