Improve drawing export and foundation quantity views

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-06 15:35:56 +09:00
1 parent a0ec74b09b
commit d22c5008f0
72 files changed
+24346 -12

No files matched your search

+14
View File
@@ -1,5 +1,6 @@
import './style.css';
import { createDrawingWorkspace } from '../도면작성/workspace';
import { createTakeoffWorkspace } from '../수량산출/workspace';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { swapXYAzimuth } from './core/coordinate-frame';
@@ -170,6 +171,16 @@ const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
}), selectPier);
const quantityRebarTon = new Map<string,number|null>();
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
project: state.project, selected: state.active,
piers: state.piers.map(p => ({input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:quantityRebarTon.get(p.input.name)})),
}), selectPier, (name,data) => {
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return;
pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
renderSaveStatus();scheduleBackup();
});
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -1922,6 +1933,9 @@ function renderQuantityScreen(): void {
const entries = targets.flatMap((pier) =>
pier.model ? [{ pier, q: buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material) }] : [],
);
quantityRebarTon.clear();
for(const {pier,q} of entries)quantityRebarTon.set(pier.input.name,q.missing.length?null:q.kg/1000);
quantityWorkspace.refresh();
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
for (const { pier, q } of entries) {
+7 -3
View File
@@ -606,7 +606,9 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.top .brand strong { font-size: 18px; letter-spacing: 0.01em; }
.top .brand > span:not(.proj) { font-size: 12px; color: #b8c2ce; }
.proj { padding-left: 14px; border-left: 1px solid #33404f; color: #ffffff; font-size: 13px; font-weight: 700; }
.top { flex-wrap: nowrap; }
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); flex-wrap: nowrap; }
.top > .steps { justify-self: center; }
.top > .actions { justify-self: end; }
.top .brand { flex: 1 1 0; min-width: 0; align-items: center; }
.top .brand > strong, .top .brand > span:not(.proj) { flex-shrink: 0; white-space: nowrap; }
.top .proj { min-width: 0; overflow: hidden; overflow-wrap: anywhere; line-height: 1.5; max-height: 3em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
@@ -614,12 +616,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.top .steps .step, .top .actions button, .top .savest { white-space: nowrap; }
@media (max-width: 1600px) {
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.top .actions { grid-column: 1 / -1; justify-content: flex-end; }
.top .brand { grid-column: 1; grid-row: 1; }
.top .actions { grid-column: 2; grid-row: 1; justify-content: flex-end; }
.top .steps { grid-column: 1 / -1; grid-row: 2; }
}
@media (max-width: 900px) {
.top { grid-template-columns: minmax(0, 1fr); }
.top .steps, .top .actions { max-width: 100%; overflow-x: auto; }
.top .actions { justify-content: flex-start; }
.top .actions { grid-column: 1; grid-row: 3; justify-content: flex-start; justify-self: start; }
}
.steps .step { color: #dce3ea; min-height: 44px; padding: 4px 14px; border-radius: 6px; background: transparent; border: 0; }
.steps .step.current { background: #ffffff; color: #18212b; }
+2 -1
View File
@@ -1,3 +1,4 @@
import { quantityPlugin } from './수량산출/server.ts';
import { groundLinkPlugin } from './pierzainer_ground/ground-link-server.ts';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
@@ -5,5 +6,5 @@ import { drawingLauncherPlugin } from './랜딩페이지/drawing-launcher.ts';
import { generalArrangementDwgPlugin } from './도면작성/dwg-server.ts';
export default defineConfig({
plugins: [groundLinkPlugin(fileURLToPath(new URL('.', import.meta.url))),drawingLauncherPlugin(fileURLToPath(new URL('.', import.meta.url))),generalArrangementDwgPlugin(fileURLToPath(new URL('.', import.meta.url)))],
plugins: [quantityPlugin(fileURLToPath(new URL('.', import.meta.url))),groundLinkPlugin(fileURLToPath(new URL('.', import.meta.url))),drawingLauncherPlugin(fileURLToPath(new URL('.', import.meta.url))),generalArrangementDwgPlugin(fileURLToPath(new URL('.', import.meta.url)))],
});
+9
View File
@@ -0,0 +1,9 @@
# DWG 호환성 점검 (2026-11-06)
현재 번들 LibreDWG dxf2dwg는 R2000/R2004까지만 지원하고 최신 버전은 지원하지 않는다. 기존 구현은 R2004 객체 누락 때문에 R2000을 사용한다. 이번에 버전 표기만 변경하거나 검증되지 않은 최신 버전을 생성하지 않았다.
최근 출력 원본 source.dxf를 재변환했다. R2000 미지원 MATERIAL 및 ACAD_MATERIAL 사전을 변환용 중간 DXF에서 제외하고, 재질 참조를 초기화했다. 원본 DXF와 프로젝트 데이터는 변경하지 않는다. ezdxf 저장에는 기본 재질이 필요하므로 저장 후 변환 입력에만 적용한다. 색상은 기존 ACI 변환을 유지한다.
결과: output/compatibility-20261106/general.dwg. 모델 8911객체, 배치 9463객체, 치수 44개 및 객체 지문/레이어/문자스타일/출력설정 일치. 이전 잘못된 MATERIAL placeholder 복구 3건은 0건으로 줄었다. 최대 치수 오차 1.1e-11mm.
단, 동일 LibreDWG 재읽기 검증이며 실제 사용자 CAD 열기 검증이 아니다. decoder에는 중복 핸들 #144 및 ENDBLK 경고가 남는다. 따라서 이 수정이 열리지 않는 원인을 확정 해결했다고 판정하지 않는다. CAD 제품·버전·오류 메시지에 따라 독립 CAD 읽기 또는 신뢰할 수 있는 최신 DWG 변환기가 필요하다.
+10
View File
@@ -121,3 +121,13 @@ npx vite build --outDir 도면작성/.cache/build
- A-A는 첫 기둥의 일정한 원형 단면이다. 생략부에 함께 표시하고 개별 단면 보기로도 확인한다. 임의 절단 위치, 복잡한 부재 단면, 모든 원본 상세의 자동 인식은 미구현이다.
- 부재 간 완전한 가림 판정, CAD 실제 글꼴을 포함한 완전한 간섭 판정, 다주 교각의 모든 개별 높이 연쇄 치수, 모델 공간과 연동되는 DXF 뷰포트, AutoCAD 실검증은 후속 범위이다.
- 대호지교/감북교의 기존 현장타설말뚝 후속 화면 오류와 승천천교의 원본 방위각 누락 등 기존 샘플 제약은 그대로 유지한다. 도면작성 때문에 외부 기능을 수정하지 않았다.
### 아이소메트릭 높이·EL 정보
표고 EL 설정이 켜져 있으면 아이소메트릭 구조물의 실제 투영점에서 지반고, 말뚝/기초 하단, 교좌면(블럭 상면), 몰탈 상면으로 지시선을 연결한다. 기둥 높이 H는 실제 상·하단 사이 치수선(mm)으로 표시하고 EL은 m 단위다. 방향 변경 시 동일한 투영식을 사용하며 구조물 아래 정보 목록은 제거했다. 현재 각 부재 종류의 첫 부재를 지시하며 없는 부재는 지시선을 생성하지 않는다. 지반고는 입력 지반고가 첫 기둥 수직선과 만나는 높이를 가리키며 지표면 형상을 새로 생성하지 않는다. 치수/EL 표시 설정을 따르고 모델은 변경하지 않는다.
### 기초 좌표·말뚝 주석
기초 평면에는 현재 직사각형 기초의 실제 네 모서리 X(N)·Y(E)를 m 단위 소수 3자리로 지시한다. 로컬 모델점을 회전·위치변환한 좌표를 사용하며, 참고 도면의 모따기 형상·좌표를 복사하지 않는다. 횡단면도에는 기초 상단의 C-C 절단 위치 지시선이 있고 기초 평면 제목과 연결한다. 기존 A-A 기둥 단면 지시선도 유지한다. 말뚝 생략 경계의 간격을 좁혀 이중 꺾임으로 표시하며 모델 공간과 길이값은 그대로 둔다. 횡단면/측면에는 입력 말뚝 종류(없는 경우 미확인), 본수, 실제 외경, 확인 가능한 두께 및 모델 길이 범위를 지시선 주석으로 표시한다. '내부굴착복합말뚝' 등 미입력 공법명은 추정하지 않는다.
+1 -1
View File
@@ -12,7 +12,7 @@ export function modelBreaks(model:PierModel,scale:number): ViewBreak[] {
const keepBottom=Math.max(member==='column'?900:350,diameter*.65);
const keepTop=Math.max(member==='column'?1300:500,diameter*.9);
const from=bottom+keepBottom,to=top-keepTop;
const gap=member==='column'?diameter+6*scale:3*scale;
const gap=member==='column'?diameter+6*scale:1.5*scale;
if(to-from>Math.max(gap+500,scale*10))result.push({from,to,gap,member});
}
return result.sort((a,b)=>a.from-b.from).filter((b,i,all)=>i===0||b.from>=all[i-1].to);
+19
View File
@@ -1,3 +1,4 @@
import { foundationNotes } from './foundation-notes';
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
@@ -114,6 +115,17 @@ describe('실제 모델 → 일반도',()=>{
expect(JSON.stringify(model)).toBe(before);
});
it('기초 좌표와 말뚝 주석은 모델 실좌표·실제 길이를 사용한다',()=>{
const m=buildPier(input),f=m.footings[0],bounds={minX:-10000,maxX:10000,minY:0,maxY:5000};
const notes=foundationNotes(m,input,'footing',bounds,100);
const corner=localPoint(m,f.k,f.s-f.length/2,f.top,-f.width/2);
expect(notes.some(e=>e.kind==='text'&&e.text===`X(N) ${(-corner.z+m.pivot.n).toFixed(3)}`)).toBe(true);
expect(notes.some(e=>e.kind==='text'&&e.text===`Y(E) ${(corner.x+m.pivot.e).toFixed(3)}`)).toBe(true);
expect(notes.filter(e=>e.kind==='text')).toHaveLength(m.footings.length*8);
const pileNotes=foundationNotes(m,input,'front',bounds,100);
expect(pileNotes.some(e=>e.kind==='text'&&e.text.startsWith('L=')&&e.text.endsWith(' m'))).toBe(true);
});
it('참고 치수표 항목은 모델의 받침 제원·실제 높이를 사용하고 미확인 값을 만들지 않는다',()=>{
const m=buildPier(input),rows=bearingSchedule(m);
expect(rows).toHaveLength(m.blocks.length);expect(rows[0].A).toBeNull();expect(rows[0].T1).toBeNull();
@@ -200,6 +212,13 @@ describe('실제 모델 → 일반도',()=>{
it('전체 치수는 외측 레인에 배치하고 출력 문자 크기와 실제 점유 범위를 검증한다',()=>{
const doc=generateDrawing(buildPier(input),input,project,DEFAULT_SETTINGS),front=doc.views[0];
const info=doc.views.find(v=>v.id==='iso')!.entities.filter(e=>e.kind==='text'&&e.source.member==='iso-info');
expect(info.some(e=>e.kind==='text'&&e.text==='지반고')).toBe(true);
expect(info.some(e=>e.kind==='text'&&e.text===`EL. ${input.groundEl.toFixed(3)}`)).toBe(true);
expect(info.some(e=>e.kind==='text'&&e.text==='말뚝 하단')).toBe(true);
const iso=doc.views.find(v=>v.id==='iso')!;
expect(iso.entities.some(e=>e.kind==='dimension'&&e.label?.startsWith('H='))).toBe(true);
expect(iso.entities.some(e=>e.kind==='line'&&e.source.member==='iso-info')).toBe(true);
expect(doc.quality?.geometryUnchanged).toBe(true);expect(doc.quality?.after).toBe(0);expect(doc.quality!.minimumTextMm).toBeGreaterThanOrEqual(2.5);
const vertical=front.entities.filter((e):e is Extract<typeof e,{kind:'dimension'}>=>e.kind==='dimension'&&e.axis==='y');
const overall=vertical.reduce((a,b)=>a.value>b.value?a:b);
+10 -6
View File
@@ -1,3 +1,4 @@
import { foundationNotes } from './foundation-notes';
import { sectionCut } from './reference-symbols';
import { Vector3 } from 'three';
import type { PierInput } from '../src/core/types';
@@ -6,7 +7,7 @@ import { entityBounds, localPoint, modelPrimitives, projectModel, projectPoint }
import { modelBreaks, shortenedBounds } from './breaks';
import { bearingDetail, scheduleEntities } from './bearing-detail';
import { templateSlots, defaultSheet, wrappedLines } from './template';
import { isometricEntities, keyplanEntities } from './project-views';
import { isometricEntities, isometricInfo, keyplanEntities } from './project-views';
import { hatchGeometry } from './hatches';
import { componentDimensions } from './component-dimensions';
import { optimiseLayout } from './quality-layout';
@@ -15,7 +16,7 @@ import type { Bounds, DrawingDocument, DrawingSettings, DrawingView, Entity, Poi
export const DEFAULT_SETTINGS: DrawingSettings = { paper:'A3',scale:'auto',dimensions:true,centers:true,elevations:true,shorten:true,hatches:true,isoDirection:0 };
const SCALES=[10,20,25,30,40,50,75,100,150,200,250,300,400,500,750,1000,1500,2000];
export const VIEW_TITLES: Record<ViewId,string>={front:'횡단면도',side:'단면도 · 측면',top:'단면도 · 두부보 평면',footing:'단면도 · 기초 평면',section:'A-A 기둥 단면',detail:'상세 A · 받침 및 교좌면',schedule:'받침 및 교좌면 상세 변화 치수표',heights:'받침 높이·표고표',iso:'ISOMETRIC VIEW',keyplan:'KEY PLAN'};
export const VIEW_TITLES: Record<ViewId,string>={front:'횡단면도',side:'단면도 · 측면',top:'두부보 평면도',footing:'단면도 C-C · 기초 평면',section:'A-A 기둥 단면',detail:'상세 A · 받침 및 교좌면',schedule:'받침 및 교좌면 상세 변화 치수표',heights:'받침 높이·표고표',iso:'ISOMETRIC VIEW',keyplan:'KEY PLAN'};
function slots(width: number,height: number): Record<ViewId,Rect> {
return templateSlots(width,height);
@@ -25,7 +26,7 @@ function recommend(bounds: Bounds,slot: Rect,id: ViewId): number {
const need=Math.max((bounds.maxX-bounds.minX)/Math.max(10,slot.width-(side?36:24)),(bounds.maxY-bounds.minY)/Math.max(10,slot.height-(id==='top'?47:31)));
return SCALES.find(s=>s>=need)??Math.ceil(need/500)*500;
}
function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings: DrawingSettings,geometry?:Entity[]): Entity[] {
function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings: DrawingSettings,input:PierInput,geometry?:Entity[]): Entity[] {
const out: Entity[]=[],source: Source={member:'annotation',index:0,pier:model.name},h=2.3*scale;
const line=(a:Point,c:Point,layer:Entity['layer']='CENTER')=>out.push({kind:'line',a,b:c,layer,source});
const text=(p:Point,value:string,layer:Entity['layer']='TEXT',align:'left'|'center'='left')=>out.push({kind:'text',point:p,text:value,height:h,layer,source,align});
@@ -110,6 +111,8 @@ function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings:
out.push({kind:'hatch',points:Array.from({length:16},(_,i)=>[p[0]+.5*scale*Math.cos(i*Math.PI/8),p[1]+.5*scale*Math.sin(i*Math.PI/8)] as Point),pattern:'SOLID',spacing:scale,angle:0,color:'#ff0000',layer:'SECTION',source});
text([end[0]+scale,end[1]+scale],`말뚝 Ø${formatMm(pile.diameter*1000)} · ${model.piles.length}본`);
}
out.push(...foundationNotes(model,input,id,b,scale));
if(id==='front'&&model.footings[0]){const y=model.footings[0].top*1000;line([b.minX-3*scale,y],[b.maxX+3*scale,y],'SECTION');out.push(...sectionCut([b.minX-3*scale,y],scale,'C',false,source).filter(e=>e.kind!=='text'),...sectionCut([b.maxX+3*scale,y],scale,'C',true,source).filter(e=>e.kind!=='text'));out.push({kind:'text',point:[b.minX-5*scale,y+2*scale],text:'C',height:2.5*scale,align:'right',layer:'SECTION',source});}
if(settings.dimensions)out.push(...componentDimensions(model,id,geometry??projectModel(model,id),scale));
return settings.hatches===false?out.filter(e=>e.kind!=='hatch'):out;
}
@@ -129,7 +132,7 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
let scale=settings.scale==='auto'?recommend(geometryBounds,slot,id):settings.scale;
if(settings.scale==='auto'&&wide&&settings.shorten!==false)for(let i=0;i<3;i++)scale=recommend(shortenedBounds(geometryBounds,modelBreaks(model,scale)),slot,id);
const breaks=wide&&settings.shorten!==false?modelBreaks(model,scale):undefined;
const entities=[...geometry,...annotate(model,id,geometryBounds,scale,settings)];
const entities=[...geometry,...annotate(model,id,geometryBounds,scale,settings,input)];
const bounds={minX:geometryBounds.minX-(wide?17:12)*scale,maxX:geometryBounds.maxX+(wide&&settings.elevations?18:10)*scale,
minY:geometryBounds.minY-(id==='top'?27:20)*scale,maxY:geometryBounds.maxY+(id==='top'?19:10)*scale};
return{id,title:VIEW_TITLES[id],entities,geometryBounds,bounds,scale,slot,breaks};
@@ -137,7 +140,7 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
if(settings.scale==='auto'){
const common=Math.max(views[0].scale,views[1].scale);
for(const v of views.slice(0,2)){
const b=v.geometryBounds;v.entities=[...v.entities.filter(e=>e.source.member!=='annotation'),...annotate(model,v.id,b,common,settings)];
const b=v.geometryBounds;v.entities=[...v.entities.filter(e=>e.source.member!=='annotation'),...annotate(model,v.id,b,common,settings,input)];
v.scale=common;v.breaks=settings.shorten===false?undefined:modelBreaks(model,common);
v.bounds={minX:b.minX-17*common,maxX:b.maxX+(settings.elevations?18:10)*common,minY:b.minY-20*common,maxY:b.maxY+10*common};
}
@@ -173,8 +176,9 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
const originals=new Map(views.map(v=>[v.id,v])),geometry=new Map(views.slice(0,5).map(v=>[v.id,v.entities.filter(e=>e.kind!=='hatch'&&e.source.member!=='annotation'&&e.source.member!=='bearing-location')]));
optimiseLayout(doc,(id,scale)=>{
const original=originals.get(id)!;let entities=original.entities;
if(geometry.has(id))entities=[...geometry.get(id)!,...annotate(model,id,original.geometryBounds,scale,settings,geometry.get(id))];
if(geometry.has(id))entities=[...geometry.get(id)!,...annotate(model,id,original.geometryBounds,scale,settings,input,geometry.get(id))];
else if(id==='detail')entities=bearingDetail(model,scale,settings.detailBearing,settings.dimensions,settings.centers).entities;
else if(id==='iso')entities=[...original.entities,...(settings.elevations?isometricInfo(model,input,original.entities,scale,settings.isoDirection??0,settings.dimensions):[])];
else if(id==='keyplan')entities=keyplanEntities(models,model,scale,cfg.startLabel,cfg.endLabel);
const breaks=['front','side'].includes(id)&&settings.shorten!==false?modelBreaks(model,scale):undefined;
if(settings.hatches!==false&&id!=='iso')entities=[...hatchGeometry(entities,scale),...entities];
+27
View File
@@ -56,6 +56,13 @@ def convert(source,output):
original=ezdxf.readfile(source);audit=original.audit()
if audit.errors:raise ValueError('Input DXF audit failed')
doc=original
# R2000 has no MATERIAL objects. LibreDWG used to turn the default material
# dictionary into invalid ACDBPLACEHOLDER entries that its own reader repairs.
# Remove unsupported metadata before encoding, preserving indexed colours.
materials=[e for e in doc.objects if e.dxftype()=='MATERIAL']
material_dictionary=doc.rootdict.get('ACAD_MATERIAL')
unsupported_handles={e.dxf.handle for e in materials}
if material_dictionary:unsupported_handles.add(material_dictionary.dxf.handle)
# The bundled writer's R2004 path drops entities. R2000 is used after a full round-trip check.
# R2000 uses indexed colours; retain the intended greys with the nearest ACI colour.
colour_count=0
@@ -69,6 +76,25 @@ def convert(source,output):
expected={layout.name:Counter(fingerprint(e) for e in layout) for layout in doc.layouts}
expected_blocks={block.name:Counter(fingerprint(e) for e in block) for block in doc.blocks if not block.name.startswith('*')}
compatibility_angles(intermediate)
# Strip only converter-input metadata after ezdxf has finished serialization;
# ezdxf requires its default materials while saving even an R2000 document.
lines=intermediate.read_text(encoding='cp949').splitlines()
pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[]
for code,value in pairs:
if code.strip()=='0' and group:groups.append(group);group=[]
group.append((code,value))
if group:groups.append(group)
cleaned=[]
for group in groups:
handle=next((v for c,v in group if c.strip()=='5'),None)
if handle in unsupported_handles:continue
skip=False
for i,(code,value) in enumerate(group):
if skip:skip=False;continue
if code.strip()=='3' and value=='ACAD_MATERIAL':skip=True;continue
if code.strip()=='347':value='0'
cleaned.extend([code,value])
intermediate.write_text('\n'.join(cleaned)+'\n',encoding='cp949')
logs=[]
for args in [[str(TOOLS/'dxf2dwg.exe'),'--as','r2000','-o',str(output),str(intermediate)],
[str(TOOLS/'dwgread.exe'),'-O','DXF','-o',str(output.with_suffix('.roundtrip.dxf')),str(output)]]:
@@ -109,6 +135,7 @@ def convert(source,output):
object_fingerprints_match=True,source_links_match=True,dimension_measurements_match=True,max_dimension_error_mm=max_error,lineweights_match=True,dimstyles_match=True,paper_plot_settings_match=True,font_styles_match=True,audit_errors=len(audit.errors),
decoder_metadata_repairs=[str(e.message) for e in reader_audit.fixes],indexed_colour_conversions=colour_count,
native_autocad_open_tested=False)
report['unsupported_materials_removed']=len(materials)
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
output.with_suffix('.conversion.log').write_text('\n'.join(logs),encoding='utf-8')
return report
+10
View File
@@ -0,0 +1,10 @@
import {it,expect} from 'vitest';
import {readFileSync,writeFileSync} from 'node:fs';
import {buildPier} from '../src/core/geometry';
import {generateDrawing,DEFAULT_SETTINGS} from './drawing';
it('회전된 다주 기초의 좌표 주석은 겹치지 않는다',()=>{
const s=JSON.parse(readFileSync(new URL('../랜딩페이지/assets/samples/sample06.pzr.json',import.meta.url),'utf8'));
const p=s.piers[1],m=buildPier(p.input),d=generateDrawing(m,p.input,s.project,DEFAULT_SETTINGS);
if(d.quality?.after)writeFileSync(new URL('./.cache/foundation-debug.json',import.meta.url),JSON.stringify(d.quality.issues.map(q=>({q,a:d.views.find(v=>v.id===q.view)?.entities[q.first],b:d.views.find(v=>v.id===q.view)?.entities[q.second??-1]})),null,2));
expect(d.quality?.after).toBe(0);
});
+35
View File
@@ -0,0 +1,35 @@
import type { PierModel } from '../src/core/geometry';
import type { PierInput } from '../src/core/types';
import { localPoint,projectPoint } from './projection';
import type { Entity,ViewId,Bounds,Point } from './types';
/** Survey labels refer to actual model corners; X=Northing, Y=Easting. */
export function foundationNotes(model:PierModel,input:PierInput,id:ViewId,b:Bounds,scale:number):Entity[]{
const out:Entity[]=[],source={pier:model.name,member:'annotation',index:0};
const text=(point:Point,text:string,align:'left'|'right'='left')=>out.push({kind:'text',point,text,height:2.5*scale,align,layer:'TEXT',source});
const line=(a:Point,b:Point)=>out.push({kind:'line',a,b,layer:'TEXT',source});
if(id==='footing')model.footings.forEach((f,index)=>{
const corners=[[-1,-1],[-1,1],[1,-1],[1,1]].map(([s,n])=>{
const world=localPoint(model,f.k,f.s+s*f.length/2,f.top,n*f.width/2);
return {point:projectPoint(model,world,id),e:world.x+model.pivot.e,n:-world.z+model.pivot.n};
});
corners.sort((a,b)=>b.point[1]-a.point[1]);
const ordered=[...corners.slice(0,2).sort((a,b)=>a.point[0]-b.point[0]),...corners.slice(2).sort((a,b)=>a.point[0]-b.point[0])];
for(const [i,c] of ordered.entries()){const right=i%2===1,upper=i<2,sign=right?1:-1;
const x=(right?b.maxX:b.minX),y=(upper?b.maxY+(8+index*8)*scale:b.minY-(12+index*8)*scale);
line(c.point,[x-sign*scale,y]);line([x-sign*scale,y],[x,y]);
text([x,y+3.5*scale],`X(N) ${c.n.toFixed(3)}`,right?'right':'left');
text([x,y],`Y(E) ${c.e.toFixed(3)}`,right?'right':'left');
}
});
if((id==='front'||id==='side')&&model.piles.length){
const pile=model.piles[0],anchor=projectPoint(model,localPoint(model,pile.k,pile.s,pile.top-.2,pile.n),id);
const x=b.minX,y=b.minY-25*scale;
line(anchor,[x-scale,y]);line([x-scale,y],[x+12*scale,y]);
const lengths=model.piles.map(p=>p.top-p.bottom),lo=Math.min(...lengths),hi=Math.max(...lengths);
text([x,y+3.5*scale],`${input.pileKind??'종류 미확인'} 말뚝 · ${model.piles.length}본`);
text([x,y],`Ø${(pile.diameter*1000).toFixed(1)}${input.pileThicknessMm!=null?' × t'+input.pileThicknessMm:''}`);
text([x,y-3.5*scale],`L=${lo.toFixed(3)}${hi-lo>.0005?'~'+hi.toFixed(3):''} m`);
}
return out;
}
+36
View File
@@ -1,4 +1,6 @@
import { Vector3 } from 'three';
import type { PierInput } from '../src/core/types';
import { entityBounds } from './projection';
import type { PierModel } from '../src/core/geometry';
import { modelPrimitives, projectPoint, projectModel } from './projection';
import { modelBreaks, shortenedY } from './breaks';
@@ -61,3 +63,37 @@ export function keyplanEntities(models:readonly PierModel[],selected:PierModel,s
{kind:'polyline',points:[[tip[0]-north[0]*2*scale+north[1]*scale,tip[1]-north[1]*2*scale-north[0]*scale],tip,[tip[0]-north[0]*2*scale-north[1]*scale,tip[1]-north[1]*2*scale+north[0]*scale]],closed:false,layer:'TEXT',source});
return entities;
}
/** Actual projected model points anchor all isometric dimensions and EL leaders. */
export function isometricInfo(model:PierModel,input:PierInput,geometry:Entity[],scale:number,direction=0,dimensions=true):Entity[]{
const bounds=entityBounds(geometry),out:Entity[]=[],primitives=modelPrimitives(model);
const datum=Math.min(...model.columns.map(c=>c.bottom),...model.piles.map(c=>c.bottom),...model.footings.map(c=>c.bottom))*1000,theta=direction*Math.PI/2;
const project=(p:Vector3):Point=>{const [s,n]=projectPoint(model,p,'top'),a=s*Math.cos(theta)-n*Math.sin(theta),b=s*Math.sin(theta)+n*Math.cos(theta);return [(a-b)*Math.sqrt(3)/2,p.y*1000-datum+(a+b)*.5];};
const source={pier:model.name,member:'iso-info',index:0};
const text=(p:Point,value:string)=>out.push({kind:'text',point:p,text:value,height:2.5*scale,layer:'ELEVATION',source,align:'left'});
const line=(a:Point,b:Point)=>out.push({kind:'line',a,b,layer:'ELEVATION',source});
const rows:{point:Point;name:string;el:number}[]=[];
const column=primitives.cylinders.find(c=>c.source.member==='column');
if(column){
const bottom=project(column.center.clone().setY(column.bottom)),top=project(column.center.clone().setY(column.top));
if(dimensions)out.push({kind:'dimension',a:bottom,b:top,position:bounds.minX-4*scale,axis:'y',height:2.5*scale,value:(column.top-column.bottom)*1000,label:`H=${((column.top-column.bottom)*1000).toFixed(0)}`,source,layer:'DIMENSION'});
if(Number.isFinite(input.groundEl))rows.push({point:project(column.center.clone().setY(input.groundEl)),name:'지반고',el:input.groundEl});
}
const pile=primitives.cylinders.find(c=>c.source.member==='pile');
if(pile)rows.push({point:project(pile.center.clone().setY(pile.bottom)),name:'말뚝 하단',el:pile.bottom});
const footing=primitives.solids.find(s=>s.source.member==='footing');
if(footing){const points=footing.faces.flat(),el=Math.min(...points.map(p=>p.y)),p=points.find(p=>Math.abs(p.y-el)<1e-7)!;rows.push({point:project(p),name:'기초 하단',el});}
for(const [member,name,field] of [['block','교좌면','top'],['pad','몰탈 상면','padTop']] as const){
const solid=primitives.solids.find(s=>s.source.member===member);if(!solid)continue;
const block=model.blocks[solid.source.index];if(!block)continue;const el=block[field],points=solid.faces.flat(),p=points.reduce((a,b)=>Math.abs(a.y-el)<Math.abs(b.y-el)?a:b);
rows.push({point:project(p),name,el});
}
rows.sort((a,b)=>a.point[1]-b.point[1]);let last=-Infinity;
for(const row of rows){
const y=Math.max(row.point[1],last+8*scale);last=y;const x=bounds.maxX+3*scale;
line(row.point,[x-scale,y]);line([x-scale,y],[x+13*scale,y]);
out.push({kind:'polyline',points:[[row.point[0]+scale,row.point[1]+.6*scale],row.point,[row.point[0]+scale,row.point[1]-.6*scale]],closed:false,layer:'ELEVATION',source});
text([x,y+3.3*scale],row.name);text([x,y],`EL. ${row.el.toFixed(3)}`);
}
return out;
}
+6 -1
View File
@@ -102,7 +102,7 @@ export function optimiseLayout(doc:DrawingDocument,factory:Factory):void{
const left=Math.max(bs[0].maxX-bs[0].minX,bs[2].maxX-bs[2].minX),right=Math.max(bs[1].maxX-bs[1].minX,bs[3].maxX-bs[3].minX);
const top=Math.max(bs[0].maxY,bs[1].maxY)-Math.min(bs[0].minY,bs[1].minY),bottom=Math.max(bs[2].maxY-bs[2].minY,bs[3].maxY-bs[3].minY);
const overflow=Math.max(0,left+right+gap-major.width)+Math.max(0,top+bottom+gap+12-major.height);
const score=overflow*10000+elevation+plan*.45+Math.abs(elevation-plan)*.1;
const score=overflow*10000+vs.reduce((n,v)=>n+collisionCount(v),0)*100000+elevation+plan*.45+Math.abs(elevation-plan)*.1;
if(!best||score<best.score)best={views:vs,bs,left,right,top,bottom,score};
}
// The smallest feasible elevation scale is preferred; larger denominators cannot improve clarity.
@@ -124,6 +124,11 @@ export function optimiseLayout(doc:DrawingDocument,factory:Factory):void{
const centre=column[0].paperOrigin![0]+(column[0].geometryBounds.minX+column[0].geometryBounds.maxX)/(2*column[0].scale);
const v=column[1],b=measure(v);v.paperOrigin![0]=centre-(v.geometryBounds.minX+v.geometryBounds.maxX)/(2*v.scale);v.slot.x=v.paperOrigin![0]+b.minX;
}
// Coordinate callouts may be asymmetric: retain alignment only within the major drawing zone.
for(const v of best.views){const b=measure(v),left=v.paperOrigin![0]+b.minX,right=v.paperOrigin![0]+b.maxX;
const shift=right>major.x+major.width?major.x+major.width-right:left<major.x?major.x-left:0;
v.paperOrigin![0]+=shift;v.slot.x+=shift;
}
const table=finish(doc.views.find(v=>v.id==='schedule')!),tb=measure(table),tableHeight=tb.maxY-tb.minY;
fit(table,{x:538*k,y:527*ky-tableHeight,width:280*k,height:tableHeight});
const small:Rect={x:538*k,y:46*ky,width:126*k,height:Math.min(table.slot.y-9,365*ky)-46*ky};
+3
View File
@@ -0,0 +1,3 @@
.cache/
outputs/
tools/node_modules/
+89
View File
@@ -0,0 +1,89 @@
# 교각 수량산출
`5. 재료표` 탭의 **교각 수량산출**에서 현재 생성된 교각을 선택합니다. 기존 철근재료표 메뉴는 그대로 유지합니다.
1. 자동 입력값을 확인합니다. 길이/EL은 m, 본수는 개, 횡경사는 %입니다.
2. 비어 있는 직접 입력값을 확인해 입력합니다. 토층 깊이·지하수위는 현장값을 사용하고, 시험 횟수·시설 수·재료 규격은 원본 Excel 기본값을 표시하므로 적용 여부를 확인합니다.
3. 원본 산식/고정 기준의 적용 여부를 확인하고 체크합니다.
4. **재계산**을 누릅니다. 중앙 항목을 클릭하면 우측에서 원식·대입값과 해당 원본 셀을 함께 확인할 수 있습니다.
5. 완료 상태에서 **Excel 출력**을 누릅니다.
화면은 좌측 입력·누락 검토 / 중앙 요약·수량표 / 우측 원본 미리보기·산식 상세의 3열 대시보드입니다. 상세 변경과 검증 결과는 [대시보드 보고서](docs/DASHBOARD_REPORT.md)를 참고합니다.
직접 입력은 `input.quantityTakeoff` 확장 필드로 교각별 저장되며 기존 프로젝트 JSON 저장·열기·백업을 사용합니다. 형상을 다시 생성하면 자동값은 갱신하고 직접 입력은 유지합니다. 형상/재료표 철근량/직접 입력이 바뀌면 재계산이 필요합니다.
## 원본과 계산
- 원본: `교각수량산출.xlsx` (13시트). 원본은 변경하지 않습니다.
- 29개 관련변수와 철근량/시험·시설 직접값 4개를 식별했습니다. 말뚝 두께 Tp는 원본 수량식에서 사용하지 않는 참고변수이므로 누락만으로 출력 완료를 막지 않습니다.
- 철근량은 기존 재료표가 길이 누락 없이 계산됐을 때 할증 전 kg/1000을 자동 연결합니다. 미산정 철근이 있으면 직접 확인한 철근량이 필요합니다.
- 받침 제원·지하수위 등은 모델/입력에 있으면 자동 연결합니다. 블럭 높이는 동일 평면 크기에 대해 높이 합/Nb의 등가값을 사용합니다. 서로 다른 말뚝 직경·길이, 일체 교좌면 등은 원본 단일변수식에 임의로 맞추지 않습니다.
- 원본의 실제 수식을 파싱해 평가합니다. 3D 체적을 수량으로 대체하지 않습니다.
- `ANALYSIS.md`에 시트·변수·수량항목별 원식과 원본 위치, `MAPPING.md`에 자동/수동 매핑이 있습니다.
## 중요: 원본 산식 적용 범위
원본은 단일 원형 기둥, 직사각형 기초 1개, 균일 제원의 말뚝, 곡률 R을 가진 코핑 계산체계입니다. 다기둥·꺾인 교각·일체 교좌면·PHC 시공 및 8m 초과 토공은 검토 상태로 표시하고 완료 출력을 제한합니다. 수량/형상 관계를 임의 배수로 확장하지 않습니다.
현재 모델의 직선 코핑에는 원본 곡률식의 R→∞ 극한식을 명시적으로 적용합니다. 횡경사는 실제 양 끝 표고차의 등가값이며 돌출부가 없는 모델은 0입니다. 원본에 고정된 버림/몰탈/솔플레이트 여유와 현재 모델이 다른 경우는 별도 검토가 필요합니다.
원본 W51의 기둥 거푸집 산식은 적힌 Hcol 설명과 다릅니다. W50의 계수 및 W63의 표시 두께와 실제 계산 두께도 불일치합니다. 이들을 임의 수정하지 않고 화면에 표시합니다. **원본 수식 일치는 설계 수량의 타당성 승인을 의미하지 않습니다.**
## Excel 출력 방식
- 원본 ZIP 패키지를 복사하고 입력셀·제목·수식 계산캐시만 변경합니다. WMF/도형은 일반 라이브러리 재저장 시 손실될 수 있어 OOXML 패키지를 직접 보존합니다.
- 선택 교각은 P1 슬롯을 사용합니다. 표지/변수/상세/집계의 명칭을 실제 교각명으로 바꿉니다.
- 13시트는 유지합니다. P2~P6 비선택 교각의 숫자·수식은 빈 상태로 만들고 미사용으로 표시합니다. 그 결과가 집계에 원본 예시 수량으로 섞이지 않습니다. `_ex)`는 명시적인 원본 참고 예시로 유지합니다.
- 선택 교각 수식 121개 중 119개는 원식을 유지하고 직선 코핑의 W50/W58 두 식에는 명시한 극한식을 적용합니다. 재료 규격은 사용자가 확인한 텍스트를 반영합니다. 집계 수식과 원본 표시형식/병합/인쇄 설정을 유지합니다.
- 계산된 캐시를 넣고 Excel 열기 시 전체 재계산하도록 설정합니다. 출력마다 `outputs/<ID>/`에 입력 패치와 검증 보고서를 남깁니다.
- 실제 Microsoft Excel COM 재계산은 이 PC에서 호출 거부/대기시간 초과로 완료하지 못했습니다. 원본 cached 값 비교, Artifact Tool 재계산/렌더, ZIP/수식/오류 검사를 수행했습니다.
## 검증 결과
- 원본의 수식 캐시 1,214개: 계산 엔진과 비교해 차이 0건(상대/절대 허용오차 1e-8).
- 선택 교각 수식 121개 유지, 13시트 유지, 도형/이미지/스타일/인쇄기 설정 등 57개 패키지 부품 바이트 일치.
- 병합/인쇄설정/열너비 유지, 출력 캐시의 Excel 오류 0건, 비선택 교각 수량 빈 상태 확인.
- 실제 운곡교 P1 연결: 모델 자동값, Excel 기본값, 직접 입력을 구분합니다(개수는 모델에 따라 달라짐). 53개 상세 항목, 교각 전환 후 입력 유지, Excel 다운로드 및 기존 재료표 표시를 브라우저에서 확인했습니다.
- `docs/*-verification.json`에 근거를 저장했습니다. `outputs/verification/운곡교P1_모델연동.xlsx`는 실제 모델 연동 출력 검증본이며 직접 입력값에는 원본 예시 조건을 검증용으로 적용했습니다. 현장 조건이 확정된 설계 결과로 간주하지 않습니다.
## 모듈 및 연결
- `template-data.ts`, `tools/analyze.py`: 원본 셀·수식·표시형식/변수 분석.
- `formula.ts`: 제한된 원식 파서 및 계산·참조 추적(동적 코드 실행 없음).
- `model.ts`, `schema.ts`: 기존 모델 매핑, 직접입력, 적용 범위, 결과/동일 행·단위 집계 기반.
- `workspace.ts`, `workspace.css`: 재료표 탭 내부 UI.
- `export.ts`, `server.ts`, `tools/preserve_template.py`: 원본 보존 출력.
- 외부 연결은 `src/main.ts`의 기존 상태/재료표 결과 연결과 `vite.config.ts`의 로컬 출력 API 등록만 변경했습니다. 코어 모델/철근 계산 로직은 변경하지 않습니다.
## 실행 및 검증 명령
```text
npx vitest run 수량산출/formula.test.ts 수량산출/model.test.ts
npx tsc --noEmit
pierzainer_ground/.venv/Scripts/python.exe -B -X utf8 수량산출/tools/analyze.py
```
원본을 교체하면 `analyze.py`로 분석 데이터를 다시 생성하고 전체 비교 검증을 다시 해야 합니다. 로컬 Vite 개발/preview 서버와 기존 Python 환경이 필요합니다. 정적 호스팅만으로는 Python Excel 출력을 실행할 수 없습니다.
추후 범위: 다기둥/꺾인/비대칭 형식별 승인 산식, 원본 설명식 불일치의 설계자 결정, 전체 교각 총괄 출력 UI, 실제 Excel/현장 규격 확인. 현재 총괄 계산은 행·단위별 집계 함수로 준비했으며 서로 다른 공종을 무조건 합하지 않습니다.
### Excel 샘플형 화면
수량산출서 미리보기에서 원본의 수량집계/관련변수/상세 산출서를 선택할 수 있다. 원본에서 추출한 병합셀, 행 높이, 열 너비, 글꼴, 테두리, 바탕색, 소수 표시형식으로 현재 계산값을 그린다. 80~150% 확대를 지원하며 수량 셀은 원식 추적과 연결된다. 빈 입력은 미산정으로 표시하고 원본의 예시 수량으로 대체하지 않는다. 미리보기의 원본 인쇄영역 밖 그림은 표시하지 않지만 Excel 출력에는 기존 WMF·도형 패키지를 그대로 보존한다. `tools/extract-layout.py`는 원본을 읽기만 하고 `template-layout.ts`를 생성한다.
### 표지부터 전체 보기
미리보기 기본값은 원본 순서의 13시트를 이어 보여주는 전체 보기다. 표지/목차, 집계, 토공·구조체 산출기준(원본 설명 그림 포함), 관련변수와 설명도, 참고 예시, P1~P6 상세 양식을 모두 표시한다. 개별 시트 선택 및 이전/다음 이동도 지원한다. 표지의 프로젝트/교량/교각명은 현재 정보로 바꾼다. 비선택 P2~P6는 출력과 동일하게 미사용 빈 양식으로 보이며 원본 예시 수량을 현재 프로젝트 수량으로 표시하지 않는다. 산출기준 그림은 원본 참고도이고 현재 교각 형상을 새로 만든 것이 아니다.
원본 변경 시 `tools/extract-layout.py`와 검증용 Artifact Tool의 `tools/render-reference-pages.mjs`도 다시 실행해야 한다. 원본 Excel 자체를 재저장하지 않는다.
### 입력 자동화 기준 변경 (사용자 지정)
지하수위/토사/풍화암/연암 깊이는 직접 입력한다(기존 지하수 입력이 있는 경우 연동). E.O.I.D=원본 K68(2회), Restrike=K69(2회), 점검시설=K74(1EA), 콘크리트·받침·몰탈 규격은 원본 Excel 셀에서 기본값으로 채우며 수정 가능하다. 기존 직접값을 입력한 경우 보존한다.
횡경사는 현재 모델 양단 상면 차/Lc의 등가 경사이고, 별도 돌출부가 없는 현재 모델의 a는0이다. 곡률을 갖지 않는 현재 코핑은 R을 임의의 숫자로 만들지 않는다. W50/W58에서 원본 원호항의 R→∞ 극한(원호길이→현 길이, 원호 공제면적→0)을 적용한다. 이 두 식은 출력 Excel에도 명시적으로 적용하고 나머지 원식은 유지한다. R 입력셀은 비운다. 원본 파일과 원본 곡선식 비교 시험은 변경하지 않는다. 예시 시트는 원본 입력값을 독립적으로 보존한다.
받침 A/B 및 솔플레이트 T가 모델/공통 입력에 있으면 자동 연동한다. 모델에 해당 본체 제원이 없는 샘플은 블럭 크기를 받침 크기로 대체하거나 원본 예시 치수를 실제 치수로 가장하지 않는다. 해당 항목에 필요한 실제 제원은 별도 확인이 필요하다. 제원이 모두 있는 단일기둥 모델에서는 지하수와 토층 깊이4개만 직접 입력하면 된다(철근량이 기존 재료표에서 완전하게 계산된 경우).
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+10
View File
@@ -0,0 +1,10 @@
import {template} from './template-data.ts';
import {DETAIL} from './schema.ts';
/** R→∞: original circular-segment area tends to 0; arc length tends to its chord. */
const chord='SQRT(((BG12+2*BG17-BG19-0.2)/2)^2+BG14^2)';
const area='((BG19+0.2)+(BG12+2*BG17))*BG14/2+(BG12+(BG12+2*BG17))*BG15/2+(BG12*((0.01*BG16*BG12)+BG17)/(BG15+0.01*BG16*BG12))*(0.01*BG16*BG12)/2';
export const straightCopingFormulas:Record<string,string>={
[DETAIL+'!W50']:'=('+area+')+2*BG13*'+chord+'+BG13*SQRT(BG17^2+BG15^2)+BG13*SQRT(BG17^2+(BG15+0.01*BG16*BG12)^2)',
[DETAIL+'!W58']:'=('+area+')*BG13+PI()*BG19^2/4*(BG6-BG14-BG15-BG7+BG20)+BG31*BG32*BG33*BG30',
};
export function sourceFormula(cell:string){return template[DETAIL][cell]?.value;}
+13
View File
@@ -0,0 +1,13 @@
import type {calculate,QuantityInputs} from './model';
import type {PierInput} from '../src/core/types';
import type {PierModel} from '../src/core/geometry';
export type TakeoffResult=ReturnType<typeof calculate>;
export type QuantityRow=TakeoffResult['results'][number];
export type Selection={id:string;title:string;rows:QuantityRow[];unit:string;value:number|null;formula:string;substitution:string};
export type DashboardState={query:string;view:'member'|'work'|'material';selected:string;closed:Set<string>;openInputs:Set<string>;inputScroll:number};
export type PierContext={input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null};
export const node=<K extends keyof HTMLElementTagNameMap>(tag:K,text?:string,cls?:string)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n;};
export const fmt=(value:number|null,digits=3)=>value===null?'미산정':value.toLocaleString('ko-KR',{maximumFractionDigits:digits,minimumFractionDigits:digits});
export function icon(name:string):SVGSVGElement{const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.6');svg.setAttribute('aria-hidden','true');const p=document.createElementNS(svg.namespaceURI,'path');p.setAttribute('d',({input:'M8 4h8v3H8zM8 5H5v16h14V5h-3',review:'M20 12a8 8 0 1 1-3-6M8 12l3 3 8-9',result:'m3 8 9-5 9 5-9 5zM3 12l9 5 9-5M3 16l9 5 9-5',preview:'M6 3h9l4 4v14H6zM14 3v5h5M9 12h7M9 16h7',concrete:'m4 7 8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 11v10',rebar:'M4 18 18 4M3 14l7 7M6 11l7 7M9 8l7 7M12 5l7 7',formwork:'M3 4h18v16H3zM7 4v16M17 4v16M7 8h10M7 16h10',earth:'m3 19 6-10 4 5 3-8 5 13z'} as Record<string,string>)[name]??'M5 12h14');svg.append(p);return svg;}
export function rawSelection(row:QuantityRow,book:TakeoffResult['book']):Selection{return{id:row.cell,title:row.name,rows:[row],unit:row.unit??'',value:row.value,formula:String(row.formula),substitution:book.substitution('3.3 수량산출_P1',String(row.formula))};}
export function sumSelection(rows:QuantityRow[],title:string):Selection{const unit=rows[0]?.unit??'';return{id:'sum:'+rows.map(r=>r.cell).join(','),title,rows,unit,value:rows.length&&rows.every(r=>r.value!==null&&r.unit===unit)?rows.reduce((s,r)=>s+r.value!,0):null,formula:rows.map(r=>r.cell).join(' + '),substitution:rows.map(r=>r.value===null?'미산정':String(r.value)).join(' + ')};}
+123
View File
@@ -0,0 +1,123 @@
# 원본 수량산출서 분석
원본 SHA256: `1c5d935a134523a5dc3a292cfc01bf4190b9bc538313322b33add6926e21c90f`
## 시트 구성
- 표지및목차: 43행 × 46열, 수식 0개, 병합 5개, visible
- 1. 수량집계: 76행 × 31열, 수식 371개, 병합 477개, visible
- 3.1 수량산출기준_토공: 84행 × 59열, 수식 0개, 병합 102개, visible
- 3.1 수량산출기준_구조체 현황: 160행 × 61열, 수식 0개, 병합 30개, visible
- 3.1 수량산출기준_구조체: 160행 × 58열, 수식 0개, 병합 30개, visible
- 3.2 관련변수: 66행 × 45열, 수식 0개, 병합 251개, visible
- 3.3 수량산출_ex): 79행 × 60열, 수식 117개, 병합 276개, visible
- 3.3 수량산출_P1: 79행 × 60열, 수식 121개, 병합 276개, visible
- 3.3 수량산출_P2: 79행 × 60열, 수식 121개, 병합 276개, visible
- 3.3 수량산출_P3: 79행 × 60열, 수식 121개, 병합 276개, visible
- 3.3 수량산출_P4: 79행 × 60열, 수식 121개, 병합 276개, visible
- 3.3 수량산출_P5: 79행 × 60열, 수식 121개, 병합 276개, visible
- 3.3 수량산출_P6: 79행 × 60열, 수식 121개, 병합 276개, visible
## 최소 입력과 원본 수식 매핑
|변수|기호|단위|입력셀(P1)|
|---|---|---|---|
|받침 EL|Zb|m|3.2 관련변수!N18|
|지반고 EL|Zs|m|3.2 관련변수!N19|
|지하수위 WL|Zw|m|3.2 관련변수!N20|
|토사 깊이|H1|m|3.2 관련변수!N21|
|풍화암 깊이|H2|m|3.2 관련변수!N22|
|연암 깊이|H3|m|3.2 관련변수!N23|
|두부보 길이|Lc|m|3.2 관련변수!N24|
|두부보 폭|Bc|m|3.2 관련변수!N25|
|두부보 하부높이|H1|m|3.2 관련변수!N26|
|두부보 상부높이|H2|m|3.2 관련변수!N27|
|횡경사(%)|s|%|3.2 관련변수!N28|
|두부보 돌출부 폭|a|m|3.2 관련변수!N29|
|두부보 변화부 곡률|R|m|3.2 관련변수!N30|
|기둥 직경|Dc|m|3.2 관련변수!N31|
|저판 저면 EL|Zf|m|3.2 관련변수!N32|
|저판 길이|Lf|m|3.2 관련변수!N33|
|저판 폭|Bf|m|3.2 관련변수!N34|
|저판 높이|Hf|m|3.2 관련변수!N35|
|말뚝 직경|Dp|m|3.2 관련변수!N36|
|말뚝 두께|Tp|m|3.2 관련변수!N37|
|말뚝 길이|Lp|m|3.2 관련변수!N38|
|말뚝 수|Np|개|3.2 관련변수!N39|
|받침 길이|Lb|m|3.2 관련변수!N40|
|받침 폭|Bb|m|3.2 관련변수!N41|
|받침 수|Nb|개|3.2 관련변수!N42|
|받침 블럭 길이|Lbc|m|3.2 관련변수!N43|
|받침 블럭 폭|Bbc|m|3.2 관련변수!N44|
|받침 블럭 높이|Hbc|m|3.2 관련변수!N45|
|솔플레이트 두께|Ts|m|3.2 관련변수!N46|
## 수량항목
|항목|필요 입력(직접 참조)|실제 수식/고정값|원본 위치|
|---|---|---|---|
|구조물터파기 / 육상토사 / 0-2m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK6/2`|3.3 수량산출_P1!W6|
|구조물터파기 / 육상토사 / 2-4m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK7/2`|3.3 수량산출_P1!W7|
|구조물터파기 / 육상토사 / 4-6m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK8/2`|3.3 수량산출_P1!W8|
|구조물터파기 / 육상토사 / 6-8m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK9/2`|3.3 수량산출_P1!W9|
|구조물터파기 / 용수토사 / 0-2m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK10/2`|3.3 수량산출_P1!W10|
|구조물터파기 / 용수토사 / 2-4m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK11/2`|3.3 수량산출_P1!W11|
|구조물터파기 / 용수토사 / 4-6m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK12/2`|3.3 수량산출_P1!W12|
|구조물터파기 / 용수토사 / 6-8m||`=($AO$6*$AO$10+$AO$7*$AO$11)*AK13/2`|3.3 수량산출_P1!W13|
|구조물터파기 / 육상풍화암 / 0-2m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK14/2`|3.3 수량산출_P1!W14|
|구조물터파기 / 육상풍화암 / 2-4m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK15/2`|3.3 수량산출_P1!W15|
|구조물터파기 / 육상풍화암 / 4-6m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK16/2`|3.3 수량산출_P1!W16|
|구조물터파기 / 육상풍화암 / 6-8m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK17/2`|3.3 수량산출_P1!W17|
|구조물터파기 / 용수풍화암 / 0-2m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK18/2`|3.3 수량산출_P1!W18|
|구조물터파기 / 용수풍화암 / 2-4m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK19/2`|3.3 수량산출_P1!W19|
|구조물터파기 / 용수풍화암 / 4-6m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK20/2`|3.3 수량산출_P1!W20|
|구조물터파기 / 용수풍화암 / 6-8m||`=($AO$7*$AO$11+$AO$8*$AO$12)*AK21/2`|3.3 수량산출_P1!W21|
|구조물터파기 / 육상연암 / 0-2m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK22/2`|3.3 수량산출_P1!W22|
|구조물터파기 / 육상연암 / 2-4m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK23/2`|3.3 수량산출_P1!W23|
|구조물터파기 / 육상연암 / 4-6m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK24/2`|3.3 수량산출_P1!W24|
|구조물터파기 / 육상연암 / 6-8m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK25/2`|3.3 수량산출_P1!W25|
|구조물터파기 / 용수연암 / 0-2m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK26/2`|3.3 수량산출_P1!W26|
|구조물터파기 / 용수연암 / 2-4m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK27/2`|3.3 수량산출_P1!W27|
|구조물터파기 / 용수연암 / 4-6m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK28/2`|3.3 수량산출_P1!W28|
|구조물터파기 / 용수연암 / 6-8m||`=($AO$8*$AO$12+$AO$9*$AO$13)*AK29/2`|3.3 수량산출_P1!W29|
|잔토||`=AO21+AO22+AO23`|3.3 수량산출_P1!W30|
|되메우기 / 토사, 다짐 / 토사, 다짐||`=AO17-(AO21+AO22+AO23)`|3.3 수량산출_P1!W31|
|거푸집 / 강재 거푸집 / 코핑부|BG12, BG13, BG14, BG15, BG16, BG17, BG18, BG19|`=(((BG19+0.2)+(BG12+2*BG17))*BG14/2-(BG18^2/2)*(2*ASIN(SQRT((((BG12+2*BG17)-(BG19+0.2))^2/4+BG14^2)/(4*BG18^2)))-SIN(2*ASIN(SQRT((((BG12+2*BG17)-(BG19+0.2))^2/4+BG14^2)/(4*BG18^2)))))+((BG12+(BG12+2*BG17))*BG15/2)+((BG12*((0.01*BG16*BG12)+BG17)/(BG15+0.01*BG16*BG12))*(0.01*BG16*BG12)/2)+(2*BG13*BG18*(2*ASIN(SQRT((((BG12+2*BG17)-(BG19+0.2))^2/4+BG14^2)/(4*BG18^2)))))+(BG13*SQRT(BG17^2+BG15^2))+(BG13*SQRT(BG17^2+(BG15+0.01*BG16*BG12)^2)))`|3.3 수량산출_P1!W50|
|거푸집 / 강재 거푸집 / 기둥부|BG14, BG15, BG19, BG20, BG6, BG7|`=PI()*BG19*(BG6-BG14-BG15-BG7+BG20)`|3.3 수량산출_P1!W51|
|거푸집 / 합판3회 / 받침블럭|BG30, BG31, BG32, BG33|`=(2*BG31*BG33+2*BG32*BG33)*BG30`|3.3 수량산출_P1!W52|
|거푸집 / 합판6회 / 버림콘크리트|BG21, BG22|`=2*(BG21+0.2)*0.1+2*(BG22+0.2)*0.1`|3.3 수량산출_P1!W53|
|거푸집 / 유로폼 (복잡) / 0~7m|BG21, BG22, BG23|`=2*BG21*BG23+2*BG22*BG23`|3.3 수량산출_P1!W54|
|거푸집 / 유로폼 (복잡) / 7~10m||`0`|3.3 수량산출_P1!W55|
|거푸집 / 유로폼 (복잡) / 10~13m||`0`|3.3 수량산출_P1!W56|
|콘크리트 / 저판 (25-27-15) / 저판 (25-27-15)|BG21, BG22, BG23|`=BG21*BG22*BG23`|3.3 수량산출_P1!W57|
|콘크리트 / 본체 (25-40-15) / 본체 (25-40-15)|BG12, BG13, BG14, BG15, BG16, BG17, BG18, BG19, BG20, BG30, BG31, BG32, BG33, BG6, BG7|`=((((BG19+0.2)+(BG12+2*BG17))*BG14/2-(BG18^2/2)*(2*ASIN(SQRT((((BG12+2*BG17)-(BG19+0.2))^2/4+BG14^2)/(4*BG18^2)))-SIN(2*ASIN(SQRT((((BG12+2*BG17)-(BG19+0.2))^2/4+BG14^2)/(4*BG18^2)))))+((BG12+(BG12+2*BG17))*BG15/2)+((BG12*((0.01*BG16*BG12)+BG17)/(BG15+0.01*BG16*BG12))*(0.01*BG16*BG12)/2))*BG13+PI()*BG19^2/4*(BG6-BG14-BG15-BG7+BG20)+BG31*BG32*BG33*BG30)`|3.3 수량산출_P1!W58|
|콘크리트 / 버림콘크리트(40-15-8) / 버림콘크리트(40-15-8)|BG21, BG22|`=(BG21+0.2)*(BG22+0.2)*0.1`|3.3 수량산출_P1!W59|
|콘크리트 타설 / 펌프카타설 / 0~15(육상)||`=W57+W58+W59`|3.3 수량산출_P1!W60|
|가설계단 / 워킹타워 / 워킹타워|BG6, BG7|`=BG6-BG7`|3.3 수량산출_P1!W61|
|교량받침(2800kN) / 양방향 H=166 / 양방향 H=166|BG30|`=BG30`|3.3 수량산출_P1!W62|
|무수축몰탈 / T=300mm / T=300mm|BG28, BG29, BG30|`=(((BG28+0.2)*(BG29+0.2)+(BG28+0.1)*(BG29+0.1))*0.05/2)*BG30`|3.3 수량산출_P1!W63|
|솔플레이트|BG28, BG29, BG30, BG34|`=(BG28+0.1)*(BG29+0.1)*BG34*BG30*7.85`|3.3 수량산출_P1!W64|
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|BG21, BG22, BG23|`=2*BG21*BG23+2*BG22*BG23`|3.3 수량산출_P1!W65|
|스페이서 / 벽체용 / 벽체용||`=W50+W51`|3.3 수량산출_P1!W66|
|철근가공조립 / 매우복잡 / 매우복잡||`19.316`|3.3 수량산출_P1!W67|
|복합말뚝 / E.O.I.D / E.O.I.D||`=K68`|3.3 수량산출_P1!W68|
|복합말뚝 / Restrike / Restrike||`=K69`|3.3 수량산출_P1!W69|
|복합말뚝 / 자재비(직항) / 자재비(직항)|BG27|`=BG27`|3.3 수량산출_P1!W70|
|복합말뚝 / 천공비 / 천공비|BG26, BG27|`=BG26*BG27`|3.3 수량산출_P1!W71|
|복합말뚝 / 주면고정액 / 주면고정액|BG24, BG26, BG27|`=PI()/4*(((BG24+0.1)^2)-BG24^2)*BG26*BG27`|3.3 수량산출_P1!W72|
|복합말뚝 / 두부보강 / 두부보강|BG27|`=BG27`|3.3 수량산출_P1!W73|
|점검시설||`=K74`|3.3 수량산출_P1!W74|
|콘크리트 양생|BG12, BG13, BG21, BG22|`=BG21*BG22+BG12*BG13`|3.3 수량산출_P1!W75|
|비파괴검사 / M.T / M.T|BG24, BG27|`=BG27/10*3*PI()*BG24`|3.3 수량산출_P1!W76|
## 원본의 중요한 제약
- 설명식과 실제 수식이 불일치하는 부분을 수정하지 않는다. W51은 표시 Hcol 설명과 달리 Zb-H1-H2-Zs+Zf를 사용한다. 원본 P1 값은 236.80068785698427㎡이다. 기둥 실제 높이와 일치한다고 간주하지 않는다.
- AO8은 풍화암 BG10을 사용하고 AO12는 연암 BG11을 사용한다. 그대로 재현한다.
- W50 설명에는 2(Ac1+Ac2+Ac3)이 있으나 실제 수식은 해당 부분에 계수 2가 없다. W58과 공유되는 곡률 ASIN 식을 그대로 보존한다.
- W63의 규격 표기는 T=300mm이지만 수식은 0.05m를 사용한다. 규격 텍스트와 계산 기준을 검토해야 한다.
- W67은 수식이 아닌 철근량 직접값, K68/K69/K74는 시험/시설 횟수 직접값이다. 자동 수량으로 가장하지 않는다.
- 실제 수식에 ROUND 계열은 없다. 원값으로 계산 후 원본 표시형식(상세 주로 소수3자리, 집계 주로2자리)만 적용한다.
- 13시트 모두 visible, 데이터 유효성 규칙 없음, 외부통합문서 링크와 매크로 없음. 이름 정의 3개는 원본 보존하며 계산 수식에서 사용되지 않는다.
- 토공 심도는 0~8m 구간만 있다. 8m 초과는 원본 적용 범위 밖이다.
- 단일 원형기둥·단일 직사각형기초·균일 말뚝의 곡률 두부보 산식이다. 다기둥/꺾인/비대칭 구조의 수량을 임의 배수로 확장하지 않는다.
+43
View File
@@ -0,0 +1,43 @@
# 교각 수량산출 대시보드 개선
## 변경 파일과 역할
- `workspace.ts`: 기존 연결 API를 유지한 화면 조립, 교각 선택, 저장, 재계산, Excel 출력, 전체 미리보기 전환.
- `workspace.css`: 화면 범위를 제한한 3열 배치, 상태/태그/표/반응형 스타일.
- `dashboard-types.ts`: 화면 선택 상태와 실제 원본 행 기반 표시 자료.
- `input-panel.ts`: 입력 그룹 아코디언, 필수 진행률, 누락 항목 이동, 자동/직접/Excel 기본값 구분.
- `results-panel.ts`: 요약 카드, 부재/공종/자재 보기, 검색, 접기, 선택 행과 상태 표시.
- `review-panel.ts`: 미니 문서 뷰어, 전체화면, 선택 항목 산식·입력값·대입식·결과.
- `excel-preview.ts`: 기존 원본 미리보기의 축소 모드, 페이지 표시, 선택 셀 강조와 이동.
## 사용 흐름
교각 선택 → 입력 그룹 확인 및 누락 보완 → 원본 기준 검토 → 재계산 → 중앙 행 선택 → 우측 원본 셀/산식 확인 → Excel 출력.
자동값은 읽기 전용이며 직접값은 교각별 기존 저장 필드를 사용한다. Excel에서 가져온 시험 횟수·규격은 별도 출처 태그로 구분한다. 진행률의 분모는 실제 계산에 필요한 항목을 기준으로 하며 시안의 예시 숫자를 고정하지 않는다.
요약은 원본 W60 콘크리트, W67 철근, W50~W56 거푸집, W6~W29 토공을 사용한다. 집계는 같은 단위의 원본 항목만 합산하며 콘크리트와 타설 작업을 중복 합산하지 않는다. 미산정 결과는 0으로 대체하지 않는다.
## 기존 기능 유지
이번 UI 변경에서 계산 엔진, 모델 매핑, 원본 분석, Excel 출력 서버/패키지 보존 로직은 변경하지 않았다. 기존 재료표 메뉴, 교각별 직접값 저장, 형상 변경 재계산 상태, 출력 완료 조건을 유지한다. 선택 행은 실제 원본 셀로 연결되며, 원본 산식과 실제 적용식이 다르면 함께 확인할 수 있다.
## 검증
- TypeScript 검사 통과.
- 수량산출 단위 테스트 10개 통과.
- Vite 빌드 통과. 기존 단일 번들 크기 경고는 남아 있다.
- 실제 운곡교 샘플: 1920×1080 / 1366×768에서 세 패널이 화면 안에 배치되는지 확인.
- 누락 필드 이동, 입력 저장과 교각 전환, 재계산 변경 강조, 53개 원본 행, 검색, 공종/자재 보기 확인.
- 행 선택에 따른 산식 상세와 원본 셀 강조 연동, 13시트 전체 미리보기, 전체화면, Excel 다운로드 확인.
- 기존 재료표 화면 정상 표시, 브라우저 pageerror 0건.
- 근거: `dashboard-verification.json`, 검증 출력: `../outputs/verification/운곡교P1_대시보드검증.xlsx`.
검증 출력의 누락 현장 조건은 원본 예시값으로 입력한 테스트 조건이며 현장 확정 수량이 아니다. 이번 검증은 Microsoft Excel 앱에서 직접 열기/재계산까지 확인한 결과는 아니다.
## 시안과 차이 및 기존 한계
- 시안의 6페이지 예시 대신 실제 원본 13시트를 표시한다. 원본 표지·양식의 색상과 배치는 유지한다.
- 원본에 없는 입력변수나 공제조건은 새 계산 조건으로 만들지 않았다. 원본 고정 기준은 검토 영역에서 확인한다.
- 작은 미리보기는 원본 전체 폭을 축소하므로 정밀 검토에는 확대 또는 전체화면을 사용한다.
- 지원 형상 및 원본 수식 불일치에 대한 기존 검토 제한은 유지한다. 모든 교각 형식의 설계 검증이 완료되었다는 의미는 아니다.
@@ -0,0 +1,44 @@
# 교각 수량산출 구현 결과
## 샘플 분석과 입력
원본은 13시트, 관련변수29개, 상세 P1 수식121개다. 숨김 시트/외부통합문서 연결/매크로/유효성 규칙은 없고, 이름 정의3개는 원본 그대로 보존한다. 상세 항목53개와 공제·중간식·집계를 그대로 재현했다. `ANALYSIS.md`에 변수/원식/참조 위치를, `MAPPING.md`에 항목별 자동·수동 입력 출처를 정리했다.
최소 입력은 모델에 없는 값만 필요하다. 모델 기본정보, 코핑/기둥/기초/말뚝/받침의 제원과 수는 자동 연결하며 기존 철근재료표에서 미산정이 없으면 할증 전 철근량도 재사용한다. 지하수위·받침 제원 등은 입력에 존재하면 자동 연결한다. 없는 토층 깊이/곡률R/횡경사s/돌출폭a/시험·시설 횟수/확정 규격은 직접 입력한다. Tp는 원본에 있지만 계산에 사용되지 않는 참고변수다. 운곡교 P1에서는 자동20개와 직접18개였다.
## 데이터 흐름과 구현
기존 모델 참조 → 원본 변수 매핑 → 부족한 입력 저장 → 원식 파서 → 항목별 계산·참조 추적 → 원본 보존 Excel 출력이다. 3D 체적만으로 수량을 대체하지 않았다. 결과는 계산 중 반올림하지 않고 원본 표시형식만 적용한다.
5. 재료표에 재료표/교각 수량산출/미리보기 메뉴를 추가했다. 입력·결과·원식 추적, 재계산 상태, Excel 출력이 구현됐다. 직접값은 교각별 `input.quantityTakeoff`에 넣어 기존 프로젝트 저장/복원/백업을 사용한다. 형상 갱신 시 수동값은 유지한다. 기존 재료표와 코어 계산은 변경하지 않았다.
## Excel 출력
원본 ZIP 패키지를 복사하고 입력셀/제목/수식 캐시만 수정한다. 활성 P1슬롯의 수식121개는 유지한다. 선택한 실제 교각명으로 바꾸고 비선택 P2~P6는 미사용 빈 페이지로 유지해 예시 수량이 집계에 섞이지 않게 한다. 원본 `_ex)`는 참고 예시로 남긴다. 출력 시 원본의 13시트/병합/열너비/인쇄 설정을 유지하고 Excel 전체 재계산 설정을 켠다.
실제 모델 연동 검증본은 `outputs/verification/운곡교P1_모델연동.xlsx`, 원본 입력 일치 검증본은 `원본P1_수식검증.xlsx`다. 직접값에는 검증용 원본 예시 조건을 적용했으므로 현장 조건이 확정된 설계 결과로 간주하지 않는다.
## 검증
- 원본 계산값 1,214개: 차이0 (상대/절대 허용오차1e-8).
- 출력 Excel을 Artifact Tool에서 재계산: 수식셀609개, 차이0.
- 활성 수식121개, 13시트 유지. WMF/도형/스타일/테마/인쇄기 설정 등57개 패키지 부품 바이트 동일.
- 병합/열너비/인쇄 설정 동일, 출력 캐시 Excel 오류0, 비선택 교각 수량 빈 상태 확인.
- 브라우저 실제 샘플 로드/입력/교각 전환/입력 유지/산출/셀 추적/실제 Excel 다운로드/기존 재료표 복귀 확인. 페이지 오류0.
- 타입 검사, 프로덕션 빌드, 수량산출 전용6개 테스트 통과. 기존 번들 크기 경고는 남아 있다.
- 전체552개 중548개 통과. 기존 실패4개는 axis, bearing-coordinate-frame, integrated-block, validate-and-foundation이며 코어를 수정하지 않았다.
- 실제 Microsoft Excel COM 재계산은 호출 거부/대기시간 초과로 완료하지 못했다. 테스트에서 만든 숨김 Excel만 종료했고 기존 사용자 Excel과 원본은 건드리지 않았다. 실제 Excel 재계산 성공으로 보고하지 않는다.
## 원본 불일치 및 남은 범위
W51의 Hcol 설명/실제 식, W50 계수, W63 표시두께/계산두께는 원본부터 서로 다르다. 프로그램은 실제 셀 수식을 임의 수정하지 않고 적용 확인을 요구한다. 현재 직선/단차 코핑과 원본 곡률 코핑식의 차이, 고정 버림/몰탈/여유 조건도 검토해야 한다.
다기둥/꺾인 교각/일체 교좌면/PHC 시공/비균일 말뚝/8m초과 토공은 원본 규칙이 없어 완료 출력을 제한한다. 해당 형식을 임의 배수로 확장하지 않는다. 총괄 수량은 행·단위별 합산 엔진만 준비했고 별도 UI/Excel은 후속 범위다. 미리보기는 원본 병합/서식/행·열 크기의 셀 양식이며 집계/변수/상세 시트를 전환할 수 있다. 원본 인쇄영역 밖 도형은 Excel에서 확인한다.
## 수정 파일
신규 기능은 수량산출 폴더의 `template-data.ts`, `formula.ts`, `schema.ts`, `model.ts`, `workspace.ts/css`, `export.ts`, `server.ts`, 도구·테스트로 분리했다. 외부 변경은 `src/main.ts`의 기존 상태/재료표 결과 연결과 `vite.config.ts`의 로컬 API 등록만 수행했다. 원본 수량산출서는 보존했다.
미리보기 후속 개선: 표지를 기본 시작으로 원본13시트 전체 보기, 개별 선택, 이전/다음 이동, 원본 산출기준 설명 그림 표시를 구현·브라우저 검증했다. 표지의 실제 프로젝트/교량/교각명을 확인했고 P2~P6는 미사용으로 표시한다.
최신 입력 정책: 사용자 지정으로 시험·시설·재료 규격은 원본 기본값 적용. s/a는 모델에서 판단하며 직선 R은 해당 없음 처리. 원본 곡률식 W50/W58의 수학적 직선 극한을 적용하므로 현재 출력은119개 원식 유지+2개 직선식이며 원본 파일은 그대로다. 모델 제원 없는 받침 A/B/T는 추정하지 않는다. 원본 값 비교 검증과 별도로 직선 극한 검증, 기본값 적용·사용자값 보존·제원 있는 모델의 직접입력4개 검증을 추가했다.
+91
View File
@@ -0,0 +1,91 @@
# 변수 자동/수동 매핑 (운곡교 P1 현재 모델 기준)
|변수|기호|단위|원본 입력셀|자동 추출 가능 여부|출처|
|---|---|---|---|---|---|
|받침 EL|Zb|m|3.2 관련변수!N18|자동|model.copingRefTop (원본 Zb 의미 검토)|
|지반고 EL|Zs|m|3.2 관련변수!N19|자동|input.groundEl|
|지하수위 WL|Zw|m|3.2 관련변수!N20|직접 입력/검토|input.groundWaterEl / 지하수 미지정은 직접 확인|
|토사 깊이|H1|m|3.2 관련변수!N21|직접 입력/검토|3.2 관련변수!N21 · 모델에 없는 조건|
|풍화암 깊이|H2|m|3.2 관련변수!N22|직접 입력/검토|3.2 관련변수!N22 · 모델에 없는 조건|
|연암 깊이|H3|m|3.2 관련변수!N23|직접 입력/검토|3.2 관련변수!N23 · 모델에 없는 조건|
|두부보 길이|Lc|m|3.2 관련변수!N24|자동|model.copingL|
|두부보 폭|Bc|m|3.2 관련변수!N25|자동|model.copingB|
|두부보 하부높이|H1|m|3.2 관련변수!N26|자동|model.h2 · 하부 테이퍼 높이|
|두부보 상부높이|H2|m|3.2 관련변수!N27|자동|model.h1 · 상부 높이|
|횡경사(%)|s|%|3.2 관련변수!N28|자동|모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사|
|두부보 돌출부 폭|a|m|3.2 관련변수!N29|자동|현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음|
|두부보 변화부 곡률|R|m|3.2 관련변수!N30|자동|직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용|
|기둥 직경|Dc|m|3.2 관련변수!N31|자동|model.columns[].diameter|
|저판 저면 EL|Zf|m|3.2 관련변수!N32|자동|model.footings[0].bottom|
|저판 길이|Lf|m|3.2 관련변수!N33|자동|model.footings[0].length|
|저판 폭|Bf|m|3.2 관련변수!N34|자동|model.footings[0].width|
|저판 높이|Hf|m|3.2 관련변수!N35|자동|기초 상면-하면|
|말뚝 직경|Dp|m|3.2 관련변수!N36|자동|model.piles[].diameter|
|말뚝 두께|Tp|m|3.2 관련변수!N37|자동|입력 말뚝두께 mm→m|
|말뚝 길이|Lp|m|3.2 관련변수!N38|자동|말뚝 상면-하면|
|말뚝 수|Np|개|3.2 관련변수!N39|자동|model.piles.length|
|받침 길이|Lb|m|3.2 관련변수!N40|직접 입력/검토|받침 plan.A mm→m|
|받침 폭|Bb|m|3.2 관련변수!N41|직접 입력/검토|받침 plan.B mm→m|
|받침 수|Nb|개|3.2 관련변수!N42|자동|model.blocks.length|
|받침 블럭 길이|Lbc|m|3.2 관련변수!N43|자동|모델 분리블럭 크기|
|받침 블럭 폭|Bbc|m|3.2 관련변수!N44|자동|모델 분리블럭 크기|
|받침 블럭 높이|Hbc|m|3.2 관련변수!N45|자동|동일 평면 블럭 높이 합/Nb (원식의 등가 높이)|
|솔플레이트 두께|Ts|m|3.2 관련변수!N46|직접 입력/검토|실제 솔플레이트 plan.T 또는 입력 공통 T mm→m|
## 수량 항목별 필요한 변수와 추적 위치
|수량 항목|필요 입력변수|계산식 참조 위치|자동/수동|
|---|---|---|---|
|구조물터파기 / 육상토사 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W6 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상토사 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W7 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상토사 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W8 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상토사 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W9 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수토사 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W10 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수토사 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W11 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수토사 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W12 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수토사 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W13 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상풍화암 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W14 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상풍화암 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W15 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상풍화암 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W16 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상풍화암 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W17 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수풍화암 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W18 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수풍화암 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W19 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수풍화암 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W20 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수풍화암 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W21 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상연암 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W22 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상연암 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W23 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상연암 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W24 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 육상연암 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W25 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수연암 / 0-2m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W26 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수연암 / 2-4m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W27 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수연암 / 4-6m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W28 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|구조물터파기 / 용수연암 / 6-8m|지반고 EL(Zs), 지하수위 WL(Zw), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W29 · 원식은 ANALYSIS.md|자동 3 / 직접 4|
|잔토|지반고 EL(Zs), 기둥 직경(Dc), 저판 저면 EL(Zf), 저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W30 · 원식은 ANALYSIS.md|자동 6 / 직접 0|
|되메우기 / 토사, 다짐 / 토사, 다짐|지반고 EL(Zs), 토사 깊이(H1), 풍화암 깊이(H2), 연암 깊이(H3), 기둥 직경(Dc), 저판 저면 EL(Zf), 저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W31 · 원식은 ANALYSIS.md|자동 6 / 직접 3|
|거푸집 / 강재 거푸집 / 코핑부|두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc)|3.3 수량산출_P1!W50 · 원식은 ANALYSIS.md|자동 7 / 직접 0|
|거푸집 / 강재 거푸집 / 기둥부|받침 EL(Zb), 지반고 EL(Zs), 두부보 하부높이(H1), 두부보 상부높이(H2), 기둥 직경(Dc), 저판 저면 EL(Zf)|3.3 수량산출_P1!W51 · 원식은 ANALYSIS.md|자동 6 / 직접 0|
|거푸집 / 합판3회 / 받침블럭|받침 수(Nb), 받침 블럭 길이(Lbc), 받침 블럭 폭(Bbc), 받침 블럭 높이(Hbc)|3.3 수량산출_P1!W52 · 원식은 ANALYSIS.md|자동 4 / 직접 0|
|거푸집 / 합판6회 / 버림콘크리트|저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W53 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|거푸집 / 유로폼 (복잡) / 0~7m|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W54 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|거푸집 / 유로폼 (복잡) / 7~10m||3.3 수량산출_P1!W55 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|거푸집 / 유로폼 (복잡) / 10~13m||3.3 수량산출_P1!W56 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|콘크리트 / 저판 (25-27-15) / 저판 (25-27-15)|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W57 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|콘크리트 / 본체 (25-40-15) / 본체 (25-40-15)|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf), 받침 수(Nb), 받침 블럭 길이(Lbc), 받침 블럭 폭(Bbc), 받침 블럭 높이(Hbc)|3.3 수량산출_P1!W58 · 원식은 ANALYSIS.md|자동 14 / 직접 0|
|콘크리트 / 버림콘크리트(40-15-8) / 버림콘크리트(40-15-8)|저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W59 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|콘크리트 타설 / 펌프카타설 / 0~15(육상)|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf), 저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf), 받침 수(Nb), 받침 블럭 길이(Lbc), 받침 블럭 폭(Bbc), 받침 블럭 높이(Hbc)|3.3 수량산출_P1!W60 · 원식은 ANALYSIS.md|자동 17 / 직접 0|
|가설계단 / 워킹타워 / 워킹타워|받침 EL(Zb), 지반고 EL(Zs)|3.3 수량산출_P1!W61 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|교량받침(2800kN) / 양방향 H=166 / 양방향 H=166|받침 수(Nb)|3.3 수량산출_P1!W62 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|무수축몰탈 / T=300mm / T=300mm|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb)|3.3 수량산출_P1!W63 · 원식은 ANALYSIS.md|자동 1 / 직접 2|
|솔플레이트|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb), 솔플레이트 두께(Ts)|3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 1 / 직접 3|
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W65 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|스페이서 / 벽체용 / 벽체용|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf)|3.3 수량산출_P1!W66 · 원식은 ANALYSIS.md|자동 10 / 직접 0|
|철근가공조립 / 매우복잡 / 매우복잡||3.3 수량산출_P1!W67 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|복합말뚝 / E.O.I.D / E.O.I.D||3.3 수량산출_P1!W68 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|복합말뚝 / Restrike / Restrike||3.3 수량산출_P1!W69 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|복합말뚝 / 자재비(직항) / 자재비(직항)|말뚝 수(Np)|3.3 수량산출_P1!W70 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|복합말뚝 / 천공비 / 천공비|말뚝 길이(Lp), 말뚝 수(Np)|3.3 수량산출_P1!W71 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|복합말뚝 / 주면고정액 / 주면고정액|말뚝 직경(Dp), 말뚝 길이(Lp), 말뚝 수(Np)|3.3 수량산출_P1!W72 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|복합말뚝 / 두부보강 / 두부보강|말뚝 수(Np)|3.3 수량산출_P1!W73 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|점검시설||3.3 수량산출_P1!W74 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|콘크리트 양생|두부보 길이(Lc), 두부보 폭(Bc), 저판 길이(Lf), 저판 폭(Bf)|3.3 수량산출_P1!W75 · 원식은 ANALYSIS.md|자동 4 / 직접 0|
|비파괴검사 / M.T / M.T|말뚝 직경(Dp), 말뚝 수(Np)|3.3 수량산출_P1!W76 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
+16
View File
@@ -0,0 +1,16 @@
# PDF 삽도 및 프로젝트 PLAN 연결
- 원본 `교각수량산출.pdf` 23페이지 중 3.1에 해당하는 4~8페이지를 Poppler 180dpi PNG로 그대로 렌더했다. 토공 4~5, 구조체 현황 6, 구조체 7~8페이지를 해당 시트 미리보기에 배치한다. 행·열 머리글이 없는 원본 인쇄 형태이며 그림·설명·원본 수식을 그대로 표시한다.
- 3.2의 고정 샘플 교량 삽도는 더 이상 사용하지 않는다. 도면작성의 `keyplanEntities`, `drawingSvg`를 가져와 현재 생성된 프로젝트 모델의 교각 배치와 번호, 북쪽 방향을 표시한다. 연결선은 실제 교각 중심을 잇는 선이며 도로 선형을 임의로 만드는 것은 아니다.
- 새 `plan-preview.ts`가 미리보기와 Excel 출력에 같은 PLAN을 제공한다. 교각 선택/형상 갱신 시 현재 모델을 사용한다. 자동값·직접값과 변수표 계산은 유지한다.
- Excel 출력 시 3.2의 화면 안 샘플 삽도에 연결된 이미지 관계만 새 PLAN PNG로 교체한다. 원본 Excel은 덮어쓰지 않고 3.1의 원래 Excel 도형과 수식은 유지한다.
- 변경: `excel-preview.ts/css`, `plan-preview.ts`, `review-panel.ts`, `workspace.ts`, `export.ts`, `tools/preserve_template.py`, `assets/reference-pdf-04~08.png`.
## 검증
- TypeScript 검사, 수량산출 테스트 10개, Vite 빌드 통과.
- 실제 운곡교 샘플에서 PDF 이미지 5개, 고정 샘플 삽도 미사용, 생성 PLAN의 P1~P6 표시 확인.
- 1920×1080, 1366×768, 입력 저장·재계산·산식 연결·기존 재료표·Excel 다운로드 회귀 검증 통과. 브라우저 오류 0건.
- 출력 ZIP 내 `xl/media/quantity-project-plan.png`와 3.2 이미지 관계 정상 확인.
- 원본 Excel SHA256은 `1c5d935a134523a5dc3a292cfc01bf4190b9bc538313322b33add6926e21c90f`로 변경 없음.
- 검증 출력은 `outputs/verification/운곡교P1_PDF삽도_PLAN검증.xlsx`. 입력 현장 조건은 원본 예시를 적용한 검증용이며 확정 설계 수량이 아니다. 실제 Excel 앱 재계산 검증까지 수행했다는 의미는 아니다.
@@ -0,0 +1,5 @@
{
"checked": 584,
"differences": [],
"engine": "Artifact Tool recalculation of generated model workbook"
}
@@ -0,0 +1,9 @@
{
"automaticFields": 23,
"manualFields": 15,
"rows": 53,
"projectSwitchPreservesInput": true,
"excelDownloaded": true,
"legacyMaterials": true,
"errors": []
}
@@ -0,0 +1,19 @@
{
"threeColumns": true,
"viewports": [
1920,
1366
],
"sourceRows": 53,
"missingFieldNavigation": true,
"accordionInputs": true,
"search": true,
"workAndMaterialViews": true,
"selectionPreviewFormulaSynchronized": true,
"inputPersistence": true,
"recalculationHighlights": true,
"all13SheetPreview": true,
"download": true,
"legacyMaterials": true,
"errors": []
}
@@ -0,0 +1,7 @@
{
"compared": 1214,
"differences": 0,
"tolerance": "1e-8 relative / absolute",
"engines": "TypeScript evaluator vs original Excel cached values",
"allSourceFormulasUnmodified": true
}
+11
View File
@@ -0,0 +1,11 @@
{
"total": 552,
"passed": 548,
"failed": 4,
"existing_failed_files": [
"axis.test.ts",
"bearing-coordinate-frame.test.ts",
"integrated-block.test.ts",
"validate-and-foundation.test.ts"
]
}
@@ -0,0 +1,9 @@
{
"status": "not_verified",
"engine": "Microsoft Excel COM",
"attempts": 3,
"reason": "E_ACCESSDENIED in sandbox; isolated retries received RPC_E_CALL_REJECTED/timeouts",
"existing_user_workbooks_modified": false,
"test_created_excel_processes_closed": true,
"alternative_verification": "Artifact Tool recalc 609 formula cells, 0 differences; 1214 source cached results matched"
}
@@ -0,0 +1,30 @@
{
"sheets": 13,
"protected_parts_byte_identical": 57,
"active_pier_formulas_total": 121,
"active_pier_formulas_unchanged": 119,
"straight_limit_formulas": [
"W50",
"W58"
],
"merges_print_settings_column_widths_preserved": true,
"cached_excel_errors": [],
"unused_pier_quantities_blank": true,
"template_sha256": "1c5d935a134523a5dc3a292cfc01bf4190b9bc538313322b33add6926e21c90f",
"model_linked_values": {
"N18": 43.411,
"N19": 28.931,
"N24": 12.5,
"N25": 3.5,
"N31": 2,
"N32": 26.208,
"N33": 10.4,
"N34": 8.15,
"N35": 2,
"N36": 0.6096,
"N38": 24.4,
"N39": 20,
"N42": 10
},
"native_excel_recalculation": "not_verified: Excel COM rejected calls after initialization; artifact recalculation and original cached-result comparison used"
}
@@ -0,0 +1,19 @@
{
"threeColumns": true,
"viewports": [
1920,
1366
],
"sourceRows": 53,
"missingFieldNavigation": true,
"accordionInputs": true,
"search": true,
"workAndMaterialViews": true,
"selectionPreviewFormulaSynchronized": true,
"inputPersistence": true,
"recalculationHighlights": true,
"all13SheetPreview": true,
"download": true,
"legacyMaterials": true,
"errors": []
}
+179
View File
@@ -0,0 +1,179 @@
{
"sha256": "1c5d935a134523a5dc3a292cfc01bf4190b9bc538313322b33add6926e21c90f",
"sheets": [
{
"name": "표지및목차",
"state": "visible",
"rows": 43,
"cols": 46,
"formulas": 0,
"merged": 5,
"print_area": "'표지및목차'!$A$1:$AH$43",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "1. 수량집계",
"state": "visible",
"rows": 76,
"cols": 31,
"formulas": 371,
"merged": 477,
"print_area": "'1. 수량집계'!$A$2:$AE$86",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.1 수량산출기준_토공",
"state": "visible",
"rows": 84,
"cols": 59,
"formulas": 0,
"merged": 102,
"print_area": "'3.1 수량산출기준_토공'!$A$2:$AA$95",
"hidden_rows": [
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84
],
"hidden_columns": []
},
{
"name": "3.1 수량산출기준_구조체 현황",
"state": "visible",
"rows": 160,
"cols": 61,
"formulas": 0,
"merged": 30,
"print_area": "'3.1 수량산출기준_구조체 현황'!$A$2:$AC$51",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.1 수량산출기준_구조체",
"state": "visible",
"rows": 160,
"cols": 58,
"formulas": 0,
"merged": 30,
"print_area": "'3.1 수량산출기준_구조체'!$A$2:$AC$102",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.2 관련변수",
"state": "visible",
"rows": 66,
"cols": 45,
"formulas": 0,
"merged": 251,
"print_area": "'3.2 관련변수'!$A$2:$AA$46",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_ex)",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 117,
"merged": 276,
"print_area": "'3.3 수량산출_ex)'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P1",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P1'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P2",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P2'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P3",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P3'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P4",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P4'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P5",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P5'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
},
{
"name": "3.3 수량산출_P6",
"state": "visible",
"rows": 79,
"cols": 60,
"formulas": 121,
"merged": 276,
"print_area": "'3.3 수량산출_P6'!$A$2:$AE$92",
"hidden_rows": [],
"hidden_columns": []
}
],
"names": {
"asd": "{\"'공사부문'!$A$6:$A$32\"}",
"XXXXXX": "{\"'공사부문'!$A$6:$A$32\"}",
"ㅁㄴㅇ": "{\"'공사부문'!$A$6:$A$32\"}"
},
"external_links": 0,
"macros": false
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
.takeoff-excel-toolbar{display:flex;align-items:center;gap:10px;margin:6px 0 12px;flex-wrap:wrap}.takeoff-excel-toolbar span{font-size:12px;color:#63778b}.takeoff-excel-viewport{overflow:auto;background:#e9edf2;border:1px solid #d4dee8;padding:24px;max-height:70vh}.takeoff-excel-paper{width:max-content;background:#fff;padding:30px 24px;box-shadow:0 3px 18px #18324b1a}.takeoff-excel-sheet{table-layout:fixed;border-collapse:collapse;background:white;color:#111;border:0}.takeoff-excel-sheet td{box-sizing:border-box;padding:0 3px;line-height:1.1;white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal;overflow:hidden;cursor:default}.takeoff-excel-sheet td.takeoff-excel-quantity{cursor:pointer}.takeoff-excel-sheet td.takeoff-excel-quantity:hover,.takeoff-excel-sheet td.takeoff-excel-quantity:focus{outline:2px solid #1684c3;outline-offset:-2px;background:#edf7ff!important}.takeoff-excel-sheet tr{border:0}@media print{.takeoff-excel-toolbar{display:none}.takeoff-excel-viewport{padding:0;border:0;background:white;max-height:none;overflow:visible}.takeoff-excel-paper{zoom:1!important;box-shadow:none;padding:0}}
.takeoff-excel-paper{margin-inline:auto}
.takeoff-excel-page{width:max-content;margin:0 auto 26px;break-after:page}.takeoff-excel-page>h4{margin:0;padding:10px 8px;font-size:12px;font-weight:500;color:#506478;border-bottom:1px solid #dde5ee;background:#f4f7fa}.takeoff-sheet-body{position:relative}.takeoff-reference-page{display:block;height:auto;background:white}.takeoff-variables-diagram{position:absolute;left:0;top:0;object-fit:fill;pointer-events:none}.takeoff-excel-page:last-child{margin-bottom:0}@media print{.takeoff-excel-page>h4{display:none}}
.takeoff-variables-plan{position:absolute;z-index:2;left:0;top:0;box-sizing:border-box;background:white;display:flex;flex-direction:column;justify-content:center;padding:10px 8px;color:#172b42;overflow:hidden}
.takeoff-variables-plan>strong{font-size:15px;margin-bottom:6px}.takeoff-variables-plan>div{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}.takeoff-variables-plan svg{display:block;width:100%;height:100%;max-height:100%}.takeoff-reference-page+.takeoff-reference-page{margin-top:16px;border-top:1px solid #e0e6ed}
+74
View File
@@ -0,0 +1,74 @@
import {sheetLayouts,excelStyles} from './template-layout';
import {template} from './template-data';
import {FormulaBook,cellKey,type Overrides} from './formula';
import {DETAIL,INPUTS,specifications} from './schema';
import type {QuantityInputs,calculate} from './model';
import './excel-preview.css';
import type {PierModel} from '../src/core/geometry';
import {quantityPlanSvg} from './plan-preview';
type Layout={firstRow:number;rows:number;cols:number;widths:number[];heights:number[];merges:number[][];cells:Record<string,number>};
type Style={font:string;size:number;bold:boolean;italic:boolean;color:string;background:string;align:string;valign:string;border:Record<string,{width:number;style:string;color:string}>;wrap:boolean};
const layouts=sheetLayouts as unknown as Record<string,Layout>,styles=excelStyles as unknown as Record<string,Style>;
const referenceImages:Record<string,string[]>={
'3.1 수량산출기준_토공':[new URL('./assets/reference-pdf-04.png',import.meta.url).href,new URL('./assets/reference-pdf-05.png',import.meta.url).href],
'3.1 수량산출기준_구조체 현황':[new URL('./assets/reference-pdf-06.png',import.meta.url).href],
'3.1 수량산출기준_구조체':[new URL('./assets/reference-pdf-07.png',import.meta.url).href,new URL('./assets/reference-pdf-08.png',import.meta.url).href],
};
const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):'';
const node=<K extends keyof HTMLElementTagNameMap>(tag:K,text?:string)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;return n;};
export function createExcelPreview(options:{result:ReturnType<typeof calculate>;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{
const {result,stored,pier,state}=options,host=node('section');host.className='takeoff-excel-preview'+(options.compact?' is-compact':'');const toolbar=node('div');toolbar.className='takeoff-excel-toolbar';
const selector=node('select');selector.setAttribute('aria-label',options.compact?'미니 산출서 시트 선택':'Excel 샘플 시트 선택');
const names=Object.keys(layouts);for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 전체 보기 (13시트)':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector);
const previous=node('button',options.compact?'‹':'이전 시트'),next=node('button',options.compact?'›':'다음 시트');previous.setAttribute('aria-label','이전 시트');next.setAttribute('aria-label','다음 시트');previous.type=next.type='button';const counter=node('output');counter.className='takeoff-excel-page-count';toolbar.append(previous,counter,next);
const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5]){const o=node('option',Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인'));
const viewport=node('div');viewport.className='takeoff-excel-viewport';const paper=node('div');paper.className='takeoff-excel-paper';viewport.append(paper);host.append(toolbar,viewport);
const overrides:Overrides={...result.overrides};
for(let i=2;i<=6;i++){const s='3.3 수량산출_P'+i;for(const [cell,c]of Object.entries(template[s]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))overrides[cellKey(s,cell)]=0;const col=['P','R','T','V','X'][i-2];for(let r=18;r<=46;r++)overrides[cellKey(INPUTS,col+r)]=0;}
for(let r=6;r<=34;r++){const c=template['3.3 수량산출_ex)']['BG'+r];if(c&&typeof c.cached==='number')overrides[cellKey('3.3 수량산출_ex)','BG'+r)]=c.cached;}
const book=new FormulaBook(overrides,template,result.formulas);
const display=(sheet:string,cell:string):unknown=>{
if(sheet==='표지및목차'){if(cell==='A6')return options.project.name;if(cell==='A14')return options.project.bridge+' · '+pier;if(cell==='A24')return new Date().toISOString().slice(0,10);}
if(/^3\.3 수량산출_P[2-6]$/.test(sheet)){if(cell==='F2')return '(미사용)';const raw=template[sheet]?.[cell]?.value;if(typeof raw==='number'||typeof raw==='string'&&raw.startsWith('=')&&raw.length>1)return '';}
if(sheet===DETAIL){if(cell==='F2')return '('+pier+')';const spec=specifications.find(v=>v.cell===cell);if(spec)return stored.values[spec.key]||'규격 확인 필요';const row=result.results.find(r=>r.cell===cell);if(row)return row.value;}
if(sheet===INPUTS){if(cell==='N30'&&result.copingMode==='straight')return '직선';if(cell==='N17')return pier;if(/^[PRTVX](1[89]|[234]\d)$/.test(cell))return '';if(/^[PRTVX]17$/.test(cell))return '미사용';if(/^N\d+$/.test(cell)&&Object.hasOwn(result.overrides,cellKey(INPUTS,cell)))return result.overrides[cellKey(INPUTS,cell)];}
if(sheet==='1. 수량집계'){if(cell==='N4')return pier;if(/^(Q|T|W|Z|AC)4$/.test(cell))return '미사용';}
const c=template[sheet]?.[cell];if(!c)return '';if(typeof c.value==='string'&&c.value.startsWith('=')&&c.value.length>1){try{return book.cell(sheet,cell);}catch{return null;}}return c.value;
};
const formatted=(value:unknown,format:string)=>{if(value===null)return '미산정';if(value===undefined)return '';if(typeof value!=='number')return String(value);if(format==='General')return String(value);
if(value===0&&format.includes('"-"'))return '−';const m=format.split(';')[0].match(/\.([0#]+)/),digits=m?.[1].length??0;
return value.toLocaleString('ko-KR',{minimumFractionDigits:digits,maximumFractionDigits:digits,useGrouping:format.includes(',')});};
const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom);counter.textContent=state.sheet==='__all__'?'전체 / 13':(names.indexOf(state.sheet)+1)+' / '+names.length;
const renderSheet=(sheet:string)=>{
const page=node('article');page.className='takeoff-excel-page';page.dataset.previewSheet=sheet;
const title=sheet===DETAIL?'3.3 수량산출 · '+pier:sheet==='3.3 수량산출_ex)'?sheet+' · 원본 참고 예시':/^3\.3 수량산출_P[2-6]$/.test(sheet)?sheet+' · 미사용':sheet;
page.append(node('h4',title));paper.append(page);
const layout=layouts[sheet],table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px';
if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;}
const cols=node('colgroup');for(const w of layout.widths){const c=node('col');c.style.width=w+'px';cols.append(c);}table.append(cols);
const merged=new Map<string,number[]>(),covered=new Set<string>();for(const [r,c,re,ce]of layout.merges){merged.set(r+','+c,[r,c,re,Math.min(ce,layout.cols)]);for(let y=r;y<=re;y++)for(let x=c;x<=ce;x++)if(y!==r||x!==c)covered.add(y+','+x);}
for(let r=layout.firstRow;r<=layout.rows;r++){const tr=node('tr');tr.style.height=layout.heights[r-1]+'px';for(let c=1;c<=layout.cols;c++){
if(covered.has(r+','+c))continue;const cell=letters(c)+r,td=node('td'),m=merged.get(r+','+c);if(m){td.rowSpan=m[2]-r+1;td.colSpan=m[3]-c+1;}
const style=styles[layout.cells[cell]],value=display(sheet,cell),raw=template[sheet]?.[cell];td.textContent=formatted(value,raw?.format??'General');td.dataset.excelCell=cell;
if(style){td.style.fontFamily=`"${style.font}","Malgun Gothic",sans-serif`;td.style.fontSize=style.size*4/3+'px';td.style.fontWeight=style.bold?'700':'400';td.style.fontStyle=style.italic?'italic':'normal';td.style.color=value===null?'#b45309':style.color;td.style.background=style.background;td.style.textAlign=style.align==='general'?(typeof value==='number'?'right':'left'):style.align;td.style.verticalAlign=style.valign==='center'?'middle':style.valign;
const border=(side:string,s:Style)=>{const b=s.border[side];return b?`${b.width}px ${b.style} ${b.color}`:'none';};
td.style.whiteSpace=style.wrap?'pre-wrap':'pre';td.style.overflowWrap=style.wrap?'anywhere':'normal';
if(!style.wrap&&typeof value==='string'&&value&&!Object.keys(style.border).length){const label=node('span',String(value));label.style.position='relative';label.style.zIndex='1';label.style.display='inline-block';label.style.whiteSpace='pre';td.replaceChildren(label);td.style.overflow='visible';}
td.style.borderTop=border('top',style);td.style.borderLeft=border('left',style);
td.style.borderRight=border('right',m?styles[layout.cells[letters(m[3])+r]]??style:style);td.style.borderBottom=border('bottom',m?styles[layout.cells[letters(c)+m[2]]]??style:style);
}
if(sheet===DETAIL&&result.results.some(x=>x.cell===cell)){td.tabIndex=0;td.classList.add('takeoff-excel-quantity');td.setAttribute('role','button');td.setAttribute('aria-label',cell+' 수량 산식 확인');td.onclick=()=>options.onCell(cell);td.onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();options.onCell(cell);}};}
tr.append(td);
}table.append(tr);}
const body=node('div');body.className='takeoff-sheet-body';body.append(table);page.append(body);
if(sheet===INPUTS){const plan=node('div');plan.className='takeoff-variables-plan';plan.style.width=table.style.width;plan.style.height=layout.heights.slice(layout.firstRow-1,16).reduce((a,b)=>a+b,0)+'px';plan.append(node('strong','3.2 수량 변수 · 프로젝트 KEY PLAN'));if(options.plan){const graphic=node('div');graphic.innerHTML=quantityPlanSvg(options.plan.selected,options.plan.models);plan.append(graphic);}else plan.append(node('p','프로젝트 평면도 연결 정보가 없습니다.'));body.append(plan);}
};
for(const sheet of state.sheet==='__all__'?names:[state.sheet])renderSheet(sheet);
previous.disabled=state.sheet!=='__all__'&&names.indexOf(state.sheet)===0;next.disabled=names.indexOf(state.sheet)===names.length-1;
};
previous.onclick=()=>{selector.value=names[Math.max(0,names.indexOf(state.sheet)-1)];draw();viewport.scrollTo(0,0);};
next.onclick=()=>{selector.value=names[Math.min(names.length-1,names.indexOf(state.sheet)+1)];draw();viewport.scrollTo(0,0);};
host.addEventListener('takeoff:focus-cells',((event:CustomEvent<string[]>)=>{selector.value=DETAIL;draw();const selected=event.detail.map(cell=>paper.querySelector<HTMLElement>(`[data-excel-cell="${cell}"]`)).filter((n):n is HTMLElement=>!!n);for(const cell of selected)cell.classList.add('is-linked');if(selected[0]){const rect=selected[0].getBoundingClientRect(),v=viewport.getBoundingClientRect();viewport.scrollTo({top:rect.top-v.top+viewport.scrollTop-viewport.clientHeight*.3,left:0});}}) as EventListener);
selector.onchange=()=>{draw();viewport.scrollTo(0,0);};zoom.onchange=draw;draw();return host;
}
+37
View File
@@ -0,0 +1,37 @@
import {straightCopingFormulas} from './coping-formulas.ts';
import {FormulaBook,cellKey,type Overrides} from './formula.ts';
import {template,templateHash} from './template-data.ts';
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema.ts';
export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string};
export function exportEdits(request:ExportRequest){
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.');
const edits:Record<string,Record<string,{value?:string|number|null;cached?:number|string;formula?:string}>>={},evaluation:Overrides={...request.overrides};
const value=(sheet:string,cell:string,v:string|number|null)=>{(edits[sheet]??={})[cell]={value:v};evaluation[cellKey(sheet,cell)]=v??0;};
for(let r=18;r<=46;r++){
const raw=request.overrides[cellKey(INPUTS,'N'+r)];if(raw!==null&&(typeof raw!=='number'||!Number.isFinite(raw)))throw new Error('입력값 누락 N'+r);
value(INPUTS,'N'+r,request.copingMode==='straight'&&r===30?null:raw as number|null);
}
for(const v of extraNumbers){const n=request.overrides[cellKey(DETAIL,v.cell)];if(typeof n!=='number'||!Number.isFinite(n)||n<0)throw new Error(v.name+' 입력을 확인하세요.');value(DETAIL,v.cell,n);}
for(const v of specifications){const s=request.specs[v.key];if(typeof s!=='string'||!s.trim())throw new Error(v.name+'을 확인하세요.');value(DETAIL,v.cell,s);}
for(let i=2;i<=6;i++){
const sheet='3.3 수량산출_P'+i;
for(const [cell,c]of Object.entries(template[sheet]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))value(sheet,cell,null);
value(sheet,'F2','(미사용)');value(sheet,'BG5','미사용');
const col=['P','R','T','V','X'][i-2];for(let r=18;r<=46;r++)value(INPUTS,col+r,null);value(INPUTS,col+'17','미사용');
value('1. 수량집계',['Q','T','W','Z','AC'][i-2]+'4','미사용');
}
// The example sheet must stay a source example, not follow the active pier's straight R.
for(let r=6;r<=34;r++){const c=template['3.3 수량산출_ex)']['BG'+r];if(c&&typeof c.cached==='number')value('3.3 수량산출_ex)','BG'+r,c.cached);}
value(INPUTS,'N17',request.pier);value(DETAIL,'F2','('+request.pier+')');value(DETAIL,'BG5',request.pier);value('1. 수량집계','N4',request.pier);
value('표지및목차','A6',request.project.name);value('표지및목차','A14',request.project.bridge+' · '+request.pier);value('표지및목차','A24',new Date().toISOString().slice(0,10));
const formulas=request.copingMode==='straight'?straightCopingFormulas:{};
for(const [key,formula]of Object.entries(formulas)){const pos=key.lastIndexOf('!');(edits[key.slice(0,pos)]??={})[key.slice(pos+1)]={formula};}
const book=new FormulaBook(evaluation,template,formulas);
for(const [sheet,cells]of Object.entries(template))for(const [cell,c]of Object.entries(cells)){
if(typeof c.value!=='string'||(!c.value.startsWith('=')||c.value.length===1)||edits[sheet]?.[cell]&&Object.hasOwn(edits[sheet][cell],'value'))continue;
const n=book.cell(sheet,cell);if(typeof n!=='number'||!Number.isFinite(n))throw new Error('출력 수식 검증 실패 '+sheet+'!'+cell);
(edits[sheet]??={})[cell]={...edits[sheet]?.[cell],cached:n};
}
if(request.planPng&&(!/^[A-Za-z0-9+/=]+$/.test(request.planPng)||request.planPng.length>1500000))throw new Error('평면도 이미지 형식을 확인하세요.');
return {templateHash,edits,planPng:request.planPng};
}
+19
View File
@@ -0,0 +1,19 @@
import {sheetLayouts} from './template-layout';
import {it,expect} from 'vitest';
import {FormulaBook} from './formula';
import {template} from './template-data';
import {writeFileSync} from 'node:fs';
import {resolve} from 'node:path';
it('원본의 모든 수식 결과가 저장된 Excel 계산값과 일치한다',()=>{
const book=new FormulaBook();let count=0;
for(const [sheet,cells]of Object.entries(template))for(const [cell,c]of Object.entries(cells)){
if(typeof c.value!=='string'||!c.value.startsWith('=')||typeof c.cached!=='number')continue;
const actual=Number(book.cell(sheet,cell));expect(Math.abs(actual-c.cached),sheet+'!'+cell).toBeLessThanOrEqual(1e-8*Math.max(1,Math.abs(c.cached)));count++;
}expect(count).toBeGreaterThan(1000);writeFileSync(resolve(import.meta.dirname,'docs/formula-verification.json'),JSON.stringify({compared:count,differences:0,tolerance:'1e-8 relative / absolute',engines:'TypeScript evaluator vs original Excel cached values',allSourceFormulasUnmodified:true},null,2));
});
it('표지부터 원본 13개 시트의 인쇄영역과 병합을 포함한다',()=>{
const names=Object.keys(sheetLayouts);expect(names).toHaveLength(13);expect(names[0]).toBe('표지및목차');expect(names).toEqual(Object.keys(template));
expect(sheetLayouts['표지및목차'].firstRow).toBe(1);expect(sheetLayouts['표지및목차'].cols).toBe(34);
expect(sheetLayouts['3.3 수량산출_P1'].merges).toContainEqual([50,2,56,4]);
});
+44
View File
@@ -0,0 +1,44 @@
import {template,type Cell} from './template-data.ts';
export type Overrides=Record<string,string|number|null>;
export class FormulaError extends Error{}
type Node={kind:'number';n:number}|{kind:'ref';sheet?:string;cell:string}|{kind:'range';a:Node;b:Node}|{kind:'unary';op:string;a:Node}|{kind:'op';op:string;a:Node;b:Node}|{kind:'call';name:string;args:Node[]};
const trees=new Map<string,Node>();
function parse(formula:string):Node{
if(trees.has(formula))return trees.get(formula)!;
const tokens=formula.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+|(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?|[A-Z][A-Z0-9_.]*|[()+\-*/^,:]/g)??[];
if(tokens.join('').replace(/\s+/g,'')!==formula.slice(1).replace(/\s+/g,''))throw new FormulaError('지원하지 않는 수식: '+formula);
let i=0;
const expression=(min=0):Node=>{let a=atom();while(i<tokens.length){const op=tokens[i],p=op==='+'||op==='-'?1:op==='*'||op==='/'?2:op==='^'?3:0;if(p<=min)break;i++;a={kind:'op',op,a,b:expression(op==='^'?p-1:p)};}return a;};
const atom=():Node=>{const t=tokens[i++];if(!t)throw new FormulaError('수식 끝 오류');
if(t==='+'||t==='-')return {kind:'unary',op:t,a:atom()};
if(t==='('){const a=expression();if(tokens[i++]!==')')throw new FormulaError('괄호 오류');return a;}
if(/^\d|^\./.test(t))return {kind:'number',n:Number(t)};
if(t.includes('!')||/^\$?[A-Z]+\$?\d+$/.test(t)){const split=t.lastIndexOf('!');let a:Node={kind:'ref',sheet:split>=0?t.slice(1,split-1).replace(/''/g,"'"):undefined,cell:t.slice(split+1).replace(/\$/g,'')};if(tokens[i]===':'){i++;a={kind:'range',a,b:atom()};}return a;}
if(tokens[i++]!=='(')throw new FormulaError('함수 오류: '+t);const args:Node[]=[];if(tokens[i]!==')'){args.push(expression());while(tokens[i]===','){i++;args.push(expression());}}
if(tokens[i++]!==')')throw new FormulaError('함수 괄호 오류: '+t);return {kind:'call',name:t,args};
};
const result=expression();if(i!==tokens.length)throw new FormulaError('수식 토큰 잔여: '+formula);trees.set(formula,result);return result;
}
const col=(s:string)=>[...s].reduce((n,c)=>n*26+c.charCodeAt(0)-64,0);
const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):'';
export const cellKey=(sheet:string,cell:string)=>sheet+'!'+cell;
export class FormulaBook{
cache=new Map<string,number|string>();active=new Set<string>();
constructor(public overrides:Overrides={},public data:Record<string,Record<string,Cell>>=template,public formulas:Record<string,string>={}){}
cell(sheet:string,cell:string):number|string{const k=cellKey(sheet,cell);if(this.cache.has(k))return this.cache.get(k)!;if(this.active.has(k))throw new FormulaError('순환참조 '+k);this.active.add(k);
try{const raw=Object.hasOwn(this.overrides,k)?this.overrides[k]:this.formulas[k]??this.data[sheet]?.[cell]?.value;if(raw===null)throw new FormulaError('필수입력 누락 '+k);const value=typeof raw==='string'&&raw.startsWith('=')&&raw.length>1?this.evaluate(parse(raw),sheet):raw??0;
if(typeof value==='number'&&!Number.isFinite(value))throw new FormulaError('정의역/0 나누기 '+k);const result=typeof value==='boolean'?Number(value):value as number|string;this.cache.set(k,result);return result;
}finally{this.active.delete(k);}}
private numeric(v:unknown):number{const n=typeof v==='number'?v:Number(v);if(!Number.isFinite(n))throw new FormulaError('숫자 변환 불가 '+v);return n;}
private evaluate(n:Node,sheet:string):number|number[]{
if(n.kind==='number')return n.n;
if(n.kind==='ref')return this.numeric(this.cell(n.sheet??sheet,n.cell));
if(n.kind==='range'){if(n.a.kind!=='ref'||n.b.kind!=='ref')throw new FormulaError('범위 오류');const a=n.a.cell.match(/([A-Z]+)(\d+)/)!,b=n.b.cell.match(/([A-Z]+)(\d+)/)!;const out:number[]=[];for(let r=+a[2];r<=+b[2];r++)for(let c=col(a[1]);c<=col(b[1]);c++)out.push(this.numeric(this.cell(n.a.sheet??sheet,letters(c)+r)));return out;}
if(n.kind==='unary')return (n.op==='-'?-1:1)*this.numeric(this.evaluate(n.a,sheet));
if(n.kind==='op'){const a=this.numeric(this.evaluate(n.a,sheet)),b=this.numeric(this.evaluate(n.b,sheet));return n.op==='+'?a+b:n.op==='-'?a-b:n.op==='*'?a*b:n.op==='/'?a/b:a**b;}
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Record<string,()=>number>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name]();
}
substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});}
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];}
}
+38
View File
@@ -0,0 +1,38 @@
import {extraNumbers,specifications} from './schema';
import {template} from './template-data';
import {cellKey} from './formula';
import {node,type TakeoffResult,type PierContext,type DashboardState} from './dashboard-types';
import type {QuantityInputs} from './model';
type Field={key:string;name:string;unit:string;value:string;kind:'auto'|'manual'|'template';source:string;required:boolean;numeric:boolean;missing:boolean};
export function inputFields(result:TakeoffResult,stored:QuantityInputs,pier:PierContext):Field[]{
const needed=new Set(result.results.flatMap(r=>r.dependencies));
const fields:Field[]=result.mapped.map(v=>{const raw=v.automatic?v.value:stored.values[v.key];return {key:v.key,name:v.name+' ('+v.symbol+')',unit:v.unit,value:v.notApplicable?'직선 · 해당 없음':raw==null?'':String(typeof raw==='number'?Number(raw.toFixed(6)):raw),kind:v.automatic?'auto':'manual',source:v.source,required:needed.has(cellKey('3.2 관련변수','N'+v.key.slice(1))),numeric:!v.automatic,missing:!v.automatic&&(raw==null||raw==='')};});
for(const v of extraNumbers){const auto=v.key==='rebar'&&pier.rebarTon!=null,raw=auto?pier.rebarTon:stored.values[v.key];fields.push({key:v.key,name:v.name,unit:v.unit,value:raw==null?'':String(raw),kind:auto?'auto':v.key==='rebar'?'manual':'template',source:auto?'기존 철근재료표 · 할증 전 / 길이 누락 없음':`원본 ${v.cell}${v.key==='rebar'?' 직접값':' 기본값'}`,numeric:!auto,required:true,missing:raw==null||raw===''});}
for(const v of specifications){const raw=stored.values[v.key];fields.push({key:v.key,name:v.name,unit:'규격',value:String(raw??''),kind:'template',source:`원본 Excel ${v.cell} · ${String(template['3.3 수량산출_P1'][v.cell].value)}`,numeric:false,required:true,missing:raw==null||String(raw).trim()===''});}
return fields;
}
export function createInputPanel(options:{result:TakeoffResult;stored:QuantityInputs;pier:PierContext;project:{name:string;bridge:string};state:DashboardState;onChange:(data:QuantityInputs)=>void}){
const {result,stored,pier,state}=options,fields=inputFields(result,stored,pier),required=fields.filter(f=>f.required),done=required.filter(f=>!f.missing).length;
const panel=node('section',undefined,'tq-panel tq-input-panel takeoff-inputs');panel.append(node('h3','입력 정보'));
const progress=node('div',undefined,'tq-progress');progress.append(node('span','필수 입력 진행률'),node('b',`${done} / ${required.length} (${Math.round(done/Math.max(1,required.length)*100)}%)`));const bar=node('progress');bar.max=required.length;bar.value=done;progress.append(bar);panel.append(progress);
const scroll=node('div',undefined,'tq-input-scroll');panel.append(scroll);scroll.onscroll=()=>state.inputScroll=scroll.scrollTop;
const groups=[{id:'basic',name:'기본정보 / 두부보·기둥',accept:(f:Field)=>['r18','r24','r25','r26','r27','r28','r29','r30','r31'].includes(f.key)},{id:'ground',name:'지반 / 기초 / 말뚝',accept:(f:Field)=>/^r(19|2[0-3]|3[2-9])$/.test(f.key)},{id:'material',name:'받침 / 재료',accept:(f:Field)=>/^r4/.test(f.key)||f.key==='rebar'||f.key.startsWith('spec')},{id:'conditions',name:'공제 / 시공조건',accept:(f:Field)=>['eoid','restrike','inspection'].includes(f.key)}];
const locations=new Map<string,{group:HTMLDetailsElement;input:HTMLElement}>();
const jump=(key:string)=>{const target=locations.get(key);if(!target)return;target.group.open=true;state.openInputs.add(target.group.dataset.group!);requestAnimationFrame(()=>{scroll.scrollTo({top:target.input.getBoundingClientRect().top-scroll.getBoundingClientRect().top+scroll.scrollTop-60,behavior:'smooth'});target.input.focus({preventScroll:true});});};
const missing=required.filter(f=>f.missing);
if(missing.length){const alert=node('details',undefined,'tq-missing-box');alert.append(node('summary',`⚠ 누락 입력 ${missing.length}개`),node('p','필수 항목을 입력하면 관련 수량이 산출됩니다.'));for(const f of missing){const b=node('button',f.name);b.type='button';b.onclick=()=>jump(f.key);alert.append(b);}scroll.append(alert);}
for(const g of groups){const subset=fields.filter(g.accept),req=subset.filter(f=>f.required),filled=req.filter(f=>!f.missing).length,details=node('details',undefined,'tq-input-group');details.dataset.group=g.id;details.open=state.openInputs.has(g.id);const summary=node('summary');summary.append(node('strong',g.name),node('span',`${filled}/${req.length}`,filled===req.length?'tq-count-done':'tq-count-missing'));details.append(summary);details.ontoggle=()=>{if(details.open)state.openInputs.add(g.id);else state.openInputs.delete(g.id);};
if(g.id==='basic'){const dl=node('dl',undefined,'tq-project-identity');for(const [key,v]of [['프로젝트',options.project.name],['교량',options.project.bridge],['교각 / 형식',pier.input.name+' · '+pier.input.type],['기초 형식',pier.input.foundationType]])dl.append(node('dt',key),node('dd',v));details.append(dl);}
for(const f of subset){const row=node('label',undefined,'takeoff-variable tq-field'+(f.required&&f.missing?' is-missing':'')),title=node('span',f.name+(f.unit?' · '+f.unit:''),'tq-field-name'),tag=node('span',f.kind==='auto'?'자동':f.kind==='template'?'원본 Excel':'직접입력','tq-input-tag '+f.kind);row.append(title,tag);row.title=f.source;
let control:HTMLElement;
if(f.kind==='auto'){const out=node('output',f.value);out.setAttribute('aria-label',f.name+' 자동 입력');out.tabIndex=0;row.append(out);control=out;}
else{const n=node('input');n.type=f.numeric?'number':'text';if(f.numeric)n.step='any';n.value=f.value;n.placeholder=f.required?'필수 입력':'참고 입력';n.dataset.variable=f.key;n.setAttribute('aria-label',f.name);n.setAttribute('aria-invalid',String(f.required&&f.missing));n.onchange=()=>options.onChange({...stored,values:{...stored.values,[f.key]:n.value===''?null:f.numeric?Number(n.value):n.value},lastSignature:undefined});row.append(n);control=n;}
if(f.required&&f.missing){const warning=node('span','!', 'tq-field-warning');warning.title='필수 입력 누락';row.append(warning);}row.append(node('small',f.source));details.append(row);locations.set(f.key,{group:details,input:control});
}
if(g.id==='conditions'){const fixed=node('details',undefined,'tq-fixed-conditions');fixed.append(node('summary','원본 고정 공제·여유 기준'));for(const note of ['터파기 여유: 각 1m','버림: 두께 0.1m / 치수여유 0.2m','몰탈: 계산 두께 0.05m','솔플레이트: 치수여유 0.1m','원본에 없는 공제조건은 추가하지 않음'])fixed.append(node('p',note));details.append(fixed);}
scroll.append(details);
}
const review=node('label',undefined,'takeoff-review tq-approval');const checkbox=node('input');checkbox.type='checkbox';checkbox.checked=stored.reviewed;checkbox.onchange=()=>options.onChange({...stored,reviewed:checkbox.checked,lastSignature:undefined});review.append(checkbox,document.createTextNode('원본 고정 기준·설명식 불일치 및 모델 적용 기준을 확인했습니다.'));scroll.append(review);
requestAnimationFrame(()=>scroll.scrollTop=state.inputScroll);
return {panel,fields,done,total:required.length,jump,focusMissing:()=>jump(missing[0]?.key??fields[0].key)};
}
+60
View File
@@ -0,0 +1,60 @@
import {it,expect} from 'vitest';
import {readFileSync,writeFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {buildPier} from '../src/core/geometry';
import {parseProject,serializeProject} from '../src/core/project';
import {calculate,withTemplateDefaults,automaticInputs,geometrySignature,extraNumbers,specifications,DETAIL,INPUTS,type QuantityInputs} from './model';
import {exportEdits} from './export';
import {template,templateHash,quantityRows} from './template-data';
import {FormulaBook,cellKey} from './formula';
const sample=JSON.parse(readFileSync(resolve(import.meta.dirname,'../랜딩페이지/assets/samples/sample02.pzr.json'),'utf8'));
const input=sample.piers[0].input,model=buildPier(input);
const manual=():QuantityInputs=>{const values:QuantityInputs['values']={};for(let r=18;r<=46;r++)values['r'+r]=Number(template[INPUTS]['N'+r].cached);for(const v of extraNumbers)values[v.key]=Number(template[DETAIL][v.cell].cached);for(const v of specifications)values[v.key]=String(template[DETAIL][v.cell].value);return {values,reviewed:true};};
it('선택 교각의 자동값이 원본 예시값이나 직접입력으로 덮어써지지 않는다',()=>{
const s=manual(),r=calculate(input,model,s);expect(r.overrides[cellKey(INPUTS,'N24')]).toBe(model.copingL);expect(r.overrides[cellKey(INPUTS,'N39')]).toBe(model.piles.length);expect(r.overrides[cellKey(INPUTS,'N33')]).toBe(model.footings[0].length);expect(r.results).toHaveLength(quantityRows.length);
const lines=['# 변수 자동/수동 매핑 (운곡교 P1 현재 모델 기준)','','|변수|기호|단위|원본 입력셀|자동 추출 가능 여부|출처|','|---|---|---|---|---|---|'];
for(const v of r.mapped)lines.push(`|${v.name}|${v.symbol}|${v.unit}|3.2 관련변수!N${v.key.slice(1)}|${v.automatic?'자동':'직접 입력/검토'}|${v.source}|`);
lines.push('','## 수량 항목별 필요한 변수와 추적 위치','','|수량 항목|필요 입력변수|계산식 참조 위치|자동/수동|','|---|---|---|---|');
for(const row of r.results){const needed=r.mapped.filter(v=>row.dependencies.includes(cellKey(INPUTS,'N'+v.key.slice(1))));lines.push(`|${row.name}|${needed.map(v=>v.name+'('+v.symbol+')').join(', ')}|${DETAIL}!${row.cell} · 원식은 ANALYSIS.md|자동 ${needed.filter(v=>v.automatic).length} / 직접 ${needed.filter(v=>!v.automatic).length}|`);}
writeFileSync(resolve(import.meta.dirname,'docs/MAPPING.md'),lines.join('\n'));
const p=JSON.stringify(input);calculate(input,model,s);expect(JSON.stringify(input)).toBe(p);
});
it('입력누락은 0으로 계산되지 않고 의존 수량만 미산정이 된다',()=>{const r=calculate(input,model,{values:{},reviewed:false});expect(r.complete).toBe(false);expect(r.results.find(r=>r.cell==='W67')!.value).toBeNull();expect(r.results.find(r=>r.cell==='W57')!.value).not.toBeNull();});
it('형상 갱신·저장복원은 직접입력과 독립적이다',()=>{
const stored=manual(),i={...input,quantityTakeoff:stored};sample.piers[0].input=i;const serialized=serializeProject(sample);expect(parseProject(serialized).piers[0].input.quantityTakeoff).toEqual(stored);
const changed={...i,copingLengthMm:i.copingLengthMm+500};expect(geometrySignature(changed,buildPier(changed))).not.toBe(geometrySignature(i,buildPier(i)));expect(changed.quantityTakeoff.values).toEqual(stored.values);
});
it('다기둥은 원본 산식 적용범위 밖으로 분류된다',()=>{const i={...input,type:'2주형' as const};expect(calculate(i,buildPier(i),manual()).issues.some(s=>s.includes('기둥 1개'))).toBe(true);});
it('원본 P1의 출력 캐시는 수식과 일치하고 미사용 교각 수량을 합산하지 않는다',()=>{
const overrides:Record<string,number>={};for(let r=18;r<=46;r++)overrides[cellKey(INPUTS,'N'+r)]=Number(template[INPUTS]['N'+r].cached);for(const v of extraNumbers)overrides[cellKey(DETAIL,v.cell)]=Number(template[DETAIL][v.cell].cached);
const payload=exportEdits({project:{name:'원본 산식 검증',bridge:'운곡교'},pier:'P1',overrides,specs:manual().values,reviewed:true,templateHash});
for(const r of quantityRows)if(typeof template[DETAIL][r.cell].value==='string'&&String(template[DETAIL][r.cell].value).startsWith('='))expect(payload.edits[DETAIL][r.cell].cached).toBeCloseTo(Number(template[DETAIL][r.cell].cached),8);
expect(payload.edits['3.3 수량산출_P2'].W57.value).toBeNull();expect(payload.edits['1. 수량집계'].K57.cached).toBeCloseTo(Number(template[DETAIL].W57.cached),8);
writeFileSync(resolve(import.meta.dirname,'.cache/baseline-edits.json'),JSON.stringify(payload));
});
it('시험·시설·규격은 원본 Excel을 사용하고 모델 제원이 있으면 토층 4개만 남는다',()=>{
const i={...input,groundWaterOn:false,bearingPlans:input.bearings.map((b:{no:number})=>({no:b.no,A:700,B:640,S:0})),solplate:{T:35,margin:100,h3:181}};
const m=buildPier(i),empty={values:{},reviewed:false},r=calculate(i,m,empty,1.25);
expect(r.missing.sort()).toEqual(['지하수위 WL','토사 깊이','풍화암 깊이','연암 깊이'].sort());
expect(r.overrides[cellKey(DETAIL,'K68')]).toBe(2);expect(r.overrides[cellKey(DETAIL,'K69')]).toBe(2);expect(r.overrides[cellKey(DETAIL,'K74')]).toBe(1);
expect(withTemplateDefaults(empty).values.specFooting).toBe(template[DETAIL].E57.value);
expect(r.mapped.find(x=>x.key==='r46')!.value).toBe(.035);expect(r.mapped.find(x=>x.key==='r29')!.value).toBe(0);expect(r.mapped.find(x=>x.key==='r30')!.notApplicable).toBe(true);
const full={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true},done=calculate(i,m,full,1.25);expect(done.complete).toBe(true);
const payload=exportEdits({project:sample.project,pier:i.name,overrides:done.overrides,specs:withTemplateDefaults(full).values,reviewed:true,copingMode:done.copingMode,templateHash});
expect(payload.edits[DETAIL].W50.formula).not.toContain('ASIN');expect(payload.edits[INPUTS].N30.value).toBeNull();
expect(payload.edits[DETAIL].W58.cached).toBeCloseTo(done.results.find(x=>x.cell==='W58')!.value!,8);
writeFileSync(resolve(import.meta.dirname,'.cache/straight-edits.json'),JSON.stringify(payload));
});
it('직선식은 원본 곡률식의 R→무한대 극한과 일치한다',()=>{
const o:Record<string,number>={};for(let r=18;r<=46;r++)o[cellKey(INPUTS,'N'+r)]=Number(template[INPUTS]['N'+r].cached);o[cellKey(INPUTS,'N30')]=1e6;
const curved=new FormulaBook(o),straight=new FormulaBook(o,template,calculate(input,model,manual()).formulas);
for(const c of ['W50','W58'])expect(Math.abs(Number(curved.cell(DETAIL,c))-Number(straight.cell(DETAIL,c)))).toBeLessThan(.001);
});
it('원본 기본값보다 사용자 지정값(0 포함)을 우선하고 없는 받침 치수는 추정하지 않는다',()=>{
const x=withTemplateDefaults({values:{eoid:0,specBody:'사용자 규격'},reviewed:false});expect(x.values.eoid).toBe(0);expect(x.values.specBody).toBe('사용자 규격');
const r=calculate(input,model,{values:{},reviewed:false},1.2);for(const key of ['r40','r41'])expect(r.mapped.find(v=>v.key===key)!.value).toBeNull();
expect(r.missing).not.toContain('E.O.I.D 시험 횟수');expect(r.missing).not.toContain('본체 콘크리트 규격');
});
+49
View File
@@ -0,0 +1,49 @@
import {straightCopingFormulas} from './coping-formulas';
import type {PierInput} from '../src/core/types';
import {DIM,type PierModel} from '../src/core/geometry';
import {variables,quantityRows,template,templateHash} from './template-data';
import {FormulaBook,cellKey,type Overrides} from './formula';
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
export {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
export type QuantityInputs={values:Record<string,number|string|null>;reviewed:boolean;lastSignature?:string};
declare module '../src/core/types'{interface PierInput{quantityTakeoff?:QuantityInputs}}
export type Mapped={key:string;name:string;symbol:string;unit:string;value:number|null;source:string;automatic:boolean;notApplicable?:boolean};
export function withTemplateDefaults(stored:QuantityInputs):QuantityInputs{
const values={...stored.values};
for(const v of extraNumbers.filter(x=>x.key!=='rebar'))if(values[v.key]==null||values[v.key]==='')values[v.key]=Number(template[DETAIL][v.cell].cached);
for(const v of specifications)if(values[v.key]==null||values[v.key]==='')values[v.key]=String(template[DETAIL][v.cell].value);
return {...stored,values};
}
export function automaticInputs(input:PierInput,m:PierModel):Mapped[]{
const uniform=(v:(number|undefined)[])=>v.length&&v.every(x=>x!==undefined&&Number.isFinite(x)&&Math.abs(x!-v[0]!)<1e-7)?v[0]!:null;
const footing=m.footings[0],nopiles=m.piles.length===0;
const slope=m.copingL>0&&m.segments.length?((m.segments[m.segments.length-1].top-m.segments[0].top)/m.copingL)*100:null;
const auto:Record<number,[number|null,string]>={18:[m.copingRefTop,'model.copingRefTop (원본 Zb 의미 검토)'],19:[input.groundEl,'input.groundEl'],20:[input.groundWaterOn===true&&Number.isFinite(input.groundWaterEl)?input.groundWaterEl!:null,'input.groundWaterEl / 지하수 미지정은 직접 확인'],24:[m.copingL,'model.copingL'],25:[m.copingB,'model.copingB'],26:[m.h2,'model.h2 · 하부 테이퍼 높이'],27:[m.h1,'model.h1 · 상부 높이'],28:[slope,'모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사'],29:[0,'현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음'],30:[null,'직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용'],31:[uniform(m.columns.map(c=>c.diameter)),'model.columns[].diameter'],32:[footing?.bottom??null,'model.footings[0].bottom'],33:[footing?.length??null,'model.footings[0].length'],34:[footing?.width??null,'model.footings[0].width'],35:[footing?footing.top-footing.bottom:null,'기초 상면-하면'],36:[nopiles?0:uniform(m.piles.map(p=>p.diameter)),'model.piles[].diameter'],37:[nopiles?0:input.pileThicknessMm!=null?input.pileThicknessMm/1000:null,'입력 말뚝두께 mm→m'],38:[nopiles?0:uniform(m.piles.map(p=>p.top-p.bottom)),'말뚝 상면-하면'],39:[m.piles.length,'model.piles.length'],40:[uniform(m.blocks.map(b=>b.plan?.A!==undefined?b.plan.A/1000:undefined)),'받침 plan.A mm→m'],41:[uniform(m.blocks.map(b=>b.plan?.B!==undefined?b.plan.B/1000:undefined)),'받침 plan.B mm→m'],42:[m.blocks.length,'model.blocks.length'],43:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],44:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],45:[m.blocks.length?m.blocks.reduce((s,b)=>s+b.top-b.bottom,0)/m.blocks.length:null,'동일 평면 블럭 높이 합/Nb (원식의 등가 높이)'],46:[uniform(m.blocks.map(b=>b.plan?.T!==undefined?b.plan.T/1000:undefined))??(input.solplate?.T!=null?input.solplate.T/1000:null),'실제 솔플레이트 plan.T 또는 입력 공통 T mm→m']};
return variables.map(v=>{const a=auto[v.row];return {...v,value:a?.[0]??null,source:a?.[1]??`${INPUTS}!N${v.row} · 모델에 없는 조건`,automatic:a?.[0]!=null||v.row===30,notApplicable:v.row===30};});
}
export function geometrySignature(input:PierInput,m:PierModel):string{const {quantityTakeoff,...raw}=input;return JSON.stringify({raw,auto:automaticInputs(input,m),version:templateHash});}
export function supportIssues(input:PierInput,m:PierModel):string[]{const issues:string[]=[];if(m.columns.length!==1)issues.push('원본은 기둥 1개 산식입니다. 다기둥 수량 규칙이 없어 자동 완료할 수 없습니다.');if(m.footings.length!==1)issues.push('원본은 기초 1개 산식입니다. 기초 수/형식을 검토해야 합니다.');if(m.stretches.length!==1)issues.push('꺾인 교각의 원본 수량 규칙이 없습니다.');if(m.integratedBlocks.length)issues.push('일체 교좌면을 개별 블럭 Nb배로 계산할 수 없습니다.');if(input.pileKind==='PHC')issues.push('원본 복합말뚝 산식은 PHC 시공수량에 적용하지 않습니다.');const p=m.piles;if(p.length&&p.some(x=>Math.abs(x.diameter-p[0].diameter)>1e-7||Math.abs(x.top-x.bottom-p[0].top+p[0].bottom)>1e-7))issues.push('원본은 말뚝 직경·길이 단일값을 전제로 합니다.');return issues;}
export function overridesFor(mapped:Mapped[],stored:QuantityInputs):Overrides{
stored=withTemplateDefaults(stored);
const result:Overrides={};for(const v of mapped){const raw=v.notApplicable?0:v.automatic?v.value:stored.values[v.key];result[cellKey(INPUTS,'N'+v.key.slice(1))]=typeof raw==='number'&&Number.isFinite(raw)?raw:null;}
for(const v of extraNumbers){const raw=stored.values[v.key];result[cellKey(DETAIL,v.cell)]=typeof raw==='number'&&Number.isFinite(raw)?raw:null;}
return result;
}
export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,rebarTon?:number|null){
stored=withTemplateDefaults(stored);
const mapped=automaticInputs(input,m),overrides=overridesFor(mapped,stored),copingMode='straight' as const,formulas=straightCopingFormulas,book=new FormulaBook(overrides,template,formulas);
if(rebarTon!=null&&Number.isFinite(rebarTon))overrides[cellKey(DETAIL,'W67')]=rebarTon;
const results=quantityRows.map(row=>{const dependencies=book.references(DETAIL,row.cell),missing=dependencies.filter(k=>Object.hasOwn(overrides,k)&&overrides[k]===null);let value:number|null=null,error='';
try{if(!missing.length)value=Number(book.cell(DETAIL,row.cell));}catch(e){error=(e as Error).message;}
return {...row,value,error,missing,dependencies,formula:formulas[cellKey(DETAIL,row.cell)]??template[DETAIL][row.cell]?.value,sourceFormula:template[DETAIL][row.cell]?.value};});
const needed=new Set(results.flatMap(r=>r.dependencies));
const missing=mapped.filter(v=>!v.automatic&&needed.has(cellKey(INPUTS,'N'+v.key.slice(1)))&&overrides[cellKey(INPUTS,'N'+v.key.slice(1))]===null).map(v=>v.name);
for(const v of extraNumbers)if(overrides[cellKey(DETAIL,v.cell)]===null)missing.push(v.name);
for(const v of specifications)if(typeof stored.values[v.key]!=='string'||!String(stored.values[v.key]).trim())missing.push(v.name);
const issues=supportIssues(input,m);const totalDepth=[21,22,23].map(r=>overrides[cellKey(INPUTS,'N'+r)]);if(totalDepth.every(v=>typeof v==='number')&&totalDepth.reduce((a,b)=>Number(a)+Number(b),0)!>8)issues.push('원본 토공 심도구간은 8m까지입니다. 초과 수량을 완료 처리하지 않습니다.');
for(const [key,v]of Object.entries(overrides))if(typeof v==='number'&&(/!N(21|22|23|24|25|26|27|29|30|31|33|34|35|36|37|38|39|40|41|42|43|44|45|46)$/.test(key)||key.startsWith(DETAIL+'!'))&&v<0)issues.push(`${key}: 음수 입력을 확인하세요.`);
const warnings=['원본 W51의 실제 기둥 거푸집식은 설명 Hcol과 다릅니다(Zb-H1-H2-Zs+Zf). 원식을 유지합니다.','원본 W50 코핑 거푸집식의 계수와 W63 몰탈 0.05m는 표시 설명/규격과 다릅니다.','현재 코핑은 직선/단차 형상: a=0, R 미적용이며 W50/W58의 원본 원호항에 직선 극한(R→∞)을 적용합니다. s는 단차를 포함한 양단 등가경사입니다.','원본 고정 기준: 터파기 여유 각 1m, 버림 두께 0.1m·치수여유 0.2m, 몰탈 두께 0.05m, 솔플레이트 여유 0.1m, 강재비중 7.85.'];
return {mapped,overrides,book,results,copingMode,formulas,missing,issues,warnings,complete:!missing.length&&!issues.length&&!results.some(r=>r.value===null||r.error)&&stored.reviewed,signature:geometrySignature(input,m)+'|rebar:'+String(rebarTon??'manual')} ;
}
export function aggregate(results:ReturnType<typeof calculate>[]){return quantityRows.map((r,i)=>({...r,value:results.every(x=>x.complete)?results.reduce((s,x)=>s+x.results[i].value!,0):null}));}
+18
View File
@@ -0,0 +1,18 @@
import type {PierModel} from '../src/core/geometry';
import {keyplanEntities} from '../도면작성/project-views';
import {entityBounds} from '../도면작성/projection';
import {drawingSvg} from '../도면작성/svg';
import {LAYERS,type DrawingDocument} from '../도면작성/types';
/** Reuse the drawing workspace's actual model key plan, without a sample alignment. */
export function quantityPlanSvg(selected:PierModel,models:readonly PierModel[]):string{
const available=models.length?models:[selected];let scale=100;
for(let i=0;i<4;i++){const b=entityBounds(keyplanEntities(available,selected,scale*1.8,'',''));scale=Math.max(1,(b.maxX-b.minX)/220,(b.maxY-b.minY)/60);}
const entities=keyplanEntities(available,selected,scale*1.8,'',''),bounds=entityBounds(entities);
const doc:DrawingDocument={project:'',bridge:'',pier:selected.name,units:'mm',paper:{name:'PLAN',width:240,height:80},warnings:[],notes:[],settings:{paper:'A3',scale:'auto',dimensions:false,centers:true,elevations:false},views:[{id:'keyplan',title:'KEY PLAN',entities,bounds,geometryBounds:bounds,scale,slot:{x:10,y:10,width:220,height:60}}]};
return drawingSvg(doc,new Set(Object.keys(LAYERS) as (keyof typeof LAYERS)[]),false,'keyplan').replace('width="240mm" height="80mm"','width="640" height="213"').replaceAll('#00ffff','#087fcb').replaceAll('#00ff00','#173b55').replaceAll('#ff0000','#657f95');
}
export async function planPng(svg:string):Promise<string>{
const url=URL.createObjectURL(new Blob([svg],{type:'image/svg+xml'}));
try{const img=new Image();img.src=url;await img.decode();const canvas=document.createElement('canvas');canvas.width=1920;canvas.height=640;canvas.getContext('2d')!.drawImage(img,0,0,1920,640);return canvas.toDataURL('image/png').split(',')[1];}finally{URL.revokeObjectURL(url);}
}
+28
View File
@@ -0,0 +1,28 @@
import {node,icon,fmt,rawSelection,sumSelection,type TakeoffResult,type QuantityRow,type DashboardState,type Selection} from './dashboard-types';
function member(row:QuantityRow):string{if(row.row<=31)return '지반 · 토공';if(row.row===50)return '두부보';if(row.row===51)return '기둥';if([54,55,56,57,65].includes(row.row))return '기초';if([53,59].includes(row.row))return '버림콘크리트';if([52,62,63,64].includes(row.row))return '받침 · 교좌면';if(row.row===58)return '본체 · 두부보/기둥/블럭';if(row.row>=68&&row.row<=73||row.row===76)return '말뚝 · 시공';if(row.row===67)return '철근';return '공통 · 가설/타설/양생';}
export function rowStatus(rows:QuantityRow[],stale:boolean,issues:boolean,reviewed:boolean):{text:string;kind:string}{if(rows.some(r=>r.value===null||r.error))return{text:'검토필요',kind:'review'};if(stale)return{text:'재계산 필요',kind:'stale'};if(issues||!reviewed)return{text:'검토필요',kind:'review'};return{text:'완료',kind:'done'};}
export function createResultsPanel(o:{result:TakeoffResult;stale:boolean;reviewed:boolean;state:DashboardState;changed:Set<string>;onSelect:(s:Selection)=>void;onReview:()=>void}){
const {result,state}=o,panel=node('section',undefined,'tq-panel tq-results-panel');panel.append(node('h3','수량 결과'));
const totals=node('div',undefined,'takeoff-totals tq-summary-cards');
for(const [label,type,cells,unit]of [['콘크리트','concrete',['W60'],'m³'],['철근','rebar',['W67'],'ton'],['거푸집','formwork',Array.from({length:7},(_,i)=>'W'+(50+i)),'m²'],['굴착량','earth',Array.from({length:24},(_,i)=>'W'+(6+i)),'m³']] as [string,string,string[],string][]){const rows=result.results.filter(r=>cells.includes(r.cell)),s=sumSelection(rows,label),card=node('button',undefined,'tq-summary-card');card.type='button';const title=node('span',undefined,'tq-summary-title');title.append(icon(type),node('span',label+(cells.length===1?' ('+cells[0]+')':'')));const amount=node('strong',fmt(s.value,s.value!==null&&Math.abs(s.value)>=100?2:3));amount.append(node('small',' '+unit));card.append(title,amount);card.title='기존 산출 행: '+cells.join(', ');card.onclick=()=>o.onSelect(s);totals.append(card);}panel.append(totals);
const review=node('details',undefined,'tq-review-notices');review.append(node('summary',`입력·산식 검토 · 누락 ${result.missing.length}개 / 적용 검토 ${result.issues.length}개`));for(const text of [...result.issues,...result.warnings])review.append(node('p',text));if(!o.reviewed)review.append(node('p','좌측 하단에서 원본 산식과 모델 적용 기준을 확인하세요.'));panel.append(review);
const controls=node('div',undefined,'tq-result-controls'),tabs=node('div',undefined,'tq-view-tabs');tabs.setAttribute('role','group');tabs.setAttribute('aria-label','수량 결과 보기');for(const [key,label]of [['member','부재별 수량'],['work','공종별 집계'],['material','자재별 집계']]){const b=node('button',label);b.type='button';b.dataset.quantityView=key;b.setAttribute('aria-pressed',String(state.view===key));b.onclick=()=>{state.view=key as DashboardState['view'];tabs.querySelectorAll('button').forEach(n=>n.setAttribute('aria-pressed',String(n.dataset.quantityView===key)));paint();};tabs.append(b);}
const search=node('input');search.type='search';search.placeholder='항목 검색 (예: 구조물, 육상토사)';search.setAttribute('aria-label','수량 항목 검색');search.value=state.query;search.oninput=()=>{state.query=search.value;paint();};controls.append(tabs,search);panel.append(controls);
const scroll=node('div',undefined,'tq-table-scroll'),table=node('table',undefined,'takeoff-table tq-result-table');const head=node('thead'),hr=node('tr');for(const title of ['항목','세부구분','산식 / 참조','단위','수량','상태'])hr.append(node('th',title));head.append(hr);const body=node('tbody');table.append(head,body);scroll.append(table);panel.append(scroll);
const footer=node('p','집계는 동일 공종·규격·단위 안에서만 수행합니다. 콘크리트와 타설량은 중복 합산하지 않습니다.','tq-table-note');panel.append(footer);
let visibleSelections:Selection[]=[];
const activate=(s:Selection)=>{state.selected=s.id;body.querySelectorAll<HTMLTableRowElement>('tr[data-selection]').forEach(tr=>{tr.classList.toggle('is-selected',tr.dataset.selection===s.id);tr.setAttribute('aria-selected',String(tr.dataset.selection===s.id));});o.onSelect(s);};
const rowElement=(s:Selection)=>{const tr=node('tr');tr.dataset.selection=s.id;tr.tabIndex=0;tr.setAttribute('aria-selected',String(s.id===state.selected));tr.classList.toggle('is-selected',s.id===state.selected);if(s.rows.some(r=>o.changed.has(r.cell)))tr.classList.add('tq-quantity-changed');
const first=s.rows[0],parts=s.title.split(' / '),a=node('td'),label=node('button',parts[0]);label.type='button';label.dataset.quantityCell=s.rows.length===1?first.cell:'';label.title=s.title;a.append(label);const formula=node('td');formula.append(node('span',s.rows.length>1?'동일 공종·규격 합계':String(first.description??'원본 산식')),node('code',s.rows.map(r=>r.cell).join(' + ')));
const status=rowStatus(s.rows,o.stale,!!result.issues.length,o.reviewed),stateCell=node('td');stateCell.append(node('span',status.text,'tq-status-tag '+status.kind));tr.append(a,node('td',parts.slice(1).join(' / ')||'—'),formula,node('td',s.unit),node('td',fmt(s.value)),stateCell);tr.onclick=()=>activate(s);tr.onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();activate(s);}};return tr;
};
function paint(){body.replaceChildren();visibleSelections=[];const query=state.query.trim().toLowerCase(),rows=result.results.filter(r=>!query||(r.name+' '+r.description+' '+r.cell+' '+r.formula).toLowerCase().includes(query)),groups=new Map<string,Selection[]>();
if(state.view==='member'){for(const row of rows){const key=member(row);if(!groups.has(key))groups.set(key,[]);groups.get(key)!.push(rawSelection(row,result.book));}}
else if(state.view==='work'){const work=new Map<string,QuantityRow[]>();for(const row of rows){const parts=row.name.split(' / '),key=[parts[0],parts[1]??'',row.unit??''].join(' / ');if(!work.has(key))work.set(key,[]);work.get(key)!.push(row);}for(const [name,rs]of work){const section=rs[0].name.split(' / ')[0];if(!groups.has(section))groups.set(section,[]);groups.get(section)!.push(rs.length===1?rawSelection(rs[0],result.book):sumSelection(rs,name));}}
else{const material=new Map<string,QuantityRow[]>();for(const row of rows.filter(r=>[57,58,59,62,63,64,67,70].includes(r.row))){const parts=row.name.split(' / '),key=parts.slice(0,2).join(' / ');if(!material.has(key))material.set(key,[]);material.get(key)!.push(row);}for(const [key,rs]of material)groups.set(key,[rs.length===1?rawSelection(rs[0],result.book):sumSelection(rs,key)]);}
for(const [name,selections]of groups){const gkey=state.view+':'+name,tr=node('tr',undefined,'tq-group-row'),td=node('td');td.colSpan=6;const b=node('button',`${state.closed.has(gkey)?'▸':'▾'} ${name}`);b.type='button';b.setAttribute('aria-expanded',String(!state.closed.has(gkey)));b.append(node('small',String(selections.length)+'항목'));b.onclick=()=>{if(state.closed.has(gkey))state.closed.delete(gkey);else state.closed.add(gkey);paint();};td.append(b);tr.append(td);body.append(tr);visibleSelections.push(...selections);if(!state.closed.has(gkey))for(const s of selections)body.append(rowElement(s));}
if(!groups.size){const row=node('tr'),cell=node('td','검색 결과가 없습니다.');cell.colSpan=6;row.append(cell);body.append(row);}
const selected=visibleSelections.find(s=>s.id===state.selected)??visibleSelections[0];if(selected)activate(selected);
}
return {panel,paint,review:()=>{review.open=true;review.focus();review.scrollIntoView({block:'nearest'});},focusTable:()=>scroll.focus()};
}
+29
View File
@@ -0,0 +1,29 @@
import type {PierModel} from '../src/core/geometry';
import {createExcelPreview} from './excel-preview';
import {DETAIL,INPUTS,extraNumbers} from './schema';
import {cellKey} from './formula';
import {node,fmt,type TakeoffResult,type Selection} from './dashboard-types';
import {rowStatus} from './results-panel';
import type {QuantityInputs} from './model';
export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan:{selected:PierModel;models:readonly PierModel[]};stale:boolean;miniState:{sheet:string;zoom:number};onCell:(cell:string)=>void}){
const panel=node('aside',undefined,'tq-right-panel'),preview=node('section',undefined,'tq-panel tq-mini-panel');const heading=node('div',undefined,'tq-panel-heading');heading.append(node('h3','산출서 미리보기'));const expand=node('button','⛶');expand.type='button';expand.setAttribute('aria-label','산출서 미리보기 전체화면');heading.append(expand);preview.append(heading);
const mini=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:o.miniState,compact:true,onCell:o.onCell});preview.append(mini);panel.append(preview);
const trace=node('section',undefined,'tq-panel tq-formula-panel takeoff-trace');trace.setAttribute('aria-live','polite');panel.append(trace);
expand.onclick=()=>{const dialog=node('dialog',undefined,'tq-preview-dialog');dialog.dataset.quantityPreviewDialog='true';dialog.setAttribute('aria-label','수량산출서 전체화면');const close=node('button','닫기 ×');close.type='button';close.className='tq-dialog-close';close.onclick=()=>dialog.close();dialog.append(close,createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:{sheet:'__all__',zoom:1},onCell:o.onCell}));dialog.addEventListener('close',()=>{dialog.remove();expand.focus({preventScroll:true});},{once:true});document.body.append(dialog);dialog.showModal();};
const select=(s:Selection)=>{
trace.replaceChildren(node('h3','산식 상세'),node('p',s.title,'tq-selected-title'));
const block=(label:string,content:HTMLElement)=>{const row=node('div',undefined,'tq-formula-block');row.append(node('b',label),content);trace.append(row);};
block('산식',node('pre',s.rows.length===1?String(s.rows[0].description??s.formula):s.formula));
const inputs=node('dl',undefined,'tq-formula-inputs');const deps=new Set(s.rows.flatMap(r=>r.dependencies));
for(const v of o.result.mapped){const key=cellKey(INPUTS,'N'+v.key.slice(1));if(!deps.has(key))continue;let value:string;try{value=String(o.result.book.cell(INPUTS,'N'+v.key.slice(1)));}catch{value='미입력';}inputs.append(node('dt',v.symbol+' · '+v.name),node('dd',value+' '+v.unit));}
for(const v of extraNumbers){if(!deps.has(cellKey(DETAIL,v.cell)))continue;let value:string;try{value=String(o.result.book.cell(DETAIL,v.cell));}catch{value='미입력';}inputs.append(node('dt',v.name),node('dd',value+' '+v.unit));}
if(inputs.children.length)block('입력값',inputs);
block('계산식',node('pre',s.substitution));
const answer=node('div',undefined,'tq-formula-answer');answer.append(node('b','결과'),node('strong',fmt(s.value)+' '+s.unit));const status=rowStatus(s.rows,o.stale,!!o.result.issues.length,o.stored.reviewed);answer.append(node('span',status.text,'tq-status-tag '+status.kind));trace.append(answer);
const source=node('details',undefined,'tq-formula-source');source.append(node('summary','원본식 · 중간 계산값 · 참조 셀'));source.append(node('pre',s.formula));
for(const r of s.rows)if(r.formula!==r.sourceFormula)source.append(node('p',r.cell+' · 직선 모델: 원본 곡률항의 R→∞ 극한 적용'),node('pre','원본: '+r.sourceFormula));
for(const key of deps){const ix=key.lastIndexOf('!'),sheet=key.slice(0,ix),cell=key.slice(ix+1);if(s.rows.some(r=>sheet===DETAIL&&r.cell===cell))continue;let value:string;try{value=String(o.result.book.cell(sheet,cell));}catch{value='미입력';}source.append(node('p',key+' = '+value));}trace.append(source);
mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));
};
return {panel,select,focusPreview:()=>{preview.tabIndex=-1;preview.focus();}};
}
+3
View File
@@ -0,0 +1,3 @@
export const DETAIL='3.3 수량산출_P1',INPUTS='3.2 관련변수';
export const extraNumbers=[{key:'rebar',name:'철근가공조립량',symbol:'W67',unit:'ton',cell:'W67'},{key:'eoid',name:'E.O.I.D 시험 횟수',symbol:'K68',unit:'회',cell:'K68'},{key:'restrike',name:'Restrike 시험 횟수',symbol:'K69',unit:'회',cell:'K69'},{key:'inspection',name:'점검시설 수',symbol:'K74',unit:'EA',cell:'K74'}];
export const specifications=[{key:'specFooting',name:'기초 콘크리트 규격',cell:'E57'},{key:'specBody',name:'본체 콘크리트 규격',cell:'E58'},{key:'specBlinding',name:'버림 콘크리트 규격',cell:'E59'},{key:'specBearing',name:'교량받침 규격',cell:'B62'},{key:'specMortar',name:'몰탈 규격',cell:'E63'}];
+20
View File
@@ -0,0 +1,20 @@
import type {Plugin} from 'vite';
import {spawn} from 'node:child_process';
import {mkdir,readFile,writeFile} from 'node:fs/promises';
import {randomUUID} from 'node:crypto';
import path from 'node:path';
import {exportEdits,type ExportRequest} from './export.ts';
export function quantityPlugin(root:string):Plugin{
const handler=async(req:any,res:any)=>{
const reply=(status:number,value:unknown)=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8'});res.end(JSON.stringify(value));};
const host=req.headers.host??'',remote=req.socket.remoteAddress??'';
if(req.method!=='POST'||req.headers['x-pierzainer-action']!=='quantity'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(remote)||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 출력하세요.'});return;}
try{let raw='';for await(const part of req){raw+=part;if(raw.length>2000000)throw new Error('출력 요청이 너무 큽니다.');}
const payload=exportEdits(JSON.parse(raw) as ExportRequest),folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true});
const input=path.join(folder,'edits.json'),output=path.join(folder,'quantity.xlsx');await writeFile(input,JSON.stringify(payload),'utf8');
await new Promise<void>((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/preserve_template.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});});
res.writeHead(200,{'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','Content-Disposition':'attachment; filename="quantity.xlsx"','Cache-Control':'no-store'});res.end(await readFile(output));
}catch(e){reply(422,{error:(e as Error).message});}
};
return {name:'pierzainer-quantity',configureServer(s){s.middlewares.use('/__pierzainer/quantity/export',(req,res)=>void handler(req,res));},configurePreviewServer(s){s.middlewares.use('/__pierzainer/quantity/export',(req,res)=>void handler(req,res));}};
}
+5
View File
@@ -0,0 +1,5 @@
export type Cell={value:string|number|boolean|null;cached:string|number|boolean|null;format:string};
export const template:Record<string,Record<string,Cell>>={"표지및목차":{"A6":{"value":"고속국도 제 25호선 강진~광주간 건설공사 ","cached":"고속국도 제 25호선 강진~광주간 건설공사 ","format":"General"},"A9":{"value":"교각 수량산출서","cached":"교각 수량산출서","format":"General"},"A14":{"value":"(운곡교 완도방향)","cached":"(운곡교 완도방향)","format":"General"},"A24":{"value":2026.07,"cached":2026.07,"format":"General"},"X35":{"value":"1. 수량 집계","cached":"1. 수량 집계","format":"General"},"X37":{"value":"2. 주자재 집계","cached":"2. 주자재 집계","format":"General"},"X39":{"value":"3. 수량산출","cached":"3. 수량산출","format":"General"},"A43":{"value":"- 한맥기술 - ","cached":"- 한맥기술 - ","format":"General"}},"1. 수량집계":{"A2":{"value":"1. 수량집계","cached":"1. 수량집계","format":"0.000"},"A3":{"value":"▪","cached":"▪","format":"General"},"B3":{"value":"토공","cached":"토공","format":"General"},"A4":{"value":"공 종","cached":"공 종","format":"General"},"E4":{"value":"규 격","cached":"규 격","format":"General"},"J4":{"value":"단위","cached":"단위","format":"General"},"K4":{"value":"합 계","cached":"합 계","format":"General"},"N4":{"value":"P1","cached":"P1","format":"General"},"Q4":{"value":"P2","cached":"P2","format":"General"},"T4":{"value":"P3","cached":"P3","format":"General"},"W4":{"value":"P4","cached":"P4","format":"General"},"Z4":{"value":"P5","cached":"P5","format":"General"},"AC4":{"value":"P6","cached":"P6","format":"General"},"A6":{"value":"1)","cached":"1)","format":"General"},"B6":{"value":"구조물터파기","cached":"구조물터파기","format":"General"},"E6":{"value":"육상토사","cached":"육상토사","format":"General"},"G6":{"value":"0-2m","cached":"0-2m","format":"General"},"J6":{"value":"㎥","cached":"㎥","format":"General"},"K6":{"value":"=SUM(N6:AE6)","cached":2273.5416,"format":"#,##0.00;\\-#,##0.00;\"-\""},"N6":{"value":"='3.3 수량산출_P1'!$W6","cached":372.26,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Q6":{"value":"='3.3 수량산출_P2'!$W6","cached":368.3924,"format":"#,##0.00;\\-#,##0.00;\"-\""},"T6":{"value":"='3.3 수량산출_P3'!$W6","cached":375.50500000000005,"format":"#,##0.00;\\-#,##0.00;\"-\""},"W6":{"value":"='3.3 수량산출_P4'!$W6","cached":375.50500000000005,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Z6":{"value":"='3.3 수량산출_P5'!$W6","cached":374.2046,"format":"#,##0.00;\\-#,##0.00;\"-\""},"AC6":{"value":"='3.3 수량산출_P6'!$W6","cached":407.6746,"format":"#,##0.00;\\-#,##0.00;\"-\""},"G7":{"value":"2-4m","cached":"2-4m","format":"General"},"J7":{"value":"㎥","cached":"㎥","format":"General"},"K7":{"value":"=SUM(N7:AE7)","cached":1030.7869560000001,"format":"#,##0.00;\\-#,##0.00;\"-\""},"N7":{"value":"='3.3 수량산출_P1'!$W7","cached":148.90399999999997,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Q7":{"value":"='3.3 수량산출_P2'!$W7","cached":136.30518800000004,"format":"#,##0.00;\\-#,##0.00;\"-\""},"T7":{"value":"='3.3 수량산출_P3'!$W7","cached":159.58962500000004,"format":"#,##0.00;\\-#,##0.00;\"-\""},"W7":{"value":"='3.3 수량산출_P4'!$W7","cached":159.58962500000004,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Z7":{"value":"='3.3 수량산출_P5'!$W7","cached":155.29490900000002,"format":"#,##0.00;\\-#,##0.00;\"-\""},"AC7":{"value":"='3.3 수량산출_P6'!$W7","cached":271.103609,"format":"#,##0.00;\\-#,##0.00;\"-\""},"G8":{"value":"4-6m","cached":"4-6m","format":"General"},"J8":{"value":"㎥","cached":"㎥","format":"General"},"K8":{"value":"=SUM(N8:AE8)","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"N8":{"value":"='3.3 수량산출_P1'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Q8":{"value":"='3.3 수량산출_P2'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"T8":{"value":"='3.3 수량산출_P3'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"W8":{"value":"='3.3 수량산출_P4'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Z8":{"value":"='3.3 수량산출_P5'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"AC8":{"value":"='3.3 수량산출_P6'!$W8","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"G9":{"value":"6-8m","cached":"6-8m","format":"General"},"J9":{"value":"㎥","cached":"㎥","format":"General"},"K9":{"value":"=SUM(N9:AE9)","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"N9":{"value":"='3.3 수량산출_P1'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Q9":{"value":"='3.3 수량산출_P2'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"T9":{"value":"='3.3 수량산출_P3'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"W9":{"value":"='3.3 수량산출_P4'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"Z9":{"value":"='3.3 수량산출_P5'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\""},"AC9":{"value":"='3.3 수량산출_P6'!$W9","cached":0,"format":"#,##0.00;\\-#,##0.00;\"-\Line truncated
export const variables=[{"key": "r18", "row": 18, "name": "받침 EL", "symbol": "Zb", "unit": "m"}, {"key": "r19", "row": 19, "name": "지반고 EL", "symbol": "Zs", "unit": "m"}, {"key": "r20", "row": 20, "name": "지하수위 WL", "symbol": "Zw", "unit": "m"}, {"key": "r21", "row": 21, "name": "토사 깊이", "symbol": "H1", "unit": "m"}, {"key": "r22", "row": 22, "name": "풍화암 깊이", "symbol": "H2", "unit": "m"}, {"key": "r23", "row": 23, "name": "연암 깊이", "symbol": "H3", "unit": "m"}, {"key": "r24", "row": 24, "name": "두부보 길이", "symbol": "Lc", "unit": "m"}, {"key": "r25", "row": 25, "name": "두부보 폭", "symbol": "Bc", "unit": "m"}, {"key": "r26", "row": 26, "name": "두부보 하부높이", "symbol": "H1", "unit": "m"}, {"key": "r27", "row": 27, "name": "두부보 상부높이", "symbol": "H2", "unit": "m"}, {"key": "r28", "row": 28, "name": "횡경사(%)", "symbol": "s", "unit": "%"}, {"key": "r29", "row": 29, "name": "두부보 돌출부 폭", "symbol": "a", "unit": "m"}, {"key": "r30", "row": 30, "name": "두부보 변화부 곡률", "symbol": "R", "unit": "m"}, {"key": "r31", "row": 31, "name": "기둥 직경", "symbol": "Dc", "unit": "m"}, {"key": "r32", "row": 32, "name": "저판 저면 EL", "symbol": "Zf", "unit": "m"}, {"key": "r33", "row": 33, "name": "저판 길이", "symbol": "Lf", "unit": "m"}, {"key": "r34", "row": 34, "name": "저판 폭", "symbol": "Bf", "unit": "m"}, {"key": "r35", "row": 35, "name": "저판 높이", "symbol": "Hf", "unit": "m"}, {"key": "r36", "row": 36, "name": "말뚝 직경", "symbol": "Dp", "unit": "m"}, {"key": "r37", "row": 37, "name": "말뚝 두께", "symbol": "Tp", "unit": "m"}, {"key": "r38", "row": 38, "name": "말뚝 길이", "symbol": "Lp", "unit": "m"}, {"key": "r39", "row": 39, "name": "말뚝 수", "symbol": "Np", "unit": "개"}, {"key": "r40", "row": 40, "name": "받침 길이", "symbol": "Lb", "unit": "m"}, {"key": "r41", "row": 41, "name": "받침 폭", "symbol": "Bb", "unit": "m"}, {"key": "r42", "row": 42, "name": "받침 수", "symbol": "Nb", "unit": "개"}, {"key": "r43", "row": 43, "name": "받침 블럭 길이", "symbol": "Lbc", "unit": "m"}, {"key": "r44", "row": 44, "name": "받침 블럭 폭", "symbol": "Bbc", "unit": "m"}, {"key": "r45", "row": 45, "name": "받침 블럭 높이", "symbol": "Hbc", "unit": "m"}, {"key": "r46", "row": 46, "name": "솔플레이트 두께", "symbol": "Ts", "unit": "m"}];
export const quantityRows=[{"row": 6, "cell": "W6", "name": "구조물터파기 / 육상토사 / 0-2m", "unit": "㎥", "description": "Vs = (Ln x Bn + Ln+1 x Bn+1) x Hn/2"}, {"row": 7, "cell": "W7", "name": "구조물터파기 / 육상토사 / 2-4m", "unit": "㎥", "description": null}, {"row": 8, "cell": "W8", "name": "구조물터파기 / 육상토사 / 4-6m", "unit": "㎥", "description": null}, {"row": 9, "cell": "W9", "name": "구조물터파기 / 육상토사 / 6-8m", "unit": "㎥", "description": null}, {"row": 10, "cell": "W10", "name": "구조물터파기 / 용수토사 / 0-2m", "unit": "㎥", "description": null}, {"row": 11, "cell": "W11", "name": "구조물터파기 / 용수토사 / 2-4m", "unit": "㎥", "description": null}, {"row": 12, "cell": "W12", "name": "구조물터파기 / 용수토사 / 4-6m", "unit": "㎥", "description": null}, {"row": 13, "cell": "W13", "name": "구조물터파기 / 용수토사 / 6-8m", "unit": "㎥", "description": null}, {"row": 14, "cell": "W14", "name": "구조물터파기 / 육상풍화암 / 0-2m", "unit": "㎥", "description": null}, {"row": 15, "cell": "W15", "name": "구조물터파기 / 육상풍화암 / 2-4m", "unit": "㎥", "description": null}, {"row": 16, "cell": "W16", "name": "구조물터파기 / 육상풍화암 / 4-6m", "unit": "㎥", "description": null}, {"row": 17, "cell": "W17", "name": "구조물터파기 / 육상풍화암 / 6-8m", "unit": "㎥", "description": null}, {"row": 18, "cell": "W18", "name": "구조물터파기 / 용수풍화암 / 0-2m", "unit": "㎥", "description": null}, {"row": 19, "cell": "W19", "name": "구조물터파기 / 용수풍화암 / 2-4m", "unit": "㎥", "description": null}, {"row": 20, "cell": "W20", "name": "구조물터파기 / 용수풍화암 / 4-6m", "unit": "㎥", "description": null}, {"row": 21, "cell": "W21", "name": "구조물터파기 / 용수풍화암 / 6-8m", "unit": "㎥", "description": null}, {"row": 22, "cell": "W22", "name": "구조물터파기 / 육상연암 / 0-2m", "unit": "㎥", "description": null}, {"row": 23, "cell": "W23", "name": "구조물터파기 / 육상연암 / 2-4m", "unit": "㎥", "description": null}, {"row": 24, "cell": "W24", "name": "구조물터파기 / 육상연암 / 4-6m", "unit": "㎥", "description": null}, {"row": 25, "cell": "W25", "name": "구조물터파기 / 육상연암 / 6-8m", "unit": "㎥", "description": null}, {"row": 26, "cell": "W26", "name": "구조물터파기 / 용수연암 / 0-2m", "unit": "㎥", "description": null}, {"row": 27, "cell": "W27", "name": "구조물터파기 / 용수연암 / 2-4m", "unit": "㎥", "description": null}, {"row": 28, "cell": "W28", "name": "구조물터파기 / 용수연암 / 4-6m", "unit": "㎥", "description": null}, {"row": 29, "cell": "W29", "name": "구조물터파기 / 용수연암 / 6-8m", "unit": "㎥", "description": null}, {"row": 30, "cell": "W30", "name": "잔토", "unit": "㎥", "description": "V1+V2+V3"}, {"row": 31, "cell": "W31", "name": "되메우기 / 토사, 다짐 / 토사, 다짐", "unit": "㎥", "description": "Vs-(V1+V2+V3)"}, {"row": 50, "cell": "W50", "name": "거푸집 / 강재 거푸집 / 코핑부", "unit": "㎡", "description": "2(Ac1+Ac2+Ac3)+As1+As2+2As3"}, {"row": 51, "cell": "W51", "name": "거푸집 / 강재 거푸집 / 기둥부", "unit": "㎡", "description": "π × Dc × Hcol"}, {"row": 52, "cell": "W52", "name": "거푸집 / 합판3회 / 받침블럭", "unit": "㎡", "description": "(2LbHb + 2BbHb) × Nb"}, {"row": 53, "cell": "W53", "name": "거푸집 / 합판6회 / 버림콘크리트", "unit": "㎡", "description": "2(Lf+0.2)×0.1+2(Bf+0.2)×0.1"}, {"row": 54, "cell": "W54", "name": "거푸집 / 유로폼 (복잡) / 0~7m", "unit": "㎡", "description": "2LfHf + 2BfHf"}, {"row": 55, "cell": "W55", "name": "거푸집 / 유로폼 (복잡) / 7~10m", "unit": "㎡", "description": 0}, {"row": 56, "cell": "W56", "name": "거푸집 / 유로폼 (복잡) / 10~13m", "unit": "㎡", "description": 0}, {"row": 57, "cell": "W57", "name": "콘크리트 / 저판 (25-27-15) / 저판 (25-27-15)", "unit": "㎥", "description": "Lf × Bf × Hf"}, {"row": 58, "cell": "W58", "name": "콘크리트 / 본체 (25-40-15) / 본체 (25-40-15)", "unit": "㎥", "description": "Vcop+Vcol+Vbear"}, {"row": 59, "cell": "W59", "name": "콘크리트 / 버림콘크리트(40-15-8) / 버림콘크리트(40-15-8)", "unit": "㎥", "description": "(Lf+0.2)(Bf+0.2)×0.1"}, {"row": 60, "cell": "W60", "name": "콘크리트 타설 / 펌프카타설 / 0~15(육상)", "unit": "㎥", "description": "Vcop+Vcol+Vfot+Vbear+Vlean"}, {"row": 61, "cell": "W61", "name": "가설계단 / 워킹타워 / 워킹타워", "unit": "m", "description": "(Zb-Zs) x 1EA X 1SET"}, {"row": 62, "cell": "W62", "name": "교량받침(2800kN) / 양방향 H=166 / 양방향 H=166", "unit": "EA", "description": "Nb"}, {"row": 63, "cell": "W63", "name": "무수축몰탈 / T=300mm / T=300mm", "unit": "㎥", "description": "((Lb+0.2)(Bb+0.2) + (Lb+0.1)(Bb+0.1)) x \n0.05 / 2 xLine truncated
export const templateHash="1c5d935a134523a5dc3a292cfc01bf4190b9bc538313322b33add6926e21c90f";
+2
View File
@@ -0,0 +1,2 @@
export const sheetLayouts={"표지및목차":{"firstRow":1,"rows":43,"cols":34,"widths":[21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32,21.32],"heights":[18.0,18.0,18.0,18.0,18.0,22.0,13.27,18.0,22.0,19.0,33.0,11.0,20.0,14.0,41.0,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,26.8,13.27,26.8,13.27,26.8,13.27,26.8,13.27,18.0,24.0,22.0],"merges":[[9,1,12,34],[43,1,43,34],[6,1,7,34],[14,1,15,34],[24,1,25,34]],"cells":{"A1":159,"B1":159,"C1":159,"D1":159,"E1":159,"F1":159,"G1":159,"H1":159,"I1":159,"J1":159,"K1":159,"L1":159,"M1":159,"N1":159,"O1":159,"P1":159,"Q1":159,"R1":159,"S1":159,"T1":159,"U1":159,"V1":159,"W1":159,"X1":159,"Y1":159,"Z1":159,"AA1":159,"AB1":159,"AC1":159,"AD1":159,"AE1":159,"AF1":159,"AG1":159,"A2":159,"B2":159,"C2":159,"D2":159,"E2":159,"F2":159,"G2":159,"H2":159,"I2":159,"J2":159,"K2":159,"L2":159,"M2":159,"N2":159,"O2":159,"P2":159,"Q2":159,"R2":159,"S2":159,"T2":159,"U2":159,"V2":159,"W2":159,"X2":159,"Y2":159,"Z2":159,"AA2":159,"AB2":159,"AC2":159,"AD2":159,"AE2":159,"AF2":159,"AG2":159,"A3":159,"B3":159,"C3":159,"D3":159,"E3":159,"F3":159,"G3":159,"H3":159,"I3":159,"J3":159,"K3":159,"L3":159,"M3":159,"N3":159,"O3":159,"P3":159,"Q3":159,"R3":159,"S3":159,"T3":159,"U3":159,"V3":159,"W3":159,"X3":159,"Y3":159,"Z3":159,"AA3":159,"AB3":159,"AC3":159,"AD3":159,"AE3":159,"AF3":159,"AG3":159,"A4":159,"B4":159,"C4":159,"D4":159,"E4":159,"F4":159,"G4":159,"H4":159,"I4":159,"J4":159,"K4":159,"L4":159,"M4":159,"N4":159,"O4":159,"P4":159,"Q4":159,"R4":159,"S4":159,"T4":159,"U4":159,"V4":159,"W4":159,"X4":159,"Y4":159,"Z4":159,"AA4":159,"AB4":159,"AC4":159,"AD4":159,"AE4":159,"AF4":159,"AG4":159,"A5":159,"B5":159,"C5":159,"D5":159,"E5":159,"F5":159,"G5":159,"H5":159,"I5":159,"J5":159,"K5":159,"L5":159,"M5":159,"N5":159,"O5":159,"P5":159,"Q5":159,"R5":159,"S5":159,"T5":159,"U5":159,"V5":159,"W5":159,"X5":159,"Y5":159,"Z5":159,"AA5":159,"AB5":159,"AC5":159,"AD5":159,"AE5":159,"AF5":159,"AG5":159,"A6":166,"A8":159,"B8":159,"C8":159,"D8":159,"E8":159,"F8":159,"G8":159,"H8":159,"I8":159,"J8":159,"K8":159,"L8":159,"M8":159,"N8":159,"O8":159,"P8":159,"Q8":159,"R8":159,"S8":159,"T8":159,"U8":159,"V8":159,"W8":159,"X8":159,"Y8":159,"Z8":159,"AA8":159,"AB8":159,"AC8":159,"AD8":159,"AE8":159,"AF8":159,"AG8":159,"A9":168,"A13":156,"B13":156,"C13":156,"D13":156,"E13":156,"F13":156,"G13":156,"H13":156,"I13":156,"J13":156,"K13":156,"L13":156,"M13":156,"N13":156,"O13":156,"P13":156,"Q13":156,"R13":156,"S13":156,"T13":156,"U13":156,"V13":156,"W13":156,"X13":156,"Y13":156,"Z13":156,"AA13":156,"AB13":156,"AC13":156,"AD13":156,"AE13":156,"AF13":156,"AG13":156,"AH13":156,"A14":169,"D17":157,"D18":158,"D19":157,"D20":158,"D21":157,"D22":158,"AH22":159,"D23":157,"AH23":159,"A24":170,"AH26":160,"AH27":160,"AH28":160,"AH29":160,"AH30":160,"AH31":160,"AH32":160,"X33":162,"AE33":160,"AH33":160,"AF34":160,"AG34":160,"X35":162,"AE35":160,"AF35":160,"AG35":160,"X36":162,"AE36":160,"AF36":160,"AG36":160,"X37":162,"AE37":160,"AF37":160,"AG37":160,"X38":162,"AE38":160,"AF38":160,"AG38":160,"X39":162,"AE39":160,"AF39":160,"AG39":160,"X40":162,"AE40":160,"AF40":160,"AG40":160,"A42":163,"B42":163,"C42":163,"D42":163,"E42":163,"F42":163,"G42":163,"H42":163,"I42":163,"J42":163,"K42":163,"L42":163,"M42":163,"N42":163,"O42":163,"P42":163,"Q42":163,"R42":163,"S42":163,"T42":163,"U42":163,"V42":163,"W42":163,"X42":163,"Y42":163,"Z42":163,"AA42":163,"AB42":163,"AC42":163,"AD42":163,"AE42":163,"AF42":163,"AG42":163,"AH42":163,"A43":171}},"1. 수량집계":{"firstRow":2,"rows":86,"cols":31,"widths":[24.44,28.32,28.32,28.32,24.44,34.53,24.44,24.44,24.44,24.44,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53,20.53],"heights":[40.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,30.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,30.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0,20.0],"merges":[[14,17,14,19],[60,2,60,4],[17,14,17,16],[56,11,56,13],[7,23,7,25],[68,26,68,28],[15,17,15,19],[19,14,19,16],[67,26,67,28],[71,23,71,25],[58,23,58,25],[8,23,8,25],[69,26,69,28],[73,23,73,25],[16,7,16,9],[28,20,28,22],[60,23,60,25],[9,7,9,9],[53,23,53,25],[30,20,30,22],[25,17,25,19],[57,20,57,22],[25,11,25,13],[68,23,68,25],[29,20,29,22],[57,29,57,31],[53,5,53,6],[29,11,29,13],[58,20,58,22],[58,5,58,9],[54,20,54,22],[58,29,58,31],[62,26,62,28],[56,26,56,28],[31,11,31,13],[59,23,59,25],[64,26,64,28],[59,17,59,19],[21,23,21,25],[51,26,51,28],[22,17,22,19],[61,23,61,25],[74,2,74,4],[60,20,60,22],[61,17,61,19],[51,7,51,9],[76,2,76,4],[9,Line truncated
export const excelStyles={"159":{"font":"제주명조","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"166":{"font":"Noto Sans KR Medium","size":16.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":false},"168":{"font":"Noto Sans KR SemiBold","size":42.0,"bold":true,"italic":false,"color":"#006666","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":true},"156":{"font":"제주명조","size":42.0,"bold":true,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":true},"169":{"font":"Noto Sans KR SemiBold","size":28.0,"bold":true,"italic":false,"color":"#006666","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":true},"157":{"font":"제주명조","size":16.0,"bold":true,"italic":false,"color":"#FF0000","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"158":{"font":"제주명조","size":16.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"170":{"font":"Noto Sans KR SemiBold","size":15.0,"bold":false,"italic":false,"color":"#006666","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":false},"160":{"font":"제주명조","size":12.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"162":{"font":"Noto Sans KR","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"163":{"font":"Noto Sans KR","size":10.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"171":{"font":"Noto Sans KR","size":10.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":false},"299":{"font":"맑은 고딕","size":10.0,"bold":true,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":true},"300":{"font":"제주명조","size":11.0,"bold":true,"italic":false,"color":"#111111","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":true},"17":{"font":"제주명조","size":9.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":false},"258":{"font":"맑은 고딕","size":10.0,"bold":false,"italic":false,"color":"#000000","background":"#ffffff","align":"center","valign":"center","border":{},"wrap":false},"12":{"font":"맑은 고딕","size":10.0,"bold":false,"italic":false,"color":"#000000","background":"#ffffff","align":"left","valign":"center","border":{},"wrap":false},"194":{"font":"맑은 고딕","size":8.0,"bold":true,"italic":false,"color":"#111111","background":"#ffffff","align":"center","valign":"center","border":{"top":{"width":1,"style":"solid","color":"#222222"},"bottom":{"width":1,"style":"solid","color":"#222222"},"left":{"width":1,"style":"solid","color":"#222222"},"right":{"width":1,"style":"solid","color":"#222222"}},"wrap":true},"301":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"top":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"302":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"top":{"width":1,"style":"solid","color":"#222222"},"right":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"303":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"bottom":{"width":1,"style":"solid","color":"#222222"},"left":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"304":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"bottom":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"305":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"bottom":{"width":1,"style":"solid","color":"#222222"},"right":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"306":{"font":"돋움","size":11.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","align":"left","valign":"center","border":{"bottom":{"width":1,"style":"solid","color":"#222222"},"left":{"width":1,"style":"solid","color":"#222222"},"right":{"width":1,"style":"solid","color":"#222222"}},"wrap":false},"182":{"font":"맑은 고딕","size":8.0,"bold":false,"italic":false,"color":"#111111","background":"#ffffff","aligLine truncated
+32
View File
@@ -0,0 +1,32 @@
import json,re,hashlib,zipfile,warnings
from pathlib import Path
import openpyxl
warnings.filterwarnings('ignore',category=UserWarning)
root=Path(__file__).resolve().parent.parent
source=root/'교각수량산출.xlsx';w=openpyxl.load_workbook(source,data_only=False);v=openpyxl.load_workbook(source,data_only=True)
data={s.title:{c.coordinate:{'value':c.value,'cached':v[s.title][c.coordinate].value,'format':c.number_format} for row in s for c in row if c.value is not None} for s in w}
variables=[]
for r in range(18,47):
variables.append(dict(key=f'r{r}',row=r,name=w['3.2 관련변수'][f'G{r}'].value,symbol=w['3.2 관련변수'][f'L{r}'].value,unit='%' if r==28 else '개' if r in (39,42) else 'm'))
def merged_label(sheet,column,row):
cell=sheet.cell(row,column)
for merged in sheet.merged_cells.ranges:
if merged.min_row<=row<=merged.max_row and merged.min_col<=column<=merged.max_col:return sheet.cell(merged.min_row,merged.min_col).value
return cell.value
rows=[]
for r in list(range(6,32))+list(range(50,77)):
s=w['3.3 수량산출_P1'];b=merged_label(s,2,r);e=merged_label(s,5,r);g=merged_label(s,7,r)
rows.append(dict(row=r,cell=f'W{r}',name=' / '.join(str(x).replace('\n',' ') for x in [b,e,g] if x),unit=s[f'J{r}'].value,description=s[f'K{r}'].value))
meta=dict(sha256=hashlib.sha256(source.read_bytes()).hexdigest(),sheets=[dict(name=s.title,state=s.sheet_state,rows=s.max_row,cols=s.max_column,formulas=sum(c.data_type=='f' for row in s for c in row),merged=len(s.merged_cells.ranges),print_area=str(s.print_area),hidden_rows=[i for i,d in s.row_dimensions.items() if d.hidden],hidden_columns=[i for i,d in s.column_dimensions.items() if d.hidden]) for s in w],names={k:str(x.attr_text) for k,x in w.defined_names.items()},external_links=len(w._external_links),macros=any('vbaProject' in n for n in zipfile.ZipFile(source).namelist()))
(root/'template-data.ts').write_text('export type Cell={value:string|number|boolean|null;cached:string|number|boolean|null;format:string};\nexport const template:Record<string,Record<string,Cell>>='+json.dumps(data,ensure_ascii=False,separators=(',',':'),default=str)+';\nexport const variables='+json.dumps(variables,ensure_ascii=False)+';\nexport const quantityRows='+json.dumps(rows,ensure_ascii=False)+';\nexport const templateHash='+json.dumps(meta['sha256'])+';\n',encoding='utf8')
(root/'docs/template-analysis.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2),encoding='utf8')
md=['# 원본 수량산출서 분석','',f"원본 SHA256: `{meta['sha256']}`",'', '## 시트 구성','']
for s in meta['sheets']:md.append(f"- {s['name']}: {s['rows']}행 × {s['cols']}열, 수식 {s['formulas']}개, 병합 {s['merged']}개, {s['state']}")
md+=['','## 최소 입력과 원본 수식 매핑','','|변수|기호|단위|입력셀(P1)|','|---|---|---|---|']
for x in variables:md.append(f"|{x['name']}|{x['symbol']}|{x['unit']}|3.2 관련변수!N{x['row']}|")
md+=['','## 수량항목','','|항목|필요 입력(직접 참조)|실제 수식/고정값|원본 위치|','|---|---|---|---|']
for r in rows:
f=data['3.3 수량산출_P1'][r['cell']]['value'];refs=sorted(set(re.findall(r'\$?BG\$?\d+',str(f))))
md.append(f"|{r['name']}|{', '.join(refs)}|`{f}`|3.3 수량산출_P1!{r['cell']}|")
md+=['','## 원본의 중요한 제약','','- 설명식과 실제 수식이 불일치하는 부분을 수정하지 않는다. W51은 표시 Hcol 설명과 달리 Zb-H1-H2-Zs+Zf를 사용한다. 원본 P1 값은 236.80068785698427㎡이다. 기둥 실제 높이와 일치한다고 간주하지 않는다.','- AO8은 풍화암 BG10을 사용하고 AO12는 연암 BG11을 사용한다. 그대로 재현한다.','- W50 설명에는 2(Ac1+Ac2+Ac3)이 있으나 실제 수식은 해당 부분에 계수 2가 없다. W58과 공유되는 곡률 ASIN 식을 그대로 보존한다.','- W63의 규격 표기는 T=300mm이지만 수식은 0.05m를 사용한다. 규격 텍스트와 계산 기준을 검토해야 한다.','- W67은 수식이 아닌 철근량 직접값, K68/K69/K74는 시험/시설 횟수 직접값이다. 자동 수량으로 가장하지 않는다.','- 실제 수식에 ROUND 계열은 없다. 원값으로 계산 후 원본 표시형식(상세 주로 소수3자리, 집계 주로2자리)만 적용한다.','- 13시트 모두 visible, 데이터 유효성 규칙 없음, 외부통합문서 링크와 매크로 없음. 이름 정의 3개는 원본 보존하며 계산 수식에서 사용되지 않는다.','- 토공 심도는 0~8m 구간만 있다. 8m 초과는 원본 적용 범위 밖이다.','- 단일 원형기둥·단일 직사각형기초·균일 말뚝의 곡률 두부보 산식이다. 다기둥/꺾인/비대칭 구조의 수량을 임의 배수로 확장하지 않는다.']
(root/'docs/ANALYSIS.md').write_text('\n'.join(md),encoding='utf8')
+150
View File
@@ -0,0 +1,150 @@
"""Read-only capture preparation: original DrawingML + native rasterized EMF/WMF.
Builds header-free SVG pages for the browser to capture; never edits the workbook.
"""
import base64, html, json, math, posixpath, re, zipfile
from pathlib import Path
from xml.etree import ElementTree as E
import openpyxl
ROOT=Path(__file__).resolve().parents[1]
NS={'a':'http://schemas.openxmlformats.org/drawingml/2006/main','x':'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing','s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main','r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships'}
Z=zipfile.ZipFile(ROOT/'교각수량산출.xlsx')
WB=openpyxl.load_workbook(ROOT/'교각수량산출.xlsx',data_only=True)
THEME={}
for entry in E.fromstring(Z.read('xl/theme/theme1.xml')).find('a:themeElements/a:clrScheme',NS):
value=next(iter(entry));THEME[entry.tag.split('}')[-1]]='#'+value.get('val',value.get('lastClr','000000'))
esc=lambda s:html.escape(str(s),quote=True)
def child(n,path): return n.find(path,NS) if n is not None else None
def num(n,key,default=0): return float(n.get(key,default)) if n is not None else default
def color(n,default='black'):
if n is None:return default
rgb=child(n,'a:srgbClr'); scheme=child(n,'a:schemeClr')
if rgb is not None:return '#'+rgb.get('val')
if scheme is not None:
val=THEME.get({'tx1':'dk1','bg1':'lt1'}.get(scheme.get('val'),scheme.get('val')),default)
mod=child(scheme,'a:lumMod')
if mod is not None and val.startswith('#'):val='#'+''.join(f'{round(int(val[i:i+2],16)*num(mod,"val")/100000):02x}' for i in (1,3,5))
return val
return default
def frame(n):
f=child(n,'a:off');e=child(n,'a:ext')
return num(f,'x'),num(f,'y'),num(e,'cx',1),num(e,'cy',1)
def render(n,rels):
kind=n.tag.split('}')[-1]
if kind=='grpSp':
xf=child(n,'x:grpSpPr/a:xfrm');x,y,w,h=frame(xf);co=child(xf,'a:chOff');ce=child(xf,'a:chExt')
tr=f'translate({x} {y}) '
rot=num(xf,'rot')/60000
if rot:tr+=f'translate({w/2} {h/2}) rotate({rot}) translate({-w/2} {-h/2}) '
tr+=f'scale({w/max(1,num(ce,"cx",w))} {h/max(1,num(ce,"cy",h))}) translate({-num(co,"x")} {-num(co,"y")})'
return f'<g transform="{tr}">'+''.join(render(c,rels) for c in n if c.tag.split('}')[-1] in ('grpSp','pic','sp','cxnSp'))+'</g>'
sp=child(n,'x:spPr');xf=child(sp,'a:xfrm');x,y,w,h=frame(xf)
tr=f'translate({x} {y})';rot=num(xf,'rot')/60000
if rot:tr+=f' translate({w/2} {h/2}) rotate({rot}) translate({-w/2} {-h/2})'
if xf is not None and (xf.get('flipH')=='1' or xf.get('flipV')=='1'):
sx=-1 if xf.get('flipH')=='1' else 1;sy=-1 if xf.get('flipV')=='1' else 1
tr+=f' translate({w if sx<0 else 0} {h if sy<0 else 0}) scale({sx} {sy})'
out=[]
if kind=='pic':
blip=child(n,'x:blipFill/a:blip');rid=blip.get('{'+NS['r']+'}embed') if blip is not None else None
if rid in rels:
name=posixpath.basename(rels[rid]);p=ROOT/'.cache/source-media'/name
if p.suffix in ('.emf','.wmf'):p=Path(str(p)+'.png')
mime='image/png' if p.suffix=='.png' else 'image/jpeg'
out.append(f'<image width="{w}" height="{h}" preserveAspectRatio="none" href="data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}"/>')
else:
fill='none' if child(sp,'a:noFill') is not None else color(child(sp,'a:solidFill'),'none')
ln=child(sp,'a:ln');stroke='none' if ln is None or child(ln,'a:noFill') is not None else color(child(ln,'a:solidFill'))
width=num(ln,'w',9525)
attr=f'fill="{fill}" stroke="{stroke}" stroke-width="{width}"'
paths=sp.findall('a:custGeom/a:pathLst/a:path',NS) if sp is not None else []
for path in paths:
cmds=[]
for cmd in path:
t=cmd.tag.split('}')[-1];pts=cmd.findall('a:pt',NS);v=' '.join(f'{num(p,"x")} {num(p,"y")}' for p in pts)
if t in ('moveTo','lnTo','cubicBezTo','quadBezTo'):cmds.append({'moveTo':'M','lnTo':'L','cubicBezTo':'C','quadBezTo':'Q'}[t]+v)
elif t=='close':cmds.append('Z')
pw=num(path,'w',w);ph=num(path,'h',h)
out.append(f'<path transform="scale({w/max(1,pw)} {h/max(1,ph)})" d="{" ".join(cmds)}" {attr}/>')
preset=child(sp,'a:prstGeom');prst=preset.get('prst') if preset is not None else ''
if prst=='ellipse':out.append(f'<ellipse cx="{w/2}" cy="{h/2}" rx="{w/2}" ry="{h/2}" {attr}/>')
elif prst=='line':out.append(f'<path d="M0 0 L{w} {h}" {attr}/>')
elif prst and not paths:out.append(f'<rect width="{w}" height="{h}" {attr}/>')
body=child(n,'x:txBody')
if body is not None:
bp=child(body,'a:bodyPr');paras=body.findall('a:p',NS);fs=127000;lineY=num(bp,'tIns',45720)
for para in paras:
runs=para.findall('a:r',NS);defaults=child(para,'a:pPr/a:defRPr');end=child(para,'a:endParaRPr')
if runs:fs=num(child(runs[0],'a:rPr'),'sz',1000)*127
else:fs=num(defaults if defaults is not None else end,'sz',1000)*127
lineY+=fs
pp=child(para,'a:pPr');align=pp.get('algn','l') if pp is not None else 'l'
tx=w/2 if align=='ctr' else w-num(bp,'rIns',91440) if align=='r' else num(bp,'lIns',91440)
spans=[]
for run in runs:
rp=child(run,'a:rPr');t=child(run,'a:t');size=num(rp,'sz',fs/127)*127
spans.append(f'<tspan font-size="{size/9525}" font-weight="{"700" if rp is not None and rp.get("b")=="1" else "400"}" fill="{color(child(rp,"a:solidFill"))}">{esc(t.text or "") if t is not None else ""}</tspan>')
out.append(f'<text transform="scale(9525)" x="{tx/9525}" y="{lineY/9525}" text-anchor="{ "middle" if align=="ctr" else "end" if align=="r" else "start"}" font-family="Malgun Gothic,Arial" font-size="{fs/9525}">{"".join(spans)}</text>')
lineY+=fs*.12
return f'<g transform="{tr}">'+''.join(out)+'</g>'
items=[(3,'criteria-earth',95,27),(4,'criteria-status',51,29),(5,'criteria-structure',102,29),(6,'variables-diagram',16,27)]
for index,name,last,colcount in items:
ws=WB.worksheets[index-1];widths=[]
for c in range(1,colcount+1):
letter=openpyxl.utils.get_column_letter(c);dim=ws.column_dimensions.get(letter)
# Grouped column ranges share the first dimension entry.
if dim is None:dim=next((d for d in ws.column_dimensions.values() if d.min and d.min<=c<=d.max),None)
width=dim.width if dim is not None else ws.sheet_format.defaultColWidth or 8.43
widths.append(math.floor(width*7+5))
heights=[(ws.row_dimensions[r].height or ws.sheet_format.defaultRowHeight or 15)*4/3 for r in range(1,last+1)]
xs=[0];ys=[0]
for v in widths:xs.append(xs[-1]+v)
for v in heights:ys.append(ys[-1]+v)
offset=ys[1];out=[];covered=set();merges={}
for m in ws.merged_cells.ranges:
merges[(m.min_row,m.min_col)]=(m.max_row,m.max_col)
covered.update((r,c) for r in range(m.min_row,m.max_row+1) for c in range(m.min_col,m.max_col+1) if (r,c)!=(m.min_row,m.min_col))
for row in ws.iter_rows(min_row=2,max_row=last,max_col=colcount):
for cell in row:
r,c=cell.row,cell.column
if (r,c) in covered:continue
rr,cc=merges.get((r,c),(r,c));cc=min(cc,colcount);rr=min(rr,last)
x,y,w,h=xs[c-1],ys[r-1]-offset,xs[cc]-xs[c-1],ys[rr]-ys[r-1]
fill=cell.fill.fgColor.rgb if cell.fill.patternType=='solid' and cell.fill.fgColor.type=='rgb' else None
if fill:out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="#{fill[-6:]}"/>')
for side,x1,y1,x2,y2 in [('left',x,y,x,y+h),('right',x+w,y,x+w,y+h),('top',x,y,x+w,y),('bottom',x,y+h,x+w,y+h)]:
b=getattr(cell.border,side)
if b and b.style:out.append(f'<path d="M{x1} {y1} L{x2} {y2}" stroke="black" stroke-width="{1 if b.style=="thin" else 1.4}"/>')
if cell.value is not None:
size=(cell.font.sz or 10)*4/3;align=cell.alignment.horizontal
tx=x+w/2 if align=='center' else x+w-3 if align=='right' else x+3
fc=cell.font.color;rgb='#'+fc.rgb[-6:] if fc and fc.type=='rgb' else 'black'
out.append(f'<text x="{tx}" y="{y+h/2}" dominant-baseline="central" text-anchor="{"middle" if align=="center" else "end" if align=="right" else "start"}" font-family="Malgun Gothic,Arial" font-size="{size}" font-weight="{700 if cell.font.b else 400}" fill="{rgb}">{esc(cell.value)}</text>')
relfile=f'xl/worksheets/_rels/sheet{index}.xml.rels';dre=None
for rel in E.fromstring(Z.read(relfile)):
if rel.get('Type','').endswith('/drawing'):dre=posixpath.normpath(posixpath.join('xl/worksheets',rel.get('Target')))
rels={}
if dre:
for rel in E.fromstring(Z.read(posixpath.dirname(dre)+'/_rels/'+posixpath.basename(dre)+'.rels')):
if rel.get('TargetMode')!='External':rels[rel.get('Id')]=rel.get('Target')
for anchor in E.fromstring(Z.read(dre)):
fr=child(anchor,'x:from');to=child(anchor,'x:to')
def point(p):
c=int(child(p,'x:col').text);r=int(child(p,'x:row').text)
return sum(widths[:c])+float(child(p,'x:colOff').text)/9525,sum(heights[:r])+float(child(p,'x:rowOff').text)/9525-offset
if fr is None:continue
left,top=point(fr)
if to is not None:right,bottom=point(to);aw,ah=right-left,bottom-top
else:ext=child(anchor,'x:ext');aw,ah=num(ext,'cx')/9525,num(ext,'cy')/9525
for shape in anchor:
kind=shape.tag.split('}')[-1]
if kind not in ('grpSp','sp','pic','cxnSp'):continue
xf=child(shape,'x:grpSpPr/a:xfrm' if kind=='grpSp' else 'x:spPr/a:xfrm');x,y,w,h=frame(xf)
# xfrm stores the unrotated source geometry. Cell anchors describe its rotated
# bounding box; rescaling to that box distorts rotated text and grouped figures.
out.append(f'<g transform="translate(0 {-offset}) scale({1/9525})">{render(shape,rels)}</g>')
svg=f'<svg xmlns="http://www.w3.org/2000/svg" width="{xs[-1]}" height="{ys[-1]-offset}" viewBox="0 0 {xs[-1]} {ys[-1]-offset}"><rect width="100%" height="100%" fill="white"/>{"".join(out)}</svg>'
(ROOT/'.cache'/f'{name}.svg').write_text(svg,encoding='utf-8')
print(name,xs[-1],ys[-1]-offset)
+34
View File
@@ -0,0 +1,34 @@
$ErrorActionPreference='Stop'
function Retry([scriptblock]$Action){for($i=0;$i -lt 40;$i++){try{return & $Action}catch{Start-Sleep -Milliseconds 250}};throw 'Excel busy'}
$root=Split-Path $PSScriptRoot -Parent
$excel=$null;$book=$null
try {
$excel=New-Object -ComObject Excel.Application
Retry {$excel.Visible=$true}
Retry {$excel.DisplayAlerts=$false}
Retry {$excel.AskToUpdateLinks=$false}
Retry {$excel.AutomationSecurity=3}
$book=Retry {$excel.Workbooks.Open((Join-Path $root '교각수량산출.xlsx'),0,$true)}
$excel.ActiveWindow.DisplayHeadings=$false
$excel.ActiveWindow.DisplayGridlines=$false
$items=@(
@('3.1 수량산출기준_토공','A2:AA95','criteria-earth'),
@('3.1 수량산출기준_구조체 현황','A2:AC51','criteria-status'),
@('3.1 수량산출기준_구조체','A2:AC102','criteria-structure'),
@('3.2 관련변수','A2:AA16','variables-diagram')
)
foreach($item in $items){
$sheet=$book.Worksheets.Item($item[0])
$sheet.PageSetup.PrintArea=$item[1]
$sheet.PageSetup.PrintHeadings=$false
$sheet.PageSetup.PrintGridlines=$false
$sheet.PageSetup.Zoom=$false
$sheet.PageSetup.FitToPagesWide=1
$sheet.PageSetup.FitToPagesTall=1
$sheet.ExportAsFixedFormat(0,(Join-Path $root ('.cache/'+$item[2]+'.pdf')))
Write-Output $item[2]
}
} finally {
if($book){Retry {$book.Close($false)};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($book)}
if($excel){Retry {$excel.Quit()};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}
}
+36
View File
@@ -0,0 +1,36 @@
"""Extract original worksheet layout for the in-app preview without saving the template."""
from pathlib import Path
import json,warnings,openpyxl
from openpyxl.utils import get_column_letter
warnings.filterwarnings('ignore')
root=Path(__file__).resolve().parent.parent;w=openpyxl.load_workbook(root/'교각수량산출.xlsx')
layouts={};styles={}
def color(c,fallback):
if c is None:return fallback
if c.type=='rgb':return '#'+c.rgb[-6:]
if c.type=='indexed':
from openpyxl.styles.colors import COLOR_INDEX
return '#'+COLOR_INDEX[c.indexed][-6:] if c.indexed<len(COLOR_INDEX) else fallback
return fallback
from openpyxl.utils.cell import range_boundaries
for s in w:
name=s.title
area=str(s.print_area).split('!')[-1].replace('$','').replace("'",'')
firstcol,firstrow,lastcol,lastrow=range_boundaries(area)
cells={};widths=[]
for c in range(1,lastcol+1):
width=s.sheet_format.defaultColWidth or 8.43
for d in s.column_dimensions.values():
if (d.min or 0)<=c<=(d.max or d.min or 0):width=d.width
widths.append(round(width*7+5,2))
for row in s.iter_rows(min_row=firstrow,max_row=lastrow,max_col=lastcol):
for c in row:
border={}
for side in ['top','bottom','left','right']:
b=getattr(c.border,side)
if b and b.style:border[side]={'width':2 if b.style in ['medium','thick','double'] else 1,'style':'dashed' if b.style in ['dashed','dotted','dashDot'] else 'solid','color':color(b.color,'#222222')}
style={'font':c.font.name or 'Malgun Gothic','size':c.font.sz or 8,'bold':bool(c.font.bold),'italic':bool(c.font.italic),'color':color(c.font.color,'#111111'),'background':color(c.fill.fgColor,'#ffffff') if c.fill.patternType=='solid' else '#ffffff','align':c.alignment.horizontal or ('right' if isinstance(c.value,(int,float)) else 'left'),'valign':c.alignment.vertical or 'center','border':border,'wrap':bool(c.alignment.wrap_text)}
if c.value is not None or c.has_style:
styles[c.style_id]=style;cells[c.coordinate]=c.style_id
layouts[name]={'firstRow':firstrow,'rows':lastrow,'cols':lastcol,'widths':widths,'heights':[round((s.row_dimensions[r].height or s.sheet_format.defaultRowHeight or 15)*4/3,2) for r in range(1,lastrow+1)],'merges':[[m.min_row,m.min_col,m.max_row,m.max_col] for m in s.merged_cells.ranges if m.min_row<=lastrow and m.min_col<=lastcol],'cells':cells}
(root/'template-layout.ts').write_text('export const sheetLayouts='+json.dumps(layouts,ensure_ascii=False,separators=(',',':'))+';\nexport const excelStyles='+json.dumps(styles,ensure_ascii=False,separators=(',',':'))+';\n',encoding='utf8')
+7
View File
@@ -0,0 +1,7 @@
import fs from 'node:fs/promises';
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
console.log((await w.inspect({kind:'sheet',include:'id,name',maxChars:1500})).ndjson);
console.log((await w.inspect({kind:'region',sheetId:'3.3 수량산출_P1',range:'W50:W64',maxChars:1600})).ndjson);
const preview=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});
await fs.writeFile('수량산출/.cache/template-preview.png',new Uint8Array(await preview.arrayBuffer()));
+68
View File
@@ -0,0 +1,68 @@
"""Keep the original OOXML package (including WMF and shapes); patch selected cells only."""
from pathlib import Path
import zipfile,json,sys,re,hashlib,html,base64
from xml.etree import ElementTree as ET
ROOT=Path(__file__).resolve().parent.parent
NS={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
def write(payload_path,output_path):
source=ROOT/'교각수량산출.xlsx';output=Path(output_path).resolve()
if not output.is_relative_to((ROOT/'outputs').resolve()) or output.exists():raise ValueError('결과 경로는 수량산출/outputs 내부의 새 파일이어야 합니다.')
data=json.loads(Path(payload_path).read_text(encoding='utf-8-sig'))
if hashlib.sha256(source.read_bytes()).hexdigest()!=data['templateHash']:raise ValueError('원본 템플릿이 변경되었습니다. 분석을 다시 실행하세요.')
with zipfile.ZipFile(source) as zin:
wb=ET.fromstring(zin.read('xl/workbook.xml'));rels=ET.fromstring(zin.read('xl/_rels/workbook.xml.rels'))
targets={r.attrib['Id']:r.attrib['Target'] for r in rels}
paths={s.attrib['name']:('xl/'+targets[s.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']].lstrip('/').removeprefix('xl/')) for s in wb.find('s:sheets',NS)}
updates={}
for sheet,edits in data['edits'].items():
path=paths[sheet];xml=zin.read(path).decode('utf8')
for cell,change in edits.items():
pattern=r'<c\b([^>]*\br="'+re.escape(cell)+r'"[^>]*)(?:>(.*?)</c>|/>)'
def replace(match):
attrs=match[1];body=match[2] or ''
if 'value' in change:
attrs=re.sub(r'\s+t="[^"]*"','',attrs);v=change['value']
if v is None:return '<c'+attrs+'/>'
if isinstance(v,str):return '<c'+attrs+' t="inlineStr"><is><t xml:space="preserve">'+html.escape(v)+'</t></is></c>'
return '<c'+attrs+'><v>'+str(v)+'</v></c>'
if '<f' not in body:raise ValueError('수식 캐시 대상에 수식이 없습니다: '+sheet+'!'+cell)
if 'formula' in change:body=re.sub(r'<f\b[^>]*>.*?</f>',lambda _: '<f>'+html.escape(change['formula'].removeprefix('='))+'</f>',body,flags=re.S)
body=re.sub(r'<v(?:\s[^>]*)?>.*?</v>|<v\s*/>','',body,flags=re.S)
return '<c'+attrs+'>'+body+'<v>'+str(change['cached'])+'</v></c>'
xml,count=re.subn(pattern,replace,xml,count=1,flags=re.S)
if count!=1:raise ValueError('기존 입력셀을 찾을 수 없습니다: '+sheet+'!'+cell)
updates[path]=xml.encode('utf8')
xml=zin.read('xl/workbook.xml').decode('utf8')
xml=re.sub(r'<calcPr\b[^>]*/>', '<calcPr calcId="191029" fullCalcOnLoad="1" forceFullCalc="1"/>',xml)
updates['xl/workbook.xml']=xml.encode('utf8')
updates['xl/_rels/workbook.xml.rels']=re.sub(r'<Relationship\b[^>]*Type="[^"]*/calcChain"[^>]*/>','',zin.read('xl/_rels/workbook.xml.rels').decode('utf8')).encode('utf8')
updates['[Content_Types].xml']=re.sub(r'<Override\b[^>]*PartName="/xl/calcChain.xml"[^>]*/>','',zin.read('[Content_Types].xml').decode('utf8')).encode('utf8')
additions={}
if data.get('planPng'):
image=base64.b64decode(data['planPng'],validate=True)
if not image.startswith(b'\x89PNG\r\n\x1a\n') or len(image)>1200000:raise ValueError('KEY PLAN PNG가 올바르지 않습니다.')
# Only the visible 3.2 sample alignment picture is replaced. Source
# workbook and all calculation cells / other figures remain intact.
drawing=ET.fromstring(zin.read('xl/drawings/drawing5.xml'))
dns={'x':'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing','a':'http://schemas.openxmlformats.org/drawingml/2006/main'}
target=next(a for a in drawing if a.find('x:from/x:col',dns).text=='0')
rid=target.find('.//a:blip',dns).get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
relpath='xl/drawings/_rels/drawing5.xml.rels'
relxml=zin.read(relpath).decode('utf8')
pattern=r'<Relationship\b(?=[^>]*\bId="'+re.escape(rid)+r'")[^>]*/>'
relxml,count=re.subn(pattern,lambda m:re.sub(r'Target="[^"]*"','Target="../media/quantity-project-plan.png"',m[0]),relxml)
if count!=1:raise ValueError('3.2 KEY PLAN 연결을 찾지 못했습니다.')
updates[relpath]=relxml.encode('utf8')
additions['xl/media/quantity-project-plan.png']=image
output.parent.mkdir(parents=True,exist_ok=True)
with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
if info.filename=='xl/calcChain.xml':continue
zout.writestr(info,updates.get(info.filename,zin.read(info.filename)))
for name,content in additions.items():zout.writestr(name,content)
report={'status':'generated','sheets':len(paths),'patched_cells':sum(map(len,data['edits'].values())),'original_preserved':True,'straight_limit_formulas':sum('formula' in c for cells in data['edits'].values() for c in cells.values()),'unchanged_package_parts':len(zin.namelist())-len(updates)-('xl/calcChain.xml' in zin.namelist()),'formula_policy':'P1 source formulas with explicit straight-limit overrides when applicable; P2-P6 unused blank; native full recalculation on open'}
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
return report
if __name__=='__main__':
try:print(json.dumps(write(sys.argv[1],sys.argv[2]),ensure_ascii=False))
except Exception as e:print(json.dumps({'error':str(e)},ensure_ascii=False));sys.exit(1)
+3
View File
@@ -0,0 +1,3 @@
import fs from 'node:fs/promises';import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
const png=await w.render({sheetName:'3.1 수량산출기준_구조체',range:'A1:AD90',scale:1.3,format:'png'});await fs.writeFile('수량산출/.cache/criteria-reference.png',new Uint8Array(await png.arrayBuffer()));
@@ -0,0 +1,5 @@
import fs from 'node:fs/promises';import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/교각수량산출.xlsx'));
for(const [sheetName,range,name]of [['3.1 수량산출기준_토공','A2:AA95','criteria-earth'],['3.1 수량산출기준_구조체 현황','A2:AC51','criteria-status'],['3.1 수량산출기준_구조체','A2:AC102','criteria-structure'],['3.2 관련변수','A2:AA16','variables-diagram']]){
w.worksheets.getItem(sheetName).showGridLines=false;const image=await w.render({sheetName,range,scale:1.25,format:'png'});await fs.writeFile('수량산출/assets/'+name+'.png',new Uint8Array(await image.arrayBuffer()));console.log(name);
}
@@ -0,0 +1,8 @@
import fs from 'node:fs/promises';
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/outputs/verification/운곡교P1_모델연동.xlsx'));
w.recalculate();
const checks=JSON.parse(await fs.readFile('수량산출/.cache/artifact-expected.json','utf8')),differences=[];
for(const c of checks){const actual=Number(w.worksheets.getItem(c.sheet).getRange(c.cell).values[0][0]);if(!Number.isFinite(actual)||Math.abs(actual-c.value)>1e-8*Math.max(1,Math.abs(c.value)))differences.push({...c,actual});}
await fs.writeFile('수량산출/docs/artifact-verification.json',JSON.stringify({checked:checks.length,differences,engine:'Artifact Tool recalculation of generated model workbook'},null,2));
const png=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});await fs.writeFile('수량산출/.cache/model-output-preview.png',new Uint8Array(await png.arrayBuffer()));console.log({checked:checks.length,differences:differences.length});if(differences.length)process.exitCode=1;
+17
View File
@@ -0,0 +1,17 @@
param([string]$Source,[string]$Destination,[string]$Expected,[string]$Report)
$ErrorActionPreference='Stop'
if(Test-Path -LiteralPath $Destination){throw '검증 대상 파일이 이미 있습니다.'}
Copy-Item -LiteralPath $Source -Destination $Destination
function Invoke-ExcelCall([scriptblock]$Action){for($attempt=0;$attempt -lt 30;$attempt++){try{return & $Action}catch{if($_.Exception.HResult -notin @(-2147418111,-2147417846)){throw};Start-Sleep -Milliseconds 300}};throw 'Excel COM 호출 대기시간 초과'}
$excel=$null;$book=$null
try{
$excel=New-Object -ComObject Excel.Application
Invoke-ExcelCall {$excel.Visible=$false;$excel.DisplayAlerts=$false;$excel.AskToUpdateLinks=$false;$excel.AutomationSecurity=3}
$book=Invoke-ExcelCall {$excel.Workbooks.Open($Destination,0,$false)}
Invoke-ExcelCall {$excel.CalculateFullRebuild()}
$checks=Get-Content -LiteralPath $Expected -Raw -Encoding utf8 | ConvertFrom-Json
$errors=@();$count=0
foreach($item in $checks){$sheet=Invoke-ExcelCall {$book.Worksheets.Item($item.sheet)};$v=Invoke-ExcelCall {$sheet.Range($item.cell).Value2};$count++;if($v -isnot [double] -and $v -isnot [int]){$errors+="$($item.sheet)!$($item.cell): $v"}elseif([Math]::Abs([double]$v-$item.value) -gt 1e-8*[Math]::Max(1,[Math]::Abs($item.value))){$errors+="$($item.sheet)!$($item.cell): $v != $($item.value)"};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($sheet)}
Invoke-ExcelCall {$book.Save()}
@{engine='Microsoft Excel';checked=$count;differences=$errors;worksheets=$book.Worksheets.Count}|ConvertTo-Json -Depth 4|Set-Content -LiteralPath $Report -Encoding utf8
}finally{if($book){try{Invoke-ExcelCall {$book.Close($false)}}catch{};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($book)};if($excel){try{Invoke-ExcelCall {$excel.Quit()}}catch{};[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)};[GC]::Collect();[GC]::WaitForPendingFinalizers()}
+6
View File
@@ -0,0 +1,6 @@
import {FileBlob,SpreadsheetFile} from '@oai/artifact-tool';
import fs from 'node:fs/promises';
const w=await SpreadsheetFile.importXlsx(await FileBlob.load('수량산출/outputs/verification/원본P1_수식검증.xlsx'));
w.recalculate();
console.log((await w.inspect({kind:'region',sheetId:'3.3 수량산출_P1',range:'W50:W64',maxChars:1800})).ndjson);
const png=await w.render({sheetName:'3.3 수량산출_P1',range:'A48:AH76',scale:1.4,format:'png'});await fs.writeFile('수량산출/.cache/output-preview.png',new Uint8Array(await png.arrayBuffer()));
+28
View File
@@ -0,0 +1,28 @@
import zipfile,hashlib,xml.etree.ElementTree as E,json,re,warnings
from pathlib import Path
warnings.filterwarnings('ignore')
import openpyxl
root=Path(__file__).resolve().parent.parent
source=root/'교각수량산출.xlsx';output=root/'outputs/verification/운곡교P1_모델연동.xlsx'
ns={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
with zipfile.ZipFile(source) as a,zipfile.ZipFile(output) as b:
required=[p for p in a.namelist() if any(t in p for t in ('xl/media/','xl/drawings/','xl/styles.xml','xl/theme/','xl/printerSettings/'))]
changed=[p for p in required if a.read(p)!=b.read(p)]
assert not changed,changed
sheet='xl/worksheets/sheet8.xml';sa=E.fromstring(a.read(sheet));sb=E.fromstring(b.read(sheet))
formula=lambda s:{c.attrib['r']:E.tostring(c.find('s:f',ns),encoding='unicode') for c in s.findall('.//s:c',ns) if c.find('s:f',ns)is not None}
original_formulas=formula(sa);output_formulas=formula(sb)
changed_formulas=[c for c in original_formulas if original_formulas[c]!=output_formulas.get(c)]
assert set(changed_formulas).issubset({'W50','W58'}),changed_formulas
for p in [p for p in a.namelist() if re.fullmatch(r'xl/worksheets/sheet\d+.xml',p)]:
ra=E.fromstring(a.read(p));rb=E.fromstring(b.read(p))
for tag in ['mergeCells','pageSetup','pageMargins','printOptions','rowBreaks','colBreaks','cols']:
x=ra.find('s:'+tag,ns);y=rb.find('s:'+tag,ns)
assert (E.tostring(x)if x is not None else None)==(E.tostring(y)if y is not None else None),(p,tag)
w=openpyxl.load_workbook(output,data_only=False);v=openpyxl.load_workbook(output,data_only=True)
errors=[(s.title,c.coordinate,c.value) for s in v for row in s for c in row if c.data_type=='e']
assert not errors,errors
assert all(w['3.3 수량산출_P2'][f'W{r}'].value is None for r in list(range(6,32))+list(range(50,77)))
report={'sheets':len(w.sheetnames),'protected_parts_byte_identical':len(required),'active_pier_formulas_total':len(formula(sa)),'active_pier_formulas_unchanged':len(formula(sa))-len(changed_formulas),'straight_limit_formulas':changed_formulas,'merges_print_settings_column_widths_preserved':True,'cached_excel_errors':errors,'unused_pier_quantities_blank':True,'template_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model_linked_values':{f'N{r}':v['3.2 관련변수'][f'N{r}'].value for r in [18,19,24,25,31,32,33,34,35,36,38,39,42]},'native_excel_recalculation':'not_verified: Excel COM rejected calls after initialization; artifact recalculation and original cached-result comparison used'}
(root/'docs/package-verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
print(json.dumps(report,ensure_ascii=False))
+53
View File
@@ -0,0 +1,53 @@
#view-quantity{grid-template-rows:auto minmax(0,1fr);overflow:hidden;gap:8px}
#view-quantity>.takeoff-menu{grid-column:1/-1;display:flex;gap:6px;min-height:44px;align-items:center}
.takeoff-menu button,.takeoff-workspace button,.tq-preview-dialog button{font:inherit;cursor:pointer;border:1px solid #d1dfed;background:white;color:#24567f;border-radius:5px;padding:8px 11px;line-height:1.3}
.takeoff-menu button{font-size:12px;min-height:36px}
.takeoff-menu button[aria-pressed=true]{background:#1f5fa8;color:white;border-color:#1f5fa8;font-weight:600}
#view-quantity>.takeoff-workspace{grid-column:1/-1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:10px;color:#19324b;font-family:inherit;padding:0;border:0;background:transparent}
#view-quantity>.takeoff-workspace[hidden]{display:none}
.tq-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;background:#fff;padding:11px 14px;border:1px solid #dbe5ef;border-radius:7px;flex-shrink:0}
.tq-toolbar h2{font-size:18px;line-height:1.3;margin:0;font-weight:700;color:#152f48}
.takeoff-workspace select,.takeoff-workspace input:not([type=checkbox]){font:inherit;border:1px solid #cadcec;border-radius:5px;background:#fff;color:#234666;padding:6px 8px;min-width:0}
.tq-toolbar>select{max-width:170px;font-size:12px;height:34px}
.takeoff-actions{display:flex;gap:7px;align-items:center;margin-left:auto}
.takeoff-actions button{font-size:12px;min-height:34px}
.takeoff-actions button:last-child{background:#1f5fa8;border-color:#1f5fa8;color:white}
.takeoff-workspace button:disabled{opacity:.45;cursor:not-allowed}
.tq-header-progress{font-size:11px;font-weight:600;color:#995210;background:#fff0e0;padding:6px 9px;border-radius:14px;white-space:nowrap}
.takeoff-status{margin:0;color:#8f5a17;background:#fff5e7;border-radius:12px;padding:5px 9px;line-height:1.3;font-size:11px;max-width:170px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.takeoff-status[data-state=done]{color:#236b49;background:#e8f6ed}
.tq-flow{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;background:linear-gradient(100deg,#edf6ff,#f4f9ff);border:1px solid #e1edf8;border-radius:7px;padding:10px;flex-shrink:0}
.tq-flow button{display:flex;gap:12px;align-items:center;background:none;border:0;text-align:left;position:relative;padding:2px 14px}
.tq-flow button:not(:last-child)::after{content:'›';font-size:25px;color:#a3bed7;position:absolute;right:0;top:4px}
.tq-flow svg{width:30px;height:30px;flex-shrink:0;color:#1787e0;background:#e0f0ff;border-radius:50%;padding:5px;box-sizing:content-box}
.tq-flow b{display:block;font-size:13px;color:#193e61}.tq-flow small{display:block;font-size:10px;color:#5b7793;margin-top:4px}.tq-flow em{display:inline-block;font-style:normal;background:#ffead2;color:#aa570d;padding:2px 5px;border-radius:4px;font-size:10px;margin-top:3px}
.tq-dashboard-grid{display:grid;grid-template-columns:280px minmax(0,1fr) 365px;gap:10px;flex:1;min-height:0}
.tq-panel{background:white;border:1px solid #d9e4ef;border-radius:7px;padding:12px;min-width:0;min-height:0;overflow:hidden;box-sizing:border-box}
#view-quantity .tq-panel h3{font-size:14px;line-height:1.4;margin:0 0 10px;font-weight:700;color:#1c3650}
.tq-input-panel{display:flex;flex-direction:column}
.tq-progress{display:flex;justify-content:space-between;gap:5px;flex-wrap:wrap;flex-shrink:0;font-size:10px;color:#55718c;margin-bottom:10px}.tq-progress b{font-size:10px;font-weight:500}.tq-progress progress{width:100%;height:7px;border:0;appearance:none}.tq-progress progress::-webkit-progress-bar{background:#eaf1f7;border-radius:5px}.tq-progress progress::-webkit-progress-value{background:#1e6dc4;border-radius:5px}
.tq-input-scroll{overflow:auto;min-height:0;flex:1;scrollbar-width:thin;padding-right:3px;overscroll-behavior:contain}
.tq-missing-box{background:#fff4e7;border:1px solid #ffdab4;border-radius:6px;padding:9px;margin-bottom:10px;color:#a45d13;font-size:11px}.tq-missing-box summary{font-weight:700;cursor:pointer}.tq-missing-box p{font-size:10px;margin:7px 0;line-height:1.5}.tq-missing-box button{display:block;font-size:10px;border:0;background:transparent;color:#994d0e;padding:4px 0;text-decoration:underline;text-align:left}
.tq-input-group{border:1px solid #dce6f1;border-radius:6px;overflow:hidden;margin-bottom:9px}.tq-input-group>summary{cursor:pointer;background:#f3f7fc;display:flex;align-items:center;justify-content:space-between;gap:5px;padding:8px;font-size:11px}.tq-input-group>summary::before{content:'›';color:#50718e}.tq-input-group[open]>summary::before{content:'⌄'}.tq-input-group>summary strong{margin-right:auto}.tq-count-done{color:#287b4d}.tq-count-missing{color:#c47111}
.tq-project-identity{display:grid;grid-template-columns:64px minmax(0,1fr);font-size:10px;gap:5px;margin:9px}.tq-project-identity dt{color:#6b8195}.tq-project-identity dd{margin:0;overflow-wrap:anywhere}
.tq-field{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px;margin:0;padding:8px;border-top:1px solid #edf1f6;font-size:10px;align-items:center}.tq-field-name{line-height:1.4}.tq-field input,.tq-field output{grid-column:1/-1;width:100%;box-sizing:border-box;display:block;min-height:30px;padding:6px 8px;border-radius:4px;font-size:11px!important;font-variant-numeric:tabular-nums}.tq-field output{border:1px solid #cfe1f4;color:#295979;background:#edf6ff}.tq-field small{display:none;grid-column:1/-1;overflow-wrap:anywhere;font-size:9px;color:#748aa0}.tq-field:focus-within small{display:block}.tq-field.is-missing input{border-color:#f2a551;background:#fffaf3}.tq-field-warning{position:absolute;right:15px;top:39px;border-radius:50%;background:#ee963d;color:white;width:13px;height:13px;text-align:center;line-height:13px;font-weight:700}.tq-field.is-missing input{padding-right:25px}
.tq-input-tag{font-size:9px;line-height:1.3;border-radius:8px;padding:2px 6px;white-space:nowrap}.tq-input-tag.auto{background:#e1f0ff;color:#236fb6}.tq-input-tag.manual{background:#edf1f6;color:#3c536d}.tq-input-tag.template{background:#e8effb;color:#4b68a4}.tq-fixed-conditions{font-size:10px;padding:8px;color:#708399}.tq-fixed-conditions summary{cursor:pointer}.tq-fixed-conditions p{margin:6px 0}.tq-approval{font-size:10px;line-height:1.6;display:flex;gap:5px;background:#f7f9fc;padding:9px;border:1px solid #e1e9f2;border-radius:5px}.tq-approval input{flex-shrink:0;align-self:flex-start;margin-top:3px}.tq-input-reset{flex-shrink:0;padding-top:8px}.tq-input-reset button{font-size:10px;width:100%;color:#657c92}
.tq-results-panel{display:flex;flex-direction:column}
.tq-summary-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;flex-shrink:0;margin-bottom:10px}
.tq-summary-card{text-align:left;background:linear-gradient(135deg,#fff,#f5f9ff)!important;border:1px solid #dce8f5!important;padding:11px 9px!important;border-radius:7px!important;min-width:0}.tq-summary-title{display:flex;gap:6px;align-items:center;font-size:10px;color:#47617a;white-space:nowrap}.tq-summary-title svg{width:20px;height:20px;flex-shrink:0;color:#2487db}.tq-summary-card strong{display:block;margin-top:10px;font-size:21px;letter-spacing:-.03em;color:#152e4c;white-space:nowrap;font-variant-numeric:tabular-nums}.tq-summary-card strong small{font-size:11px;font-weight:600;letter-spacing:0}
.tq-review-notices{flex-shrink:0;border:1px solid #e6e2d8;background:#fffbf4;border-radius:5px;font-size:10px;margin-bottom:10px;max-height:135px;overflow:auto}.tq-review-notices summary{cursor:pointer;padding:7px 9px;color:#926222}.tq-review-notices p{line-height:1.5;margin:5px 9px;color:#6e6d69}
.tq-result-controls{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:7px;flex-shrink:0;margin-bottom:10px}.tq-view-tabs{display:flex;gap:4px}.tq-view-tabs button{padding:7px 8px;font-size:10px;white-space:nowrap}.tq-view-tabs button[aria-pressed=true]{background:#1f5fa8;border-color:#1f5fa8;color:white}.tq-result-controls input{font-size:10px!important;min-width:150px;flex:1;max-width:240px;min-height:30px}
.tq-table-scroll{flex:1;min-height:0;overflow:auto;scrollbar-width:thin;outline:none}.tq-result-table{border-collapse:separate;border-spacing:0;table-layout:fixed;width:100%;font-size:10px;color:#34506b}.tq-result-table th{position:sticky;top:0;background:#eff4fa;z-index:2;border-top:1px solid #dbe5ef;color:#253f58;text-align:left;font-size:10px;font-weight:600}.tq-result-table th,.tq-result-table td{padding:7px 6px;border-bottom:1px solid #e0e8f1;border-right:1px solid #e4ebf3;vertical-align:middle;line-height:1.45;overflow-wrap:anywhere}.tq-result-table th:first-child,.tq-result-table td:first-child{border-left:1px solid #dbe5ef}.tq-result-table th:nth-child(1){width:17%}.tq-result-table th:nth-child(2){width:19%}.tq-result-table th:nth-child(3){width:26%}.tq-result-table th:nth-child(4){width:6%}.tq-result-table th:nth-child(5){width:15%}.tq-result-table th:nth-child(6){width:17%}.tq-result-table td:nth-child(5){text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;font-weight:500}.tq-result-table td:nth-child(3) span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:9px}.tq-result-table code{display:block;font-size:9px;color:#6f8da8;margin-top:3px}.tq-result-table td button{font-size:10px;text-align:left;padding:0;border:0;background:none;color:inherit;white-space:normal}.tq-result-table tr[data-selection]{cursor:pointer}.tq-result-table tr[data-selection]:hover{background:#f1f8ff}.tq-result-table tr.is-selected{background:#dceeff!important;color:#1e527c}.tq-group-row td{background:#f8fbfe;font-weight:600}.tq-group-row button{display:flex;align-items:center;gap:8px;width:100%;font-weight:600}.tq-group-row small{margin-left:auto;color:#8aa0b3;font-size:9px;font-weight:400}
.tq-status-tag{display:inline-block;font-size:9px;border-radius:10px;padding:2px 5px;white-space:nowrap}.tq-status-tag.done{background:#e5f5e9;color:#2a794a}.tq-status-tag.review{background:#fff0dc;color:#af6715}.tq-status-tag.stale{background:#f7efdb;color:#90772f}.tq-table-note{font-size:9px;line-height:1.5;color:#7a8fa2;margin:7px 0 0;flex-shrink:0}
.tq-right-panel{display:grid;grid-template-rows:minmax(0,1.15fr) minmax(0,1fr);gap:10px;min-width:0;min-height:0}.tq-mini-panel{display:flex;flex-direction:column;padding:10px}.tq-panel-heading{display:flex;align-items:center;justify-content:space-between;flex-shrink:0}.tq-panel-heading button{padding:2px 7px;font-size:16px}.tq-panel-heading h3{margin-bottom:0!important}
.tq-mini-panel .takeoff-excel-preview{display:flex;flex-direction:column;flex:1;min-height:0}.tq-mini-panel .takeoff-excel-toolbar{display:flex;gap:3px;margin:8px 0 6px;flex-wrap:nowrap;flex-shrink:0}.tq-mini-panel .takeoff-excel-toolbar select{font-size:9px!important;padding:3px!important;min-height:24px;height:24px;max-width:115px}.tq-mini-panel .takeoff-excel-toolbar select:last-of-type{max-width:50px;margin-left:auto}.tq-mini-panel .takeoff-excel-toolbar button{padding:2px 6px;min-height:22px;font-size:12px}.tq-mini-panel .takeoff-excel-toolbar>span{display:none}.takeoff-excel-page-count{font-size:10px;color:#58748e;white-space:nowrap;align-self:center}.tq-mini-panel .takeoff-excel-viewport{min-height:0;max-height:none;height:auto;flex:1;padding:8px;overscroll-behavior:contain}.tq-mini-panel .takeoff-excel-paper{padding:12px;margin:auto}.takeoff-excel-quantity.is-linked{background:#bfe3ff!important;outline:3px solid #1888d5;outline-offset:-3px}
.tq-formula-panel{overflow:auto;scrollbar-width:thin;padding:11px}.tq-selected-title{font-size:10px;color:#667d93;line-height:1.5;margin:0 0 8px}.tq-formula-block{display:grid;grid-template-columns:42px minmax(0,1fr);gap:7px;padding:8px 0;border-bottom:1px solid #edf2f7;font-size:10px;line-height:1.5}.tq-formula-block>b{color:#4f6c86;font-size:10px}.tq-formula-block pre{margin:0;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:130px;overflow:auto}.tq-formula-inputs{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 7px;margin:0;font-size:9px}.tq-formula-inputs dt{color:#71879c}.tq-formula-inputs dd{margin:0;text-align:right;color:#35536c;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.tq-formula-answer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:#e6f3ff;padding:10px;border-radius:5px;margin-top:8px;font-size:11px}.tq-formula-answer strong{font-size:20px;color:#174c80;font-variant-numeric:tabular-nums}.tq-formula-source{font-size:10px;margin-top:9px;color:#637c93}.tq-formula-source summary{cursor:pointer}.tq-formula-source pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;line-height:1.5}.tq-formula-source p{overflow-wrap:anywhere;font-size:9px;line-height:1.4;margin:5px 0}
.tq-preview-dialog{width:94vw;height:94dvh;max-width:94vw;max-height:94dvh;box-sizing:border-box;border:1px solid #d5e2ed;border-radius:12px;padding:20px;background:#f8fbff;color:#244b6c;overflow:auto}.tq-preview-dialog::backdrop{background:#19324b66;backdrop-filter:blur(4px)}.tq-dialog-close{display:block;margin-left:auto;margin-bottom:8px}.tq-preview-dialog .takeoff-excel-viewport{max-height:76dvh}
.tq-full-preview{flex:1;min-height:0;overflow:auto}.tq-full-preview .takeoff-excel-viewport{max-height:calc(100dvh - 305px)}.tq-full-trace:empty{display:none}.tq-full-trace{background:white;padding:15px;margin-top:12px;border:1px solid #dbe5ef}.tq-full-trace pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}
@keyframes quantity-update{0%,30%{background:#fff0b8}100%{background:transparent}}.tq-quantity-changed{animation:quantity-update 2.5s ease-out}
@media(min-width:1700px){.tq-dashboard-grid{grid-template-columns:300px minmax(0,1fr) 410px}.tq-result-table{font-size:12px}.tq-result-table td button{font-size:11px}.tq-summary-card strong{font-size:25px}.tq-field{font-size:11px}.tq-field input,.tq-field output{font-size:12px!important}.tq-formula-block{font-size:12px}.tq-formula-inputs{font-size:11px}}
@media(max-width:1450px){.tq-dashboard-grid{grid-template-columns:245px minmax(0,1fr) 315px;gap:8px}.tq-panel{padding:9px}.tq-summary-card{padding:9px 6px!important}.tq-summary-card strong{font-size:17px}.tq-summary-title{font-size:9px;gap:3px}.tq-summary-title svg{width:16px;height:16px}.tq-summary-card strong small{font-size:9px}.tq-flow{padding:7px}.tq-flow button{gap:7px;padding:2px 8px}.tq-flow svg{width:23px;height:23px}.tq-flow small{font-size:9px}.tq-toolbar{padding:9px 11px;gap:7px}.tq-toolbar h2{font-size:16px}}
@media(max-width:1100px){#view-quantity>.takeoff-workspace{overflow:auto}.tq-dashboard-grid{grid-template-columns:235px minmax(0,1fr);flex:none;min-height:650px;height:calc(100dvh - 250px)}.tq-right-panel{grid-column:1/-1;grid-template-columns:1fr 1fr;grid-template-rows:380px;min-height:380px}.tq-flow small{display:none}.tq-flow svg{width:20px;height:20px}.tq-flow b{font-size:11px}.tq-flow button{padding:2px}.tq-header-progress{font-size:10px}.takeoff-status{max-width:130px}}
@media(max-width:700px){.tq-dashboard-grid{display:flex;flex-direction:column;height:auto;min-height:0}.tq-input-panel{height:430px;flex-shrink:0}.tq-results-panel{height:650px;flex-shrink:0}.tq-right-panel{display:grid;grid-template-columns:1fr;grid-template-rows:360px 400px;flex-shrink:0}.tq-flow{gap:2px}.tq-flow button{gap:3px}.tq-flow svg{display:none}.tq-flow em{font-size:8px}.tq-toolbar{gap:6px}.takeoff-actions{width:100%;justify-content:flex-end}.tq-header-progress{display:none}}
@media(prefers-reduced-motion:reduce){.tq-quantity-changed{animation:none}}
@media print{.takeoff-menu,.takeoff-actions,.tq-flow,.tq-input-panel,.tq-result-controls{display:none!important}.tq-dashboard-grid{display:block}.tq-results-panel,.tq-right-panel,.tq-panel{overflow:visible;height:auto}.tq-table-scroll{overflow:visible}.tq-summary-cards{break-inside:avoid}}
+52
View File
@@ -0,0 +1,52 @@
import {createExcelPreview} from './excel-preview';
import {quantityPlanSvg,planPng} from './plan-preview';
import {createInputPanel} from './input-panel';
import {createResultsPanel} from './results-panel';
import {createReviewPanel} from './review-panel';
import {node as e,icon,rawSelection,type DashboardState,type Selection} from './dashboard-types';
import type {PierInput} from '../src/core/types';
import type {PierModel} from '../src/core/geometry';
import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
import {templateHash} from './template-data';
import './workspace.css';
type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
const blank=():QuantityInputs=>({values:{},reviewed:false});
export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,select:(name:string)=>void,save:(name:string,data:QuantityInputs)=>void){
let mode='takeoff';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'3.3 수량산출_P1',zoom:.4};
const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0};
const calculated=new Map<string,Map<string,number|null>>();let changed=new Set<string>(),changedUntil=0,lastPier='';
const legacy=[...host.children] as HTMLElement[],nav=e('nav');nav.className='takeoff-menu';nav.setAttribute('aria-label','재료표 및 수량산출');
const area=e('section');area.className='takeoff-workspace';area.hidden=true;
for(const [id,label]of [['materials','재료표'],['takeoff','교각 수량산출'],['preview','수량산출서 미리보기']]){const b=e('button',label);b.type='button';b.dataset.takeoffMode=id;b.onclick=()=>{mode=id;refresh();};nav.append(b);}host.prepend(nav);host.append(area);
function refresh(){
document.querySelectorAll<HTMLDialogElement>('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();});
legacy.forEach(n=>n.hidden=mode!=='materials');area.hidden=mode==='materials';for(const b of nav.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.takeoffMode===mode));if(mode==='materials')return;
const snap=snapshot(),pier=snap.piers.find(p=>p.input.name===snap.selected)??snap.piers[0];area.replaceChildren();area.classList.toggle('is-full-preview',mode==='preview');
const toolbar=e('header',undefined,'takeoff-toolbar tq-toolbar'),selector=e('select');selector.setAttribute('aria-label','수량산출 대상 교각');for(const p of snap.piers){const opt=e('option',p.input.name+' · '+p.input.type);opt.value=p.input.name;opt.selected=p.input.name===pier?.input.name;selector.append(opt);}selector.onchange=()=>select(selector.value);toolbar.append(e('h2','교각 수량산출'),selector);area.append(toolbar);
if(!pier?.model){area.append(e('p','형상 생성에서 교각을 먼저 생성하세요.'));return;}
if(lastPier!==pier.input.name){lastPier=pier.input.name;state.selected='W6';state.inputScroll=0;changed=new Set();}
const plan={selected:pier.model,models:snap.piers.flatMap(p=>p.model?[p.model]:[])};
const stored=withTemplateDefaults(pier.input.quantityTakeoff??blank()),result=calculate(pier.input,pier.model,stored,pier.rebarTon),stale=stored.lastSignature!==result.signature||pier.dirty;
const status=e('p',pier.dirty?'형상 재생성 필요':result.issues.length?'적용 범위 검토 필요':result.missing.length?`누락 ${result.missing.length}개`:stale?'재계산 필요':result.complete?'계산 완료':'원본 기준 확인 필요','takeoff-status');status.dataset.state=!stale&&result.complete?'done':'pending';status.setAttribute('role','status');toolbar.append(status);
const persist=(next:QuantityInputs)=>{save(pier.input.name,next);refresh();};
const actions=e('div',undefined,'takeoff-actions'),calc=e('button','↻ 재계산'),exporter=e('button','Excel 출력'),openPreview=e('button',mode==='preview'?'대시보드로 돌아가기':'미리보기 열기');calc.type=exporter.type=openPreview.type='button';calc.setAttribute('aria-label','수량산출 재계산');
calc.onclick=()=>{const before=calculated.get(pier.input.name);changed=new Set(result.results.filter(r=>before?.has(r.cell)&&before.get(r.cell)!==r.value).map(r=>r.cell));changedUntil=Date.now()+2500;calculated.set(pier.input.name,new Map(result.results.map(r=>[r.cell,r.value])));persist({...stored,lastSignature:result.signature});};
exporter.disabled=stale||!result.complete;exporter.title=result.missing.length?'필수 누락 항목을 입력하고 재계산하세요.':result.issues.length?'원본 적용 범위를 먼저 검토하세요.':stale?'재계산 후 출력할 수 있습니다.':'원본 양식 Excel 출력';
exporter.onclick=async()=>{exporter.disabled=true;status.textContent='Excel 생성 중…';try{const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({project:snap.project,pier:pier.input.name,overrides:result.overrides,specs:stored.values,reviewed:stored.reviewed,copingMode:result.copingMode,templateHash,planPng:await planPng(quantityPlanSvg(plan.selected,plan.models))})});if(!response.ok){const error=await response.json();throw new Error(error.error??'Excel 출력 실패');}const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${pier.input.name}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);status.textContent='Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.title=status.textContent;}finally{exporter.disabled=false;}};
openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};actions.append(calc,exporter,openPreview);toolbar.append(actions);
if(mode==='preview'){
const full=e('section',undefined,'tq-full-preview');const detail=e('section',undefined,'takeoff-trace tq-full-trace');
const paper=createExcelPreview({result,stored,pier:pier.input.name,project:snap.project,plan,state:fullState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(!row)return;detail.replaceChildren(e('h3',row.name),e('pre',String(row.formula)),e('pre',result.book.substitution('3.3 수량산출_P1',String(row.formula))),e('p',String(row.value??'미산정')+' '+row.unit));detail.scrollIntoView({block:'nearest'});}});full.append(paper,detail);area.append(full);return;
}
const inputs=createInputPanel({result,stored,pier,project:snap.project,state,onChange:persist});
const review=createReviewPanel({result,stored,pier:pier.input.name,project:snap.project,plan,stale,miniState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(row){state.selected=cell;results.paint();review.select(rawSelection(row,result.book));}}});
const results=createResultsPanel({result,stale,reviewed:stored.reviewed,state,changed:Date.now()<changedUntil?changed:new Set(),onSelect:s=>review.select(s),onReview:()=>{}});
const progress=e('span',`필수입력 ${inputs.done}/${inputs.total}`,'tq-header-progress');toolbar.insertBefore(progress,status);
const flow=e('div',undefined,'tq-flow');flow.setAttribute('aria-label','수량산출 작업 단계');
for(const [i,title,description,type]of [[1,'입력','교각 정보 및 계산 조건 입력','input'],[2,'검토','입력값 및 누락 항목 확인','review'],[3,'수량 결과','항목별 수량 산출 및 확인','result'],[4,'산출서 미리보기','최종 검토 및 출력','preview']] as [number,string,string,string][]){const b=e('button');b.type='button';b.dataset.workflowStep=String(i);b.append(icon(type));const text=e('span');text.append(e('b',title),e('small',description));if(i===2&&result.missing.length)text.append(e('em',`누락 ${result.missing.length}개`));b.append(text);b.onclick=()=>{if(i===1)inputs.focusMissing();else if(i===2)results.review();else if(i===3)results.focusTable();else review.focusPreview();};flow.append(b);}
const grid=e('div',undefined,'tq-dashboard-grid');grid.append(inputs.panel,results.panel,review.panel);area.append(flow,grid);
const footer=e('div',undefined,'tq-input-reset');const reset=e('button','직접 입력 초기화');reset.type='button';reset.onclick=()=>persist(blank());footer.append(reset);inputs.panel.append(footer);
results.paint();
}
return {refresh};
}
Binary file not shown.
Binary file not shown.