feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)

공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:02 +09:00
1 parent b2bebe47eb
commit fc520ec03d
47 files changed
+2848 -149

No files matched your search

+4 -1
View File
@@ -18,6 +18,7 @@
<button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button> <button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button>
<button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button> <button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button>
<button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button> <button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
<button id="nav-quantity" class="step" aria-label="⑤ 재료표·수량산출" type="button">⑤ 재료표·수량</button>
</nav> </nav>
<div class="actions"> <div class="actions">
<span id="save-status" class="savest">저장 전</span> <span id="save-status" class="savest">저장 전</span>
@@ -47,6 +48,8 @@
<main class="grid" id="view-shape"> <main class="grid" id="view-shape">
<aside class="panel piers" aria-labelledby="pier-title"> <aside class="panel piers" aria-labelledby="pier-title">
<button id="project-ground-open" class="btn-o" type="button">프로젝트 지반모델</button>
<p id="project-ground-status" class="pnote">모델 없음</p>
<p id="pier-title" class="lbl">교각·교대 목록</p> <p id="pier-title" class="lbl">교각·교대 목록</p>
<div class="pier-tools"> <div class="pier-tools">
<label>형식 <label>형식
@@ -307,7 +310,7 @@
</div> </div>
<div class="viewer-slot" id="slot-rebar"></div> <div class="viewer-slot" id="slot-rebar"></div>
<section class="panel viewer" aria-labelledby="rebar-title"> <section class="panel viewer" aria-labelledby="rebar-title">
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div> <div class="viewer-head"><h2 id="rebar-title">철근표</h2><button id="rebar-materials" class="btn-o" type="button">재료표·수량산출 보기</button></div>
<p id="rebar-note" class="num"></p> <p id="rebar-note" class="num"></p>
<div id="rebar-table"></div> <div id="rebar-table"></div>
</section> </section>
+18 -9
View File
@@ -1,5 +1,7 @@
import './ground-popup.css'; import './ground-popup.css';
const API='/__pierzainer/ground-link'; const API='/__pierzainer/ground-link';
let importGround: ((text: string, name: string) => Promise<void>) | undefined;
export function registerGroundImporter(importer: (text: string, name: string) => Promise<void>) { importGround = importer; }
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0; let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;}; const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);}); const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
@@ -9,6 +11,10 @@ document.addEventListener('click',event=>{if(!(event.target as Element).closest(
let opening=false,running=false,launchSerial=0; let opening=false,running=false,launchSerial=0;
let popup:HTMLDialogElement; let popup:HTMLDialogElement;
window.addEventListener('pierzainer:project-changed',()=>{
version++;launchSerial++;running=false;opening=false;
if(popup){popup.close();popup.querySelector<HTMLElement>('[data-status]')!.textContent='프로젝트가 변경되어 이전 작업의 결과를 연결하지 않습니다.';}
});
function getPopup(){ function getPopup(){
if(popup)return popup; if(popup)return popup;
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title'); popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
@@ -28,13 +34,13 @@ window.addEventListener('pierzainer:open-ground',async()=>{
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!; const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal(); drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
let stageId:string|undefined,drawing=''; let stageId:string|undefined,drawing='';
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing; try{stageId=await staged;const prepared=await request('prepare',{stageId});if(snapshot!==version||ticket!==launchSerial)return;drawing=prepared.drawing;
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';}; const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.'; const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected); display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing); drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false; excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
}catch(error){status.textContent=(error as Error).message;opening=false;return;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;opening=false;return;}
run.onclick=async()=>{ run.onclick=async()=>{
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;} if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
run.disabled=true;drawingInput.disabled=excelInput.disabled=true; run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
@@ -43,23 +49,26 @@ window.addEventListener('pierzainer:open-ground',async()=>{
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0]; status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.'); if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
const excelPath=excel?await upload(excel):undefined; const excelPath=excel?await upload(excel):undefined;
if(snapshot!==version||ticket!==launchSerial)return;
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.'); if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.'; const job=await request('launch',{stageId,drawing,excel:excelPath});if(snapshot!==version||ticket!==launchSerial)return;running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
const deadline=Date.now()+30*60*1000; const deadline=Date.now()+30*60*1000;
const poll=async()=>{ const poll=async()=>{
if(snapshot!==version||ticket!==launchSerial){running=false;return;} if(snapshot!==version||ticket!==launchSerial)return;
try{ try{
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight; const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
if(snapshot!==version||ticket!==launchSerial)return;
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.'); if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
if(result.ready){ if(result.ready){
const input=document.querySelector<HTMLInputElement>('#ground-import'); if(!importGround)throw new Error('프로젝트 지반모델 불러오기를 사용할 수 없습니다.');
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.'; await importGround(result.text,result.filename);
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;} status.textContent='지반 생성 완료. 프로젝트 공통 모델로 불러왔습니다. 관리창에서 비교 후 적용하세요.';
running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;
} }
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000); if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
};setTimeout(poll,1000); };setTimeout(poll,1000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
}; };
}); });
// Closing hides the panel; the current job and its polling continue. // Closing hides the panel; the current job and its polling continue.
+64
View File
@@ -0,0 +1,64 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const preview=process.argv.includes('--horizontal-preview');
const lowerPreview=process.argv.includes('--lower-preview');
const out = path.resolve(__dirname, lowerPreview?'../.verify/landing-lower-20261109':process.argv.includes('--all')?'../.verify/landing-hero-20261109':'../.verify/landing-horizontal-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const projectPath=process.env.OKSEONG_PROJECT||path.resolve(__dirname,'../.verify/ground-abutments-20261109/saved.pzr.json');
const projectText=fs.readFileSync(projectPath,'utf8');
const hash=require('node:crypto').createHash('sha256').update(projectText).digest('hex');
await ev(`window.__heroProject=JSON.parse(${JSON.stringify(projectText)})`);
const variants=lowerPreview ? [['lower-33',.65,0,30,true,true],['lower-27',.5,0,30,true,true],['lower-19',.35,0,30,true,true],['lower-27-cutaway',.5,0,30,true,true,8]] : preview ? [['horizontal',.85,0,30,true],['horizontal-smooth',.85,0,30,true,true]] : process.argv.includes('--all') ? [['low',.35,0],['balanced',.55,.12],['high',.8,-.12],['trim30',.55,.12,30],['trim20',.55,.12,20],['refined',.85,.12,30,true]] : [['horizontal-smooth',.85,0,30,true,true]];
for(const [name,elevation,along,margin,refined,smooth,cutaway] of variants){
const result=await ev(`(async()=>{const {renderLandingHero}=await import('/scripts/render-landing-hero.ts');return renderLandingHero(window.__heroProject,${elevation},${along},${margin},${refined},${smooth},${cutaway});})()`);
for(const type of ['png','webp'])fs.writeFileSync(path.join(out,name+'.'+type),Buffer.from(result[type].split(',')[1],'base64'));
fs.writeFileSync(path.join(out,name+'.json'),JSON.stringify({...result.metadata,sourceSha256:hash},null,2));
check(name+' includes six piers and two abutments',result.metadata.piers===6&&result.metadata.abutments===2&&result.metadata.points.length===8);
check(name+' all projected markers stay inside image',result.metadata.points.every(p=>p.x>0&&p.x<result.metadata.width&&p.y>0&&p.y<result.metadata.height));
check(name+' light samples actual terrain densely',result.metadata.terrainFlow.filter(Boolean).length>100&&result.metadata.terrainFlow.every(p=>!p||Math.abs(p.world[1]-p.el-.12)<.0001));
if(refined)check('both road extensions contain continuous terrain-supported sections',result.metadata.roads.length===2&&result.metadata.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.triangles>0));
if(smooth){
check('outline trims real pixels without filling missing terrain',result.metadata.outline.addedOutsideSourcePixels===0&&result.metadata.outline.retainedTerrainRatio>.8&&result.metadata.outline.retainedTerrainRatio<.99);
check('smooth outline preserves all eight structure tops',await ev(`(()=>{const c=document.createElement('canvas');c.width=${result.metadata.width*2};c.height=${result.metadata.height*2};return new Promise(resolve=>{const i=new Image();i.onload=()=>{const ctx=c.getContext('2d');ctx.drawImage(i,0,0);resolve(${JSON.stringify(result.metadata.points)}.every(p=>{let n=0;for(let x=-8;x<=8;x++)for(let y=0;y<30;y++)n+=ctx.getImageData(Math.round(p.x*2+x),Math.round(p.y*2+y),1,1).data[3]>0;return n>15;}));};i.src=${JSON.stringify(result.png)};});})()`));
}
}
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+42
View File
@@ -0,0 +1,42 @@
"""Create a review sheet from native Three.js captures; validate local structure crops."""
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFont
root = Path(__file__).resolve().parents[1]
out = root / '.verify/landing-horizontal-20261109'
metadata = json.loads((out / 'horizontal-smooth.json').read_text())
raw = Image.open(out / 'horizontal.png').convert('RGBA')
smooth = Image.open(out / 'horizontal-smooth.png').convert('RGBA')
checks = []
for point in metadata['points']:
x, y = point['x'] * 2, point['y'] * 2
box = (int(x - 48), int(y - 8), int(x + 48), int(y + 120))
assert ImageChops.difference(raw.crop(box), smooth.crop(box)).getbbox() is None, point['name']
checks.append(point['name'] + ' local structure pixels unchanged')
assert metadata['along'] == 0
assert metadata['outline']['addedOutsideSourcePixels'] == 0
checks.extend(['horizontal camera has zero along-axis component', 'no pixels added outside source'])
sheet = Image.new('RGB', (1600, 1050), '#f5f8fb')
draw = ImageDraw.Draw(sheet)
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 23)
small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17)
previous = out / 'before-horizontal/okseong-model-hero.png'
panels = [
('PREVIOUS / diagonal view, rough survey boundary', previous if previous.exists() else root / '랜딩페이지/assets/okseong-model-hero.png'),
('PROPOSAL / horizontal orthographic view, inward curved boundary', out / 'horizontal-smooth.png'),
]
for i, (label, path) in enumerate(panels):
y = 25 + i * 510
draw.rounded_rectangle((20, y, 1580, y + 480), radius=20, fill='white', outline='#dce4ed')
draw.text((45, y + 20), label, font=font, fill='#19334d')
image = Image.open(path).convert('RGBA')
image = image.crop(image.getbbox())
image.thumbnail((1500, 380), Image.Resampling.LANCZOS)
sheet.paste(image, ((1600 - image.width) // 2, y + 80 + (380 - image.height) // 2), image)
if i == 1:
draw.text((45, y + 445), '6 piers + 2 abutments preserved / original survey unchanged / preview only', font=small, fill='#52667a')
sheet.save(out / 'comparison.png')
(out / 'review-report.json').write_text(json.dumps({'result': 'PASS', 'checks': checks}, indent=2))
print(f'PASS {len(checks)} review checks; {out / "comparison.png"}')
+57
View File
@@ -0,0 +1,57 @@
import * as THREE from 'three';
/** Rounded capture footprint; the underlying survey grid remains untouched. */
export function roundedTerrainPlanes(axis: THREE.Vector3, start: number, end: number, halfWidth: number, radius: number) {
const lateral = new THREE.Vector3(-axis.z, 0, axis.x);
const corners = [[end-radius,halfWidth-radius,0],[start+radius,halfWidth-radius,90],[start+radius,-halfWidth+radius,180],[end-radius,-halfWidth+radius,270]];
const points: [number,number][]=[];
for(const [s,t,angle] of corners) for(let i=0;i<=8;i++) {
const a=(angle+i*90/8)*Math.PI/180;
points.push([s+radius*Math.cos(a),t+radius*Math.sin(a)]);
}
return points.map((p,i)=>{
const q=points[(i+1)%points.length],ds=q[0]-p[0],dt=q[1]-p[1];
const normal=axis.clone().multiplyScalar(-dt).addScaledVector(lateral,ds).normalize();
const origin=axis.clone().multiplyScalar(p[0]).addScaledVector(lateral,p[1]);
return new THREE.Plane(normal,-normal.dot(origin));
});
}
/** Landing-only road ribbons, joining the photographed road to ground at each abutment. */
export function buildRoadExtensions(terrain: THREE.Mesh, anchors: {name:string;kind:string;point:THREE.Vector3}[], axis: THREE.Vector3, top: number) {
const piers=anchors.filter(a=>a.kind==='pier').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
const abuts=anchors.filter(a=>a.kind==='abutment').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
if(piers.length<2||abuts.length!==2)throw new Error('Road extensions require two abutments and at least two piers');
const group=new THREE.Group();group.name='landing-road-extensions';
const ray=new THREE.Raycaster(),down=new THREE.Vector3(0,-1,0),widthM=8;
const sample=(x:number,z:number)=>{
ray.set(new THREE.Vector3(x,top+100,z),down);
return ray.intersectObject(terrain,false)[0]?.point;
};
const descriptions=[];
const ends=[{name:'A1–P1',points:[abuts[0].point,piers[0].point,piers[1].point],from:0,to:.58},
{name:'P6–A2',points:[piers.at(-2)!.point,piers.at(-1)!.point,abuts[1].point],from:.42,to:1}];
for(const end of ends){
const curve=new THREE.CatmullRomCurve3(end.points.map(p=>new THREE.Vector3(p.x,0,p.z)),false,'catmullrom',.2);
const steps=80,positions:number[]=[],colors:number[]=[],indices:number[]=[];
let previous=-1,validSections=0,missingSections=0;
for(let i=0;i<=steps;i++){
const t=i/steps,u=end.from+(end.to-end.from)*t,p=curve.getPoint(u),tangent=curve.getTangent(u).normalize();
const cross=new THREE.Vector3(-tangent.z,0,tangent.x);
const offsets=Array.from({length:17},(_,j)=>-widthM/2+widthM*j/16);
const hits=offsets.map(o=>sample(p.x+cross.x*o,p.z+cross.z*o));
if(hits.some(h=>!h)){previous=-1;missingSections++;continue;}
const first=positions.length/3;
const blend=end.from===0?1-THREE.MathUtils.smoothstep(t,.72,1):THREE.MathUtils.smoothstep(t,0,.28);
const color=new THREE.Color('#dcd8c7').multiplyScalar(1+Math.sin(i*2.33)*.018);
hits.forEach((hit,j)=>{positions.push(hit!.x,hit!.y+.15,hit!.z);colors.push(color.r,color.g,color.b,blend*(j===0||j===offsets.length-1?0:.96));});
if(previous>=0)for(let j=0;j<offsets.length-1;j++)indices.push(previous+j,first+j,previous+j+1,previous+j+1,first+j,first+j+1);
previous=first;validSections++;
}
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,4));geometry.setIndex(indices);geometry.computeVertexNormals();
const mesh=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.DoubleSide,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
mesh.name=end.name;mesh.renderOrder=2;group.add(mesh);
descriptions.push({name:end.name,widthM,validSections,missingSections,triangles:indices.length/3,visualizationOnly:true});
}
return {group,descriptions};
}
+48
View File
@@ -0,0 +1,48 @@
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
const width=source.width,height=source.height,scale=width/2172;
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
const tops:number[]=[],bottoms:number[]=[];
for(let x=0;x<width;x++) {
let top=height,bottom=-1;
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
tops.push(top);bottoms.push(bottom);
}
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
if(!support.length)throw new Error('No continuous terrain silhouette');
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
});
const upper=eroded(tops,true),lower=eroded(bottoms,false);
const mean=(values:number[],x:number)=>{
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
return samples.reduce((a,b)=>a+b,0)/samples.length;
};
const points:[number,number][]=[];
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
const first=mid(points.at(-1)!,points[0]);
let path=`M${first[0]} ${first[1]}`;
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
const original=ctx.getImageData(0,0,width,height).data;
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
if(preserveExposedStructures){
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
ctx.putImageData(outputImage,0,0);
}
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
let before=0,after=0,addedPixels=0;
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
}
+30
View File
@@ -0,0 +1,30 @@
"""Promote one rendered camera variant; preserve the previous landing image."""
import json
import shutil
import sys
from pathlib import Path
root = Path(__file__).resolve().parents[1]
assets = root / '랜딩페이지/assets'
variant = sys.argv[1] if len(sys.argv) > 1 else 'horizontal-smooth'
if variant not in ('balanced', 'low', 'high', 'trim30', 'trim20', 'refined', 'horizontal-smooth'):
raise SystemExit('Unknown camera variant')
source = root / ('.verify/landing-horizontal-20261109' if variant == 'horizontal-smooth' else '.verify/landing-hero-20261109')
metadata = json.loads((source / f'{variant}.json').read_text())
if variant == 'horizontal-smooth':
assert metadata['along'] == 0 and metadata['outline']['addedOutsideSourcePixels'] == 0
assert metadata['piers'] == 6 and metadata['abutments'] == 2
# Full curve control points remain in the capture audit, outside the service bundle.
metadata['outline'].pop('path')
backup = source / ('before-horizontal' if variant == 'horizontal-smooth' else 'before-refined' if variant == 'refined' else 'before-trim30')
backup.mkdir(exist_ok=True)
for name in ('okseong-model-hero.png', 'okseong-model-hero.webp', 'okseong-hero-capture.ts'):
if (assets / name).exists() and not (backup / name).exists():
shutil.copyfile(assets / name, backup / name)
for kind in ('png', 'webp'):
shutil.copyfile(source / f'{variant}.{kind}', assets / f'okseong-model-hero.{kind}')
metadata['terrainFlow'] = [None if p is None else {'x': p['x'], 'y': p['y']} for p in metadata['terrainFlow']]
(assets / 'okseong-hero-capture.ts').write_text(
'// Generated by scripts/promote-landing-hero.py from actual model projection.\n'
'export const heroCapture = ' + json.dumps(metadata, ensure_ascii=False, indent=2) + ' as const;\n'
)
+123
View File
@@ -0,0 +1,123 @@
import * as THREE from 'three';
import type { ProjectFile } from '../src/core/project';
import { buildPier } from '../src/core/geometry';
import { buildAllPiers } from '../src/view/all-piers';
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
import { buildAbut } from '../src/core/abut/geometry';
import { toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
import { bridgePairs } from '../src/core/bridge-pairs';
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
import { smoothLandingOutline } from './landing-smooth-outline';
/** Actual structure/terrain capture with optional landing-only road extensions. */
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
for (const rec of project.abutments ?? []) {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
pairs,slopeI:rec.slopeI,
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
});
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
}
const layers = project.groundModel?.mapLayers ?? [];
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
let mapError = false;
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
const deadline=performance.now()+15000;
while (!maps.children.every(m => {
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
return image?.complete && image.naturalWidth>0;
})) {
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
await new Promise(resolve=>setTimeout(resolve,20));
}
if (mapError) throw new Error('Map texture loading failed');
scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
if (endMarginM !== undefined) {
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
if(ends.length!==2)throw new Error('End margin requires both abutments');
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
}
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
if(road)scene.add(road.group);
if(cutawayFrontM!==undefined){
const front=new THREE.Vector3(-axis.z,0,axis.x);
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
for(const mesh of [all.terrain,...maps.children])if(mesh){
const material=(mesh as THREE.Mesh).material as THREE.Material;
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
}
}
scene.updateMatrixWorld(true);
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
const bounds = new THREE.Box3();
scene.traverse(obj => {
if (!(obj instanceof THREE.Mesh)) return;
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
const pos = obj.geometry.getAttribute('position');
for (let i=0;i<pos.count;i++) {
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
}
});
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
let terrainCanvas:HTMLCanvasElement|undefined;
if(smoothOutline){
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
hidden.forEach(o=>{o.visible=true;});
}
renderer.render(scene, camera);
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
for(let i=0;i<ordered.length-1;i++){
const from=ordered[i].point,to=ordered[i+1].point;
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
for(let j=i===0?0:1;j<=steps;j++){
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
if(!hit){terrainFlow.push(null);continue;}
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
}
}
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
const output=smooth?.canvas??renderer.domElement;
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
disposeTerrainMapGroup(maps);
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
renderer.dispose();renderer.forceContextLoss();return result;
}
+64
View File
@@ -0,0 +1,64 @@
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import * as XLSX from 'xlsx';
import * as THREE from 'three';
import { it, expect } from 'vitest';
import { parseGroundModel } from '../src/core/ground-model';
import { parseWorkbook } from '../src/core/parseWorkbook';
import { buildPier } from '../src/core/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildAbut } from '../src/core/abut/geometry';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { terrainGeometry } from '../src/view/terrain';
import { terrainMapGeometry } from '../src/view/terrain-map';
it('records terrain and map placement against the actual Okseong project', () => {
const folder = '/mnt/c/Users/User/Downloads/NAVER WORKS';
const ground = parseGroundModel(readFileSync(path.join(folder, '옥성교 지형.json'), 'utf8'));
const wb = XLSX.read(readFileSync(path.join(folder, '옥성교 데이터_261106.xlsx')), { type: 'buffer' });
const piers = parseWorkbook(wb).piers;
const abuts = parseAbutments(wb, piers.map(p => p.name)).abutments;
const pivot = buildPier(piers[0]).pivot, grid = ground.terrainGrid!;
const terrain = terrainGeometry(grid, pivot), pos = terrain.getAttribute('position');
const layers = ground.mapLayers!.map(layer => {
const g = terrainMapGeometry(layer, pivot, 0, grid);
return { id: layer.id, corners: layer.survey_corners, vertices: g.getAttribute('position').count, triangles: g.getIndex()!.count / 3 };
});
const duplicates = abuts.map(rec => {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const root = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab);
const shared = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, undefined, undefined, true);
expect(shared.getObjectByName('ground')).toBeUndefined();
root.updateMatrixWorld(true);
const meshes: object[] = [];
root.getObjectByName('ground')?.traverse(obj => {
if (!(obj instanceof THREE.Mesh)) return;
const vertices = obj.geometry.getAttribute('position');
let maxDisplacement = 0;
if (vertices.count === pos.count) for (let i = 0; i < vertices.count; i++) {
const actual = new THREE.Vector3().fromBufferAttribute(vertices, i).applyMatrix4(obj.matrixWorld);
const expected = new THREE.Vector3().fromBufferAttribute(pos, i);
maxDisplacement = Math.max(maxDisplacement, actual.distanceTo(expected));
}
meshes.push({ vertices: vertices.count, triangles: obj.geometry.getIndex()?.count! / 3, sameGrid: vertices.count === pos.count, maxDisplacementM: maxDisplacement });
});
const gx = (rec.ip2.n - grid.origin[0]) / grid.step, gy = (rec.ip2.e - grid.origin[1]) / grid.step;
const i = Math.floor(gx), j = Math.floor(gy);
const inside = i >= 0 && j >= 0 && i + 1 < grid.nx && j + 1 < grid.ny;
const heights = inside ? [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]] : [];
const valid = heights.length === 4 && heights.every(h => h !== null);
const u = gx - i, v = gy - j;
const sampledAtIp2 = valid ? heights[0]! * (1-u) * (1-v) + heights[1]! * u * (1-v) + heights[2]! * (1-u) * v + heights[3]! * u * v : null;
return { abutment: rec.name, excelGroundEl: rec.groundEl, ip2: rec.ip2, modelEntry: ground.piers.some(p => p.id === rec.name), ip2GridSample: sampledAtIp2, meshes };
});
const values = grid.z.filter((v): v is number => v !== null);
const report = { grid: { origin: grid.origin, step: grid.step, nx: grid.nx, ny: grid.ny, valid: values.length, total: grid.z.length, min: Math.min(...values), max: Math.max(...values) }, terrain: { vertices: pos.count, triangles: terrain.getIndex()!.count / 3 }, layers, duplicates };
const out = path.resolve('.verify/terrain-review-20261109'); mkdirSync(out, { recursive: true });
writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report));
expect(layers.every(layer => layer.triangles === report.terrain.triangles)).toBe(true);
expect(duplicates.every(d => d.meshes.every(m => (m as { maxDisplacementM: number }).maxDisplacementM < 0.001))).toBe(true);
});
+55
View File
@@ -0,0 +1,55 @@
"""Apply the project ground manager integration to the existing application."""
from pathlib import Path
root = Path(__file__).resolve().parents[1]
p = root / 'src/main.ts'
s = p.read_text()
s = "import { createProjectGroundManager } from './view/project-ground';\nimport { applyProjectGround, groundInputChanged } from './core/project-ground';\nimport { registerGroundImporter } from '../pierzainer_ground/ground-link';\n" + s
s = s.replace('let groundModel: GroundModelFile | null = null;', 'let groundModel: GroundModelFile | null = null;\nlet groundProjectVersion = 0;\nlet groundImportSerial = 0;')
marker = 'function el<K extends keyof HTMLElementTagNameMap>'
insert = '''async function importProjectGround(text: string, name: string): Promise<void> {
const version = groundProjectVersion, serial = ++groundImportSerial;
const imported = parseGroundModel(text);
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
groundModel = imported;
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.visible.ground = true;
setupScene(); renderAll();
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
}
registerGroundImporter(importProjectGround);
const projectGround = createProjectGroundManager({
snapshot: () => ({ model: groundModel, piers: state.piers }),
importModel: importProjectGround,
apply: (names, fields) => {
if (!groundModel) return;
for (const p of state.piers) if (names.has(p.input.name)) p.draft = applyProjectGround(p.draft, groundModel, fields);
renderAll();
},
regenerate: () => {
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
setupScene(); renderAll();
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
},
});
function resetProjectGround(): void {
groundProjectVersion++; groundImportSerial++;
projectGround.reset();
window.dispatchEvent(new Event('pierzainer:project-changed'));
}
'''
assert marker in s
s = s.replace(marker, insert + marker, 1)
s = s.replace(' updatePartToggles();\n $<HTMLButtonElement>', ' updatePartToggles();\n projectGround.refresh();\n $<HTMLButtonElement>', 1)
start = s.index(' // ② 지반 모델 (승인 2026-10-23):')
end = s.index(' if (!groundModel) {', start)
s = s[:start] + " wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));\n\n" + s[end:]
s = s.replace(' * The ground model import and the 지반고 apply stay at the bottom, as before.', ' * Project model generation/import is managed from ①; this card retains local inputs.')
s = s.replace(' state.project = { ...parsed.project };', ' resetProjectGround();\n state.project = { ...parsed.project };', 1)
marker = " state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };"
assert marker in s
s = s.replace(marker, " resetProjectGround();\n groundModel = null;\n groundMapMode = 'none';\n setGroundMapMode(groundMapMode);\n" + marker, 1)
p.write_text(s)
+95
View File
@@ -0,0 +1,95 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/ground-abutments-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const selectFile=async(selector,files)=>{
const doc=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector});
await send('DOM.setFileInputFiles',{nodeId,files});
};
const folder='C:\\Users\\User\\Downloads\\NAVER WORKS';
await ev(`window.openSampleProject()`);
await selectFile('#file',[path.join(folder,'옥성교 데이터_261106.xlsx')]);
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')`);
await ev(`(()=>{const e=document.getElementById('project-name');e.value='교대 지반 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('manager lists six piers and both abutments before model import',await ev(`document.querySelectorAll('[data-ground-pier]').length===8&&document.querySelectorAll('[data-ground-kind="교대"]').length===2`));
await selectFile('#ground-import',[path.join(folder,'옥성교 지형.json')]);
await until(`document.getElementById('project-ground-status').textContent.includes('8/8')`);
check('both abutments show IP2 grid source and finite comparison elevations',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.closest('tr').textContent.includes('IP2 격자 보간')&&!e.disabled)`));
const values=()=>ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(r=>({name:r.children[1].textContent,el:Number(r.children[2].textContent),model:Number(r.children[3].textContent)}))`);
const before=await values();
check('import alone preserves original abutment inputs',before.find(r=>r.name==='A1').el===11.24&&before.find(r=>r.name==='A2').el===14.43);
await click(`document.querySelector('[data-ground-pier="A1"]')`);
await click(`document.getElementById('project-ground-apply')`);
const one=await values();
check('only selected A1 changes; A2 and P1 remain unchanged',one.find(r=>r.name==='A1').el===11.789&&one.find(r=>r.name==='A2').el===14.43&&one.find(r=>r.name==='P1').el===before.find(r=>r.name==='P1').el);
await click(`document.getElementById('project-ground-select-all')`);
check('select all includes both types',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===8`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await values();
check('A2 receives IP2 interpolation',applied.find(r=>r.name==='A2').el===14.61);
check('pier regeneration remains explicit',await ev(`!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('pending pier changes regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
check('missing abutment soil layers cannot be applied',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.disabled)`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await shot('01-unified-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await shot('02-shared-terrain');
await ev(`(()=>{const original=URL.createObjectURL;window.__saved=null;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__saved=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await click(`document.getElementById('save-project')`);
await until(`document.getElementById('save-dialog').open`);
await click(`document.getElementById('sv-save')`);
await until(`!!window.__saved?.abutments`);
const saved=await ev(`window.__saved`);
check('saved project retains both abutment elevations and Excel source',Math.abs(saved.abutments.find(a=>a.name==='A1').groundEl-11.7886759049651)<1e-6&&Math.abs(saved.abutments.find(a=>a.name==='A2').groundEl-14.610314099767017)<1e-6&&saved.source.fileName==='옥성교 데이터_261106.xlsx');
const savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,JSON.stringify(saved));
await selectFile('#project-file',[savedPath]);
await click(`document.getElementById('project-ground-open')`);
check('reopening project restores abutment inputs', (await values()).filter(r=>r.name==='A1'||r.name==='A2').every(r=>Math.abs(r.el-r.model)<0.001));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+168
View File
@@ -0,0 +1,168 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/ground-import-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
// Real generated JSON and the adjacent settings file reproduce the reported failure.
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
const actualModel=path.join(actualFolder,'ground_model.json');
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
const input={...fixture.piers[0].input,name:'P'+(i+1)};
return{id:input.name,input,defaults:input,draft:input};
})};
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
await selectFile('#project-file',[sixFile]);
await until(`document.querySelectorAll('#pier-items button').length===6`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
await selectFile('#ground-import',[actualModel]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
await click(`document.getElementById('project-ground-regenerate')`);
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
await shot('02-real-okseong');
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+85
View File
@@ -0,0 +1,85 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/landing-horizontal-20261109/applied-browser');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
await until(`document.querySelector('.landing-promo-image')?.complete&&document.querySelector('.landing-promo-image').naturalWidth>0`);
await delay(1800);
check('landing uses the new high-resolution WebP',await ev(`document.querySelector('.landing-promo-image').src.includes('okseong-model-hero.webp')&&document.querySelector('.landing-promo-image').naturalWidth===4344`));
check('eight projected markers include both abutments',await ev(`document.querySelectorAll('.landing-pier-beacon').length===8&&!!document.querySelector('[data-structure="A1"]')&&!!document.querySelector('[data-structure="A2"]')`));
check('30m end margin is active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.endMarginM===30;})()`));
check('taller frame and rounded terrain are active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.height===870&&heroCapture.cornerRadiusM===18&&document.querySelector('.landing-promo-image').naturalHeight===1740;})()`));
check('horizontal view and smooth inward outline are applied',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.along===0&&heroCapture.outline.method==='inward-alpha-envelope-quadratic'&&heroCapture.outline.addedOutsideSourcePixels===0&&heroCapture.outline.retainedTerrainRatio>.9;})()`));
const expectedHash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.resolve(__dirname,'../.verify/landing-horizontal-20261109/horizontal-smooth.webp'))).digest('hex');
check('served landing image matches the reviewed horizontal capture',await ev(`(async()=>{const bytes=await(await fetch(document.querySelector('.landing-promo-image').src)).arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');return hash===${JSON.stringify(expectedHash)};})()`));
check('both road extensions follow valid terrain sections',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.roads.length===2&&heroCapture.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.visualizationOnly);})()`));
check('decorative frame includes both abutments',await ev(`(()=>{const box=document.querySelector('.landing-hud-bracket').getBBox(),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return box.x<a.e&&box.x+box.width>b.e;})()`));
check('restored light follows projected structures from left to right',await ev(`(()=>{const path=document.getElementById('landing-alignment-path'),first=path.getPointAtLength(0),last=path.getPointAtLength(path.getTotalLength()),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return first.x<last.x&&Math.abs(first.x-a.e)<.1&&Math.abs(last.x-b.e)<.1;})()`));
check('light follows dense terrain samples rather than coping elevations',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');const path=document.getElementById('landing-alignment-path'),samples=heroCapture.terrainFlow.filter(Boolean),first=path.getPointAtLength(0),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix;return samples.length>100&&(path.getAttribute('d').match(/L/g)||[]).length>100&&Math.abs(first.y-samples[0].y)<.1&&Math.abs(first.y-a.f)>10;})()`));
check('vertical scan, moving grid, and rising sparks are removed',await ev(`!document.querySelector('.landing-scan-mask,.landing-scan-beam,.landing-terrain-grid,.landing-pier-spark')&&![...document.querySelectorAll('.landing-hero *')].some(e=>e.getAnimations().some(a=>['landing-scan-pass','landing-beacon-spark','landing-grid-breathe'].includes(a.animationName)))`));
check('traveling light is white',await ev(`[...document.querySelectorAll('#landing-flow-fade stop')].every(e=>getComputedStyle(e).stopColor==='rgb(255, 255, 255)')&&getComputedStyle(document.querySelector('.landing-energy-track')).stroke==='rgb(255, 255, 255)'`));
const offset=async time=>ev(`(()=>{const e=document.querySelector('.landing-energy-flow'),a=e.getAnimations()[0];if(!a)throw new Error('Missing flow animation');a.pause();a.currentTime=${time};return parseFloat(getComputedStyle(e).strokeDashoffset);})()`);
const left=await offset(3500),right=await offset(5000);
check('light actually advances along the path over time',right<left);
await shot('light-moving');
await ev(`document.querySelector('.landing-energy-flow').getAnimations()[0].play()`);
check('asset has transparent background and opaque terrain',await ev(`(()=>{const c=document.createElement('canvas');c.width=100;c.height=34;const ctx=c.getContext('2d');ctx.drawImage(document.querySelector('.landing-promo-image'),0,0,100,34);const data=ctx.getImageData(0,0,100,34).data;let lo=255,hi=0;for(let i=3;i<data.length;i+=4){lo=Math.min(lo,data[i]);hi=Math.max(hi,data[i]);}return lo===0&&hi===255;})()`));
await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
for(const [width,height] of [[1920,1080],[1600,900],[1280,800],[390,844]]){
await send('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:width<760});
await delay(200);
await shot(`${width}-landing`);
report.layout=await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].map(e=>({text:e.textContent,rect:e.getBoundingClientRect().toJSON()}))`);
check(`no horizontal overflow at ${width}`,await ev(`document.documentElement.scrollWidth<=innerWidth`));
check(`all entry actions remain visible at ${width}`,await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].every(e=>{const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return r.width>0&&r.height>0&&r.left>=0&&r.right<=innerWidth+1&&r.top>=0&&r.bottom<=innerHeight&&!!hit&&(hit===e||e.contains(hit));})`));
check(`image and projected markers align at ${width}`,await ev(`(()=>{const img=document.querySelector('.landing-promo-image').getBoundingClientRect(),svg=document.querySelector('.landing-model-motion'),view=svg.viewBox.baseVal;return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const t=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=t.e;p.y=t.f;const actual=p.matrixTransform(svg.getScreenCTM());return Math.abs(actual.x-(img.left+t.e/view.width*img.width))<2&&Math.abs(actual.y-(img.top+t.f/view.height*img.height))<2;});})()`));
check(`first-visit guide does not cover structures at ${width}`,await ev(`(()=>{const hint=document.querySelector('.landing-guide-hint');if(hint.hidden)return true;const r=hint.getBoundingClientRect(),svg=document.querySelector('.landing-model-motion');return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const m=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=m.e;p.y=m.f;const q=p.matrixTransform(svg.getScreenCTM());return q.x<r.left||q.x>r.right||q.y<r.top||q.y>r.bottom;});})()`));
await shot(`${width}-landing`);
}
check('reduced motion stops beacon animation',await ev(`getComputedStyle(document.querySelector('.landing-pier-pulse')).animationName==='none'`));
check('reduced motion stops light travel',await ev(`getComputedStyle(document.querySelector('.landing-energy-flow')).animationName==='none'`));
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await click(`document.querySelector('[data-card="sample"] .landing-primary')`);
check('design library still opens',await ev(`document.querySelector('.sample-library-popup').open`));
await click(`document.querySelector('.sample-library-close')`);
await click(`document.querySelector('[data-card="excel"] .landing-primary')`);
check('Excel entry still opens',await ev(`document.getElementById('import-dialog').open`));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+200
View File
@@ -0,0 +1,200 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/okseong-user-files-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
// Real generated JSON and the adjacent settings file reproduce the reported failure.
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
const actualModel=path.join(actualFolder,'ground_model.json');
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
const input={...fixture.piers[0].input,name:'P'+(i+1)};
return{id:input.name,input,defaults:input,draft:input};
})};
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
await selectFile('#project-file',[sixFile]);
await until(`document.querySelectorAll('#pier-items button').length===6`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
await selectFile('#ground-import',[actualModel]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
await click(`document.getElementById('project-ground-regenerate')`);
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
await shot('02-real-okseong');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
const userFolder=process.env.OKSEONG_FILES||'C:\\Users\\User\\Downloads\\NAVER WORKS';
const userExcel=path.join(userFolder,'옥성교 데이터_261106.xlsx'),userJson=path.join(userFolder,'옥성교 지형.json');
await selectFile('#file',[userExcel]);
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')&&!document.getElementById('dlg-apply').disabled`);
await ev(`document.getElementById('import-dialog').showModal()`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
const before=await ev(`[...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent)`);
const projectBefore=await ev(`document.getElementById('project-line').textContent`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[userJson]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('user JSON imports six elevations plus terrain and two maps',await ev(`document.getElementById('project-ground-info').textContent.includes('지도 2개')&&document.getElementById('project-ground-info').textContent.includes('지형 있음')`));
check('importing embedded model preserves user Excel inputs and project',await ev(`JSON.stringify([...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent))`)===JSON.stringify(before)&&await ev(`document.getElementById('project-line').textContent`)===projectBefore);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
await click(`document.getElementById('project-ground-apply')`);
const userExpected=JSON.parse(fs.readFileSync(userJson,'utf8')).groundModel;
const userApplied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('user Excel accepts all six elevations from embedded ground model',userApplied.length===6&&userApplied.every((el,i)=>el===userExpected.piers[i].centerEl));
await click(`document.getElementById('project-ground-regenerate')`);
check('user project regenerates after ground application',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await shot('03-user-files-applied');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await ev(`window.__savedGround=null`);
await click(`document.getElementById('save-project')`);
await until(`document.getElementById('save-dialog').open`);
await click(`document.getElementById('sv-save')`);
await until(`window.__savedGround?.source?.fileName==='옥성교 데이터_261106.xlsx'`);
check('saved result retains user Excel source and applied embedded terrain',await ev(`window.__savedGround.piers.length===6&&window.__savedGround.piers[0].input.groundEl===18.011&&window.__savedGround.groundModel.mapLayers.length===2`));
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+138
View File
@@ -0,0 +1,138 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/project-ground-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+97
View File
@@ -0,0 +1,97 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/quantity-generation-20261106');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+109
View File
@@ -0,0 +1,109 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/quantity-navigation-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
check('takeoff displays input, results and preview panels', await ev(`document.querySelectorAll('.tq-dashboard-grid > section, .tq-dashboard-grid > aside').length===3&&!!document.querySelector('.takeoff-excel-preview')`));
await shot('04-takeoff');
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut preserves takeoff tab and selected pier', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="takeoff"]').getAttribute('aria-pressed')==='true'&&document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
check('quantity header alone is highlighted', await ev(`document.getElementById('nav-quantity').getAttribute('aria-current')==='true'&&document.getElementById('nav-rebar').getAttribute('aria-current')==='false'`));
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
await click(`document.getElementById('nav-quantity')`);
check('header entry restores previous result pier after another screen changes selection', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('rebar shortcut preserves materials tab', await ev(`document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+120
View File
@@ -0,0 +1,120 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/takeoff-alignment-20261106/browser');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Browser.setDownloadBehavior', {behavior:'deny'}); // Request transport is mocked below; native Excel is verified separately.
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
for(const pier of fixture.piers){
const settings={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
for(const key of ['input','defaults','draft'])pier[key]={...pier[key],quantityTakeoff:settings};
}
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
check('selected and all Excel exports are separate', await ev(`document.querySelector('[data-takeoff-export="selected"]').textContent==='선택 교각 Excel'&&document.querySelector('[data-takeoff-export="all"]').textContent==='전체 교각 Excel'`));
await click(`document.querySelector('button[aria-label="수량산출 재계산"]')`);
await ev(`(()=>{
const original=window.fetch;window.__takeoffRequests=[];
window.fetch=(url,options)=>String(url).includes('/__pierzainer/quantity/export')?new Promise(resolve=>{window.__takeoffRequests.push(JSON.parse(options.body));window.__finishTakeoff=()=>resolve(new Response(new Uint8Array([80,75]),{status:200}));}):original(url,options);
})()`);
await click(`document.querySelector('[data-takeoff-export="selected"]')`);
await until(`window.__takeoffRequests.length===1`);
check('selected request carries one pier and model solplate mode', await ev(`window.__takeoffRequests[0].piers.length===1&&window.__takeoffRequests[0].piers[0].pier==='P1'&&window.__takeoffRequests[0].piers[0].solplateMode==='model'`));
await ev(`(()=>{const e=document.querySelector('input[data-variable="r21"]');e.value='4';e.dispatchEvent(new Event('change'));})()`);
check('rerender during export keeps both export buttons locked', await ev(`[...document.querySelectorAll('[data-takeoff-export]')].every(b=>b.disabled)`));
await ev(`window.__finishTakeoff()`);
await until(`!document.querySelector('[data-takeoff-export="selected"]').disabled`);
check('async export preserves edited input and displays snapshot warning', await ev(`document.querySelector('input[data-variable="r21"]').value==='4'&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('입력은 보존')`));
const requests=await ev(`window.__takeoffRequests.length`);
await click(`document.querySelector('[data-takeoff-export="all"]')`);
await delay(200);
check('all export validates other incomplete piers before requesting server', await ev(`window.__takeoffRequests.length===${requests}&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('P3')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+13
View File
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
export function checkGroundModel(raw: unknown): GroundModelFile { export function checkGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null; const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.'); if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
if (!('schema' in obj) && 'schemaVersion' in obj) {
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
return checkStoredGroundModel(project.groundModel);
}
const knownSchema = schemaOrLegacy(obj); const knownSchema = schemaOrLegacy(obj);
if (knownSchema === undefined) { if (knownSchema === undefined) {
const data = obj as Record<string, unknown>;
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
}
if (!('schema' in data)) {
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
}
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`); throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
} }
if (!READABLE_SCHEMAS.includes(knownSchema as number)) { if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
+34
View File
@@ -0,0 +1,34 @@
import type { GroundModelFile, TerrainGrid } from './ground-model';
import type { AbutRecord } from './abut/parse';
import type { PierInput } from './types';
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
const entry = model.piers.find(p => p.id === draft.name);
if (!entry) return draft;
return { ...draft,
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
};
}
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
}
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
if (h.some(v => v == null || !Number.isFinite(v))) return null;
const u = x - i, v = y - j;
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
}
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
const explicit = model?.piers.find(p => p.id === abut.name);
if (explicit) return { ...explicit, source: 'JSON 개별값' };
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
}
+56
View File
@@ -0,0 +1,56 @@
import * as XLSX from 'xlsx';
import { quantityRows, type Quantity } from './rebar/quantity';
export interface QuantityRecord {
name: string;
quantity: Quantity | null;
dirty: boolean;
}
/** Export checks use the same records as the visible result, including ungenerated piers. */
export function quantityExportIssues(records: QuantityRecord[]): string[] {
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
}
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
line.pieces.forEach((p, i) => {
const totalM = p.length * p.count / 1000;
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
});
}
return rows;
}
/** Generated values remain numbers; only the display format rounds them. */
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
const issues = quantityExportIssues(records);
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
const sheetName = (name: string) => {
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
let candidate = base.slice(0, 31), n = 1;
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
used.add(candidate.toLowerCase()); return candidate;
};
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
let kg = 0, kgWithWaste = 0;
for (const r of records) {
const q = r.quantity!;
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
kg += q.kg; kgWithWaste += q.kgWithWaste;
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
q.missing.forEach(m => review.push([r.name, m]));
}
const partial = records.some(r => r.quantity!.missing.length);
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
return wb;
}
+2 -2
View File
@@ -110,11 +110,11 @@ export function buildQuantity(rows: RebarRow[], material: Material, incomplete:
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */ /** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
export function quantityRows(q: Quantity): (string | number)[][] { export function quantityRows(q: Quantity): (string | number)[][] {
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']]; const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수', '부재']];
for (const g of q.groups) { for (const g of q.groups) {
for (const d of g.diameters) { for (const d of g.diameters) {
for (const l of d.lines) { for (const l of d.lines) {
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg)]); out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg), l.pieceCount, l.member]);
} }
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]); out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
} }
+134 -70
View File
@@ -1,3 +1,7 @@
import { createProjectGroundManager } from './view/project-ground';
import { hasTerrainSurface } from './view/terrain';
import { abutGroundFromModel, applyProjectGround, groundInputChanged } from './core/project-ground';
import { registerGroundImporter } from '../pierzainer_ground/ground-link';
import './style.css'; import './style.css';
import { createDrawingWorkspace } from '../도면작성/workspace'; import { createDrawingWorkspace } from '../도면작성/workspace';
import { createTakeoffWorkspace } from '../수량산출/workspace'; import { createTakeoffWorkspace } from '../수량산출/workspace';
@@ -86,13 +90,14 @@ import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets'; import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
import { quantityTargetNames } from './core/quantity-scope'; import { quantityTargetNames } from './core/quantity-scope';
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total'; import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows, type QuantityRecord } from './core/quantity-workbook';
import { defaultLevelWidthMm, pileGeometry } from './core/geometry'; import { defaultLevelWidthMm, pileGeometry } from './core/geometry';
import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { rebarSummary, type RebarRow } from './core/rebar/summary';
import { shearSegments } from './core/rebar/coping'; import { shearSegments } from './core/rebar/coping';
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
import { buildAbutRebar3D } from './view/abut-rebar3d'; import { buildAbutRebar3D } from './view/abut-rebar3d';
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common'; import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { buildQuantity, type Quantity, type QuantityLine } from './core/rebar/quantity';
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model'; import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground'; import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout'; import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
@@ -130,6 +135,8 @@ import { type BarSummary, elevationRows, solplateRows } from './core/bearing-tab
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */ /** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
let groundModel: GroundModelFile | null = null; let groundModel: GroundModelFile | null = null;
let groundProjectVersion = 0;
let groundImportSerial = 0;
import type { TypeFilter } from './core/selection'; import type { TypeFilter } from './core/selection';
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
@@ -147,8 +154,9 @@ interface Pier extends PierValues {
} }
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */ /** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성 (origin). 'quantity' is the 재료표 · 수량산출 screen opened from ③ (시안: no header step, 2026-11-06). */ /** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성, ⑤ 재료표·수량산출. */
type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity'; type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity';
let quantitySelected: string | null = null;
/** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */ /** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */
const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [ const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
@@ -284,18 +292,67 @@ const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id)
const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({ const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
project: state.project, selected: state.active, project: state.project, selected: state.active,
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })), piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
}), selectPier); }), selectPier, () => openQuantity());
const quantityRebarTon = new Map<string,number|null>(); const quantityResults = new Map<string, Quantity>();
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({ const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
project: state.project, selected: state.active, 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)})), piers: state.piers.map(p => {
const q = quantityResults.get(p.input.name);
return {input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:q&&!q.missing.length?q.kg/1000:null,rebarIssues:q?.missing};
}),
}), selectPier, (name,data) => { }), selectPier, (name,data) => {
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return; 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}; pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
renderSaveStatus();scheduleBackup(); renderSaveStatus();scheduleBackup();
}); });
async function importProjectGround(text: string, name: string): Promise<void> {
const version = groundProjectVersion, serial = ++groundImportSerial;
const imported = parseGroundModel(text);
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
groundModel = imported;
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.visible.ground = true;
setupScene(); renderAll();
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
}
registerGroundImporter(importProjectGround);
const projectGround = createProjectGroundManager({
snapshot: () => ({ model: groundModel, piers: state.piers, abutments: state.abutments }),
importModel: async file => {
const version = groundProjectVersion, serial = ++groundImportSerial;
const text = await file.text();
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 파일을 불러오지 않았습니다.');
await importProjectGround(text, file.name);
},
apply: (names, fields) => {
if (!groundModel) return;
for (const p of state.piers) if (names.has(`pier:${p.input.name}`)) p.draft = applyProjectGround(p.draft, groundModel, fields);
state.abutments = state.abutments.map(a => {
if (!names.has(`abut:${a.name}`)) return a;
const entry = abutGroundFromModel(groundModel, a);
let next = fields.ground && entry.centerEl !== null ? applyAbutEdit(a, 'groundEl', entry.centerEl) : a;
if (fields.layers && entry.layers?.length) next = setAbutGround(next, { strataRows: entry.layers.map(row => ({ ...row })) });
return next;
});
setupScene();
renderAll();
},
regenerate: () => {
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
setupScene(); renderAll();
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
},
});
function resetProjectGround(): void {
groundProjectVersion++; groundImportSerial++;
projectGround.reset();
window.dispatchEvent(new Event('pierzainer:project-changed'));
}
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] { function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag); const node = document.createElement(tag);
if (text !== undefined) node.textContent = text; if (text !== undefined) node.textContent = text;
@@ -687,7 +744,8 @@ function setupScene(): void {
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])), inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])),
}); });
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles, const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles,
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount)); jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount),
!!(groundModel?.terrainGrid && hasTerrainSurface(groundModel.terrainGrid) && shownPiers().some(p => p.model) && piersShown(state.stage, state.abutActive)));
group.visible = abutShown(state.stage, state.abutActive, rec.name); group.visible = abutShown(state.stage, state.abutActive, rec.name);
// The 부재 표시 toggles hold for a new abutment group too. // The 부재 표시 toggles hold for a new abutment group too.
for (const [name, on] of Object.entries(state.abutVisible)) { for (const [name, on] of Object.entries(state.abutVisible)) {
@@ -2214,6 +2272,9 @@ let lastScrolledPier: string | null = null;
function renderAll(): void { function renderAll(): void {
updatePartToggles(); updatePartToggles();
projectGround.refresh();
$<HTMLButtonElement>('rebar-materials').disabled = !state.piers.length;
$('rebar-materials').title = '교각 재료표·수량산출을 엽니다. 교대는 집계 대상에서 제외됩니다.';
const allToggle = $<HTMLButtonElement>('all-toggle'); const allToggle = $<HTMLButtonElement>('all-toggle');
allToggle.setAttribute('aria-pressed', String(state.allView)); allToggle.setAttribute('aria-pressed', String(state.allView));
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`; allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
@@ -2701,6 +2762,11 @@ function setStage(stage: Stage): void {
// ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered. // ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered.
if (stage === 'shape' && state.stage !== 'shape') state.allView = true; if (stage === 'shape' && state.stage !== 'shape') state.allView = true;
const previous = state.stage; const previous = state.stage;
if (previous === 'quantity') quantitySelected = state.active;
if (stage === 'quantity') {
if (quantitySelected && state.piers.some(p => p.input.name === quantitySelected)) state.active = quantitySelected;
state.abutActive = null;
}
state.stage = stage; state.stage = stage;
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout. // Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
// Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then. // Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then.
@@ -2719,8 +2785,7 @@ function setStage(stage: Stage): void {
placeViewer(stage); placeViewer(stage);
if (rebar3d) rebar3d.group.visible = stage === 'rebar'; if (rebar3d) rebar3d.group.visible = stage === 'rebar';
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech. // The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
// The 재료표 screen belongs to ③, so ③ stays highlighted while it is open. const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar']], ['nav-drawing', ['drawing']], ['nav-quantity', ['quantity']]];
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar', 'quantity']], ['nav-drawing', ['drawing']]];
for (const [id, keys] of nav) { for (const [id, keys] of nav) {
const btn = $<HTMLButtonElement>(id); const btn = $<HTMLButtonElement>(id);
btn.classList.toggle('current', keys.includes(stage)); btn.classList.toggle('current', keys.includes(stage));
@@ -4568,15 +4633,19 @@ function renderQuantityLink(box: HTMLElement, rows: RebarRow[], incomplete: read
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote'); const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
const link = el('button', '철근재료표 보기', 'btn small'); const link = el('button', '철근재료표 보기', 'btn small');
link.type = 'button'; link.type = 'button';
link.addEventListener('click', () => setStage('quantity')); link.addEventListener('click', () => openQuantity());
box.append(line, link); box.append(line, link);
} }
function openQuantity(): void {
setStage('quantity');
}
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */ /** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
/** The piers the 재료표 shows: 전체, or the pier chosen in the list. */ /** The piers the 재료표 shows: 전체, or the pier chosen in the list. */
function quantityTargets(): Pier[] { function quantityTargets(): Pier[] {
const names = new Set(quantityTargetNames(state.piers.map((p) => ({ name: p.input.name, hasModel: p.model !== null })), state.quantityAll, state.active)); const names = new Set(quantityTargetNames(state.piers.map((p) => ({ name: p.input.name, hasModel: p.model !== null })), state.quantityAll, state.active));
return state.piers.filter((p) => names.has(p.input.name)); return state.piers.filter((p) => state.quantityAll || names.has(p.input.name));
} }
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */ /** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
@@ -4591,7 +4660,7 @@ function quantityTable(q: Quantity): HTMLTableElement {
const table = el('table', undefined, 'grid-table rebar-table'); const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead'); const head = el('thead');
const hr = el('tr'); const hr = el('tr');
for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h)); for (const h of ['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수']) hr.appendChild(el('th', h));
head.appendChild(hr); head.appendChild(hr);
table.appendChild(head); table.appendChild(head);
const body = el('tbody'); const body = el('tbody');
@@ -4600,12 +4669,12 @@ function quantityTable(q: Quantity): HTMLTableElement {
for (const l of d.lines) { for (const l of d.lines) {
body.appendChild(el('tr')).append( body.appendChild(el('tr')).append(
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)), el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)), lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)), el('td', String(l.pieceCount)),
); );
} }
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1))); body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)), el('td', String(d.lines.reduce((n,l)=>n+l.pieceCount,0))));
} }
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1))); body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)), el('td', ''));
} }
table.appendChild(body); table.appendChild(body);
return table; return table;
@@ -4617,17 +4686,27 @@ function renderQuantityScreen(): void {
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity'); renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
const allButton = $<HTMLButtonElement>('quantity-all'); const allButton = $<HTMLButtonElement>('quantity-all');
const generated = state.piers.filter((p) => p.model).length; const generated = state.piers.filter((p) => p.model).length;
allButton.textContent = `전체 합계 (${generated}기)`; allButton.textContent = `교각 전체 (${state.piers.length}기 · 생성 ${generated}기)`;
allButton.setAttribute('aria-pressed', String(state.quantityAll)); allButton.setAttribute('aria-pressed', String(state.quantityAll));
const targets = quantityTargets(); const targets = quantityTargets();
$('quantity-note').textContent = targets.length $('quantity-note').textContent = targets.length
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.` ? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
: '계산된 교각이 없습니다. 교각을 생성하세요.'; : '계산된 교각이 없습니다. 교각을 생성하세요.';
const entries = targets.flatMap((pier) => quantityResults.clear();
pier.model ? [{ pier, q: pierQuantity(pier) }] : [], for (const pier of state.piers) if (pier.model) quantityResults.set(pier.input.name, pierQuantity(pier));
); const records = quantityRecords(targets), issues = quantityExportIssues(records);
quantityRebarTon.clear(); const entries = targets.flatMap(pier => {
for(const {pier,q} of entries)quantityRebarTon.set(pier.input.name,q.missing.length?null:q.kg/1000); const q = quantityResults.get(pier.input.name);
return q ? [{pier,q}] : [];
});
const partial = entries.some(e=>e.q.missing.length);
const output = $<HTMLButtonElement>('quantity-export');
output.disabled = !targets.length || issues.length > 0;
output.textContent = partial ? '검토용 Excel' : 'Excel 출력';
output.title = issues.join(' · ') || (partial ? '미계산 항목을 제외한 검토용 소계입니다.' : '현재 계산 결과를 출력합니다.');
$<HTMLButtonElement>('quantity-print').disabled = output.disabled;
if (issues.length) body.appendChild(el('p', `출력 불가 · ${issues.join(' · ')}`, 'pnote'));
body.appendChild(el('p', `대상: 교각 ${targets.length}기 · 교대 ${state.abutments.length}기 제외. 원표 수량은 철근표 행 수량이며 가공 본수와 다를 수 있습니다.`, 'pnote'));
quantityWorkspace.refresh(); quantityWorkspace.refresh();
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow. // 전체 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)); if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
@@ -4635,6 +4714,14 @@ function renderQuantityScreen(): void {
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`)); body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
if (q.missing.length) body.appendChild(el('p', `전체 수량 미완료 · 미계산 항목 ${q.missing.length}개 제외: ${q.missing.join(', ')}`, 'pnote')); if (q.missing.length) body.appendChild(el('p', `전체 수량 미완료 · 미계산 항목 ${q.missing.length}개 제외: ${q.missing.join(', ')}`, 'pnote'));
body.appendChild(quantityTable(q)); body.appendChild(quantityTable(q));
const details = el('details'); details.appendChild(el('summary', '길이별 가공 상세'));
const table = el('table', undefined, 'grid-table rebar-table');
const rows = quantityPieceRows([{name:pier.input.name,quantity:q,dirty:false}]);
for (const [i,row] of rows.entries()) {
const tr = el('tr'); for (const value of row) tr.appendChild(el(i===0?'th':'td', typeof value==='number'?String(Math.round(value*1000)/1000):value));
table.appendChild(tr);
}
details.appendChild(table); body.appendChild(details);
body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote')); body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
} }
} }
@@ -4671,20 +4758,18 @@ function pierQuantity(pier: Pier): Quantity {
return buildQuantity(result.rows, state.material, result.incomplete); return buildQuantity(result.rows, state.material, result.incomplete);
} }
function quantityRecords(targets = quantityTargets()): QuantityRecord[] {
return targets.map(pier => ({name:pier.input.name,quantity:quantityResults.get(pier.input.name)??null,dirty:pendingKeys(pier).length>0}));
}
function exportQuantityWorkbook(): void { function exportQuantityWorkbook(): void {
const targets = quantityTargets(); renderQuantityScreen();
if (!targets.length) return; try {
const wb = XLSX.utils.book_new(); const records = quantityRecords();
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']]; const wb = createQuantityWorkbook(records, {project:state.project.name,bridge:state.project.bridge,generatedAt:new Date().toISOString(),abutmentCount:state.abutments.length});
for (const pier of targets) { const prefix = records.some(r=>r.quantity?.missing.length) ? '검토용_' : '';
if (!pier.model) continue; XLSX.writeFile(wb, `${prefix}${state.project.bridge || '교량'}_철근재료표.xlsx`);
const q = pierQuantity(pier); } catch (error) { setError((error as Error).message); }
const sheet = pier.input.name.replace(/[[\]:*?/\\]/g, '_').slice(0, 31);
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), sheet);
summary.push([pier.input.name, Math.round(q.kg * 10) / 10, Math.round(q.kgWithWaste * 10) / 10, Math.round(q.ton * 1000) / 1000, q.missing.length ? `전체 수량 미완료: ${q.missing.join(' · ')}` : '계산 완료']);
}
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), '합계');
XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`);
} }
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => { $<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
@@ -4692,7 +4777,11 @@ $<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
renderAll(); renderAll();
}); });
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook); $<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print()); $<HTMLButtonElement>('quantity-print').addEventListener('click', () => {
const issues = quantityExportIssues(quantityRecords());
if (issues.length) { setError(issues.join(' · ')); return; }
window.print();
});
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */ /** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
function rebarSelectBar(names: string[]): HTMLDivElement { function rebarSelectBar(names: string[]): HTMLDivElement {
@@ -5006,7 +5095,7 @@ function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: Strat
/** /**
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력). * ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
* The ground model import and the 지반고 apply stay at the bottom, as before. * Project model generation/import is managed from ①; this card retains local inputs.
*/ */
function renderGroundModel(box: HTMLDivElement, pier: Pier): void { function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
const name = pier.input.name; const name = pier.input.name;
@@ -5102,41 +5191,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
wrap.appendChild(fig); wrap.appendChild(fig);
} }
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
const importInput = el('input');
importInput.type = 'file';
importInput.accept = '.json';
importInput.id = 'ground-import';
importInput.addEventListener('change', async () => {
const file = importInput.files?.[0];
if (!file) return;
try {
const imported = parseGroundModel(await file.text());
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
groundModel = imported;
groundMapMode = groundModel.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.visible.ground = true;
const mapStatus = groundModel.mapLayers?.length ? '2D 지도·위성사진을 지반면 위에 굴곡대로 표시합니다.' : '포함된 지도 이미지가 없습니다.';
setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 3D 지반 표시를 켰습니다. ${mapStatus}`);
setupScene();
renderAll();
} catch (err) {
setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`);
} finally {
importInput.value = '';
}
});
const groundLaunch = el('button', '지반 모델 파일 불러오기 · 지반 생성 프로그램');
groundLaunch.type = 'button';groundLaunch.className = 'btn';
groundLaunch.addEventListener('click', () => window.dispatchEvent(new Event('pierzainer:open-ground')));
wrap.appendChild(groundLaunch);
const linkStatus = el('p', '현재 Excel을 사용하여 종평면도에서 지반을 생성합니다.', 'muted small');linkStatus.id='ground-link-status';wrap.appendChild(linkStatus);
const importLabel = el('label', '기존 지반 JSON 직접 불러오기');
importLabel.appendChild(importInput);
wrap.appendChild(importLabel);
if (!groundModel) { if (!groundModel) {
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small')); wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
@@ -5717,8 +5772,10 @@ function writeProjectFile(): void {
/** Opens a project from its text. A backup stays unsaved until the user saves it. */ /** Opens a project from its text. A backup stays unsaved until the user saves it. */
function openProjectText(text: string, saved: boolean): void { function openProjectText(text: string, saved: boolean): void {
const parsed = parseProject(text); const parsed = parseProject(text);
resetProjectGround();
state.project = { ...parsed.project }; state.project = { ...parsed.project };
state.source = parsed.source; state.source = parsed.source;
quantitySelected = null;
state.history = parsed.history; state.history = parsed.history;
state.material = materialFrom(parsed.common); state.material = materialFrom(parsed.common);
state.fckByMember = { ...parsed.fckByMember }; state.fckByMember = { ...parsed.fckByMember };
@@ -6015,7 +6072,12 @@ function applyImport(): void {
const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e)); const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e));
const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec)); const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
if (!p || !name || (!anyPier && !anyAbut)) return; if (!p || !name || (!anyPier && !anyAbut)) return;
resetProjectGround();
groundModel = null;
groundMapMode = 'none';
setGroundMapMode(groundMapMode);
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() }; state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
quantitySelected = null;
state.piers = p.entries.filter((e) => e.checked && selectable(p, e)); state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec)); state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[]; state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
@@ -6111,7 +6173,9 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan')); $<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar')); $<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar')); $<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
$<HTMLButtonElement>('rebar-materials').addEventListener('click', openQuantity);
$<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing')); $<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing'));
$<HTMLButtonElement>('nav-quantity').addEventListener('click', openQuantity);
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value)); $<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value)); $<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) { for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
+9
View File
@@ -13,6 +13,15 @@
color-scheme: light; color-scheme: light;
} }
#project-ground-dialog { width: min(1050px, 94vw); max-height: 88vh; overflow: auto; padding: 20px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--panel); }
#project-ground-dialog::backdrop { background: #0006; }
.project-ground-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
#project-ground-dialog fieldset { display: flex; flex-wrap: wrap; gap: 16px; }
#project-ground-dialog label { display: flex; align-items: center; gap: 6px; }
.project-ground-table { overflow: auto; max-height: 42vh; }
.project-ground-table table { min-width: 720px; }
#project-ground-dialog > p { font-size: 12px; }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
+8 -3
View File
@@ -248,6 +248,8 @@ export function buildAbutGroup(
soles?: BearingSole[], soles?: BearingSole[],
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */ /** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
jointS?: number[], jointS?: number[],
/** Another object already draws the shared terrain in this scene. */
sharedTerrain = false,
): THREE.Group { ): THREE.Group {
const root = new THREE.Group(); const root = new THREE.Group();
root.name = model.name; root.name = model.name;
@@ -486,13 +488,16 @@ export function buildAbutGroup(
// piers); otherwise a flat pad at 지반고 covers the footing and the wings. // piers); otherwise a flat pad at 지반고 covers the footing and the wings.
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)]; const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined; const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
if (grid && pivot) { if (grid && pivot && !sharedTerrain) {
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide })); const geometry = terrainGeometry(grid, pivot);
// Terrain is already in the common scene frame; cancel the root placement once.
geometry.applyMatrix4(abutPlacement(model, pivot).invert());
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'ground'; group.name = 'ground';
group.add(mesh); group.add(mesh);
root.add(group); root.add(group);
} else if (groundEl !== undefined) { } else if (!sharedTerrain && groundEl !== undefined) {
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))]; const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M; const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M; const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
+92
View File
@@ -0,0 +1,92 @@
import type { GroundModelFile } from '../core/ground-model';
import type { PierInput } from '../core/types';
import { abutGroundFromModel, groundInputChanged } from '../core/project-ground';
import type { AbutRecord } from '../core/abut/parse';
type Snapshot = { model: GroundModelFile | null; piers: { input: PierInput; draft: PierInput }[]; abutments: AbutRecord[] };
const node = <K extends keyof HTMLElementTagNameMap>(tag: K, text?: string) => {
const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e;
};
export function createProjectGroundManager(options: {
snapshot: () => Snapshot;
importModel: (file: File) => Promise<void>;
apply: (names: Set<string>, fields: { ground: boolean; layers: boolean }) => void;
regenerate: () => void;
}) {
const dialog = node('dialog'); dialog.id = 'project-ground-dialog';
dialog.setAttribute('aria-labelledby', 'project-ground-title');
const header = node('div'); header.className = 'viewer-head';
const title = node('h2', '프로젝트 지반모델'); title.id = 'project-ground-title';
const close = node('button', '닫기'); close.type = 'button'; close.className = 'btn-o'; close.onclick = () => dialog.close();
header.append(title, close);
const controls = node('div'); controls.className = 'project-ground-actions';
const generate = node('button', '지반모델 생성'); generate.type = 'button'; generate.className = 'btn-o';
generate.onclick = () => window.dispatchEvent(new Event('pierzainer:open-ground'));
const picker = node('input'); picker.type = 'file'; picker.accept = '.json'; picker.id = 'ground-import'; picker.hidden = true;
const load = node('button', '기존 JSON 불러오기'); load.type = 'button'; load.className = 'btn-o'; load.onclick = () => picker.click();
const message = node('p'); message.setAttribute('role', 'status'); message.className = 'pnote';
picker.onchange = async () => {
const file = picker.files?.[0]; if (!file) return;
try { await options.importModel(file); message.textContent = `${file.name}에서 지반모델을 불러왔습니다. 기존 입력은 유지됩니다. 적용할 구조물을 선택하세요.`; }
catch (error) { message.textContent = `${file.name}: ${(error as Error).message}`; }
finally { picker.value = ''; }
};
controls.append(generate, load, picker);
const info = node('p'); info.id = 'project-ground-info'; info.className = 'pnote';
const fields = node('fieldset'); const legend = node('legend', '선택 구조물에 적용할 값'); fields.append(legend);
const ground = node('input'); ground.type = 'checkbox'; ground.checked = true; ground.id = 'project-ground-el';
const layers = node('input'); layers.type = 'checkbox'; layers.id = 'project-ground-layers';
const gl = node('label'); gl.append(ground, ' 지반고'); const ll = node('label'); ll.append(layers, ' 지층 (기존 지층·N값 대체)'); fields.append(gl, ll);
const scroll = node('div'); scroll.className = 'project-ground-table';
const table = node('table'); table.className = 'grid-table'; scroll.append(table);
const actions = node('div'); actions.className = 'project-ground-actions';
const all = node('button', '적용 가능한 구조물 선택'); all.type = 'button'; all.className = 'btn-o'; all.id = 'project-ground-select-all';
const apply = node('button', '선택 구조물에 적용'); apply.type = 'button'; apply.id = 'project-ground-apply'; apply.className = 'btn';
const regenerate = node('button'); regenerate.type = 'button'; regenerate.id = 'project-ground-regenerate'; regenerate.className = 'btn-o'; regenerate.onclick = options.regenerate;
const selected = new Set<string>();
let lastModel: GroundModelFile | null = null;
const rows = () => {
const snap = options.snapshot();
return [
...snap.abutments.map(a => ({ key: `abut:${a.name}`, name: a.name, kind: '교대', current: a.groundEl, layers: a.ground?.strataRows, pending: false, entry: snap.model ? abutGroundFromModel(snap.model, a) : undefined })),
...snap.piers.map(p => ({ key: `pier:${p.input.name}`, name: p.input.name, kind: '교각', current: p.draft.groundEl, layers: p.draft.strataRows, pending: groundInputChanged(p.input, p.draft), entry: snap.model?.piers.find(e => e.id === p.input.name) ? { ...snap.model.piers.find(e => e.id === p.input.name)!, source: 'JSON 개별값' } : undefined })),
];
};
const eligible = (key: string) => {
const entry = rows().find(p => p.key === key)?.entry;
return !!entry && ((ground.checked && entry.centerEl !== null) || (layers.checked && !!entry.layers?.length));
};
const update = () => { apply.disabled = ![...selected].some(eligible); };
all.onclick = () => { for (const p of rows()) if (eligible(p.key)) selected.add(p.key); refresh(); };
// Keep the message outside refresh so applying a model retains the result notice.
apply.onclick = () => { options.apply(new Set([...selected].filter(eligible)), { ground: ground.checked, layers: layers.checked }); message.textContent = '선택한 값을 적용했습니다. 교대는 즉시 갱신되며, 변경 교각은 다시 생성하세요.'; };
ground.onchange = layers.onchange = () => refresh();
function refresh() {
const { model, piers } = options.snapshot();
if (model !== lastModel) { selected.clear(); lastModel = model; }
const items = rows();
document.getElementById('project-ground-status')!.textContent = model ? `불러옴 · 구조물 ${items.filter(p => p.entry?.centerEl != null).length}/${items.length}기 연결` : '모델 없음';
if (!dialog.open) return;
info.textContent = model ? `생성일 ${model.generated || '정보 없음'} · 모델 ${model.piers.length}기 · 지형 ${model.terrainGrid ? '있음' : '없음'} · 지도 ${model.mapLayers?.length ?? 0}개. 프로젝트에 함께 저장됩니다.` : '지반모델 JSON 또는 지반모델이 포함된 프로젝트 JSON을 불러온 뒤 적용할 교각을 선택하세요. 프로젝트 JSON에서는 지반모델만 가져옵니다. 생성 설정 JSON은 사용할 수 없습니다.';
table.replaceChildren();
const head = node('thead'), hr = node('tr'); for (const label of ['선택', '구조물', '현재 입력 EL (m)', '모델 EL (m)', '차이 (m)', '모델 지층', '상태', '종류', '값의 출처']) hr.append(node('th', label)); head.append(hr); table.append(head);
const body = node('tbody');
for (const p of items) {
const entry = p.entry, value = entry?.centerEl;
const row = node('tr'), cell = node('td'), check = node('input'); check.type = 'checkbox'; check.dataset.groundPier = p.name; check.dataset.groundKind = p.kind; check.setAttribute('aria-label', `${p.name} ${p.kind} 적용 선택`); check.disabled = !eligible(p.key); check.checked = selected.has(p.key) && !check.disabled;
check.onchange = () => { if (check.checked) selected.add(p.key); else selected.delete(p.key); update(); }; cell.append(check); row.append(cell);
let status = !model ? '모델 없음' : !entry ? '모델에 없음' : value === null ? '지형 범위 밖/자료 없음' : p.pending ? '형상 재생성 필요' : value === p.current ? '지반고 일치' : '지반고 차이';
if (entry?.layers?.length && JSON.stringify(entry.layers) !== JSON.stringify(p.layers ?? [])) status += ' · 지층 차이';
for (const text of [p.name, p.current.toFixed(3), value == null ? '—' : value.toFixed(3), value == null ? '—' : (value - p.current).toFixed(3), `${entry?.layers?.length ?? 0}개`, status, p.kind, entry?.source ?? '—']) row.append(node('td', text)); body.append(row);
}
table.append(body);
const count = piers.filter(p => groundInputChanged(p.input, p.draft)).length;
regenerate.textContent = `변경 교각 다시 생성 (${count}기)`; regenerate.disabled = count === 0;
all.disabled = !items.some(p => eligible(p.key)); update();
}
actions.append(all, apply, regenerate);
dialog.append(header, controls, info, fields, scroll, actions, message, node('p', '교대는 JSON 개별값이 없으면 IP2 위치의 유효 격자 4점으로 지반고를 보간합니다. 범위 밖·자료 없음은 적용하지 않으며 지층은 개별 자료만 사용합니다. 교대는 적용 즉시 갱신됩니다. 교각은 다시 생성할 때 다른 수정 입력도 함께 반영됩니다.'));
document.body.append(dialog);
document.getElementById('project-ground-open')!.onclick = () => { dialog.showModal(); refresh(); };
return { refresh, reset: () => { selected.clear(); ground.checked = true; layers.checked = false; message.textContent = ''; dialog.close(); } };
}
+8
View File
@@ -1,6 +1,14 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { TerrainGrid } from '../core/ground-model'; import type { TerrainGrid } from '../core/ground-model';
export function hasTerrainSurface(grid: TerrainGrid): boolean {
for (let j = 0; j < grid.ny - 1; j++) for (let i = 0; i < grid.nx - 1; i++) {
const k = j * grid.nx + i;
if ([grid.z[k], grid.z[k + 1], grid.z[k + grid.nx], grid.z[k + grid.nx + 1]].every(h => h != null)) return true;
}
return false;
}
/** /**
* Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up: * Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up:
* JSON 격자는 X 열(nx), Y 행(ny)이다. 내부 X/Y 축 교환을 적용해 x = rawY − pivot.e, z = −(rawX − pivot.n)로 놓는다. * JSON 격자는 X 열(nx), Y 행(ny)이다. 내부 X/Y 축 교환을 적용해 x = rawY − pivot.e, z = −(rawX − pivot.n)로 놓는다.
+16
View File
@@ -114,6 +114,22 @@ describe('지형 격자 (schema 2)', () => {
}); });
describe('형식 번호 검사', () => { describe('형식 번호 검사', () => {
it('프로젝트 JSON에서는 내장 지반모델만 가져온다', () => {
const groundModel = { schema: 2, generated: '2026-11-09', piers: [{ id: 'P1', centerEl: 18.011, excelEl: null }], terrainGrid: { origin: [0, 0], step: 1, nx: 2, ny: 2, z: [1, 2, 3, 4] } };
const imported = parseGroundModel(JSON.stringify({ schemaVersion: 1, project: { name: '다른 프로젝트' }, piers: [{ input: { groundEl: 999 } }], groundModel }));
expect(imported).toEqual(groundModel);
expect(imported).not.toHaveProperty('project');
expect(imported.piers[0].centerEl).toBe(18.011);
});
it('프로젝트 내부의 잘못된 지반모델과 알 수 없는 프로젝트 형식을 거부한다', () => {
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, groundModel: { schema: 2, piers: [{ id: 'P1', centerEl: 'wrong', excelEl: null }] } }))).toThrow('값이 맞지 않습니다');
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 99, groundModel: { schema: 2, piers: [] } }))).toThrow('프로젝트 형식 번호 99');
});
it('구조가 다른 JSON을 형식 번호 undefined 대신 구체적인 파일 안내로 거부한다', () => {
expect(() => parseGroundModel(JSON.stringify({ drawing: '도면.dwg', output: 'output', piers: '입력.xlsx' }))).toThrow('지반 생성 설정 파일');
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, piers: [] }))).toThrow('프로젝트 열기');
expect(() => parseGroundModel(JSON.stringify({ status: 'ok', json: 'ground_model.json' }))).toThrow('결과 요약 JSON');
});
it('accepts a legacy file without a schema number when it has the standard pier structure', () => { it('accepts a legacy file without a schema number when it has the standard pier structure', () => {
const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] })); const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] }));
expect(m.schema).toBe(2); expect(m.schema).toBe(2);
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { abutGroundFromModel, applyProjectGround, groundInputChanged, sampleGroundGrid } from '../src/core/project-ground';
import type { GroundModelFile } from '../src/core/ground-model';
import { loadP1 } from './helpers';
const model: GroundModelFile = { schema: 2, generated: '', piers: [
{ id: 'P1', centerEl: -2, excelEl: 3, layers: [{ name: '암반', bottomEl: -10 }] },
{ id: 'P2', centerEl: null, excelEl: 5 },
] };
describe('프로젝트 지반 선택 적용', () => {
it('교대 IP2에서 4점을 보간하고 외곽 경계도 처리한다', () => {
const grid = { origin: [100, 200] as [number, number], step: 10, nx: 2, ny: 2, z: [10, 20, 30, 40] };
expect(sampleGroundGrid(grid, { n: 105, e: 205 })).toBe(25);
expect(sampleGroundGrid(grid, { n: 110, e: 210 })).toBe(40);
expect(sampleGroundGrid(grid, { n: 111, e: 205 })).toBeNull();
expect(sampleGroundGrid({ ...grid, z: [10, null, 30, 40] }, { n: 105, e: 205 })).toBeNull();
const result = abutGroundFromModel({ ...model, terrainGrid: grid }, { name: 'A1', ip2: { n: 105, e: 205 } });
expect(result.centerEl).toBe(25); expect(result.source).toBe('IP2 격자 보간'); expect(result.layers).toBeUndefined();
});
it('교대 개별 모델값이 있으면 null도 보간으로 대체하지 않는다', () => {
const result = abutGroundFromModel({ ...model, piers: [{ id: 'A1', centerEl: null, excelEl: 10, layers: [{ name: '암반', bottomEl: -10 }] }] }, { name: 'A1', ip2: { n: 0, e: 0 } });
expect(result.centerEl).toBeNull(); expect(result.source).toBe('JSON 개별값'); expect(result.layers).toHaveLength(1);
});
it('지반고만 적용하면 기존 지층과 N값을 보존한다', () => {
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20, nValue: '50/5' }] };
const next = applyProjectGround(p, model, { ground: true, layers: false });
expect(next.groundEl).toBe(-2); expect(next.strataRows).toEqual(p.strataRows);
expect(p.groundEl).not.toBe(-2); expect(groundInputChanged(p, next)).toBe(true);
});
it('지층만 적용하면 지반고와 다른 입력을 보존하고 모델을 복사한다', () => {
const p = loadP1(), next = applyProjectGround(p, model, { ground: false, layers: true });
expect(next.groundEl).toBe(p.groundEl); expect(next.copingLengthMm).toBe(p.copingLengthMm);
expect(next.strataRows).toEqual(model.piers[0].layers); expect(next.strataRows![0]).not.toBe(model.piers[0].layers![0]);
});
it('모델 누락과 범위 밖은 기존 값을 보존한다', () => {
for (const name of ['P2', 'P3']) {
const p = { ...loadP1(), name, strataRows: [{ name: '기존', bottomEl: -20 }] };
expect(applyProjectGround(p, model, { ground: true, layers: true })).toEqual(p);
}
});
it('빈 지층으로 기존 지층을 지우지 않는다', () => {
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20 }] };
const empty = { ...model, piers: [{ ...model.piers[0], layers: [] }] };
expect(applyProjectGround(p, empty, { ground: false, layers: true })).toEqual(p);
});
it('지반고·지층이 같으면 지반 재생성 대상이 아니다', () => {
const p = loadP1(); expect(groundInputChanged(p, { ...p, copingLengthMm: p.copingLengthMm + 100 })).toBe(false);
expect(groundInputChanged(p, { ...p, strataRows: [{ name: '층', bottomEl: -5 }] })).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import { buildQuantity } from '../src/core/rebar/quantity';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import type { RebarRow } from '../src/core/rebar/summary';
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows } from '../src/core/quantity-workbook';
import { projectRecords } from '../수량산출/project-inputs';
import { buildPier } from '../src/core/geometry';
import { loadP1 } from './helpers';
const row: RebarRow = {member:'coping',name:'C9',size:'H19',count:2,length:null,pieces:[{length:1234.567,count:2},{length:765.432,count:4}],note:'',spacing:'',actual:'',position:'',direction:'',shape:'가공 조각',basis:'calc'};
const q = buildQuantity([row], {...DEFAULT_MATERIAL,waste:10});
const records = [{name:'P1',quantity:q,dirty:false}];
const meta = {project:'검증',bridge:'검증교',generatedAt:'2026-11-06T00:00:00.000Z',abutmentCount:2};
const values = (sheet: XLSX.WorkSheet) => XLSX.utils.sheet_to_json<(string|number)[]>(sheet,{header:1});
describe('재료표 Excel과 가공 상세', () => {
it('실제 파일 왕복 후 길이별 본수·중량 합이 반올림 없이 집계와 일치한다', () => {
const wb = XLSX.read(XLSX.write(createQuantityWorkbook(records,meta),{type:'buffer',bookType:'xlsx'}),{type:'buffer'});
const pieces = values(wb.Sheets['가공 상세']).slice(1);
expect(pieces.reduce((n,r)=>n+Number(r[7]),0)).toBe(6);
expect(pieces.reduce((n,r)=>n+Number(r[8]),0)).toBeCloseTo(q.groups[0].diameters[0].totalM,12);
expect(pieces.reduce((n,r)=>n+Number(r[9]),0)).toBeCloseTo(q.kg,12);
expect(values(wb.Sheets.P1).find(r=>r[1]==='C9')).toMatchObject({4:2,9:6});
expect(values(wb.Sheets['합계'])[1][2]).toBeCloseTo(q.kgWithWaste,12);
expect(values(wb.Sheets['생성 정보']).find(r=>r[0]==='범위')![1]).toContain('교대 2기 제외');
});
it('미재생성·미생성 교각이 있으면 Excel 생성을 막는다', () => {
const bad=[{...records[0],dirty:true},{name:'P2',quantity:null,dirty:false}];
expect(quantityExportIssues(bad)).toEqual(['P1: 형상 재생성 필요','P2: 형상 없음']);
expect(()=>createQuantityWorkbook(bad,meta)).toThrow('재생성 필요');
});
it('부분 소계와 미계산 사유를 파일에 보존한다', () => {
const partial=buildQuantity([row],DEFAULT_MATERIAL,['저부 P6 미확정']);
const wb=createQuantityWorkbook([{name:'P1',quantity:partial,dirty:false}],meta);
expect(values(wb.Sheets['검토 항목'])[1]).toEqual(['P1','저부 P6 미확정']);
expect(values(wb.Sheets['합계'])[1][4]).toContain('검토용');
});
it('예약명·대소문자·절단·문자치환으로 시트명이 충돌해도 모두 출력한다', () => {
const names=['합계','가공 상세','P1','p1','A/B','A:B','긴이름'.repeat(20)+'1','긴이름'.repeat(20)+'2'];
const wb=createQuantityWorkbook(names.map(name=>({...records[0],name})),meta);
expect(new Set(wb.SheetNames.map(n=>n.toLowerCase())).size).toBe(names.length+5);
expect(wb.SheetNames.every(n=>n.length<=31)).toBe(true);
expect(quantityPieceRows(names.map(name=>({...records[0],name})))).toHaveLength(1+names.length*2);
});
it('공종 수량에 수동 중량이 있어도 미확정 철근을 계산 완료로 처리하지 않는다', () => {
const input={...loadP1(),quantityTakeoff:{values:{rebar:123},reviewed:true}},model=buildPier(input);
const result=projectRecords({project:{name:'',bridge:''},selected:'P1',piers:[{input,model,dirty:false,rebarTon:null,rebarIssues:['저부 미확정']}]})[0].result!;
expect(result.issues).toContain('철근재료표 일부 계산: 저부 미확정');
expect(result.complete).toBe(false);
});
});
+5 -1
View File
@@ -9,7 +9,7 @@ import { createSheetEditor } from './sheet-editor';
import { shortenedBounds } from './breaks'; import { shortenedBounds } from './breaks';
import { createQualityPanel } from './quality-panel'; import { createQualityPanel } from './quality-panel';
export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapshot,select:(id:string)=>void): { update:()=>void } { export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapshot,select:(id:string)=>void,openMaterials?:()=>void): { update:()=>void } {
host.className='ga-workspace'; host.className='ga-workspace';
host.innerHTML=`<aside class="ga-settings"><div class="ga-sidebar-title">도면 설정 <span>GENERAL ARRANGEMENT</span></div> host.innerHTML=`<aside class="ga-settings"><div class="ga-sidebar-title">도면 설정 <span>GENERAL ARRANGEMENT</span></div>
<label>교각 선택<select id="ga-pier" aria-label="도면 교각 선택"></select></label> <label>교각 선택<select id="ga-pier" aria-label="도면 교각 선택"></select></label>
@@ -29,6 +29,10 @@ export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapsho
<footer class="ga-status"><span id="ga-object-info">휠 확대·축소 / 드래그 이동 / 객체 클릭</span><span id="ga-count">모델 mm · EL m</span></footer></section> <footer class="ga-status"><span id="ga-object-info">휠 확대·축소 / 드래그 이동 / 객체 클릭</span><span id="ga-count">모델 mm · EL m</span></footer></section>
<dialog class="ga-print-dialog" id="ga-print-dialog" aria-label="일반도 출력 미리보기"><div class="ga-print-toolbar"><strong>출력 미리보기</strong><span id="ga-print-paper"></span><button id="ga-print" class="ga-primary" type="button">인쇄 / PDF</button><button id="ga-print-close" type="button">닫기</button></div><div id="ga-print-sheet"></div></dialog>`; <dialog class="ga-print-dialog" id="ga-print-dialog" aria-label="일반도 출력 미리보기"><div class="ga-print-toolbar"><strong>출력 미리보기</strong><span id="ga-print-paper"></span><button id="ga-print" class="ga-primary" type="button">인쇄 / PDF</button><button id="ga-print-close" type="button">닫기</button></div><div id="ga-print-sheet"></div></dialog>`;
const node=<T extends HTMLElement=HTMLElement>(id:string)=>host.querySelector<T>(`#${id}`)!; const node=<T extends HTMLElement=HTMLElement>(id:string)=>host.querySelector<T>(`#${id}`)!;
if(openMaterials){
const button=window.document.createElement('button');button.type='button';button.id='ga-materials';button.textContent='재료표';button.addEventListener('click',openMaterials);
host.querySelector('.ga-toolbar-actions')!.prepend(button);
}
const pier=node<HTMLSelectElement>('ga-pier'),view=node<HTMLSelectElement>('ga-view'),paper=node<HTMLSelectElement>('ga-paper'),scale=node<HTMLSelectElement>('ga-scale'); const pier=node<HTMLSelectElement>('ga-pier'),view=node<HTMLSelectElement>('ga-view'),paper=node<HTMLSelectElement>('ga-paper'),scale=node<HTMLSelectElement>('ga-scale');
const bearing=node<HTMLSelectElement>('ga-bearing'); const bearing=node<HTMLSelectElement>('ga-bearing');
const canvas=node('ga-canvas'),empty=node('ga-empty'),warnings=node('ga-warnings'),note=node('ga-data-note'); const canvas=node('ga-canvas'),empty=node('ga-empty'),warnings=node('ga-warnings'),note=node('ga-data-note');
@@ -0,0 +1,664 @@
// Generated by scripts/promote-landing-hero.py from actual model projection.
export const heroCapture = {
"width": 2172,
"height": 870,
"elevation": 0.85,
"along": 0,
"outline": {
"method": "inward-alpha-envelope-quadratic",
"retainedTerrainRatio": 0.9268103107372453,
"addedOutsideSourcePixels": 0
},
"endMarginM": 30,
"cornerRadiusM": 18,
"terrainHalfWidthM": 56,
"roads": [
{
"name": "A1–P1",
"widthM": 8,
"validSections": 81,
"missingSections": 0,
"triangles": 2560,
"visualizationOnly": true
},
{
"name": "P6–A2",
"widthM": 8,
"validSections": 81,
"missingSections": 0,
"triangles": 2560,
"visualizationOnly": true
}
],
"points": [
{
"name": "A1",
"kind": "abutment",
"x": 264.03,
"y": 399.71
},
{
"name": "P1",
"kind": "pier",
"x": 462.89,
"y": 407.62
},
{
"name": "P2",
"kind": "pier",
"x": 712.6,
"y": 401.43
},
{
"name": "P3",
"kind": "pier",
"x": 962.31,
"y": 395.46
},
{
"name": "P4",
"kind": "pier",
"x": 1213.33,
"y": 391.75
},
{
"name": "P5",
"kind": "pier",
"x": 1464.3,
"y": 394.12
},
{
"name": "P6",
"kind": "pier",
"x": 1713.67,
"y": 407.08
},
{
"name": "A2",
"kind": "abutment",
"x": 1911.22,
"y": 410.06
}
],
"terrainFlow": [
{
"x": 264.033,
"y": 448.436
},
{
"x": 275.731,
"y": 448.555
},
{
"x": 287.428,
"y": 448.674
},
{
"x": 299.126,
"y": 449.022
},
{
"x": 310.824,
"y": 448.307
},
{
"x": 322.521,
"y": 447.275
},
{
"x": 334.219,
"y": 446.007
},
{
"x": 345.917,
"y": 445.252
},
{
"x": 357.614,
"y": 444.752
},
{
"x": 369.312,
"y": 443.105
},
{
"x": 381.009,
"y": 441.457
},
{
"x": 392.707,
"y": 439.355
},
{
"x": 404.405,
"y": 437.296
},
{
"x": 416.102,
"y": 435.21
},
{
"x": 427.8,
"y": 432.427
},
{
"x": 439.498,
"y": 429.689
},
{
"x": 451.195,
"y": 426.961
},
{
"x": 462.893,
"y": 424.25
},
{
"x": 474.784,
"y": 421.577
},
{
"x": 486.675,
"y": 419.082
},
{
"x": 498.565,
"y": 416.661
},
{
"x": 510.456,
"y": 414.622
},
{
"x": 522.347,
"y": 415.821
},
{
"x": 534.238,
"y": 416.946
},
{
"x": 546.129,
"y": 419.229
},
{
"x": 558.02,
"y": 423.307
},
{
"x": 569.911,
"y": 427.384
},
{
"x": 581.802,
"y": 431.453
},
{
"x": 593.692,
"y": 435.523
},
{
"x": 605.583,
"y": 439.146
},
{
"x": 617.474,
"y": 440.666
},
{
"x": 629.365,
"y": 441.616
},
{
"x": 641.256,
"y": 441.504
},
{
"x": 653.147,
"y": 441.125
},
{
"x": 665.038,
"y": 440.763
},
{
"x": 676.928,
"y": 440.769
},
{
"x": 688.819,
"y": 440.77
},
{
"x": 700.71,
"y": 440.772
},
{
"x": 712.601,
"y": 440.775
},
{
"x": 724.492,
"y": 440.777
},
{
"x": 736.383,
"y": 440.72
},
{
"x": 748.274,
"y": 440.623
},
{
"x": 760.165,
"y": 440.506
},
{
"x": 772.057,
"y": 440.384
},
{
"x": 783.948,
"y": 440.263
},
{
"x": 795.839,
"y": 440.153
},
{
"x": 807.73,
"y": 440.075
},
{
"x": 819.621,
"y": 439.997
},
{
"x": 831.512,
"y": 439.889
},
{
"x": 843.403,
"y": 439.753
},
{
"x": 855.294,
"y": 439.604
},
{
"x": 867.186,
"y": 439.365
},
{
"x": 879.077,
"y": 439.223
},
{
"x": 890.968,
"y": 439.22
},
{
"x": 902.859,
"y": 439.363
},
{
"x": 914.75,
"y": 439.208
},
{
"x": 926.641,
"y": 438.641
},
{
"x": 938.532,
"y": 438.027
},
{
"x": 950.423,
"y": 437.98
},
{
"x": 962.315,
"y": 439.693
},
{
"x": 974.268,
"y": 441.41
},
{
"x": 986.221,
"y": 444.7
},
{
"x": 998.174,
"y": 448.696
},
{
"x": 1010.127,
"y": 452.659
},
{
"x": 1022.08,
"y": 455.006
},
{
"x": 1034.033,
"y": 457.171
},
{
"x": 1045.986,
"y": 458.569
},
{
"x": 1057.939,
"y": 458.814
},
{
"x": 1069.892,
"y": 459.134
},
{
"x": 1081.845,
"y": 459.621
},
{
"x": 1093.798,
"y": 460.055
},
{
"x": 1105.751,
"y": 460.438
},
{
"x": 1117.704,
"y": 460.085
},
{
"x": 1129.657,
"y": 459.728
},
{
"x": 1141.61,
"y": 459.372
},
{
"x": 1153.563,
"y": 459.015
},
{
"x": 1165.516,
"y": 458.66
},
{
"x": 1177.469,
"y": 457.484
},
{
"x": 1189.422,
"y": 456.209
},
{
"x": 1201.375,
"y": 455.301
},
{
"x": 1213.328,
"y": 455
},
{
"x": 1225.279,
"y": 454.84
},
{
"x": 1237.23,
"y": 454.627
},
{
"x": 1249.181,
"y": 454.385
},
{
"x": 1261.132,
"y": 454.143
},
{
"x": 1273.083,
"y": 453.906
},
{
"x": 1285.034,
"y": 453.693
},
{
"x": 1296.985,
"y": 453.477
},
{
"x": 1308.936,
"y": 453.261
},
{
"x": 1320.887,
"y": 453.048
},
{
"x": 1332.838,
"y": 452.917
},
{
"x": 1344.789,
"y": 452.794
},
{
"x": 1356.74,
"y": 452.12
},
{
"x": 1368.691,
"y": 450.348
},
{
"x": 1380.642,
"y": 448.577
},
{
"x": 1392.593,
"y": 448.16
},
{
"x": 1404.544,
"y": 447.954
},
{
"x": 1416.495,
"y": 447.746
},
{
"x": 1428.446,
"y": 447.807
},
{
"x": 1440.397,
"y": 447.996
},
{
"x": 1452.348,
"y": 446.603
},
{
"x": 1464.299,
"y": 443.94
},
{
"x": 1476.174,
"y": 441.755
},
{
"x": 1488.049,
"y": 441.693
},
{
"x": 1499.924,
"y": 441.701
},
{
"x": 1511.799,
"y": 441.422
},
{
"x": 1523.674,
"y": 440.908
},
{
"x": 1535.549,
"y": 440.562
},
{
"x": 1547.424,
"y": 437.6
},
{
"x": 1559.299,
"y": 434.638
},
{
"x": 1571.173,
"y": 431.58
},
{
"x": 1583.048,
"y": 428.343
},
{
"x": 1594.923,
"y": 425.105
},
{
"x": 1606.798,
"y": 421.806
},
{
"x": 1618.673,
"y": 418.513
},
{
"x": 1630.548,
"y": 416.153
},
{
"x": 1642.423,
"y": 414.67
},
{
"x": 1654.298,
"y": 413.145
},
{
"x": 1666.173,
"y": 414.583
},
{
"x": 1678.048,
"y": 415.925
},
{
"x": 1689.923,
"y": 418.147
},
{
"x": 1701.798,
"y": 421.257
},
{
"x": 1713.672,
"y": 424.538
},
{
"x": 1725.293,
"y": 428.715
},
{
"x": 1736.913,
"y": 432.916
},
{
"x": 1748.534,
"y": 435.788
},
{
"x": 1760.154,
"y": 437.518
},
{
"x": 1771.774,
"y": 439.215
},
{
"x": 1783.395,
"y": 440.178
},
{
"x": 1795.015,
"y": 441.136
},
{
"x": 1806.635,
"y": 442.009
},
{
"x": 1818.256,
"y": 442.838
},
{
"x": 1829.876,
"y": 443.654
},
{
"x": 1841.496,
"y": 444.383
},
{
"x": 1853.117,
"y": 445.119
},
{
"x": 1864.737,
"y": 445.637
},
{
"x": 1876.357,
"y": 446.014
},
{
"x": 1887.978,
"y": 446.453
},
{
"x": 1899.598,
"y": 447.172
},
{
"x": 1911.219,
"y": 447.903
}
],
"camera": {
"position": [
-1142.5706300600066,
1298.0918545724896,
1012.9959736070584
],
"target": [
77.50014114379883,
2.7950143814086914,
99.96070098876953
],
"left": -220.05126480649932,
"right": 219.07033276742936,
"top": 95.74252164639043,
"bottom": -80.1487260006252
},
"piers": 6,
"abutments": 2,
"sourceSha256": "59f2e09500dc55dbd8ca588d99ad8dd8daa92fd55e34b4eeb1daf10a89c3d4c8"
} as const;
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+25 -13
View File
@@ -1,25 +1,37 @@
/** Decorative geometry uses the PNG's exact 2172×724 pixel coordinate system. */ import { heroCapture } from './assets/okseong-hero-capture';
export const heroMotionMarkup = (imageUrl: string): string => `
<div class="landing-scan-mask" style="mask-image:url('${imageUrl}');-webkit-mask-image:url('${imageUrl}')"> const flowPath = (() => {
<div class="landing-terrain-grid"></div><div class="landing-scan-beam"></div> let connected = false;
</div> return heroCapture.terrainFlow.map(point => {
<svg class="landing-model-motion" viewBox="0 0 2172 724" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> if (point === null) { connected = false; return ''; }
const command = connected ? 'L' : 'M'; connected = true;
return `${command}${point.x} ${point.y}`;
}).join(' ');
})();
const frame = {
left: Math.min(...heroCapture.points.map(p=>p.x))-50,
right: Math.max(...heroCapture.points.map(p=>p.x))+50,
top: Math.min(...heroCapture.points.map(p=>p.y))-60,
bottom: Math.max(...heroCapture.points.map(p=>p.y))+70,
};
/** Markers share the exact camera projection used to capture the model. */
export const heroMotionMarkup = (): string => `
<svg class="landing-model-motion" viewBox="0 0 ${heroCapture.width} ${heroCapture.height}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs> <defs>
<filter id="landing-energy-glow" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="3"/></filter> <filter id="landing-energy-glow" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="3"/></filter>
<linearGradient id="landing-flow-fade"><stop offset="0" stop-color="#087fcb" stop-opacity="0"/><stop offset=".12" stop-color="#087fcb"/><stop offset=".88" stop-color="#087fcb"/><stop offset="1" stop-color="#087fcb" stop-opacity="0"/></linearGradient> <linearGradient id="landing-flow-fade"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset=".12" stop-color="#ffffff"/><stop offset=".88" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
<!-- Trace only the clearly visible pale alignment. Fade the uncertain ends instead of inventing bridge geometry. --> <path id="landing-alignment-path" pathLength="1000" d="${flowPath}"/>
<path id="landing-alignment-path" pathLength="1000" d="M600 335 C650 341 700 367 750 368.5 S850 370 950 370.5 S1020 379 1050 382 S1120 398 1150 398 S1220 395 1250 395.5 S1320 396 1350 395 S1420 392 1450 390 S1520 384 1550 378.5 S1610 352 1650 350"/>
</defs> </defs>
<use href="#landing-alignment-path" class="landing-energy-track"/> <use href="#landing-alignment-path" class="landing-energy-track"/>
<use href="#landing-alignment-path" class="landing-energy-haze" filter="url(#landing-energy-glow)"/> <use href="#landing-alignment-path" class="landing-energy-haze" filter="url(#landing-energy-glow)"/>
<use href="#landing-alignment-path" class="landing-energy-flow"/> <use href="#landing-alignment-path" class="landing-energy-flow"/>
${[[546,331],[774,335],[996,344],[1238,346],[1478,334],[1745,332]].map(([x,y],i)=>` ${heroCapture.points.map(({name,x,y},i)=>`
<g class="landing-pier-beacon" style="--beacon-delay:${(i*.72+1.6).toFixed(2)}s" transform="translate(${x} ${y})"> <g class="landing-pier-beacon" data-structure="${name}" style="--beacon-delay:${(i*.72+1.6).toFixed(2)}s" transform="translate(${x} ${y})">
<line x1="0" y1="-31" x2="0" y2="-8"/> <line x1="0" y1="-31" x2="0" y2="-8"/>
<circle class="landing-pier-pulse" r="11"/><circle class="landing-pier-point" r="3.5"/> <circle class="landing-pier-pulse" r="11"/><circle class="landing-pier-point" r="3.5"/>
<circle class="landing-pier-spark" cx="8" cy="-15" r="1.5"/>
</g>`).join('')} </g>`).join('')}
<path class="landing-hud-bracket" d="M460 260h-24v22 M1775 260h24v22 M460 485h-24v-22 M1775 485h24v-22"/> <path class="landing-hud-bracket" d="M${frame.left} ${frame.top}h-24v22 M${frame.right} ${frame.top}h24v22 M${frame.left} ${frame.bottom}h-24v-22 M${frame.right} ${frame.bottom}h24v-22"/>
</svg>`; </svg>`;
export function createHeroMotion(root: HTMLElement): void { export function createHeroMotion(root: HTMLElement): void {
+2 -10
View File
@@ -308,10 +308,7 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
.landing-visual { animation: landing-premium-float 10s ease-in-out infinite; } .landing-visual { animation: landing-premium-float 10s ease-in-out infinite; }
.landing-artwork { position: absolute; left: 0; top: 40%; width: 100%; aspect-ratio: 2172 / 724; transform: translateY(-50%); } .landing-artwork { position: absolute; left: 0; top: 40%; width: 100%; aspect-ratio: 2172 / 724; transform: translateY(-50%); }
.landing-artwork .landing-promo-image { display: block; position: static; width: 100%; height: auto; transform: none; filter: brightness(1.12) contrast(1.04) saturate(1.04); } .landing-artwork .landing-promo-image { display: block; position: static; width: 100%; height: auto; transform: none; filter: brightness(1.12) contrast(1.04) saturate(1.04); }
.landing-model-motion, .landing-scan-mask { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } .landing-model-motion { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.landing-scan-mask { mask-size: 100% 100%; mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; overflow: hidden; }
.landing-terrain-grid { position: absolute; inset: 0; opacity: .11; background-image: linear-gradient(#89eaff80 1px,transparent 1px),linear-gradient(90deg,#89eaff80 1px,transparent 1px); background-size: 30px 24px; transform: perspective(650px) rotateX(48deg) scale(1.6); animation: landing-grid-breathe 9.7s ease-in-out infinite; }
.landing-scan-beam { position: absolute; top: -10%; left: -18%; width: 18%; height: 120%; background: linear-gradient(90deg,transparent,#88efff1a 35%,#c1f8ff66 49%,#78dfff17 65%,transparent); transform: translateX(0); animation: landing-scan-pass 14s ease-in-out 1.8s infinite; }
.landing-energy-track { fill: none; stroke: #70dfff; stroke-width: 1.4; opacity: .18; } .landing-energy-track { fill: none; stroke: #70dfff; stroke-width: 1.4; opacity: .18; }
.landing-energy-flow, .landing-energy-haze { fill: none; stroke: url(#landing-flow-fade); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 90 910; animation: landing-energy-run 8.3s linear 1.5s infinite; opacity: .8; } .landing-energy-flow, .landing-energy-haze { fill: none; stroke: url(#landing-flow-fade); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 90 910; animation: landing-energy-run 8.3s linear 1.5s infinite; opacity: .8; }
.landing-energy-haze { stroke-width: 8; opacity: .48; } .landing-energy-haze { stroke-width: 8; opacity: .48; }
@@ -319,7 +316,6 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
.landing-pier-beacon line { stroke: currentColor; stroke-width: 1; opacity: .42; stroke-dasharray: 3 4; } .landing-pier-beacon line { stroke: currentColor; stroke-width: 1; opacity: .42; stroke-dasharray: 3 4; }
.landing-pier-point { fill: #d3fdff; filter: drop-shadow(0 0 5px #62ddff); animation: landing-beacon-point 7.9s ease-in-out var(--beacon-delay) infinite; } .landing-pier-point { fill: #d3fdff; filter: drop-shadow(0 0 5px #62ddff); animation: landing-beacon-point 7.9s ease-in-out var(--beacon-delay) infinite; }
.landing-pier-pulse { fill: none; stroke: #8decff; stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: center; animation: landing-beacon-pulse 7.9s ease-out var(--beacon-delay) infinite; } .landing-pier-pulse { fill: none; stroke: #8decff; stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: center; animation: landing-beacon-pulse 7.9s ease-out var(--beacon-delay) infinite; }
.landing-pier-spark { fill: #c1faff; opacity: 0; animation: landing-beacon-spark 7.9s ease-out var(--beacon-delay) infinite; }
.landing-hud-bracket { fill: none; stroke: #8dcfff; stroke-width: 1; opacity: .28; } .landing-hud-bracket { fill: none; stroke: #8dcfff; stroke-width: 1; opacity: .28; }
.landing-showcase-label { position: absolute; left: 50%; top: 56%; transform: translateX(-50%); color: #84c4dd; font-size: 9px; letter-spacing: .23em; white-space: nowrap; } .landing-showcase-label { position: absolute; left: 50%; top: 56%; transform: translateX(-50%); color: #84c4dd; font-size: 9px; letter-spacing: .23em; white-space: nowrap; }
.landing-showcase-label > span { display: block; margin-top: 8px; font-size: 8px; letter-spacing: .06em; color: #729db8; text-align: center; } .landing-showcase-label > span { display: block; margin-top: 8px; font-size: 8px; letter-spacing: .06em; color: #729db8; text-align: center; }
@@ -387,19 +383,15 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
.landing-paused .landing-light-stage *, .landing-inactive .landing-light-stage *, .landing-paused .landing-light-stage *, .landing-inactive .landing-light-stage *,
.landing-paused .landing-visual, .landing-inactive .landing-visual, .landing-paused .landing-visual, .landing-inactive .landing-visual,
.landing-paused .landing-model-motion *, .landing-inactive .landing-model-motion *, .landing-paused .landing-model-motion *, .landing-inactive .landing-model-motion *,
.landing-paused .landing-scan-mask *, .landing-inactive .landing-scan-mask *,
.landing-paused .landing-hero::before, .landing-inactive .landing-hero::before { animation-play-state: paused !important; } .landing-paused .landing-hero::before, .landing-inactive .landing-hero::before { animation-play-state: paused !important; }
@keyframes landing-atmosphere { from { transform: translate3d(-1%,-1%,0) scale(1); opacity: .6; } to { transform: translate3d(2%,1%,0) scale(1.06); opacity: 1; } } @keyframes landing-atmosphere { from { transform: translate3d(-1%,-1%,0) scale(1); opacity: .6; } to { transform: translate3d(2%,1%,0) scale(1.06); opacity: 1; } }
@keyframes landing-glow-breathe { from { opacity: .65; scale: .96; } to { opacity: 1; scale: 1.08; } } @keyframes landing-glow-breathe { from { opacity: .65; scale: .96; } to { opacity: 1; scale: 1.08; } }
@keyframes landing-title-shimmer { 0%,55% { background-position: 110% 50%; } 85%,100% { background-position: -90% 50%; } } @keyframes landing-title-shimmer { 0%,55% { background-position: 110% 50%; } 85%,100% { background-position: -90% 50%; } }
@keyframes landing-premium-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } @keyframes landing-premium-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
@keyframes landing-scan-pass { 0%,12% { transform: translateX(0); opacity: 0; } 20% { opacity: .7; } 75% { opacity: .7; } 88%,100% { transform: translateX(740%); opacity: 0; } }
@keyframes landing-grid-breathe { 0%,100% { opacity: .06; } 45% { opacity: .17; } }
@keyframes landing-energy-run { 0% { stroke-dashoffset: 1000; opacity: 0; } 12% { opacity: .8; } 88% { opacity: .8; } 100% { stroke-dashoffset: -1000; opacity: 0; } } @keyframes landing-energy-run { 0% { stroke-dashoffset: 1000; opacity: 0; } 12% { opacity: .8; } 88% { opacity: .8; } 100% { stroke-dashoffset: -1000; opacity: 0; } }
@keyframes landing-beacon-point { 0%,55%,100% { opacity: .35; } 12%,27% { opacity: 1; } } @keyframes landing-beacon-point { 0%,55%,100% { opacity: .35; } 12%,27% { opacity: 1; } }
@keyframes landing-beacon-pulse { 0% { opacity: 0; transform: scale(.4); } 12% { opacity: .85; } 42%,100% { opacity: 0; transform: scale(2.3); } } @keyframes landing-beacon-pulse { 0% { opacity: 0; transform: scale(.4); } 12% { opacity: .85; } 42%,100% { opacity: 0; transform: scale(2.3); } }
@keyframes landing-beacon-spark { 0%,8% { opacity: 0; transform: translateY(0); } 18% { opacity: .8; } 40%,100% { opacity: 0; transform: translateY(-18px); } }
@keyframes landing-guide-enter { from { opacity: 0; translate: 0 8px; } to { opacity: 1; translate: 0 0; } } @keyframes landing-guide-enter { from { opacity: 0; translate: 0 8px; } to { opacity: 1; translate: 0 0; } }
@keyframes landing-guide-icon-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } @keyframes landing-guide-icon-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
@keyframes landing-guide-dot { 0%,100% { opacity: .2; } 50% { opacity: 1; } } @keyframes landing-guide-dot { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
@@ -441,7 +433,7 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
.landing-artwork *, .landing-guide *, .landing-guide[open] { animation: none !important; transition: none !important; } .landing-artwork *, .landing-guide *, .landing-guide[open] { animation: none !important; transition: none !important; }
.landing-title { background: none; color: #f2fbff; -webkit-text-fill-color: #f2fbff; } .landing-title { background: none; color: #f2fbff; -webkit-text-fill-color: #f2fbff; }
.landing-title > span { -webkit-text-fill-color: #7ff1ff; } .landing-title > span { -webkit-text-fill-color: #7ff1ff; }
.landing-scan-beam, .landing-pier-pulse, .landing-pier-spark, .landing-energy-haze { opacity: 0; } .landing-pier-pulse, .landing-energy-haze { opacity: 0; }
.landing-energy-flow { stroke-dasharray: none; opacity: .35; } .landing-energy-flow { stroke-dasharray: none; opacity: .35; }
.landing-light-stage { transform: none; } .landing-light-stage { transform: none; }
} }
+5 -4
View File
@@ -3,6 +3,7 @@ import { loadSampleIntoApp } from './sample-loader';
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer'; import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
import { createLandingGuide } from './landing-guide'; import { createLandingGuide } from './landing-guide';
import { createHeroMotion, heroMotionMarkup } from './landing-motion'; import { createHeroMotion, heroMotionMarkup } from './landing-motion';
import { heroCapture } from './assets/okseong-hero-capture';
import './landing-white.css'; import './landing-white.css';
import './landing-typography.css'; import './landing-typography.css';
import './sample-library-popup.css'; import './sample-library-popup.css';
@@ -10,7 +11,7 @@ import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
const appRoot = document.querySelector<HTMLDivElement>('.app'); const appRoot = document.querySelector<HTMLDivElement>('.app');
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app'); const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
const promoImageUrl = new URL('./assets/pierzainer-terrain-promo.png', import.meta.url).href; const promoImageUrl = new URL('./assets/okseong-model-hero.webp', import.meta.url).href;
const companyLogosUrl = new URL('./assets/family-company-logos.png', import.meta.url).href; const companyLogosUrl = new URL('./assets/family-company-logos.png', import.meta.url).href;
const landingOverlay = document.createElement('div'); const landingOverlay = document.createElement('div');
@@ -48,9 +49,9 @@ const landingPanelMarkup = `
</div> </div>
<div class="landing-hero-visual" aria-hidden="true"> <div class="landing-hero-visual" aria-hidden="true">
<div class="landing-visual"> <div class="landing-visual">
<div class="landing-artwork"> <div class="landing-artwork" style="aspect-ratio:${heroCapture.width}/${heroCapture.height}">
<img class="landing-promo-image" src="${promoImageUrl}" alt="" draggable="false" fetchpriority="high" /> <img class="landing-promo-image" src="${promoImageUrl}" width="${heroCapture.width}" height="${heroCapture.height}" alt="" draggable="false" fetchpriority="high" />
${heroMotionMarkup(promoImageUrl)} ${heroMotionMarkup()}
</div> </div>
</div> </div>
</div> </div>
+9 -10
View File
@@ -1,4 +1,10 @@
/* Premium White UI. Theme selectors never reach the application outside this overlay. */ /* 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-overlay.landing-white {
--landing-bg: #f8fafc; --landing-bg: #f8fafc;
--landing-panel: rgba(255, 255, 255, .84); --landing-panel: rgba(255, 255, 255, .84);
@@ -85,21 +91,14 @@
pointer-events: none; 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-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-model-motion { z-index: 2; }
.landing-white .landing-terrain-grid { .landing-white .landing-energy-track { stroke: #ffffff; stroke-width: 1.2; opacity: .16; }
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-energy-flow { stroke-width: 2; } .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 { color: #087fcb; }
.landing-white .landing-pier-beacon line { stroke: #167ead; opacity: .58; } .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-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-pulse { stroke: #087fcb; stroke-width: 1.5; }
.landing-white .landing-pier-spark { fill: #1284bf; }
.landing-white .landing-hud-bracket { stroke: #538bb0; opacity: .35; } .landing-white .landing-hud-bracket { stroke: #538bb0; opacity: .35; }
/* White glass surfaces with consistent cobalt actions. */ /* White glass surfaces with consistent cobalt actions. */
+1 -1
View File
@@ -76,7 +76,7 @@
|가설계단 / 워킹타워 / 워킹타워|받침 EL(Zb), 지반고 EL(Zs)|3.3 수량산출_P1!W61 · 원식은 ANALYSIS.md|자동 2 / 직접 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| |교량받침(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| |무수축몰탈 / 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| |스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(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| |스페이서 / 벽체용 / 벽체용|받침 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| |철근가공조립 / 매우복잡 / 매우복잡||3.3 수량산출_P1!W67 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
+21
View File
@@ -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;
}
+8 -1
View File
@@ -2,10 +2,11 @@ import {straightCopingFormulas} from './coping-formulas.ts';
import {FormulaBook,cellKey,type Overrides} from './formula.ts'; import {FormulaBook,cellKey,type Overrides} from './formula.ts';
import {template,templateHash} from './template-data.ts'; import {template,templateHash} from './template-data.ts';
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema.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 type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string;solplateMode?:'model'};
export function exportEdits(request:ExportRequest){ export function exportEdits(request:ExportRequest){
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.'); 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 edits:Record<string,Record<string,{value?:string|number|null;cached?:number|string;formula?:string}>>={},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;}; 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++){ 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); 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 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(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++){ for(let i=2;i<=6;i++){
const sheet='3.3 수량산출_P'+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); 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);
+1 -1
View File
@@ -39,6 +39,6 @@ export class FormulaBook{
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](); 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 '미입력';}});} 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; 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);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];} 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];}
} }
+70
View File
@@ -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]);
});
});
+8 -2
View File
@@ -2,6 +2,7 @@ import {bearingDimensions} from './bearing-dimensions';
import {straightCopingFormulas} from './coping-formulas'; import {straightCopingFormulas} from './coping-formulas';
import type {PierInput} from '../src/core/types'; import type {PierInput} from '../src/core/types';
import {DIM,type PierModel} from '../src/core/geometry'; import {DIM,type PierModel} from '../src/core/geometry';
import {solplateGeometry} from '../src/core/solplate';
import {variables,quantityRows,template,templateHash} from './template-data'; import {variables,quantityRows,template,templateHash} from './template-data';
import {FormulaBook,cellKey,type Overrides} from './formula'; import {FormulaBook,cellKey,type Overrides} from './formula';
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema'; import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
@@ -34,9 +35,13 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
stored=withTemplateDefaults(stored); stored=withTemplateDefaults(stored);
const mapped=automaticInputs(input,m),overrides=overridesFor(mapped,stored),copingMode='straight' as const,formulas=straightCopingFormulas,book=new FormulaBook(overrides,template,formulas); 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; 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=''; 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;} 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 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); 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 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}: 음수 입력을 확인하세요.`); 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.']; 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<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}));} 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}));}
+7 -2
View File
@@ -3,8 +3,13 @@ import type {PierModel} from '../src/core/geometry';
import {calculate,withTemplateDefaults,type QuantityInputs} from './model'; import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
import {inputFields} from './input-panel'; import {inputFields} from './input-panel';
import {node} from './dashboard-types'; 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 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});return {pier:p,stored,result:p.model?calculate(p.input,p.model,stored,p.rebarTon):null};});} 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<void>){ export function openProjectInputs(read:()=>QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void,onClose:()=>void,onExport:()=>Promise<void>){
const dialog=node('dialog');dialog.className='tq-project-dialog';dialog.dataset.projectInputs='true'; 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); const head=node('header'),title=node('h2','전체 교각 입력 · 수량산출'),close=node('button','닫기 ×');close.type='button';close.onclick=()=>dialog.close();head.append(title,close);dialog.append(head);
+1 -1
View File
@@ -23,7 +23,7 @@ export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;
block('계산식',node('pre',s.substitution)); 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 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)); 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); 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(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; if(hasSelection)mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));hasSelection=true;
+30 -18
View File
@@ -1,34 +1,41 @@
import {createExcelPreview} from './excel-preview'; import {createExcelPreview} from './excel-preview';
import {quantityPlanSvg,planPng} from './plan-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 {createInputPanel} from './input-panel';
import {createResultsPanel} from './results-panel'; import {createResultsPanel} from './results-panel';
import {createReviewPanel} from './review-panel'; import {createReviewPanel} from './review-panel';
import {node as e,icon,rawSelection,type DashboardState,type Selection} from './dashboard-types'; import {node as e,icon,rawSelection,type DashboardState,type Selection} from './dashboard-types';
import type {PierInput} from '../src/core/types'; import type {QuantityInputs} from './model';
import type {PierModel} from '../src/core/geometry';
import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
import {templateHash} from './template-data'; import {templateHash} from './template-data';
import {exportRecords,finishQuantityExport,type ExportScope} from './export-state';
import './workspace.css'; 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}); const blank=():QuantityInputs=>({values:{},reviewed:false});
export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,select:(name:string)=>void,save:(name:string,data:QuantityInputs)=>void){ 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 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 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 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; 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); 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(){ async function exportProject(scope:ExportScope='all'){
const snap=snapshot(),records=projectRecords(snap),problems:string[]=[]; if(exportBusy)throw new Error('Excel을 생성 중입니다. 완료 후 다시 출력하세요.');
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(', '));} 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'):'출력할 교각이 없습니다.'); 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 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})}); 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 출력 실패');} 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 blob=await response.blob();
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 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(){ function refresh(){
document.querySelectorAll<HTMLDialogElement>('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();}); document.querySelectorAll<HTMLDialogElement>('[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); 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(!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();} 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 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 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 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','수량산출 재계산'); 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});}; 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.textContent='선택 교각 Excel';exporter.disabled=exportBusy;exporter.title='선택한 교각만 출력합니다.';exporter.dataset.takeoffExport='selected';
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;}}; const allExporter=e('button','전체 교각 Excel');allExporter.type='button';allExporter.disabled=exportBusy;allExporter.dataset.takeoffExport='all';
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); const notice=(message:string)=>{const current=area.querySelector<HTMLElement>('[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<HTMLButtonElement>('[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'){ if(mode==='preview'){
const full=e('section',undefined,'tq-full-preview');const detail=e('section',undefined,'takeoff-trace tq-full-trace'); 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; 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;