diff --git a/index.html b/index.html
index b71b8e5..8dd588c 100644
--- a/index.html
+++ b/index.html
@@ -18,6 +18,7 @@
+
-
-

- ${heroMotionMarkup(promoImageUrl)}
+
+

+ ${heroMotionMarkup()}
diff --git a/랜딩페이지/landing-white.css b/랜딩페이지/landing-white.css
index 9c79a02..b30176b 100644
--- a/랜딩페이지/landing-white.css
+++ b/랜딩페이지/landing-white.css
@@ -1,4 +1,10 @@
/* Premium White UI. Theme selectors never reach the application outside this overlay. */
+@media (min-width: 761px) and (max-width: 1400px) {
+ .landing-white .landing-guide-hint { top: 20px; }
+}
+@media (max-width: 760px) and (min-height: 760px) {
+ .landing-white .landing-guide-hint { position: fixed; top: auto; bottom: 16px; }
+}
.landing-overlay.landing-white {
--landing-bg: #f8fafc;
--landing-panel: rgba(255, 255, 255, .84);
@@ -85,21 +91,14 @@
pointer-events: none;
}
.landing-white .landing-artwork .landing-promo-image { position: relative; inset: auto; z-index: 1; filter: brightness(1.02) contrast(1.02); }
-.landing-white .landing-model-motion, .landing-white .landing-scan-mask { z-index: 2; }
-.landing-white .landing-terrain-grid {
- background-image: linear-gradient(#167eae47 1px, transparent 1px), linear-gradient(90deg, #167eae47 1px, transparent 1px);
-}
-.landing-white .landing-scan-beam {
- background: linear-gradient(90deg, transparent, #3b9fd31a 36%, #6bc6ed47 49%, #59abd917 64%, transparent);
-}
-.landing-white .landing-energy-track { stroke: #087fcb; stroke-width: 1.2; opacity: .25; }
+.landing-white .landing-model-motion { z-index: 2; }
+.landing-white .landing-energy-track { stroke: #ffffff; stroke-width: 1.2; opacity: .16; }
.landing-white .landing-energy-flow { stroke-width: 2; }
-.landing-white .landing-energy-haze { stroke-width: 5; opacity: .14; }
+.landing-white .landing-energy-haze { stroke-width: 5; opacity: .4; }
.landing-white .landing-pier-beacon { color: #087fcb; }
.landing-white .landing-pier-beacon line { stroke: #167ead; opacity: .58; }
.landing-white .landing-pier-point { fill: #087fcb; stroke: #f6fcff; stroke-width: 1.5; filter: drop-shadow(0 1px 2px #087fcb33); }
.landing-white .landing-pier-pulse { stroke: #087fcb; stroke-width: 1.5; }
-.landing-white .landing-pier-spark { fill: #1284bf; }
.landing-white .landing-hud-bracket { stroke: #538bb0; opacity: .35; }
/* White glass surfaces with consistent cobalt actions. */
diff --git a/수량산출/docs/MAPPING.md b/수량산출/docs/MAPPING.md
index eb4e0eb..7144091 100644
--- a/수량산출/docs/MAPPING.md
+++ b/수량산출/docs/MAPPING.md
@@ -76,7 +76,7 @@
|가설계단 / 워킹타워 / 워킹타워|받침 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|자동 3 / 직접 0|
-|솔플레이트|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb), 솔플레이트 두께(Ts)|3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 4 / 직접 0|
+|솔플레이트||3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(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|
diff --git a/수량산출/export-state.ts b/수량산출/export-state.ts
new file mode 100644
index 0000000..890b031
--- /dev/null
+++ b/수량산출/export-state.ts
@@ -0,0 +1,21 @@
+import type {QuantityInputs} from './model';
+import type {QuantitySnapshot} from './project-inputs';
+import {projectRecords} from './project-inputs';
+
+export type ExportScope='selected'|'all';
+export function exportRecords(snapshot:QuantitySnapshot,scope:ExportScope){
+ return projectRecords(snapshot).filter(r=>scope==='all'||r.pier.input.name===snapshot.selected);
+}
+
+/** Preserve edits made while PNG/Excel generation awaited IO, even across project replacement. */
+export function finishQuantityExport(start:QuantitySnapshot,scope:ExportScope,live:QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void):string[]{
+ const current=projectRecords(live),changed:string[]=[];
+ for(const before of exportRecords(start,scope)){
+ const after=current.find(r=>r.pier.input.name===before.pier.input.name);
+ if(!after||JSON.stringify(start.project)!==JSON.stringify(live.project)||after.pier.dirty||!after.result||after.pier.model!==before.pier.model||after.result.signature!==before.result?.signature||JSON.stringify(after.stored)!==JSON.stringify(before.stored)){
+ changed.push(before.pier.input.name);continue;
+ }
+ save(after.pier.input.name,{...after.stored,lastSignature:after.result.signature});
+ }
+ return changed;
+}
diff --git a/수량산출/export.ts b/수량산출/export.ts
index aca984b..39620c6 100644
--- a/수량산출/export.ts
+++ b/수량산출/export.ts
@@ -2,10 +2,11 @@ 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
;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string};
+export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string;solplateMode?:'model'};
export function exportEdits(request:ExportRequest){
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.');
const edits:Record>={},evaluation:Overrides={...request.overrides};
+ if(request.solplateMode!=='model'){delete evaluation[cellKey(DETAIL,'W64')];delete evaluation[cellKey(DETAIL,'K64')];}
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);
@@ -13,6 +14,12 @@ export function exportEdits(request:ExportRequest){
}
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);}
+ if(request.solplateMode==='model'){
+ const ton=request.overrides[cellKey(DETAIL,'W64')];
+ if(typeof ton!=='number'||!Number.isFinite(ton)||ton<0)throw new Error('모델 솔플레이트 중량을 확인하세요.');
+ value(DETAIL,'W64',ton);
+ value(DETAIL,'K64','3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)');
+ }
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);
diff --git a/수량산출/formula.ts b/수량산출/formula.ts
index c5a781d..795b7e5 100644
--- a/수량산출/formula.ts
+++ b/수량산출/formula.ts
@@ -39,6 +39,6 @@ export class FormulaBook{
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Recordnumber>={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(),visiting=new Set();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;
+ references(sheet:string,cell:string):string[]{const result=new Set(),visiting=new Set();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);if(Object.hasOwn(this.overrides,key))return;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];}
}
diff --git a/수량산출/model-alignment.test.ts b/수량산출/model-alignment.test.ts
new file mode 100644
index 0000000..da494b2
--- /dev/null
+++ b/수량산출/model-alignment.test.ts
@@ -0,0 +1,70 @@
+import {describe,it,expect} from 'vitest';
+import {buildPier} from '../src/core/geometry';
+import {solplateGeometry} from '../src/core/solplate';
+import {loadP1} from '../tests/helpers';
+import {calculate,withTemplateDefaults,DETAIL,type QuantityInputs} from './model';
+import {exportEdits} from './export';
+import {template,templateHash} from './template-data';
+import {cellKey} from './formula';
+import {exportRecords,finishQuantityExport} from './export-state';
+import type {QuantitySnapshot} from './project-inputs';
+
+const base=loadP1();
+const input={...base,sole:{h3Mm:181,marginMm:100,tByRow:{전열:35,후열:35},slopePct:Object.fromEntries(base.bearings.map(b=>[String(b.no),0]))}};
+const model=buildPier(input);
+const stored:QuantityInputs={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
+const raw=()=>calculate(input,model,stored,1.25);
+const snapshot=():QuantitySnapshot=>({project:{name:'프로젝트',bridge:'교량'},selected:input.name,piers:[{input:{...input,quantityTakeoff:stored},model,dirty:false,rebarTon:1.25}]});
+
+describe('W64 모델 기준 수량·Excel',()=>{
+ it('800×740×35mm 10개는 1.62652t이며 Excel에도 같은 숫자를 전달한다',()=>{
+ const result=raw(),row=result.results.find(r=>r.cell==='W64')!;
+ expect(model.blocks).toHaveLength(10);
+ expect(row.value).toBeCloseTo(1.62652,10);
+ expect(row.sourceFormula).toBe(template[DETAIL].W64.value);
+ expect(row.formula).toContain('3D 모델');
+ const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,solplateMode:result.solplateMode,templateHash});
+ expect(payload.edits[DETAIL].W64.value).toBeCloseTo(row.value!,12);
+ expect(payload.edits[DETAIL].K64.value).toContain('3D 모델');
+ expect(template[DETAIL].W64.value).toBe('=(BG28+0.1)*(BG29+0.1)*BG34*BG30*7.85');
+ });
+ it('구형 요청은 모델 값을 원식의 캐시로 섞지 않고 기존 계산을 유지한다',()=>{
+ const result=raw();
+ const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,templateHash});
+ expect(payload.edits[DETAIL].W64.cached).toBeCloseTo(1.23088,10);
+ expect(payload.edits[DETAIL].W64.value).toBeUndefined();
+ });
+ it('받침별 크기·경사·두께가 달라도 개별 모델 체적으로 합산한다',()=>{
+ const next={...input,sole:{...input.sole,plates:{[String(base.bearings[0].no)]:{aMm:900,bMm:800}},slopePct:{[String(base.bearings[0].no)]:2.1}}};
+ const m=buildPier(next),r=calculate(next,m,{...stored,values:{...stored.values,r40:999,r41:999,r46:999}},1.25);
+ const actual=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
+ expect(r.results.find(x=>x.cell==='W64')!.value).toBeCloseTo(actual,12);
+ expect(r.results.find(x=>x.cell==='W64')!.dependencies).toEqual([cellKey(DETAIL,'W64')]);
+ });
+});
+
+describe('Excel 비동기 생성 후 입력 보존·범위',()=>{
+ it('선택 출력은 해당 교각만, 전체 출력은 미생성 교각도 검사 대상으로 반환한다',()=>{
+ const s=snapshot();s.piers.push({...s.piers[0],input:{...input,name:'P2'},model:null});
+ expect(exportRecords(s,'selected').map(r=>r.pier.input.name)).toEqual([input.name]);
+ expect(exportRecords(s,'all').map(r=>r.pier.input.name)).toEqual([input.name,'P2']);
+ });
+ it('생성 중 수정한 입력·검토 체크를 이전 스냅샷으로 저장하지 않는다',()=>{
+ const before=snapshot(),live=snapshot(),saved:unknown[]=[];
+ live.piers[0].input={...live.piers[0].input,quantityTakeoff:{values:{...stored.values,r21:6},reviewed:false}};
+ expect(finishQuantityExport(before,'all',live,(...args)=>saved.push(args))).toEqual([input.name]);
+ expect(saved).toEqual([]);
+ expect(live.piers[0].input.quantityTakeoff).toMatchObject({values:{r21:6},reviewed:false});
+ });
+ it('변경이 없으면 현재 입력에 계산 서명만 기록한다',()=>{
+ const s=snapshot(),saved:{name:string;data:QuantityInputs}[]=[];
+ expect(finishQuantityExport(s,'selected',s,(name,data)=>saved.push({name,data}))).toEqual([]);
+ expect(saved[0].data).toMatchObject({values:stored.values,reviewed:true,lastSignature:raw().signature});
+ });
+ it('다른 프로젝트로 바뀌거나 모델이 재생성되면 이전 출력의 서명을 저장하지 않는다',()=>{
+ const before=snapshot(),live=snapshot();live.project={...live.project,name:'다른 프로젝트'};
+ expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
+ live.project=before.project;live.piers[0].model=buildPier(input);
+ expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
+ });
+});
diff --git a/수량산출/model.ts b/수량산출/model.ts
index adff7ce..003c989 100644
--- a/수량산출/model.ts
+++ b/수량산출/model.ts
@@ -2,6 +2,7 @@ import {bearingDimensions} from './bearing-dimensions';
import {straightCopingFormulas} from './coping-formulas';
import type {PierInput} from '../src/core/types';
import {DIM,type PierModel} from '../src/core/geometry';
+import {solplateGeometry} from '../src/core/solplate';
import {variables,quantityRows,template,templateHash} from './template-data';
import {FormulaBook,cellKey,type Overrides} from './formula';
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
@@ -34,9 +35,13 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
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 solplateMode='model' as const;
+ const solplateTon=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
+ overrides[cellKey(DETAIL,'W64')]=solplateTon;
+ overrides[cellKey(DETAIL,'K64')]='3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)';
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};});
+ return {...row,value,error,missing,dependencies,description:row.cell==='W64'?'3D 모델 판별 체적 합계 × 7.85':row.description,formula:row.cell==='W64'?'3D 모델 솔플레이트 체적 합계 × 7.85 t/m³':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);
@@ -45,6 +50,7 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
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')} ;
+ warnings.push('W64 솔플레이트는 생성 모델의 판별 체적 합계×7.85t/m³를 사용합니다. 원본 Excel 식은 비교 근거로 보존하며 출력 W64는 모델 계산값입니다.');
+ return {mapped,overrides,book,results,copingMode,solplateMode,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')+'|solplate-model:'+solplateTon} ;
}
export function aggregate(results:ReturnType[]){return quantityRows.map((r,i)=>({...r,value:results.every(x=>x.complete)?results.reduce((s,x)=>s+x.results[i].value!,0):null}));}
diff --git a/수량산출/project-inputs.ts b/수량산출/project-inputs.ts
index 211e2e2..515cd73 100644
--- a/수량산출/project-inputs.ts
+++ b/수량산출/project-inputs.ts
@@ -3,8 +3,13 @@ import type {PierModel} from '../src/core/geometry';
import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
import {inputFields} from './input-panel';
import {node} from './dashboard-types';
-export type QuantitySnapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
-export function projectRecords(s:QuantitySnapshot){return s.piers.map(p=>{const stored=withTemplateDefaults(p.input.quantityTakeoff??{values:{},reviewed:false});return {pier:p,stored,result:p.model?calculate(p.input,p.model,stored,p.rebarTon):null};});}
+export type QuantitySnapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null;rebarIssues?:string[]}[]};
+export function projectRecords(s:QuantitySnapshot){return s.piers.map(p=>{
+ const stored=withTemplateDefaults(p.input.quantityTakeoff??{values:{},reviewed:false});
+ const calculated=p.model?calculate(p.input,p.model,stored,p.rebarTon):null;
+ const result=calculated?{...calculated,issues:[...calculated.issues,...(p.rebarIssues??[]).map(m=>'철근재료표 일부 계산: '+m)],complete:calculated.complete&&!p.rebarIssues?.length}:null;
+ return {pier:p,stored,result};
+});}
export function openProjectInputs(read:()=>QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void,onClose:()=>void,onExport:()=>Promise){
const dialog=node('dialog');dialog.className='tq-project-dialog';dialog.dataset.projectInputs='true';
const head=node('header'),title=node('h2','전체 교각 입력 · 수량산출'),close=node('button','닫기 ×');close.type='button';close.onclick=()=>dialog.close();head.append(title,close);dialog.append(head);
diff --git a/수량산출/review-panel.ts b/수량산출/review-panel.ts
index 3002b9e..4cd2638 100644
--- a/수량산출/review-panel.ts
+++ b/수량산출/review-panel.ts
@@ -23,7 +23,7 @@ export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;
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 r of s.rows)if(r.formula!==r.sourceFormula)source.append(node('p',r.cell==='W64'?'W64 · 생성 모델의 판별 체적 합계 사용. Excel 입력을 바꾸면 앱에서 형상을 재생성해 다시 출력하세요.':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);
if(dialogTrace)dialogTrace.replaceChildren(...Array.from(trace.childNodes,n=>n.cloneNode(true)));
if(hasSelection)mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));hasSelection=true;
diff --git a/수량산출/workspace.ts b/수량산출/workspace.ts
index 3ae5297..04e02fe 100644
--- a/수량산출/workspace.ts
+++ b/수량산출/workspace.ts
@@ -1,34 +1,41 @@
import {createExcelPreview} from './excel-preview';
import {quantityPlanSvg,planPng} from './plan-preview';
-import {openProjectInputs,projectRecords} from './project-inputs';
+import {openProjectInputs,projectRecords,type QuantitySnapshot} from './project-inputs';
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 type {QuantityInputs} from './model';
import {templateHash} from './template-data';
+import {exportRecords,finishQuantityExport,type ExportScope} from './export-state';
import './workspace.css';
-type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
+type Snapshot=QuantitySnapshot;
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:'표지및목차',zoom:0};
+ let mode='materials';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'표지및목차',zoom:0};
+ let exportBusy=false;
const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0};
const calculated=new Map>();let changed=new Set(),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','수량산출']]){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);
- async function exportProject(){
- const snap=snapshot(),records=projectRecords(snap),problems:string[]=[];
- for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[])];if(reasons.length)problems.push(r.pier.input.name+': '+reasons.join(', '));}
+ for(const [id,label]of [['materials','철근재료표'],['takeoff','공종별 수량산출']]){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);
+ async function exportProject(scope:ExportScope='all'){
+ if(exportBusy)throw new Error('Excel을 생성 중입니다. 완료 후 다시 출력하세요.');
+ exportBusy=true;
+ try{
+ const snap=snapshot(),records=exportRecords(snap,scope),problems:string[]=[];
+ for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[]),...(r.stored.lastSignature!==r.result.signature?['재계산 필요']:[])];if(reasons.length)problems.push(r.pier.input.name+': '+reasons.join(', '));}
if(!records.length||problems.length)throw new Error(problems.length?problems.join('\n'):'출력할 교각이 없습니다.');
const models=records.flatMap(r=>r.pier.model?[r.pier.model]:[]),image=await planPng(quantityPlanSvg(models[0],models));
- const piers=records.map((r,i)=>({project:snap.project,pier:r.pier.input.name,overrides:r.result!.overrides,specs:r.stored.values,reviewed:r.stored.reviewed,copingMode:r.result!.copingMode,templateHash,...(i===0?{planPng:image}:{})}));
+ const piers=records.map((r,i)=>({project:snap.project,pier:r.pier.input.name,overrides:r.result!.overrides,specs:r.stored.values,reviewed:r.stored.reviewed,copingMode:r.result!.copingMode,solplateMode:r.result!.solplateMode,templateHash,...(i===0?{planPng:image}:{})}));
const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({piers})});
if(!response.ok){const error=await response.json();throw new Error(error.error??'전체 교각 Excel 출력 실패');}
- for(const r of records)save(r.pier.input.name,{...r.stored,lastSignature:r.result!.signature});
- const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_전체교각_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
+ const blob=await response.blob();
+ const changed=finishQuantityExport(snap,scope,snapshot(),save);
+ const target=scope==='all'?'전체교각':records[0].pier.input.name;
+ const url=URL.createObjectURL(blob),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${target}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
+ if(changed.length)throw new Error(`Excel은 생성 시작 시점 기준으로 저장했습니다. 생성 중 변경된 ${changed.join(', ')} 입력은 보존했습니다. 재계산 후 다시 출력하세요.`);
+ }finally{exportBusy=false;}
}
function refresh(){
document.querySelectorAll('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();});
@@ -37,16 +44,21 @@ export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,se
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(selector);area.append(toolbar);
if(!pier?.model){area.append(e('p','형상 생성에서 교각을 먼저 생성하세요.'));return;}
if(lastPier!==pier.input.name){lastPier=pier.input.name;miniState.sheet='표지및목차';state.selected='W6';state.inputScroll=0;changed=new Set();}
- const projectRows=projectRecords(snap).flatMap(r=>r.result?[{name:r.pier.input.name,result:r.result}]:[]);
+ const records=projectRecords(snap);
+ const projectRows=records.flatMap(r=>r.result?[{name:r.pier.input.name,result:r.result}]:[]);
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 record=records.find(r=>r.pier===pier)!;
+ const stored=record.stored,result=record.result!,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=false;exporter.title='모든 교각의 산출서와 합산 수량집계를 출력합니다.';
- exporter.onclick=async()=>{exporter.disabled=true;status.textContent='전체 교각 Excel 생성 중…';try{await exportProject();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();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,exportProject);actions.append(allInputs,calc,exporter,openPreview);toolbar.append(actions);
+ exporter.textContent='선택 교각 Excel';exporter.disabled=exportBusy;exporter.title='선택한 교각만 출력합니다.';exporter.dataset.takeoffExport='selected';
+ const allExporter=e('button','전체 교각 Excel');allExporter.type='button';allExporter.disabled=exportBusy;allExporter.dataset.takeoffExport='all';
+ const notice=(message:string)=>{const current=area.querySelector('[role="status"]')??status;current.textContent=message;current.title=message;};
+ const runExport=async(scope:ExportScope)=>{exporter.disabled=allExporter.disabled=true;notice((scope==='all'?'전체 교각':'선택 교각')+' Excel 생성 중…');try{await exportProject(scope);notice('Excel 출력 완료');}catch(error){notice((error as Error).message);}finally{host.querySelectorAll('[data-takeoff-export]').forEach(b=>{b.disabled=exportBusy;});}};
+ exporter.onclick=()=>runExport('selected');allExporter.onclick=()=>runExport('all');
+ openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,()=>exportProject('all'));actions.append(allInputs,calc,exporter,allExporter,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,projectRows,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;