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>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:02 +09:00
1 parent b2bebe47eb
commit fc520ec03d
47 files changed
+2848 -149

No files matched your search

+13
View File
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
export function checkGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
if (!('schema' in obj) && 'schemaVersion' in obj) {
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
return checkStoredGroundModel(project.groundModel);
}
const knownSchema = schemaOrLegacy(obj);
if (knownSchema === undefined) {
const data = obj as Record<string, unknown>;
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
}
if (!('schema' in data)) {
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
}
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
}
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
+34
View File
@@ -0,0 +1,34 @@
import type { GroundModelFile, TerrainGrid } from './ground-model';
import type { AbutRecord } from './abut/parse';
import type { PierInput } from './types';
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
const entry = model.piers.find(p => p.id === draft.name);
if (!entry) return draft;
return { ...draft,
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
};
}
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
}
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
if (h.some(v => v == null || !Number.isFinite(v))) return null;
const u = x - i, v = y - j;
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
}
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
const explicit = model?.piers.find(p => p.id === abut.name);
if (explicit) return { ...explicit, source: 'JSON 개별값' };
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
}
+56
View File
@@ -0,0 +1,56 @@
import * as XLSX from 'xlsx';
import { quantityRows, type Quantity } from './rebar/quantity';
export interface QuantityRecord {
name: string;
quantity: Quantity | null;
dirty: boolean;
}
/** Export checks use the same records as the visible result, including ungenerated piers. */
export function quantityExportIssues(records: QuantityRecord[]): string[] {
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
}
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
line.pieces.forEach((p, i) => {
const totalM = p.length * p.count / 1000;
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
});
}
return rows;
}
/** Generated values remain numbers; only the display format rounds them. */
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
const issues = quantityExportIssues(records);
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
const sheetName = (name: string) => {
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
let candidate = base.slice(0, 31), n = 1;
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
used.add(candidate.toLowerCase()); return candidate;
};
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
let kg = 0, kgWithWaste = 0;
for (const r of records) {
const q = r.quantity!;
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
kg += q.kg; kgWithWaste += q.kgWithWaste;
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
q.missing.forEach(m => review.push([r.name, m]));
}
const partial = records.some(r => r.quantity!.missing.length);
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
return wb;
}
+2 -2
View File
@@ -110,11 +110,11 @@ export function buildQuantity(rows: RebarRow[], material: Material, incomplete:
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
export function quantityRows(q: Quantity): (string | number)[][] {
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']];
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수', '부재']];
for (const g of q.groups) {
for (const d of g.diameters) {
for (const l of d.lines) {
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg)]);
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg), l.pieceCount, l.member]);
}
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
}