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

+16
View File
@@ -114,6 +114,22 @@ describe('지형 격자 (schema 2)', () => {
});
describe('형식 번호 검사', () => {
it('프로젝트 JSON에서는 내장 지반모델만 가져온다', () => {
const groundModel = { schema: 2, generated: '2026-11-09', piers: [{ id: 'P1', centerEl: 18.011, excelEl: null }], terrainGrid: { origin: [0, 0], step: 1, nx: 2, ny: 2, z: [1, 2, 3, 4] } };
const imported = parseGroundModel(JSON.stringify({ schemaVersion: 1, project: { name: '다른 프로젝트' }, piers: [{ input: { groundEl: 999 } }], groundModel }));
expect(imported).toEqual(groundModel);
expect(imported).not.toHaveProperty('project');
expect(imported.piers[0].centerEl).toBe(18.011);
});
it('프로젝트 내부의 잘못된 지반모델과 알 수 없는 프로젝트 형식을 거부한다', () => {
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, groundModel: { schema: 2, piers: [{ id: 'P1', centerEl: 'wrong', excelEl: null }] } }))).toThrow('값이 맞지 않습니다');
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 99, groundModel: { schema: 2, piers: [] } }))).toThrow('프로젝트 형식 번호 99');
});
it('구조가 다른 JSON을 형식 번호 undefined 대신 구체적인 파일 안내로 거부한다', () => {
expect(() => parseGroundModel(JSON.stringify({ drawing: '도면.dwg', output: 'output', piers: '입력.xlsx' }))).toThrow('지반 생성 설정 파일');
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, piers: [] }))).toThrow('프로젝트 열기');
expect(() => parseGroundModel(JSON.stringify({ status: 'ok', json: 'ground_model.json' }))).toThrow('결과 요약 JSON');
});
it('accepts a legacy file without a schema number when it has the standard pier structure', () => {
const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] }));
expect(m.schema).toBe(2);
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { abutGroundFromModel, applyProjectGround, groundInputChanged, sampleGroundGrid } from '../src/core/project-ground';
import type { GroundModelFile } from '../src/core/ground-model';
import { loadP1 } from './helpers';
const model: GroundModelFile = { schema: 2, generated: '', piers: [
{ id: 'P1', centerEl: -2, excelEl: 3, layers: [{ name: '암반', bottomEl: -10 }] },
{ id: 'P2', centerEl: null, excelEl: 5 },
] };
describe('프로젝트 지반 선택 적용', () => {
it('교대 IP2에서 4점을 보간하고 외곽 경계도 처리한다', () => {
const grid = { origin: [100, 200] as [number, number], step: 10, nx: 2, ny: 2, z: [10, 20, 30, 40] };
expect(sampleGroundGrid(grid, { n: 105, e: 205 })).toBe(25);
expect(sampleGroundGrid(grid, { n: 110, e: 210 })).toBe(40);
expect(sampleGroundGrid(grid, { n: 111, e: 205 })).toBeNull();
expect(sampleGroundGrid({ ...grid, z: [10, null, 30, 40] }, { n: 105, e: 205 })).toBeNull();
const result = abutGroundFromModel({ ...model, terrainGrid: grid }, { name: 'A1', ip2: { n: 105, e: 205 } });
expect(result.centerEl).toBe(25); expect(result.source).toBe('IP2 격자 보간'); expect(result.layers).toBeUndefined();
});
it('교대 개별 모델값이 있으면 null도 보간으로 대체하지 않는다', () => {
const result = abutGroundFromModel({ ...model, piers: [{ id: 'A1', centerEl: null, excelEl: 10, layers: [{ name: '암반', bottomEl: -10 }] }] }, { name: 'A1', ip2: { n: 0, e: 0 } });
expect(result.centerEl).toBeNull(); expect(result.source).toBe('JSON 개별값'); expect(result.layers).toHaveLength(1);
});
it('지반고만 적용하면 기존 지층과 N값을 보존한다', () => {
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20, nValue: '50/5' }] };
const next = applyProjectGround(p, model, { ground: true, layers: false });
expect(next.groundEl).toBe(-2); expect(next.strataRows).toEqual(p.strataRows);
expect(p.groundEl).not.toBe(-2); expect(groundInputChanged(p, next)).toBe(true);
});
it('지층만 적용하면 지반고와 다른 입력을 보존하고 모델을 복사한다', () => {
const p = loadP1(), next = applyProjectGround(p, model, { ground: false, layers: true });
expect(next.groundEl).toBe(p.groundEl); expect(next.copingLengthMm).toBe(p.copingLengthMm);
expect(next.strataRows).toEqual(model.piers[0].layers); expect(next.strataRows![0]).not.toBe(model.piers[0].layers![0]);
});
it('모델 누락과 범위 밖은 기존 값을 보존한다', () => {
for (const name of ['P2', 'P3']) {
const p = { ...loadP1(), name, strataRows: [{ name: '기존', bottomEl: -20 }] };
expect(applyProjectGround(p, model, { ground: true, layers: true })).toEqual(p);
}
});
it('빈 지층으로 기존 지층을 지우지 않는다', () => {
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20 }] };
const empty = { ...model, piers: [{ ...model.piers[0], layers: [] }] };
expect(applyProjectGround(p, empty, { ground: false, layers: true })).toEqual(p);
});
it('지반고·지층이 같으면 지반 재생성 대상이 아니다', () => {
const p = loadP1(); expect(groundInputChanged(p, { ...p, copingLengthMm: p.copingLengthMm + 100 })).toBe(false);
expect(groundInputChanged(p, { ...p, strataRows: [{ name: '층', bottomEl: -5 }] })).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import { buildQuantity } from '../src/core/rebar/quantity';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import type { RebarRow } from '../src/core/rebar/summary';
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows } from '../src/core/quantity-workbook';
import { projectRecords } from '../수량산출/project-inputs';
import { buildPier } from '../src/core/geometry';
import { loadP1 } from './helpers';
const row: RebarRow = {member:'coping',name:'C9',size:'H19',count:2,length:null,pieces:[{length:1234.567,count:2},{length:765.432,count:4}],note:'',spacing:'',actual:'',position:'',direction:'',shape:'가공 조각',basis:'calc'};
const q = buildQuantity([row], {...DEFAULT_MATERIAL,waste:10});
const records = [{name:'P1',quantity:q,dirty:false}];
const meta = {project:'검증',bridge:'검증교',generatedAt:'2026-11-06T00:00:00.000Z',abutmentCount:2};
const values = (sheet: XLSX.WorkSheet) => XLSX.utils.sheet_to_json<(string|number)[]>(sheet,{header:1});
describe('재료표 Excel과 가공 상세', () => {
it('실제 파일 왕복 후 길이별 본수·중량 합이 반올림 없이 집계와 일치한다', () => {
const wb = XLSX.read(XLSX.write(createQuantityWorkbook(records,meta),{type:'buffer',bookType:'xlsx'}),{type:'buffer'});
const pieces = values(wb.Sheets['가공 상세']).slice(1);
expect(pieces.reduce((n,r)=>n+Number(r[7]),0)).toBe(6);
expect(pieces.reduce((n,r)=>n+Number(r[8]),0)).toBeCloseTo(q.groups[0].diameters[0].totalM,12);
expect(pieces.reduce((n,r)=>n+Number(r[9]),0)).toBeCloseTo(q.kg,12);
expect(values(wb.Sheets.P1).find(r=>r[1]==='C9')).toMatchObject({4:2,9:6});
expect(values(wb.Sheets['합계'])[1][2]).toBeCloseTo(q.kgWithWaste,12);
expect(values(wb.Sheets['생성 정보']).find(r=>r[0]==='범위')![1]).toContain('교대 2기 제외');
});
it('미재생성·미생성 교각이 있으면 Excel 생성을 막는다', () => {
const bad=[{...records[0],dirty:true},{name:'P2',quantity:null,dirty:false}];
expect(quantityExportIssues(bad)).toEqual(['P1: 형상 재생성 필요','P2: 형상 없음']);
expect(()=>createQuantityWorkbook(bad,meta)).toThrow('재생성 필요');
});
it('부분 소계와 미계산 사유를 파일에 보존한다', () => {
const partial=buildQuantity([row],DEFAULT_MATERIAL,['저부 P6 미확정']);
const wb=createQuantityWorkbook([{name:'P1',quantity:partial,dirty:false}],meta);
expect(values(wb.Sheets['검토 항목'])[1]).toEqual(['P1','저부 P6 미확정']);
expect(values(wb.Sheets['합계'])[1][4]).toContain('검토용');
});
it('예약명·대소문자·절단·문자치환으로 시트명이 충돌해도 모두 출력한다', () => {
const names=['합계','가공 상세','P1','p1','A/B','A:B','긴이름'.repeat(20)+'1','긴이름'.repeat(20)+'2'];
const wb=createQuantityWorkbook(names.map(name=>({...records[0],name})),meta);
expect(new Set(wb.SheetNames.map(n=>n.toLowerCase())).size).toBe(names.length+5);
expect(wb.SheetNames.every(n=>n.length<=31)).toBe(true);
expect(quantityPieceRows(names.map(name=>({...records[0],name})))).toHaveLength(1+names.length*2);
});
it('공종 수량에 수동 중량이 있어도 미확정 철근을 계산 완료로 처리하지 않는다', () => {
const input={...loadP1(),quantityTakeoff:{values:{rebar:123},reviewed:true}},model=buildPier(input);
const result=projectRecords({project:{name:'',bridge:''},selected:'P1',piers:[{input,model,dirty:false,rebarTon:null,rebarIssues:['저부 미확정']}]})[0].result!;
expect(result.issues).toContain('철근재료표 일부 계산: 저부 미확정');
expect(result.complete).toBe(false);
});
});